2023-02-21 09:05:16 +00:00
|
|
|
package opensea
|
2021-08-20 19:53:24 +00:00
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/json"
|
2021-09-20 16:24:07 +00:00
|
|
|
"errors"
|
2021-08-20 19:53:24 +00:00
|
|
|
"fmt"
|
|
|
|
"io/ioutil"
|
|
|
|
"net/http"
|
2023-02-28 03:10:38 +00:00
|
|
|
"net/url"
|
2021-08-30 07:50:18 +00:00
|
|
|
"strconv"
|
2022-08-03 07:42:56 +00:00
|
|
|
"strings"
|
2023-01-17 09:56:16 +00:00
|
|
|
"sync"
|
2021-08-20 19:53:24 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/ethereum/go-ethereum/common"
|
|
|
|
"github.com/ethereum/go-ethereum/log"
|
2023-02-28 03:10:38 +00:00
|
|
|
|
|
|
|
"github.com/status-im/status-go/services/wallet/bigint"
|
2021-08-20 19:53:24 +00:00
|
|
|
)
|
|
|
|
|
2023-03-06 16:15:48 +00:00
|
|
|
const AssetLimit = 200
|
2021-08-20 19:53:24 +00:00
|
|
|
const CollectionLimit = 300
|
|
|
|
|
2023-02-28 03:10:38 +00:00
|
|
|
const RequestRetryMaxCount = 1
|
|
|
|
const RequestWaitTime = 300 * time.Millisecond
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
var OpenseaClientInstances = make(map[uint64]*Client)
|
2023-01-17 09:56:16 +00:00
|
|
|
|
2021-09-20 16:24:07 +00:00
|
|
|
var BaseURLs = map[uint64]string{
|
|
|
|
1: "https://api.opensea.io/api/v1",
|
|
|
|
4: "https://rinkeby-api.opensea.io/api/v1",
|
2022-10-12 07:59:38 +00:00
|
|
|
5: "https://testnets-api.opensea.io/api/v1",
|
2021-09-20 16:24:07 +00:00
|
|
|
}
|
|
|
|
|
2023-03-09 20:03:57 +00:00
|
|
|
const ChainIDRequiringAPIKey = 1
|
|
|
|
|
2021-08-30 07:50:18 +00:00
|
|
|
type TraitValue string
|
|
|
|
|
2023-02-28 03:10:38 +00:00
|
|
|
type NFTUniqueID struct {
|
|
|
|
ContractAddress common.Address `json:"contractAddress"`
|
|
|
|
TokenID bigint.BigInt `json:"tokenID"`
|
|
|
|
}
|
|
|
|
|
2021-08-30 07:50:18 +00:00
|
|
|
func (st *TraitValue) UnmarshalJSON(b []byte) error {
|
|
|
|
var item interface{}
|
|
|
|
if err := json.Unmarshal(b, &item); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2021-09-17 08:01:35 +00:00
|
|
|
|
2021-08-30 07:50:18 +00:00
|
|
|
switch v := item.(type) {
|
2021-09-17 08:01:35 +00:00
|
|
|
case float64:
|
|
|
|
*st = TraitValue(strconv.FormatFloat(v, 'f', 2, 64))
|
2021-08-30 07:50:18 +00:00
|
|
|
case int:
|
|
|
|
*st = TraitValue(strconv.Itoa(v))
|
|
|
|
case string:
|
|
|
|
*st = TraitValue(v)
|
|
|
|
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
type AssetContainer struct {
|
2023-02-28 03:10:38 +00:00
|
|
|
Assets []Asset `json:"assets"`
|
|
|
|
NextCursor string `json:"next"`
|
|
|
|
PreviousCursor string `json:"previous"`
|
2021-08-20 19:53:24 +00:00
|
|
|
}
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
type Contract struct {
|
2021-08-20 19:53:24 +00:00
|
|
|
Address string `json:"address"`
|
|
|
|
}
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
type Trait struct {
|
2021-08-30 07:50:18 +00:00
|
|
|
TraitType string `json:"trait_type"`
|
|
|
|
Value TraitValue `json:"value"`
|
|
|
|
DisplayType string `json:"display_type"`
|
2021-09-17 08:01:35 +00:00
|
|
|
MaxValue string `json:"max_value"`
|
|
|
|
}
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
type PaymentToken struct {
|
2021-09-17 08:01:35 +00:00
|
|
|
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"`
|
|
|
|
}
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
type LastSale struct {
|
|
|
|
PaymentToken PaymentToken `json:"payment_token"`
|
2021-08-30 07:50:18 +00:00
|
|
|
}
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
type SellOrder struct {
|
2021-09-17 08:01:35 +00:00
|
|
|
CurrentPrice string `json:"current_price"`
|
|
|
|
}
|
2023-02-28 03:10:38 +00:00
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
type Asset struct {
|
2023-02-28 03:10:38 +00:00
|
|
|
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"`
|
|
|
|
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"`
|
2023-02-21 09:05:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type CollectionTrait struct {
|
2021-09-17 08:01:35 +00:00
|
|
|
Min float64 `json:"min"`
|
|
|
|
Max float64 `json:"max"`
|
2021-08-20 19:53:24 +00:00
|
|
|
}
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
type Collection struct {
|
2023-02-28 03:10:38 +00:00
|
|
|
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"`
|
2021-08-20 19:53:24 +00:00
|
|
|
}
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
type Client struct {
|
2023-01-17 09:56:16 +00:00
|
|
|
client *http.Client
|
|
|
|
url string
|
|
|
|
apiKey string
|
|
|
|
IsConnected bool
|
2023-02-23 13:55:57 +00:00
|
|
|
LastCheckedAt int64
|
2023-01-17 09:56:16 +00:00
|
|
|
IsConnectedLock sync.RWMutex
|
2023-02-28 03:10:38 +00:00
|
|
|
requestLock sync.RWMutex
|
2021-08-20 19:53:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// new opensea client.
|
2023-02-21 09:05:16 +00:00
|
|
|
func NewOpenseaClient(chainID uint64, apiKey string) (*Client, error) {
|
2023-03-09 20:03:57 +00:00
|
|
|
var tmpAPIKey string = ""
|
|
|
|
if chainID == ChainIDRequiringAPIKey {
|
|
|
|
tmpAPIKey = apiKey
|
|
|
|
}
|
2023-01-17 09:56:16 +00:00
|
|
|
if client, ok := OpenseaClientInstances[chainID]; ok {
|
2023-03-09 20:03:57 +00:00
|
|
|
if client.apiKey == tmpAPIKey {
|
2023-01-17 09:56:16 +00:00
|
|
|
return client, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-08-20 19:53:24 +00:00
|
|
|
client := &http.Client{
|
|
|
|
Timeout: time.Second * 5,
|
|
|
|
}
|
2021-09-20 16:24:07 +00:00
|
|
|
if url, ok := BaseURLs[chainID]; ok {
|
2023-02-28 03:10:38 +00:00
|
|
|
openseaClient := &Client{
|
|
|
|
client: client,
|
|
|
|
url: url,
|
2023-03-09 20:03:57 +00:00
|
|
|
apiKey: tmpAPIKey,
|
2023-02-28 03:10:38 +00:00
|
|
|
IsConnected: true,
|
|
|
|
LastCheckedAt: time.Now().Unix(),
|
|
|
|
}
|
2023-01-17 09:56:16 +00:00
|
|
|
OpenseaClientInstances[chainID] = openseaClient
|
|
|
|
return openseaClient, nil
|
2021-09-20 16:24:07 +00:00
|
|
|
}
|
2021-08-20 19:53:24 +00:00
|
|
|
|
2021-09-20 16:24:07 +00:00
|
|
|
return nil, errors.New("ChainID not supported")
|
2021-08-20 19:53:24 +00:00
|
|
|
}
|
|
|
|
|
2023-02-28 03:10:38 +00:00
|
|
|
func (o *Client) setConnected(value bool) {
|
2023-01-17 09:56:16 +00:00
|
|
|
o.IsConnectedLock.Lock()
|
|
|
|
defer o.IsConnectedLock.Unlock()
|
2023-02-28 03:10:38 +00:00
|
|
|
o.IsConnected = value
|
2023-02-23 13:55:57 +00:00
|
|
|
o.LastCheckedAt = time.Now().Unix()
|
2023-02-28 03:10:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (o *Client) FetchAllCollectionsByOwner(owner common.Address) ([]OwnedCollection, error) {
|
|
|
|
offset := 0
|
|
|
|
var collections []OwnedCollection
|
2021-08-20 19:53:24 +00:00
|
|
|
for {
|
|
|
|
url := fmt.Sprintf("%s/collections?asset_owner=%s&offset=%d&limit=%d", o.url, owner, offset, CollectionLimit)
|
|
|
|
body, err := o.doOpenseaRequest(url)
|
|
|
|
if err != nil {
|
2023-02-28 03:10:38 +00:00
|
|
|
o.setConnected(false)
|
2021-08-20 19:53:24 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-02-28 03:10:38 +00:00
|
|
|
var tmp []OwnedCollection
|
2021-08-20 19:53:24 +00:00
|
|
|
err = json.Unmarshal(body, &tmp)
|
|
|
|
if err != nil {
|
2023-02-28 03:10:38 +00:00
|
|
|
o.setConnected(false)
|
2021-08-20 19:53:24 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
collections = append(collections, tmp...)
|
|
|
|
|
|
|
|
if len(tmp) < CollectionLimit {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
2023-02-28 03:10:38 +00:00
|
|
|
o.setConnected(true)
|
2021-08-20 19:53:24 +00:00
|
|
|
return collections, nil
|
|
|
|
}
|
|
|
|
|
2023-03-06 16:15:48 +00:00
|
|
|
func (o *Client) FetchAllAssetsByOwnerAndCollection(owner common.Address, collectionSlug string, cursor string, limit int) (*AssetContainer, error) {
|
2023-02-28 03:10:38 +00:00
|
|
|
queryParams := url.Values{
|
|
|
|
"owner": {owner.String()},
|
|
|
|
"collection": {collectionSlug},
|
|
|
|
}
|
|
|
|
|
2023-03-06 16:15:48 +00:00
|
|
|
if len(cursor) > 0 {
|
|
|
|
queryParams["cursor"] = []string{cursor}
|
|
|
|
}
|
|
|
|
|
|
|
|
return o.fetchAssets(queryParams, limit)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (o *Client) FetchAllAssetsByOwner(owner common.Address, cursor string, limit int) (*AssetContainer, error) {
|
|
|
|
queryParams := url.Values{
|
|
|
|
"owner": {owner.String()},
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(cursor) > 0 {
|
|
|
|
queryParams["cursor"] = []string{cursor}
|
|
|
|
}
|
|
|
|
|
2023-02-28 03:10:38 +00:00
|
|
|
return o.fetchAssets(queryParams, limit)
|
|
|
|
}
|
|
|
|
|
2023-03-06 16:15:48 +00:00
|
|
|
func (o *Client) FetchAssetsByNFTUniqueID(uniqueIDs []NFTUniqueID, limit int) (*AssetContainer, error) {
|
2023-02-28 03:10:38 +00:00
|
|
|
queryParams := url.Values{}
|
|
|
|
|
|
|
|
for _, uniqueID := range uniqueIDs {
|
|
|
|
queryParams.Add("token_ids", uniqueID.TokenID.String())
|
|
|
|
queryParams.Add("asset_contract_addresses", uniqueID.ContractAddress.String())
|
|
|
|
}
|
|
|
|
|
|
|
|
return o.fetchAssets(queryParams, limit)
|
|
|
|
}
|
|
|
|
|
2023-03-06 16:15:48 +00:00
|
|
|
func (o *Client) fetchAssets(queryParams url.Values, limit int) (*AssetContainer, error) {
|
|
|
|
assets := new(AssetContainer)
|
|
|
|
|
|
|
|
if len(queryParams["cursor"]) > 0 {
|
|
|
|
assets.PreviousCursor = queryParams["cursor"][0]
|
|
|
|
}
|
|
|
|
|
|
|
|
tmpLimit := limit
|
|
|
|
if AssetLimit < limit {
|
|
|
|
tmpLimit = AssetLimit
|
|
|
|
}
|
2023-02-28 03:10:38 +00:00
|
|
|
|
2023-03-06 16:15:48 +00:00
|
|
|
queryParams["limit"] = []string{strconv.Itoa(tmpLimit)}
|
2021-08-20 19:53:24 +00:00
|
|
|
for {
|
2023-02-28 03:10:38 +00:00
|
|
|
url := o.url + "/assets?" + queryParams.Encode()
|
|
|
|
|
2021-08-20 19:53:24 +00:00
|
|
|
body, err := o.doOpenseaRequest(url)
|
|
|
|
if err != nil {
|
2023-02-28 03:10:38 +00:00
|
|
|
o.setConnected(false)
|
2021-08-20 19:53:24 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
container := AssetContainer{}
|
2021-08-20 19:53:24 +00:00
|
|
|
err = json.Unmarshal(body, &container)
|
|
|
|
if err != nil {
|
2023-02-28 03:10:38 +00:00
|
|
|
o.setConnected(false)
|
2021-08-20 19:53:24 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-08-03 07:42:56 +00:00
|
|
|
for _, asset := range container.Assets {
|
|
|
|
for i := range asset.Traits {
|
2022-11-29 12:28:37 +00:00
|
|
|
asset.Traits[i].TraitType = strings.Replace(asset.Traits[i].TraitType, "_", " ", 1)
|
2022-08-03 07:42:56 +00:00
|
|
|
asset.Traits[i].Value = TraitValue(strings.Title(string(asset.Traits[i].Value)))
|
|
|
|
}
|
2023-03-06 16:15:48 +00:00
|
|
|
assets.Assets = append(assets.Assets, asset)
|
2022-08-03 07:42:56 +00:00
|
|
|
}
|
2023-03-06 16:15:48 +00:00
|
|
|
assets.NextCursor = container.NextCursor
|
2021-08-20 19:53:24 +00:00
|
|
|
|
2023-03-06 16:15:48 +00:00
|
|
|
if len(assets.NextCursor) == 0 {
|
2023-02-28 03:10:38 +00:00
|
|
|
break
|
|
|
|
}
|
|
|
|
|
2023-03-06 16:15:48 +00:00
|
|
|
queryParams["cursor"] = []string{assets.NextCursor}
|
2023-02-28 03:10:38 +00:00
|
|
|
|
2023-03-06 16:15:48 +00:00
|
|
|
if len(assets.Assets) >= limit {
|
2021-08-20 19:53:24 +00:00
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
2023-01-17 09:56:16 +00:00
|
|
|
|
2023-02-28 03:10:38 +00:00
|
|
|
o.setConnected(true)
|
2021-08-20 19:53:24 +00:00
|
|
|
return assets, nil
|
|
|
|
}
|
|
|
|
|
2023-02-21 09:05:16 +00:00
|
|
|
func (o *Client) doOpenseaRequest(url string) ([]byte, error) {
|
2023-02-28 03:10:38 +00:00
|
|
|
// Ensure only one thread makes a request at a time
|
|
|
|
o.requestLock.Lock()
|
|
|
|
defer o.requestLock.Unlock()
|
2023-01-17 09:56:16 +00:00
|
|
|
|
2023-02-28 03:10:38 +00:00
|
|
|
retryCount := 0
|
|
|
|
statusCode := http.StatusOK
|
2021-08-20 19:53:24 +00:00
|
|
|
|
2023-02-28 03:10:38 +00:00
|
|
|
for {
|
|
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2021-08-20 19:53:24 +00:00
|
|
|
}
|
|
|
|
|
2023-02-28 03:10:38 +00:00
|
|
|
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")
|
2023-03-09 20:03:57 +00:00
|
|
|
if len(o.apiKey) > 0 {
|
|
|
|
req.Header.Set("X-API-KEY", o.apiKey)
|
|
|
|
}
|
2023-02-28 03:10:38 +00:00
|
|
|
|
|
|
|
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 < RequestRetryMaxCount {
|
|
|
|
// sleep and retry
|
|
|
|
time.Sleep(RequestWaitTime)
|
|
|
|
retryCount++
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
// break and error
|
|
|
|
default:
|
|
|
|
// break and error
|
|
|
|
}
|
|
|
|
break
|
|
|
|
}
|
|
|
|
return nil, fmt.Errorf("unsuccessful request: %d %s", statusCode, http.StatusText(statusCode))
|
2021-08-20 19:53:24 +00:00
|
|
|
}
|