diff --git a/client/asset/bch/spv.go b/client/asset/bch/spv.go index 891c031710..9e509eb60c 100644 --- a/client/asset/bch/spv.go +++ b/client/asset/bch/spv.go @@ -794,6 +794,18 @@ func (w *bchSPVWallet) RemovePeer(addr string) error { return w.peerManager.RemovePeer(addr) } +func (w *bchSPVWallet) TotalReceivedForAddr(btcAddr btcutil.Address, minConf int32) (btcutil.Amount, error) { + bchAddr, err := dexbch.BTCAddrToBCHAddr(btcAddr, w.btcParams) + if err != nil { + return 0, err + } + amt, err := w.Wallet.TotalReceivedForAddr(bchAddr, 0) + if err != nil { + return 0, err + } + return btcutil.Amount(amt), nil +} + // secretSource is used to locate keys and redemption scripts while signing a // transaction. secretSource satisfies the txauthor.SecretsSource interface. type secretSource struct { diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 5eb8fba11e..f1a34140b7 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -892,6 +892,7 @@ var _ asset.Authenticator = (*ExchangeWalletFullNode)(nil) var _ asset.Authenticator = (*ExchangeWalletAccelerator)(nil) var _ asset.AddressReturner = (*baseWallet)(nil) var _ asset.WalletHistorian = (*ExchangeWalletSPV)(nil) +var _ asset.NewAddresser = (*baseWallet)(nil) // RecoveryCfg is the information that is transferred from the old wallet // to the new one when the wallet is recovered. @@ -4257,6 +4258,11 @@ func (btc *baseWallet) NewAddress() (string, error) { return btc.DepositAddress() } +// AddressUsed checks if a wallet address has been used. +func (btc *baseWallet) AddressUsed(addrStr string) (bool, error) { + return btc.node.addressUsed(addrStr) +} + // EstimateRegistrationTxFee returns an estimate for the tx fee needed to // pay the registration fee using the provided feeRate. func (btc *baseWallet) EstimateRegistrationTxFee(feeRate uint64) uint64 { @@ -5398,7 +5404,7 @@ func (btc *intermediaryWallet) checkPendingTxs(tip uint64) { } } - btc.addTxToHistory(asset.Receive, txHash, toSatoshi(tx.Amount), fee, nil, nil, true) + btc.addTxToHistory(asset.Receive, txHash, toSatoshi(tx.Amount), fee, nil, &tx.Address, true) } } } diff --git a/client/asset/btc/electrum_client.go b/client/asset/btc/electrum_client.go index c9a657dbc0..3da1605e03 100644 --- a/client/asset/btc/electrum_client.go +++ b/client/asset/btc/electrum_client.go @@ -1159,3 +1159,11 @@ func (ew *electrumWallet) findOutputSpender(ctx context.Context, txHash *chainha return nil, 0, nil // caller should check msgTx (internal method) } + +func (ew *electrumWallet) addressUsed(addrStr string) (bool, error) { + txs, err := ew.wallet.GetAddressHistory(ew.ctx, addrStr) + if err != nil { + return false, fmt.Errorf("error getting address history: %w", err) + } + return len(txs) > 0, nil +} diff --git a/client/asset/btc/rpcclient.go b/client/asset/btc/rpcclient.go index 510b1ab659..2cd52fa509 100644 --- a/client/asset/btc/rpcclient.go +++ b/client/asset/btc/rpcclient.go @@ -29,37 +29,38 @@ import ( ) const ( - methodGetBalances = "getbalances" - methodGetBalance = "getbalance" - methodListUnspent = "listunspent" - methodLockUnspent = "lockunspent" - methodListLockUnspent = "listlockunspent" - methodChangeAddress = "getrawchangeaddress" - methodNewAddress = "getnewaddress" - methodSignTx = "signrawtransactionwithwallet" - methodSignTxLegacy = "signrawtransaction" - methodUnlock = "walletpassphrase" - methodLock = "walletlock" - methodPrivKeyForAddress = "dumpprivkey" - methodGetTransaction = "gettransaction" - methodSendToAddress = "sendtoaddress" - methodSetTxFee = "settxfee" - methodGetWalletInfo = "getwalletinfo" - methodGetAddressInfo = "getaddressinfo" - methodListDescriptors = "listdescriptors" - methodValidateAddress = "validateaddress" - methodEstimateSmartFee = "estimatesmartfee" - methodSendRawTransaction = "sendrawtransaction" - methodGetTxOut = "gettxout" - methodGetBlock = "getblock" - methodGetBlockHash = "getblockhash" - methodGetBestBlockHash = "getbestblockhash" - methodGetRawMempool = "getrawmempool" - methodGetRawTransaction = "getrawtransaction" - methodGetBlockHeader = "getblockheader" - methodGetNetworkInfo = "getnetworkinfo" - methodGetBlockchainInfo = "getblockchaininfo" - methodFundRawTransaction = "fundrawtransaction" + methodGetBalances = "getbalances" + methodGetBalance = "getbalance" + methodListUnspent = "listunspent" + methodLockUnspent = "lockunspent" + methodListLockUnspent = "listlockunspent" + methodChangeAddress = "getrawchangeaddress" + methodNewAddress = "getnewaddress" + methodSignTx = "signrawtransactionwithwallet" + methodSignTxLegacy = "signrawtransaction" + methodUnlock = "walletpassphrase" + methodLock = "walletlock" + methodPrivKeyForAddress = "dumpprivkey" + methodGetTransaction = "gettransaction" + methodSendToAddress = "sendtoaddress" + methodSetTxFee = "settxfee" + methodGetWalletInfo = "getwalletinfo" + methodGetAddressInfo = "getaddressinfo" + methodListDescriptors = "listdescriptors" + methodValidateAddress = "validateaddress" + methodEstimateSmartFee = "estimatesmartfee" + methodSendRawTransaction = "sendrawtransaction" + methodGetTxOut = "gettxout" + methodGetBlock = "getblock" + methodGetBlockHash = "getblockhash" + methodGetBestBlockHash = "getbestblockhash" + methodGetRawMempool = "getrawmempool" + methodGetRawTransaction = "getrawtransaction" + methodGetBlockHeader = "getblockheader" + methodGetNetworkInfo = "getnetworkinfo" + methodGetBlockchainInfo = "getblockchaininfo" + methodFundRawTransaction = "fundrawtransaction" + methodGetReceivedByAddress = "getreceivedbyaddress" ) // IsTxNotFoundErr will return true if the error indicates that the requested @@ -1133,6 +1134,15 @@ func SearchBlockForRedemptions( return } +func (wc *rpcClient) addressUsed(addr string) (bool, error) { + var recv float64 + const minConf = 0 + if err := wc.call(methodGetReceivedByAddress, []any{addr, minConf}, &recv); err != nil { + return false, err + } + return recv != 0, nil +} + // call is used internally to marshal parameters and send requests to the RPC // server via (*rpcclient.Client).RawRequest. If thing is non-nil, the result // will be marshaled into thing. diff --git a/client/asset/btc/spv_test.go b/client/asset/btc/spv_test.go index 3fe9ba8d57..e48c06c712 100644 --- a/client/asset/btc/spv_test.go +++ b/client/asset/btc/spv_test.go @@ -362,6 +362,10 @@ func (c *tBtcWallet) RemovePeer(string) error { return nil } +func (c *tBtcWallet) TotalReceivedForAddr(addr btcutil.Address, minConf int32) (btcutil.Amount, error) { + return 0, nil +} + type tNeutrinoClient struct { *testData } diff --git a/client/asset/btc/spv_wrapper.go b/client/asset/btc/spv_wrapper.go index 3f259b3943..1f38bf3a53 100644 --- a/client/asset/btc/spv_wrapper.go +++ b/client/asset/btc/spv_wrapper.go @@ -121,6 +121,7 @@ type BTCWallet interface { AddPeer(string) error RemovePeer(string) error ListSinceBlock(start, end, syncHeight int32) ([]btcjson.ListTransactionsResult, error) + TotalReceivedForAddr(addr btcutil.Address, minConf int32) (btcutil.Amount, error) } type XCWalletAccount struct { @@ -1653,6 +1654,20 @@ func (w *spvWallet) getWalletTransaction(txHash *chainhash.Hash) (*GetTransactio */ } +func (w *spvWallet) addressUsed(addrStr string) (bool, error) { + addr, err := w.decodeAddr(addrStr, w.chainParams) + if err != nil { + return false, fmt.Errorf("error decoding address: %w", err) + } + + const minConfs = 0 + amt, err := w.wallet.TotalReceivedForAddr(addr, minConfs) + if err != nil { + return false, fmt.Errorf("error getting address received: %v", err) + } + return amt != 0, nil +} + func confirms(txHeight, curHeight int32) int32 { switch { case txHeight == -1, txHeight > curHeight: diff --git a/client/asset/btc/wallet.go b/client/asset/btc/wallet.go index c09f01b711..b2adb6293d 100644 --- a/client/asset/btc/wallet.go +++ b/client/asset/btc/wallet.go @@ -44,6 +44,7 @@ type Wallet interface { ownsAddress(addr btcutil.Address) (bool, error) // this should probably just take a string getWalletTransaction(txHash *chainhash.Hash) (*GetTransactionResult, error) reconfigure(walletCfg *asset.WalletConfig, currentAddress string) (restartRequired bool, err error) + addressUsed(addr string) (bool, error) } type txLister interface { diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index b10c01c8ab..c61910d0f9 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -695,6 +695,7 @@ var _ asset.TxFeeEstimator = (*ExchangeWallet)(nil) var _ asset.Bonder = (*ExchangeWallet)(nil) var _ asset.Authenticator = (*ExchangeWallet)(nil) var _ asset.TicketBuyer = (*ExchangeWallet)(nil) +var _ asset.NewAddresser = (*ExchangeWallet)(nil) type block struct { height int64 @@ -4008,6 +4009,11 @@ func (dcr *ExchangeWallet) NewAddress() (string, error) { return dcr.DepositAddress() } +// AddressUsed checks if a wallet address has been used. +func (dcr *ExchangeWallet) AddressUsed(addrStr string) (bool, error) { + return dcr.wallet.AddressUsed(dcr.ctx, addrStr) +} + // Unlock unlocks the exchange wallet. func (dcr *ExchangeWallet) Unlock(pw []byte) error { // Older SPV wallet potentially need an upgrade while we have a password. @@ -5704,7 +5710,8 @@ func (dcr *ExchangeWallet) checkPendingTxs(ctx context.Context, tip uint64) { blockToQuery = tip - blockQueryBuffer } - recentTxs, err := dcr.wallet.ListSinceBlock(ctx, int32(blockToQuery), int32(tip), int32(tip)) + const rangeEndMempool = -1 + recentTxs, err := dcr.wallet.ListSinceBlock(ctx, int32(blockToQuery), rangeEndMempool, int32(tip)) if err != nil { dcr.log.Errorf("Error listing transactions since block %d: %v", blockToQuery, err) recentTxs = nil @@ -5742,7 +5749,12 @@ func (dcr *ExchangeWallet) checkPendingTxs(ctx context.Context, tip uint64) { } } - dcr.addTxToHistory(txType, txHash, toAtoms(tx.Amount), fee, nil, nil, true) + var addr *string + if txType == asset.Receive { + addr = &tx.Address + } + + dcr.addTxToHistory(txType, txHash, toAtoms(tx.Amount), fee, nil, addr, true) } for _, tx := range recentTxs { @@ -6065,6 +6077,10 @@ func (dcr *ExchangeWallet) monitorBlocks(ctx context.Context) { if walletTip == nil { // Mempool tx seen. dcr.emitBalance() + dcr.tipMtx.RLock() + tipHeight := uint64(dcr.currentTip.height) + dcr.tipMtx.RUnlock() + go dcr.checkPendingTxs(ctx, tipHeight) continue } if queuedBlock != nil && walletTip.height >= queuedBlock.height { diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index db157e20eb..9cbde0b953 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -714,6 +714,10 @@ func (c *tRPCClient) SetTxFee(ctx context.Context, fee dcrutil.Amount) error { return nil } +func (c *tRPCClient) GetReceivedByAddressMinConf(ctx context.Context, address stdaddr.Address, minConfs int) (dcrutil.Amount, error) { + return 0, nil +} + func (c *tRPCClient) ListSinceBlock(ctx context.Context, hash *chainhash.Hash) (*walletjson.ListSinceBlockResult, error) { return nil, nil } diff --git a/client/asset/dcr/rpcwallet.go b/client/asset/dcr/rpcwallet.go index 9936a3a6c5..3ae02ffc82 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -149,6 +149,7 @@ type rpcClient interface { SetVoteChoice(ctx context.Context, agendaID, choiceID string) error SetTxFee(ctx context.Context, fee dcrutil.Amount) error ListSinceBlock(ctx context.Context, hash *chainhash.Hash) (*walletjson.ListSinceBlockResult, error) + GetReceivedByAddressMinConf(ctx context.Context, address stdaddr.Address, minConfs int) (dcrutil.Amount, error) } // newRPCWallet creates an rpcClient and uses it to construct a new instance @@ -1149,6 +1150,19 @@ func (w *rpcWallet) SetTxFee(ctx context.Context, feePerKB dcrutil.Amount) error return w.rpcClient.SetTxFee(ctx, feePerKB) } +func (w *rpcWallet) AddressUsed(ctx context.Context, addrStr string) (bool, error) { + addr, err := stdaddr.DecodeAddress(addrStr, w.chainParams) + if err != nil { + return false, err + } + const minConf = 0 + recv, err := w.rpcClient.GetReceivedByAddressMinConf(ctx, addr, minConf) + if err != nil { + return false, err + } + return recv != 0, nil +} + // anylist is a list of RPC parameters to be converted to []json.RawMessage and // sent via nodeRawRequest. type anylist []any diff --git a/client/asset/dcr/spv.go b/client/asset/dcr/spv.go index d6593b5981..81e9f26ebb 100644 --- a/client/asset/dcr/spv.go +++ b/client/asset/dcr/spv.go @@ -100,6 +100,7 @@ type dcrWallet interface { SetRelayFee(relayFee dcrutil.Amount) GetTicketInfo(ctx context.Context, hash *chainhash.Hash) (*wallet.TicketSummary, *wire.BlockHeader, error) ListSinceBlock(ctx context.Context, start, end, syncHeight int32) ([]walletjson.ListTransactionsResult, error) + TotalReceivedForAddr(ctx context.Context, addr stdaddr.Address, minConf int32) (dcrutil.Amount, error) vspclient.Wallet // TODO: Rescan and DiscoverActiveAddresses can be used for a Rescanner. } @@ -1304,6 +1305,19 @@ func (w *spvWallet) SetTxFee(_ context.Context, feePerKB dcrutil.Amount) error { return nil } +func (w *spvWallet) AddressUsed(ctx context.Context, addrStr string) (bool, error) { + addr, err := stdaddr.DecodeAddress(addrStr, w.chainParams) + if err != nil { + return false, err + } + const minConf = 0 + recv, err := w.TotalReceivedForAddr(ctx, addr, minConf) + if err != nil { + return false, err + } + return recv != 0, nil +} + // cacheBlock caches a block for future use. The block has a lastAccess stamp // added, and will be discarded if not accessed again within 2 hours. func (w *spvWallet) cacheBlock(block *wire.MsgBlock) { diff --git a/client/asset/dcr/spv_test.go b/client/asset/dcr/spv_test.go index a942e1a9a8..e858ddd34c 100644 --- a/client/asset/dcr/spv_test.go +++ b/client/asset/dcr/spv_test.go @@ -374,6 +374,10 @@ func (w *tDcrWallet) ListSinceBlock(ctx context.Context, start, end, syncHeight return nil, nil } +func (w *tDcrWallet) TotalReceivedForAddr(ctx context.Context, addr stdaddr.Address, minConf int32) (dcrutil.Amount, error) { + return 0, nil +} + func tNewSpvWallet() (*spvWallet, *tDcrWallet) { dcrw := &tDcrWallet{ blockHeader: make(map[chainhash.Hash]*wire.BlockHeader), diff --git a/client/asset/dcr/wallet.go b/client/asset/dcr/wallet.go index c8b5177d49..2a46f14c41 100644 --- a/client/asset/dcr/wallet.go +++ b/client/asset/dcr/wallet.go @@ -169,6 +169,7 @@ type Wallet interface { SetTxFee(ctx context.Context, feePerKB dcrutil.Amount) error StakeInfo(ctx context.Context) (*wallet.StakeInfoData, error) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress string) (restart bool, err error) + AddressUsed(ctx context.Context, addrStr string) (bool, error) } // WalletTransaction is a pared down version of walletjson.GetTransactionResult. diff --git a/client/asset/interface.go b/client/asset/interface.go index 93fa975265..4a8899c621 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -678,6 +678,7 @@ type Sweeper interface { // NewAddresser is a wallet that can generate new deposit addresses. type NewAddresser interface { NewAddress() (string, error) + AddressUsed(string) (bool, error) } // AddressReturner is a wallet that allows recycling of unused redemption or refund diff --git a/client/asset/ltc/spv.go b/client/asset/ltc/spv.go index 50c613ae46..c101243626 100644 --- a/client/asset/ltc/spv.go +++ b/client/asset/ltc/spv.go @@ -836,6 +836,18 @@ func (w *ltcSPVWallet) RemovePeer(addr string) error { return w.peerManager.RemovePeer(addr) } +func (w *ltcSPVWallet) TotalReceivedForAddr(btcAddr btcutil.Address, minConf int32) (btcutil.Amount, error) { + ltcAddr, err := w.addrBTC2LTC(btcAddr) + if err != nil { + return 0, err + } + amt, err := w.Wallet.TotalReceivedForAddr(ltcAddr, 0) + if err != nil { + return 0, err + } + return btcutil.Amount(amt), nil +} + // secretSource is used to locate keys and redemption scripts while signing a // transaction. secretSource satisfies the txauthor.SecretsSource interface. type secretSource struct { diff --git a/client/asset/zec/transparent_rpc.go b/client/asset/zec/transparent_rpc.go index c8cb39e0a6..04db4cb9d3 100644 --- a/client/asset/zec/transparent_rpc.go +++ b/client/asset/zec/transparent_rpc.go @@ -365,3 +365,9 @@ func syncStatus(c rpcCaller) (*btc.SyncStatus, error) { Syncing: chainInfo.Syncing(), }, nil } + +func getReceivedByAddress(c rpcCaller, addrStr string) (recv uint64, _ error) { + const minConf = 0 + const inZats = true + return recv, c.CallRPC("getreceivedbyaddress", []any{addrStr, minConf, inZats}, &recv) +} diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index 4c6b5c8fea..b4cd9f2fac 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -303,6 +303,7 @@ type zecWallet struct { var _ asset.FeeRater = (*zecWallet)(nil) var _ asset.Wallet = (*zecWallet)(nil) +var _ asset.NewAddresser = (*zecWallet)(nil) // DRAFT TODO: Implement LiveReconfigurer // var _ asset.LiveReconfigurer = (*zecWallet)(nil) @@ -1573,6 +1574,13 @@ func (w *zecWallet) NewAddress() (string, error) { return w.DepositAddress() } +// AddressUsed checks if a wallet address has been used. +func (w *zecWallet) AddressUsed(addrStr string) (bool, error) { + // TODO: Resolve with new unified address encoding in https://github.com/decred/dcrdex/pull/2675 + recv, err := getReceivedByAddress(w, addrStr) + return recv != 0, err +} + // DEPRECATED func (w *zecWallet) EstimateRegistrationTxFee(feeRate uint64) uint64 { return math.MaxUint64 diff --git a/client/core/core.go b/client/core/core.go index 2bb1eb177b..40f47d4c97 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -3852,6 +3852,21 @@ func (c *Core) NewDepositAddress(assetID uint32) (string, error) { return addr, nil } +// AddressUsed checks whether an address for a NewAddresser has been used. +func (c *Core) AddressUsed(assetID uint32, addr string) (bool, error) { + w, exists := c.wallet(assetID) + if !exists { + return false, newError(missingWalletErr, "no wallet found for %s", unbip(assetID)) + } + + na, ok := w.Wallet.(asset.NewAddresser) + if !ok { + return false, errors.New("wallet is not a NewAddresser") + } + + return na.AddressUsed(addr) +} + // AutoWalletConfig attempts to load setting from a wallet package's // asset.WalletInfo.DefaultConfigPath. If settings are not found, an empty map // is returned. diff --git a/client/core/core_test.go b/client/core/core_test.go index 29f2072413..436aa699e5 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -927,6 +927,10 @@ func (w *TXCWallet) NewAddress() (string, error) { return "", w.addrErr } +func (w *TXCWallet) AddressUsed(addr string) (bool, error) { + return false, nil +} + func (w *TXCWallet) Unlock(pw []byte) error { return w.unlockErr } diff --git a/client/webserver/api.go b/client/webserver/api.go index 9092bec023..c50ccf47b4 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -619,6 +619,36 @@ func (s *WebServer) apiNewDepositAddress(w http.ResponseWriter, r *http.Request) }, s.indent) } +// apiAddressUsed checks whether an address has been used. +func (s *WebServer) apiAddressUsed(w http.ResponseWriter, r *http.Request) { + form := &struct { + AssetID *uint32 `json:"assetID"` + Addr string `json:"addr"` + }{} + if !readPost(w, r, form) { + return + } + if form.AssetID == nil { + s.writeAPIError(w, errors.New("missing asset ID")) + return + } + assetID := *form.AssetID + + used, err := s.core.AddressUsed(assetID, form.Addr) + if err != nil { + s.writeAPIError(w, err) + return + } + + writeJSON(w, &struct { + OK bool `json:"ok"` + Used bool `json:"used"` + }{ + OK: true, + Used: used, + }, s.indent) +} + // apiConnectWallet is the handler for the '/connectwallet' API request. // Connects to a specified wallet, but does not unlock it. func (s *WebServer) apiConnectWallet(w http.ResponseWriter, r *http.Request) { diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 4428a5a6a1..faa85cb0b3 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -1730,6 +1730,10 @@ func (c *TCore) NewDepositAddress(assetID uint32) (string, error) { return ordertest.RandomAddress(), nil } +func (c *TCore) AddressUsed(assetID uint32, addr string) (bool, error) { + return rand.Float32() > 0.5, nil +} + func (c *TCore) SetWalletPassword(appPW []byte, assetID uint32, newPW []byte) error { return nil } func (c *TCore) User() *core.User { diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index 4e096a7a65..b85d305f78 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -552,4 +552,5 @@ var EnUS = map[string]string{ "configure_cex_prompt": "Configure your exchange API to enable arbitrage features.", "API Key": "API Key", "API Secret": "API Secret", + "address has been used": "address has been used", } diff --git a/client/webserver/site/src/html/forms.tmpl b/client/webserver/site/src/html/forms.tmpl index 8fd2bd2363..37c83e19d1 100644 --- a/client/webserver/site/src/html/forms.tmpl +++ b/client/webserver/site/src/html/forms.tmpl @@ -149,6 +149,9 @@ [[[copied]]] +