decred.org/dcrdex@v1.0.5/client/asset/btc/redemption_finder.go (about) 1 package btc 2 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "fmt" 8 "math" 9 "sync" 10 "time" 11 12 "decred.org/dcrdex/dex" 13 dexbtc "decred.org/dcrdex/dex/networks/btc" 14 "github.com/btcsuite/btcd/chaincfg" 15 "github.com/btcsuite/btcd/chaincfg/chainhash" 16 "github.com/btcsuite/btcd/wire" 17 ) 18 19 // FindRedemptionReq represents a request to find a contract's redemption, 20 // which is submitted to the RedemptionFinder. 21 type FindRedemptionReq struct { 22 outPt OutPoint 23 blockHash *chainhash.Hash 24 blockHeight int32 25 resultChan chan *FindRedemptionResult 26 pkScript []byte 27 contractHash []byte 28 } 29 30 func (req *FindRedemptionReq) fail(s string, a ...any) { 31 req.sendResult(&FindRedemptionResult{err: fmt.Errorf(s, a...)}) 32 } 33 34 func (req *FindRedemptionReq) success(res *FindRedemptionResult) { 35 req.sendResult(res) 36 } 37 38 func (req *FindRedemptionReq) sendResult(res *FindRedemptionResult) { 39 select { 40 case req.resultChan <- res: 41 default: 42 // In-case two separate threads find a result. 43 } 44 } 45 46 func (req *FindRedemptionReq) PkScript() []byte { 47 return req.pkScript 48 } 49 50 // FindRedemptionResult models the result of a find redemption attempt. 51 type FindRedemptionResult struct { 52 redemptionCoinID dex.Bytes 53 secret dex.Bytes 54 err error 55 } 56 57 // RedemptionFinder searches on-chain for the redemption of a swap transactions. 58 type RedemptionFinder struct { 59 mtx sync.RWMutex 60 log dex.Logger 61 redemptions map[OutPoint]*FindRedemptionReq 62 63 getWalletTransaction func(txHash *chainhash.Hash) (*GetTransactionResult, error) 64 getBlockHeight func(*chainhash.Hash) (int32, error) 65 getBlock func(h chainhash.Hash) (*wire.MsgBlock, error) 66 getBlockHeader func(blockHash *chainhash.Hash) (hdr *BlockHeader, mainchain bool, err error) 67 hashTx func(*wire.MsgTx) *chainhash.Hash 68 deserializeTx func([]byte) (*wire.MsgTx, error) 69 getBestBlockHeight func() (int32, error) 70 searchBlockForRedemptions func(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq, blockHash chainhash.Hash) (discovered map[OutPoint]*FindRedemptionResult) 71 getBlockHash func(blockHeight int64) (*chainhash.Hash, error) 72 findRedemptionsInMempool func(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq) (discovered map[OutPoint]*FindRedemptionResult) 73 } 74 75 func NewRedemptionFinder( 76 log dex.Logger, 77 getWalletTransaction func(txHash *chainhash.Hash) (*GetTransactionResult, error), 78 getBlockHeight func(*chainhash.Hash) (int32, error), 79 getBlock func(h chainhash.Hash) (*wire.MsgBlock, error), 80 getBlockHeader func(blockHash *chainhash.Hash) (hdr *BlockHeader, mainchain bool, err error), 81 hashTx func(*wire.MsgTx) *chainhash.Hash, 82 deserializeTx func([]byte) (*wire.MsgTx, error), 83 getBestBlockHeight func() (int32, error), 84 searchBlockForRedemptions func(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq, blockHash chainhash.Hash) (discovered map[OutPoint]*FindRedemptionResult), 85 getBlockHash func(blockHeight int64) (*chainhash.Hash, error), 86 findRedemptionsInMempool func(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq) (discovered map[OutPoint]*FindRedemptionResult), 87 ) *RedemptionFinder { 88 return &RedemptionFinder{ 89 log: log, 90 getWalletTransaction: getWalletTransaction, 91 getBlockHeight: getBlockHeight, 92 getBlock: getBlock, 93 getBlockHeader: getBlockHeader, 94 hashTx: hashTx, 95 deserializeTx: deserializeTx, 96 getBestBlockHeight: getBestBlockHeight, 97 searchBlockForRedemptions: searchBlockForRedemptions, 98 getBlockHash: getBlockHash, 99 findRedemptionsInMempool: findRedemptionsInMempool, 100 redemptions: make(map[OutPoint]*FindRedemptionReq), 101 } 102 } 103 104 func (r *RedemptionFinder) FindRedemption(ctx context.Context, coinID dex.Bytes) (redemptionCoin, secret dex.Bytes, err error) { 105 txHash, vout, err := decodeCoinID(coinID) 106 if err != nil { 107 return nil, nil, fmt.Errorf("cannot decode contract coin id: %w", err) 108 } 109 110 outPt := NewOutPoint(txHash, vout) 111 112 tx, err := r.getWalletTransaction(txHash) 113 if err != nil { 114 return nil, nil, fmt.Errorf("error finding wallet transaction: %v", err) 115 } 116 117 txOut, err := TxOutFromTxBytes(tx.Bytes, vout, r.deserializeTx, r.hashTx) 118 if err != nil { 119 return nil, nil, err 120 } 121 pkScript := txOut.PkScript 122 123 var blockHash *chainhash.Hash 124 if tx.BlockHash != "" { 125 blockHash, err = chainhash.NewHashFromStr(tx.BlockHash) 126 if err != nil { 127 return nil, nil, fmt.Errorf("error decoding block hash from string %q: %w", 128 tx.BlockHash, err) 129 } 130 } 131 132 var blockHeight int32 133 if blockHash != nil { 134 r.log.Infof("FindRedemption - Checking block %v for swap %v", blockHash, outPt) 135 blockHeight, err = r.checkRedemptionBlockDetails(outPt, blockHash, pkScript) 136 if err != nil { 137 return nil, nil, fmt.Errorf("checkRedemptionBlockDetails: op %v / block %q: %w", 138 outPt, tx.BlockHash, err) 139 } 140 } 141 142 req := &FindRedemptionReq{ 143 outPt: outPt, 144 blockHash: blockHash, 145 blockHeight: blockHeight, 146 resultChan: make(chan *FindRedemptionResult, 1), 147 pkScript: pkScript, 148 contractHash: dexbtc.ExtractScriptHash(pkScript), 149 } 150 151 if err := r.queueFindRedemptionRequest(req); err != nil { 152 return nil, nil, fmt.Errorf("queueFindRedemptionRequest error for redemption %s: %w", outPt, err) 153 } 154 155 go r.tryRedemptionRequests(ctx, nil, []*FindRedemptionReq{req}) 156 157 var result *FindRedemptionResult 158 select { 159 case result = <-req.resultChan: 160 if result == nil { 161 err = fmt.Errorf("unexpected nil result for redemption search for %s", outPt) 162 } 163 case <-ctx.Done(): 164 err = fmt.Errorf("context cancelled during search for redemption for %s", outPt) 165 } 166 167 // If this contract is still tracked, remove from the queue to prevent 168 // further redemption search attempts for this contract. 169 r.mtx.Lock() 170 delete(r.redemptions, outPt) 171 r.mtx.Unlock() 172 173 // result would be nil if ctx is canceled or the result channel is closed 174 // without data, which would happen if the redemption search is aborted when 175 // this ExchangeWallet is shut down. 176 if result != nil { 177 return result.redemptionCoinID, result.secret, result.err 178 } 179 return nil, nil, err 180 } 181 182 func (r *RedemptionFinder) checkRedemptionBlockDetails(outPt OutPoint, blockHash *chainhash.Hash, pkScript []byte) (int32, error) { 183 blockHeight, err := r.getBlockHeight(blockHash) 184 if err != nil { 185 return 0, fmt.Errorf("GetBlockHeight for redemption block %s error: %w", blockHash, err) 186 } 187 blk, err := r.getBlock(*blockHash) 188 if err != nil { 189 return 0, fmt.Errorf("error retrieving redemption block %s: %w", blockHash, err) 190 } 191 192 var tx *wire.MsgTx 193 out: 194 for _, iTx := range blk.Transactions { 195 if *r.hashTx(iTx) == outPt.TxHash { 196 tx = iTx 197 break out 198 } 199 } 200 if tx == nil { 201 return 0, fmt.Errorf("transaction %s not found in block %s", outPt.TxHash, blockHash) 202 } 203 if uint32(len(tx.TxOut)) < outPt.Vout+1 { 204 return 0, fmt.Errorf("no output %d in redemption transaction %s found in block %s", outPt.Vout, outPt.TxHash, blockHash) 205 } 206 if !bytes.Equal(tx.TxOut[outPt.Vout].PkScript, pkScript) { 207 return 0, fmt.Errorf("pubkey script mismatch for redemption at %s", outPt) 208 } 209 210 return blockHeight, nil 211 } 212 213 func (r *RedemptionFinder) queueFindRedemptionRequest(req *FindRedemptionReq) error { 214 r.mtx.Lock() 215 defer r.mtx.Unlock() 216 if _, exists := r.redemptions[req.outPt]; exists { 217 return fmt.Errorf("duplicate find redemption request for %s", req.outPt) 218 } 219 r.redemptions[req.outPt] = req 220 return nil 221 } 222 223 // tryRedemptionRequests searches all mainchain blocks with height >= startBlock 224 // for redemptions. 225 func (r *RedemptionFinder) tryRedemptionRequests(ctx context.Context, startBlock *chainhash.Hash, reqs []*FindRedemptionReq) { 226 undiscovered := make(map[OutPoint]*FindRedemptionReq, len(reqs)) 227 mempoolReqs := make(map[OutPoint]*FindRedemptionReq) 228 for _, req := range reqs { 229 // If there is no block hash yet, this request hasn't been mined, and a 230 // spending tx cannot have been mined. Only check mempool. 231 if req.blockHash == nil { 232 mempoolReqs[req.outPt] = req 233 continue 234 } 235 undiscovered[req.outPt] = req 236 } 237 238 epicFail := func(s string, a ...any) { 239 for _, req := range reqs { 240 req.fail(s, a...) 241 } 242 } 243 244 // Only search up to the current tip. This does leave two unhandled 245 // scenarios worth mentioning. 246 // 1) A new block is mined during our search. In this case, we won't 247 // see the new block, but tryRedemptionRequests should be called again 248 // by the block monitoring loop. 249 // 2) A reorg happens, and this tip becomes orphaned. In this case, the 250 // worst that can happen is that a shorter chain will replace a longer 251 // one (extremely rare). Even in that case, we'll just log the error and 252 // exit the block loop. 253 tipHeight, err := r.getBestBlockHeight() 254 if err != nil { 255 epicFail("tryRedemptionRequests getBestBlockHeight error: %v", err) 256 return 257 } 258 259 // If a startBlock is provided at a higher height, use that as the starting 260 // point. 261 var iHash *chainhash.Hash 262 var iHeight int32 263 if startBlock != nil { 264 h, err := r.getBlockHeight(startBlock) 265 if err != nil { 266 epicFail("tryRedemptionRequests startBlock getBlockHeight error: %v", err) 267 return 268 } 269 iHeight = h 270 iHash = startBlock 271 } else { 272 iHeight = math.MaxInt32 273 for _, req := range undiscovered { 274 if req.blockHash != nil && req.blockHeight < iHeight { 275 iHeight = req.blockHeight 276 iHash = req.blockHash 277 } 278 } 279 } 280 281 // Helper function to check that the request hasn't been located in another 282 // thread and removed from queue already. 283 reqStillQueued := func(outPt OutPoint) bool { 284 _, found := r.redemptions[outPt] 285 return found 286 } 287 288 for iHeight <= tipHeight { 289 validReqs := make(map[OutPoint]*FindRedemptionReq, len(undiscovered)) 290 r.mtx.RLock() 291 for outPt, req := range undiscovered { 292 if iHeight >= req.blockHeight && reqStillQueued(req.outPt) { 293 validReqs[outPt] = req 294 } 295 } 296 r.mtx.RUnlock() 297 298 if len(validReqs) == 0 { 299 iHeight++ 300 continue 301 } 302 303 r.log.Debugf("tryRedemptionRequests - Checking block %v for redemptions...", iHash) 304 discovered := r.searchBlockForRedemptions(ctx, validReqs, *iHash) 305 for outPt, res := range discovered { 306 req, found := undiscovered[outPt] 307 if !found { 308 r.log.Critical("Request not found in undiscovered map. This shouldn't be possible.") 309 continue 310 } 311 redeemTxID, redeemTxInput, _ := decodeCoinID(res.redemptionCoinID) 312 r.log.Debugf("Found redemption %s:%d", redeemTxID, redeemTxInput) 313 req.success(res) 314 delete(undiscovered, outPt) 315 } 316 317 if len(undiscovered) == 0 { 318 break 319 } 320 321 iHeight++ 322 if iHeight <= tipHeight { 323 if iHash, err = r.getBlockHash(int64(iHeight)); err != nil { 324 // This might be due to a reorg. Don't abandon yet, since 325 // tryRedemptionRequests will be tried again by the block 326 // monitor loop. 327 r.log.Warn("error getting block hash for height %d: %v", iHeight, err) 328 return 329 } 330 } 331 } 332 333 // Check mempool for any remaining undiscovered requests. 334 for outPt, req := range undiscovered { 335 mempoolReqs[outPt] = req 336 } 337 338 if len(mempoolReqs) == 0 { 339 return 340 } 341 342 // Do we really want to do this? Mempool could be huge. 343 searchDur := time.Minute * 5 344 searchCtx, cancel := context.WithTimeout(ctx, searchDur) 345 defer cancel() 346 for outPt, res := range r.findRedemptionsInMempool(searchCtx, mempoolReqs) { 347 req, ok := mempoolReqs[outPt] 348 if !ok { 349 r.log.Errorf("findRedemptionsInMempool discovered outpoint not found") 350 continue 351 } 352 req.success(res) 353 } 354 if err := searchCtx.Err(); err != nil { 355 if errors.Is(err, context.DeadlineExceeded) { 356 r.log.Errorf("mempool search exceeded %s time limit", searchDur) 357 } else { 358 r.log.Error("mempool search was cancelled") 359 } 360 } 361 } 362 363 // prepareRedemptionRequestsForBlockCheck prepares a copy of the currently 364 // tracked redemptions, checking for missing block data along the way. 365 func (r *RedemptionFinder) prepareRedemptionRequestsForBlockCheck() []*FindRedemptionReq { 366 // Search for contract redemption in new blocks if there 367 // are contracts pending redemption. 368 r.mtx.Lock() 369 defer r.mtx.Unlock() 370 reqs := make([]*FindRedemptionReq, 0, len(r.redemptions)) 371 for _, req := range r.redemptions { 372 // If the request doesn't have a block hash yet, check if we can get one 373 // now. 374 if req.blockHash == nil { 375 r.trySetRedemptionRequestBlock(req) 376 } 377 reqs = append(reqs, req) 378 } 379 return reqs 380 } 381 382 // ReportNewTip sets the currentTip. The tipChange callback function is invoked 383 // and a goroutine is started to check if any contracts in the 384 // findRedemptionQueue are redeemed in the new blocks. 385 func (r *RedemptionFinder) ReportNewTip(ctx context.Context, prevTip, newTip *BlockVector) { 386 reqs := r.prepareRedemptionRequestsForBlockCheck() 387 // Redemption search would be compromised if the starting point cannot 388 // be determined, as searching just the new tip might result in blocks 389 // being omitted from the search operation. If that happens, cancel all 390 // find redemption requests in queue. 391 notifyFatalFindRedemptionError := func(s string, a ...any) { 392 for _, req := range reqs { 393 req.fail("tipChange handler - "+s, a...) 394 } 395 } 396 397 var startPoint *BlockVector 398 // Check if the previous tip is still part of the mainchain (prevTip confs >= 0). 399 // Redemption search would typically resume from prevTipHeight + 1 unless the 400 // previous tip was re-orged out of the mainchain, in which case redemption 401 // search will resume from the mainchain ancestor of the previous tip. 402 prevTipHeader, isMainchain, err := r.getBlockHeader(&prevTip.Hash) 403 switch { 404 case err != nil: 405 // Redemption search cannot continue reliably without knowing if there 406 // was a reorg, cancel all find redemption requests in queue. 407 notifyFatalFindRedemptionError("getBlockHeader error for prev tip hash %s: %w", 408 prevTip.Hash, err) 409 return 410 411 case !isMainchain: 412 // The previous tip is no longer part of the mainchain. Crawl blocks 413 // backwards until finding a mainchain block. Start with the block 414 // that is the immediate ancestor to the previous tip. 415 ancestorBlockHash, err := chainhash.NewHashFromStr(prevTipHeader.PreviousBlockHash) 416 if err != nil { 417 notifyFatalFindRedemptionError("hash decode error for block %s: %w", prevTipHeader.PreviousBlockHash, err) 418 return 419 } 420 for { 421 aBlock, isMainchain, err := r.getBlockHeader(ancestorBlockHash) 422 if err != nil { 423 notifyFatalFindRedemptionError("getBlockHeader error for block %s: %w", ancestorBlockHash, err) 424 return 425 } 426 if isMainchain { 427 // Found the mainchain ancestor of previous tip. 428 startPoint = &BlockVector{Height: aBlock.Height, Hash: *ancestorBlockHash} 429 r.log.Debugf("reorg detected from height %d to %d", aBlock.Height, newTip.Height) 430 break 431 } 432 if aBlock.Height == 0 { 433 // Crawled back to genesis block without finding a mainchain ancestor 434 // for the previous tip. Should never happen! 435 notifyFatalFindRedemptionError("no mainchain ancestor for orphaned block %s", prevTipHeader.Hash) 436 return 437 } 438 ancestorBlockHash, err = chainhash.NewHashFromStr(aBlock.PreviousBlockHash) 439 if err != nil { 440 notifyFatalFindRedemptionError("hash decode error for block %s: %w", prevTipHeader.PreviousBlockHash, err) 441 return 442 } 443 } 444 445 case newTip.Height-prevTipHeader.Height > 1: 446 // 2 or more blocks mined since last tip, start at prevTip height + 1. 447 afterPrivTip := prevTipHeader.Height + 1 448 hashAfterPrevTip, err := r.getBlockHash(afterPrivTip) 449 if err != nil { 450 notifyFatalFindRedemptionError("getBlockHash error for height %d: %w", afterPrivTip, err) 451 return 452 } 453 startPoint = &BlockVector{Hash: *hashAfterPrevTip, Height: afterPrivTip} 454 455 default: 456 // Just 1 new block since last tip report, search the lone block. 457 startPoint = newTip 458 } 459 460 if len(reqs) > 0 { 461 go r.tryRedemptionRequests(ctx, &startPoint.Hash, reqs) 462 } 463 } 464 465 // trySetRedemptionRequestBlock should be called with findRedemptionMtx Lock'ed. 466 func (r *RedemptionFinder) trySetRedemptionRequestBlock(req *FindRedemptionReq) { 467 tx, err := r.getWalletTransaction(&req.outPt.TxHash) 468 if err != nil { 469 r.log.Errorf("getWalletTransaction error for FindRedemption transaction: %v", err) 470 return 471 } 472 473 if tx.BlockHash == "" { 474 return 475 } 476 blockHash, err := chainhash.NewHashFromStr(tx.BlockHash) 477 if err != nil { 478 r.log.Errorf("error decoding block hash %q: %v", tx.BlockHash, err) 479 return 480 } 481 482 blockHeight, err := r.checkRedemptionBlockDetails(req.outPt, blockHash, req.pkScript) 483 if err != nil { 484 r.log.Error(err) 485 return 486 } 487 // Don't update the FindRedemptionReq, since the findRedemptionMtx only 488 // protects the map. 489 req = &FindRedemptionReq{ 490 outPt: req.outPt, 491 blockHash: blockHash, 492 blockHeight: blockHeight, 493 resultChan: req.resultChan, 494 pkScript: req.pkScript, 495 contractHash: req.contractHash, 496 } 497 r.redemptions[req.outPt] = req 498 } 499 500 func (r *RedemptionFinder) CancelRedemptionSearches() { 501 // Close all open channels for contract redemption searches 502 // to prevent leakages and ensure goroutines that are started 503 // to wait on these channels end gracefully. 504 r.mtx.Lock() 505 for contractOutpoint, req := range r.redemptions { 506 req.fail("shutting down") 507 delete(r.redemptions, contractOutpoint) 508 } 509 r.mtx.Unlock() 510 } 511 512 func FindRedemptionsInTxWithHasher(ctx context.Context, segwit bool, reqs map[OutPoint]*FindRedemptionReq, msgTx *wire.MsgTx, 513 chainParams *chaincfg.Params, hashTx func(*wire.MsgTx) *chainhash.Hash) (discovered map[OutPoint]*FindRedemptionResult) { 514 515 discovered = make(map[OutPoint]*FindRedemptionResult, len(reqs)) 516 517 for vin, txIn := range msgTx.TxIn { 518 if ctx.Err() != nil { 519 return discovered 520 } 521 poHash, poVout := txIn.PreviousOutPoint.Hash, txIn.PreviousOutPoint.Index 522 for outPt, req := range reqs { 523 if discovered[outPt] != nil { 524 continue 525 } 526 if outPt.TxHash == poHash && outPt.Vout == poVout { 527 // Match! 528 txHash := hashTx(msgTx) 529 secret, err := dexbtc.FindKeyPush(txIn.Witness, txIn.SignatureScript, req.contractHash[:], segwit, chainParams) 530 if err != nil { 531 req.fail("no secret extracted from redemption input %s:%d for swap output %s: %v", 532 txHash, vin, outPt, err) 533 continue 534 } 535 discovered[outPt] = &FindRedemptionResult{ 536 redemptionCoinID: ToCoinID(txHash, uint32(vin)), 537 secret: secret, 538 } 539 } 540 } 541 } 542 return 543 }