github.com/status-im/status-go@v1.1.0/services/wallet/thirdparty/opensea/client_v2.go (about) 1 package opensea 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "net/url" 8 "strconv" 9 "strings" 10 11 "github.com/ethereum/go-ethereum/common" 12 "github.com/ethereum/go-ethereum/log" 13 14 walletCommon "github.com/status-im/status-go/services/wallet/common" 15 "github.com/status-im/status-go/services/wallet/connection" 16 "github.com/status-im/status-go/services/wallet/thirdparty" 17 ) 18 19 const assetLimitV2 = 50 20 21 func getV2BaseURL(chainID walletCommon.ChainID) (string, error) { 22 switch uint64(chainID) { 23 case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet, walletCommon.OptimismMainnet: 24 return "https://api.opensea.io/v2", nil 25 case walletCommon.EthereumSepolia, walletCommon.ArbitrumSepolia, walletCommon.OptimismSepolia: 26 return "https://testnets-api.opensea.io/v2", nil 27 } 28 29 return "", thirdparty.ErrChainIDNotSupported 30 } 31 32 func (o *ClientV2) ID() string { 33 return OpenseaV2ID 34 } 35 36 func (o *ClientV2) IsChainSupported(chainID walletCommon.ChainID) bool { 37 _, err := getV2BaseURL(chainID) 38 return err == nil 39 } 40 41 func (o *ClientV2) IsConnected() bool { 42 return o.connectionStatus.IsConnected() 43 } 44 45 func getV2URL(chainID walletCommon.ChainID, path string) (string, error) { 46 baseURL, err := getV2BaseURL(chainID) 47 if err != nil { 48 return "", err 49 } 50 51 return fmt.Sprintf("%s/%s", baseURL, path), nil 52 } 53 54 type ClientV2 struct { 55 client *HTTPClient 56 apiKey string 57 connectionStatus *connection.Status 58 urlGetter urlGetter 59 } 60 61 // new opensea v2 client. 62 func NewClientV2(apiKey string, httpClient *HTTPClient) *ClientV2 { 63 if apiKey == "" { 64 log.Warn("OpenseaV2 API key not available") 65 } 66 67 return &ClientV2{ 68 client: httpClient, 69 apiKey: apiKey, 70 connectionStatus: connection.NewStatus(), 71 urlGetter: getV2URL, 72 } 73 } 74 75 func (o *ClientV2) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { 76 // No dedicated endpoint to filter owned assets by contract address. 77 // Will probably be available at some point, for now do the filtering ourselves. 78 assets := new(thirdparty.FullCollectibleDataContainer) 79 80 // Build map for more efficient contract address check 81 contractHashMap := make(map[string]bool) 82 for _, contractAddress := range contractAddresses { 83 contractID := thirdparty.ContractID{ 84 ChainID: chainID, 85 Address: contractAddress, 86 } 87 contractHashMap[contractID.HashKey()] = true 88 } 89 90 assets.PreviousCursor = cursor 91 assets.NextCursor = cursor 92 assets.Provider = o.ID() 93 94 for { 95 assetsPage, err := o.FetchAllAssetsByOwner(ctx, chainID, owner, assets.NextCursor, assetLimitV2) 96 if err != nil { 97 return nil, err 98 } 99 100 for _, asset := range assetsPage.Items { 101 if contractHashMap[asset.CollectibleData.ID.ContractID.HashKey()] { 102 assets.Items = append(assets.Items, asset) 103 } 104 } 105 106 assets.NextCursor = assetsPage.NextCursor 107 108 if assets.NextCursor == "" { 109 break 110 } 111 112 if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit { 113 break 114 } 115 } 116 117 return assets, nil 118 } 119 120 func (o *ClientV2) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { 121 pathParams := []string{ 122 "chain", chainIDToChainString(chainID), 123 "account", owner.String(), 124 "nfts", 125 } 126 127 queryParams := url.Values{} 128 129 return o.fetchAssets(ctx, chainID, pathParams, queryParams, limit, cursor) 130 } 131 132 func (o *ClientV2) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) { 133 return o.fetchDetailedAssets(ctx, uniqueIDs) 134 } 135 136 func (o *ClientV2) FetchCollectionSocials(ctx context.Context, contractID thirdparty.ContractID) (*thirdparty.CollectionSocials, error) { 137 // we dont want to use opensea as any small number of requests can also lead to throttling 138 return nil, thirdparty.ErrEndpointNotSupported 139 } 140 141 func (o *ClientV2) fetchAssets(ctx context.Context, chainID walletCommon.ChainID, pathParams []string, queryParams url.Values, limit int, cursor string) (*thirdparty.FullCollectibleDataContainer, error) { 142 assets := new(thirdparty.FullCollectibleDataContainer) 143 144 tmpLimit := assetLimitV2 145 if limit > thirdparty.FetchNoLimit && limit < tmpLimit { 146 tmpLimit = limit 147 } 148 queryParams["limit"] = []string{strconv.Itoa(tmpLimit)} 149 150 assets.PreviousCursor = cursor 151 if cursor != "" { 152 queryParams["next"] = []string{cursor} 153 } 154 assets.Provider = o.ID() 155 156 for { 157 path := fmt.Sprintf("%s?%s", strings.Join(pathParams, "/"), queryParams.Encode()) 158 url, err := o.urlGetter(chainID, path) 159 if err != nil { 160 return nil, err 161 } 162 163 body, err := o.client.doGetRequest(ctx, url, o.apiKey) 164 if err != nil { 165 if ctx.Err() == nil { 166 o.connectionStatus.SetIsConnected(false) 167 } 168 return nil, err 169 } 170 o.connectionStatus.SetIsConnected(true) 171 172 // If body is empty, it means the account has no collectibles for this chain. 173 // (Workaround implemented in http_client.go) 174 if body == nil { 175 assets.NextCursor = "" 176 break 177 } 178 179 // if Json is not returned there must be an error 180 if !json.Valid(body) { 181 return nil, fmt.Errorf("invalid json: %s", string(body)) 182 } 183 184 container := NFTContainer{} 185 err = json.Unmarshal(body, &container) 186 if err != nil { 187 return nil, err 188 } 189 190 for _, asset := range container.NFTs { 191 assets.Items = append(assets.Items, asset.toCommon(chainID)) 192 } 193 assets.NextCursor = container.NextCursor 194 195 if assets.NextCursor == "" { 196 break 197 } 198 199 queryParams["next"] = []string{assets.NextCursor} 200 201 if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit { 202 break 203 } 204 } 205 206 return assets, nil 207 } 208 209 func (o *ClientV2) fetchDetailedAssets(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) { 210 assets := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs)) 211 212 for _, id := range uniqueIDs { 213 path := fmt.Sprintf("chain/%s/contract/%s/nfts/%s", chainIDToChainString(id.ContractID.ChainID), id.ContractID.Address.String(), id.TokenID.String()) 214 url, err := o.urlGetter(id.ContractID.ChainID, path) 215 if err != nil { 216 return nil, err 217 } 218 219 body, err := o.client.doGetRequest(ctx, url, o.apiKey) 220 if err != nil { 221 if ctx.Err() == nil { 222 o.connectionStatus.SetIsConnected(false) 223 } 224 return nil, err 225 } 226 o.connectionStatus.SetIsConnected(true) 227 228 // if Json is not returned there must be an error 229 if !json.Valid(body) { 230 return nil, fmt.Errorf("invalid json: %s", string(body)) 231 } 232 233 nftContainer := DetailedNFTContainer{} 234 err = json.Unmarshal(body, &nftContainer) 235 if err != nil { 236 return nil, err 237 } 238 239 assets = append(assets, nftContainer.NFT.toCommon(id.ContractID.ChainID)) 240 } 241 242 return assets, nil 243 } 244 245 func (o *ClientV2) fetchContractDataByContractID(ctx context.Context, id thirdparty.ContractID) (*ContractData, error) { 246 path := fmt.Sprintf("chain/%s/contract/%s", chainIDToChainString(id.ChainID), id.Address.String()) 247 url, err := o.urlGetter(id.ChainID, path) 248 if err != nil { 249 return nil, err 250 } 251 252 body, err := o.client.doGetRequest(ctx, url, o.apiKey) 253 if err != nil { 254 if ctx.Err() == nil { 255 o.connectionStatus.SetIsConnected(false) 256 } 257 return nil, err 258 } 259 o.connectionStatus.SetIsConnected(true) 260 261 // if Json is not returned there must be an error 262 if !json.Valid(body) { 263 return nil, fmt.Errorf("invalid json: %s", string(body)) 264 } 265 266 contract := ContractData{} 267 err = json.Unmarshal(body, &contract) 268 if err != nil { 269 return nil, err 270 } 271 272 return &contract, nil 273 } 274 275 func (o *ClientV2) fetchCollectionDataBySlug(ctx context.Context, chainID walletCommon.ChainID, slug string) (*CollectionData, error) { 276 path := fmt.Sprintf("collections/%s", slug) 277 url, err := o.urlGetter(chainID, path) 278 if err != nil { 279 return nil, err 280 } 281 282 body, err := o.client.doGetRequest(ctx, url, o.apiKey) 283 if err != nil { 284 if ctx.Err() == nil { 285 o.connectionStatus.SetIsConnected(false) 286 } 287 return nil, err 288 } 289 o.connectionStatus.SetIsConnected(true) 290 291 // if Json is not returned there must be an error 292 if !json.Valid(body) { 293 return nil, fmt.Errorf("invalid json: %s", string(body)) 294 } 295 296 collection := CollectionData{} 297 err = json.Unmarshal(body, &collection) 298 if err != nil { 299 return nil, err 300 } 301 302 return &collection, nil 303 } 304 305 func (o *ClientV2) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) { 306 ret := make([]thirdparty.CollectionData, 0, len(contractIDs)) 307 308 for _, id := range contractIDs { 309 contractData, err := o.fetchContractDataByContractID(ctx, id) 310 if err != nil { 311 return nil, err 312 } 313 314 if contractData == nil || contractData.Collection == "" { 315 continue 316 } 317 318 collectionData, err := o.fetchCollectionDataBySlug(ctx, id.ChainID, contractData.Collection) 319 if err != nil { 320 return nil, err 321 } 322 323 ret = append(ret, collectionData.toCommon(id, contractData.ContractStandard)) 324 } 325 326 return ret, nil 327 }