decred.org/dcrdex@v1.0.3/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 // In (*baseWallet).feeRate, use ExchangeWalletElectrum's walletFeeRate 80 // override for localFeeRate. No externalFeeRate is required but will be 81 // used if eew.walletFeeRate returned an error and an externalFeeRate is 82 // enabled. 83 btc.localFeeRate = eew.walletFeeRate 84 85 return eew, nil 86 } 87 88 // DepositAddress returns an address for depositing funds into the exchange 89 // wallet. The address will be unused but not necessarily new. Use NewAddress to 90 // request a new address, but it should be used immediately. 91 func (btc *ExchangeWalletElectrum) DepositAddress() (string, error) { 92 return btc.ew.wallet.GetUnusedAddress(btc.ew.ctx) 93 } 94 95 // RedemptionAddress gets an address for use in redeeming the counterparty's 96 // swap. This would be included in their swap initialization. The address will 97 // be unused but not necessarily new because these addresses often go unused. 98 func (btc *ExchangeWalletElectrum) RedemptionAddress() (string, error) { 99 return btc.ew.wallet.GetUnusedAddress(btc.ew.ctx) 100 } 101 102 // Connect connects to the Electrum wallet's RPC server and an electrum server 103 // directly. Goroutines are started to monitor for new blocks and server 104 // connection changes. Satisfies the dex.Connector interface. 105 func (btc *ExchangeWalletElectrum) Connect(ctx context.Context) (*sync.WaitGroup, error) { 106 wg, err := btc.connect(ctx) // prepares btc.ew.chainV via btc.node.connect() 107 if err != nil { 108 return nil, err 109 } 110 111 commands, err := btc.ew.wallet.Commands(ctx) 112 if err != nil { 113 return nil, err 114 } 115 var hasFreezeUTXO bool 116 for i := range commands { 117 if commands[i] == "freeze_utxo" { 118 hasFreezeUTXO = true 119 break 120 } 121 } 122 if !hasFreezeUTXO { 123 return nil, errors.New("wallet does not support the freeze_utxo command") 124 } 125 126 serverFeats, err := btc.ew.chain().Features(ctx) 127 if err != nil { 128 return nil, err 129 } 130 // TODO: for chainforks with the same genesis hash (BTC -> BCH), compare a 131 // block hash at some post-fork height. 132 if genesis := btc.chainParams.GenesisHash; genesis != nil && genesis.String() != serverFeats.Genesis { 133 return nil, fmt.Errorf("wanted genesis hash %v, got %v (wrong network)", 134 genesis.String(), serverFeats.Genesis) 135 } 136 137 verStr, err := btc.ew.wallet.Version(ctx) 138 if err != nil { 139 return nil, err 140 } 141 gotVer, err := dex.SemverFromString(verStr) 142 if err != nil { 143 return nil, err 144 } 145 if !dex.SemverCompatible(btc.minElectrumVersion, *gotVer) { 146 return nil, fmt.Errorf("wanted electrum wallet version %s but got %s", btc.minElectrumVersion, gotVer) 147 } 148 149 if btc.minElectrumVersion.Major >= 4 && btc.minElectrumVersion.Minor >= 5 { 150 btc.ew.wallet.SetIncludeIgnoreWarnings(true) 151 } 152 153 // TODO: Firo electrum does not have "onchain_history" method as of firo 154 // electrum 4.1.5.3, find an alternative. 155 hasOnchainHistory := btc.symbol != "firo" 156 157 if hasOnchainHistory { 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 170 wg.Add(1) 171 go func() { 172 defer wg.Done() 173 btc.watchBlocks(ctx) // ExchangeWalletElectrum override 174 btc.cancelRedemptionSearches() 175 }() 176 wg.Add(1) 177 go func() { 178 defer wg.Done() 179 btc.monitorPeers(ctx) 180 }() 181 182 if hasOnchainHistory { 183 wg.Add(1) 184 go func() { 185 defer wg.Done() 186 btc.tipMtx.RLock() 187 tip := btc.currentTip 188 btc.tipMtx.RUnlock() 189 go btc.syncTxHistory(uint64(tip.Height)) 190 }() 191 } 192 193 return wg, nil 194 } 195 196 func (btc *ExchangeWalletElectrum) cancelRedemptionSearches() { 197 // Close all open channels for contract redemption searches 198 // to prevent leakages and ensure goroutines that are started 199 // to wait on these channels end gracefully. 200 btc.findRedemptionMtx.Lock() 201 for contractOutpoint, req := range btc.findRedemptionQueue { 202 req.fail("shutting down") 203 delete(btc.findRedemptionQueue, contractOutpoint) 204 } 205 btc.findRedemptionMtx.Unlock() 206 } 207 208 // walletFeeRate satisfies BTCCloneCFG.FeeEstimator. 209 func (btc *ExchangeWalletElectrum) walletFeeRate(ctx context.Context, _ RawRequester, confTarget uint64) (uint64, error) { 210 satPerKB, err := btc.ew.wallet.FeeRate(ctx, int64(confTarget)) 211 if err != nil { 212 return 0, err 213 } 214 return uint64(dex.IntDivUp(satPerKB, 1000)), nil 215 } 216 217 // findRedemption will search for the spending transaction of specified 218 // outpoint. If found, the secret key will be extracted from the input scripts. 219 // If not found, but otherwise without an error, a nil Hash will be returned 220 // along with a nil error. Thus, both the error and the Hash should be checked. 221 // This convention is only used since this is not part of the public API. 222 func (btc *ExchangeWalletElectrum) findRedemption(ctx context.Context, op OutPoint, contractHash []byte) (*chainhash.Hash, uint32, []byte, error) { 223 msgTx, vin, err := btc.ew.findOutputSpender(ctx, &op.TxHash, op.Vout) 224 if err != nil { 225 return nil, 0, nil, err 226 } 227 if msgTx == nil { 228 return nil, 0, nil, nil 229 } 230 txHash := msgTx.TxHash() 231 txIn := msgTx.TxIn[vin] 232 secret, err := dexbtc.FindKeyPush(txIn.Witness, txIn.SignatureScript, 233 contractHash, btc.segwit, btc.chainParams) 234 if err != nil { 235 return nil, 0, nil, fmt.Errorf("failed to extract secret key from tx %v input %d: %w", 236 txHash, vin, err) // name the located tx in the error since we found it 237 } 238 return &txHash, vin, secret, nil 239 } 240 241 func (btc *ExchangeWalletElectrum) tryRedemptionRequests(ctx context.Context) { 242 btc.findRedemptionMtx.RLock() 243 reqs := make([]*FindRedemptionReq, 0, len(btc.findRedemptionQueue)) 244 for _, req := range btc.findRedemptionQueue { 245 reqs = append(reqs, req) 246 } 247 btc.findRedemptionMtx.RUnlock() 248 249 for _, req := range reqs { 250 txHash, vin, secret, err := btc.findRedemption(ctx, req.outPt, req.contractHash) 251 if err != nil { 252 req.fail("findRedemption: %w", err) 253 continue 254 } 255 if txHash == nil { 256 continue // maybe next time 257 } 258 req.success(&FindRedemptionResult{ 259 redemptionCoinID: ToCoinID(txHash, vin), 260 secret: secret, 261 }) 262 } 263 } 264 265 // FindRedemption locates a swap contract output's redemption transaction input 266 // and the secret key used to spend the output. 267 func (btc *ExchangeWalletElectrum) FindRedemption(ctx context.Context, coinID, contract dex.Bytes) (redemptionCoin, secret dex.Bytes, err error) { 268 txHash, vout, err := decodeCoinID(coinID) 269 if err != nil { 270 return nil, nil, err 271 } 272 contractHash := btc.hashContract(contract) 273 // We can verify the contract hash via: 274 // txRes, _ := btc.ewc.getWalletTransaction(txHash) 275 // msgTx, _ := msgTxFromBytes(txRes.Hex) 276 // contractHash := dexbtc.ExtractScriptHash(msgTx.TxOut[vout].PkScript) 277 // OR 278 // txOut, _, _ := btc.ew.getTxOutput(txHash, vout) 279 // contractHash := dexbtc.ExtractScriptHash(txOut.PkScript) 280 281 // Check once before putting this in the queue. 282 outPt := NewOutPoint(txHash, vout) 283 spendTxID, vin, secret, err := btc.findRedemption(ctx, outPt, contractHash) 284 if err != nil { 285 return nil, nil, err 286 } 287 if spendTxID != nil { 288 return ToCoinID(spendTxID, vin), secret, nil 289 } 290 291 req := &FindRedemptionReq{ 292 outPt: outPt, 293 resultChan: make(chan *FindRedemptionResult, 1), 294 contractHash: contractHash, 295 // blockHash, blockHeight, and pkScript not used by this impl. 296 blockHash: &chainhash.Hash{}, 297 } 298 if err := btc.queueFindRedemptionRequest(req); err != nil { 299 return nil, nil, err 300 } 301 302 var result *FindRedemptionResult 303 select { 304 case result = <-req.resultChan: 305 if result == nil { 306 err = fmt.Errorf("unexpected nil result for redemption search for %s", outPt) 307 } 308 case <-ctx.Done(): 309 err = fmt.Errorf("context cancelled during search for redemption for %s", outPt) 310 } 311 312 // If this contract is still in the findRedemptionQueue, remove from the 313 // queue to prevent further redemption search attempts for this contract. 314 btc.findRedemptionMtx.Lock() 315 delete(btc.findRedemptionQueue, outPt) 316 btc.findRedemptionMtx.Unlock() 317 318 // result would be nil if ctx is canceled or the result channel is closed 319 // without data, which would happen if the redemption search is aborted when 320 // this ExchangeWallet is shut down. 321 if result != nil { 322 return result.redemptionCoinID, result.secret, result.err 323 } 324 return nil, nil, err 325 } 326 327 func (btc *ExchangeWalletElectrum) queueFindRedemptionRequest(req *FindRedemptionReq) error { 328 btc.findRedemptionMtx.Lock() 329 defer btc.findRedemptionMtx.Unlock() 330 if _, exists := btc.findRedemptionQueue[req.outPt]; exists { 331 return fmt.Errorf("duplicate find redemption request for %s", req.outPt) 332 } 333 btc.findRedemptionQueue[req.outPt] = req 334 return nil 335 } 336 337 // watchBlocks pings for new blocks and runs the tipChange callback function 338 // when the block changes. 339 func (btc *ExchangeWalletElectrum) watchBlocks(ctx context.Context) { 340 const electrumBlockTick = 5 * time.Second 341 ticker := time.NewTicker(electrumBlockTick) 342 defer ticker.Stop() 343 344 bestBlock := func() (*BlockVector, error) { 345 hdr, err := btc.node.getBestBlockHeader() 346 if err != nil { 347 return nil, fmt.Errorf("getBestBlockHeader: %v", err) 348 } 349 hash, err := chainhash.NewHashFromStr(hdr.Hash) 350 if err != nil { 351 return nil, fmt.Errorf("invalid best block hash %s: %v", hdr.Hash, err) 352 } 353 return &BlockVector{hdr.Height, *hash}, nil 354 } 355 356 currentTip, err := bestBlock() 357 if err != nil { 358 btc.log.Errorf("Failed to get best block: %v", err) 359 currentTip = new(BlockVector) // zero height and hash 360 } 361 362 for { 363 select { 364 case <-ticker.C: 365 // Don't make server requests on every tick. Wallet has a headers 366 // subscription, so we can just ask wallet the height. That means 367 // only comparing heights instead of hashes, which means we might 368 // not notice a reorg to a block at the same height, which is 369 // unimportant because of how electrum searches for transactions. 370 ss, err := btc.node.syncStatus() 371 if err != nil { 372 btc.log.Errorf("failed to get sync status: %w", err) 373 continue 374 } 375 376 sameTip := currentTip.Height == int64(ss.Blocks) 377 if sameTip { 378 // Could have actually been a reorg to different block at same 379 // height. We'll report a new tip block on the next block. 380 continue 381 } 382 383 newTip, err := bestBlock() 384 if err != nil { 385 // NOTE: often says "height X out of range", then succeeds on next tick 386 if !strings.Contains(err.Error(), "out of range") { 387 btc.log.Errorf("failed to get best block from %s electrum server: %v", btc.symbol, err) 388 } 389 continue 390 } 391 392 go btc.syncTxHistory(uint64(newTip.Height)) 393 394 btc.log.Tracef("tip change: %d (%s) => %d (%s)", currentTip.Height, currentTip.Hash, 395 newTip.Height, newTip.Hash) 396 currentTip = newTip 397 btc.emit.TipChange(uint64(newTip.Height)) 398 go btc.tryRedemptionRequests(ctx) 399 400 case <-ctx.Done(): 401 return 402 } 403 } 404 } 405 406 // syncTxHistory checks to see if there are any transactions which the wallet 407 // has made or recieved that are not part of the transaction history, then 408 // identifies and adds them. It also checks all the pending transactions to see 409 // if they have been mined into a block, and if so, updates the transaction 410 // history to reflect the block height. 411 func (btc *ExchangeWalletElectrum) syncTxHistory(tip uint64) { 412 if !btc.syncingTxHistory.CompareAndSwap(false, true) { 413 return 414 } 415 defer btc.syncingTxHistory.Store(false) 416 417 txHistoryDB := btc.txDB() 418 if txHistoryDB == nil { 419 return 420 } 421 422 ss, err := btc.SyncStatus() 423 if err != nil { 424 btc.log.Errorf("Error getting sync status: %v", err) 425 return 426 } 427 if !ss.Synced { 428 return 429 } 430 431 btc.addUnknownTransactionsToHistory(tip) 432 433 pendingTxsCopy := make(map[chainhash.Hash]ExtendedWalletTx, len(btc.pendingTxs)) 434 btc.pendingTxsMtx.RLock() 435 for hash, tx := range btc.pendingTxs { 436 pendingTxsCopy[hash] = tx 437 } 438 btc.pendingTxsMtx.RUnlock() 439 440 handlePendingTx := func(txHash chainhash.Hash, tx *ExtendedWalletTx) { 441 if !tx.Submitted { 442 return 443 } 444 445 gtr, err := btc.node.getWalletTransaction(&txHash) 446 if errors.Is(err, asset.CoinNotFoundError) { 447 err = txHistoryDB.RemoveTx(txHash.String()) 448 if err == nil || errors.Is(err, asset.CoinNotFoundError) { 449 btc.pendingTxsMtx.Lock() 450 delete(btc.pendingTxs, txHash) 451 btc.pendingTxsMtx.Unlock() 452 } else { 453 // Leave it in the pendingPendingTxs and attempt to remove it 454 // again next time. 455 btc.log.Errorf("Error removing tx %s from the history store: %v", txHash.String(), err) 456 } 457 return 458 } 459 if err != nil { 460 btc.log.Errorf("Error getting transaction %s: %v", txHash.String(), err) 461 return 462 } 463 464 var updated bool 465 if gtr.BlockHash != "" { 466 bestHeight, err := btc.node.getBestBlockHeight() 467 if err != nil { 468 btc.log.Errorf("getBestBlockHeader: %v", err) 469 return 470 } 471 // TODO: Just get the block height with the header. 472 blockHeight := bestHeight - int32(gtr.Confirmations) + 1 473 i := 0 474 for { 475 if i > 20 || blockHeight < 0 { 476 btc.log.Errorf("Cannot find mined tx block number for %s", gtr.BlockHash) 477 return 478 } 479 bh, err := btc.ew.getBlockHeaderByHeight(btc.ctx, int64(blockHeight)) 480 if err != nil { 481 btc.log.Errorf("Error getting mined tx block number %s: %v", gtr.BlockHash, err) 482 return 483 } 484 if bh.BlockHash().String() == gtr.BlockHash { 485 break 486 } 487 i++ 488 blockHeight-- 489 } 490 if tx.BlockNumber != uint64(blockHeight) { 491 tx.BlockNumber = uint64(blockHeight) 492 tx.Timestamp = gtr.BlockTime 493 updated = true 494 } 495 } else if gtr.BlockHash == "" && tx.BlockNumber != 0 { 496 tx.BlockNumber = 0 497 tx.Timestamp = 0 498 updated = true 499 } 500 501 var confs uint64 502 if tx.BlockNumber > 0 && tip >= tx.BlockNumber { 503 confs = tip - tx.BlockNumber + 1 504 } 505 if confs >= requiredRedeemConfirms { 506 tx.Confirmed = true 507 updated = true 508 } 509 510 if updated { 511 err = txHistoryDB.StoreTx(tx) 512 if err != nil { 513 btc.log.Errorf("Error updating tx %s: %v", txHash, err) 514 return 515 } 516 517 btc.pendingTxsMtx.Lock() 518 if tx.Confirmed { 519 delete(btc.pendingTxs, txHash) 520 } else { 521 btc.pendingTxs[txHash] = *tx 522 } 523 btc.pendingTxsMtx.Unlock() 524 525 btc.emit.TransactionNote(tx.WalletTransaction, false) 526 } 527 } 528 529 for hash, tx := range pendingTxsCopy { 530 if btc.ctx.Err() != nil { 531 return 532 } 533 handlePendingTx(hash, &tx) 534 } 535 } 536 537 // WalletTransaction returns a transaction that either the wallet has made or 538 // one in which the wallet has received funds. The txID can be either a byte 539 // reversed tx hash or a hex encoded coin ID. 540 func (btc *ExchangeWalletElectrum) WalletTransaction(ctx context.Context, txID string) (*asset.WalletTransaction, error) { 541 coinID, err := hex.DecodeString(txID) 542 if err == nil { 543 txHash, _, err := decodeCoinID(coinID) 544 if err == nil { 545 txID = txHash.String() 546 } 547 } 548 549 txHistoryDB := btc.txDB() 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 }