diff --git a/internal/testdata/bitcoin/transaction.go b/internal/testdata/bitcoin/transaction.go index f936a01ea6..e4e2c6e2ed 100644 --- a/internal/testdata/bitcoin/transaction.go +++ b/internal/testdata/bitcoin/transaction.go @@ -270,6 +270,7 @@ var Transactions = map[bitcoin.Network]map[string]struct { var TransactionsForPublicKeyHash = map[bitcoin.Network]struct { PublicKeyHash []byte Transactions []bitcoin.Hash + Utxos []string // txHash:outputIndex:value sorted asc }{ bitcoin.Testnet: { PublicKeyHash: decodeString("e6f9d74726b19b75f16fe1e9feaec048aa4fa1d0"), @@ -288,15 +289,31 @@ var TransactionsForPublicKeyHash = map[bitcoin.Network]struct { hashFromString("605edd75ae0b4fa7cfc7aae8f1399119e9d7ecc212e6253156b60d60f4925d44"), hashFromString("4f9affc5b418385d5aa61e23caa0b55156bf0682d5fedf2d905446f3f88aec6c"), }, + Utxos: []string{ + "00cc0cd13fc4de7a15cb41ab6d58f8b31c75b6b9b4194958c381441a67d09b08:1:1099200", + "05dabb0291c0a6aa522de5ded5cb6d14ee2159e7ff109d3ef0f21de128b56b94:1:1099200", + "2724545276df61f43f1e92c4b9f1dd3c9109595c022dbd9dc003efbad8ded38b:1:191169", + "3ca4ae3f8ee3b48949192bc7a146c8d9862267816258c85e02a44678364551e1:1:299200", + "44863a79ce2b8fec9792403d5048506e50ffa7338191db0e6c30d3d3358ea2f6:1:299200", + "4c6b33b7c0550e0e536a5d119ac7189d71e1296fcb0c258e0c115356895bc0e6:1:299200", + "4f9affc5b418385d5aa61e23caa0b55156bf0682d5fedf2d905446f3f88aec6c:0:100000", + "605edd75ae0b4fa7cfc7aae8f1399119e9d7ecc212e6253156b60d60f4925d44:1:299200", + "e648838e528ca0666e2612e18634fe86cb7a40fb3c594a444a58c810dd08977b:1:299200", + "ea374ab6842723c647c3fc0ab281ca0641eaa768576cf9df695ca5b827140214:0:10000", + "f65bc5029251f0042aedb37f90dbb2bfb63a2e81694beef9cae5ec62e954c22e:1:299200", + }, }, bitcoin.Mainnet: { - PublicKeyHash: decodeString("c3ac203924063c91e70a43c7b97c70745a7635c6"), + PublicKeyHash: decodeString("b0ba76edfe18e81365bddd1d46511a57a4ff8dce"), Transactions: []bitcoin.Hash{ - hashFromString("546c6d90285334a2b84c412c2d541db1f96bb22df6dc9f674c6df8a15506df02"), - hashFromString("948d9b3a1f35c2bcf39f1c08c7df8c3e0b4a9331985a8890c9ba1e1d66b05f8b"), - hashFromString("fbe0689ea2ff2e89c978406819b16e119a9842d9b11bb7d19b31c38693d2db11"), - hashFromString("d71c0f1ce9c0aa6fe8fed1e0ebb52227b2c8c042e1d27818298a255f94562972"), - hashFromString("c7248847ddbcbe4a8b0404ef7e372afff49dc04f26d3f4a27a40cd4a07565ac1"), + hashFromString("4099f8d3e7dcbf3e4df50daae839cace2630b653175289448bcd2cc3b796149c"), + hashFromString("58fe99a67333f88db25d991eefd27589c3866f45308c2f325ee0ef80d6a164bc"), + hashFromString("d48507610c55a33c9c72d8e055a880c7ee4e9b1e1d22c6c7cd7595efef90ad44"), + hashFromString("f649c502e875b7b51bb68206ae8e655c86cccc4b13979aaf241b63ba617c40e4"), + hashFromString("42eae14e7234004c48f335baf7d38e799d7a44bf7a6203aaadb1f558e4e74f7f"), + }, + Utxos: []string{ + "42eae14e7234004c48f335baf7d38e799d7a44bf7a6203aaadb1f558e4e74f7f:0:302376", }, }, } diff --git a/pkg/bitcoin/chain.go b/pkg/bitcoin/chain.go index d3795107c3..f68b3419d6 100644 --- a/pkg/bitcoin/chain.go +++ b/pkg/bitcoin/chain.go @@ -44,17 +44,50 @@ type Chain interface { // not contain unconfirmed transactions living in the mempool at the moment // of request. The returned transactions list can be limited using the // `limit` parameter. For example, if `limit` is set to `5`, only the - // latest five transactions will be returned. + // latest five transactions will be returned. Note that taking an unlimited + // transaction history may be time-consuming as this function fetches + // complete transactions with all necessary data. GetTransactionsForPublicKeyHash( publicKeyHash [20]byte, limit int, ) ([]*Transaction, error) + // GetTxHashesForPublicKeyHash gets hashes of confirmed transactions that pays + // the given public key hash using either a P2PKH or P2WPKH script. The returned + // transactions hashes are ordered by block height in the ascending order, i.e. + // the latest transaction hash is at the end of the list. The returned list does + // not contain unconfirmed transactions hashes living in the mempool at the + // moment of request. + GetTxHashesForPublicKeyHash( + publicKeyHash [20]byte, + ) ([]Hash, error) + // GetMempoolForPublicKeyHash gets the unconfirmed mempool transactions // that pays the given public key hash using either a P2PKH or P2WPKH script. // The returned transactions are in an indefinite order. GetMempoolForPublicKeyHash(publicKeyHash [20]byte) ([]*Transaction, error) + // GetUtxosForPublicKeyHash gets unspent outputs of confirmed transactions that + // are controlled by the given public key hash (either a P2PKH or P2WPKH script). + // The returned UTXOs are ordered by block height in the ascending order, i.e. + // the latest UTXO is at the end of the list. The returned list does not contain + // unspent outputs of unconfirmed transactions living in the mempool at the + // moment of request. Outputs used as inputs of confirmed or mempool + // transactions are not returned as well because they are no longer UTXOs. + GetUtxosForPublicKeyHash( + publicKeyHash [20]byte, + ) ([]*UnspentTransactionOutput, error) + + // GetMempoolUtxosForPublicKeyHash gets unspent outputs of unconfirmed transactions + // that are controlled by the given public key hash (either a P2PKH or P2WPKH script). + // The returned UTXOs are in an indefinite order. The returned list does not + // contain unspent outputs of confirmed transactions. Outputs used as inputs of + // confirmed or mempool transactions are not returned as well because they are + // no longer UTXOs. + GetMempoolUtxosForPublicKeyHash( + publicKeyHash [20]byte, + ) ([]*UnspentTransactionOutput, error) + // EstimateSatPerVByteFee returns the estimated sat/vbyte fee for a // transaction to be confirmed within the given number of blocks. EstimateSatPerVByteFee(blocks uint32) (int64, error) diff --git a/pkg/bitcoin/chain_test.go b/pkg/bitcoin/chain_test.go index 74872d80e8..00b4f9e21e 100644 --- a/pkg/bitcoin/chain_test.go +++ b/pkg/bitcoin/chain_test.go @@ -116,12 +116,30 @@ func (lc *localChain) GetTransactionsForPublicKeyHash( panic("not implemented") } +func (lc *localChain) GetTxHashesForPublicKeyHash( + publicKeyHash [20]byte, +) ([]Hash, error) { + panic("unsupported") +} + func (lc *localChain) GetMempoolForPublicKeyHash( publicKeyHash [20]byte, ) ([]*Transaction, error) { panic("not implemented") } +func (lc *localChain) GetUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*UnspentTransactionOutput, error) { + panic("unsupported") +} + +func (lc *localChain) GetMempoolUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*UnspentTransactionOutput, error) { + panic("unsupported") +} + func (lc *localChain) EstimateSatPerVByteFee( blocks uint32, ) (int64, error) { diff --git a/pkg/bitcoin/electrum/electrum.go b/pkg/bitcoin/electrum/electrum.go index 15d2acf0ce..6d3eeb8352 100644 --- a/pkg/bitcoin/electrum/electrum.go +++ b/pkg/bitcoin/electrum/electrum.go @@ -348,11 +348,47 @@ func (c *Connection) GetTransactionMerkleProof( // not contain unconfirmed transactions living in the mempool at the moment // of request. The returned transactions list can be limited using the // `limit` parameter. For example, if `limit` is set to `5`, only the -// latest five transactions will be returned. +// latest five transactions will be returned. Note that taking an unlimited +// transaction history may be time-consuming as this function fetches +// complete transactions with all necessary data. func (c *Connection) GetTransactionsForPublicKeyHash( publicKeyHash [20]byte, limit int, ) ([]*bitcoin.Transaction, error) { + txHashes, err := c.GetTxHashesForPublicKeyHash(publicKeyHash) + if err != nil { + return nil, err + } + + var selectedTxHashes []bitcoin.Hash + if len(txHashes) > limit { + selectedTxHashes = txHashes[len(txHashes)-limit:] + } else { + selectedTxHashes = txHashes + } + + transactions := make([]*bitcoin.Transaction, len(selectedTxHashes)) + for i, txHash := range selectedTxHashes { + transaction, err := c.GetTransaction(txHash) + if err != nil { + return nil, fmt.Errorf("cannot get transaction: [%v]", err) + } + + transactions[i] = transaction + } + + return transactions, nil +} + +// GetTxHashesForPublicKeyHash gets hashes of confirmed transactions that pays +// the given public key hash using either a P2PKH or P2WPKH script. The returned +// transactions hashes are ordered by block height in the ascending order, i.e. +// the latest transaction hash is at the end of the list. The returned list does +// not contain unconfirmed transactions hashes living in the mempool at the +// moment of request. +func (c *Connection) GetTxHashesForPublicKeyHash( + publicKeyHash [20]byte, +) ([]bitcoin.Hash, error) { p2pkh, err := bitcoin.PayToPublicKeyHash(publicKeyHash) if err != nil { return nil, fmt.Errorf( @@ -398,24 +434,12 @@ func (c *Connection) GetTransactionsForPublicKeyHash( }, ) - var selectedItems []*scriptHistoryItem - if len(items) > limit { - selectedItems = items[len(items)-limit:] - } else { - selectedItems = items - } - - transactions := make([]*bitcoin.Transaction, len(selectedItems)) - for i, item := range selectedItems { - transaction, err := c.GetTransaction(item.txHash) - if err != nil { - return nil, fmt.Errorf("cannot get transaction: [%v]", err) - } - - transactions[i] = transaction + txHashes := make([]bitcoin.Hash, len(items)) + for i, item := range items { + txHashes[i] = item.txHash } - return transactions, nil + return txHashes, nil } type scriptHistoryItem struct { @@ -610,6 +634,242 @@ func (c *Connection) getScriptMempool( return convertedItems, nil } +// GetUtxosForPublicKeyHash gets unspent outputs of confirmed transactions that +// are controlled by the given public key hash (either a P2PKH or P2WPKH script). +// The returned UTXOs are ordered by block height in the ascending order, i.e. +// the latest UTXO is at the end of the list. The returned list does not contain +// unspent outputs of unconfirmed transactions living in the mempool at the +// moment of request. Outputs used as inputs of confirmed or mempool +// transactions are not returned as well because they are no longer UTXOs. +func (c *Connection) GetUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*bitcoin.UnspentTransactionOutput, error) { + p2pkh, err := bitcoin.PayToPublicKeyHash(publicKeyHash) + if err != nil { + return nil, fmt.Errorf( + "cannot build P2PKH for public key hash [0x%x]: [%v]", + publicKeyHash, + err, + ) + } + + p2wpkh, err := bitcoin.PayToWitnessPublicKeyHash(publicKeyHash) + if err != nil { + return nil, fmt.Errorf( + "cannot build P2WPKH for public key hash [0x%x]: [%v]", + publicKeyHash, + err, + ) + } + + p2pkhItems, err := c.getScriptUtxos(p2pkh, true) + if err != nil { + return nil, fmt.Errorf( + "cannot get P2PKH UTXOs for public key hash [0x%x]: [%v]", + publicKeyHash, + err, + ) + } + + p2wpkhItems, err := c.getScriptUtxos(p2wpkh, true) + if err != nil { + return nil, fmt.Errorf( + "cannot get P2WPKH UTXOs for public key hash [0x%x]: [%v]", + publicKeyHash, + err, + ) + } + + items := append(p2pkhItems, p2wpkhItems...) + + sort.SliceStable( + items, + func(i, j int) bool { + return items[i].blockHeight < items[j].blockHeight + }, + ) + + utxos := make([]*bitcoin.UnspentTransactionOutput, len(items)) + for i, item := range items { + utxos[i] = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: item.txHash, + OutputIndex: item.outputIndex, + }, + Value: int64(item.value), + } + } + + return utxos, nil +} + +// GetMempoolUtxosForPublicKeyHash gets unspent outputs of unconfirmed transactions +// that are controlled by the given public key hash (either a P2PKH or P2WPKH script). +// The returned UTXOs are in an indefinite order. The returned list does not +// contain unspent outputs of confirmed transactions. Outputs used as inputs of +// confirmed or mempool transactions are not returned as well because they are +// no longer UTXOs. +func (c *Connection) GetMempoolUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*bitcoin.UnspentTransactionOutput, error) { + p2pkh, err := bitcoin.PayToPublicKeyHash(publicKeyHash) + if err != nil { + return nil, fmt.Errorf( + "cannot build P2PKH for public key hash [0x%x]: [%v]", + publicKeyHash, + err, + ) + } + + p2wpkh, err := bitcoin.PayToWitnessPublicKeyHash(publicKeyHash) + if err != nil { + return nil, fmt.Errorf( + "cannot build P2WPKH for public key hash [0x%x]: [%v]", + publicKeyHash, + err, + ) + } + + p2pkhItems, err := c.getScriptUtxos(p2pkh, false) + if err != nil { + return nil, fmt.Errorf( + "cannot get P2PKH UTXOs for public key hash [0x%x]: [%v]", + publicKeyHash, + err, + ) + } + + p2wpkhItems, err := c.getScriptUtxos(p2wpkh, false) + if err != nil { + return nil, fmt.Errorf( + "cannot get P2WPKH UTXOs for public key hash [0x%x]: [%v]", + publicKeyHash, + err, + ) + } + + items := append(p2pkhItems, p2wpkhItems...) + + utxos := make([]*bitcoin.UnspentTransactionOutput, len(items)) + for i, item := range items { + utxos[i] = &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: item.txHash, + OutputIndex: item.outputIndex, + }, + Value: int64(item.value), + } + } + + return utxos, nil +} + +type scriptUtxoItem struct { + txHash bitcoin.Hash + outputIndex uint32 + value uint64 + blockHeight uint32 +} + +// getScriptUtxos returns unspent outputs of confirmed/unconfirmed transactions +// that are locked using the given script (P2PKH, P2WPKH, P2SH, P2WSH, etc.). +// +// If the `confirmed` flag is true, the returned list contains unspent outputs +// of confirmed transactions, sorted by the block height in the ascending order, +// i.e. the latest UTXO is at the end of the list. The resulting list does not +// contain unspent outputs of unconfirmed transactions living in the mempool +// at the moment of request. +// +// If the `confirmed` flag is false, the returned list contains unspent outputs +// of unconfirmed transactions, in an indefinite order. The resulting list +// does not contain unspent outputs of confirmed transactions. +// +// In both cases, the resulted list DOES NOT CONTAIN outputs already used as +// inputs of confirmed or mempool transactions because they are no longer UTXOs. +func (c *Connection) getScriptUtxos( + script []byte, + confirmed bool, +) ([]*scriptUtxoItem, error) { + scriptHash := sha256.Sum256(script) + reversedScriptHash := byteutils.Reverse(scriptHash[:]) + reversedScriptHashString := hex.EncodeToString(reversedScriptHash) + + items, err := requestWithRetry( + c, + func( + ctx context.Context, + client *electrum.Client, + ) ([]*electrum.ListUnspentResult, error) { + return client.ListUnspent(ctx, reversedScriptHashString) + }, + "ListUnspent", + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get UTXOs for script [0x%x]: [%v]", + script, + err, + ) + } + + // According to https://electrumx.readthedocs.io/en/stable/protocol-methods.html#blockchain-scripthash-listunspent + // unconfirmed items living in the mempool are appended at the end of the + // returned list and their height value is either -1 or 0. That means + // we need to take all items with height >0 to obtain confirmed UTXO + // items and <=0 if we want to take the unconfirmed ones. + var filterFn func(item *electrum.ListUnspentResult) bool + if confirmed { + filterFn = func(item *electrum.ListUnspentResult) bool { + return item.Height > 0 + } + } else { + filterFn = func(item *electrum.ListUnspentResult) bool { + return item.Height <= 0 + } + } + + filteredItems := make([]*scriptUtxoItem, 0) + for _, item := range items { + if filterFn(item) { + txHash, err := bitcoin.NewHashFromString( + item.Hash, + bitcoin.ReversedByteOrder, + ) + if err != nil { + return nil, fmt.Errorf( + "cannot parse hash [%s]: [%v]", + item.Hash, + err, + ) + } + + filteredItems = append( + filteredItems, &scriptUtxoItem{ + txHash: txHash, + outputIndex: item.Position, + value: item.Value, + blockHeight: item.Height, + }, + ) + } + } + + if confirmed { + // The list returned from client.ListUnspent is sorted by the block height + // in the ascending order though we are sorting it again just in case + // (e.g. API contract changes). Sorting makes sense only for confirmed + // items as unconfirmed ones have a block height of -1 or 0. + sort.SliceStable( + filteredItems, + func(i, j int) bool { + return filteredItems[i].blockHeight < filteredItems[j].blockHeight + }, + ) + } + + return filteredItems, nil +} + // EstimateSatPerVByteFee returns the estimated sat/vbyte fee for a // transaction to be confirmed within the given number of blocks. func (c *Connection) EstimateSatPerVByteFee(blocks uint32) (int64, error) { diff --git a/pkg/bitcoin/electrum/electrum_integration_test.go b/pkg/bitcoin/electrum/electrum_integration_test.go index c5b80c8710..93eb7f3473 100644 --- a/pkg/bitcoin/electrum/electrum_integration_test.go +++ b/pkg/bitcoin/electrum/electrum_integration_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "math" + "sort" "strings" "testing" "time" @@ -442,6 +443,86 @@ func TestGetTransactionsForPublicKeyHash_Integration(t *testing.T) { }) } +func TestGetTxHashesForPublicKeyHash_Integration(t *testing.T) { + runParallel(t, func(t *testing.T, testConfig testConfig) { + electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + defer cancelCtx() + + data, ok := testData.TransactionsForPublicKeyHash[testConfig.network] + if !ok { + t.Fatalf( + "transactions for public key hash data not defined for network %s", + testConfig.network, + ) + } + + publicKeyHash := (*[20]byte)(data.PublicKeyHash) + expectedHashes := data.Transactions + + actualHashes, err := electrum.GetTxHashesForPublicKeyHash(*publicKeyHash) + if err != nil { + t.Fatal(err) + } + + // If the actual hashes set is greater than the expected set, we need + // to adjust them to the same length to make a comparison that makes sense. + if len(actualHashes) > len(expectedHashes) { + actualHashes = actualHashes[len(actualHashes)-len(expectedHashes):] + } + + if diff := deep.Equal(actualHashes, expectedHashes); diff != nil { + t.Errorf("compare failed: %v", diff) + } + }) +} + +func TestGetUtxosForPublicKeyHash_Integration(t *testing.T) { + runParallel(t, func(t *testing.T, testConfig testConfig) { + electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + defer cancelCtx() + + data, ok := testData.TransactionsForPublicKeyHash[testConfig.network] + if !ok { + t.Fatalf( + "transactions for public key hash data not defined for network %s", + testConfig.network, + ) + } + + publicKeyHash := (*[20]byte)(data.PublicKeyHash) + expectedUtxos := data.Utxos + + utxos, err := electrum.GetUtxosForPublicKeyHash(*publicKeyHash) + if err != nil { + t.Fatal(err) + } + + actualUtxos := make([]string, len(utxos)) + for i, utxo := range utxos { + actualUtxos[i] = fmt.Sprintf("%v:%v:%v", + utxo.Outpoint.TransactionHash.Hex(bitcoin.ReversedByteOrder), + utxo.Outpoint.OutputIndex, + utxo.Value, + ) + } + + // Some UTXOs in the test data come from the same block and their + // position is sometimes switched. Let's use another sort criteria + // to achieve a predictable order, i.e. sort the whole UTXO string + // (txHash:outputIndex:value) in the ascending order. + sort.SliceStable( + actualUtxos, + func(i, j int) bool { + return actualUtxos[i] < actualUtxos[j] + }, + ) + + if diff := deep.Equal(actualUtxos, expectedUtxos); diff != nil { + t.Errorf("compare failed: %v", diff) + } + }) +} + func TestEstimateSatPerVByteFee_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) diff --git a/pkg/maintainer/btcdiff/bitcoin_chain_test.go b/pkg/maintainer/btcdiff/bitcoin_chain_test.go index 2704e38b1b..3dbf262153 100644 --- a/pkg/maintainer/btcdiff/bitcoin_chain_test.go +++ b/pkg/maintainer/btcdiff/bitcoin_chain_test.go @@ -90,12 +90,30 @@ func (lbc *localBitcoinChain) GetTransactionsForPublicKeyHash( panic("unsupported") } +func (lbc *localBitcoinChain) GetTxHashesForPublicKeyHash( + publicKeyHash [20]byte, +) ([]bitcoin.Hash, error) { + panic("unsupported") +} + func (lbc *localBitcoinChain) GetMempoolForPublicKeyHash( publicKeyHash [20]byte, ) ([]*bitcoin.Transaction, error) { panic("unsupported") } +func (lbc *localBitcoinChain) GetUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*bitcoin.UnspentTransactionOutput, error) { + panic("unsupported") +} + +func (lbc *localBitcoinChain) GetMempoolUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*bitcoin.UnspentTransactionOutput, error) { + panic("unsupported") +} + // SetBlockHeaders sets internal headers for testing purposes. func (lbc *localBitcoinChain) SetBlockHeaders( blockHeaders map[uint]*bitcoin.BlockHeader, diff --git a/pkg/maintainer/spv/bitcoin_chain_test.go b/pkg/maintainer/spv/bitcoin_chain_test.go index 673bc919e8..ed9465a051 100644 --- a/pkg/maintainer/spv/bitcoin_chain_test.go +++ b/pkg/maintainer/spv/bitcoin_chain_test.go @@ -148,6 +148,12 @@ func (lbc *localBitcoinChain) GetTransactionsForPublicKeyHash( return matchingTransactions, nil } +func (lbc *localBitcoinChain) GetTxHashesForPublicKeyHash( + publicKeyHash [20]byte, +) ([]bitcoin.Hash, error) { + panic("unsupported") +} + func (lbc *localBitcoinChain) GetMempoolForPublicKeyHash(publicKeyHash [20]byte) ( []*bitcoin.Transaction, error, @@ -155,6 +161,18 @@ func (lbc *localBitcoinChain) GetMempoolForPublicKeyHash(publicKeyHash [20]byte) panic("unsupported") } +func (lbc *localBitcoinChain) GetUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*bitcoin.UnspentTransactionOutput, error) { + panic("unsupported") +} + +func (lbc *localBitcoinChain) GetMempoolUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*bitcoin.UnspentTransactionOutput, error) { + panic("unsupported") +} + func (lbc *localBitcoinChain) EstimateSatPerVByteFee(blocks uint32) ( int64, error, diff --git a/pkg/maintainer/wallet/bitcoin_chain_test.go b/pkg/maintainer/wallet/bitcoin_chain_test.go index 2a250a8ed3..eb608482f2 100644 --- a/pkg/maintainer/wallet/bitcoin_chain_test.go +++ b/pkg/maintainer/wallet/bitcoin_chain_test.go @@ -99,12 +99,30 @@ func (lbc *LocalBitcoinChain) GetTransactionsForPublicKeyHash( panic("unsupported") } +func (lbc *LocalBitcoinChain) GetTxHashesForPublicKeyHash( + publicKeyHash [20]byte, +) ([]bitcoin.Hash, error) { + panic("unsupported") +} + func (lbc *LocalBitcoinChain) GetMempoolForPublicKeyHash( publicKeyHash [20]byte, ) ([]*bitcoin.Transaction, error) { panic("unsupported") } +func (lbc *LocalBitcoinChain) GetUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*bitcoin.UnspentTransactionOutput, error) { + panic("unsupported") +} + +func (lbc *LocalBitcoinChain) GetMempoolUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*bitcoin.UnspentTransactionOutput, error) { + panic("unsupported") +} + func (lbc *LocalBitcoinChain) EstimateSatPerVByteFee( blocks uint32, ) (int64, error) { diff --git a/pkg/tbtc/bitcoin_chain_test.go b/pkg/tbtc/bitcoin_chain_test.go index 6cdfd8ff50..7efabc9398 100644 --- a/pkg/tbtc/bitcoin_chain_test.go +++ b/pkg/tbtc/bitcoin_chain_test.go @@ -123,6 +123,37 @@ func (lbc *localBitcoinChain) GetTransactionsForPublicKeyHash( return matchingTransactions, nil } +func (lbc *localBitcoinChain) GetTxHashesForPublicKeyHash( + publicKeyHash [20]byte, +) ([]bitcoin.Hash, error) { + lbc.transactionsMutex.Lock() + defer lbc.transactionsMutex.Unlock() + + p2pkh, err := bitcoin.PayToPublicKeyHash(publicKeyHash) + if err != nil { + return nil, err + } + + p2wpkh, err := bitcoin.PayToWitnessPublicKeyHash(publicKeyHash) + if err != nil { + return nil, err + } + + matchingTxHashes := make([]bitcoin.Hash, 0) + + for _, transaction := range lbc.transactions { + for _, output := range transaction.Outputs { + script := output.PublicKeyScript + if bytes.Equal(script, p2pkh) || bytes.Equal(script, p2wpkh) { + matchingTxHashes = append(matchingTxHashes, transaction.Hash()) + break + } + } + } + + return matchingTxHashes, nil +} + func (lbc *localBitcoinChain) GetMempoolForPublicKeyHash( publicKeyHash [20]byte, ) ([]*bitcoin.Transaction, error) { @@ -154,6 +185,48 @@ func (lbc *localBitcoinChain) GetMempoolForPublicKeyHash( return matchingTransactions, nil } +func (lbc *localBitcoinChain) GetUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*bitcoin.UnspentTransactionOutput, error) { + lbc.transactionsMutex.Lock() + defer lbc.transactionsMutex.Unlock() + + p2pkh, err := bitcoin.PayToPublicKeyHash(publicKeyHash) + if err != nil { + return nil, err + } + + p2wpkh, err := bitcoin.PayToWitnessPublicKeyHash(publicKeyHash) + if err != nil { + return nil, err + } + + matchingUtxos := make([]*bitcoin.UnspentTransactionOutput, 0) + + for _, transaction := range lbc.transactions { + for i, output := range transaction.Outputs { + script := output.PublicKeyScript + if bytes.Equal(script, p2pkh) || bytes.Equal(script, p2wpkh) { + matchingUtxos = append(matchingUtxos, &bitcoin.UnspentTransactionOutput{ + Outpoint: &bitcoin.TransactionOutpoint{ + TransactionHash: transaction.Hash(), + OutputIndex: uint32(i), + }, + Value: output.Value, + }) + } + } + } + + return matchingUtxos, nil +} + +func (lbc *localBitcoinChain) GetMempoolUtxosForPublicKeyHash( + publicKeyHash [20]byte, +) ([]*bitcoin.UnspentTransactionOutput, error) { + return nil, nil +} + func (lbc *localBitcoinChain) EstimateSatPerVByteFee( blocks uint32, ) (int64, error) { diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index efc3ef0f8b..533b5ab925 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -207,6 +207,20 @@ type BridgeChain interface { walletPublicKeyHash [20]byte, redeemerOutputScript bitcoin.Script, ) (*RedemptionRequest, bool, error) + + // GetDepositRequest gets the on-chain deposit request for the given + // funding transaction hash and output index.The returned values represent: + // - deposit request which is non-nil only when the deposit request was + // found, + // - boolean value which is true if the deposit request was found, false + // otherwise, + // - error which is non-nil only when the function execution failed. It will + // be nil if the deposit request was not found, but the function execution + // succeeded. + GetDepositRequest( + fundingTxHash bitcoin.Hash, + fundingOutputIndex uint32, + ) (*DepositChainRequest, bool, error) } // NewWalletRegisteredEvent represents a new wallet registered event. diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 07ff55ecb3..4035ec7f16 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -543,6 +543,13 @@ func (lc *localChain) GetPendingRedemptionRequest( return request, true, nil } +func (lc *localChain) GetDepositRequest( + fundingTxHash bitcoin.Hash, + fundingOutputIndex uint32, +) (*DepositChainRequest, bool, error) { + panic("not supported") +} + func (lc *localChain) setPendingRedemptionRequest( walletPublicKeyHash [20]byte, request *RedemptionRequest, diff --git a/pkg/tbtc/deposit_sweep.go b/pkg/tbtc/deposit_sweep.go index 68e0785fbb..902ca3722d 100644 --- a/pkg/tbtc/deposit_sweep.go +++ b/pkg/tbtc/deposit_sweep.go @@ -131,6 +131,7 @@ func (dsa *depositSweepAction) execute() error { err = EnsureWalletSyncedBetweenChains( walletPublicKeyHash, walletMainUtxo, + dsa.chain, dsa.btcChain, ) if err != nil { diff --git a/pkg/tbtc/redemption.go b/pkg/tbtc/redemption.go index 56d4e52801..472966bf6f 100644 --- a/pkg/tbtc/redemption.go +++ b/pkg/tbtc/redemption.go @@ -173,6 +173,7 @@ func (ra *redemptionAction) execute() error { err = EnsureWalletSyncedBetweenChains( walletPublicKeyHash, walletMainUtxo, + ra.chain, ra.btcChain, ) if err != nil { diff --git a/pkg/tbtc/wallet.go b/pkg/tbtc/wallet.go index 6d39ff0cba..0a1ff1b67e 100644 --- a/pkg/tbtc/wallet.go +++ b/pkg/tbtc/wallet.go @@ -384,11 +384,6 @@ func (w *wallet) String() string { // currently registered in the Bridge on-chain contract. The returned // main UTXO can be nil if the wallet does not have a main UTXO registered // in the Bridge at the moment. -// -// WARNING: THIS FUNCTION CANNOT DETERMINE THE MAIN UTXO IF IT COMES FROM A -// BITCOIN TRANSACTION THAT IS NOT ONE OF THE LATEST FIVE TRANSACTIONS -// TARGETING THE GIVEN WALLET PUBLIC KEY HASH. HOWEVER, SUCH A CASE IS -// VERY UNLIKELY. func DetermineWalletMainUtxo( walletPublicKeyHash [20]byte, bridgeChain BridgeChain, @@ -412,9 +407,12 @@ func DetermineWalletMainUtxo( // to the second last BTC transaction. In theory, such a gap between // the actual latest BTC transaction and the registered main UTXO in // the Bridge may be even wider. To cover the worst possible cases, we - // always take the five latest transactions made by the wallet for - // consideration. - transactions, err := btcChain.GetTransactionsForPublicKeyHash(walletPublicKeyHash, 5) + // must rely on the full transaction history. Due to performance reasons, + // we are first taking just the transactions hashes (fast call) and then + // fetch full transaction data (time-consuming calls) starting from + // the most recent transactions as there is a high chance the main UTXO + // comes from there. + txHashes, err := btcChain.GetTxHashesForPublicKeyHash(walletPublicKeyHash) if err != nil { return nil, fmt.Errorf("cannot get transactions history for wallet: [%v]", err) } @@ -430,8 +428,17 @@ func DetermineWalletMainUtxo( // Start iterating from the latest transaction as the chance it matches // the wallet main UTXO is the highest. - for i := len(transactions) - 1; i >= 0; i-- { - transaction := transactions[i] + for i := len(txHashes) - 1; i >= 0; i-- { + txHash := txHashes[i] + + transaction, err := btcChain.GetTransaction(txHash) + if err != nil { + return nil, fmt.Errorf( + "cannot get transaction with hash [%s]: [%v]", + txHash.String(), + err, + ) + } // Iterate over transaction's outputs and find the one that targets // the wallet public key hash. @@ -464,89 +471,127 @@ func DetermineWalletMainUtxo( } // EnsureWalletSyncedBetweenChains makes sure all actions taken by the wallet -// on the Bitcoin chain are reflected in the host chain Bridge. This translates -// to two conditions that must be met: -// - The wallet main UTXO registered in the host chain Bridge comes from the -// latest BTC transaction OR wallet main UTXO is unset and wallet's BTC -// transaction history is empty. This condition ensures that all expected SPV -// proofs of confirmed BTC transactions were submitted to the host chain Bridge -// thus the wallet state held known to the Bridge matches the actual state -// on the BTC chain. -// - There are no pending BTC transactions in the mempool. This condition -// ensures the wallet doesn't currently perform any action on the BTC chain. -// Such a transactions indicate a possible state change in the future -// but their outcome cannot be determined at this stage so, the wallet -// should not perform new actions at the moment. +// on the Bitcoin chain are reflected in the host chain Bridge. func EnsureWalletSyncedBetweenChains( walletPublicKeyHash [20]byte, walletMainUtxo *bitcoin.UnspentTransactionOutput, + bridgeChain BridgeChain, btcChain bitcoin.Chain, ) error { - // Take the recent transactions history for the wallet. - history, err := btcChain.GetTransactionsForPublicKeyHash(walletPublicKeyHash, 5) + // Take UTXOs controlled by the wallet on Bitcoin chain. Those are outputs + // coming from confirmed transactions, ready to be spent right now, and + // not used as inputs of other (either confirmed or mempool) transactions. + confirmedUtxos, err := btcChain.GetUtxosForPublicKeyHash(walletPublicKeyHash) if err != nil { - return fmt.Errorf("cannot get transactions history: [%v]", err) + return fmt.Errorf("cannot get confirmed UTXOs: [%v]", err) } if walletMainUtxo != nil { - // If the wallet main UTXO exists, the transaction history must + // If the wallet main UTXO exists, the UTXOs set must // contain at least one item. If it is empty, something went // really wrong. This should never happen but check this scenario // just in case. - if len(history) == 0 { + if len(confirmedUtxos) == 0 { return fmt.Errorf( - "wallet main UTXO exists but there are no BTC " + - "transactions produced by the wallet", + "wallet main UTXO exists but there are no " + + "UTXOs controlled by the wallet on Bitcoin chain", ) } - // The transaction history is not empty for sure. Take the latest BTC - // transaction from the history. - latestTransaction := history[len(history)-1] - - // Make sure the wallet main UTXO comes from the latest transaction. - // That means all expected SPV proofs were submitted to the Bridge. - // If the wallet main UTXO transaction hash doesn't match the latest - // transaction, that means the SPV proof for the latest transaction was - // not submitted to the Bridge yet. - // - // Note that it is enough to check that the wallet main UTXO transaction - // hash matches the latest transaction hash. There is no way the main - // UTXO changes and the transaction hash stays the same. The Bridge - // enforces that all wallet transactions form a sequence and refer - // each other. - if walletMainUtxo.Outpoint.TransactionHash != latestTransaction.Hash() { - return fmt.Errorf( - "wallet main UTXO doesn't come from the latest BTC transaction", - ) + // Start iterating from the latest UTXO as the chance it matches + // the wallet main UTXO is the highest. + for i := len(confirmedUtxos) - 1; i >= 0; i-- { + utxo := confirmedUtxos[i] + + // If the wallet main UTXO is among the UTXOs returned by Bitcoin + // client, that means the wallet has not spent it by creating + // a Bitcoin transaction. That implies the wallet is not doing + // any action on Bitcoin right now and their state here is synced + // with the host chain Bridge. + if walletMainUtxo.Outpoint.TransactionHash == utxo.Outpoint.TransactionHash && + walletMainUtxo.Outpoint.OutputIndex == utxo.Outpoint.OutputIndex && + walletMainUtxo.Value == utxo.Value { + return nil + } } + + return fmt.Errorf("wallet main UTXO registered in the " + + "host chain Bridge is actually spent on Bitcoin; " + + "Bridge is probably awaiting some SPV proofs", + ) } else { - // If the wallet main UTXO doesn't exist, the transaction history must - // be empty. If it is not, that could mean there is a Bitcoin transaction - // produced by the wallet whose SPV proof was not submitted to - // the Bridge yet. - if len(history) != 0 { - return fmt.Errorf( - "wallet main UTXO doesn't exist but there are BTC " + - "transactions produced by the wallet", - ) + // Otherwise, the wallet is a fresh one and requires special + // treatment. We need to minimize the chance the wallet is + // currently doing their first Bitcoin transaction but, in the same + // time, we cannot just assume their transaction history must be + // empty as there can be spam transactions which arbitrarily send BTC + // to the wallet address. We need to look at the confirmed and mempool + // UTXOs and make sure there are no transactions produced by the wallet + // there. + mempoolUtxos, err := btcChain.GetMempoolUtxosForPublicKeyHash(walletPublicKeyHash) + if err != nil { + return fmt.Errorf("cannot get mempool UTXOs: [%v]", err) } - } - // Regardless of the main UTXO state, we need to make sure that - // no pending wallet transactions exist in the mempool. That way, - // we are handling a plenty of corner cases like transactions races - // that could potentially lead to fraudulent transactions and funds loss. - mempool, err := btcChain.GetMempoolForPublicKeyHash(walletPublicKeyHash) - if err != nil { - return fmt.Errorf("cannot get mempool: [%v]", err) - } + allUtxos := append(confirmedUtxos, mempoolUtxos...) + if len(allUtxos) == 0 { + // Wallet have not produced any transactions - we are good. + return nil + } - if len(mempool) != 0 { - return fmt.Errorf("unconfirmed transactions exist in the mempool") - } + for _, utxo := range allUtxos { + // We know that valid first transaction of the wallet always + // have just one output. Any utxos with output index other + // than 0 are certainly not produced by the wallet and, we should + // not take them into account. + if utxo.Outpoint.OutputIndex != 0 { + continue + } - return nil + transaction, err := btcChain.GetTransaction(utxo.Outpoint.TransactionHash) + if err != nil { + return fmt.Errorf( + "cannot get transaction with hash [%s]: [%v]", + utxo.Outpoint.TransactionHash.String(), + err, + ) + } + + // We know that valid first transaction of the wallet have all their + // inputs referring to revealed deposits. We need to check just + // one input. If it points to a revealed deposit, that means + // the given transaction is produced by our wallet. Otherwise, + // such a transaction is a spam. + input := transaction.Inputs[0] + _, isDeposit, err := bridgeChain.GetDepositRequest( + input.Outpoint.TransactionHash, + input.Outpoint.OutputIndex, + ) + if err != nil { + return fmt.Errorf( + "cannot get deposit request for hash [%s] "+ + "and output index [%v]: [%v]", + input.Outpoint.TransactionHash.String(), + input.Outpoint.OutputIndex, + err, + ) + } + + if isDeposit { + // If that's the case, the wallet was already done their + // first Bitcoin transaction and the Bridge is awaiting the + // SPV proof. + return fmt.Errorf("wallet already produced their first " + + "Bitcoin transaction; Bridge is probably awaiting the SPV proof", + ) + } + + // If the transaction does not refer revealed deposits, it is + // a spam, and we go to the next one. + } + + return nil + } } // signer represents a threshold signer of a tBTC wallet. A signer holds diff --git a/pkg/tbtc/wallet_test.go b/pkg/tbtc/wallet_test.go index 977f491d7a..ec46fb0a3e 100644 --- a/pkg/tbtc/wallet_test.go +++ b/pkg/tbtc/wallet_test.go @@ -213,11 +213,11 @@ func TestDetermineWalletMainUtxo(t *testing.T) { expectedMainUtxo: nil, expectedErr: nil, }, - "wallet main UTXO comes from a too old transaction": { - mainUtxoHash: chain.ComputeMainUtxoHash(walletUtxoFrom(transactions[0])), - expectedErr: fmt.Errorf("main UTXO not found"), + "wallet main UTXO comes from the oldest transaction": { + mainUtxoHash: chain.ComputeMainUtxoHash(walletUtxoFrom(transactions[0])), + expectedMainUtxo: walletUtxoFrom(transactions[0]), }, - "wallet main UTXO comes from the oldest acceptable transaction": { + "wallet main UTXO comes from the middle transaction": { mainUtxoHash: chain.ComputeMainUtxoHash(walletUtxoFrom(transactions[1])), expectedMainUtxo: walletUtxoFrom(transactions[1]), },