feat: fetch NFT metadata from Communities

This commit is contained in:
Dario Gabriel Lipicar 2023-03-21 10:52:14 -03:00 committed by dlipicar
parent fae7e8dba5
commit a1e7eed141
7 changed files with 337 additions and 40 deletions

View File

@ -473,10 +473,17 @@ func (b *StatusNode) appmetricsService() common.StatusService {
func (b *StatusNode) walletService(accountsDB *accounts.Database, accountsFeed *event.Feed, openseaAPIKey string) common.StatusService { func (b *StatusNode) walletService(accountsDB *accounts.Database, accountsFeed *event.Feed, openseaAPIKey string) common.StatusService {
if b.walletSrvc == nil { 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.walletSrvc = wallet.NewService(
b.appDB, accountsDB, b.rpcClient, accountsFeed, openseaAPIKey, b.gethAccountManager, b.transactor, b.config, b.appDB, accountsDB, b.rpcClient, accountsFeed, openseaAPIKey, b.gethAccountManager, b.transactor, b.config,
b.ensService(), b.ensService(),
b.stickersService(accountsDB), b.stickersService(accountsDB),
extService,
) )
} }
return b.walletSrvc return b.walletSrvc

View File

@ -6,9 +6,11 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt"
"math/big" "math/big"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb"
@ -24,6 +26,7 @@ import (
gethrpc "github.com/ethereum/go-ethereum/rpc" gethrpc "github.com/ethereum/go-ethereum/rpc"
"github.com/status-im/status-go/account" "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/connection"
"github.com/status-im/status-go/db" "github.com/status-im/status-go/db"
coretypes "github.com/status-im/status-go/eth-node/core/types" 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/params"
"github.com/status-im/status-go/protocol" "github.com/status-im/status-go/protocol"
"github.com/status-im/status-go/protocol/anonmetrics" "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/pushnotificationclient"
"github.com/status-im/status-go/protocol/pushnotificationserver" "github.com/status-im/status-go/protocol/pushnotificationserver"
"github.com/status-im/status-go/protocol/transport" "github.com/status-im/status-go/protocol/transport"
@ -43,6 +47,7 @@ import (
"github.com/status-im/status-go/services/ext/mailservers" "github.com/status-im/status-go/services/ext/mailservers"
localnotifications "github.com/status-im/status-go/services/local-notifications" localnotifications "github.com/status-im/status-go/services/local-notifications"
mailserversDB "github.com/status-im/status-go/services/mailservers" 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" "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. // Service is a service that provides some additional API to whisper-based protocols like Whisper or Waku.
type Service struct { type Service struct {
thirdparty.NFTMetadataProvider
messenger *protocol.Messenger messenger *protocol.Messenger
identity *ecdsa.PrivateKey identity *ecdsa.PrivateKey
cancelMessenger chan struct{} cancelMessenger chan struct{}
@ -507,3 +513,68 @@ func (s *Service) ConnectionChanged(state connection.State) {
func (s *Service) Messenger() *protocol.Messenger { func (s *Service) Messenger() *protocol.Messenger {
return s.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
}

View File

@ -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) { func (api *API) GetOpenseaCollectionsByOwner(ctx context.Context, chainID uint64, owner common.Address) ([]opensea.OwnedCollection, error) {
log.Debug("call to get opensea collections") log.Debug("call to get opensea collections")
client, err := opensea.NewOpenseaClient(chainID, api.s.openseaAPIKey) return api.s.collectiblesManager.FetchAllCollectionsByOwner(chainID, owner)
if err != nil {
return nil, err
}
return client.FetchAllCollectionsByOwner(owner)
} }
// Kept for compatibility with mobile app // 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) { 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") log.Debug("call to get opensea assets")
client, err := opensea.NewOpenseaClient(chainID, api.s.openseaAPIKey) return api.s.collectiblesManager.FetchAllAssetsByOwnerAndCollection(chainID, owner, collectionSlug, cursor, limit)
if err != nil {
return nil, err
}
return client.FetchAllAssetsByOwnerAndCollection(owner, collectionSlug, cursor, limit)
} }
func (api *API) GetOpenseaAssetsByOwnerWithCursor(ctx context.Context, chainID uint64, owner common.Address, cursor string, limit int) (*opensea.AssetContainer, error) { func (api *API) GetOpenseaAssetsByOwnerWithCursor(ctx context.Context, chainID uint64, owner common.Address, cursor string, limit int) (*opensea.AssetContainer, error) {
log.Debug("call to FetchAllAssetsByOwner") log.Debug("call to FetchAllAssetsByOwner")
client, err := opensea.NewOpenseaClient(chainID, api.s.openseaAPIKey) return api.s.collectiblesManager.FetchAllAssetsByOwner(chainID, owner, cursor, limit)
if err != nil {
return nil, err
}
return client.FetchAllAssetsByOwner(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) { 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") log.Debug("call to GetOpenseaAssetsByOwnerAndContractAddressWithCursor")
client, err := opensea.NewOpenseaClient(chainID, api.s.openseaAPIKey) return api.s.collectiblesManager.FetchAllAssetsByOwnerAndContractAddress(chainID, owner, contractAddresses, cursor, limit)
if err != nil {
return nil, err
}
return client.FetchAllAssetsByOwnerAndContractAddress(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") log.Debug("call to GetOpenseaAssetsByNFTUniqueID")
return api.s.collectiblesManager.FetchAssetsByNFTUniqueID(chainID, uniqueIDs, limit)
client, err := opensea.NewOpenseaClient(chainID, api.s.openseaAPIKey)
if err != nil {
return nil, err
}
return client.FetchAssetsByNFTUniqueID(uniqueIDs, limit)
} }
func (api *API) AddEthereumChain(ctx context.Context, network params.Network) error { func (api *API) AddEthereumChain(ctx context.Context, network params.Network) error {

View File

@ -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
}

View File

@ -16,9 +16,11 @@ import (
"github.com/status-im/status-go/rpc" "github.com/status-im/status-go/rpc"
"github.com/status-im/status-go/services/ens" "github.com/status-im/status-go/services/ens"
"github.com/status-im/status-go/services/stickers" "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/currency"
"github.com/status-im/status-go/services/wallet/history" "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/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/coingecko"
"github.com/status-im/status-go/services/wallet/thirdparty/cryptocompare" "github.com/status-im/status-go/services/wallet/thirdparty/cryptocompare"
"github.com/status-im/status-go/services/wallet/thirdparty/opensea" "github.com/status-im/status-go/services/wallet/thirdparty/opensea"
@ -51,6 +53,7 @@ func NewService(
config *params.NodeConfig, config *params.NodeConfig,
ens *ens.Service, ens *ens.Service,
stickers *stickers.Service, stickers *stickers.Service,
nftMetadataProvider thirdparty.NFTMetadataProvider,
) *Service { ) *Service {
cryptoOnRampManager := NewCryptoOnRampManager(&CryptoOnRampOptions{ cryptoOnRampManager := NewCryptoOnRampManager(&CryptoOnRampOptions{
dataSourceType: DataSourceStatic, dataSourceType: DataSourceStatic,
@ -69,6 +72,7 @@ func NewService(
reader := NewReader(rpcClient, tokenManager, marketManager, accountsDB, walletFeed) reader := NewReader(rpcClient, tokenManager, marketManager, accountsDB, walletFeed)
history := history.NewService(db, walletFeed, rpcClient, tokenManager, marketManager) history := history.NewService(db, walletFeed, rpcClient, tokenManager, marketManager)
currency := currency.NewService(db, walletFeed, tokenManager, marketManager) currency := currency.NewService(db, walletFeed, tokenManager, marketManager)
collectiblesManager := collectibles.NewManager(rpcClient, nftMetadataProvider, openseaAPIKey)
return &Service{ return &Service{
db: db, db: db,
accountsDB: accountsDB, accountsDB: accountsDB,
@ -78,7 +82,7 @@ func NewService(
transactionManager: transactionManager, transactionManager: transactionManager,
transferController: transferController, transferController: transferController,
cryptoOnRampManager: cryptoOnRampManager, cryptoOnRampManager: cryptoOnRampManager,
openseaAPIKey: openseaAPIKey, collectiblesManager: collectiblesManager,
feesManager: &FeeManager{rpcClient}, feesManager: &FeeManager{rpcClient},
gethManager: gethManager, gethManager: gethManager,
marketManager: marketManager, marketManager: marketManager,
@ -106,7 +110,7 @@ type Service struct {
feesManager *FeeManager feesManager *FeeManager
marketManager *market.Manager marketManager *market.Manager
started bool started bool
openseaAPIKey string collectiblesManager *collectibles.Manager
gethManager *account.GethManager gethManager *account.GethManager
transactor *transactions.Transactor transactor *transactions.Transactor
ens *ens.Service ens *ens.Service

View File

@ -16,6 +16,7 @@ import (
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/status-im/status-go/services/wallet/bigint" "github.com/status-im/status-go/services/wallet/bigint"
"github.com/status-im/status-go/services/wallet/thirdparty"
) )
const AssetLimit = 200 const AssetLimit = 200
@ -36,11 +37,6 @@ const ChainIDRequiringAPIKey = 1
type TraitValue string type TraitValue string
type NFTUniqueID struct {
ContractAddress common.Address `json:"contractAddress"`
TokenID bigint.BigInt `json:"tokenID"`
}
func (st *TraitValue) UnmarshalJSON(b []byte) error { func (st *TraitValue) UnmarshalJSON(b []byte) error {
var item interface{} var item interface{}
if err := json.Unmarshal(b, &item); err != nil { if err := json.Unmarshal(b, &item); err != nil {
@ -109,6 +105,7 @@ type Asset struct {
LastSale LastSale `json:"last_sale"` LastSale LastSale `json:"last_sale"`
SellOrders []SellOrder `json:"sell_orders"` SellOrders []SellOrder `json:"sell_orders"`
BackgroundColor string `json:"background_color"` BackgroundColor string `json:"background_color"`
TokenURI string `json:"token_metadata"`
} }
type CollectionTrait struct { type CollectionTrait struct {
@ -249,7 +246,7 @@ func (o *Client) FetchAllAssetsByOwner(owner common.Address, cursor string, limi
return o.fetchAssets(queryParams, limit) 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{} queryParams := url.Values{}
for _, uniqueID := range uniqueIDs { for _, uniqueID := range uniqueIDs {

View File

@ -1,5 +1,10 @@
package thirdparty package thirdparty
import (
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/services/wallet/bigint"
)
type HistoricalPrice struct { type HistoricalPrice struct {
Timestamp int64 `json:"time"` Timestamp int64 `json:"time"`
Value float64 `json:"close"` Value float64 `json:"close"`
@ -35,3 +40,19 @@ type MarketDataProvider interface {
FetchTokenMarketValues(symbols []string, currency string) (map[string]TokenMarketValues, error) FetchTokenMarketValues(symbols []string, currency string) (map[string]TokenMarketValues, error)
FetchTokenDetails(symbols []string) (map[string]TokenDetails, 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)
}