decred.org/dcrdex@v1.0.5/client/asset/btc/electrum.go (about) 1 // This code is available on the terms of the project LICENSE.md file, 2 // also available online at https://blueoakcouncil.org/license/1.0.0. 3 4 package btc 5 6 import ( 7 "context" 8 "encoding/hex" 9 "errors" 10 "fmt" 11 "strings" 12 "sync" 13 "sync/atomic" 14 "time" 15 16 "decred.org/dcrdex/client/asset" 17 "decred.org/dcrdex/client/asset/btc/electrum" 18 "decred.org/dcrdex/dex" 19 "decred.org/dcrdex/dex/config" 20 dexbtc "decred.org/dcrdex/dex/networks/btc" 21 "github.com/btcsuite/btcd/chaincfg/chainhash" 22 ) 23 24 const needElectrumVersion = "4.5.5" 25 26 // ExchangeWalletElectrum is the asset.Wallet for an external Electrum wallet. 27 type ExchangeWalletElectrum struct { 28 *baseWallet 29 *authAddOn 30 ew *electrumWallet 31 minElectrumVersion dex.Semver 32 33 findRedemptionMtx sync.RWMutex 34 findRedemptionQueue map[OutPoint]*FindRedemptionReq 35 36 syncingTxHistory atomic.Bool 37 } 38 39 var _ asset.Wallet = (*ExchangeWalletElectrum)(nil) 40 var _ asset.Authenticator = (*ExchangeWalletElectrum)(nil) 41 var _ asset.WalletHistorian = (*ExchangeWalletElectrum)(nil) 42 43 // ElectrumWallet creates a new ExchangeWalletElectrum for the provided 44 // configuration, which must contain the necessary details for accessing the 45 // Electrum wallet's RPC server in the WalletCFG.Settings map. 46 func ElectrumWallet(cfg *BTCCloneCFG) (*ExchangeWalletElectrum, error) { 47 clientCfg := new(RPCWalletConfig) 48 err := config.Unmapify(cfg.WalletCFG.Settings, clientCfg) 49 if err != nil { 50 return nil, fmt.Errorf("error parsing rpc wallet config: %w", err) 51 } 52 53 btc, err := newUnconnectedWallet(cfg, &clientCfg.WalletConfig) 54 if err != nil { 55 return nil, err 56 } 57 58 rpcCfg := &clientCfg.RPCConfig 59 dexbtc.StandardizeRPCConf(&rpcCfg.RPCConfig, "") 60 ewc := electrum.NewWalletClient(rpcCfg.RPCUser, rpcCfg.RPCPass, 61 "http://"+rpcCfg.RPCBind, rpcCfg.WalletName) 62 ew := newElectrumWallet(ewc, &electrumWalletConfig{ 63 params: cfg.ChainParams, 64 log: cfg.Logger.SubLogger("ELECTRUM"), 65 addrDecoder: cfg.AddressDecoder, 66 addrStringer: cfg.AddressStringer, 67 segwit: cfg.Segwit, 68 rpcCfg: rpcCfg, 69 }) 70 btc.setNode(ew) 71 72 eew := &ExchangeWalletElectrum{ 73 baseWallet: btc, 74 authAddOn: &authAddOn{btc.node}, 75 ew: ew, 76 findRedemptionQueue: make(map[OutPoint]*FindRedemptionReq), 77 minElectrumVersion: cfg.MinElectrumVersion, 78 } 79 80 // In (*baseWallet).feeRate, use ExchangeWalletElectrum's walletFeeRate 81 // override for localFeeRate. No externalFeeRate is required but will be 82 // used if eew.walletFeeRate returned an error and an externalFeeRate is 83 // enabled. 84 btc.localFeeRate = eew.walletFeeRate 85 86 // Firo electrum does not have "onchain_history" method as of firo 87 // electrum 4.1.5.3, find an alternative. 88 btc.noListTxHistory = cfg.Symbol == "firo" 89 90 return eew, nil 91 } 92 93 // DepositAddress returns an address for depositing funds into the exchange 94 // wallet. The address will be unused but not necessarily new. Use NewAddress to 95 // request a new address, but it should be used immediately. 96 func (btc *ExchangeWalletElectrum) DepositAddress() (string, error) { 97 return btc.ew.wallet.GetUnusedAddress(btc.ew.ctx) 98 } 99 100 // RedemptionAddress gets an address for use in redeeming the counterparty's 101 // swap. This would be included in their swap initialization. The address will 102 // be unused but not necessarily new because these addresses often go unused. 103 func (btc *ExchangeWalletElectrum) RedemptionAddress() (string, error) { 104 return btc.ew.wallet.GetUnusedAddress(btc.ew.ctx) 105 } 106 107 // Connect connects to the Electrum wallet's RPC server and an electrum server 108 // directly. Goroutines are started to monitor for new blocks and server 109 // connection changes. Satisfies the dex.Connector interface. 110 func (btc *ExchangeWalletElectrum) Connect(ctx context.Context) (*sync.WaitGroup, error) { 111 wg, err := btc.connect(ctx) // prepares btc.ew.chainV via btc.node.connect() 112 if err != nil { 113 return nil, err 114 } 115 116 commands, err := btc.ew.wallet.Commands(ctx) 117 if err != nil { 118 return nil, err 119 } 120 var hasFreezeUTXO bool 121 for i := range commands { 122 if commands[i] == "freeze_utxo" { 123 hasFreezeUTXO = true 124 break 125 } 126 } 127 if !hasFreezeUTXO { 128 return nil, errors.New("wallet does not support the freeze_utxo command") 129 } 130 131 serverFeats, err := btc.ew.chain().Features(ctx) 132 if err != nil { 133 return nil, err 134 } 135 // TODO: for chainforks with the same genesis hash (BTC -> BCH), compare a 136 // block hash at some post-fork height. 137 if genesis := btc.chainParams.GenesisHash; genesis != nil && genesis.String() != serverFeats.Genesis { 138 return nil, fmt.Errorf("wanted genesis hash %v, got %v (wrong network)", 139 genesis.String(), serverFeats.Genesis) 140 } 141 142 verStr, err := btc.ew.wallet.Version(ctx) 143 if err != nil { 144 return nil, err 145 } 146 gotVer, err := dex.SemverFromString(verStr) 147 if err != nil { 148 return nil, err 149 } 150 if !dex.SemverCompatible(btc.minElectrumVersion, *gotVer) { 151 return nil, fmt.Errorf("wanted electrum wallet version %s but got %s", btc.minElectrumVersion, gotVer) 152 } 153 154 if btc.minElectrumVersion.Major >= 4 && btc.minElectrumVersion.Minor >= 5 { 155 btc.ew.wallet.SetIncludeIgnoreWarnings(true) 156 } 157 158 dbWG, err := btc.startTxHistoryDB(ctx) 159 if err != nil { 160 return nil, err 161 } 162 163 wg.Add(1) 164 go func() { 165 defer wg.Done() 166 dbWG.Wait() 167 }() 168 169 wg.Add(1) 170 go func() { 171 defer wg.Done() 172 btc.watchBlocks(ctx) // ExchangeWalletElectrum override 173 btc.cancelRedemptionSearches() 174 }() 175 wg.Add(1) 176 go func() { 177 defer wg.Done() 178 btc.monitorPeers(ctx) 179 }() 180 181 wg.Add(1) 182 go func() { 183 defer wg.Done() 184 btc.tipMtx.RLock() 185 tip := btc.currentTip 186 btc.tipMtx.RUnlock() 187 go btc.syncTxHistory(uint64(tip.Height)) 188 }() 189 190 return wg, nil 191 } 192 193 func (btc *ExchangeWalletElectrum) cancelRedemptionSearches() { 194 // Close all open channels for contract redemption searches 195 // to prevent leakages and ensure goroutines that are started 196 // to wait on these channels end gracefully. 197 btc.findRedemptionMtx.Lock() 198 for contractOutpoint, req := range btc.findRedemptionQueue { 199 req.fail("shutting down") 200 delete(btc.findRedemptionQueue, contractOutpoint) 201 } 202 btc.findRedemptionMtx.Unlock() 203 } 204 205 // walletFeeRate satisfies BTCCloneCFG.FeeEstimator. 206 func (btc *ExchangeWalletElectrum) walletFeeRate(ctx context.Context, _ RawRequester, confTarget uint64) (uint64, error) { 207 satPerKB, err := btc.ew.wallet.FeeRate(ctx, int64(confTarget)) 208 if err != nil { 209 return 0, err 210 } 211 return uint64(dex.IntDivUp(satPerKB, 1000)), nil 212 } 213 214 // findRedemption will search for the spending transaction of specified 215 // outpoint. If found, the secret key will be extracted from the input scripts. 216 // If not found, but otherwise without an error, a nil Hash will be returned 217 // along with a nil error. Thus, both the error and the Hash should be checked. 218 // This convention is only used since this is not part of the public API. 219 func (btc *ExchangeWalletElectrum) findRedemption(ctx context.Context, op OutPoint, contractHash []byte) (*chainhash.Hash, uint32, []byte, error) { 220 msgTx, vin, err := btc.ew.findOutputSpender(ctx, &op.TxHash, op.Vout) 221 if err != nil { 222 return nil, 0, nil, err 223 } 224 if msgTx == nil { 225 return nil, 0, nil, nil 226 } 227 txHash := msgTx.TxHash() 228 txIn := msgTx.TxIn[vin] 229 secret, err := dexbtc.FindKeyPush(txIn.Witness, txIn.SignatureScript, 230 contractHash, btc.segwit, btc.chainParams) 231 if err != nil { 232 return nil, 0, nil, fmt.Errorf("failed to extract secret key from tx %v input %d: %w", 233 txHash, vin, err) // name the located tx in the error since we found it 234 } 235 return &txHash, vin, secret, nil 236 } 237 238 func (btc *ExchangeWalletElectrum) tryRedemptionRequests(ctx context.Context) { 239 btc.findRedemptionMtx.RLock() 240 reqs := make([]*FindRedemptionReq, 0, len(btc.findRedemptionQueue)) 241 for _, req := range btc.findRedemptionQueue { 242 reqs = append(reqs, req) 243 } 244 btc.findRedemptionMtx.RUnlock() 245 246 for _, req := range reqs { 247 txHash, vin, secret, err := btc.findRedemption(ctx, req.outPt, req.contractHash) 248 if err != nil { 249 req.fail("findRedemption: %w", err) 250 continue 251 } 252 if txHash == nil { 253 continue // maybe next time 254 } 255 req.success(&FindRedemptionResult{ 256 redemptionCoinID: ToCoinID(txHash, vin), 257 secret: secret, 258 }) 259 } 260 } 261 262 // FindRedemption locates a swap contract output's redemption transaction input 263 // and the secret key used to spend the output. 264 func (btc *ExchangeWalletElectrum) FindRedemption(ctx context.Context, coinID, contract dex.Bytes) (redemptionCoin, secret dex.Bytes, err error) { 265 txHash, vout, err := decodeCoinID(coinID) 266 if err != nil { 267 return nil, nil, err 268 } 269 contractHash := btc.hashContract(contract) 270 // We can verify the contract hash via: 271 // txRes, _ := btc.ewc.getWalletTransaction(txHash) 272 // msgTx, _ := msgTxFromBytes(txRes.Hex) 273 // contractHash := dexbtc.ExtractScriptHash(msgTx.TxOut[vout].PkScript) 274 // OR 275 // txOut, _, _ := btc.ew.getTxOutput(txHash, vout) 276 // contractHash := dexbtc.ExtractScriptHash(txOut.PkScript) 277 278 // Check once before putting this in the queue. 279 outPt := NewOutPoint(txHash, vout) 280 spendTxID, vin, secret, err := btc.findRedemption(ctx, outPt, contractHash) 281 if err != nil { 282 return nil, nil, err 283 } 284 if spendTxID != nil { 285 return ToCoinID(spendTxID, vin), secret, nil 286 } 287 288 req := &FindRedemptionReq{ 289 outPt: outPt, 290 resultChan: make(chan *FindRedemptionResult, 1), 291 contractHash: contractHash, 292 // blockHash, blockHeight, and pkScript not used by this impl. 293 blockHash: &chainhash.Hash{}, 294 } 295 if err := btc.queueFindRedemptionRequest(req); err != nil { 296 return nil, nil, err 297 } 298 299 var result *FindRedemptionResult 300 select { 301 case result = <-req.resultChan: 302 if result == nil { 303 err = fmt.Errorf("unexpected nil result for redemption search for %s", outPt) 304 } 305 case <-ctx.Done(): 306 err = fmt.Errorf("context cancelled during search for redemption for %s", outPt) 307 } 308 309 // If this contract is still in the findRedemptionQueue, remove from the 310 // queue to prevent further redemption search attempts for this contract. 311 btc.findRedemptionMtx.Lock() 312 delete(btc.findRedemptionQueue, outPt) 313 btc.findRedemptionMtx.Unlock() 314 315 // result would be nil if ctx is canceled or the result channel is closed 316 // without data, which would happen if the redemption search is aborted when 317 // this ExchangeWallet is shut down. 318 if result != nil { 319 return result.redemptionCoinID, result.secret, result.err 320 } 321 return nil, nil, err 322 } 323 324 func (btc *ExchangeWalletElectrum) queueFindRedemptionRequest(req *FindRedemptionReq) error { 325 btc.findRedemptionMtx.Lock() 326 defer btc.findRedemptionMtx.Unlock() 327 if _, exists := btc.findRedemptionQueue[req.outPt]; exists { 328 return fmt.Errorf("duplicate find redemption request for %s", req.outPt) 329 } 330 btc.findRedemptionQueue[req.outPt] = req 331 return nil 332 } 333 334 // watchBlocks pings for new blocks and runs the tipChange callback function 335 // when the block changes. 336 func (btc *ExchangeWalletElectrum) watchBlocks(ctx context.Context) { 337 const electrumBlockTick = 5 * time.Second 338 ticker := time.NewTicker(electrumBlockTick) 339 defer ticker.Stop() 340 341 bestBlock := func() (*BlockVector, error) { 342 hdr, err := btc.node.GetBestBlockHeader() 343 if err != nil { 344 return nil, fmt.Errorf("getBestBlockHeader: %v", err) 345 } 346 hash, err := chainhash.NewHashFromStr(hdr.Hash) 347 if err != nil { 348 return nil, fmt.Errorf("invalid best block hash %s: %v", hdr.Hash, err) 349 } 350 return &BlockVector{hdr.Height, *hash}, nil 351 } 352 353 currentTip, err := bestBlock() 354 if err != nil { 355 btc.log.Errorf("Failed to get best block: %v", err) 356 currentTip = new(BlockVector) // zero height and hash 357 } 358 359 for { 360 select { 361 case <-ticker.C: 362 // Don't make server requests on every tick. Wallet has a headers 363 // subscription, so we can just ask wallet the height. That means 364 // only comparing heights instead of hashes, which means we might 365 // not notice a reorg to a block at the same height, which is 366 // unimportant because of how electrum searches for transactions. 367 ss, err := btc.node.SyncStatus() 368 if err != nil { 369 btc.log.Errorf("failed to get sync status: %w", err) 370 continue 371 } 372 373 sameTip := currentTip.Height == int64(ss.Blocks) 374 if sameTip { 375 // Could have actually been a reorg to different block at same 376 // height. We'll report a new tip block on the next block. 377 continue 378 } 379 380 newTip, err := bestBlock() 381 if err != nil { 382 // NOTE: often says "height X out of range", then succeeds on next tick 383 if !strings.Contains(err.Error(), "out of range") { 384 btc.log.Errorf("failed to get best block from %s electrum server: %v", btc.symbol, err) 385 } 386 continue 387 } 388 389 go btc.syncTxHistory(uint64(newTip.Height)) 390 391 btc.log.Tracef("tip change: %d (%s) => %d (%s)", currentTip.Height, currentTip.Hash, 392 newTip.Height, newTip.Hash) 393 currentTip = newTip 394 btc.emit.TipChange(uint64(newTip.Height)) 395 go btc.tryRedemptionRequests(ctx) 396 397 case <-ctx.Done(): 398 return 399 } 400 } 401 } 402 403 // syncTxHistory checks to see if there are any transactions which the wallet 404 // has made or recieved that are not part of the transaction history, then 405 // identifies and adds them. It also checks all the pending transactions to see 406 // if they have been mined into a block, and if so, updates the transaction 407 // history to reflect the block height. 408 func (btc *ExchangeWalletElectrum) syncTxHistory(tip uint64) { 409 if !btc.syncingTxHistory.CompareAndSwap(false, true) { 410 return 411 } 412 defer btc.syncingTxHistory.Store(false) 413 414 txHistoryDB := btc.txDB() 415 if txHistoryDB == nil { 416 return 417 } 418 419 ss, err := btc.SyncStatus() 420 if err != nil { 421 btc.log.Errorf("Error getting sync status: %v", err) 422 return 423 } 424 if !ss.Synced { 425 return 426 } 427 428 btc.addUnknownTransactionsToHistory(tip) 429 430 pendingTxsCopy := make(map[chainhash.Hash]ExtendedWalletTx, len(btc.pendingTxs)) 431 btc.pendingTxsMtx.RLock() 432 for hash, tx := range btc.pendingTxs { 433 pendingTxsCopy[hash] = tx 434 } 435 btc.pendingTxsMtx.RUnlock() 436 437 handlePendingTx := func(txHash chainhash.Hash, tx *ExtendedWalletTx) { 438 if !tx.Submitted { 439 return 440 } 441 442 gtr, err := btc.node.GetWalletTransaction(&txHash) 443 if errors.Is(err, asset.CoinNotFoundError) { 444 err = txHistoryDB.RemoveTx(txHash.String()) 445 if err == nil || errors.Is(err, asset.CoinNotFoundError) { 446 btc.pendingTxsMtx.Lock() 447 delete(btc.pendingTxs, txHash) 448 btc.pendingTxsMtx.Unlock() 449 } else { 450 // Leave it in the pendingPendingTxs and attempt to remove it 451 // again next time. 452 btc.log.Errorf("Error removing tx %s from the history store: %v", txHash.String(), err) 453 } 454 return 455 } 456 if err != nil { 457 btc.log.Errorf("Error getting transaction %s: %v", txHash.String(), err) 458 return 459 } 460 461 var updated bool 462 if gtr.BlockHash != "" { 463 bestHeight, err := btc.node.GetBestBlockHeight() 464 if err != nil { 465 btc.log.Errorf("GetBestBlockHeader: %v", err) 466 return 467 } 468 // TODO: Just get the block height with the header. 469 blockHeight := bestHeight - int32(gtr.Confirmations) + 1 470 i := 0 471 for { 472 if i > 20 || blockHeight < 0 { 473 btc.log.Errorf("Cannot find mined tx block number for %s", gtr.BlockHash) 474 return 475 } 476 bh, err := btc.ew.GetBlockHash(int64(blockHeight)) 477 if err != nil { 478 btc.log.Errorf("Error getting mined tx block number %s: %v", gtr.BlockHash, err) 479 return 480 } 481 if bh.String() == gtr.BlockHash { 482 break 483 } 484 i++ 485 blockHeight-- 486 } 487 if tx.BlockNumber != uint64(blockHeight) { 488 tx.BlockNumber = uint64(blockHeight) 489 tx.Timestamp = gtr.BlockTime 490 updated = true 491 } 492 } else if gtr.BlockHash == "" && tx.BlockNumber != 0 { 493 tx.BlockNumber = 0 494 tx.Timestamp = 0 495 updated = true 496 } 497 498 var confs uint64 499 if tx.BlockNumber > 0 && tip >= tx.BlockNumber { 500 confs = tip - tx.BlockNumber + 1 501 } 502 if confs >= requiredRedeemConfirms { 503 tx.Confirmed = true 504 updated = true 505 } 506 507 if updated { 508 err = txHistoryDB.StoreTx(tx) 509 if err != nil { 510 btc.log.Errorf("Error updating tx %s: %v", txHash, err) 511 return 512 } 513 514 btc.pendingTxsMtx.Lock() 515 if tx.Confirmed { 516 delete(btc.pendingTxs, txHash) 517 } else { 518 btc.pendingTxs[txHash] = *tx 519 } 520 btc.pendingTxsMtx.Unlock() 521 522 btc.emit.TransactionNote(tx.WalletTransaction, false) 523 } 524 } 525 526 for hash, tx := range pendingTxsCopy { 527 if btc.ctx.Err() != nil { 528 return 529 } 530 handlePendingTx(hash, &tx) 531 } 532 } 533 534 // WalletTransaction returns a transaction that either the wallet has made or 535 // one in which the wallet has received funds. The txID can be either a byte 536 // reversed tx hash or a hex encoded coin ID. 537 func (btc *ExchangeWalletElectrum) WalletTransaction(ctx context.Context, txID string) (*asset.WalletTransaction, error) { 538 coinID, err := hex.DecodeString(txID) 539 if err == nil { 540 txHash, _, err := decodeCoinID(coinID) 541 if err == nil { 542 txID = txHash.String() 543 } 544 } 545 546 txHistoryDB := btc.txDB() 547 if txHistoryDB == nil { 548 return nil, fmt.Errorf("tx database not initialized") 549 } 550 tx, err := txHistoryDB.GetTx(txID) 551 if err != nil && !errors.Is(err, asset.CoinNotFoundError) { 552 return nil, err 553 } 554 555 if tx == nil { 556 txHash, err := chainhash.NewHashFromStr(txID) 557 if err != nil { 558 return nil, fmt.Errorf("error decoding txid %s: %w", txID, err) 559 } 560 561 gtr, err := btc.node.GetWalletTransaction(txHash) 562 if err != nil { 563 return nil, fmt.Errorf("error getting transaction %s: %w", txID, err) 564 } 565 566 var blockHeight uint32 567 if gtr.BlockHash != "" { 568 bestHeight, err := btc.node.GetBestBlockHeight() 569 if err != nil { 570 return nil, fmt.Errorf("GetBestBlockHeader: %v", err) 571 } 572 // TODO: Just get the block height with the header. 573 blockHeight := bestHeight - int32(gtr.Confirmations) + 1 574 i := 0 575 for { 576 if i > 20 || blockHeight < 0 { 577 return nil, fmt.Errorf("Cannot find mined tx block number for %s", gtr.BlockHash) 578 } 579 bh, err := btc.ew.getBlockHeaderByHeight(btc.ctx, int64(blockHeight)) 580 if err != nil { 581 return nil, fmt.Errorf("Error getting mined tx block number %s: %v", gtr.BlockHash, err) 582 } 583 if bh.BlockHash().String() == gtr.BlockHash { 584 break 585 } 586 i++ 587 blockHeight-- 588 } 589 } 590 591 tx, err = btc.idUnknownTx(&ListTransactionsResult{ 592 BlockHeight: blockHeight, 593 BlockTime: gtr.BlockTime, 594 TxID: txID, 595 }) 596 if err != nil { 597 return nil, fmt.Errorf("error identifying transaction: %v", err) 598 } 599 600 tx.BlockNumber = uint64(blockHeight) 601 tx.Timestamp = gtr.BlockTime 602 tx.Confirmed = blockHeight > 0 603 btc.addTxToHistory(tx, txHash, true, false) 604 } 605 606 return tx, nil 607 } 608 609 // TxHistory returns all the transactions the wallet has made. If refID is nil, 610 // then transactions starting from the most recent are returned (past is ignored). 611 // If past is true, the transactions prior to the refID are returned, otherwise 612 // the transactions after the refID are returned. n is the number of 613 // transactions to return. If n is <= 0, all the transactions will be returned. 614 func (btc *ExchangeWalletElectrum) TxHistory(n int, refID *string, past bool) ([]*asset.WalletTransaction, error) { 615 txHistoryDB := btc.txDB() 616 if txHistoryDB == nil { 617 return nil, fmt.Errorf("tx database not initialized") 618 } 619 return txHistoryDB.GetTxs(n, refID, past) 620 }