chore: reorganized collectibles clients code

This commit is contained in:
Dario Gabriel Lipicar 2023-07-31 20:34:53 -03:00 committed by dlipicar
parent c2ac108556
commit 51d676bb08
10 changed files with 430 additions and 395 deletions

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
@ -12,6 +13,7 @@ import (
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
"github.com/status-im/status-go/contracts/collectibles"
"github.com/status-im/status-go/rpc"
"github.com/status-im/status-go/services/wallet/bigint"
@ -20,9 +22,6 @@ import (
"github.com/status-im/status-go/services/wallet/thirdparty/opensea"
)
const FetchNoLimit = 0
const FetchFromStartCursor = ""
const requestTimeout = 5 * time.Second
const hystrixContractOwnershipClientName = "contractOwnershipClient"
@ -40,6 +39,7 @@ type Manager struct {
fallbackContractOwnershipProvider thirdparty.CollectibleContractOwnershipProvider
metadataProvider thirdparty.CollectibleMetadataProvider
opensea *opensea.Client
httpClient *http.Client
collectiblesDataCache map[string]thirdparty.CollectibleData
collectiblesDataCacheLock sync.RWMutex
collectionsDataCache map[string]thirdparty.CollectionData
@ -59,8 +59,11 @@ func NewManager(rpcClient *rpc.Client, mainContractOwnershipProvider thirdparty.
mainContractOwnershipProvider: mainContractOwnershipProvider,
fallbackContractOwnershipProvider: fallbackContractOwnershipProvider,
opensea: opensea,
collectiblesDataCache: make(map[string]thirdparty.CollectibleData),
collectionsDataCache: make(map[string]thirdparty.CollectionData),
httpClient: &http.Client{
Timeout: requestTimeout,
},
collectiblesDataCache: make(map[string]thirdparty.CollectibleData),
collectionsDataCache: make(map[string]thirdparty.CollectionData),
}
}
@ -93,6 +96,25 @@ func makeContractOwnershipCall(main func() (any, error), fallback func() (any, e
}
}
func (o *Manager) doContentTypeRequest(url string) (string, error) {
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
return "", err
}
resp, err := o.httpClient.Do(req)
if err != nil {
return "", err
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Error("failed to close head request body", "err", err)
}
}()
return resp.Header.Get("Content-Type"), nil
}
// Used to break circular dependency, call once as soon as possible after initialization
func (o *Manager) SetMetadataProvider(metadataProvider thirdparty.CollectibleMetadataProvider) {
o.metadataProvider = metadataProvider
@ -129,7 +151,7 @@ func (o *Manager) FetchBalancesByOwnerAndContractAddress(chainID walletCommon.Ch
}
// Try with more direct endpoint first (OpenSea)
assetsContainer, err := o.FetchAllAssetsByOwnerAndContractAddress(chainID, ownerAddress, contractAddresses, FetchFromStartCursor, FetchNoLimit)
assetsContainer, err := o.FetchAllAssetsByOwnerAndContractAddress(chainID, ownerAddress, contractAddresses, thirdparty.FetchFromStartCursor, thirdparty.FetchNoLimit)
if err == thirdparty.ErrChainIDNotSupported {
// Use contract ownership providers
for _, contractAddress := range contractAddresses {
@ -279,6 +301,7 @@ func (o *Manager) processFullCollectibleData(assets []thirdparty.FullCollectible
for idx, asset := range assets {
id := asset.CollectibleData.ID
// Get Metadata from alternate source if empty
if isMetadataEmpty(asset.CollectibleData) {
if o.metadataProvider == nil {
return fmt.Errorf("CollectibleMetadataProvider not available")
@ -309,6 +332,15 @@ func (o *Manager) processFullCollectibleData(assets []thirdparty.FullCollectible
}
}
// Get Animation MediaType
if len(assets[idx].CollectibleData.AnimationURL) > 0 {
contentType, err := o.doContentTypeRequest(assets[idx].CollectibleData.AnimationURL)
if err != nil {
assets[idx].CollectibleData.AnimationURL = ""
}
assets[idx].CollectibleData.AnimationMediaType = contentType
}
o.setCacheCollectibleData(assets[idx].CollectibleData)
if assets[idx].CollectionData != nil {
o.setCacheCollectionData(*assets[idx].CollectionData)

View File

@ -136,7 +136,7 @@ func (c *loadOwnedCollectiblesCommand) Run(parent context.Context) (err error) {
log.Debug("start loadOwnedCollectiblesCommand", "chain", c.chainID, "account", c.account)
pageNr := 0
cursor := FetchFromStartCursor
cursor := thirdparty.FetchFromStartCursor
c.triggerEvent(EventCollectiblesOwnershipUpdateStarted, c.chainID, c.account, "")
// Fetch collectibles in chunks
@ -161,7 +161,7 @@ func (c *loadOwnedCollectiblesCommand) Run(parent context.Context) (err error) {
pageNr++
cursor = partialOwnership.NextCursor
if cursor == FetchFromStartCursor {
if cursor == thirdparty.FetchFromStartCursor {
err = c.ownershipDB.Update(c.chainID, c.account, c.partialOwnership)
if err != nil {
log.Error("failed updating ownershipDB in loadOwnedCollectiblesCommand", "chain", c.chainID, "account", c.account, "error", err)

View File

@ -10,11 +10,12 @@ import (
"time"
"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"
)
const AlchemyID = "alchemy"
func getBaseURL(chainID walletCommon.ChainID) (string, error) {
switch uint64(chainID) {
case walletCommon.EthereumMainnet:
@ -37,7 +38,7 @@ func getBaseURL(chainID walletCommon.ChainID) (string, error) {
}
func (o *Client) ID() string {
return "alchemy"
return AlchemyID
}
func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool {
@ -62,21 +63,6 @@ func getNFTBaseURL(chainID walletCommon.ChainID, apiKey string) (string, error)
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 CollectibleOwner struct {
OwnerAddress common.Address `json:"ownerAddress"`
TokenBalances []TokenBalance `json:"tokenBalances"`
}
type CollectibleContractOwnership struct {
Owners []CollectibleOwner `json:"ownerAddresses"`
PageKey string `json:"pageKey"`
}
type Client struct {
thirdparty.CollectibleContractOwnershipProvider
client *http.Client
@ -102,33 +88,6 @@ func (o *Client) doQuery(url string) (*http.Response, error) {
return resp, nil
}
func alchemyOwnershipToCommon(contractAddress common.Address, alchemyOwnership CollectibleContractOwnership) (*thirdparty.CollectibleContractOwnership, error) {
owners := make([]thirdparty.CollectibleOwner, 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.CollectibleOwner{
OwnerAddress: alchemyOwner.OwnerAddress,
TokenBalances: balances,
}
owners = append(owners, owner)
}
ownership := thirdparty.CollectibleContractOwnership{
ContractAddress: contractAddress,
Owners: owners,
}
return &ownership, nil
}
func (o *Client) FetchCollectibleOwnersByContractAddress(chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) {
queryParams := url.Values{
"contractAddress": {contractAddress.String()},

View File

@ -0,0 +1,49 @@
package alchemy
import (
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/services/wallet/bigint"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
type TokenBalance struct {
TokenID *bigint.HexBigInt `json:"tokenId"`
Balance *bigint.BigInt `json:"balance"`
}
type CollectibleOwner struct {
OwnerAddress common.Address `json:"ownerAddress"`
TokenBalances []TokenBalance `json:"tokenBalances"`
}
type CollectibleContractOwnership struct {
Owners []CollectibleOwner `json:"ownerAddresses"`
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 {
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.CollectibleOwner{
OwnerAddress: alchemyOwner.OwnerAddress,
TokenBalances: balances,
}
owners = append(owners, owner)
}
ownership := thirdparty.CollectibleContractOwnership{
ContractAddress: contractAddress,
Owners: owners,
}
return &ownership, nil
}

View File

@ -13,6 +13,9 @@ var (
ErrChainIDNotSupported = errors.New("chainID not supported")
)
const FetchNoLimit = 0
const FetchFromStartCursor = ""
type CollectibleProvider interface {
ID() string
IsChainSupported(chainID w_common.ChainID) bool

View File

@ -9,25 +9,12 @@ import (
"time"
"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"
)
const baseURL = "https://nft.api.infura.io"
type CollectibleOwner struct {
ContractAddress common.Address `json:"tokenAddress"`
TokenID *bigint.BigInt `json:"tokenId"`
Amount *bigint.BigInt `json:"amount"`
OwnerAddress common.Address `json:"ownerOf"`
}
type CollectibleContractOwnership struct {
Owners []CollectibleOwner `json:"owners"`
Network string `json:"network"`
Cursor string `json:"cursor"`
}
const InfuraID = "infura"
type Client struct {
thirdparty.CollectibleContractOwnershipProvider
@ -64,44 +51,18 @@ func (o *Client) doQuery(url string) (*http.Response, error) {
}
func (o *Client) ID() string {
return "infura"
return InfuraID
}
func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool {
switch uint64(chainID) {
case walletCommon.EthereumMainnet, walletCommon.EthereumGoerli, walletCommon.EthereumSepolia, walletCommon.ArbitrumMainnet:
case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet:
case walletCommon.EthereumGoerli, walletCommon.EthereumSepolia:
return true
}
return false
}
func infuraOwnershipToCommon(contractAddress common.Address, ownersMap map[common.Address][]CollectibleOwner) (*thirdparty.CollectibleContractOwnership, error) {
owners := make([]thirdparty.CollectibleOwner, 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.CollectibleOwner{
OwnerAddress: ownerAddress,
TokenBalances: tokenBalances,
})
}
ownership := thirdparty.CollectibleContractOwnership{
ContractAddress: contractAddress,
Owners: owners,
}
return &ownership, nil
}
func (o *Client) FetchCollectibleOwnersByContractAddress(chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) {
cursor := ""
ownersMap := make(map[common.Address][]CollectibleOwner)

View File

@ -0,0 +1,47 @@
package infura
import (
"github.com/ethereum/go-ethereum/common"
"github.com/status-im/status-go/services/wallet/bigint"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
type CollectibleOwner struct {
ContractAddress common.Address `json:"tokenAddress"`
TokenID *bigint.BigInt `json:"tokenId"`
Amount *bigint.BigInt `json:"amount"`
OwnerAddress common.Address `json:"ownerOf"`
}
type CollectibleContractOwnership struct {
Owners []CollectibleOwner `json:"owners"`
Network string `json:"network"`
Cursor string `json:"cursor"`
}
func infuraOwnershipToCommon(contractAddress common.Address, ownersMap map[common.Address][]CollectibleOwner) (*thirdparty.CollectibleContractOwnership, error) {
owners := make([]thirdparty.CollectibleOwner, 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.CollectibleOwner{
OwnerAddress: ownerAddress,
TokenBalances: tokenBalances,
})
}
ownership := thirdparty.CollectibleContractOwnership{
ContractAddress: contractAddress,
Owners: owners,
}
return &ownership, nil
}

View File

@ -3,22 +3,14 @@ package opensea
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/event"
"github.com/ethereum/go-ethereum/log"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"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/connection"
"github.com/status-im/status-go/services/wallet/thirdparty"
@ -26,9 +18,11 @@ import (
)
const (
EventCollectibleStatusChanged walletevent.EventType = "wallet-collectible-status-changed"
EventCollectibleStatusChanged walletevent.EventType = "wallet-collectible-opensea-v1-status-changed"
)
const OpenseaV1ID = "openseaV1"
const AssetLimit = 200
const CollectionLimit = 300
@ -38,8 +32,6 @@ const GetRequestWaitTime = 300 * time.Millisecond
const ChainIDRequiringAPIKey = walletCommon.EthereumMainnet
const FetchNoLimit = 0
type urlGetter func(walletCommon.ChainID, string) (string, error)
func getBaseURL(chainID walletCommon.ChainID) (string, error) {
@ -55,7 +47,7 @@ func getBaseURL(chainID walletCommon.ChainID) (string, error) {
}
func (o *Client) ID() string {
return "opensea"
return OpenseaV1ID
}
func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool {
@ -72,279 +64,6 @@ func getURL(chainID walletCommon.ChainID, path string) (string, error) {
return fmt.Sprintf("%s/%s", baseURL, path), nil
}
func chainStringToChainID(chainString string) walletCommon.ChainID {
chainID := walletCommon.UnknownChainID
switch chainString {
case "ethereum":
chainID = walletCommon.EthereumMainnet
case "arbitrum":
chainID = walletCommon.ArbitrumMainnet
case "optimism":
chainID = walletCommon.OptimismMainnet
case "goerli":
chainID = walletCommon.EthereumGoerli
case "arbitrum_goerli":
chainID = walletCommon.ArbitrumGoerli
case "optimism_goerli":
chainID = walletCommon.OptimismGoerli
}
return walletCommon.ChainID(chainID)
}
type TraitValue string
func (st *TraitValue) UnmarshalJSON(b []byte) error {
var item interface{}
if err := json.Unmarshal(b, &item); err != nil {
return err
}
switch v := item.(type) {
case float64:
*st = TraitValue(strconv.FormatFloat(v, 'f', 2, 64))
case int:
*st = TraitValue(strconv.Itoa(v))
case string:
*st = TraitValue(v)
}
return nil
}
type AssetContainer struct {
Assets []Asset `json:"assets"`
NextCursor string `json:"next"`
PreviousCursor string `json:"previous"`
}
type Contract struct {
Address string `json:"address"`
ChainIdentifier string `json:"chain_identifier"`
}
type Trait struct {
TraitType string `json:"trait_type"`
Value TraitValue `json:"value"`
DisplayType string `json:"display_type"`
MaxValue string `json:"max_value"`
}
type PaymentToken struct {
ID int `json:"id"`
Symbol string `json:"symbol"`
Address string `json:"address"`
ImageURL string `json:"image_url"`
Name string `json:"name"`
Decimals int `json:"decimals"`
EthPrice string `json:"eth_price"`
UsdPrice string `json:"usd_price"`
}
type LastSale struct {
PaymentToken PaymentToken `json:"payment_token"`
}
type SellOrder struct {
CurrentPrice string `json:"current_price"`
}
type Asset struct {
ID int `json:"id"`
TokenID *bigint.BigInt `json:"token_id"`
Name string `json:"name"`
Description string `json:"description"`
Permalink string `json:"permalink"`
ImageThumbnailURL string `json:"image_thumbnail_url"`
ImageURL string `json:"image_url"`
AnimationURL string `json:"animation_url"`
AnimationMediaType string `json:"animation_media_type"`
Contract Contract `json:"asset_contract"`
Collection Collection `json:"collection"`
Traits []Trait `json:"traits"`
LastSale LastSale `json:"last_sale"`
SellOrders []SellOrder `json:"sell_orders"`
BackgroundColor string `json:"background_color"`
TokenURI string `json:"token_metadata"`
}
type CollectionTrait struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
}
type Collection struct {
Name string `json:"name"`
Slug string `json:"slug"`
ImageURL string `json:"image_url"`
Traits map[string]CollectionTrait `json:"traits"`
}
type OwnedCollection struct {
Collection
OwnedAssetCount *bigint.BigInt `json:"owned_asset_count"`
}
func (c *Asset) id() thirdparty.CollectibleUniqueID {
return thirdparty.CollectibleUniqueID{
ContractID: thirdparty.ContractID{
ChainID: chainStringToChainID(c.Contract.ChainIdentifier),
Address: common.HexToAddress(c.Contract.Address),
},
TokenID: c.TokenID,
}
}
func openseaToCollectibleTraits(traits []Trait) []thirdparty.CollectibleTrait {
ret := make([]thirdparty.CollectibleTrait, 0, len(traits))
caser := cases.Title(language.Und, cases.NoLower)
for _, orig := range traits {
dest := thirdparty.CollectibleTrait{
TraitType: strings.Replace(orig.TraitType, "_", " ", 1),
Value: caser.String(string(orig.Value)),
DisplayType: orig.DisplayType,
MaxValue: orig.MaxValue,
}
ret = append(ret, dest)
}
return ret
}
func (c *Asset) toCollectionData() thirdparty.CollectionData {
ret := thirdparty.CollectionData{
ID: c.id().ContractID,
Name: c.Collection.Name,
Slug: c.Collection.Slug,
ImageURL: c.Collection.ImageURL,
Traits: make(map[string]thirdparty.CollectionTrait),
}
for traitType, trait := range c.Collection.Traits {
ret.Traits[traitType] = thirdparty.CollectionTrait{
Min: trait.Min,
Max: trait.Max,
}
}
return ret
}
func (c *Asset) toCollectiblesData() thirdparty.CollectibleData {
return thirdparty.CollectibleData{
ID: c.id(),
Name: c.Name,
Description: c.Description,
Permalink: c.Permalink,
ImageURL: c.ImageURL,
AnimationURL: c.AnimationURL,
AnimationMediaType: c.AnimationMediaType,
Traits: openseaToCollectibleTraits(c.Traits),
BackgroundColor: c.BackgroundColor,
TokenURI: c.TokenURI,
}
}
func (c *Asset) toCommon() thirdparty.FullCollectibleData {
collection := c.toCollectionData()
return thirdparty.FullCollectibleData{
CollectibleData: c.toCollectiblesData(),
CollectionData: &collection,
}
}
type HTTPClient struct {
client *http.Client
getRequestLock sync.RWMutex
}
func newHTTPClient() *HTTPClient {
return &HTTPClient{
client: &http.Client{
Timeout: RequestTimeout,
},
}
}
func (o *HTTPClient) doGetRequest(url string, apiKey string) ([]byte, error) {
// Ensure only one thread makes a request at a time
o.getRequestLock.Lock()
defer o.getRequestLock.Unlock()
retryCount := 0
statusCode := http.StatusOK
// Try to do the request without an apiKey first
tmpAPIKey := ""
for {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:96.0) Gecko/20100101 Firefox/96.0")
if len(tmpAPIKey) > 0 {
req.Header.Set("X-API-KEY", tmpAPIKey)
}
resp, err := o.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Error("failed to close opensea request body", "err", err)
}
}()
statusCode = resp.StatusCode
switch resp.StatusCode {
case http.StatusOK:
body, err := ioutil.ReadAll(resp.Body)
return body, err
case http.StatusTooManyRequests:
if retryCount < GetRequestRetryMaxCount {
// sleep and retry
time.Sleep(GetRequestWaitTime)
retryCount++
continue
}
// break and error
case http.StatusForbidden:
// Request requires an apiKey, set it and retry
if tmpAPIKey == "" && apiKey != "" {
tmpAPIKey = apiKey
// sleep and retry
time.Sleep(GetRequestWaitTime)
continue
}
// break and error
default:
// break and error
}
break
}
return nil, fmt.Errorf("unsuccessful request: %d %s", statusCode, http.StatusText(statusCode))
}
func (o *HTTPClient) doContentTypeRequest(url string) (string, error) {
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
return "", err
}
resp, err := o.client.Do(req)
if err != nil {
return "", err
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Error("failed to close head request body", "err", err)
}
}()
return resp.Header.Get("Content-Type"), nil
}
type Client struct {
client *HTTPClient
apiKey string
@ -453,7 +172,7 @@ func (o *Client) FetchAssetsByCollectibleUniqueID(uniqueIDs []thirdparty.Collect
queryParams.Add("asset_contract_addresses", id.ContractID.Address.String())
}
data, err := o.fetchAssets(chainID, queryParams, FetchNoLimit)
data, err := o.fetchAssets(chainID, queryParams, thirdparty.FetchNoLimit)
if err != nil {
return nil, err
}
@ -472,7 +191,7 @@ func (o *Client) fetchAssets(chainID walletCommon.ChainID, queryParams url.Value
}
tmpLimit := AssetLimit
if limit > FetchNoLimit && limit < tmpLimit {
if limit > thirdparty.FetchNoLimit && limit < tmpLimit {
tmpLimit = limit
}
@ -503,12 +222,6 @@ func (o *Client) fetchAssets(chainID walletCommon.ChainID, queryParams url.Value
}
for _, asset := range container.Assets {
if len(asset.AnimationURL) > 0 {
asset.AnimationMediaType, err = o.client.doContentTypeRequest(asset.AnimationURL)
if err != nil {
asset.AnimationURL = ""
}
}
assets.Items = append(assets.Items, asset.toCommon())
}
assets.NextCursor = container.NextCursor
@ -519,7 +232,7 @@ func (o *Client) fetchAssets(chainID walletCommon.ChainID, queryParams url.Value
queryParams["cursor"] = []string{assets.NextCursor}
if limit > FetchNoLimit && len(assets.Items) >= limit {
if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit {
break
}
}
@ -586,12 +299,6 @@ func (o *Client) fetchOpenseaAssets(chainID walletCommon.ChainID, queryParams ur
asset.Traits[i].TraitType = strings.Replace(asset.Traits[i].TraitType, "_", " ", 1)
asset.Traits[i].Value = TraitValue(strings.Title(string(asset.Traits[i].Value)))
}
if len(asset.AnimationURL) > 0 {
asset.AnimationMediaType, err = o.client.doContentTypeRequest(asset.AnimationURL)
if err != nil {
asset.AnimationURL = ""
}
}
assets.Assets = append(assets.Assets, asset)
}
assets.NextCursor = container.NextCursor

View File

@ -0,0 +1,87 @@
package opensea
import (
"fmt"
"io/ioutil"
"net/http"
"sync"
"time"
"github.com/ethereum/go-ethereum/log"
)
type HTTPClient struct {
client *http.Client
getRequestLock sync.RWMutex
}
func newHTTPClient() *HTTPClient {
return &HTTPClient{
client: &http.Client{
Timeout: RequestTimeout,
},
}
}
func (o *HTTPClient) doGetRequest(url string, apiKey string) ([]byte, error) {
// Ensure only one thread makes a request at a time
o.getRequestLock.Lock()
defer o.getRequestLock.Unlock()
retryCount := 0
statusCode := http.StatusOK
// Try to do the request without an apiKey first
tmpAPIKey := ""
for {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:96.0) Gecko/20100101 Firefox/96.0")
if len(tmpAPIKey) > 0 {
req.Header.Set("X-API-KEY", tmpAPIKey)
}
resp, err := o.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Error("failed to close opensea request body", "err", err)
}
}()
statusCode = resp.StatusCode
switch resp.StatusCode {
case http.StatusOK:
body, err := ioutil.ReadAll(resp.Body)
return body, err
case http.StatusTooManyRequests:
if retryCount < GetRequestRetryMaxCount {
// sleep and retry
time.Sleep(GetRequestWaitTime)
retryCount++
continue
}
// break and error
case http.StatusForbidden:
// Request requires an apiKey, set it and retry
if tmpAPIKey == "" && apiKey != "" {
tmpAPIKey = apiKey
// sleep and retry
time.Sleep(GetRequestWaitTime)
continue
}
// break and error
default:
// break and error
}
break
}
return nil, fmt.Errorf("unsuccessful request: %d %s", statusCode, http.StatusText(statusCode))
}

View File

@ -0,0 +1,190 @@
package opensea
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 "optimism":
chainID = walletCommon.OptimismMainnet
case "goerli":
chainID = walletCommon.EthereumGoerli
case "arbitrum_goerli":
chainID = walletCommon.ArbitrumGoerli
case "optimism_goerli":
chainID = walletCommon.OptimismGoerli
}
return walletCommon.ChainID(chainID)
}
type TraitValue string
func (st *TraitValue) UnmarshalJSON(b []byte) error {
var item interface{}
if err := json.Unmarshal(b, &item); err != nil {
return err
}
switch v := item.(type) {
case float64:
*st = TraitValue(strconv.FormatFloat(v, 'f', 2, 64))
case int:
*st = TraitValue(strconv.Itoa(v))
case string:
*st = TraitValue(v)
}
return nil
}
type AssetContainer struct {
Assets []Asset `json:"assets"`
NextCursor string `json:"next"`
PreviousCursor string `json:"previous"`
}
type Contract struct {
Address string `json:"address"`
ChainIdentifier string `json:"chain_identifier"`
}
type Trait struct {
TraitType string `json:"trait_type"`
Value TraitValue `json:"value"`
DisplayType string `json:"display_type"`
MaxValue string `json:"max_value"`
}
type PaymentToken struct {
ID int `json:"id"`
Symbol string `json:"symbol"`
Address string `json:"address"`
ImageURL string `json:"image_url"`
Name string `json:"name"`
Decimals int `json:"decimals"`
EthPrice string `json:"eth_price"`
UsdPrice string `json:"usd_price"`
}
type LastSale struct {
PaymentToken PaymentToken `json:"payment_token"`
}
type SellOrder struct {
CurrentPrice string `json:"current_price"`
}
type Asset struct {
ID int `json:"id"`
TokenID *bigint.BigInt `json:"token_id"`
Name string `json:"name"`
Description string `json:"description"`
Permalink string `json:"permalink"`
ImageThumbnailURL string `json:"image_thumbnail_url"`
ImageURL string `json:"image_url"`
AnimationURL string `json:"animation_url"`
Contract Contract `json:"asset_contract"`
Collection Collection `json:"collection"`
Traits []Trait `json:"traits"`
LastSale LastSale `json:"last_sale"`
SellOrders []SellOrder `json:"sell_orders"`
BackgroundColor string `json:"background_color"`
TokenURI string `json:"token_metadata"`
}
type CollectionTrait struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
}
type Collection struct {
Name string `json:"name"`
Slug string `json:"slug"`
ImageURL string `json:"image_url"`
Traits map[string]CollectionTrait `json:"traits"`
}
type OwnedCollection struct {
Collection
OwnedAssetCount *bigint.BigInt `json:"owned_asset_count"`
}
func (c *Asset) id() thirdparty.CollectibleUniqueID {
return thirdparty.CollectibleUniqueID{
ContractID: thirdparty.ContractID{
ChainID: chainStringToChainID(c.Contract.ChainIdentifier),
Address: common.HexToAddress(c.Contract.Address),
},
TokenID: c.TokenID,
}
}
func openseaToCollectibleTraits(traits []Trait) []thirdparty.CollectibleTrait {
ret := make([]thirdparty.CollectibleTrait, 0, len(traits))
caser := cases.Title(language.Und, cases.NoLower)
for _, orig := range traits {
dest := thirdparty.CollectibleTrait{
TraitType: strings.Replace(orig.TraitType, "_", " ", 1),
Value: caser.String(string(orig.Value)),
DisplayType: orig.DisplayType,
MaxValue: orig.MaxValue,
}
ret = append(ret, dest)
}
return ret
}
func (c *Asset) toCollectionData() thirdparty.CollectionData {
ret := thirdparty.CollectionData{
ID: c.id().ContractID,
Name: c.Collection.Name,
Slug: c.Collection.Slug,
ImageURL: c.Collection.ImageURL,
Traits: make(map[string]thirdparty.CollectionTrait),
}
for traitType, trait := range c.Collection.Traits {
ret.Traits[traitType] = thirdparty.CollectionTrait{
Min: trait.Min,
Max: trait.Max,
}
}
return ret
}
func (c *Asset) toCollectiblesData() thirdparty.CollectibleData {
return thirdparty.CollectibleData{
ID: c.id(),
Name: c.Name,
Description: c.Description,
Permalink: c.Permalink,
ImageURL: c.ImageURL,
AnimationURL: c.AnimationURL,
Traits: openseaToCollectibleTraits(c.Traits),
BackgroundColor: c.BackgroundColor,
TokenURI: c.TokenURI,
}
}
func (c *Asset) toCommon() thirdparty.FullCollectibleData {
collection := c.toCollectionData()
return thirdparty.FullCollectibleData{
CollectibleData: c.toCollectiblesData(),
CollectionData: &collection,
}
}