github.com/status-im/status-go@v1.1.0/services/wallet/collectibles/service.go (about)

     1  package collectibles
     2  
     3  import (
     4  	"context"
     5  	"database/sql"
     6  	"encoding/json"
     7  	"errors"
     8  	"math/big"
     9  	"time"
    10  
    11  	"github.com/ethereum/go-ethereum/common"
    12  	"github.com/ethereum/go-ethereum/event"
    13  	"github.com/ethereum/go-ethereum/log"
    14  
    15  	"github.com/status-im/status-go/multiaccounts/accounts"
    16  	"github.com/status-im/status-go/rpc/network"
    17  
    18  	"github.com/status-im/status-go/services/wallet/async"
    19  	"github.com/status-im/status-go/services/wallet/bigint"
    20  	walletCommon "github.com/status-im/status-go/services/wallet/common"
    21  	"github.com/status-im/status-go/services/wallet/community"
    22  	"github.com/status-im/status-go/services/wallet/thirdparty"
    23  	"github.com/status-im/status-go/services/wallet/transfer"
    24  	"github.com/status-im/status-go/services/wallet/walletevent"
    25  )
    26  
    27  // These events are used to notify the UI of state changes
    28  const (
    29  	EventCollectiblesOwnershipUpdateStarted           walletevent.EventType = "wallet-collectibles-ownership-update-started"
    30  	EventCollectiblesOwnershipUpdatePartial           walletevent.EventType = "wallet-collectibles-ownership-update-partial"
    31  	EventCollectiblesOwnershipUpdateFinished          walletevent.EventType = "wallet-collectibles-ownership-update-finished"
    32  	EventCollectiblesOwnershipUpdateFinishedWithError walletevent.EventType = "wallet-collectibles-ownership-update-finished-with-error"
    33  	EventCommunityCollectiblesReceived                walletevent.EventType = "wallet-collectibles-community-collectibles-received"
    34  	EventCollectiblesDataUpdated                      walletevent.EventType = "wallet-collectibles-data-updated"
    35  
    36  	EventOwnedCollectiblesFilteringDone walletevent.EventType = "wallet-owned-collectibles-filtering-done"
    37  	EventGetCollectiblesDetailsDone     walletevent.EventType = "wallet-get-collectibles-details-done"
    38  	EventGetCollectionSocialsDone       walletevent.EventType = "wallet-get-collection-socials-done"
    39  )
    40  
    41  type OwnershipUpdateMessage struct {
    42  	Added   []thirdparty.CollectibleUniqueID `json:"added"`
    43  	Updated []thirdparty.CollectibleUniqueID `json:"updated"`
    44  	Removed []thirdparty.CollectibleUniqueID `json:"removed"`
    45  }
    46  
    47  type CollectionSocialsMessage struct {
    48  	ID      thirdparty.ContractID         `json:"id"`
    49  	Socials *thirdparty.CollectionSocials `json:"socials"`
    50  }
    51  
    52  type CollectibleDataType byte
    53  
    54  const (
    55  	CollectibleDataTypeUniqueID CollectibleDataType = iota
    56  	CollectibleDataTypeHeader
    57  	CollectibleDataTypeDetails
    58  	CollectibleDataTypeCommunityHeader
    59  )
    60  
    61  type FetchType byte
    62  
    63  const (
    64  	FetchTypeNeverFetch FetchType = iota
    65  	FetchTypeAlwaysFetch
    66  	FetchTypeFetchIfNotCached
    67  	FetchTypeFetchIfCacheOld
    68  )
    69  
    70  type TxHashData struct {
    71  	Hash common.Hash
    72  	TxID common.Hash
    73  }
    74  
    75  type FetchCriteria struct {
    76  	FetchType          FetchType `json:"fetch_type"`
    77  	MaxCacheAgeSeconds int64     `json:"max_cache_age_seconds"`
    78  }
    79  
    80  var (
    81  	filterOwnedCollectiblesTask = async.TaskType{
    82  		ID:     1,
    83  		Policy: async.ReplacementPolicyCancelOld,
    84  	}
    85  	getCollectiblesDataTask = async.TaskType{
    86  		ID:     2,
    87  		Policy: async.ReplacementPolicyCancelOld,
    88  	}
    89  )
    90  
    91  type Service struct {
    92  	manager          *Manager
    93  	controller       *Controller
    94  	db               *sql.DB
    95  	ownershipDB      *OwnershipDB
    96  	transferDB       *transfer.Database
    97  	communityManager *community.Manager
    98  	walletFeed       *event.Feed
    99  	scheduler        *async.MultiClientScheduler
   100  	group            *async.Group
   101  }
   102  
   103  func NewService(
   104  	db *sql.DB,
   105  	walletFeed *event.Feed,
   106  	accountsDB *accounts.Database,
   107  	accountsFeed *event.Feed,
   108  	settingsFeed *event.Feed,
   109  	communityManager *community.Manager,
   110  	networkManager *network.Manager,
   111  	manager *Manager) *Service {
   112  	s := &Service{
   113  		manager:          manager,
   114  		controller:       NewController(db, walletFeed, accountsDB, accountsFeed, settingsFeed, networkManager, manager),
   115  		db:               db,
   116  		ownershipDB:      NewOwnershipDB(db),
   117  		transferDB:       transfer.NewDB(db),
   118  		communityManager: communityManager,
   119  		walletFeed:       walletFeed,
   120  		scheduler:        async.NewMultiClientScheduler(),
   121  		group:            async.NewGroup(context.Background()),
   122  	}
   123  	s.controller.SetOwnedCollectiblesChangeCb(s.onOwnedCollectiblesChange)
   124  	s.controller.SetCollectiblesTransferCb(s.onCollectiblesTransfer)
   125  	return s
   126  }
   127  
   128  type ErrorCode = int
   129  
   130  const (
   131  	ErrorCodeSuccess ErrorCode = iota + 1
   132  	ErrorCodeTaskCanceled
   133  	ErrorCodeFailed
   134  )
   135  
   136  type OwnershipStatus struct {
   137  	State     OwnershipState `json:"state"`
   138  	Timestamp int64          `json:"timestamp"`
   139  }
   140  
   141  type OwnershipStatusPerChainID = map[walletCommon.ChainID]OwnershipStatus
   142  type OwnershipStatusPerAddressAndChainID = map[common.Address]OwnershipStatusPerChainID
   143  
   144  type GetOwnedCollectiblesResponse struct {
   145  	Collectibles []Collectible `json:"collectibles"`
   146  	Offset       int           `json:"offset"`
   147  	// Used to indicate that there might be more collectibles that were not returned
   148  	// based on a simple heuristic
   149  	HasMore         bool                                `json:"hasMore"`
   150  	OwnershipStatus OwnershipStatusPerAddressAndChainID `json:"ownershipStatus"`
   151  	ErrorCode       ErrorCode                           `json:"errorCode"`
   152  }
   153  
   154  type GetCollectiblesByUniqueIDResponse struct {
   155  	Collectibles []Collectible `json:"collectibles"`
   156  	ErrorCode    ErrorCode     `json:"errorCode"`
   157  }
   158  
   159  type GetOwnedCollectiblesReturnType struct {
   160  	collectibles    []Collectible
   161  	hasMore         bool
   162  	ownershipStatus OwnershipStatusPerAddressAndChainID
   163  }
   164  
   165  type GetCollectiblesByUniqueIDReturnType struct {
   166  	collectibles []Collectible
   167  }
   168  
   169  func (s *Service) GetOwnedCollectibles(
   170  	ctx context.Context,
   171  	chainIDs []walletCommon.ChainID,
   172  	addresses []common.Address,
   173  	filter Filter,
   174  	offset int,
   175  	limit int,
   176  	dataType CollectibleDataType,
   177  	fetchCriteria FetchCriteria) (*GetOwnedCollectiblesReturnType, error) {
   178  	err := s.fetchOwnedCollectiblesIfNeeded(ctx, chainIDs, addresses, fetchCriteria)
   179  	if err != nil {
   180  		return nil, err
   181  	}
   182  
   183  	ids, hasMore, err := s.FilterOwnedCollectibles(chainIDs, addresses, filter, offset, limit)
   184  	if err != nil {
   185  		return nil, err
   186  	}
   187  
   188  	collectibles, err := s.collectibleIDsToDataType(ctx, ids, dataType)
   189  	if err != nil {
   190  		return nil, err
   191  	}
   192  
   193  	ownershipStatus, err := s.GetOwnershipStatus(chainIDs, addresses)
   194  	if err != nil {
   195  		return nil, err
   196  	}
   197  
   198  	return &GetOwnedCollectiblesReturnType{
   199  		collectibles:    collectibles,
   200  		hasMore:         hasMore,
   201  		ownershipStatus: ownershipStatus,
   202  	}, err
   203  }
   204  
   205  func (s *Service) needsToFetch(chainID walletCommon.ChainID, address common.Address, fetchCriteria FetchCriteria) (bool, error) {
   206  	mustFetch := false
   207  	switch fetchCriteria.FetchType {
   208  	case FetchTypeAlwaysFetch:
   209  		mustFetch = true
   210  	case FetchTypeNeverFetch:
   211  		mustFetch = false
   212  	case FetchTypeFetchIfNotCached, FetchTypeFetchIfCacheOld:
   213  		timestamp, err := s.ownershipDB.GetOwnershipUpdateTimestamp(address, chainID)
   214  		if err != nil {
   215  			return false, err
   216  		}
   217  		if timestamp == InvalidTimestamp ||
   218  			(fetchCriteria.FetchType == FetchTypeFetchIfCacheOld && timestamp+fetchCriteria.MaxCacheAgeSeconds < time.Now().Unix()) {
   219  			mustFetch = true
   220  		}
   221  	}
   222  	return mustFetch, nil
   223  }
   224  
   225  func (s *Service) fetchOwnedCollectiblesIfNeeded(ctx context.Context, chainIDs []walletCommon.ChainID, addresses []common.Address, fetchCriteria FetchCriteria) error {
   226  	if fetchCriteria.FetchType == FetchTypeNeverFetch {
   227  		return nil
   228  	}
   229  
   230  	group := async.NewGroup(ctx)
   231  	for _, address := range addresses {
   232  		for _, chainID := range chainIDs {
   233  			mustFetch, err := s.needsToFetch(chainID, address, fetchCriteria)
   234  			if err != nil {
   235  				return err
   236  			}
   237  			if mustFetch {
   238  				command := newLoadOwnedCollectiblesCommand(s.manager, s.ownershipDB, s.walletFeed, chainID, address, nil)
   239  				group.Add(command.Command())
   240  			}
   241  		}
   242  	}
   243  	select {
   244  	case <-ctx.Done():
   245  		return ctx.Err()
   246  	case <-group.WaitAsync():
   247  		return nil
   248  	}
   249  }
   250  
   251  // GetOwnedCollectiblesAsync allows only one filter task to run at a time
   252  // and it cancels the current one if a new one is started
   253  // All calls will trigger an EventOwnedCollectiblesFilteringDone event with the result of the filtering
   254  func (s *Service) GetOwnedCollectiblesAsync(
   255  	requestID int32,
   256  	chainIDs []walletCommon.ChainID,
   257  	addresses []common.Address,
   258  	filter Filter,
   259  	offset int,
   260  	limit int,
   261  	dataType CollectibleDataType,
   262  	fetchCriteria FetchCriteria) {
   263  	s.scheduler.Enqueue(requestID, filterOwnedCollectiblesTask, func(ctx context.Context) (interface{}, error) {
   264  		return s.GetOwnedCollectibles(ctx, chainIDs, addresses, filter, offset, limit, dataType, fetchCriteria)
   265  	}, func(result interface{}, taskType async.TaskType, err error) {
   266  		res := GetOwnedCollectiblesResponse{
   267  			ErrorCode: ErrorCodeFailed,
   268  		}
   269  
   270  		if errors.Is(err, context.Canceled) || errors.Is(err, async.ErrTaskOverwritten) {
   271  			res.ErrorCode = ErrorCodeTaskCanceled
   272  		} else if err == nil {
   273  			fnRet := result.(*GetOwnedCollectiblesReturnType)
   274  
   275  			if err == nil {
   276  				res.Collectibles = fnRet.collectibles
   277  				res.Offset = offset
   278  				res.HasMore = fnRet.hasMore
   279  				res.OwnershipStatus = fnRet.ownershipStatus
   280  				res.ErrorCode = ErrorCodeSuccess
   281  			}
   282  		}
   283  
   284  		s.sendResponseEvent(&requestID, EventOwnedCollectiblesFilteringDone, res, err)
   285  	})
   286  }
   287  
   288  func (s *Service) GetCollectiblesByUniqueID(
   289  	ctx context.Context,
   290  	uniqueIDs []thirdparty.CollectibleUniqueID,
   291  	dataType CollectibleDataType) (*GetCollectiblesByUniqueIDReturnType, error) {
   292  	collectibles, err := s.collectibleIDsToDataType(ctx, uniqueIDs, dataType)
   293  	if err != nil {
   294  		return nil, err
   295  	}
   296  	return &GetCollectiblesByUniqueIDReturnType{
   297  		collectibles: collectibles,
   298  	}, err
   299  }
   300  
   301  func (s *Service) GetCollectiblesByUniqueIDAsync(
   302  	requestID int32,
   303  	uniqueIDs []thirdparty.CollectibleUniqueID,
   304  	dataType CollectibleDataType) {
   305  	s.scheduler.Enqueue(requestID, getCollectiblesDataTask, func(ctx context.Context) (interface{}, error) {
   306  		return s.GetCollectiblesByUniqueID(ctx, uniqueIDs, dataType)
   307  	}, func(result interface{}, taskType async.TaskType, err error) {
   308  		res := GetCollectiblesByUniqueIDResponse{
   309  			ErrorCode: ErrorCodeFailed,
   310  		}
   311  
   312  		if errors.Is(err, context.Canceled) || errors.Is(err, async.ErrTaskOverwritten) {
   313  			res.ErrorCode = ErrorCodeTaskCanceled
   314  		} else if err == nil {
   315  			fnRet := result.(*GetCollectiblesByUniqueIDReturnType)
   316  
   317  			if err == nil {
   318  				res.Collectibles = fnRet.collectibles
   319  				res.ErrorCode = ErrorCodeSuccess
   320  			}
   321  		}
   322  
   323  		s.sendResponseEvent(&requestID, EventGetCollectiblesDetailsDone, res, err)
   324  	})
   325  }
   326  
   327  func (s *Service) RefetchOwnedCollectibles() {
   328  	s.controller.RefetchOwnedCollectibles()
   329  }
   330  
   331  func (s *Service) Start() {
   332  	s.controller.Start()
   333  }
   334  
   335  func (s *Service) Stop() {
   336  	s.controller.Stop()
   337  
   338  	s.scheduler.Stop()
   339  }
   340  
   341  func (s *Service) sendResponseEvent(requestID *int32, eventType walletevent.EventType, payloadObj interface{}, resErr error) {
   342  	payload, err := json.Marshal(payloadObj)
   343  	if err != nil {
   344  		log.Error("Error marshaling response: %v; result error: %w", err, resErr)
   345  	} else {
   346  		err = resErr
   347  	}
   348  
   349  	log.Debug("wallet.api.collectibles.Service RESPONSE", "requestID", requestID, "eventType", eventType, "error", err, "payload.len", len(payload))
   350  
   351  	event := walletevent.Event{
   352  		Type:    eventType,
   353  		Message: string(payload),
   354  	}
   355  
   356  	if requestID != nil {
   357  		event.RequestID = new(int)
   358  		*event.RequestID = int(*requestID)
   359  	}
   360  
   361  	s.walletFeed.Send(event)
   362  }
   363  
   364  func (s *Service) FilterOwnedCollectibles(chainIDs []walletCommon.ChainID, owners []common.Address, filter Filter, offset int, limit int) ([]thirdparty.CollectibleUniqueID, bool, error) {
   365  	ctx := context.Background()
   366  	// Request one more than limit, to check if DB has more available
   367  	ids, err := filterOwnedCollectibles(ctx, s.db, chainIDs, owners, filter, offset, limit+1)
   368  	if err != nil {
   369  		return nil, false, err
   370  	}
   371  
   372  	hasMore := len(ids) > limit
   373  	if hasMore {
   374  		ids = ids[:limit]
   375  	}
   376  
   377  	return ids, hasMore, nil
   378  }
   379  
   380  func (s *Service) GetOwnedCollectible(chainID walletCommon.ChainID, owner common.Address, contractAddress common.Address, tokenID *big.Int) (*thirdparty.CollectibleUniqueID, error) {
   381  	return s.ownershipDB.GetOwnedCollectible(chainID, owner, contractAddress, tokenID)
   382  }
   383  
   384  func (s *Service) GetOwnershipStatus(chainIDs []walletCommon.ChainID, owners []common.Address) (OwnershipStatusPerAddressAndChainID, error) {
   385  	ret := make(OwnershipStatusPerAddressAndChainID)
   386  	for _, address := range owners {
   387  		ret[address] = make(OwnershipStatusPerChainID)
   388  		for _, chainID := range chainIDs {
   389  			timestamp, err := s.ownershipDB.GetOwnershipUpdateTimestamp(address, chainID)
   390  			if err != nil {
   391  				return nil, err
   392  			}
   393  			ret[address][chainID] = OwnershipStatus{
   394  				State:     s.controller.GetCommandState(chainID, address),
   395  				Timestamp: timestamp,
   396  			}
   397  		}
   398  	}
   399  
   400  	return ret, nil
   401  }
   402  
   403  func (s *Service) collectibleIDsToDataType(ctx context.Context, ids []thirdparty.CollectibleUniqueID, dataType CollectibleDataType) ([]Collectible, error) {
   404  	switch dataType {
   405  	case CollectibleDataTypeUniqueID:
   406  		return idsToCollectibles(ids), nil
   407  	case CollectibleDataTypeHeader, CollectibleDataTypeDetails, CollectibleDataTypeCommunityHeader:
   408  		collectibles, err := s.manager.FetchAssetsByCollectibleUniqueID(ctx, ids, true)
   409  		if err != nil {
   410  			return nil, err
   411  		}
   412  		switch dataType {
   413  		case CollectibleDataTypeHeader:
   414  			return fullCollectiblesDataToHeaders(collectibles), nil
   415  		case CollectibleDataTypeDetails:
   416  			return fullCollectiblesDataToDetails(collectibles), nil
   417  		case CollectibleDataTypeCommunityHeader:
   418  			return fullCollectiblesDataToCommunityHeader(collectibles), nil
   419  		}
   420  	}
   421  	return nil, errors.New("unknown data type")
   422  }
   423  
   424  func (s *Service) onOwnedCollectiblesChange(ownedCollectiblesChange OwnedCollectiblesChange) {
   425  	// Try to find a matching transfer for newly added/updated collectibles
   426  	switch ownedCollectiblesChange.changeType {
   427  	case OwnedCollectiblesChangeTypeAdded, OwnedCollectiblesChangeTypeUpdated:
   428  		// For recently added/updated collectibles, try to find a matching transfer
   429  		hashMap := s.lookupTransferForCollectibles(ownedCollectiblesChange.ownedCollectibles)
   430  		s.notifyCommunityCollectiblesReceived(ownedCollectiblesChange.ownedCollectibles, hashMap)
   431  	}
   432  }
   433  
   434  func (s *Service) onCollectiblesTransfer(account common.Address, chainID walletCommon.ChainID, transfers []transfer.Transfer) {
   435  	for _, transfer := range transfers {
   436  		// If Collectible is already in the DB, update transfer ID with the latest detected transfer
   437  		id := thirdparty.CollectibleUniqueID{
   438  			ContractID: thirdparty.ContractID{
   439  				ChainID: chainID,
   440  				Address: transfer.Log.Address,
   441  			},
   442  			TokenID: &bigint.BigInt{Int: transfer.TokenID},
   443  		}
   444  		err := s.manager.SetCollectibleTransferID(account, id, transfer.ID, true)
   445  		if err != nil {
   446  			log.Error("Error setting transfer ID for collectible", "error", err)
   447  		}
   448  	}
   449  }
   450  
   451  func (s *Service) lookupTransferForCollectibles(ownedCollectibles OwnedCollectibles) map[thirdparty.CollectibleUniqueID]TxHashData {
   452  	// There are some limitations to this approach:
   453  	// - Collectibles ownership and transfers are not in sync and might represent the state at different moments.
   454  	// - We have no way of knowing if the latest collectible transfer we've detected is actually the latest one, so the timestamp we
   455  	// use might be older than the real one.
   456  	// - There might be detected transfers that are temporarily not reflected in the collectibles ownership.
   457  	// - For ERC721 tokens we should only look for incoming transfers. For ERC1155 tokens we should look for both incoming and outgoing transfers.
   458  	// We need to get the contract standard for each collectible to know which approach to take.
   459  
   460  	result := make(map[thirdparty.CollectibleUniqueID]TxHashData)
   461  
   462  	for _, id := range ownedCollectibles.ids {
   463  		transfer, err := s.transferDB.GetLatestCollectibleTransfer(ownedCollectibles.account, id)
   464  		if err != nil {
   465  			log.Error("Error fetching latest collectible transfer", "error", err)
   466  			continue
   467  		}
   468  		if transfer != nil {
   469  			result[id] = TxHashData{
   470  				Hash: transfer.Transaction.Hash(),
   471  				TxID: transfer.ID,
   472  			}
   473  			err = s.manager.SetCollectibleTransferID(ownedCollectibles.account, id, transfer.ID, false)
   474  			if err != nil {
   475  				log.Error("Error setting transfer ID for collectible", "error", err)
   476  			}
   477  		}
   478  	}
   479  	return result
   480  }
   481  
   482  func (s *Service) notifyCommunityCollectiblesReceived(ownedCollectibles OwnedCollectibles, hashMap map[thirdparty.CollectibleUniqueID]TxHashData) {
   483  	ctx := context.Background()
   484  
   485  	firstCollectibles, err := s.ownershipDB.GetIsFirstOfCollection(ownedCollectibles.account, ownedCollectibles.ids)
   486  	if err != nil {
   487  		return
   488  	}
   489  
   490  	collectiblesData, err := s.manager.FetchAssetsByCollectibleUniqueID(ctx, ownedCollectibles.ids, false)
   491  	if err != nil {
   492  		log.Error("Error fetching collectibles data", "error", err)
   493  		return
   494  	}
   495  
   496  	communityCollectibles := fullCollectiblesDataToCommunityHeader(collectiblesData)
   497  
   498  	if len(communityCollectibles) == 0 {
   499  		return
   500  	}
   501  
   502  	type CollectibleGroup struct {
   503  		contractID thirdparty.ContractID
   504  		txHash     string
   505  	}
   506  
   507  	groups := make(map[CollectibleGroup]Collectible)
   508  	for _, localCollectible := range communityCollectibles {
   509  		// to satisfy gosec: C601 checks
   510  		collectible := localCollectible
   511  		txHash := ""
   512  		for key, value := range hashMap {
   513  			if key.Same(&collectible.ID) {
   514  				collectible.LatestTxHash = value.TxID.Hex()
   515  				txHash = value.Hash.Hex()
   516  				break
   517  			}
   518  		}
   519  
   520  		for id, value := range firstCollectibles {
   521  			if value && id.Same(&collectible.ID) {
   522  				collectible.IsFirst = true
   523  				break
   524  			}
   525  		}
   526  
   527  		group := CollectibleGroup{
   528  			contractID: collectible.ID.ContractID,
   529  			txHash:     txHash,
   530  		}
   531  		_, ok := groups[group]
   532  		if !ok {
   533  			collectible.ReceivedAmount = float64(0)
   534  		}
   535  		collectible.ReceivedAmount = collectible.ReceivedAmount + 1
   536  		groups[group] = collectible
   537  	}
   538  
   539  	groupedCommunityCollectibles := make([]Collectible, 0, len(groups))
   540  	for _, collectible := range groups {
   541  		groupedCommunityCollectibles = append(groupedCommunityCollectibles, collectible)
   542  	}
   543  
   544  	encodedMessage, err := json.Marshal(groupedCommunityCollectibles)
   545  	if err != nil {
   546  		return
   547  	}
   548  
   549  	s.walletFeed.Send(walletevent.Event{
   550  		Type:    EventCommunityCollectiblesReceived,
   551  		ChainID: uint64(ownedCollectibles.chainID),
   552  		Accounts: []common.Address{
   553  			ownedCollectibles.account,
   554  		},
   555  		Message: string(encodedMessage),
   556  	})
   557  }