feat: add api to get list of owners from a given nft contract

Fixes #10290
This commit is contained in:
Dario Gabriel Lipicar 2023-04-17 08:42:01 -03:00 committed by dlipicar
parent 2ebe5b97e7
commit c8f0ceccc8
10 changed files with 476 additions and 18 deletions

View File

@ -136,7 +136,7 @@ func (b *StatusNode) initServices(config *params.NodeConfig, mediaServer *server
if len(openseaKey) == 0 {
openseaKey = OpenseaKeyFromEnv
}
walletService := b.walletService(accDB, accountsFeed, openseaKey)
walletService := b.walletService(accDB, accountsFeed, openseaKey, config.WalletConfig.AlchemyAPIKeys, config.WalletConfig.InfuraAPIKey, config.WalletConfig.InfuraAPIKeySecret)
services = append(services, walletService)
}
@ -471,7 +471,7 @@ func (b *StatusNode) appmetricsService() common.StatusService {
return b.appMetricsSrvc
}
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, alchemyAPIKeys map[uint64]string, infuraAPIKey string, infuraAPIKeySecret string) common.StatusService {
if b.walletSrvc == nil {
var extService *ext.Service
if b.WakuV2ExtService() != nil {
@ -480,7 +480,7 @@ func (b *StatusNode) walletService(accountsDB *accounts.Database, accountsFeed *
extService = b.WakuExtService().Service
}
b.walletSrvc = wallet.NewService(
b.appDB, accountsDB, b.rpcClient, accountsFeed, openseaAPIKey, b.gethAccountManager, b.transactor, b.config,
b.appDB, accountsDB, b.rpcClient, accountsFeed, openseaAPIKey, alchemyAPIKeys, infuraAPIKey, infuraAPIKeySecret, b.gethAccountManager, b.transactor, b.config,
b.ensService(),
b.stickersService(accountsDB),
extService,

View File

@ -515,8 +515,11 @@ type Network struct {
// WalletConfig extra configuration for wallet.Service.
type WalletConfig struct {
Enabled bool
OpenseaAPIKey string `json:"OpenseaAPIKey"`
Enabled bool
OpenseaAPIKey string `json:"OpenseaAPIKey"`
AlchemyAPIKeys map[uint64]string `json:"AlchemyAPIKeys"`
InfuraAPIKey string `json:"InfuraAPIKey"`
InfuraAPIKeySecret string `json:"InfuraAPIKeySecret"`
}
// LocalNotificationsConfig extra configuration for localnotifications.Service.

View File

@ -320,6 +320,11 @@ func (api *API) GetOpenseaAssetsByNFTUniqueID(ctx context.Context, chainID uint6
return api.s.collectiblesManager.FetchAssetsByNFTUniqueID(chainID, uniqueIDs, limit)
}
func (api *API) GetCollectibleOwnersByContractAddress(chainID uint64, contractAddress common.Address) (*thirdparty.NFTContractOwnership, error) {
log.Debug("call to GetCollectibleOwnersByContractAddress")
return api.s.collectiblesManager.FetchNFTOwnersByContractAddress(chainID, contractAddress)
}
func (api *API) AddEthereumChain(ctx context.Context, network params.Network) error {
log.Debug("call to AddEthereumChain")
return api.s.rpcClient.NetworkManager.Upsert(&network)

View File

@ -0,0 +1,33 @@
package bigint
import (
"math/big"
"reflect"
"github.com/ethereum/go-ethereum/common/hexutil"
)
// Unmarshals a u256 as a fixed-length hex string with 0x prefix and leading zeros
type HexBigInt struct {
*big.Int
}
const FixedLength = 32 // u256 -> 32 bytes
var (
hexBigIntT = reflect.TypeOf(HexBigInt{})
)
func (b *HexBigInt) UnmarshalJSON(input []byte) error {
var buf [FixedLength]byte
err := hexutil.UnmarshalFixedJSON(hexBigIntT, input, buf[:])
if err != nil {
return err
}
z := new(big.Int)
z.SetBytes(buf[:])
b.Int = z
return nil
}

View File

@ -0,0 +1,25 @@
package bigint
import (
"encoding/json"
"math/big"
"testing"
"github.com/stretchr/testify/require"
)
func TestMarshalUnmarshal(t *testing.T) {
inputString := "0x09abc5177d51c36ef4c6a36197d023b60d8fec0100000000000001000000000a"
inputInt := new(big.Int)
inputInt.SetString(inputString[2:], 16)
inputBytes, err := json.Marshal(inputString)
require.NoError(t, err)
u := new(HexBigInt)
err = u.UnmarshalJSON(inputBytes)
require.NoError(t, err)
require.Equal(t, inputInt, u.Int)
}

View File

@ -6,6 +6,8 @@ import (
"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"
@ -17,22 +19,64 @@ import (
const requestTimeout = 5 * time.Second
const hystrixContractOwnershipClientName = "contractOwnershipClient"
type Manager struct {
rpcClient *rpc.Client
metadataProvider thirdparty.NFTMetadataProvider
openseaAPIKey string
nftCache map[uint64]map[string]opensea.Asset
nftCacheLock sync.RWMutex
walletFeed *event.Feed
rpcClient *rpc.Client
mainContractOwnershipProvider thirdparty.NFTContractOwnershipProvider
fallbackContractOwnershipProvider thirdparty.NFTContractOwnershipProvider
metadataProvider thirdparty.NFTMetadataProvider
openseaAPIKey string
nftCache map[uint64]map[string]opensea.Asset
nftCacheLock sync.RWMutex
walletFeed *event.Feed
}
func NewManager(rpcClient *rpc.Client, metadataProvider thirdparty.NFTMetadataProvider, openseaAPIKey string, walletFeed *event.Feed) *Manager {
func NewManager(rpcClient *rpc.Client, mainContractOwnershipProvider thirdparty.NFTContractOwnershipProvider, fallbackContractOwnershipProvider thirdparty.NFTContractOwnershipProvider, metadataProvider thirdparty.NFTMetadataProvider, openseaAPIKey string, walletFeed *event.Feed) *Manager {
hystrix.ConfigureCommand(hystrixContractOwnershipClientName, hystrix.CommandConfig{
Timeout: 10000,
MaxConcurrentRequests: 100,
SleepWindow: 300000,
ErrorPercentThreshold: 25,
})
return &Manager{
rpcClient: rpcClient,
metadataProvider: metadataProvider,
openseaAPIKey: openseaAPIKey,
nftCache: make(map[uint64]map[string]opensea.Asset),
walletFeed: walletFeed,
rpcClient: rpcClient,
mainContractOwnershipProvider: mainContractOwnershipProvider,
fallbackContractOwnershipProvider: fallbackContractOwnershipProvider,
metadataProvider: metadataProvider,
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
}
}
@ -130,6 +174,24 @@ func (o *Manager) FetchAssetsByNFTUniqueID(chainID uint64, uniqueIDs []thirdpart
return assetContainer, nil
}
func (o *Manager) FetchNFTOwnersByContractAddress(chainID uint64, contractAddress common.Address) (*thirdparty.NFTContractOwnership, error) {
mainFunc := func() (any, error) {
return o.mainContractOwnershipProvider.FetchNFTOwnersByContractAddress(chainID, contractAddress)
}
var fallbackFunc func() (any, error) = nil
if o.fallbackContractOwnershipProvider != nil && o.fallbackContractOwnershipProvider.IsChainSupported(chainID) {
fallbackFunc = func() (any, error) {
return o.fallbackContractOwnershipProvider.FetchNFTOwnersByContractAddress(chainID, contractAddress)
}
}
owners, err := makeContractOwnershipCall(mainFunc, fallbackFunc)
if err != nil {
return nil, err
}
return owners.(*thirdparty.NFTContractOwnership), nil
}
func isMetadataEmpty(asset opensea.Asset) bool {
return asset.Name == "" &&
asset.Description == "" &&

View File

@ -22,8 +22,10 @@ import (
"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/alchemy"
"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/infura"
"github.com/status-im/status-go/services/wallet/token"
"github.com/status-im/status-go/services/wallet/transfer"
"github.com/status-im/status-go/services/wallet/walletevent"
@ -41,6 +43,9 @@ func NewService(
rpcClient *rpc.Client,
accountFeed *event.Feed,
openseaAPIKey string,
alchemyAPIKeys map[uint64]string,
infuraAPIKey string,
infuraAPIKeySecret string,
gethManager *account.GethManager,
transactor *transactions.Transactor,
config *params.NodeConfig,
@ -92,7 +97,10 @@ 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, walletFeed)
alchemyClient := alchemy.NewClient(alchemyAPIKeys)
infuraClient := infura.NewClient(infuraAPIKey, infuraAPIKeySecret)
collectiblesManager := collectibles.NewManager(rpcClient, alchemyClient, infuraClient, nftMetadataProvider, openseaAPIKey, walletFeed)
return &Service{
db: db,
accountsDB: accountsDB,

View File

@ -0,0 +1,161 @@
package alchemy
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"sync"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/services/wallet/bigint"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
func getBaseURL(chainID uint64) (string, error) {
switch chainID {
case 1:
return "https://eth-mainnet.g.alchemy.com", nil
case 5:
return "https://eth-goerli.g.alchemy.com", nil
case 11155111:
return "https://eth-sepolia.g.alchemy.com", nil
case 10:
return "https://opt-mainnet.g.alchemy.com", nil
case 420:
return "https://opt-goerli.g.alchemy.com", nil
case 42161:
return "https://arb-mainnet.g.alchemy.com", nil
case 421613:
return "https://arb-goerli.g.alchemy.com", nil
}
return "", fmt.Errorf("chainID not supported: %d", chainID)
}
func getAPIKeySubpath(apiKey string) string {
if apiKey == "" {
return "demo"
}
return apiKey
}
func getNFTBaseURL(chainID uint64, apiKey string) (string, error) {
baseURL, err := getBaseURL(chainID)
if err != nil {
return "", err
}
return fmt.Sprintf("%s/nft/v2/%s", baseURL, getAPIKeySubpath(apiKey)), nil
}
type TokenBalance struct {
TokenID *bigint.HexBigInt `json:"tokenId"`
Balance *bigint.BigInt `json:"balance"`
}
type NFTOwner struct {
OwnerAddress common.Address `json:"ownerAddress"`
TokenBalances []TokenBalance `json:"tokenBalances"`
}
type NFTContractOwnership struct {
Owners []NFTOwner `json:"ownerAddresses"`
PageKey string `json:"pageKey"`
}
type Client struct {
thirdparty.NFTContractOwnershipProvider
client *http.Client
apiKeys map[uint64]string
IsConnected bool
IsConnectedLock sync.RWMutex
}
func NewClient(apiKeys map[uint64]string) *Client {
return &Client{
client: &http.Client{Timeout: time.Minute},
apiKeys: apiKeys,
}
}
func (o *Client) doQuery(url string) (*http.Response, error) {
resp, err := o.client.Get(url)
if err != nil {
return nil, err
}
return resp, nil
}
func (o *Client) IsChainSupported(chainID uint64) bool {
_, err := getBaseURL(chainID)
return err == nil
}
func alchemyOwnershipToCommon(contractAddress common.Address, alchemyOwnership NFTContractOwnership) (*thirdparty.NFTContractOwnership, error) {
owners := make([]thirdparty.NFTOwner, 0, len(alchemyOwnership.Owners))
for _, alchemyOwner := range alchemyOwnership.Owners {
balances := make([]thirdparty.TokenBalance, 0, len(alchemyOwner.TokenBalances))
for _, alchemyBalance := range alchemyOwner.TokenBalances {
balances = append(balances, thirdparty.TokenBalance{
TokenID: &bigint.BigInt{Int: alchemyBalance.TokenID.Int},
Balance: alchemyBalance.Balance,
})
}
owner := thirdparty.NFTOwner{
OwnerAddress: alchemyOwner.OwnerAddress,
TokenBalances: balances,
}
owners = append(owners, owner)
}
ownership := thirdparty.NFTContractOwnership{
ContractAddress: contractAddress,
Owners: owners,
}
return &ownership, nil
}
func (o *Client) FetchNFTOwnersByContractAddress(chainID uint64, contractAddress common.Address) (*thirdparty.NFTContractOwnership, error) {
queryParams := url.Values{
"contractAddress": {contractAddress.String()},
"withTokenBalances": {"true"},
}
url, err := getNFTBaseURL(chainID, o.apiKeys[chainID])
if err != nil {
return nil, err
}
url = url + "/getOwnersForCollection?" + queryParams.Encode()
resp, err := o.doQuery(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var alchemyOwnership NFTContractOwnership
err = json.Unmarshal(body, &alchemyOwnership)
if err != nil {
return nil, err
}
return alchemyOwnershipToCommon(contractAddress, alchemyOwnership)
}

View File

@ -0,0 +1,141 @@
package infura
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"sync"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/services/wallet/bigint"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
const baseURL = "https://nft.api.infura.io"
type NFTOwner struct {
ContractAddress common.Address `json:"tokenAddress"`
TokenID *bigint.BigInt `json:"tokenId"`
Amount *bigint.BigInt `json:"amount"`
OwnerAddress common.Address `json:"ownerOf"`
}
type NFTContractOwnership struct {
Owners []NFTOwner `json:"owners"`
Network string `json:"network"`
Cursor string `json:"cursor"`
}
type Client struct {
thirdparty.NFTContractOwnershipProvider
client *http.Client
apiKey string
apiKeySecret string
IsConnected bool
IsConnectedLock sync.RWMutex
}
func NewClient(apiKey string, apiKeySecret string) *Client {
return &Client{
client: &http.Client{Timeout: time.Minute},
apiKey: apiKey,
}
}
func (o *Client) doQuery(url string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(o.apiKey, o.apiKeySecret)
resp, err := o.client.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}
func (o *Client) IsChainSupported(chainID uint64) bool {
switch chainID {
case 1, 5, 42161, 11155111:
return true
}
return false
}
func infuraOwnershipToCommon(contractAddress common.Address, ownersMap map[common.Address][]NFTOwner) (*thirdparty.NFTContractOwnership, error) {
owners := make([]thirdparty.NFTOwner, 0, len(ownersMap))
for ownerAddress, ownerTokens := range ownersMap {
tokenBalances := make([]thirdparty.TokenBalance, 0, len(ownerTokens))
for _, token := range ownerTokens {
tokenBalances = append(tokenBalances, thirdparty.TokenBalance{
TokenID: token.TokenID,
Balance: token.Amount,
})
}
owners = append(owners, thirdparty.NFTOwner{
OwnerAddress: ownerAddress,
TokenBalances: tokenBalances,
})
}
ownership := thirdparty.NFTContractOwnership{
ContractAddress: contractAddress,
Owners: owners,
}
return &ownership, nil
}
func (o *Client) FetchNFTOwnersByContractAddress(chainID uint64, contractAddress common.Address) (*thirdparty.NFTContractOwnership, error) {
cursor := ""
ownersMap := make(map[common.Address][]NFTOwner)
for {
url := fmt.Sprintf("%s/networks/%d/nfts/%s/owners", baseURL, chainID, contractAddress.String())
if cursor != "" {
url = url + "?cursor=" + cursor
}
resp, err := o.doQuery(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var infuraOwnership NFTContractOwnership
err = json.Unmarshal(body, &infuraOwnership)
if err != nil {
return nil, err
}
for _, infuraOwner := range infuraOwnership.Owners {
ownersMap[infuraOwner.OwnerAddress] = append(ownersMap[infuraOwner.OwnerAddress], infuraOwner)
}
cursor = infuraOwnership.Cursor
if cursor == "" {
break
}
}
return infuraOwnershipToCommon(contractAddress, ownersMap)
}

View File

@ -61,3 +61,23 @@ type NFTMetadataProvider interface {
CanProvideNFTMetadata(chainID uint64, id NFTUniqueID, tokenURI string) (bool, error)
FetchNFTMetadata(chainID uint64, id NFTUniqueID, tokenURI string) (*NFTMetadata, error)
}
type TokenBalance struct {
TokenID *bigint.BigInt `json:"tokenId"`
Balance *bigint.BigInt `json:"balance"`
}
type NFTOwner struct {
OwnerAddress common.Address `json:"ownerAddress"`
TokenBalances []TokenBalance `json:"tokenBalances"`
}
type NFTContractOwnership struct {
ContractAddress common.Address `json:"contractAddress"`
Owners []NFTOwner `json:"owners"`
}
type NFTContractOwnershipProvider interface {
FetchNFTOwnersByContractAddress(chainID uint64, contractAddress common.Address) (*NFTContractOwnership, error)
IsChainSupported(chainID uint64) bool
}