feat: Waku v2 bridge

Issue #12610
This commit is contained in:
Michal Iskierko
2023-11-12 13:29:38 +01:00
parent 56e7bd01ca
commit 6d31343205
6716 changed files with 1982502 additions and 5891 deletions
@@ -0,0 +1,339 @@
package collectibles
import (
"database/sql"
"fmt"
"math/big"
"github.com/status-im/status-go/protocol/communities/token"
"github.com/status-im/status-go/services/wallet/bigint"
"github.com/status-im/status-go/services/wallet/thirdparty"
"github.com/status-im/status-go/sqlite"
)
type CollectibleDataDB struct {
db *sql.DB
}
func NewCollectibleDataDB(sqlDb *sql.DB) *CollectibleDataDB {
return &CollectibleDataDB{
db: sqlDb,
}
}
const collectibleDataColumns = "chain_id, contract_address, token_id, provider, name, description, permalink, image_url, image_payload, animation_url, animation_media_type, background_color, token_uri, community_id"
const collectibleCommunityDataColumns = "community_privileges_level"
const collectibleTraitsColumns = "chain_id, contract_address, token_id, trait_type, trait_value, display_type, max_value"
const selectCollectibleTraitsColumns = "trait_type, trait_value, display_type, max_value"
func rowsToCollectibleTraits(rows *sql.Rows) ([]thirdparty.CollectibleTrait, error) {
var traits []thirdparty.CollectibleTrait = make([]thirdparty.CollectibleTrait, 0)
for rows.Next() {
var trait thirdparty.CollectibleTrait
err := rows.Scan(
&trait.TraitType,
&trait.Value,
&trait.DisplayType,
&trait.MaxValue,
)
if err != nil {
return nil, err
}
traits = append(traits, trait)
}
return traits, nil
}
func getCollectibleTraits(creator sqlite.StatementCreator, id thirdparty.CollectibleUniqueID) ([]thirdparty.CollectibleTrait, error) {
// Get traits list
selectTraits, err := creator.Prepare(fmt.Sprintf(`SELECT %s
FROM collectible_traits_cache
WHERE chain_id = ? AND contract_address = ? AND token_id = ?`, selectCollectibleTraitsColumns))
if err != nil {
return nil, err
}
rows, err := selectTraits.Query(
id.ContractID.ChainID,
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
)
if err != nil {
return nil, err
}
return rowsToCollectibleTraits(rows)
}
func upsertCollectibleTraits(creator sqlite.StatementCreator, id thirdparty.CollectibleUniqueID, traits []thirdparty.CollectibleTrait) error {
// Remove old traits list
deleteTraits, err := creator.Prepare(`DELETE FROM collectible_traits_cache WHERE chain_id = ? AND contract_address = ? AND token_id = ?`)
if err != nil {
return err
}
_, err = deleteTraits.Exec(
id.ContractID.ChainID,
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
)
if err != nil {
return err
}
// Insert new traits list
insertTrait, err := creator.Prepare(fmt.Sprintf(`INSERT INTO collectible_traits_cache (%s)
VALUES (?, ?, ?, ?, ?, ?, ?)`, collectibleTraitsColumns))
if err != nil {
return err
}
for _, t := range traits {
_, err = insertTrait.Exec(
id.ContractID.ChainID,
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
t.TraitType,
t.Value,
t.DisplayType,
t.MaxValue,
)
if err != nil {
return err
}
}
return nil
}
func setCollectiblesData(creator sqlite.StatementCreator, collectibles []thirdparty.CollectibleData, allowUpdate bool) error {
insertCollectible, err := creator.Prepare(fmt.Sprintf(`%s INTO collectible_data_cache (%s)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, insertStatement(allowUpdate), collectibleDataColumns))
if err != nil {
return err
}
for _, c := range collectibles {
_, err = insertCollectible.Exec(
c.ID.ContractID.ChainID,
c.ID.ContractID.Address,
(*bigint.SQLBigIntBytes)(c.ID.TokenID.Int),
c.Provider,
c.Name,
c.Description,
c.Permalink,
c.ImageURL,
c.ImagePayload,
c.AnimationURL,
c.AnimationMediaType,
c.BackgroundColor,
c.TokenURI,
c.CommunityID,
)
if err != nil {
return err
}
err = upsertContractType(creator, c.ID.ContractID, c.ContractType)
if err != nil {
return err
}
if allowUpdate {
err = upsertCollectibleTraits(creator, c.ID, c.Traits)
if err != nil {
return err
}
}
}
return nil
}
func (o *CollectibleDataDB) SetData(collectibles []thirdparty.CollectibleData, allowUpdate bool) (err error) {
tx, err := o.db.Begin()
if err != nil {
return err
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
// Insert new collectibles data
err = setCollectiblesData(tx, collectibles, allowUpdate)
if err != nil {
return err
}
return
}
func scanCollectiblesDataRow(row *sql.Row) (*thirdparty.CollectibleData, error) {
c := thirdparty.CollectibleData{
ID: thirdparty.CollectibleUniqueID{
TokenID: &bigint.BigInt{Int: big.NewInt(0)},
},
Traits: make([]thirdparty.CollectibleTrait, 0),
}
err := row.Scan(
&c.ID.ContractID.ChainID,
&c.ID.ContractID.Address,
(*bigint.SQLBigIntBytes)(c.ID.TokenID.Int),
&c.Provider,
&c.Name,
&c.Description,
&c.Permalink,
&c.ImageURL,
&c.ImagePayload,
&c.AnimationURL,
&c.AnimationMediaType,
&c.BackgroundColor,
&c.TokenURI,
&c.CommunityID,
)
if err != nil {
return nil, err
}
return &c, nil
}
func (o *CollectibleDataDB) GetIDsNotInDB(ids []thirdparty.CollectibleUniqueID) ([]thirdparty.CollectibleUniqueID, error) {
ret := make([]thirdparty.CollectibleUniqueID, 0, len(ids))
exists, err := o.db.Prepare(`SELECT EXISTS (
SELECT 1 FROM collectible_data_cache
WHERE chain_id=? AND contract_address=? AND token_id=?
)`)
if err != nil {
return nil, err
}
for _, id := range ids {
row := exists.QueryRow(
id.ContractID.ChainID,
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
)
var exists bool
err = row.Scan(&exists)
if err != nil {
return nil, err
}
if !exists {
ret = append(ret, id)
}
}
return ret, nil
}
func (o *CollectibleDataDB) GetData(ids []thirdparty.CollectibleUniqueID) (map[string]thirdparty.CollectibleData, error) {
ret := make(map[string]thirdparty.CollectibleData)
getData, err := o.db.Prepare(fmt.Sprintf(`SELECT %s
FROM collectible_data_cache
WHERE chain_id=? AND contract_address=? AND token_id=?`, collectibleDataColumns))
if err != nil {
return nil, err
}
for _, id := range ids {
row := getData.QueryRow(
id.ContractID.ChainID,
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
)
c, err := scanCollectiblesDataRow(row)
if err == sql.ErrNoRows {
continue
} else if err != nil {
return nil, err
} else {
// Get traits from different table
c.Traits, err = getCollectibleTraits(o.db, c.ID)
if err != nil {
return nil, err
}
// Get contract type from different table
c.ContractType, err = readContractType(o.db, c.ID.ContractID)
if err != nil {
return nil, err
}
ret[c.ID.HashKey()] = *c
}
}
return ret, nil
}
func (o *CollectibleDataDB) SetCommunityInfo(id thirdparty.CollectibleUniqueID, communityInfo thirdparty.CollectibleCommunityInfo) (err error) {
tx, err := o.db.Begin()
if err != nil {
return err
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
update, err := tx.Prepare(`UPDATE collectible_data_cache
SET community_privileges_level=?
WHERE chain_id=? AND contract_address=? AND token_id=?`)
if err != nil {
return err
}
_, err = update.Exec(
communityInfo.PrivilegesLevel,
id.ContractID.ChainID,
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
)
return err
}
func (o *CollectibleDataDB) GetCommunityInfo(id thirdparty.CollectibleUniqueID) (*thirdparty.CollectibleCommunityInfo, error) {
ret := thirdparty.CollectibleCommunityInfo{
PrivilegesLevel: token.CommunityLevel,
}
getData, err := o.db.Prepare(fmt.Sprintf(`SELECT %s
FROM collectible_data_cache
WHERE chain_id=? AND contract_address=? AND token_id=?`, collectibleCommunityDataColumns))
if err != nil {
return nil, err
}
row := getData.QueryRow(
id.ContractID.ChainID,
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
)
var dbPrivilegesLevel sql.NullByte
err = row.Scan(
&dbPrivilegesLevel,
)
if err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
if dbPrivilegesLevel.Valid {
ret.PrivilegesLevel = token.PrivilegesLevel(dbPrivilegesLevel.Byte)
}
return &ret, nil
}
@@ -0,0 +1,247 @@
package collectibles
import (
"database/sql"
"fmt"
"github.com/status-im/status-go/services/wallet/thirdparty"
"github.com/status-im/status-go/sqlite"
)
type CollectionDataDB struct {
db *sql.DB
}
func NewCollectionDataDB(sqlDb *sql.DB) *CollectionDataDB {
return &CollectionDataDB{
db: sqlDb,
}
}
const collectionDataColumns = "chain_id, contract_address, provider, name, slug, image_url, image_payload, community_id"
const collectionTraitsColumns = "chain_id, contract_address, trait_type, min, max"
const selectCollectionTraitsColumns = "trait_type, min, max"
func rowsToCollectionTraits(rows *sql.Rows) (map[string]thirdparty.CollectionTrait, error) {
traits := make(map[string]thirdparty.CollectionTrait)
for rows.Next() {
var traitType string
var trait thirdparty.CollectionTrait
err := rows.Scan(
&traitType,
&trait.Min,
&trait.Max,
)
if err != nil {
return nil, err
}
traits[traitType] = trait
}
return traits, nil
}
func getCollectionTraits(creator sqlite.StatementCreator, id thirdparty.ContractID) (map[string]thirdparty.CollectionTrait, error) {
// Get traits list
selectTraits, err := creator.Prepare(fmt.Sprintf(`SELECT %s
FROM collection_traits_cache
WHERE chain_id = ? AND contract_address = ?`, selectCollectionTraitsColumns))
if err != nil {
return nil, err
}
rows, err := selectTraits.Query(
id.ChainID,
id.Address,
)
if err != nil {
return nil, err
}
return rowsToCollectionTraits(rows)
}
func upsertCollectionTraits(creator sqlite.StatementCreator, id thirdparty.ContractID, traits map[string]thirdparty.CollectionTrait) error {
// Rremove old traits list
deleteTraits, err := creator.Prepare(`DELETE FROM collection_traits_cache WHERE chain_id = ? AND contract_address = ?`)
if err != nil {
return err
}
_, err = deleteTraits.Exec(
id.ChainID,
id.Address,
)
if err != nil {
return err
}
// Insert new traits list
insertTrait, err := creator.Prepare(fmt.Sprintf(`INSERT OR REPLACE INTO collection_traits_cache (%s)
VALUES (?, ?, ?, ?, ?)`, collectionTraitsColumns))
if err != nil {
return err
}
for traitType, trait := range traits {
_, err = insertTrait.Exec(
id.ChainID,
id.Address,
traitType,
trait.Min,
trait.Max,
)
if err != nil {
return err
}
}
return nil
}
func setCollectionsData(creator sqlite.StatementCreator, collections []thirdparty.CollectionData, allowUpdate bool) error {
insertCollection, err := creator.Prepare(fmt.Sprintf(`%s INTO collection_data_cache (%s)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, insertStatement(allowUpdate), collectionDataColumns))
if err != nil {
return err
}
for _, c := range collections {
_, err = insertCollection.Exec(
c.ID.ChainID,
c.ID.Address,
c.Provider,
c.Name,
c.Slug,
c.ImageURL,
c.ImagePayload,
c.CommunityID,
)
if err != nil {
return err
}
err = upsertContractType(creator, c.ID, c.ContractType)
if err != nil {
return err
}
if allowUpdate {
err = upsertCollectionTraits(creator, c.ID, c.Traits)
if err != nil {
return err
}
}
}
return nil
}
func (o *CollectionDataDB) SetData(collections []thirdparty.CollectionData, allowUpdate bool) (err error) {
tx, err := o.db.Begin()
if err != nil {
return err
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
// Insert new collections data
err = setCollectionsData(tx, collections, allowUpdate)
if err != nil {
return err
}
return
}
func scanCollectionsDataRow(row *sql.Row) (*thirdparty.CollectionData, error) {
c := thirdparty.CollectionData{
Traits: make(map[string]thirdparty.CollectionTrait),
}
err := row.Scan(
&c.ID.ChainID,
&c.ID.Address,
&c.Provider,
&c.Name,
&c.Slug,
&c.ImageURL,
&c.ImagePayload,
&c.CommunityID,
)
if err != nil {
return nil, err
}
return &c, nil
}
func (o *CollectionDataDB) GetIDsNotInDB(ids []thirdparty.ContractID) ([]thirdparty.ContractID, error) {
ret := make([]thirdparty.ContractID, 0, len(ids))
exists, err := o.db.Prepare(`SELECT EXISTS (
SELECT 1 FROM collection_data_cache
WHERE chain_id=? AND contract_address=?
)`)
if err != nil {
return nil, err
}
for _, id := range ids {
row := exists.QueryRow(
id.ChainID,
id.Address,
)
var exists bool
err = row.Scan(&exists)
if err != nil {
return nil, err
}
if !exists {
ret = append(ret, id)
}
}
return ret, nil
}
func (o *CollectionDataDB) GetData(ids []thirdparty.ContractID) (map[string]thirdparty.CollectionData, error) {
ret := make(map[string]thirdparty.CollectionData)
getData, err := o.db.Prepare(fmt.Sprintf(`SELECT %s
FROM collection_data_cache
WHERE chain_id=? AND contract_address=?`, collectionDataColumns))
if err != nil {
return nil, err
}
for _, id := range ids {
row := getData.QueryRow(
id.ChainID,
id.Address,
)
c, err := scanCollectionsDataRow(row)
if err == sql.ErrNoRows {
continue
} else if err != nil {
return nil, err
} else {
// Get traits from different table
c.Traits, err = getCollectionTraits(o.db, c.ID)
if err != nil {
return nil, err
}
// Get contract type from different table
c.ContractType, err = readContractType(o.db, c.ID)
if err != nil {
return nil, err
}
ret[c.ID.HashKey()] = *c
}
}
return ret, nil
}
@@ -0,0 +1,341 @@
package collectibles
import (
"context"
"encoding/json"
"errors"
"math/big"
"sync/atomic"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/event"
"github.com/ethereum/go-ethereum/log"
"github.com/status-im/status-go/services/wallet/async"
"github.com/status-im/status-go/services/wallet/bigint"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/thirdparty"
"github.com/status-im/status-go/services/wallet/transfer"
"github.com/status-im/status-go/services/wallet/walletevent"
)
const (
fetchLimit = 50 // Limit number of collectibles we fetch per provider call
accountOwnershipUpdateInterval = 60 * time.Minute
accountOwnershipUpdateDelayInterval = 30 * time.Second
)
type OwnershipState = int
type OwnedCollectibles struct {
chainID walletCommon.ChainID
account common.Address
ids []thirdparty.CollectibleUniqueID
}
type OwnedCollectiblesChangeType = int
const (
OwnedCollectiblesChangeTypeAdded OwnedCollectiblesChangeType = iota + 1
OwnedCollectiblesChangeTypeUpdated
OwnedCollectiblesChangeTypeRemoved
)
type OwnedCollectiblesChange struct {
ownedCollectibles OwnedCollectibles
changeType OwnedCollectiblesChangeType
}
type OwnedCollectiblesChangeCb func(OwnedCollectiblesChange)
type TransferCb func(common.Address, walletCommon.ChainID, []transfer.Transfer)
const (
OwnershipStateIdle OwnershipState = iota + 1
OwnershipStateDelayed
OwnershipStateUpdating
OwnershipStateError
)
type periodicRefreshOwnedCollectiblesCommand struct {
chainID walletCommon.ChainID
account common.Address
manager *Manager
ownershipDB *OwnershipDB
walletFeed *event.Feed
ownedCollectiblesChangeCb OwnedCollectiblesChangeCb
group *async.Group
state atomic.Value
}
func newPeriodicRefreshOwnedCollectiblesCommand(
manager *Manager,
ownershipDB *OwnershipDB,
walletFeed *event.Feed,
chainID walletCommon.ChainID,
account common.Address,
ownedCollectiblesChangeCb OwnedCollectiblesChangeCb) *periodicRefreshOwnedCollectiblesCommand {
ret := &periodicRefreshOwnedCollectiblesCommand{
manager: manager,
ownershipDB: ownershipDB,
walletFeed: walletFeed,
chainID: chainID,
account: account,
ownedCollectiblesChangeCb: ownedCollectiblesChangeCb,
}
ret.state.Store(OwnershipStateIdle)
return ret
}
func (c *periodicRefreshOwnedCollectiblesCommand) DelayedCommand() async.Command {
return async.SingleShotCommand{
Interval: accountOwnershipUpdateDelayInterval,
Init: func(ctx context.Context) (err error) {
c.state.Store(OwnershipStateDelayed)
return nil
},
Runable: c.Command(),
}.Run
}
func (c *periodicRefreshOwnedCollectiblesCommand) Command() async.Command {
return async.InfiniteCommand{
Interval: accountOwnershipUpdateInterval,
Runable: c.Run,
}.Run
}
func (c *periodicRefreshOwnedCollectiblesCommand) Run(ctx context.Context) (err error) {
return c.loadOwnedCollectibles(ctx)
}
func (c *periodicRefreshOwnedCollectiblesCommand) GetState() OwnershipState {
return c.state.Load().(OwnershipState)
}
func (c *periodicRefreshOwnedCollectiblesCommand) Stop() {
if c.group != nil {
c.group.Stop()
c.group.Wait()
c.group = nil
}
}
func (c *periodicRefreshOwnedCollectiblesCommand) loadOwnedCollectibles(ctx context.Context) error {
c.group = async.NewGroup(ctx)
ownedCollectiblesChangeCh := make(chan OwnedCollectiblesChange)
command := newLoadOwnedCollectiblesCommand(c.manager, c.ownershipDB, c.walletFeed, c.chainID, c.account, ownedCollectiblesChangeCh)
c.state.Store(OwnershipStateUpdating)
defer func() {
if command.err != nil {
c.state.Store(OwnershipStateError)
} else {
c.state.Store(OwnershipStateIdle)
}
}()
c.group.Add(command.Command())
select {
case ownedCollectiblesChange := <-ownedCollectiblesChangeCh:
if c.ownedCollectiblesChangeCb != nil {
c.ownedCollectiblesChangeCb(ownedCollectiblesChange)
}
case <-ctx.Done():
return ctx.Err()
case <-c.group.WaitAsync():
return nil
}
return nil
}
// Fetches owned collectibles for a ChainID+OwnerAddress combination in chunks
// and updates the ownershipDB when all chunks are loaded
type loadOwnedCollectiblesCommand struct {
chainID walletCommon.ChainID
account common.Address
manager *Manager
ownershipDB *OwnershipDB
walletFeed *event.Feed
ownedCollectiblesChangeCh chan<- OwnedCollectiblesChange
// Not to be set by the caller
partialOwnership []thirdparty.CollectibleUniqueID
err error
}
func newLoadOwnedCollectiblesCommand(
manager *Manager,
ownershipDB *OwnershipDB,
walletFeed *event.Feed,
chainID walletCommon.ChainID,
account common.Address,
ownedCollectiblesChangeCh chan<- OwnedCollectiblesChange) *loadOwnedCollectiblesCommand {
return &loadOwnedCollectiblesCommand{
manager: manager,
ownershipDB: ownershipDB,
walletFeed: walletFeed,
chainID: chainID,
account: account,
ownedCollectiblesChangeCh: ownedCollectiblesChangeCh,
}
}
func (c *loadOwnedCollectiblesCommand) Command() async.Command {
return c.Run
}
func (c *loadOwnedCollectiblesCommand) triggerEvent(eventType walletevent.EventType, chainID walletCommon.ChainID, account common.Address, message string) {
c.walletFeed.Send(walletevent.Event{
Type: eventType,
ChainID: uint64(chainID),
Accounts: []common.Address{
account,
},
Message: message,
})
}
func ownedTokensToTokenBalancesPerContractAddress(ownership []thirdparty.CollectibleUniqueID) thirdparty.TokenBalancesPerContractAddress {
ret := make(thirdparty.TokenBalancesPerContractAddress)
for _, id := range ownership {
balance := thirdparty.TokenBalance{
TokenID: id.TokenID,
Balance: &bigint.BigInt{Int: big.NewInt(1)},
}
ret[id.ContractID.Address] = append(ret[id.ContractID.Address], balance)
}
return ret
}
func (c *loadOwnedCollectiblesCommand) sendOwnedCollectiblesChanges(removed, updated, added []thirdparty.CollectibleUniqueID) {
if len(removed) > 0 {
c.ownedCollectiblesChangeCh <- OwnedCollectiblesChange{
ownedCollectibles: OwnedCollectibles{
chainID: c.chainID,
account: c.account,
ids: removed,
},
changeType: OwnedCollectiblesChangeTypeRemoved,
}
}
if len(updated) > 0 {
c.ownedCollectiblesChangeCh <- OwnedCollectiblesChange{
ownedCollectibles: OwnedCollectibles{
chainID: c.chainID,
account: c.account,
ids: updated,
},
changeType: OwnedCollectiblesChangeTypeUpdated,
}
}
if len(added) > 0 {
c.ownedCollectiblesChangeCh <- OwnedCollectiblesChange{
ownedCollectibles: OwnedCollectibles{
chainID: c.chainID,
account: c.account,
ids: added,
},
changeType: OwnedCollectiblesChangeTypeAdded,
}
}
}
func (c *loadOwnedCollectiblesCommand) Run(parent context.Context) (err error) {
log.Debug("start loadOwnedCollectiblesCommand", "chain", c.chainID, "account", c.account)
pageNr := 0
cursor := thirdparty.FetchFromStartCursor
providerID := thirdparty.FetchFromAnyProvider
start := time.Now()
c.triggerEvent(EventCollectiblesOwnershipUpdateStarted, c.chainID, c.account, "")
updateMessage := OwnershipUpdateMessage{}
lastFetchTimestamp, err := c.ownershipDB.GetOwnershipUpdateTimestamp(c.account, c.chainID)
if err != nil {
c.err = err
} else {
initialFetch := lastFetchTimestamp == InvalidTimestamp
// Fetch collectibles in chunks
for {
if walletCommon.ShouldCancel(parent) {
c.err = errors.New("context cancelled")
break
}
pageStart := time.Now()
log.Debug("start loadOwnedCollectiblesCommand", "chain", c.chainID, "account", c.account, "page", pageNr)
partialOwnership, err := c.manager.FetchCollectibleOwnershipByOwner(parent, c.chainID, c.account, cursor, fetchLimit, providerID)
if err != nil {
log.Error("failed loadOwnedCollectiblesCommand", "chain", c.chainID, "account", c.account, "page", pageNr, "error", err)
c.err = err
break
}
log.Debug("partial loadOwnedCollectiblesCommand", "chain", c.chainID, "account", c.account, "page", pageNr, "in", time.Since(pageStart), "found", len(partialOwnership.Items))
c.partialOwnership = append(c.partialOwnership, partialOwnership.Items...)
pageNr++
cursor = partialOwnership.NextCursor
providerID = partialOwnership.Provider
finished := cursor == thirdparty.FetchFromStartCursor
// Normally, update the DB once we've finished fetching
// If this is the first fetch, make partial updates to the client to get a better UX
if initialFetch || finished {
// Token balances should come from the providers. For now we assume all balances are 1, which
// is only valid for ERC721.
// TODO (#13025): Fetch balances from the providers.
balances := ownedTokensToTokenBalancesPerContractAddress(c.partialOwnership)
updateMessage.Removed, updateMessage.Updated, updateMessage.Added, err = c.ownershipDB.Update(c.chainID, c.account, balances, start.Unix())
if err != nil {
log.Error("failed updating ownershipDB in loadOwnedCollectiblesCommand", "chain", c.chainID, "account", c.account, "error", err)
c.err = err
break
}
c.sendOwnedCollectiblesChanges(updateMessage.Removed, updateMessage.Updated, updateMessage.Added)
}
if finished || c.err != nil {
break
} else if initialFetch {
encodedMessage, err := json.Marshal(updateMessage)
if err != nil {
c.err = err
break
}
c.triggerEvent(EventCollectiblesOwnershipUpdatePartial, c.chainID, c.account, string(encodedMessage))
updateMessage = OwnershipUpdateMessage{}
}
}
}
var encodedMessage []byte
if c.err == nil {
encodedMessage, c.err = json.Marshal(updateMessage)
}
if c.err != nil {
c.triggerEvent(EventCollectiblesOwnershipUpdateFinishedWithError, c.chainID, c.account, c.err.Error())
} else {
c.triggerEvent(EventCollectiblesOwnershipUpdateFinished, c.chainID, c.account, string(encodedMessage))
}
log.Debug("end loadOwnedCollectiblesCommand", "chain", c.chainID, "account", c.account, "in", time.Since(start))
return nil
}
@@ -0,0 +1,68 @@
package collectibles
import (
"database/sql"
sq "github.com/Masterminds/squirrel"
w_common "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/thirdparty"
"github.com/status-im/status-go/sqlite"
)
func upsertContractType(creator sqlite.StatementCreator, id thirdparty.ContractID, contractType w_common.ContractType) error {
if contractType == w_common.ContractTypeUnknown {
return nil
}
q := sq.Replace("contract_type_cache").
SetMap(sq.Eq{"chain_id": id.ChainID, "contract_address": id.Address, "contract_type": contractType})
query, args, err := q.ToSql()
if err != nil {
return err
}
stmt, err := creator.Prepare(query)
if err != nil {
return err
}
defer stmt.Close()
_, err = stmt.Exec(args...)
return err
}
func readContractType(creator sqlite.StatementCreator, id thirdparty.ContractID) (w_common.ContractType, error) {
q := sq.Select("contract_type").
From("contract_type_cache").
Where(sq.Eq{"chain_id": id.ChainID, "contract_address": id.Address})
query, args, err := q.ToSql()
if err != nil {
return w_common.ContractTypeUnknown, err
}
stmt, err := creator.Prepare(query)
if err != nil {
return w_common.ContractTypeUnknown, err
}
defer stmt.Close()
_, err = stmt.Exec(args...)
if err != nil {
return w_common.ContractTypeUnknown, err
}
var transferType w_common.ContractType
err = stmt.QueryRow(args...).Scan(&transferType)
if err == sql.ErrNoRows {
return w_common.ContractTypeUnknown, nil
} else if err != nil {
return w_common.ContractTypeUnknown, err
}
return transferType, nil
}
@@ -0,0 +1,418 @@
package collectibles
import (
"context"
"database/sql"
"errors"
"sync"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/event"
"github.com/ethereum/go-ethereum/log"
"github.com/status-im/status-go/multiaccounts/accounts"
"github.com/status-im/status-go/multiaccounts/settings"
"github.com/status-im/status-go/rpc/network"
"github.com/status-im/status-go/services/accounts/accountsevent"
"github.com/status-im/status-go/services/accounts/settingsevent"
"github.com/status-im/status-go/services/wallet/async"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/transfer"
"github.com/status-im/status-go/services/wallet/walletevent"
)
const (
activityRefetchMarginSeconds = 30 * 60 // Trigger a fetch if activity is detected this many seconds before the last fetch
)
type commandPerChainID = map[walletCommon.ChainID]*periodicRefreshOwnedCollectiblesCommand
type commandPerAddressAndChainID = map[common.Address]commandPerChainID
type timerPerChainID = map[walletCommon.ChainID]*time.Timer
type timerPerAddressAndChainID = map[common.Address]timerPerChainID
type Controller struct {
manager *Manager
ownershipDB *OwnershipDB
walletFeed *event.Feed
accountsDB *accounts.Database
accountsFeed *event.Feed
settingsFeed *event.Feed
networkManager *network.Manager
cancelFn context.CancelFunc
commands commandPerAddressAndChainID
timers timerPerAddressAndChainID
group *async.Group
accountsWatcher *accountsevent.Watcher
walletEventsWatcher *walletevent.Watcher
settingsWatcher *settingsevent.Watcher
ownedCollectiblesChangeCb OwnedCollectiblesChangeCb
collectiblesTransferCb TransferCb
commandsLock sync.RWMutex
}
func NewController(
db *sql.DB,
walletFeed *event.Feed,
accountsDB *accounts.Database,
accountsFeed *event.Feed,
settingsFeed *event.Feed,
networkManager *network.Manager,
manager *Manager) *Controller {
return &Controller{
manager: manager,
ownershipDB: NewOwnershipDB(db),
walletFeed: walletFeed,
accountsDB: accountsDB,
accountsFeed: accountsFeed,
settingsFeed: settingsFeed,
networkManager: networkManager,
commands: make(commandPerAddressAndChainID),
timers: make(timerPerAddressAndChainID),
}
}
func (c *Controller) SetOwnedCollectiblesChangeCb(cb OwnedCollectiblesChangeCb) {
c.ownedCollectiblesChangeCb = cb
}
func (c *Controller) SetCollectiblesTransferCb(cb TransferCb) {
c.collectiblesTransferCb = cb
}
func (c *Controller) Start() {
// Setup periodical collectibles refresh
_ = c.startPeriodicalOwnershipFetch()
// Setup collectibles fetch when a new account gets added
c.startAccountsWatcher()
// Setup collectibles fetch when relevant activity is detected
c.startWalletEventsWatcher()
// Setup collectibles fetch when chain-related settings change
c.startSettingsWatcher()
}
func (c *Controller) Stop() {
c.stopSettingsWatcher()
c.stopWalletEventsWatcher()
c.stopAccountsWatcher()
c.stopPeriodicalOwnershipFetch()
}
func (c *Controller) RefetchOwnedCollectibles() {
c.stopPeriodicalOwnershipFetch()
c.manager.ResetConnectionStatus()
_ = c.startPeriodicalOwnershipFetch()
}
func (c *Controller) GetCommandState(chainID walletCommon.ChainID, address common.Address) OwnershipState {
c.commandsLock.RLock()
defer c.commandsLock.RUnlock()
state := OwnershipStateIdle
if c.commands[address] != nil && c.commands[address][chainID] != nil {
state = c.commands[address][chainID].GetState()
}
return state
}
func (c *Controller) isPeriodicalOwnershipFetchRunning() bool {
return c.group != nil
}
// Starts periodical fetching for the all wallet addresses and all chains
func (c *Controller) startPeriodicalOwnershipFetch() error {
c.commandsLock.Lock()
defer c.commandsLock.Unlock()
if c.isPeriodicalOwnershipFetchRunning() {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
c.cancelFn = cancel
c.group = async.NewGroup(ctx)
addresses, err := c.accountsDB.GetWalletAddresses()
if err != nil {
return err
}
for _, addr := range addresses {
err := c.startPeriodicalOwnershipFetchForAccount(common.Address(addr))
if err != nil {
log.Error("Error starting periodical collectibles fetch for accpunt", "address", addr, "error", err)
return err
}
}
return nil
}
func (c *Controller) stopPeriodicalOwnershipFetch() {
c.commandsLock.Lock()
defer c.commandsLock.Unlock()
if !c.isPeriodicalOwnershipFetchRunning() {
return
}
if c.cancelFn != nil {
c.cancelFn()
c.cancelFn = nil
}
if c.group != nil {
c.group.Stop()
c.group.Wait()
c.group = nil
c.commands = make(commandPerAddressAndChainID)
}
}
// Starts (or restarts) periodical fetching for the given account address for all chains
func (c *Controller) startPeriodicalOwnershipFetchForAccount(address common.Address) error {
log.Debug("wallet.api.collectibles.Controller Start periodical fetching", "address", address)
networks, err := c.networkManager.Get(false)
if err != nil {
return err
}
areTestNetworksEnabled, err := c.accountsDB.GetTestNetworksEnabled()
if err != nil {
return err
}
for _, network := range networks {
if network.IsTest != areTestNetworksEnabled {
continue
}
chainID := walletCommon.ChainID(network.ChainID)
err := c.startPeriodicalOwnershipFetchForAccountAndChainID(address, chainID, false)
if err != nil {
return err
}
}
return nil
}
// Starts (or restarts) periodical fetching for the given account address for all chains
func (c *Controller) startPeriodicalOwnershipFetchForAccountAndChainID(address common.Address, chainID walletCommon.ChainID, delayed bool) error {
log.Debug("wallet.api.collectibles.Controller Start periodical fetching", "address", address, "chainID", chainID, "delayed", delayed)
if !c.isPeriodicalOwnershipFetchRunning() {
return errors.New("periodical fetch not initialized")
}
err := c.stopPeriodicalOwnershipFetchForAccountAndChainID(address, chainID)
if err != nil {
return err
}
if _, ok := c.commands[address]; !ok {
c.commands[address] = make(commandPerChainID)
}
command := newPeriodicRefreshOwnedCollectiblesCommand(
c.manager,
c.ownershipDB,
c.walletFeed,
chainID,
address,
c.ownedCollectiblesChangeCb,
)
c.commands[address][chainID] = command
if delayed {
c.group.Add(command.DelayedCommand())
} else {
c.group.Add(command.Command())
}
return nil
}
// Stop periodical fetching for the given account address for all chains
func (c *Controller) stopPeriodicalOwnershipFetchForAccount(address common.Address) error {
log.Debug("wallet.api.collectibles.Controller Stop periodical fetching", "address", address)
if !c.isPeriodicalOwnershipFetchRunning() {
return errors.New("periodical fetch not initialized")
}
if _, ok := c.commands[address]; ok {
for chainID := range c.commands[address] {
err := c.stopPeriodicalOwnershipFetchForAccountAndChainID(address, chainID)
if err != nil {
return err
}
}
}
return nil
}
func (c *Controller) stopPeriodicalOwnershipFetchForAccountAndChainID(address common.Address, chainID walletCommon.ChainID) error {
log.Debug("wallet.api.collectibles.Controller Stop periodical fetching", "address", address, "chainID", chainID)
if !c.isPeriodicalOwnershipFetchRunning() {
return errors.New("periodical fetch not initialized")
}
if _, ok := c.commands[address]; ok {
if _, ok := c.commands[address][chainID]; ok {
c.commands[address][chainID].Stop()
delete(c.commands[address], chainID)
}
// If it was the last chain, delete the address as well
if len(c.commands[address]) == 0 {
delete(c.commands, address)
}
}
return nil
}
func (c *Controller) startAccountsWatcher() {
if c.accountsWatcher != nil {
return
}
accountChangeCb := func(changedAddresses []common.Address, eventType accountsevent.EventType, currentAddresses []common.Address) {
c.commandsLock.Lock()
defer c.commandsLock.Unlock()
// Whenever an account gets added, start fetching
if eventType == accountsevent.EventTypeAdded {
for _, address := range changedAddresses {
err := c.startPeriodicalOwnershipFetchForAccount(address)
if err != nil {
log.Error("Error starting periodical collectibles fetch", "address", address, "error", err)
}
}
} else if eventType == accountsevent.EventTypeRemoved {
for _, address := range changedAddresses {
err := c.stopPeriodicalOwnershipFetchForAccount(address)
if err != nil {
log.Error("Error starting periodical collectibles fetch", "address", address, "error", err)
}
}
}
}
c.accountsWatcher = accountsevent.NewWatcher(c.accountsDB, c.accountsFeed, accountChangeCb)
c.accountsWatcher.Start()
}
func (c *Controller) stopAccountsWatcher() {
if c.accountsWatcher != nil {
c.accountsWatcher.Stop()
c.accountsWatcher = nil
}
}
func (c *Controller) startWalletEventsWatcher() {
if c.walletEventsWatcher != nil {
return
}
walletEventCb := func(event walletevent.Event) {
// EventRecentHistoryReady ?
if event.Type != transfer.EventInternalERC721TransferDetected &&
event.Type != transfer.EventInternalERC1155TransferDetected {
return
}
chainID := walletCommon.ChainID(event.ChainID)
for _, account := range event.Accounts {
// Call external callback
if c.collectiblesTransferCb != nil {
c.collectiblesTransferCb(account, chainID, event.EventParams.([]transfer.Transfer))
}
c.refetchOwnershipIfRecentTransfer(account, chainID, event.At)
}
}
c.walletEventsWatcher = walletevent.NewWatcher(c.walletFeed, walletEventCb)
c.walletEventsWatcher.Start()
}
func (c *Controller) stopWalletEventsWatcher() {
if c.walletEventsWatcher != nil {
c.walletEventsWatcher.Stop()
c.walletEventsWatcher = nil
}
}
func (c *Controller) startSettingsWatcher() {
if c.settingsWatcher != nil {
return
}
settingChangeCb := func(setting settings.SettingField, value interface{}) {
if setting.Equals(settings.TestNetworksEnabled) || setting.Equals(settings.IsSepoliaEnabled) {
c.stopPeriodicalOwnershipFetch()
err := c.startPeriodicalOwnershipFetch()
if err != nil {
log.Error("Error starting periodical collectibles fetch", "error", err)
}
}
}
c.settingsWatcher = settingsevent.NewWatcher(c.settingsFeed, settingChangeCb)
c.settingsWatcher.Start()
}
func (c *Controller) stopSettingsWatcher() {
if c.settingsWatcher != nil {
c.settingsWatcher.Stop()
c.settingsWatcher = nil
}
}
func (c *Controller) refetchOwnershipIfRecentTransfer(account common.Address, chainID walletCommon.ChainID, latestTxTimestamp int64) {
// Check last ownership update timestamp
timestamp, err := c.ownershipDB.GetOwnershipUpdateTimestamp(account, chainID)
if err != nil {
log.Error("Error getting ownership update timestamp", "error", err)
return
}
if timestamp == InvalidTimestamp {
// Ownership was never fetched for this account
return
}
timeCheck := timestamp - activityRefetchMarginSeconds
if timeCheck < 0 {
timeCheck = 0
}
if latestTxTimestamp > timeCheck {
// Restart fetching for account + chainID
c.commandsLock.Lock()
err := c.startPeriodicalOwnershipFetchForAccountAndChainID(account, chainID, true)
c.commandsLock.Unlock()
if err != nil {
log.Error("Error starting periodical collectibles fetch", "address", account, "error", err)
}
}
}
@@ -0,0 +1,8 @@
package collectibles
func insertStatement(allowUpdate bool) string {
if allowUpdate {
return `INSERT OR REPLACE`
}
return `INSERT OR IGNORE`
}
@@ -0,0 +1,127 @@
package collectibles
import (
"context"
"database/sql"
"errors"
"github.com/ethereum/go-ethereum/common"
sq "github.com/Masterminds/squirrel"
"github.com/status-im/status-go/protocol/communities/token"
"github.com/status-im/status-go/services/wallet/bigint"
wcommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
func allCollectibleIDsFilter() []thirdparty.CollectibleUniqueID {
return []thirdparty.CollectibleUniqueID{}
}
func allCommunityIDsFilter() []string {
return []string{}
}
func allCommunityPrivilegesLevelsFilter() []token.PrivilegesLevel {
return []token.PrivilegesLevel{}
}
func allFilter() Filter {
return Filter{
CollectibleIDs: allCollectibleIDsFilter(),
CommunityIDs: allCommunityIDsFilter(),
CommunityPrivilegesLevels: allCommunityPrivilegesLevelsFilter(),
FilterCommunity: All,
}
}
type FilterCommunityType int
const (
All FilterCommunityType = iota
OnlyNonCommunity
OnlyCommunity
)
type Filter struct {
CollectibleIDs []thirdparty.CollectibleUniqueID `json:"collectible_ids"`
CommunityIDs []string `json:"community_ids"`
CommunityPrivilegesLevels []token.PrivilegesLevel `json:"community_privileges_levels"`
FilterCommunity FilterCommunityType `json:"filter_community"`
}
func filterOwnedCollectibles(ctx context.Context, db *sql.DB, chainIDs []wcommon.ChainID, addresses []common.Address, filter Filter, offset int, limit int) ([]thirdparty.CollectibleUniqueID, error) {
if len(addresses) == 0 {
return nil, errors.New("no addresses provided")
}
if len(chainIDs) == 0 {
return nil, errors.New("no chainIDs provided")
}
q := sq.Select("ownership.chain_id,ownership.contract_address,ownership.token_id")
q = q.From("collectibles_ownership_cache ownership").
LeftJoin(`collectible_data_cache data ON
ownership.chain_id = data.chain_id AND
ownership.contract_address = data.contract_address AND
ownership.token_id = data.token_id`)
qConditions := sq.And{}
qConditions = append(qConditions, sq.Eq{"ownership.chain_id": chainIDs})
qConditions = append(qConditions, sq.Eq{"ownership.owner_address": addresses})
if len(filter.CollectibleIDs) > 0 {
collectibleIDConditions := sq.Or{}
for _, collectibleID := range filter.CollectibleIDs {
collectibleIDConditions = append(collectibleIDConditions,
sq.And{
sq.Eq{"ownership.chain_id": collectibleID.ContractID.ChainID},
sq.Eq{"ownership.contract_address": collectibleID.ContractID.Address},
sq.Eq{"ownership.token_id": (*bigint.SQLBigIntBytes)(collectibleID.TokenID.Int)},
})
}
qConditions = append(qConditions, collectibleIDConditions)
}
switch filter.FilterCommunity {
case All:
// nothing to do
case OnlyNonCommunity:
qConditions = append(qConditions, sq.Eq{"data.community_id": ""})
case OnlyCommunity:
qConditions = append(qConditions, sq.NotEq{"data.community_id": ""})
}
if len(filter.CommunityIDs) > 0 {
qConditions = append(qConditions, sq.Eq{"data.community_id": filter.CommunityIDs})
}
if len(filter.CommunityPrivilegesLevels) > 0 {
qConditions = append(qConditions, sq.Eq{"data.community_privileges_level": filter.CommunityPrivilegesLevels})
}
q = q.Where(qConditions)
q = q.Limit(uint64(limit))
q = q.Offset(uint64(offset))
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
stmt, err := db.Prepare(query)
if err != nil {
return nil, err
}
defer stmt.Close()
rows, err := stmt.Query(args...)
if err != nil {
return nil, err
}
defer rows.Close()
return thirdparty.RowsToCollectibles(rows)
}
@@ -0,0 +1,870 @@
package collectibles
import (
"context"
"database/sql"
"encoding/json"
"errors"
"math/big"
"net/http"
"strings"
"time"
"github.com/afex/hystrix-go/hystrix"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/event"
"github.com/ethereum/go-ethereum/log"
"github.com/status-im/status-go/contracts/community-tokens/collectibles"
"github.com/status-im/status-go/rpc"
"github.com/status-im/status-go/server"
"github.com/status-im/status-go/services/wallet/async"
"github.com/status-im/status-go/services/wallet/bigint"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/community"
"github.com/status-im/status-go/services/wallet/connection"
"github.com/status-im/status-go/services/wallet/thirdparty"
"github.com/status-im/status-go/services/wallet/walletevent"
)
const requestTimeout = 5 * time.Second
const signalUpdatedCollectiblesDataPageSize = 10
const hystrixContractOwnershipClientName = "contractOwnershipClient"
const EventCollectiblesConnectionStatusChanged walletevent.EventType = "wallet-collectible-status-changed"
// ERC721 does not support function "TokenURI" if call
// returns error starting with one of these strings
var noTokenURIErrorPrefixes = []string{
"execution reverted",
"abi: attempting to unmarshall",
}
var (
ErrAllProvidersFailedForChainID = errors.New("all providers failed for chainID")
ErrNoProvidersAvailableForChainID = errors.New("no providers available for chainID")
)
type ManagerInterface interface {
FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID, asyncFetch bool) ([]thirdparty.FullCollectibleData, error)
}
type Manager struct {
rpcClient *rpc.Client
contractOwnershipProviders []thirdparty.CollectibleContractOwnershipProvider
accountOwnershipProviders []thirdparty.CollectibleAccountOwnershipProvider
collectibleDataProviders []thirdparty.CollectibleDataProvider
collectionDataProviders []thirdparty.CollectionDataProvider
collectibleProviders []thirdparty.CollectibleProvider
httpClient *http.Client
collectiblesDataDB *CollectibleDataDB
collectionsDataDB *CollectionDataDB
communityManager *community.Manager
ownershipDB *OwnershipDB
mediaServer *server.MediaServer
statuses map[string]*connection.Status
statusNotifier *connection.StatusNotifier
feed *event.Feed
}
func NewManager(
db *sql.DB,
rpcClient *rpc.Client,
communityManager *community.Manager,
contractOwnershipProviders []thirdparty.CollectibleContractOwnershipProvider,
accountOwnershipProviders []thirdparty.CollectibleAccountOwnershipProvider,
collectibleDataProviders []thirdparty.CollectibleDataProvider,
collectionDataProviders []thirdparty.CollectionDataProvider,
mediaServer *server.MediaServer,
feed *event.Feed) *Manager {
hystrix.ConfigureCommand(hystrixContractOwnershipClientName, hystrix.CommandConfig{
Timeout: 10000,
MaxConcurrentRequests: 100,
SleepWindow: 300000,
ErrorPercentThreshold: 25,
})
ownershipDB := NewOwnershipDB(db)
statuses := make(map[string]*connection.Status)
allChainIDs := walletCommon.AllChainIDs()
for _, chainID := range allChainIDs {
status := connection.NewStatus()
state := status.GetState()
latestUpdateTimestamp, err := ownershipDB.GetLatestOwnershipUpdateTimestamp(chainID)
if err == nil {
state.LastSuccessAt = latestUpdateTimestamp
status.SetState(state)
}
statuses[chainID.String()] = status
}
statusNotifier := connection.NewStatusNotifier(
statuses,
EventCollectiblesConnectionStatusChanged,
feed,
)
// Get list of all providers
collectibleProvidersMap := make(map[string]thirdparty.CollectibleProvider)
collectibleProviders := make([]thirdparty.CollectibleProvider, 0)
for _, provider := range contractOwnershipProviders {
collectibleProvidersMap[provider.ID()] = provider
}
for _, provider := range accountOwnershipProviders {
collectibleProvidersMap[provider.ID()] = provider
}
for _, provider := range collectibleDataProviders {
collectibleProvidersMap[provider.ID()] = provider
}
for _, provider := range collectionDataProviders {
collectibleProvidersMap[provider.ID()] = provider
}
for _, provider := range collectibleProvidersMap {
collectibleProviders = append(collectibleProviders, provider)
}
return &Manager{
rpcClient: rpcClient,
contractOwnershipProviders: contractOwnershipProviders,
accountOwnershipProviders: accountOwnershipProviders,
collectibleDataProviders: collectibleDataProviders,
collectionDataProviders: collectionDataProviders,
collectibleProviders: collectibleProviders,
httpClient: &http.Client{
Timeout: requestTimeout,
},
collectiblesDataDB: NewCollectibleDataDB(db),
collectionsDataDB: NewCollectionDataDB(db),
communityManager: communityManager,
ownershipDB: ownershipDB,
mediaServer: mediaServer,
statuses: statuses,
statusNotifier: statusNotifier,
feed: feed,
}
}
func mapToList[K comparable, T any](m map[K]T) []T {
list := make([]T, 0, len(m))
for _, v := range m {
list = append(list, v)
}
return list
}
func makeContractOwnershipCall(main func() (any, error), fallback func() (any, error)) (any, error) {
resultChan := make(chan any, 1)
errChan := hystrix.Go(hystrixContractOwnershipClientName, func() error {
res, err := main()
if err != nil {
return err
}
resultChan <- res
return nil
}, func(err error) error {
if fallback == nil {
return err
}
res, err := fallback()
if err != nil {
return err
}
resultChan <- res
return nil
})
select {
case result := <-resultChan:
return result, nil
case err := <-errChan:
return nil, err
}
}
func (o *Manager) doContentTypeRequest(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return "", err
}
resp, err := o.httpClient.Do(req)
if err != nil {
return "", err
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Error("failed to close head request body", "err", err)
}
}()
return resp.Header.Get("Content-Type"), nil
}
// Need to combine different providers to support all needed ChainIDs
func (o *Manager) FetchBalancesByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, ownerAddress common.Address, contractAddresses []common.Address) (thirdparty.TokenBalancesPerContractAddress, error) {
ret := make(thirdparty.TokenBalancesPerContractAddress)
for _, contractAddress := range contractAddresses {
ret[contractAddress] = make([]thirdparty.TokenBalance, 0)
}
// Try with account ownership providers first
assetsContainer, err := o.FetchAllAssetsByOwnerAndContractAddress(ctx, chainID, ownerAddress, contractAddresses, thirdparty.FetchFromStartCursor, thirdparty.FetchNoLimit, thirdparty.FetchFromAnyProvider)
if err == ErrNoProvidersAvailableForChainID {
// Use contract ownership providers
for _, contractAddress := range contractAddresses {
ownership, err := o.FetchCollectibleOwnersByContractAddress(ctx, chainID, contractAddress)
if err != nil {
return nil, err
}
for _, nftOwner := range ownership.Owners {
if nftOwner.OwnerAddress == ownerAddress {
ret[contractAddress] = nftOwner.TokenBalances
break
}
}
}
} else if err == nil {
// Account ownership providers succeeded
for _, fullData := range assetsContainer.Items {
contractAddress := fullData.CollectibleData.ID.ContractID.Address
balance := thirdparty.TokenBalance{
TokenID: fullData.CollectibleData.ID.TokenID,
Balance: &bigint.BigInt{Int: big.NewInt(1)},
}
ret[contractAddress] = append(ret[contractAddress], balance)
}
} else {
// OpenSea could have provided, but returned error
return nil, err
}
return ret, nil
}
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) {
defer o.checkConnectionStatus(chainID)
anyProviderAvailable := false
for _, provider := range o.accountOwnershipProviders {
if !provider.IsChainSupported(chainID) {
continue
}
anyProviderAvailable = true
if providerID != thirdparty.FetchFromAnyProvider && providerID != provider.ID() {
continue
}
assetContainer, err := provider.FetchAllAssetsByOwnerAndContractAddress(ctx, chainID, owner, contractAddresses, cursor, limit)
if err != nil {
log.Error("FetchAllAssetsByOwnerAndContractAddress failed for", "provider", provider.ID(), "chainID", chainID, "err", err)
continue
}
_, err = o.processFullCollectibleData(ctx, assetContainer.Items, true)
if err != nil {
return nil, err
}
return assetContainer, nil
}
if anyProviderAvailable {
return nil, ErrAllProvidersFailedForChainID
}
return nil, ErrNoProvidersAvailableForChainID
}
func (o *Manager) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int, providerID string) (*thirdparty.FullCollectibleDataContainer, error) {
defer o.checkConnectionStatus(chainID)
anyProviderAvailable := false
for _, provider := range o.accountOwnershipProviders {
if !provider.IsChainSupported(chainID) {
continue
}
anyProviderAvailable = true
if providerID != thirdparty.FetchFromAnyProvider && providerID != provider.ID() {
continue
}
assetContainer, err := provider.FetchAllAssetsByOwner(ctx, chainID, owner, cursor, limit)
if err != nil {
log.Error("FetchAllAssetsByOwner failed for", "provider", provider.ID(), "chainID", chainID, "err", err)
continue
}
_, err = o.processFullCollectibleData(ctx, assetContainer.Items, true)
if err != nil {
return nil, err
}
return assetContainer, nil
}
if anyProviderAvailable {
return nil, ErrAllProvidersFailedForChainID
}
return nil, ErrNoProvidersAvailableForChainID
}
func (o *Manager) FetchCollectibleOwnershipByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int, providerID string) (*thirdparty.CollectibleOwnershipContainer, error) {
// We don't yet have an API that will return only Ownership data
// Use the full Ownership + Metadata endpoint and use the data we need
assetContainer, err := o.FetchAllAssetsByOwner(ctx, chainID, owner, cursor, limit, providerID)
if err != nil {
return nil, err
}
ret := assetContainer.ToOwnershipContainer()
return &ret, nil
}
// Returns collectible metadata for the given unique IDs.
// If asyncFetch is true, empty metadata will be returned for any missing collectibles and an EventCollectiblesDataUpdated will be sent when the data is ready.
// If asyncFetch is false, it will wait for all collectibles' metadata to be retrieved before returning.
func (o *Manager) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID, asyncFetch bool) ([]thirdparty.FullCollectibleData, error) {
missingIDs, err := o.collectiblesDataDB.GetIDsNotInDB(uniqueIDs)
if err != nil {
return nil, err
}
missingIDsPerChainID := thirdparty.GroupCollectibleUIDsByChainID(missingIDs)
group := async.NewGroup(ctx)
group.Add(func(ctx context.Context) error {
for chainID, idsToFetch := range missingIDsPerChainID {
defer o.checkConnectionStatus(chainID)
for _, provider := range o.collectibleDataProviders {
if !provider.IsChainSupported(chainID) {
continue
}
fetchedAssets, err := provider.FetchAssetsByCollectibleUniqueID(ctx, idsToFetch)
if err != nil {
log.Error("FetchAssetsByCollectibleUniqueID failed for", "provider", provider.ID(), "chainID", chainID, "err", err)
continue
}
updatedCollectibles, err := o.processFullCollectibleData(ctx, fetchedAssets, asyncFetch)
if err != nil {
log.Error("processFullCollectibleData failed for", "provider", provider.ID(), "chainID", chainID, "len(fetchedAssets)", len(fetchedAssets), "err", err)
return err
}
if asyncFetch {
o.signalUpdatedCollectiblesData(updatedCollectibles)
}
break
}
}
return nil
})
if !asyncFetch {
group.Wait()
}
return o.getCacheFullCollectibleData(uniqueIDs)
}
func (o *Manager) FetchCollectionsDataByContractID(ctx context.Context, ids []thirdparty.ContractID) ([]thirdparty.CollectionData, error) {
missingIDs, err := o.collectionsDataDB.GetIDsNotInDB(ids)
if err != nil {
return nil, err
}
missingIDsPerChainID := thirdparty.GroupContractIDsByChainID(missingIDs)
for chainID, idsToFetch := range missingIDsPerChainID {
defer o.checkConnectionStatus(chainID)
for _, provider := range o.collectionDataProviders {
if !provider.IsChainSupported(chainID) {
continue
}
fetchedCollections, err := provider.FetchCollectionsDataByContractID(ctx, idsToFetch)
if err != nil {
log.Error("FetchCollectionsDataByContractID failed for", "provider", provider.ID(), "chainID", chainID, "err", err)
continue
}
err = o.processCollectionData(ctx, fetchedCollections)
if err != nil {
return nil, err
}
break
}
}
data, err := o.collectionsDataDB.GetData(ids)
if err != nil {
return nil, err
}
return mapToList(data), nil
}
func (o *Manager) getContractOwnershipProviders(chainID walletCommon.ChainID) (mainProvider thirdparty.CollectibleContractOwnershipProvider, fallbackProvider thirdparty.CollectibleContractOwnershipProvider) {
mainProvider = nil
fallbackProvider = nil
for _, provider := range o.contractOwnershipProviders {
if provider.IsChainSupported(chainID) {
if mainProvider == nil {
// First provider found
mainProvider = provider
continue
}
// Second provider found
fallbackProvider = provider
break
}
}
return
}
func getCollectibleOwnersByContractAddressFunc(ctx context.Context, chainID walletCommon.ChainID, contractAddress common.Address, provider thirdparty.CollectibleContractOwnershipProvider) func() (any, error) {
if provider == nil {
return nil
}
return func() (any, error) {
res, err := provider.FetchCollectibleOwnersByContractAddress(ctx, chainID, contractAddress)
if err != nil {
log.Error("FetchCollectibleOwnersByContractAddress failed for", "provider", provider.ID(), "chainID", chainID, "err", err)
}
return res, err
}
}
func (o *Manager) FetchCollectibleOwnersByContractAddress(ctx context.Context, chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) {
defer o.checkConnectionStatus(chainID)
mainProvider, fallbackProvider := o.getContractOwnershipProviders(chainID)
if mainProvider == nil {
return nil, ErrNoProvidersAvailableForChainID
}
mainFn := getCollectibleOwnersByContractAddressFunc(ctx, chainID, contractAddress, mainProvider)
fallbackFn := getCollectibleOwnersByContractAddressFunc(ctx, chainID, contractAddress, fallbackProvider)
owners, err := makeContractOwnershipCall(mainFn, fallbackFn)
if err != nil {
return nil, err
}
return owners.(*thirdparty.CollectibleContractOwnership), nil
}
func (o *Manager) fetchTokenURI(ctx context.Context, id thirdparty.CollectibleUniqueID) (string, error) {
if id.TokenID == nil {
return "", errors.New("empty token ID")
}
backend, err := o.rpcClient.EthClient(uint64(id.ContractID.ChainID))
if err != nil {
return "", err
}
caller, err := collectibles.NewCollectiblesCaller(id.ContractID.Address, backend)
if err != nil {
return "", err
}
tokenURI, err := caller.TokenURI(&bind.CallOpts{
Context: ctx,
}, id.TokenID.Int)
if err != nil {
for _, errorPrefix := range noTokenURIErrorPrefixes {
if strings.HasPrefix(err.Error(), errorPrefix) {
// Contract doesn't support "TokenURI" method
return "", nil
}
}
return "", err
}
return tokenURI, err
}
func isMetadataEmpty(asset thirdparty.CollectibleData) bool {
return asset.Description == "" &&
asset.ImageURL == ""
}
// Processes collectible metadata obtained from a provider and ensures any missing data is fetched.
// If asyncFetch is true, community collectibles metadata will be fetched async and an EventCollectiblesDataUpdated will be sent when the data is ready.
// If asyncFetch is false, it will wait for all community collectibles' metadata to be retrieved before returning.
func (o *Manager) processFullCollectibleData(ctx context.Context, assets []thirdparty.FullCollectibleData, asyncFetch bool) ([]thirdparty.CollectibleUniqueID, error) {
fullyFetchedAssets := make(map[string]*thirdparty.FullCollectibleData)
communityCollectibles := make(map[string][]*thirdparty.FullCollectibleData)
processedIDs := make([]thirdparty.CollectibleUniqueID, 0, len(assets))
// Start with all assets, remove if any of the fetch steps fail
for idx := range assets {
asset := &assets[idx]
id := asset.CollectibleData.ID
fullyFetchedAssets[id.HashKey()] = asset
}
// Detect community collectibles
for _, asset := range fullyFetchedAssets {
// Only check community ownership if metadata is empty
if isMetadataEmpty(asset.CollectibleData) {
// Get TokenURI if not given by provider
err := o.fillTokenURI(ctx, asset)
if err != nil {
log.Error("fillTokenURI failed", "err", err)
delete(fullyFetchedAssets, asset.CollectibleData.ID.HashKey())
continue
}
// Get CommunityID if obtainable from TokenURI
err = o.fillCommunityID(asset)
if err != nil {
log.Error("fillCommunityID failed", "err", err)
delete(fullyFetchedAssets, asset.CollectibleData.ID.HashKey())
continue
}
// Get metadata from community if community collectible
communityID := asset.CollectibleData.CommunityID
if communityID != "" {
if _, ok := communityCollectibles[communityID]; !ok {
communityCollectibles[communityID] = make([]*thirdparty.FullCollectibleData, 0)
}
communityCollectibles[communityID] = append(communityCollectibles[communityID], asset)
// Community collectibles are handled separately, remove from list
delete(fullyFetchedAssets, asset.CollectibleData.ID.HashKey())
}
}
}
// Community collectibles are grouped by community ID
for communityID, communityAssets := range communityCollectibles {
if asyncFetch {
o.fetchCommunityAssetsAsync(ctx, communityID, communityAssets)
} else {
err := o.fetchCommunityAssets(communityID, communityAssets)
if err != nil {
log.Error("fetchCommunityAssets failed", "communityID", communityID, "err", err)
continue
}
for _, asset := range communityAssets {
processedIDs = append(processedIDs, asset.CollectibleData.ID)
}
}
}
for _, asset := range fullyFetchedAssets {
err := o.fillAnimationMediatype(ctx, asset)
if err != nil {
log.Error("fillAnimationMediatype failed", "err", err)
delete(fullyFetchedAssets, asset.CollectibleData.ID.HashKey())
continue
}
}
// Save successfully fetched data to DB
collectiblesData := make([]thirdparty.CollectibleData, 0, len(assets))
collectionsData := make([]thirdparty.CollectionData, 0, len(assets))
missingCollectionIDs := make([]thirdparty.ContractID, 0)
for _, asset := range fullyFetchedAssets {
id := asset.CollectibleData.ID
processedIDs = append(processedIDs, id)
collectiblesData = append(collectiblesData, asset.CollectibleData)
if asset.CollectionData != nil {
collectionsData = append(collectionsData, *asset.CollectionData)
} else {
missingCollectionIDs = append(missingCollectionIDs, id.ContractID)
}
}
err := o.collectiblesDataDB.SetData(collectiblesData, true)
if err != nil {
return nil, err
}
err = o.collectionsDataDB.SetData(collectionsData, true)
if err != nil {
return nil, err
}
if len(missingCollectionIDs) > 0 {
// Calling this ensures collection data is fetched and cached (if not already available)
_, err := o.FetchCollectionsDataByContractID(ctx, missingCollectionIDs)
if err != nil {
return nil, err
}
}
return processedIDs, nil
}
func (o *Manager) fillTokenURI(ctx context.Context, asset *thirdparty.FullCollectibleData) error {
id := asset.CollectibleData.ID
tokenURI := asset.CollectibleData.TokenURI
// Only need to fetch it from contract if it was empty
if tokenURI == "" {
tokenURI, err := o.fetchTokenURI(ctx, id)
if err != nil {
return err
}
asset.CollectibleData.TokenURI = tokenURI
}
return nil
}
func (o *Manager) fillCommunityID(asset *thirdparty.FullCollectibleData) error {
tokenURI := asset.CollectibleData.TokenURI
communityID := ""
if tokenURI != "" {
communityID = o.communityManager.GetCommunityID(tokenURI)
}
asset.CollectibleData.CommunityID = communityID
return nil
}
func (o *Manager) fetchCommunityAssets(communityID string, communityAssets []*thirdparty.FullCollectibleData) error {
communityInfo, err := o.communityManager.FetchCommunityInfo(communityID)
// If the community is found, we update the DB.
// If the community is not found, we only insert new entries to the DB (don't replace what is already there).
allowUpdate := false
if err != nil {
log.Error("fetchCommunityInfo failed", "communityID", communityID, "err", err)
} else if communityInfo == nil {
log.Warn("fetchCommunityAssets community not found", "communityID", communityID)
} else {
for _, communityAsset := range communityAssets {
err := o.communityManager.FillCollectibleMetadata(communityAsset)
if err != nil {
log.Error("FillCollectibleMetadata failed", "communityID", communityID, "err", err)
return err
}
}
allowUpdate = true
}
collectiblesData := make([]thirdparty.CollectibleData, 0, len(communityAssets))
collectionsData := make([]thirdparty.CollectionData, 0, len(communityAssets))
for _, asset := range communityAssets {
collectiblesData = append(collectiblesData, asset.CollectibleData)
if asset.CollectionData != nil {
collectionsData = append(collectionsData, *asset.CollectionData)
}
}
err = o.collectiblesDataDB.SetData(collectiblesData, allowUpdate)
if err != nil {
log.Error("collectiblesDataDB SetData failed", "communityID", communityID, "err", err)
return err
}
err = o.collectionsDataDB.SetData(collectionsData, allowUpdate)
if err != nil {
log.Error("collectionsDataDB SetData failed", "communityID", communityID, "err", err)
return err
}
for _, asset := range communityAssets {
if asset.CollectibleCommunityInfo != nil {
err = o.collectiblesDataDB.SetCommunityInfo(asset.CollectibleData.ID, *asset.CollectibleCommunityInfo)
if err != nil {
log.Error("collectiblesDataDB SetCommunityInfo failed", "communityID", communityID, "err", err)
return err
}
}
}
return nil
}
func (o *Manager) fetchCommunityAssetsAsync(ctx context.Context, communityID string, communityAssets []*thirdparty.FullCollectibleData) {
if len(communityAssets) == 0 {
return
}
go func() {
err := o.fetchCommunityAssets(communityID, communityAssets)
if err != nil {
log.Error("fetchCommunityAssets failed", "communityID", communityID, "err", err)
return
}
// Metadata is up to date in db at this point, fetch and send Event.
ids := make([]thirdparty.CollectibleUniqueID, 0, len(communityAssets))
for _, asset := range communityAssets {
ids = append(ids, asset.CollectibleData.ID)
}
o.signalUpdatedCollectiblesData(ids)
}()
}
func (o *Manager) fillAnimationMediatype(ctx context.Context, asset *thirdparty.FullCollectibleData) error {
if len(asset.CollectibleData.AnimationURL) > 0 {
contentType, err := o.doContentTypeRequest(ctx, asset.CollectibleData.AnimationURL)
if err != nil {
asset.CollectibleData.AnimationURL = ""
}
asset.CollectibleData.AnimationMediaType = contentType
}
return nil
}
func (o *Manager) processCollectionData(ctx context.Context, collections []thirdparty.CollectionData) error {
return o.collectionsDataDB.SetData(collections, true)
}
func (o *Manager) getCacheFullCollectibleData(uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
ret := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs))
collectiblesData, err := o.collectiblesDataDB.GetData(uniqueIDs)
if err != nil {
return nil, err
}
contractIDs := make([]thirdparty.ContractID, 0, len(uniqueIDs))
for _, id := range uniqueIDs {
contractIDs = append(contractIDs, id.ContractID)
}
collectionsData, err := o.collectionsDataDB.GetData(contractIDs)
if err != nil {
return nil, err
}
for _, id := range uniqueIDs {
collectibleData, ok := collectiblesData[id.HashKey()]
if !ok {
// Use empty data, set only ID
collectibleData = thirdparty.CollectibleData{
ID: id,
}
}
if o.mediaServer != nil && len(collectibleData.ImagePayload) > 0 {
collectibleData.ImageURL = o.mediaServer.MakeWalletCollectibleImagesURL(collectibleData.ID)
}
collectionData, ok := collectionsData[id.ContractID.HashKey()]
if !ok {
// Use empty data, set only ID
collectionData = thirdparty.CollectionData{
ID: id.ContractID,
}
}
if o.mediaServer != nil && len(collectionData.ImagePayload) > 0 {
collectionData.ImageURL = o.mediaServer.MakeWalletCollectionImagesURL(collectionData.ID)
}
communityInfo, _, err := o.communityManager.GetCommunityInfo(collectibleData.CommunityID)
if err != nil {
return nil, err
}
collectibleCommunityInfo, err := o.collectiblesDataDB.GetCommunityInfo(id)
if err != nil {
return nil, err
}
ownership, err := o.ownershipDB.GetOwnership(id)
if err != nil {
return nil, err
}
fullData := thirdparty.FullCollectibleData{
CollectibleData: collectibleData,
CollectionData: &collectionData,
CommunityInfo: communityInfo,
CollectibleCommunityInfo: collectibleCommunityInfo,
Ownership: ownership,
}
ret = append(ret, fullData)
}
return ret, nil
}
func (o *Manager) SetCollectibleTransferID(ownerAddress common.Address, id thirdparty.CollectibleUniqueID, transferID common.Hash, notify bool) error {
changed, err := o.ownershipDB.SetTransferID(ownerAddress, id, transferID)
if err != nil {
return err
}
if changed && notify {
o.signalUpdatedCollectiblesData([]thirdparty.CollectibleUniqueID{id})
}
return nil
}
// Reset connection status to trigger notifications
// on the next status update
func (o *Manager) ResetConnectionStatus() {
for _, status := range o.statuses {
status.ResetStateValue()
}
}
func (o *Manager) checkConnectionStatus(chainID walletCommon.ChainID) {
for _, provider := range o.collectibleProviders {
if provider.IsChainSupported(chainID) && provider.IsConnected() {
o.statuses[chainID.String()].SetIsConnected(true)
return
}
}
o.statuses[chainID.String()].SetIsConnected(false)
}
func (o *Manager) signalUpdatedCollectiblesData(ids []thirdparty.CollectibleUniqueID) {
// We limit how much collectibles data we send in each event to avoid problems on the client side
for startIdx := 0; startIdx < len(ids); startIdx += signalUpdatedCollectiblesDataPageSize {
endIdx := startIdx + signalUpdatedCollectiblesDataPageSize
if endIdx > len(ids) {
endIdx = len(ids)
}
pageIDs := ids[startIdx:endIdx]
collectibles, err := o.getCacheFullCollectibleData(pageIDs)
if err != nil {
log.Error("Error getting FullCollectibleData from cache: %v", err)
return
}
// Send update event with most complete data type available
details := fullCollectiblesDataToDetails(collectibles)
payload, err := json.Marshal(details)
if err != nil {
log.Error("Error marshaling response: %v", err)
return
}
event := walletevent.Event{
Type: EventCollectiblesDataUpdated,
Message: string(payload),
}
o.feed.Send(event)
}
}
@@ -0,0 +1,612 @@
package collectibles
import (
"database/sql"
"fmt"
"math"
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/jmoiron/sqlx"
"github.com/status-im/status-go/services/wallet/bigint"
w_common "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/thirdparty"
"github.com/status-im/status-go/sqlite"
)
const InvalidTimestamp = int64(-1)
type OwnershipDB struct {
db *sql.DB
}
func NewOwnershipDB(sqlDb *sql.DB) *OwnershipDB {
return &OwnershipDB{
db: sqlDb,
}
}
const unknownUpdateTimestamp = int64(math.MaxInt64)
const selectOwnershipColumns = "chain_id, contract_address, token_id"
const ownershipTimestampColumns = "owner_address, chain_id, timestamp"
const selectOwnershipTimestampColumns = "timestamp"
func insertTmpOwnership(
db *sql.DB,
chainID w_common.ChainID,
ownerAddress common.Address,
balancesPerContractAdddress thirdparty.TokenBalancesPerContractAddress,
) error {
// Put old/new ownership data into temp tables
// NOTE: Temp table CREATE doesn't work with prepared statements,
// so we have to use Exec directly
_, err := db.Exec(`
DROP TABLE IF EXISTS temp.old_collectibles_ownership_cache;
CREATE TABLE temp.old_collectibles_ownership_cache(
contract_address VARCHAR NOT NULL,
token_id BLOB NOT NULL,
balance BLOB NOT NULL
);
DROP TABLE IF EXISTS temp.new_collectibles_ownership_cache;
CREATE TABLE temp.new_collectibles_ownership_cache(
contract_address VARCHAR NOT NULL,
token_id BLOB NOT NULL,
balance BLOB NOT NULL
);`)
if err != nil {
return err
}
insertTmpOldOwnership, err := db.Prepare(`
INSERT INTO temp.old_collectibles_ownership_cache
SELECT contract_address, token_id, balance FROM collectibles_ownership_cache
WHERE chain_id = ? AND owner_address = ?`)
if err != nil {
return err
}
defer insertTmpOldOwnership.Close()
_, err = insertTmpOldOwnership.Exec(chainID, ownerAddress)
if err != nil {
return err
}
insertTmpNewOwnership, err := db.Prepare(`
INSERT INTO temp.new_collectibles_ownership_cache (contract_address, token_id, balance)
VALUES (?, ?, ?)`)
if err != nil {
return err
}
defer insertTmpNewOwnership.Close()
for contractAddress, balances := range balancesPerContractAdddress {
for _, balance := range balances {
_, err = insertTmpNewOwnership.Exec(
contractAddress,
(*bigint.SQLBigIntBytes)(balance.TokenID.Int),
(*bigint.SQLBigIntBytes)(balance.Balance.Int),
)
if err != nil {
return err
}
}
}
return nil
}
func removeOldAddressOwnership(
creator sqlite.StatementCreator,
chainID w_common.ChainID,
ownerAddress common.Address,
) ([]thirdparty.CollectibleUniqueID, error) {
// Find collectibles in the DB that are not in the temp table
removedQuery, err := creator.Prepare(fmt.Sprintf(`
SELECT %d, tOld.contract_address, tOld.token_id
FROM temp.old_collectibles_ownership_cache tOld
LEFT JOIN temp.new_collectibles_ownership_cache tNew ON
tOld.contract_address = tNew.contract_address AND tOld.token_id = tNew.token_id
WHERE
tNew.contract_address IS NULL
`, chainID))
if err != nil {
return nil, err
}
defer removedQuery.Close()
removedRows, err := removedQuery.Query()
if err != nil {
return nil, err
}
defer removedRows.Close()
removedIDs, err := thirdparty.RowsToCollectibles(removedRows)
if err != nil {
return nil, err
}
removeOwnership, err := creator.Prepare("DELETE FROM collectibles_ownership_cache WHERE chain_id = ? AND owner_address = ? AND contract_address = ? AND token_id = ?")
if err != nil {
return nil, err
}
defer removeOwnership.Close()
for _, id := range removedIDs {
_, err = removeOwnership.Exec(
chainID,
ownerAddress,
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
)
if err != nil {
return nil, err
}
}
return removedIDs, nil
}
func updateChangedAddressOwnership(
creator sqlite.StatementCreator,
chainID w_common.ChainID,
ownerAddress common.Address,
) ([]thirdparty.CollectibleUniqueID, error) {
// Find collectibles in the temp table that are in the DB and have a different balance
updatedQuery, err := creator.Prepare(fmt.Sprintf(`
SELECT %d, tNew.contract_address, tNew.token_id
FROM temp.new_collectibles_ownership_cache tNew
LEFT JOIN temp.old_collectibles_ownership_cache tOld ON
tOld.contract_address = tNew.contract_address AND tOld.token_id = tNew.token_id
WHERE
tOld.contract_address IS NOT NULL AND tOld.balance != tNew.balance
`, chainID))
if err != nil {
return nil, err
}
defer updatedQuery.Close()
updatedRows, err := updatedQuery.Query()
if err != nil {
return nil, err
}
defer updatedRows.Close()
updatedIDs, err := thirdparty.RowsToCollectibles(updatedRows)
if err != nil {
return nil, err
}
updateOwnership, err := creator.Prepare(`
UPDATE collectibles_ownership_cache
SET balance = (SELECT tNew.balance
FROM temp.new_collectibles_ownership_cache tNew
WHERE tNew.contract_address = collectibles_ownership_cache.contract_address AND tNew.token_id = collectibles_ownership_cache.token_id)
WHERE chain_id = ? AND owner_address = ? AND contract_address = ? AND token_id = ?
`)
if err != nil {
return nil, err
}
defer updateOwnership.Close()
for _, id := range updatedIDs {
_, err = updateOwnership.Exec(
chainID,
ownerAddress,
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int))
if err != nil {
return nil, err
}
}
return updatedIDs, nil
}
func insertNewAddressOwnership(
creator sqlite.StatementCreator,
chainID w_common.ChainID,
ownerAddress common.Address,
) ([]thirdparty.CollectibleUniqueID, error) {
// Find collectibles in the temp table that are not in the DB
insertedQuery, err := creator.Prepare(fmt.Sprintf(`
SELECT %d, tNew.contract_address, tNew.token_id
FROM temp.new_collectibles_ownership_cache tNew
LEFT JOIN temp.old_collectibles_ownership_cache tOld ON
tOld.contract_address = tNew.contract_address AND tOld.token_id = tNew.token_id
WHERE
tOld.contract_address IS NULL
`, chainID))
if err != nil {
return nil, err
}
defer insertedQuery.Close()
insertedRows, err := insertedQuery.Query()
if err != nil {
return nil, err
}
defer insertedRows.Close()
insertedIDs, err := thirdparty.RowsToCollectibles(insertedRows)
if err != nil {
return nil, err
}
insertOwnership, err := creator.Prepare(fmt.Sprintf(`
INSERT INTO collectibles_ownership_cache
SELECT
%d, tNew.contract_address, tNew.token_id, X'%s', tNew.balance, NULL
FROM temp.new_collectibles_ownership_cache tNew
WHERE
tNew.contract_address = ? AND tNew.token_id = ?
`, chainID, ownerAddress.Hex()[2:]))
if err != nil {
return nil, err
}
defer insertOwnership.Close()
for _, id := range insertedIDs {
_, err = insertOwnership.Exec(
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int))
if err != nil {
return nil, err
}
}
return insertedIDs, nil
}
func updateAddressOwnership(
tx sqlite.StatementCreator,
chainID w_common.ChainID,
ownerAddress common.Address,
) (removedIDs, updatedIDs, insertedIDs []thirdparty.CollectibleUniqueID, err error) {
removedIDs, err = removeOldAddressOwnership(tx, chainID, ownerAddress)
if err != nil {
return
}
updatedIDs, err = updateChangedAddressOwnership(tx, chainID, ownerAddress)
if err != nil {
return
}
insertedIDs, err = insertNewAddressOwnership(tx, chainID, ownerAddress)
if err != nil {
return
}
return
}
func updateAddressOwnershipTimestamp(creator sqlite.StatementCreator, ownerAddress common.Address, chainID w_common.ChainID, timestamp int64) error {
updateTimestamp, err := creator.Prepare(fmt.Sprintf(`INSERT OR REPLACE INTO collectibles_ownership_update_timestamps (%s)
VALUES (?, ?, ?)`, ownershipTimestampColumns))
if err != nil {
return err
}
defer updateTimestamp.Close()
_, err = updateTimestamp.Exec(ownerAddress, chainID, timestamp)
return err
}
// Returns the list of added/removed IDs when comparing the given list of IDs with the ones in the DB.
// Call before Update for the result to be useful.
func (o *OwnershipDB) GetIDsNotInDB(
chainID w_common.ChainID,
ownerAddress common.Address,
newIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.CollectibleUniqueID, error) {
ret := make([]thirdparty.CollectibleUniqueID, 0, len(newIDs))
exists, err := o.db.Prepare(`SELECT EXISTS (
SELECT 1 FROM collectibles_ownership_cache
WHERE chain_id=? AND contract_address=? AND token_id=? AND owner_address=?
)`)
if err != nil {
return nil, err
}
for _, id := range newIDs {
row := exists.QueryRow(
id.ContractID.ChainID,
id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
ownerAddress,
)
var exists bool
err = row.Scan(&exists)
if err != nil {
return nil, err
}
if !exists {
ret = append(ret, id)
}
}
return ret, nil
}
func (o *OwnershipDB) Update(chainID w_common.ChainID, ownerAddress common.Address, balances thirdparty.TokenBalancesPerContractAddress, timestamp int64) (removedIDs, updatedIDs, insertedIDs []thirdparty.CollectibleUniqueID, err error) {
err = insertTmpOwnership(o.db, chainID, ownerAddress, balances)
if err != nil {
return
}
var (
tx *sql.Tx
)
tx, err = o.db.Begin()
if err != nil {
return
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
// Compare tmp and current ownership tables and update the current one
removedIDs, updatedIDs, insertedIDs, err = updateAddressOwnership(tx, chainID, ownerAddress)
if err != nil {
return
}
// Update timestamp
err = updateAddressOwnershipTimestamp(tx, ownerAddress, chainID, timestamp)
return
}
func (o *OwnershipDB) GetOwnedCollectibles(chainIDs []w_common.ChainID, ownerAddresses []common.Address, offset int, limit int) ([]thirdparty.CollectibleUniqueID, error) {
query, args, err := sqlx.In(fmt.Sprintf(`SELECT %s
FROM collectibles_ownership_cache
WHERE chain_id IN (?) AND owner_address IN (?)
LIMIT ? OFFSET ?`, selectOwnershipColumns), chainIDs, ownerAddresses, limit, offset)
if err != nil {
return nil, err
}
stmt, err := o.db.Prepare(query)
if err != nil {
return nil, err
}
defer stmt.Close()
rows, err := stmt.Query(args...)
if err != nil {
return nil, err
}
defer rows.Close()
return thirdparty.RowsToCollectibles(rows)
}
func (o *OwnershipDB) GetOwnedCollectible(chainID w_common.ChainID, ownerAddresses common.Address, contractAddress common.Address, tokenID *big.Int) (*thirdparty.CollectibleUniqueID, error) {
query := fmt.Sprintf(`SELECT %s
FROM collectibles_ownership_cache
WHERE chain_id = ? AND owner_address = ? AND contract_address = ? AND token_id = ?`, selectOwnershipColumns)
stmt, err := o.db.Prepare(query)
if err != nil {
return nil, err
}
defer stmt.Close()
rows, err := stmt.Query(chainID, ownerAddresses, contractAddress, (*bigint.SQLBigIntBytes)(tokenID))
if err != nil {
return nil, err
}
defer rows.Close()
ids, err := thirdparty.RowsToCollectibles(rows)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return nil, nil
}
return &ids[0], nil
}
func (o *OwnershipDB) GetOwnershipUpdateTimestamp(owner common.Address, chainID w_common.ChainID) (int64, error) {
query := fmt.Sprintf(`SELECT %s
FROM collectibles_ownership_update_timestamps
WHERE owner_address = ? AND chain_id = ?`, selectOwnershipTimestampColumns)
stmt, err := o.db.Prepare(query)
if err != nil {
return InvalidTimestamp, err
}
defer stmt.Close()
row := stmt.QueryRow(owner, chainID)
var timestamp int64
err = row.Scan(&timestamp)
if err == sql.ErrNoRows {
return InvalidTimestamp, nil
} else if err != nil {
return InvalidTimestamp, err
}
return timestamp, nil
}
func (o *OwnershipDB) GetLatestOwnershipUpdateTimestamp(chainID w_common.ChainID) (int64, error) {
query := `SELECT MAX(timestamp)
FROM collectibles_ownership_update_timestamps
WHERE chain_id = ?`
stmt, err := o.db.Prepare(query)
if err != nil {
return InvalidTimestamp, err
}
defer stmt.Close()
row := stmt.QueryRow(chainID)
var timestamp sql.NullInt64
err = row.Scan(&timestamp)
if err != nil {
return InvalidTimestamp, err
}
if timestamp.Valid {
return timestamp.Int64, nil
}
return InvalidTimestamp, nil
}
func (o *OwnershipDB) GetOwnership(id thirdparty.CollectibleUniqueID) ([]thirdparty.AccountBalance, error) {
query := fmt.Sprintf(`SELECT c.owner_address, c.balance, COALESCE(t.timestamp, %d)
FROM collectibles_ownership_cache c
LEFT JOIN transfers t ON
c.transfer_id = t.hash
WHERE
c.chain_id = ? AND c.contract_address = ? AND c.token_id = ?`, unknownUpdateTimestamp)
stmt, err := o.db.Prepare(query)
if err != nil {
return nil, err
}
defer stmt.Close()
rows, err := stmt.Query(id.ContractID.ChainID, id.ContractID.Address, (*bigint.SQLBigIntBytes)(id.TokenID.Int))
if err != nil {
return nil, err
}
defer rows.Close()
var ret []thirdparty.AccountBalance
for rows.Next() {
accountBalance := thirdparty.AccountBalance{
Balance: &bigint.BigInt{Int: big.NewInt(0)},
}
err = rows.Scan(
&accountBalance.Address,
(*bigint.SQLBigIntBytes)(accountBalance.Balance.Int),
&accountBalance.TxTimestamp,
)
if err != nil {
return nil, err
}
ret = append(ret, accountBalance)
}
return ret, nil
}
func (o *OwnershipDB) SetTransferID(ownerAddress common.Address, id thirdparty.CollectibleUniqueID, transferID common.Hash) (bool, error) {
query := `UPDATE collectibles_ownership_cache
SET transfer_id = ?
WHERE chain_id = ? AND contract_address = ? AND token_id = ? AND owner_address = ?`
stmt, err := o.db.Prepare(query)
if err != nil {
return false, err
}
defer stmt.Close()
res, err := stmt.Exec(transferID, id.ContractID.ChainID, id.ContractID.Address, (*bigint.SQLBigIntBytes)(id.TokenID.Int), ownerAddress)
if err != nil {
return false, err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return false, err
}
if rowsAffected > 0 {
return true, nil
}
return false, nil
}
func (o *OwnershipDB) GetTransferID(ownerAddress common.Address, id thirdparty.CollectibleUniqueID) (*common.Hash, error) {
query := `SELECT transfer_id
FROM collectibles_ownership_cache
WHERE chain_id = ? AND contract_address = ? AND token_id = ? AND owner_address = ?
LIMIT 1`
stmt, err := o.db.Prepare(query)
if err != nil {
return nil, err
}
defer stmt.Close()
row := stmt.QueryRow(id.ContractID.ChainID, id.ContractID.Address, (*bigint.SQLBigIntBytes)(id.TokenID.Int), ownerAddress)
var dbTransferID []byte
err = row.Scan(&dbTransferID)
if err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
if len(dbTransferID) > 0 {
transferID := common.BytesToHash(dbTransferID)
return &transferID, nil
}
return nil, nil
}
func (o *OwnershipDB) GetCollectiblesWithNoTransferID(account common.Address, chainID w_common.ChainID) ([]thirdparty.CollectibleUniqueID, error) {
query := `SELECT contract_address, token_id
FROM collectibles_ownership_cache
WHERE chain_id = ? AND owner_address = ? AND transfer_id IS NULL`
stmt, err := o.db.Prepare(query)
if err != nil {
return nil, err
}
defer stmt.Close()
rows, err := stmt.Query(chainID, account)
if err != nil {
return nil, err
}
defer rows.Close()
var ret []thirdparty.CollectibleUniqueID
for rows.Next() {
id := thirdparty.CollectibleUniqueID{
ContractID: thirdparty.ContractID{
ChainID: chainID,
},
TokenID: &bigint.BigInt{Int: big.NewInt(0)},
}
err = rows.Scan(
&id.ContractID.Address,
(*bigint.SQLBigIntBytes)(id.TokenID.Int),
)
if err != nil {
return nil, err
}
ret = append(ret, id)
}
return ret, nil
}
@@ -0,0 +1,491 @@
package collectibles
import (
"context"
"database/sql"
"encoding/json"
"errors"
"math/big"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/event"
"github.com/ethereum/go-ethereum/log"
"github.com/status-im/status-go/multiaccounts/accounts"
"github.com/status-im/status-go/rpc/network"
"github.com/status-im/status-go/services/wallet/async"
"github.com/status-im/status-go/services/wallet/bigint"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/community"
"github.com/status-im/status-go/services/wallet/thirdparty"
"github.com/status-im/status-go/services/wallet/transfer"
"github.com/status-im/status-go/services/wallet/walletevent"
)
// These events are used to notify the UI of state changes
const (
EventCollectiblesOwnershipUpdateStarted walletevent.EventType = "wallet-collectibles-ownership-update-started"
EventCollectiblesOwnershipUpdatePartial walletevent.EventType = "wallet-collectibles-ownership-update-partial"
EventCollectiblesOwnershipUpdateFinished walletevent.EventType = "wallet-collectibles-ownership-update-finished"
EventCollectiblesOwnershipUpdateFinishedWithError walletevent.EventType = "wallet-collectibles-ownership-update-finished-with-error"
EventCommunityCollectiblesReceived walletevent.EventType = "wallet-collectibles-community-collectibles-received"
EventCollectiblesDataUpdated walletevent.EventType = "wallet-collectibles-data-updated"
EventOwnedCollectiblesFilteringDone walletevent.EventType = "wallet-owned-collectibles-filtering-done"
EventGetCollectiblesDetailsDone walletevent.EventType = "wallet-get-collectibles-details-done"
)
type OwnershipUpdateMessage struct {
Added []thirdparty.CollectibleUniqueID `json:"added"`
Updated []thirdparty.CollectibleUniqueID `json:"updated"`
Removed []thirdparty.CollectibleUniqueID `json:"removed"`
}
type CollectibleDataType byte
const (
CollectibleDataTypeUniqueID CollectibleDataType = iota
CollectibleDataTypeHeader
CollectibleDataTypeDetails
CollectibleDataTypeCommunityHeader
)
type FetchType byte
const (
FetchTypeNeverFetch FetchType = iota
FetchTypeAlwaysFetch
FetchTypeFetchIfNotCached
FetchTypeFetchIfCacheOld
)
type FetchCriteria struct {
FetchType FetchType `json:"fetch_type"`
MaxCacheAgeSeconds int64 `json:"max_cache_age_seconds"`
}
var (
filterOwnedCollectiblesTask = async.TaskType{
ID: 1,
Policy: async.ReplacementPolicyCancelOld,
}
getCollectiblesDataTask = async.TaskType{
ID: 2,
Policy: async.ReplacementPolicyCancelOld,
}
)
type Service struct {
manager *Manager
controller *Controller
db *sql.DB
ownershipDB *OwnershipDB
transferDB *transfer.Database
communityManager *community.Manager
walletFeed *event.Feed
scheduler *async.MultiClientScheduler
group *async.Group
}
func NewService(
db *sql.DB,
walletFeed *event.Feed,
accountsDB *accounts.Database,
accountsFeed *event.Feed,
settingsFeed *event.Feed,
communityManager *community.Manager,
networkManager *network.Manager,
manager *Manager) *Service {
s := &Service{
manager: manager,
controller: NewController(db, walletFeed, accountsDB, accountsFeed, settingsFeed, networkManager, manager),
db: db,
ownershipDB: NewOwnershipDB(db),
transferDB: transfer.NewDB(db),
communityManager: communityManager,
walletFeed: walletFeed,
scheduler: async.NewMultiClientScheduler(),
group: async.NewGroup(context.Background()),
}
s.controller.SetOwnedCollectiblesChangeCb(s.onOwnedCollectiblesChange)
s.controller.SetCollectiblesTransferCb(s.onCollectiblesTransfer)
return s
}
type ErrorCode = int
const (
ErrorCodeSuccess ErrorCode = iota + 1
ErrorCodeTaskCanceled
ErrorCodeFailed
)
type OwnershipStatus struct {
State OwnershipState `json:"state"`
Timestamp int64 `json:"timestamp"`
}
type OwnershipStatusPerChainID = map[walletCommon.ChainID]OwnershipStatus
type OwnershipStatusPerAddressAndChainID = map[common.Address]OwnershipStatusPerChainID
type GetOwnedCollectiblesResponse struct {
Collectibles []Collectible `json:"collectibles"`
Offset int `json:"offset"`
// Used to indicate that there might be more collectibles that were not returned
// based on a simple heuristic
HasMore bool `json:"hasMore"`
OwnershipStatus OwnershipStatusPerAddressAndChainID `json:"ownershipStatus"`
ErrorCode ErrorCode `json:"errorCode"`
}
type GetCollectiblesByUniqueIDResponse struct {
Collectibles []Collectible `json:"collectibles"`
ErrorCode ErrorCode `json:"errorCode"`
}
type GetOwnedCollectiblesReturnType struct {
collectibles []Collectible
hasMore bool
ownershipStatus OwnershipStatusPerAddressAndChainID
}
type GetCollectiblesByUniqueIDReturnType struct {
collectibles []Collectible
}
func (s *Service) GetOwnedCollectibles(
ctx context.Context,
chainIDs []walletCommon.ChainID,
addresses []common.Address,
filter Filter,
offset int,
limit int,
dataType CollectibleDataType,
fetchCriteria FetchCriteria) (*GetOwnedCollectiblesReturnType, error) {
err := s.fetchOwnedCollectiblesIfNeeded(ctx, chainIDs, addresses, fetchCriteria)
if err != nil {
return nil, err
}
ids, hasMore, err := s.FilterOwnedCollectibles(chainIDs, addresses, filter, offset, limit)
if err != nil {
return nil, err
}
collectibles, err := s.collectibleIDsToDataType(ctx, ids, dataType)
if err != nil {
return nil, err
}
ownershipStatus, err := s.GetOwnershipStatus(chainIDs, addresses)
if err != nil {
return nil, err
}
return &GetOwnedCollectiblesReturnType{
collectibles: collectibles,
hasMore: hasMore,
ownershipStatus: ownershipStatus,
}, err
}
func (s *Service) needsToFetch(chainID walletCommon.ChainID, address common.Address, fetchCriteria FetchCriteria) (bool, error) {
mustFetch := false
switch fetchCriteria.FetchType {
case FetchTypeAlwaysFetch:
mustFetch = true
case FetchTypeNeverFetch:
mustFetch = false
case FetchTypeFetchIfNotCached, FetchTypeFetchIfCacheOld:
timestamp, err := s.ownershipDB.GetOwnershipUpdateTimestamp(address, chainID)
if err != nil {
return false, err
}
if timestamp == InvalidTimestamp ||
(fetchCriteria.FetchType == FetchTypeFetchIfCacheOld && timestamp+fetchCriteria.MaxCacheAgeSeconds < time.Now().Unix()) {
mustFetch = true
}
}
return mustFetch, nil
}
func (s *Service) fetchOwnedCollectiblesIfNeeded(ctx context.Context, chainIDs []walletCommon.ChainID, addresses []common.Address, fetchCriteria FetchCriteria) error {
if fetchCriteria.FetchType == FetchTypeNeverFetch {
return nil
}
group := async.NewGroup(ctx)
for _, address := range addresses {
for _, chainID := range chainIDs {
mustFetch, err := s.needsToFetch(chainID, address, fetchCriteria)
if err != nil {
return err
}
if mustFetch {
command := newLoadOwnedCollectiblesCommand(s.manager, s.ownershipDB, s.walletFeed, chainID, address, nil)
group.Add(command.Command())
}
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-group.WaitAsync():
return nil
}
}
// GetOwnedCollectiblesAsync allows only one filter task to run at a time
// and it cancels the current one if a new one is started
// All calls will trigger an EventOwnedCollectiblesFilteringDone event with the result of the filtering
func (s *Service) GetOwnedCollectiblesAsync(
requestID int32,
chainIDs []walletCommon.ChainID,
addresses []common.Address,
filter Filter,
offset int,
limit int,
dataType CollectibleDataType,
fetchCriteria FetchCriteria) {
s.scheduler.Enqueue(requestID, filterOwnedCollectiblesTask, func(ctx context.Context) (interface{}, error) {
return s.GetOwnedCollectibles(ctx, chainIDs, addresses, filter, offset, limit, dataType, fetchCriteria)
}, func(result interface{}, taskType async.TaskType, err error) {
res := GetOwnedCollectiblesResponse{
ErrorCode: ErrorCodeFailed,
}
if errors.Is(err, context.Canceled) || errors.Is(err, async.ErrTaskOverwritten) {
res.ErrorCode = ErrorCodeTaskCanceled
} else if err == nil {
fnRet := result.(*GetOwnedCollectiblesReturnType)
if err == nil {
res.Collectibles = fnRet.collectibles
res.Offset = offset
res.HasMore = fnRet.hasMore
res.OwnershipStatus = fnRet.ownershipStatus
res.ErrorCode = ErrorCodeSuccess
}
}
s.sendResponseEvent(&requestID, EventOwnedCollectiblesFilteringDone, res, err)
})
}
func (s *Service) GetCollectiblesByUniqueID(
ctx context.Context,
uniqueIDs []thirdparty.CollectibleUniqueID,
dataType CollectibleDataType) (*GetCollectiblesByUniqueIDReturnType, error) {
collectibles, err := s.collectibleIDsToDataType(ctx, uniqueIDs, dataType)
if err != nil {
return nil, err
}
return &GetCollectiblesByUniqueIDReturnType{
collectibles: collectibles,
}, err
}
func (s *Service) GetCollectiblesByUniqueIDAsync(
requestID int32,
uniqueIDs []thirdparty.CollectibleUniqueID,
dataType CollectibleDataType) {
s.scheduler.Enqueue(requestID, getCollectiblesDataTask, func(ctx context.Context) (interface{}, error) {
return s.GetCollectiblesByUniqueID(ctx, uniqueIDs, dataType)
}, func(result interface{}, taskType async.TaskType, err error) {
res := GetCollectiblesByUniqueIDResponse{
ErrorCode: ErrorCodeFailed,
}
if errors.Is(err, context.Canceled) || errors.Is(err, async.ErrTaskOverwritten) {
res.ErrorCode = ErrorCodeTaskCanceled
} else if err == nil {
fnRet := result.(*GetCollectiblesByUniqueIDReturnType)
if err == nil {
res.Collectibles = fnRet.collectibles
res.ErrorCode = ErrorCodeSuccess
}
}
s.sendResponseEvent(&requestID, EventGetCollectiblesDetailsDone, res, err)
})
}
func (s *Service) RefetchOwnedCollectibles() {
s.controller.RefetchOwnedCollectibles()
}
func (s *Service) Start() {
s.controller.Start()
}
func (s *Service) Stop() {
s.controller.Stop()
s.scheduler.Stop()
}
func (s *Service) sendResponseEvent(requestID *int32, eventType walletevent.EventType, payloadObj interface{}, resErr error) {
payload, err := json.Marshal(payloadObj)
if err != nil {
log.Error("Error marshaling response: %v; result error: %w", err, resErr)
} else {
err = resErr
}
log.Debug("wallet.api.collectibles.Service RESPONSE", "requestID", requestID, "eventType", eventType, "error", err, "payload.len", len(payload))
event := walletevent.Event{
Type: eventType,
Message: string(payload),
}
if requestID != nil {
event.RequestID = new(int)
*event.RequestID = int(*requestID)
}
s.walletFeed.Send(event)
}
func (s *Service) FilterOwnedCollectibles(chainIDs []walletCommon.ChainID, owners []common.Address, filter Filter, offset int, limit int) ([]thirdparty.CollectibleUniqueID, bool, error) {
ctx := context.Background()
// Request one more than limit, to check if DB has more available
ids, err := filterOwnedCollectibles(ctx, s.db, chainIDs, owners, filter, offset, limit+1)
if err != nil {
return nil, false, err
}
hasMore := len(ids) > limit
if hasMore {
ids = ids[:limit]
}
return ids, hasMore, nil
}
func (s *Service) GetOwnedCollectible(chainID walletCommon.ChainID, owner common.Address, contractAddress common.Address, tokenID *big.Int) (*thirdparty.CollectibleUniqueID, error) {
return s.ownershipDB.GetOwnedCollectible(chainID, owner, contractAddress, tokenID)
}
func (s *Service) GetOwnershipStatus(chainIDs []walletCommon.ChainID, owners []common.Address) (OwnershipStatusPerAddressAndChainID, error) {
ret := make(OwnershipStatusPerAddressAndChainID)
for _, address := range owners {
ret[address] = make(OwnershipStatusPerChainID)
for _, chainID := range chainIDs {
timestamp, err := s.ownershipDB.GetOwnershipUpdateTimestamp(address, chainID)
if err != nil {
return nil, err
}
ret[address][chainID] = OwnershipStatus{
State: s.controller.GetCommandState(chainID, address),
Timestamp: timestamp,
}
}
}
return ret, nil
}
func (s *Service) collectibleIDsToDataType(ctx context.Context, ids []thirdparty.CollectibleUniqueID, dataType CollectibleDataType) ([]Collectible, error) {
switch dataType {
case CollectibleDataTypeUniqueID:
return idsToCollectibles(ids), nil
case CollectibleDataTypeHeader, CollectibleDataTypeDetails, CollectibleDataTypeCommunityHeader:
collectibles, err := s.manager.FetchAssetsByCollectibleUniqueID(ctx, ids, true)
if err != nil {
return nil, err
}
switch dataType {
case CollectibleDataTypeHeader:
return fullCollectiblesDataToHeaders(collectibles), nil
case CollectibleDataTypeDetails:
return fullCollectiblesDataToDetails(collectibles), nil
case CollectibleDataTypeCommunityHeader:
return fullCollectiblesDataToCommunityHeader(collectibles), nil
}
}
return nil, errors.New("unknown data type")
}
func (s *Service) onOwnedCollectiblesChange(ownedCollectiblesChange OwnedCollectiblesChange) {
// Try to find a matching transfer for newly added/updated collectibles
switch ownedCollectiblesChange.changeType {
case OwnedCollectiblesChangeTypeAdded, OwnedCollectiblesChangeTypeUpdated:
// For recently added/updated collectibles, try to find a matching transfer
s.lookupTransferForCollectibles(ownedCollectiblesChange.ownedCollectibles)
s.notifyCommunityCollectiblesReceived(ownedCollectiblesChange.ownedCollectibles)
}
}
func (s *Service) onCollectiblesTransfer(account common.Address, chainID walletCommon.ChainID, transfers []transfer.Transfer) {
for _, transfer := range transfers {
// If Collectible is already in the DB, update transfer ID with the latest detected transfer
id := thirdparty.CollectibleUniqueID{
ContractID: thirdparty.ContractID{
ChainID: chainID,
Address: transfer.Log.Address,
},
TokenID: &bigint.BigInt{Int: transfer.TokenID},
}
err := s.manager.SetCollectibleTransferID(account, id, transfer.ID, true)
if err != nil {
log.Error("Error setting transfer ID for collectible", "error", err)
}
}
}
func (s *Service) lookupTransferForCollectibles(ownedCollectibles OwnedCollectibles) {
// There are some limitations to this approach:
// - Collectibles ownership and transfers are not in sync and might represent the state at different moments.
// - We have no way of knowing if the latest collectible transfer we've detected is actually the latest one, so the timestamp we
// use might be older than the real one.
// - There might be detected transfers that are temporarily not reflected in the collectibles ownership.
// - For ERC721 tokens we should only look for incoming transfers. For ERC1155 tokens we should look for both incoming and outgoing transfers.
// We need to get the contract standard for each collectible to know which approach to take.
for _, id := range ownedCollectibles.ids {
transfer, err := s.transferDB.GetLatestCollectibleTransfer(ownedCollectibles.account, id)
if err != nil {
log.Error("Error fetching latest collectible transfer", "error", err)
continue
}
if transfer != nil {
err = s.manager.SetCollectibleTransferID(ownedCollectibles.account, id, transfer.ID, false)
if err != nil {
log.Error("Error setting transfer ID for collectible", "error", err)
}
}
}
}
func (s *Service) notifyCommunityCollectiblesReceived(ownedCollectibles OwnedCollectibles) {
ctx := context.Background()
collectiblesData, err := s.manager.FetchAssetsByCollectibleUniqueID(ctx, ownedCollectibles.ids, false)
if err != nil {
log.Error("Error fetching collectibles data", "error", err)
return
}
communityCollectibles := fullCollectiblesDataToCommunityHeader(collectiblesData)
if len(communityCollectibles) == 0 {
return
}
encodedMessage, err := json.Marshal(communityCollectibles)
if err != nil {
return
}
s.walletFeed.Send(walletevent.Event{
Type: EventCommunityCollectiblesReceived,
ChainID: uint64(ownedCollectibles.chainID),
Accounts: []common.Address{
ownedCollectibles.account,
},
Message: string(encodedMessage),
})
}
@@ -0,0 +1,198 @@
package collectibles
import (
"github.com/status-im/status-go/protocol/communities/token"
w_common "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
// Combined Collection+Collectible info, used to display a detailed view of a collectible
type Collectible struct {
DataType CollectibleDataType `json:"data_type"`
ID thirdparty.CollectibleUniqueID `json:"id"`
ContractType w_common.ContractType `json:"contract_type"`
CollectibleData *CollectibleData `json:"collectible_data,omitempty"`
CollectionData *CollectionData `json:"collection_data,omitempty"`
CommunityData *CommunityData `json:"community_data,omitempty"`
Ownership []thirdparty.AccountBalance `json:"ownership,omitempty"`
}
type CollectibleData struct {
Name string `json:"name"`
Description *string `json:"description,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
AnimationURL *string `json:"animation_url,omitempty"`
AnimationMediaType *string `json:"animation_media_type,omitempty"`
Traits *[]thirdparty.CollectibleTrait `json:"traits,omitempty"`
BackgroundColor *string `json:"background_color,omitempty"`
}
type CollectionData struct {
Name string `json:"name"`
Slug string `json:"slug"`
ImageURL string `json:"image_url"`
}
type CommunityData struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
PrivilegesLevel token.PrivilegesLevel `json:"privileges_level"`
ImageURL *string `json:"image_url,omitempty"`
}
func idToCollectible(id thirdparty.CollectibleUniqueID) Collectible {
ret := Collectible{
DataType: CollectibleDataTypeUniqueID,
ID: id,
}
return ret
}
func idsToCollectibles(ids []thirdparty.CollectibleUniqueID) []Collectible {
res := make([]Collectible, 0, len(ids))
for _, id := range ids {
c := idToCollectible(id)
res = append(res, c)
}
return res
}
func getContractType(c thirdparty.FullCollectibleData) w_common.ContractType {
if c.CollectibleData.ContractType != w_common.ContractTypeUnknown {
return c.CollectibleData.ContractType
}
if c.CollectionData != nil && c.CollectionData.ContractType != w_common.ContractTypeUnknown {
return c.CollectionData.ContractType
}
return w_common.ContractTypeUnknown
}
func fullCollectibleDataToHeader(c thirdparty.FullCollectibleData) Collectible {
ret := Collectible{
DataType: CollectibleDataTypeHeader,
ID: c.CollectibleData.ID,
ContractType: getContractType(c),
CollectibleData: &CollectibleData{
Name: c.CollectibleData.Name,
ImageURL: &c.CollectibleData.ImageURL,
AnimationURL: &c.CollectibleData.AnimationURL,
AnimationMediaType: &c.CollectibleData.AnimationMediaType,
BackgroundColor: &c.CollectibleData.BackgroundColor,
},
}
if c.CollectionData != nil {
ret.CollectionData = &CollectionData{
Name: c.CollectionData.Name,
Slug: c.CollectionData.Slug,
ImageURL: c.CollectionData.ImageURL,
}
}
if c.CollectibleData.CommunityID != "" {
communityData := communityInfoToData(c.CollectibleData.CommunityID, c.CommunityInfo, c.CollectibleCommunityInfo)
ret.CommunityData = &communityData
}
ret.Ownership = c.Ownership
return ret
}
func fullCollectiblesDataToHeaders(data []thirdparty.FullCollectibleData) []Collectible {
res := make([]Collectible, 0, len(data))
for _, c := range data {
header := fullCollectibleDataToHeader(c)
res = append(res, header)
}
return res
}
func fullCollectibleDataToDetails(c thirdparty.FullCollectibleData) Collectible {
ret := Collectible{
DataType: CollectibleDataTypeDetails,
ID: c.CollectibleData.ID,
ContractType: getContractType(c),
CollectibleData: &CollectibleData{
Name: c.CollectibleData.Name,
Description: &c.CollectibleData.Description,
ImageURL: &c.CollectibleData.ImageURL,
AnimationURL: &c.CollectibleData.AnimationURL,
AnimationMediaType: &c.CollectibleData.AnimationMediaType,
BackgroundColor: &c.CollectibleData.BackgroundColor,
Traits: &c.CollectibleData.Traits,
},
}
if c.CollectionData != nil {
ret.CollectionData = &CollectionData{
Name: c.CollectionData.Name,
Slug: c.CollectionData.Slug,
ImageURL: c.CollectionData.ImageURL,
}
}
if c.CollectibleData.CommunityID != "" {
communityData := communityInfoToData(c.CollectibleData.CommunityID, c.CommunityInfo, c.CollectibleCommunityInfo)
ret.CommunityData = &communityData
}
ret.Ownership = c.Ownership
return ret
}
func fullCollectiblesDataToDetails(data []thirdparty.FullCollectibleData) []Collectible {
res := make([]Collectible, 0, len(data))
for _, c := range data {
details := fullCollectibleDataToDetails(c)
res = append(res, details)
}
return res
}
func fullCollectiblesDataToCommunityHeader(data []thirdparty.FullCollectibleData) []Collectible {
res := make([]Collectible, 0, len(data))
for _, c := range data {
collectibleID := c.CollectibleData.ID
communityID := c.CollectibleData.CommunityID
if communityID == "" {
continue
}
communityData := communityInfoToData(communityID, c.CommunityInfo, c.CollectibleCommunityInfo)
header := Collectible{
ID: collectibleID,
ContractType: getContractType(c),
CollectibleData: &CollectibleData{
Name: c.CollectibleData.Name,
},
CommunityData: &communityData,
Ownership: c.Ownership,
}
res = append(res, header)
}
return res
}
func communityInfoToData(communityID string, community *thirdparty.CommunityInfo, communityCollectible *thirdparty.CollectibleCommunityInfo) CommunityData {
ret := CommunityData{
ID: communityID,
}
if community != nil {
ret.Name = community.CommunityName
ret.Color = community.CommunityColor
ret.ImageURL = &community.CommunityImage
}
if communityCollectible != nil {
ret.PrivilegesLevel = communityCollectible.PrivilegesLevel
}
return ret
}