From 42bc5dca5687e8f6699aa92a29b6ba2ae019f4ad Mon Sep 17 00:00:00 2001 From: martonp Date: Sun, 3 Sep 2023 22:52:38 -0400 Subject: [PATCH] client/asset,rpcserver: Add WalletHistorian interface and RPC method This adds a `WalletHistorian` interface which exposes a `TxHistory` function. This will allow wallets to display the history of their transactions. Also adds a method to the RPC server to retrieve the wallet history. --- client/asset/interface.go | 51 +++++++++++++++++++++++++++++++++ client/core/core.go | 15 ++++++++++ client/core/wallet.go | 19 ++++++++++++ client/rpcserver/handlers.go | 29 +++++++++++++++++++ client/rpcserver/rpcserver.go | 1 + client/rpcserver/types.go | 54 +++++++++++++++++++++++++++++++++++ dex/msgjson/types.go | 1 + 7 files changed, 170 insertions(+) diff --git a/client/asset/interface.go b/client/asset/interface.go index a114c678a1..1a8f4707a5 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -33,6 +33,7 @@ const ( WalletTraitTokenApprover // The wallet is a TokenApprover WalletTraitAccountLocker // The wallet must have enough balance for redemptions before a trade. WalletTraitTicketBuyer // The wallet can participate in decred staking. + WalletTraitHistorian // This wallet can return its transaction history ) // IsRescanner tests if the WalletTrait has the WalletTraitRescanner bit set. @@ -131,6 +132,12 @@ func (wt WalletTrait) IsTicketBuyer() bool { return wt&WalletTraitTicketBuyer != 0 } +// IsHistorian tests if the WalletTrait has the WalletTraitHistorian bit set, +// which indicates the wallet implements the WalletHistorian interface. +func (wt WalletTrait) IsHistorian() bool { + return wt&WalletTraitHistorian != 0 +} + // DetermineWalletTraits returns the WalletTrait bitset for the provided Wallet. func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(Rescanner); is { @@ -181,6 +188,9 @@ func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(TicketBuyer); is { t |= WalletTraitTicketBuyer } + if _, is := w.(TicketBuyer); is { + t |= WalletTraitHistorian + } return t } @@ -1017,6 +1027,47 @@ type TicketBuyer interface { TicketPage(scanStart int32, n, skipN int) ([]*Ticket, error) } +// TransactionType is type type of transaction made by a wallet. +type TransactionType uint16 + +const ( + Send TransactionType = iota + Receive + Swap + Redeem + Refund + CreateBond + RedeemBond + ApproveToken + Acceleration +) + +// WalletTransaction represents a transaction that was made by a wallet. +type WalletTransaction struct { + Type TransactionType `json:"type"` + ID dex.Bytes `json:"id"` + AmtIn uint64 `json:"amtIn"` + AmtOut uint64 `json:"amtOut"` + Fees uint64 `json:"fees"` + BlockNumber uint64 `json:"blockNumber"` + UpdatedBalance uint64 `json:"updatedBalance"` + // AdditionalData contains asset specific information, i.e. nonce + // for ETH. + AdditionalData map[string]string `json:"additionalData"` +} + +// WalletHistorian is a wallet that is able to retrieve the history of all +// transactions it has made. +type WalletHistorian interface { + // TxHistory returns all the transactions a wallet has made. If refID + // is nil, then transactions starting from the most recent are returned + // (past is ignored). If past is true, the transactions prior to the + // refID are returned, otherwise the transactions after the refID are + // returned. n is the number of transactions to return. If n is <= 0, + // all the transactions will be returned. + TxHistory(n int, refID *dex.Bytes, past bool) ([]*WalletTransaction, error) +} + // Bond is the fidelity bond info generated for a certain account ID, amount, // and lock time. These data are intended for the "post bond" request, in which // the server pre-validates the unsigned transaction, the client then publishes diff --git a/client/core/core.go b/client/core/core.go index ad41b934e4..9046348a43 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -5769,6 +5769,21 @@ func (c *Core) MultiTrade(pw []byte, form *MultiTradeForm) ([]*Order, error) { return orders, nil } +// TxHistory returns all the transactions a wallet has made. If refID +// is nil, then transactions starting from the most recent are returned +// (past is ignored). If past is true, the transactions prior to the +// refID are returned, otherwise the transactions after the refID are +// returned. n is the number of transactions to return. If n is <= 0, +// all the transactions will be returned +func (c *Core) TxHistory(assetID uint32, n int, refID *dex.Bytes, past bool) ([]*asset.WalletTransaction, error) { + wallet, found := c.wallet(assetID) + if !found { + return nil, newError(missingWalletErr, "no wallet found for %s", unbip(assetID)) + } + + return wallet.TxHistory(n, refID, past) +} + // Trade is used to place a market or limit order. func (c *Core) Trade(pw []byte, form *TradeForm) (*Order, error) { req, err := c.prepareTradeRequest(pw, form) diff --git a/client/core/wallet.go b/client/core/wallet.go index f3e3d314ab..64e575e85d 100644 --- a/client/core/wallet.go +++ b/client/core/wallet.go @@ -533,6 +533,25 @@ func (w *xcWallet) swapConfirmations(ctx context.Context, coinID []byte, contrac return w.Wallet.SwapConfirmations(ctx, coinID, contract, time.UnixMilli(int64(matchTime))) } +// TxHistory returns all the transactions a wallet has made. If refID +// is nil, then transactions starting from the most recent are returned +// (past is ignored). If past is true, the transactions prior to the +// refID are returned, otherwise the transactions after the refID are +// returned. n is the number of transactions to return. If n is <= 0, +// all the transactions will be returned. +func (w *xcWallet) TxHistory(n int, refID *dex.Bytes, past bool) ([]*asset.WalletTransaction, error) { + if !w.connected() { + return nil, errWalletNotConnected + } + + historian, ok := w.Wallet.(asset.WalletHistorian) + if !ok { + return nil, fmt.Errorf("wallet does not support transaction history") + } + + return historian.TxHistory(n, refID, past) +} + // MakeBondTx authors a DEX time-locked fidelity bond transaction if the // asset.Wallet implementation is a Bonder. func (w *xcWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time.Time, priv *secp256k1.PrivateKey, acctID []byte) (*asset.Bond, func(), error) { diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index 00f55d1727..c4a6145c4a 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -59,6 +59,7 @@ const ( setVSPRoute = "setvsp" purchaseTicketsRoute = "purchasetickets" setVotingPreferencesRoute = "setvotingprefs" + txHistoryRoute = "txhistory" ) const ( @@ -130,6 +131,7 @@ var routes = map[string]func(s *RPCServer, params *RawParams) *msgjson.ResponseP setVSPRoute: handleSetVSP, purchaseTicketsRoute: handlePurchaseTickets, setVotingPreferencesRoute: handleSetVotingPreferences, + txHistoryRoute: handleTxHistory, } // handleHelp handles requests for help. Returns general help for all commands @@ -1033,6 +1035,22 @@ func handleSetVotingPreferences(s *RPCServer, params *RawParams) *msgjson.Respon return createResponse(setVotingPreferencesRoute, "vote preferences set", nil) } +func handleTxHistory(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + form, err := parseTxHistoryArgs(params) + if err != nil { + return usage(txHistoryRoute, err) + } + + txs, err := s.core.TxHistory(form.assetID, form.num, form.refID, form.past) + if err != nil { + errMsg := fmt.Sprintf("unable to get tx history: %v", err) + resErr := msgjson.NewError(msgjson.RPCTxHistoryError, errMsg) + return createResponse(txHistoryRoute, nil, resErr) + } + + return createResponse(txHistoryRoute, txs, nil) +} + // format concatenates thing and tail. If thing is empty, returns an empty // string. func format(thing, tail string) string { @@ -1752,4 +1770,15 @@ an spv wallet and enables options to view and set the vsp. returns: `Returns: string: The message "` + setVotePrefsStr + `"`, }, + txHistoryRoute: { + argsShort: `assetID (n) (refTxID) (past)`, + cmdSummary: `Get transaction history for a wallet`, + argsLong: `Args: + assetID (int): The asset's BIP-44 registered coin index. + n (int): Optional. The number of transactions to return. If <= 0 or unset, all transactions are returned. + refTxID (string): Optional. If set, the transactions before or after this tx (depending on the past argument) + will be returned. + past (bool): If true, the transactions before the reference tx will be returned. If false, the + transactions after the reference tx will be returned.`, + }, } diff --git a/client/rpcserver/rpcserver.go b/client/rpcserver/rpcserver.go index e7e33b33af..1bae53355c 100644 --- a/client/rpcserver/rpcserver.go +++ b/client/rpcserver/rpcserver.go @@ -85,6 +85,7 @@ type clientCore interface { RemoveWalletPeer(assetID uint32, host string) error Notifications(int) ([]*db.Notification, error) MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) + TxHistory(assetID uint32, n int, refID *dex.Bytes, past bool) ([]*asset.WalletTransaction, error) // These are core's ticket buying interface. StakeStatus(assetID uint32) (*asset.TicketStakingStatus, error) diff --git a/client/rpcserver/types.go b/client/rpcserver/types.go index 74f6e36874..344b41b48c 100644 --- a/client/rpcserver/types.go +++ b/client/rpcserver/types.go @@ -213,6 +213,13 @@ type setVotingPreferencesForm struct { voteChoices, tSpendPolicy, treasuryPolicy map[string]string } +type txHistoryForm struct { + assetID uint32 + num int + refID *dex.Bytes + past bool +} + // checkNArgs checks that args and pwArgs are the correct length. func checkNArgs(params *RawParams, nPWArgs, nArgs []int) error { // For want, one integer indicates an exact match, two are the min and max. @@ -940,3 +947,50 @@ func parseSetVotingPreferencesArgs(params *RawParams) (*setVotingPreferencesForm } return form, nil } + +func parseTxHistoryArgs(params *RawParams) (*txHistoryForm, error) { + err := checkNArgs(params, []int{0}, []int{1, 4}) + if err != nil { + return nil, err + } + + assetID, err := checkUIntArg(params.Args[0], "assetID", 32) + if err != nil { + return nil, fmt.Errorf("invalid assetID: %v", err) + } + + var num int64 + if len(params.Args) > 1 { + num, err = checkIntArg(params.Args[1], "num", 64) + if err != nil { + return nil, fmt.Errorf("invalid num: %v", err) + } + } + + var refID *dex.Bytes + var past bool + if len(params.Args) > 2 { + if len(params.Args) != 4 { + return nil, fmt.Errorf("refID provided without past") + } + + id, err := hex.DecodeString(params.Args[2]) + if err != nil { + return nil, fmt.Errorf("invalid refID: %v", err) + } + idDB := dex.Bytes(id) + refID = &idDB + + past, err = checkBoolArg(params.Args[3], "past") + if err != nil { + return nil, err + } + } + + return &txHistoryForm{ + assetID: uint32(assetID), + num: int(num), + refID: refID, + past: past, + }, nil +} diff --git a/dex/msgjson/types.go b/dex/msgjson/types.go index fc6fb5089a..b838bc2ded 100644 --- a/dex/msgjson/types.go +++ b/dex/msgjson/types.go @@ -93,6 +93,7 @@ const ( RPCPurchaseTicketsError // 75 RPCStakeStatusError // 76 RPCSetVotingPreferencesError // 77 + RPCTxHistoryError // 78 ) // Routes are destinations for a "payload" of data. The type of data being