feat: implement alchemy and infura collectible account ownership endpoints

This commit is contained in:
Dario Gabriel Lipicar 2023-08-01 20:16:57 -03:00 committed by dlipicar
parent 51d676bb08
commit c92a10b846
6 changed files with 540 additions and 30 deletions

View File

@ -60,7 +60,7 @@ func getNFTBaseURL(chainID walletCommon.ChainID, apiKey string) (string, error)
return "", err
}
return fmt.Sprintf("%s/nft/v2/%s", baseURL, getAPIKeySubpath(apiKey)), nil
return fmt.Sprintf("%s/nft/v3/%s", baseURL, getAPIKeySubpath(apiKey)), nil
}
type Client struct {
@ -89,37 +89,128 @@ func (o *Client) doQuery(url string) (*http.Response, error) {
}
func (o *Client) FetchCollectibleOwnersByContractAddress(chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) {
ownership := thirdparty.CollectibleContractOwnership{
ContractAddress: contractAddress,
Owners: make([]thirdparty.CollectibleOwner, 0),
}
queryParams := url.Values{
"contractAddress": {contractAddress.String()},
"withTokenBalances": {"true"},
}
url, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
if err != nil {
return nil, err
}
url = url + "/getOwnersForCollection?" + queryParams.Encode()
for {
url := fmt.Sprintf("%s/getOwnersForContract?%s", baseURL, queryParams.Encode())
resp, err := o.doQuery(url)
resp, err := o.doQuery(url)
if err != nil {
return nil, err
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var alchemyOwnership CollectibleContractOwnership
err = json.Unmarshal(body, &alchemyOwnership)
if err != nil {
return nil, err
}
ownership.Owners = append(ownership.Owners, alchemyCollectibleOwnersToCommon(alchemyOwnership.Owners)...)
if alchemyOwnership.PageKey == "" {
break
}
queryParams["pageKey"] = []string{alchemyOwnership.PageKey}
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var alchemyOwnership CollectibleContractOwnership
err = json.Unmarshal(body, &alchemyOwnership)
if err != nil {
return nil, err
}
return alchemyOwnershipToCommon(contractAddress, alchemyOwnership)
return &ownership, nil
}
func (o *Client) FetchAllAssetsByOwner(chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
queryParams := url.Values{}
return o.fetchOwnedAssets(chainID, owner, queryParams, cursor, limit)
}
func (o *Client) FetchAllAssetsByOwnerAndContractAddress(chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
queryParams := url.Values{}
for _, contractAddress := range contractAddresses {
queryParams.Add("contractAddresses", contractAddress.String())
}
return o.fetchOwnedAssets(chainID, owner, queryParams, cursor, limit)
}
func (o *Client) fetchOwnedAssets(chainID walletCommon.ChainID, owner common.Address, queryParams url.Values, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
assets := new(thirdparty.FullCollectibleDataContainer)
queryParams["owner"] = []string{owner.String()}
queryParams["withMetadata"] = []string{"true"}
if len(cursor) > 0 {
queryParams["pageKey"] = []string{cursor}
assets.PreviousCursor = cursor
}
baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
if err != nil {
return nil, err
}
for {
url := fmt.Sprintf("%s/getNFTsForOwner?%s", baseURL, 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
}
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
container := NFTList{}
err = json.Unmarshal(body, &container)
if err != nil {
return nil, err
}
assets.Items = append(assets.Items, container.toCommon(chainID)...)
assets.NextCursor = container.PageKey
if len(assets.NextCursor) == 0 {
break
}
queryParams["cursor"] = []string{assets.NextCursor}
if limit != thirdparty.FetchNoLimit && len(assets.Items) >= limit {
break
}
}
return assets, nil
}

View File

@ -1,9 +1,18 @@
package alchemy
import (
"encoding/json"
"strconv"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/services/wallet/bigint"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/thirdparty"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type TokenBalance struct {
@ -21,9 +30,9 @@ type CollectibleContractOwnership struct {
PageKey string `json:"pageKey"`
}
func alchemyOwnershipToCommon(contractAddress common.Address, alchemyOwnership CollectibleContractOwnership) (*thirdparty.CollectibleContractOwnership, error) {
owners := make([]thirdparty.CollectibleOwner, 0, len(alchemyOwnership.Owners))
for _, alchemyOwner := range alchemyOwnership.Owners {
func alchemyCollectibleOwnersToCommon(alchemyOwners []CollectibleOwner) []thirdparty.CollectibleOwner {
owners := make([]thirdparty.CollectibleOwner, 0, len(alchemyOwners))
for _, alchemyOwner := range alchemyOwners {
balances := make([]thirdparty.TokenBalance, 0, len(alchemyOwner.TokenBalances))
for _, alchemyBalance := range alchemyOwner.TokenBalances {
@ -39,11 +48,129 @@ func alchemyOwnershipToCommon(contractAddress common.Address, alchemyOwnership C
owners = append(owners, owner)
}
return owners
}
ownership := thirdparty.CollectibleContractOwnership{
ContractAddress: contractAddress,
Owners: owners,
type AttributeValue string
func (st *AttributeValue) UnmarshalJSON(b []byte) error {
var item interface{}
if err := json.Unmarshal(b, &item); err != nil {
return err
}
return &ownership, nil
switch v := item.(type) {
case float64:
*st = AttributeValue(strconv.FormatFloat(v, 'f', 2, 64))
case int:
*st = AttributeValue(strconv.Itoa(v))
case string:
*st = AttributeValue(v)
}
return nil
}
type Attribute struct {
TraitType string `json:"trait_type"`
Value AttributeValue `json:"value"`
}
type RawMetadata struct {
Attributes []Attribute `json:"attributes"`
}
type Raw struct {
RawMetadata RawMetadata `json:"metadata"`
}
type OpenSeaMetadata struct {
ImageURL string `json:"imageUrl"`
}
type Contract struct {
Address common.Address `json:"address"`
Name string `json:"name"`
Symbol string `json:"symbol"`
TokenType string `json:"tokenType"`
OpenSeaMetadata OpenSeaMetadata `json:"openSeaMetadata"`
}
type Image struct {
ImageURL string `json:"pngUrl"`
CachedAnimationURL string `json:"cachedUrl"`
OriginalAnimationURL string `json:"originalUrl"`
}
type Asset struct {
Contract Contract `json:"contract"`
TokenID *bigint.BigInt `json:"tokenId"`
Name string `json:"name"`
Description string `json:"description"`
Image Image `json:"image"`
Raw Raw `json:"raw"`
TokenURI string `json:"tokenUri"`
}
type NFTList struct {
OwnedNFTs []Asset `json:"ownedNfts"`
TotalCount *bigint.BigInt `json:"totalCount"`
PageKey string `json:"pageKey"`
}
func alchemyToCollectibleTraits(attributes []Attribute) []thirdparty.CollectibleTrait {
ret := make([]thirdparty.CollectibleTrait, 0, len(attributes))
caser := cases.Title(language.Und, cases.NoLower)
for _, orig := range attributes {
dest := thirdparty.CollectibleTrait{
TraitType: strings.Replace(orig.TraitType, "_", " ", 1),
Value: caser.String(string(orig.Value)),
}
ret = append(ret, dest)
}
return ret
}
func (c *Asset) toCollectionData(id thirdparty.ContractID) thirdparty.CollectionData {
ret := thirdparty.CollectionData{
ID: id,
Name: c.Contract.Name,
ImageURL: c.Contract.OpenSeaMetadata.ImageURL,
}
return ret
}
func (c *Asset) toCollectiblesData(id thirdparty.CollectibleUniqueID) thirdparty.CollectibleData {
return thirdparty.CollectibleData{
ID: id,
Name: c.Name,
Description: c.Description,
ImageURL: c.Image.ImageURL,
AnimationURL: c.Image.OriginalAnimationURL,
Traits: alchemyToCollectibleTraits(c.Raw.RawMetadata.Attributes),
}
}
func (c *Asset) toCommon(id thirdparty.CollectibleUniqueID) thirdparty.FullCollectibleData {
contractData := c.toCollectionData(id.ContractID)
return thirdparty.FullCollectibleData{
CollectibleData: c.toCollectiblesData(id),
CollectionData: &contractData,
}
}
func (l *NFTList) toCommon(chainID walletCommon.ChainID) []thirdparty.FullCollectibleData {
ret := make([]thirdparty.FullCollectibleData, 0, len(l.OwnedNFTs))
for _, asset := range l.OwnedNFTs {
id := thirdparty.CollectibleUniqueID{
ContractID: thirdparty.ContractID{
ChainID: chainID,
Address: asset.Contract.Address,
},
TokenID: asset.TokenID,
}
item := asset.toCommon(id)
ret = append(ret, item)
}
return ret
}

View File

@ -145,6 +145,12 @@ type CollectibleContractOwnership struct {
}
type CollectibleContractOwnershipProvider interface {
CollectibleProvider
FetchCollectibleOwnersByContractAddress(chainID w_common.ChainID, contractAddress common.Address) (*CollectibleContractOwnership, error)
IsChainSupported(chainID w_common.ChainID) bool
}
type CollectibleAccountOwnershipProvider interface {
CollectibleProvider
FetchAllAssetsByOwner(chainID w_common.ChainID, owner common.Address, cursor string, limit int) (*FullCollectibleDataContainer, error)
FetchAllAssetsByOwnerAndContractAddress(chainID w_common.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*FullCollectibleDataContainer, error)
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"io/ioutil"
"net/http"
"net/url"
"sync"
"time"
@ -56,8 +57,7 @@ func (o *Client) ID() string {
func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool {
switch uint64(chainID) {
case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet:
case walletCommon.EthereumGoerli, walletCommon.EthereumSepolia:
case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet, walletCommon.EthereumGoerli, walletCommon.EthereumSepolia:
return true
}
return false
@ -105,3 +105,151 @@ func (o *Client) FetchCollectibleOwnersByContractAddress(chainID walletCommon.Ch
return infuraOwnershipToCommon(contractAddress, ownersMap)
}
func (o *Client) FetchAllAssetsByOwner(chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
queryParams := url.Values{}
if len(cursor) > 0 {
queryParams["cursor"] = []string{cursor}
}
return o.fetchOwnedAssets(chainID, owner, queryParams, limit)
}
func (o *Client) FetchAllAssetsByOwnerAndContractAddress(chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
queryParams := url.Values{}
if len(cursor) > 0 {
queryParams["cursor"] = []string{cursor}
}
for _, contractAddress := range contractAddresses {
queryParams.Add("tokenAddress", contractAddress.String())
}
return o.fetchOwnedAssets(chainID, owner, queryParams, limit)
}
func (o *Client) fetchOwnedAssets(chainID walletCommon.ChainID, owner common.Address, queryParams url.Values, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
assets := new(thirdparty.FullCollectibleDataContainer)
if len(queryParams["cursor"]) > 0 {
assets.PreviousCursor = queryParams["cursor"][0]
}
for {
url := fmt.Sprintf("%s/networks/%d/accounts/%s/assets/nfts?%s", baseURL, chainID, owner.String(), 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
}
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
container := NFTList{}
err = json.Unmarshal(body, &container)
if err != nil {
return nil, err
}
assets.Items = append(assets.Items, container.toCommon()...)
assets.NextCursor = container.Cursor
if len(assets.NextCursor) == 0 {
break
}
queryParams["cursor"] = []string{assets.NextCursor}
if limit != thirdparty.FetchNoLimit && len(assets.Items) >= limit {
break
}
}
return assets, nil
}
func (o *Client) FetchAssetsByCollectibleUniqueID(uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
ret := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs))
for _, id := range uniqueIDs {
url := fmt.Sprintf("%s/networks/%d/nfts/%s/tokens/%s", baseURL, id.ContractID.ChainID, id.ContractID.Address.String(), id.TokenID.String())
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
}
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
asset := Asset{}
err = json.Unmarshal(body, &asset)
if err != nil {
return nil, err
}
item := asset.toCommon(id)
ret = append(ret, item)
}
return ret, nil
}
func (o *Client) FetchCollectionDataByContractID(contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) {
ret := make([]thirdparty.CollectionData, 0, len(contractIDs))
for _, id := range contractIDs {
url := fmt.Sprintf("%s/networks/%d/nfts/%s", baseURL, id.ChainID, id.Address.String())
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
}
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
contract := ContractMetadata{}
err = json.Unmarshal(body, &contract)
if err != nil {
return nil, err
}
item := contract.toCommon(id)
ret = append(ret, item)
}
return ret, nil
}

View File

@ -1,11 +1,35 @@
package infura
import (
"encoding/json"
"strconv"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/services/wallet/bigint"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/thirdparty"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func chainStringToChainID(chainString string) walletCommon.ChainID {
chainID := walletCommon.UnknownChainID
switch chainString {
case "ETHEREUM":
chainID = walletCommon.EthereumMainnet
case "ARBITRUM":
chainID = walletCommon.ArbitrumMainnet
case "GOERLI":
chainID = walletCommon.EthereumGoerli
case "SEPOLIA":
chainID = walletCommon.EthereumSepolia
}
return walletCommon.ChainID(chainID)
}
type CollectibleOwner struct {
ContractAddress common.Address `json:"tokenAddress"`
TokenID *bigint.BigInt `json:"tokenId"`
@ -45,3 +69,115 @@ func infuraOwnershipToCommon(contractAddress common.Address, ownersMap map[commo
return &ownership, nil
}
type AttributeValue string
func (st *AttributeValue) UnmarshalJSON(b []byte) error {
var item interface{}
if err := json.Unmarshal(b, &item); err != nil {
return err
}
switch v := item.(type) {
case float64:
*st = AttributeValue(strconv.FormatFloat(v, 'f', 2, 64))
case int:
*st = AttributeValue(strconv.Itoa(v))
case string:
*st = AttributeValue(v)
}
return nil
}
type Attribute struct {
TraitType string `json:"trait_type"`
Value AttributeValue `json:"value"`
}
type AssetMetadata struct {
Name string `json:"name"`
Description string `json:"description"`
Permalink string `json:"permalink"`
ImageURL string `json:"image"`
AnimationURL string `json:"animation_url"`
Attributes []Attribute `json:"attributes"`
}
type ContractMetadata struct {
ContractAddress string `json:"contract"`
Name string `json:"name"`
Symbol string `json:"symbol"`
TokenType string `json:"tokenType"`
}
type Asset struct {
ContractAddress common.Address `json:"contract"`
TokenID *bigint.BigInt `json:"tokenId"`
Metadata AssetMetadata `json:"metadata"`
}
type NFTList struct {
Total *bigint.BigInt `json:"total"`
PageNumber int `json:"pageNumber"`
PageSize int `json:"pageSize"`
Network string `json:"network"`
Account string `json:"account"`
Cursor string `json:"cursor"`
Assets []Asset `json:"assets"`
}
func (c *Asset) toCollectiblesData(id thirdparty.CollectibleUniqueID) thirdparty.CollectibleData {
return thirdparty.CollectibleData{
ID: id,
Name: c.Metadata.Name,
Description: c.Metadata.Description,
Permalink: c.Metadata.Permalink,
ImageURL: c.Metadata.ImageURL,
AnimationURL: c.Metadata.AnimationURL,
Traits: infuraToCollectibleTraits(c.Metadata.Attributes),
}
}
func (c *Asset) toCommon(id thirdparty.CollectibleUniqueID) thirdparty.FullCollectibleData {
return thirdparty.FullCollectibleData{
CollectibleData: c.toCollectiblesData(id),
CollectionData: nil,
}
}
func (l *NFTList) toCommon() []thirdparty.FullCollectibleData {
ret := make([]thirdparty.FullCollectibleData, 0, len(l.Assets))
for _, asset := range l.Assets {
id := thirdparty.CollectibleUniqueID{
ContractID: thirdparty.ContractID{
ChainID: chainStringToChainID(l.Network),
Address: asset.ContractAddress,
},
TokenID: asset.TokenID,
}
item := asset.toCommon(id)
ret = append(ret, item)
}
return ret
}
func infuraToCollectibleTraits(attributes []Attribute) []thirdparty.CollectibleTrait {
ret := make([]thirdparty.CollectibleTrait, 0, len(attributes))
caser := cases.Title(language.Und, cases.NoLower)
for _, orig := range attributes {
dest := thirdparty.CollectibleTrait{
TraitType: strings.Replace(orig.TraitType, "_", " ", 1),
Value: caser.String(string(orig.Value)),
}
ret = append(ret, dest)
}
return ret
}
func (c *ContractMetadata) toCommon(id thirdparty.ContractID) thirdparty.CollectionData {
return thirdparty.CollectionData{
ID: id,
Name: c.Name,
}
}

View File

@ -6,9 +6,11 @@ import (
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/services/wallet/bigint"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/thirdparty"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)