github.com/status-im/status-go@v1.1.0/services/wallet/thirdparty/alchemy/client.go (about) 1 package alchemy 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io/ioutil" 8 "net/http" 9 "net/url" 10 "strings" 11 "time" 12 13 "github.com/cenkalti/backoff/v4" 14 15 "github.com/ethereum/go-ethereum/common" 16 "github.com/ethereum/go-ethereum/log" 17 18 walletCommon "github.com/status-im/status-go/services/wallet/common" 19 "github.com/status-im/status-go/services/wallet/connection" 20 "github.com/status-im/status-go/services/wallet/thirdparty" 21 ) 22 23 const nftMetadataBatchLimit = 100 24 const contractMetadataBatchLimit = 100 25 26 func getBaseURL(chainID walletCommon.ChainID) (string, error) { 27 switch uint64(chainID) { 28 case walletCommon.EthereumMainnet: 29 return "https://eth-mainnet.g.alchemy.com", nil 30 case walletCommon.EthereumGoerli: 31 return "https://eth-goerli.g.alchemy.com", nil 32 case walletCommon.EthereumSepolia: 33 return "https://eth-sepolia.g.alchemy.com", nil 34 case walletCommon.OptimismMainnet: 35 return "https://opt-mainnet.g.alchemy.com", nil 36 case walletCommon.OptimismSepolia: 37 return "https://opt-sepolia.g.alchemy.com", nil 38 case walletCommon.ArbitrumMainnet: 39 return "https://arb-mainnet.g.alchemy.com", nil 40 case walletCommon.ArbitrumGoerli: 41 return "https://arb-goerli.g.alchemy.com", nil 42 case walletCommon.ArbitrumSepolia: 43 return "https://arb-sepolia.g.alchemy.com", nil 44 } 45 46 return "", thirdparty.ErrChainIDNotSupported 47 } 48 49 func (o *Client) ID() string { 50 return AlchemyID 51 } 52 53 func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool { 54 _, err := getBaseURL(chainID) 55 return err == nil 56 } 57 58 func (o *Client) IsConnected() bool { 59 return o.connectionStatus.IsConnected() 60 } 61 62 func getAPIKeySubpath(apiKey string) string { 63 if apiKey == "" { 64 return "demo" 65 } 66 return apiKey 67 } 68 69 func getNFTBaseURL(chainID walletCommon.ChainID, apiKey string) (string, error) { 70 baseURL, err := getBaseURL(chainID) 71 72 if err != nil { 73 return "", err 74 } 75 76 return fmt.Sprintf("%s/nft/v3/%s", baseURL, getAPIKeySubpath(apiKey)), nil 77 } 78 79 type Client struct { 80 thirdparty.CollectibleContractOwnershipProvider 81 client *http.Client 82 apiKeys map[uint64]string 83 connectionStatus *connection.Status 84 } 85 86 func NewClient(apiKeys map[uint64]string) *Client { 87 for _, chainID := range walletCommon.AllChainIDs() { 88 if apiKeys[uint64(chainID)] == "" { 89 log.Warn("Alchemy API key not available for", "chainID", chainID) 90 } 91 } 92 93 return &Client{ 94 client: &http.Client{Timeout: time.Minute}, 95 apiKeys: apiKeys, 96 connectionStatus: connection.NewStatus(), 97 } 98 } 99 100 func (o *Client) doQuery(ctx context.Context, url string) (*http.Response, error) { 101 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 102 if err != nil { 103 return nil, err 104 } 105 106 return o.doWithRetries(req) 107 } 108 109 func (o *Client) doPostWithJSON(ctx context.Context, url string, payload any) (*http.Response, error) { 110 payloadJSON, err := json.Marshal(payload) 111 if err != nil { 112 return nil, err 113 } 114 115 payloadString := string(payloadJSON) 116 payloadReader := strings.NewReader(payloadString) 117 118 req, err := http.NewRequestWithContext(ctx, "POST", url, payloadReader) 119 if err != nil { 120 return nil, err 121 } 122 123 req.Header.Add("accept", "application/json") 124 req.Header.Add("content-type", "application/json") 125 126 return o.doWithRetries(req) 127 } 128 129 func (o *Client) doWithRetries(req *http.Request) (*http.Response, error) { 130 b := backoff.NewExponentialBackOff() 131 b.InitialInterval = time.Millisecond * 1000 132 b.RandomizationFactor = 0.1 133 b.Multiplier = 1.5 134 b.MaxInterval = time.Second * 32 135 b.MaxElapsedTime = time.Second * 70 136 137 b.Reset() 138 139 op := func() (*http.Response, error) { 140 resp, err := o.client.Do(req) 141 if err != nil { 142 return nil, backoff.Permanent(err) 143 } 144 145 if resp.StatusCode == http.StatusOK { 146 return resp, nil 147 } 148 149 err = fmt.Errorf("unsuccessful request: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) 150 if resp.StatusCode == http.StatusTooManyRequests { 151 log.Error("doWithRetries failed with http.StatusTooManyRequests", "provider", o.ID(), "elapsed time", b.GetElapsedTime(), "next backoff", b.NextBackOff()) 152 return nil, err 153 } 154 return nil, backoff.Permanent(err) 155 } 156 157 return backoff.RetryWithData(op, b) 158 } 159 160 func (o *Client) FetchCollectibleOwnersByContractAddress(ctx context.Context, chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) { 161 ownership := thirdparty.CollectibleContractOwnership{ 162 ContractAddress: contractAddress, 163 Owners: make([]thirdparty.CollectibleOwner, 0), 164 } 165 166 queryParams := url.Values{ 167 "contractAddress": {contractAddress.String()}, 168 "withTokenBalances": {"true"}, 169 } 170 171 baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)]) 172 173 if err != nil { 174 return nil, err 175 } 176 177 for { 178 url := fmt.Sprintf("%s/getOwnersForContract?%s", baseURL, queryParams.Encode()) 179 180 resp, err := o.doQuery(ctx, url) 181 if err != nil { 182 if ctx.Err() == nil { 183 o.connectionStatus.SetIsConnected(false) 184 } 185 return nil, err 186 } 187 o.connectionStatus.SetIsConnected(true) 188 189 defer resp.Body.Close() 190 191 body, err := ioutil.ReadAll(resp.Body) 192 if err != nil { 193 return nil, err 194 } 195 196 var alchemyOwnership CollectibleContractOwnership 197 err = json.Unmarshal(body, &alchemyOwnership) 198 if err != nil { 199 return nil, err 200 } 201 202 ownership.Owners = append(ownership.Owners, alchemyCollectibleOwnersToCommon(alchemyOwnership.Owners)...) 203 204 if alchemyOwnership.PageKey == "" { 205 break 206 } 207 208 queryParams["pageKey"] = []string{alchemyOwnership.PageKey} 209 } 210 211 return &ownership, nil 212 } 213 214 func (o *Client) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { 215 queryParams := url.Values{} 216 217 return o.fetchOwnedAssets(ctx, chainID, owner, queryParams, cursor, limit) 218 } 219 220 func (o *Client) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { 221 queryParams := url.Values{} 222 223 for _, contractAddress := range contractAddresses { 224 queryParams.Add("contractAddresses", contractAddress.String()) 225 } 226 227 return o.fetchOwnedAssets(ctx, chainID, owner, queryParams, cursor, limit) 228 } 229 230 func (o *Client) fetchOwnedAssets(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, queryParams url.Values, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) { 231 assets := new(thirdparty.FullCollectibleDataContainer) 232 233 queryParams["owner"] = []string{owner.String()} 234 queryParams["withMetadata"] = []string{"true"} 235 236 if len(cursor) > 0 { 237 queryParams["pageKey"] = []string{cursor} 238 assets.PreviousCursor = cursor 239 } 240 assets.Provider = o.ID() 241 242 baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)]) 243 244 if err != nil { 245 return nil, err 246 } 247 248 for { 249 url := fmt.Sprintf("%s/getNFTsForOwner?%s", baseURL, queryParams.Encode()) 250 251 resp, err := o.doQuery(ctx, url) 252 if err != nil { 253 if ctx.Err() == nil { 254 o.connectionStatus.SetIsConnected(false) 255 } 256 return nil, err 257 } 258 o.connectionStatus.SetIsConnected(true) 259 260 defer resp.Body.Close() 261 262 body, err := ioutil.ReadAll(resp.Body) 263 if err != nil { 264 return nil, err 265 } 266 267 // if Json is not returned there must be an error 268 if !json.Valid(body) { 269 return nil, fmt.Errorf("invalid json: %s", string(body)) 270 } 271 272 container := OwnedNFTList{} 273 err = json.Unmarshal(body, &container) 274 if err != nil { 275 return nil, err 276 } 277 278 assets.Items = append(assets.Items, alchemyToCollectiblesData(chainID, container.OwnedNFTs)...) 279 assets.NextCursor = container.PageKey 280 281 if len(assets.NextCursor) == 0 { 282 break 283 } 284 285 queryParams["cursor"] = []string{assets.NextCursor} 286 287 if limit != thirdparty.FetchNoLimit && len(assets.Items) >= limit { 288 break 289 } 290 } 291 292 return assets, nil 293 } 294 295 func getCollectibleUniqueIDBatches(ids []thirdparty.CollectibleUniqueID) []BatchTokenIDs { 296 batches := make([]BatchTokenIDs, 0) 297 298 for startIdx := 0; startIdx < len(ids); startIdx += nftMetadataBatchLimit { 299 endIdx := startIdx + nftMetadataBatchLimit 300 if endIdx > len(ids) { 301 endIdx = len(ids) 302 } 303 304 pageIDs := ids[startIdx:endIdx] 305 306 batchIDs := BatchTokenIDs{ 307 IDs: make([]TokenID, 0, len(pageIDs)), 308 } 309 for _, id := range pageIDs { 310 batchID := TokenID{ 311 ContractAddress: id.ContractID.Address, 312 TokenID: id.TokenID, 313 } 314 batchIDs.IDs = append(batchIDs.IDs, batchID) 315 } 316 317 batches = append(batches, batchIDs) 318 } 319 320 return batches 321 } 322 323 func (o *Client) fetchAssetsByBatchTokenIDs(ctx context.Context, chainID walletCommon.ChainID, batchIDs BatchTokenIDs) ([]thirdparty.FullCollectibleData, error) { 324 baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)]) 325 if err != nil { 326 return nil, err 327 } 328 329 url := fmt.Sprintf("%s/getNFTMetadataBatch", baseURL) 330 331 resp, err := o.doPostWithJSON(ctx, url, batchIDs) 332 if err != nil { 333 return nil, err 334 } 335 336 defer resp.Body.Close() 337 338 body, err := ioutil.ReadAll(resp.Body) 339 if err != nil { 340 return nil, err 341 } 342 343 // if Json is not returned there must be an error 344 if !json.Valid(body) { 345 return nil, fmt.Errorf("invalid json: %s", string(body)) 346 } 347 348 assets := NFTList{} 349 err = json.Unmarshal(body, &assets) 350 if err != nil { 351 return nil, err 352 } 353 354 ret := alchemyToCollectiblesData(chainID, assets.NFTs) 355 356 return ret, nil 357 } 358 359 func (o *Client) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) { 360 ret := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs)) 361 362 idsPerChainID := thirdparty.GroupCollectibleUIDsByChainID(uniqueIDs) 363 364 for chainID, ids := range idsPerChainID { 365 batches := getCollectibleUniqueIDBatches(ids) 366 for _, batch := range batches { 367 assets, err := o.fetchAssetsByBatchTokenIDs(ctx, chainID, batch) 368 if err != nil { 369 return nil, err 370 } 371 372 ret = append(ret, assets...) 373 } 374 } 375 376 return ret, nil 377 } 378 379 func (o *Client) FetchCollectionSocials(ctx context.Context, contractID thirdparty.ContractID) (*thirdparty.CollectionSocials, error) { 380 resp, err := o.FetchCollectionsDataByContractID(ctx, []thirdparty.ContractID{contractID}) 381 if err != nil { 382 return nil, err 383 } 384 if len(resp) > 0 { 385 return resp[0].Socials, nil 386 } 387 return nil, nil 388 } 389 390 func getContractAddressBatches(ids []thirdparty.ContractID) []BatchContractAddresses { 391 batches := make([]BatchContractAddresses, 0) 392 393 for startIdx := 0; startIdx < len(ids); startIdx += contractMetadataBatchLimit { 394 endIdx := startIdx + contractMetadataBatchLimit 395 if endIdx > len(ids) { 396 endIdx = len(ids) 397 } 398 399 pageIDs := ids[startIdx:endIdx] 400 401 batchIDs := BatchContractAddresses{ 402 Addresses: make([]common.Address, 0, len(pageIDs)), 403 } 404 for _, id := range pageIDs { 405 batchIDs.Addresses = append(batchIDs.Addresses, id.Address) 406 } 407 408 batches = append(batches, batchIDs) 409 } 410 411 return batches 412 } 413 414 func (o *Client) fetchCollectionsDataByBatchContractAddresses(ctx context.Context, chainID walletCommon.ChainID, batchAddresses BatchContractAddresses) ([]thirdparty.CollectionData, error) { 415 baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)]) 416 if err != nil { 417 return nil, err 418 } 419 420 url := fmt.Sprintf("%s/getContractMetadataBatch", baseURL) 421 422 resp, err := o.doPostWithJSON(ctx, url, batchAddresses) 423 if err != nil { 424 return nil, err 425 } 426 427 defer resp.Body.Close() 428 429 body, err := ioutil.ReadAll(resp.Body) 430 if err != nil { 431 return nil, err 432 } 433 434 // if Json is not returned there must be an error 435 if !json.Valid(body) { 436 return nil, fmt.Errorf("invalid json: %s", string(body)) 437 } 438 439 collections := ContractList{} 440 err = json.Unmarshal(body, &collections) 441 if err != nil { 442 return nil, err 443 } 444 445 ret := alchemyToCollectionsData(chainID, collections.Contracts) 446 447 return ret, nil 448 } 449 450 func (o *Client) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) { 451 ret := make([]thirdparty.CollectionData, 0, len(contractIDs)) 452 453 idsPerChainID := thirdparty.GroupContractIDsByChainID(contractIDs) 454 455 for chainID, ids := range idsPerChainID { 456 batches := getContractAddressBatches(ids) 457 for _, batch := range batches { 458 contractsData, err := o.fetchCollectionsDataByBatchContractAddresses(ctx, chainID, batch) 459 if err != nil { 460 return nil, err 461 } 462 463 ret = append(ret, contractsData...) 464 } 465 } 466 467 return ret, nil 468 }