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  }