diff --git a/services/wallet/collectibles/commands.go b/services/wallet/collectibles/commands.go index d0245b91a..3375c8cef 100644 --- a/services/wallet/collectibles/commands.go +++ b/services/wallet/collectibles/commands.go @@ -164,7 +164,7 @@ type loadOwnedCollectiblesCommand struct { ownedCollectiblesChangeCh chan<- OwnedCollectiblesChange // Not to be set by the caller - partialOwnership []thirdparty.CollectibleUniqueID + partialOwnership []thirdparty.CollectibleIDBalance err error } @@ -200,14 +200,18 @@ func (c *loadOwnedCollectiblesCommand) triggerEvent(eventType walletevent.EventT }) } -func ownedTokensToTokenBalancesPerContractAddress(ownership []thirdparty.CollectibleUniqueID) thirdparty.TokenBalancesPerContractAddress { +func ownedTokensToTokenBalancesPerContractAddress(ownership []thirdparty.CollectibleIDBalance) thirdparty.TokenBalancesPerContractAddress { ret := make(thirdparty.TokenBalancesPerContractAddress) - for _, id := range ownership { - balance := thirdparty.TokenBalance{ - TokenID: id.TokenID, - Balance: &bigint.BigInt{Int: big.NewInt(1)}, + for _, idBalance := range ownership { + balanceBigInt := idBalance.Balance + if balanceBigInt == nil { + balanceBigInt = &bigint.BigInt{Int: big.NewInt(1)} } - ret[id.ContractID.Address] = append(ret[id.ContractID.Address], balance) + balance := thirdparty.TokenBalance{ + TokenID: idBalance.ID.TokenID, + Balance: balanceBigInt, + } + ret[idBalance.ID.ContractID.Address] = append(ret[idBalance.ID.ContractID.Address], balance) } return ret } @@ -295,9 +299,6 @@ func (c *loadOwnedCollectiblesCommand) Run(parent context.Context) (err error) { // Normally, update the DB once we've finished fetching // If this is the first fetch, make partial updates to the client to get a better UX if initialFetch || finished { - // Token balances should come from the providers. For now we assume all balances are 1, which - // is only valid for ERC721. - // TODO (#13025): Fetch balances from the providers. balances := ownedTokensToTokenBalancesPerContractAddress(c.partialOwnership) updateMessage.Removed, updateMessage.Updated, updateMessage.Added, err = c.ownershipDB.Update(c.chainID, c.account, balances, start.Unix()) diff --git a/services/wallet/collectibles/manager.go b/services/wallet/collectibles/manager.go index a9e6c7718..d91423f4c 100644 --- a/services/wallet/collectibles/manager.go +++ b/services/wallet/collectibles/manager.go @@ -17,6 +17,7 @@ import ( "github.com/ethereum/go-ethereum/event" "github.com/ethereum/go-ethereum/log" "github.com/status-im/status-go/contracts/community-tokens/collectibles" + "github.com/status-im/status-go/contracts/ierc1155" "github.com/status-im/status-go/rpc" "github.com/status-im/status-go/server" "github.com/status-im/status-go/services/wallet/async" @@ -315,6 +316,85 @@ func (o *Manager) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommo } return nil, ErrNoProvidersAvailableForChainID } +func (o *Manager) FetchERC1155Balances(ctx context.Context, owner common.Address, chainID walletCommon.ChainID, contractAddress common.Address, tokenIDs []*bigint.BigInt) ([]*bigint.BigInt, error) { + if len(tokenIDs) == 0 { + return nil, nil + } + + backend, err := o.rpcClient.EthClient(uint64(chainID)) + if err != nil { + return nil, err + } + + caller, err := ierc1155.NewIerc1155Caller(contractAddress, backend) + if err != nil { + return nil, err + } + + owners := make([]common.Address, len(tokenIDs)) + ids := make([]*big.Int, len(tokenIDs)) + for i, tokenID := range tokenIDs { + owners[i] = owner + ids[i] = tokenID.Int + } + + balances, err := caller.BalanceOfBatch(&bind.CallOpts{ + Context: ctx, + }, owners, ids) + + if err != nil { + return nil, err + } + + bigIntBalances := make([]*bigint.BigInt, len(balances)) + for i, balance := range balances { + bigIntBalances[i] = &bigint.BigInt{Int: balance} + } + + return bigIntBalances, err +} + +func (o *Manager) fillMissingBalances(ctx context.Context, owner common.Address, collectibles []*thirdparty.FullCollectibleData) { + collectiblesByChainIDAndContractAddress := thirdparty.GroupCollectiblesByChainIDAndContractAddress(collectibles) + + for chainID, collectiblesByContract := range collectiblesByChainIDAndContractAddress { + for contractAddress, contractCollectibles := range collectiblesByContract { + collectiblesToFetchPerTokenID := make(map[string]*thirdparty.FullCollectibleData) + + for _, collectible := range contractCollectibles { + if collectible.AccountBalance == nil { + switch getContractType(*collectible) { + case walletCommon.ContractTypeERC1155: + collectiblesToFetchPerTokenID[collectible.CollectibleData.ID.TokenID.String()] = collectible + default: + // Any other type of collectible is non-fungible, balance is 1 + collectible.AccountBalance = &bigint.BigInt{Int: big.NewInt(1)} + } + } + } + + if len(collectiblesToFetchPerTokenID) == 0 { + continue + } + + tokenIDs := make([]*bigint.BigInt, 0, len(collectiblesToFetchPerTokenID)) + for _, c := range collectiblesToFetchPerTokenID { + tokenIDs = append(tokenIDs, c.CollectibleData.ID.TokenID) + } + + balances, err := o.FetchERC1155Balances(ctx, owner, chainID, contractAddress, tokenIDs) + if err != nil { + log.Error("FetchERC1155Balances failed", "chainID", chainID, "contractAddress", contractAddress, "err", err) + continue + } + + for i := range balances { + collectible := collectiblesToFetchPerTokenID[tokenIDs[i].String()] + collectible.AccountBalance = balances[i] + } + } + } +} func (o *Manager) FetchCollectibleOwnershipByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int, providerID string) (*thirdparty.CollectibleOwnershipContainer, error) { // We don't yet have an API that will return only Ownership data @@ -324,7 +404,15 @@ func (o *Manager) FetchCollectibleOwnershipByOwner(ctx context.Context, chainID return nil, err } + // Some providers do not give us the balances for ERC1155 tokens, so we need to fetch them separately. + collectibles := make([]*thirdparty.FullCollectibleData, 0, len(assetContainer.Items)) + for i := range assetContainer.Items { + collectibles = append(collectibles, &assetContainer.Items[i]) + } + o.fillMissingBalances(ctx, owner, collectibles) + ret := assetContainer.ToOwnershipContainer() + return &ret, nil } diff --git a/services/wallet/router.go b/services/wallet/router.go index 4eb520335..2a24b2daf 100644 --- a/services/wallet/router.go +++ b/services/wallet/router.go @@ -16,7 +16,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/status-im/status-go/contracts" - "github.com/status-im/status-go/contracts/ierc1155" "github.com/status-im/status-go/contracts/ierc20" "github.com/status-im/status-go/eth-node/types" "github.com/status-im/status-go/params" @@ -525,24 +524,27 @@ func (r *Router) getBalance(ctx context.Context, network *params.Network, token } func (r *Router) getERC1155Balance(ctx context.Context, network *params.Network, token *token.Token, account common.Address) (*big.Int, error) { - client, err := r.s.rpcClient.EthClient(network.ChainID) - if err != nil { - return nil, err - } - tokenID, success := new(big.Int).SetString(token.Symbol, 10) if !success { return nil, errors.New("failed to convert token symbol to big.Int") } - caller, err := ierc1155.NewIerc1155Caller(token.Address, client) + balances, err := r.s.collectiblesManager.FetchERC1155Balances( + ctx, + account, + walletCommon.ChainID(network.ChainID), + token.Address, + []*bigint.BigInt{&bigint.BigInt{Int: tokenID}}, + ) if err != nil { return nil, err } - return caller.BalanceOf(&bind.CallOpts{ - Context: ctx, - }, account, tokenID) + if len(balances) != 1 || balances[0] == nil { + return nil, errors.New("invalid ERC1155 balance fetch response") + } + + return balances[0].Int, nil } func (r *Router) suggestedRoutes( diff --git a/services/wallet/thirdparty/alchemy/client_test.go b/services/wallet/thirdparty/alchemy/client_test.go index aa0f00a64..a84c4124c 100644 --- a/services/wallet/thirdparty/alchemy/client_test.go +++ b/services/wallet/thirdparty/alchemy/client_test.go @@ -43,6 +43,9 @@ func TestUnmarshallOwnedCollectibles(t *testing.T) { expectedTokenID0, _ := big.NewInt(0).SetString("50659039041325838222074459099120411190538227963344971355684955900852972814336", 10) expectedTokenID1, _ := big.NewInt(0).SetString("900", 10) + expectedBalance0, _ := big.NewInt(0).SetString("15", 10) + expectedBalance1, _ := big.NewInt(0).SetString("1", 10) + expectedCollectiblesData := []thirdparty.FullCollectibleData{ { CollectibleData: thirdparty.CollectibleData{ @@ -77,6 +80,9 @@ func TestUnmarshallOwnedCollectibles(t *testing.T) { ImageURL: "", Traits: make(map[string]thirdparty.CollectionTrait), }, + AccountBalance: &bigint.BigInt{ + Int: expectedBalance0, + }, }, { CollectibleData: thirdparty.CollectibleData{ @@ -132,6 +138,9 @@ func TestUnmarshallOwnedCollectibles(t *testing.T) { ImageURL: "https://raw.seadn.io/files/e7765f13c4658f514d0efc008ae7f300.png", Traits: make(map[string]thirdparty.CollectionTrait), }, + AccountBalance: &bigint.BigInt{ + Int: expectedBalance1, + }, }, } diff --git a/services/wallet/thirdparty/alchemy/client_test_data.go b/services/wallet/thirdparty/alchemy/client_test_data.go index a5469e982..dfc7c09ba 100644 --- a/services/wallet/thirdparty/alchemy/client_test_data.go +++ b/services/wallet/thirdparty/alchemy/client_test_data.go @@ -87,7 +87,7 @@ const ownedCollectiblesJSON = `{ }, "owners": null, "timeLastUpdated": "2024-01-03T19:11:04.681Z", - "balance": "1", + "balance": "15", "acquiredAt": { "blockTimestamp": null, "blockNumber": null diff --git a/services/wallet/thirdparty/alchemy/types.go b/services/wallet/thirdparty/alchemy/types.go index 19b30af1e..e184657bc 100644 --- a/services/wallet/thirdparty/alchemy/types.go +++ b/services/wallet/thirdparty/alchemy/types.go @@ -133,6 +133,7 @@ type Asset struct { Image Image `json:"image"` Raw Raw `json:"raw"` TokenURI string `json:"tokenUri"` + Balance *bigint.BigInt `json:"balance,omitempty"` } type OwnedNFTList struct { @@ -216,6 +217,7 @@ func (c *Asset) toCommon(id thirdparty.CollectibleUniqueID) thirdparty.FullColle return thirdparty.FullCollectibleData{ CollectibleData: c.toCollectiblesData(id), CollectionData: &contractData, + AccountBalance: c.Balance, } } diff --git a/services/wallet/thirdparty/collectible_types.go b/services/wallet/thirdparty/collectible_types.go index 1b66e97d7..583fd85cc 100644 --- a/services/wallet/thirdparty/collectible_types.go +++ b/services/wallet/thirdparty/collectible_types.go @@ -97,6 +97,45 @@ func GroupContractIDsByChainID(ids []ContractID) map[w_common.ChainID][]Contract return ret } +func GroupCollectiblesByChainID(collectibles []*FullCollectibleData) map[w_common.ChainID][]*FullCollectibleData { + ret := make(map[w_common.ChainID][]*FullCollectibleData) + + for i, collectible := range collectibles { + chainID := collectible.CollectibleData.ID.ContractID.ChainID + if _, ok := ret[chainID]; !ok { + ret[chainID] = make([]*FullCollectibleData, 0, len(collectibles)) + } + ret[chainID] = append(ret[chainID], collectibles[i]) + } + + return ret +} + +func GroupCollectiblesByContractAddress(collectibles []*FullCollectibleData) map[common.Address][]*FullCollectibleData { + ret := make(map[common.Address][]*FullCollectibleData) + + for i, collectible := range collectibles { + contractAddress := collectible.CollectibleData.ID.ContractID.Address + if _, ok := ret[contractAddress]; !ok { + ret[contractAddress] = make([]*FullCollectibleData, 0, len(collectibles)) + } + ret[contractAddress] = append(ret[contractAddress], collectibles[i]) + } + + return ret +} + +func GroupCollectiblesByChainIDAndContractAddress(collectibles []*FullCollectibleData) map[w_common.ChainID]map[common.Address][]*FullCollectibleData { + ret := make(map[w_common.ChainID]map[common.Address][]*FullCollectibleData) + + collectiblesByChainID := GroupCollectiblesByChainID(collectibles) + for chainID, chainCollectibles := range collectiblesByChainID { + ret[chainID] = GroupCollectiblesByContractAddress(chainCollectibles) + } + + return ret +} + type CollectionTrait struct { Min float64 `json:"min"` Max float64 `json:"max"` @@ -153,7 +192,8 @@ type FullCollectibleData struct { CollectionData *CollectionData CommunityInfo *CommunityInfo CollectibleCommunityInfo *CollectibleCommunityInfo - Ownership []AccountBalance + Ownership []AccountBalance // This is a list of all the owners of the collectible + AccountBalance *bigint.BigInt // This is the balance of the collectible for the requested account } type CollectiblesContainer[T any] struct { @@ -163,29 +203,38 @@ type CollectiblesContainer[T any] struct { Provider string } -type CollectibleOwnershipContainer CollectiblesContainer[CollectibleUniqueID] +type CollectibleOwnershipContainer CollectiblesContainer[CollectibleIDBalance] type CollectionDataContainer CollectiblesContainer[CollectionData] type CollectibleDataContainer CollectiblesContainer[CollectibleData] type FullCollectibleDataContainer CollectiblesContainer[FullCollectibleData] // Tried to find a way to make this generic, but couldn't, so the code below is duplicated somewhere else -func collectibleItemsToIDs(items []FullCollectibleData) []CollectibleUniqueID { - ret := make([]CollectibleUniqueID, 0, len(items)) +func collectibleItemsToBalances(items []FullCollectibleData) []CollectibleIDBalance { + ret := make([]CollectibleIDBalance, 0, len(items)) for _, item := range items { - ret = append(ret, item.CollectibleData.ID) + balance := CollectibleIDBalance{ + ID: item.CollectibleData.ID, + Balance: item.AccountBalance, + } + ret = append(ret, balance) } return ret } func (c *FullCollectibleDataContainer) ToOwnershipContainer() CollectibleOwnershipContainer { return CollectibleOwnershipContainer{ - Items: collectibleItemsToIDs(c.Items), + Items: collectibleItemsToBalances(c.Items), NextCursor: c.NextCursor, PreviousCursor: c.PreviousCursor, Provider: c.Provider, } } +type CollectibleIDBalance struct { + ID CollectibleUniqueID `json:"id"` + Balance *bigint.BigInt `json:"balance"` +} + type TokenBalance struct { TokenID *bigint.BigInt `json:"tokenId"` Balance *bigint.BigInt `json:"balance"`