status-go/services/wallet/collectibles/collectibles.go

384 lines
11 KiB
Go

package collectibles
import (
"context"
"fmt"
"math/big"
"strings"
"sync"
"time"
"github.com/afex/hystrix-go/hystrix"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/event"
"github.com/status-im/status-go/contracts/collectibles"
"github.com/status-im/status-go/rpc"
"github.com/status-im/status-go/services/wallet/bigint"
"github.com/status-im/status-go/services/wallet/thirdparty"
"github.com/status-im/status-go/services/wallet/thirdparty/opensea"
)
const requestTimeout = 5 * time.Second
const hystrixContractOwnershipClientName = "contractOwnershipClient"
const maxNFTDescriptionLength = 1024
// ERC721 does not support function "TokenURI" if call
// returns error starting with one of these strings
var noTokenURIErrorPrefixes = []string{
"execution reverted",
"abi: attempting to unmarshall",
}
type Manager struct {
rpcClient *rpc.Client
mainContractOwnershipProvider thirdparty.CollectibleContractOwnershipProvider
fallbackContractOwnershipProvider thirdparty.CollectibleContractOwnershipProvider
metadataProvider thirdparty.CollectibleMetadataProvider
openseaAPIKey string
nftCache map[uint64]map[string]opensea.Asset
nftCacheLock sync.RWMutex
walletFeed *event.Feed
}
func NewManager(rpcClient *rpc.Client, mainContractOwnershipProvider thirdparty.CollectibleContractOwnershipProvider, fallbackContractOwnershipProvider thirdparty.CollectibleContractOwnershipProvider, openseaAPIKey string, walletFeed *event.Feed) *Manager {
hystrix.ConfigureCommand(hystrixContractOwnershipClientName, hystrix.CommandConfig{
Timeout: 10000,
MaxConcurrentRequests: 100,
SleepWindow: 300000,
ErrorPercentThreshold: 25,
})
return &Manager{
rpcClient: rpcClient,
mainContractOwnershipProvider: mainContractOwnershipProvider,
fallbackContractOwnershipProvider: fallbackContractOwnershipProvider,
openseaAPIKey: openseaAPIKey,
nftCache: make(map[uint64]map[string]opensea.Asset),
walletFeed: walletFeed,
}
}
func makeContractOwnershipCall(main func() (any, error), fallback func() (any, error)) (any, error) {
resultChan := make(chan any, 1)
errChan := hystrix.Go(hystrixContractOwnershipClientName, func() error {
res, err := main()
if err != nil {
return err
}
resultChan <- res
return nil
}, func(err error) error {
if fallback == nil {
return err
}
res, err := fallback()
if err != nil {
return err
}
resultChan <- res
return nil
})
select {
case result := <-resultChan:
return result, nil
case err := <-errChan:
return nil, err
}
}
// Used to break circular dependency, call once as soon as possible after initialization
func (o *Manager) SetMetadataProvider(metadataProvider thirdparty.CollectibleMetadataProvider) {
o.metadataProvider = metadataProvider
}
func (o *Manager) FetchAllCollectionsByOwner(chainID uint64, owner common.Address) ([]opensea.OwnedCollection, error) {
client, err := opensea.NewOpenseaClient(chainID, o.openseaAPIKey, o.walletFeed)
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, o.walletFeed)
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
}
// Need to combine different providers to support all needed ChainIDs
func (o *Manager) FetchBalancesByOwnerAndContractAddress(chainID uint64, ownerAddress common.Address, contractAddresses []common.Address) (thirdparty.TokenBalancesPerContractAddress, error) {
ret := make(thirdparty.TokenBalancesPerContractAddress)
for _, contractAddress := range contractAddresses {
ret[contractAddress] = make([]thirdparty.TokenBalance, 0)
}
// Try with more direct endpoint first (OpenSea)
assetsContainer, err := o.FetchAllAssetsByOwnerAndContractAddress(chainID, ownerAddress, contractAddresses, "", 0)
if err == opensea.ErrChainIDNotSupported {
// Use contract ownership providers
for _, contractAddress := range contractAddresses {
ownership, err := o.FetchCollectibleOwnersByContractAddress(chainID, contractAddress)
if err != nil {
return nil, err
}
for _, nftOwner := range ownership.Owners {
if nftOwner.OwnerAddress == ownerAddress {
ret[contractAddress] = nftOwner.TokenBalances
break
}
}
}
} else if err == nil {
// OpenSea could provide
for _, asset := range assetsContainer.Assets {
contractAddress := common.HexToAddress(asset.Contract.Address)
balance := thirdparty.TokenBalance{
TokenID: asset.TokenID,
Balance: &bigint.BigInt{Int: big.NewInt(1)},
}
ret[contractAddress] = append(ret[contractAddress], balance)
}
} else {
// OpenSea could have provided, but returned error
return nil, err
}
return ret, 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, o.walletFeed)
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, o.walletFeed)
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.CollectibleUniqueID, limit int) (*opensea.AssetContainer, error) {
assetContainer := new(opensea.AssetContainer)
idsToFetch := o.getIDsNotInCache(chainID, uniqueIDs)
if len(idsToFetch) > 0 {
client, err := opensea.NewOpenseaClient(chainID, o.openseaAPIKey, o.walletFeed)
if err != nil {
return nil, err
}
fetchedAssetContainer, err := client.FetchAssetsByNFTUniqueID(idsToFetch, limit)
if err != nil {
return nil, err
}
err = o.processAssets(chainID, fetchedAssetContainer.Assets)
if err != nil {
return nil, err
}
assetContainer.NextCursor = fetchedAssetContainer.NextCursor
assetContainer.PreviousCursor = fetchedAssetContainer.PreviousCursor
}
assetContainer.Assets = o.getCachedAssets(chainID, uniqueIDs)
return assetContainer, nil
}
func (o *Manager) FetchCollectibleOwnersByContractAddress(chainID uint64, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) {
mainFunc := func() (any, error) {
return o.mainContractOwnershipProvider.FetchCollectibleOwnersByContractAddress(chainID, contractAddress)
}
var fallbackFunc func() (any, error) = nil
if o.fallbackContractOwnershipProvider != nil && o.fallbackContractOwnershipProvider.IsChainSupported(chainID) {
fallbackFunc = func() (any, error) {
return o.fallbackContractOwnershipProvider.FetchCollectibleOwnersByContractAddress(chainID, contractAddress)
}
}
owners, err := makeContractOwnershipCall(mainFunc, fallbackFunc)
if err != nil {
return nil, err
}
return owners.(*thirdparty.CollectibleContractOwnership), nil
}
func isMetadataEmpty(asset opensea.Asset) bool {
return asset.Name == "" &&
asset.Description == "" &&
asset.ImageURL == "" &&
asset.TokenURI == ""
}
func (o *Manager) fetchTokenURI(chainID uint64, id thirdparty.CollectibleUniqueID) (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()
tokenURI, err := caller.TokenURI(&bind.CallOpts{
Context: timeoutContext,
}, id.TokenID.Int)
if err != nil {
for _, errorPrefix := range noTokenURIErrorPrefixes {
if strings.HasPrefix(err.Error(), errorPrefix) {
// Contract doesn't support "TokenURI" method
return "", nil
}
}
return "", err
}
return tokenURI, err
}
func (o *Manager) processAssets(chainID uint64, assets []opensea.Asset) error {
o.nftCacheLock.Lock()
defer o.nftCacheLock.Unlock()
if _, ok := o.nftCache[chainID]; !ok {
o.nftCache[chainID] = make(map[string]opensea.Asset)
}
for idx, asset := range assets {
id := thirdparty.CollectibleUniqueID{
ContractAddress: common.HexToAddress(asset.Contract.Address),
TokenID: asset.TokenID,
}
if isMetadataEmpty(asset) {
if o.metadataProvider == nil {
return fmt.Errorf("CollectibleMetadataProvider not available")
}
tokenURI, err := o.fetchTokenURI(chainID, id)
if err != nil {
return err
}
assets[idx].TokenURI = tokenURI
canProvide, err := o.metadataProvider.CanProvideCollectibleMetadata(chainID, id, tokenURI)
if err != nil {
return err
}
if canProvide {
metadata, err := o.metadataProvider.FetchCollectibleMetadata(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.CollectionImageURL
assets[idx].ImageURL = metadata.ImageURL
}
}
}
// The NFT description field could be arbitrarily large, causing memory management issues upstream.
// Trim it to a reasonable length here.
if len(assets[idx].Description) > maxNFTDescriptionLength {
assets[idx].Description = assets[idx].Description[:maxNFTDescriptionLength]
}
o.nftCache[chainID][id.HashKey()] = assets[idx]
}
return nil
}
func (o *Manager) getIDsNotInCache(chainID uint64, uniqueIDs []thirdparty.CollectibleUniqueID) []thirdparty.CollectibleUniqueID {
o.nftCacheLock.RLock()
defer o.nftCacheLock.RUnlock()
idsToFetch := make([]thirdparty.CollectibleUniqueID, 0, len(uniqueIDs))
if _, ok := o.nftCache[chainID]; !ok {
idsToFetch = uniqueIDs
} else {
for _, id := range uniqueIDs {
if _, ok := o.nftCache[chainID][id.HashKey()]; !ok {
idsToFetch = append(idsToFetch, id)
}
}
}
return idsToFetch
}
func (o *Manager) getCachedAssets(chainID uint64, uniqueIDs []thirdparty.CollectibleUniqueID) []opensea.Asset {
o.nftCacheLock.RLock()
defer o.nftCacheLock.RUnlock()
assets := make([]opensea.Asset, 0, len(uniqueIDs))
if _, ok := o.nftCache[chainID]; ok {
for _, id := range uniqueIDs {
if asset, ok := o.nftCache[chainID][id.HashKey()]; ok {
assets = append(assets, asset)
}
}
}
return assets
}