github.com/status-im/status-go@v1.1.0/services/wallet/thirdparty/rarible/client.go (about) 1 package rarible 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io/ioutil" 8 "net/http" 9 "net/url" 10 "strconv" 11 "strings" 12 "time" 13 14 "github.com/cenkalti/backoff/v4" 15 16 "github.com/ethereum/go-ethereum/common" 17 "github.com/ethereum/go-ethereum/log" 18 19 walletCommon "github.com/status-im/status-go/services/wallet/common" 20 "github.com/status-im/status-go/services/wallet/connection" 21 "github.com/status-im/status-go/services/wallet/thirdparty" 22 ) 23 24 const ownedNFTLimit = 100 25 const collectionOwnershipLimit = 50 26 const nftMetadataBatchLimit = 50 27 const searchCollectiblesLimit = 1000 28 const searchCollectionsLimit = 1000 29 30 func (o *Client) ID() string { 31 return RaribleID 32 } 33 34 func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool { 35 _, err := getBaseURL(chainID) 36 return err == nil 37 } 38 39 func (o *Client) IsConnected() bool { 40 return o.connectionStatus.IsConnected() 41 } 42 43 func getBaseURL(chainID walletCommon.ChainID) (string, error) { 44 switch uint64(chainID) { 45 case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet: 46 return "https://api.rarible.org", nil 47 case walletCommon.EthereumSepolia, walletCommon.ArbitrumSepolia: 48 return "https://testnet-api.rarible.org", nil 49 } 50 51 return "", thirdparty.ErrChainIDNotSupported 52 } 53 54 func getItemBaseURL(chainID walletCommon.ChainID) (string, error) { 55 baseURL, err := getBaseURL(chainID) 56 57 if err != nil { 58 return "", err 59 } 60 61 return fmt.Sprintf("%s/v0.1/items", baseURL), nil 62 } 63 64 func getOwnershipBaseURL(chainID walletCommon.ChainID) (string, error) { 65 baseURL, err := getBaseURL(chainID) 66 67 if err != nil { 68 return "", err 69 } 70 71 return fmt.Sprintf("%s/v0.1/ownerships", baseURL), nil 72 } 73 74 func getCollectionBaseURL(chainID walletCommon.ChainID) (string, error) { 75 baseURL, err := getBaseURL(chainID) 76 77 if err != nil { 78 return "", err 79 } 80 81 return fmt.Sprintf("%s/v0.1/collections", baseURL), nil 82 } 83 84 type Client struct { 85 thirdparty.CollectibleContractOwnershipProvider 86 client *http.Client 87 mainnetAPIKey string 88 testnetAPIKey string 89 connectionStatus *connection.Status 90 } 91 92 func NewClient(mainnetAPIKey string, testnetAPIKey string) *Client { 93 if mainnetAPIKey == "" { 94 log.Warn("Rarible API key not available for Mainnet") 95 } 96 97 if testnetAPIKey == "" { 98 log.Warn("Rarible API key not available for Testnet") 99 } 100 101 return &Client{ 102 client: &http.Client{Timeout: time.Minute}, 103 mainnetAPIKey: mainnetAPIKey, 104 testnetAPIKey: testnetAPIKey, 105 connectionStatus: connection.NewStatus(), 106 } 107 } 108 109 func (o *Client) getAPIKey(chainID walletCommon.ChainID) string { 110 if chainID.IsMainnet() { 111 return o.mainnetAPIKey 112 } 113 return o.testnetAPIKey 114 } 115 116 func (o *Client) doQuery(ctx context.Context, url string, apiKey string) (*http.Response, error) { 117 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 118 if err != nil { 119 return nil, err 120 } 121 122 req.Header.Set("content-type", "application/json") 123 124 return o.doWithRetries(req, apiKey) 125 } 126 127 func (o *Client) doPostWithJSON(ctx context.Context, url string, payload any, apiKey string) (*http.Response, error) { 128 payloadJSON, err := json.Marshal(payload) 129 if err != nil { 130 return nil, err 131 } 132 133 payloadString := string(payloadJSON) 134 payloadReader := strings.NewReader(payloadString) 135 136 req, err := http.NewRequestWithContext(ctx, "POST", url, payloadReader) 137 if err != nil { 138 return nil, err 139 } 140 141 req.Header.Add("accept", "application/json") 142 req.Header.Add("content-type", "application/json") 143 144 return o.doWithRetries(req, apiKey) 145 } 146 147 func (o *Client) doWithRetries(req *http.Request, apiKey string) (*http.Response, error) { 148 b := backoff.NewExponentialBackOff() 149 b.InitialInterval = time.Millisecond * 1000 150 b.RandomizationFactor = 0.1 151 b.Multiplier = 1.5 152 b.MaxInterval = time.Second * 32 153 b.MaxElapsedTime = time.Second * 70 154 155 b.Reset() 156 157 req.Header.Set("X-API-KEY", apiKey) 158 159 op := func() (*http.Response, error) { 160 resp, err := o.client.Do(req) 161 if err != nil { 162 return nil, backoff.Permanent(err) 163 } 164 165 if resp.StatusCode == http.StatusOK { 166 return resp, nil 167 } 168 169 err = fmt.Errorf("unsuccessful request: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) 170 if resp.StatusCode == http.StatusTooManyRequests { 171 log.Error("doWithRetries failed with http.StatusTooManyRequests", "provider", o.ID(), "elapsed time", b.GetElapsedTime(), "next backoff", b.NextBackOff()) 172 return nil, err 173 } 174 return nil, backoff.Permanent(err) 175 } 176 177 return backoff.RetryWithData(op, b) 178 } 179 180 func (o *Client) FetchCollectibleOwnersByContractAddress(ctx context.Context, chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) { 181 ownership := thirdparty.CollectibleContractOwnership{ 182 ContractAddress: contractAddress, 183 Owners: make([]thirdparty.CollectibleOwner, 0), 184 } 185 186 queryParams := url.Values{ 187 "collection": {fmt.Sprintf("%s:%s", chainIDToChainString(chainID), contractAddress.String())}, 188 "size": {strconv.Itoa(collectionOwnershipLimit)}, 189 } 190 191 baseURL, err := getOwnershipBaseURL(chainID) 192 193 if err != nil { 194 return nil, err 195 } 196 197 for { 198 url := fmt.Sprintf("%s/byCollection?%s", baseURL, queryParams.Encode()) 199 200 resp, err := o.doQuery(ctx, url, o.getAPIKey(chainID)) 201 if err != nil { 202 if ctx.Err() == nil { 203 o.connectionStatus.SetIsConnected(false) 204 } 205 return nil, err 206 } 207 o.connectionStatus.SetIsConnected(true) 208 209 defer resp.Body.Close() 210 211 body, err := ioutil.ReadAll(resp.Body) 212 if err != nil { 213 return nil, err 214 } 215 216 var raribleOwnership ContractOwnershipContainer 217 err = json.Unmarshal(body, &raribleOwnership) 218 if err != nil { 219 return nil, err 220 } 221 222 ownership.Owners = append(ownership.Owners, raribleContractOwnershipsToCommon(raribleOwnership.Ownerships)...) 223 224 if raribleOwnership.Continuation == "" { 225 break 226 } 227 228 queryParams["continuation"] = []string{raribleOwnership.Continuation} 229 } 230 231 return &ownership, nil 232 } 233 234 func (o *Client) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { 235 assets := new(thirdparty.FullCollectibleDataContainer) 236 237 queryParams := url.Values{ 238 "owner": {fmt.Sprintf("%s:%s", ethereumString, owner.String())}, 239 "blockchains": {chainIDToChainString(chainID)}, 240 } 241 242 tmpLimit := ownedNFTLimit 243 if limit > thirdparty.FetchNoLimit && limit < tmpLimit { 244 tmpLimit = limit 245 } 246 queryParams["size"] = []string{strconv.Itoa(tmpLimit)} 247 248 if len(cursor) > 0 { 249 queryParams["continuation"] = []string{cursor} 250 assets.PreviousCursor = cursor 251 } 252 assets.Provider = o.ID() 253 254 baseURL, err := getItemBaseURL(chainID) 255 256 if err != nil { 257 return nil, err 258 } 259 260 for { 261 url := fmt.Sprintf("%s/byOwner?%s", baseURL, queryParams.Encode()) 262 263 resp, err := o.doQuery(ctx, url, o.getAPIKey(chainID)) 264 if err != nil { 265 if ctx.Err() == nil { 266 o.connectionStatus.SetIsConnected(false) 267 } 268 return nil, err 269 } 270 o.connectionStatus.SetIsConnected(true) 271 272 defer resp.Body.Close() 273 274 body, err := ioutil.ReadAll(resp.Body) 275 if err != nil { 276 return nil, err 277 } 278 279 // if Json is not returned there must be an error 280 if !json.Valid(body) { 281 return nil, fmt.Errorf("invalid json: %s", string(body)) 282 } 283 284 var container CollectiblesContainer 285 err = json.Unmarshal(body, &container) 286 if err != nil { 287 return nil, err 288 } 289 290 assets.Items = append(assets.Items, raribleToCollectiblesData(container.Collectibles, chainID.IsMainnet())...) 291 assets.NextCursor = container.Continuation 292 293 if len(assets.NextCursor) == 0 { 294 break 295 } 296 297 queryParams["continuation"] = []string{assets.NextCursor} 298 299 if limit != thirdparty.FetchNoLimit && len(assets.Items) >= limit { 300 break 301 } 302 } 303 304 return assets, nil 305 } 306 307 func (o *Client) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { 308 return nil, thirdparty.ErrEndpointNotSupported 309 } 310 311 func getCollectibleUniqueIDBatches(ids []thirdparty.CollectibleUniqueID) []BatchTokenIDs { 312 batches := make([]BatchTokenIDs, 0) 313 314 for startIdx := 0; startIdx < len(ids); startIdx += nftMetadataBatchLimit { 315 endIdx := startIdx + nftMetadataBatchLimit 316 if endIdx > len(ids) { 317 endIdx = len(ids) 318 } 319 320 pageIDs := ids[startIdx:endIdx] 321 322 batchIDs := BatchTokenIDs{ 323 IDs: make([]string, 0, len(pageIDs)), 324 } 325 for _, id := range pageIDs { 326 batchID := fmt.Sprintf("%s:%s:%s", chainIDToChainString(id.ContractID.ChainID), id.ContractID.Address.String(), id.TokenID.String()) 327 batchIDs.IDs = append(batchIDs.IDs, batchID) 328 } 329 330 batches = append(batches, batchIDs) 331 } 332 333 return batches 334 } 335 336 func (o *Client) fetchAssetsByBatchTokenIDs(ctx context.Context, chainID walletCommon.ChainID, batchIDs BatchTokenIDs) ([]thirdparty.FullCollectibleData, error) { 337 baseURL, err := getItemBaseURL(chainID) 338 339 if err != nil { 340 return nil, err 341 } 342 343 url := fmt.Sprintf("%s/byIds", baseURL) 344 345 resp, err := o.doPostWithJSON(ctx, url, batchIDs, o.getAPIKey(chainID)) 346 if err != nil { 347 return nil, err 348 } 349 350 defer resp.Body.Close() 351 352 body, err := ioutil.ReadAll(resp.Body) 353 if err != nil { 354 return nil, err 355 } 356 357 // if Json is not returned there must be an error 358 if !json.Valid(body) { 359 return nil, fmt.Errorf("invalid json: %s", string(body)) 360 } 361 362 var assets CollectiblesContainer 363 err = json.Unmarshal(body, &assets) 364 if err != nil { 365 return nil, err 366 } 367 368 ret := raribleToCollectiblesData(assets.Collectibles, chainID.IsMainnet()) 369 370 return ret, nil 371 } 372 373 func (o *Client) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) { 374 ret := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs)) 375 376 idsPerChainID := thirdparty.GroupCollectibleUIDsByChainID(uniqueIDs) 377 378 for chainID, ids := range idsPerChainID { 379 batches := getCollectibleUniqueIDBatches(ids) 380 for _, batch := range batches { 381 assets, err := o.fetchAssetsByBatchTokenIDs(ctx, chainID, batch) 382 if err != nil { 383 return nil, err 384 } 385 386 ret = append(ret, assets...) 387 } 388 } 389 390 return ret, nil 391 } 392 393 func (o *Client) FetchCollectionSocials(ctx context.Context, contractID thirdparty.ContractID) (*thirdparty.CollectionSocials, error) { 394 return nil, thirdparty.ErrEndpointNotSupported 395 } 396 397 func (o *Client) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) { 398 ret := make([]thirdparty.CollectionData, 0, len(contractIDs)) 399 400 for _, contractID := range contractIDs { 401 baseURL, err := getCollectionBaseURL(contractID.ChainID) 402 403 if err != nil { 404 return nil, err 405 } 406 407 url := fmt.Sprintf("%s/%s:%s", baseURL, chainIDToChainString(contractID.ChainID), contractID.Address.String()) 408 409 resp, err := o.doQuery(ctx, url, o.getAPIKey(contractID.ChainID)) 410 if err != nil { 411 if ctx.Err() == nil { 412 o.connectionStatus.SetIsConnected(false) 413 } 414 return nil, err 415 } 416 o.connectionStatus.SetIsConnected(true) 417 418 defer resp.Body.Close() 419 420 body, err := ioutil.ReadAll(resp.Body) 421 if err != nil { 422 return nil, err 423 } 424 425 // if Json is not returned there must be an error 426 if !json.Valid(body) { 427 return nil, fmt.Errorf("invalid json: %s", string(body)) 428 } 429 430 var collection Collection 431 err = json.Unmarshal(body, &collection) 432 if err != nil { 433 return nil, err 434 } 435 436 ret = append(ret, collection.toCommon(contractID)) 437 } 438 439 return ret, nil 440 } 441 442 func (o *Client) searchCollectibles(ctx context.Context, chainID walletCommon.ChainID, collections []common.Address, fullText CollectibleFilterFullText, sort CollectibleFilterContainerSort, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { 443 baseURL, err := getItemBaseURL(chainID) 444 if err != nil { 445 return nil, err 446 } 447 448 url := fmt.Sprintf("%s/search", baseURL) 449 450 ret := &thirdparty.FullCollectibleDataContainer{ 451 Provider: o.ID(), 452 Items: make([]thirdparty.FullCollectibleData, 0), 453 PreviousCursor: cursor, 454 NextCursor: "", 455 } 456 457 if fullText.Text == "" { 458 return ret, nil 459 } 460 461 tmpLimit := searchCollectiblesLimit 462 if limit > thirdparty.FetchNoLimit && limit < tmpLimit { 463 tmpLimit = limit 464 } 465 466 blockchainString := chainIDToChainString(chainID) 467 468 filterContainer := CollectibleFilterContainer{ 469 Cursor: cursor, 470 Limit: tmpLimit, 471 Filter: CollectibleFilter{ 472 Blockchains: []string{blockchainString}, 473 Deleted: false, 474 FullText: fullText, 475 }, 476 Sort: sort, 477 } 478 479 for _, collection := range collections { 480 filterContainer.Filter.Collections = append(filterContainer.Filter.Collections, fmt.Sprintf("%s:%s", blockchainString, collection.String())) 481 } 482 483 for { 484 resp, err := o.doPostWithJSON(ctx, url, filterContainer, o.getAPIKey(chainID)) 485 if err != nil { 486 if ctx.Err() == nil { 487 o.connectionStatus.SetIsConnected(false) 488 } 489 return nil, err 490 } 491 o.connectionStatus.SetIsConnected(true) 492 493 defer resp.Body.Close() 494 495 body, err := ioutil.ReadAll(resp.Body) 496 if err != nil { 497 return nil, err 498 } 499 500 // if Json is not returned there must be an error 501 if !json.Valid(body) { 502 return nil, fmt.Errorf("invalid json: %s", string(body)) 503 } 504 505 var collectibles CollectiblesContainer 506 err = json.Unmarshal(body, &collectibles) 507 if err != nil { 508 return nil, err 509 } 510 511 ret.Items = append(ret.Items, raribleToCollectiblesData(collectibles.Collectibles, chainID.IsMainnet())...) 512 ret.NextCursor = collectibles.Continuation 513 514 if len(ret.NextCursor) == 0 { 515 break 516 } 517 518 filterContainer.Cursor = ret.NextCursor 519 520 if limit != thirdparty.FetchNoLimit && len(ret.Items) >= limit { 521 break 522 } 523 } 524 525 return ret, nil 526 } 527 528 func (o *Client) searchCollections(ctx context.Context, chainID walletCommon.ChainID, text string, cursor string, limit int) (*thirdparty.CollectionDataContainer, error) { 529 baseURL, err := getCollectionBaseURL(chainID) 530 if err != nil { 531 return nil, err 532 } 533 534 url := fmt.Sprintf("%s/search", baseURL) 535 536 ret := &thirdparty.CollectionDataContainer{ 537 Provider: o.ID(), 538 Items: make([]thirdparty.CollectionData, 0), 539 PreviousCursor: cursor, 540 NextCursor: "", 541 } 542 543 if text == "" { 544 return ret, nil 545 } 546 547 tmpLimit := searchCollectionsLimit 548 if limit > thirdparty.FetchNoLimit && limit < tmpLimit { 549 tmpLimit = limit 550 } 551 552 filterContainer := CollectionFilterContainer{ 553 Cursor: cursor, 554 Limit: tmpLimit, 555 Filter: CollectionFilter{ 556 Blockchains: []string{chainIDToChainString(chainID)}, 557 Text: text, 558 }, 559 } 560 561 for { 562 resp, err := o.doPostWithJSON(ctx, url, filterContainer, o.getAPIKey(chainID)) 563 if err != nil { 564 if ctx.Err() == nil { 565 o.connectionStatus.SetIsConnected(false) 566 } 567 return nil, err 568 } 569 o.connectionStatus.SetIsConnected(true) 570 571 defer resp.Body.Close() 572 573 body, err := ioutil.ReadAll(resp.Body) 574 if err != nil { 575 return nil, err 576 } 577 578 // if Json is not returned there must be an error 579 if !json.Valid(body) { 580 return nil, fmt.Errorf("invalid json: %s", string(body)) 581 } 582 583 var collections CollectionsContainer 584 err = json.Unmarshal(body, &collections) 585 if err != nil { 586 return nil, err 587 } 588 589 ret.Items = append(ret.Items, raribleToCollectionsData(collections.Collections, chainID.IsMainnet())...) 590 ret.NextCursor = collections.Continuation 591 592 if len(ret.NextCursor) == 0 { 593 break 594 } 595 596 filterContainer.Cursor = ret.NextCursor 597 598 if limit != thirdparty.FetchNoLimit && len(ret.Items) >= limit { 599 break 600 } 601 } 602 603 return ret, nil 604 } 605 606 func (o *Client) SearchCollections(ctx context.Context, chainID walletCommon.ChainID, text string, cursor string, limit int) (*thirdparty.CollectionDataContainer, error) { 607 return o.searchCollections(ctx, chainID, text, cursor, limit) 608 } 609 610 func (o *Client) SearchCollectibles(ctx context.Context, chainID walletCommon.ChainID, collections []common.Address, text string, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { 611 fullText := CollectibleFilterFullText{ 612 Text: text, 613 Fields: []string{ 614 CollectibleFilterFullTextFieldName, 615 }, 616 } 617 618 sort := CollectibleFilterContainerSortRelevance 619 620 return o.searchCollectibles(ctx, chainID, collections, fullText, sort, cursor, limit) 621 }