diff --git a/node/status_node_services.go b/node/status_node_services.go index 39cdea6cb..7f37f78d4 100644 --- a/node/status_node_services.go +++ b/node/status_node_services.go @@ -473,10 +473,17 @@ func (b *StatusNode) appmetricsService() common.StatusService { func (b *StatusNode) walletService(accountsDB *accounts.Database, accountsFeed *event.Feed, openseaAPIKey string) common.StatusService { if b.walletSrvc == nil { + var extService *ext.Service + if b.WakuV2ExtService() != nil { + extService = b.WakuV2ExtService().Service + } else if b.WakuExtService() != nil { + extService = b.WakuExtService().Service + } b.walletSrvc = wallet.NewService( b.appDB, accountsDB, b.rpcClient, accountsFeed, openseaAPIKey, b.gethAccountManager, b.transactor, b.config, b.ensService(), b.stickersService(accountsDB), + extService, ) } return b.walletSrvc diff --git a/services/ext/service.go b/services/ext/service.go index b8c640bb9..5f057df46 100644 --- a/services/ext/service.go +++ b/services/ext/service.go @@ -6,9 +6,11 @@ import ( "database/sql" "encoding/hex" "errors" + "fmt" "math/big" "os" "path/filepath" + "strings" "time" "github.com/syndtr/goleveldb/leveldb" @@ -24,6 +26,7 @@ import ( gethrpc "github.com/ethereum/go-ethereum/rpc" "github.com/status-im/status-go/account" + "github.com/status-im/status-go/api/multiformat" "github.com/status-im/status-go/connection" "github.com/status-im/status-go/db" coretypes "github.com/status-im/status-go/eth-node/core/types" @@ -34,6 +37,7 @@ import ( "github.com/status-im/status-go/params" "github.com/status-im/status-go/protocol" "github.com/status-im/status-go/protocol/anonmetrics" + "github.com/status-im/status-go/protocol/common" "github.com/status-im/status-go/protocol/pushnotificationclient" "github.com/status-im/status-go/protocol/pushnotificationserver" "github.com/status-im/status-go/protocol/transport" @@ -43,6 +47,7 @@ import ( "github.com/status-im/status-go/services/ext/mailservers" localnotifications "github.com/status-im/status-go/services/local-notifications" mailserversDB "github.com/status-im/status-go/services/mailservers" + "github.com/status-im/status-go/services/wallet/thirdparty" "github.com/status-im/status-go/services/wallet/transfer" ) @@ -56,6 +61,7 @@ type EnvelopeEventsHandler interface { // Service is a service that provides some additional API to whisper-based protocols like Whisper or Waku. type Service struct { + thirdparty.NFTMetadataProvider messenger *protocol.Messenger identity *ecdsa.PrivateKey cancelMessenger chan struct{} @@ -507,3 +513,68 @@ func (s *Service) ConnectionChanged(state connection.State) { func (s *Service) Messenger() *protocol.Messenger { return s.messenger } + +func tokenURIToCommunityID(tokenURI string) string { + tmpStr := strings.Split(tokenURI, "/") + + // Community NFTs have a tokenURI of the form "compressedCommunityID/tokenID" + if len(tmpStr) != 2 { + return "" + } + compressedCommunityID := tmpStr[0] + + hexCommunityID, err := multiformat.DeserializeCompressedKey(compressedCommunityID) + if err != nil { + return "" + } + + pubKey, err := common.HexToPubkey(hexCommunityID) + if err != nil { + return "" + } + + communityID := types.EncodeHex(crypto.CompressPubkey(pubKey)) + + return communityID +} + +func (s *Service) CanProvideNFTMetadata(chainID uint64, id thirdparty.NFTUniqueID, tokenURI string) (bool, error) { + ret := tokenURI != "" && tokenURIToCommunityID(tokenURI) != "" + return ret, nil +} + +func (s *Service) FetchNFTMetadata(chainID uint64, id thirdparty.NFTUniqueID, tokenURI string) (*thirdparty.NFTMetadata, error) { + if s.messenger == nil { + return nil, fmt.Errorf("messenger not ready") + } + + communityID := tokenURIToCommunityID(tokenURI) + + if communityID == "" { + return nil, fmt.Errorf("invalid tokenURI") + } + + // Try to fetch metadata from Messenger communities + community, err := s.messenger.RequestCommunityInfoFromMailserver(communityID, true) + + if err != nil { + return nil, err + } + + if community != nil { + tokensMetadata := community.CommunityTokensMetadata() + + for _, tokenMetadata := range tokensMetadata { + contractAddresses := tokenMetadata.GetContractAddresses() + if contractAddresses[chainID] == id.ContractAddress.Hex() { + return &thirdparty.NFTMetadata{ + Name: tokenMetadata.GetName(), + Description: tokenMetadata.GetDescription(), + ImageURL: tokenMetadata.GetImage(), + }, nil + } + } + } + + return nil, nil +} diff --git a/services/wallet/api.go b/services/wallet/api.go index 77211defe..c6a58ada6 100644 --- a/services/wallet/api.go +++ b/services/wallet/api.go @@ -295,12 +295,7 @@ func (api *API) GetCryptoOnRamps(ctx context.Context) ([]CryptoOnRamp, error) { func (api *API) GetOpenseaCollectionsByOwner(ctx context.Context, chainID uint64, owner common.Address) ([]opensea.OwnedCollection, error) { log.Debug("call to get opensea collections") - client, err := opensea.NewOpenseaClient(chainID, api.s.openseaAPIKey) - if err != nil { - return nil, err - } - - return client.FetchAllCollectionsByOwner(owner) + return api.s.collectiblesManager.FetchAllCollectionsByOwner(chainID, owner) } // Kept for compatibility with mobile app @@ -314,43 +309,22 @@ func (api *API) GetOpenseaAssetsByOwnerAndCollection(ctx context.Context, chainI func (api *API) GetOpenseaAssetsByOwnerAndCollectionWithCursor(ctx context.Context, chainID uint64, owner common.Address, collectionSlug string, cursor string, limit int) (*opensea.AssetContainer, error) { log.Debug("call to get opensea assets") - client, err := opensea.NewOpenseaClient(chainID, api.s.openseaAPIKey) - if err != nil { - return nil, err - } - - return client.FetchAllAssetsByOwnerAndCollection(owner, collectionSlug, cursor, limit) + return api.s.collectiblesManager.FetchAllAssetsByOwnerAndCollection(chainID, owner, collectionSlug, cursor, limit) } func (api *API) GetOpenseaAssetsByOwnerWithCursor(ctx context.Context, chainID uint64, owner common.Address, cursor string, limit int) (*opensea.AssetContainer, error) { log.Debug("call to FetchAllAssetsByOwner") - client, err := opensea.NewOpenseaClient(chainID, api.s.openseaAPIKey) - if err != nil { - return nil, err - } - - return client.FetchAllAssetsByOwner(owner, cursor, limit) + return api.s.collectiblesManager.FetchAllAssetsByOwner(chainID, owner, cursor, limit) } func (api *API) GetOpenseaAssetsByOwnerAndContractAddressWithCursor(ctx context.Context, chainID uint64, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*opensea.AssetContainer, error) { log.Debug("call to GetOpenseaAssetsByOwnerAndContractAddressWithCursor") - client, err := opensea.NewOpenseaClient(chainID, api.s.openseaAPIKey) - if err != nil { - return nil, err - } - - return client.FetchAllAssetsByOwnerAndContractAddress(owner, contractAddresses, cursor, limit) + return api.s.collectiblesManager.FetchAllAssetsByOwnerAndContractAddress(chainID, owner, contractAddresses, cursor, limit) } -func (api *API) GetOpenseaAssetsByNFTUniqueID(ctx context.Context, chainID uint64, uniqueIDs []opensea.NFTUniqueID, limit int) (*opensea.AssetContainer, error) { +func (api *API) GetOpenseaAssetsByNFTUniqueID(ctx context.Context, chainID uint64, uniqueIDs []thirdparty.NFTUniqueID, limit int) (*opensea.AssetContainer, error) { log.Debug("call to GetOpenseaAssetsByNFTUniqueID") - - client, err := opensea.NewOpenseaClient(chainID, api.s.openseaAPIKey) - if err != nil { - return nil, err - } - - return client.FetchAssetsByNFTUniqueID(uniqueIDs, limit) + return api.s.collectiblesManager.FetchAssetsByNFTUniqueID(chainID, uniqueIDs, limit) } func (api *API) AddEthereumChain(ctx context.Context, network params.Network) error { diff --git a/services/wallet/collectibles/collectibles.go b/services/wallet/collectibles/collectibles.go new file mode 100644 index 000000000..7e9b5b60e --- /dev/null +++ b/services/wallet/collectibles/collectibles.go @@ -0,0 +1,223 @@ +package collectibles + +import ( + "context" + "strings" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/status-im/status-go/contracts/collectibles" + "github.com/status-im/status-go/rpc" + "github.com/status-im/status-go/services/wallet/thirdparty" + "github.com/status-im/status-go/services/wallet/thirdparty/opensea" +) + +const requestTimeout = 5 * time.Second + +func erc721MetadataInterfaceID() [4]byte { + return [...]byte{0x5b, 0x5e, 0x13, 0x9f} // 0x5b5e139f +} + +type Manager struct { + rpcClient *rpc.Client + metadataProvider thirdparty.NFTMetadataProvider + openseaAPIKey string +} + +func NewManager(rpcClient *rpc.Client, metadataProvider thirdparty.NFTMetadataProvider, openseaAPIKey string) *Manager { + return &Manager{ + rpcClient: rpcClient, + metadataProvider: metadataProvider, + openseaAPIKey: openseaAPIKey, + } +} + +func (o *Manager) FetchAllCollectionsByOwner(chainID uint64, owner common.Address) ([]opensea.OwnedCollection, error) { + client, err := opensea.NewOpenseaClient(chainID, o.openseaAPIKey) + if err != nil { + return nil, err + } + return client.FetchAllCollectionsByOwner(owner) +} + +func (o *Manager) FetchAllAssetsByOwnerAndCollection(chainID uint64, owner common.Address, collectionSlug string, cursor string, limit int) (*opensea.AssetContainer, error) { + client, err := opensea.NewOpenseaClient(chainID, o.openseaAPIKey) + if err != nil { + return nil, err + } + + assetContainer, err := client.FetchAllAssetsByOwnerAndCollection(owner, collectionSlug, cursor, limit) + if err != nil { + return nil, err + } + + err = o.processAssets(chainID, assetContainer.Assets) + if err != nil { + return nil, err + } + + return assetContainer, nil +} + +func (o *Manager) FetchAllAssetsByOwnerAndContractAddress(chainID uint64, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*opensea.AssetContainer, error) { + client, err := opensea.NewOpenseaClient(chainID, o.openseaAPIKey) + if err != nil { + return nil, err + } + + assetContainer, err := client.FetchAllAssetsByOwnerAndContractAddress(owner, contractAddresses, cursor, limit) + if err != nil { + return nil, err + } + + err = o.processAssets(chainID, assetContainer.Assets) + if err != nil { + return nil, err + } + + return assetContainer, nil +} + +func (o *Manager) FetchAllAssetsByOwner(chainID uint64, owner common.Address, cursor string, limit int) (*opensea.AssetContainer, error) { + client, err := opensea.NewOpenseaClient(chainID, o.openseaAPIKey) + if err != nil { + return nil, err + } + + assetContainer, err := client.FetchAllAssetsByOwner(owner, cursor, limit) + if err != nil { + return nil, err + } + + err = o.processAssets(chainID, assetContainer.Assets) + if err != nil { + return nil, err + } + + return assetContainer, nil +} + +func (o *Manager) FetchAssetsByNFTUniqueID(chainID uint64, uniqueIDs []thirdparty.NFTUniqueID, limit int) (*opensea.AssetContainer, error) { + client, err := opensea.NewOpenseaClient(chainID, o.openseaAPIKey) + if err != nil { + return nil, err + } + + assetContainer, err := client.FetchAssetsByNFTUniqueID(uniqueIDs, limit) + if err != nil { + return nil, err + } + + err = o.processAssets(chainID, assetContainer.Assets) + if err != nil { + return nil, err + } + + return assetContainer, nil +} + +func isMetadataEmpty(asset opensea.Asset) bool { + return asset.Name == "" && + asset.Description == "" && + asset.ImageURL == "" && + asset.TokenURI == "" +} + +func (o *Manager) supportsERC721Metadata(chainID uint64, contractAddress common.Address) (bool, error) { + backend, err := o.rpcClient.EthClient(chainID) + if err != nil { + return false, err + } + + caller, err := collectibles.NewCollectiblesCaller(contractAddress, backend) + if err != nil { + return false, err + } + + timeoutContext, timeoutCancel := context.WithTimeout(context.Background(), requestTimeout) + defer timeoutCancel() + + supports, err := caller.SupportsInterface(&bind.CallOpts{ + Context: timeoutContext, + }, erc721MetadataInterfaceID()) + + if err != nil { + if strings.HasPrefix(err.Error(), vm.ErrExecutionReverted.Error()) { + // Contract doesn't support "SupportsInterface" + return false, nil + } + return false, err + } + + return supports, nil +} + +func (o *Manager) fetchTokenURI(chainID uint64, id thirdparty.NFTUniqueID) (string, error) { + backend, err := o.rpcClient.EthClient(chainID) + if err != nil { + return "", err + } + + caller, err := collectibles.NewCollectiblesCaller(id.ContractAddress, backend) + if err != nil { + return "", err + } + + timeoutContext, timeoutCancel := context.WithTimeout(context.Background(), requestTimeout) + defer timeoutCancel() + + return caller.TokenURI(&bind.CallOpts{ + Context: timeoutContext, + }, id.TokenID.Int) +} + +func (o *Manager) processAssets(chainID uint64, assets []opensea.Asset) error { + for idx, asset := range assets { + if isMetadataEmpty(asset) { + id := thirdparty.NFTUniqueID{ + ContractAddress: common.HexToAddress(asset.Contract.Address), + TokenID: asset.TokenID, + } + + supportsERC721Metadata, err := o.supportsERC721Metadata(chainID, id.ContractAddress) + + if err != nil { + return err + } + + if supportsERC721Metadata { + tokenURI, err := o.fetchTokenURI(chainID, id) + + if err != nil { + return err + } + + assets[idx].TokenURI = tokenURI + + canProvide, err := o.metadataProvider.CanProvideNFTMetadata(chainID, id, tokenURI) + + if err != nil { + return err + } + + if canProvide { + metadata, err := o.metadataProvider.FetchNFTMetadata(chainID, id, tokenURI) + if err != nil { + return err + } + + if metadata != nil { + assets[idx].Name = metadata.Name + assets[idx].Description = metadata.Description + assets[idx].Collection.ImageURL = metadata.ImageURL + assets[idx].ImageURL = metadata.ImageURL + } + } + } + } + } + + return nil +} diff --git a/services/wallet/service.go b/services/wallet/service.go index 6241bc1ef..d1167ebe5 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -16,9 +16,11 @@ import ( "github.com/status-im/status-go/rpc" "github.com/status-im/status-go/services/ens" "github.com/status-im/status-go/services/stickers" + "github.com/status-im/status-go/services/wallet/collectibles" "github.com/status-im/status-go/services/wallet/currency" "github.com/status-im/status-go/services/wallet/history" "github.com/status-im/status-go/services/wallet/market" + "github.com/status-im/status-go/services/wallet/thirdparty" "github.com/status-im/status-go/services/wallet/thirdparty/coingecko" "github.com/status-im/status-go/services/wallet/thirdparty/cryptocompare" "github.com/status-im/status-go/services/wallet/thirdparty/opensea" @@ -51,6 +53,7 @@ func NewService( config *params.NodeConfig, ens *ens.Service, stickers *stickers.Service, + nftMetadataProvider thirdparty.NFTMetadataProvider, ) *Service { cryptoOnRampManager := NewCryptoOnRampManager(&CryptoOnRampOptions{ dataSourceType: DataSourceStatic, @@ -69,6 +72,7 @@ func NewService( reader := NewReader(rpcClient, tokenManager, marketManager, accountsDB, walletFeed) history := history.NewService(db, walletFeed, rpcClient, tokenManager, marketManager) currency := currency.NewService(db, walletFeed, tokenManager, marketManager) + collectiblesManager := collectibles.NewManager(rpcClient, nftMetadataProvider, openseaAPIKey) return &Service{ db: db, accountsDB: accountsDB, @@ -78,7 +82,7 @@ func NewService( transactionManager: transactionManager, transferController: transferController, cryptoOnRampManager: cryptoOnRampManager, - openseaAPIKey: openseaAPIKey, + collectiblesManager: collectiblesManager, feesManager: &FeeManager{rpcClient}, gethManager: gethManager, marketManager: marketManager, @@ -106,7 +110,7 @@ type Service struct { feesManager *FeeManager marketManager *market.Manager started bool - openseaAPIKey string + collectiblesManager *collectibles.Manager gethManager *account.GethManager transactor *transactions.Transactor ens *ens.Service diff --git a/services/wallet/thirdparty/opensea/client.go b/services/wallet/thirdparty/opensea/client.go index 88a229e43..835793595 100644 --- a/services/wallet/thirdparty/opensea/client.go +++ b/services/wallet/thirdparty/opensea/client.go @@ -16,6 +16,7 @@ import ( "github.com/ethereum/go-ethereum/log" "github.com/status-im/status-go/services/wallet/bigint" + "github.com/status-im/status-go/services/wallet/thirdparty" ) const AssetLimit = 200 @@ -36,11 +37,6 @@ const ChainIDRequiringAPIKey = 1 type TraitValue string -type NFTUniqueID struct { - ContractAddress common.Address `json:"contractAddress"` - TokenID bigint.BigInt `json:"tokenID"` -} - func (st *TraitValue) UnmarshalJSON(b []byte) error { var item interface{} if err := json.Unmarshal(b, &item); err != nil { @@ -109,6 +105,7 @@ type Asset struct { LastSale LastSale `json:"last_sale"` SellOrders []SellOrder `json:"sell_orders"` BackgroundColor string `json:"background_color"` + TokenURI string `json:"token_metadata"` } type CollectionTrait struct { @@ -249,7 +246,7 @@ func (o *Client) FetchAllAssetsByOwner(owner common.Address, cursor string, limi return o.fetchAssets(queryParams, limit) } -func (o *Client) FetchAssetsByNFTUniqueID(uniqueIDs []NFTUniqueID, limit int) (*AssetContainer, error) { +func (o *Client) FetchAssetsByNFTUniqueID(uniqueIDs []thirdparty.NFTUniqueID, limit int) (*AssetContainer, error) { queryParams := url.Values{} for _, uniqueID := range uniqueIDs { diff --git a/services/wallet/thirdparty/types.go b/services/wallet/thirdparty/types.go index 23218d91c..76aa43974 100644 --- a/services/wallet/thirdparty/types.go +++ b/services/wallet/thirdparty/types.go @@ -1,5 +1,10 @@ package thirdparty +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/status-im/status-go/services/wallet/bigint" +) + type HistoricalPrice struct { Timestamp int64 `json:"time"` Value float64 `json:"close"` @@ -35,3 +40,19 @@ type MarketDataProvider interface { FetchTokenMarketValues(symbols []string, currency string) (map[string]TokenMarketValues, error) FetchTokenDetails(symbols []string) (map[string]TokenDetails, error) } + +type NFTUniqueID struct { + ContractAddress common.Address `json:"contractAddress"` + TokenID *bigint.BigInt `json:"tokenID"` +} + +type NFTMetadata struct { + Name string `json:"name"` + Description string `json:"description"` + ImageURL string `json:"image"` +} + +type NFTMetadataProvider interface { + CanProvideNFTMetadata(chainID uint64, id NFTUniqueID, tokenURI string) (bool, error) + FetchNFTMetadata(chainID uint64, id NFTUniqueID, tokenURI string) (*NFTMetadata, error) +}