diff --git a/chain/bitcoind_client.go b/chain/bitcoind_client.go index 4082b729df..a2537da617 100644 --- a/chain/bitcoind_client.go +++ b/chain/bitcoind_client.go @@ -1400,3 +1400,11 @@ func (c *BitcoindClient) filterTx(txDetails *btcutil.Tx, return true, rec, nil } + +// LookupInputMempoolSpend returns the transaction hash and true if the given +// input is found being spent in mempool, otherwise it returns nil and false. +func (c *BitcoindClient) LookupInputMempoolSpend(op wire.OutPoint) ( + chainhash.Hash, bool) { + + return c.chainConn.events.LookupInputSpend(op) +} diff --git a/chain/bitcoind_events_test.go b/chain/bitcoind_events_test.go index fc9f8b8f8c..3056cc495d 100644 --- a/chain/bitcoind_events_test.go +++ b/chain/bitcoind_events_test.go @@ -67,6 +67,9 @@ func TestBitcoindEvents(t *testing.T) { // mempool. btcClient = setupBitcoind(t, addr, test.rpcPolling) testNotifySpentMempool(t, miner1, btcClient) + + // Test looking up mempool for input spent. + testLookupInputMempoolSpend(t, miner1, btcClient) }) } } @@ -214,6 +217,46 @@ func testNotifySpentMempool(t *testing.T, miner *rpctest.Harness, } } +// testLookupInputMempoolSpend tests that LookupInputMempoolSpend returns the +// correct tx hash and whether the input has been spent in the mempool. +func testLookupInputMempoolSpend(t *testing.T, miner *rpctest.Harness, + client *BitcoindClient) { + + rt := require.New(t) + + script, _, err := randPubKeyHashScript() + rt.NoError(err) + + // Create a test tx. + tx, err := miner.CreateTransaction( + []*wire.TxOut{{Value: 1000, PkScript: script}}, 5, false, + ) + rt.NoError(err) + + // Lookup the input in mempool. + op := tx.TxIn[0].PreviousOutPoint + txid, found := client.LookupInputMempoolSpend(op) + + // Expect that the input has not been spent in the mempool. + rt.False(found) + rt.Zero(txid) + + // Send the tx which will put it in the mempool. + _, err = client.SendRawTransaction(tx, true) + rt.NoError(err) + + // Lookup the input again should return the spending tx. + // + // NOTE: We need to wait for the tx to propagate to the mempool. + rt.Eventually(func() bool { + txid, found = client.LookupInputMempoolSpend(op) + return found + }, 5*time.Second, 100*time.Millisecond) + + // Check the expected txid is returned. + rt.Equal(tx.TxHash(), txid) +} + // testReorg tests that the given BitcoindClient correctly responds to a chain // re-org. func testReorg(t *testing.T, miner1, miner2 *rpctest.Harness, diff --git a/chain/bitcoind_zmq_events.go b/chain/bitcoind_zmq_events.go index 0f8b2f5b91..66e1f3388c 100644 --- a/chain/bitcoind_zmq_events.go +++ b/chain/bitcoind_zmq_events.go @@ -2,7 +2,6 @@ package chain import ( "bytes" - "encoding/json" "fmt" "io" "math/rand" @@ -219,22 +218,6 @@ func (b *bitcoindZMQEvents) BlockNotifications() <-chan *wire.MsgBlock { return b.blockNtfns } -// getTxSpendingPrevOutReq is the rpc request format for bitcoind's -// gettxspendingprevout call. -type getTxSpendingPrevOutReq struct { - Txid string `json:"txid"` - Vout uint32 `json:"vout"` -} - -// getTxSpendingPrevOutResp is the rpc response format for bitcoind's -// gettxspendingprevout call. It returns the "spendingtxid" if one exists in -// the mempool. -type getTxSpendingPrevOutResp struct { - Txid string `json:"txid"` - Vout float64 `json:"vout"` - SpendingTxid string `json:"spendingtxid"` -} - // LookupInputSpend returns the transaction that spends the given outpoint // found in the mempool. func (b *bitcoindZMQEvents) LookupInputSpend( @@ -501,28 +484,7 @@ func (b *bitcoindZMQEvents) mempoolPoller() { func getTxSpendingPrevOut(op wire.OutPoint, client *rpcclient.Client) (chainhash.Hash, bool) { - prevoutReq := &getTxSpendingPrevOutReq{ - Txid: op.Hash.String(), Vout: op.Index, - } - - // The RPC takes an array of prevouts so we have an array with a single - // item since we don't yet batch calls to LookupInputSpend. - prevoutArr := []*getTxSpendingPrevOutReq{prevoutReq} - - req, err := json.Marshal(prevoutArr) - if err != nil { - return chainhash.Hash{}, false - } - - resp, err := client.RawRequest( - "gettxspendingprevout", []json.RawMessage{req}, - ) - if err != nil { - return chainhash.Hash{}, false - } - - var prevoutResps []getTxSpendingPrevOutResp - err = json.Unmarshal(resp, &prevoutResps) + prevoutResps, err := client.GetTxSpendingPrevOut([]wire.OutPoint{op}) if err != nil { return chainhash.Hash{}, false } diff --git a/chain/btcd.go b/chain/btcd.go index 4ddb183c40..85d8013a9f 100644 --- a/chain/btcd.go +++ b/chain/btcd.go @@ -49,6 +49,8 @@ var _ Interface = (*RPCClient)(nil) // but must be done using the Start method. If the remote server does not // operate on the same bitcoin network as described by the passed chain // parameters, the connection will be disconnected. +// +// TODO(yy): deprecate it in favor of NewRPCClientWithConfig. func NewRPCClient(chainParams *chaincfg.Params, connect, user, pass string, certs []byte, disableTLS bool, reconnectAttempts int) (*RPCClient, error) { @@ -91,6 +93,109 @@ func NewRPCClient(chainParams *chaincfg.Params, connect, user, pass string, cert return client, nil } +// RPCClientConfig defines the config options used when initializing the RPC +// Client. +type RPCClientConfig struct { + // Conn describes the connection configuration parameters for the + // client. + Conn *rpcclient.ConnConfig + + // Params defines a Bitcoin network by its parameters. + Chain *chaincfg.Params + + // NotificationHandlers defines callback function pointers to invoke + // with notifications. If not set, the default handlers defined in this + // client will be used. + NotificationHandlers *rpcclient.NotificationHandlers + + // ReconnectAttempts defines the number to reties (each after an + // increasing backoff) if the connection can not be established. + ReconnectAttempts int +} + +// validate checks the required config options are set. +func (r *RPCClientConfig) validate() error { + if r == nil { + return errors.New("missing rpc config") + } + + // Make sure retry attempts is positive. + if r.ReconnectAttempts < 0 { + return errors.New("reconnectAttempts must be positive") + } + + // Make sure the chain params are configed. + if r.Chain == nil { + return errors.New("missing chain params config") + } + + // Make sure connection config is supplied. + if r.Conn == nil { + return errors.New("missing conn config") + } + + // If disableTLS is false, the remote RPC certificate must be provided + // in the certs slice. + if !r.Conn.DisableTLS && r.Conn.Certificates == nil { + return errors.New("must provide certs when TLS is enabled") + } + + return nil +} + +// NewRPCClientWithConfig creates a client connection to the server based on +// the config options supplised. +// +// The connection is not established immediately, but must be done using the +// Start method. If the remote server does not operate on the same bitcoin +// network as described by the passed chain parameters, the connection will be +// disconnected. +func NewRPCClientWithConfig(cfg *RPCClientConfig) (*RPCClient, error) { + // Make sure the config is valid. + if err := cfg.validate(); err != nil { + return nil, err + } + + // Mimic the old behavior defined in `NewRPCClient`. We will remove + // these hard-codings once this package is more properly refactored. + cfg.Conn.DisableAutoReconnect = false + cfg.Conn.DisableConnectOnNew = true + + client := &RPCClient{ + connConfig: cfg.Conn, + chainParams: cfg.Chain, + reconnectAttempts: cfg.ReconnectAttempts, + enqueueNotification: make(chan interface{}), + dequeueNotification: make(chan interface{}), + currentBlock: make(chan *waddrmgr.BlockStamp), + quit: make(chan struct{}), + } + + // Use the configed notification callbacks, if not set, default to the + // callbacks defined in this package. + ntfnCallbacks := cfg.NotificationHandlers + if ntfnCallbacks == nil { + ntfnCallbacks = &rpcclient.NotificationHandlers{ + OnClientConnected: client.onClientConnect, + OnBlockConnected: client.onBlockConnected, + OnBlockDisconnected: client.onBlockDisconnected, + OnRecvTx: client.onRecvTx, + OnRedeemingTx: client.onRedeemingTx, + OnRescanFinished: client.onRescanFinished, + OnRescanProgress: client.onRescanProgress, + } + } + + // Create the RPC client using the above config. + rpcClient, err := rpcclient.New(client.connConfig, ntfnCallbacks) + if err != nil { + return nil, err + } + + client.Client = rpcClient + return client, nil +} + // BackEnd returns the name of the driver. func (c *RPCClient) BackEnd() string { return "btcd" @@ -465,3 +570,11 @@ func (c *RPCClient) POSTClient() (*rpcclient.Client, error) { configCopy.HTTPPostMode = true return rpcclient.New(&configCopy, nil) } + +// LookupInputMempoolSpend returns the transaction hash and true if the given +// input is found being spent in mempool, otherwise it returns nil and false. +func (c *RPCClient) LookupInputMempoolSpend(op wire.OutPoint) ( + chainhash.Hash, bool) { + + return getTxSpendingPrevOut(op, c.Client) +} diff --git a/chain/btcd_test.go b/chain/btcd_test.go new file mode 100644 index 0000000000..5d0abb575b --- /dev/null +++ b/chain/btcd_test.go @@ -0,0 +1,58 @@ +package chain + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/rpcclient" + "github.com/stretchr/testify/require" +) + +// TestValidateConfig checks the `validate` method on the RPCClientConfig +// behaves as expected. +func TestValidateConfig(t *testing.T) { + t.Parallel() + + rt := require.New(t) + + // ReconnectAttempts must be positive. + cfg := &RPCClientConfig{ + ReconnectAttempts: -1, + } + rt.ErrorContains(cfg.validate(), "reconnectAttempts") + + // Must specify a chain params. + cfg = &RPCClientConfig{ + ReconnectAttempts: 1, + } + rt.ErrorContains(cfg.validate(), "chain params") + + // Must specify a connection config. + cfg = &RPCClientConfig{ + ReconnectAttempts: 1, + Chain: &chaincfg.Params{}, + } + rt.ErrorContains(cfg.validate(), "conn config") + + // Must specify a certificate when using TLS. + cfg = &RPCClientConfig{ + ReconnectAttempts: 1, + Chain: &chaincfg.Params{}, + Conn: &rpcclient.ConnConfig{}, + } + rt.ErrorContains(cfg.validate(), "certs") + + // Validate config. + cfg = &RPCClientConfig{ + ReconnectAttempts: 1, + Chain: &chaincfg.Params{}, + Conn: &rpcclient.ConnConfig{ + DisableTLS: true, + }, + } + rt.NoError(cfg.validate()) + + // When a nil config is provided, it should return an error. + _, err := NewRPCClientWithConfig(nil) + rt.ErrorContains(err, "missing rpc config") +} diff --git a/go.mod b/go.mod index 5c2e69c089..a6546069f8 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,7 @@ module github.com/btcsuite/btcwallet require ( - github.com/btcsuite/btcd v0.24.1-0.20240116200649-17fdc5219b36 + github.com/btcsuite/btcd v0.24.1-0.20240301210420-1a2b599bf1af github.com/btcsuite/btcd/btcec/v2 v2.2.2 github.com/btcsuite/btcd/btcutil v1.1.5 github.com/btcsuite/btcd/btcutil/psbt v1.1.8 diff --git a/go.sum b/go.sum index 6d99908b02..709752211d 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13P github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.22.0-beta.0.20220207191057-4dc4ff7963b4/go.mod h1:7alexyj/lHlOtr2PJK7L/+HDJZpcGDn/pAU98r7DY08= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= -github.com/btcsuite/btcd v0.24.1-0.20240116200649-17fdc5219b36 h1:Us/FoCuHjjn1OfE278h9QTGuuydc0n+SA+NlycvfNsM= -github.com/btcsuite/btcd v0.24.1-0.20240116200649-17fdc5219b36/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= +github.com/btcsuite/btcd v0.24.1-0.20240301210420-1a2b599bf1af h1:F60A3wst4/fy9Yr1Vn8MYmFlfn7DNLxp8o8UTvhqgBE= +github.com/btcsuite/btcd v0.24.1-0.20240301210420-1a2b599bf1af/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= github.com/btcsuite/btcd/btcec/v2 v2.2.2 h1:5uxe5YjoCq+JeOpg0gZSNHuFgeogrocBYxvg6w9sAgc=