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 }