github.com/status-im/status-go@v1.1.0/services/wallet/collectibles/manager.go (about) 1 package collectibles 2 3 import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "math/big" 9 "net/http" 10 "strings" 11 "sync" 12 "time" 13 14 "github.com/ethereum/go-ethereum/accounts/abi/bind" 15 "github.com/ethereum/go-ethereum/common" 16 "github.com/ethereum/go-ethereum/event" 17 "github.com/ethereum/go-ethereum/log" 18 "github.com/status-im/status-go/circuitbreaker" 19 "github.com/status-im/status-go/contracts/community-tokens/collectibles" 20 "github.com/status-im/status-go/contracts/ierc1155" 21 "github.com/status-im/status-go/rpc" 22 "github.com/status-im/status-go/rpc/chain" 23 "github.com/status-im/status-go/server" 24 "github.com/status-im/status-go/services/wallet/async" 25 "github.com/status-im/status-go/services/wallet/bigint" 26 walletCommon "github.com/status-im/status-go/services/wallet/common" 27 "github.com/status-im/status-go/services/wallet/community" 28 "github.com/status-im/status-go/services/wallet/connection" 29 "github.com/status-im/status-go/services/wallet/thirdparty" 30 "github.com/status-im/status-go/services/wallet/walletevent" 31 ) 32 33 const requestTimeout = 5 * time.Second 34 const signalUpdatedCollectiblesDataPageSize = 10 35 36 const EventCollectiblesConnectionStatusChanged walletevent.EventType = "wallet-collectible-status-changed" 37 38 // ERC721 does not support function "TokenURI" if call 39 // returns error starting with one of these strings 40 var noTokenURIErrorPrefixes = []string{ 41 "execution reverted", 42 "abi: attempting to unmarshall", 43 } 44 45 var ( 46 ErrAllProvidersFailedForChainID = errors.New("all providers failed for chainID") 47 ErrNoProvidersAvailableForChainID = errors.New("no providers available for chainID") 48 ) 49 50 type ManagerInterface interface { 51 FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID, asyncFetch bool) ([]thirdparty.FullCollectibleData, error) 52 FetchCollectionSocialsAsync(contractID thirdparty.ContractID) error 53 } 54 55 type Manager struct { 56 rpcClient rpc.ClientInterface 57 providers thirdparty.CollectibleProviders 58 59 httpClient *http.Client 60 61 collectiblesDataDB CollectibleDataStorage 62 collectionsDataDB CollectionDataStorage 63 communityManager *community.Manager 64 ownershipDB *OwnershipDB 65 66 mediaServer *server.MediaServer 67 68 statuses *sync.Map 69 statusNotifier *connection.StatusNotifier 70 feed *event.Feed 71 circuitBreaker *circuitbreaker.CircuitBreaker 72 } 73 74 func NewManager( 75 db *sql.DB, 76 rpcClient rpc.ClientInterface, 77 communityManager *community.Manager, 78 providers thirdparty.CollectibleProviders, 79 mediaServer *server.MediaServer, 80 feed *event.Feed) *Manager { 81 82 var ownershipDB *OwnershipDB 83 var statuses *sync.Map 84 var statusNotifier *connection.StatusNotifier 85 if db != nil { 86 ownershipDB = NewOwnershipDB(db) 87 statuses = initStatuses(ownershipDB) 88 statusNotifier = createStatusNotifier(statuses, feed) 89 } 90 91 cb := circuitbreaker.NewCircuitBreaker(circuitbreaker.Config{ 92 Timeout: 10000, 93 MaxConcurrentRequests: 100, 94 RequestVolumeThreshold: 25, 95 SleepWindow: 300000, 96 ErrorPercentThreshold: 25, 97 }) 98 99 return &Manager{ 100 rpcClient: rpcClient, 101 providers: providers, 102 httpClient: &http.Client{ 103 Timeout: requestTimeout, 104 }, 105 collectiblesDataDB: NewCollectibleDataDB(db), 106 collectionsDataDB: NewCollectionDataDB(db), 107 communityManager: communityManager, 108 ownershipDB: ownershipDB, 109 mediaServer: mediaServer, 110 statuses: statuses, 111 statusNotifier: statusNotifier, 112 feed: feed, 113 circuitBreaker: cb, 114 } 115 } 116 117 func mapToList[K comparable, T any](m map[K]T) []T { 118 list := make([]T, 0, len(m)) 119 for _, v := range m { 120 list = append(list, v) 121 } 122 return list 123 } 124 125 func (o *Manager) doContentTypeRequest(ctx context.Context, url string) (string, error) { 126 req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) 127 if err != nil { 128 return "", err 129 } 130 131 resp, err := o.httpClient.Do(req) 132 if err != nil { 133 return "", err 134 } 135 defer func() { 136 if err := resp.Body.Close(); err != nil { 137 log.Error("failed to close head request body", "err", err) 138 } 139 }() 140 141 return resp.Header.Get("Content-Type"), nil 142 } 143 144 func (o *Manager) getTokenBalancesByOwnerAddress(collectibles *thirdparty.CollectibleContractOwnership, ownerAddress common.Address) map[common.Address][]thirdparty.TokenBalance { 145 ret := make(map[common.Address][]thirdparty.TokenBalance) 146 147 for _, nftOwner := range collectibles.Owners { 148 if nftOwner.OwnerAddress == ownerAddress { 149 ret[collectibles.ContractAddress] = nftOwner.TokenBalances 150 break 151 } 152 } 153 154 return ret 155 } 156 157 func (o *Manager) FetchCachedBalancesByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, ownerAddress common.Address, contractAddresses []common.Address) (thirdparty.TokenBalancesPerContractAddress, error) { 158 ret := make(map[common.Address][]thirdparty.TokenBalance) 159 160 for _, contractAddress := range contractAddresses { 161 ret[contractAddress] = make([]thirdparty.TokenBalance, 0) 162 } 163 164 for _, contractAddress := range contractAddresses { 165 ownership, err := o.ownershipDB.FetchCachedCollectibleOwnersByContractAddress(chainID, contractAddress) 166 if err != nil { 167 return nil, err 168 } 169 170 t := o.getTokenBalancesByOwnerAddress(ownership, ownerAddress) 171 172 for address, tokenBalances := range t { 173 ret[address] = append(ret[address], tokenBalances...) 174 } 175 } 176 177 return ret, nil 178 } 179 180 // Need to combine different providers to support all needed ChainIDs 181 func (o *Manager) FetchBalancesByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, ownerAddress common.Address, contractAddresses []common.Address) (thirdparty.TokenBalancesPerContractAddress, error) { 182 ret := make(thirdparty.TokenBalancesPerContractAddress) 183 184 for _, contractAddress := range contractAddresses { 185 ret[contractAddress] = make([]thirdparty.TokenBalance, 0) 186 } 187 188 // Try with account ownership providers first 189 assetsContainer, err := o.FetchAllAssetsByOwnerAndContractAddress(ctx, chainID, ownerAddress, contractAddresses, thirdparty.FetchFromStartCursor, thirdparty.FetchNoLimit, thirdparty.FetchFromAnyProvider) 190 if err == ErrNoProvidersAvailableForChainID { 191 // Use contract ownership providers 192 for _, contractAddress := range contractAddresses { 193 ownership, err := o.FetchCollectibleOwnersByContractAddress(ctx, chainID, contractAddress) 194 if err != nil { 195 return nil, err 196 } 197 198 ret = o.getTokenBalancesByOwnerAddress(ownership, ownerAddress) 199 } 200 } else if err == nil { 201 // Account ownership providers succeeded 202 for _, fullData := range assetsContainer.Items { 203 contractAddress := fullData.CollectibleData.ID.ContractID.Address 204 balance := thirdparty.TokenBalance{ 205 TokenID: fullData.CollectibleData.ID.TokenID, 206 Balance: &bigint.BigInt{Int: big.NewInt(1)}, 207 } 208 ret[contractAddress] = append(ret[contractAddress], balance) 209 } 210 } else { 211 // OpenSea could have provided, but returned error 212 return nil, err 213 } 214 215 return ret, nil 216 } 217 218 func (o *Manager) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int, providerID string) (*thirdparty.FullCollectibleDataContainer, error) { 219 defer o.checkConnectionStatus(chainID) 220 221 cmd := circuitbreaker.NewCommand(ctx, nil) 222 for _, provider := range o.providers.AccountOwnershipProviders { 223 if !provider.IsChainSupported(chainID) { 224 continue 225 } 226 if providerID != thirdparty.FetchFromAnyProvider && providerID != provider.ID() { 227 continue 228 } 229 230 provider := provider 231 f := circuitbreaker.NewFunctor( 232 func() ([]interface{}, error) { 233 assetContainer, err := provider.FetchAllAssetsByOwnerAndContractAddress(ctx, chainID, owner, contractAddresses, cursor, limit) 234 if err != nil { 235 log.Error("FetchAllAssetsByOwnerAndContractAddress failed for", "provider", provider.ID(), "chainID", chainID, "err", err) 236 } 237 return []interface{}{assetContainer}, err 238 }, getCircuitName(provider, chainID), 239 ) 240 cmd.Add(f) 241 } 242 243 if cmd.IsEmpty() { 244 return nil, ErrNoProvidersAvailableForChainID 245 } 246 247 cmdRes := o.circuitBreaker.Execute(cmd) 248 if cmdRes.Error() != nil { 249 log.Error("FetchAllAssetsByOwnerAndContractAddress failed for", "chainID", chainID, "err", cmdRes.Error()) 250 return nil, cmdRes.Error() 251 } 252 253 assetContainer := cmdRes.Result()[0].(*thirdparty.FullCollectibleDataContainer) 254 _, err := o.processFullCollectibleData(ctx, assetContainer.Items, true) 255 if err != nil { 256 return nil, err 257 } 258 259 return assetContainer, nil 260 } 261 262 func (o *Manager) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int, providerID string) (*thirdparty.FullCollectibleDataContainer, error) { 263 defer o.checkConnectionStatus(chainID) 264 265 cmd := circuitbreaker.NewCommand(ctx, nil) 266 for _, provider := range o.providers.AccountOwnershipProviders { 267 if !provider.IsChainSupported(chainID) { 268 continue 269 } 270 if providerID != thirdparty.FetchFromAnyProvider && providerID != provider.ID() { 271 continue 272 } 273 274 provider := provider 275 f := circuitbreaker.NewFunctor( 276 func() ([]interface{}, error) { 277 assetContainer, err := provider.FetchAllAssetsByOwner(ctx, chainID, owner, cursor, limit) 278 if err != nil { 279 log.Error("FetchAllAssetsByOwner failed for", "provider", provider.ID(), "chainID", chainID, "err", err) 280 } 281 return []interface{}{assetContainer}, err 282 }, getCircuitName(provider, chainID), 283 ) 284 cmd.Add(f) 285 } 286 287 if cmd.IsEmpty() { 288 return nil, ErrNoProvidersAvailableForChainID 289 } 290 291 cmdRes := o.circuitBreaker.Execute(cmd) 292 if cmdRes.Error() != nil { 293 log.Error("FetchAllAssetsByOwner failed for", "chainID", chainID, "err", cmdRes.Error()) 294 return nil, cmdRes.Error() 295 } 296 297 assetContainer := cmdRes.Result()[0].(*thirdparty.FullCollectibleDataContainer) 298 _, err := o.processFullCollectibleData(ctx, assetContainer.Items, true) 299 if err != nil { 300 return nil, err 301 } 302 303 return assetContainer, nil 304 } 305 306 func (o *Manager) FetchERC1155Balances(ctx context.Context, owner common.Address, chainID walletCommon.ChainID, contractAddress common.Address, tokenIDs []*bigint.BigInt) ([]*bigint.BigInt, error) { 307 if len(tokenIDs) == 0 { 308 return nil, nil 309 } 310 311 backend, err := o.rpcClient.EthClient(uint64(chainID)) 312 if err != nil { 313 return nil, err 314 } 315 316 caller, err := ierc1155.NewIerc1155Caller(contractAddress, backend) 317 if err != nil { 318 return nil, err 319 } 320 321 owners := make([]common.Address, len(tokenIDs)) 322 ids := make([]*big.Int, len(tokenIDs)) 323 for i, tokenID := range tokenIDs { 324 owners[i] = owner 325 ids[i] = tokenID.Int 326 } 327 328 balances, err := caller.BalanceOfBatch(&bind.CallOpts{ 329 Context: ctx, 330 }, owners, ids) 331 332 if err != nil { 333 return nil, err 334 } 335 336 bigIntBalances := make([]*bigint.BigInt, len(balances)) 337 for i, balance := range balances { 338 bigIntBalances[i] = &bigint.BigInt{Int: balance} 339 } 340 341 return bigIntBalances, err 342 } 343 344 func (o *Manager) fillMissingBalances(ctx context.Context, owner common.Address, collectibles []*thirdparty.FullCollectibleData) { 345 collectiblesByChainIDAndContractAddress := thirdparty.GroupCollectiblesByChainIDAndContractAddress(collectibles) 346 347 for chainID, collectiblesByContract := range collectiblesByChainIDAndContractAddress { 348 for contractAddress, contractCollectibles := range collectiblesByContract { 349 collectiblesToFetchPerTokenID := make(map[string]*thirdparty.FullCollectibleData) 350 351 for _, collectible := range contractCollectibles { 352 if collectible.AccountBalance == nil { 353 switch getContractType(*collectible) { 354 case walletCommon.ContractTypeERC1155: 355 collectiblesToFetchPerTokenID[collectible.CollectibleData.ID.TokenID.String()] = collectible 356 default: 357 // Any other type of collectible is non-fungible, balance is 1 358 collectible.AccountBalance = &bigint.BigInt{Int: big.NewInt(1)} 359 } 360 } 361 } 362 363 if len(collectiblesToFetchPerTokenID) == 0 { 364 continue 365 } 366 367 tokenIDs := make([]*bigint.BigInt, 0, len(collectiblesToFetchPerTokenID)) 368 for _, c := range collectiblesToFetchPerTokenID { 369 tokenIDs = append(tokenIDs, c.CollectibleData.ID.TokenID) 370 } 371 372 balances, err := o.FetchERC1155Balances(ctx, owner, chainID, contractAddress, tokenIDs) 373 if err != nil { 374 log.Error("FetchERC1155Balances failed", "chainID", chainID, "contractAddress", contractAddress, "err", err) 375 continue 376 } 377 378 for i := range balances { 379 collectible := collectiblesToFetchPerTokenID[tokenIDs[i].String()] 380 collectible.AccountBalance = balances[i] 381 } 382 } 383 } 384 } 385 386 func (o *Manager) FetchCollectibleOwnershipByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int, providerID string) (*thirdparty.CollectibleOwnershipContainer, error) { 387 // We don't yet have an API that will return only Ownership data 388 // Use the full Ownership + Metadata endpoint and use the data we need 389 assetContainer, err := o.FetchAllAssetsByOwner(ctx, chainID, owner, cursor, limit, providerID) 390 if err != nil { 391 return nil, err 392 } 393 394 // Some providers do not give us the balances for ERC1155 tokens, so we need to fetch them separately. 395 collectibles := make([]*thirdparty.FullCollectibleData, 0, len(assetContainer.Items)) 396 for i := range assetContainer.Items { 397 collectibles = append(collectibles, &assetContainer.Items[i]) 398 } 399 o.fillMissingBalances(ctx, owner, collectibles) 400 401 ret := assetContainer.ToOwnershipContainer() 402 403 return &ret, nil 404 } 405 406 // Returns collectible metadata for the given unique IDs. 407 // If asyncFetch is true, empty metadata will be returned for any missing collectibles and an EventCollectiblesDataUpdated will be sent when the data is ready. 408 // If asyncFetch is false, it will wait for all collectibles' metadata to be retrieved before returning. 409 func (o *Manager) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID, asyncFetch bool) ([]thirdparty.FullCollectibleData, error) { 410 err := o.FetchMissingAssetsByCollectibleUniqueID(ctx, uniqueIDs, asyncFetch) 411 if err != nil { 412 return nil, err 413 } 414 415 return o.getCacheFullCollectibleData(uniqueIDs) 416 } 417 418 func (o *Manager) FetchMissingAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID, asyncFetch bool) error { 419 missingIDs, err := o.collectiblesDataDB.GetIDsNotInDB(uniqueIDs) 420 if err != nil { 421 return err 422 } 423 424 missingIDsPerChainID := thirdparty.GroupCollectibleUIDsByChainID(missingIDs) 425 426 // Atomic group stores the error from the first failed command and stops other commands on error 427 group := async.NewAtomicGroup(ctx) 428 for chainID, idsToFetch := range missingIDsPerChainID { 429 group.Add(func(ctx context.Context) error { 430 defer o.checkConnectionStatus(chainID) 431 432 fetchedAssets, err := o.fetchMissingAssetsForChainByCollectibleUniqueID(ctx, chainID, idsToFetch) 433 if err != nil { 434 log.Error("FetchMissingAssetsByCollectibleUniqueID failed for", "chainID", chainID, "ids", idsToFetch, "err", err) 435 return err 436 } 437 438 updatedCollectibles, err := o.processFullCollectibleData(ctx, fetchedAssets, asyncFetch) 439 if err != nil { 440 log.Error("processFullCollectibleData failed for", "chainID", chainID, "len(fetchedAssets)", len(fetchedAssets), "err", err) 441 return err 442 } 443 444 o.signalUpdatedCollectiblesData(updatedCollectibles) 445 return nil 446 }) 447 } 448 449 if asyncFetch { 450 group.Wait() 451 return group.Error() 452 } 453 454 return nil 455 } 456 457 func (o *Manager) fetchMissingAssetsForChainByCollectibleUniqueID(ctx context.Context, chainID walletCommon.ChainID, idsToFetch []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) { 458 cmd := circuitbreaker.NewCommand(ctx, nil) 459 for _, provider := range o.providers.CollectibleDataProviders { 460 if !provider.IsChainSupported(chainID) { 461 continue 462 } 463 464 provider := provider 465 cmd.Add(circuitbreaker.NewFunctor(func() ([]any, error) { 466 fetchedAssets, err := provider.FetchAssetsByCollectibleUniqueID(ctx, idsToFetch) 467 if err != nil { 468 log.Error("fetchMissingAssetsForChainByCollectibleUniqueID failed for", "provider", provider.ID(), "chainID", chainID, "err", err) 469 } 470 471 return []any{fetchedAssets}, err 472 }, getCircuitName(provider, chainID))) 473 } 474 475 if cmd.IsEmpty() { 476 return nil, ErrNoProvidersAvailableForChainID // lets not stop the group if no providers are available for the chain 477 } 478 479 cmdRes := o.circuitBreaker.Execute(cmd) 480 if cmdRes.Error() != nil { 481 log.Error("fetchMissingAssetsForChainByCollectibleUniqueID failed for", "chainID", chainID, "err", cmdRes.Error()) 482 return nil, cmdRes.Error() 483 } 484 return cmdRes.Result()[0].([]thirdparty.FullCollectibleData), cmdRes.Error() 485 } 486 487 func (o *Manager) FetchCollectionsDataByContractID(ctx context.Context, ids []thirdparty.ContractID) ([]thirdparty.CollectionData, error) { 488 missingIDs, err := o.collectionsDataDB.GetIDsNotInDB(ids) 489 if err != nil { 490 return nil, err 491 } 492 493 missingIDsPerChainID := thirdparty.GroupContractIDsByChainID(missingIDs) 494 495 // Atomic group stores the error from the first failed command and stops other commands on error 496 group := async.NewAtomicGroup(ctx) 497 for chainID, idsToFetch := range missingIDsPerChainID { 498 group.Add(func(ctx context.Context) error { 499 defer o.checkConnectionStatus(chainID) 500 501 cmd := circuitbreaker.NewCommand(ctx, nil) 502 for _, provider := range o.providers.CollectionDataProviders { 503 if !provider.IsChainSupported(chainID) { 504 continue 505 } 506 507 provider := provider 508 cmd.Add(circuitbreaker.NewFunctor(func() ([]any, error) { 509 fetchedCollections, err := provider.FetchCollectionsDataByContractID(ctx, idsToFetch) 510 return []any{fetchedCollections}, err 511 }, getCircuitName(provider, chainID))) 512 } 513 514 if cmd.IsEmpty() { 515 return nil 516 } 517 518 cmdRes := o.circuitBreaker.Execute(cmd) 519 if cmdRes.Error() != nil { 520 log.Error("FetchCollectionsDataByContractID failed for", "chainID", chainID, "err", cmdRes.Error()) 521 return cmdRes.Error() 522 } 523 524 fetchedCollections := cmdRes.Result()[0].([]thirdparty.CollectionData) 525 err = o.processCollectionData(ctx, fetchedCollections) 526 if err != nil { 527 return err 528 } 529 530 return err 531 }) 532 } 533 534 group.Wait() 535 536 if group.Error() != nil { 537 return nil, group.Error() 538 } 539 540 data, err := o.collectionsDataDB.GetData(ids) 541 if err != nil { 542 return nil, err 543 } 544 545 return mapToList(data), nil 546 } 547 548 func (o *Manager) GetCollectibleOwnership(id thirdparty.CollectibleUniqueID) ([]thirdparty.AccountBalance, error) { 549 return o.ownershipDB.GetOwnership(id) 550 } 551 552 func (o *Manager) FetchCollectibleOwnersByContractAddress(ctx context.Context, chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) { 553 defer o.checkConnectionStatus(chainID) 554 555 cmd := circuitbreaker.NewCommand(ctx, nil) 556 for _, provider := range o.providers.ContractOwnershipProviders { 557 if !provider.IsChainSupported(chainID) { 558 continue 559 } 560 561 provider := provider 562 cmd.Add(circuitbreaker.NewFunctor(func() ([]any, error) { 563 res, err := provider.FetchCollectibleOwnersByContractAddress(ctx, chainID, contractAddress) 564 if err != nil { 565 log.Error("FetchCollectibleOwnersByContractAddress failed for", "provider", provider.ID(), "chainID", chainID, "err", err) 566 } 567 return []any{res}, err 568 }, getCircuitName(provider, chainID))) 569 } 570 571 if cmd.IsEmpty() { 572 return nil, ErrNoProvidersAvailableForChainID 573 } 574 575 cmdRes := o.circuitBreaker.Execute(cmd) 576 if cmdRes.Error() != nil { 577 log.Error("FetchCollectibleOwnersByContractAddress failed for", "chainID", chainID, "err", cmdRes.Error()) 578 return nil, cmdRes.Error() 579 } 580 return cmdRes.Result()[0].(*thirdparty.CollectibleContractOwnership), cmdRes.Error() 581 } 582 583 func (o *Manager) fetchTokenURI(ctx context.Context, id thirdparty.CollectibleUniqueID) (string, error) { 584 if id.TokenID == nil { 585 return "", errors.New("empty token ID") 586 } 587 588 backend, err := o.rpcClient.EthClient(uint64(id.ContractID.ChainID)) 589 if err != nil { 590 return "", err 591 } 592 593 backend = getClientWithNoCircuitTripping(backend) 594 caller, err := collectibles.NewCollectiblesCaller(id.ContractID.Address, backend) 595 596 if err != nil { 597 return "", err 598 } 599 600 tokenURI, err := caller.TokenURI(&bind.CallOpts{ 601 Context: ctx, 602 }, id.TokenID.Int) 603 604 if err != nil { 605 for _, errorPrefix := range noTokenURIErrorPrefixes { 606 if strings.Contains(err.Error(), errorPrefix) { 607 // Contract doesn't support "TokenURI" method 608 return "", nil 609 } 610 } 611 return "", err 612 } 613 614 return tokenURI, err 615 } 616 617 func isMetadataEmpty(asset thirdparty.CollectibleData) bool { 618 return asset.Description == "" && 619 asset.ImageURL == "" 620 } 621 622 // Processes collectible metadata obtained from a provider and ensures any missing data is fetched. 623 // If asyncFetch is true, community collectibles metadata will be fetched async and an EventCollectiblesDataUpdated will be sent when the data is ready. 624 // If asyncFetch is false, it will wait for all community collectibles' metadata to be retrieved before returning. 625 func (o *Manager) processFullCollectibleData(ctx context.Context, assets []thirdparty.FullCollectibleData, asyncFetch bool) ([]thirdparty.CollectibleUniqueID, error) { 626 fullyFetchedAssets := make(map[string]*thirdparty.FullCollectibleData) 627 communityCollectibles := make(map[string][]*thirdparty.FullCollectibleData) 628 processedIDs := make([]thirdparty.CollectibleUniqueID, 0, len(assets)) 629 630 // Start with all assets, remove if any of the fetch steps fail 631 for idx := range assets { 632 asset := &assets[idx] 633 id := asset.CollectibleData.ID 634 fullyFetchedAssets[id.HashKey()] = asset 635 } 636 637 // Detect community collectibles 638 for _, asset := range fullyFetchedAssets { 639 // Only check community ownership if metadata is empty 640 if isMetadataEmpty(asset.CollectibleData) { 641 // Get TokenURI if not given by provider 642 err := o.fillTokenURI(ctx, asset) 643 if err != nil { 644 log.Error("fillTokenURI failed", "err", err) 645 delete(fullyFetchedAssets, asset.CollectibleData.ID.HashKey()) 646 continue 647 } 648 649 // Get CommunityID if obtainable from TokenURI 650 err = o.fillCommunityID(asset) 651 if err != nil { 652 log.Error("fillCommunityID failed", "err", err) 653 delete(fullyFetchedAssets, asset.CollectibleData.ID.HashKey()) 654 continue 655 } 656 657 // Get metadata from community if community collectible 658 communityID := asset.CollectibleData.CommunityID 659 if communityID != "" { 660 if _, ok := communityCollectibles[communityID]; !ok { 661 communityCollectibles[communityID] = make([]*thirdparty.FullCollectibleData, 0) 662 } 663 communityCollectibles[communityID] = append(communityCollectibles[communityID], asset) 664 665 // Community collectibles are handled separately, remove from list 666 delete(fullyFetchedAssets, asset.CollectibleData.ID.HashKey()) 667 } 668 } 669 } 670 671 // Community collectibles are grouped by community ID 672 for communityID, communityAssets := range communityCollectibles { 673 if asyncFetch { 674 o.fetchCommunityAssetsAsync(ctx, communityID, communityAssets) 675 } else { 676 err := o.fetchCommunityAssets(communityID, communityAssets) 677 if err != nil { 678 log.Error("fetchCommunityAssets failed", "communityID", communityID, "err", err) 679 continue 680 } 681 for _, asset := range communityAssets { 682 processedIDs = append(processedIDs, asset.CollectibleData.ID) 683 } 684 } 685 } 686 687 for _, asset := range fullyFetchedAssets { 688 err := o.fillAnimationMediatype(ctx, asset) 689 if err != nil { 690 log.Error("fillAnimationMediatype failed", "err", err) 691 delete(fullyFetchedAssets, asset.CollectibleData.ID.HashKey()) 692 continue 693 } 694 } 695 696 // Save successfully fetched data to DB 697 collectiblesData := make([]thirdparty.CollectibleData, 0, len(assets)) 698 collectionsData := make([]thirdparty.CollectionData, 0, len(assets)) 699 missingCollectionIDs := make([]thirdparty.ContractID, 0) 700 701 for _, asset := range fullyFetchedAssets { 702 id := asset.CollectibleData.ID 703 processedIDs = append(processedIDs, id) 704 705 collectiblesData = append(collectiblesData, asset.CollectibleData) 706 if asset.CollectionData != nil { 707 collectionsData = append(collectionsData, *asset.CollectionData) 708 } else { 709 missingCollectionIDs = append(missingCollectionIDs, id.ContractID) 710 } 711 } 712 713 err := o.collectiblesDataDB.SetData(collectiblesData, true) 714 if err != nil { 715 return nil, err 716 } 717 718 err = o.collectionsDataDB.SetData(collectionsData, true) 719 if err != nil { 720 return nil, err 721 } 722 723 if len(missingCollectionIDs) > 0 { 724 // Calling this ensures collection data is fetched and cached (if not already available) 725 _, err := o.FetchCollectionsDataByContractID(ctx, missingCollectionIDs) 726 if err != nil { 727 return nil, err 728 } 729 } 730 731 return processedIDs, nil 732 } 733 734 func (o *Manager) fillTokenURI(ctx context.Context, asset *thirdparty.FullCollectibleData) error { 735 id := asset.CollectibleData.ID 736 737 tokenURI := asset.CollectibleData.TokenURI 738 // Only need to fetch it from contract if it was empty 739 if tokenURI == "" { 740 tokenURI, err := o.fetchTokenURI(ctx, id) 741 742 if err != nil { 743 return err 744 } 745 746 asset.CollectibleData.TokenURI = tokenURI 747 } 748 return nil 749 } 750 751 func (o *Manager) fillCommunityID(asset *thirdparty.FullCollectibleData) error { 752 tokenURI := asset.CollectibleData.TokenURI 753 754 communityID := "" 755 if tokenURI != "" { 756 communityID = o.communityManager.GetCommunityID(tokenURI) 757 } 758 759 asset.CollectibleData.CommunityID = communityID 760 return nil 761 } 762 763 func (o *Manager) fetchCommunityAssets(communityID string, communityAssets []*thirdparty.FullCollectibleData) error { 764 communityFound, err := o.communityManager.FillCollectiblesMetadata(communityID, communityAssets) 765 if err != nil { 766 log.Error("FillCollectiblesMetadata failed", "communityID", communityID, "err", err) 767 } else if !communityFound { 768 log.Warn("fetchCommunityAssets community not found", "communityID", communityID) 769 } 770 771 // If the community is found, we update the DB. 772 // If the community is not found, we only insert new entries to the DB (don't replace what is already there). 773 allowUpdate := communityFound 774 775 collectiblesData := make([]thirdparty.CollectibleData, 0, len(communityAssets)) 776 collectionsData := make([]thirdparty.CollectionData, 0, len(communityAssets)) 777 778 for _, asset := range communityAssets { 779 collectiblesData = append(collectiblesData, asset.CollectibleData) 780 if asset.CollectionData != nil { 781 collectionsData = append(collectionsData, *asset.CollectionData) 782 } 783 } 784 785 err = o.collectiblesDataDB.SetData(collectiblesData, allowUpdate) 786 if err != nil { 787 log.Error("collectiblesDataDB SetData failed", "communityID", communityID, "err", err) 788 return err 789 } 790 791 err = o.collectionsDataDB.SetData(collectionsData, allowUpdate) 792 if err != nil { 793 log.Error("collectionsDataDB SetData failed", "communityID", communityID, "err", err) 794 return err 795 } 796 797 for _, asset := range communityAssets { 798 if asset.CollectibleCommunityInfo != nil { 799 err = o.collectiblesDataDB.SetCommunityInfo(asset.CollectibleData.ID, *asset.CollectibleCommunityInfo) 800 if err != nil { 801 log.Error("collectiblesDataDB SetCommunityInfo failed", "communityID", communityID, "err", err) 802 return err 803 } 804 } 805 } 806 807 return nil 808 } 809 810 func (o *Manager) fetchCommunityAssetsAsync(_ context.Context, communityID string, communityAssets []*thirdparty.FullCollectibleData) { 811 if len(communityAssets) == 0 { 812 return 813 } 814 815 go func() { 816 err := o.fetchCommunityAssets(communityID, communityAssets) 817 if err != nil { 818 log.Error("fetchCommunityAssets failed", "communityID", communityID, "err", err) 819 return 820 } 821 822 // Metadata is up to date in db at this point, fetch and send Event. 823 ids := make([]thirdparty.CollectibleUniqueID, 0, len(communityAssets)) 824 for _, asset := range communityAssets { 825 ids = append(ids, asset.CollectibleData.ID) 826 } 827 o.signalUpdatedCollectiblesData(ids) 828 }() 829 } 830 831 func (o *Manager) fillAnimationMediatype(ctx context.Context, asset *thirdparty.FullCollectibleData) error { 832 if len(asset.CollectibleData.AnimationURL) > 0 { 833 contentType, err := o.doContentTypeRequest(ctx, asset.CollectibleData.AnimationURL) 834 if err != nil { 835 asset.CollectibleData.AnimationURL = "" 836 } 837 asset.CollectibleData.AnimationMediaType = contentType 838 } 839 return nil 840 } 841 842 func (o *Manager) processCollectionData(_ context.Context, collections []thirdparty.CollectionData) error { 843 return o.collectionsDataDB.SetData(collections, true) 844 } 845 846 func (o *Manager) getCacheFullCollectibleData(uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) { 847 ret := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs)) 848 849 collectiblesData, err := o.collectiblesDataDB.GetData(uniqueIDs) 850 if err != nil { 851 return nil, err 852 } 853 854 contractIDs := make([]thirdparty.ContractID, 0, len(uniqueIDs)) 855 for _, id := range uniqueIDs { 856 contractIDs = append(contractIDs, id.ContractID) 857 } 858 859 collectionsData, err := o.collectionsDataDB.GetData(contractIDs) 860 if err != nil { 861 return nil, err 862 } 863 864 for _, id := range uniqueIDs { 865 collectibleData, ok := collectiblesData[id.HashKey()] 866 if !ok { 867 // Use empty data, set only ID 868 collectibleData = thirdparty.CollectibleData{ 869 ID: id, 870 } 871 } 872 if o.mediaServer != nil && len(collectibleData.ImagePayload) > 0 { 873 collectibleData.ImageURL = o.mediaServer.MakeWalletCollectibleImagesURL(collectibleData.ID) 874 } 875 876 collectionData, ok := collectionsData[id.ContractID.HashKey()] 877 if !ok { 878 // Use empty data, set only ID 879 collectionData = thirdparty.CollectionData{ 880 ID: id.ContractID, 881 } 882 } 883 if o.mediaServer != nil && len(collectionData.ImagePayload) > 0 { 884 collectionData.ImageURL = o.mediaServer.MakeWalletCollectionImagesURL(collectionData.ID) 885 } 886 887 communityInfo, _, err := o.communityManager.GetCommunityInfo(collectibleData.CommunityID) 888 if err != nil { 889 return nil, err 890 } 891 892 collectibleCommunityInfo, err := o.collectiblesDataDB.GetCommunityInfo(id) 893 if err != nil { 894 return nil, err 895 } 896 897 ownership, err := o.ownershipDB.GetOwnership(id) 898 if err != nil { 899 return nil, err 900 } 901 902 fullData := thirdparty.FullCollectibleData{ 903 CollectibleData: collectibleData, 904 CollectionData: &collectionData, 905 CommunityInfo: communityInfo, 906 CollectibleCommunityInfo: collectibleCommunityInfo, 907 Ownership: ownership, 908 } 909 ret = append(ret, fullData) 910 } 911 912 return ret, nil 913 } 914 915 func (o *Manager) SetCollectibleTransferID(ownerAddress common.Address, id thirdparty.CollectibleUniqueID, transferID common.Hash, notify bool) error { 916 changed, err := o.ownershipDB.SetTransferID(ownerAddress, id, transferID) 917 if err != nil { 918 return err 919 } 920 921 if changed && notify { 922 o.signalUpdatedCollectiblesData([]thirdparty.CollectibleUniqueID{id}) 923 } 924 return nil 925 } 926 927 // Reset connection status to trigger notifications 928 // on the next status update 929 func (o *Manager) ResetConnectionStatus() { 930 o.statuses.Range(func(key, value interface{}) bool { 931 value.(*connection.Status).ResetStateValue() 932 return true 933 }) 934 } 935 936 func (o *Manager) checkConnectionStatus(chainID walletCommon.ChainID) { 937 for _, provider := range o.providers.GetProviderList() { 938 if provider.IsChainSupported(chainID) && provider.IsConnected() { 939 if status, ok := o.statuses.Load(chainID.String()); ok { 940 status.(*connection.Status).SetIsConnected(true) 941 } 942 return 943 } 944 } 945 946 // If no chain in statuses, add it 947 statusVal, ok := o.statuses.Load(chainID.String()) 948 if !ok { 949 status := connection.NewStatus() 950 status.SetIsConnected(false) 951 o.statuses.Store(chainID.String(), status) 952 o.updateStatusNotifier() 953 } else { 954 statusVal.(*connection.Status).SetIsConnected(false) 955 } 956 } 957 958 func (o *Manager) signalUpdatedCollectiblesData(ids []thirdparty.CollectibleUniqueID) { 959 // We limit how much collectibles data we send in each event to avoid problems on the client side 960 for startIdx := 0; startIdx < len(ids); startIdx += signalUpdatedCollectiblesDataPageSize { 961 endIdx := startIdx + signalUpdatedCollectiblesDataPageSize 962 if endIdx > len(ids) { 963 endIdx = len(ids) 964 } 965 pageIDs := ids[startIdx:endIdx] 966 967 collectibles, err := o.getCacheFullCollectibleData(pageIDs) 968 if err != nil { 969 log.Error("Error getting FullCollectibleData from cache: %v", err) 970 return 971 } 972 973 // Send update event with most complete data type available 974 details := fullCollectiblesDataToDetails(collectibles) 975 976 payload, err := json.Marshal(details) 977 if err != nil { 978 log.Error("Error marshaling response: %v", err) 979 return 980 } 981 982 event := walletevent.Event{ 983 Type: EventCollectiblesDataUpdated, 984 Message: string(payload), 985 } 986 987 o.feed.Send(event) 988 } 989 } 990 991 func (o *Manager) SearchCollectibles(ctx context.Context, chainID walletCommon.ChainID, text string, cursor string, limit int, providerID string) (*thirdparty.FullCollectibleDataContainer, error) { 992 defer o.checkConnectionStatus(chainID) 993 994 anyProviderAvailable := false 995 for _, provider := range o.providers.SearchProviders { 996 if !provider.IsChainSupported(chainID) { 997 continue 998 } 999 anyProviderAvailable = true 1000 if providerID != thirdparty.FetchFromAnyProvider && providerID != provider.ID() { 1001 continue 1002 } 1003 1004 // TODO (#13951): Be smarter about how we handle the user-entered string 1005 collections := []common.Address{} 1006 1007 container, err := provider.SearchCollectibles(ctx, chainID, collections, text, cursor, limit) 1008 if err != nil { 1009 log.Error("FetchAllAssetsByOwner failed for", "provider", provider.ID(), "chainID", chainID, "err", err) 1010 continue 1011 } 1012 1013 _, err = o.processFullCollectibleData(ctx, container.Items, true) 1014 if err != nil { 1015 return nil, err 1016 } 1017 1018 return container, nil 1019 } 1020 1021 if anyProviderAvailable { 1022 return nil, ErrAllProvidersFailedForChainID 1023 } 1024 return nil, ErrNoProvidersAvailableForChainID 1025 } 1026 1027 func (o *Manager) SearchCollections(ctx context.Context, chainID walletCommon.ChainID, query string, cursor string, limit int, providerID string) (*thirdparty.CollectionDataContainer, error) { 1028 defer o.checkConnectionStatus(chainID) 1029 1030 anyProviderAvailable := false 1031 for _, provider := range o.providers.SearchProviders { 1032 if !provider.IsChainSupported(chainID) { 1033 continue 1034 } 1035 anyProviderAvailable = true 1036 if providerID != thirdparty.FetchFromAnyProvider && providerID != provider.ID() { 1037 continue 1038 } 1039 1040 // TODO (#13951): Be smarter about how we handle the user-entered string 1041 container, err := provider.SearchCollections(ctx, chainID, query, cursor, limit) 1042 if err != nil { 1043 log.Error("FetchAllAssetsByOwner failed for", "provider", provider.ID(), "chainID", chainID, "err", err) 1044 continue 1045 } 1046 1047 err = o.processCollectionData(ctx, container.Items) 1048 if err != nil { 1049 return nil, err 1050 } 1051 1052 return container, nil 1053 } 1054 1055 if anyProviderAvailable { 1056 return nil, ErrAllProvidersFailedForChainID 1057 } 1058 return nil, ErrNoProvidersAvailableForChainID 1059 } 1060 1061 func (o *Manager) FetchCollectionSocialsAsync(contractID thirdparty.ContractID) error { 1062 go func() { 1063 defer o.checkConnectionStatus(contractID.ChainID) 1064 1065 socials, err := o.getOrFetchSocialsForCollection(context.Background(), contractID) 1066 if err != nil || socials == nil { 1067 log.Debug("FetchCollectionSocialsAsync failed for", "chainID", contractID.ChainID, "address", contractID.Address, "err", err) 1068 return 1069 } 1070 1071 socialsMessage := CollectionSocialsMessage{ 1072 ID: contractID, 1073 Socials: socials, 1074 } 1075 1076 payload, err := json.Marshal(socialsMessage) 1077 if err != nil { 1078 log.Error("Error marshaling response: %v", err) 1079 return 1080 } 1081 1082 event := walletevent.Event{ 1083 Type: EventGetCollectionSocialsDone, 1084 Message: string(payload), 1085 } 1086 1087 o.feed.Send(event) 1088 }() 1089 1090 return nil 1091 } 1092 1093 func (o *Manager) getOrFetchSocialsForCollection(_ context.Context, contractID thirdparty.ContractID) (*thirdparty.CollectionSocials, error) { 1094 socials, err := o.collectionsDataDB.GetSocialsForID(contractID) 1095 if err != nil { 1096 log.Debug("getOrFetchSocialsForCollection failed for", "chainID", contractID.ChainID, "address", contractID.Address, "err", err) 1097 return nil, err 1098 } 1099 if socials == nil { 1100 return o.fetchSocialsForCollection(context.Background(), contractID) 1101 } 1102 return socials, nil 1103 } 1104 1105 func (o *Manager) fetchSocialsForCollection(ctx context.Context, contractID thirdparty.ContractID) (*thirdparty.CollectionSocials, error) { 1106 cmd := circuitbreaker.NewCommand(ctx, nil) 1107 for _, provider := range o.providers.CollectibleDataProviders { 1108 if !provider.IsChainSupported(contractID.ChainID) { 1109 continue 1110 } 1111 1112 provider := provider 1113 cmd.Add(circuitbreaker.NewFunctor(func() ([]interface{}, error) { 1114 socials, err := provider.FetchCollectionSocials(ctx, contractID) 1115 if err != nil { 1116 log.Error("FetchCollectionSocials failed for", "provider", provider.ID(), "chainID", contractID.ChainID, "err", err) 1117 } 1118 return []interface{}{socials}, err 1119 }, getCircuitName(provider, contractID.ChainID))) 1120 } 1121 1122 if cmd.IsEmpty() { 1123 return nil, ErrNoProvidersAvailableForChainID // lets not stop the group if no providers are available for the chain 1124 } 1125 1126 cmdRes := o.circuitBreaker.Execute(cmd) 1127 if cmdRes.Error() != nil { 1128 log.Error("fetchSocialsForCollection failed for", "chainID", contractID.ChainID, "err", cmdRes.Error()) 1129 return nil, cmdRes.Error() 1130 } 1131 1132 socials := cmdRes.Result()[0].(*thirdparty.CollectionSocials) 1133 err := o.collectionsDataDB.SetCollectionSocialsData(contractID, socials) 1134 if err != nil { 1135 log.Error("Error saving socials to DB: %v", err) 1136 return nil, err 1137 } 1138 1139 return socials, cmdRes.Error() 1140 } 1141 1142 func (o *Manager) updateStatusNotifier() { 1143 o.statusNotifier = createStatusNotifier(o.statuses, o.feed) 1144 } 1145 1146 func initStatuses(ownershipDB *OwnershipDB) *sync.Map { 1147 statuses := &sync.Map{} 1148 for _, chainID := range walletCommon.AllChainIDs() { 1149 status := connection.NewStatus() 1150 state := status.GetState() 1151 latestUpdateTimestamp, err := ownershipDB.GetLatestOwnershipUpdateTimestamp(chainID) 1152 if err == nil { 1153 state.LastSuccessAt = latestUpdateTimestamp 1154 status.SetState(state) 1155 } 1156 statuses.Store(chainID.String(), status) 1157 } 1158 1159 return statuses 1160 } 1161 1162 func createStatusNotifier(statuses *sync.Map, feed *event.Feed) *connection.StatusNotifier { 1163 return connection.NewStatusNotifier( 1164 statuses, 1165 EventCollectiblesConnectionStatusChanged, 1166 feed, 1167 ) 1168 } 1169 1170 // Different providers have API keys per chain or per testnet/mainnet. 1171 // Proper implementation should respect that. For now, the safest solution is to use the provider ID and chain ID as the key. 1172 func getCircuitName(provider thirdparty.CollectibleProvider, chainID walletCommon.ChainID) string { 1173 return provider.ID() + chainID.String() 1174 } 1175 1176 func getCircuitNameForTokenURI(mainCircuitName string) string { 1177 return mainCircuitName + "_tokenURI" 1178 } 1179 1180 // As we don't use hystrix internal way of switching to another circuit, just its metrics, 1181 // we still can switch to another provider without tripping the circuit. 1182 func getClientWithNoCircuitTripping(backend chain.ClientInterface) chain.ClientInterface { 1183 copyable := backend.(chain.Copyable) 1184 if copyable != nil { 1185 backendCopy := copyable.Copy().(chain.ClientInterface) 1186 hm := backendCopy.(chain.HealthMonitor) 1187 if hm != nil { 1188 cb := circuitbreaker.NewCircuitBreaker(circuitbreaker.Config{ 1189 Timeout: 20000, 1190 MaxConcurrentRequests: 100, 1191 SleepWindow: 300000, 1192 ErrorPercentThreshold: 101, // Always healthy 1193 }) 1194 cb.SetOverrideCircuitNameHandler(func(circuitName string) string { 1195 return getCircuitNameForTokenURI(circuitName) 1196 }) 1197 hm.SetCircuitBreaker(cb) 1198 backend = backendCopy 1199 } 1200 } 1201 1202 return backend 1203 }