diff --git a/services/wallet/service.go b/services/wallet/service.go index 12609d585..819e36947 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -105,7 +105,9 @@ func NewService( currency := currency.NewService(db, walletFeed, tokenManager, marketManager) activity := activity.NewService(db, tokenManager, walletFeed, accountsDB) - openseaClient := opensea.NewClient(config.WalletConfig.OpenseaAPIKey, walletFeed) + openseaHTTPClient := opensea.NewHTTPClient() + openseaClient := opensea.NewClient(config.WalletConfig.OpenseaAPIKey, openseaHTTPClient, walletFeed) + openseaV2Client := opensea.NewClientV2(config.WalletConfig.OpenseaAPIKey, openseaHTTPClient, walletFeed) infuraClient := infura.NewClient(config.WalletConfig.InfuraAPIKey, config.WalletConfig.InfuraAPIKeySecret) alchemyClient := alchemy.NewClient(config.WalletConfig.AlchemyAPIKeys) @@ -117,12 +119,14 @@ func NewService( accountOwnershipProviders := []thirdparty.CollectibleAccountOwnershipProvider{ openseaClient, + openseaV2Client, infuraClient, alchemyClient, } collectibleDataProviders := []thirdparty.CollectibleDataProvider{ openseaClient, + openseaV2Client, infuraClient, alchemyClient, } diff --git a/services/wallet/thirdparty/opensea/client.go b/services/wallet/thirdparty/opensea/client.go index b2931b130..b0f70a593 100644 --- a/services/wallet/thirdparty/opensea/client.go +++ b/services/wallet/thirdparty/opensea/client.go @@ -6,7 +6,6 @@ import ( "net/url" "strconv" "strings" - "time" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/event" @@ -18,26 +17,20 @@ import ( ) const ( - EventCollectibleStatusChanged walletevent.EventType = "wallet-collectible-opensea-v1-status-changed" + EventOpenseaV1StatusChanged walletevent.EventType = "wallet-collectible-opensea-v1-status-changed" ) const AssetLimit = 200 const CollectionLimit = 300 -const RequestTimeout = 5 * time.Second -const GetRequestRetryMaxCount = 15 -const GetRequestWaitTime = 300 * time.Millisecond - const ChainIDRequiringAPIKey = walletCommon.EthereumMainnet -type urlGetter func(walletCommon.ChainID, string) (string, error) - func getBaseURL(chainID walletCommon.ChainID) (string, error) { // v1 Endpoints only support L1 chain switch uint64(chainID) { case walletCommon.EthereumMainnet: return "https://api.opensea.io/api/v1", nil - case walletCommon.EthereumGoerli: + case walletCommon.EthereumSepolia: return "https://testnets-api.opensea.io/api/v1", nil } @@ -69,12 +62,12 @@ type Client struct { urlGetter urlGetter } -// new opensea client. -func NewClient(apiKey string, feed *event.Feed) *Client { +// new opensea v1 client. +func NewClient(apiKey string, httpClient *HTTPClient, feed *event.Feed) *Client { return &Client{ - client: newHTTPClient(), + client: httpClient, apiKey: apiKey, - connectionStatus: connection.NewStatus(EventCollectibleStatusChanged, feed), + connectionStatus: connection.NewStatus(EventOpenseaV1StatusChanged, feed), urlGetter: getURL, } } diff --git a/services/wallet/thirdparty/opensea/client_v2.go b/services/wallet/thirdparty/opensea/client_v2.go new file mode 100644 index 000000000..03e52988f --- /dev/null +++ b/services/wallet/thirdparty/opensea/client_v2.go @@ -0,0 +1,220 @@ +package opensea + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/event" + + 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" + "github.com/status-im/status-go/services/wallet/walletevent" +) + +const ( + EventOpenseaV2StatusChanged walletevent.EventType = "wallet-collectible-opensea-v2-status-changed" +) + +const assetLimitV2 = 50 + +func getV2BaseURL(chainID walletCommon.ChainID) (string, error) { + switch uint64(chainID) { + case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet, walletCommon.OptimismMainnet: + return "https://api.opensea.io/api/v2", nil + case walletCommon.EthereumGoerli, walletCommon.EthereumSepolia, walletCommon.ArbitrumGoerli, walletCommon.OptimismGoerli: + return "https://testnets-api.opensea.io/api/v2", nil + } + + return "", thirdparty.ErrChainIDNotSupported +} + +func (o *ClientV2) ID() string { + return OpenseaV2ID +} + +func (o *ClientV2) IsChainSupported(chainID walletCommon.ChainID) bool { + _, err := getV2BaseURL(chainID) + return err == nil +} + +func getV2URL(chainID walletCommon.ChainID, path string) (string, error) { + baseURL, err := getV2BaseURL(chainID) + if err != nil { + return "", err + } + + return fmt.Sprintf("%s/%s", baseURL, path), nil +} + +type ClientV2 struct { + client *HTTPClient + apiKey string + connectionStatus *connection.Status + urlGetter urlGetter +} + +// new opensea v2 client. +func NewClientV2(apiKey string, httpClient *HTTPClient, feed *event.Feed) *ClientV2 { + return &ClientV2{ + client: httpClient, + apiKey: apiKey, + connectionStatus: connection.NewStatus(EventOpenseaV2StatusChanged, feed), + urlGetter: getV2URL, + } +} + +func (o *ClientV2) FetchAllAssetsByOwnerAndContractAddress(chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { + // No dedicated endpoint to filter owned assets by contract address. + // Will probably be available at some point, for now do the filtering ourselves. + assets := new(thirdparty.FullCollectibleDataContainer) + + // Build map for more efficient contract address check + contractHashMap := make(map[string]bool) + for _, contractAddress := range contractAddresses { + contractID := thirdparty.ContractID{ + ChainID: chainID, + Address: contractAddress, + } + contractHashMap[contractID.HashKey()] = true + } + + assets.PreviousCursor = cursor + + for { + assetsPage, err := o.FetchAllAssetsByOwner(chainID, owner, cursor, assetLimitV2) + if err != nil { + return nil, err + } + + for _, asset := range assetsPage.Items { + if contractHashMap[asset.CollectibleData.ID.ContractID.HashKey()] { + assets.Items = append(assets.Items, asset) + } + } + + assets.NextCursor = assetsPage.NextCursor + + if assets.NextCursor == "" { + break + } + + if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit { + break + } + } + + return assets, nil +} + +func (o *ClientV2) FetchAllAssetsByOwner(chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { + pathParams := []string{ + "chain", chainIDToChainString(chainID), + "account", owner.String(), + "nfts", + } + + queryParams := url.Values{} + + return o.fetchAssets(chainID, pathParams, queryParams, limit, cursor) +} + +func (o *ClientV2) FetchAssetsByCollectibleUniqueID(uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) { + return o.fetchDetailedAssets(uniqueIDs) +} + +func (o *ClientV2) fetchAssets(chainID walletCommon.ChainID, pathParams []string, queryParams url.Values, limit int, cursor string) (*thirdparty.FullCollectibleDataContainer, error) { + assets := new(thirdparty.FullCollectibleDataContainer) + + tmpLimit := AssetLimit + if limit > thirdparty.FetchNoLimit && limit < tmpLimit { + tmpLimit = limit + } + queryParams["limit"] = []string{strconv.Itoa(tmpLimit)} + + assets.PreviousCursor = cursor + if cursor != "" { + queryParams["next"] = []string{cursor} + } + + for { + path := fmt.Sprintf("%s?%s", strings.Join(pathParams, "/"), queryParams.Encode()) + url, err := o.urlGetter(chainID, path) + if err != nil { + return nil, err + } + + body, err := o.client.doGetRequest(url, o.apiKey) + if err != nil { + o.connectionStatus.SetIsConnected(false) + return nil, err + } + o.connectionStatus.SetIsConnected(true) + + // if Json is not returned there must be an error + if !json.Valid(body) { + return nil, fmt.Errorf("invalid json: %s", string(body)) + } + + container := NFTContainer{} + err = json.Unmarshal(body, &container) + if err != nil { + return nil, err + } + + for _, asset := range container.NFTs { + assets.Items = append(assets.Items, asset.toCommon(chainID)) + } + assets.NextCursor = container.NextCursor + + if assets.NextCursor == "" { + break + } + + queryParams["next"] = []string{assets.NextCursor} + + if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit { + break + } + } + + return assets, nil +} + +func (o *ClientV2) fetchDetailedAssets(uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) { + assets := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs)) + + for _, id := range uniqueIDs { + path := fmt.Sprintf("chain/%s/contract/%s/nfts/%s", chainIDToChainString(id.ContractID.ChainID), id.ContractID.Address.String(), id.TokenID.String()) + url, err := o.urlGetter(id.ContractID.ChainID, path) + if err != nil { + return nil, err + } + + body, err := o.client.doGetRequest(url, o.apiKey) + if err != nil { + o.connectionStatus.SetIsConnected(false) + return nil, err + } + o.connectionStatus.SetIsConnected(true) + + // if Json is not returned there must be an error + if !json.Valid(body) { + return nil, fmt.Errorf("invalid json: %s", string(body)) + } + + nft := DetailedNFT{} + err = json.Unmarshal(body, &nft) + if err != nil { + return nil, err + } + + assets = append(assets, nft.toCommon(id.ContractID.ChainID)) + } + + return assets, nil +} diff --git a/services/wallet/thirdparty/opensea/http_client.go b/services/wallet/thirdparty/opensea/http_client.go index 5e7182782..875fae21a 100644 --- a/services/wallet/thirdparty/opensea/http_client.go +++ b/services/wallet/thirdparty/opensea/http_client.go @@ -10,15 +10,19 @@ import ( "github.com/ethereum/go-ethereum/log" ) +const requestTimeout = 5 * time.Second +const getRequestRetryMaxCount = 15 +const getRequestWaitTime = 300 * time.Millisecond + type HTTPClient struct { client *http.Client getRequestLock sync.RWMutex } -func newHTTPClient() *HTTPClient { +func NewHTTPClient() *HTTPClient { return &HTTPClient{ client: &http.Client{ - Timeout: RequestTimeout, + Timeout: requestTimeout, }, } } @@ -62,9 +66,9 @@ func (o *HTTPClient) doGetRequest(url string, apiKey string) ([]byte, error) { body, err := ioutil.ReadAll(resp.Body) return body, err case http.StatusTooManyRequests: - if retryCount < GetRequestRetryMaxCount { + if retryCount < getRequestRetryMaxCount { // sleep and retry - time.Sleep(GetRequestWaitTime) + time.Sleep(getRequestWaitTime) retryCount++ continue } @@ -74,7 +78,7 @@ func (o *HTTPClient) doGetRequest(url string, apiKey string) ([]byte, error) { if tmpAPIKey == "" && apiKey != "" { tmpAPIKey = apiKey // sleep and retry - time.Sleep(GetRequestWaitTime) + time.Sleep(getRequestWaitTime) continue } // break and error diff --git a/services/wallet/thirdparty/opensea/types.go b/services/wallet/thirdparty/opensea/types.go index abe29044f..6bafdc249 100644 --- a/services/wallet/thirdparty/opensea/types.go +++ b/services/wallet/thirdparty/opensea/types.go @@ -17,24 +17,7 @@ import ( const OpenseaV1ID = "openseaV1" -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 urlGetter func(walletCommon.ChainID, string) (string, error) type TraitValue string diff --git a/services/wallet/thirdparty/opensea/types_v2.go b/services/wallet/thirdparty/opensea/types_v2.go new file mode 100644 index 000000000..86f153496 --- /dev/null +++ b/services/wallet/thirdparty/opensea/types_v2.go @@ -0,0 +1,154 @@ +package opensea + +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" +) + +const ( + OpenseaV2ID = "openseaV2" + ethereumMainnetString = "ethereum" + arbitrumMainnetString = "arbitrum" + optimismMainnetString = "optimism" + ethereumGoerliString = "goerli" + arbitrumGoerliString = "arbitrum_goerli" + optimismGoerliString = "optimism_goerli" +) + +func chainStringToChainID(chainString string) walletCommon.ChainID { + chainID := walletCommon.UnknownChainID + switch chainString { + case ethereumMainnetString: + chainID = walletCommon.EthereumMainnet + case arbitrumMainnetString: + chainID = walletCommon.ArbitrumMainnet + case optimismMainnetString: + chainID = walletCommon.OptimismMainnet + case ethereumGoerliString: + chainID = walletCommon.EthereumGoerli + case arbitrumGoerliString: + chainID = walletCommon.ArbitrumGoerli + case optimismGoerliString: + chainID = walletCommon.OptimismGoerli + } + return walletCommon.ChainID(chainID) +} + +func chainIDToChainString(chainID walletCommon.ChainID) string { + chainString := "" + switch uint64(chainID) { + case walletCommon.EthereumMainnet: + chainString = ethereumMainnetString + case walletCommon.ArbitrumMainnet: + chainString = arbitrumMainnetString + case walletCommon.OptimismMainnet: + chainString = optimismMainnetString + case walletCommon.EthereumGoerli: + chainString = ethereumGoerliString + case walletCommon.ArbitrumGoerli: + chainString = arbitrumGoerliString + case walletCommon.OptimismGoerli: + chainString = optimismGoerliString + } + return chainString +} + +type NFTContainer struct { + NFTs []NFT `json:"nfts"` + NextCursor string `json:"next"` +} + +type NFT struct { + TokenID *bigint.BigInt `json:"identifier"` + Collection string `json:"collection"` + Contract common.Address `json:"contract"` + TokenStandard string `json:"token_standard"` + Name string `json:"name"` + Description string `json:"description"` + ImageURL string `json:"image_url"` + MetadataURL string `json:"metadata_url"` +} + +type DetailedNFT struct { + NFT + Owners []OwnerV2 `json:"owners"` + Traits []TraitV2 `json:"traits"` +} + +type OwnerV2 struct { + Address common.Address `json:"address"` + Quantity *bigint.BigInt `json:"quantity"` +} + +type TraitV2 struct { + TraitType string `json:"trait_type"` + DisplayType string `json:"display_type"` + MaxValue string `json:"max_value"` + TraitCount int `json:"trait_count"` + Order string `json:"order"` + Value TraitValue `json:"value"` +} + +func (c *NFT) id(chainID walletCommon.ChainID) thirdparty.CollectibleUniqueID { + return thirdparty.CollectibleUniqueID{ + ContractID: thirdparty.ContractID{ + ChainID: chainID, + Address: c.Contract, + }, + TokenID: c.TokenID, + } +} + +func openseaV2ToCollectibleTraits(traits []TraitV2) []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 *NFT) toCollectiblesData(chainID walletCommon.ChainID) thirdparty.CollectibleData { + return thirdparty.CollectibleData{ + ID: c.id(chainID), + Provider: OpenseaV2ID, + Name: c.Name, + Description: c.Description, + ImageURL: c.ImageURL, + AnimationURL: c.ImageURL, + Traits: make([]thirdparty.CollectibleTrait, 0), + TokenURI: c.MetadataURL, + } +} + +func (c *NFT) toCommon(chainID walletCommon.ChainID) thirdparty.FullCollectibleData { + return thirdparty.FullCollectibleData{ + CollectibleData: c.toCollectiblesData(chainID), + CollectionData: nil, + } +} + +func (c *DetailedNFT) toCommon(chainID walletCommon.ChainID) thirdparty.FullCollectibleData { + fullData := c.NFT.toCommon(chainID) + fullData.CollectibleData.Traits = openseaV2ToCollectibleTraits(c.Traits) + + return thirdparty.FullCollectibleData{ + CollectibleData: c.toCollectiblesData(chainID), + CollectionData: nil, + } +}