From 04512430b10427ad68d00faaaf62a7251d3a6f1e Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Fri, 15 Jan 2021 16:20:40 +0100 Subject: [PATCH 01/22] client/asset/dcr: validate and broadcast txData in AuditContract() --- client/asset/dcr/dcr.go | 118 ++++++++++++++++++++++------------- client/asset/dcr/dcr_test.go | 85 ++++++++++++++++++------- go.mod | 3 +- go.sum | 6 +- 4 files changed, 144 insertions(+), 68 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index cb4af4b12d..50df8e3961 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -25,6 +25,8 @@ import ( dexdcr "decred.org/dcrdex/dex/networks/dcr" "decred.org/dcrwallet/v2/rpc/client/dcrwallet" walletjson "decred.org/dcrwallet/v2/rpc/jsonrpc/types" + "github.com/decred/dcrd/blockchain/stake/v4" + "github.com/decred/dcrd/blockchain/v4" "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -1484,65 +1486,53 @@ func (dcr *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, return pubkeys, sigs, nil } -// AuditContract retrieves information about a swap contract on the -// blockchain. This would be used to verify the counter-party's contract -// during a swap. +// AuditContract retrieves information about a swap contract from the provided +// txData if the provided txData +// - represents a valid transaction that pays to the provided contract at the +// specified coinID and +// - can be broadcasted or is already broadcasted to the blockchain network. +// This information would be used to verify the counter-party's contract during +// a swap. func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ time.Time) (*asset.AuditInfo, error) { txHash, vout, err := decodeCoinID(coinID) if err != nil { return nil, err } + // Get the receiving address. _, receiver, stamp, secretHash, err := dexdcr.ExtractSwapDetails(contract, dcr.chainParams) if err != nil { return nil, fmt.Errorf("error extracting swap addresses: %w", err) } - // Get the contracts P2SH address from the tx output's pubkey script. - txOutRes, txTree, err := dcr.getTxOut(txHash, vout, true) - if err != nil { - return nil, fmt.Errorf("error finding unspent contract: %w", err) + + // Validate the provided txData against the provided coinID (hash and vout). + contractTx := wire.NewMsgTx() + if err := contractTx.Deserialize(bytes.NewReader(txData)); err != nil { + return nil, fmt.Errorf("invalid contract tx data: %w", err) } - coinNotFound := txOutRes == nil - var pkScript []byte - var value uint64 - var version uint16 - if txOutRes != nil { - pkScript, err = hex.DecodeString(txOutRes.ScriptPubKey.Hex) - if err != nil { - return nil, fmt.Errorf("error decoding pubkey script from hex '%s': %w", - txOutRes.ScriptPubKey.Hex, err) - } - value = toAtoms(txOutRes.Value) - version = txOutRes.ScriptPubKey.Version - } else { - tx, err := msgTxFromBytes(txData) - if err != nil { - return nil, fmt.Errorf("coin not found, and error encountered decoding tx data: %v", err) - } - if len(tx.TxOut) <= int(vout) { - return nil, fmt.Errorf("specified output %d not found in decoded tx %s", vout, txHash) - } - txOut := tx.TxOut[vout] - pkScript = txOut.PkScript - value = uint64(txOut.Value) - version = txOut.Version + if checkHash := contractTx.TxHash(); checkHash != *txHash { + return nil, fmt.Errorf("invalid contract tx data: expected hash %s, got %s", txHash, checkHash) + } + if int(vout) >= len(contractTx.TxOut) { + return nil, fmt.Errorf("invalid contract tx data: no output at %d", vout) } - // Check for standard P2SH. - scriptClass, addrs, numReq, err := txscript.ExtractPkScriptAddrs(version, - pkScript, dcr.chainParams, false) + // Verify that the output of interest pays to the hash of the provided contract. + // Output script must be P2SH, with 1 address and 1 required signature. + contractTxOut := contractTx.TxOut[vout] + scriptClass, addrs, sigsReq, err := txscript.ExtractPkScriptAddrs(contractTxOut.Version, contractTxOut.PkScript, dcr.chainParams, false) if err != nil { - return nil, fmt.Errorf("error extracting script addresses from '%x': %w", pkScript, err) + return nil, fmt.Errorf("error extracting script addresses from '%x': %w", contractTxOut.PkScript, err) } if scriptClass != txscript.ScriptHashTy { return nil, fmt.Errorf("unexpected script class %d", scriptClass) } - if numReq != 1 { - return nil, fmt.Errorf("unexpected number of signatures expected for P2SH script: %d", numReq) - } if len(addrs) != 1 { return nil, fmt.Errorf("unexpected number of addresses for P2SH script: %d", len(addrs)) } + if sigsReq != 1 { + return nil, fmt.Errorf("unexpected number of signatures for P2SH script: %d", sigsReq) + } // Compare the contract hash to the P2SH address. contractHash := dcrutil.Hash160(contract) addr := addrs[0] @@ -1554,11 +1544,39 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t return nil, fmt.Errorf("contract hash doesn't match script address. %x != %x", contractHash, addrScript) } - if coinNotFound { - return nil, asset.CoinNotFoundError + + // SPV clients don't check tx sanity before broadcasting, so do that here. + if err = blockchain.CheckTransactionSanity(contractTx, dcr.chainParams); err != nil { + return nil, fmt.Errorf("invalid contract tx data: %v", err) + } + + // The counter-party should have broadcasted the contract tx but + // rebroadcast just in case to ensure that the tx is sent to the + // network and so this wallet records the tx and can treat it just + // like any other of its own. + dcr.log.Debugf("Rebroadcasting contract tx %v.", txData) + allowHighFees := true // high fees shouldn't prevent this tx from being bcast + finalTxHash, err := dcr.wallet.SendRawTransaction(dcr.ctx, contractTx, allowHighFees) + if err != nil { + // TODO: Check if the error indicates that the tx is mined and + // use cfilters to find and validate the contract. + // If the error indicates that the tx is in mempool, how do we + // confirm that the rawtx we just valided is the same as the one + // in mempool? + return nil, translateRPCCancelErr(err) + } + // TODO: SPV wallets broadcasts without any error. What if the tx was actually + // invalid? Do we later re-validate the contract once it is observed on the + // blockchain? Err, if the tx is later found in a block with the same fullhash + // as this audited tx, the audited tx very, very likley coudn't have been invalid. + // Perhaps safe to re-audit the mined tx before acting. + if !finalTxHash.IsEqual(txHash) { + return nil, fmt.Errorf("broadcasted contract tx, but received unexpected transaction ID back from RPC server. "+ + "expected %s, got %s", txHash, finalTxHash) } + return &asset.AuditInfo{ - Coin: newOutput(txHash, vout, value, txTree), + Coin: newOutput(txHash, vout, uint64(contractTxOut.Value), determineTxTree(contractTx)), Contract: contract, SecretHash: secretHash, Recipient: receiver.String(), @@ -1566,6 +1584,22 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t }, nil } +func determineTxTree(msgTx *wire.MsgTx) int8 { + // Try with treasury disabled first. + txType := stake.DetermineTxType(msgTx, false, false) + if txType != stake.TxTypeRegular { + return wire.TxTreeStake + } + + // Try with treasury enabled. + txType = stake.DetermineTxType(msgTx, true, false) + if txType != stake.TxTypeRegular { + return wire.TxTreeStake + } + + return wire.TxTreeRegular +} + // RefundAddress extracts and returns the refund address from a contract. func (dcr *ExchangeWallet) RefundAddress(contract dex.Bytes) (string, error) { sender, _, _, _, err := dexdcr.ExtractSwapDetails(contract, dcr.chainParams) @@ -2015,7 +2049,7 @@ func (dcr *ExchangeWallet) Refund(coinID, contract dex.Bytes, feeSuggestion uint } if *refundHash != checkHash { return nil, fmt.Errorf("refund sent, but received unexpected transaction ID back from RPC server. "+ - "expected %s, got %s", *refundHash, checkHash) + "expected %s, got %s", checkHash, *refundHash) } return toCoinID(refundHash, 0), nil } diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index bab1115848..de7f768dd9 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -1650,12 +1650,11 @@ func TestSignMessage(t *testing.T) { } func TestAuditContract(t *testing.T) { - wallet, node, shutdown, err := tNewWallet() + wallet, _, shutdown, err := tNewWallet() defer shutdown() if err != nil { t.Fatal(err) } - vout := uint32(2) secretHash, _ := hex.DecodeString("5124208c80d33507befa517c08ed01aa8d33adbf37ecd70fb5f9352f7a51a88d") lockTime := time.Now().Add(time.Hour * 12) addrStr := tPKHAddr.String() @@ -1666,21 +1665,23 @@ func TestAuditContract(t *testing.T) { addr, _ := stdaddr.NewAddressScriptHashV0(contract, tChainParams) _, pkScript := addr.PaymentScript() - txoutRes := makeGetTxOutRes(1, 5, pkScript) // 1 conf - node.txOutRes[newOutPoint(tTxHash, vout)] = txoutRes - - // need getblock, getblockhash, and getrawtransaction results too - _, bestBlockHeight, err := node.GetBestBlock(context.Background()) + // Prepare the contract tx data. + contractTx := wire.NewMsgTx() + contractTx.AddTxIn(&wire.TxIn{}) + contractTx.AddTxOut(&wire.TxOut{ + Value: 5 * int64(tLotSize), + PkScript: pkScript, + }) + contractTxData, err := contractTx.Bytes() if err != nil { - t.Fatalf("unexpected GetBestBlock error: %v", err) + t.Fatalf("error preparing contract txdata: %v", err) } - contractHeight := bestBlockHeight + 1 - inputs := []chainjson.Vin{makeRPCVin("feeddabeef", 0, nil)} - newBlockHash, _ := node.blockchain.addRawTx(makeRawTx(contractHeight, tTxHash.String(), inputs, []dex.Bytes{nil, nil, pkScript /* vout 2 */})) - txoutRes.BestBlock = newBlockHash.String() // with 1 conf and this best block hash, the tx is in this block + contractHash := contractTx.TxHash() + contractVout := uint32(0) + contractCoinID := toCoinID(&contractHash, contractVout) - audit, err := wallet.AuditContract(toCoinID(tTxHash, vout), contract, nil, time.Time{}) + audit, err := wallet.AuditContract(contractCoinID, contract, contractTxData, time.Time{}) if err != nil { t.Fatalf("audit error: %v", err) } @@ -1695,27 +1696,63 @@ func TestAuditContract(t *testing.T) { } // Invalid txid - _, err = wallet.AuditContract(make([]byte, 15), contract, nil, time.Time{}) + _, err = wallet.AuditContract(make([]byte, 15), contract, contractTxData, time.Time{}) if err == nil { t.Fatalf("no error for bad txid") } - // GetTxOut error - node.txOutErr = tErr - _, err = wallet.AuditContract(toCoinID(tTxHash, vout), contract, nil, time.Time{}) - if err == nil { - t.Fatalf("no error for unknown txout") - } - node.txOutErr = nil - // Wrong contract pkh, _ := hex.DecodeString("c6a704f11af6cbee8738ff19fc28cdc70aba0b82") wrongAddr, _ := stdaddr.NewAddressPubKeyHashEcdsaSecp256k1V0(pkh, tChainParams) - _, badContract := wrongAddr.PaymentScript() - _, err = wallet.AuditContract(toCoinID(tTxHash, vout), badContract, nil, time.Time{}) + wrongAddrStr := wrongAddr.String() + wrongContract, err := dexdcr.MakeContract(wrongAddrStr, wrongAddrStr, secretHash, lockTime.Unix(), tChainParams) + if err != nil { + t.Fatalf("error making wrong swap contract: %v", err) + } + _, err = wallet.AuditContract(contractCoinID, wrongContract, contractTxData, time.Time{}) if err == nil { t.Fatalf("no error for wrong contract") } + + // Invalid contract + _, wrongPkScript := wrongAddr.PaymentScript() + _, err = wallet.AuditContract(contractCoinID, wrongPkScript, contractTxData, time.Time{}) // addrPkScript not a valid contract + if err == nil { + t.Fatalf("no error for invalid contract") + } + + // No txdata + _, err = wallet.AuditContract(contractCoinID, contract, nil, time.Time{}) + if err == nil { + t.Fatalf("no error for no txdata") + } + + // Invalid txdata, zero inputs + contractTx.TxIn = nil + invalidContractTxData, err := contractTx.Bytes() + if err != nil { + t.Fatalf("error preparing invalid contract txdata: %v", err) + } + _, err = wallet.AuditContract(contractCoinID, contract, invalidContractTxData, time.Time{}) + if err == nil { + t.Fatalf("no error for unknown txout") + } + + // Wrong txdata, wrong output script + wrongContractTx := wire.NewMsgTx() + wrongContractTx.AddTxIn(&wire.TxIn{}) + wrongContractTx.AddTxOut(&wire.TxOut{ + Value: 5 * int64(tLotSize), + PkScript: wrongPkScript, + }) + wrongContractTxData, err := wrongContractTx.Bytes() + if err != nil { + t.Fatalf("error preparing wrong contract txdata: %v", err) + } + _, err = wallet.AuditContract(contractCoinID, contract, wrongContractTxData, time.Time{}) + if err == nil { + t.Fatalf("no error for unknown txout") + } } type tReceipt struct { diff --git a/go.mod b/go.mod index ba4489cd12..3c7fb723d3 100644 --- a/go.mod +++ b/go.mod @@ -15,9 +15,10 @@ require ( github.com/btcsuite/btcwallet/wtxmgr v1.3.0 github.com/davecgh/go-spew v1.1.1 github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210914193033-2efb9bda71fe + github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914193033-2efb9bda71fe github.com/decred/dcrd/certgen v1.1.2-0.20210901152745-8830d9c9cdba github.com/decred/dcrd/chaincfg/chainhash v1.0.3 - github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210901152745-8830d9c9cdba + github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914193033-2efb9bda71fe github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210901152745-8830d9c9cdba github.com/decred/dcrd/database/v3 v3.0.0-20210914193033-2efb9bda71fe // indirect github.com/decred/dcrd/dcrec v1.0.1-0.20210901152745-8830d9c9cdba diff --git a/go.sum b/go.sum index b86a0ba2ba..dc8265cf91 100644 --- a/go.sum +++ b/go.sum @@ -148,8 +148,11 @@ github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210901152745-8830d9c9cdba/go github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210914193033-2efb9bda71fe h1:KUoG7npDcaZTcp6SSq85Os15FkIH9a0Sk11kTFWP1Gk= github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210914193033-2efb9bda71fe/go.mod h1:CStg0VQxxpVWphul8V3BtBOlhkkHfGE3CgwZK00xYwE= github.com/decred/dcrd/blockchain/standalone/v2 v2.0.0/go.mod h1:t2qaZ3hNnxHZ5kzVJDgW5sp47/8T5hYJt7SR+/JtRhI= +github.com/decred/dcrd/blockchain/standalone/v2 v2.0.1-0.20210901152745-8830d9c9cdba h1:t26cyqn+rLrBld91rfQm9/sYjGF56cZN+7uIOjQAilo= github.com/decred/dcrd/blockchain/standalone/v2 v2.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:t2qaZ3hNnxHZ5kzVJDgW5sp47/8T5hYJt7SR+/JtRhI= github.com/decred/dcrd/blockchain/v4 v4.0.0-20210901152745-8830d9c9cdba/go.mod h1:DrP4/0VuweZtbGjT4+HVxsX+ETs8TTx3JIc5wfHykLE= +github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914193033-2efb9bda71fe h1:eEmS41C9QX2B7iPSD7zuEhGtypyq4OSia2LplO/1ZU0= +github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914193033-2efb9bda71fe/go.mod h1:DrP4/0VuweZtbGjT4+HVxsX+ETs8TTx3JIc5wfHykLE= github.com/decred/dcrd/certgen v1.1.2-0.20210901152745-8830d9c9cdba h1:oI24XC4N6KKbiAkqGkKPlT5EwgFwZHjk613LpQayBPI= github.com/decred/dcrd/certgen v1.1.2-0.20210901152745-8830d9c9cdba/go.mod h1:ivkPLChfjdAgFh7ZQOtl6kJRqVkfrCq67dlq3AbZBQE= github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= @@ -157,8 +160,9 @@ github.com/decred/dcrd/chaincfg/chainhash v1.0.3-0.20210901152745-8830d9c9cdba/g github.com/decred/dcrd/chaincfg/chainhash v1.0.3 h1:PF2czcYZGW3dz4i/35AUfVAgnqHl9TMNQt1ADTYGOoE= github.com/decred/dcrd/chaincfg/chainhash v1.0.3/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= github.com/decred/dcrd/chaincfg/v3 v3.0.0/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= -github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210901152745-8830d9c9cdba h1:l3RuL7ihGlM+9U9ZbhgQFbl5MSOYkRHmaBdTWfOhDXk= github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= +github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914193033-2efb9bda71fe h1:emvB/rSrQ9VNHVqfKfSMnS/A/suah0bq3eNOumORUDQ= +github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914193033-2efb9bda71fe/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= github.com/decred/dcrd/connmgr/v3 v3.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:cPI43Aggp1lOhrVG75eJ3c3BwuFx0NhT77FK34ky+ak= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210901152745-8830d9c9cdba h1:xeu3k+84zLOaK/jdM6StVS28M+iLIxgfi6a+Y/Jihsk= From 8fa63e5947b27918f1992e9275a44852b5ebcc57 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Fri, 15 Jan 2021 04:59:24 +0100 Subject: [PATCH 02/22] client/asset/dcr: add basic support for spv wallets - Don't error for missing dcrdjsonrpcapi, instead set wallet.spvMode=true. - Replace getblockchaininfo with syncstatus to determine sync status. --- client/asset/dcr/dcr_test.go | 105 ++++++++++++++++------------------ client/asset/dcr/rpcwallet.go | 55 +++++++----------- 2 files changed, 70 insertions(+), 90 deletions(-) diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index de7f768dd9..4ef16d939e 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -182,40 +182,38 @@ func signFunc(msgTx *wire.MsgTx, scriptSize int) (*wire.MsgTx, bool, error) { } type tRPCClient struct { - sendRawHash *chainhash.Hash - sendRawErr error - sentRawTx *wire.MsgTx - txOutRes map[outPoint]*chainjson.GetTxOutResult - txOutErr error - bestBlockErr error - mempoolErr error - rawTxErr error - unspent []walletjson.ListUnspentResult - unspentErr error - balanceResult *walletjson.GetBalanceResult - balanceErr error - lockUnspentErr error - changeAddr stdaddr.Address - changeAddrErr error - newAddr stdaddr.Address - newAddrErr error - signFunc func(tx *wire.MsgTx) (*wire.MsgTx, bool, error) - privWIF *dcrutil.WIF - privWIFErr error - walletTx *walletjson.GetTransactionResult - walletTxErr error - lockErr error - passErr error - disconnected bool - rawRes map[string]json.RawMessage - rawErr map[string]error - blockchain *tBlockchain - lluCoins []walletjson.ListUnspentResult // Returned from ListLockUnspent - lockedCoins []*wire.OutPoint // Last submitted to LockUnspent - listLockedErr error - blockchainInfo *chainjson.GetBlockChainInfoResult - blockchainInfoErr error - estFeeErr error + sendRawHash *chainhash.Hash + sendRawErr error + sentRawTx *wire.MsgTx + txOutRes map[outPoint]*chainjson.GetTxOutResult + txOutErr error + bestBlockErr error + mempoolErr error + rawTxErr error + unspent []walletjson.ListUnspentResult + unspentErr error + balanceResult *walletjson.GetBalanceResult + balanceErr error + lockUnspentErr error + changeAddr stdaddr.Address + changeAddrErr error + newAddr stdaddr.Address + newAddrErr error + signFunc func(tx *wire.MsgTx) (*wire.MsgTx, bool, error) + privWIF *dcrutil.WIF + privWIFErr error + walletTx *walletjson.GetTransactionResult + walletTxErr error + lockErr error + passErr error + disconnected bool + rawRes map[string]json.RawMessage + rawErr map[string]error + blockchain *tBlockchain + lluCoins []walletjson.ListUnspentResult // Returned from ListLockUnspent + lockedCoins []*wire.OutPoint // Last submitted to LockUnspent + listLockedErr error + estFeeErr error } type tBlockchain struct { @@ -340,10 +338,6 @@ func (c *tRPCClient) EstimateSmartFee(_ context.Context, confirmations int64, mo return &chainjson.EstimateSmartFeeResult{FeeRate: optimalRate}, nil } -func (c *tRPCClient) GetBlockChainInfo(_ context.Context) (*chainjson.GetBlockChainInfoResult, error) { - return c.blockchainInfo, c.blockchainInfoErr -} - func (c *tRPCClient) SendRawTransaction(_ context.Context, tx *wire.MsgTx, allowHighFees bool) (*chainhash.Hash, error) { c.sentRawTx = tx if c.sendRawErr == nil && c.sendRawHash == nil { @@ -2313,46 +2307,45 @@ func TestSyncStatus(t *testing.T) { if err != nil { t.Fatal(err) } - node.blockchainInfo = &chainjson.GetBlockChainInfoResult{ - Headers: 100, - Blocks: 99, - } + node.rawRes[methodSyncStatus], node.rawErr[methodSyncStatus] = json.Marshal(&walletjson.SyncStatusResult{ + Synced: true, + InitialBlockDownload: false, + HeadersFetchProgress: 1, + }) synced, progress, err := wallet.SyncStatus() if err != nil { t.Fatalf("SyncStatus error (synced expected): %v", err) } if !synced { - t.Fatalf("synced = false for 1 block to go") + t.Fatalf("synced = false for progress=1") } if progress < 1 { - t.Fatalf("progress not complete when loading last block") + t.Fatalf("progress not complete with sync true") } - node.blockchainInfoErr = tErr + node.rawErr[methodSyncStatus] = tErr _, _, err = wallet.SyncStatus() if err == nil { t.Fatalf("SyncStatus error not propagated") } - node.blockchainInfoErr = nil + node.rawErr[methodSyncStatus] = nil - wallet.wallet = &rpcWallet{ - rpcClient: node, - tipAtConnect: 100, - } - node.blockchainInfo = &chainjson.GetBlockChainInfoResult{ - Headers: 200, - Blocks: 150, + nodeSyncStatusResult := &walletjson.SyncStatusResult{ + Synced: false, + InitialBlockDownload: false, + HeadersFetchProgress: 0.5, // Headers: 200, WalletTip: 100 } + node.rawRes[methodSyncStatus], node.rawErr[methodSyncStatus] = json.Marshal(nodeSyncStatusResult) synced, progress, err = wallet.SyncStatus() if err != nil { t.Fatalf("SyncStatus error (half-synced): %v", err) } if synced { - t.Fatalf("synced = true for 50 blocks to go") + t.Fatalf("synced = true for progress=0.5") } - if progress > 0.500001 || progress < 0.4999999 { - t.Fatalf("progress out of range. Expected 0.5, got %.2f", progress) + if progress != nodeSyncStatusResult.HeadersFetchProgress { + t.Fatalf("progress out of range. Expected %.2f, got %.2f", nodeSyncStatusResult.HeadersFetchProgress, progress) } } diff --git a/client/asset/dcr/rpcwallet.go b/client/asset/dcr/rpcwallet.go index bc96465186..310f4ffb83 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -10,7 +10,6 @@ import ( "fmt" "os" "strings" - "sync/atomic" "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" @@ -37,17 +36,15 @@ const ( methodListUnspent = "listunspent" methodListLockUnspent = "listlockunspent" methodSignRawTransaction = "signrawtransaction" + methodSyncStatus = "syncstatus" ) // rpcWallet implements Wallet functionality using an rpc client to communicate // with the json-rpc server of an external dcrwallet daemon. type rpcWallet struct { - // 64-bit atomic variables first. See - // https://golang.org/pkg/sync/atomic/#pkg-note-BUG - tipAtConnect int64 - chainParams *chaincfg.Params log dex.Logger + spvMode bool // rpcConnector is a rpcclient.Client, does not need to be // set for testing. @@ -90,7 +87,6 @@ type rpcConnector interface { type rpcClient interface { GetCurrentNet(ctx context.Context) (wire.CurrencyNet, error) EstimateSmartFee(ctx context.Context, confirmations int64, mode chainjson.EstimateSmartFeeMode) (*chainjson.EstimateSmartFeeResult, error) - GetBlockChainInfo(ctx context.Context) (*chainjson.GetBlockChainInfoResult, error) SendRawTransaction(ctx context.Context, tx *wire.MsgTx, allowHighFees bool) (*chainhash.Hash, error) GetTxOut(ctx context.Context, txHash *chainhash.Hash, index uint32, tree int8, mempool bool) (*chainjson.GetTxOutResult, error) GetBalanceMinConf(ctx context.Context, account string, minConfirms int) (*walletjson.GetBalanceResult, error) @@ -214,26 +210,25 @@ func (w *rpcWallet) Connect(ctx context.Context) error { return fmt.Errorf("dcrwallet has an incompatible JSON-RPC version: got %s, expected %s", walletSemver, requiredWalletVersion) } + ver, exists = versions["dcrdjsonrpcapi"] if !exists { - return fmt.Errorf("dcrwallet.Version response missing 'dcrdjsonrpcapi'") - } - nodeSemver := dex.NewSemver(ver.Major, ver.Minor, ver.Patch) - if !dex.SemverCompatible(requiredNodeVersion, nodeSemver) { - return fmt.Errorf("dcrd has an incompatible JSON-RPC version: got %s, expected %s", - nodeSemver, requiredNodeVersion) - } - - // Set the tipAtConnect, we'll use it later in determining SyncStatus. - _, currentTip, err := w.GetBestBlock(ctx) - if err != nil { - return fmt.Errorf("error getting best block height: %w", translateRPCCancelErr(err)) + w.spvMode = true + w.log.Infof("Connected to dcrwallet (JSON-RPC API v%s) in SPV mode", walletSemver) + // TODO: Thr wallet may actually not be connected to an spv syncer, use the walletinfo + // rpc to confirm and return the following error if this is not spv wallet. + // return fmt.Errorf("dcrwallet.Version response missing 'dcrdjsonrpcapi'") + } else { + nodeSemver := dex.NewSemver(ver.Major, ver.Minor, ver.Patch) + if !dex.SemverCompatible(requiredNodeVersion, nodeSemver) { + return fmt.Errorf("dcrd has an incompatible JSON-RPC version: got %s, expected %s", + nodeSemver, requiredNodeVersion) + } + w.log.Infof("Connected to dcrwallet (JSON-RPC API v%s) proxying dcrd (JSON-RPC API v%s) on %v", + walletSemver, nodeSemver, w.chainParams.Name) } - atomic.StoreInt64(&w.tipAtConnect, currentTip) success = true - w.log.Infof("Connected to dcrwallet (JSON-RPC API v%s) proxying dcrd (JSON-RPC API v%s) on %v", - walletSemver, nodeSemver, w.chainParams.Name) return nil } @@ -494,21 +489,13 @@ func (w *rpcWallet) UnlockAccount(ctx context.Context, account, passphrase strin // SyncStatus returns the wallet's sync status. // Part of the Wallet interface. func (w *rpcWallet) SyncStatus(ctx context.Context) (bool, float32, error) { - chainInfo, err := w.rpcClient.GetBlockChainInfo(ctx) + syncStatus := new(walletjson.SyncStatusResult) + err := w.rpcClientRawRequest(ctx, methodSyncStatus, nil, syncStatus) if err != nil { - return false, 0, fmt.Errorf("getblockchaininfo error: %w", translateRPCCancelErr(err)) - } - toGo := chainInfo.Headers - chainInfo.Blocks - if chainInfo.InitialBlockDownload || toGo > 1 { - ogTip := atomic.LoadInt64(&w.tipAtConnect) - totalToSync := chainInfo.Headers - ogTip - var progress float32 = 1 - if totalToSync > 0 { - progress = 1 - (float32(toGo) / float32(totalToSync)) - } - return false, progress, nil + return false, 0, fmt.Errorf("rawrequest error: %w", err) } - return true, 1, nil + ready := syncStatus.Synced && !syncStatus.InitialBlockDownload + return ready, syncStatus.HeadersFetchProgress, nil } // AddressPrivKey fetches the privkey for the specified address. From c3d9007241d668a1e1288e654520be29721c59c8 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Sat, 22 Aug 2020 04:31:41 +0100 Subject: [PATCH 03/22] dcr harness: use spv for trading2 wallet --- dex/testing/dcr/create-wallet.sh | 17 +++++++++++++++-- dex/testing/dcr/harness.sh | 19 +++++++++++-------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/dex/testing/dcr/create-wallet.sh b/dex/testing/dcr/create-wallet.sh index 72ced28386..5757de49bc 100755 --- a/dex/testing/dcr/create-wallet.sh +++ b/dex/testing/dcr/create-wallet.sh @@ -7,8 +7,9 @@ TMUX_WIN_ID=$1 NAME=$2 SEED=$3 RPC_PORT=$4 -ENABLE_VOTING=$5 -HTTPPROF_PORT=$6 +USE_SPV=$5 +ENABLE_VOTING=$6 +HTTPPROF_PORT=$7 WALLET_DIR="${NODES_ROOT}/${NAME}" mkdir -p ${WALLET_DIR} @@ -16,9 +17,11 @@ mkdir -p ${WALLET_DIR} export SHELL=$(which bash) # Connect to alpha or beta node +DCRD_SPV_PORT="${ALPHA_NODE_PORT}" DCRD_RPC_PORT="${ALPHA_NODE_RPC_PORT}" DCRD_RPC_CERT="${NODES_ROOT}/alpha/rpc.cert" if [ "${NAME}" = "beta" ] || [ "${NAME}" = "alpha-clone" ]; then + DCRD_SPV_PORT="${BETA_NODE_PORT}" DCRD_RPC_PORT="${BETA_NODE_RPC_PORT}" DCRD_RPC_CERT="${NODES_ROOT}/beta/rpc.cert" fi @@ -35,9 +38,19 @@ password=${RPC_PASS} rpclisten=127.0.0.1:${RPC_PORT} rpccert=${WALLET_DIR}/rpc.cert pass=${WALLET_PASS} +EOF + +if [ "${USE_SPV}" = "1" ]; then + cat >> "${WALLET_DIR}/${NAME}.conf" <> "${WALLET_DIR}/${NAME}.conf" <> "${WALLET_DIR}/${NAME}.conf" diff --git a/dex/testing/dcr/harness.sh b/dex/testing/dcr/harness.sh index 220ed9c736..fc2a82546f 100755 --- a/dex/testing/dcr/harness.sh +++ b/dex/testing/dcr/harness.sh @@ -7,11 +7,10 @@ export RPC_PASS="pass" export WALLET_PASS=abc # --listen and --rpclisten ports for alpha and beta nodes. -# The --rpclisten ports are exported for use by create-wallet.sh -# to decide which node to connect a wallet to. -ALPHA_NODE_PORT="19560" +# The ports are exported for use by create-wallet.sh. +export ALPHA_NODE_PORT="19560" export ALPHA_NODE_RPC_PORT="19561" -BETA_NODE_PORT="19570" +export BETA_NODE_PORT="19570" export BETA_NODE_RPC_PORT="19571" ALPHA_WALLET_SEED="b280922d2cffda44648346412c5ec97f429938105003730414f10b01e1402eac" @@ -272,26 +271,30 @@ sleep 3 ################################################################################ echo "Creating simnet alpha wallet" +USE_SPV="0" ENABLE_VOTING="2" # 2 = enable voting and ticket buyer "${HARNESS_DIR}/create-wallet.sh" "$SESSION:3" "alpha" ${ALPHA_WALLET_SEED} \ -${ALPHA_WALLET_RPC_PORT} ${ENABLE_VOTING} ${ALPHA_WALLET_HTTPPROF_PORT} +${ALPHA_WALLET_RPC_PORT} ${USE_SPV} ${ENABLE_VOTING} ${ALPHA_WALLET_HTTPPROF_PORT} # alpha uses walletpassphrase/walletlock. echo "Creating simnet beta wallet" +USE_SPV="0" ENABLE_VOTING="0" "${HARNESS_DIR}/create-wallet.sh" "$SESSION:4" "beta" ${BETA_WALLET_SEED} \ -${BETA_WALLET_RPC_PORT} ${ENABLE_VOTING} ${BETA_WALLET_HTTPPROF_PORT} +${BETA_WALLET_RPC_PORT} ${USE_SPV} ${ENABLE_VOTING} ${BETA_WALLET_HTTPPROF_PORT} # The trading wallets need to be created from scratch every time. echo "Creating simnet trading wallet 1" +USE_SPV="0" ENABLE_VOTING="0" "${HARNESS_DIR}/create-wallet.sh" "$SESSION:5" "trading1" ${TRADING_WALLET1_SEED} \ -${TRADING_WALLET1_PORT} ${ENABLE_VOTING} +${TRADING_WALLET1_PORT} ${USE_SPV} ${ENABLE_VOTING} echo "Creating simnet trading wallet 2" +USE_SPV="1" ENABLE_VOTING="0" "${HARNESS_DIR}/create-wallet.sh" "$SESSION:6" "trading2" ${TRADING_WALLET2_SEED} \ -${TRADING_WALLET2_PORT} ${ENABLE_VOTING} +${TRADING_WALLET2_PORT} ${USE_SPV} ${ENABLE_VOTING} sleep 15 From af46a2573b16c003f694abd7e08d3a2ae3d19cbf Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Thu, 11 Mar 2021 12:02:21 +0100 Subject: [PATCH 04/22] client/asset/dcr: use cfilters to find txout confirmations --- client/asset/dcr/dcr.go | 176 ++++++++++---- client/asset/dcr/dcr_test.go | 25 +- client/asset/dcr/externaltx.go | 408 +++++++++++++++++++++++++++++++++ client/asset/dcr/rpcwallet.go | 8 + client/asset/dcr/wallet.go | 3 + go.mod | 12 +- go.sum | 19 +- 7 files changed, 582 insertions(+), 69 deletions(-) create mode 100644 client/asset/dcr/externaltx.go diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 50df8e3961..a7b30d845c 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -32,8 +32,6 @@ import ( "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" "github.com/decred/dcrd/dcrutil/v4" - "github.com/decred/dcrd/gcs/v3" - "github.com/decred/dcrd/gcs/v3/blockcf2" chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v3" "github.com/decred/dcrd/txscript/v4" "github.com/decred/dcrd/txscript/v4/sign" @@ -66,6 +64,8 @@ const ( ) var ( + zeroHash chainhash.Hash + // blockTicker is the delay between calls to check for new blocks. blockTicker = time.Second conventionalConversionFactor = float64(dexdcr.UnitInfo.Conventional.ConversionFactor) @@ -384,6 +384,9 @@ type ExchangeWallet struct { findRedemptionMtx sync.RWMutex findRedemptionQueue map[outPoint]*findRedemptionReq + + externalTxMtx sync.RWMutex + externalTxs map[chainhash.Hash]*externalTx } type block struct { @@ -484,6 +487,7 @@ func unconnectedWallet(cfg *asset.WalletConfig, dcrCfg *Config, chainParams *cha tipChange: cfg.TipChange, fundingCoins: make(map[outPoint]*fundingCoin), findRedemptionQueue: make(map[outPoint]*findRedemptionReq), + externalTxs: make(map[chainhash.Hash]*externalTx), fallbackFeeRate: fallbackFeesPerByte, feeRateLimit: feesLimitPerByte, redeemConfTarget: redeemConfTarget, @@ -1575,6 +1579,12 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t "expected %s, got %s", txHash, finalTxHash) } + if dcr.wallet.SpvMode() { + // Record this contract tx to easily get confirmations later + // using cfilters. + dcr.trackExternalTx(txHash, contractTxOut.PkScript) + } + return &asset.AuditInfo{ Coin: newOutput(txHash, vout, uint64(contractTxOut.Value), determineTxTree(contractTx)), Contract: contract, @@ -1585,18 +1595,25 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t } func determineTxTree(msgTx *wire.MsgTx) int8 { - // Try with treasury disabled first. - txType := stake.DetermineTxType(msgTx, false, false) - if txType != stake.TxTypeRegular { + // stake.DetermineTxType will produce correct results if we pass true for + // isTreasuryEnabled regardless of whether the treasury vote has activated + // or not. + // The only possibility for wrong results is passing isTreasuryEnabled=false + // _after_ the treasury vote activates - some stake tree votes may identify + // as regular tree transactions. + // Could try with isTreasuryEnabled false, then true and if neither comes up + // as a stake transaction, then we infer regular, but that isn't necessary + // as explained above. + isTreasuryEnabled := true + // Consider the automatic ticket revocations agenda NOT active. Specifying + // true just adds the constraints that revocations must have an empty + // signature script for its input and must have zero fee. Thus, false will + // correctly identify consensus-validated transactions before OR after + // activation of this agenda. + isAutoRevocationsEnabled := false + if stake.DetermineTxType(msgTx, isTreasuryEnabled, isAutoRevocationsEnabled) != stake.TxTypeRegular { return wire.TxTreeStake } - - // Try with treasury enabled. - txType = stake.DetermineTxType(msgTx, true, false) - if txType != stake.TxTypeRegular { - return wire.TxTreeStake - } - return wire.TxTreeRegular } @@ -1855,15 +1872,16 @@ rangeBlocks: } dcr.findRedemptionMtx.RUnlock() - // Check if any of the above p2sh scripts is possibly included in this block. - hit, err := dcr.blockMaybeContainsScripts(blockHash, contractP2SHScripts) - if err != nil { // error checking a block's cfilters is a fatal error - err = fmt.Errorf("error checking cfilters for block %d (%s) for likely contract inclusion: %w", - blockHeight, blockHash, err) + // Get the cfilters for this block to check if any of the above p2sh scripts is + // possibly included in this block. + blkCFilter, err := dcr.getBlockFilterV2(blockHash) + if err != nil { // error retrieving a block's cfilters is a fatal error + err = fmt.Errorf("get cfilters error for block %d (%s): %w", blockHeight, blockHash, + translateRPCCancelErr(err)) dcr.fatalFindRedemptionsError(err, contractOutpoints) return } - if !hit { + if !blkCFilter.MatchAny(contractP2SHScripts) { lastScannedBlockHeight = blockHeight continue // block does not reference any of these contracts, continue to next block } @@ -2004,32 +2022,6 @@ func (dcr *ExchangeWallet) fatalFindRedemptionsError(err error, contractOutpoint dcr.findRedemptionMtx.Unlock() } -// blockMaybeContainsScripts uses the cfilters of the specified block to -// determine if the block likely includes any of the passed scripts. -func (dcr *ExchangeWallet) blockMaybeContainsScripts(blockHash *chainhash.Hash, scripts [][]byte) (bool, error) { - bf, key, err := dcr.wallet.BlockCFilter(dcr.ctx, blockHash) - if err != nil { - return false, err - } - - filterB, err := hex.DecodeString(bf) - if err != nil { - return false, err - } - keyB, err := hex.DecodeString(key) - if err != nil { - return false, err - } - - filter, err := gcs.FromBytesV2(blockcf2.B, blockcf2.M, filterB) - if err != nil { - return false, err - } - var bcf2Key [gcs.KeySize]byte - copy(bcf2Key[:], keyB) - return filter.MatchAny(bcf2Key, scripts), nil -} - // Refund refunds a contract. This can only be used after the time lock has // expired. // NOTE: The contract cannot be retrieved from the unspent coin info as the @@ -2242,22 +2234,56 @@ func (dcr *ExchangeWallet) ValidateSecret(secret, secretHash []byte) bool { return bytes.Equal(h[:], secretHash) } +// coinConfirmations gets the number of confirmations for the specified coin by +// first checking for a unspent output, and if not found, searching transactions +// indexed by the node (if connected) or the wallet. func (dcr *ExchangeWallet) coinConfirmations(ctx context.Context, id dex.Bytes) (confs uint32, spent bool, err error) { txHash, vout, err := decodeCoinID(id) if err != nil { return 0, false, err } + // Check for an unspent output. txOut, _, err := dcr.getTxOut(txHash, vout, true) - if err == nil && txOut != nil { + if err != nil { + return 0, false, fmt.Errorf("gettxout error for %s:%d: %w", txHash, vout, translateRPCCancelErr(err)) + } + if txOut != nil { return uint32(txOut.Confirmations), false, nil } - // Check wallet transactions. - tx, err := dcr.wallet.GetTransaction(ctx, txHash) - if err != nil { - return 0, false, err + + // Unspent output not found. Check if the transaction exists. + // If it exists, then the output must either be spent or we + // are dealing with an invalid vout. Regardless, assume spent. + if dcr.wallet.SpvMode() { + // SPV wallets can only look up wallet transactions. Check + // if this tx is indexed by the wallet. + tx, err := dcr.wallet.GetTransaction(ctx, txHash) + if err == nil { + return uint32(tx.Confirmations), true, nil + } + if !isTxNotFoundErr(err) { + return 0, false, translateRPCCancelErr(err) + } + // Tx not found by wallet. Attempt using cfilters to find + // the tx in a block and determine if the requested output + // is spent. This tx must have been previously associated + // with a script using dcr.trackExternalTx otherwise, the + // tx cannot be located in a mined block using cfitler and + // an asset.CoinNotFoundError will be returned. + return dcr.externalTxOutConfirmations(txHash, vout) } - return uint32(tx.Confirmations), true, nil + + // Node-backed wallets can look up any transaction. Check if this + // tx exists on the blockchain. + tx, err := dcr.wallet.GetRawTransactionVerbose(ctx, txHash) + if err == nil { + return uint32(tx.Confirmations), true, nil + } + if !isTxNotFoundErr(err) { + return 0, false, translateRPCCancelErr(err) + } + return 0, false, asset.CoinNotFoundError } // SwapConfirmations gets the number of confirmations for the specified coin ID @@ -2880,6 +2906,56 @@ func (dcr *ExchangeWallet) getBestBlock(ctx context.Context) (*block, error) { return &block{hash: hash, height: height}, nil } +func (dcr *ExchangeWallet) getBlock(blockHash *chainhash.Hash, verboseTx bool) (*chainjson.GetBlockVerboseResult, error) { + blockVerbose, err := dcr.wallet.GetBlockVerbose(dcr.ctx, blockHash, verboseTx) + if err != nil { + return nil, fmt.Errorf("error retrieving block %s: %w", blockHash, translateRPCCancelErr(err)) + } + return blockVerbose, nil +} + +func (dcr *ExchangeWallet) getBlockHash(blockHeight int64) (*chainhash.Hash, error) { + blockHash, err := dcr.wallet.GetBlockHash(dcr.ctx, blockHeight) + if err != nil { + return nil, translateRPCCancelErr(err) + } + return blockHash, nil +} + +// mainChainAncestor crawls blocks backwards starting at the provided hash +// until finding a mainchain block. Returns the first mainchain block found. +func (dcr *ExchangeWallet) mainChainAncestor(blockHash *chainhash.Hash) (*chainhash.Hash, *chainjson.GetBlockVerboseResult, error) { + if *blockHash == zeroHash { + return nil, nil, fmt.Errorf("invalid block hash %s", blockHash.String()) + } + + checkHash := blockHash + for { + checkBlock, err := dcr.wallet.GetBlockVerbose(dcr.ctx, checkHash, false) + if err != nil { + return nil, nil, fmt.Errorf("error retrieving block %s: %w", checkHash, translateRPCCancelErr(err)) + } + if checkBlock.Confirmations > -1 { + // This is a mainchain block, return the hash. + return checkHash, checkBlock, nil + } + if checkBlock.Height == 0 { + return nil, nil, fmt.Errorf("no mainchain ancestor for block %s", blockHash.String()) + } + checkHash, err = chainhash.NewHashFromStr(checkBlock.PreviousHash) + if err != nil { + return nil, nil, fmt.Errorf("error decoding previous hash %s for block %s: %w", + checkBlock.PreviousHash, checkHash.String(), translateRPCCancelErr(err)) + } + } +} + +func (dcr *ExchangeWallet) cachedBestBlock() block { + dcr.tipMtx.RLock() + defer dcr.tipMtx.RUnlock() + return *dcr.currentTip +} + // wireBytes dumps the serialized transaction bytes. func (dcr *ExchangeWallet) wireBytes(tx *wire.MsgTx) []byte { s, err := tx.Bytes() diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 4ef16d939e..9bfd9bbe3b 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -27,6 +27,7 @@ import ( "github.com/decred/dcrd/dcrec" "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "github.com/decred/dcrd/dcrjson/v4" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/gcs/v3" "github.com/decred/dcrd/gcs/v3/blockcf2" @@ -441,7 +442,7 @@ func (c *tRPCClient) GetRawTransactionVerbose(_ context.Context, txHash *chainha } tx, found := c.blockchain.rawTxs[txHash.String()] if !found { - return nil, fmt.Errorf("no text transaction %s", txHash) + return nil, dcrjson.NewRPCError(dcrjson.ErrRPCNoTxInfo, "no test raw tx "+txHash.String()) } if tx.BlockHeight < 0 { tx.Confirmations = -1 @@ -2204,19 +2205,31 @@ func TestCoinConfirmations(t *testing.T) { t.Fatalf("expected spent = false for gettxout path, got true") } - // gettransaction error - node.walletTxErr = tErr + // gettransaction or getrawtransaction error delete(node.txOutRes, op) + if wallet.wallet.SpvMode() { + node.walletTxErr = tErr + } else { + node.rawTxErr = tErr + } _, spent, err = wallet.coinConfirmations(context.Background(), coinID) if err == nil { t.Fatalf("no error for gettransaction error") } if spent { - t.Fatalf("spent is non-zero with gettransaction error") + t.Fatalf("spent is true with gettransaction error") + } + if wallet.wallet.SpvMode() { + node.walletTxErr = nil + } else { + node.rawTxErr = nil } - node.walletTxErr = nil - node.walletTx = &walletjson.GetTransactionResult{} + if wallet.wallet.SpvMode() { + node.walletTx = &walletjson.GetTransactionResult{} + } else { + node.blockchain.addRawTx(&chainjson.TxRawResult{Txid: tTxHash.String()}) + } _, spent, err = wallet.coinConfirmations(context.Background(), coinID) if err != nil { t.Fatalf("coin error: %v", err) diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go new file mode 100644 index 0000000000..5ceb362323 --- /dev/null +++ b/client/asset/dcr/externaltx.go @@ -0,0 +1,408 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package dcr + +import ( + "encoding/hex" + "fmt" + "sync" + + "decred.org/dcrdex/client/asset" + "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/dcrutil/v4" + "github.com/decred/dcrd/gcs/v3" + "github.com/decred/dcrd/gcs/v3/blockcf2" + chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v3" + "github.com/decred/dcrd/wire" +) + +type externalTx struct { + hash *chainhash.Hash + + mtx sync.RWMutex + relevantScripts [][]byte + + blockFiltersScanner + + // The folowing are protected by the blockFiltersScanner.scanMtx + // because they are set when the tx's block is found and cleared + // when the previously found tx block is orphaned. The scanMtx + // lock must be held for read before accessing these fields. + tree int8 + outputs map[uint32]*externalTxOutput +} + +type externalTxOutput struct { + *wire.TxOut + txHash *chainhash.Hash + vout uint32 + spender blockFiltersScanner +} + +func (output *externalTxOutput) String() string { + return fmt.Sprintf("%s:%d", output.txHash, output.vout) +} + +type blockFiltersScanner struct { + scanMtx sync.RWMutex + lastScannedBlock *chainhash.Hash + relevantBlock *block +} + +// relevantBlockHash returns the hash of the block relevant to this scanner, if +// the relevantBlok is set and is a mainchain block. Returns a nil hash and nil +// error if the relevantBlock is set but is no longer part of the mainchain. +// The scanner's scanMtx MUST be write-locked. +func (scanner *blockFiltersScanner) relevantBlockHash(nodeGetBlockHashFn func(int64) (*chainhash.Hash, error)) (*chainhash.Hash, error) { + if scanner.relevantBlock == nil { + return nil, nil + } + mainchainBlockHash, err := nodeGetBlockHashFn(scanner.relevantBlock.height) + if err != nil { + return nil, fmt.Errorf("cannot get hash for block %d: %v", scanner.relevantBlock.height, err) + } + if mainchainBlockHash.IsEqual(scanner.relevantBlock.hash) { + return scanner.relevantBlock.hash, nil + } + scanner.relevantBlock = nil // clear so we don't keep checking if it is a mainchain block + return nil, nil +} + +// trackExternalTx records the script associated with a tx to enable spv +// wallets locate the tx in a block when it is mined. Once mined, the block +// containing the tx and the pkScripts of all of the tx outputs are recorded. +// The recorded output scripts can then be used to subsequently check if an +// output is spent in a mined transaction. +func (dcr *ExchangeWallet) trackExternalTx(hash *chainhash.Hash, script []byte) { + defer func() { + dcr.log.Debugf("Script %x cached for non-wallet tx %s.", script, hash) + }() + + tx, tracked := dcr.externalTx(hash) + if tracked { + tx.mtx.Lock() + tx.relevantScripts = append(tx.relevantScripts, script) + tx.mtx.Unlock() + return + } + + dcr.externalTxMtx.Lock() + dcr.externalTxs[*hash] = &externalTx{ + hash: hash, + relevantScripts: [][]byte{script}, + } + dcr.externalTxMtx.Unlock() +} + +func (dcr *ExchangeWallet) externalTx(hash *chainhash.Hash) (*externalTx, bool) { + dcr.externalTxMtx.RLock() + tx, tracked := dcr.externalTxs[*hash] + dcr.externalTxMtx.RUnlock() + return tx, tracked +} + +// externalTxOutConfirmations uses the script(s) associated with an externalTx +// to find the block in which the tx is mined and checks if the specified tx +// output is spent by a mined transaction. This tx must have been previously +// associated with one or more scripts using dcr.trackExternalTx, otherwise +// this will return asset.CoinNotFoundError. +func (dcr *ExchangeWallet) externalTxOutConfirmations(hash *chainhash.Hash, vout uint32) (uint32, bool, error) { + tx, tracked := dcr.externalTx(hash) + if !tracked { + dcr.log.Errorf("Attempted to find confirmations for tx %s without an associated script.", hash) + return 0, false, asset.CoinNotFoundError + } + + // If this tx's block is not yet known, and some other process got here + // first, a search for the tx's block might be underway already. Wait + // until any previous search completes and releases this lock. Retain + // the lock once acquired until we are done accessing tx.relevantBlock, + // tx.tree and tx.outputSpendScanners. + tx.scanMtx.Lock() + + // First try to determine if the tx has been mined before checking if + // the specified output is spent. Outputs of unmined txs cannot be + // spent in mined blocks. + txBlockFound, err := dcr.findExternalTxBlock(tx) + if !txBlockFound || err != nil { + tx.scanMtx.Unlock() + return 0, false, err + } + // Tx block is known. Read the tx block and desired output before + // releasing the scanMtx lock. + txBlock := tx.relevantBlock + output := tx.outputs[vout] + if output == nil { + tx.scanMtx.Unlock() + return 0, false, fmt.Errorf("tx %s has no output at index %d", hash, vout) + } + tx.scanMtx.Unlock() + + isSpent, err := dcr.findExternalTxOutputSpender(output, txBlock.hash) + if err != nil { + return 0, false, fmt.Errorf("unable to determine if output %s:%d is spent: %v", hash, vout, err) + } + + bestBlockHeight := dcr.cachedBestBlock().height + return uint32(bestBlockHeight - txBlock.height + 1), isSpent, nil +} + +// findExternalTxBlock returns true if the block containing the provided tx is +// known and is a mainchain block. If the tx block is not yet known or has been +// re-orged out of the mainchain, this method matches the script(s) associated +// with the tx against multiple block cfilters (starting from the current best +// block down till the genesis block) in an attempt to find the block containing +// the tx. +// If found, the block hash, height and the tx output scripts are recorded. +// The tx's scanMtx MUST be write-locked. +func (dcr *ExchangeWallet) findExternalTxBlock(tx *externalTx) (bool, error) { + // Check if this tx's block was found in a previous search attempt. + txBlockHash, err := tx.relevantBlockHash(dcr.getBlockHash) + if err != nil { + return false, err + } + if txBlockHash != nil { + return true, nil + } + + // This tx's block is yet unknown. Clear the output scripts if they + // were previously set using data from a previously recorded block + // that is now invalidated. + tx.tree = -1 + tx.outputs = nil + + // Start a new search for this tx's block using the associated scripts. + tx.mtx.RLock() + txScripts := tx.relevantScripts + tx.mtx.RUnlock() + + // Scan block filters in reverse from the current best block to the last + // scanned block. If the last scanned block has been re-orged out of the + // mainchain, scan back to the mainchain ancestor of the lastScannedBlock. + var lastScannedBlock block + if tx.lastScannedBlock != nil { + stopBlockHash, stopBlock, err := dcr.mainChainAncestor(tx.lastScannedBlock) + if err != nil { + return false, fmt.Errorf("error looking up mainchain ancestor for block %s", err) + } + lastScannedBlock.height = stopBlock.Height + lastScannedBlock.hash = stopBlockHash + tx.lastScannedBlock = stopBlockHash + } else { + // TODO: Determine a stopHeight to use based on when this tx was first seen + // or some constant min block height value. + } + + // Run cfilters scan in reverse from best block to lastScannedBlock. + currentTip := dcr.cachedBestBlock() + dcr.log.Debugf("Searching for tx %s in blocks %d (%s) to %d (%s).", tx.hash, + currentTip.height, currentTip.hash, lastScannedBlock.height, lastScannedBlock.hash) + for blockHeight := currentTip.height; blockHeight > lastScannedBlock.height; blockHeight-- { + blockHash, err := dcr.wallet.GetBlockHash(dcr.ctx, blockHeight) + if err != nil { + return false, translateRPCCancelErr(err) + } + blockFilter, err := dcr.getBlockFilterV2(blockHash) + if err != nil { + return false, err + } + if !blockFilter.MatchAny(txScripts) { + continue // check previous block's filters (blockHeight--) + } + dcr.log.Debugf("Block %d (%s) likely contains tx %s. Confirming.", blockHeight, blockHash, tx.hash) + blk, err := dcr.getBlock(blockHash, true) + if err != nil { + return false, err + } + blockTxs := append(blk.RawTx, blk.RawSTx...) + for i := range blockTxs { + blkTx := &blockTxs[i] + if blkTx.Txid == tx.hash.String() { + dcr.log.Debugf("Found mined tx %s in block %d (%s).", tx.hash, blockHeight, blockHash) + + msgTx, err := msgTxFromHex(blkTx.Hex) + if err != nil { + return false, fmt.Errorf("invalid hex for tx %s: %v", tx.hash, err) + } + + tx.relevantBlock = &block{hash: blockHash, height: blockHeight} + tx.tree = determineTxTree(msgTx) + + // Store the pkScripts for all of this tx's outputs so they + // can be used to later to determine if an output is spent. + tx.outputs = make(map[uint32]*externalTxOutput) + for i := range blkTx.Vout { + output := &blkTx.Vout[i] + amt, err := dcrutil.NewAmount(output.Value) + if err != nil { + dcr.log.Errorf("tx output %s:%d has invalid amount: %v", tx.hash, i, err) + continue + } + pkScript, err := hex.DecodeString(output.ScriptPubKey.Hex) + if err != nil { + dcr.log.Errorf("tx output %s:%d has invalid pkScript: %v", tx.hash, i, err) + continue + } + tx.outputs[uint32(i)] = &externalTxOutput{ + // TODO: output.ScriptPubKey.Version with dcrd 1.7 *release*, not yet + TxOut: newTxOut(int64(amt), output.Version, pkScript), + txHash: tx.hash, + vout: uint32(i), + } + } + return true, nil + } + } + } + + // Scan completed from current tip to last scanned block. Set the + // current tip as the last scanned block so subsequent scans cover + // the latest tip back to this current tip. + tx.lastScannedBlock = currentTip.hash + dcr.log.Debugf("Tx %s NOT found in blocks %d (%s) to %d (%s).", tx.hash, + currentTip.height, currentTip.hash, lastScannedBlock.height, lastScannedBlock.hash) + return false, nil +} + +// findExternalTxOutputSpender returns true if a block is found that contains +// a tx that spends the provided output AND the block is a mainchain block. +// If no block has been found to contain a tx that spends the provided output +// or the block that was found to contain the spender is re-orged out of the +// mainchain, this method matches the output's pkScript against multiple block +// cfilters (starting from the block containing the output up till the current +// best block) in an attempt to find the block containing the output spender. +// If found, the block hash and height is recorded. +func (dcr *ExchangeWallet) findExternalTxOutputSpender(output *externalTxOutput, txBlockHash *chainhash.Hash) (bool, error) { + // If the spender of this tx output is not yet known, and some other + // process got here first, a search for the tx output spender might + // be underway already. Wait until any previous search completes and + // releases this lock. + output.spender.scanMtx.Lock() + defer output.spender.scanMtx.Unlock() + + // Check if the spender of this tx output was found in a previous search + // attempt. + spenderBlockHash, err := output.spender.relevantBlockHash(dcr.getBlockHash) + if err != nil { + return false, err + } + if spenderBlockHash != nil { + return true, nil + } + + // This tx output is not known to be spent as of last search (if any). + // Scan blocks from the lastScannedBlock (if there was a previous scan) + // or from the tx block to attempt finding the spender of this output. + // Use mainChainAncestor to ensure that scanning starts from a mainchain + // block in the event that either tx block or lastScannedBlock have been + // re-orged out of the mainchain. + var startBlock *chainjson.GetBlockVerboseResult + if output.spender.lastScannedBlock == nil { + _, startBlock, err = dcr.mainChainAncestor(txBlockHash) + } else { + _, startBlock, err = dcr.mainChainAncestor(output.spender.lastScannedBlock) + } + if err != nil { + return false, err + } + + // Search for this output's spender in the blocks between startBlock and bestBlock. + bestBlock := dcr.cachedBestBlock() + dcr.log.Debugf("Searching for the tx that spends output %s in blocks %d (%s) to %d (%s).", + output, startBlock.Height, startBlock.Hash, bestBlock.height, bestBlock.hash) + for blockHeight := startBlock.Height; blockHeight <= bestBlock.height; blockHeight++ { + blockHash, err := dcr.wallet.GetBlockHash(dcr.ctx, blockHeight) + if err != nil { + return false, translateRPCCancelErr(err) + } + blockFilter, err := dcr.getBlockFilterV2(blockHash) + if err != nil { + return false, err + } + if !blockFilter.Match(output.PkScript) { + output.spender.lastScannedBlock = blockHash + continue // check next block's filters (blockHeight++) + } + dcr.log.Debugf("Block %d (%s) likely contains a tx that spends %s. Confirming.", + blockHeight, blockHash, output) + blk, err := dcr.getBlock(blockHash, true) + if err != nil { + return false, err + } + for i := range blk.RawTx { + blkTx := &blk.RawTx[i] + if txSpendsOutput(blkTx, output) { + dcr.log.Debugf("Found spender for output %s in block %d (%s), spender tx hash %s.", + output, blockHeight, blockHash, blkTx.Txid) + output.spender.relevantBlock = &block{hash: blockHash, height: blockHeight} + return true, nil + } + } + for i := range blk.RawSTx { + blkTx := &blk.RawSTx[i] + if txSpendsOutput(blkTx, output) { + dcr.log.Debugf("Found spender for output %s in block %d (%s), spender tx hash %s.", + output, blockHeight, blockHash, blkTx.Txid) + output.spender.relevantBlock = &block{hash: blockHash, height: blockHeight} + return true, nil + } + } + output.spender.lastScannedBlock = blockHash + } + + dcr.log.Debugf("No spender tx found for %s in blocks %d (%s) to %d (%s).", + output, startBlock.Height, startBlock.Hash, bestBlock.height, bestBlock.hash) + return false, nil // scanned up to best block, no spender found +} + +// txSpendsOutput returns true if the passed tx has an input that spends the +// specified output. +func txSpendsOutput(tx *chainjson.TxRawResult, txOut *externalTxOutput) bool { + for i := range tx.Vin { + input := &tx.Vin[i] + if input.Vout == txOut.vout && input.Txid == txOut.txHash.String() { + return true // found spender + } + } + return false +} + +type blockFilter struct { + v2cfilters *gcs.FilterV2 + key [gcs.KeySize]byte +} + +func (bf *blockFilter) Match(data []byte) bool { + return bf.v2cfilters.Match(bf.key, data) +} + +func (bf *blockFilter) MatchAny(data [][]byte) bool { + return bf.v2cfilters.MatchAny(bf.key, data) +} + +func (dcr *ExchangeWallet) getBlockFilterV2(blockHash *chainhash.Hash) (*blockFilter, error) { + bf, key, err := dcr.wallet.BlockCFilter(dcr.ctx, blockHash) + if err != nil { + return nil, err + } + filterB, err := hex.DecodeString(bf) + if err != nil { + return nil, fmt.Errorf("error decoding block filter: %w", err) + } + keyB, err := hex.DecodeString(key) + if err != nil { + return nil, fmt.Errorf("error decoding block filter key: %w", err) + } + filter, err := gcs.FromBytesV2(blockcf2.B, blockcf2.M, filterB) + if err != nil { + return nil, fmt.Errorf("error deserializing block filter: %w", err) + } + var bcf2Key [gcs.KeySize]byte + copy(bcf2Key[:], keyB) + + return &blockFilter{ + v2cfilters: filter, + key: bcf2Key, + }, nil +} diff --git a/client/asset/dcr/rpcwallet.go b/client/asset/dcr/rpcwallet.go index 310f4ffb83..906c016209 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -252,6 +252,14 @@ func (w *rpcWallet) Network(ctx context.Context) (wire.CurrencyNet, error) { return net, translateRPCCancelErr(err) } +// SpvMode returns through if the wallet is connected to +// Part of the Wallet interface. +func (w *rpcWallet) SpvMode() bool { + // TODO: Should probably re-check walletinfo to be sure + // the network backend has not been changed to dcrd. + return w.spvMode +} + // NotifyOnTipChange registers a callback function that should be invoked when // the wallet sees new mainchain blocks. The return value indicates if this // notification can be provided. diff --git a/client/asset/dcr/wallet.go b/client/asset/dcr/wallet.go index b7c0fd6426..cc567341e4 100644 --- a/client/asset/dcr/wallet.go +++ b/client/asset/dcr/wallet.go @@ -60,6 +60,9 @@ type Wallet interface { Disconnected() bool // Network returns the network of the connected wallet. Network(ctx context.Context) (wire.CurrencyNet, error) + // SpvMode returns through if the wallet is connected to the Decred + // network via SPV peers. + SpvMode() bool // NotifyOnTipChange registers a callback function that the should be // invoked when the wallet sees new mainchain blocks. The return value // indicates if this notification can be provided. Where this tip change diff --git a/go.mod b/go.mod index 3c7fb723d3..f2e7f32771 100644 --- a/go.mod +++ b/go.mod @@ -16,22 +16,22 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210914193033-2efb9bda71fe github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914193033-2efb9bda71fe - github.com/decred/dcrd/certgen v1.1.2-0.20210901152745-8830d9c9cdba + github.com/decred/dcrd/certgen v1.1.2-0.20210914193033-2efb9bda71fe github.com/decred/dcrd/chaincfg/chainhash v1.0.3 github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914193033-2efb9bda71fe - github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210901152745-8830d9c9cdba + github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210914193033-2efb9bda71fe github.com/decred/dcrd/database/v3 v3.0.0-20210914193033-2efb9bda71fe // indirect - github.com/decred/dcrd/dcrec v1.0.1-0.20210901152745-8830d9c9cdba - github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2-0.20210715032435-c9521b468f95 + github.com/decred/dcrd/dcrec v1.0.1-0.20210914193033-2efb9bda71fe + github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2-0.20210914193033-2efb9bda71fe github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0 github.com/decred/dcrd/dcrjson/v4 v4.0.0 github.com/decred/dcrd/dcrutil/v4 v4.0.0-20210914193033-2efb9bda71fe github.com/decred/dcrd/gcs/v3 v3.0.0-20210914193033-2efb9bda71fe - github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210901152745-8830d9c9cdba + github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210914193033-2efb9bda71fe github.com/decred/dcrd/rpc/jsonrpc/types/v3 v3.0.0-20210914193033-2efb9bda71fe github.com/decred/dcrd/rpcclient/v7 v7.0.0-20210914193033-2efb9bda71fe github.com/decred/dcrd/txscript/v4 v4.0.0-20210914193033-2efb9bda71fe - github.com/decred/dcrd/wire v1.4.1-0.20210901152745-8830d9c9cdba + github.com/decred/dcrd/wire v1.4.1-0.20210914193033-2efb9bda71fe github.com/decred/go-socks v1.1.0 github.com/decred/slog v1.2.0 github.com/ethereum/go-ethereum v1.10.11 diff --git a/go.sum b/go.sum index dc8265cf91..6e8e4e9dd3 100644 --- a/go.sum +++ b/go.sum @@ -153,8 +153,9 @@ github.com/decred/dcrd/blockchain/standalone/v2 v2.0.1-0.20210901152745-8830d9c9 github.com/decred/dcrd/blockchain/v4 v4.0.0-20210901152745-8830d9c9cdba/go.mod h1:DrP4/0VuweZtbGjT4+HVxsX+ETs8TTx3JIc5wfHykLE= github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914193033-2efb9bda71fe h1:eEmS41C9QX2B7iPSD7zuEhGtypyq4OSia2LplO/1ZU0= github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914193033-2efb9bda71fe/go.mod h1:DrP4/0VuweZtbGjT4+HVxsX+ETs8TTx3JIc5wfHykLE= -github.com/decred/dcrd/certgen v1.1.2-0.20210901152745-8830d9c9cdba h1:oI24XC4N6KKbiAkqGkKPlT5EwgFwZHjk613LpQayBPI= github.com/decred/dcrd/certgen v1.1.2-0.20210901152745-8830d9c9cdba/go.mod h1:ivkPLChfjdAgFh7ZQOtl6kJRqVkfrCq67dlq3AbZBQE= +github.com/decred/dcrd/certgen v1.1.2-0.20210914193033-2efb9bda71fe h1:ycy++nUvV2y0l+7mYeBekUCMxpGYLEuT494PFSbO3Oc= +github.com/decred/dcrd/certgen v1.1.2-0.20210914193033-2efb9bda71fe/go.mod h1:ivkPLChfjdAgFh7ZQOtl6kJRqVkfrCq67dlq3AbZBQE= github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= github.com/decred/dcrd/chaincfg/chainhash v1.0.3-0.20210901152745-8830d9c9cdba/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= github.com/decred/dcrd/chaincfg/chainhash v1.0.3 h1:PF2czcYZGW3dz4i/35AUfVAgnqHl9TMNQt1ADTYGOoE= @@ -165,8 +166,9 @@ github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914193033-2efb9bda71fe h1:emvB/ github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914193033-2efb9bda71fe/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= github.com/decred/dcrd/connmgr/v3 v3.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:cPI43Aggp1lOhrVG75eJ3c3BwuFx0NhT77FK34ky+ak= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210901152745-8830d9c9cdba h1:xeu3k+84zLOaK/jdM6StVS28M+iLIxgfi6a+Y/Jihsk= github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210914193033-2efb9bda71fe h1:7LVYo85EnU4uOoyL87p+v5fhNOzLa1DTgCOeLIFqiKg= +github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210914193033-2efb9bda71fe/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/ripemd160 v1.0.1/go.mod h1:F0H8cjIuWTRoixr/LM3REB8obcWkmYx0gbxpQWR8RPg= github.com/decred/dcrd/crypto/ripemd160 v1.0.2-0.20210901152745-8830d9c9cdba h1:Jv2ENh9AxouG058q/VHHjdrVd9ULnAsv4kURTNssTSY= github.com/decred/dcrd/crypto/ripemd160 v1.0.2-0.20210901152745-8830d9c9cdba/go.mod h1:F0H8cjIuWTRoixr/LM3REB8obcWkmYx0gbxpQWR8RPg= @@ -175,11 +177,12 @@ github.com/decred/dcrd/database/v3 v3.0.0-20210802132946-9ede6ae83e0f/go.mod h1: github.com/decred/dcrd/database/v3 v3.0.0-20210914193033-2efb9bda71fe h1:8jMoLB3AL8OYSIAek4SHo/OAHZMz3QH9EmbqIYXfr0U= github.com/decred/dcrd/database/v3 v3.0.0-20210914193033-2efb9bda71fe/go.mod h1:3WUAfz3R0FOz6wJcqTZ0CcUDfyIMrlO10f3aqa2/7vk= github.com/decred/dcrd/dcrec v1.0.0/go.mod h1:HIaqbEJQ+PDzQcORxnqen5/V1FR3B4VpIfmePklt8Q8= -github.com/decred/dcrd/dcrec v1.0.1-0.20210901152745-8830d9c9cdba h1:x1ti3P9WMqjxyh7K36OxUNhlhmWFZq6bOICeSB4OslA= github.com/decred/dcrd/dcrec v1.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:mIVCrTyD2BEUoie2drr/KAGNvjJCHwf01I0ZCkVQu6c= +github.com/decred/dcrd/dcrec v1.0.1-0.20210914193033-2efb9bda71fe h1:GdgdjJdSyxpstl2bOfQr1A4qWYCnT07jyYdJNvjMUek= +github.com/decred/dcrd/dcrec v1.0.1-0.20210914193033-2efb9bda71fe/go.mod h1:mIVCrTyD2BEUoie2drr/KAGNvjJCHwf01I0ZCkVQu6c= github.com/decred/dcrd/dcrec/edwards/v2 v2.0.1/go.mod h1:d0H8xGMWbiIQP7gN3v2rByWUcuZPm9YsgmnfoxgbINc= -github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2-0.20210715032435-c9521b468f95 h1:QiK8Emx0TKDC7Dc8p+Pibvy+Ifq1FSXXKH3AelYq7sA= -github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2-0.20210715032435-c9521b468f95/go.mod h1:d0H8xGMWbiIQP7gN3v2rByWUcuZPm9YsgmnfoxgbINc= +github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2-0.20210914193033-2efb9bda71fe h1:CU8+7wyF365N9VIGFBe4BQs1cdzlD4vLje00Gv0yzg8= +github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2-0.20210914193033-2efb9bda71fe/go.mod h1:d0H8xGMWbiIQP7gN3v2rByWUcuZPm9YsgmnfoxgbINc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210127014238-b33b46cf1a24/go.mod h1:UkVqoxmJlLgUvBjJD+GdJz6mgdSdf3UjX83xfwUAYDk= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210901152745-8830d9c9cdba/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0 h1:Fe5DW39aaoS/fqZiYlylEqQWIKznnbatWSHpWdFA3oQ= @@ -197,8 +200,9 @@ github.com/decred/dcrd/gcs/v3 v3.0.0-20210129195202-a4265d63b619/go.mod h1:aGuAa github.com/decred/dcrd/gcs/v3 v3.0.0-20210901152745-8830d9c9cdba/go.mod h1:SLlU9PRSMFL4jdAMGZ3m7smAZEbeboDwNOr3BOj4BjY= github.com/decred/dcrd/gcs/v3 v3.0.0-20210914193033-2efb9bda71fe h1:o04eymmi2LReTF4H1q/fhuc5SpiBI3OLzKZGo3kVJ0I= github.com/decred/dcrd/gcs/v3 v3.0.0-20210914193033-2efb9bda71fe/go.mod h1:SLlU9PRSMFL4jdAMGZ3m7smAZEbeboDwNOr3BOj4BjY= -github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210901152745-8830d9c9cdba h1:Coro49F65Ym8mUEpRzDKzLWixNnrR4+MHzKfUi50tYc= github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:A9Aqp4kStmkAwbZeuIlS1hZjTeDkxgVXSg+nSo4FJCs= +github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210914193033-2efb9bda71fe h1:BAo63ZodY9ykmVxAXmbuWisEoYc3amUdm/Of3/cXJbQ= +github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210914193033-2efb9bda71fe/go.mod h1:A9Aqp4kStmkAwbZeuIlS1hZjTeDkxgVXSg+nSo4FJCs= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/decred/dcrd/lru v1.1.0 h1:QwT6v8LFKOL3xQ3qtucgRk4pdiawrxIfCbUXWpm+JL4= github.com/decred/dcrd/lru v1.1.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= @@ -216,8 +220,9 @@ github.com/decred/dcrd/txscript/v4 v4.0.0-20210914193033-2efb9bda71fe h1:4rHewRU github.com/decred/dcrd/txscript/v4 v4.0.0-20210914193033-2efb9bda71fe/go.mod h1:2SlEW/rXtGK1mImNUwONANCRuKuYY+J/qHzwp2wzyRc= github.com/decred/dcrd/wire v1.3.0/go.mod h1:fnKGlUY2IBuqnpxx5dYRU5Oiq392OBqAuVjRVSkIoXM= github.com/decred/dcrd/wire v1.4.0/go.mod h1:WxC/0K+cCAnBh+SKsRjIX9YPgvrjhmE+6pZlel1G7Ro= -github.com/decred/dcrd/wire v1.4.1-0.20210901152745-8830d9c9cdba h1:hamC+kbHSDxywT7TU77nzZpRGG5SmnNAhcdVc2nKScQ= github.com/decred/dcrd/wire v1.4.1-0.20210901152745-8830d9c9cdba/go.mod h1:fzAjVqw32LkbAZIt5mnrvBR751GTa3e0rRQdOIhPY3w= +github.com/decred/dcrd/wire v1.4.1-0.20210914193033-2efb9bda71fe h1:ZM06aSORT/lv6rxxOGR3RJAkLyK8bN7goS40U9zE2c8= +github.com/decred/dcrd/wire v1.4.1-0.20210914193033-2efb9bda71fe/go.mod h1:fzAjVqw32LkbAZIt5mnrvBR751GTa3e0rRQdOIhPY3w= github.com/decred/go-socks v1.1.0 h1:dnENcc0KIqQo3HSXdgboXAHgqsCIutkqq6ntQjYtm2U= github.com/decred/go-socks v1.1.0/go.mod h1:sDhHqkZH0X4JjSa02oYOGhcGHYp12FsY1jQ/meV8md0= github.com/decred/slog v1.1.0/go.mod h1:kVXlGnt6DHy2fV5OjSeuvCJ0OmlmTF6LFpEPMu/fOY0= From 2355326eaf63fccfb72815bf68590202e3ffda65 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Wed, 28 Jul 2021 03:08:41 +0100 Subject: [PATCH 05/22] client/asset/dcr.AuditContract: handle txdata broadcast errors --- client/asset/dcr/dcr.go | 105 ++++++++++++++++++++++++++-------------- client/core/core.go | 5 ++ 2 files changed, 74 insertions(+), 36 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index a7b30d845c..bf188dcffe 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1524,30 +1524,9 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t // Verify that the output of interest pays to the hash of the provided contract. // Output script must be P2SH, with 1 address and 1 required signature. contractTxOut := contractTx.TxOut[vout] - scriptClass, addrs, sigsReq, err := txscript.ExtractPkScriptAddrs(contractTxOut.Version, contractTxOut.PkScript, dcr.chainParams, false) - if err != nil { - return nil, fmt.Errorf("error extracting script addresses from '%x': %w", contractTxOut.PkScript, err) - } - if scriptClass != txscript.ScriptHashTy { - return nil, fmt.Errorf("unexpected script class %d", scriptClass) - } - if len(addrs) != 1 { - return nil, fmt.Errorf("unexpected number of addresses for P2SH script: %d", len(addrs)) - } - if sigsReq != 1 { - return nil, fmt.Errorf("unexpected number of signatures for P2SH script: %d", sigsReq) - } - // Compare the contract hash to the P2SH address. - contractHash := dcrutil.Hash160(contract) - addr := addrs[0] - addrScript, err := dexdcr.AddressScript(addr) - if err != nil { + if err = dcr.validateContractOutputScript(contractTxOut.PkScript, contractTxOut.Version, contract); err != nil { return nil, err } - if !bytes.Equal(contractHash, addrScript) { - return nil, fmt.Errorf("contract hash doesn't match script address. %x != %x", - contractHash, addrScript) - } // SPV clients don't check tx sanity before broadcasting, so do that here. if err = blockchain.CheckTransactionSanity(contractTx, dcr.chainParams); err != nil { @@ -1556,25 +1535,51 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t // The counter-party should have broadcasted the contract tx but // rebroadcast just in case to ensure that the tx is sent to the - // network and so this wallet records the tx and can treat it just - // like any other of its own. + // network. dcr.log.Debugf("Rebroadcasting contract tx %v.", txData) allowHighFees := true // high fees shouldn't prevent this tx from being bcast finalTxHash, err := dcr.wallet.SendRawTransaction(dcr.ctx, contractTx, allowHighFees) if err != nil { - // TODO: Check if the error indicates that the tx is mined and - // use cfilters to find and validate the contract. - // If the error indicates that the tx is in mempool, how do we - // confirm that the rawtx we just valided is the same as the one - // in mempool? - return nil, translateRPCCancelErr(err) + dcr.log.Errorf("Error rebroadcasting contract tx %v: %v", txData, translateRPCCancelErr(err)) + + if !dcr.spvMode { + // The broadcast may have failed because the tx was already broadcasted (and maybe + // even mined). Full node wallets can verify this by calling gettxout. If the tx + // is not found by gettxout, it's possible that the tx isn't yet broadcasted. An + // asset.CoinNotFoundError is returned which signals callers to repeat this audit + // (including the tx rebroadcast attempt) until the tx is found or the match is + // revoked by the server. + dcr.log.Debugf("Attempting to find and audit contract using dcrd.") + txOut, _, err := dcr.getTxOut(txHash, vout, true) + if err != nil { + return nil, fmt.Errorf("error finding unspent contract: %w", translateRPCCancelErr(err)) + } + if txOut == nil { + return nil, asset.CoinNotFoundError + } + pkScript, err := hex.DecodeString(txOut.ScriptPubKey.Hex) + if err != nil { + return nil, fmt.Errorf("error decoding pubkey script from hex '%s': %w", + txOut.ScriptPubKey.Hex, err) + } + if err = dcr.validateContractOutputScript(pkScript, txOut.ScriptPubKey.Version, contract); err != nil { + return nil, err + } + } + + // SPV wallets do not produce sendrawtransaction errors for already broadcasted + // txs. This must be some other unexpected/undesired error. + // Do NOT return an asset.CoinNotFoundError so callers do not recall this method + // as there's no gaurantee that re-attempting the broadcast will succeed. + // Return a successful audit response because it is possible that the tx was + // already broadcasted and the caller can safely begin waiting for confirmations. + // Granted, this wait would be futile if the tx was never broadcasted but as + // explained above, retrying the broadcast isn't a better course of action, neither + // is returning an error here because that would cause the caller to potentially + // give up on this match prematurely. } - // TODO: SPV wallets broadcasts without any error. What if the tx was actually - // invalid? Do we later re-validate the contract once it is observed on the - // blockchain? Err, if the tx is later found in a block with the same fullhash - // as this audited tx, the audited tx very, very likley coudn't have been invalid. - // Perhaps safe to re-audit the mined tx before acting. - if !finalTxHash.IsEqual(txHash) { + + if err == nil && !finalTxHash.IsEqual(txHash) { return nil, fmt.Errorf("broadcasted contract tx, but received unexpected transaction ID back from RPC server. "+ "expected %s, got %s", txHash, finalTxHash) } @@ -1594,6 +1599,34 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t }, nil } +func (dcr *ExchangeWallet) validateContractOutputScript(pkScript []byte, scriptVer uint16, contract []byte) error { + scriptClass, addrs, sigsReq, err := txscript.ExtractPkScriptAddrs(scriptVer, pkScript, dcr.chainParams, false) + if err != nil { + return fmt.Errorf("error extracting script addresses from '%x': %w", pkScript, err) + } + if scriptClass != txscript.ScriptHashTy { + return fmt.Errorf("unexpected script class %d", scriptClass) + } + if len(addrs) != 1 { + return fmt.Errorf("unexpected number of addresses for P2SH script: %d", len(addrs)) + } + if sigsReq != 1 { + return fmt.Errorf("unexpected number of signatures for P2SH script: %d", sigsReq) + } + // Compare the contract hash to the P2SH address. + contractHash := dcrutil.Hash160(contract) + addr := addrs[0] + addrScript, err := dexdcr.AddressScript(addr) + if err != nil { + return err + } + if !bytes.Equal(contractHash, addrScript) { + return fmt.Errorf("contract hash doesn't match script address. %x != %x", + contractHash, addrScript) + } + return nil +} + func determineTxTree(msgTx *wire.MsgTx) int8 { // stake.DetermineTxType will produce correct results if we pass true for // isTreasuryEnabled regardless of whether the treasury vote has activated diff --git a/client/core/core.go b/client/core/core.go index 2cf864c683..80464c6675 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -4911,6 +4911,11 @@ func (c *Core) resumeTrades(dc *dexConnection, trackers []*trackedTrade) assetMa defer tracker.mtx.Unlock() if err != nil { match.swapErr = fmt.Errorf("audit error: %w", err) + // NOTE: This behaviour differs from the audit request handler behaviour for failed audits. + // handleAuditRoute does NOT set a swapErr in case a revised audit request is received from + // the server. Audit requests are currently NOT resent, so this difference is trivial. IF + // a revised audit request did come through though, no further actions will be taken for this + // match even if the revised audit passes validation. c.log.Debugf("AuditContract error for match %v status %v, refunded = %v, revoked = %v: %v", match, match.Status, len(match.MetaData.Proof.RefundCoin) > 0, match.MetaData.Proof.IsRevoked(), err) From 4f5a5e25c58cecdfe68ce340367a6e2804511ba7 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Thu, 29 Jul 2021 13:58:12 +0100 Subject: [PATCH 06/22] multi: re-audit contracts after confirmation, before acting There is a very slight possibility that contract outputs that pass audit while in mempool or in some cases before they are broadcasted (rawtx audit) may differ from the output later observed in a block for the same coin. This risk is higher for spv wallets that will mostly only perform audits on rawtxs before broadcasting the txs, without a guarantee that the tx is accepted to the mempool. A malicious actor could broadcast a different tx with same hash (theoretically possible) but with a different output at the expected vout index. There is risk of funds if clients only later check that the hash for the earlier-audited tx is found in a block and proceed to send their counter swap or expose their contract secret via a redemption. This commit aims to mitigate that risk by repeating contract audits after the initial tx hash is observed on the blockchain, ensuring that the tx now observed on the blockchain is as desired. --- client/asset/dcr/dcr.go | 216 ++++++++++++++++++++++++--------- client/asset/dcr/dcr_test.go | 5 +- client/asset/dcr/externaltx.go | 34 ++++++ client/asset/interface.go | 26 ++-- client/core/core_test.go | 22 +++- client/core/trade.go | 110 +++++++++++++---- 6 files changed, 321 insertions(+), 92 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index bf188dcffe..0f51f10e11 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1490,13 +1490,24 @@ func (dcr *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, return pubkeys, sigs, nil } -// AuditContract retrieves information about a swap contract from the provided -// txData if the provided txData -// - represents a valid transaction that pays to the provided contract at the -// specified coinID and -// - can be broadcasted or is already broadcasted to the blockchain network. -// This information would be used to verify the counter-party's contract during -// a swap. +// AuditContract retrieves information about a swap contract from the blockchain +// (if possible) or from the provided txData if the provided txData represents a +// valid transaction that pays to the provided contract at the specified coinID +// and (for full node-backed wallets) can be broadcasted to the blockchain network. +// +// The information returned would be used to verify the counter-party's contract +// during a swap. +// +// NOTE: For SPV wallets, a successful audit response is no gaurantee that the +// txData provided to this method was actually broadcasted to the blockchain. +// An error may have occured while trying to broadcast the txData or even if +// there was no broadcast error, the tx might still not enter mempool or get +// mined e.g. if the tx references invalid or already spent inputs. +// +// Granted, clients wait for the contract tx to be included in a block before +// taking further actions on a match; but it is generally safer to repeat this +// audit after the contract tx is mined to ensure that the tx observed on the +// blockchain is as expected. func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ time.Time) (*asset.AuditInfo, error) { txHash, vout, err := decodeCoinID(coinID) if err != nil { @@ -1509,7 +1520,33 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t return nil, fmt.Errorf("error extracting swap addresses: %w", err) } - // Validate the provided txData against the provided coinID (hash and vout). + // If this coin can be located on the blockchain, audit this contract + // using the output script gotten from the blockchain. It is especially + // important for contracts that have been mined to audit the tx that was + // mined rather than the txData provided to this method. + contractCoin, contractOutputScript, scriptVer, err := dcr.findContractInBlockchain(txHash, vout) + if err != nil { + return nil, err + } + if contractCoin != nil { + // Found the contract on the blockchain. Audit using the output script + // gotten from the blockchain. + if err = dcr.validateContractOutputScript(contractOutputScript, scriptVer, contract); err != nil { + return nil, err + } + dcr.log.Debugf("Audited contract coin %s:%d using tx data gotten from the blockchain. SPV mode = %t", + txHash, vout, dcr.wallet.SpvMode()) + return &asset.AuditInfo{ + Coin: contractCoin, + Contract: contract, + SecretHash: secretHash, + Recipient: receiver.String(), + Expiration: time.Unix(int64(stamp), 0).UTC(), + }, nil + } + + // Assume tx has not been broadcasted. Audit the provided txData + // and broadcast it. contractTx := wire.NewMsgTx() if err := contractTx.Deserialize(bytes.NewReader(txData)); err != nil { return nil, fmt.Errorf("invalid contract tx data: %w", err) @@ -1520,9 +1557,6 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t if int(vout) >= len(contractTx.TxOut) { return nil, fmt.Errorf("invalid contract tx data: no output at %d", vout) } - - // Verify that the output of interest pays to the hash of the provided contract. - // Output script must be P2SH, with 1 address and 1 required signature. contractTxOut := contractTx.TxOut[vout] if err = dcr.validateContractOutputScript(contractTxOut.PkScript, contractTxOut.Version, contract); err != nil { return nil, err @@ -1542,41 +1576,39 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t if err != nil { dcr.log.Errorf("Error rebroadcasting contract tx %v: %v", txData, translateRPCCancelErr(err)) - if !dcr.spvMode { - // The broadcast may have failed because the tx was already broadcasted (and maybe - // even mined). Full node wallets can verify this by calling gettxout. If the tx - // is not found by gettxout, it's possible that the tx isn't yet broadcasted. An - // asset.CoinNotFoundError is returned which signals callers to repeat this audit - // (including the tx rebroadcast attempt) until the tx is found or the match is - // revoked by the server. - dcr.log.Debugf("Attempting to find and audit contract using dcrd.") - txOut, _, err := dcr.getTxOut(txHash, vout, true) - if err != nil { - return nil, fmt.Errorf("error finding unspent contract: %w", translateRPCCancelErr(err)) - } - if txOut == nil { - return nil, asset.CoinNotFoundError - } - pkScript, err := hex.DecodeString(txOut.ScriptPubKey.Hex) - if err != nil { - return nil, fmt.Errorf("error decoding pubkey script from hex '%s': %w", - txOut.ScriptPubKey.Hex, err) - } - if err = dcr.validateContractOutputScript(pkScript, txOut.ScriptPubKey.Version, contract); err != nil { - return nil, err - } + if !dcr.wallet.SpvMode() { + // The broadcast may have failed because the tx was already + // broadcasted (and maybe mined or even spent), or some other + // unexpected error occurred. Return asset.CoinNotFoundError + // to signal the caller to repeat this audit (including the + // tx rebroadcast attempt) until the tx is found, the raw tx + // is broadcasted successfully or the match is revoked by the + // server. + + // TODO: It'd be unnecessary to continue trying to find this + // contract or to broadcast the rawtx if the tx was already + // broadcasted, mined and spent. + // Consider modifying dcr.findContractInBlockchain to check if + // the tx exists on the blockchain using the gettransaction rpc + // if gettxout returns a nil response. If the gettransaction rpc + // confirms the existence of the tx output, return a CoinSpent + // error to the caller. + + return nil, asset.CoinNotFoundError } - // SPV wallets do not produce sendrawtransaction errors for already broadcasted - // txs. This must be some other unexpected/undesired error. - // Do NOT return an asset.CoinNotFoundError so callers do not recall this method - // as there's no gaurantee that re-attempting the broadcast will succeed. - // Return a successful audit response because it is possible that the tx was - // already broadcasted and the caller can safely begin waiting for confirmations. - // Granted, this wait would be futile if the tx was never broadcasted but as - // explained above, retrying the broadcast isn't a better course of action, neither - // is returning an error here because that would cause the caller to potentially - // give up on this match prematurely. + // SPV wallets do not produce sendrawtransaction errors for already + // broadcasted txs. This must be some other unexpected error. + // Do NOT return an asset.CoinNotFoundError so callers do not recall + // this method as there's no gaurantee that the broadcast will succeed + // on subsequent attempts. + // Return a successful audit response because it is possible that the + // tx was already broadcasted and the caller can safely begin waiting + // for confirmations. + // Granted, this wait would be futile if the tx was never broadcasted + // but as explained above, retrying the broadcast isn't a better course + // of action, neither is returning an error here because that would cause + // the caller to potentially give up on this match prematurely. } if err == nil && !finalTxHash.IsEqual(txHash) { @@ -1590,6 +1622,7 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t dcr.trackExternalTx(txHash, contractTxOut.PkScript) } + dcr.log.Debugf("Audited contract coin %s:%d using raw tx data. SPV mode = %t", txHash, vout, dcr.wallet.SpvMode()) return &asset.AuditInfo{ Coin: newOutput(txHash, vout, uint64(contractTxOut.Value), determineTxTree(contractTx)), Contract: contract, @@ -1599,7 +1632,68 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t }, nil } +// findContractInBlockchain attempts to locate the specified contract output in +// the blockchain. If found and unspent, the output is returned along with its +// pkScript. Returns an error if the output is spent. It is not an error if the +// tx cannot be located, a nil response and nil error is returned. +func (dcr *ExchangeWallet) findContractInBlockchain(txHash *chainhash.Hash, vout uint32) (*output, []byte, uint16, error) { + if dcr.wallet.SpvMode() { + // SPV wallets can only locate contract outputs on the blockchain + // using cfilters. + txOut, txTree, isSpent, err := dcr.externalTxOut(txHash, vout) + if err != nil { + if errors.Is(err, asset.CoinNotFoundError) { + return nil, nil, 0, nil + } + return nil, nil, 0, fmt.Errorf("error checking if contract is mined or spent: %v", err) + } + if isSpent { + return nil, nil, 0, fmt.Errorf("contract output %s:%d is spent", txHash, vout) + } + // Extract relevant info from the tx data retrieved from the blockchain. + contractOutput := newOutput(txHash, vout, uint64(txOut.Value), txTree) + return contractOutput, txOut.PkScript, txOut.Version, nil + } + + // Full-node wallets can locate 'unspent' contract outputs using gettxout. + txOut, txTree, err := dcr.getTxOut(txHash, vout, true) + if err != nil { + return nil, nil, 0, fmt.Errorf("error finding unspent contract: %w", translateRPCCancelErr(err)) + } + if txOut == nil { + // Output does not exist or has been spent. Use getrawtransaction + // to confirm if this tx exists and has an output at `vout`. If it + // does, then it must have been spent for gettxout to return a nil + // response. + tx, err := dcr.wallet.GetRawTransactionVerbose(dcr.ctx, txHash) + if err != nil { + if isTxNotFoundErr(err) { + return nil, nil, 0, nil + } + return nil, nil, 0, fmt.Errorf("error looking up contract tx %s: %w", txHash, translateRPCCancelErr(err)) + } + if len(tx.Vout) <= int(vout) { + return nil, nil, 0, fmt.Errorf("tx %s has no output at index %d", txHash, vout) + } + // Tx found and contains the requested output. + return nil, nil, 0, fmt.Errorf("contract output %s:%d is spent", txHash, vout) + } + + txOutAmt, err := dcrutil.NewAmount(txOut.Value) + if err != nil { + return nil, nil, 0, fmt.Errorf("error parsing output amount %f: %w", txOut.Value, err) + } + contractOutput := newOutput(txHash, vout, uint64(txOutAmt), txTree) + contractOutputScript, err := hex.DecodeString(txOut.ScriptPubKey.Hex) + if err != nil { + return nil, nil, 0, fmt.Errorf("error decoding pubkey script from hex '%s': %w", + txOut.ScriptPubKey.Hex, err) + } + return contractOutput, contractOutputScript, txOut.ScriptPubKey.Version, nil +} + func (dcr *ExchangeWallet) validateContractOutputScript(pkScript []byte, scriptVer uint16, contract []byte) error { + // Output script must be P2SH, with 1 address and 1 required signature. scriptClass, addrs, sigsReq, err := txscript.ExtractPkScriptAddrs(scriptVer, pkScript, dcr.chainParams, false) if err != nil { return fmt.Errorf("error extracting script addresses from '%x': %w", pkScript, err) @@ -2285,14 +2379,22 @@ func (dcr *ExchangeWallet) coinConfirmations(ctx context.Context, id dex.Bytes) return uint32(txOut.Confirmations), false, nil } - // Unspent output not found. Check if the transaction exists. - // If it exists, then the output must either be spent or we - // are dealing with an invalid vout. Regardless, assume spent. + // Unspent output not found. Using wallet tx lookup for spv + // wallets and getrawtransaction for non-spv wallets, check + // if the transaction exists and has an output at `vout`. + if dcr.wallet.SpvMode() { - // SPV wallets can only look up wallet transactions. Check - // if this tx is indexed by the wallet. tx, err := dcr.wallet.GetTransaction(ctx, txHash) if err == nil { + // Tx found, check if it contains the requested output. + msgTx, err := msgTxFromHex(tx.Hex) + if err != nil { + return 0, false, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) + } + if len(msgTx.TxOut) <= int(vout) { + return 0, false, fmt.Errorf("tx %s has no output at index %d", txHash, vout) + } + // Tx found and contains the requested output. return uint32(tx.Confirmations), true, nil } if !isTxNotFoundErr(err) { @@ -2308,15 +2410,19 @@ func (dcr *ExchangeWallet) coinConfirmations(ctx context.Context, id dex.Bytes) } // Node-backed wallets can look up any transaction. Check if this - // tx exists on the blockchain. + // tx output can be found. tx, err := dcr.wallet.GetRawTransactionVerbose(ctx, txHash) - if err == nil { - return uint32(tx.Confirmations), true, nil - } - if !isTxNotFoundErr(err) { + if err != nil { + if isTxNotFoundErr(err) { + return 0, false, asset.CoinNotFoundError + } return 0, false, translateRPCCancelErr(err) } - return 0, false, asset.CoinNotFoundError + if len(tx.Vout) <= int(vout) { + return 0, false, fmt.Errorf("tx %s has no output at index %d", txHash, vout) + } + // Tx found and contains the requested output. + return uint32(tx.Confirmations), true, nil } // SwapConfirmations gets the number of confirmations for the specified coin ID diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 9bfd9bbe3b..027fd6b2e5 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -2228,7 +2228,10 @@ func TestCoinConfirmations(t *testing.T) { if wallet.wallet.SpvMode() { node.walletTx = &walletjson.GetTransactionResult{} } else { - node.blockchain.addRawTx(&chainjson.TxRawResult{Txid: tTxHash.String()}) + node.blockchain.addRawTx(&chainjson.TxRawResult{ + Txid: tTxHash.String(), + Vout: []chainjson.Vout{{}}, // rawTx must have an output at index 0 to be considered spent + }) } _, spent, err = wallet.coinConfirmations(context.Background(), coinID) if err != nil { diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index 5ceb362323..1665b47089 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -102,6 +102,40 @@ func (dcr *ExchangeWallet) externalTx(hash *chainhash.Hash) (*externalTx, bool) return tx, tracked } +func (dcr *ExchangeWallet) externalTxOut(hash *chainhash.Hash, vout uint32) (*wire.TxOut, int8, bool, error) { + tx, tracked := dcr.externalTx(hash) + if !tracked { + return nil, 0, false, asset.CoinNotFoundError + } + + // Lock the scanMtx to prevent attempted rescans from + // mutating the tx block, outputs map or tree field. + tx.scanMtx.RLock() + txBlockHash, err := tx.relevantBlockHash(dcr.getBlockHash) + if err != nil { + tx.scanMtx.RUnlock() + return nil, 0, false, err + } + if txBlockHash == nil { + tx.scanMtx.RUnlock() + return nil, 0, false, asset.CoinNotFoundError + } + output := tx.outputs[vout] + txTree := tx.tree + tx.scanMtx.RUnlock() + + if output == nil { + return nil, 0, false, fmt.Errorf("tx %s has no output at index %d", hash, vout) + } + + isSpent, err := dcr.findExternalTxOutputSpender(output, txBlockHash) + if err != nil { + return nil, 0, false, err + } + + return output.TxOut, txTree, isSpent, nil +} + // externalTxOutConfirmations uses the script(s) associated with an externalTx // to find the block in which the tx is mined and checks if the specified tx // output is spent by a mined transaction. This tx must have been previously diff --git a/client/asset/interface.go b/client/asset/interface.go index 5c7c398c25..3d424263b0 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -152,14 +152,24 @@ type Wallet interface { // specified Coin. A slice of pubkeys required to spend the Coin and a // signature for each pubkey are returned. SignMessage(Coin, dex.Bytes) (pubkeys, sigs []dex.Bytes, err error) - // AuditContract retrieves information about a swap contract on the - // blockchain. This would be used to verify the counter-party's contract - // during a swap. If the coin cannot be found for the coin ID, the - // ExchangeWallet should return CoinNotFoundError. This enables the client - // to properly handle network latency. The matchTime is provided so that - // wallets can limit their scan when matching against transaction filters. - // necessary for wallets without full chain backing, but the caller should - // have it on hand anyway. + // AuditContract retrieves information about a swap contract from the + // blockchain (where possible) or from the provided txData (if valid). + // The information returned would be used to verify the counter-party's + // contract during a swap. If the coin cannot be found on the blockchain + // and the provided txData cannot be broadcasted, a CoinNotFoundError + // may be returned. This enables the client to properly handle network + // latency where appropriate. + // + // NOTE: For SPV wallets, a successful audit response is no gaurantee that + // the txData provided was actually broadcasted to the blockchain. An error + // may have occured while trying to broadcast the txData or even if there + // was no broadcast error, the tx might still not enter mempool or get mined + // e.g. if the tx references invalid or already spent inputs. + // + // Granted, clients wait for the contract tx to be included in a block before + // taking further actions on a match; but it is generally safer to repeat this + // audit after the contract tx is mined to ensure that the tx observed on the + // blockchain is as expected. AuditContract(coinID, contract, txData dex.Bytes, matchTime time.Time) (*AuditInfo, error) // LocktimeExpired returns true if the specified contract's locktime has // expired, making it possible to issue a Refund. The contract expiry time diff --git a/client/core/core_test.go b/client/core/core_test.go index 4e8cf53676..87a1339130 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -2005,7 +2005,7 @@ func TestLogin(t *testing.T) { MatchID: extraID[:], Status: uint8(order.MakerSwapCast), MakerContract: missedContract, - MakerSwap: encode.RandomBytes(36), + MakerSwap: auditInfo.Coin.ID(), Active: true, MakerTxData: []byte{0x01}, }}, nil) @@ -4511,8 +4511,6 @@ func TestResolveActiveTrades(t *testing.T) { rig.db.activeMatchOIDs = []order.OrderID{oid} rig.db.matchesForOID = []*db.MetaMatch{match} tDcrWallet.fundingCoins = asset.Coins{changeCoin} - _, auditInfo := tMsgAudit(oid, mid, addr, qty, nil) - tBtcWallet.auditInfo = auditInfo // reset reset := func() { @@ -6740,7 +6738,7 @@ func TestSuspectTrades(t *testing.T) { tCore.wallets[tBTC.ID] = btcWallet walletSet, _ := tCore.walletSet(dc, tDCR.ID, tBTC.ID, true) - lo, dbOrder, preImg, _ := makeLimitOrder(dc, true, 0, 0) + lo, dbOrder, preImg, addr := makeLimitOrder(dc, true, 0, 0) oid := lo.ID() mkt := dc.marketConfig(tDcrBtcMktName) tracker := newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, rig.core.lockTimeTaker, rig.core.lockTimeMaker, @@ -6776,9 +6774,14 @@ func TestSuspectTrades(t *testing.T) { swappableMatch2 = newMatch(order.Taker, order.MakerSwapCast) // Set counterswaps for both swaps. - _, auditInfo := tMsgAudit(oid, swappableMatch2.MatchID, ordertest.RandomAddress(), 1, encode.RandomBytes(32)) + // Set valid wallet auditInfo for swappableMatch2, taker will repeat audit before swapping. + auditQty := calc.BaseToQuote(swappableMatch2.Rate, swappableMatch2.Quantity) + _, auditInfo := tMsgAudit(oid, swappableMatch2.MatchID, addr, auditQty, encode.RandomBytes(32)) + auditInfo.Expiration = encode.DropMilliseconds(swappableMatch2.matchTime().Add(tracker.lockTimeMaker)) tBtcWallet.setConfs(auditInfo.Coin.ID(), tDCR.SwapConf, nil) + tBtcWallet.auditInfo = auditInfo swappableMatch2.counterSwap = auditInfo + _, auditInfo = tMsgAudit(oid, swappableMatch1.MatchID, ordertest.RandomAddress(), 1, encode.RandomBytes(32)) tBtcWallet.setConfs(auditInfo.Coin.ID(), tDCR.SwapConf, nil) swappableMatch1.counterSwap = auditInfo @@ -6847,9 +6850,16 @@ func TestSuspectTrades(t *testing.T) { setRedeems := func() { redeemableMatch1 = newMatch(order.Maker, order.TakerSwapCast) redeemableMatch2 = newMatch(order.Taker, order.MakerRedeemed) - _, auditInfo := tMsgAudit(oid, redeemableMatch1.MatchID, ordertest.RandomAddress(), 1, encode.RandomBytes(32)) + + // Set valid wallet auditInfo for redeemableMatch1, maker will repeat audit before redeeming. + auditQty := calc.BaseToQuote(redeemableMatch1.Rate, redeemableMatch1.Quantity) + _, auditInfo := tMsgAudit(oid, redeemableMatch1.MatchID, addr, auditQty, encode.RandomBytes(32)) + auditInfo.Expiration = encode.DropMilliseconds(redeemableMatch1.matchTime().Add(tracker.lockTimeTaker)) tBtcWallet.setConfs(auditInfo.Coin.ID(), tBTC.SwapConf, nil) + tBtcWallet.auditInfo = auditInfo redeemableMatch1.counterSwap = auditInfo + redeemableMatch1.MetaData.Proof.SecretHash = auditInfo.SecretHash + tBtcWallet.redeemCounter = 0 tracker.matches = map[order.MatchID]*matchTracker{ redeemableMatch1.MatchID: redeemableMatch1, diff --git a/client/core/trade.go b/client/core/trade.go index ba007bd1eb..4131ae9b8e 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -896,7 +896,18 @@ func (t *trackedTrade) isSwappable(ctx context.Context, match *matchTracker) boo match.MetaData.Proof.SelfRevoked = true return false } - return ready + if !ready { + return false + } + // Re-audit the contract now that we know it is mined + // and has the required confirmations. + if err := t.reAuditContract(match); err != nil { + t.dc.log.Errorf("Match %s not swappable: repeat audit of maker's contract failed: %v", + match, err) + match.swapErr = fmt.Errorf("Counter-party contract repeat audit error: %v", err) + return false + } + return true } // If we're the maker, check the confirmations anyway so we can notify. t.dc.log.Tracef("Checking confirmations on our OWN swap txn %v (%s)...", @@ -956,7 +967,18 @@ func (t *trackedTrade) isRedeemable(ctx context.Context, match *matchTracker) bo match.MetaData.Proof.SelfRevoked = true return false } - return ready + if !ready { + return false + } + // Re-audit the contract now that we know it is mined + // and has the required confirmations. + if err := t.reAuditContract(match); err != nil { + t.dc.log.Errorf("Match %s not redeemable: repeat audit of taker's contract failed: %v", + match, err) + match.swapErr = fmt.Errorf("Repeat counter-party contract audit error: %v", err) + return false + } + return true } // If we're the taker, check the confirmations anyway so we can notify. confs, spent, err := t.wallets.fromWallet.SwapConfirmations(ctx, match.MetaData.Proof.TakerSwap, @@ -2141,6 +2163,70 @@ func (t *trackedTrade) auditContract(match *matchTracker, coinID, contract, txDa if err != nil { return err } + + t.mtx.Lock() + defer t.mtx.Unlock() + + err = t.validateAuditInfo(auditInfo, match, coinID, contract) + if err != nil { + return err + } + // Audit successful. Update status and other match data. + proof := &match.MetaData.Proof + if match.Side == order.Maker { + match.Status = order.TakerSwapCast + proof.TakerSwap = coinID + } else { + proof.SecretHash = auditInfo.SecretHash + match.Status = order.MakerSwapCast + proof.MakerSwap = coinID + } + proof.CounterTxData = txData + proof.CounterContract = contract + match.counterSwap = auditInfo + + err = t.db.UpdateMatch(&match.MetaMatch) + if err != nil { + t.dc.log.Errorf("Error updating database for match %v: %s", match, err) + } + + t.dc.log.Infof("Audited contract (%s: %v) paying to %s for order %s, match %s, with tx data = %t", + t.wallets.toAsset.Symbol, auditInfo.Coin, auditInfo.Recipient, t.ID(), match, len(txData) > 0) + + return nil +} + +// reAuditContract performs a repeat contract audit for the specified match and +// returns an error if the repeat audit fails. This does NOT update match fields +// but the trackedTrade mtx MUST be locked for reading mutable match fields. +func (t *trackedTrade) reAuditContract(match *matchTracker) error { + if match.Side == order.Maker && match.Status != order.TakerSwapCast { + return fmt.Errorf("Invalid repeat audit request for maker at status %s.", match.Status) + } + if match.Side == order.Taker && match.Status != order.MakerSwapCast { + return fmt.Errorf("Invalid repeat audit request for taker at status %s.", match.Status) + } + + proof := &match.MetaData.Proof + coinID, contract, txData := match.counterSwap.Coin.ID(), proof.CounterContract, proof.CounterTxData + auditInfo, err := t.wallets.toWallet.AuditContract(coinID, contract, txData, encode.UnixTimeMilli(int64(match.MetaData.Stamp))) // why not match.matchTime()? + if err != nil { + return err + } + if err = t.validateAuditInfo(auditInfo, match, coinID, contract); err != nil { + return err + } + // Audit successful. + t.dc.log.Infof("Re audited contract (%s: %v) paying to %s for order %s, match %s, with tx data = %t", + t.wallets.toAsset.Symbol, auditInfo.Coin, auditInfo.Recipient, t.ID(), match, len(txData) > 0) + return nil +} + +// validateAuditInfo checks the audit info of a contract for validity, ensuring +// that the contract pays the right amount to the right address and if maker, +// also ensures that the secret hash is as expected. +// The trackedTrade mtx MUST be locked for reading mutable match fields. +func (t *trackedTrade) validateAuditInfo(auditInfo *asset.AuditInfo, match *matchTracker, coinID, contract []byte) error { contractID, contractSymb := coinIDString(t.wallets.toAsset.ID, coinID), t.wallets.toAsset.Symbol // Audit the contract. @@ -2179,8 +2265,6 @@ func (t *trackedTrade) auditContract(match *matchTracker, coinID, contract, txDa return fmt.Errorf("lock time too early. Need %s, got %s", reqLockTime, auditInfo.Expiration) } - t.mtx.Lock() - defer t.mtx.Unlock() proof := &match.MetaData.Proof if match.Side == order.Maker { // Check that the secret hash is correct. @@ -2188,26 +2272,8 @@ func (t *trackedTrade) auditContract(match *matchTracker, coinID, contract, txDa return fmt.Errorf("secret hash mismatch for contract coin %v (%s), contract %v. expected %x, got %v", auditInfo.Coin, t.wallets.toAsset.Symbol, contract, proof.SecretHash, auditInfo.SecretHash) } - // Audit successful. Update status and other match data. - match.Status = order.TakerSwapCast - proof.TakerSwap = coinID - } else { - proof.SecretHash = auditInfo.SecretHash - match.Status = order.MakerSwapCast - proof.MakerSwap = coinID - } - proof.CounterTxData = txData - proof.CounterContract = contract - match.counterSwap = auditInfo - - err = t.db.UpdateMatch(&match.MetaMatch) - if err != nil { - t.dc.log.Errorf("Error updating database for match %v: %s", match, err) } - t.dc.log.Infof("Audited contract (%s: %v) paying to %s for order %s, match %s, with tx data = %t", - t.wallets.toAsset.Symbol, auditInfo.Coin, auditInfo.Recipient, t.ID(), match, len(txData) > 0) - return nil } From 3c11408502847e628d62ace0f4fc5dde5e1f4549 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Fri, 30 Jul 2021 15:32:26 +0100 Subject: [PATCH 07/22] fix trade_simnet_test duplicate balance change tracking --- client/core/trade_simnet_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/core/trade_simnet_test.go b/client/core/trade_simnet_test.go index 3995d1392c..139018beac 100644 --- a/client/core/trade_simnet_test.go +++ b/client/core/trade_simnet_test.go @@ -1068,6 +1068,7 @@ func monitorTrackedTrade(ctx context.Context, client *tClient, tracker *trackedT client.psMTX.Unlock() continue } + lastStatus := client.processedStatus[match.MatchID] client.processedStatus[match.MatchID] = status client.psMTX.Unlock() client.log("NOW =====> %s", status) @@ -1089,8 +1090,8 @@ func monitorTrackedTrade(ctx context.Context, client *tClient, tracker *trackedT // Our toAsset == counter-party's fromAsset. assetToMine, swapOrRedeem = tracker.wallets.toAsset, "swap" - case status == order.MatchComplete, // maker normally jumps MakerRedeemed if 'redeem' succeeds - side == order.Maker && status == order.MakerRedeemed: + case side == order.Maker && status == order.MakerRedeemed, + status == order.MatchComplete && (side == order.Taker || lastStatus != order.MakerRedeemed): // allow MatchComplete for Maker if lastStatus != order.MakerRedeemed recordBalanceChanges(tracker.wallets.toAsset.ID, false, match.Quantity, match.Rate) // Mine blocks for redemption since counter-party does not wait // for redeem tx confirmations before performing follow-up action. From 206f27d0de59e3ec2da1dc99e67260a2920d1bd2 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Sun, 1 Aug 2021 00:09:58 +0100 Subject: [PATCH 08/22] client/asset/dcr: remove added calls to getrawtransaction The getrawtransaction rpc requires txindex to be enabled on full nodes but clients may run full nodes without enabling txindex as it is not a requirement. Use gettransaction where possible instead of getrawtransaction to avoid errors when clients use full nodes without txindex enabled. --- client/asset/dcr/dcr.go | 105 ++++++++++++++++------------------- client/asset/dcr/dcr_test.go | 24 +++++--- 2 files changed, 65 insertions(+), 64 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 0f51f10e11..2c9c2ae1ee 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1661,22 +1661,25 @@ func (dcr *ExchangeWallet) findContractInBlockchain(txHash *chainhash.Hash, vout return nil, nil, 0, fmt.Errorf("error finding unspent contract: %w", translateRPCCancelErr(err)) } if txOut == nil { - // Output does not exist or has been spent. Use getrawtransaction - // to confirm if this tx exists and has an output at `vout`. If it - // does, then it must have been spent for gettxout to return a nil - // response. - tx, err := dcr.wallet.GetRawTransactionVerbose(dcr.ctx, txHash) - if err != nil { - if isTxNotFoundErr(err) { - return nil, nil, 0, nil - } - return nil, nil, 0, fmt.Errorf("error looking up contract tx %s: %w", txHash, translateRPCCancelErr(err)) - } - if len(tx.Vout) <= int(vout) { - return nil, nil, 0, fmt.Errorf("tx %s has no output at index %d", txHash, vout) - } - // Tx found and contains the requested output. - return nil, nil, 0, fmt.Errorf("contract output %s:%d is spent", txHash, vout) + // Output does not exist or has been spent. + // We can try to look up the tx to determine if the output exists + // and return a 'output is spent' error, but the getrawtransaction + // rpc requires txindex to be enabled which may not be enabled by + // the client. We also can't use the gettransaction rpc because + // contract outputs that we audit are typically NOT indexed by the + // wallet and gettransaction only returns data for wallet txs. + // + // It is safe to assume that this output does not yet exist since + // contracts we audit can only be spent when we redeem them or + // when they are refunded (after the contract locktime expires). + // Worst case scenario is that the contract was refunded and the + // client keeps fruitlessly trying to find and audit the contract + // until the client refunds their own swap contract or the match + // is revoked by the server. E.g. maker keeps checking for taker's + // contract after taker has refunded it; until maker's own contract + // locktime expires and maker refunds self or the match is revoked + // by the server. + return nil, nil, 0, nil } txOutAmt, err := dcrutil.NewAmount(txOut.Value) @@ -2362,8 +2365,10 @@ func (dcr *ExchangeWallet) ValidateSecret(secret, secretHash []byte) bool { } // coinConfirmations gets the number of confirmations for the specified coin by -// first checking for a unspent output, and if not found, searching transactions -// indexed by the node (if connected) or the wallet. +// first checking for an unspent output, and if not found, searching indexed +// wallet transactions. Additionally, in spv mode, if the output is not found +// in the wallet and a script referenced by the tx is known, the script is used +// to search block filters to attempt finding the tx. func (dcr *ExchangeWallet) coinConfirmations(ctx context.Context, id dex.Bytes) (confs uint32, spent bool, err error) { txHash, vout, err := decodeCoinID(id) if err != nil { @@ -2379,50 +2384,38 @@ func (dcr *ExchangeWallet) coinConfirmations(ctx context.Context, id dex.Bytes) return uint32(txOut.Confirmations), false, nil } - // Unspent output not found. Using wallet tx lookup for spv - // wallets and getrawtransaction for non-spv wallets, check - // if the transaction exists and has an output at `vout`. - - if dcr.wallet.SpvMode() { - tx, err := dcr.wallet.GetTransaction(ctx, txHash) - if err == nil { - // Tx found, check if it contains the requested output. - msgTx, err := msgTxFromHex(tx.Hex) - if err != nil { - return 0, false, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) - } - if len(msgTx.TxOut) <= int(vout) { - return 0, false, fmt.Errorf("tx %s has no output at index %d", txHash, vout) - } - // Tx found and contains the requested output. - return uint32(tx.Confirmations), true, nil + // Unspent output not found. Check if this tx is indexed by the + // wallet and has an output at `vout`. + tx, err := dcr.wallet.GetTransaction(ctx, txHash) + if err == nil { + // Tx found, check if it contains the requested output. + msgTx, err := msgTxFromHex(tx.Hex) + if err != nil { + return 0, false, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) } - if !isTxNotFoundErr(err) { - return 0, false, translateRPCCancelErr(err) + if len(msgTx.TxOut) <= int(vout) { + return 0, false, fmt.Errorf("tx %s has no output at index %d", txHash, vout) } - // Tx not found by wallet. Attempt using cfilters to find - // the tx in a block and determine if the requested output - // is spent. This tx must have been previously associated - // with a script using dcr.trackExternalTx otherwise, the - // tx cannot be located in a mined block using cfitler and - // an asset.CoinNotFoundError will be returned. - return dcr.externalTxOutConfirmations(txHash, vout) + // Tx found and contains the requested output. The output + // must be spent since it is known by the wallet but gettxout + // returned a nil result for it. + return uint32(tx.Confirmations), true, nil } - - // Node-backed wallets can look up any transaction. Check if this - // tx output can be found. - tx, err := dcr.wallet.GetRawTransactionVerbose(ctx, txHash) - if err != nil { - if isTxNotFoundErr(err) { - return 0, false, asset.CoinNotFoundError - } + if !isTxNotFoundErr(err) { return 0, false, translateRPCCancelErr(err) } - if len(tx.Vout) <= int(vout) { - return 0, false, fmt.Errorf("tx %s has no output at index %d", txHash, vout) + + // Tx not found by wallet. + if !dcr.wallet.SpvMode() { + return 0, false, asset.CoinNotFoundError } - // Tx found and contains the requested output. - return uint32(tx.Confirmations), true, nil + + // In spv mode, attempt using cfilters to find the tx in a block and + // to determine if the requested output is spent. This tx must have + // been previously associated with a script using dcr.trackExternalTx + // otherwise, the tx cannot be located in a mined block using cfilters + // and an asset.CoinNotFoundError will be returned. + return dcr.externalTxOutConfirmations(txHash, vout) } // SwapConfirmations gets the number of confirmations for the specified coin ID diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 027fd6b2e5..e88d4ddb15 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -477,7 +477,10 @@ func (c *tRPCClient) DumpPrivKey(_ context.Context, address stdaddr.Address) (*d } func (c *tRPCClient) GetTransaction(_ context.Context, txHash *chainhash.Hash) (*walletjson.GetTransactionResult, error) { - return c.walletTx, c.walletTxErr + if c.walletTx != nil || c.walletTxErr != nil { + return c.walletTx, c.walletTxErr + } + return nil, dcrjson.NewRPCError(dcrjson.ErrRPCNoTxInfo, "no test transaction") } func (c *tRPCClient) AccountUnlocked(_ context.Context, acct string) (*walletjson.AccountUnlockedResult, error) { @@ -2225,13 +2228,18 @@ func TestCoinConfirmations(t *testing.T) { node.rawTxErr = nil } - if wallet.wallet.SpvMode() { - node.walletTx = &walletjson.GetTransactionResult{} - } else { - node.blockchain.addRawTx(&chainjson.TxRawResult{ - Txid: tTxHash.String(), - Vout: []chainjson.Vout{{}}, // rawTx must have an output at index 0 to be considered spent - }) + // wallet.Confirmations will check if the tx hex is valid + // and contains an output at index 0, for the output to be + // considered spent. + tx := wire.NewMsgTx() + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{}) + txHex, err := msgTxToHex(tx) + if err != nil { + t.Fatalf("error preparing tx hex with 1 output: %v", err) + } + node.walletTx = &walletjson.GetTransactionResult{ + Hex: txHex, } _, spent, err = wallet.coinConfirmations(context.Background(), coinID) if err != nil { From b0749cfc957670260094efdc60fba68a859ea930 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Wed, 6 Oct 2021 00:35:40 +0100 Subject: [PATCH 09/22] rebase fix, partial chapp review corrections --- client/asset/dcr/dcr.go | 28 +++++++++++++--------------- client/asset/dcr/externaltx.go | 27 +++++++++++++-------------- dex/testing/dcr/harness.sh | 4 ++-- 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 2c9c2ae1ee..ea26448a48 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1498,9 +1498,9 @@ func (dcr *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, // The information returned would be used to verify the counter-party's contract // during a swap. // -// NOTE: For SPV wallets, a successful audit response is no gaurantee that the +// NOTE: For SPV wallets, a successful audit response is no guarantee that the // txData provided to this method was actually broadcasted to the blockchain. -// An error may have occured while trying to broadcast the txData or even if +// An error may have occurred while trying to broadcast the txData or even if // there was no broadcast error, the tx might still not enter mempool or get // mined e.g. if the tx references invalid or already spent inputs. // @@ -1534,7 +1534,7 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t if err = dcr.validateContractOutputScript(contractOutputScript, scriptVer, contract); err != nil { return nil, err } - dcr.log.Debugf("Audited contract coin %s:%d using tx data gotten from the blockchain. SPV mode = %t", + dcr.log.Infof("Audited contract coin %s:%d using tx data gotten from the blockchain. SPV mode = %t", txHash, vout, dcr.wallet.SpvMode()) return &asset.AuditInfo{ Coin: contractCoin, @@ -1600,7 +1600,7 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t // SPV wallets do not produce sendrawtransaction errors for already // broadcasted txs. This must be some other unexpected error. // Do NOT return an asset.CoinNotFoundError so callers do not recall - // this method as there's no gaurantee that the broadcast will succeed + // this method as there's no guarantee that the broadcast will succeed // on subsequent attempts. // Return a successful audit response because it is possible that the // tx was already broadcasted and the caller can safely begin waiting @@ -1609,9 +1609,7 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t // but as explained above, retrying the broadcast isn't a better course // of action, neither is returning an error here because that would cause // the caller to potentially give up on this match prematurely. - } - - if err == nil && !finalTxHash.IsEqual(txHash) { + } else if !finalTxHash.IsEqual(txHash) { return nil, fmt.Errorf("broadcasted contract tx, but received unexpected transaction ID back from RPC server. "+ "expected %s, got %s", txHash, finalTxHash) } @@ -1622,7 +1620,7 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t dcr.trackExternalTx(txHash, contractTxOut.PkScript) } - dcr.log.Debugf("Audited contract coin %s:%d using raw tx data. SPV mode = %t", txHash, vout, dcr.wallet.SpvMode()) + dcr.log.Infof("Audited contract coin %s:%d using raw tx data. SPV mode = %t", txHash, vout, dcr.wallet.SpvMode()) return &asset.AuditInfo{ Coin: newOutput(txHash, vout, uint64(contractTxOut.Value), determineTxTree(contractTx)), Contract: contract, @@ -3056,27 +3054,27 @@ func (dcr *ExchangeWallet) getBlockHash(blockHeight int64) (*chainhash.Hash, err // mainChainAncestor crawls blocks backwards starting at the provided hash // until finding a mainchain block. Returns the first mainchain block found. -func (dcr *ExchangeWallet) mainChainAncestor(blockHash *chainhash.Hash) (*chainhash.Hash, *chainjson.GetBlockVerboseResult, error) { +func (dcr *ExchangeWallet) mainChainAncestor(blockHash *chainhash.Hash) (*chainhash.Hash, int64, error) { if *blockHash == zeroHash { - return nil, nil, fmt.Errorf("invalid block hash %s", blockHash.String()) + return nil, 0, fmt.Errorf("invalid block hash %s", blockHash.String()) } checkHash := blockHash for { - checkBlock, err := dcr.wallet.GetBlockVerbose(dcr.ctx, checkHash, false) + checkBlock, err := dcr.wallet.GetBlockHeaderVerbose(dcr.ctx, checkHash) if err != nil { - return nil, nil, fmt.Errorf("error retrieving block %s: %w", checkHash, translateRPCCancelErr(err)) + return nil, 0, fmt.Errorf("error retrieving block %s: %w", checkHash, translateRPCCancelErr(err)) } if checkBlock.Confirmations > -1 { // This is a mainchain block, return the hash. - return checkHash, checkBlock, nil + return checkHash, int64(checkBlock.Height), nil } if checkBlock.Height == 0 { - return nil, nil, fmt.Errorf("no mainchain ancestor for block %s", blockHash.String()) + return nil, 0, fmt.Errorf("no mainchain ancestor for block %s", blockHash.String()) } checkHash, err = chainhash.NewHashFromStr(checkBlock.PreviousHash) if err != nil { - return nil, nil, fmt.Errorf("error decoding previous hash %s for block %s: %w", + return nil, 0, fmt.Errorf("error decoding previous hash %s for block %s: %w", checkBlock.PreviousHash, checkHash.String(), translateRPCCancelErr(err)) } } diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index 1665b47089..a328ce6cc0 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -25,7 +25,7 @@ type externalTx struct { blockFiltersScanner - // The folowing are protected by the blockFiltersScanner.scanMtx + // The following are protected by the blockFiltersScanner.scanMtx // because they are set when the tx's block is found and cleared // when the previously found tx block is orphaned. The scanMtx // lock must be held for read before accessing these fields. @@ -51,7 +51,7 @@ type blockFiltersScanner struct { } // relevantBlockHash returns the hash of the block relevant to this scanner, if -// the relevantBlok is set and is a mainchain block. Returns a nil hash and nil +// the relevantBlock is set and is a mainchain block. Returns a nil hash and nil // error if the relevantBlock is set but is no longer part of the mainchain. // The scanner's scanMtx MUST be write-locked. func (scanner *blockFiltersScanner) relevantBlockHash(nodeGetBlockHashFn func(int64) (*chainhash.Hash, error)) (*chainhash.Hash, error) { @@ -79,8 +79,7 @@ func (dcr *ExchangeWallet) trackExternalTx(hash *chainhash.Hash, script []byte) dcr.log.Debugf("Script %x cached for non-wallet tx %s.", script, hash) }() - tx, tracked := dcr.externalTx(hash) - if tracked { + if tx, tracked := dcr.externalTx(hash); tracked { tx.mtx.Lock() tx.relevantScripts = append(tx.relevantScripts, script) tx.mtx.Unlock() @@ -216,11 +215,11 @@ func (dcr *ExchangeWallet) findExternalTxBlock(tx *externalTx) (bool, error) { // mainchain, scan back to the mainchain ancestor of the lastScannedBlock. var lastScannedBlock block if tx.lastScannedBlock != nil { - stopBlockHash, stopBlock, err := dcr.mainChainAncestor(tx.lastScannedBlock) + stopBlockHash, stopBlockHeight, err := dcr.mainChainAncestor(tx.lastScannedBlock) if err != nil { return false, fmt.Errorf("error looking up mainchain ancestor for block %s", err) } - lastScannedBlock.height = stopBlock.Height + lastScannedBlock.height = stopBlockHeight lastScannedBlock.hash = stopBlockHash tx.lastScannedBlock = stopBlockHash } else { @@ -279,8 +278,7 @@ func (dcr *ExchangeWallet) findExternalTxBlock(tx *externalTx) (bool, error) { continue } tx.outputs[uint32(i)] = &externalTxOutput{ - // TODO: output.ScriptPubKey.Version with dcrd 1.7 *release*, not yet - TxOut: newTxOut(int64(amt), output.Version, pkScript), + TxOut: newTxOut(int64(amt), output.ScriptPubKey.Version, pkScript), txHash: tx.hash, vout: uint32(i), } @@ -331,11 +329,12 @@ func (dcr *ExchangeWallet) findExternalTxOutputSpender(output *externalTxOutput, // Use mainChainAncestor to ensure that scanning starts from a mainchain // block in the event that either tx block or lastScannedBlock have been // re-orged out of the mainchain. - var startBlock *chainjson.GetBlockVerboseResult + var startBlockHash *chainhash.Hash + var startBlockHeight int64 if output.spender.lastScannedBlock == nil { - _, startBlock, err = dcr.mainChainAncestor(txBlockHash) + startBlockHash, startBlockHeight, err = dcr.mainChainAncestor(txBlockHash) } else { - _, startBlock, err = dcr.mainChainAncestor(output.spender.lastScannedBlock) + startBlockHash, startBlockHeight, err = dcr.mainChainAncestor(output.spender.lastScannedBlock) } if err != nil { return false, err @@ -344,8 +343,8 @@ func (dcr *ExchangeWallet) findExternalTxOutputSpender(output *externalTxOutput, // Search for this output's spender in the blocks between startBlock and bestBlock. bestBlock := dcr.cachedBestBlock() dcr.log.Debugf("Searching for the tx that spends output %s in blocks %d (%s) to %d (%s).", - output, startBlock.Height, startBlock.Hash, bestBlock.height, bestBlock.hash) - for blockHeight := startBlock.Height; blockHeight <= bestBlock.height; blockHeight++ { + output, startBlockHeight, startBlockHash, bestBlock.height, bestBlock.hash) + for blockHeight := startBlockHeight; blockHeight <= bestBlock.height; blockHeight++ { blockHash, err := dcr.wallet.GetBlockHash(dcr.ctx, blockHeight) if err != nil { return false, translateRPCCancelErr(err) @@ -386,7 +385,7 @@ func (dcr *ExchangeWallet) findExternalTxOutputSpender(output *externalTxOutput, } dcr.log.Debugf("No spender tx found for %s in blocks %d (%s) to %d (%s).", - output, startBlock.Height, startBlock.Hash, bestBlock.height, bestBlock.hash) + output, startBlockHeight, startBlockHash, bestBlock.height, bestBlock.hash) return false, nil // scanned up to best block, no spender found } diff --git a/dex/testing/dcr/harness.sh b/dex/testing/dcr/harness.sh index fc2a82546f..353d725d4a 100755 --- a/dex/testing/dcr/harness.sh +++ b/dex/testing/dcr/harness.sh @@ -278,14 +278,14 @@ ${ALPHA_WALLET_RPC_PORT} ${USE_SPV} ${ENABLE_VOTING} ${ALPHA_WALLET_HTTPPROF_POR # alpha uses walletpassphrase/walletlock. echo "Creating simnet beta wallet" -USE_SPV="0" +USE_SPV="1" ENABLE_VOTING="0" "${HARNESS_DIR}/create-wallet.sh" "$SESSION:4" "beta" ${BETA_WALLET_SEED} \ ${BETA_WALLET_RPC_PORT} ${USE_SPV} ${ENABLE_VOTING} ${BETA_WALLET_HTTPPROF_PORT} # The trading wallets need to be created from scratch every time. echo "Creating simnet trading wallet 1" -USE_SPV="0" +USE_SPV="1" ENABLE_VOTING="0" "${HARNESS_DIR}/create-wallet.sh" "$SESSION:5" "trading1" ${TRADING_WALLET1_SEED} \ ${TRADING_WALLET1_PORT} ${USE_SPV} ${ENABLE_VOTING} From 7e211c85ebcb32e21e8a79b295301a24b18bf1c6 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Fri, 8 Oct 2021 16:26:58 +0100 Subject: [PATCH 10/22] rework swap confirmations code --- client/asset/dcr/dcr.go | 314 +++++++--------- client/asset/dcr/dcr_test.go | 32 +- client/asset/dcr/externaltx.go | 644 ++++++++++++++++++--------------- client/asset/interface.go | 3 +- 4 files changed, 502 insertions(+), 491 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index ea26448a48..6f966bd0c3 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -64,8 +64,6 @@ const ( ) var ( - zeroHash chainhash.Hash - // blockTicker is the delay between calls to check for new blocks. blockTicker = time.Second conventionalConversionFactor = float64(dexdcr.UnitInfo.Conventional.ConversionFactor) @@ -385,8 +383,8 @@ type ExchangeWallet struct { findRedemptionMtx sync.RWMutex findRedemptionQueue map[outPoint]*findRedemptionReq - externalTxMtx sync.RWMutex - externalTxs map[chainhash.Hash]*externalTx + externalTxMtx sync.RWMutex + externalTxCache map[chainhash.Hash]*externalTx } type block struct { @@ -487,7 +485,7 @@ func unconnectedWallet(cfg *asset.WalletConfig, dcrCfg *Config, chainParams *cha tipChange: cfg.TipChange, fundingCoins: make(map[outPoint]*fundingCoin), findRedemptionQueue: make(map[outPoint]*findRedemptionReq), - externalTxs: make(map[chainhash.Hash]*externalTx), + externalTxCache: make(map[chainhash.Hash]*externalTx), fallbackFeeRate: fallbackFeesPerByte, feeRateLimit: feesLimitPerByte, redeemConfTarget: redeemConfTarget, @@ -1493,7 +1491,7 @@ func (dcr *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, // AuditContract retrieves information about a swap contract from the blockchain // (if possible) or from the provided txData if the provided txData represents a // valid transaction that pays to the provided contract at the specified coinID -// and (for full node-backed wallets) can be broadcasted to the blockchain network. +// and (for full node wallets) can be broadcasted to the blockchain network. // // The information returned would be used to verify the counter-party's contract // during a swap. @@ -1520,51 +1518,65 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t return nil, fmt.Errorf("error extracting swap addresses: %w", err) } - // If this coin can be located on the blockchain, audit this contract - // using the output script gotten from the blockchain. It is especially - // important for contracts that have been mined to audit the tx that was - // mined rather than the txData provided to this method. - contractCoin, contractOutputScript, scriptVer, err := dcr.findContractInBlockchain(txHash, vout) - if err != nil { + var contractOutput *wire.TxOut + var txTree int8 + + // First try to pull the contract output details from the blockchain, if + // possible. SPV wallets do not need to try to look for the contract in + // a block yet. SwapConfirmations handles that. + tryFindTx := false + op := newOutPoint(txHash, vout) + contractOutput, _, spent, txTree, err := dcr.externalTxOut(dcr.ctx, op, tryFindTx, nil, time.Time{}) + if err != nil && err != asset.CoinNotFoundError { return nil, err } - if contractCoin != nil { - // Found the contract on the blockchain. Audit using the output script - // gotten from the blockchain. - if err = dcr.validateContractOutputScript(contractOutputScript, scriptVer, contract); err != nil { - return nil, err - } - dcr.log.Infof("Audited contract coin %s:%d using tx data gotten from the blockchain. SPV mode = %t", - txHash, vout, dcr.wallet.SpvMode()) - return &asset.AuditInfo{ - Coin: contractCoin, - Contract: contract, - SecretHash: secretHash, - Recipient: receiver.String(), - Expiration: time.Unix(int64(stamp), 0).UTC(), - }, nil + if spent { + return nil, dex.NewError(asset.CoinIsSpentError, op.String()) } - // Assume tx has not been broadcasted. Audit the provided txData - // and broadcast it. - contractTx := wire.NewMsgTx() - if err := contractTx.Deserialize(bytes.NewReader(txData)); err != nil { - return nil, fmt.Errorf("invalid contract tx data: %w", err) - } - if checkHash := contractTx.TxHash(); checkHash != *txHash { - return nil, fmt.Errorf("invalid contract tx data: expected hash %s, got %s", txHash, checkHash) - } - if int(vout) >= len(contractTx.TxOut) { - return nil, fmt.Errorf("invalid contract tx data: no output at %d", vout) + var contractTx *wire.MsgTx + contractFoundInBlockchain := contractOutput != nil + if !contractFoundInBlockchain { + // Contract not found on the blockchain. Assume the tx has not been + // broadcasted. Pull the contract output details from the provided + // txData to validate. The txData will be broadcasted below if the + // contract details are valid. + contractTx = wire.NewMsgTx() + if err := contractTx.Deserialize(bytes.NewReader(txData)); err != nil { + return nil, fmt.Errorf("invalid contract tx data: %w", err) + } + if err = blockchain.CheckTransactionSanity(contractTx, dcr.chainParams); err != nil { + return nil, fmt.Errorf("invalid contract tx data: %w", err) + } + if checkHash := contractTx.TxHash(); checkHash != *txHash { + return nil, fmt.Errorf("invalid contract tx data: expected hash %s, got %s", txHash, checkHash) + } + if int(vout) >= len(contractTx.TxOut) { + return nil, fmt.Errorf("invalid contract tx data: no output at %d", vout) + } + contractOutput = contractTx.TxOut[vout] + txTree = determineTxTree(contractTx) } - contractTxOut := contractTx.TxOut[vout] - if err = dcr.validateContractOutputScript(contractTxOut.PkScript, contractTxOut.Version, contract); err != nil { + + // Validate the contract output script gotten from the blockchain or the + // provided txData. + if err = dcr.validateContractOutput(contractOutput, contract); err != nil { return nil, err } - // SPV clients don't check tx sanity before broadcasting, so do that here. - if err = blockchain.CheckTransactionSanity(contractTx, dcr.chainParams); err != nil { - return nil, fmt.Errorf("invalid contract tx data: %v", err) + auditInfo := &asset.AuditInfo{ + Coin: newOutput(txHash, vout, uint64(contractOutput.Value), txTree), + Contract: contract, + SecretHash: secretHash, + Recipient: receiver.String(), + Expiration: time.Unix(int64(stamp), 0).UTC(), + } + + // No need to broadcast txData if we found the contract in the blockchain earlier. + if contractFoundInBlockchain { + dcr.log.Infof("Audited contract coin %s:%d using tx data gotten from the blockchain. SPV mode = %t", + txHash, vout, dcr.wallet.SpvMode()) + return auditInfo, nil } // The counter-party should have broadcasted the contract tx but @@ -1587,12 +1599,11 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t // TODO: It'd be unnecessary to continue trying to find this // contract or to broadcast the rawtx if the tx was already - // broadcasted, mined and spent. - // Consider modifying dcr.findContractInBlockchain to check if - // the tx exists on the blockchain using the gettransaction rpc - // if gettxout returns a nil response. If the gettransaction rpc - // confirms the existence of the tx output, return a CoinSpent - // error to the caller. + // broadcasted, mined AND spent. + // Consider modifying dcr.externalTxOut to use block filters to + // check if the tx exists on the blockchain if gettxout returns + // a nil response. If the tx does exist, return CoinIsSpentError + // to the caller. return nil, asset.CoinNotFoundError } @@ -1614,90 +1625,15 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t "expected %s, got %s", txHash, finalTxHash) } - if dcr.wallet.SpvMode() { - // Record this contract tx to easily get confirmations later - // using cfilters. - dcr.trackExternalTx(txHash, contractTxOut.PkScript) - } - dcr.log.Infof("Audited contract coin %s:%d using raw tx data. SPV mode = %t", txHash, vout, dcr.wallet.SpvMode()) - return &asset.AuditInfo{ - Coin: newOutput(txHash, vout, uint64(contractTxOut.Value), determineTxTree(contractTx)), - Contract: contract, - SecretHash: secretHash, - Recipient: receiver.String(), - Expiration: time.Unix(int64(stamp), 0).UTC(), - }, nil -} - -// findContractInBlockchain attempts to locate the specified contract output in -// the blockchain. If found and unspent, the output is returned along with its -// pkScript. Returns an error if the output is spent. It is not an error if the -// tx cannot be located, a nil response and nil error is returned. -func (dcr *ExchangeWallet) findContractInBlockchain(txHash *chainhash.Hash, vout uint32) (*output, []byte, uint16, error) { - if dcr.wallet.SpvMode() { - // SPV wallets can only locate contract outputs on the blockchain - // using cfilters. - txOut, txTree, isSpent, err := dcr.externalTxOut(txHash, vout) - if err != nil { - if errors.Is(err, asset.CoinNotFoundError) { - return nil, nil, 0, nil - } - return nil, nil, 0, fmt.Errorf("error checking if contract is mined or spent: %v", err) - } - if isSpent { - return nil, nil, 0, fmt.Errorf("contract output %s:%d is spent", txHash, vout) - } - // Extract relevant info from the tx data retrieved from the blockchain. - contractOutput := newOutput(txHash, vout, uint64(txOut.Value), txTree) - return contractOutput, txOut.PkScript, txOut.Version, nil - } - - // Full-node wallets can locate 'unspent' contract outputs using gettxout. - txOut, txTree, err := dcr.getTxOut(txHash, vout, true) - if err != nil { - return nil, nil, 0, fmt.Errorf("error finding unspent contract: %w", translateRPCCancelErr(err)) - } - if txOut == nil { - // Output does not exist or has been spent. - // We can try to look up the tx to determine if the output exists - // and return a 'output is spent' error, but the getrawtransaction - // rpc requires txindex to be enabled which may not be enabled by - // the client. We also can't use the gettransaction rpc because - // contract outputs that we audit are typically NOT indexed by the - // wallet and gettransaction only returns data for wallet txs. - // - // It is safe to assume that this output does not yet exist since - // contracts we audit can only be spent when we redeem them or - // when they are refunded (after the contract locktime expires). - // Worst case scenario is that the contract was refunded and the - // client keeps fruitlessly trying to find and audit the contract - // until the client refunds their own swap contract or the match - // is revoked by the server. E.g. maker keeps checking for taker's - // contract after taker has refunded it; until maker's own contract - // locktime expires and maker refunds self or the match is revoked - // by the server. - return nil, nil, 0, nil - } - - txOutAmt, err := dcrutil.NewAmount(txOut.Value) - if err != nil { - return nil, nil, 0, fmt.Errorf("error parsing output amount %f: %w", txOut.Value, err) - } - contractOutput := newOutput(txHash, vout, uint64(txOutAmt), txTree) - contractOutputScript, err := hex.DecodeString(txOut.ScriptPubKey.Hex) - if err != nil { - return nil, nil, 0, fmt.Errorf("error decoding pubkey script from hex '%s': %w", - txOut.ScriptPubKey.Hex, err) - } - return contractOutput, contractOutputScript, txOut.ScriptPubKey.Version, nil + return auditInfo, nil } -func (dcr *ExchangeWallet) validateContractOutputScript(pkScript []byte, scriptVer uint16, contract []byte) error { +func (dcr *ExchangeWallet) validateContractOutput(output *wire.TxOut, contract []byte) error { // Output script must be P2SH, with 1 address and 1 required signature. - scriptClass, addrs, sigsReq, err := txscript.ExtractPkScriptAddrs(scriptVer, pkScript, dcr.chainParams, false) + scriptClass, addrs, sigsReq, err := txscript.ExtractPkScriptAddrs(output.Version, output.PkScript, dcr.chainParams, false) if err != nil { - return fmt.Errorf("error extracting script addresses from '%x': %w", pkScript, err) + return fmt.Errorf("error extracting script addresses from '%x': %w", output.PkScript, err) } if scriptClass != txscript.ScriptHashTy { return fmt.Errorf("unexpected script class %d", scriptClass) @@ -2002,7 +1938,7 @@ rangeBlocks: // Get the cfilters for this block to check if any of the above p2sh scripts is // possibly included in this block. - blkCFilter, err := dcr.getBlockFilterV2(blockHash) + blkCFilter, err := dcr.getBlockFilterV2(dcr.ctx, blockHash) if err != nil { // error retrieving a block's cfilters is a fatal error err = fmt.Errorf("get cfilters error for block %d (%s): %w", blockHeight, blockHash, translateRPCCancelErr(err)) @@ -2362,26 +2298,21 @@ func (dcr *ExchangeWallet) ValidateSecret(secret, secretHash []byte) bool { return bytes.Equal(h[:], secretHash) } -// coinConfirmations gets the number of confirmations for the specified coin by -// first checking for an unspent output, and if not found, searching indexed -// wallet transactions. Additionally, in spv mode, if the output is not found -// in the wallet and a script referenced by the tx is known, the script is used -// to search block filters to attempt finding the tx. -func (dcr *ExchangeWallet) coinConfirmations(ctx context.Context, id dex.Bytes) (confs uint32, spent bool, err error) { - txHash, vout, err := decodeCoinID(id) - if err != nil { - return 0, false, err - } - +// walletOutputConfirmations gets the number of confirmations for the specified +// outPoint by first checking for an unspent output, and if not found, searching +// indexed wallet transactions. +// This method is only guaranteed to return results for wallet outputs. For +// non-wallet outputs, use dcr.externalTxOut. +func (dcr *ExchangeWallet) walletOutputConfirmations(ctx context.Context, op outPoint) (confs uint32, spent bool, err error) { + txHash, vout := &op.txHash, op.vout // Check for an unspent output. - txOut, _, err := dcr.getTxOut(txHash, vout, true) + txOut, _, err := dcr.unspentTxOut(ctx, txHash, vout, true) if err != nil { return 0, false, fmt.Errorf("gettxout error for %s:%d: %w", txHash, vout, translateRPCCancelErr(err)) } if txOut != nil { return uint32(txOut.Confirmations), false, nil } - // Unspent output not found. Check if this tx is indexed by the // wallet and has an output at `vout`. tx, err := dcr.wallet.GetTransaction(ctx, txHash) @@ -2402,31 +2333,50 @@ func (dcr *ExchangeWallet) coinConfirmations(ctx context.Context, id dex.Bytes) if !isTxNotFoundErr(err) { return 0, false, translateRPCCancelErr(err) } + return 0, false, asset.CoinNotFoundError +} - // Tx not found by wallet. - if !dcr.wallet.SpvMode() { - return 0, false, asset.CoinNotFoundError +// SwapConfirmations gets the number of confirmations and the spend status for +// the specified swap. The contract and matchTime are provided so that wallets +// may search for the coin using light filters. +// +// If the swap was not funded by this wallet, and it is already spent, this +// method may return asset.CoinNotFoundError. Compare dcr.externalTxOut. +// TODO: Could this method be called for wallet-funded swaps? +// +// If the coin is located, but recognized as spent, no error is returned. +func (dcr *ExchangeWallet) SwapConfirmations(ctx context.Context, coinID, contract dex.Bytes, matchTime time.Time) (confs uint32, spent bool, err error) { + txHash, vout, err := decodeCoinID(coinID) + if err != nil { + return 0, false, err } + op := newOutPoint(txHash, vout) - // In spv mode, attempt using cfilters to find the tx in a block and - // to determine if the requested output is spent. This tx must have - // been previously associated with a script using dcr.trackExternalTx - // otherwise, the tx cannot be located in a mined block using cfilters - // and an asset.CoinNotFoundError will be returned. - return dcr.externalTxOutConfirmations(txHash, vout) -} + // First attempt to find this contract in the wallet. TODO: Any need? + confs, spent, err = dcr.walletOutputConfirmations(ctx, op) + if err != nil && err != asset.CoinNotFoundError { + return confs, spent, err + } -// SwapConfirmations gets the number of confirmations for the specified coin ID -// by first checking for a unspent output, and if not found, searching indexed -// wallet transactions. -func (dcr *ExchangeWallet) SwapConfirmations(ctx context.Context, coinID dex.Bytes, _ dex.Bytes, _ time.Time) (confs uint32, spent bool, err error) { - return dcr.coinConfirmations(ctx, coinID) + // Perform an external txout lookup. Prepare the pkScript to use in + // finding the txout using block filters. + scriptAddr, err := stdaddr.NewAddressScriptHashV0(contract, dcr.chainParams) + if err != nil { + return 0, false, fmt.Errorf("error encoding script address: %w", err) + } + _, p2shScript := scriptAddr.PaymentScript() + _, confs, spent, _, err = dcr.externalTxOut(ctx, op, true, p2shScript, matchTime) + return confs, spent, err } // RegFeeConfirmations gets the number of confirmations for the specified // output. func (dcr *ExchangeWallet) RegFeeConfirmations(ctx context.Context, coinID dex.Bytes) (confs uint32, err error) { - confs, _, err = dcr.coinConfirmations(ctx, coinID) + txHash, vout, err := decodeCoinID(coinID) + if err != nil { + return 0, err + } + confs, _, err = dcr.walletOutputConfirmations(ctx, newOutPoint(txHash, vout)) return confs, err } @@ -2528,12 +2478,17 @@ func (dcr *ExchangeWallet) lockedAtoms() (uint64, error) { return sum, nil } -// getTxOut attempts to find the specified txout from the regular tree and if -// not found in the regular tree, checks the stake tree. Also returns the tree -// where the output is found. -func (dcr *ExchangeWallet) getTxOut(txHash *chainhash.Hash, index uint32, mempool bool) (*chainjson.GetTxOutResult, int8, error) { +// unspentTxOut returns details for the specified tx output if it exists and is +// not spent by a mined transaction. Also returns the transaction tree where the +// output is found. Returns nil error and nil result if the output is not found. +// +// This method should ideally only be used to look up outputs that are indexed +// by the wallet even though it might be able to return details for a non-wallet +// output (if the wallet is connected to a full node). Use dcr.externalTxOut for +// non-wallet outputs. +func (dcr *ExchangeWallet) unspentTxOut(ctx context.Context, txHash *chainhash.Hash, index uint32, mempool bool) (*chainjson.GetTxOutResult, int8, error) { tree := wire.TxTreeRegular - txout, err := dcr.wallet.GetTxOut(dcr.ctx, txHash, index, tree, mempool) // check regular tree first + txout, err := dcr.wallet.GetTxOut(ctx, txHash, index, tree, mempool) // check regular tree first if err == nil && txout == nil { tree = wire.TxTreeStake txout, err = dcr.wallet.GetTxOut(dcr.ctx, txHash, index, tree, mempool) // check stake tree @@ -2542,6 +2497,8 @@ func (dcr *ExchangeWallet) getTxOut(txHash *chainhash.Hash, index uint32, mempoo } // convertCoin converts the asset.Coin to an unspent output. +// In SPV mode, this method may return asset.CoinNotFoundError for an existing, +// unspent output if the provided asset.Coin is not indexed by the wallet. func (dcr *ExchangeWallet) convertCoin(coin asset.Coin) (*output, error) { op, _ := coin.(*output) if op != nil { @@ -2551,7 +2508,7 @@ func (dcr *ExchangeWallet) convertCoin(coin asset.Coin) (*output, error) { if err != nil { return nil, err } - txOut, tree, err := dcr.getTxOut(txHash, vout, true) + txOut, tree, err := dcr.unspentTxOut(dcr.ctx, txHash, vout, true) if err != nil { return nil, fmt.Errorf("error finding unspent output %s:%d: %w", txHash, vout, err) } @@ -3036,8 +2993,8 @@ func (dcr *ExchangeWallet) getBestBlock(ctx context.Context) (*block, error) { return &block{hash: hash, height: height}, nil } -func (dcr *ExchangeWallet) getBlock(blockHash *chainhash.Hash, verboseTx bool) (*chainjson.GetBlockVerboseResult, error) { - blockVerbose, err := dcr.wallet.GetBlockVerbose(dcr.ctx, blockHash, verboseTx) +func (dcr *ExchangeWallet) getBlock(ctx context.Context, blockHash *chainhash.Hash, verboseTx bool) (*chainjson.GetBlockVerboseResult, error) { + blockVerbose, err := dcr.wallet.GetBlockVerbose(ctx, blockHash, verboseTx) if err != nil { return nil, fmt.Errorf("error retrieving block %s: %w", blockHash, translateRPCCancelErr(err)) } @@ -3054,19 +3011,15 @@ func (dcr *ExchangeWallet) getBlockHash(blockHeight int64) (*chainhash.Hash, err // mainChainAncestor crawls blocks backwards starting at the provided hash // until finding a mainchain block. Returns the first mainchain block found. -func (dcr *ExchangeWallet) mainChainAncestor(blockHash *chainhash.Hash) (*chainhash.Hash, int64, error) { - if *blockHash == zeroHash { - return nil, 0, fmt.Errorf("invalid block hash %s", blockHash.String()) - } - +func (dcr *ExchangeWallet) mainChainAncestor(ctx context.Context, blockHash *chainhash.Hash) (*chainhash.Hash, int64, error) { checkHash := blockHash for { - checkBlock, err := dcr.wallet.GetBlockHeaderVerbose(dcr.ctx, checkHash) + checkBlock, err := dcr.wallet.GetBlockHeaderVerbose(ctx, checkHash) if err != nil { - return nil, 0, fmt.Errorf("error retrieving block %s: %w", checkHash, translateRPCCancelErr(err)) + return nil, 0, fmt.Errorf("getblockheader error for block %s: %w", checkHash, translateRPCCancelErr(err)) } if checkBlock.Confirmations > -1 { - // This is a mainchain block, return the hash. + // This is a mainchain block, return the hash and height. return checkHash, int64(checkBlock.Height), nil } if checkBlock.Height == 0 { @@ -3075,11 +3028,22 @@ func (dcr *ExchangeWallet) mainChainAncestor(blockHash *chainhash.Hash) (*chainh checkHash, err = chainhash.NewHashFromStr(checkBlock.PreviousHash) if err != nil { return nil, 0, fmt.Errorf("error decoding previous hash %s for block %s: %w", - checkBlock.PreviousHash, checkHash.String(), translateRPCCancelErr(err)) + checkBlock.PreviousHash, checkHash.String(), err) } } } +func (dcr *ExchangeWallet) isMainchainBlock(ctx context.Context, block *block) (bool, error) { + if block == nil { + return false, nil + } + blockHeader, err := dcr.wallet.GetBlockHeaderVerbose(ctx, block.hash) + if err != nil { + return false, fmt.Errorf("getblockheader error for block %s: %w", block.hash, translateRPCCancelErr(err)) + } + return blockHeader.Confirmations > -1 && int64(blockHeader.Height) == block.height, nil +} + func (dcr *ExchangeWallet) cachedBestBlock() block { dcr.tipMtx.RLock() defer dcr.tipMtx.RUnlock() diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index e88d4ddb15..3af3e9f722 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -2185,19 +2185,21 @@ func TestCoinConfirmations(t *testing.T) { coinID := make([]byte, 36) copy(coinID[:32], tTxHash[:]) + op := newOutPoint(tTxHash, 0) - // Bad coin idea - _, spent, err := wallet.coinConfirmations(context.Background(), randBytes(35)) + // Bad output coin + op.vout = 10 + _, spent, err := wallet.walletOutputConfirmations(context.Background(), op) if err == nil { - t.Fatalf("no error for bad coin ID") + t.Fatalf("no error for bad output coin") } if spent { - t.Fatalf("spent is non-zero for non-nil error") + t.Fatalf("spent is true for bad output coin") } + op.vout = 0 - op := newOutPoint(tTxHash, 0) node.txOutRes[op] = makeGetTxOutRes(2, 1, tP2PKHScript) - confs, spent, err := wallet.coinConfirmations(context.Background(), coinID) + confs, spent, err := wallet.walletOutputConfirmations(context.Background(), op) if err != nil { t.Fatalf("error for gettransaction path: %v", err) } @@ -2208,25 +2210,17 @@ func TestCoinConfirmations(t *testing.T) { t.Fatalf("expected spent = false for gettxout path, got true") } - // gettransaction or getrawtransaction error + // gettransaction error delete(node.txOutRes, op) - if wallet.wallet.SpvMode() { - node.walletTxErr = tErr - } else { - node.rawTxErr = tErr - } - _, spent, err = wallet.coinConfirmations(context.Background(), coinID) + node.walletTxErr = tErr + _, spent, err = wallet.walletOutputConfirmations(context.Background(), op) if err == nil { t.Fatalf("no error for gettransaction error") } if spent { t.Fatalf("spent is true with gettransaction error") } - if wallet.wallet.SpvMode() { - node.walletTxErr = nil - } else { - node.rawTxErr = nil - } + node.walletTxErr = nil // wallet.Confirmations will check if the tx hex is valid // and contains an output at index 0, for the output to be @@ -2241,7 +2235,7 @@ func TestCoinConfirmations(t *testing.T) { node.walletTx = &walletjson.GetTransactionResult{ Hex: txHex, } - _, spent, err = wallet.coinConfirmations(context.Background(), coinID) + _, spent, err = wallet.walletOutputConfirmations(context.Background(), op) if err != nil { t.Fatalf("coin error: %v", err) } diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index a328ce6cc0..e3271349e4 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -4,9 +4,11 @@ package dcr import ( + "context" "encoding/hex" "fmt" "sync" + "time" "decred.org/dcrdex/client/asset" "github.com/decred/dcrd/chaincfg/chainhash" @@ -20,381 +22,419 @@ import ( type externalTx struct { hash *chainhash.Hash - mtx sync.RWMutex - relevantScripts [][]byte + mtx sync.RWMutex // protects access to pkScripts + pkScripts [][]byte - blockFiltersScanner - - // The following are protected by the blockFiltersScanner.scanMtx - // because they are set when the tx's block is found and cleared - // when the previously found tx block is orphaned. The scanMtx - // lock must be held for read before accessing these fields. - tree int8 - outputs map[uint32]*externalTxOutput + // blockMtx protects access to the fields below because + // they are set when the tx's block is found and cleared + // when the previously found tx block is orphaned. + blockMtx sync.RWMutex + lastScannedBlock *chainhash.Hash + block *block + tree int8 + outputs []*externalTxOutput } type externalTxOutput struct { - *wire.TxOut - txHash *chainhash.Hash - vout uint32 - spender blockFiltersScanner -} - -func (output *externalTxOutput) String() string { - return fmt.Sprintf("%s:%d", output.txHash, output.vout) -} - -type blockFiltersScanner struct { - scanMtx sync.RWMutex + outPoint + value float64 + pkScriptHex string + pkScriptVersion uint16 + + // The spenderMtx protects access to the fields below + // because they are set when the block containing the tx + // that spends this output is found and cleared when the + // previously found block is orphaned. + spenderMtx sync.RWMutex lastScannedBlock *chainhash.Hash - relevantBlock *block -} - -// relevantBlockHash returns the hash of the block relevant to this scanner, if -// the relevantBlock is set and is a mainchain block. Returns a nil hash and nil -// error if the relevantBlock is set but is no longer part of the mainchain. -// The scanner's scanMtx MUST be write-locked. -func (scanner *blockFiltersScanner) relevantBlockHash(nodeGetBlockHashFn func(int64) (*chainhash.Hash, error)) (*chainhash.Hash, error) { - if scanner.relevantBlock == nil { - return nil, nil - } - mainchainBlockHash, err := nodeGetBlockHashFn(scanner.relevantBlock.height) - if err != nil { - return nil, fmt.Errorf("cannot get hash for block %d: %v", scanner.relevantBlock.height, err) - } - if mainchainBlockHash.IsEqual(scanner.relevantBlock.hash) { - return scanner.relevantBlock.hash, nil - } - scanner.relevantBlock = nil // clear so we don't keep checking if it is a mainchain block - return nil, nil + spenderBlock *block } -// trackExternalTx records the script associated with a tx to enable spv -// wallets locate the tx in a block when it is mined. Once mined, the block -// containing the tx and the pkScripts of all of the tx outputs are recorded. -// The recorded output scripts can then be used to subsequently check if an -// output is spent in a mined transaction. -func (dcr *ExchangeWallet) trackExternalTx(hash *chainhash.Hash, script []byte) { - defer func() { - dcr.log.Debugf("Script %x cached for non-wallet tx %s.", script, hash) - }() - - if tx, tracked := dcr.externalTx(hash); tracked { - tx.mtx.Lock() - tx.relevantScripts = append(tx.relevantScripts, script) - tx.mtx.Unlock() - return - } - +// externalTx returns details for the provided hash, if cached. If the tx cache +// doesn't yet exist and addToCache is true, the provided script will be cached +// against the tx hash to enable SPV wallets locate the tx in a block when it is +// mined. Once mined, the block containing the tx and the tx outputs details are +// also cached, to enable subsequently checking if any of the tx's output is +// spent in a mined transaction. +// +// This method should only be used with transactions that are NOT indexed by the +// wallet such as counter-party swaps. +func (dcr *ExchangeWallet) externalTx(hash *chainhash.Hash, pkScript []byte, addToCache bool) *externalTx { dcr.externalTxMtx.Lock() - dcr.externalTxs[*hash] = &externalTx{ - hash: hash, - relevantScripts: [][]byte{script}, + defer dcr.externalTxMtx.Unlock() + + tx := dcr.externalTxCache[*hash] + if tx == nil && addToCache && len(pkScript) > 0 { + tx := &externalTx{ + hash: hash, + pkScripts: [][]byte{pkScript}, + } + dcr.externalTxCache[*hash] = tx + dcr.log.Debugf("Script %x cached for non-wallet tx %s.", pkScript, hash) } - dcr.externalTxMtx.Unlock() -} + // TODO: Consider appending this pkScript to the tx if cached. -func (dcr *ExchangeWallet) externalTx(hash *chainhash.Hash) (*externalTx, bool) { - dcr.externalTxMtx.RLock() - tx, tracked := dcr.externalTxs[*hash] - dcr.externalTxMtx.RUnlock() - return tx, tracked + return tx } -func (dcr *ExchangeWallet) externalTxOut(hash *chainhash.Hash, vout uint32) (*wire.TxOut, int8, bool, error) { - tx, tracked := dcr.externalTx(hash) - if !tracked { - return nil, 0, false, asset.CoinNotFoundError +// externalTxOut returns details for the specified transaction output, along +// with the confirmations, spend status and tx tree. If the tx details are not +// currently cached, a search will be conducted to attempt finding the tx in a +// mainchain block, unless tryFindTx is false. asset.CoinNotFoundError is +// returned if the output cannot be found or (for full node wallets), if the +// output is spent. +// +// This method should only be used for transactions that are NOT indexed by the +// wallet. For wallet transactions, use dcr.walletOutputConfirmations. +// +// NOTE: SPV wallets are unable to look up unmined transaction outputs. Also, +// the `tryFindTx`, `pkScript` and `earliestTxTime` parameters are irrelevant +// for full node wallets, but required for SPV wallets if the caller intends to +// perform a search for the tx in a mainchain block. +func (dcr *ExchangeWallet) externalTxOut(ctx context.Context, op outPoint, tryFindTx bool, pkScript []byte, earliestTxTime time.Time) ( + *wire.TxOut, uint32, bool, int8, error) { + + if !dcr.wallet.SpvMode() { + // Use the gettxout rpc to look up the requested tx output. Unlike + // SPV wallets, full node wallets are able to look up outputs for + // all transactions whether or not they are indexed by the wallet, + // including outputs in mempool. + output, txTree, err := dcr.unspentTxOut(ctx, &op.txHash, op.vout, true) + if err != nil { + return nil, 0, false, 0, fmt.Errorf("error finding unspent output %s: %w", op, translateRPCCancelErr(err)) + } + if output == nil { + // Output does not exist or has been spent. + // We can try to look up the tx to determine if the output exists + // and return a 'output is spent' error, but the getrawtransaction + // rpc requires txindex to be enabled which may not be enabled by + // the client. We also can't use the gettransaction rpc because + // this method is particularly designed to work with txs that are + // NOT indexed by the wallet and gettransaction only returns data + // for wallet txs. + // + // TODO: Attempt finding the tx using block filters. If the tx is + // found, then we can assert that the output is spent instead of + // returning asset.CoinNotFoundError. + return nil, 0, false, 0, asset.CoinNotFoundError + } + amt, outputPkScript, err := parseAmountAndScript(output.Value, output.ScriptPubKey.Hex) + if err != nil { + return nil, 0, false, 0, fmt.Errorf("error parsing tx output %s: %v", op, err) + } + return newTxOut(amt, output.ScriptPubKey.Version, outputPkScript), uint32(output.Confirmations), false, txTree, nil } - // Lock the scanMtx to prevent attempted rescans from - // mutating the tx block, outputs map or tree field. - tx.scanMtx.RLock() - txBlockHash, err := tx.relevantBlockHash(dcr.getBlockHash) - if err != nil { - tx.scanMtx.RUnlock() - return nil, 0, false, err - } - if txBlockHash == nil { - tx.scanMtx.RUnlock() - return nil, 0, false, asset.CoinNotFoundError - } - output := tx.outputs[vout] - txTree := tx.tree - tx.scanMtx.RUnlock() + // This is an SPV wallet. First try to determine if the tx has been mined + // before checking if the specified output is spent. This will require + // scanning block filters to try to locate the tx, if the tx's block is + // not already known or the previously found tx block is no longer part of + // the mainchain. If tryFindTx is false however, do NOT scan scan block + // filters; instead, return asset.CoinNotFoundError. - if output == nil { - return nil, 0, false, fmt.Errorf("tx %s has no output at index %d", hash, vout) + tx := dcr.externalTx(&op.txHash, pkScript, tryFindTx) // the tx hash and script will be cached if not previously cached and if tryFindTx is true + if tx == nil { + return nil, 0, false, 0, asset.CoinNotFoundError } - isSpent, err := dcr.findExternalTxOutputSpender(output, txBlockHash) + // Hold the tx.blockMtx lock for 2 reasons: + // 1) To read the tx block, outputs map, tree and outputs fields. + // 2) To prevent duplicate tx block scans if this tx block is not already + // known and tryFindTx is true. + // The closure below helps to ensure that blockMtx lock is released + // as soon as all those are done. + txBlock, output, txTree, err := func() (*block, *externalTxOutput, int8, error) { + tx.blockMtx.Lock() + defer tx.blockMtx.Unlock() + txBlock, err := dcr.externalTxBlock(ctx, tx, tryFindTx, earliestTxTime) + if err != nil { + return nil, nil, 0, fmt.Errorf("error checking if tx %s is mined: %v", op.txHash, err) + } + if txBlock == nil { + // SPV wallets cannot look up unmined txs. + return nil, nil, 0, asset.CoinNotFoundError + } + if len(tx.outputs) <= int(op.vout) { + return nil, nil, 0, fmt.Errorf("tx %s does not have an output at index %d", op.txHash, op.vout) + } + return txBlock, tx.outputs[op.vout], tx.tree, nil + }() if err != nil { - return nil, 0, false, err + return nil, 0, false, 0, err } - return output.TxOut, txTree, isSpent, nil -} - -// externalTxOutConfirmations uses the script(s) associated with an externalTx -// to find the block in which the tx is mined and checks if the specified tx -// output is spent by a mined transaction. This tx must have been previously -// associated with one or more scripts using dcr.trackExternalTx, otherwise -// this will return asset.CoinNotFoundError. -func (dcr *ExchangeWallet) externalTxOutConfirmations(hash *chainhash.Hash, vout uint32) (uint32, bool, error) { - tx, tracked := dcr.externalTx(hash) - if !tracked { - dcr.log.Errorf("Attempted to find confirmations for tx %s without an associated script.", hash) - return 0, false, asset.CoinNotFoundError + amt, outputPkScript, err := parseAmountAndScript(output.value, output.pkScriptHex) + if err != nil { + return nil, 0, false, 0, fmt.Errorf("error parsing tx output %s: %v", op, err) } + txOut := newTxOut(amt, output.pkScriptVersion, outputPkScript) + + // We have the requested output, let's check if it is spent. + // Hold the output.spenderMtx lock for 2 reasons: + // 1) To read (and set) the spenderBlock field. + // 2) To prevent duplicate spender block scans if the spenderBlock is not + // already known. + // The closure below helps to ensure that spenderMtx lock is released + // as soon as all those are done. + isSpent, err := func() (bool, error) { + output.spenderMtx.Lock() + defer output.spenderMtx.Unlock() + + // Check if this output is known to be spent in a mainchain block. + spenderFound, err := dcr.isMainchainBlock(ctx, output.spenderBlock) + if err != nil { + return false, err + } else if spenderFound { + return true, nil + } else if output.spenderBlock != nil { + // Output was previously found to have been spent but the block + // containing the spending tx seems to have been invalidated. + dcr.log.Warnf("Block %s found to contain spender for output %s has been invalidated.", tx.block.hash, op) + output.spenderBlock = nil + } - // If this tx's block is not yet known, and some other process got here - // first, a search for the tx's block might be underway already. Wait - // until any previous search completes and releases this lock. Retain - // the lock once acquired until we are done accessing tx.relevantBlock, - // tx.tree and tx.outputSpendScanners. - tx.scanMtx.Lock() - - // First try to determine if the tx has been mined before checking if - // the specified output is spent. Outputs of unmined txs cannot be - // spent in mined blocks. - txBlockFound, err := dcr.findExternalTxBlock(tx) - if !txBlockFound || err != nil { - tx.scanMtx.Unlock() - return 0, false, err - } - // Tx block is known. Read the tx block and desired output before - // releasing the scanMtx lock. - txBlock := tx.relevantBlock - output := tx.outputs[vout] - if output == nil { - tx.scanMtx.Unlock() - return 0, false, fmt.Errorf("tx %s has no output at index %d", hash, vout) - } - tx.scanMtx.Unlock() + // This tx output is not known to be spent as of last search (if any). + // Scan blocks from the lastScannedBlock (if there was a previous scan) + // or from the block containing the output to attempt finding the spender + // of this output. Use mainChainAncestor to ensure that scanning starts + // from a mainchain block in the event that either the output block or + // the lastScannedBlock have been re-orged out of the mainchain. + startBlock := new(block) + if output.lastScannedBlock == nil { + startBlock.hash, startBlock.height, err = dcr.mainChainAncestor(ctx, txBlock.hash) + } else { + startBlock.hash, startBlock.height, err = dcr.mainChainAncestor(ctx, output.lastScannedBlock) + } + if err != nil { + return false, err + } - isSpent, err := dcr.findExternalTxOutputSpender(output, txBlock.hash) + // Search for this output's spender in the blocks between startBlock and + // the current best block. + spenderTx, stopBlockHash, err := dcr.findTxOutSpender(ctx, op, outputPkScript, startBlock) + if stopBlockHash != nil { // might be nil if the search never scanned a block + output.lastScannedBlock = stopBlockHash + } + if err != nil { + return false, err + } + spent := spenderTx != nil + if spent { + spenderBlockHash, err := chainhash.NewHashFromStr(spenderTx.BlockHash) + if err != nil { + return false, err + } + output.spenderBlock = &block{hash: spenderBlockHash, height: spenderTx.BlockHeight} + } + return spent, nil + }() if err != nil { - return 0, false, fmt.Errorf("unable to determine if output %s:%d is spent: %v", hash, vout, err) + return nil, 0, false, 0, fmt.Errorf("unable to check if output %s is spent: %v", op, err) } bestBlockHeight := dcr.cachedBestBlock().height - return uint32(bestBlockHeight - txBlock.height + 1), isSpent, nil + confs := uint32(bestBlockHeight - txBlock.height + 1) + return txOut, confs, isSpent, txTree, nil } -// findExternalTxBlock returns true if the block containing the provided tx is -// known and is a mainchain block. If the tx block is not yet known or has been -// re-orged out of the mainchain, this method matches the script(s) associated -// with the tx against multiple block cfilters (starting from the current best -// block down till the genesis block) in an attempt to find the block containing -// the tx. -// If found, the block hash, height and the tx output scripts are recorded. -// The tx's scanMtx MUST be write-locked. -func (dcr *ExchangeWallet) findExternalTxBlock(tx *externalTx) (bool, error) { - // Check if this tx's block was found in a previous search attempt. - txBlockHash, err := tx.relevantBlockHash(dcr.getBlockHash) +// externalTxBlock returns the mainchain block containing the provided tx, if +// it is known. If the tx block is yet unknown or has been re-orged out of the +// mainchain AND if tryFindTxBlock is true, this method attempts to find the +// block containing the provided tx by scanning block filters from the current +// best block down to the block just before earliestTxTime or the block that was +// last scanned, if there was a previous scan. If the tx block is found, the +// block hash, height and the tx outputs details are cached. +// Requires the tx.scanMtx to be locked for write. +func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, tryFindTxBlock bool, earliestTxTime time.Time) (*block, error) { + txBlockFound, err := dcr.isMainchainBlock(ctx, tx.block) if err != nil { - return false, err - } - if txBlockHash != nil { - return true, nil + return nil, err + } else if txBlockFound { + return tx.block, nil + } else if tx.block != nil { + // Tx block was previously set but seems to have been invalidated. + // Log a warning(?) and clear the tx tree, outputs and block info + // fields that must have been previously set. + dcr.log.Warnf("Block %s found to contain tx %s has been invalidated.", tx.block.hash, tx.hash) + tx.block = nil + tx.tree = -1 + tx.outputs = nil } - // This tx's block is yet unknown. Clear the output scripts if they - // were previously set using data from a previously recorded block - // that is now invalidated. - tx.tree = -1 - tx.outputs = nil + // Tx block is currently unknown. Return if the caller does not want + // to start a search for the block. + if !tryFindTxBlock { + return nil, nil + } // Start a new search for this tx's block using the associated scripts. tx.mtx.RLock() - txScripts := tx.relevantScripts + txScripts := tx.pkScripts tx.mtx.RUnlock() // Scan block filters in reverse from the current best block to the last // scanned block. If the last scanned block has been re-orged out of the // mainchain, scan back to the mainchain ancestor of the lastScannedBlock. - var lastScannedBlock block + var lastScannedBlock *block if tx.lastScannedBlock != nil { - stopBlockHash, stopBlockHeight, err := dcr.mainChainAncestor(tx.lastScannedBlock) + stopBlockHash, stopBlockHeight, err := dcr.mainChainAncestor(ctx, tx.lastScannedBlock) if err != nil { - return false, fmt.Errorf("error looking up mainchain ancestor for block %s", err) + return nil, fmt.Errorf("error looking up mainchain ancestor for block %s", err) } - lastScannedBlock.height = stopBlockHeight - lastScannedBlock.hash = stopBlockHash tx.lastScannedBlock = stopBlockHash - } else { - // TODO: Determine a stopHeight to use based on when this tx was first seen - // or some constant min block height value. + lastScannedBlock = &block{hash: stopBlockHash, height: stopBlockHeight} } - // Run cfilters scan in reverse from best block to lastScannedBlock. + // Run cfilters scan in reverse from best block to lastScannedBlock or + // to block just before earliestTxTime. currentTip := dcr.cachedBestBlock() - dcr.log.Debugf("Searching for tx %s in blocks %d (%s) to %d (%s).", tx.hash, - currentTip.height, currentTip.hash, lastScannedBlock.height, lastScannedBlock.hash) - for blockHeight := currentTip.height; blockHeight > lastScannedBlock.height; blockHeight-- { - blockHash, err := dcr.wallet.GetBlockHash(dcr.ctx, blockHeight) - if err != nil { - return false, translateRPCCancelErr(err) + if lastScannedBlock != nil { + dcr.log.Debugf("Searching for tx %s in blocks %d (%s) to %d (%s).", tx.hash, + currentTip.height, currentTip.hash, lastScannedBlock.height, lastScannedBlock.hash) + } else { + dcr.log.Debugf("Searching for tx %s in blocks between block %d (%s) to the block just before %s.", + tx.hash, currentTip.height, currentTip.hash, earliestTxTime) + } + + iHash := currentTip.hash + iHeight := currentTip.height + + // Set the current tip as the last scanned block so subsequent + // scans cover the latest tip back to this current tip. + scanCompletedWithoutResults := func() (*block, error) { + tx.lastScannedBlock = currentTip.hash + dcr.log.Debugf("Tx %s NOT found in blocks %d (%s) to %d (%s).", tx.hash, + currentTip.height, currentTip.hash, iHeight, iHash) + return nil, nil + } + + for { + // Abort the search if we've scanned blocks from the tip back to the + // block we scanned last or the block just before earliestTxTime. + if iHeight == 0 { + return scanCompletedWithoutResults() } - blockFilter, err := dcr.getBlockFilterV2(blockHash) + if lastScannedBlock != nil && iHeight <= lastScannedBlock.height { + return scanCompletedWithoutResults() + } + iBlock, err := dcr.wallet.GetBlockHeaderVerbose(dcr.ctx, iHash) if err != nil { - return false, err + return nil, fmt.Errorf("getblockheader error for block %s: %w", iHash, translateRPCCancelErr(err)) } - if !blockFilter.MatchAny(txScripts) { - continue // check previous block's filters (blockHeight--) + if iBlock.Time <= earliestTxTime.Unix() { + return scanCompletedWithoutResults() } - dcr.log.Debugf("Block %d (%s) likely contains tx %s. Confirming.", blockHeight, blockHash, tx.hash) - blk, err := dcr.getBlock(blockHash, true) + + // Check if this block has the tx we're looking for. + blockFilter, err := dcr.getBlockFilterV2(ctx, iHash) if err != nil { - return false, err + return nil, err } - blockTxs := append(blk.RawTx, blk.RawSTx...) - for i := range blockTxs { - blkTx := &blockTxs[i] - if blkTx.Txid == tx.hash.String() { - dcr.log.Debugf("Found mined tx %s in block %d (%s).", tx.hash, blockHeight, blockHash) + if blockFilter.MatchAny(txScripts) { + dcr.log.Debugf("Block %d (%s) likely contains tx %s. Confirming.", iHeight, iHash, tx.hash) + blk, err := dcr.getBlock(ctx, iHash, true) + if err != nil { + return nil, err + } + blockTxs := append(blk.RawTx, blk.RawSTx...) + for i := range blockTxs { + blkTx := &blockTxs[i] + if blkTx.Txid != tx.hash.String() { + continue // check next block tx + } + dcr.log.Debugf("Found mined tx %s in block %d (%s).", tx.hash, iHeight, iHash) msgTx, err := msgTxFromHex(blkTx.Hex) if err != nil { - return false, fmt.Errorf("invalid hex for tx %s: %v", tx.hash, err) + return nil, fmt.Errorf("invalid hex for tx %s: %v", tx.hash, err) } - - tx.relevantBlock = &block{hash: blockHash, height: blockHeight} + tx.block = &block{hash: iHash, height: iHeight} tx.tree = determineTxTree(msgTx) - - // Store the pkScripts for all of this tx's outputs so they - // can be used to later to determine if an output is spent. - tx.outputs = make(map[uint32]*externalTxOutput) + tx.outputs = make([]*externalTxOutput, len(blkTx.Vout)) for i := range blkTx.Vout { - output := &blkTx.Vout[i] - amt, err := dcrutil.NewAmount(output.Value) - if err != nil { - dcr.log.Errorf("tx output %s:%d has invalid amount: %v", tx.hash, i, err) - continue - } - pkScript, err := hex.DecodeString(output.ScriptPubKey.Hex) - if err != nil { - dcr.log.Errorf("tx output %s:%d has invalid pkScript: %v", tx.hash, i, err) - continue - } - tx.outputs[uint32(i)] = &externalTxOutput{ - TxOut: newTxOut(int64(amt), output.ScriptPubKey.Version, pkScript), - txHash: tx.hash, - vout: uint32(i), + blkTxOut := &blkTx.Vout[i] + tx.outputs[i] = &externalTxOutput{ + outPoint: newOutPoint(tx.hash, blkTxOut.N), + value: blkTxOut.Value, + pkScriptHex: blkTxOut.ScriptPubKey.Hex, + pkScriptVersion: blkTxOut.ScriptPubKey.Version, } } - return true, nil + return tx.block, nil } + dcr.log.Debugf("Block %d (%s) does NOT contain tx %s.", iHeight, iHash, tx.hash) } - } - - // Scan completed from current tip to last scanned block. Set the - // current tip as the last scanned block so subsequent scans cover - // the latest tip back to this current tip. - tx.lastScannedBlock = currentTip.hash - dcr.log.Debugf("Tx %s NOT found in blocks %d (%s) to %d (%s).", tx.hash, - currentTip.height, currentTip.hash, lastScannedBlock.height, lastScannedBlock.hash) - return false, nil -} - -// findExternalTxOutputSpender returns true if a block is found that contains -// a tx that spends the provided output AND the block is a mainchain block. -// If no block has been found to contain a tx that spends the provided output -// or the block that was found to contain the spender is re-orged out of the -// mainchain, this method matches the output's pkScript against multiple block -// cfilters (starting from the block containing the output up till the current -// best block) in an attempt to find the block containing the output spender. -// If found, the block hash and height is recorded. -func (dcr *ExchangeWallet) findExternalTxOutputSpender(output *externalTxOutput, txBlockHash *chainhash.Hash) (bool, error) { - // If the spender of this tx output is not yet known, and some other - // process got here first, a search for the tx output spender might - // be underway already. Wait until any previous search completes and - // releases this lock. - output.spender.scanMtx.Lock() - defer output.spender.scanMtx.Unlock() - - // Check if the spender of this tx output was found in a previous search - // attempt. - spenderBlockHash, err := output.spender.relevantBlockHash(dcr.getBlockHash) - if err != nil { - return false, err - } - if spenderBlockHash != nil { - return true, nil - } - // This tx output is not known to be spent as of last search (if any). - // Scan blocks from the lastScannedBlock (if there was a previous scan) - // or from the tx block to attempt finding the spender of this output. - // Use mainChainAncestor to ensure that scanning starts from a mainchain - // block in the event that either tx block or lastScannedBlock have been - // re-orged out of the mainchain. - var startBlockHash *chainhash.Hash - var startBlockHeight int64 - if output.spender.lastScannedBlock == nil { - startBlockHash, startBlockHeight, err = dcr.mainChainAncestor(txBlockHash) - } else { - startBlockHash, startBlockHeight, err = dcr.mainChainAncestor(output.spender.lastScannedBlock) - } - if err != nil { - return false, err + // Block does not include the tx, check the previous block. + iHeight-- + iHash, err = chainhash.NewHashFromStr(iBlock.PreviousHash) + if err != nil { + return nil, fmt.Errorf("error decoding previous hash %s for block %s: %w", + iBlock.PreviousHash, iHash.String(), err) + } + continue } +} - // Search for this output's spender in the blocks between startBlock and bestBlock. +// findTxOutSpender attempts to find and return the tx that spends the provided +// output by matching the provided outputPkScript against the block filters of +// the mainchain blocks between the provided startBlock and the current best +// block. +// If no tx is found to spend the provided output, the hash of the block that +// was last checked is returned along with any error that may have occurred +// during the search. +func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, outputPkScript []byte, startBlock *block) (*chainjson.TxRawResult, *chainhash.Hash, error) { bestBlock := dcr.cachedBestBlock() - dcr.log.Debugf("Searching for the tx that spends output %s in blocks %d (%s) to %d (%s).", - output, startBlockHeight, startBlockHash, bestBlock.height, bestBlock.hash) - for blockHeight := startBlockHeight; blockHeight <= bestBlock.height; blockHeight++ { - blockHash, err := dcr.wallet.GetBlockHash(dcr.ctx, blockHeight) - if err != nil { - return false, translateRPCCancelErr(err) - } - blockFilter, err := dcr.getBlockFilterV2(blockHash) + dcr.log.Debugf("Searching for the tx that spends output %s in blocks %d (%s) to %d (%s) using output pkScript %x.", + op, startBlock.height, startBlock.hash, bestBlock.height, bestBlock.hash, outputPkScript) + + iHeight := startBlock.height + iHash := startBlock.hash + for iHeight <= bestBlock.height { + blockFilter, err := dcr.getBlockFilterV2(ctx, iHash) if err != nil { - return false, err + return nil, nil, err } - if !blockFilter.Match(output.PkScript) { - output.spender.lastScannedBlock = blockHash - continue // check next block's filters (blockHeight++) - } - dcr.log.Debugf("Block %d (%s) likely contains a tx that spends %s. Confirming.", - blockHeight, blockHash, output) - blk, err := dcr.getBlock(blockHash, true) - if err != nil { - return false, err - } - for i := range blk.RawTx { - blkTx := &blk.RawTx[i] - if txSpendsOutput(blkTx, output) { - dcr.log.Debugf("Found spender for output %s in block %d (%s), spender tx hash %s.", - output, blockHeight, blockHash, blkTx.Txid) - output.spender.relevantBlock = &block{hash: blockHash, height: blockHeight} - return true, nil + + if blockFilter.Match(outputPkScript) { + dcr.log.Debugf("Block %d (%s) likely contains a tx that spends %s. Confirming.", + iHeight, iHash, op) + blk, err := dcr.getBlock(ctx, iHash, true) + if err != nil { + return nil, iHash, err } - } - for i := range blk.RawSTx { - blkTx := &blk.RawSTx[i] - if txSpendsOutput(blkTx, output) { - dcr.log.Debugf("Found spender for output %s in block %d (%s), spender tx hash %s.", - output, blockHeight, blockHash, blkTx.Txid) - output.spender.relevantBlock = &block{hash: blockHash, height: blockHeight} - return true, nil + blockTxs := append(blk.RawTx, blk.RawSTx...) + for i := range blockTxs { + blkTx := &blockTxs[i] + if txSpendsOutput(blkTx, op) { + dcr.log.Debugf("Found spender for output %s in block %d (%s), spender tx hash %s.", + op, iHeight, iHash, blkTx.Txid) + return blkTx, iHash, nil + } } + dcr.log.Debugf("Block %d (%s) does NOT contain a tx that spends %s.", iHeight, iHash, op) + } + + // Block does not include the output spender, check the next block. + iHeight++ + nextHash, err := dcr.wallet.GetBlockHash(ctx, iHeight) + if err != nil { + return nil, iHash, translateRPCCancelErr(err) } - output.spender.lastScannedBlock = blockHash + iHash = nextHash } dcr.log.Debugf("No spender tx found for %s in blocks %d (%s) to %d (%s).", - output, startBlockHeight, startBlockHash, bestBlock.height, bestBlock.hash) - return false, nil // scanned up to best block, no spender found + op, startBlock.height, startBlock.hash, bestBlock.height, bestBlock.hash) + return nil, bestBlock.hash, nil // scanned up to best block, no spender found } // txSpendsOutput returns true if the passed tx has an input that spends the // specified output. -func txSpendsOutput(tx *chainjson.TxRawResult, txOut *externalTxOutput) bool { +func txSpendsOutput(tx *chainjson.TxRawResult, op outPoint) bool { for i := range tx.Vin { input := &tx.Vin[i] - if input.Vout == txOut.vout && input.Txid == txOut.txHash.String() { + if input.Vout == op.vout && input.Txid == op.txHash.String() { return true // found spender } } @@ -414,8 +454,8 @@ func (bf *blockFilter) MatchAny(data [][]byte) bool { return bf.v2cfilters.MatchAny(bf.key, data) } -func (dcr *ExchangeWallet) getBlockFilterV2(blockHash *chainhash.Hash) (*blockFilter, error) { - bf, key, err := dcr.wallet.BlockCFilter(dcr.ctx, blockHash) +func (dcr *ExchangeWallet) getBlockFilterV2(ctx context.Context, blockHash *chainhash.Hash) (*blockFilter, error) { + bf, key, err := dcr.wallet.BlockCFilter(ctx, blockHash) if err != nil { return nil, err } @@ -439,3 +479,15 @@ func (dcr *ExchangeWallet) getBlockFilterV2(blockHash *chainhash.Hash) (*blockFi key: bcf2Key, }, nil } + +func parseAmountAndScript(amount float64, pkScriptHex string) (int64, []byte, error) { + amt, err := dcrutil.NewAmount(amount) + if err != nil { + return 0, nil, fmt.Errorf("invalid amount: %v", err) + } + pkScript, err := hex.DecodeString(pkScriptHex) + if err != nil { + return 0, nil, fmt.Errorf("invalid pkScript: %v", err) + } + return int64(amt), pkScript, nil +} diff --git a/client/asset/interface.go b/client/asset/interface.go index 3d424263b0..479bac5ba7 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -16,6 +16,7 @@ import ( // exist and be unspent. const ( CoinNotFoundError = dex.ErrorKind("coin not found") + CoinIsSpentError = dex.ErrorKind("coin is spent") ErrRequestTimeout = dex.ErrorKind("request timeout") ErrConnectionDown = dex.ErrorKind("wallet not connected") ErrNotImplemented = dex.ErrorKind("not implemented") @@ -162,7 +163,7 @@ type Wallet interface { // // NOTE: For SPV wallets, a successful audit response is no gaurantee that // the txData provided was actually broadcasted to the blockchain. An error - // may have occured while trying to broadcast the txData or even if there + // may have occurred while trying to broadcast the txData or even if there // was no broadcast error, the tx might still not enter mempool or get mined // e.g. if the tx references invalid or already spent inputs. // From ad2e26cdbd8af91f2df27f334a3fc0436d25dceb Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Tue, 12 Oct 2021 12:16:36 +0100 Subject: [PATCH 11/22] more review fixes fix dcr harness spv wallets startup issues fix SwapConfirmations error for wallet contracts fix output spent check bug and repaired log messages --- client/asset/dcr/dcr.go | 21 ++++++++++++++++----- client/asset/dcr/externaltx.go | 25 +++++++++++++++---------- dex/testing/dcr/harness.sh | 10 +++++++--- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 6f966bd0c3..612010f8ed 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -2342,7 +2342,6 @@ func (dcr *ExchangeWallet) walletOutputConfirmations(ctx context.Context, op out // // If the swap was not funded by this wallet, and it is already spent, this // method may return asset.CoinNotFoundError. Compare dcr.externalTxOut. -// TODO: Could this method be called for wallet-funded swaps? // // If the coin is located, but recognized as spent, no error is returned. func (dcr *ExchangeWallet) SwapConfirmations(ctx context.Context, coinID, contract dex.Bytes, matchTime time.Time) (confs uint32, spent bool, err error) { @@ -2352,14 +2351,26 @@ func (dcr *ExchangeWallet) SwapConfirmations(ctx context.Context, coinID, contra } op := newOutPoint(txHash, vout) - // First attempt to find this contract in the wallet. TODO: Any need? + // First attempt to find this contract in the wallet. confs, spent, err = dcr.walletOutputConfirmations(ctx, op) - if err != nil && err != asset.CoinNotFoundError { + if err == nil || err != asset.CoinNotFoundError { + // nil err means success, non-nil err that is not CoinNotFoundError means trouble return confs, spent, err } - // Perform an external txout lookup. Prepare the pkScript to use in - // finding the txout using block filters. + // Perform an external txout lookup. + if !dcr.wallet.SpvMode() { + // Calling dcr.externalTxOut for non-spv wallets will only + // re-attempt dcr.unspentTxOut which was already done by + // dcr.walletOutputConfirmations above. + // TODO: It might still be necessary to call dcr.externalTxOut + // to use block filters to determine if the output exists but + // is spent, because dcr.unspentTxOut doesn't return results for + // non-wallet outputs that are spent. + return 0, false, asset.CoinNotFoundError + } + + // Prepare the pkScript to use in finding the txout using block filters. scriptAddr, err := stdaddr.NewAddressScriptHashV0(contract, dcr.chainParams) if err != nil { return 0, false, fmt.Errorf("error encoding script address: %w", err) diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index e3271349e4..32f18aba9c 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -385,7 +385,7 @@ func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, // during the search. func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, outputPkScript []byte, startBlock *block) (*chainjson.TxRawResult, *chainhash.Hash, error) { bestBlock := dcr.cachedBestBlock() - dcr.log.Debugf("Searching for the tx that spends output %s in blocks %d (%s) to %d (%s) using output pkScript %x.", + dcr.log.Debugf("Searching if output %s is spent in blocks %d (%s) to %d (%s) using pkScript %x.", op, startBlock.height, startBlock.hash, bestBlock.height, bestBlock.hash, outputPkScript) iHeight := startBlock.height @@ -397,8 +397,8 @@ func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, ou } if blockFilter.Match(outputPkScript) { - dcr.log.Debugf("Block %d (%s) likely contains a tx that spends %s. Confirming.", - iHeight, iHash, op) + dcr.log.Debugf("Output %s is likely spent in block %d (%s). Confirming.", + op, iHeight, iHash) blk, err := dcr.getBlock(ctx, iHash, true) if err != nil { return nil, iHash, err @@ -412,19 +412,21 @@ func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, ou return blkTx, iHash, nil } } - dcr.log.Debugf("Block %d (%s) does NOT contain a tx that spends %s.", iHeight, iHash, op) + dcr.log.Debugf("Output %s is NOT spent in block %d (%s).", op, iHeight, iHash) } // Block does not include the output spender, check the next block. - iHeight++ - nextHash, err := dcr.wallet.GetBlockHash(ctx, iHeight) - if err != nil { - return nil, iHash, translateRPCCancelErr(err) + if iHeight < bestBlock.height { + iHeight++ + nextHash, err := dcr.wallet.GetBlockHash(ctx, iHeight) + if err != nil { + return nil, iHash, translateRPCCancelErr(err) + } + iHash = nextHash } - iHash = nextHash } - dcr.log.Debugf("No spender tx found for %s in blocks %d (%s) to %d (%s).", + dcr.log.Debugf("Output %s is NOT spent in blocks %d (%s) to %d (%s).", op, startBlock.height, startBlock.hash, bestBlock.height, bestBlock.hash) return nil, bestBlock.hash, nil // scanned up to best block, no spender found } @@ -432,6 +434,9 @@ func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, ou // txSpendsOutput returns true if the passed tx has an input that spends the // specified output. func txSpendsOutput(tx *chainjson.TxRawResult, op outPoint) bool { + if tx.Txid == op.txHash.String() { + return false // no need to check inputs if this tx is the same tx that pays to the specified op + } for i := range tx.Vin { input := &tx.Vin[i] if input.Vout == op.vout && input.Txid == op.txHash.String() { diff --git a/dex/testing/dcr/harness.sh b/dex/testing/dcr/harness.sh index 353d725d4a..b2df4829ca 100755 --- a/dex/testing/dcr/harness.sh +++ b/dex/testing/dcr/harness.sh @@ -277,6 +277,11 @@ ENABLE_VOTING="2" # 2 = enable voting and ticket buyer ${ALPHA_WALLET_RPC_PORT} ${USE_SPV} ${ENABLE_VOTING} ${ALPHA_WALLET_HTTPPROF_PORT} # alpha uses walletpassphrase/walletlock. +# SPV wallets will declare peers stalled and disconnect with only ancient blocks +# from the archive, so we must mine a couple blocks first, but only now after the +# voting wallet (alpha) is running. +tmux send-keys -t $SESSION:0 "./mine-alpha 2${WAIT}" C-m\; wait-for donedcr + echo "Creating simnet beta wallet" USE_SPV="1" ENABLE_VOTING="0" @@ -300,9 +305,8 @@ sleep 15 # Give beta's "default" account a password, so it uses unlockaccount/lockaccount. tmux send-keys -t $SESSION:0 "./beta setaccountpassphrase default ${WALLET_PASS}${WAIT}" C-m\; wait-for donedcr -# Lock the wallet so we know we can function with just account unlocking. There -# is also a bug in dcrwallet that breaks validateaddress if the wallet is -# unlocked but not the account, so keep the wallet locked. + +# Lock the wallet so we know we can function with just account unlocking. tmux send-keys -t $SESSION:0 "./beta walletlock${WAIT}" C-m\; wait-for donedcr # Create fee account on alpha wallet for use by dcrdex simnet instances. From 742432eadf9fddb08e638b6acbcf65592c9363dd Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Tue, 12 Oct 2021 13:33:41 +0100 Subject: [PATCH 12/22] pass feeratesuggestion to PayFee, fix txoutspender loop --- client/asset/dcr/dcr.go | 20 +++++++++++++------- client/asset/dcr/externaltx.go | 18 ++++++++++-------- client/asset/interface.go | 4 ++-- client/core/core.go | 4 ++-- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 612010f8ed..de39aa20c5 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -617,10 +617,16 @@ func (dcr *ExchangeWallet) feeRate(confTarget uint64) (uint64, error) { // feeRateWithFallback attempts to get the optimal fee rate in atoms / byte via // FeeRate. If that fails, it will return the configured fallback fee rate. func (dcr *ExchangeWallet) feeRateWithFallback(confTarget, feeSuggestion uint64) uint64 { - feeRate, err := dcr.feeRate(confTarget) - if err == nil { - dcr.log.Tracef("Obtained local estimate for %d-conf fee rate, %d", confTarget, feeRate) - return feeRate + var err error + if !dcr.wallet.SpvMode() { + var feeRate uint64 + feeRate, err = dcr.feeRate(confTarget) + if err == nil { + dcr.log.Tracef("Obtained local estimate for %d-conf fee rate, %d", confTarget, feeRate) + return feeRate + } + } else { + err = errors.New("SPV does not support estimatesmartfee") } if feeSuggestion > 0 && feeSuggestion < dcr.fallbackFeeRate && feeSuggestion < dcr.feeRateLimit { dcr.log.Tracef("feeRateWithFallback using caller's suggestion for %d-conf fee rate, %d. Local estimate unavailable (%q)", @@ -2351,10 +2357,10 @@ func (dcr *ExchangeWallet) SwapConfirmations(ctx context.Context, coinID, contra } op := newOutPoint(txHash, vout) - // First attempt to find this contract in the wallet. + // First attempt to find this contract in the wallet. Only continue if + // err is CoinNotFoundError. confs, spent, err = dcr.walletOutputConfirmations(ctx, op) - if err == nil || err != asset.CoinNotFoundError { - // nil err means success, non-nil err that is not CoinNotFoundError means trouble + if err != asset.CoinNotFoundError { return confs, spent, err } diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index 32f18aba9c..7221b7e558 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -390,7 +390,7 @@ func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, ou iHeight := startBlock.height iHash := startBlock.hash - for iHeight <= bestBlock.height { + for { blockFilter, err := dcr.getBlockFilterV2(ctx, iHash) if err != nil { return nil, nil, err @@ -415,15 +415,17 @@ func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, ou dcr.log.Debugf("Output %s is NOT spent in block %d (%s).", op, iHeight, iHash) } + if iHeight >= bestBlock.height { // reached the tip, stop searching + break + } + // Block does not include the output spender, check the next block. - if iHeight < bestBlock.height { - iHeight++ - nextHash, err := dcr.wallet.GetBlockHash(ctx, iHeight) - if err != nil { - return nil, iHash, translateRPCCancelErr(err) - } - iHash = nextHash + iHeight++ + nextHash, err := dcr.wallet.GetBlockHash(ctx, iHeight) + if err != nil { + return nil, iHash, translateRPCCancelErr(err) } + iHash = nextHash } dcr.log.Debugf("Output %s is NOT spent in blocks %d (%s) to %d (%s).", diff --git a/client/asset/interface.go b/client/asset/interface.go index 479bac5ba7..b7781a96c1 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -214,8 +214,8 @@ type Wallet interface { // Locked will be true if the wallet is currently locked. Locked() bool // PayFee sends the dex registration fee. Transaction fees are in addition to - // the registration fee, and the fee rate is taken from the DEX configuration. - PayFee(address string, regFee, feeRateSuggestion uint64) (Coin, error) + // the registration fee, and the feeRateSuggestion is gotten from the server. + PayFee(address string, feeAmt, feeRateSuggestion uint64) (Coin, error) // SwapConfirmations gets the number of confirmations and the spend status // for the specified swap. If the swap was not funded by this wallet, and // it is already spent, you may see CoinNotFoundError. diff --git a/client/core/core.go b/client/core/core.go index 80464c6675..42ce2b649e 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -2725,8 +2725,8 @@ func (c *Core) Register(form *RegisterForm) (*RegisterResult, error) { c.log.Infof("Attempting registration fee payment to %s, account ID %v, of %d units of %s. "+ "Do NOT manually send funds to this address even if this fails.", regRes.Address, dc.acct.id, regRes.Fee, regFeeAssetSymbol) - - coin, err := wallet.PayFee(regRes.Address, regRes.Fee, dc.fetchFeeRate(feeAsset.ID)) + feeRateSuggestion := dc.fetchFeeRate(feeAsset.ID) + coin, err := wallet.PayFee(regRes.Address, regRes.Fee, feeRateSuggestion) if err != nil { return nil, newError(feeSendErr, "error paying registration fee: %v", err) } From 19fd34a0168e56d13612a349bf8754301be2bf99 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Fri, 22 Oct 2021 14:18:01 +0100 Subject: [PATCH 13/22] use wallet+external output lookup for audit, refund and swapconfs --- client/asset/dcr/dcr.go | 330 +++++++++++++++++-------- client/asset/dcr/dcr_test.go | 108 ++++++--- client/asset/dcr/externaltx.go | 430 +++++++++++++++++---------------- client/asset/dcr/rpcwallet.go | 38 ++- client/asset/dcr/wallet.go | 9 +- 5 files changed, 561 insertions(+), 354 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index de39aa20c5..7f862066af 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1174,7 +1174,7 @@ func (dcr *ExchangeWallet) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { if !notFound[pt] { continue } - txOut, err := dcr.wallet.GetTxOut(dcr.ctx, txHash, output.Vout, output.Tree, true) + txOut, _, err := dcr.wallet.GetTxOut(dcr.ctx, txHash, output.Vout, output.Tree, true) if err != nil { return nil, fmt.Errorf("gettxout error for locked output %v: %w", pt.String(), err) } @@ -1460,8 +1460,10 @@ func (dcr *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, addr = fCoin.addr } else { // Check if we can get the address from gettxout. - txOut, err := dcr.wallet.GetTxOut(dcr.ctx, op.txHash(), op.vout(), op.tree, true) - if err == nil && txOut != nil { + txOut, _, err := dcr.wallet.GetTxOut(dcr.ctx, op.txHash(), op.vout(), op.tree, true) + if err != nil { + dcr.log.Errorf("gettxout error for SignMessage coin %s: %v", op, err) + } else if txOut != nil { addrs := txOut.ScriptPubKey.Addresses if len(addrs) != 1 { // TODO: SignMessage is usually called for coins selected by @@ -1524,25 +1526,24 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t return nil, fmt.Errorf("error extracting swap addresses: %w", err) } - var contractOutput *wire.TxOut + // contractTx will be set using txData if the contract is not found + // on the blockchain. + var contractTx *wire.MsgTx + + // contractTxOut and txTree are set using data from the blockchain + // or from txData/contractTx. + var contractTxOut *wire.TxOut var txTree int8 - // First try to pull the contract output details from the blockchain, if - // possible. SPV wallets do not need to try to look for the contract in - // a block yet. SwapConfirmations handles that. - tryFindTx := false + // First try to pull the contract output details from the blockchain, + // if possible. Do not attempt finding the coin using block filters, + // SwapConfirmations handles that. So pass nil pkScript to the lookup + // method. op := newOutPoint(txHash, vout) - contractOutput, _, spent, txTree, err := dcr.externalTxOut(dcr.ctx, op, tryFindTx, nil, time.Time{}) + contractOutput, spendStatus, err := dcr.lookupTxOutput(dcr.ctx, op, true, nil, time.Time{}) if err != nil && err != asset.CoinNotFoundError { return nil, err - } - if spent { - return nil, dex.NewError(asset.CoinIsSpentError, op.String()) - } - - var contractTx *wire.MsgTx - contractFoundInBlockchain := contractOutput != nil - if !contractFoundInBlockchain { + } else if err == asset.CoinNotFoundError { // Contract not found on the blockchain. Assume the tx has not been // broadcasted. Pull the contract output details from the provided // txData to validate. The txData will be broadcasted below if the @@ -1560,18 +1561,37 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t if int(vout) >= len(contractTx.TxOut) { return nil, fmt.Errorf("invalid contract tx data: no output at %d", vout) } - contractOutput = contractTx.TxOut[vout] + contractTxOut = contractTx.TxOut[vout] txTree = determineTxTree(contractTx) + } else { + // Contract found on the blockchain. Let's audit that. + spent := spendStatus == outputSpendStatusSpent + if spendStatus == outputSpendStatusUnknown { + dcr.log.Infof("Spend status for counter-party contract %s is unknown, assuming unspent.", op) + } + if spent { + return nil, dex.NewError(asset.CoinIsSpentError, op.String()) + } + amt, err := contractOutput.Amount() + if err != nil { + return nil, err + } + pkScript, err := contractOutput.PkScript() + if err != nil { + return nil, err + } + contractTxOut = newTxOut(int64(amt), contractOutput.scriptVersion, pkScript) + txTree = contractOutput.tree } // Validate the contract output script gotten from the blockchain or the // provided txData. - if err = dcr.validateContractOutput(contractOutput, contract); err != nil { + if err = dcr.validateContractOutput(contractTxOut, contract); err != nil { return nil, err } auditInfo := &asset.AuditInfo{ - Coin: newOutput(txHash, vout, uint64(contractOutput.Value), txTree), + Coin: newOutput(txHash, vout, uint64(contractTxOut.Value), txTree), Contract: contract, SecretHash: secretHash, Recipient: receiver.String(), @@ -1579,7 +1599,7 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t } // No need to broadcast txData if we found the contract in the blockchain earlier. - if contractFoundInBlockchain { + if contractTx == nil { dcr.log.Infof("Audited contract coin %s:%d using tx data gotten from the blockchain. SPV mode = %t", txHash, vout, dcr.wallet.SpvMode()) return auditInfo, nil @@ -1687,6 +1707,162 @@ func determineTxTree(msgTx *wire.MsgTx) int8 { return wire.TxTreeRegular } +func (dcr *ExchangeWallet) lookupTxOutput(ctx context.Context, op outPoint, mempool bool, pkScript []byte, earliestTxTime time.Time) (*txOutput, outputSpendStatus, error) { + errorOut := func(err error) (*txOutput, outputSpendStatus, error) { + return nil, outputSpendStatusUnknown, err + } + + // First perform wallet lookup, may be able to find the output + // even if it doesn't pay to the wallet. + txOut, spendStatus, err := dcr.checkWalletForTxOutput(ctx, op, mempool) + if err != nil && err != asset.CoinNotFoundError { + // Do not return CoinNotFoundError yet, check cache first. + return errorOut(err) + } + + if txOut != nil { + // Output is found in wallet. Is the spend status known? + if spendStatus != outputSpendStatusUnknown { + dcr.log.Debugf("Found output %s in wallet, spent %t", op, spendStatus == outputSpendStatusSpent) + return txOut, spendStatus, nil + } else if len(pkScript) == 0 { + dcr.log.Debugf("Found output %s in wallet, but spend status cannot be determined", op) + return txOut, spendStatus, nil + } + } + + // If the output was not found by the wallet, check the externalTx + // cache and if also not found, scan block filters to find the output. + var eTxOut *externalTxOutput + if txOut == nil { + dcr.log.Debugf("Output %s NOT found in wallet, checking cache/block filters", op) + eTxOut, err = dcr.lookupTxOutWithBlockFilters(ctx, op, pkScript, earliestTxTime) + if err != nil { + return errorOut(err) + } + } else { + dcr.log.Debugf("Found output %s in wallet, will determine spend status using block filters", op) + // TODO: If it is found in wallet, just assume unspent? + eTxOut = &externalTxOutput{ + txOutput: txOut, + } + } + + // If we have the output details from the wallet lookup or + // the block filters scan above, let's check if it is spent. + spendStatus, err = dcr.checkOutputSpendStatus(ctx, eTxOut) + return eTxOut.txOutput, spendStatus, err +} + +// checkWalletForTxOutput attempts to find and return details for the specified +// tx output. Returns asset.CoinNotFoundError if the output is not found. +// NOTE: SPV wallets are only able to lookup outputs for transactions that are +// tracked by the wallet. +func (dcr *ExchangeWallet) checkWalletForTxOutput(ctx context.Context, op outPoint, mempool bool) (*txOutput, outputSpendStatus, error) { + errorOut := func(err error) (*txOutput, outputSpendStatus, error) { + return nil, outputSpendStatusUnknown, err + } + + // First use wallet.GetTxOut to look up the output. + unspentTxOut, txTree, err := dcr.wallet.GetTxOut(ctx, &op.txHash, op.vout, wire.TxTreeUnknown, mempool) + if err != nil { + if err != asset.CoinNotFoundError { + return errorOut(err) + } + } else { + output := &txOutput{ + op: op, + tree: txTree, + value: unspentTxOut.Value, + scriptVersion: unspentTxOut.ScriptPubKey.Version, + scriptHex: unspentTxOut.ScriptPubKey.Hex, + } + if unspentTxOut.Confirmations > 0 { + tipHash, err := chainhash.NewHashFromStr(unspentTxOut.BestBlock) + if err != nil { + return errorOut(fmt.Errorf("invalid bestblock hash in gettxout response: %v", err)) + } + tip, err := dcr.wallet.GetBlockHeaderVerbose(ctx, tipHash) + if err != nil { + return errorOut(fmt.Errorf("invalid bestblock in gettxout response: %w", err)) + } + if unspentTxOut.Confirmations == 1 { + output.blockHash, output.blockHeight = tipHash, int64(tip.Height) + } else { + output.blockHeight = int64(tip.Height) - unspentTxOut.Confirmations + 1 + output.blockHash, err = dcr.wallet.GetBlockHash(ctx, output.blockHeight) + if err != nil { + return errorOut(fmt.Errorf("invalid block for confirmed gettxout response, confs %d, bestblock %s: %w", + unspentTxOut.Confirmations, unspentTxOut.BestBlock, err)) + } + } + } + return output, outputSpendStatusUnspent, nil + } + + // Output not found with wallet.UnspentOutput, check wallet txs. + tx, err := dcr.wallet.GetTransaction(ctx, &op.txHash) + if err != nil { + return errorOut(err) + } + msgTx, err := msgTxFromHex(tx.Hex) + if err != nil { + return errorOut(fmt.Errorf("invalid tx hex: %v", err)) + } + if int(op.vout) >= len(msgTx.TxOut) { + return errorOut(fmt.Errorf("tx %s has no output at %d", &op.txHash, op.vout)) + } + + // Output found amongst wallet transactions. Get the requested details. + txTree = determineTxTree(msgTx) + txOut := msgTx.TxOut[op.vout] + output := &txOutput{ + op: op, + tree: txTree, + value: dcrutil.Amount(txOut.Value).ToCoin(), + scriptVersion: txOut.Version, + scriptHex: hex.EncodeToString(txOut.PkScript), + } + if tx.Confirmations == 0 { + // Only counts as spent if spent in a mined transaction, + // unconfirmed tx outputs can't be spent in a mined tx. + return output, outputSpendStatusUnspent, nil + } + + txBlockHash, err := chainhash.NewHashFromStr(tx.BlockHash) + if err != nil { + return nil, 0, fmt.Errorf("invalid tx block hash: %v", err) + } + txBlock, err := dcr.wallet.GetBlockHeaderVerbose(ctx, txBlockHash) + if err != nil { + return nil, 0, fmt.Errorf("invalid tx block: %w", err) + } + output.blockHash, output.blockHeight = txBlockHash, int64(txBlock.Height) + + // Infer output spend status. For full node wallets, finding an output + // via gettransaction but not via wallet.GetTxOut means the output is + // spent. + spvMode := dcr.wallet.SpvMode() + if !spvMode { + dcr.log.Debugf("Output %s found by gettransaction but not by gettxout is considered SPENT. SPV mode = %t", op, spvMode) + return output, outputSpendStatusSpent, nil + } + + // For SPV wallets, the output also has to pay to the wallet to correctly + // infer that it is spent, since SPV wallets do not track spend status for + // external outputs, even if the wallet tracks the tx. + for _, details := range tx.Details { + if details.Vout == op.vout { + dcr.log.Debugf("Output %s found by gettransaction but not by gettxout is considered SPENT. SPV mode = %t", op, spvMode) + return output, outputSpendStatusSpent, nil + } + } + + // SPV wallets do not track spend status for outputs that don't pay to + // the wallet. + return output, outputSpendStatusUnknown, nil +} + // RefundAddress extracts and returns the refund address from a contract. func (dcr *ExchangeWallet) RefundAddress(contract dex.Bytes) (string, error) { sender, _, _, _, err := dexdcr.ExtractSwapDetails(contract, dcr.chainParams) @@ -2126,14 +2302,14 @@ func (dcr *ExchangeWallet) refundTx(coinID, contract dex.Bytes, val uint64, refu } // Grab the unspent output to make sure it's good and to get the value if not supplied. if val == 0 { - utxo, err := dcr.wallet.GetTxOut(dcr.ctx, txHash, vout, wire.TxTreeRegular, true) + utxo, _, err := dcr.lookupTxOutput(dcr.ctx, newOutPoint(txHash, vout), true, nil, time.Time{}) if err != nil { return nil, fmt.Errorf("error finding unspent contract: %w", err) } if utxo == nil { return nil, asset.CoinNotFoundError } - val = toAtoms(utxo.Value) + val = toAtoms(utxo.value) } sender, _, lockTime, _, err := dexdcr.ExtractSwapDetails(contract, dcr.chainParams) if err != nil { @@ -2304,44 +2480,6 @@ func (dcr *ExchangeWallet) ValidateSecret(secret, secretHash []byte) bool { return bytes.Equal(h[:], secretHash) } -// walletOutputConfirmations gets the number of confirmations for the specified -// outPoint by first checking for an unspent output, and if not found, searching -// indexed wallet transactions. -// This method is only guaranteed to return results for wallet outputs. For -// non-wallet outputs, use dcr.externalTxOut. -func (dcr *ExchangeWallet) walletOutputConfirmations(ctx context.Context, op outPoint) (confs uint32, spent bool, err error) { - txHash, vout := &op.txHash, op.vout - // Check for an unspent output. - txOut, _, err := dcr.unspentTxOut(ctx, txHash, vout, true) - if err != nil { - return 0, false, fmt.Errorf("gettxout error for %s:%d: %w", txHash, vout, translateRPCCancelErr(err)) - } - if txOut != nil { - return uint32(txOut.Confirmations), false, nil - } - // Unspent output not found. Check if this tx is indexed by the - // wallet and has an output at `vout`. - tx, err := dcr.wallet.GetTransaction(ctx, txHash) - if err == nil { - // Tx found, check if it contains the requested output. - msgTx, err := msgTxFromHex(tx.Hex) - if err != nil { - return 0, false, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) - } - if len(msgTx.TxOut) <= int(vout) { - return 0, false, fmt.Errorf("tx %s has no output at index %d", txHash, vout) - } - // Tx found and contains the requested output. The output - // must be spent since it is known by the wallet but gettxout - // returned a nil result for it. - return uint32(tx.Confirmations), true, nil - } - if !isTxNotFoundErr(err) { - return 0, false, translateRPCCancelErr(err) - } - return 0, false, asset.CoinNotFoundError -} - // SwapConfirmations gets the number of confirmations and the spend status for // the specified swap. The contract and matchTime are provided so that wallets // may search for the coin using light filters. @@ -2357,44 +2495,42 @@ func (dcr *ExchangeWallet) SwapConfirmations(ctx context.Context, coinID, contra } op := newOutPoint(txHash, vout) - // First attempt to find this contract in the wallet. Only continue if - // err is CoinNotFoundError. - confs, spent, err = dcr.walletOutputConfirmations(ctx, op) - if err != asset.CoinNotFoundError { - return confs, spent, err - } - - // Perform an external txout lookup. - if !dcr.wallet.SpvMode() { - // Calling dcr.externalTxOut for non-spv wallets will only - // re-attempt dcr.unspentTxOut which was already done by - // dcr.walletOutputConfirmations above. - // TODO: It might still be necessary to call dcr.externalTxOut - // to use block filters to determine if the output exists but - // is spent, because dcr.unspentTxOut doesn't return results for - // non-wallet outputs that are spent. - return 0, false, asset.CoinNotFoundError - } - - // Prepare the pkScript to use in finding the txout using block filters. + // May need the pkScript to find the txout (or its spender) using block filters. scriptAddr, err := stdaddr.NewAddressScriptHashV0(contract, dcr.chainParams) if err != nil { return 0, false, fmt.Errorf("error encoding script address: %w", err) } _, p2shScript := scriptAddr.PaymentScript() - _, confs, spent, _, err = dcr.externalTxOut(ctx, op, true, p2shScript, matchTime) - return confs, spent, err + + output, spendStatus, err := dcr.lookupTxOutput(ctx, op, true, p2shScript, matchTime) + if err != nil { + return 0, false, err + } + if spendStatus == outputSpendStatusUnknown { + dcr.log.Infof("Spend status for swap output %s is unknown, assuming unspent.", op) + } + + confs = confirms(dcr.cachedBestBlock().height, output.blockHeight) + spent = spendStatus == outputSpendStatusSpent + return confs, spent, nil +} + +func confirms(bestBlock, blockHeight int64) uint32 { + return uint32(bestBlock - blockHeight + 1) } // RegFeeConfirmations gets the number of confirmations for the specified // output. func (dcr *ExchangeWallet) RegFeeConfirmations(ctx context.Context, coinID dex.Bytes) (confs uint32, err error) { - txHash, vout, err := decodeCoinID(coinID) + txHash, _, err := decodeCoinID(coinID) if err != nil { return 0, err } - confs, _, err = dcr.walletOutputConfirmations(ctx, newOutPoint(txHash, vout)) - return confs, err + tx, err := dcr.wallet.GetTransaction(ctx, txHash) + if err != nil { + return 0, err + } + return uint32(tx.Confirmations), nil } // addInputCoins adds inputs to the MsgTx to spend the specified outputs. @@ -2495,27 +2631,7 @@ func (dcr *ExchangeWallet) lockedAtoms() (uint64, error) { return sum, nil } -// unspentTxOut returns details for the specified tx output if it exists and is -// not spent by a mined transaction. Also returns the transaction tree where the -// output is found. Returns nil error and nil result if the output is not found. -// -// This method should ideally only be used to look up outputs that are indexed -// by the wallet even though it might be able to return details for a non-wallet -// output (if the wallet is connected to a full node). Use dcr.externalTxOut for -// non-wallet outputs. -func (dcr *ExchangeWallet) unspentTxOut(ctx context.Context, txHash *chainhash.Hash, index uint32, mempool bool) (*chainjson.GetTxOutResult, int8, error) { - tree := wire.TxTreeRegular - txout, err := dcr.wallet.GetTxOut(ctx, txHash, index, tree, mempool) // check regular tree first - if err == nil && txout == nil { - tree = wire.TxTreeStake - txout, err = dcr.wallet.GetTxOut(dcr.ctx, txHash, index, tree, mempool) // check stake tree - } - return txout, tree, err -} - // convertCoin converts the asset.Coin to an unspent output. -// In SPV mode, this method may return asset.CoinNotFoundError for an existing, -// unspent output if the provided asset.Coin is not indexed by the wallet. func (dcr *ExchangeWallet) convertCoin(coin asset.Coin) (*output, error) { op, _ := coin.(*output) if op != nil { @@ -2525,7 +2641,7 @@ func (dcr *ExchangeWallet) convertCoin(coin asset.Coin) (*output, error) { if err != nil { return nil, err } - txOut, tree, err := dcr.unspentTxOut(dcr.ctx, txHash, vout, true) + txOut, tree, err := dcr.wallet.GetTxOut(dcr.ctx, txHash, vout, wire.TxTreeUnknown, true) if err != nil { return nil, fmt.Errorf("error finding unspent output %s:%d: %w", txHash, vout, err) } diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 3af3e9f722..f9faa180c4 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -162,6 +162,7 @@ func tNewWallet() (*ExchangeWallet, *tRPCClient, func(), error) { } wallet.wallet = &rpcWallet{ rpcClient: client, + log: tLogger.SubLogger("trpc"), } wallet.ctx = walletCtx @@ -287,6 +288,13 @@ func (blockchain *tBlockchain) blockAt(height int64) (*chainhash.Hash, *chainjso blockchain.verboseBlocks[newBlock.Hash] = newBlock blockchain.verboseBlocks[prevBlockHash].NextHash = newBlock.Hash + blockchain.verboseBlockHeaders[newBlock.Hash] = &chainjson.GetBlockHeaderVerboseResult{ + Height: uint32(height), + Hash: newBlock.Hash, + PreviousHash: prevBlockHash, + } + blockchain.verboseBlockHeaders[prevBlockHash].NextHash = newBlock.Hash + return &newBlockHash, newBlock } @@ -1576,16 +1584,6 @@ func TestSignMessage(t *testing.T) { signature := ecdsa.Sign(privKey, msg) sig := signature.Serialize() - node.walletTx = &walletjson.GetTransactionResult{ - Details: []walletjson.GetTransactionDetailsResult{ - { - Address: tPKHAddr.String(), - Category: txCatReceive, - Vout: vout, - }, - }, - } - node.privWIF, err = dcrutil.NewWIF(privBytes, tChainParams.PrivateKeyID, dcrec.STEcdsaSecp256k1) if err != nil { t.Fatalf("NewWIF error: %v", err) @@ -1623,7 +1621,7 @@ func TestSignMessage(t *testing.T) { node.txOutRes[newOutPoint(tTxHash, vout)] = txOut check() - // gettransaction error + // gettxout error node.txOutErr = tErr _, _, err = wallet.SignMessage(op, msg) if err == nil { @@ -1924,9 +1922,16 @@ func TestRefund(t *testing.T) { } const feeSuggestion = 100 - bigTxOut := makeGetTxOutRes(2, 5, nil) + tipHash, tipHeight := node.getBestBlock() + var confs int64 = 1 + if tipHeight > 1 { + confs = 2 + } + + bigTxOut := makeGetTxOutRes(confs, 5, nil) bigOutID := newOutPoint(tTxHash, 0) node.txOutRes[bigOutID] = bigTxOut + node.txOutRes[bigOutID].BestBlock = tipHash.String() // required to calculate the block for the output node.changeAddr = tPKHAddr node.newAddr = tPKHAddr @@ -2176,7 +2181,7 @@ func Test_sendMinusFees(t *testing.T) { } } -func TestCoinConfirmations(t *testing.T) { +func TestLookupTxOutput(t *testing.T) { wallet, node, shutdown, err := tNewWallet() defer shutdown() if err != nil { @@ -2189,41 +2194,55 @@ func TestCoinConfirmations(t *testing.T) { // Bad output coin op.vout = 10 - _, spent, err := wallet.walletOutputConfirmations(context.Background(), op) + _, spendStatus, err := wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) if err == nil { t.Fatalf("no error for bad output coin") } - if spent { + if spendStatus == outputSpendStatusSpent { t.Fatalf("spent is true for bad output coin") } op.vout = 0 + // Build the blockchain with some dummy blocks. + rand.Seed(time.Now().Unix()) + for i := 0; i < rand.Intn(100); i++ { + node.blockchain.blockAt(int64(i)) + } + tipHash, tipHeight := node.getBestBlock() + outputHeight := tipHeight - 1 // i.e. 2 confirmations + outputBlockHash, _ := node.blockchain.blockAt(outputHeight) + + // Add the txOutRes with 2 confs and BestBlock correctly set. node.txOutRes[op] = makeGetTxOutRes(2, 1, tP2PKHScript) - confs, spent, err := wallet.walletOutputConfirmations(context.Background(), op) + node.txOutRes[op].BestBlock = tipHash.String() + txOut, spendStatus, err := wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) if err != nil { - t.Fatalf("error for gettransaction path: %v", err) + t.Fatalf("unexpected lookupTxOutput error: %v", err) + } + if txOut.blockHeight != outputHeight { + t.Fatalf("output block height not retrieved from gettxout path. expected %d, got %d", outputHeight, txOut.blockHeight) } - if confs != 2 { - t.Fatalf("confs not retrieved from gettxout path. expected 2, got %d", confs) + if confirms(tipHeight, txOut.blockHeight) != 2 { + t.Fatalf("confs not retrieved from gettxout path. expected 2, got %d", confirms(tipHeight, txOut.blockHeight)) } - if spent { + if spendStatus == outputSpendStatusSpent { t.Fatalf("expected spent = false for gettxout path, got true") } // gettransaction error delete(node.txOutRes, op) node.walletTxErr = tErr - _, spent, err = wallet.walletOutputConfirmations(context.Background(), op) + _, spendStatus, err = wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) if err == nil { t.Fatalf("no error for gettransaction error") } - if spent { + if spendStatus == outputSpendStatusSpent { t.Fatalf("spent is true with gettransaction error") } node.walletTxErr = nil - // wallet.Confirmations will check if the tx hex is valid - // and contains an output at index 0, for the output to be + // wallet.lookupTxOutput will check if the tx is confirmed, its hex + // is valid and contains an output at index 0, for the output to be // considered spent. tx := wire.NewMsgTx() tx.AddTxIn(&wire.TxIn{}) @@ -2233,15 +2252,50 @@ func TestCoinConfirmations(t *testing.T) { t.Fatalf("error preparing tx hex with 1 output: %v", err) } node.walletTx = &walletjson.GetTransactionResult{ - Hex: txHex, + Hex: txHex, + Confirmations: 0, // unconfirmed = unspent } - _, spent, err = wallet.walletOutputConfirmations(context.Background(), op) + _, spendStatus, err = wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) if err != nil { t.Fatalf("coin error: %v", err) } - if !spent { + if spendStatus != outputSpendStatusUnspent { + t.Fatalf("expected spent = false for gettransaction path, got true") + } + + // Confirmed wallet tx without gettxout response is spent. + node.walletTx.Confirmations = 2 + node.walletTx.BlockHash = outputBlockHash.String() + _, spendStatus, err = wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) + if err != nil { + t.Fatalf("coin error: %v", err) + } + if spendStatus != outputSpendStatusSpent { t.Fatalf("expected spent = true for gettransaction path, got false") } + + // In spv mode, spend status is unknown unless the output pays to the + // wallet (then it's considered be considered spent). + (wallet.wallet.(*rpcWallet)).spvMode = true + _, spendStatus, err = wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) + if err != nil { + t.Fatalf("coin error: %v", err) + } + if spendStatus != outputSpendStatusUnknown { + t.Fatalf("expected spent = unknown for spv gettransaction path, got %t", spendStatus == outputSpendStatusSpent) + } + + // In spv mode, the output must pay to the wallet be considered spent. + node.walletTx.Details = []walletjson.GetTransactionDetailsResult{{ + Vout: 0, + }} + _, spendStatus, err = wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) + if err != nil { + t.Fatalf("coin error: %v", err) + } + if spendStatus != outputSpendStatusSpent { + t.Fatalf("expected spent = true for spv gettransaction path, got false") + } } func TestSendEdges(t *testing.T) { diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index 7221b7e558..99f9194e57 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -16,7 +16,14 @@ import ( "github.com/decred/dcrd/gcs/v3" "github.com/decred/dcrd/gcs/v3/blockcf2" chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v3" - "github.com/decred/dcrd/wire" +) + +type outputSpendStatus uint8 + +const ( + outputSpendStatusUnknown outputSpendStatus = iota + outputSpendStatusSpent + outputSpendStatusUnspent ) type externalTx struct { @@ -36,10 +43,7 @@ type externalTx struct { } type externalTxOutput struct { - outPoint - value float64 - pkScriptHex string - pkScriptVersion uint16 + *txOutput // The spenderMtx protects access to the fields below // because they are set when the block containing the tx @@ -50,22 +54,50 @@ type externalTxOutput struct { spenderBlock *block } +// txOutput defines properties of a transaction output, including the +// details of the block containing the tx, if mined. +type txOutput struct { + op outPoint + tree int8 + value float64 + scriptVersion uint16 + scriptHex string + blockHash *chainhash.Hash + blockHeight int64 +} + +func (txOut *txOutput) PkScript() ([]byte, error) { + pkScript, err := hex.DecodeString(txOut.scriptHex) + if err != nil { + return nil, fmt.Errorf("invalid pkScript: %v", err) + } + return pkScript, nil +} + +func (txOut *txOutput) Amount() (dcrutil.Amount, error) { + amt, err := dcrutil.NewAmount(txOut.value) + if err != nil { + return 0, fmt.Errorf("invalid amount: %v", err) + } + return amt, nil +} + // externalTx returns details for the provided hash, if cached. If the tx cache // doesn't yet exist and addToCache is true, the provided script will be cached -// against the tx hash to enable SPV wallets locate the tx in a block when it is -// mined. Once mined, the block containing the tx and the tx outputs details are -// also cached, to enable subsequently checking if any of the tx's output is -// spent in a mined transaction. +// against the tx hash so block filters can be used to locate the tx in a block +// when it is mined. Once mined, the block containing the tx and the tx outputs +// details are also cached, to enable subsequently checking if any of the tx's +// output is spent in a mined transaction. // // This method should only be used with transactions that are NOT indexed by the // wallet such as counter-party swaps. -func (dcr *ExchangeWallet) externalTx(hash *chainhash.Hash, pkScript []byte, addToCache bool) *externalTx { +func (dcr *ExchangeWallet) externalTx(hash *chainhash.Hash, pkScript []byte) *externalTx { dcr.externalTxMtx.Lock() defer dcr.externalTxMtx.Unlock() tx := dcr.externalTxCache[*hash] - if tx == nil && addToCache && len(pkScript) > 0 { - tx := &externalTx{ + if tx == nil && len(pkScript) > 0 { + tx = &externalTx{ hash: hash, pkScripts: [][]byte{pkScript}, } @@ -77,164 +109,48 @@ func (dcr *ExchangeWallet) externalTx(hash *chainhash.Hash, pkScript []byte, add return tx } -// externalTxOut returns details for the specified transaction output, along -// with the confirmations, spend status and tx tree. If the tx details are not -// currently cached, a search will be conducted to attempt finding the tx in a -// mainchain block, unless tryFindTx is false. asset.CoinNotFoundError is -// returned if the output cannot be found or (for full node wallets), if the -// output is spent. +// lookupTxOutWithBlockFilters returns details for the specified transaction +// output if cached or if found via a block filters scan. If the pkScript is +// not provided, details will only be returned if cached and if the block that +// was found to contain the output is still part of the mainchain. If a block +// filters scan is conducted and the output is found in a mainchain block, its +// details is cached and returned. +// Returns asset.CoinNotFoundError if the requested output details is not cached +// and (if pkScript is provided), if the tx is not found in a block between the +// current best block and the block just before the provided earliestTxTime. // -// This method should only be used for transactions that are NOT indexed by the -// wallet. For wallet transactions, use dcr.walletOutputConfirmations. -// -// NOTE: SPV wallets are unable to look up unmined transaction outputs. Also, -// the `tryFindTx`, `pkScript` and `earliestTxTime` parameters are irrelevant -// for full node wallets, but required for SPV wallets if the caller intends to -// perform a search for the tx in a mainchain block. -func (dcr *ExchangeWallet) externalTxOut(ctx context.Context, op outPoint, tryFindTx bool, pkScript []byte, earliestTxTime time.Time) ( - *wire.TxOut, uint32, bool, int8, error) { - - if !dcr.wallet.SpvMode() { - // Use the gettxout rpc to look up the requested tx output. Unlike - // SPV wallets, full node wallets are able to look up outputs for - // all transactions whether or not they are indexed by the wallet, - // including outputs in mempool. - output, txTree, err := dcr.unspentTxOut(ctx, &op.txHash, op.vout, true) - if err != nil { - return nil, 0, false, 0, fmt.Errorf("error finding unspent output %s: %w", op, translateRPCCancelErr(err)) - } - if output == nil { - // Output does not exist or has been spent. - // We can try to look up the tx to determine if the output exists - // and return a 'output is spent' error, but the getrawtransaction - // rpc requires txindex to be enabled which may not be enabled by - // the client. We also can't use the gettransaction rpc because - // this method is particularly designed to work with txs that are - // NOT indexed by the wallet and gettransaction only returns data - // for wallet txs. - // - // TODO: Attempt finding the tx using block filters. If the tx is - // found, then we can assert that the output is spent instead of - // returning asset.CoinNotFoundError. - return nil, 0, false, 0, asset.CoinNotFoundError - } - amt, outputPkScript, err := parseAmountAndScript(output.Value, output.ScriptPubKey.Hex) - if err != nil { - return nil, 0, false, 0, fmt.Errorf("error parsing tx output %s: %v", op, err) - } - return newTxOut(amt, output.ScriptPubKey.Version, outputPkScript), uint32(output.Confirmations), false, txTree, nil - } - - // This is an SPV wallet. First try to determine if the tx has been mined - // before checking if the specified output is spent. This will require - // scanning block filters to try to locate the tx, if the tx's block is - // not already known or the previously found tx block is no longer part of - // the mainchain. If tryFindTx is false however, do NOT scan scan block - // filters; instead, return asset.CoinNotFoundError. - - tx := dcr.externalTx(&op.txHash, pkScript, tryFindTx) // the tx hash and script will be cached if not previously cached and if tryFindTx is true +// This method should only be used with transactions that are NOT indexed by the +// wallet such as counter-party swaps. +func (dcr *ExchangeWallet) lookupTxOutWithBlockFilters(ctx context.Context, op outPoint, pkScript []byte, earliestTxTime time.Time) (*externalTxOutput, error) { + tx := dcr.externalTx(&op.txHash, pkScript) // the txHash and script will be cached if not previously cached and if pkScript is provided if tx == nil { - return nil, 0, false, 0, asset.CoinNotFoundError + return nil, asset.CoinNotFoundError } // Hold the tx.blockMtx lock for 2 reasons: - // 1) To read the tx block, outputs map, tree and outputs fields. + // 1) To read/write the tx.block, tx.tree and tx.outputs fields. // 2) To prevent duplicate tx block scans if this tx block is not already - // known and tryFindTx is true. - // The closure below helps to ensure that blockMtx lock is released - // as soon as all those are done. - txBlock, output, txTree, err := func() (*block, *externalTxOutput, int8, error) { - tx.blockMtx.Lock() - defer tx.blockMtx.Unlock() - txBlock, err := dcr.externalTxBlock(ctx, tx, tryFindTx, earliestTxTime) - if err != nil { - return nil, nil, 0, fmt.Errorf("error checking if tx %s is mined: %v", op.txHash, err) - } - if txBlock == nil { - // SPV wallets cannot look up unmined txs. - return nil, nil, 0, asset.CoinNotFoundError - } - if len(tx.outputs) <= int(op.vout) { - return nil, nil, 0, fmt.Errorf("tx %s does not have an output at index %d", op.txHash, op.vout) - } - return txBlock, tx.outputs[op.vout], tx.tree, nil - }() + // known and tryFindTx is true. Holding this lock now ensures that any + // ongoing scan completes before we try to access the tx.block field + // which may prevent unnecassary rescan. + tx.blockMtx.Lock() + defer tx.blockMtx.Unlock() + + // Check if we already know the block for this tx and if it is still + // part of the mainchain. Otherwise, and if tryFindTx is true, perform + // a block filters scan for this tx. + tryFindTx := len(pkScript) > 0 // the tx scripts are already cached but don't scan if this caller did not provide scripts + txBlock, err := dcr.externalTxBlock(ctx, tx, tryFindTx, earliestTxTime) if err != nil { - return nil, 0, false, 0, err + return nil, fmt.Errorf("error checking if tx %s is mined: %v", op.txHash, err) } - - amt, outputPkScript, err := parseAmountAndScript(output.value, output.pkScriptHex) - if err != nil { - return nil, 0, false, 0, fmt.Errorf("error parsing tx output %s: %v", op, err) + if txBlock == nil { + return nil, asset.CoinNotFoundError } - txOut := newTxOut(amt, output.pkScriptVersion, outputPkScript) - - // We have the requested output, let's check if it is spent. - // Hold the output.spenderMtx lock for 2 reasons: - // 1) To read (and set) the spenderBlock field. - // 2) To prevent duplicate spender block scans if the spenderBlock is not - // already known. - // The closure below helps to ensure that spenderMtx lock is released - // as soon as all those are done. - isSpent, err := func() (bool, error) { - output.spenderMtx.Lock() - defer output.spenderMtx.Unlock() - - // Check if this output is known to be spent in a mainchain block. - spenderFound, err := dcr.isMainchainBlock(ctx, output.spenderBlock) - if err != nil { - return false, err - } else if spenderFound { - return true, nil - } else if output.spenderBlock != nil { - // Output was previously found to have been spent but the block - // containing the spending tx seems to have been invalidated. - dcr.log.Warnf("Block %s found to contain spender for output %s has been invalidated.", tx.block.hash, op) - output.spenderBlock = nil - } - - // This tx output is not known to be spent as of last search (if any). - // Scan blocks from the lastScannedBlock (if there was a previous scan) - // or from the block containing the output to attempt finding the spender - // of this output. Use mainChainAncestor to ensure that scanning starts - // from a mainchain block in the event that either the output block or - // the lastScannedBlock have been re-orged out of the mainchain. - startBlock := new(block) - if output.lastScannedBlock == nil { - startBlock.hash, startBlock.height, err = dcr.mainChainAncestor(ctx, txBlock.hash) - } else { - startBlock.hash, startBlock.height, err = dcr.mainChainAncestor(ctx, output.lastScannedBlock) - } - if err != nil { - return false, err - } - - // Search for this output's spender in the blocks between startBlock and - // the current best block. - spenderTx, stopBlockHash, err := dcr.findTxOutSpender(ctx, op, outputPkScript, startBlock) - if stopBlockHash != nil { // might be nil if the search never scanned a block - output.lastScannedBlock = stopBlockHash - } - if err != nil { - return false, err - } - spent := spenderTx != nil - if spent { - spenderBlockHash, err := chainhash.NewHashFromStr(spenderTx.BlockHash) - if err != nil { - return false, err - } - output.spenderBlock = &block{hash: spenderBlockHash, height: spenderTx.BlockHeight} - } - return spent, nil - }() - if err != nil { - return nil, 0, false, 0, fmt.Errorf("unable to check if output %s is spent: %v", op, err) + if len(tx.outputs) <= int(op.vout) { + return nil, fmt.Errorf("tx %s does not have an output at index %d", op.txHash, op.vout) } - - bestBlockHeight := dcr.cachedBestBlock().height - confs := uint32(bestBlockHeight - txBlock.height + 1) - return txOut, confs, isSpent, txTree, nil + return tx.outputs[op.vout], nil } // externalTxBlock returns the mainchain block containing the provided tx, if @@ -246,23 +162,26 @@ func (dcr *ExchangeWallet) externalTxOut(ctx context.Context, op outPoint, tryFi // block hash, height and the tx outputs details are cached. // Requires the tx.scanMtx to be locked for write. func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, tryFindTxBlock bool, earliestTxTime time.Time) (*block, error) { - txBlockFound, err := dcr.isMainchainBlock(ctx, tx.block) - if err != nil { - return nil, err - } else if txBlockFound { - return tx.block, nil - } else if tx.block != nil { - // Tx block was previously set but seems to have been invalidated. - // Log a warning(?) and clear the tx tree, outputs and block info - // fields that must have been previously set. - dcr.log.Warnf("Block %s found to contain tx %s has been invalidated.", tx.block.hash, tx.hash) - tx.block = nil - tx.tree = -1 - tx.outputs = nil + if tx.block != nil { + txBlockStillValid, err := dcr.isMainchainBlock(ctx, tx.block) + if err != nil { + return nil, err + } else if txBlockStillValid { + dcr.log.Debugf("Cached tx %s is mined in block %d (%s).", tx.hash, tx.block.height, tx.block.hash) + return tx.block, nil + } else { + // Tx block was previously set but seems to have been invalidated. + // Clear the tx tree, outputs and block info fields that must have + // been previously set. + dcr.log.Warnf("Block %s found to contain tx %s has been invalidated.", tx.block.hash, tx.hash) + tx.block = nil + tx.tree = -1 + tx.outputs = nil + } } // Tx block is currently unknown. Return if the caller does not want - // to start a search for the block. + // to start a search for the tx block. if !tryFindTxBlock { return nil, nil } @@ -288,12 +207,18 @@ func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, // Run cfilters scan in reverse from best block to lastScannedBlock or // to block just before earliestTxTime. currentTip := dcr.cachedBestBlock() - if lastScannedBlock != nil { + if lastScannedBlock == nil { + dcr.log.Debugf("Searching for tx %s in blocks between block %d (%s) to the block just before %s.", + tx.hash, currentTip.height, currentTip.hash, earliestTxTime) + } else if lastScannedBlock.height < currentTip.height { dcr.log.Debugf("Searching for tx %s in blocks %d (%s) to %d (%s).", tx.hash, currentTip.height, currentTip.hash, lastScannedBlock.height, lastScannedBlock.hash) } else { - dcr.log.Debugf("Searching for tx %s in blocks between block %d (%s) to the block just before %s.", - tx.hash, currentTip.height, currentTip.hash, earliestTxTime) + if lastScannedBlock.height > currentTip.height { + dcr.log.Warnf("Previous cfilters look up for tx %s stopped at block %d but current tip is %d?", + tx.hash, lastScannedBlock.height, currentTip.height) + } + return nil, nil } iHash := currentTip.hash @@ -309,22 +234,6 @@ func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, } for { - // Abort the search if we've scanned blocks from the tip back to the - // block we scanned last or the block just before earliestTxTime. - if iHeight == 0 { - return scanCompletedWithoutResults() - } - if lastScannedBlock != nil && iHeight <= lastScannedBlock.height { - return scanCompletedWithoutResults() - } - iBlock, err := dcr.wallet.GetBlockHeaderVerbose(dcr.ctx, iHash) - if err != nil { - return nil, fmt.Errorf("getblockheader error for block %s: %w", iHash, translateRPCCancelErr(err)) - } - if iBlock.Time <= earliestTxTime.Unix() { - return scanCompletedWithoutResults() - } - // Check if this block has the tx we're looking for. blockFilter, err := dcr.getBlockFilterV2(ctx, iHash) if err != nil { @@ -354,10 +263,15 @@ func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, for i := range blkTx.Vout { blkTxOut := &blkTx.Vout[i] tx.outputs[i] = &externalTxOutput{ - outPoint: newOutPoint(tx.hash, blkTxOut.N), - value: blkTxOut.Value, - pkScriptHex: blkTxOut.ScriptPubKey.Hex, - pkScriptVersion: blkTxOut.ScriptPubKey.Version, + txOutput: &txOutput{ + op: newOutPoint(tx.hash, blkTxOut.N), + tree: tx.tree, + value: blkTxOut.Value, + scriptVersion: blkTxOut.ScriptPubKey.Version, + scriptHex: blkTxOut.ScriptPubKey.Hex, + blockHash: iHash, + blockHeight: iHeight, + }, } } return tx.block, nil @@ -366,6 +280,22 @@ func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, } // Block does not include the tx, check the previous block. + // Abort the search if we've scanned blocks from the tip back to the + // block we scanned last or the block just before earliestTxTime. + if iHeight == 0 { + return scanCompletedWithoutResults() + } + if lastScannedBlock != nil && iHeight <= lastScannedBlock.height { + return scanCompletedWithoutResults() + } + iBlock, err := dcr.wallet.GetBlockHeaderVerbose(dcr.ctx, iHash) + if err != nil { + return nil, fmt.Errorf("getblockheader error for block %s: %w", iHash, translateRPCCancelErr(err)) + } + if iBlock.Time <= earliestTxTime.Unix() { + return scanCompletedWithoutResults() + } + iHeight-- iHash, err = chainhash.NewHashFromStr(iBlock.PreviousHash) if err != nil { @@ -376,6 +306,86 @@ func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, } } +// checkOutputSpendStatus checks if the provided output is known to be spent +// by a mined transaction. This may involve scanning block filters to attempt +// finding a block that contains the spender of the provided output. +// +// This method should only be used with transaction outputs that do NOT pay to +// the wallet such as swap contracts including those sent from this wallet. +func (dcr *ExchangeWallet) checkOutputSpendStatus(ctx context.Context, output *externalTxOutput) (outputSpendStatus, error) { + // Hold the output.spenderMtx lock for 2 reasons: + // 1) To read (and set) the spenderBlock field. + // 2) To prevent duplicate spender block scans if the spenderBlock is not + // already known. Holding this lock now ensures that any ongoing scan + // completes before we try to access the output.spenderBlock field + // which may prevent unnecassary rescan. + output.spenderMtx.Lock() + defer output.spenderMtx.Unlock() + + errorOut := func(err error) (outputSpendStatus, error) { + return outputSpendStatusUnknown, err + } + + // Check if this output is known to be spent in a mainchain block. + if output.spenderBlock != nil { + spenderBlockStillValid, err := dcr.isMainchainBlock(ctx, output.spenderBlock) + if err != nil { + return errorOut(err) + } else if spenderBlockStillValid { + dcr.log.Debugf("Found cached information for the spender of %s.", output.op) + return outputSpendStatusSpent, nil + } else { + // Output was previously found to have been spent but the block + // containing the spending tx seems to have been invalidated. + dcr.log.Warnf("Block %s found to contain spender of output %s has been invalidated.", output.spenderBlock.hash, output.op) + output.spenderBlock = nil + } + } + + // This tx output is not known to be spent as of last search (if any). + // Scan blocks from the lastScannedBlock (if there was a previous scan) + // or from the block containing the output to attempt finding the spender + // of this output. Use mainChainAncestor to ensure that scanning starts + // from a mainchain block in the event that either the output block or + // the lastScannedBlock have been re-orged out of the mainchain. + firstSearch := output.lastScannedBlock == nil + startBlock := new(block) + var err error + if firstSearch { + // TODO: Should be a fatal error if the output's block is re-orged + // out of the mainchain! + startBlock.hash, startBlock.height, err = dcr.mainChainAncestor(ctx, output.blockHash) + } else { + startBlock.hash, startBlock.height, err = dcr.mainChainAncestor(ctx, output.lastScannedBlock) + } + if err != nil { + return errorOut(err) + } + + // Search for this output's spender in the blocks between startBlock and + // the current best block. + outputPkScript, err := output.PkScript() + if err != nil { + return errorOut(err) + } + spenderTx, stopBlockHash, err := dcr.findTxOutSpender(ctx, output.op, outputPkScript, startBlock, firstSearch) + if stopBlockHash != nil { // might be nil if the search never scanned a block + output.lastScannedBlock = stopBlockHash + } + if err != nil { + return errorOut(err) + } + if spenderTx != nil { + spenderBlockHash, err := chainhash.NewHashFromStr(spenderTx.BlockHash) + if err != nil { + return errorOut(err) + } + output.spenderBlock = &block{hash: spenderBlockHash, height: spenderTx.BlockHeight} + return outputSpendStatusSpent, nil + } + return outputSpendStatusUnspent, nil +} + // findTxOutSpender attempts to find and return the tx that spends the provided // output by matching the provided outputPkScript against the block filters of // the mainchain blocks between the provided startBlock and the current best @@ -383,10 +393,18 @@ func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, // If no tx is found to spend the provided output, the hash of the block that // was last checked is returned along with any error that may have occurred // during the search. -func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, outputPkScript []byte, startBlock *block) (*chainjson.TxRawResult, *chainhash.Hash, error) { +func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, outputPkScript []byte, startBlock *block, firstSearch bool) (*chainjson.TxRawResult, *chainhash.Hash, error) { bestBlock := dcr.cachedBestBlock() - dcr.log.Debugf("Searching if output %s is spent in blocks %d (%s) to %d (%s) using pkScript %x.", - op, startBlock.height, startBlock.hash, bestBlock.height, bestBlock.hash, outputPkScript) + if startBlock.height < bestBlock.height || (firstSearch && startBlock.height == bestBlock.height) { + dcr.log.Debugf("Searching if output %s is spent in blocks %d (%s) to %d (%s) using pkScript %x.", + op, startBlock.height, startBlock.hash, bestBlock.height, bestBlock.hash, outputPkScript) + } else { + if startBlock.height > bestBlock.height { + dcr.log.Warnf("Attempting to look for output spender in block %d but current tip is %d?", + startBlock.height, bestBlock.height) + } + return nil, nil, nil + } iHeight := startBlock.height iHash := startBlock.hash @@ -486,15 +504,3 @@ func (dcr *ExchangeWallet) getBlockFilterV2(ctx context.Context, blockHash *chai key: bcf2Key, }, nil } - -func parseAmountAndScript(amount float64, pkScriptHex string) (int64, []byte, error) { - amt, err := dcrutil.NewAmount(amount) - if err != nil { - return 0, nil, fmt.Errorf("invalid amount: %v", err) - } - pkScript, err := hex.DecodeString(pkScriptHex) - if err != nil { - return 0, nil, fmt.Errorf("invalid pkScript: %v", err) - } - return int64(amt), pkScript, nil -} diff --git a/client/asset/dcr/rpcwallet.go b/client/asset/dcr/rpcwallet.go index 906c016209..89d8dc244a 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -128,7 +128,7 @@ func newRPCWallet(cfg *Config, chainParams *chaincfg.Params, logger dex.Logger) return nil, fmt.Errorf("missing dcrwallet rpc credentials:%s", missing) } - log := logger.SubLogger("rpcw") + log := logger.SubLogger("RPC") log.Infof("Setting up rpc client to communicate with dcrwallet at %s with TLS certificate %q.", cfg.RPCListen, cfg.RPCCert) nodeRPCClient, err := newClient(cfg.RPCListen, cfg.RPCUser, cfg.RPCPass, cfg.RPCCert, log) @@ -347,11 +347,37 @@ func (w *rpcWallet) LockUnspent(ctx context.Context, unlock bool, ops []*wire.Ou return translateRPCCancelErr(w.rpcClient.LockUnspent(ctx, unlock, ops)) } -// GetTxOut returns information about an unspent tx output. -// Part of the Wallet interface. -func (w *rpcWallet) GetTxOut(ctx context.Context, txHash *chainhash.Hash, index uint32, tree int8, mempool bool) (*chainjson.GetTxOutResult, error) { - txOut, err := w.rpcClient.GetTxOut(ctx, txHash, index, tree, mempool) - return txOut, translateRPCCancelErr(err) +// GetTxOut returns information about an unspent tx output, if found and +// is unspent. Use wire.TxTreeUnknown if the output tree is unknown, the +// correct tree will be returned if the unspent output is found. +// An asset.CoinNotFoundError is returned if the unspent output cannot be +// located. UnspentOutput is only guaranteed to return results for outputs +// that pay to the wallet. +// Part of the Wallet interface. +func (w *rpcWallet) GetTxOut(ctx context.Context, txHash *chainhash.Hash, index uint32, tree int8, mempool bool) (*chainjson.GetTxOutResult, int8, error) { + // Check for unspent output with gettxout rpc. + var checkTrees []int8 + switch { + case tree == wire.TxTreeUnknown: + checkTrees = []int8{wire.TxTreeRegular, wire.TxTreeStake} + case tree == wire.TxTreeRegular || tree == wire.TxTreeStake: + checkTrees = []int8{tree} + default: + return nil, wire.TxTreeUnknown, fmt.Errorf("invalid tx tree %d", tree) + } + + for _, tree := range checkTrees { + txout, err := w.rpcClient.GetTxOut(ctx, txHash, index, tree, mempool) + if err != nil { + return nil, tree, translateRPCCancelErr(err) + } + if txout != nil { + return txout, tree, nil + } + } + + // Return asset.CoinNotFoundError if no result was gotten from gettxout. + return nil, wire.TxTreeUnknown, asset.CoinNotFoundError } // GetNewAddressGapPolicy returns an address from the specified account using diff --git a/client/asset/dcr/wallet.go b/client/asset/dcr/wallet.go index cc567341e4..5e9b291c5b 100644 --- a/client/asset/dcr/wallet.go +++ b/client/asset/dcr/wallet.go @@ -84,8 +84,13 @@ type Wallet interface { GetChangeAddress(ctx context.Context, account string) (stdaddr.Address, error) // LockUnspent locks or unlocks the specified outpoint. LockUnspent(ctx context.Context, unlock bool, ops []*wire.OutPoint) error - // GetTxOut returns information about an unspent tx output. - GetTxOut(ctx context.Context, txHash *chainhash.Hash, index uint32, tree int8, mempool bool) (*chainjson.GetTxOutResult, error) + // GetTxOut returns information about an unspent tx output, if found and + // is unspent. Use wire.TxTreeUnknown if the output tree is unknown, the + // correct tree will be returned if the unspent output is found. + // An asset.CoinNotFoundError is returned if the unspent output cannot be + // located. UnspentOutput is only guaranteed to return results for outputs + // that pay to the wallet. + GetTxOut(ctx context.Context, txHash *chainhash.Hash, index uint32, tree int8, mempool bool) (*chainjson.GetTxOutResult, int8, error) // GetNewAddressGapPolicy returns an address from the specified account using // the specified gap policy. GetNewAddressGapPolicy(ctx context.Context, account string, gap dcrwallet.GapPolicy) (stdaddr.Address, error) From e569f680ef8a0505e1d0946197c07716d69cb79c Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Tue, 26 Oct 2021 13:47:18 +0100 Subject: [PATCH 14/22] client/asset/dcr: check walletInfo.SPV and handle rpcclient reconnects --- client/asset/dcr/rpcwallet.go | 118 +++++++++++++++++++++------------- client/asset/dcr/wallet.go | 2 +- go.mod | 31 +++++---- go.sum | 91 +++++++++++--------------- 4 files changed, 128 insertions(+), 114 deletions(-) diff --git a/client/asset/dcr/rpcwallet.go b/client/asset/dcr/rpcwallet.go index 89d8dc244a..263f6533c3 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "strings" + "sync/atomic" "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" @@ -26,7 +27,7 @@ import ( ) var ( - requiredWalletVersion = dex.Semver{Major: 8, Minor: 5, Patch: 0} + requiredWalletVersion = dex.Semver{Major: 8, Minor: 7, Patch: 0} // TODO: Update to 8.8.0 for spv getcurrentnetwork support requiredNodeVersion = dex.Semver{Major: 7, Minor: 0, Patch: 0} ) @@ -52,6 +53,8 @@ type rpcWallet struct { // rpcClient is a combined rpcclient.Client+dcrwallet.Client, // or a stub for testing. rpcClient + + connectCount uint32 } // Ensure rpcWallet satisfies the Wallet interface. @@ -129,27 +132,27 @@ func newRPCWallet(cfg *Config, chainParams *chaincfg.Params, logger dex.Logger) } log := logger.SubLogger("RPC") + rpcw := &rpcWallet{ + chainParams: chainParams, + log: log, + } + log.Infof("Setting up rpc client to communicate with dcrwallet at %s with TLS certificate %q.", cfg.RPCListen, cfg.RPCCert) - nodeRPCClient, err := newClient(cfg.RPCListen, cfg.RPCUser, cfg.RPCPass, cfg.RPCCert, log) + err := rpcw.setupRPCClient(cfg.RPCListen, cfg.RPCUser, cfg.RPCPass, cfg.RPCCert) if err != nil { return nil, fmt.Errorf("error setting up rpc client: %w", err) } - return &rpcWallet{ - chainParams: chainParams, - log: log, - rpcConnector: nodeRPCClient, - rpcClient: &combinedClient{nodeRPCClient, dcrwallet.NewClient(dcrwallet.RawRequestCaller(nodeRPCClient), chainParams)}, - }, nil + return rpcw, nil } -// newClient attempts to create a new websocket connection to a dcrwallet +// setupRPCClient attempts to create a new websocket connection to a dcrwallet // instance with the given credentials and notification handlers. -func newClient(host, user, pass, cert string, logger dex.Logger) (*rpcclient.Client, error) { +func (w *rpcWallet) setupRPCClient(host, user, pass, cert string) error { certs, err := os.ReadFile(cert) if err != nil { - return nil, fmt.Errorf("TLS certificate read error: %w", err) + return fmt.Errorf("TLS certificate read error: %w", err) } config := &rpcclient.ConnConfig{ @@ -163,37 +166,39 @@ func newClient(host, user, pass, cert string, logger dex.Logger) (*rpcclient.Cli ntfnHandlers := &rpcclient.NotificationHandlers{ // Setup an on-connect handler for logging (re)connects. - OnClientConnected: func() { - logger.Infof("Connected to Decred wallet at %s", host) - }, + OnClientConnected: w.handleRPCClientReconnection, } - cl, err := rpcclient.New(config, ntfnHandlers) + nodeRPCClient, err := rpcclient.New(config, ntfnHandlers) if err != nil { - return nil, fmt.Errorf("Failed to start dcrwallet RPC client: %w", err) + return fmt.Errorf("Failed to start dcrwallet RPC client: %w", err) } - return cl, nil + w.rpcConnector = nodeRPCClient + w.rpcClient = &combinedClient{nodeRPCClient, dcrwallet.NewClient(dcrwallet.RawRequestCaller(nodeRPCClient), w.chainParams)} + return nil } -// Connect establishes a connection to the previously created rpc client. -// Part of the Wallet interface. -func (w *rpcWallet) Connect(ctx context.Context) error { - err := w.rpcConnector.Connect(ctx, false) +func (w *rpcWallet) handleRPCClientReconnection() { + connectCount := atomic.AddUint32(&w.connectCount, 1) + if connectCount == 1 { + // first connection, below check will be performed + // by *rpcWallet.Connect. + return + } + + w.log.Debugf("dcrwallet reconnected (%d)", connectCount-1) + err := w.checkRPCConnection(context.TODO()) if err != nil { - return fmt.Errorf("dcrwallet connect error: %w", err) + w.log.Errorf("dcrwallet reconnect handler error: %v", err) } +} - // The websocket client is connected now, so if any of the following checks - // fails and we return with a non-nil error, we must shutdown the rpc client - // or subsequent reconnect attempts will be met with "websocket client has - // already connected". - var success bool - defer func() { - if !success { - w.rpcConnector.Shutdown() - w.rpcConnector.WaitForShutdown() - } - }() +// isSpvMode uses the walletinfo rpc to determine if this wallet is +// connected to the Decred network via SPV peers. +func (w *rpcWallet) checkRPCConnection(ctx context.Context) error { + // Reset spvMode to false, until we're sure we're connected to + // an SPV wallet below. + w.spvMode = false // Check the required API versions. versions, err := w.rpcConnector.Version(ctx) @@ -212,13 +217,7 @@ func (w *rpcWallet) Connect(ctx context.Context) error { } ver, exists = versions["dcrdjsonrpcapi"] - if !exists { - w.spvMode = true - w.log.Infof("Connected to dcrwallet (JSON-RPC API v%s) in SPV mode", walletSemver) - // TODO: Thr wallet may actually not be connected to an spv syncer, use the walletinfo - // rpc to confirm and return the following error if this is not spv wallet. - // return fmt.Errorf("dcrwallet.Version response missing 'dcrdjsonrpcapi'") - } else { + if exists { nodeSemver := dex.NewSemver(ver.Major, ver.Minor, ver.Patch) if !dex.SemverCompatible(requiredNodeVersion, nodeSemver) { return fmt.Errorf("dcrd has an incompatible JSON-RPC version: got %s, expected %s", @@ -226,9 +225,41 @@ func (w *rpcWallet) Connect(ctx context.Context) error { } w.log.Infof("Connected to dcrwallet (JSON-RPC API v%s) proxying dcrd (JSON-RPC API v%s) on %v", walletSemver, nodeSemver, w.chainParams.Name) + } else { + // SPV maybe? + walletInfo, err := w.rpcClient.WalletInfo(ctx) + if err != nil { + return fmt.Errorf("walletinfo rpc error: %w", translateRPCCancelErr(err)) + } + if !walletInfo.SPV { + return fmt.Errorf("dcrwallet.Version response missing 'dcrdjsonrpcapi' for non-spv wallet") + } + w.spvMode = true + w.log.Infof("Connected to dcrwallet (JSON-RPC API v%s) in SPV mode", walletSemver) + } + + return nil +} + +// Connect establishes a connection to the previously created rpc client. +// Part of the Wallet interface. +func (w *rpcWallet) Connect(ctx context.Context) error { + err := w.rpcConnector.Connect(ctx, false) + if err != nil { + return fmt.Errorf("dcrwallet connect error: %w", err) + } + + // The websocket client is connected now, so if the following check + // fails and we return with a non-nil error, we must shutdown the + // rpc client otherwise subsequent reconnect attempts will be met + // with "websocket client has already connected". + err = w.checkRPCConnection(ctx) + if err != nil { + w.rpcConnector.Shutdown() + w.rpcConnector.WaitForShutdown() + return err } - success = true return nil } @@ -252,11 +283,10 @@ func (w *rpcWallet) Network(ctx context.Context) (wire.CurrencyNet, error) { return net, translateRPCCancelErr(err) } -// SpvMode returns through if the wallet is connected to +// SpvMode returns true if the wallet is connected to the Decred +// network via SPV peers. // Part of the Wallet interface. func (w *rpcWallet) SpvMode() bool { - // TODO: Should probably re-check walletinfo to be sure - // the network backend has not been changed to dcrd. return w.spvMode } diff --git a/client/asset/dcr/wallet.go b/client/asset/dcr/wallet.go index 5e9b291c5b..7324776c3a 100644 --- a/client/asset/dcr/wallet.go +++ b/client/asset/dcr/wallet.go @@ -60,7 +60,7 @@ type Wallet interface { Disconnected() bool // Network returns the network of the connected wallet. Network(ctx context.Context) (wire.CurrencyNet, error) - // SpvMode returns through if the wallet is connected to the Decred + // SpvMode returns true if the wallet is connected to the Decred // network via SPV peers. SpvMode() bool // NotifyOnTipChange registers a callback function that the should be diff --git a/go.mod b/go.mod index f2e7f32771..5b6bb52cad 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module decred.org/dcrdex go 1.16 require ( - decred.org/dcrwallet/v2 v2.0.0-20210913145543-714c2f555f04 + decred.org/dcrwallet/v2 v2.0.0-20211006150810-61ab76379a26 // json-rpc 8.7.0 server, TODO: update to 8.8.0 github.com/btcsuite/btcd v0.22.0-beta.0.20211026140004-31791ba4dc6e github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f github.com/btcsuite/btcutil v1.0.3-0.20210527170813-e2ba6805a890 // note: hoists btcd's own require of btcutil @@ -14,24 +14,23 @@ require ( github.com/btcsuite/btcwallet/walletdb v1.4.0 github.com/btcsuite/btcwallet/wtxmgr v1.3.0 github.com/davecgh/go-spew v1.1.1 - github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210914193033-2efb9bda71fe - github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914193033-2efb9bda71fe - github.com/decred/dcrd/certgen v1.1.2-0.20210914193033-2efb9bda71fe - github.com/decred/dcrd/chaincfg/chainhash v1.0.3 - github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914193033-2efb9bda71fe - github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210914193033-2efb9bda71fe - github.com/decred/dcrd/database/v3 v3.0.0-20210914193033-2efb9bda71fe // indirect - github.com/decred/dcrd/dcrec v1.0.1-0.20210914193033-2efb9bda71fe + github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210914212651-723d86274b0d + github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914212651-723d86274b0d + github.com/decred/dcrd/certgen v1.1.2-0.20210914212651-723d86274b0d + github.com/decred/dcrd/chaincfg/chainhash v1.0.4-0.20210914212651-723d86274b0d + github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914212651-723d86274b0d + github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210914212651-723d86274b0d + github.com/decred/dcrd/dcrec v1.0.1-0.20210914212651-723d86274b0d github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2-0.20210914193033-2efb9bda71fe - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1-0.20210914212651-723d86274b0d github.com/decred/dcrd/dcrjson/v4 v4.0.0 - github.com/decred/dcrd/dcrutil/v4 v4.0.0-20210914193033-2efb9bda71fe - github.com/decred/dcrd/gcs/v3 v3.0.0-20210914193033-2efb9bda71fe - github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210914193033-2efb9bda71fe - github.com/decred/dcrd/rpc/jsonrpc/types/v3 v3.0.0-20210914193033-2efb9bda71fe + github.com/decred/dcrd/dcrutil/v4 v4.0.0-20210914212651-723d86274b0d + github.com/decred/dcrd/gcs/v3 v3.0.0-20210914212651-723d86274b0d + github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210914212651-723d86274b0d + github.com/decred/dcrd/rpc/jsonrpc/types/v3 v3.0.0-20210914212651-723d86274b0d github.com/decred/dcrd/rpcclient/v7 v7.0.0-20210914193033-2efb9bda71fe - github.com/decred/dcrd/txscript/v4 v4.0.0-20210914193033-2efb9bda71fe - github.com/decred/dcrd/wire v1.4.1-0.20210914193033-2efb9bda71fe + github.com/decred/dcrd/txscript/v4 v4.0.0-20210914212651-723d86274b0d + github.com/decred/dcrd/wire v1.4.1-0.20210914212651-723d86274b0d github.com/decred/go-socks v1.1.0 github.com/decred/slog v1.2.0 github.com/ethereum/go-ethereum v1.10.11 diff --git a/go.sum b/go.sum index 6e8e4e9dd3..60273818c6 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= collectd.org v0.3.0/go.mod h1:A/8DzQBkF6abtvrT2j/AU/4tiBgJWYyh0y/oB/4MlWE= decred.org/cspp v0.3.0/go.mod h1:UygjYilC94dER3BEU65Zzyoqy9ngJfWCD2rdJqvUs2A= -decred.org/dcrwallet/v2 v2.0.0-20210913145543-714c2f555f04 h1:1mt0CfpyS8z0AVyRCN4CXVFTgBRthH5jWtcB6q6OXoE= -decred.org/dcrwallet/v2 v2.0.0-20210913145543-714c2f555f04/go.mod h1:v7R6jLH7uF5Z3CoE0lnAYSb0Ph6iS367YSR5BcclVbo= +decred.org/dcrwallet/v2 v2.0.0-20211006150810-61ab76379a26 h1:4wHBsnnlKIXKr2sNEyWE1kd2pvhhUuEA2GchpZcvy9Q= +decred.org/dcrwallet/v2 v2.0.0-20211006150810-61ab76379a26/go.mod h1:jEw3MCDN3x3QfxaKABdebEalLtN4Fv3zRSavQtLy6ms= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= github.com/Azure/azure-pipeline-go v0.2.2/go.mod h1:4rQ/NZncSvGqNkkOsNpOU1tgoNuIlp9AfUH5G1tvCHc= @@ -141,88 +141,73 @@ github.com/deckarep/golang-set v0.0.0-20180603214616-504e848d77ea h1:j4317fAZh7X github.com/deckarep/golang-set v0.0.0-20180603214616-504e848d77ea/go.mod h1:93vsz/8Wt4joVM7c2AVqh+YRMiUSc14yDtF28KmMOgQ= github.com/decred/base58 v1.0.3 h1:KGZuh8d1WEMIrK0leQRM47W85KqCAdl2N+uagbctdDI= github.com/decred/base58 v1.0.3/go.mod h1:pXP9cXCfM2sFLb2viz2FNIdeMWmZDBKG3ZBYbiSM78E= -github.com/decred/dcrd/addrmgr/v2 v2.0.0-20210901152745-8830d9c9cdba/go.mod h1:pFpkgqaKOORZmZ+GwO719PaXqBvBqt5ATUbMQ3QgYl8= +github.com/decred/dcrd/addrmgr/v2 v2.0.0-20210914212651-723d86274b0d/go.mod h1:pFpkgqaKOORZmZ+GwO719PaXqBvBqt5ATUbMQ3QgYl8= github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210129192908-660d0518b4cf/go.mod h1:zALtZt59lCrhoj6dVMptHHAMw1hq0Zz9s2ZULWjhtZs= github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210409183916-7f402345f0a6/go.mod h1:YtLRoqodBkyTSLNhr4bvLlkn/C24gIzwTG16gtvxVQo= -github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210901152745-8830d9c9cdba/go.mod h1:CStg0VQxxpVWphul8V3BtBOlhkkHfGE3CgwZK00xYwE= -github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210914193033-2efb9bda71fe h1:KUoG7npDcaZTcp6SSq85Os15FkIH9a0Sk11kTFWP1Gk= -github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210914193033-2efb9bda71fe/go.mod h1:CStg0VQxxpVWphul8V3BtBOlhkkHfGE3CgwZK00xYwE= +github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210914212651-723d86274b0d h1:6liarkv448wXYdxCbk7vtFR87c0LXc/2hVTHxq6MoRY= +github.com/decred/dcrd/blockchain/stake/v4 v4.0.0-20210914212651-723d86274b0d/go.mod h1:CStg0VQxxpVWphul8V3BtBOlhkkHfGE3CgwZK00xYwE= github.com/decred/dcrd/blockchain/standalone/v2 v2.0.0/go.mod h1:t2qaZ3hNnxHZ5kzVJDgW5sp47/8T5hYJt7SR+/JtRhI= -github.com/decred/dcrd/blockchain/standalone/v2 v2.0.1-0.20210901152745-8830d9c9cdba h1:t26cyqn+rLrBld91rfQm9/sYjGF56cZN+7uIOjQAilo= -github.com/decred/dcrd/blockchain/standalone/v2 v2.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:t2qaZ3hNnxHZ5kzVJDgW5sp47/8T5hYJt7SR+/JtRhI= -github.com/decred/dcrd/blockchain/v4 v4.0.0-20210901152745-8830d9c9cdba/go.mod h1:DrP4/0VuweZtbGjT4+HVxsX+ETs8TTx3JIc5wfHykLE= -github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914193033-2efb9bda71fe h1:eEmS41C9QX2B7iPSD7zuEhGtypyq4OSia2LplO/1ZU0= -github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914193033-2efb9bda71fe/go.mod h1:DrP4/0VuweZtbGjT4+HVxsX+ETs8TTx3JIc5wfHykLE= -github.com/decred/dcrd/certgen v1.1.2-0.20210901152745-8830d9c9cdba/go.mod h1:ivkPLChfjdAgFh7ZQOtl6kJRqVkfrCq67dlq3AbZBQE= -github.com/decred/dcrd/certgen v1.1.2-0.20210914193033-2efb9bda71fe h1:ycy++nUvV2y0l+7mYeBekUCMxpGYLEuT494PFSbO3Oc= -github.com/decred/dcrd/certgen v1.1.2-0.20210914193033-2efb9bda71fe/go.mod h1:ivkPLChfjdAgFh7ZQOtl6kJRqVkfrCq67dlq3AbZBQE= +github.com/decred/dcrd/blockchain/standalone/v2 v2.0.1-0.20210914212651-723d86274b0d h1:VDopOMLSG2kiIMasj+h5ee3OqeIZ1MSBisr4LppZVjE= +github.com/decred/dcrd/blockchain/standalone/v2 v2.0.1-0.20210914212651-723d86274b0d/go.mod h1:t2qaZ3hNnxHZ5kzVJDgW5sp47/8T5hYJt7SR+/JtRhI= +github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914212651-723d86274b0d h1:ani01ik6n9MHzzRNbv+WG40/5iplJ8WU9xOKN+8lQXY= +github.com/decred/dcrd/blockchain/v4 v4.0.0-20210914212651-723d86274b0d/go.mod h1:DrP4/0VuweZtbGjT4+HVxsX+ETs8TTx3JIc5wfHykLE= +github.com/decred/dcrd/certgen v1.1.2-0.20210914212651-723d86274b0d h1:xOlX73Yij20WWhpBf5Jh8iZdjHhW9cdt/L9n/8KkgPI= +github.com/decred/dcrd/certgen v1.1.2-0.20210914212651-723d86274b0d/go.mod h1:ivkPLChfjdAgFh7ZQOtl6kJRqVkfrCq67dlq3AbZBQE= github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= -github.com/decred/dcrd/chaincfg/chainhash v1.0.3-0.20210901152745-8830d9c9cdba/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= -github.com/decred/dcrd/chaincfg/chainhash v1.0.3 h1:PF2czcYZGW3dz4i/35AUfVAgnqHl9TMNQt1ADTYGOoE= github.com/decred/dcrd/chaincfg/chainhash v1.0.3/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= +github.com/decred/dcrd/chaincfg/chainhash v1.0.4-0.20210914212651-723d86274b0d h1:oz2YqSIwjxfpmtten+Hv9uZRE0TWFwOmm0u0Sj2b4n4= +github.com/decred/dcrd/chaincfg/chainhash v1.0.4-0.20210914212651-723d86274b0d/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= github.com/decred/dcrd/chaincfg/v3 v3.0.0/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= -github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= -github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914193033-2efb9bda71fe h1:emvB/rSrQ9VNHVqfKfSMnS/A/suah0bq3eNOumORUDQ= -github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914193033-2efb9bda71fe/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= -github.com/decred/dcrd/connmgr/v3 v3.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:cPI43Aggp1lOhrVG75eJ3c3BwuFx0NhT77FK34ky+ak= +github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914212651-723d86274b0d h1:yijU1+LFCzBV77uD85l4TdK+lRPGKpwKrW7qijTU9HI= +github.com/decred/dcrd/chaincfg/v3 v3.0.1-0.20210914212651-723d86274b0d/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= +github.com/decred/dcrd/connmgr/v3 v3.0.1-0.20210914212651-723d86274b0d/go.mod h1:cPI43Aggp1lOhrVG75eJ3c3BwuFx0NhT77FK34ky+ak= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210914193033-2efb9bda71fe h1:7LVYo85EnU4uOoyL87p+v5fhNOzLa1DTgCOeLIFqiKg= -github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210914193033-2efb9bda71fe/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210914212651-723d86274b0d h1:y+2KBjfqiv6p+RYDIUzB5g0Zplrqq4SRUOy6whAjuZY= +github.com/decred/dcrd/crypto/blake256 v1.0.1-0.20210914212651-723d86274b0d/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/ripemd160 v1.0.1/go.mod h1:F0H8cjIuWTRoixr/LM3REB8obcWkmYx0gbxpQWR8RPg= -github.com/decred/dcrd/crypto/ripemd160 v1.0.2-0.20210901152745-8830d9c9cdba h1:Jv2ENh9AxouG058q/VHHjdrVd9ULnAsv4kURTNssTSY= -github.com/decred/dcrd/crypto/ripemd160 v1.0.2-0.20210901152745-8830d9c9cdba/go.mod h1:F0H8cjIuWTRoixr/LM3REB8obcWkmYx0gbxpQWR8RPg= +github.com/decred/dcrd/crypto/ripemd160 v1.0.2-0.20210914212651-723d86274b0d h1:BWcBdQQRt11+N5kuYfrIo2Y74drc/IweOHcHaZ2ANjc= +github.com/decred/dcrd/crypto/ripemd160 v1.0.2-0.20210914212651-723d86274b0d/go.mod h1:F0H8cjIuWTRoixr/LM3REB8obcWkmYx0gbxpQWR8RPg= github.com/decred/dcrd/database/v2 v2.0.3-0.20210129190127-4ebd135a82f1/go.mod h1:C5nb1qImTy2sxAfV1KJFW6KHae+NbD6lSMJl58KY7XM= github.com/decred/dcrd/database/v3 v3.0.0-20210802132946-9ede6ae83e0f/go.mod h1:3WUAfz3R0FOz6wJcqTZ0CcUDfyIMrlO10f3aqa2/7vk= -github.com/decred/dcrd/database/v3 v3.0.0-20210914193033-2efb9bda71fe h1:8jMoLB3AL8OYSIAek4SHo/OAHZMz3QH9EmbqIYXfr0U= -github.com/decred/dcrd/database/v3 v3.0.0-20210914193033-2efb9bda71fe/go.mod h1:3WUAfz3R0FOz6wJcqTZ0CcUDfyIMrlO10f3aqa2/7vk= +github.com/decred/dcrd/database/v3 v3.0.0-20210914212651-723d86274b0d h1:V561LNgSlcM7YqyBARh7djr5+MqV9bQD/OSpW95YxCQ= +github.com/decred/dcrd/database/v3 v3.0.0-20210914212651-723d86274b0d/go.mod h1:3WUAfz3R0FOz6wJcqTZ0CcUDfyIMrlO10f3aqa2/7vk= github.com/decred/dcrd/dcrec v1.0.0/go.mod h1:HIaqbEJQ+PDzQcORxnqen5/V1FR3B4VpIfmePklt8Q8= -github.com/decred/dcrd/dcrec v1.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:mIVCrTyD2BEUoie2drr/KAGNvjJCHwf01I0ZCkVQu6c= -github.com/decred/dcrd/dcrec v1.0.1-0.20210914193033-2efb9bda71fe h1:GdgdjJdSyxpstl2bOfQr1A4qWYCnT07jyYdJNvjMUek= -github.com/decred/dcrd/dcrec v1.0.1-0.20210914193033-2efb9bda71fe/go.mod h1:mIVCrTyD2BEUoie2drr/KAGNvjJCHwf01I0ZCkVQu6c= +github.com/decred/dcrd/dcrec v1.0.1-0.20210914212651-723d86274b0d h1:hNyjcQ0MFOlY/3FhSWJdd/DKibqR7ehrhknHCPL7yLY= +github.com/decred/dcrd/dcrec v1.0.1-0.20210914212651-723d86274b0d/go.mod h1:mIVCrTyD2BEUoie2drr/KAGNvjJCHwf01I0ZCkVQu6c= github.com/decred/dcrd/dcrec/edwards/v2 v2.0.1/go.mod h1:d0H8xGMWbiIQP7gN3v2rByWUcuZPm9YsgmnfoxgbINc= github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2-0.20210914193033-2efb9bda71fe h1:CU8+7wyF365N9VIGFBe4BQs1cdzlD4vLje00Gv0yzg8= github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2-0.20210914193033-2efb9bda71fe/go.mod h1:d0H8xGMWbiIQP7gN3v2rByWUcuZPm9YsgmnfoxgbINc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210127014238-b33b46cf1a24/go.mod h1:UkVqoxmJlLgUvBjJD+GdJz6mgdSdf3UjX83xfwUAYDk= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210901152745-8830d9c9cdba/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0 h1:Fe5DW39aaoS/fqZiYlylEqQWIKznnbatWSHpWdFA3oQ= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1-0.20210914212651-723d86274b0d h1:v0KlnIOZ0KP0UJh+Qikn3jC57v0Qx8osNoklVVAXNqM= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1-0.20210914212651-723d86274b0d/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/decred/dcrd/dcrjson/v3 v3.1.0/go.mod h1:fnTHev/ABGp8IxFudDhjGi9ghLiXRff1qZz/wvq12Mg= -github.com/decred/dcrd/dcrjson/v3 v3.1.1-0.20210901152745-8830d9c9cdba h1:bs4KIALqsVsWyoJ3DgQkmp8kheESSuKJHMprmkBrorI= -github.com/decred/dcrd/dcrjson/v3 v3.1.1-0.20210901152745-8830d9c9cdba/go.mod h1:fnTHev/ABGp8IxFudDhjGi9ghLiXRff1qZz/wvq12Mg= github.com/decred/dcrd/dcrjson/v4 v4.0.0 h1:KsaFhHAYO+vLYz7Qmx/fs1gOY5ouTEz8hRuDm8jmJtU= github.com/decred/dcrd/dcrjson/v4 v4.0.0/go.mod h1:DMnSpU8lsVh+Nt5kHl63tkrjBDA7UIs4+ov8Kwwgvjs= github.com/decred/dcrd/dcrutil/v4 v4.0.0-20210129181600-6ae0142d3b28/go.mod h1:xe59jKcMx5G/dbRmsZ8+FzY+WQDE/7YBP3k3uzJTtmI= -github.com/decred/dcrd/dcrutil/v4 v4.0.0-20210901152745-8830d9c9cdba/go.mod h1:9SBpuzWthriVnxKeHm2Dh5jSPKq51q1rvgqac/+kgMI= -github.com/decred/dcrd/dcrutil/v4 v4.0.0-20210914193033-2efb9bda71fe h1:iN8TZ+Mv0hLbBDHU4UggCg+f0adNlRtuik+kel8are4= -github.com/decred/dcrd/dcrutil/v4 v4.0.0-20210914193033-2efb9bda71fe/go.mod h1:9SBpuzWthriVnxKeHm2Dh5jSPKq51q1rvgqac/+kgMI= +github.com/decred/dcrd/dcrutil/v4 v4.0.0-20210914212651-723d86274b0d h1:hzywVpfk4ufdPxpzuaxzprVRXk5XN0oTP/95w254frA= +github.com/decred/dcrd/dcrutil/v4 v4.0.0-20210914212651-723d86274b0d/go.mod h1:9SBpuzWthriVnxKeHm2Dh5jSPKq51q1rvgqac/+kgMI= github.com/decred/dcrd/gcs/v3 v3.0.0-20210129195202-a4265d63b619/go.mod h1:aGuAajYbDJB2oal17G371wiosGgVCc5d5FlT2EwZtoE= -github.com/decred/dcrd/gcs/v3 v3.0.0-20210901152745-8830d9c9cdba/go.mod h1:SLlU9PRSMFL4jdAMGZ3m7smAZEbeboDwNOr3BOj4BjY= -github.com/decred/dcrd/gcs/v3 v3.0.0-20210914193033-2efb9bda71fe h1:o04eymmi2LReTF4H1q/fhuc5SpiBI3OLzKZGo3kVJ0I= -github.com/decred/dcrd/gcs/v3 v3.0.0-20210914193033-2efb9bda71fe/go.mod h1:SLlU9PRSMFL4jdAMGZ3m7smAZEbeboDwNOr3BOj4BjY= -github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210901152745-8830d9c9cdba/go.mod h1:A9Aqp4kStmkAwbZeuIlS1hZjTeDkxgVXSg+nSo4FJCs= -github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210914193033-2efb9bda71fe h1:BAo63ZodY9ykmVxAXmbuWisEoYc3amUdm/Of3/cXJbQ= -github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210914193033-2efb9bda71fe/go.mod h1:A9Aqp4kStmkAwbZeuIlS1hZjTeDkxgVXSg+nSo4FJCs= +github.com/decred/dcrd/gcs/v3 v3.0.0-20210914212651-723d86274b0d h1:DGiBOalOyXkmORMB/SNNSFjXskDmZfI4C4owdhvdV5I= +github.com/decred/dcrd/gcs/v3 v3.0.0-20210914212651-723d86274b0d/go.mod h1:SLlU9PRSMFL4jdAMGZ3m7smAZEbeboDwNOr3BOj4BjY= +github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210914212651-723d86274b0d h1:+WuyO8SJb+6H1kLX45wzZTyX98/cmw9TMtOGyxC7+p4= +github.com/decred/dcrd/hdkeychain/v3 v3.0.1-0.20210914212651-723d86274b0d/go.mod h1:A9Aqp4kStmkAwbZeuIlS1hZjTeDkxgVXSg+nSo4FJCs= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/decred/dcrd/lru v1.1.0 h1:QwT6v8LFKOL3xQ3qtucgRk4pdiawrxIfCbUXWpm+JL4= github.com/decred/dcrd/lru v1.1.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/decred/dcrd/rpc/jsonrpc/types/v3 v3.0.0-20210129200153-14fd1a785bf2/go.mod h1:9izQEJ5wU0ZwYHESMaaOIvE6H6y3IvDsQL3ByYGn9oc= -github.com/decred/dcrd/rpc/jsonrpc/types/v3 v3.0.0-20210901152745-8830d9c9cdba/go.mod h1:9izQEJ5wU0ZwYHESMaaOIvE6H6y3IvDsQL3ByYGn9oc= -github.com/decred/dcrd/rpc/jsonrpc/types/v3 v3.0.0-20210914193033-2efb9bda71fe h1:eXFrAJ3A1AKciOicqz6YsXHBGZTBQtHe7h1HqVNAwq0= -github.com/decred/dcrd/rpc/jsonrpc/types/v3 v3.0.0-20210914193033-2efb9bda71fe/go.mod h1:PIaKghQZNa5mbwCN+app8xPjK/5BHf6qpj3Svth5qLI= +github.com/decred/dcrd/rpc/jsonrpc/types/v3 v3.0.0-20210914212651-723d86274b0d h1:Y3unN+CMB2+BSpLyyJa+bSHw3FcV5mf2UI4oumMJhKg= +github.com/decred/dcrd/rpc/jsonrpc/types/v3 v3.0.0-20210914212651-723d86274b0d/go.mod h1:PIaKghQZNa5mbwCN+app8xPjK/5BHf6qpj3Svth5qLI= github.com/decred/dcrd/rpcclient/v7 v7.0.0-20210914193033-2efb9bda71fe h1:BLM6aLwwdZf6Wfa5xT3f5RWlD61x2MZH8BWD0rwF0FQ= github.com/decred/dcrd/rpcclient/v7 v7.0.0-20210914193033-2efb9bda71fe/go.mod h1:rA8pGrf5+9Ag+oJeAQolhkTtMwxudbhkYqe7j6aGTDE= github.com/decred/dcrd/txscript/v4 v4.0.0-20210129190127-4ebd135a82f1/go.mod h1:EnS4vtxTESoI59geLo9M8AUOvIprJy+O4gSVsQp6/h4= github.com/decred/dcrd/txscript/v4 v4.0.0-20210330065944-a2366e6e0b3b/go.mod h1:G6b6ERb4KkSqMOCcfSS6m5QV2dgXKVohRpK0HEECw5Q= github.com/decred/dcrd/txscript/v4 v4.0.0-20210415215133-96b98390a9a9/go.mod h1:LBGwMZRfpS50huRsc0Bihy7w2Sl9vK3TNqv8nhCRj0U= -github.com/decred/dcrd/txscript/v4 v4.0.0-20210901152745-8830d9c9cdba/go.mod h1:2SlEW/rXtGK1mImNUwONANCRuKuYY+J/qHzwp2wzyRc= -github.com/decred/dcrd/txscript/v4 v4.0.0-20210914193033-2efb9bda71fe h1:4rHewRU0nyOmHuYLpyL+jzdCTFOBhBRNg8qhKInQuJM= -github.com/decred/dcrd/txscript/v4 v4.0.0-20210914193033-2efb9bda71fe/go.mod h1:2SlEW/rXtGK1mImNUwONANCRuKuYY+J/qHzwp2wzyRc= +github.com/decred/dcrd/txscript/v4 v4.0.0-20210914212651-723d86274b0d h1:GiGBir8+1xgaZoqvyb/tWyeCS8K+czI1LvIFDcFCzEg= +github.com/decred/dcrd/txscript/v4 v4.0.0-20210914212651-723d86274b0d/go.mod h1:2SlEW/rXtGK1mImNUwONANCRuKuYY+J/qHzwp2wzyRc= github.com/decred/dcrd/wire v1.3.0/go.mod h1:fnKGlUY2IBuqnpxx5dYRU5Oiq392OBqAuVjRVSkIoXM= github.com/decred/dcrd/wire v1.4.0/go.mod h1:WxC/0K+cCAnBh+SKsRjIX9YPgvrjhmE+6pZlel1G7Ro= -github.com/decred/dcrd/wire v1.4.1-0.20210901152745-8830d9c9cdba/go.mod h1:fzAjVqw32LkbAZIt5mnrvBR751GTa3e0rRQdOIhPY3w= -github.com/decred/dcrd/wire v1.4.1-0.20210914193033-2efb9bda71fe h1:ZM06aSORT/lv6rxxOGR3RJAkLyK8bN7goS40U9zE2c8= -github.com/decred/dcrd/wire v1.4.1-0.20210914193033-2efb9bda71fe/go.mod h1:fzAjVqw32LkbAZIt5mnrvBR751GTa3e0rRQdOIhPY3w= +github.com/decred/dcrd/wire v1.4.1-0.20210914212651-723d86274b0d h1:CCYnEcEm3x1xXtqVg3H2QOERPeDX/e66JXARpa1IwfI= +github.com/decred/dcrd/wire v1.4.1-0.20210914212651-723d86274b0d/go.mod h1:fzAjVqw32LkbAZIt5mnrvBR751GTa3e0rRQdOIhPY3w= github.com/decred/go-socks v1.1.0 h1:dnENcc0KIqQo3HSXdgboXAHgqsCIutkqq6ntQjYtm2U= github.com/decred/go-socks v1.1.0/go.mod h1:sDhHqkZH0X4JjSa02oYOGhcGHYp12FsY1jQ/meV8md0= github.com/decred/slog v1.1.0/go.mod h1:kVXlGnt6DHy2fV5OjSeuvCJ0OmlmTF6LFpEPMu/fOY0= From eb24720361c2ae061ddf8974227b01944ddcd271 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Tue, 26 Oct 2021 12:15:44 +0100 Subject: [PATCH 15/22] no txdata for post-confirmation repeat audit --- client/asset/btc/btc.go | 3 +++ client/asset/dcr/dcr.go | 3 +++ client/core/trade.go | 10 +++++----- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 552adf283f..0143f10b86 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -1853,6 +1853,9 @@ func (btc *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, sin // retrieval at the asset.Wallet interface level. coinNotFound := txOut == nil if coinNotFound { + if len(txData) == 0 { + return nil, fmt.Errorf("contract %s:%d not found in blockchain and tx data not provided to audit", txHash, vout) + } tx, err := msgTxFromBytes(txData) if err != nil { return nil, fmt.Errorf("coin not found, and error encountered decoding tx data: %v", err) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 7f862066af..7648bf990b 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1548,6 +1548,9 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t // broadcasted. Pull the contract output details from the provided // txData to validate. The txData will be broadcasted below if the // contract details are valid. + if len(txData) == 0 { + return nil, fmt.Errorf("contract %s not found in blockchain and tx data not provided to audit", op) + } contractTx = wire.NewMsgTx() if err := contractTx.Deserialize(bytes.NewReader(txData)); err != nil { return nil, fmt.Errorf("invalid contract tx data: %w", err) diff --git a/client/core/trade.go b/client/core/trade.go index 4131ae9b8e..47787478ca 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -2107,7 +2107,7 @@ func (t *trackedTrade) searchAuditInfo(match *matchTracker, coinID []byte, contr Expiration: time.Now().Add(24 * time.Hour), // effectively forever TryFunc: func() bool { var err error - auditInfo, err = t.wallets.toWallet.AuditContract(coinID, contract, txData, encode.UnixTimeMilli(int64(match.MetaData.Stamp))) + auditInfo, err = t.wallets.toWallet.AuditContract(coinID, contract, txData, encode.UnixTimeMilli(int64(match.MetaData.Stamp))) // why not match.matchTime()? if err == nil { // Success. errChan <- nil @@ -2208,8 +2208,8 @@ func (t *trackedTrade) reAuditContract(match *matchTracker) error { } proof := &match.MetaData.Proof - coinID, contract, txData := match.counterSwap.Coin.ID(), proof.CounterContract, proof.CounterTxData - auditInfo, err := t.wallets.toWallet.AuditContract(coinID, contract, txData, encode.UnixTimeMilli(int64(match.MetaData.Stamp))) // why not match.matchTime()? + coinID, contract := match.counterSwap.Coin.ID(), proof.CounterContract + auditInfo, err := t.wallets.toWallet.AuditContract(coinID, contract, nil, time.Time{}) if err != nil { return err } @@ -2217,8 +2217,8 @@ func (t *trackedTrade) reAuditContract(match *matchTracker) error { return err } // Audit successful. - t.dc.log.Infof("Re audited contract (%s: %v) paying to %s for order %s, match %s, with tx data = %t", - t.wallets.toAsset.Symbol, auditInfo.Coin, auditInfo.Recipient, t.ID(), match, len(txData) > 0) + t.dc.log.Infof("Re audited contract (%s: %v) paying to %s for order %s, match %s", + t.wallets.toAsset.Symbol, auditInfo.Coin, auditInfo.Recipient, t.ID(), match) return nil } From 785973aebdc9a79a9e193c78b23676b71c13dea4 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Tue, 26 Oct 2021 12:35:53 +0100 Subject: [PATCH 16/22] trade_simnet_test changes for better btc spv wallet support --- client/core/trade.go | 6 +++--- client/core/trade_simnet_test.go | 36 +++++++++++++++++++------------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/client/core/trade.go b/client/core/trade.go index 47787478ca..04674be4a5 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -872,7 +872,7 @@ func (t *trackedTrade) isSwappable(ctx context.Context, match *matchTracker) boo // Just a quick check here. We'll perform a more thorough check if there are // actually swappables. if !wallet.locallyUnlocked() { - t.dc.log.Errorf("cannot swap order %s, match %s, because %s wallet is not unlocked", + t.dc.log.Errorf("not checking if order %s, match %s is swappable because %s wallet is not unlocked", t.ID(), match, unbip(wallet.AssetID)) return false } @@ -948,7 +948,7 @@ func (t *trackedTrade) isRedeemable(ctx context.Context, match *matchTracker) bo // Just a quick check here. We'll perform a more thorough check if there are // actually redeemables. if !wallet.locallyUnlocked() { - t.dc.log.Errorf("cannot redeem order %s, match %s, because %s wallet is not unlocked", + t.dc.log.Errorf("not checking if order %s, match %s is redeemable because %s wallet is not unlocked", t.ID(), match, unbip(wallet.AssetID)) return false } @@ -1024,7 +1024,7 @@ func (t *trackedTrade) isRefundable(match *matchTracker) bool { // Just a quick check here. We'll perform a more thorough check if there are // actually refundables. if !wallet.locallyUnlocked() { - t.dc.log.Errorf("cannot refund order %s, match %s, because %s wallet is not unlocked", + t.dc.log.Errorf("not checking if order %s, match %s is refundable because %s wallet is not unlocked", t.ID(), match, unbip(wallet.AssetID)) return false } diff --git a/client/core/trade_simnet_test.go b/client/core/trade_simnet_test.go index 139018beac..91804fe93b 100644 --- a/client/core/trade_simnet_test.go +++ b/client/core/trade_simnet_test.go @@ -66,7 +66,7 @@ var ( appPass: []byte("client2"), wallets: map[uint32]*tWallet{ dcr.BipID: dcrWallet("trading2"), - btc.BipID: {spv: true}, // the btc/spv startWallet method auto connects to the alpha node for spv syncing + btc.BipID: {created: true, _type: "SPV"}, // the btc/spv startWallet method auto connects to the alpha node for spv syncing }, processedStatus: make(map[order.MatchID]order.MatchStatus), } @@ -102,7 +102,7 @@ func readWalletCfgsAndDexCert() error { for _, client := range clients { dcrw, btcw := client.wallets[dcr.BipID], client.wallets[btc.BipID] - if dcrw.spv { + if dcrw.created { dcrw.config = map[string]string{} } else { dcrw.config, err = config.Parse(filepath.Join(user.HomeDir, "dextest", "dcr", dcrw.daemon, dcrw.daemon+".conf")) @@ -111,7 +111,7 @@ func readWalletCfgsAndDexCert() error { } } - if btcw.spv { + if btcw.created { btcw.config = map[string]string{} } else { btcw.config, err = config.Parse(filepath.Join(user.HomeDir, "dextest", "btc", btcw.daemon, btcw.daemon+".conf")) @@ -153,19 +153,10 @@ func startClients(ctx context.Context) error { // connect wallets for assetID, wallet := range c.wallets { - walletType := "dcrwalletRPC" - if assetID == btc.BipID { - walletType = "bitcoindRPC" - if wallet.spv { - walletType = "SPV" - wallet.pass = nil // should not be set for spv wallets in the first place, but play safe - } - } - os.RemoveAll(c.core.assetDataDirectory(assetID)) err = c.core.CreateWallet(c.appPass, wallet.pass, &WalletForm{ - Type: walletType, + Type: wallet._type, AssetID: assetID, Config: wallet.config, }) @@ -174,7 +165,7 @@ func startClients(ctx context.Context) error { } c.log("Connected %s wallet (spv = %v)", unbip(assetID), wallet.spv) - if wallet.spv { + if wallet.created { c.log("Waiting for %s wallet to sync", unbip(assetID)) for !c.core.WalletState(assetID).Synced { time.Sleep(time.Second) @@ -696,6 +687,11 @@ func TestOrderStatusReconciliation(t *testing.T) { t.Fatalf("client 2 dex not disconnected after %v", disconnectTimeout) } + // Disconnect the wallets, they'll be reconnected when Login is called below. + // Login->connectWallets will error for btc spv wallets if the wallet is not + // first disconnected. + client2.disconnectWallets() + // Allow some time for orders to be revoked due to inaction, and // for requests pending on the server to expire (usually bTimeout). bTimeout := time.Millisecond * time.Duration(c2dc.cfg.BroadcastTimeout) @@ -1307,7 +1303,8 @@ type tWallet struct { walletName string // for btc wallets, put into config map pass []byte config map[string]string - spv bool + _type string // type is a keyword + created bool } func dcrWallet(daemon string) *tWallet { @@ -1608,6 +1605,15 @@ func (client *tClient) disableWallets() { client.core.walletMtx.Unlock() } +func (client *tClient) disconnectWallets() { + client.log("Disconnecting wallets") + client.core.walletMtx.Lock() + for _, wallet := range client.core.wallets { + wallet.Disconnect() + } + client.core.walletMtx.Unlock() +} + func mineBlocks(assetID, blocks uint32) error { return tmuxRun(assetID, fmt.Sprintf("./mine-alpha %d", blocks)) } From 5a53e29d2aad32177d3dfb2d6e825514f9b4f7f5 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Wed, 27 Oct 2021 03:51:47 +0100 Subject: [PATCH 17/22] fix wallet output check for spv wallets --- client/asset/dcr/dcr.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 7648bf990b..5955e41688 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1738,7 +1738,11 @@ func (dcr *ExchangeWallet) lookupTxOutput(ctx context.Context, op outPoint, memp // cache and if also not found, scan block filters to find the output. var eTxOut *externalTxOutput if txOut == nil { - dcr.log.Debugf("Output %s NOT found in wallet, checking cache/block filters", op) + if len(pkScript) == 0 { + dcr.log.Debugf("Output %s NOT found in wallet, checking external tx cache", op) + } else { + dcr.log.Debugf("Output %s NOT found in wallet, checking cache/block filters", op) + } eTxOut, err = dcr.lookupTxOutWithBlockFilters(ctx, op, pkScript, earliestTxTime) if err != nil { return errorOut(err) @@ -1856,6 +1860,10 @@ func (dcr *ExchangeWallet) checkWalletForTxOutput(ctx context.Context, op outPoi // external outputs, even if the wallet tracks the tx. for _, details := range tx.Details { if details.Vout == op.vout { + externalOutput := details.Category == "send" || details.Amount < 0 + if externalOutput { + break + } dcr.log.Debugf("Output %s found by gettransaction but not by gettxout is considered SPENT. SPV mode = %t", op, spvMode) return output, outputSpendStatusSpent, nil } From ab0d0bd88accd9a0c77bb20b0ad5d1b65f746a9a Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Thu, 28 Oct 2021 00:36:00 +0100 Subject: [PATCH 18/22] bump dcr rpc requiredWalletVersion --- client/asset/dcr/rpcwallet.go | 2 +- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/asset/dcr/rpcwallet.go b/client/asset/dcr/rpcwallet.go index 263f6533c3..3b907ff755 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -27,7 +27,7 @@ import ( ) var ( - requiredWalletVersion = dex.Semver{Major: 8, Minor: 7, Patch: 0} // TODO: Update to 8.8.0 for spv getcurrentnetwork support + requiredWalletVersion = dex.Semver{Major: 8, Minor: 8, Patch: 0} requiredNodeVersion = dex.Semver{Major: 7, Minor: 0, Patch: 0} ) diff --git a/go.mod b/go.mod index 5b6bb52cad..acdcd7d62b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module decred.org/dcrdex go 1.16 require ( - decred.org/dcrwallet/v2 v2.0.0-20211006150810-61ab76379a26 // json-rpc 8.7.0 server, TODO: update to 8.8.0 + decred.org/dcrwallet/v2 v2.0.0-20211027145433-305bed7bace0 github.com/btcsuite/btcd v0.22.0-beta.0.20211026140004-31791ba4dc6e github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f github.com/btcsuite/btcutil v1.0.3-0.20210527170813-e2ba6805a890 // note: hoists btcd's own require of btcutil diff --git a/go.sum b/go.sum index 60273818c6..435c8aac50 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= collectd.org v0.3.0/go.mod h1:A/8DzQBkF6abtvrT2j/AU/4tiBgJWYyh0y/oB/4MlWE= decred.org/cspp v0.3.0/go.mod h1:UygjYilC94dER3BEU65Zzyoqy9ngJfWCD2rdJqvUs2A= -decred.org/dcrwallet/v2 v2.0.0-20211006150810-61ab76379a26 h1:4wHBsnnlKIXKr2sNEyWE1kd2pvhhUuEA2GchpZcvy9Q= -decred.org/dcrwallet/v2 v2.0.0-20211006150810-61ab76379a26/go.mod h1:jEw3MCDN3x3QfxaKABdebEalLtN4Fv3zRSavQtLy6ms= +decred.org/dcrwallet/v2 v2.0.0-20211027145433-305bed7bace0 h1:1VQ784qlhC+SW1d3iKN/+ZnyrnmleIc1N2dnGio7Osk= +decred.org/dcrwallet/v2 v2.0.0-20211027145433-305bed7bace0/go.mod h1:jEw3MCDN3x3QfxaKABdebEalLtN4Fv3zRSavQtLy6ms= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= github.com/Azure/azure-pipeline-go v0.2.2/go.mod h1:4rQ/NZncSvGqNkkOsNpOU1tgoNuIlp9AfUH5G1tvCHc= From d79852853d5f930cde12a3899b1fa8f203ad5146 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Thu, 4 Nov 2021 09:23:28 +0100 Subject: [PATCH 19/22] rework dcr.lookupTxOut{WithBlockFilters}, rename dcr.Wallet.GetTxOut --- client/asset/dcr/dcr.go | 422 ++++++++++---------------------- client/asset/dcr/dcr_test.go | 99 ++++---- client/asset/dcr/externaltx.go | 424 +++++++++++++++------------------ client/asset/dcr/rpcwallet.go | 43 ++-- client/asset/dcr/wallet.go | 22 +- client/asset/interface.go | 1 - client/core/trade.go | 109 ++------- 7 files changed, 435 insertions(+), 685 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 5955e41688..e4dc51e135 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -25,6 +25,7 @@ import ( dexdcr "decred.org/dcrdex/dex/networks/dcr" "decred.org/dcrwallet/v2/rpc/client/dcrwallet" walletjson "decred.org/dcrwallet/v2/rpc/jsonrpc/types" + "github.com/btcsuite/btcwallet/wallet" "github.com/decred/dcrd/blockchain/stake/v4" "github.com/decred/dcrd/blockchain/v4" "github.com/decred/dcrd/chaincfg/chainhash" @@ -1174,13 +1175,13 @@ func (dcr *ExchangeWallet) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { if !notFound[pt] { continue } - txOut, _, err := dcr.wallet.GetTxOut(dcr.ctx, txHash, output.Vout, output.Tree, true) + txOut, err := dcr.wallet.UnspentOutput(dcr.ctx, txHash, output.Vout, output.Tree) if err != nil { return nil, fmt.Errorf("gettxout error for locked output %v: %w", pt.String(), err) } var address string - if len(txOut.ScriptPubKey.Addresses) > 0 { - address = txOut.ScriptPubKey.Addresses[0] + if len(txOut.Addresses) > 0 { + address = txOut.Addresses[0] } coin := newOutput(txHash, output.Vout, toAtoms(output.Amount), output.Tree) coins = append(coins, coin) @@ -1460,19 +1461,18 @@ func (dcr *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, addr = fCoin.addr } else { // Check if we can get the address from gettxout. - txOut, _, err := dcr.wallet.GetTxOut(dcr.ctx, op.txHash(), op.vout(), op.tree, true) + txOut, err := dcr.wallet.UnspentOutput(dcr.ctx, op.txHash(), op.vout(), op.tree) if err != nil { dcr.log.Errorf("gettxout error for SignMessage coin %s: %v", op, err) } else if txOut != nil { - addrs := txOut.ScriptPubKey.Addresses - if len(addrs) != 1 { + if len(txOut.Addresses) != 1 { // TODO: SignMessage is usually called for coins selected by // FundOrder. Should consider rejecting/ignoring multisig ops // in FundOrder to prevent this SignMessage error from killing // order placements. return nil, nil, fmt.Errorf("multi-sig not supported") } - addr = addrs[0] + addr = txOut.Addresses[0] found = true } } @@ -1526,73 +1526,49 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t return nil, fmt.Errorf("error extracting swap addresses: %w", err) } - // contractTx will be set using txData if the contract is not found - // on the blockchain. - var contractTx *wire.MsgTx - - // contractTxOut and txTree are set using data from the blockchain - // or from txData/contractTx. - var contractTxOut *wire.TxOut - var txTree int8 - - // First try to pull the contract output details from the blockchain, - // if possible. Do not attempt finding the coin using block filters, - // SwapConfirmations handles that. So pass nil pkScript to the lookup - // method. - op := newOutPoint(txHash, vout) - contractOutput, spendStatus, err := dcr.lookupTxOutput(dcr.ctx, op, true, nil, time.Time{}) - if err != nil && err != asset.CoinNotFoundError { - return nil, err - } else if err == asset.CoinNotFoundError { - // Contract not found on the blockchain. Assume the tx has not been - // broadcasted. Pull the contract output details from the provided - // txData to validate. The txData will be broadcasted below if the - // contract details are valid. - if len(txData) == 0 { - return nil, fmt.Errorf("contract %s not found in blockchain and tx data not provided to audit", op) - } - contractTx = wire.NewMsgTx() - if err := contractTx.Deserialize(bytes.NewReader(txData)); err != nil { - return nil, fmt.Errorf("invalid contract tx data: %w", err) - } - if err = blockchain.CheckTransactionSanity(contractTx, dcr.chainParams); err != nil { - return nil, fmt.Errorf("invalid contract tx data: %w", err) - } - if checkHash := contractTx.TxHash(); checkHash != *txHash { - return nil, fmt.Errorf("invalid contract tx data: expected hash %s, got %s", txHash, checkHash) - } - if int(vout) >= len(contractTx.TxOut) { - return nil, fmt.Errorf("invalid contract tx data: no output at %d", vout) - } - contractTxOut = contractTx.TxOut[vout] - txTree = determineTxTree(contractTx) - } else { - // Contract found on the blockchain. Let's audit that. - spent := spendStatus == outputSpendStatusSpent - if spendStatus == outputSpendStatusUnknown { - dcr.log.Infof("Spend status for counter-party contract %s is unknown, assuming unspent.", op) - } - if spent { - return nil, dex.NewError(asset.CoinIsSpentError, op.String()) - } - amt, err := contractOutput.Amount() - if err != nil { - return nil, err - } - pkScript, err := contractOutput.PkScript() - if err != nil { - return nil, err - } - contractTxOut = newTxOut(int64(amt), contractOutput.scriptVersion, pkScript) - txTree = contractOutput.tree + contractTx := wire.NewMsgTx() + if err := contractTx.Deserialize(bytes.NewReader(txData)); err != nil { + return nil, fmt.Errorf("invalid contract tx data: %w", err) + } + if err = blockchain.CheckTransactionSanity(contractTx, dcr.chainParams); err != nil { + return nil, fmt.Errorf("invalid contract tx data: %w", err) + } + if checkHash := contractTx.TxHash(); checkHash != *txHash { + return nil, fmt.Errorf("invalid contract tx data: expected hash %s, got %s", txHash, checkHash) + } + if int(vout) >= len(contractTx.TxOut) { + return nil, fmt.Errorf("invalid contract tx data: no output at %d", vout) } - // Validate the contract output script gotten from the blockchain or the - // provided txData. - if err = dcr.validateContractOutput(contractTxOut, contract); err != nil { + // Validate contract output. + // Script must be P2SH, with 1 address and 1 required signature. + contractTxOut := contractTx.TxOut[vout] + scriptClass, addrs, sigsReq, err := txscript.ExtractPkScriptAddrs(contractTxOut.Version, contractTxOut.PkScript, dcr.chainParams, false) + if err != nil { + return nil, fmt.Errorf("error extracting script addresses from '%x': %w", contractTxOut.PkScript, err) + } + if scriptClass != txscript.ScriptHashTy { + return nil, fmt.Errorf("unexpected script class %d", scriptClass) + } + if len(addrs) != 1 { + return nil, fmt.Errorf("unexpected number of addresses for P2SH script: %d", len(addrs)) + } + if sigsReq != 1 { + return nil, fmt.Errorf("unexpected number of signatures for P2SH script: %d", sigsReq) + } + // Compare the contract hash to the P2SH address. + contractHash := dcrutil.Hash160(contract) + addr := addrs[0] + addrScript, err := dexdcr.AddressScript(addr) + if err != nil { return nil, err } + if !bytes.Equal(contractHash, addrScript) { + return nil, fmt.Errorf("contract hash doesn't match script address. %x != %x", + contractHash, addrScript) + } + txTree := determineTxTree(contractTx) auditInfo := &asset.AuditInfo{ Coin: newOutput(txHash, vout, uint64(contractTxOut.Value), txTree), Contract: contract, @@ -1601,13 +1577,6 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t Expiration: time.Unix(int64(stamp), 0).UTC(), } - // No need to broadcast txData if we found the contract in the blockchain earlier. - if contractTx == nil { - dcr.log.Infof("Audited contract coin %s:%d using tx data gotten from the blockchain. SPV mode = %t", - txHash, vout, dcr.wallet.SpvMode()) - return auditInfo, nil - } - // The counter-party should have broadcasted the contract tx but // rebroadcast just in case to ensure that the tx is sent to the // network. @@ -1615,40 +1584,7 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t allowHighFees := true // high fees shouldn't prevent this tx from being bcast finalTxHash, err := dcr.wallet.SendRawTransaction(dcr.ctx, contractTx, allowHighFees) if err != nil { - dcr.log.Errorf("Error rebroadcasting contract tx %v: %v", txData, translateRPCCancelErr(err)) - - if !dcr.wallet.SpvMode() { - // The broadcast may have failed because the tx was already - // broadcasted (and maybe mined or even spent), or some other - // unexpected error occurred. Return asset.CoinNotFoundError - // to signal the caller to repeat this audit (including the - // tx rebroadcast attempt) until the tx is found, the raw tx - // is broadcasted successfully or the match is revoked by the - // server. - - // TODO: It'd be unnecessary to continue trying to find this - // contract or to broadcast the rawtx if the tx was already - // broadcasted, mined AND spent. - // Consider modifying dcr.externalTxOut to use block filters to - // check if the tx exists on the blockchain if gettxout returns - // a nil response. If the tx does exist, return CoinIsSpentError - // to the caller. - - return nil, asset.CoinNotFoundError - } - - // SPV wallets do not produce sendrawtransaction errors for already - // broadcasted txs. This must be some other unexpected error. - // Do NOT return an asset.CoinNotFoundError so callers do not recall - // this method as there's no guarantee that the broadcast will succeed - // on subsequent attempts. - // Return a successful audit response because it is possible that the - // tx was already broadcasted and the caller can safely begin waiting - // for confirmations. - // Granted, this wait would be futile if the tx was never broadcasted - // but as explained above, retrying the broadcast isn't a better course - // of action, neither is returning an error here because that would cause - // the caller to potentially give up on this match prematurely. + dcr.log.Errorf("Error rebroadcasting contract tx %v: %v.", txData, translateRPCCancelErr(err)) } else if !finalTxHash.IsEqual(txHash) { return nil, fmt.Errorf("broadcasted contract tx, but received unexpected transaction ID back from RPC server. "+ "expected %s, got %s", txHash, finalTxHash) @@ -1658,35 +1594,6 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t return auditInfo, nil } -func (dcr *ExchangeWallet) validateContractOutput(output *wire.TxOut, contract []byte) error { - // Output script must be P2SH, with 1 address and 1 required signature. - scriptClass, addrs, sigsReq, err := txscript.ExtractPkScriptAddrs(output.Version, output.PkScript, dcr.chainParams, false) - if err != nil { - return fmt.Errorf("error extracting script addresses from '%x': %w", output.PkScript, err) - } - if scriptClass != txscript.ScriptHashTy { - return fmt.Errorf("unexpected script class %d", scriptClass) - } - if len(addrs) != 1 { - return fmt.Errorf("unexpected number of addresses for P2SH script: %d", len(addrs)) - } - if sigsReq != 1 { - return fmt.Errorf("unexpected number of signatures for P2SH script: %d", sigsReq) - } - // Compare the contract hash to the P2SH address. - contractHash := dcrutil.Hash160(contract) - addr := addrs[0] - addrScript, err := dexdcr.AddressScript(addr) - if err != nil { - return err - } - if !bytes.Equal(contractHash, addrScript) { - return fmt.Errorf("contract hash doesn't match script address. %x != %x", - contractHash, addrScript) - } - return nil -} - func determineTxTree(msgTx *wire.MsgTx) int8 { // stake.DetermineTxType will produce correct results if we pass true for // isTreasuryEnabled regardless of whether the treasury vote has activated @@ -1710,168 +1617,73 @@ func determineTxTree(msgTx *wire.MsgTx) int8 { return wire.TxTreeRegular } -func (dcr *ExchangeWallet) lookupTxOutput(ctx context.Context, op outPoint, mempool bool, pkScript []byte, earliestTxTime time.Time) (*txOutput, outputSpendStatus, error) { - errorOut := func(err error) (*txOutput, outputSpendStatus, error) { - return nil, outputSpendStatusUnknown, err - } - - // First perform wallet lookup, may be able to find the output - // even if it doesn't pay to the wallet. - txOut, spendStatus, err := dcr.checkWalletForTxOutput(ctx, op, mempool) - if err != nil && err != asset.CoinNotFoundError { - // Do not return CoinNotFoundError yet, check cache first. - return errorOut(err) - } - - if txOut != nil { - // Output is found in wallet. Is the spend status known? - if spendStatus != outputSpendStatusUnknown { - dcr.log.Debugf("Found output %s in wallet, spent %t", op, spendStatus == outputSpendStatusSpent) - return txOut, spendStatus, nil - } else if len(pkScript) == 0 { - dcr.log.Debugf("Found output %s in wallet, but spend status cannot be determined", op) - return txOut, spendStatus, nil - } - } - - // If the output was not found by the wallet, check the externalTx - // cache and if also not found, scan block filters to find the output. - var eTxOut *externalTxOutput - if txOut == nil { - if len(pkScript) == 0 { - dcr.log.Debugf("Output %s NOT found in wallet, checking external tx cache", op) - } else { - dcr.log.Debugf("Output %s NOT found in wallet, checking cache/block filters", op) - } - eTxOut, err = dcr.lookupTxOutWithBlockFilters(ctx, op, pkScript, earliestTxTime) - if err != nil { - return errorOut(err) - } - } else { - dcr.log.Debugf("Found output %s in wallet, will determine spend status using block filters", op) - // TODO: If it is found in wallet, just assume unspent? - eTxOut = &externalTxOutput{ - txOutput: txOut, - } - } - - // If we have the output details from the wallet lookup or - // the block filters scan above, let's check if it is spent. - spendStatus, err = dcr.checkOutputSpendStatus(ctx, eTxOut) - return eTxOut.txOutput, spendStatus, err -} - -// checkWalletForTxOutput attempts to find and return details for the specified -// tx output. Returns asset.CoinNotFoundError if the output is not found. -// NOTE: SPV wallets are only able to lookup outputs for transactions that are -// tracked by the wallet. -func (dcr *ExchangeWallet) checkWalletForTxOutput(ctx context.Context, op outPoint, mempool bool) (*txOutput, outputSpendStatus, error) { - errorOut := func(err error) (*txOutput, outputSpendStatus, error) { - return nil, outputSpendStatusUnknown, err - } - - // First use wallet.GetTxOut to look up the output. - unspentTxOut, txTree, err := dcr.wallet.GetTxOut(ctx, &op.txHash, op.vout, wire.TxTreeUnknown, mempool) - if err != nil { - if err != asset.CoinNotFoundError { - return errorOut(err) - } - } else { - output := &txOutput{ - op: op, - tree: txTree, - value: unspentTxOut.Value, - scriptVersion: unspentTxOut.ScriptPubKey.Version, - scriptHex: unspentTxOut.ScriptPubKey.Hex, - } - if unspentTxOut.Confirmations > 0 { - tipHash, err := chainhash.NewHashFromStr(unspentTxOut.BestBlock) - if err != nil { - return errorOut(fmt.Errorf("invalid bestblock hash in gettxout response: %v", err)) - } - tip, err := dcr.wallet.GetBlockHeaderVerbose(ctx, tipHash) - if err != nil { - return errorOut(fmt.Errorf("invalid bestblock in gettxout response: %w", err)) - } - if unspentTxOut.Confirmations == 1 { - output.blockHash, output.blockHeight = tipHash, int64(tip.Height) - } else { - output.blockHeight = int64(tip.Height) - unspentTxOut.Confirmations + 1 - output.blockHash, err = dcr.wallet.GetBlockHash(ctx, output.blockHeight) - if err != nil { - return errorOut(fmt.Errorf("invalid block for confirmed gettxout response, confs %d, bestblock %s: %w", - unspentTxOut.Confirmations, unspentTxOut.BestBlock, err)) - } - } - } - return output, outputSpendStatusUnspent, nil +// lookupTxOutput attempts to find and return details for the specified output, +// first checking for an unspent output and if not found, checking wallet txs. +// Returns asset.CoinNotFoundError if the output is not found. +// NOTE: This method is only guaranteed to return results for outputs belonging +// to transactions that are tracked by the wallet, although full node wallets +// are able to look up non-wallet outputs that are unspent. +func (dcr *ExchangeWallet) lookupTxOutput(ctx context.Context, txHash *chainhash.Hash, vout uint32) (*wire.TxOut, uint32, int8, bool, error) { + // Check for an unspent output. + output, err := dcr.wallet.UnspentOutput(ctx, txHash, vout, wire.TxTreeUnknown) + if err == nil { + return output.TxOut, output.Confirmations, output.Tree, false, nil + } else if err != asset.CoinNotFoundError { + return nil, 0, 0, false, err } - // Output not found with wallet.UnspentOutput, check wallet txs. - tx, err := dcr.wallet.GetTransaction(ctx, &op.txHash) + // Check wallet transactions. + tx, err := dcr.wallet.GetTransaction(ctx, txHash) if err != nil { - return errorOut(err) + return nil, 0, 0, false, err } msgTx, err := msgTxFromHex(tx.Hex) if err != nil { - return errorOut(fmt.Errorf("invalid tx hex: %v", err)) + return nil, 0, 0, false, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) } - if int(op.vout) >= len(msgTx.TxOut) { - return errorOut(fmt.Errorf("tx %s has no output at %d", &op.txHash, op.vout)) + if int(vout) >= len(msgTx.TxOut) { + return nil, 0, 0, false, fmt.Errorf("tx %s has no output at %d", txHash, vout) } - // Output found amongst wallet transactions. Get the requested details. - txTree = determineTxTree(msgTx) - txOut := msgTx.TxOut[op.vout] - output := &txOutput{ - op: op, - tree: txTree, - value: dcrutil.Amount(txOut.Value).ToCoin(), - scriptVersion: txOut.Version, - scriptHex: hex.EncodeToString(txOut.PkScript), - } - if tx.Confirmations == 0 { + txOut := msgTx.TxOut[vout] + confs := uint32(tx.Confirmations) + tree := determineTxTree(msgTx) + + // We have the requested output. Check if it is spent. + if confs == 0 { // Only counts as spent if spent in a mined transaction, // unconfirmed tx outputs can't be spent in a mined tx. - return output, outputSpendStatusUnspent, nil - } - - txBlockHash, err := chainhash.NewHashFromStr(tx.BlockHash) - if err != nil { - return nil, 0, fmt.Errorf("invalid tx block hash: %v", err) - } - txBlock, err := dcr.wallet.GetBlockHeaderVerbose(ctx, txBlockHash) - if err != nil { - return nil, 0, fmt.Errorf("invalid tx block: %w", err) + return txOut, confs, tree, false, nil } - output.blockHash, output.blockHeight = txBlockHash, int64(txBlock.Height) - // Infer output spend status. For full node wallets, finding an output - // via gettransaction but not via wallet.GetTxOut means the output is - // spent. - spvMode := dcr.wallet.SpvMode() - if !spvMode { - dcr.log.Debugf("Output %s found by gettransaction but not by gettxout is considered SPENT. SPV mode = %t", op, spvMode) - return output, outputSpendStatusSpent, nil + if !dcr.wallet.SpvMode() { + // A mined output that is not found by wallet.UnspentOutput + // is spent if the wallet is connected to a full node. + dcr.log.Debugf("Output %s:%d that was not reported as unspent is considered SPENT, spv mode = false.", + txHash, vout) + return txOut, confs, tree, true, nil } - // For SPV wallets, the output also has to pay to the wallet to correctly - // infer that it is spent, since SPV wallets do not track spend status for - // external outputs, even if the wallet tracks the tx. + // For SPV wallets, only consider the output spent if it pays to the + // wallet because outputs that don't pay to the wallet may be unspent + // but still not found by wallet.UnspentOutput. + var outputPaysToWallet bool for _, details := range tx.Details { - if details.Vout == op.vout { - externalOutput := details.Category == "send" || details.Amount < 0 - if externalOutput { - break - } - dcr.log.Debugf("Output %s found by gettransaction but not by gettxout is considered SPENT. SPV mode = %t", op, spvMode) - return output, outputSpendStatusSpent, nil + if details.Vout == vout { + outputPaysToWallet = details.Category == wallet.CreditReceive.String() + break } } + if outputPaysToWallet { + dcr.log.Debugf("Output %s:%d was not reported as unspent, pays to the wallet and is considered SPENT.", + txHash, vout) + return txOut, confs, tree, true, nil + } - // SPV wallets do not track spend status for outputs that don't pay to - // the wallet. - return output, outputSpendStatusUnknown, nil + // Assume unspent even though the spend status is not really known. + dcr.log.Debugf("Output %s:%d was not reported as unspent, does not pay to the wallet and is assumed UNSPENT.", + txHash, vout) + return txOut, confs, tree, false, nil } // RefundAddress extracts and returns the refund address from a contract. @@ -2311,16 +2123,19 @@ func (dcr *ExchangeWallet) refundTx(coinID, contract dex.Bytes, val uint64, refu if err != nil { return nil, err } - // Grab the unspent output to make sure it's good and to get the value if not supplied. + // Grab the output, make sure it's unspent and get the value if not supplied. if val == 0 { - utxo, _, err := dcr.lookupTxOutput(dcr.ctx, newOutPoint(txHash, vout), true, nil, time.Time{}) + utxo, _, _, spent, err := dcr.lookupTxOutput(dcr.ctx, txHash, vout) if err != nil { return nil, fmt.Errorf("error finding unspent contract: %w", err) } if utxo == nil { return nil, asset.CoinNotFoundError } - val = toAtoms(utxo.value) + if spent { + return nil, fmt.Errorf("contract %s:%d is spent", txHash, vout) + } + val = uint64(utxo.Value) } sender, _, lockTime, _, err := dexdcr.ExtractSwapDetails(contract, dcr.chainParams) if err != nil { @@ -2504,30 +2319,38 @@ func (dcr *ExchangeWallet) SwapConfirmations(ctx context.Context, coinID, contra if err != nil { return 0, false, err } - op := newOutPoint(txHash, vout) - // May need the pkScript to find the txout (or its spender) using block filters. + // Check if we can find the contract onchain without using cfilters. + _, confs, _, spent, err = dcr.lookupTxOutput(ctx, txHash, vout) + if err == nil { + return confs, spent, nil + } else if err != asset.CoinNotFoundError { + return 0, false, err + } + + // Prepare the pkScript to find the contract output using block filters. scriptAddr, err := stdaddr.NewAddressScriptHashV0(contract, dcr.chainParams) if err != nil { return 0, false, fmt.Errorf("error encoding script address: %w", err) } _, p2shScript := scriptAddr.PaymentScript() - output, spendStatus, err := dcr.lookupTxOutput(ctx, op, true, p2shScript, matchTime) + // Find the contract and it's spend status using block filters. + dcr.log.Debugf("Contract output %s:%d NOT yet found, will attempt finding it with block filters.", txHash, vout) + confs, spent, err = dcr.lookupTxOutWithBlockFilters(ctx, newOutPoint(txHash, vout), p2shScript, matchTime) if err != nil { return 0, false, err } - if spendStatus == outputSpendStatusUnknown { - dcr.log.Infof("Spend status for swap output %s is unknown, assuming unspent.", op) - } - - confs = confirms(dcr.cachedBestBlock().height, output.blockHeight) - spent = spendStatus == outputSpendStatusSpent - return confs, spent, nil + return confs, spent, err } -func confirms(bestBlock, blockHeight int64) uint32 { - return uint32(bestBlock - blockHeight + 1) +func (dcr *ExchangeWallet) confirms(blockHeight int64) uint32 { + tip, err := dcr.getBestBlock(dcr.ctx) + if err != nil { + dcr.log.Errorf("getbestblock error %v", err) + *tip = dcr.cachedBestBlock() + } + return uint32(tip.height - blockHeight + 1) } // RegFeeConfirmations gets the number of confirmations for the specified @@ -2652,14 +2475,15 @@ func (dcr *ExchangeWallet) convertCoin(coin asset.Coin) (*output, error) { if err != nil { return nil, err } - txOut, tree, err := dcr.wallet.GetTxOut(dcr.ctx, txHash, vout, wire.TxTreeUnknown, true) + // TODO: We just need the tree. + txOut, err := dcr.wallet.UnspentOutput(dcr.ctx, txHash, vout, wire.TxTreeUnknown) if err != nil { return nil, fmt.Errorf("error finding unspent output %s:%d: %w", txHash, vout, err) } if txOut == nil { return nil, asset.CoinNotFoundError // maybe spent } - return newOutput(txHash, vout, coin.Value(), tree), nil + return newOutput(txHash, vout, coin.Value(), txOut.Tree), nil } // sendMinusFees sends the amount to the address. Fees are subtracted from the diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index f9faa180c4..247cd0a9d2 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -242,11 +242,6 @@ func (blockchain *tBlockchain) addRawTx(tx *chainjson.TxRawResult) (*chainhash.H // Save prevout and output scripts in block cfilters. blockFilterBuilder := blockchain.v2CFilterBuilders[block.Hash] - if blockFilterBuilder == nil { - blockFilterBuilder = &tV2CFilterBuilder{} - copy(blockFilterBuilder.key[:], randBytes(16)) - blockchain.v2CFilterBuilders[block.Hash] = blockFilterBuilder - } for i := range tx.Vin { input := &tx.Vin[i] prevTx, found := blockchain.rawTxs[input.Txid] @@ -295,6 +290,10 @@ func (blockchain *tBlockchain) blockAt(height int64) (*chainhash.Hash, *chainjso } blockchain.verboseBlockHeaders[prevBlockHash].NextHash = newBlock.Hash + blockFilterBuilder := &tV2CFilterBuilder{} + copy(blockFilterBuilder.key[:], randBytes(16)) + blockchain.v2CFilterBuilders[newBlockHash.String()] = blockFilterBuilder + return &newBlockHash, newBlock } @@ -546,7 +545,7 @@ func (c *tRPCClient) RawRequest(_ context.Context, method string, params []json. defer c.blockchain.mtx.RUnlock() blockFilterBuilder := c.blockchain.v2CFilterBuilders[blkHash] if blockFilterBuilder == nil { - return nil, fmt.Errorf("v2cfilter builder not found for block") + return nil, fmt.Errorf("cfilters builder not found for block %s", blkHash) } v2CFilter, err := blockFilterBuilder.build() if err != nil { @@ -1035,7 +1034,7 @@ func TestReturnCoins(t *testing.T) { t.Fatalf("no error for missing txout") } - node.txOutRes[newOutPoint(tTxHash, 0)] = makeGetTxOutRes(1, 1, tP2PKHScript) + node.txOutRes[newOutPoint(tTxHash, 0)] = makeGetTxOutRes(0, 1, tP2PKHScript) err = wallet.ReturnCoins(coins) if err != nil { t.Fatalf("error with custom coin type: %v", err) @@ -1616,7 +1615,7 @@ func TestSignMessage(t *testing.T) { check() delete(wallet.fundingCoins, op.pt) - txOut := makeGetTxOutRes(1, 5, nil) + txOut := makeGetTxOutRes(0, 5, nil) txOut.ScriptPubKey.Addresses = []string{tPKHAddr.String()} node.txOutRes[newOutPoint(tTxHash, vout)] = txOut check() @@ -2194,49 +2193,48 @@ func TestLookupTxOutput(t *testing.T) { // Bad output coin op.vout = 10 - _, spendStatus, err := wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) + _, _, _, spent, err := wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err == nil { t.Fatalf("no error for bad output coin") } - if spendStatus == outputSpendStatusSpent { + if spent { t.Fatalf("spent is true for bad output coin") } op.vout = 0 // Build the blockchain with some dummy blocks. - rand.Seed(time.Now().Unix()) - for i := 0; i < rand.Intn(100); i++ { - node.blockchain.blockAt(int64(i)) - } - tipHash, tipHeight := node.getBestBlock() - outputHeight := tipHeight - 1 // i.e. 2 confirmations - outputBlockHash, _ := node.blockchain.blockAt(outputHeight) + // rand.Seed(time.Now().Unix()) + // node.blockchain.mtx.Lock() + // for i := 1; i < rand.Intn(100); i++ { + // node.blockchain.blockAt(int64(i)) + // } + // node.blockchain.mtx.Unlock() + // wallet.checkForNewBlocks() // update the tip cache + // tipHash, tipHeight := node.getBestBlock() + // outputHeight := tipHeight - 1 // i.e. 2 confirmations + // outputBlockHash, _ := node.blockchain.blockAt(outputHeight) // Add the txOutRes with 2 confs and BestBlock correctly set. node.txOutRes[op] = makeGetTxOutRes(2, 1, tP2PKHScript) - node.txOutRes[op].BestBlock = tipHash.String() - txOut, spendStatus, err := wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) + _, confs, _, spent, err := wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err != nil { - t.Fatalf("unexpected lookupTxOutput error: %v", err) - } - if txOut.blockHeight != outputHeight { - t.Fatalf("output block height not retrieved from gettxout path. expected %d, got %d", outputHeight, txOut.blockHeight) + t.Fatalf("unexpected error for gettxout path: %v", err) } - if confirms(tipHeight, txOut.blockHeight) != 2 { - t.Fatalf("confs not retrieved from gettxout path. expected 2, got %d", confirms(tipHeight, txOut.blockHeight)) + if confs != 2 { + t.Fatalf("confs not retrieved from gettxout path. expected 2, got %d", confs) } - if spendStatus == outputSpendStatusSpent { + if spent { t.Fatalf("expected spent = false for gettxout path, got true") } // gettransaction error delete(node.txOutRes, op) node.walletTxErr = tErr - _, spendStatus, err = wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) + _, _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err == nil { t.Fatalf("no error for gettransaction error") } - if spendStatus == outputSpendStatusSpent { + if spent { t.Fatalf("spent is true with gettransaction error") } node.walletTxErr = nil @@ -2246,7 +2244,9 @@ func TestLookupTxOutput(t *testing.T) { // considered spent. tx := wire.NewMsgTx() tx.AddTxIn(&wire.TxIn{}) - tx.AddTxOut(&wire.TxOut{}) + tx.AddTxOut(&wire.TxOut{ + PkScript: tP2PKHScript, + }) txHex, err := msgTxToHex(tx) if err != nil { t.Fatalf("error preparing tx hex with 1 output: %v", err) @@ -2255,46 +2255,45 @@ func TestLookupTxOutput(t *testing.T) { Hex: txHex, Confirmations: 0, // unconfirmed = unspent } - _, spendStatus, err = wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) + _, _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err != nil { - t.Fatalf("coin error: %v", err) + t.Fatalf("unexpected error for gettransaction path (unconfirmed): %v", err) } - if spendStatus != outputSpendStatusUnspent { - t.Fatalf("expected spent = false for gettransaction path, got true") + if spent { + t.Fatalf("expected spent = false for gettransaction path (unconfirmed), got true") } // Confirmed wallet tx without gettxout response is spent. node.walletTx.Confirmations = 2 - node.walletTx.BlockHash = outputBlockHash.String() - _, spendStatus, err = wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) + _, _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err != nil { - t.Fatalf("coin error: %v", err) + t.Fatalf("unexpected error for gettransaction path (confirmed): %v", err) } - if spendStatus != outputSpendStatusSpent { - t.Fatalf("expected spent = true for gettransaction path, got false") + if !spent { + t.Fatalf("expected spent = true for gettransaction path (confirmed), got false") } - // In spv mode, spend status is unknown unless the output pays to the - // wallet (then it's considered be considered spent). + // In spv mode, output is assumed unspent if it doesn't pay to the wallet. (wallet.wallet.(*rpcWallet)).spvMode = true - _, spendStatus, err = wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) + _, _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err != nil { - t.Fatalf("coin error: %v", err) + t.Fatalf("unexpected error for spv gettransaction path (non-wallet output): %v", err) } - if spendStatus != outputSpendStatusUnknown { - t.Fatalf("expected spent = unknown for spv gettransaction path, got %t", spendStatus == outputSpendStatusSpent) + if spent { + t.Fatalf("expected spent = false for spv gettransaction path (non-wallet output), got true") } - // In spv mode, the output must pay to the wallet be considered spent. + // In spv mode, output is spent if it pays to the wallet. node.walletTx.Details = []walletjson.GetTransactionDetailsResult{{ - Vout: 0, + Vout: 0, + Category: "receive", // output at index 0 pays to the wallet }} - _, spendStatus, err = wallet.lookupTxOutput(context.Background(), op, true, nil, time.Time{}) + _, _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err != nil { - t.Fatalf("coin error: %v", err) + t.Fatalf("unexpected error for spv gettransaction path (wallet output): %v", err) } - if spendStatus != outputSpendStatusSpent { - t.Fatalf("expected spent = true for spv gettransaction path, got false") + if !spent { + t.Fatalf("expected spent = true for spv gettransaction path (wallet output), got false") } } diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index 99f9194e57..61be5c4559 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -12,185 +12,138 @@ import ( "decred.org/dcrdex/client/asset" "github.com/decred/dcrd/chaincfg/chainhash" - "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/gcs/v3" "github.com/decred/dcrd/gcs/v3/blockcf2" chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v3" -) - -type outputSpendStatus uint8 - -const ( - outputSpendStatusUnknown outputSpendStatus = iota - outputSpendStatusSpent - outputSpendStatusUnspent + "github.com/decred/dcrd/wire" ) type externalTx struct { hash *chainhash.Hash - mtx sync.RWMutex // protects access to pkScripts - pkScripts [][]byte - - // blockMtx protects access to the fields below because - // they are set when the tx's block is found and cleared - // when the previously found tx block is orphaned. + // blockMtx protects access to the fields below it, which + // are set when the tx's block is found and cleared when + // the previously found tx block is orphaned. blockMtx sync.RWMutex lastScannedBlock *chainhash.Hash block *block tree int8 - outputs []*externalTxOutput + outputSpenders []*outputSpenderFinder } -type externalTxOutput struct { - *txOutput +type outputSpenderFinder struct { + *wire.TxOut + op outPoint + tree int8 - // The spenderMtx protects access to the fields below - // because they are set when the block containing the tx - // that spends this output is found and cleared when the - // previously found block is orphaned. spenderMtx sync.RWMutex lastScannedBlock *chainhash.Hash spenderBlock *block } -// txOutput defines properties of a transaction output, including the -// details of the block containing the tx, if mined. -type txOutput struct { - op outPoint - tree int8 - value float64 - scriptVersion uint16 - scriptHex string - blockHash *chainhash.Hash - blockHeight int64 -} - -func (txOut *txOutput) PkScript() ([]byte, error) { - pkScript, err := hex.DecodeString(txOut.scriptHex) - if err != nil { - return nil, fmt.Errorf("invalid pkScript: %v", err) +// lookupTxOutWithBlockFilters returns confirmations and spend status of the +// requested output. If the block containing the output is not yet known, a +// a block filters scan is conducted to determine if the output is mined in a +// block between the current best block and the block just before the provided +// earliestTxTime. Returns asset.CoinNotFoundError if the block containing the +// output is not found. +func (dcr *ExchangeWallet) lookupTxOutWithBlockFilters(ctx context.Context, op outPoint, pkScript []byte, earliestTxTime time.Time) (uint32, bool, error) { + if len(pkScript) == 0 { + return 0, false, fmt.Errorf("cannot perform block filters lookup without a script") } - return pkScript, nil -} -func (txOut *txOutput) Amount() (dcrutil.Amount, error) { - amt, err := dcrutil.NewAmount(txOut.value) + output, outputBlock, err := dcr.externalTxOutput(ctx, op, pkScript, earliestTxTime) if err != nil { - return 0, fmt.Errorf("invalid amount: %v", err) + return 0, false, err } - return amt, nil -} -// externalTx returns details for the provided hash, if cached. If the tx cache -// doesn't yet exist and addToCache is true, the provided script will be cached -// against the tx hash so block filters can be used to locate the tx in a block -// when it is mined. Once mined, the block containing the tx and the tx outputs -// details are also cached, to enable subsequently checking if any of the tx's -// output is spent in a mined transaction. -// -// This method should only be used with transactions that are NOT indexed by the -// wallet such as counter-party swaps. -func (dcr *ExchangeWallet) externalTx(hash *chainhash.Hash, pkScript []byte) *externalTx { - dcr.externalTxMtx.Lock() - defer dcr.externalTxMtx.Unlock() - - tx := dcr.externalTxCache[*hash] - if tx == nil && len(pkScript) > 0 { - tx = &externalTx{ - hash: hash, - pkScripts: [][]byte{pkScript}, - } - dcr.externalTxCache[*hash] = tx - dcr.log.Debugf("Script %x cached for non-wallet tx %s.", pkScript, hash) + spent, err := dcr.isOutputSpent(ctx, output) + if err != nil { + return 0, false, fmt.Errorf("error checking if output %s is spent: %v", op, err) } - // TODO: Consider appending this pkScript to the tx if cached. - return tx + return dcr.confirms(outputBlock.height), spent, nil } -// lookupTxOutWithBlockFilters returns details for the specified transaction -// output if cached or if found via a block filters scan. If the pkScript is -// not provided, details will only be returned if cached and if the block that -// was found to contain the output is still part of the mainchain. If a block -// filters scan is conducted and the output is found in a mainchain block, its -// details is cached and returned. -// Returns asset.CoinNotFoundError if the requested output details is not cached -// and (if pkScript is provided), if the tx is not found in a block between the -// current best block and the block just before the provided earliestTxTime. -// -// This method should only be used with transactions that are NOT indexed by the -// wallet such as counter-party swaps. -func (dcr *ExchangeWallet) lookupTxOutWithBlockFilters(ctx context.Context, op outPoint, pkScript []byte, earliestTxTime time.Time) (*externalTxOutput, error) { - tx := dcr.externalTx(&op.txHash, pkScript) // the txHash and script will be cached if not previously cached and if pkScript is provided +// externalTxOutput attempts to locate the requested tx output in a mainchain +// block and if found, returns the output details along with the block details. +func (dcr *ExchangeWallet) externalTxOutput(ctx context.Context, op outPoint, pkScript []byte, earliestTxTime time.Time) (*outputSpenderFinder, *block, error) { + dcr.externalTxMtx.Lock() + tx := dcr.externalTxCache[op.txHash] if tx == nil { - return nil, asset.CoinNotFoundError + tx = &externalTx{hash: &op.txHash} + dcr.externalTxCache[op.txHash] = tx } + dcr.externalTxMtx.Unlock() // Hold the tx.blockMtx lock for 2 reasons: - // 1) To read/write the tx.block, tx.tree and tx.outputs fields. + // 1) To read/write the tx.block, tx.tree and tx.outputSpenders fields. // 2) To prevent duplicate tx block scans if this tx block is not already - // known and tryFindTx is true. Holding this lock now ensures that any - // ongoing scan completes before we try to access the tx.block field - // which may prevent unnecassary rescan. + // known. Holding this lock now ensures that any ongoing scan completes + // before we try to access the tx.block field which may prevent + // unnecessary rescan. tx.blockMtx.Lock() defer tx.blockMtx.Unlock() - // Check if we already know the block for this tx and if it is still - // part of the mainchain. Otherwise, and if tryFindTx is true, perform - // a block filters scan for this tx. - tryFindTx := len(pkScript) > 0 // the tx scripts are already cached but don't scan if this caller did not provide scripts - txBlock, err := dcr.externalTxBlock(ctx, tx, tryFindTx, earliestTxTime) + // First check if the tx block is cached. + txBlock, err := dcr.txBlockFromCache(ctx, tx) if err != nil { - return nil, fmt.Errorf("error checking if tx %s is mined: %v", op.txHash, err) + return nil, nil, fmt.Errorf("error checking if tx %s is known to be mined: %v", tx.hash, err) } + + // Scan block filters to find the tx block if it is yet unknown. if txBlock == nil { - return nil, asset.CoinNotFoundError + txBlock, err = dcr.scanFiltersForTxBlock(ctx, tx, [][]byte{pkScript}, earliestTxTime) + if err != nil { + return nil, nil, fmt.Errorf("error checking if tx %s is mined: %v", tx.hash, err) + } } - if len(tx.outputs) <= int(op.vout) { - return nil, fmt.Errorf("tx %s does not have an output at index %d", op.txHash, op.vout) + if txBlock == nil { + return nil, nil, asset.CoinNotFoundError } - return tx.outputs[op.vout], nil + if len(tx.outputSpenders) <= int(op.vout) { + return nil, nil, fmt.Errorf("tx %s does not have an output at index %d", tx.hash, op.vout) + } + + return tx.outputSpenders[op.vout], txBlock, nil } -// externalTxBlock returns the mainchain block containing the provided tx, if -// it is known. If the tx block is yet unknown or has been re-orged out of the -// mainchain AND if tryFindTxBlock is true, this method attempts to find the -// block containing the provided tx by scanning block filters from the current -// best block down to the block just before earliestTxTime or the block that was -// last scanned, if there was a previous scan. If the tx block is found, the -// block hash, height and the tx outputs details are cached. -// Requires the tx.scanMtx to be locked for write. -func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, tryFindTxBlock bool, earliestTxTime time.Time) (*block, error) { - if tx.block != nil { - txBlockStillValid, err := dcr.isMainchainBlock(ctx, tx.block) - if err != nil { - return nil, err - } else if txBlockStillValid { - dcr.log.Debugf("Cached tx %s is mined in block %d (%s).", tx.hash, tx.block.height, tx.block.hash) - return tx.block, nil - } else { - // Tx block was previously set but seems to have been invalidated. - // Clear the tx tree, outputs and block info fields that must have - // been previously set. - dcr.log.Warnf("Block %s found to contain tx %s has been invalidated.", tx.block.hash, tx.hash) - tx.block = nil - tx.tree = -1 - tx.outputs = nil - } +// txBlockFromCache returns the block containing this tx if it's known and +// still part of the mainchain. It is not an error if the block is unknown +// or invalidated. +// The tx.blockMtx MUST be locked for writing. +func (dcr *ExchangeWallet) txBlockFromCache(ctx context.Context, tx *externalTx) (*block, error) { + if tx.block == nil { + return nil, nil } - // Tx block is currently unknown. Return if the caller does not want - // to start a search for the tx block. - if !tryFindTxBlock { - return nil, nil + txBlockStillValid, err := dcr.isMainchainBlock(ctx, tx.block) + if err != nil { + return nil, err } - // Start a new search for this tx's block using the associated scripts. - tx.mtx.RLock() - txScripts := tx.pkScripts - tx.mtx.RUnlock() + if txBlockStillValid { + dcr.log.Debugf("Cached tx %s is mined in block %d (%s).", tx.hash, tx.block.height, tx.block.hash) + return tx.block, nil + } + + // Tx block was previously set but seems to have been invalidated. + // Clear the tx tree, outputs and block info fields that must have + // been previously set. + dcr.log.Warnf("Block %s found to contain tx %s has been invalidated.", tx.block.hash, tx.hash) + tx.block = nil + tx.tree = -1 + tx.outputSpenders = nil + return nil, nil +} +// scanFiltersForTxBlock attempts to find the block containing the provided tx +// by scanning block filters from the current best block down to the block just +// before earliestTxTime or the block that was last scanned, if there was a +// previous scan. If the tx block is found, the block hash, height and the tx +// outputs details are cached; and the block is returned. +// The tx.blockMtx MUST be locked for writing. +func (dcr *ExchangeWallet) scanFiltersForTxBlock(ctx context.Context, tx *externalTx, txScripts [][]byte, earliestTxTime time.Time) (*block, error) { // Scan block filters in reverse from the current best block to the last // scanned block. If the last scanned block has been re-orged out of the // mainchain, scan back to the mainchain ancestor of the lastScannedBlock. @@ -218,7 +171,7 @@ func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, dcr.log.Warnf("Previous cfilters look up for tx %s stopped at block %d but current tip is %d?", tx.hash, lastScannedBlock.height, currentTip.height) } - return nil, nil + return nil, nil // no new blocks to scan } iHash := currentTip.hash @@ -234,49 +187,16 @@ func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, } for { - // Check if this block has the tx we're looking for. - blockFilter, err := dcr.getBlockFilterV2(ctx, iHash) + msgTx, outputSpenders, err := dcr.findTxInBlock(ctx, tx.hash, txScripts, iHash) if err != nil { return nil, err } - if blockFilter.MatchAny(txScripts) { - dcr.log.Debugf("Block %d (%s) likely contains tx %s. Confirming.", iHeight, iHash, tx.hash) - blk, err := dcr.getBlock(ctx, iHash, true) - if err != nil { - return nil, err - } - blockTxs := append(blk.RawTx, blk.RawSTx...) - for i := range blockTxs { - blkTx := &blockTxs[i] - if blkTx.Txid != tx.hash.String() { - continue // check next block tx - } - dcr.log.Debugf("Found mined tx %s in block %d (%s).", tx.hash, iHeight, iHash) - msgTx, err := msgTxFromHex(blkTx.Hex) - if err != nil { - return nil, fmt.Errorf("invalid hex for tx %s: %v", tx.hash, err) - } - tx.block = &block{hash: iHash, height: iHeight} - tx.tree = determineTxTree(msgTx) - tx.outputs = make([]*externalTxOutput, len(blkTx.Vout)) - for i := range blkTx.Vout { - blkTxOut := &blkTx.Vout[i] - tx.outputs[i] = &externalTxOutput{ - txOutput: &txOutput{ - op: newOutPoint(tx.hash, blkTxOut.N), - tree: tx.tree, - value: blkTxOut.Value, - scriptVersion: blkTxOut.ScriptPubKey.Version, - scriptHex: blkTxOut.ScriptPubKey.Hex, - blockHash: iHash, - blockHeight: iHeight, - }, - } - } - return tx.block, nil - } - dcr.log.Debugf("Block %d (%s) does NOT contain tx %s.", iHeight, iHash, tx.hash) + if msgTx != nil { + tx.block = &block{hash: iHash, height: iHeight} + tx.tree = determineTxTree(msgTx) + tx.outputSpenders = outputSpenders + return tx.block, nil } // Block does not include the tx, check the previous block. @@ -306,84 +226,143 @@ func (dcr *ExchangeWallet) externalTxBlock(ctx context.Context, tx *externalTx, } } -// checkOutputSpendStatus checks if the provided output is known to be spent -// by a mined transaction. This may involve scanning block filters to attempt -// finding a block that contains the spender of the provided output. -// -// This method should only be used with transaction outputs that do NOT pay to -// the wallet such as swap contracts including those sent from this wallet. -func (dcr *ExchangeWallet) checkOutputSpendStatus(ctx context.Context, output *externalTxOutput) (outputSpendStatus, error) { +func (dcr *ExchangeWallet) findTxInBlock(ctx context.Context, txHash *chainhash.Hash, txScripts [][]byte, blockHash *chainhash.Hash) (*wire.MsgTx, []*outputSpenderFinder, error) { + blockFilter, err := dcr.getBlockFilterV2(ctx, blockHash) + if err != nil { + return nil, nil, err + } + if !blockFilter.MatchAny(txScripts) { + return nil, nil, nil + } + + blk, err := dcr.getBlock(ctx, blockHash, true) + if err != nil { + return nil, nil, err + } + + var txHex string + blockTxs := append(blk.RawTx, blk.RawSTx...) + for t := range blockTxs { + blkTx := &blockTxs[t] + if blkTx.Txid == txHash.String() { + dcr.log.Debugf("Found mined tx %s in block %d (%s).", txHash, blk.Height, blk.Hash) + txHex = blkTx.Hex + break + } + } + + if txHex == "" { + dcr.log.Debugf("Block %d (%s) filters matched scripts for tx %s but does NOT contain the tx.", blk.Height, blk.Hash, txHash) + return nil, nil, nil + } + + msgTx, err := msgTxFromHex(txHex) + if err != nil { + return nil, nil, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) + } + + // We have the txs in this block, check if any them spends an output + // from the original tx. + outputSpenders := make([]*outputSpenderFinder, len(msgTx.TxOut)) + for i, txOut := range msgTx.TxOut { + outputSpenders[i] = &outputSpenderFinder{ + TxOut: txOut, + op: newOutPoint(txHash, uint32(i)), + tree: determineTxTree(msgTx), + lastScannedBlock: blockHash, + } + } + for t := range blockTxs { + blkTx := &blockTxs[t] + if blkTx.Txid == txHash.String() { + continue // oriignal tx, ignore + } + for i := range blkTx.Vin { + input := &blkTx.Vin[i] + if input.Txid == txHash.String() { // found a spender + outputSpenders[input.Vout].spenderBlock = &block{blk.Height, blockHash} + } + } + } + + return msgTx, outputSpenders, nil +} + +func (dcr *ExchangeWallet) isOutputSpent(ctx context.Context, output *outputSpenderFinder) (bool, error) { // Hold the output.spenderMtx lock for 2 reasons: // 1) To read (and set) the spenderBlock field. // 2) To prevent duplicate spender block scans if the spenderBlock is not // already known. Holding this lock now ensures that any ongoing scan // completes before we try to access the output.spenderBlock field - // which may prevent unnecassary rescan. + // which may prevent unnecessary rescan. output.spenderMtx.Lock() defer output.spenderMtx.Unlock() - errorOut := func(err error) (outputSpendStatus, error) { - return outputSpendStatusUnknown, err - } - // Check if this output is known to be spent in a mainchain block. if output.spenderBlock != nil { spenderBlockStillValid, err := dcr.isMainchainBlock(ctx, output.spenderBlock) if err != nil { - return errorOut(err) - } else if spenderBlockStillValid { + return false, err + } + if spenderBlockStillValid { dcr.log.Debugf("Found cached information for the spender of %s.", output.op) - return outputSpendStatusSpent, nil - } else { - // Output was previously found to have been spent but the block - // containing the spending tx seems to have been invalidated. - dcr.log.Warnf("Block %s found to contain spender of output %s has been invalidated.", output.spenderBlock.hash, output.op) - output.spenderBlock = nil + return true, nil } + // Output was previously found to have been spent but the block + // containing the spending tx seems to have been invalidated. + dcr.log.Warnf("Block %s found to contain spender of output %s has been invalidated.", + output.spenderBlock.hash, output.op) + output.spenderBlock = nil } // This tx output is not known to be spent as of last search (if any). - // Scan blocks from the lastScannedBlock (if there was a previous scan) - // or from the block containing the output to attempt finding the spender - // of this output. Use mainChainAncestor to ensure that scanning starts - // from a mainchain block in the event that either the output block or - // the lastScannedBlock have been re-orged out of the mainchain. - firstSearch := output.lastScannedBlock == nil - startBlock := new(block) - var err error - if firstSearch { - // TODO: Should be a fatal error if the output's block is re-orged - // out of the mainchain! - startBlock.hash, startBlock.height, err = dcr.mainChainAncestor(ctx, output.blockHash) - } else { - startBlock.hash, startBlock.height, err = dcr.mainChainAncestor(ctx, output.lastScannedBlock) - } + // Scan block filters starting from the block after the tx block or the + // lastScannedBlock (if there was a previous scan). Use mainChainAncestor + // to ensure that scanning starts from a mainchain block in the event that + // the lastScannedBlock have been re-orged out of the mainchain. We already + // checked that the txBlock is not invalidated above. + _, lastScannedHeight, err := dcr.mainChainAncestor(ctx, output.lastScannedBlock) if err != nil { - return errorOut(err) + return false, err + } + nextScanHeight := lastScannedHeight + 1 + + bestBlock := dcr.cachedBestBlock() + if nextScanHeight >= bestBlock.height { + if nextScanHeight > bestBlock.height { + dcr.log.Warnf("Attempted to look for output spender in block %d but current tip is %d!", + nextScanHeight, bestBlock.height) + } + // No new blocks to scan, output isn't spent as of last scan. + return false, nil } // Search for this output's spender in the blocks between startBlock and // the current best block. - outputPkScript, err := output.PkScript() + nextScanHash, err := dcr.getBlockHash(nextScanHeight) if err != nil { - return errorOut(err) + return false, err } - spenderTx, stopBlockHash, err := dcr.findTxOutSpender(ctx, output.op, outputPkScript, startBlock, firstSearch) + spenderTx, stopBlockHash, err := dcr.findTxOutSpender(ctx, output.op, output.PkScript, &block{nextScanHeight, nextScanHash}) if stopBlockHash != nil { // might be nil if the search never scanned a block output.lastScannedBlock = stopBlockHash } if err != nil { - return errorOut(err) + return false, err } - if spenderTx != nil { + + // Cache relevant spender info if the spender is found. + spent := spenderTx != nil + if spent { spenderBlockHash, err := chainhash.NewHashFromStr(spenderTx.BlockHash) if err != nil { - return errorOut(err) + // dcr.log.Errorf("Invalid hash (%s) for tx that spends output %s: %v", spenderTx.BlockHash, output.op, err) + return false, fmt.Errorf("invalid hash (%s) for tx that spends output %s: %v", spenderTx.BlockHash, output.op, err) + } else { + output.spenderBlock = &block{hash: spenderBlockHash, height: spenderTx.BlockHeight} } - output.spenderBlock = &block{hash: spenderBlockHash, height: spenderTx.BlockHeight} - return outputSpendStatusSpent, nil } - return outputSpendStatusUnspent, nil + return spent, err } // findTxOutSpender attempts to find and return the tx that spends the provided @@ -393,21 +372,10 @@ func (dcr *ExchangeWallet) checkOutputSpendStatus(ctx context.Context, output *e // If no tx is found to spend the provided output, the hash of the block that // was last checked is returned along with any error that may have occurred // during the search. -func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, outputPkScript []byte, startBlock *block, firstSearch bool) (*chainjson.TxRawResult, *chainhash.Hash, error) { - bestBlock := dcr.cachedBestBlock() - if startBlock.height < bestBlock.height || (firstSearch && startBlock.height == bestBlock.height) { - dcr.log.Debugf("Searching if output %s is spent in blocks %d (%s) to %d (%s) using pkScript %x.", - op, startBlock.height, startBlock.hash, bestBlock.height, bestBlock.hash, outputPkScript) - } else { - if startBlock.height > bestBlock.height { - dcr.log.Warnf("Attempting to look for output spender in block %d but current tip is %d?", - startBlock.height, bestBlock.height) - } - return nil, nil, nil - } - +func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, outputPkScript []byte, startBlock *block) (*chainjson.TxRawResult, *chainhash.Hash, error) { iHeight := startBlock.height iHash := startBlock.hash + bestBlock := dcr.cachedBestBlock() for { blockFilter, err := dcr.getBlockFilterV2(ctx, iHash) if err != nil { diff --git a/client/asset/dcr/rpcwallet.go b/client/asset/dcr/rpcwallet.go index 3b907ff755..314ebe0876 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -5,6 +5,7 @@ package dcr import ( "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -377,15 +378,14 @@ func (w *rpcWallet) LockUnspent(ctx context.Context, unlock bool, ops []*wire.Ou return translateRPCCancelErr(w.rpcClient.LockUnspent(ctx, unlock, ops)) } -// GetTxOut returns information about an unspent tx output, if found and -// is unspent. Use wire.TxTreeUnknown if the output tree is unknown, the +// UnspentOutput returns information about an unspent tx output, if found +// and unspent. Use wire.TxTreeUnknown if the output tree is unknown, the // correct tree will be returned if the unspent output is found. -// An asset.CoinNotFoundError is returned if the unspent output cannot be -// located. UnspentOutput is only guaranteed to return results for outputs -// that pay to the wallet. +// This method is only guaranteed to return results for outputs that pay to +// the wallet. Returns asset.CoinNotFoundError if the unspent output cannot +// be located. // Part of the Wallet interface. -func (w *rpcWallet) GetTxOut(ctx context.Context, txHash *chainhash.Hash, index uint32, tree int8, mempool bool) (*chainjson.GetTxOutResult, int8, error) { - // Check for unspent output with gettxout rpc. +func (w *rpcWallet) UnspentOutput(ctx context.Context, txHash *chainhash.Hash, index uint32, tree int8) (*TxOutput, error) { var checkTrees []int8 switch { case tree == wire.TxTreeUnknown: @@ -393,21 +393,36 @@ func (w *rpcWallet) GetTxOut(ctx context.Context, txHash *chainhash.Hash, index case tree == wire.TxTreeRegular || tree == wire.TxTreeStake: checkTrees = []int8{tree} default: - return nil, wire.TxTreeUnknown, fmt.Errorf("invalid tx tree %d", tree) + return nil, fmt.Errorf("invalid tx tree %d", tree) } for _, tree := range checkTrees { - txout, err := w.rpcClient.GetTxOut(ctx, txHash, index, tree, mempool) + txOut, err := w.rpcClient.GetTxOut(ctx, txHash, index, tree, true) if err != nil { - return nil, tree, translateRPCCancelErr(err) + return nil, translateRPCCancelErr(err) } - if txout != nil { - return txout, tree, nil + if txOut == nil { + continue } + + amount, err := dcrutil.NewAmount(txOut.Value) + if err != nil { + return nil, fmt.Errorf("invalid amount %f: %v", txOut.Value, err) + } + pkScript, err := hex.DecodeString(txOut.ScriptPubKey.Hex) + if err != nil { + return nil, fmt.Errorf("invalid ScriptPubKey %s: %v", txOut.ScriptPubKey.Hex, err) + } + output := &TxOutput{ + TxOut: newTxOut(int64(amount), txOut.ScriptPubKey.Version, pkScript), + Tree: tree, + Addresses: txOut.ScriptPubKey.Addresses, + Confirmations: uint32(txOut.Confirmations), + } + return output, nil } - // Return asset.CoinNotFoundError if no result was gotten from gettxout. - return nil, wire.TxTreeUnknown, asset.CoinNotFoundError + return nil, asset.CoinNotFoundError } // GetNewAddressGapPolicy returns an address from the specified account using diff --git a/client/asset/dcr/wallet.go b/client/asset/dcr/wallet.go index 7324776c3a..af7f200ecc 100644 --- a/client/asset/dcr/wallet.go +++ b/client/asset/dcr/wallet.go @@ -84,13 +84,14 @@ type Wallet interface { GetChangeAddress(ctx context.Context, account string) (stdaddr.Address, error) // LockUnspent locks or unlocks the specified outpoint. LockUnspent(ctx context.Context, unlock bool, ops []*wire.OutPoint) error - // GetTxOut returns information about an unspent tx output, if found and - // is unspent. Use wire.TxTreeUnknown if the output tree is unknown, the + // UnspentOutput returns information about an unspent tx output, if found + // and unspent. Use wire.TxTreeUnknown if the output tree is unknown, the // correct tree will be returned if the unspent output is found. - // An asset.CoinNotFoundError is returned if the unspent output cannot be - // located. UnspentOutput is only guaranteed to return results for outputs - // that pay to the wallet. - GetTxOut(ctx context.Context, txHash *chainhash.Hash, index uint32, tree int8, mempool bool) (*chainjson.GetTxOutResult, int8, error) + // This method is only guaranteed to return results for outputs that pay to + // the wallet, although wallets connected to a full node may return results + // for non-wallet outputs. Returns asset.CoinNotFoundError if the unspent + // output cannot be located. + UnspentOutput(ctx context.Context, txHash *chainhash.Hash, index uint32, tree int8) (*TxOutput, error) // GetNewAddressGapPolicy returns an address from the specified account using // the specified gap policy. GetNewAddressGapPolicy(ctx context.Context, account string, gap dcrwallet.GapPolicy) (stdaddr.Address, error) @@ -137,3 +138,12 @@ type Wallet interface { // AddressPrivKey fetches the privkey for the specified address. AddressPrivKey(ctx context.Context, address stdaddr.Address) (*dcrutil.WIF, error) } + +// TxOutput defines properties of a transaction output, including the +// details of the block containing the tx, if mined. +type TxOutput struct { + *wire.TxOut + Tree int8 + Addresses []string + Confirmations uint32 +} diff --git a/client/asset/interface.go b/client/asset/interface.go index b7781a96c1..a21c3c50fe 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -16,7 +16,6 @@ import ( // exist and be unspent. const ( CoinNotFoundError = dex.ErrorKind("coin not found") - CoinIsSpentError = dex.ErrorKind("coin is spent") ErrRequestTimeout = dex.ErrorKind("request timeout") ErrConnectionDown = dex.ErrorKind("wallet not connected") ErrNotImplemented = dex.ErrorKind("not implemented") diff --git a/client/core/trade.go b/client/core/trade.go index 04674be4a5..15cf2c9c4d 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -896,18 +896,7 @@ func (t *trackedTrade) isSwappable(ctx context.Context, match *matchTracker) boo match.MetaData.Proof.SelfRevoked = true return false } - if !ready { - return false - } - // Re-audit the contract now that we know it is mined - // and has the required confirmations. - if err := t.reAuditContract(match); err != nil { - t.dc.log.Errorf("Match %s not swappable: repeat audit of maker's contract failed: %v", - match, err) - match.swapErr = fmt.Errorf("Counter-party contract repeat audit error: %v", err) - return false - } - return true + return ready } // If we're the maker, check the confirmations anyway so we can notify. t.dc.log.Tracef("Checking confirmations on our OWN swap txn %v (%s)...", @@ -967,18 +956,7 @@ func (t *trackedTrade) isRedeemable(ctx context.Context, match *matchTracker) bo match.MetaData.Proof.SelfRevoked = true return false } - if !ready { - return false - } - // Re-audit the contract now that we know it is mined - // and has the required confirmations. - if err := t.reAuditContract(match); err != nil { - t.dc.log.Errorf("Match %s not redeemable: repeat audit of taker's contract failed: %v", - match, err) - match.swapErr = fmt.Errorf("Repeat counter-party contract audit error: %v", err) - return false - } - return true + return ready } // If we're the taker, check the confirmations anyway so we can notify. confs, spent, err := t.wallets.fromWallet.SwapConfirmations(ctx, match.MetaData.Proof.TakerSwap, @@ -2164,69 +2142,6 @@ func (t *trackedTrade) auditContract(match *matchTracker, coinID, contract, txDa return err } - t.mtx.Lock() - defer t.mtx.Unlock() - - err = t.validateAuditInfo(auditInfo, match, coinID, contract) - if err != nil { - return err - } - // Audit successful. Update status and other match data. - proof := &match.MetaData.Proof - if match.Side == order.Maker { - match.Status = order.TakerSwapCast - proof.TakerSwap = coinID - } else { - proof.SecretHash = auditInfo.SecretHash - match.Status = order.MakerSwapCast - proof.MakerSwap = coinID - } - proof.CounterTxData = txData - proof.CounterContract = contract - match.counterSwap = auditInfo - - err = t.db.UpdateMatch(&match.MetaMatch) - if err != nil { - t.dc.log.Errorf("Error updating database for match %v: %s", match, err) - } - - t.dc.log.Infof("Audited contract (%s: %v) paying to %s for order %s, match %s, with tx data = %t", - t.wallets.toAsset.Symbol, auditInfo.Coin, auditInfo.Recipient, t.ID(), match, len(txData) > 0) - - return nil -} - -// reAuditContract performs a repeat contract audit for the specified match and -// returns an error if the repeat audit fails. This does NOT update match fields -// but the trackedTrade mtx MUST be locked for reading mutable match fields. -func (t *trackedTrade) reAuditContract(match *matchTracker) error { - if match.Side == order.Maker && match.Status != order.TakerSwapCast { - return fmt.Errorf("Invalid repeat audit request for maker at status %s.", match.Status) - } - if match.Side == order.Taker && match.Status != order.MakerSwapCast { - return fmt.Errorf("Invalid repeat audit request for taker at status %s.", match.Status) - } - - proof := &match.MetaData.Proof - coinID, contract := match.counterSwap.Coin.ID(), proof.CounterContract - auditInfo, err := t.wallets.toWallet.AuditContract(coinID, contract, nil, time.Time{}) - if err != nil { - return err - } - if err = t.validateAuditInfo(auditInfo, match, coinID, contract); err != nil { - return err - } - // Audit successful. - t.dc.log.Infof("Re audited contract (%s: %v) paying to %s for order %s, match %s", - t.wallets.toAsset.Symbol, auditInfo.Coin, auditInfo.Recipient, t.ID(), match) - return nil -} - -// validateAuditInfo checks the audit info of a contract for validity, ensuring -// that the contract pays the right amount to the right address and if maker, -// also ensures that the secret hash is as expected. -// The trackedTrade mtx MUST be locked for reading mutable match fields. -func (t *trackedTrade) validateAuditInfo(auditInfo *asset.AuditInfo, match *matchTracker, coinID, contract []byte) error { contractID, contractSymb := coinIDString(t.wallets.toAsset.ID, coinID), t.wallets.toAsset.Symbol // Audit the contract. @@ -2265,6 +2180,8 @@ func (t *trackedTrade) validateAuditInfo(auditInfo *asset.AuditInfo, match *matc return fmt.Errorf("lock time too early. Need %s, got %s", reqLockTime, auditInfo.Expiration) } + t.mtx.Lock() + defer t.mtx.Unlock() proof := &match.MetaData.Proof if match.Side == order.Maker { // Check that the secret hash is correct. @@ -2272,7 +2189,25 @@ func (t *trackedTrade) validateAuditInfo(auditInfo *asset.AuditInfo, match *matc return fmt.Errorf("secret hash mismatch for contract coin %v (%s), contract %v. expected %x, got %v", auditInfo.Coin, t.wallets.toAsset.Symbol, contract, proof.SecretHash, auditInfo.SecretHash) } + // Audit successful. Update status and other match data. + match.Status = order.TakerSwapCast + proof.TakerSwap = coinID + } else { + proof.SecretHash = auditInfo.SecretHash + match.Status = order.MakerSwapCast + proof.MakerSwap = coinID } + proof.CounterTxData = txData + proof.CounterContract = contract + match.counterSwap = auditInfo + + err = t.db.UpdateMatch(&match.MetaMatch) + if err != nil { + t.dc.log.Errorf("Error updating database for match %v: %s", match, err) + } + + t.dc.log.Infof("Audited contract (%s: %v) paying to %s for order %s, match %s, with tx data = %t", + t.wallets.toAsset.Symbol, auditInfo.Coin, auditInfo.Recipient, t.ID(), match, len(txData) > 0) return nil } From 5f884a566df866662c398f22b3a0053b35f676ea Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Thu, 4 Nov 2021 12:25:08 +0100 Subject: [PATCH 20/22] buck's review, btc audit txdata only --- client/asset/btc/btc.go | 50 ++++++++++++++-------------------- client/asset/btc/btc_test.go | 39 ++++---------------------- client/asset/dcr/dcr.go | 44 ++++++++---------------------- client/asset/dcr/externaltx.go | 9 +++--- client/asset/interface.go | 24 +++++----------- 5 files changed, 49 insertions(+), 117 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 0143f10b86..96babdd454 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -1824,9 +1824,10 @@ func (btc *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, return } -// AuditContract retrieves information about a swap contract on the blockchain. -// AuditContract would be used to audit the counter-party's contract during a -// swap. +// AuditContract retrieves information about a swap contract from the provided +// txData. The extracted information would be used to audit the counter-party's +// contract during a swap. +// TODO: Broadcast the txData. func (btc *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, since time.Time) (*asset.AuditInfo, error) { txHash, vout, err := decodeCoinID(coinID) if err != nil { @@ -1837,34 +1838,18 @@ func (btc *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, sin if err != nil { return nil, fmt.Errorf("error extracting swap addresses: %w", err) } - pkScript, err := btc.scriptHashScript(contract) - if err != nil { - return nil, fmt.Errorf("error parsing pubkey script: %w", err) - } - // Get the contracts P2SH address from the tx output's pubkey script. - txOut, _, err := btc.node.getTxOut(txHash, vout, pkScript, since) - if err != nil && !errors.Is(err, asset.CoinNotFoundError) { - return nil, fmt.Errorf("error finding unspent contract: %s:%d : %w", txHash, vout, err) - } - // Even if we haven't found the output, we can perform basic validation - // using the txData. We may also want to broadcast the transaction if using + // Perform basic validation using the txData. Also want to broadcast the transaction if using // an spvWallet. It may be worth separating data validation from coin // retrieval at the asset.Wallet interface level. - coinNotFound := txOut == nil - if coinNotFound { - if len(txData) == 0 { - return nil, fmt.Errorf("contract %s:%d not found in blockchain and tx data not provided to audit", txHash, vout) - } - tx, err := msgTxFromBytes(txData) - if err != nil { - return nil, fmt.Errorf("coin not found, and error encountered decoding tx data: %v", err) - } - if len(tx.TxOut) <= int(vout) { - return nil, fmt.Errorf("specified output %d not found in decoded tx %s", vout, txHash) - } - txOut = tx.TxOut[vout] + tx, err := msgTxFromBytes(txData) + if err != nil { + return nil, fmt.Errorf("coin not found, and error encountered decoding tx data: %v", err) } + if len(tx.TxOut) <= int(vout) { + return nil, fmt.Errorf("specified output %d not found in decoded tx %s", vout, txHash) + } + txOut := tx.TxOut[vout] // Check for standard P2SH. scriptClass, addrs, numReq, err := txscript.ExtractPkScriptAddrs(txOut.PkScript, btc.chainParams) @@ -1901,9 +1886,6 @@ func (btc *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, sin contractHash, addr.ScriptAddress()) } - if coinNotFound { - return nil, asset.CoinNotFoundError - } return &asset.AuditInfo{ Coin: newOutput(txHash, vout, uint64(txOut.Value)), Recipient: receiver.String(), @@ -2194,6 +2176,14 @@ func (btc *ExchangeWallet) Refund(coinID, contract dex.Bytes, feeSuggestion uint return nil, fmt.Errorf("error parsing pubkey script: %w", err) } + // TODO: I'd recommend not passing a pkScript without a limited startTime + // to prevent potentially long searches. In this case though, the output + // will be found in the wallet and won't need to be searched for, only + // the spender search will be conducted using the pkScript starting from + // the block containing the original tx. The script can be gotten from + // the wallet tx though and used for the spender search, while not passing + // a script here to ensure no attempt is made to find the output without + // a limited startTime. utxo, _, err := btc.node.getTxOut(txHash, vout, pkScript, time.Time{}) if err != nil { return nil, fmt.Errorf("error finding unspent contract: %w", err) diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index 91faaf710e..05a76c147c 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -1909,12 +1909,11 @@ func TestAuditContract(t *testing.T) { } func testAuditContract(t *testing.T, segwit bool, walletType string) { - wallet, node, shutdown, err := tNewWallet(segwit, walletType) + wallet, _, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) } - swapVal := toSatoshi(5) secretHash, _ := hex.DecodeString("5124208c80d33507befa517c08ed01aa8d33adbf37ecd70fb5f9352f7a51a88d") lockTime := time.Now().Add(time.Hour * 12) now := time.Now() @@ -1936,31 +1935,16 @@ func testAuditContract(t *testing.T, segwit bool, walletType string) { contractAddr, _ = btcutil.NewAddressScriptHash(contract, &chaincfg.MainNetParams) } pkScript, _ := txscript.PayToAddrScript(contractAddr) - - // Prime a blockchain - const tipHeight = 10 - const txBlockHeight = 9 - for i := int64(1); i < tipHeight; i++ { - node.addRawTx(i, dummyTx()) - } - tx := makeRawTx([]dex.Bytes{pkScript}, []*wire.TxIn{dummyInput()}) - blockHash, _ := node.addRawTx(txBlockHeight, tx) - node.getCFilterScripts[*blockHash] = [][]byte{pkScript} //spv - node.txOutRes = &btcjson.GetTxOutResult{ // rpc - Confirmations: 2, - Value: float64(swapVal) / 1e8, - ScriptPubKey: btcjson.ScriptPubKeyResult{ - Hex: hex.EncodeToString(pkScript), - }, + txData, err := serializeMsgTx(tx) + if err != nil { + t.Fatalf("error making contract tx data: %v", err) } - node.getTransactionErr = WalletTransactionNotFound txHash := tx.TxHash() const vout = 0 - outPt := newOutPoint(&txHash, vout) - audit, err := wallet.AuditContract(toCoinID(&txHash, vout), contract, nil, now) + audit, err := wallet.AuditContract(toCoinID(&txHash, vout), contract, txData, now) if err != nil { t.Fatalf("audit error: %v", err) } @@ -1975,22 +1959,11 @@ func testAuditContract(t *testing.T, segwit bool, walletType string) { } // Invalid txid - _, err = wallet.AuditContract(make([]byte, 15), contract, nil, now) + _, err = wallet.AuditContract(make([]byte, 15), contract, txData, now) if err == nil { t.Fatalf("no error for bad txid") } - // GetTxOut error - node.txOutErr = tErr - delete(node.getCFilterScripts, *blockHash) - delete(node.checkpoints, outPt) - _, err = wallet.AuditContract(toCoinID(&txHash, vout), contract, nil, now) - if err == nil { - t.Fatalf("no error for unknown txout") - } - node.txOutErr = nil - node.getCFilterScripts[*blockHash] = [][]byte{pkScript} - // Wrong contract pkh, _ := hex.DecodeString("c6a704f11af6cbee8738ff19fc28cdc70aba0b82") wrongAddr, _ := btcutil.NewAddressPubKeyHash(pkh, &chaincfg.MainNetParams) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index e4dc51e135..b887313a45 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -618,16 +618,10 @@ func (dcr *ExchangeWallet) feeRate(confTarget uint64) (uint64, error) { // feeRateWithFallback attempts to get the optimal fee rate in atoms / byte via // FeeRate. If that fails, it will return the configured fallback fee rate. func (dcr *ExchangeWallet) feeRateWithFallback(confTarget, feeSuggestion uint64) uint64 { - var err error - if !dcr.wallet.SpvMode() { - var feeRate uint64 - feeRate, err = dcr.feeRate(confTarget) - if err == nil { - dcr.log.Tracef("Obtained local estimate for %d-conf fee rate, %d", confTarget, feeRate) - return feeRate - } - } else { - err = errors.New("SPV does not support estimatesmartfee") + feeRate, err := dcr.feeRate(confTarget) + if err == nil { + dcr.log.Tracef("Obtained local estimate for %d-conf fee rate, %d", confTarget, feeRate) + return feeRate } if feeSuggestion > 0 && feeSuggestion < dcr.fallbackFeeRate && feeSuggestion < dcr.feeRateLimit { dcr.log.Tracef("feeRateWithFallback using caller's suggestion for %d-conf fee rate, %d. Local estimate unavailable (%q)", @@ -1496,24 +1490,11 @@ func (dcr *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, return pubkeys, sigs, nil } -// AuditContract retrieves information about a swap contract from the blockchain -// (if possible) or from the provided txData if the provided txData represents a -// valid transaction that pays to the provided contract at the specified coinID -// and (for full node wallets) can be broadcasted to the blockchain network. -// -// The information returned would be used to verify the counter-party's contract -// during a swap. -// -// NOTE: For SPV wallets, a successful audit response is no guarantee that the -// txData provided to this method was actually broadcasted to the blockchain. -// An error may have occurred while trying to broadcast the txData or even if -// there was no broadcast error, the tx might still not enter mempool or get -// mined e.g. if the tx references invalid or already spent inputs. -// -// Granted, clients wait for the contract tx to be included in a block before -// taking further actions on a match; but it is generally safer to repeat this -// audit after the contract tx is mined to ensure that the tx observed on the -// blockchain is as expected. +// AuditContract retrieves information about a swap contract from the provided +// txData if it represents a valid transaction that pays to the contract at the +// specified coinID. An attempt is also made to broadcasted the txData to the +// blockchain network but it is not necessary that the broadcast succeeds since +// the contract may have already been broadcasted. func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ time.Time) (*asset.AuditInfo, error) { txHash, vout, err := decodeCoinID(coinID) if err != nil { @@ -2977,9 +2958,9 @@ func (dcr *ExchangeWallet) getBlockHash(blockHeight int64) (*chainhash.Hash, err return blockHash, nil } -// mainChainAncestor crawls blocks backwards starting at the provided hash +// mainchainAncestor crawls blocks backwards starting at the provided hash // until finding a mainchain block. Returns the first mainchain block found. -func (dcr *ExchangeWallet) mainChainAncestor(ctx context.Context, blockHash *chainhash.Hash) (*chainhash.Hash, int64, error) { +func (dcr *ExchangeWallet) mainchainAncestor(ctx context.Context, blockHash *chainhash.Hash) (*chainhash.Hash, int64, error) { checkHash := blockHash for { checkBlock, err := dcr.wallet.GetBlockHeaderVerbose(ctx, checkHash) @@ -3002,9 +2983,6 @@ func (dcr *ExchangeWallet) mainChainAncestor(ctx context.Context, blockHash *cha } func (dcr *ExchangeWallet) isMainchainBlock(ctx context.Context, block *block) (bool, error) { - if block == nil { - return false, nil - } blockHeader, err := dcr.wallet.GetBlockHeaderVerbose(ctx, block.hash) if err != nil { return false, fmt.Errorf("getblockheader error for block %s: %w", block.hash, translateRPCCancelErr(err)) diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index 61be5c4559..94e28b70a5 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -149,7 +149,7 @@ func (dcr *ExchangeWallet) scanFiltersForTxBlock(ctx context.Context, tx *extern // mainchain, scan back to the mainchain ancestor of the lastScannedBlock. var lastScannedBlock *block if tx.lastScannedBlock != nil { - stopBlockHash, stopBlockHeight, err := dcr.mainChainAncestor(ctx, tx.lastScannedBlock) + stopBlockHash, stopBlockHeight, err := dcr.mainchainAncestor(ctx, tx.lastScannedBlock) if err != nil { return nil, fmt.Errorf("error looking up mainchain ancestor for block %s", err) } @@ -186,6 +186,7 @@ func (dcr *ExchangeWallet) scanFiltersForTxBlock(ctx context.Context, tx *extern return nil, nil } + earliestTxStamp := earliestTxTime.Unix() for { msgTx, outputSpenders, err := dcr.findTxInBlock(ctx, tx.hash, txScripts, iHash) if err != nil { @@ -212,7 +213,7 @@ func (dcr *ExchangeWallet) scanFiltersForTxBlock(ctx context.Context, tx *extern if err != nil { return nil, fmt.Errorf("getblockheader error for block %s: %w", iHash, translateRPCCancelErr(err)) } - if iBlock.Time <= earliestTxTime.Unix() { + if iBlock.Time <= earliestTxStamp { return scanCompletedWithoutResults() } @@ -317,11 +318,11 @@ func (dcr *ExchangeWallet) isOutputSpent(ctx context.Context, output *outputSpen // This tx output is not known to be spent as of last search (if any). // Scan block filters starting from the block after the tx block or the - // lastScannedBlock (if there was a previous scan). Use mainChainAncestor + // lastScannedBlock (if there was a previous scan). Use mainchainAncestor // to ensure that scanning starts from a mainchain block in the event that // the lastScannedBlock have been re-orged out of the mainchain. We already // checked that the txBlock is not invalidated above. - _, lastScannedHeight, err := dcr.mainChainAncestor(ctx, output.lastScannedBlock) + _, lastScannedHeight, err := dcr.mainchainAncestor(ctx, output.lastScannedBlock) if err != nil { return false, err } diff --git a/client/asset/interface.go b/client/asset/interface.go index a21c3c50fe..9440c00982 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -153,23 +153,13 @@ type Wallet interface { // signature for each pubkey are returned. SignMessage(Coin, dex.Bytes) (pubkeys, sigs []dex.Bytes, err error) // AuditContract retrieves information about a swap contract from the - // blockchain (where possible) or from the provided txData (if valid). - // The information returned would be used to verify the counter-party's - // contract during a swap. If the coin cannot be found on the blockchain - // and the provided txData cannot be broadcasted, a CoinNotFoundError - // may be returned. This enables the client to properly handle network - // latency where appropriate. - // - // NOTE: For SPV wallets, a successful audit response is no gaurantee that - // the txData provided was actually broadcasted to the blockchain. An error - // may have occurred while trying to broadcast the txData or even if there - // was no broadcast error, the tx might still not enter mempool or get mined - // e.g. if the tx references invalid or already spent inputs. - // - // Granted, clients wait for the contract tx to be included in a block before - // taking further actions on a match; but it is generally safer to repeat this - // audit after the contract tx is mined to ensure that the tx observed on the - // blockchain is as expected. + // provided txData and broadcasts the txData to ensure the contract is + // propagated to the blockchain. The information returned would be used + // to verify the counter-party's contract during a swap. It is not an + // error if the provided txData cannot be broadcasted because it may + // already be broadcasted. A successful audit response does not mean + // the tx exists on the blockchain, use SwapConfirmations to ensure + // the tx is mined. AuditContract(coinID, contract, txData dex.Bytes, matchTime time.Time) (*AuditInfo, error) // LocktimeExpired returns true if the specified contract's locktime has // expired, making it possible to issue a Refund. The contract expiry time From 73ca58ce226becc9c6796d30d1414e9653f77e29 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Sat, 6 Nov 2021 09:56:08 +0100 Subject: [PATCH 21/22] chapp review --- client/asset/dcr/dcr.go | 116 +++++++++++++++------------------ client/asset/dcr/dcr_test.go | 32 ++------- client/asset/dcr/externaltx.go | 40 ++++++++---- 3 files changed, 87 insertions(+), 101 deletions(-) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index b887313a45..3d4e53d353 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1123,7 +1123,7 @@ func (dcr *ExchangeWallet) returnCoins(unspents asset.Coins) error { if err != nil { return fmt.Errorf("error converting coin: %w", err) } - ops = append(ops, wire.NewOutPoint(op.txHash(), op.vout(), op.tree)) + ops = append(ops, op.wireOutPoint()) // op.tree may be wire.TxTreeUnknown, but that's fine since wallet.LockUnspent doesn't rely on it delete(dcr.fundingCoins, op.pt) } return dcr.wallet.LockUnspent(dcr.ctx, true, ops) @@ -1454,7 +1454,9 @@ func (dcr *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, if found { addr = fCoin.addr } else { - // Check if we can get the address from gettxout. + // Check if we can get the address from wallet.UnspentOutput. + // op.tree may be wire.TxTreeUnknown but wallet.UnspentOutput is + // able to deal with that and find the actual tree. txOut, err := dcr.wallet.UnspentOutput(dcr.ctx, op.txHash(), op.vout(), op.tree) if err != nil { dcr.log.Errorf("gettxout error for SignMessage coin %s: %v", op, err) @@ -1507,8 +1509,8 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t return nil, fmt.Errorf("error extracting swap addresses: %w", err) } - contractTx := wire.NewMsgTx() - if err := contractTx.Deserialize(bytes.NewReader(txData)); err != nil { + contractTx, err := msgTxFromBytes(txData) + if err != nil { return nil, fmt.Errorf("invalid contract tx data: %w", err) } if err = blockchain.CheckTransactionSanity(contractTx, dcr.chainParams); err != nil { @@ -1565,7 +1567,7 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t allowHighFees := true // high fees shouldn't prevent this tx from being bcast finalTxHash, err := dcr.wallet.SendRawTransaction(dcr.ctx, contractTx, allowHighFees) if err != nil { - dcr.log.Errorf("Error rebroadcasting contract tx %v: %v.", txData, translateRPCCancelErr(err)) + dcr.log.Errorf("Error rebroadcasting contract tx %v: %v.", txData, err) } else if !finalTxHash.IsEqual(txHash) { return nil, fmt.Errorf("broadcasted contract tx, but received unexpected transaction ID back from RPC server. "+ "expected %s, got %s", txHash, finalTxHash) @@ -1604,37 +1606,36 @@ func determineTxTree(msgTx *wire.MsgTx) int8 { // NOTE: This method is only guaranteed to return results for outputs belonging // to transactions that are tracked by the wallet, although full node wallets // are able to look up non-wallet outputs that are unspent. -func (dcr *ExchangeWallet) lookupTxOutput(ctx context.Context, txHash *chainhash.Hash, vout uint32) (*wire.TxOut, uint32, int8, bool, error) { +func (dcr *ExchangeWallet) lookupTxOutput(ctx context.Context, txHash *chainhash.Hash, vout uint32) (*wire.TxOut, uint32, bool, error) { // Check for an unspent output. output, err := dcr.wallet.UnspentOutput(ctx, txHash, vout, wire.TxTreeUnknown) if err == nil { - return output.TxOut, output.Confirmations, output.Tree, false, nil + return output.TxOut, output.Confirmations, false, nil } else if err != asset.CoinNotFoundError { - return nil, 0, 0, false, err + return nil, 0, false, err } // Check wallet transactions. tx, err := dcr.wallet.GetTransaction(ctx, txHash) if err != nil { - return nil, 0, 0, false, err + return nil, 0, false, err } msgTx, err := msgTxFromHex(tx.Hex) if err != nil { - return nil, 0, 0, false, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) + return nil, 0, false, fmt.Errorf("invalid hex for tx %s: %v", txHash, err) } if int(vout) >= len(msgTx.TxOut) { - return nil, 0, 0, false, fmt.Errorf("tx %s has no output at %d", txHash, vout) + return nil, 0, false, fmt.Errorf("tx %s has no output at %d", txHash, vout) } txOut := msgTx.TxOut[vout] confs := uint32(tx.Confirmations) - tree := determineTxTree(msgTx) // We have the requested output. Check if it is spent. if confs == 0 { // Only counts as spent if spent in a mined transaction, // unconfirmed tx outputs can't be spent in a mined tx. - return txOut, confs, tree, false, nil + return txOut, confs, false, nil } if !dcr.wallet.SpvMode() { @@ -1642,7 +1643,7 @@ func (dcr *ExchangeWallet) lookupTxOutput(ctx context.Context, txHash *chainhash // is spent if the wallet is connected to a full node. dcr.log.Debugf("Output %s:%d that was not reported as unspent is considered SPENT, spv mode = false.", txHash, vout) - return txOut, confs, tree, true, nil + return txOut, confs, true, nil } // For SPV wallets, only consider the output spent if it pays to the @@ -1658,13 +1659,13 @@ func (dcr *ExchangeWallet) lookupTxOutput(ctx context.Context, txHash *chainhash if outputPaysToWallet { dcr.log.Debugf("Output %s:%d was not reported as unspent, pays to the wallet and is considered SPENT.", txHash, vout) - return txOut, confs, tree, true, nil + return txOut, confs, true, nil } // Assume unspent even though the spend status is not really known. dcr.log.Debugf("Output %s:%d was not reported as unspent, does not pay to the wallet and is assumed UNSPENT.", txHash, vout) - return txOut, confs, tree, false, nil + return txOut, confs, false, nil } // RefundAddress extracts and returns the refund address from a contract. @@ -1926,8 +1927,7 @@ rangeBlocks: // possibly included in this block. blkCFilter, err := dcr.getBlockFilterV2(dcr.ctx, blockHash) if err != nil { // error retrieving a block's cfilters is a fatal error - err = fmt.Errorf("get cfilters error for block %d (%s): %w", blockHeight, blockHash, - translateRPCCancelErr(err)) + err = fmt.Errorf("get cfilters error for block %d (%s): %w", blockHeight, blockHash, err) dcr.fatalFindRedemptionsError(err, contractOutpoints) return } @@ -2106,7 +2106,7 @@ func (dcr *ExchangeWallet) refundTx(coinID, contract dex.Bytes, val uint64, refu } // Grab the output, make sure it's unspent and get the value if not supplied. if val == 0 { - utxo, _, _, spent, err := dcr.lookupTxOutput(dcr.ctx, txHash, vout) + utxo, _, spent, err := dcr.lookupTxOutput(dcr.ctx, txHash, vout) if err != nil { return nil, fmt.Errorf("error finding unspent contract: %w", err) } @@ -2302,7 +2302,7 @@ func (dcr *ExchangeWallet) SwapConfirmations(ctx context.Context, coinID, contra } // Check if we can find the contract onchain without using cfilters. - _, confs, _, spent, err = dcr.lookupTxOutput(ctx, txHash, vout) + _, confs, spent, err = dcr.lookupTxOutput(ctx, txHash, vout) if err == nil { return confs, spent, nil } else if err != asset.CoinNotFoundError { @@ -2318,20 +2318,7 @@ func (dcr *ExchangeWallet) SwapConfirmations(ctx context.Context, coinID, contra // Find the contract and it's spend status using block filters. dcr.log.Debugf("Contract output %s:%d NOT yet found, will attempt finding it with block filters.", txHash, vout) - confs, spent, err = dcr.lookupTxOutWithBlockFilters(ctx, newOutPoint(txHash, vout), p2shScript, matchTime) - if err != nil { - return 0, false, err - } - return confs, spent, err -} - -func (dcr *ExchangeWallet) confirms(blockHeight int64) uint32 { - tip, err := dcr.getBestBlock(dcr.ctx) - if err != nil { - dcr.log.Errorf("getbestblock error %v", err) - *tip = dcr.cachedBestBlock() - } - return uint32(tip.height - blockHeight + 1) + return dcr.lookupTxOutWithBlockFilters(ctx, newOutPoint(txHash, vout), p2shScript, matchTime) } // RegFeeConfirmations gets the number of confirmations for the specified @@ -2359,6 +2346,13 @@ func (dcr *ExchangeWallet) addInputCoins(msgTx *wire.MsgTx, coins asset.Coins) ( if op.value == 0 { return 0, fmt.Errorf("zero-valued output detected for %s:%d", op.txHash(), op.vout()) } + if op.tree == wire.TxTreeUnknown { // Set the correct prevout tree if unknown. + unspentPrevOut, err := dcr.wallet.UnspentOutput(dcr.ctx, op.txHash(), op.vout(), op.tree) + if err != nil { + return 0, fmt.Errorf("unable to determine tree for prevout %s: %v", op.pt, err) + } + op.tree = unspentPrevOut.Tree + } totalIn += op.value prevOut := op.wireOutPoint() txIn := wire.NewTxIn(prevOut, int64(op.value), []byte{}) @@ -2446,7 +2440,8 @@ func (dcr *ExchangeWallet) lockedAtoms() (uint64, error) { return sum, nil } -// convertCoin converts the asset.Coin to an unspent output. +// convertCoin converts the asset.Coin to an output whose tree may be unknown. +// Use wallet.UnspentOutput to determine the output tree where necessary. func (dcr *ExchangeWallet) convertCoin(coin asset.Coin) (*output, error) { op, _ := coin.(*output) if op != nil { @@ -2456,15 +2451,7 @@ func (dcr *ExchangeWallet) convertCoin(coin asset.Coin) (*output, error) { if err != nil { return nil, err } - // TODO: We just need the tree. - txOut, err := dcr.wallet.UnspentOutput(dcr.ctx, txHash, vout, wire.TxTreeUnknown) - if err != nil { - return nil, fmt.Errorf("error finding unspent output %s:%d: %w", txHash, vout, err) - } - if txOut == nil { - return nil, asset.CoinNotFoundError // maybe spent - } - return newOutput(txHash, vout, coin.Value(), txOut.Tree), nil + return newOutput(txHash, vout, coin.Value(), wire.TxTreeUnknown), nil } // sendMinusFees sends the amount to the address. Fees are subtracted from the @@ -2942,22 +2929,6 @@ func (dcr *ExchangeWallet) getBestBlock(ctx context.Context) (*block, error) { return &block{hash: hash, height: height}, nil } -func (dcr *ExchangeWallet) getBlock(ctx context.Context, blockHash *chainhash.Hash, verboseTx bool) (*chainjson.GetBlockVerboseResult, error) { - blockVerbose, err := dcr.wallet.GetBlockVerbose(ctx, blockHash, verboseTx) - if err != nil { - return nil, fmt.Errorf("error retrieving block %s: %w", blockHash, translateRPCCancelErr(err)) - } - return blockVerbose, nil -} - -func (dcr *ExchangeWallet) getBlockHash(blockHeight int64) (*chainhash.Hash, error) { - blockHash, err := dcr.wallet.GetBlockHash(dcr.ctx, blockHeight) - if err != nil { - return nil, translateRPCCancelErr(err) - } - return blockHash, nil -} - // mainchainAncestor crawls blocks backwards starting at the provided hash // until finding a mainchain block. Returns the first mainchain block found. func (dcr *ExchangeWallet) mainchainAncestor(ctx context.Context, blockHash *chainhash.Hash) (*chainhash.Hash, int64, error) { @@ -2965,7 +2936,7 @@ func (dcr *ExchangeWallet) mainchainAncestor(ctx context.Context, blockHash *cha for { checkBlock, err := dcr.wallet.GetBlockHeaderVerbose(ctx, checkHash) if err != nil { - return nil, 0, fmt.Errorf("getblockheader error for block %s: %w", checkHash, translateRPCCancelErr(err)) + return nil, 0, fmt.Errorf("getblockheader error for block %s: %w", checkHash, err) } if checkBlock.Confirmations > -1 { // This is a mainchain block, return the hash and height. @@ -2985,9 +2956,28 @@ func (dcr *ExchangeWallet) mainchainAncestor(ctx context.Context, blockHash *cha func (dcr *ExchangeWallet) isMainchainBlock(ctx context.Context, block *block) (bool, error) { blockHeader, err := dcr.wallet.GetBlockHeaderVerbose(ctx, block.hash) if err != nil { - return false, fmt.Errorf("getblockheader error for block %s: %w", block.hash, translateRPCCancelErr(err)) + return false, fmt.Errorf("getblockheader error for block %s: %w", block.hash, err) + } + // First validation check. + if blockHeader.Confirmations < 0 || int64(blockHeader.Height) != block.height { + return false, nil + } + // Check if the next block invalidated this block's regular tree txs. + // This block checks out if there is no following block yet. + if blockHeader.NextHash == "" { + return true, nil + } + nextBlockHash, err := chainhash.NewHashFromStr(blockHeader.NextHash) + if err != nil { + return false, fmt.Errorf("block %s has invalid nexthash value %s: %v", + block.hash, blockHeader.NextHash, err) + } + nextBlockHeader, err := dcr.wallet.GetBlockHeaderVerbose(ctx, nextBlockHash) + if err != nil { + return false, fmt.Errorf("getblockheader error for block %s: %w", nextBlockHash, err) } - return blockHeader.Confirmations > -1 && int64(blockHeader.Height) == block.height, nil + validated := nextBlockHeader.VoteBits&1 != 0 + return validated, nil } func (dcr *ExchangeWallet) cachedBestBlock() block { diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 247cd0a9d2..869cb90f5d 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -1030,12 +1030,6 @@ func TestReturnCoins(t *testing.T) { coinID := toCoinID(tTxHash, 0) coins = asset.Coins{&tCoin{id: coinID}, &tCoin{id: coinID}} err = wallet.ReturnCoins(coins) - if err == nil { - t.Fatalf("no error for missing txout") - } - - node.txOutRes[newOutPoint(tTxHash, 0)] = makeGetTxOutRes(0, 1, tP2PKHScript) - err = wallet.ReturnCoins(coins) if err != nil { t.Fatalf("error with custom coin type: %v", err) } @@ -2193,7 +2187,7 @@ func TestLookupTxOutput(t *testing.T) { // Bad output coin op.vout = 10 - _, _, _, spent, err := wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) + _, _, spent, err := wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err == nil { t.Fatalf("no error for bad output coin") } @@ -2202,21 +2196,9 @@ func TestLookupTxOutput(t *testing.T) { } op.vout = 0 - // Build the blockchain with some dummy blocks. - // rand.Seed(time.Now().Unix()) - // node.blockchain.mtx.Lock() - // for i := 1; i < rand.Intn(100); i++ { - // node.blockchain.blockAt(int64(i)) - // } - // node.blockchain.mtx.Unlock() - // wallet.checkForNewBlocks() // update the tip cache - // tipHash, tipHeight := node.getBestBlock() - // outputHeight := tipHeight - 1 // i.e. 2 confirmations - // outputBlockHash, _ := node.blockchain.blockAt(outputHeight) - // Add the txOutRes with 2 confs and BestBlock correctly set. node.txOutRes[op] = makeGetTxOutRes(2, 1, tP2PKHScript) - _, confs, _, spent, err := wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) + _, confs, spent, err := wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err != nil { t.Fatalf("unexpected error for gettxout path: %v", err) } @@ -2230,7 +2212,7 @@ func TestLookupTxOutput(t *testing.T) { // gettransaction error delete(node.txOutRes, op) node.walletTxErr = tErr - _, _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) + _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err == nil { t.Fatalf("no error for gettransaction error") } @@ -2255,7 +2237,7 @@ func TestLookupTxOutput(t *testing.T) { Hex: txHex, Confirmations: 0, // unconfirmed = unspent } - _, _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) + _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err != nil { t.Fatalf("unexpected error for gettransaction path (unconfirmed): %v", err) } @@ -2265,7 +2247,7 @@ func TestLookupTxOutput(t *testing.T) { // Confirmed wallet tx without gettxout response is spent. node.walletTx.Confirmations = 2 - _, _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) + _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err != nil { t.Fatalf("unexpected error for gettransaction path (confirmed): %v", err) } @@ -2275,7 +2257,7 @@ func TestLookupTxOutput(t *testing.T) { // In spv mode, output is assumed unspent if it doesn't pay to the wallet. (wallet.wallet.(*rpcWallet)).spvMode = true - _, _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) + _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err != nil { t.Fatalf("unexpected error for spv gettransaction path (non-wallet output): %v", err) } @@ -2288,7 +2270,7 @@ func TestLookupTxOutput(t *testing.T) { Vout: 0, Category: "receive", // output at index 0 pays to the wallet }} - _, _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) + _, _, spent, err = wallet.lookupTxOutput(context.Background(), &op.txHash, op.vout) if err != nil { t.Fatalf("unexpected error for spv gettransaction path (wallet output): %v", err) } diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index 94e28b70a5..f1323e6ee3 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -62,7 +62,17 @@ func (dcr *ExchangeWallet) lookupTxOutWithBlockFilters(ctx context.Context, op o return 0, false, fmt.Errorf("error checking if output %s is spent: %v", op, err) } - return dcr.confirms(outputBlock.height), spent, nil + // Get the current tip height to calculate confirmations. + tip, err := dcr.getBestBlock(ctx) + if err != nil { + dcr.log.Errorf("getbestblock error %v", err) + *tip = dcr.cachedBestBlock() + } + var confs uint32 + if tip.height >= outputBlock.height { // slight possibility that the cached tip height is behind the output's block height + confs = uint32(tip.height + 1 - outputBlock.height) + } + return confs, spent, nil } // externalTxOutput attempts to locate the requested tx output in a mainchain @@ -97,14 +107,14 @@ func (dcr *ExchangeWallet) externalTxOutput(ctx context.Context, op outPoint, pk if err != nil { return nil, nil, fmt.Errorf("error checking if tx %s is mined: %v", tx.hash, err) } + if txBlock == nil { + return nil, nil, asset.CoinNotFoundError + } } - if txBlock == nil { - return nil, nil, asset.CoinNotFoundError - } + if len(tx.outputSpenders) <= int(op.vout) { return nil, nil, fmt.Errorf("tx %s does not have an output at index %d", tx.hash, op.vout) } - return tx.outputSpenders[op.vout], txBlock, nil } @@ -236,9 +246,9 @@ func (dcr *ExchangeWallet) findTxInBlock(ctx context.Context, txHash *chainhash. return nil, nil, nil } - blk, err := dcr.getBlock(ctx, blockHash, true) + blk, err := dcr.wallet.GetBlockVerbose(ctx, blockHash, true) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("error retrieving block %s: %w", blockHash, err) } var txHex string @@ -340,7 +350,7 @@ func (dcr *ExchangeWallet) isOutputSpent(ctx context.Context, output *outputSpen // Search for this output's spender in the blocks between startBlock and // the current best block. - nextScanHash, err := dcr.getBlockHash(nextScanHeight) + nextScanHash, err := dcr.wallet.GetBlockHash(ctx, nextScanHeight) if err != nil { return false, err } @@ -374,21 +384,23 @@ func (dcr *ExchangeWallet) isOutputSpent(ctx context.Context, output *outputSpen // was last checked is returned along with any error that may have occurred // during the search. func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, outputPkScript []byte, startBlock *block) (*chainjson.TxRawResult, *chainhash.Hash, error) { + var lastScannedHash *chainhash.Hash + iHeight := startBlock.height iHash := startBlock.hash bestBlock := dcr.cachedBestBlock() for { blockFilter, err := dcr.getBlockFilterV2(ctx, iHash) if err != nil { - return nil, nil, err + return nil, lastScannedHash, err } if blockFilter.Match(outputPkScript) { dcr.log.Debugf("Output %s is likely spent in block %d (%s). Confirming.", op, iHeight, iHash) - blk, err := dcr.getBlock(ctx, iHash, true) + blk, err := dcr.wallet.GetBlockVerbose(ctx, iHash, true) if err != nil { - return nil, iHash, err + return nil, lastScannedHash, fmt.Errorf("error retrieving block %s: %w", iHash, err) } blockTxs := append(blk.RawTx, blk.RawSTx...) for i := range blockTxs { @@ -412,6 +424,7 @@ func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, ou if err != nil { return nil, iHash, translateRPCCancelErr(err) } + lastScannedHash = iHash iHash = nextHash } @@ -423,12 +436,13 @@ func (dcr *ExchangeWallet) findTxOutSpender(ctx context.Context, op outPoint, ou // txSpendsOutput returns true if the passed tx has an input that spends the // specified output. func txSpendsOutput(tx *chainjson.TxRawResult, op outPoint) bool { - if tx.Txid == op.txHash.String() { + prevOutHash := op.txHash.String() + if tx.Txid == prevOutHash { return false // no need to check inputs if this tx is the same tx that pays to the specified op } for i := range tx.Vin { input := &tx.Vin[i] - if input.Vout == op.vout && input.Txid == op.txHash.String() { + if input.Vout == op.vout && input.Txid == prevOutHash { return true // found spender } } From b20b1796f85a0a37d52b34fbf109ce1acb817b8b Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Mon, 8 Nov 2021 22:03:43 +0100 Subject: [PATCH 22/22] cleanup --- client/asset/btc/btc.go | 12 +++++++++--- client/asset/dcr/dcr.go | 29 +++++++++++------------------ client/asset/dcr/externaltx.go | 20 ++++++++++---------- client/core/trade_simnet_test.go | 2 +- 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 96babdd454..d36d24ed77 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -1827,7 +1827,6 @@ func (btc *ExchangeWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, // AuditContract retrieves information about a swap contract from the provided // txData. The extracted information would be used to audit the counter-party's // contract during a swap. -// TODO: Broadcast the txData. func (btc *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, since time.Time) (*asset.AuditInfo, error) { txHash, vout, err := decodeCoinID(coinID) if err != nil { @@ -1839,8 +1838,8 @@ func (btc *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, sin return nil, fmt.Errorf("error extracting swap addresses: %w", err) } - // Perform basic validation using the txData. Also want to broadcast the transaction if using - // an spvWallet. It may be worth separating data validation from coin + // Perform basic validation using the txData. + // It may be worth separating data validation from coin // retrieval at the asset.Wallet interface level. tx, err := msgTxFromBytes(txData) if err != nil { @@ -1886,6 +1885,13 @@ func (btc *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, sin contractHash, addr.ScriptAddress()) } + // Broadcast the transaction. + if hashSent, err := btc.node.sendRawTransaction(tx); err != nil { + btc.log.Debugf("Rebroadcasting counterparty contract %v (THIS MAY BE NORMAL): %v", txHash, err) + } else if !hashSent.IsEqual(txHash) { + btc.log.Errorf("Counterparty contract %v was rebroadcast as %v!", txHash, hashSent) + } + return &asset.AuditInfo{ Coin: newOutput(txHash, vout, uint64(txOut.Value)), Recipient: receiver.String(), diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 3d4e53d353..ce2d18afc5 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1551,30 +1551,23 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t contractHash, addrScript) } + // The counter-party should have broadcasted the contract tx but + // rebroadcast just in case to ensure that the tx is sent to the + // network. + if hashSent, err := dcr.wallet.SendRawTransaction(dcr.ctx, contractTx, true); err != nil { + dcr.log.Debugf("Rebroadcasting counterparty contract %v (THIS MAY BE NORMAL): %v", txHash, err) + } else if !hashSent.IsEqual(txHash) { + dcr.log.Errorf("Counterparty contract %v was rebroadcast as %v!", txHash, hashSent) + } + txTree := determineTxTree(contractTx) - auditInfo := &asset.AuditInfo{ + return &asset.AuditInfo{ Coin: newOutput(txHash, vout, uint64(contractTxOut.Value), txTree), Contract: contract, SecretHash: secretHash, Recipient: receiver.String(), Expiration: time.Unix(int64(stamp), 0).UTC(), - } - - // The counter-party should have broadcasted the contract tx but - // rebroadcast just in case to ensure that the tx is sent to the - // network. - dcr.log.Debugf("Rebroadcasting contract tx %v.", txData) - allowHighFees := true // high fees shouldn't prevent this tx from being bcast - finalTxHash, err := dcr.wallet.SendRawTransaction(dcr.ctx, contractTx, allowHighFees) - if err != nil { - dcr.log.Errorf("Error rebroadcasting contract tx %v: %v.", txData, err) - } else if !finalTxHash.IsEqual(txHash) { - return nil, fmt.Errorf("broadcasted contract tx, but received unexpected transaction ID back from RPC server. "+ - "expected %s, got %s", txHash, finalTxHash) - } - - dcr.log.Infof("Audited contract coin %s:%d using raw tx data. SPV mode = %t", txHash, vout, dcr.wallet.SpvMode()) - return auditInfo, nil + }, nil } func determineTxTree(msgTx *wire.MsgTx) int8 { diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index f1323e6ee3..015e53cf25 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -363,17 +363,17 @@ func (dcr *ExchangeWallet) isOutputSpent(ctx context.Context, output *outputSpen } // Cache relevant spender info if the spender is found. - spent := spenderTx != nil - if spent { - spenderBlockHash, err := chainhash.NewHashFromStr(spenderTx.BlockHash) - if err != nil { - // dcr.log.Errorf("Invalid hash (%s) for tx that spends output %s: %v", spenderTx.BlockHash, output.op, err) - return false, fmt.Errorf("invalid hash (%s) for tx that spends output %s: %v", spenderTx.BlockHash, output.op, err) - } else { - output.spenderBlock = &block{hash: spenderBlockHash, height: spenderTx.BlockHeight} - } + if spenderTx == nil { + return false, nil + } + + spenderBlockHash, err := chainhash.NewHashFromStr(spenderTx.BlockHash) + if err != nil { + return true, fmt.Errorf("invalid hash (%s) for tx that spends output %s: %w", + spenderTx.BlockHash, output.op, err) } - return spent, err + output.spenderBlock = &block{hash: spenderBlockHash, height: spenderTx.BlockHeight} + return true, nil } // findTxOutSpender attempts to find and return the tx that spends the provided diff --git a/client/core/trade_simnet_test.go b/client/core/trade_simnet_test.go index 91804fe93b..f7d48136cc 100644 --- a/client/core/trade_simnet_test.go +++ b/client/core/trade_simnet_test.go @@ -163,7 +163,7 @@ func startClients(ctx context.Context) error { if err != nil { return err } - c.log("Connected %s wallet (spv = %v)", unbip(assetID), wallet.spv) + c.log("Connected %s wallet (created/builtin = %v)", unbip(assetID), wallet.created) if wallet.created { c.log("Waiting for %s wallet to sync", unbip(assetID))