diff --git a/migrations/evm/20240912082032_erc721_holder_statistic.ts b/migrations/evm/20240912082032_erc721_holder_statistic.ts new file mode 100644 index 000000000..bbecb92b3 --- /dev/null +++ b/migrations/evm/20240912082032_erc721_holder_statistic.ts @@ -0,0 +1,21 @@ +import { Knex } from 'knex'; +export async function up(knex: Knex): Promise { + await knex.raw(`set statement_timeout to 0`); + await knex.raw(` + CREATE TABLE erc721_holder_statistic AS + select count(*), erc721_token.owner, erc721_token.erc721_contract_address + from erc721_token + group by erc721_token.owner, erc721_token.erc721_contract_address; + ALTER TABLE erc721_holder_statistic ADD COLUMN id SERIAL PRIMARY KEY; + ALTER TABLE erc721_holder_statistic ADD COLUMN last_updated_height INTEGER; + CREATE INDEX erc721_holder_statistic_owner_index + ON erc721_holder_statistic (owner); + CREATE UNIQUE INDEX erc721_holder_statistic_erc721_contract_address_owner_index + ON erc721_holder_statistic (erc721_contract_address, owner); + CREATE INDEX erc721_holder_statistic_last_updated_height_index ON erc721_holder_statistic (last_updated_height) + `); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('erc721_holder_statistic'); +} diff --git a/migrations/evm/20240916032201_erc721_contract_totalSupply.ts b/migrations/evm/20240916032201_erc721_contract_totalSupply.ts new file mode 100644 index 000000000..fc4b0aaa3 --- /dev/null +++ b/migrations/evm/20240916032201_erc721_contract_totalSupply.ts @@ -0,0 +1,32 @@ +import { Knex } from 'knex'; +import { Erc721Token } from '../../src/models'; +import { ZERO_ADDRESS } from '../../src/services/evm/constant'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('erc721_contract', (table) => { + table.bigInteger('total_supply').defaultTo(0).index(); + }); + await knex.raw(`set statement_timeout to 0`); + const totalSupplies = await Erc721Token.query(knex) + .select('erc721_token.erc721_contract_address') + .where('erc721_token.owner', '!=', ZERO_ADDRESS) + .count() + .groupBy('erc721_token.erc721_contract_address'); + if (totalSupplies.length > 0) { + const stringListUpdates = totalSupplies + .map( + (totalSuply) => + `('${totalSuply.erc721_contract_address}', ${totalSuply.count})` + ) + .join(','); + await knex.raw( + `UPDATE erc721_contract SET total_supply = temp.total_supply from (VALUES ${stringListUpdates}) as temp(address, total_supply) where temp.address = erc721_contract.address` + ); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('erc721_contract', (table) => { + table.dropColumn('total_supply'); + }); +} diff --git a/src/models/erc721_contract.ts b/src/models/erc721_contract.ts index 2a3b9fb13..4aaba11cd 100644 --- a/src/models/erc721_contract.ts +++ b/src/models/erc721_contract.ts @@ -25,6 +25,8 @@ export class Erc721Contract extends BaseModel { last_updated_height!: number; + total_supply!: string; + static get tableName() { return 'erc721_contract'; } diff --git a/src/models/erc721_holder_statistic.ts b/src/models/erc721_holder_statistic.ts new file mode 100644 index 000000000..1fd62b660 --- /dev/null +++ b/src/models/erc721_holder_statistic.ts @@ -0,0 +1,29 @@ +import BaseModel from './base'; + +export class Erc721HolderStatistic extends BaseModel { + static softDelete = false; + + erc721_contract_address!: string; + + owner!: string; + + count!: string; + + last_updated_height!: number; + + static get tableName() { + return 'erc721_holder_statistic'; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['erc721_contract_address', 'owner', 'count'], + properties: { + erc721_contract_address: { type: 'string' }, + owner: { type: 'string' }, + count: { type: 'string' }, + }, + }; + } +} diff --git a/src/models/index.ts b/src/models/index.ts index 1d4e16dda..92cbbd82a 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -48,3 +48,4 @@ export * from './erc721_stats'; export * from './evm_block'; export * from './optimism_deposit'; export * from './optimism_withdrawal'; +export * from './erc721_holder_statistic'; diff --git a/src/services/evm/erc721.service.ts b/src/services/evm/erc721.service.ts index b561549a5..49a130b32 100644 --- a/src/services/evm/erc721.service.ts +++ b/src/services/evm/erc721.service.ts @@ -15,6 +15,7 @@ import { BlockCheckpoint, EVMSmartContract, Erc721Activity, + Erc721HolderStatistic, Erc721Stats, Erc721Token, } from '../../models'; @@ -103,7 +104,15 @@ export default class Erc721Service extends BullableService { if (erc721Activities.length > 0) { // create chunk array const listChunkErc721Activities = []; - const erc721TokensOnDB: Erc721Token[] = []; + const erc721Contracts = _.keyBy( + await Erc721Contract.query() + .whereIn( + 'address', + erc721Activities.map((e) => e.erc721_contract_address) + ) + .transacting(trx), + 'address' + ); for ( let i = 0; i < erc721Activities.length; @@ -116,30 +125,57 @@ export default class Erc721Service extends BullableService { listChunkErc721Activities.push(chunk); } // process chunk array - await Promise.all( - // eslint-disable-next-line array-callback-return - listChunkErc721Activities.map(async (chunk) => { - const erc721TokensInChunk = await Erc721Token.query().whereIn( - ['erc721_contract_address', 'token_id'], - chunk.map((e) => [ - e.erc721_contract_address, - // if token_id undefined (case approval_all), replace by null => not get any token (because token must have token_id) - e.token_id || null, - ]) - ); - erc721TokensOnDB.push(...erc721TokensInChunk); - }) - ); - + const erc721TokensOnDB: Erc721Token[] = ( + await Promise.all( + listChunkErc721Activities.map(async (chunk) => + Erc721Token.query().whereIn( + ['erc721_contract_address', 'token_id'], + chunk.map((e) => [ + e.erc721_contract_address, + // if token_id undefined (case approval_all), replace by null => not get any token (because token must have token_id) + e.token_id || null, + ]) + ) + ) + ) + ).flat(); + // process chunk array + const erc721HolderStatsOnDB: Erc721HolderStatistic[] = ( + await Promise.all( + listChunkErc721Activities.map(async (chunk) => + Erc721HolderStatistic.query().whereIn( + ['erc721_contract_address', 'owner'], + _.uniqWith( + [ + ...chunk.map((e) => [e.erc721_contract_address, e.from]), + ...chunk.map((e) => [e.erc721_contract_address, e.to]), + ], + _.isEqual + ) + ) + ) + ) + ).flat(); const erc721Tokens = _.keyBy( erc721TokensOnDB, (o) => `${o.erc721_contract_address}_${o.token_id}` ); - const erc721Handler = new Erc721Handler(erc721Tokens, erc721Activities); + const erc721HolderStats = _.keyBy( + erc721HolderStatsOnDB, + (o) => `${o.erc721_contract_address}_${o.owner}` + ); + const erc721Handler = new Erc721Handler( + erc721Contracts, + erc721Tokens, + erc721Activities, + erc721HolderStats + ); erc721Handler.process(); await Erc721Handler.updateErc721( + Object.values(erc721Handler.erc721Contracts), erc721Activities, Object.values(erc721Handler.erc721Tokens), + Object.values(erc721Handler.erc721HolderStats), trx ); } @@ -278,21 +314,6 @@ export default class Erc721Service extends BullableService { this.logger.info(`Reindex erc721 contract ${address} done.`); } - @QueueHandler({ - queueName: BULL_JOB_NAME.REFRESH_ERC721_HOLDER_STATISTIC, - jobName: BULL_JOB_NAME.REFRESH_ERC721_HOLDER_STATISTIC, - }) - public async refreshErc721HolderStatistic(): Promise { - await knex.transaction(async (trx) => { - await knex - .raw(`set statement_timeout to ${config.erc721.statementTimeout}`) - .transacting(trx); - await knex.schema - .refreshMaterializedView('m_view_erc721_holder_statistic') - .transacting(trx); - }); - } - @Action({ name: SERVICE.V1.Erc721.insertNewErc721Contracts.key, params: { @@ -550,20 +571,6 @@ export default class Erc721Service extends BullableService { }, } ); - await this.createJob( - BULL_JOB_NAME.REFRESH_ERC721_HOLDER_STATISTIC, - BULL_JOB_NAME.REFRESH_ERC721_HOLDER_STATISTIC, - {}, - { - removeOnComplete: true, - removeOnFail: { - count: 3, - }, - repeat: { - pattern: config.erc721.timeRefreshMViewErc721HolderStats, - }, - } - ); } return super._start(); } diff --git a/src/services/evm/erc721_handler.ts b/src/services/evm/erc721_handler.ts index 5c9274b51..e6de23a0e 100644 --- a/src/services/evm/erc721_handler.ts +++ b/src/services/evm/erc721_handler.ts @@ -10,6 +10,7 @@ import { EVMTransaction, Erc721Activity, Erc721Contract, + Erc721HolderStatistic, Erc721Token, EvmEvent, } from '../../models'; @@ -64,18 +65,26 @@ export const ERC721_ACTION = { APPROVAL_FOR_ALL: 'approval_for_all', }; export class Erc721Handler { + erc721Contracts: Dictionary; + // key: {contract_address}_{token_id} // value: erc721 token erc721Tokens: Dictionary; erc721Activities: Erc721Activity[]; + erc721HolderStats: Dictionary; + constructor( + erc721Contracts: Dictionary, erc721Tokens: Dictionary, - erc721Activities: Erc721Activity[] + erc721Activities: Erc721Activity[], + erc721HolderStats: Dictionary ) { + this.erc721Contracts = erc721Contracts; this.erc721Tokens = erc721Tokens; this.erc721Activities = erc721Activities; + this.erc721HolderStats = erc721HolderStats; } process() { @@ -87,6 +96,8 @@ export class Erc721Handler { } handlerErc721Transfer(erc721Activity: Erc721Activity) { + const erc721Contract = + this.erc721Contracts[`${erc721Activity.erc721_contract_address}`]; const token = this.erc721Tokens[ `${erc721Activity.erc721_contract_address}_${erc721Activity.token_id}` @@ -95,6 +106,11 @@ export class Erc721Handler { // update new owner and last updated height token.owner = erc721Activity.to; token.last_updated_height = erc721Activity.height; + if (erc721Activity.to === ZERO_ADDRESS) { + erc721Contract.total_supply = ( + BigInt(erc721Contract.total_supply) - BigInt(1) + ).toString(); + } } else if (erc721Activity.from === ZERO_ADDRESS) { // handle mint this.erc721Tokens[ @@ -106,9 +122,37 @@ export class Erc721Handler { last_updated_height: erc721Activity.height, burned: false, }); + erc721Contract.total_supply = ( + BigInt(erc721Contract.total_supply) + BigInt(1) + ).toString(); } else { throw new Error('Handle erc721 tranfer error'); } + // update erc721 holder statistics + const erc721HolderStatFrom = + this.erc721HolderStats[ + `${erc721Activity.erc721_contract_address}_${erc721Activity.from}` + ]; + this.erc721HolderStats[ + `${erc721Activity.erc721_contract_address}_${erc721Activity.from}` + ] = Erc721HolderStatistic.fromJson({ + erc721_contract_address: erc721Activity.erc721_contract_address, + owner: erc721Activity.from, + count: (BigInt(erc721HolderStatFrom?.count || 0) - BigInt(1)).toString(), + last_updated_height: erc721Activity.height, + }); + const erc721HolderStatTo = + this.erc721HolderStats[ + `${erc721Activity.erc721_contract_address}_${erc721Activity.to}` + ]; + this.erc721HolderStats[ + `${erc721Activity.erc721_contract_address}_${erc721Activity.to}` + ] = Erc721HolderStatistic.fromJson({ + erc721_contract_address: erc721Activity.erc721_contract_address, + owner: erc721Activity.to, + count: (BigInt(erc721HolderStatTo?.count || 0) + BigInt(1)).toString(), + last_updated_height: erc721Activity.height, + }); } static buildTransferActivity( @@ -266,11 +310,28 @@ export class Erc721Handler { } static async updateErc721( + erc721Contracts: Erc721Contract[], erc721Activities: Erc721Activity[], erc721Tokens: Erc721Token[], + erc721HolderStats: Erc721HolderStatistic[], trx: Knex.Transaction ) { + // update erc721 contract: total supply + if (erc721Contracts.length > 0) { + const stringListUpdates = erc721Contracts + .map( + (erc721Contract) => + `(${erc721Contract.id}, ${erc721Contract.total_supply})` + ) + .join(','); + await knex + .raw( + `UPDATE erc721_contract SET total_supply = temp.total_supply from (VALUES ${stringListUpdates}) as temp(id, total_supply) where temp.id = erc721_contract.id` + ) + .transacting(trx); + } let updatedTokens: Dictionary = {}; + // update erc721 token: new token & new holder if (erc721Tokens.length > 0) { updatedTokens = _.keyBy( await Erc721Token.query() @@ -290,6 +351,7 @@ export class Erc721Handler { (o) => `${o.erc721_contract_address}_${o.token_id}` ); } + // insert new erc721 activities if (erc721Activities.length > 0) { erc721Activities.forEach((activity) => { const token = @@ -309,6 +371,23 @@ export class Erc721Handler { ) .transacting(trx); } + // update erc721 holder statistic + if (erc721HolderStats.length > 0) { + await Erc721HolderStatistic.query() + .transacting(trx) + .insert( + erc721HolderStats.map((e) => + Erc721HolderStatistic.fromJson({ + erc721_contract_address: e.erc721_contract_address, + owner: e.owner, + count: e.count, + last_updated_height: e.last_updated_height, + }) + ) + ) + .onConflict(['erc721_contract_address', 'owner']) + .merge(); + } } static async calErc721Stats(addresses?: string[]): Promise { diff --git a/src/services/evm/erc721_reindex.ts b/src/services/evm/erc721_reindex.ts index 99d56be04..0301f256c 100644 --- a/src/services/evm/erc721_reindex.ts +++ b/src/services/evm/erc721_reindex.ts @@ -1,9 +1,11 @@ import Moleculer from 'moleculer'; import { PublicClient, getContract } from 'viem'; +import { Dictionary } from 'lodash'; import knex from '../../common/utils/db_connection'; import { Erc721Activity, Erc721Contract, + Erc721HolderStatistic, Erc721Stats, Erc721Token, } from '../../models'; @@ -74,6 +76,10 @@ export class Erc721Reindexer { ) .first() .throwIfNotFound(); + await Erc721HolderStatistic.query() + .delete() + .where('erc721_contract_address', address) + .transacting(trx); await Erc721Stats.query() .delete() .where('erc721_contract_id', erc721Contract.id) @@ -100,7 +106,8 @@ export class Erc721Reindexer { contract.read.name().catch(() => Promise.resolve(undefined)), contract.read.symbol().catch(() => Promise.resolve(undefined)), ]); - await Erc721Contract.query() + const [tokens, height] = await this.getCurrentTokens(address); + const newErc721Contract: Erc721Contract = await Erc721Contract.query() .insert( Erc721Contract.fromJson({ evm_smart_contract_id: erc721Contract.evm_smart_contract_id, @@ -109,10 +116,10 @@ export class Erc721Reindexer { name: contractInfo[0], track: true, last_updated_height: Number(blockHeight), + total_supply: tokens.length, }) ) .transacting(trx); - const [tokens, height] = await this.getCurrentTokens(address); const activities = await Erc721Handler.getErc721Activities( 0, height, @@ -120,7 +127,24 @@ export class Erc721Reindexer { [address], trx ); - await Erc721Handler.updateErc721(activities, tokens, trx); + const erc721HolderStats: Dictionary = {}; + tokens.forEach((token) => { + const { owner } = token; + const currentCount = erc721HolderStats[owner]?.count || '0'; + const newCount = (BigInt(currentCount) + BigInt(1)).toString(); + erc721HolderStats[owner] = Erc721HolderStatistic.fromJson({ + erc721_contract_address: address, + owner, + count: newCount, + }); + }); + await Erc721Handler.updateErc721( + [newErc721Contract], + activities, + tokens, + Object.values(erc721HolderStats), + trx + ); }); const erc721Stats = await Erc721Handler.calErc721Stats([address]); if (erc721Stats.length > 0) { diff --git a/test/unit/services/erc721/erc721-media.spec.ts b/test/unit/services/evm/erc721-media.spec.ts similarity index 100% rename from test/unit/services/erc721/erc721-media.spec.ts rename to test/unit/services/evm/erc721-media.spec.ts diff --git a/test/unit/services/erc721/erc721.spec.ts b/test/unit/services/evm/erc721.spec.ts similarity index 66% rename from test/unit/services/erc721/erc721.spec.ts rename to test/unit/services/evm/erc721.spec.ts index d2b8c30b0..ad01f9554 100644 --- a/test/unit/services/erc721/erc721.spec.ts +++ b/test/unit/services/evm/erc721.spec.ts @@ -6,6 +6,7 @@ import { Test, } from '@jest-decorated/core'; import { ServiceBroker } from 'moleculer'; +import _ from 'lodash'; import knex from '../../../../src/common/utils/db_connection'; import { BlockCheckpoint, @@ -13,11 +14,13 @@ import { EVMTransaction, Erc721Activity, Erc721Contract, + Erc721HolderStatistic, Erc721Token, EvmEvent, } from '../../../../src/models'; import Erc721Service from '../../../../src/services/evm/erc721.service'; import { ERC721_ACTION } from '../../../../src/services/evm/erc721_handler'; +import { BULL_JOB_NAME } from '../../../../src/services/evm/constant'; @Describe('Test erc721') export default class Erc721Test { @@ -27,22 +30,24 @@ export default class Erc721Test { evmSmartContract = EVMSmartContract.fromJson({ id: 555, - address: 'ghghdfgdsgre', - creator: 'dfgdfbvxcvxgfds', + address: '0x98605ae21dd3be686337a6d7a8f156d0d8baee92', + creator: '0xc7663c6a454fc9971C93235A170c8997e8c5E661', created_height: 100, - created_hash: 'cvxcvcxv', + created_hash: + '0x9ed3b06713baf17b7d7266294a82d51c36c84514a7d29f28585d85e586249525', type: EVMSmartContract.TYPES.ERC721, - code_hash: 'dfgdfghf', + code_hash: '0x623d4ffdfa5555aa1234366sdaff1a1sdfafa4dasdfas4r', }); evmSmartContract2 = EVMSmartContract.fromJson({ id: 666, - address: 'bcvbcvbcv', - creator: 'dfgdfbvxcvxgfds', + address: '0xc211C2CF383A38933f8352CBDd326B51b94574e6', + creator: '0xc7663c6a454fc9971C93235A170c8997e8c5E661', created_height: 100, - created_hash: 'xdasfsf', + created_hash: + '0x888d3b06713baf17b7d7266294a82d51c36c84514a7d29f28585d85e586252200', type: EVMSmartContract.TYPES.ERC721, - code_hash: 'xcsadf', + code_hash: '0x5fdfa5555aa1234366sdaff1a1sdfafa4dasdfas4r', }); evmTx = EVMTransaction.fromJson({ @@ -53,10 +58,11 @@ export default class Erc721Test { tx_id: 223, contract_address: '', index: 1, + from: '0x38828FA9766dE6eb49011fCC970ed1beFE15974a', }); evmEvent = EvmEvent.fromJson({ - id: 888, + id: 1, tx_id: 1234, evm_tx_id: this.evmTx.id, tx_hash: '', @@ -73,12 +79,23 @@ export default class Erc721Test { address: this.evmSmartContract.address, }); + blockCheckpoints = [ + BlockCheckpoint.fromJson({ + job_name: BULL_JOB_NAME.HANDLE_ERC721_ACTIVITY, + height: this.evmTx.height - 1, + }), + BlockCheckpoint.fromJson({ + job_name: BULL_JOB_NAME.HANDLE_ERC721_CONTRACT, + height: 400, + }), + ]; + @BeforeAll() async initSuite() { - this.erc721Service.getQueueManager().stopAll(); + await this.erc721Service.getQueueManager().stopAll(); await this.broker.start(); await knex.raw( - 'TRUNCATE TABLE erc721_contract, account, erc721_activity, evm_smart_contract, evm_event, evm_transaction, block_checkpoint RESTART IDENTITY CASCADE' + 'TRUNCATE TABLE erc721_contract, account, erc721_activity, evm_smart_contract, evm_event, evm_transaction, block_checkpoint, erc721_holder_statistic RESTART IDENTITY CASCADE' ); await EVMSmartContract.query().insert([ this.evmSmartContract, @@ -92,8 +109,9 @@ export default class Erc721Test { @BeforeEach() async beforeEach() { await knex.raw( - 'TRUNCATE TABLE erc721_activity, erc721_token RESTART IDENTITY CASCADE' + 'TRUNCATE TABLE erc721_activity, erc721_token, block_checkpoint, evm_event RESTART IDENTITY CASCADE' ); + await BlockCheckpoint.query().insert(this.blockCheckpoints); } @AfterAll() @@ -105,27 +123,19 @@ export default class Erc721Test { @Test('test handleErc721Activity') async testHandleErc721Activity() { - jest.spyOn(BlockCheckpoint, 'getCheckpoint').mockResolvedValue([ - this.evmSmartContract.created_height - 1, - this.evmSmartContract.created_height, - BlockCheckpoint.fromJson({ - job_name: 'dfdsfgsg', - height: this.evmSmartContract.created_height - 1, - }), - ]); + const holder1 = '0x1317df02a4e712265f5376a9d34156f73ebad640'; + const holder2 = '0xa3b6d252c1df2ce88f01fdb75b5479bcdc8f5007'; const erc721Events = [ EvmEvent.fromJson({ - id: this.evmEvent.id, block_hash: '0x6d70a03cda3fb815b54742fbd47c6141a7e754ff4d7426f10a73644ac44411d2', - block_height: 21937980, + block_height: this.blockCheckpoints[0].height + 1, data: null, topic0: '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', topic1: '0x0000000000000000000000000000000000000000000000000000000000000000', - topic2: - '0x0000000000000000000000001317df02a4e712265f5376a9d34156f73ebad640', + topic2: `0x000000000000000000000000${holder1.slice(2)}`, topic3: '0x0000000000000000000000000000000000000000000000000000000000000000', address: this.evmSmartContract.address, @@ -133,22 +143,16 @@ export default class Erc721Test { tx_id: 1234, tx_hash: this.evmTx.hash, tx_index: 1, - sender: 'fgfdg', - evm_smart_contract_id: this.evmSmartContract.id, - track: true, }), EvmEvent.fromJson({ - id: this.evmEvent.id, block_hash: '0xd39b1e6c35a7985db6ca367b1e061162b7a8610097e99cadaf98bea6b81a6096', - block_height: 21937981, + block_height: this.blockCheckpoints[0].height + 2, data: null, topic0: '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925', - topic1: - '0x0000000000000000000000001317df02a4e712265f5376a9d34156f73ebad640', - topic2: - '0x000000000000000000000000a3b6d252c1df2ce88f01fdb75b5479bcdc8f5007', + topic1: `0x000000000000000000000000${holder1.slice(2)}`, + topic2: `0x000000000000000000000000${holder2.slice(2)}`, topic3: '0x0000000000000000000000000000000000000000000000000000000000000000', address: this.evmSmartContract.address, @@ -156,22 +160,16 @@ export default class Erc721Test { tx_id: 1234, tx_hash: this.evmTx.hash, tx_index: 1, - sender: 'fgfdg', - evm_smart_contract_id: this.evmSmartContract.id, - track: true, }), EvmEvent.fromJson({ - id: this.evmEvent.id, block_hash: '0x6d70a03cda3fb815b54742fbd47c6141a7e754ff4d7426f10a73644ac44411d2', - block_height: 21937982, + block_height: this.blockCheckpoints[0].height + 3, data: null, topic0: '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', - topic1: - '0x0000000000000000000000001317df02a4e712265f5376a9d34156f73ebad640', - topic2: - '0x000000000000000000000000e39633931ec4a1841e438b15005a6f141d30789e', + topic1: `0x000000000000000000000000${holder1.slice(2)}`, + topic2: `0x000000000000000000000000${holder2.slice(2)}`, topic3: '0x0000000000000000000000000000000000000000000000000000000000000000', address: this.evmSmartContract.address, @@ -179,27 +177,14 @@ export default class Erc721Test { tx_id: 1234, tx_hash: this.evmTx.hash, tx_index: 1, - sender: 'fgfdg', - evm_smart_contract_id: this.evmSmartContract.id, - track: true, }), ]; - const mockQueryEvents: any = { - select: () => erc721Events, - transacting: () => mockQueryEvents, - joinRelated: () => mockQueryEvents, - where: () => mockQueryEvents, - andWhere: () => mockQueryEvents, - orderBy: () => mockQueryEvents, - innerJoin: () => mockQueryEvents, - modify: () => mockQueryEvents, - }; - jest.spyOn(EvmEvent, 'query').mockImplementation(() => mockQueryEvents); + await EvmEvent.query().insert(erc721Events); await this.erc721Service.handleErc721Activity(); const erc721Token = await Erc721Token.query().first().throwIfNotFound(); expect(erc721Token).toMatchObject({ token_id: '0', - owner: '0xe39633931ec4a1841e438b15005a6f141d30789e', + owner: holder2, erc721_contract_address: this.evmSmartContract.address, }); const erc721Activities = await Erc721Activity.query().orderBy('height'); @@ -207,22 +192,32 @@ export default class Erc721Test { action: ERC721_ACTION.TRANSFER, erc721_contract_address: erc721Events[0].address, from: '0x0000000000000000000000000000000000000000', - to: '0x1317df02a4e712265f5376a9d34156f73ebad640', + to: holder1, erc721_token_id: erc721Token.id, }); expect(erc721Activities[1]).toMatchObject({ action: ERC721_ACTION.APPROVAL, erc721_contract_address: erc721Events[0].address, - from: '0x1317df02a4e712265f5376a9d34156f73ebad640', - to: '0xa3b6d252c1df2ce88f01fdb75b5479bcdc8f5007', + from: holder1, + to: holder2, erc721_token_id: erc721Token.id, }); expect(erc721Activities[2]).toMatchObject({ action: ERC721_ACTION.TRANSFER, erc721_contract_address: erc721Events[0].address, - from: '0x1317df02a4e712265f5376a9d34156f73ebad640', - to: '0xe39633931ec4a1841e438b15005a6f141d30789e', + from: holder1, + to: holder2, erc721_token_id: erc721Token.id, }); + const erc721Contract = await Erc721Contract.query() + .first() + .throwIfNotFound(); + expect(erc721Contract.total_supply).toEqual('1'); + const erc721HolderStats = _.keyBy( + await Erc721HolderStatistic.query(), + 'owner' + ); + expect(erc721HolderStats[holder1].count).toEqual('0'); + expect(erc721HolderStats[holder2].count).toEqual('1'); } } diff --git a/test/unit/services/erc721/erc721_handler.spec.ts b/test/unit/services/evm/erc721_handler.spec.ts similarity index 60% rename from test/unit/services/erc721/erc721_handler.spec.ts rename to test/unit/services/evm/erc721_handler.spec.ts index 6c5a752a6..802a05481 100644 --- a/test/unit/services/erc721/erc721_handler.spec.ts +++ b/test/unit/services/evm/erc721_handler.spec.ts @@ -1,6 +1,5 @@ -import { BeforeAll, Describe, Test } from '@jest-decorated/core'; +import { AfterAll, BeforeAll, Describe, Test } from '@jest-decorated/core'; import { ServiceBroker } from 'moleculer'; -import { bytesToHex, hexToBytes } from 'viem'; import config from '../../../../config.json' assert { type: 'json' }; import knex from '../../../../src/common/utils/db_connection'; import { @@ -22,35 +21,36 @@ export default class Erc721HandlerTest { broker = new ServiceBroker({ logger: false }); evmSmartContract = EVMSmartContract.fromJson({ - id: 555, - address: 'ghghdfgdsgre', - creator: 'dfgdfbvxcvxgfds', + id: 1, + address: '0xE974cC14c93FC6077B0d65F98832B846C5454A0B', + creator: '0x5606b4eA93F696Dd82Ca983BAF5723d00729f127', created_height: 100, - created_hash: 'cvxcvcxv', + created_hash: + '0x8c46cf6373f2f6e528b56becf0ce6b460b5d90cf9b0325a136b9b3a820e1e489', + code_hash: '0xdsf3335453454tsgfsdrtserf43645y4h4tAAvfgfgds', type: EVMSmartContract.TYPES.ERC721, - code_hash: 'dfgdfghf', }); evmSmartContract2 = EVMSmartContract.fromJson({ - id: 666, - address: 'bcvbcvbcv', - creator: 'dfgdfbvxcvxgfds', + id: 2, + address: '0x3CB367e7C920Ff15879Bd4CBd708b8c60eB0f537', + creator: '0xa9497CC4F95773A744D408b54dAC724626ee31d2', created_height: 100, - created_hash: 'xdasfsf', + created_hash: + '0x5bca9ee42c4c32941c58f2a510dae5ff5c6ed848d9a396a8e9e146a166b3a3fc', + code_hash: '0xdfskjgdsgfgweruwie4535t3tu34tjkewtgjwe', type: EVMSmartContract.TYPES.ERC721, - code_hash: 'xcsadf', }); evmTx = EVMTransaction.fromJson({ id: 11111, - hash: hexToBytes( - '0x3faac2ed3ca031892c04598177f7c36e9fdcdf2fb3b6c4a13c520590facb82ef' - ), + hash: '', height: 111, tx_msg_id: 222, tx_id: 223, + contract_address: '', index: 1, - from: hexToBytes('0x51aeade652867f342ddc012e15c27d0cd6220398'), + from: '0x38828FA9766dE6eb49011fCC970ed1beFE15974a', }); erc721Contract1 = Erc721Contract.fromJson({ @@ -83,6 +83,13 @@ export default class Erc721HandlerTest { await EVMTransaction.query().insert(this.evmTx); } + @AfterAll() + async tearDown() { + await this.broker.stop(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + } + @Test('test getErc721Activities') async testGetErc721Activities() { await knex.transaction(async (trx) => { @@ -103,7 +110,7 @@ export default class Erc721HandlerTest { address: this.evmSmartContract.address, evm_tx_id: this.evmTx.id, tx_id: 1234, - tx_hash: bytesToHex(this.evmTx.hash), + tx_hash: this.evmTx.hash, tx_index: 1, }), EvmEvent.fromJson({ @@ -122,7 +129,7 @@ export default class Erc721HandlerTest { address: this.evmSmartContract.address, evm_tx_id: this.evmTx.id, tx_id: 1234, - tx_hash: bytesToHex(this.evmTx.hash), + tx_hash: this.evmTx.hash, tx_index: 1, }), EvmEvent.fromJson({ @@ -141,7 +148,7 @@ export default class Erc721HandlerTest { address: this.evmSmartContract2.address, evm_tx_id: this.evmTx.id, tx_id: 1234, - tx_hash: bytesToHex(this.evmTx.hash), + tx_hash: this.evmTx.hash, tx_index: 1, }), EvmEvent.fromJson({ @@ -150,17 +157,16 @@ export default class Erc721HandlerTest { block_height: 21937982, data: null, topic0: - '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31', topic1: '0x0000000000000000000000001317df02a4e712265f5376a9d34156f73ebad640', topic2: '0x000000000000000000000000e39633931ec4a1841e438b15005a6f141d30789e', - topic3: - '0x0000000000000000000000000000000000000000000000000000000000000000', + topic3: null, address: this.evmSmartContract.address, evm_tx_id: this.evmTx.id, tx_id: 1234, - tx_hash: bytesToHex(this.evmTx.hash), + tx_hash: this.evmTx.hash, tx_index: 1, }), ]; @@ -191,11 +197,12 @@ export default class Erc721HandlerTest { to: '0xa3b6d252c1df2ce88f01fdb75b5479bcdc8f5007', }); expect(erc721Activities[3]).toMatchObject({ - action: ERC721_ACTION.TRANSFER, + action: ERC721_ACTION.APPROVAL_FOR_ALL, erc721_contract_address: this.evmSmartContract.address, from: '0x1317df02a4e712265f5376a9d34156f73ebad640', to: '0xe39633931ec4a1841e438b15005a6f141d30789e', }); + // get a contract's activities const erc721ActivitiesByContract = await Erc721Handler.getErc721Activities( this.evmTx.height - 1, @@ -214,6 +221,148 @@ export default class Erc721HandlerTest { }); } + @Test('test process') + async testProcess() { + const holders = [ + '0x38828FA9766dE6eb49011fCC970ed1beFE15974a', + '0x27CF763feEE58f14C2c69663EA8De784FCF2Dbbb', + '0xE1dbb634e2aefbe63085CB0b60f4155f9ABCa48f', + ]; + const initErc721Contract = Erc721Contract.fromJson({ + evm_smart_contract_id: 1, + address: '0x4c5f56a2FF75a8617337E33f75EB459Db422916F', + symbol: 'ITCIPA', + name: 'Iliad Testnet Commemorative IP Asset', + track: true, + last_updated_height: 0, + total_supply: '3', + }); + const initErc721Tokens = {}; + const initErc721HolderStats = {}; + const erc721Activities = [ + // mint token 0 to holder0 + Erc721Activity.fromJson({ + action: ERC721_ACTION.TRANSFER, + erc721_contract_address: initErc721Contract.address, + from: '0x0000000000000000000000000000000000000000', + to: holders[0], + token_id: '0', + height: 1, + evm_event_id: 1, + }), + // mint token 1 to holder0 + Erc721Activity.fromJson({ + action: ERC721_ACTION.TRANSFER, + erc721_contract_address: initErc721Contract.address, + from: '0x0000000000000000000000000000000000000000', + to: holders[0], + token_id: '1', + height: 1, + evm_event_id: 1, + }), + // mint token 2 to holder1 + Erc721Activity.fromJson({ + action: ERC721_ACTION.TRANSFER, + erc721_contract_address: initErc721Contract.address, + from: '0x0000000000000000000000000000000000000000', + to: holders[1], + token_id: '2', + height: 2, + evm_event_id: 1, + }), + // mint token 3 to holder0 + Erc721Activity.fromJson({ + action: ERC721_ACTION.TRANSFER, + erc721_contract_address: initErc721Contract.address, + from: '0x0000000000000000000000000000000000000000', + to: holders[0], + token_id: '3', + height: 3, + evm_event_id: 1, + }), + // transfer token 0 from holder0 to holder2 + Erc721Activity.fromJson({ + action: ERC721_ACTION.TRANSFER, + erc721_contract_address: initErc721Contract.address, + from: holders[0], + to: holders[2], + token_id: '0', + height: 4, + evm_event_id: 1, + }), + // transfer token 1 from holder0 to holder1 + Erc721Activity.fromJson({ + action: ERC721_ACTION.TRANSFER, + erc721_contract_address: initErc721Contract.address, + from: holders[0], + to: holders[1], + token_id: '1', + height: 5, + evm_event_id: 1, + }), + // burn token 3 + Erc721Activity.fromJson({ + action: ERC721_ACTION.TRANSFER, + erc721_contract_address: initErc721Contract.address, + from: holders[0], + to: '0x0000000000000000000000000000000000000000', + token_id: '3', + height: 6, + evm_event_id: 1, + }), + ]; + const erc721Handler = new Erc721Handler( + { [initErc721Contract.address]: initErc721Contract }, + initErc721Tokens, + erc721Activities, + initErc721HolderStats + ); + erc721Handler.process(); + const updatedErc721Contract = + erc721Handler.erc721Contracts[initErc721Contract.address]; + + // check total supply + expect(updatedErc721Contract.total_supply).toEqual('6'); + // check new owner for each token + const updatedTokens = erc721Handler.erc721Tokens; + expect(updatedTokens[`${initErc721Contract.address}_0`].owner).toEqual( + holders[2] + ); + expect(updatedTokens[`${initErc721Contract.address}_1`].owner).toEqual( + holders[1] + ); + expect(updatedTokens[`${initErc721Contract.address}_2`].owner).toEqual( + holders[1] + ); + const updatedErc721HolderStats = erc721Handler.erc721HolderStats; + expect( + updatedErc721HolderStats[`${initErc721Contract.address}_${holders[0]}`] + ).toMatchObject({ + count: '0', + last_updated_height: erc721Activities[6].height, + }); + expect( + updatedErc721HolderStats[`${initErc721Contract.address}_${holders[1]}`] + ).toMatchObject({ + count: '2', + last_updated_height: erc721Activities[5].height, + }); + expect( + updatedErc721HolderStats[`${initErc721Contract.address}_${holders[2]}`] + ).toMatchObject({ + count: '1', + last_updated_height: erc721Activities[4].height, + }); + expect( + updatedErc721HolderStats[ + `${initErc721Contract.address}_0x0000000000000000000000000000000000000000` + ] + ).toMatchObject({ + count: '-3', + last_updated_height: erc721Activities[6].height, + }); + } + @Test('test calErc721Stats') async testCalErc721Stats() { const evmEvent = EvmEvent.fromJson({