diff --git a/services/wallet/activity/activity.go b/services/wallet/activity/activity.go index b68860c1a..f421c8c77 100644 --- a/services/wallet/activity/activity.go +++ b/services/wallet/activity/activity.go @@ -311,8 +311,9 @@ const ( fromTrType = byte(1) toTrType = byte(2) - noEntriesInTmpTableSQLValues = "(NULL)" - noEntriesInTwoColumnsTmpTableSQLValues = "(NULL, NULL)" + noEntriesInTmpTableSQLValues = "(NULL)" + noEntriesInTwoColumnsTmpTableSQLValues = "(NULL, NULL)" + noEntriesInThreeColumnsTmpTableSQLValues = "(NULL, NULL, NULL)" ) //go:embed filter.sql @@ -374,6 +375,21 @@ func getActivityEntries(ctx context.Context, deps FilterDependencies, addresses } } + includeAllCollectibles := len(filter.Collectibles) == 0 && !filter.FilterOutCollectibles + assetsERC721 := noEntriesInThreeColumnsTmpTableSQLValues + if !includeAllCollectibles && !filter.FilterOutCollectibles { + assetsERC721 = joinItems(filter.Collectibles, func(item Token) string { + // SQL HEX() (Blob->Hex) conversion returns uppercase digits with no 0x prefix + tokenID := strings.ToUpper(item.TokenID.String()[2:]) + address := strings.ToUpper(item.Address.Hex()[2:]) + if len(tokenID)%2 == 1 { + // Hex length must be divisable by 2, otherwise append '0' at the beginning + tokenID = "0" + tokenID + } + return fmt.Sprintf("%d, '%s', '%s'", item.ChainID, tokenID, address) + }) + } + // construct chain IDs includeAllNetworks := len(chainIDs) == 0 networks := noEntriesInTmpTableSQLValues @@ -414,7 +430,7 @@ func getActivityEntries(ctx context.Context, deps FilterDependencies, addresses return strconv.Itoa(int(t)) }) - queryString := fmt.Sprintf(queryFormatString, involvedAddresses, toAddresses, assetsTokenCodes, assetsERC20, networks, + queryString := fmt.Sprintf(queryFormatString, involvedAddresses, toAddresses, assetsTokenCodes, assetsERC20, assetsERC721, networks, layer2Networks, joinedMTTypes) // The duplicated temporary table UNION with CTE acts as an optimization @@ -435,6 +451,7 @@ func getActivityEntries(ctx context.Context, deps FilterDependencies, addresses includeAllStatuses, filterStatusCompleted, filterStatusFailed, filterStatusFinalized, filterStatusPending, FailedAS, CompleteAS, FinalizedAS, PendingAS, includeAllTokenTypeAssets, + includeAllCollectibles, includeAllNetworks, transactions.Pending, deps.currentTimestamp(), diff --git a/services/wallet/activity/activity_test.go b/services/wallet/activity/activity_test.go index c73890222..0f57c8865 100644 --- a/services/wallet/activity/activity_test.go +++ b/services/wallet/activity/activity_test.go @@ -38,6 +38,15 @@ func tokenFromSymbol(chainID *common.ChainID, symbol string) *Token { return nil } +func tokenFromCollectible(c *transfer.TestCollectible) Token { + return Token{ + TokenType: Erc721, + ChainID: c.ChainID, + Address: c.TokenAddress, + TokenID: (*hexutil.Big)(c.TokenID), + } +} + func setupTestActivityDBStorageChoice(tb testing.TB, inMemory bool) (deps FilterDependencies, close func()) { var db *sql.DB var err error @@ -951,6 +960,60 @@ func TestGetActivityEntriesFilterByTokenType(t *testing.T) { require.Nil(t, entries[0].tokenOut) } +func TestGetActivityEntriesFilterByCollectibles(t *testing.T) { + deps, close := setupTestActivityDB(t) + defer close() + + // Adds 4 extractable transactions 2 transactions (ETH/Goerli, ETH/Optimism), one MT USDC to DAI and another MT USDC to SNT + td, fromTds, toTds := fillTestData(t, deps.db) + // Add 4 transactions with collectibles + trs, fromTrs, toTrs := transfer.GenerateTestTransfers(t, deps.db, td.nextIndex, 4) + for i := range trs { + collectibleData := transfer.TestCollectibles[i] + trs[i].ChainID = collectibleData.ChainID + transfer.InsertTestTransferWithOptions(t, deps.db, trs[i].To, &trs[i], &transfer.TestTransferOptions{ + TokenAddress: collectibleData.TokenAddress, + TokenID: collectibleData.TokenID, + }) + } + + allAddresses := append(append(append(fromTds, toTds...), fromTrs...), toTrs...) + + var filter Filter + filter.FilterOutCollectibles = true + entries, err := getActivityEntries(context.Background(), deps, allAddresses, true, []common.ChainID{}, filter, 0, 15) + require.NoError(t, err) + require.Equal(t, 0, len(entries)) + + filter.FilterOutCollectibles = false + filter.Collectibles = allTokensFilter() + entries, err = getActivityEntries(context.Background(), deps, allAddresses, true, []common.ChainID{}, filter, 0, 15) + require.NoError(t, err) + require.Equal(t, 8, len(entries)) + + // Search for a specific collectible + filter.Collectibles = []Token{tokenFromCollectible(&transfer.TestCollectibles[0])} + entries, err = getActivityEntries(context.Background(), deps, allAddresses, true, []common.ChainID{}, filter, 0, 15) + require.NoError(t, err) + require.Equal(t, 1, len(entries)) + require.Equal(t, entries[0].tokenIn.Address, transfer.TestCollectibles[0].TokenAddress) + require.Equal(t, entries[0].tokenIn.TokenID, (*hexutil.Big)(transfer.TestCollectibles[0].TokenID)) + + // Search for a specific collectible + filter.Collectibles = []Token{tokenFromCollectible(&transfer.TestCollectibles[3])} + entries, err = getActivityEntries(context.Background(), deps, allAddresses, true, []common.ChainID{}, filter, 0, 15) + require.NoError(t, err) + require.Equal(t, 1, len(entries)) + require.Equal(t, entries[0].tokenIn.Address, transfer.TestCollectibles[3].TokenAddress) + require.Equal(t, entries[0].tokenIn.TokenID, (*hexutil.Big)(transfer.TestCollectibles[3].TokenID)) + + // Search for a multiple collectibles + filter.Collectibles = []Token{tokenFromCollectible(&transfer.TestCollectibles[1]), tokenFromCollectible(&transfer.TestCollectibles[2])} + entries, err = getActivityEntries(context.Background(), deps, allAddresses, true, []common.ChainID{}, filter, 0, 15) + require.NoError(t, err) + require.Equal(t, 2, len(entries)) +} + func TestGetActivityEntriesFilterByToAddresses(t *testing.T) { deps, close := setupTestActivityDB(t) defer close() diff --git a/services/wallet/activity/filter.sql b/services/wallet/activity/filter.sql index d6e08557a..67c3f75c5 100644 --- a/services/wallet/activity/filter.sql +++ b/services/wallet/activity/filter.sql @@ -38,6 +38,7 @@ WITH filter_conditions AS ( ? AS statusFinalized, ? AS statusPending, ? AS includeAllTokenTypeAssets, + ? AS includeAllCollectibles, ? AS includeAllNetworks, ? AS pendingStatus, ? AS nowTimestamp, @@ -87,6 +88,10 @@ assets_erc20(chain_id, token_address) AS ( VALUES %s ), +assets_erc721(chain_id, token_id, token_address) AS ( + VALUES + %s +), filter_networks(network_id) AS ( VALUES %s @@ -291,6 +296,19 @@ WHERE ) ) ) + AND ( + includeAllCollectibles + OR ( + transfers.type = "erc721" + AND ( + ( + transfers.network_id, + HEX(transfers.token_id), + HEX(transfers.token_address) + ) IN assets_erc721 + ) + ) + ) AND ( includeAllNetworks OR (transfers.network_id IN filter_networks) @@ -360,6 +378,7 @@ WHERE filterAllActivityStatus OR filterStatusPending ) + AND includeAllCollectibles AND ( ( startFilterDisabled @@ -458,6 +477,7 @@ WHERE OR multi_transactions.timestamp <= endTimestamp ) ) + AND includeAllCollectibles AND ( filterActivityTypeAll OR (multi_transactions.type IN (%s)) diff --git a/services/wallet/transfer/testutils.go b/services/wallet/transfer/testutils.go index 01a3449a4..65f50ce14 100644 --- a/services/wallet/transfer/testutils.go +++ b/services/wallet/transfer/testutils.go @@ -8,6 +8,7 @@ import ( eth_common "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/status-im/status-go/services/wallet/common" w_common "github.com/status-im/status-go/services/wallet/common" "github.com/status-im/status-go/services/wallet/testutils" @@ -141,6 +142,35 @@ func GenerateTestTransfers(tb testing.TB, db *sql.DB, firstStartIndex int, count return } +type TestCollectible struct { + TokenAddress eth_common.Address + TokenID *big.Int + ChainID common.ChainID +} + +var TestCollectibles = []TestCollectible{ + TestCollectible{ + TokenAddress: eth_common.HexToAddress("0x97a04fda4d97c6e3547d66b572e29f4a4ff40392"), + TokenID: big.NewInt(1), + ChainID: 1, + }, + TestCollectible{ // Same token ID as above but different address + TokenAddress: eth_common.HexToAddress("0x2cec8879915cdbd80c88d8b1416aa9413a24ddfa"), + TokenID: big.NewInt(1), + ChainID: 1, + }, + TestCollectible{ + TokenAddress: eth_common.HexToAddress("0x1dea7a3e04849840c0eb15fd26a55f6c40c4a69b"), + TokenID: big.NewInt(11), + ChainID: 5, + }, + TestCollectible{ // Same address as above but different token ID + TokenAddress: eth_common.HexToAddress("0x1dea7a3e04849840c0eb15fd26a55f6c40c4a69b"), + TokenID: big.NewInt(12), + ChainID: 5, + }, +} + var EthMainnet = token.Token{ Address: eth_common.HexToAddress("0x"), Name: "Ether",