From 6f2c338f72aca6ab49c3111bad2c73bb71cf2acb Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 15 Jun 2023 19:00:40 +0200 Subject: [PATCH] feat(wallet) add filter api to retrieve recipients of a wallet The new API returns all known recipients of a wallet, by sourcing transfers, pending_transactions and multi_transactions tables The API is synchronous. Future work will be to make it async. In some corner cases, when watching a famous wallet, it can be that there are too many recipients to be returned in one go. Offset and limit can be used to paginate through the results. Updates status-desktop #10025 --- services/wallet/activity/filter.go | 77 +++++++++++++++++++++---- services/wallet/activity/filter_test.go | 63 ++++++++++++++++++++ services/wallet/api.go | 11 ++++ 3 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 services/wallet/activity/filter_test.go diff --git a/services/wallet/activity/filter.go b/services/wallet/activity/filter.go index d62f1cf9e..711af79fc 100644 --- a/services/wallet/activity/filter.go +++ b/services/wallet/activity/filter.go @@ -1,7 +1,10 @@ package activity import ( - eth_common "github.com/ethereum/go-ethereum/common" + "context" + "database/sql" + + eth "github.com/ethereum/go-ethereum/common" "github.com/status-im/status-go/services/wallet/common" ) @@ -53,21 +56,21 @@ type TokenCode string // nil means all // see allTokensFilter and noTokensFilter type Tokens struct { - Assets []TokenCode `json:"assets"` - Collectibles []eth_common.Address `json:"collectibles"` - EnabledTypes []TokenType `json:"enabledTypes"` + Assets []TokenCode `json:"assets"` + Collectibles []eth.Address `json:"collectibles"` + EnabledTypes []TokenType `json:"enabledTypes"` } func noAssetsFilter() Tokens { - return Tokens{[]TokenCode{}, []eth_common.Address{}, []TokenType{CollectiblesTT}} + return Tokens{[]TokenCode{}, []eth.Address{}, []TokenType{CollectiblesTT}} } func allTokensFilter() Tokens { return Tokens{} } -func allAddressesFilter() []eth_common.Address { - return []eth_common.Address{} +func allAddressesFilter() []eth.Address { + return []eth.Address{} } func allNetworksFilter() []common.ChainID { @@ -75,9 +78,59 @@ func allNetworksFilter() []common.ChainID { } type Filter struct { - Period Period `json:"period"` - Types []Type `json:"types"` - Statuses []Status `json:"statuses"` - Tokens Tokens `json:"tokens"` - CounterpartyAddresses []eth_common.Address `json:"counterpartyAddresses"` + Period Period `json:"period"` + Types []Type `json:"types"` + Statuses []Status `json:"statuses"` + Tokens Tokens `json:"tokens"` + CounterpartyAddresses []eth.Address `json:"counterpartyAddresses"` +} + +// TODO: consider sorting by saved address and contacts to offload the client from doing it at runtime +func GetRecipients(ctx context.Context, db *sql.DB, offset int, limit int) (addresses []eth.Address, hasMore bool, err error) { + rows, err := db.QueryContext(ctx, ` + SELECT + transfers.address as to_address, + transfers.timestamp AS timestamp + FROM transfers + WHERE transfers.multi_transaction_id = 0 + + UNION ALL + + SELECT + pending_transactions.to_address AS to_address, + pending_transactions.timestamp AS timestamp + FROM pending_transactions + WHERE pending_transactions.multi_transaction_id = 0 + + UNION ALL + + SELECT + multi_transactions.to_address AS to_address, + multi_transactions.timestamp AS timestamp + FROM multi_transactions + ORDER BY timestamp DESC + LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, false, err + } + defer rows.Close() + + var entries []eth.Address + for rows.Next() { + var toAddress eth.Address + var timestamp int64 + err := rows.Scan(&toAddress, ×tamp) + if err != nil { + return nil, false, err + } + entries = append(entries, toAddress) + } + + if err = rows.Err(); err != nil { + return nil, false, err + } + + hasMore = len(entries) == limit + + return entries, hasMore, nil } diff --git a/services/wallet/activity/filter_test.go b/services/wallet/activity/filter_test.go new file mode 100644 index 000000000..54da45907 --- /dev/null +++ b/services/wallet/activity/filter_test.go @@ -0,0 +1,63 @@ +package activity + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/status-im/status-go/appdatabase" + "github.com/status-im/status-go/services/wallet/transfer" + + "github.com/stretchr/testify/require" +) + +func setupTestFilterDB(t *testing.T) (db *sql.DB, close func()) { + db, err := appdatabase.SetupTestMemorySQLDB("wallet-activity-tests-filter") + require.NoError(t, err) + + return db, func() { + require.NoError(t, db.Close()) + } +} + +func TestGetRecipientsEmptyDB(t *testing.T) { + db, close := setupTestFilterDB(t) + defer close() + + entries, hasMore, err := GetRecipients(context.Background(), db, 0, 15) + require.NoError(t, err) + require.Equal(t, 0, len(entries)) + require.False(t, hasMore) +} + +func TestGetRecipients(t *testing.T) { + db, close := setupTestFilterDB(t) + defer close() + + // Add 6 extractable transactions + trs, _, toTrs := transfer.GenerateTestTransactions(t, db, 0, 6) + for i := range trs { + transfer.InsertTestTransfer(t, db, &trs[i]) + } + + entries, hasMore, err := GetRecipients(context.Background(), db, 0, 15) + require.NoError(t, err) + require.False(t, hasMore) + require.Equal(t, 6, len(entries)) + for i := range entries { + found := false + for j := range toTrs { + if entries[i] == toTrs[j] { + found = true + break + } + } + require.True(t, found, fmt.Sprintf("recipient %s not found in toTrs", entries[i].Hex())) + } + + entries, hasMore, err = GetRecipients(context.Background(), db, 0, 4) + require.NoError(t, err) + require.Equal(t, 4, len(entries)) + require.True(t, hasMore) +} diff --git a/services/wallet/api.go b/services/wallet/api.go index 339294b29..d6c3f3732 100644 --- a/services/wallet/api.go +++ b/services/wallet/api.go @@ -532,3 +532,14 @@ func (api *API) FilterActivityAsync(ctx context.Context, addresses []common.Addr log.Debug("[WalletAPI:: FilterActivityAsync] addr.count", len(addresses), "chainIDs.count", len(chainIDs), "filter", filter, "offset", offset, "limit", limit) return api.s.activity.FilterActivityAsync(ctx, addresses, chainIDs, filter, offset, limit) } + +type GetAllRecipientsResponse struct { + Addresses []common.Address `json:"addresses"` + HasMore bool `json:"hasMore"` +} + +func (api *API) GetAllRecipients(ctx context.Context, offset int, limit int) (result *GetAllRecipientsResponse, err error) { + result = &GetAllRecipientsResponse{} + result.Addresses, result.HasMore, err = activity.GetRecipients(ctx, api.s.db, offset, limit) + return result, err +}