2023-08-17 18:10:13 +00:00
|
|
|
package opensea
|
|
|
|
|
|
|
|
import (
|
2023-11-14 17:16:39 +00:00
|
|
|
"context"
|
2023-08-17 18:10:13 +00:00
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"net/url"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"github.com/ethereum/go-ethereum/common"
|
|
|
|
|
2024-10-28 20:54:17 +00:00
|
|
|
"github.com/status-im/status-go/logutils"
|
2023-08-17 18:10:13 +00:00
|
|
|
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"
|
|
|
|
)
|
|
|
|
|
|
|
|
const assetLimitV2 = 50
|
|
|
|
|
|
|
|
func getV2BaseURL(chainID walletCommon.ChainID) (string, error) {
|
|
|
|
switch uint64(chainID) {
|
|
|
|
case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet, walletCommon.OptimismMainnet:
|
2023-08-23 19:29:09 +00:00
|
|
|
return "https://api.opensea.io/v2", nil
|
2024-01-16 14:21:18 +00:00
|
|
|
case walletCommon.EthereumSepolia, walletCommon.ArbitrumSepolia, walletCommon.OptimismSepolia:
|
2023-08-23 19:29:09 +00:00
|
|
|
return "https://testnets-api.opensea.io/v2", nil
|
2023-08-17 18:10:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return "", thirdparty.ErrChainIDNotSupported
|
|
|
|
}
|
|
|
|
|
|
|
|
func (o *ClientV2) ID() string {
|
|
|
|
return OpenseaV2ID
|
|
|
|
}
|
|
|
|
|
|
|
|
func (o *ClientV2) IsChainSupported(chainID walletCommon.ChainID) bool {
|
|
|
|
_, err := getV2BaseURL(chainID)
|
|
|
|
return err == nil
|
|
|
|
}
|
|
|
|
|
2023-09-22 13:18:42 +00:00
|
|
|
func (o *ClientV2) IsConnected() bool {
|
|
|
|
return o.connectionStatus.IsConnected()
|
|
|
|
}
|
|
|
|
|
2023-08-17 18:10:13 +00:00
|
|
|
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.
|
2023-09-22 13:18:42 +00:00
|
|
|
func NewClientV2(apiKey string, httpClient *HTTPClient) *ClientV2 {
|
2023-10-03 18:53:36 +00:00
|
|
|
if apiKey == "" {
|
2024-10-28 20:54:17 +00:00
|
|
|
logutils.ZapLogger().Warn("OpenseaV2 API key not available")
|
2023-10-03 18:53:36 +00:00
|
|
|
}
|
|
|
|
|
2023-08-17 18:10:13 +00:00
|
|
|
return &ClientV2{
|
|
|
|
client: httpClient,
|
|
|
|
apiKey: apiKey,
|
2023-09-22 13:18:42 +00:00
|
|
|
connectionStatus: connection.NewStatus(),
|
2023-08-17 18:10:13 +00:00
|
|
|
urlGetter: getV2URL,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
func (o *ClientV2) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
|
2023-08-17 18:10:13 +00:00
|
|
|
// 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
|
2023-08-18 14:15:55 +00:00
|
|
|
assets.NextCursor = cursor
|
2023-10-04 16:21:45 +00:00
|
|
|
assets.Provider = o.ID()
|
2023-08-17 18:10:13 +00:00
|
|
|
|
|
|
|
for {
|
2023-11-14 17:16:39 +00:00
|
|
|
assetsPage, err := o.FetchAllAssetsByOwner(ctx, chainID, owner, assets.NextCursor, assetLimitV2)
|
2023-08-17 18:10:13 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
func (o *ClientV2) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
|
2023-08-17 18:10:13 +00:00
|
|
|
pathParams := []string{
|
|
|
|
"chain", chainIDToChainString(chainID),
|
|
|
|
"account", owner.String(),
|
|
|
|
"nfts",
|
|
|
|
}
|
|
|
|
|
|
|
|
queryParams := url.Values{}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
return o.fetchAssets(ctx, chainID, pathParams, queryParams, limit, cursor)
|
2023-08-17 18:10:13 +00:00
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
func (o *ClientV2) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
|
|
|
|
return o.fetchDetailedAssets(ctx, uniqueIDs)
|
2023-08-17 18:10:13 +00:00
|
|
|
}
|
|
|
|
|
2024-05-14 06:58:08 +00:00
|
|
|
func (o *ClientV2) FetchCollectionSocials(ctx context.Context, contractID thirdparty.ContractID) (*thirdparty.CollectionSocials, error) {
|
|
|
|
// we dont want to use opensea as any small number of requests can also lead to throttling
|
|
|
|
return nil, thirdparty.ErrEndpointNotSupported
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
func (o *ClientV2) fetchAssets(ctx context.Context, chainID walletCommon.ChainID, pathParams []string, queryParams url.Values, limit int, cursor string) (*thirdparty.FullCollectibleDataContainer, error) {
|
2023-08-17 18:10:13 +00:00
|
|
|
assets := new(thirdparty.FullCollectibleDataContainer)
|
|
|
|
|
2023-10-06 12:48:15 +00:00
|
|
|
tmpLimit := assetLimitV2
|
2023-08-17 18:10:13 +00:00
|
|
|
if limit > thirdparty.FetchNoLimit && limit < tmpLimit {
|
|
|
|
tmpLimit = limit
|
|
|
|
}
|
|
|
|
queryParams["limit"] = []string{strconv.Itoa(tmpLimit)}
|
|
|
|
|
|
|
|
assets.PreviousCursor = cursor
|
|
|
|
if cursor != "" {
|
|
|
|
queryParams["next"] = []string{cursor}
|
|
|
|
}
|
2023-10-04 16:21:45 +00:00
|
|
|
assets.Provider = o.ID()
|
2023-08-17 18:10:13 +00:00
|
|
|
|
|
|
|
for {
|
|
|
|
path := fmt.Sprintf("%s?%s", strings.Join(pathParams, "/"), queryParams.Encode())
|
|
|
|
url, err := o.urlGetter(chainID, path)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
|
2023-08-17 18:10:13 +00:00
|
|
|
if err != nil {
|
2024-02-27 18:00:20 +00:00
|
|
|
if ctx.Err() == nil {
|
|
|
|
o.connectionStatus.SetIsConnected(false)
|
|
|
|
}
|
2023-08-17 18:10:13 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
o.connectionStatus.SetIsConnected(true)
|
|
|
|
|
2023-08-23 19:29:09 +00:00
|
|
|
// If body is empty, it means the account has no collectibles for this chain.
|
|
|
|
// (Workaround implemented in http_client.go)
|
|
|
|
if body == nil {
|
|
|
|
assets.NextCursor = ""
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
2023-08-17 18:10:13 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
func (o *ClientV2) fetchDetailedAssets(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
|
2023-08-17 18:10:13 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
|
2023-08-17 18:10:13 +00:00
|
|
|
if err != nil {
|
2023-11-14 17:16:39 +00:00
|
|
|
if ctx.Err() == nil {
|
|
|
|
o.connectionStatus.SetIsConnected(false)
|
|
|
|
}
|
2023-08-17 18:10:13 +00:00
|
|
|
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))
|
|
|
|
}
|
|
|
|
|
2023-09-12 14:10:55 +00:00
|
|
|
nftContainer := DetailedNFTContainer{}
|
|
|
|
err = json.Unmarshal(body, &nftContainer)
|
2023-08-17 18:10:13 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-09-12 14:10:55 +00:00
|
|
|
assets = append(assets, nftContainer.NFT.toCommon(id.ContractID.ChainID))
|
2023-08-17 18:10:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return assets, nil
|
|
|
|
}
|
2023-10-06 19:46:01 +00:00
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
func (o *ClientV2) fetchContractDataByContractID(ctx context.Context, id thirdparty.ContractID) (*ContractData, error) {
|
2023-10-06 19:46:01 +00:00
|
|
|
path := fmt.Sprintf("chain/%s/contract/%s", chainIDToChainString(id.ChainID), id.Address.String())
|
|
|
|
url, err := o.urlGetter(id.ChainID, path)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
|
2023-10-06 19:46:01 +00:00
|
|
|
if err != nil {
|
2023-11-14 17:16:39 +00:00
|
|
|
if ctx.Err() == nil {
|
|
|
|
o.connectionStatus.SetIsConnected(false)
|
|
|
|
}
|
2023-10-06 19:46:01 +00:00
|
|
|
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))
|
|
|
|
}
|
|
|
|
|
|
|
|
contract := ContractData{}
|
|
|
|
err = json.Unmarshal(body, &contract)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return &contract, nil
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
func (o *ClientV2) fetchCollectionDataBySlug(ctx context.Context, chainID walletCommon.ChainID, slug string) (*CollectionData, error) {
|
2023-10-06 19:46:01 +00:00
|
|
|
path := fmt.Sprintf("collections/%s", slug)
|
|
|
|
url, err := o.urlGetter(chainID, path)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
|
2023-10-06 19:46:01 +00:00
|
|
|
if err != nil {
|
2024-02-27 18:00:20 +00:00
|
|
|
if ctx.Err() == nil {
|
|
|
|
o.connectionStatus.SetIsConnected(false)
|
|
|
|
}
|
2023-10-06 19:46:01 +00:00
|
|
|
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))
|
|
|
|
}
|
|
|
|
|
|
|
|
collection := CollectionData{}
|
|
|
|
err = json.Unmarshal(body, &collection)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return &collection, nil
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
func (o *ClientV2) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) {
|
2023-10-06 19:46:01 +00:00
|
|
|
ret := make([]thirdparty.CollectionData, 0, len(contractIDs))
|
|
|
|
|
|
|
|
for _, id := range contractIDs {
|
2023-11-14 17:16:39 +00:00
|
|
|
contractData, err := o.fetchContractDataByContractID(ctx, id)
|
2023-10-06 19:46:01 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-10-26 06:28:44 +00:00
|
|
|
if contractData == nil || contractData.Collection == "" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2023-11-14 17:16:39 +00:00
|
|
|
collectionData, err := o.fetchCollectionDataBySlug(ctx, id.ChainID, contractData.Collection)
|
2023-10-06 19:46:01 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2024-02-06 15:27:15 +00:00
|
|
|
ret = append(ret, collectionData.toCommon(id, contractData.ContractStandard))
|
2023-10-06 19:46:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return ret, nil
|
|
|
|
}
|