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

     1  package collectibles
     2  
     3  import (
     4  	"database/sql"
     5  	"fmt"
     6  	"math/big"
     7  	"sync"
     8  
     9  	"github.com/ethereum/go-ethereum/common"
    10  
    11  	"github.com/jmoiron/sqlx"
    12  
    13  	"github.com/status-im/status-go/services/wallet/bigint"
    14  	w_common "github.com/status-im/status-go/services/wallet/common"
    15  	"github.com/status-im/status-go/services/wallet/thirdparty"
    16  	"github.com/status-im/status-go/sqlite"
    17  )
    18  
    19  const InvalidTimestamp = int64(-1)
    20  
    21  type OwnershipDB struct {
    22  	db *sql.DB
    23  	mu sync.Mutex
    24  }
    25  
    26  func NewOwnershipDB(sqlDb *sql.DB) *OwnershipDB {
    27  	return &OwnershipDB{
    28  		db: sqlDb,
    29  	}
    30  }
    31  
    32  const selectOwnershipColumns = "chain_id, contract_address, token_id"
    33  
    34  const collectiblesOwnershipColumns = "token_id, owner_address, balance"
    35  
    36  const ownershipTimestampColumns = "owner_address, chain_id, timestamp"
    37  const selectOwnershipTimestampColumns = "timestamp"
    38  
    39  func insertTmpOwnership(
    40  	db *sql.DB,
    41  	chainID w_common.ChainID,
    42  	ownerAddress common.Address,
    43  	balancesPerContractAdddress thirdparty.TokenBalancesPerContractAddress,
    44  ) error {
    45  	// Put old/new ownership data into temp tables
    46  	// NOTE: Temp table CREATE doesn't work with prepared statements,
    47  	// so we have to use Exec directly
    48  	_, err := db.Exec(`
    49  		DROP TABLE IF EXISTS temp.old_collectibles_ownership_cache; 
    50  		CREATE TABLE temp.old_collectibles_ownership_cache(
    51  			contract_address VARCHAR NOT NULL,
    52  			token_id BLOB NOT NULL,
    53  			balance BLOB NOT NULL
    54  		);
    55  		DROP TABLE IF EXISTS temp.new_collectibles_ownership_cache; 
    56  		CREATE TABLE temp.new_collectibles_ownership_cache(
    57  			contract_address VARCHAR NOT NULL,
    58  			token_id BLOB NOT NULL,
    59  			balance BLOB NOT NULL
    60  		);`)
    61  	if err != nil {
    62  		return err
    63  	}
    64  
    65  	insertTmpOldOwnership, err := db.Prepare(`
    66  			INSERT INTO temp.old_collectibles_ownership_cache
    67  			SELECT contract_address, token_id, balance FROM collectibles_ownership_cache
    68  			WHERE chain_id = ? AND owner_address = ?`)
    69  	if err != nil {
    70  		return err
    71  	}
    72  	defer insertTmpOldOwnership.Close()
    73  
    74  	_, err = insertTmpOldOwnership.Exec(chainID, ownerAddress)
    75  	if err != nil {
    76  		return err
    77  	}
    78  
    79  	insertTmpNewOwnership, err := db.Prepare(`
    80  			INSERT INTO temp.new_collectibles_ownership_cache (contract_address, token_id, balance) 
    81  			VALUES (?, ?, ?)`)
    82  	if err != nil {
    83  		return err
    84  	}
    85  	defer insertTmpNewOwnership.Close()
    86  
    87  	for contractAddress, balances := range balancesPerContractAdddress {
    88  		for _, balance := range balances {
    89  			_, err = insertTmpNewOwnership.Exec(
    90  				contractAddress,
    91  				(*bigint.SQLBigIntBytes)(balance.TokenID.Int),
    92  				(*bigint.SQLBigIntBytes)(balance.Balance.Int),
    93  			)
    94  			if err != nil {
    95  				return err
    96  			}
    97  		}
    98  	}
    99  
   100  	return nil
   101  }
   102  
   103  func removeOldAddressOwnership(
   104  	creator sqlite.StatementCreator,
   105  	chainID w_common.ChainID,
   106  	ownerAddress common.Address,
   107  ) ([]thirdparty.CollectibleUniqueID, error) {
   108  	// Find collectibles in the DB that are not in the temp table
   109  	removedQuery, err := creator.Prepare(fmt.Sprintf(`
   110  	SELECT %d, tOld.contract_address, tOld.token_id 
   111  		FROM temp.old_collectibles_ownership_cache tOld
   112  		LEFT JOIN temp.new_collectibles_ownership_cache tNew ON
   113  			tOld.contract_address = tNew.contract_address AND tOld.token_id = tNew.token_id
   114  		WHERE 
   115  			tNew.contract_address IS NULL
   116  	`, chainID))
   117  	if err != nil {
   118  		return nil, err
   119  	}
   120  	defer removedQuery.Close()
   121  
   122  	removedRows, err := removedQuery.Query()
   123  	if err != nil {
   124  		return nil, err
   125  	}
   126  
   127  	defer removedRows.Close()
   128  	removedIDs, err := thirdparty.RowsToCollectibles(removedRows)
   129  	if err != nil {
   130  		return nil, err
   131  	}
   132  
   133  	removeOwnership, err := creator.Prepare("DELETE FROM collectibles_ownership_cache WHERE chain_id = ? AND owner_address = ? AND contract_address = ? AND token_id = ?")
   134  	if err != nil {
   135  		return nil, err
   136  	}
   137  	defer removeOwnership.Close()
   138  
   139  	for _, id := range removedIDs {
   140  		_, err = removeOwnership.Exec(
   141  			chainID,
   142  			ownerAddress,
   143  			id.ContractID.Address,
   144  			(*bigint.SQLBigIntBytes)(id.TokenID.Int),
   145  		)
   146  		if err != nil {
   147  			return nil, err
   148  		}
   149  	}
   150  
   151  	return removedIDs, nil
   152  }
   153  
   154  func updateChangedAddressOwnership(
   155  	creator sqlite.StatementCreator,
   156  	chainID w_common.ChainID,
   157  	ownerAddress common.Address,
   158  ) ([]thirdparty.CollectibleUniqueID, error) {
   159  	// Find collectibles in the temp table that are in the DB and have a different balance
   160  	updatedQuery, err := creator.Prepare(fmt.Sprintf(`
   161  		SELECT %d, tNew.contract_address, tNew.token_id 
   162  		FROM temp.new_collectibles_ownership_cache tNew
   163  		LEFT JOIN temp.old_collectibles_ownership_cache tOld ON
   164  			tOld.contract_address = tNew.contract_address AND tOld.token_id = tNew.token_id
   165  		WHERE 
   166  			tOld.contract_address IS NOT NULL AND tOld.balance != tNew.balance
   167  	`, chainID))
   168  	if err != nil {
   169  		return nil, err
   170  	}
   171  	defer updatedQuery.Close()
   172  
   173  	updatedRows, err := updatedQuery.Query()
   174  	if err != nil {
   175  		return nil, err
   176  	}
   177  	defer updatedRows.Close()
   178  
   179  	updatedIDs, err := thirdparty.RowsToCollectibles(updatedRows)
   180  	if err != nil {
   181  		return nil, err
   182  	}
   183  
   184  	updateOwnership, err := creator.Prepare(`
   185  		UPDATE collectibles_ownership_cache
   186  		SET balance = (SELECT tNew.balance
   187  			FROM temp.new_collectibles_ownership_cache tNew
   188  			WHERE tNew.contract_address = collectibles_ownership_cache.contract_address AND tNew.token_id = collectibles_ownership_cache.token_id)
   189  		WHERE chain_id = ? AND owner_address = ? AND contract_address = ? AND token_id = ?
   190  	`)
   191  	if err != nil {
   192  		return nil, err
   193  	}
   194  	defer updateOwnership.Close()
   195  
   196  	for _, id := range updatedIDs {
   197  		_, err = updateOwnership.Exec(
   198  			chainID,
   199  			ownerAddress,
   200  			id.ContractID.Address,
   201  			(*bigint.SQLBigIntBytes)(id.TokenID.Int))
   202  		if err != nil {
   203  			return nil, err
   204  		}
   205  	}
   206  
   207  	return updatedIDs, nil
   208  }
   209  
   210  func insertNewAddressOwnership(
   211  	creator sqlite.StatementCreator,
   212  	chainID w_common.ChainID,
   213  	ownerAddress common.Address,
   214  ) ([]thirdparty.CollectibleUniqueID, error) {
   215  	// Find collectibles in the temp table that are not in the DB
   216  	insertedQuery, err := creator.Prepare(fmt.Sprintf(`
   217  		SELECT %d, tNew.contract_address, tNew.token_id 
   218  		FROM temp.new_collectibles_ownership_cache tNew
   219  		LEFT JOIN temp.old_collectibles_ownership_cache tOld ON
   220  			tOld.contract_address = tNew.contract_address AND tOld.token_id = tNew.token_id
   221  		WHERE 
   222  			tOld.contract_address IS NULL
   223  	`, chainID))
   224  	if err != nil {
   225  		return nil, err
   226  	}
   227  	defer insertedQuery.Close()
   228  
   229  	insertedRows, err := insertedQuery.Query()
   230  	if err != nil {
   231  		return nil, err
   232  	}
   233  	defer insertedRows.Close()
   234  
   235  	insertedIDs, err := thirdparty.RowsToCollectibles(insertedRows)
   236  	if err != nil {
   237  		return nil, err
   238  	}
   239  
   240  	insertOwnership, err := creator.Prepare(fmt.Sprintf(`
   241  		INSERT INTO collectibles_ownership_cache
   242  		SELECT
   243  			%d, tNew.contract_address, tNew.token_id, X'%s', tNew.balance, NULL
   244  		FROM temp.new_collectibles_ownership_cache tNew
   245  		WHERE
   246  			tNew.contract_address = ? AND tNew.token_id = ?
   247  	`, chainID, ownerAddress.Hex()[2:]))
   248  	if err != nil {
   249  		return nil, err
   250  	}
   251  	defer insertOwnership.Close()
   252  
   253  	for _, id := range insertedIDs {
   254  		_, err = insertOwnership.Exec(
   255  			id.ContractID.Address,
   256  			(*bigint.SQLBigIntBytes)(id.TokenID.Int))
   257  		if err != nil {
   258  			return nil, err
   259  		}
   260  	}
   261  
   262  	return insertedIDs, nil
   263  }
   264  
   265  func updateAddressOwnership(
   266  	tx sqlite.StatementCreator,
   267  	chainID w_common.ChainID,
   268  	ownerAddress common.Address,
   269  ) (removedIDs, updatedIDs, insertedIDs []thirdparty.CollectibleUniqueID, err error) {
   270  	removedIDs, err = removeOldAddressOwnership(tx, chainID, ownerAddress)
   271  	if err != nil {
   272  		return
   273  	}
   274  
   275  	updatedIDs, err = updateChangedAddressOwnership(tx, chainID, ownerAddress)
   276  	if err != nil {
   277  		return
   278  	}
   279  
   280  	insertedIDs, err = insertNewAddressOwnership(tx, chainID, ownerAddress)
   281  	if err != nil {
   282  		return
   283  	}
   284  
   285  	return
   286  }
   287  
   288  func updateAddressOwnershipTimestamp(creator sqlite.StatementCreator, ownerAddress common.Address, chainID w_common.ChainID, timestamp int64) error {
   289  	updateTimestamp, err := creator.Prepare(fmt.Sprintf(`INSERT OR REPLACE INTO collectibles_ownership_update_timestamps (%s) 
   290  																				VALUES (?, ?, ?)`, ownershipTimestampColumns))
   291  	if err != nil {
   292  		return err
   293  	}
   294  	defer updateTimestamp.Close()
   295  
   296  	_, err = updateTimestamp.Exec(ownerAddress, chainID, timestamp)
   297  
   298  	return err
   299  }
   300  
   301  // Returns the list of added/removed IDs when comparing the given list of IDs with the ones in the DB.
   302  // Call before Update for the result to be useful.
   303  func (o *OwnershipDB) GetIDsNotInDB(
   304  	ownerAddress common.Address,
   305  	newIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.CollectibleUniqueID, error) {
   306  	ret := make([]thirdparty.CollectibleUniqueID, 0, len(newIDs))
   307  
   308  	exists, err := o.db.Prepare(`SELECT EXISTS (
   309  			SELECT 1 FROM collectibles_ownership_cache
   310  			WHERE chain_id=? AND contract_address=? AND token_id=? AND owner_address=?
   311  		)`)
   312  	if err != nil {
   313  		return nil, err
   314  	}
   315  
   316  	for _, id := range newIDs {
   317  		row := exists.QueryRow(
   318  			id.ContractID.ChainID,
   319  			id.ContractID.Address,
   320  			(*bigint.SQLBigIntBytes)(id.TokenID.Int),
   321  			ownerAddress,
   322  		)
   323  		var exists bool
   324  		err = row.Scan(&exists)
   325  		if err != nil {
   326  			return nil, err
   327  		}
   328  		if !exists {
   329  			ret = append(ret, id)
   330  		}
   331  	}
   332  
   333  	return ret, nil
   334  }
   335  
   336  func (o *OwnershipDB) GetIsFirstOfCollection(onwerAddress common.Address, newIDs []thirdparty.CollectibleUniqueID) (map[thirdparty.CollectibleUniqueID]bool, error) {
   337  	ret := make(map[thirdparty.CollectibleUniqueID]bool)
   338  
   339  	exists, err := o.db.Prepare(`SELECT count(*) FROM collectibles_ownership_cache
   340  			WHERE chain_id=? AND contract_address=? AND owner_address=?`)
   341  	if err != nil {
   342  		return nil, err
   343  	}
   344  
   345  	for _, id := range newIDs {
   346  		row := exists.QueryRow(
   347  			id.ContractID.ChainID,
   348  			id.ContractID.Address,
   349  			onwerAddress,
   350  		)
   351  		var count int
   352  		err = row.Scan(&count)
   353  		if err != nil {
   354  			return nil, err
   355  		}
   356  		ret[id] = count <= 1
   357  	}
   358  	return ret, nil
   359  }
   360  
   361  func (o *OwnershipDB) Update(chainID w_common.ChainID, ownerAddress common.Address, balances thirdparty.TokenBalancesPerContractAddress, timestamp int64) (removedIDs, updatedIDs, insertedIDs []thirdparty.CollectibleUniqueID, err error) {
   362  	// Ensure all steps are done atomically
   363  	o.mu.Lock()
   364  	defer o.mu.Unlock()
   365  
   366  	err = insertTmpOwnership(o.db, chainID, ownerAddress, balances)
   367  	if err != nil {
   368  		return
   369  	}
   370  
   371  	var (
   372  		tx *sql.Tx
   373  	)
   374  	tx, err = o.db.Begin()
   375  	if err != nil {
   376  		return
   377  	}
   378  	defer func() {
   379  		if err == nil {
   380  			err = tx.Commit()
   381  			return
   382  		}
   383  		_ = tx.Rollback()
   384  	}()
   385  
   386  	// Compare tmp and current ownership tables and update the current one
   387  	removedIDs, updatedIDs, insertedIDs, err = updateAddressOwnership(tx, chainID, ownerAddress)
   388  	if err != nil {
   389  		return
   390  	}
   391  
   392  	// Update timestamp
   393  	err = updateAddressOwnershipTimestamp(tx, ownerAddress, chainID, timestamp)
   394  
   395  	return
   396  }
   397  
   398  func (o *OwnershipDB) GetOwnedCollectibles(chainIDs []w_common.ChainID, ownerAddresses []common.Address, offset int, limit int) ([]thirdparty.CollectibleUniqueID, error) {
   399  	query, args, err := sqlx.In(fmt.Sprintf(`SELECT DISTINCT %s
   400  		FROM collectibles_ownership_cache
   401  		WHERE chain_id IN (?) AND owner_address IN (?)
   402  		LIMIT ? OFFSET ?`, selectOwnershipColumns), chainIDs, ownerAddresses, limit, offset)
   403  	if err != nil {
   404  		return nil, err
   405  	}
   406  
   407  	stmt, err := o.db.Prepare(query)
   408  	if err != nil {
   409  		return nil, err
   410  	}
   411  	defer stmt.Close()
   412  
   413  	rows, err := stmt.Query(args...)
   414  	if err != nil {
   415  		return nil, err
   416  	}
   417  	defer rows.Close()
   418  
   419  	return thirdparty.RowsToCollectibles(rows)
   420  }
   421  
   422  func (o *OwnershipDB) FetchCachedCollectibleOwnersByContractAddress(chainID w_common.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) {
   423  	query, args, err := sqlx.In(fmt.Sprintf(`SELECT %s
   424  		FROM collectibles_ownership_cache 
   425  		WHERE chain_id = ? AND contract_address = ?`, collectiblesOwnershipColumns), chainID, contractAddress)
   426  	if err != nil {
   427  		return nil, err
   428  	}
   429  
   430  	var ret thirdparty.CollectibleContractOwnership
   431  
   432  	stmt, err := o.db.Prepare(query)
   433  	if err != nil {
   434  		return nil, err
   435  	}
   436  	defer stmt.Close()
   437  
   438  	rows, err := stmt.Query(args...)
   439  	if err != nil {
   440  		return nil, err
   441  	}
   442  	defer rows.Close()
   443  
   444  	tokenID := &bigint.BigInt{Int: big.NewInt(0)}
   445  	var ownerAddress common.Address
   446  	balance := &bigint.BigInt{Int: big.NewInt(0)}
   447  	var tokenBalances []thirdparty.TokenBalance
   448  
   449  	for rows.Next() {
   450  		err = rows.Scan(
   451  			(*bigint.SQLBigIntBytes)(tokenID.Int),
   452  			&ownerAddress,
   453  			(*bigint.SQLBigIntBytes)(balance.Int),
   454  		)
   455  		if err != nil {
   456  			return nil, err
   457  		}
   458  
   459  		tokenBalance := thirdparty.TokenBalance{
   460  			TokenID: tokenID,
   461  			Balance: balance,
   462  		}
   463  		tokenBalances = append(tokenBalances, tokenBalance)
   464  
   465  		collectibleOwner := thirdparty.CollectibleOwner{
   466  			OwnerAddress:  ownerAddress,
   467  			TokenBalances: tokenBalances,
   468  		}
   469  
   470  		ret.ContractAddress = contractAddress
   471  		ret.Owners = append(ret.Owners, collectibleOwner)
   472  	}
   473  
   474  	return &ret, nil
   475  }
   476  
   477  func (o *OwnershipDB) GetOwnedCollectible(chainID w_common.ChainID, ownerAddresses common.Address, contractAddress common.Address, tokenID *big.Int) (*thirdparty.CollectibleUniqueID, error) {
   478  	query := fmt.Sprintf(`SELECT %s
   479  		FROM collectibles_ownership_cache
   480  		WHERE chain_id = ? AND owner_address = ? AND contract_address = ? AND token_id = ?`, selectOwnershipColumns)
   481  
   482  	stmt, err := o.db.Prepare(query)
   483  	if err != nil {
   484  		return nil, err
   485  	}
   486  	defer stmt.Close()
   487  
   488  	rows, err := stmt.Query(chainID, ownerAddresses, contractAddress, (*bigint.SQLBigIntBytes)(tokenID))
   489  	if err != nil {
   490  		return nil, err
   491  	}
   492  	defer rows.Close()
   493  
   494  	ids, err := thirdparty.RowsToCollectibles(rows)
   495  	if err != nil {
   496  		return nil, err
   497  	}
   498  
   499  	if len(ids) == 0 {
   500  		return nil, nil
   501  	}
   502  
   503  	return &ids[0], nil
   504  }
   505  
   506  func (o *OwnershipDB) GetOwnershipUpdateTimestamp(owner common.Address, chainID w_common.ChainID) (int64, error) {
   507  	query := fmt.Sprintf(`SELECT %s
   508  		FROM collectibles_ownership_update_timestamps
   509  		WHERE owner_address = ? AND chain_id = ?`, selectOwnershipTimestampColumns)
   510  
   511  	stmt, err := o.db.Prepare(query)
   512  	if err != nil {
   513  		return InvalidTimestamp, err
   514  	}
   515  	defer stmt.Close()
   516  
   517  	row := stmt.QueryRow(owner, chainID)
   518  
   519  	var timestamp int64
   520  
   521  	err = row.Scan(&timestamp)
   522  
   523  	if err == sql.ErrNoRows {
   524  		return InvalidTimestamp, nil
   525  	} else if err != nil {
   526  		return InvalidTimestamp, err
   527  	}
   528  
   529  	return timestamp, nil
   530  }
   531  
   532  func (o *OwnershipDB) GetLatestOwnershipUpdateTimestamp(chainID w_common.ChainID) (int64, error) {
   533  	query := `SELECT MAX(timestamp)
   534  		FROM collectibles_ownership_update_timestamps
   535  		WHERE chain_id = ?`
   536  
   537  	stmt, err := o.db.Prepare(query)
   538  	if err != nil {
   539  		return InvalidTimestamp, err
   540  	}
   541  	defer stmt.Close()
   542  
   543  	row := stmt.QueryRow(chainID)
   544  
   545  	var timestamp sql.NullInt64
   546  
   547  	err = row.Scan(&timestamp)
   548  
   549  	if err != nil {
   550  		return InvalidTimestamp, err
   551  	}
   552  	if timestamp.Valid {
   553  		return timestamp.Int64, nil
   554  	}
   555  
   556  	return InvalidTimestamp, nil
   557  }
   558  
   559  func (o *OwnershipDB) GetOwnership(id thirdparty.CollectibleUniqueID) ([]thirdparty.AccountBalance, error) {
   560  	query := fmt.Sprintf(`SELECT c.owner_address, c.balance, COALESCE(t.timestamp, %d)
   561  		FROM collectibles_ownership_cache c
   562  		LEFT JOIN transfers t ON
   563  			c.transfer_id = t.hash
   564  		WHERE
   565                  c.chain_id = ? AND c.contract_address = ? AND c.token_id = ?`, InvalidTimestamp)
   566  
   567  	stmt, err := o.db.Prepare(query)
   568  	if err != nil {
   569  		return nil, err
   570  	}
   571  	defer stmt.Close()
   572  
   573  	rows, err := stmt.Query(id.ContractID.ChainID, id.ContractID.Address, (*bigint.SQLBigIntBytes)(id.TokenID.Int))
   574  	if err != nil {
   575  		return nil, err
   576  	}
   577  	defer rows.Close()
   578  
   579  	var ret []thirdparty.AccountBalance
   580  	for rows.Next() {
   581  		accountBalance := thirdparty.AccountBalance{
   582  			Balance: &bigint.BigInt{Int: big.NewInt(0)},
   583  		}
   584  		err = rows.Scan(
   585  			&accountBalance.Address,
   586  			(*bigint.SQLBigIntBytes)(accountBalance.Balance.Int),
   587  			&accountBalance.TxTimestamp,
   588  		)
   589  		if err != nil {
   590  			return nil, err
   591  		}
   592  
   593  		ret = append(ret, accountBalance)
   594  	}
   595  
   596  	return ret, nil
   597  }
   598  
   599  func (o *OwnershipDB) SetTransferID(ownerAddress common.Address, id thirdparty.CollectibleUniqueID, transferID common.Hash) (bool, error) {
   600  	query := `UPDATE collectibles_ownership_cache
   601  		SET transfer_id = ?
   602  		WHERE chain_id = ? AND contract_address = ? AND token_id = ? AND owner_address = ?`
   603  
   604  	stmt, err := o.db.Prepare(query)
   605  	if err != nil {
   606  		return false, err
   607  	}
   608  	defer stmt.Close()
   609  
   610  	res, err := stmt.Exec(transferID, id.ContractID.ChainID, id.ContractID.Address, (*bigint.SQLBigIntBytes)(id.TokenID.Int), ownerAddress)
   611  	if err != nil {
   612  		return false, err
   613  	}
   614  
   615  	rowsAffected, err := res.RowsAffected()
   616  	if err != nil {
   617  		return false, err
   618  	}
   619  
   620  	if rowsAffected > 0 {
   621  		return true, nil
   622  	}
   623  
   624  	return false, nil
   625  }
   626  
   627  func (o *OwnershipDB) GetTransferID(ownerAddress common.Address, id thirdparty.CollectibleUniqueID) (*common.Hash, error) {
   628  	query := `SELECT transfer_id
   629  		FROM collectibles_ownership_cache
   630  		WHERE chain_id = ? AND contract_address = ? AND token_id = ? AND owner_address = ?
   631  		LIMIT 1`
   632  
   633  	stmt, err := o.db.Prepare(query)
   634  	if err != nil {
   635  		return nil, err
   636  	}
   637  	defer stmt.Close()
   638  
   639  	row := stmt.QueryRow(id.ContractID.ChainID, id.ContractID.Address, (*bigint.SQLBigIntBytes)(id.TokenID.Int), ownerAddress)
   640  
   641  	var dbTransferID []byte
   642  
   643  	err = row.Scan(&dbTransferID)
   644  
   645  	if err == sql.ErrNoRows {
   646  		return nil, nil
   647  	} else if err != nil {
   648  		return nil, err
   649  	}
   650  
   651  	if len(dbTransferID) > 0 {
   652  		transferID := common.BytesToHash(dbTransferID)
   653  		return &transferID, nil
   654  	}
   655  
   656  	return nil, nil
   657  }
   658  
   659  func (o *OwnershipDB) GetCollectiblesWithNoTransferID(account common.Address, chainID w_common.ChainID) ([]thirdparty.CollectibleUniqueID, error) {
   660  	query := `SELECT contract_address, token_id
   661  		FROM collectibles_ownership_cache
   662  		WHERE chain_id = ? AND owner_address = ? AND transfer_id IS NULL`
   663  
   664  	stmt, err := o.db.Prepare(query)
   665  	if err != nil {
   666  		return nil, err
   667  	}
   668  	defer stmt.Close()
   669  
   670  	rows, err := stmt.Query(chainID, account)
   671  	if err != nil {
   672  		return nil, err
   673  	}
   674  	defer rows.Close()
   675  
   676  	var ret []thirdparty.CollectibleUniqueID
   677  	for rows.Next() {
   678  		id := thirdparty.CollectibleUniqueID{
   679  			ContractID: thirdparty.ContractID{
   680  				ChainID: chainID,
   681  			},
   682  			TokenID: &bigint.BigInt{Int: big.NewInt(0)},
   683  		}
   684  		err = rows.Scan(
   685  			&id.ContractID.Address,
   686  			(*bigint.SQLBigIntBytes)(id.TokenID.Int),
   687  		)
   688  		if err != nil {
   689  			return nil, err
   690  		}
   691  
   692  		ret = append(ret, id)
   693  	}
   694  
   695  	return ret, nil
   696  }