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  }