diff --git a/src/models/account_balance.ts b/src/models/account_balance.ts index 6100451e3..4f78806cd 100644 --- a/src/models/account_balance.ts +++ b/src/models/account_balance.ts @@ -4,6 +4,8 @@ import BaseModel from './base'; import { Account } from './account'; export class AccountBalance extends BaseModel { + static softDelete = false; + account!: Account; id!: number; diff --git a/src/services/api-gateways/api_gateway.service.ts b/src/services/api-gateways/api_gateway.service.ts index 90fa8939d..43e1a3f59 100644 --- a/src/services/api-gateways/api_gateway.service.ts +++ b/src/services/api-gateways/api_gateway.service.ts @@ -44,6 +44,7 @@ import config from '../../../config.json' assert { type: 'json' }; 'v1.job.update-delegator-validator', 'v1.job.signature-mapping', 'v1.job.insert-verify-by-codehash', + 'v1.erc20-admin.*', ], }, { diff --git a/src/services/api-gateways/erc20_admin.service.ts b/src/services/api-gateways/erc20_admin.service.ts new file mode 100644 index 000000000..be9e6dbe9 --- /dev/null +++ b/src/services/api-gateways/erc20_admin.service.ts @@ -0,0 +1,49 @@ +import { Post, Service } from '@ourparentcenter/moleculer-decorators-extended'; +import { Context, ServiceBroker } from 'moleculer'; +import networks from '../../../network.json' assert { type: 'json' }; +import BaseService from '../../base/base.service'; + +@Service({ + name: 'erc20-admin', + version: 1, +}) +export default class Erc20AdminService extends BaseService { + public constructor(public broker: ServiceBroker) { + super(broker); + } + + @Post('/erc20-reindexing', { + name: 'erc20Reindexing', + params: { + chainid: { + type: 'string', + optional: false, + enum: networks.map((network) => network.chainId), + }, + addresses: { + type: 'array', + optional: false, + items: 'string', + }, + }, + }) + async erc20Reindexing( + ctx: Context< + { + chainid: string; + addresses: string[]; + }, + Record + > + ) { + const selectedChain = networks.find( + (network) => network.chainId === ctx.params.chainid + ); + return this.broker.call( + `v1.Erc20.reindexing@${selectedChain?.moleculerNamespace}`, + { + addresses: ctx.params.addresses, + } + ); + } +} diff --git a/src/services/evm/constant.ts b/src/services/evm/constant.ts index 7fc1fd612..2be3c3090 100644 --- a/src/services/evm/constant.ts +++ b/src/services/evm/constant.ts @@ -113,6 +113,10 @@ export const SERVICE = { key: 'insertNewErc20Contracts', path: 'v1.Erc20.insertNewErc20Contracts', }, + reindexing: { + key: 'reindexing', + path: 'v1.Erc20.reindexing', + }, }, Erc721: { key: 'Erc721', @@ -218,6 +222,7 @@ export const BULL_JOB_NAME = { REFRESH_ERC721_STATS: 'refresh:erc721-stats', REINDEX_ERC721: 'reindex:erc721', HANDLE_SELF_DESTRUCT: 'handle:self-destruct', + REINDEX_ERC20: 'reindex:erc20', }; export const MSG_TYPE = { diff --git a/src/services/evm/erc20.service.ts b/src/services/evm/erc20.service.ts index b02d28d29..a50edbd8e 100644 --- a/src/services/evm/erc20.service.ts +++ b/src/services/evm/erc20.service.ts @@ -6,25 +6,20 @@ import { Knex } from 'knex'; import _ from 'lodash'; import { Context, ServiceBroker } from 'moleculer'; import { PublicClient, getContract } from 'viem'; -import { QueryModuleAccountByNameResponseSDKType } from '@aura-nw/aurajs/types/codegen/cosmos/auth/v1beta1/query'; import config from '../../../config.json' assert { type: 'json' }; import BullableService, { QueueHandler } from '../../base/bullable.service'; -import { SERVICE as COSMOS_SERVICE, getLcdClient } from '../../common'; +import { SERVICE as COSMOS_SERVICE, Config } from '../../common'; import knex from '../../common/utils/db_connection'; import { getViemClient } from '../../common/utils/etherjs_client'; -import { - BlockCheckpoint, - EVMSmartContract, - EvmEvent, - Event, -} from '../../models'; -import { AccountBalance } from '../../models/account_balance'; +import { BlockCheckpoint, EVMSmartContract } from '../../models'; import { Erc20Activity } from '../../models/erc20_activity'; import { Erc20Contract } from '../../models/erc20_contract'; import { BULL_JOB_NAME, SERVICE as EVM_SERVICE, SERVICE } from './constant'; -import { ERC20_EVENT_TOPIC0, Erc20Handler } from './erc20_handler'; +import { Erc20Handler } from './erc20_handler'; +import { Erc20Reindexer } from './erc20_reindex'; import { convertEthAddressToBech32Address } from './utils'; +const { NODE_ENV } = Config; @Service({ name: EVM_SERVICE.V1.Erc20.key, version: 1, @@ -32,8 +27,6 @@ import { convertEthAddressToBech32Address } from './utils'; export default class Erc20Service extends BullableService { viemClient!: PublicClient; - erc20ModuleAccount!: string; - public constructor(public broker: ServiceBroker) { super(broker); } @@ -97,82 +90,14 @@ export default class Erc20Service extends BullableService { ], config.erc20.key ); - // TODO: handle track erc20 contract only - const erc20Events = await EvmEvent.query() - .joinRelated('[evm_smart_contract,evm_transaction]') - .innerJoin( - 'erc20_contract', - 'evm_event.address', - 'erc20_contract.address' - ) - .where('evm_event.block_height', '>', startBlock) - .andWhere('evm_event.block_height', '<=', endBlock) - .orderBy('evm_event.id', 'asc') - .select( - 'evm_event.*', - 'evm_transaction.from as sender', - 'evm_smart_contract.id as evm_smart_contract_id', - 'evm_transaction.id as evm_tx_id' - ); - let erc20CosmosEvents: Event[] = []; - if (config.evmOnly === false) { - if (!this.erc20ModuleAccount) { - const lcdClient = await getLcdClient(); - const erc20Account: QueryModuleAccountByNameResponseSDKType = - await lcdClient.provider.cosmos.auth.v1beta1.moduleAccountByName({ - name: 'erc20', - }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this.erc20ModuleAccount = erc20Account.account.base_account.address; - } - erc20CosmosEvents = await Event.query() - .where('block_height', '>', startBlock) - .andWhere('block_height', '<=', endBlock) - .andWhere((query) => { - query - .where('type', Event.EVENT_TYPE.CONVERT_COIN) - .orWhere('type', Event.EVENT_TYPE.CONVERT_ERC20); - }) - .withGraphFetched('[transaction, attributes]'); - } - await this.handleMissingErc20Contract( - erc20Events, - erc20CosmosEvents, - trx - ); - const erc20Activities: Erc20Activity[] = []; - erc20Events.forEach((e) => { - if (e.topic0 === ERC20_EVENT_TOPIC0.TRANSFER) { - const activity = Erc20Handler.buildTransferActivity(e, this.logger); - if (activity) { - erc20Activities.push(activity); - } - } else if (e.topic0 === ERC20_EVENT_TOPIC0.APPROVAL) { - const activity = Erc20Handler.buildApprovalActivity(e, this.logger); - if (activity) { - erc20Activities.push(activity); - } - } else if (config.erc20.wrapExtensionContract.includes(e.address)) { - const wrapActivity = Erc20Handler.buildWrapExtensionActivity( - e, - this.logger - ); - if (wrapActivity) { - erc20Activities.push(wrapActivity); - } - } - }); - erc20CosmosEvents.forEach((event) => { - const activity = Erc20Handler.buildTransferActivityByCosmos( - event, - this.erc20ModuleAccount, + const erc20Activities: Erc20Activity[] = + await Erc20Handler.buildErc20Activities( + startBlock, + endBlock, + trx, this.logger ); - if (activity) { - erc20Activities.push(activity); - } - }); + await this.handleMissingErc20Contract(erc20Activities, trx); if (erc20Activities.length > 0) { this.logger.info( `Crawl Erc20 activity from block ${startBlock} to block ${endBlock}:\n ${JSON.stringify( @@ -208,7 +133,10 @@ export default class Erc20Service extends BullableService { config.erc20.key ); // get Erc20 activities - let erc20Activities = await this.getErc20Activities(startBlock, endBlock); + let erc20Activities = await Erc20Handler.getErc20Activities( + startBlock, + endBlock + ); // get missing Account const missingAccountsAddress = Array.from( new Set( @@ -232,40 +160,13 @@ export default class Erc20Service extends BullableService { addresses: missingAccountsAddress, } ); - erc20Activities = await this.getErc20Activities(startBlock, endBlock); + erc20Activities = await Erc20Handler.getErc20Activities( + startBlock, + endBlock + ); } await knex.transaction(async (trx) => { - if (erc20Activities.length > 0) { - const accountBalances = _.keyBy( - await AccountBalance.query() - .transacting(trx) - .joinRelated('account') - .whereIn( - ['account.evm_address', 'denom'], - [ - ...erc20Activities.map((e) => [ - e.from, - e.erc20_contract_address, - ]), - ...erc20Activities.map((e) => [e.to, e.erc20_contract_address]), - ] - ), - (o) => `${o.account_id}_${o.denom}` - ); - // construct cw721 handler object - const erc20Handler = new Erc20Handler(accountBalances, erc20Activities); - erc20Handler.process(); - const updatedAccountBalances = Object.values( - erc20Handler.accountBalances - ); - if (updatedAccountBalances.length > 0) { - await AccountBalance.query() - .transacting(trx) - .insert(updatedAccountBalances) - .onConflict(['account_id', 'denom']) - .merge(); - } - } + await Erc20Handler.updateErc20AccountsBalance(erc20Activities, trx); updateBlockCheckpoint.height = endBlock; await BlockCheckpoint.query() .insert(updateBlockCheckpoint) @@ -275,6 +176,52 @@ export default class Erc20Service extends BullableService { }); } + @QueueHandler({ + queueName: BULL_JOB_NAME.REINDEX_ERC20, + jobName: BULL_JOB_NAME.REINDEX_ERC20, + }) + async reindexErc20(_payload: { address: `0x${string}` }): Promise { + const { address } = _payload; + const erc20Reindexer = new Erc20Reindexer(this.viemClient, this.logger); + await erc20Reindexer.reindex(address.toLowerCase() as `0x${string}`); + this.logger.info(`Reindex erc20 contract ${address} done.`); + } + + @Action({ + name: SERVICE.V1.Erc20.reindexing.key, + params: { + addresses: { + type: 'array', + items: 'string', + optional: false, + }, + }, + }) + public async reindexing( + ctx: Context<{ + addresses: `0x${string}`[]; + }> + ) { + const { addresses } = ctx.params; + if (addresses.length > 0) { + await Promise.all( + addresses.map((address) => + this.createJob( + BULL_JOB_NAME.REINDEX_ERC20, + BULL_JOB_NAME.REINDEX_ERC20, + { + address, + }, + { + jobId: address, + removeOnComplete: true, + } + ) + ) + ); + } + } + @Action({ name: SERVICE.V1.Erc20.insertNewErc20Contracts.key, params: { @@ -310,52 +257,13 @@ export default class Erc20Service extends BullableService { } } - async getErc20Activities( - startBlock: number, - endBlock: number - ): Promise { - return Erc20Activity.query() - .leftJoin( - 'account as from_account', - 'erc20_activity.from', - 'from_account.evm_address' - ) - .leftJoin( - 'account as to_account', - 'erc20_activity.to', - 'to_account.evm_address' - ) - .leftJoin( - 'erc20_contract as erc20_contract', - 'erc20_activity.erc20_contract_address', - 'erc20_contract.address' - ) - .where('erc20_activity.height', '>', startBlock) - .andWhere('erc20_activity.height', '<=', endBlock) - .andWhere('erc20_contract.track', true) - .select( - 'erc20_activity.*', - 'from_account.id as from_account_id', - 'to_account.id as to_account_id' - ) - .orderBy('erc20_activity.id'); - } - async handleMissingErc20Contract( - events: EvmEvent[], - cosmosEvents: Event[], + erc20Activities: Erc20Activity[], trx: Knex.Transaction ) { - const evmEventsUniqByAddress = _.keyBy(events, (e) => e.address); - const cosmosEventsUniqByAddress = _.keyBy(cosmosEvents, (e) => - e.attributes.find((a) => a.key === 'erc20_token')?.value.toLowerCase() + const addresses = _.uniq( + erc20Activities.map((activity) => activity.erc20_contract_address) ); - - const addresses = [ - ...Object.keys(evmEventsUniqByAddress), - ...Object.keys(cosmosEventsUniqByAddress), - ]; - const erc20ContractsByAddress = _.keyBy( await Erc20Contract.query().whereIn('address', addresses), (e) => e.address @@ -440,48 +348,50 @@ export default class Erc20Service extends BullableService { public async _start(): Promise { this.viemClient = getViemClient(); - await this.createJob( - BULL_JOB_NAME.HANDLE_ERC20_CONTRACT, - BULL_JOB_NAME.HANDLE_ERC20_CONTRACT, - {}, - { - removeOnComplete: true, - removeOnFail: { - count: 3, - }, - repeat: { - every: config.erc20.millisecondRepeatJob, - }, - } - ); - await this.createJob( - BULL_JOB_NAME.HANDLE_ERC20_ACTIVITY, - BULL_JOB_NAME.HANDLE_ERC20_ACTIVITY, - {}, - { - removeOnComplete: true, - removeOnFail: { - count: 3, - }, - repeat: { - every: config.erc20.millisecondRepeatJob, - }, - } - ); - await this.createJob( - BULL_JOB_NAME.HANDLE_ERC20_BALANCE, - BULL_JOB_NAME.HANDLE_ERC20_BALANCE, - {}, - { - removeOnComplete: true, - removeOnFail: { - count: 3, - }, - repeat: { - every: config.erc20.millisecondRepeatJob, - }, - } - ); + if (NODE_ENV !== 'test') { + await this.createJob( + BULL_JOB_NAME.HANDLE_ERC20_CONTRACT, + BULL_JOB_NAME.HANDLE_ERC20_CONTRACT, + {}, + { + removeOnComplete: true, + removeOnFail: { + count: 3, + }, + repeat: { + every: config.erc20.millisecondRepeatJob, + }, + } + ); + await this.createJob( + BULL_JOB_NAME.HANDLE_ERC20_ACTIVITY, + BULL_JOB_NAME.HANDLE_ERC20_ACTIVITY, + {}, + { + removeOnComplete: true, + removeOnFail: { + count: 3, + }, + repeat: { + every: config.erc20.millisecondRepeatJob, + }, + } + ); + await this.createJob( + BULL_JOB_NAME.HANDLE_ERC20_BALANCE, + BULL_JOB_NAME.HANDLE_ERC20_BALANCE, + {}, + { + removeOnComplete: true, + removeOnFail: { + count: 3, + }, + repeat: { + every: config.erc20.millisecondRepeatJob, + }, + } + ); + } return super._start(); } } diff --git a/src/services/evm/erc20_handler.ts b/src/services/evm/erc20_handler.ts index 7d60ac931..d4e2091a7 100644 --- a/src/services/evm/erc20_handler.ts +++ b/src/services/evm/erc20_handler.ts @@ -1,8 +1,16 @@ -import { Dictionary } from 'lodash'; +import { Knex } from 'knex'; +import _, { Dictionary } from 'lodash'; import Moleculer from 'moleculer'; import { decodeAbiParameters, keccak256, toHex } from 'viem'; import config from '../../../config.json' assert { type: 'json' }; -import { Erc20Activity, Event, EventAttribute, EvmEvent } from '../../models'; +import knex from '../../common/utils/db_connection'; +import { + Erc20Activity, + Erc20Contract, + Event, + EventAttribute, + EvmEvent, +} from '../../models'; import { AccountBalance } from '../../models/account_balance'; import { ZERO_ADDRESS } from './constant'; import { convertBech32AddressToEthAddress } from './utils'; @@ -54,12 +62,16 @@ export class Erc20Handler { erc20Activities: Erc20Activity[]; + erc20Contracts: Dictionary; + constructor( accountBalances: Dictionary, - erc20Activities: Erc20Activity[] + erc20Activities: Erc20Activity[], + erc20Contracts: Dictionary ) { this.accountBalances = accountBalances; this.erc20Activities = erc20Activities; + this.erc20Contracts = erc20Contracts; } process() { @@ -77,37 +89,60 @@ export class Erc20Handler { } handlerErc20Transfer(erc20Activity: Erc20Activity) { + const erc20Contract: Erc20Contract = + this.erc20Contracts[erc20Activity.erc20_contract_address]; + if (!erc20Contract) { + throw new Error( + `Erc20 contract not found:${erc20Activity.erc20_contract_address}` + ); + } // update from account balance if from != ZERO_ADDRESS if (erc20Activity.from !== ZERO_ADDRESS) { const fromAccountId = erc20Activity.from_account_id; const key = `${fromAccountId}_${erc20Activity.erc20_contract_address}`; const fromAccountBalance = this.accountBalances[key]; if ( - !fromAccountBalance || - fromAccountBalance.last_updated_height <= erc20Activity.height + fromAccountBalance && + erc20Activity.height < fromAccountBalance.last_updated_height ) { - // calculate new balance: decrease balance of from account - const amount = ( - BigInt(fromAccountBalance?.amount || 0) - BigInt(erc20Activity.amount) - ).toString(); - // update object accountBalance - this.accountBalances[key] = AccountBalance.fromJson({ - denom: erc20Activity.erc20_contract_address, - amount, - last_updated_height: erc20Activity.height, - account_id: fromAccountId, - type: AccountBalance.TYPE.ERC20_TOKEN, - }); + throw new Error( + `Process erc20 balance: fromAccountBalance ${erc20Activity.from} was updated` + ); } + // calculate new balance: decrease balance of from account + const amount = ( + BigInt(fromAccountBalance?.amount || 0) - BigInt(erc20Activity.amount) + ).toString(); + // update object accountBalance + this.accountBalances[key] = AccountBalance.fromJson({ + denom: erc20Activity.erc20_contract_address, + amount, + last_updated_height: erc20Activity.height, + account_id: fromAccountId, + type: AccountBalance.TYPE.ERC20_TOKEN, + }); + } else if (erc20Contract.total_supply !== null) { + // update total supply + erc20Contract.total_supply = ( + BigInt(erc20Contract.total_supply) + BigInt(erc20Activity.amount) + ).toString(); + // update last updated height + erc20Contract.last_updated_height = erc20Activity.height; } - // update to account balance - const toAccountId = erc20Activity.to_account_id; - const key = `${toAccountId}_${erc20Activity.erc20_contract_address}`; - const toAccountBalance = this.accountBalances[key]; - if ( - !toAccountBalance || - toAccountBalance.last_updated_height <= erc20Activity.height - ) { + // update from account balance if to != ZERO_ADDRESS + if (erc20Activity.to !== ZERO_ADDRESS) { + // update to account balance + const toAccountId = erc20Activity.to_account_id; + const key = `${toAccountId}_${erc20Activity.erc20_contract_address}`; + const toAccountBalance = this.accountBalances[key]; + if ( + toAccountBalance && + erc20Activity.height < toAccountBalance.last_updated_height + ) { + throw new Error( + `Process erc20 balance: toAccountBalance ${erc20Activity.to} was updated` + ); + } // calculate new balance: increase balance of to account const amount = ( BigInt(toAccountBalance?.amount || 0) + BigInt(erc20Activity.amount) @@ -120,6 +155,191 @@ export class Erc20Handler { account_id: toAccountId, type: AccountBalance.TYPE.ERC20_TOKEN, }); + } else if (erc20Contract.total_supply !== null) { + // update total supply + erc20Contract.total_supply = ( + BigInt(erc20Contract.total_supply) - BigInt(erc20Activity.amount) + ).toString(); + // update last updated height + erc20Contract.last_updated_height = erc20Activity.height; + } + } + + static async buildErc20Activities( + startBlock: number, + endBlock: number, + trx: Knex.Transaction, + logger: Moleculer.LoggerInstance, + addresses?: string[] + ) { + const erc20Activities: Erc20Activity[] = []; + const erc20Events = await EvmEvent.query() + .transacting(trx) + .joinRelated('[evm_smart_contract,evm_transaction]') + .innerJoin( + 'erc20_contract', + 'evm_event.address', + 'erc20_contract.address' + ) + .modify((builder) => { + if (addresses) { + builder.whereIn('evm_event.address', addresses); + } + }) + .where('evm_event.block_height', '>', startBlock) + .andWhere('evm_event.block_height', '<=', endBlock) + .orderBy('evm_event.id', 'asc') + .select( + 'evm_event.*', + 'evm_transaction.from as sender', + 'evm_smart_contract.id as evm_smart_contract_id', + 'evm_transaction.id as evm_tx_id' + ); + let erc20CosmosEvents: Event[] = []; + if (config.evmOnly === false) { + erc20CosmosEvents = await Event.query() + .transacting(trx) + .where('event.block_height', '>', startBlock) + .andWhere('event.block_height', '<=', endBlock) + .andWhere((query) => { + query + .where('event.type', Event.EVENT_TYPE.CONVERT_COIN) + .orWhere('event.type', Event.EVENT_TYPE.CONVERT_ERC20); + }) + .modify((builder) => { + if (addresses) { + builder + .joinRelated('attributes') + .where('attributes.key', EventAttribute.ATTRIBUTE_KEY.ERC20_TOKEN) + .whereIn(knex.raw('lower("value")'), addresses); + } + }) + .withGraphFetched('[transaction, attributes]') + .orderBy('event.id', 'asc'); + } + erc20Events.forEach((e) => { + if (e.topic0 === ERC20_EVENT_TOPIC0.TRANSFER) { + const activity = Erc20Handler.buildTransferActivity(e, logger); + if (activity) { + erc20Activities.push(activity); + } + } else if (e.topic0 === ERC20_EVENT_TOPIC0.APPROVAL) { + const activity = Erc20Handler.buildApprovalActivity(e, logger); + if (activity) { + erc20Activities.push(activity); + } + } else if (config.erc20.wrapExtensionContract.includes(e.address)) { + const wrapActivity = Erc20Handler.buildWrapExtensionActivity(e, logger); + if (wrapActivity) { + erc20Activities.push(wrapActivity); + } + } + }); + erc20CosmosEvents.forEach((event) => { + const activity = Erc20Handler.buildTransferActivityByCosmos( + event, + logger + ); + if (activity) { + erc20Activities.push(activity); + } + }); + return _.sortBy(erc20Activities, 'cosmos_tx_id'); + } + + static async getErc20Activities( + startBlock: number, + endBlock: number, + trx?: Knex.Transaction, + addresses?: string[] + ): Promise { + return Erc20Activity.query() + .modify((builder) => { + if (addresses) { + builder.whereIn('erc20_contract.address', addresses); + } + if (trx) { + builder.transacting(trx); + } + }) + .leftJoin( + 'account as from_account', + 'erc20_activity.from', + 'from_account.evm_address' + ) + .leftJoin( + 'account as to_account', + 'erc20_activity.to', + 'to_account.evm_address' + ) + .leftJoin( + 'erc20_contract as erc20_contract', + 'erc20_activity.erc20_contract_address', + 'erc20_contract.address' + ) + .where('erc20_activity.height', '>', startBlock) + .andWhere('erc20_activity.height', '<=', endBlock) + .andWhere('erc20_contract.track', true) + .select( + 'erc20_activity.*', + 'from_account.id as from_account_id', + 'to_account.id as to_account_id' + ) + .orderBy('erc20_activity.id'); + } + + static async updateErc20AccountsBalance( + erc20Activities: Erc20Activity[], + trx: Knex.Transaction + ) { + if (erc20Activities.length > 0) { + const accountBalances = _.keyBy( + await AccountBalance.query() + .transacting(trx) + .joinRelated('account') + .whereIn( + ['account.evm_address', 'denom'], + [ + ...erc20Activities.map((e) => [e.from, e.erc20_contract_address]), + ...erc20Activities.map((e) => [e.to, e.erc20_contract_address]), + ] + ), + (o) => `${o.account_id}_${o.denom}` + ); + const erc20Contracts = _.keyBy( + await Erc20Contract.query() + .transacting(trx) + .whereIn( + 'address', + erc20Activities.map((e) => e.erc20_contract_address) + ), + 'address' + ); + // construct cw721 handler object + const erc20Handler = new Erc20Handler( + accountBalances, + erc20Activities, + erc20Contracts + ); + erc20Handler.process(); + const updatedErc20Contracts = Object.values(erc20Handler.erc20Contracts); + if (updatedErc20Contracts.length > 0) { + await Erc20Contract.query() + .transacting(trx) + .insert(updatedErc20Contracts) + .onConflict(['id']) + .merge(); + } + const updatedAccountBalances = Object.values( + erc20Handler.accountBalances + ); + if (updatedAccountBalances.length > 0) { + await AccountBalance.query() + .transacting(trx) + .insert(updatedAccountBalances) + .onConflict(['account_id', 'denom']) + .merge(); + } } } @@ -147,6 +367,7 @@ export class Erc20Handler { height: e.block_height, tx_hash: e.tx_hash, evm_tx_id: e.evm_tx_id, + cosmos_tx_id: e.tx_id, }); } catch (e) { logger.error(e); @@ -156,7 +377,6 @@ export class Erc20Handler { static buildTransferActivityByCosmos( e: Event, - erc20ModuleAccount: string, logger: Moleculer.LoggerInstance ): Erc20Activity | undefined { try { @@ -188,15 +408,9 @@ export class Erc20Handler { ); const sender = from; if (e.type === Event.EVENT_TYPE.CONVERT_COIN) { - from = convertBech32AddressToEthAddress( - config.networkPrefixAddress, - erc20ModuleAccount - ).toLowerCase(); + from = ZERO_ADDRESS; } else if (e.type === Event.EVENT_TYPE.CONVERT_ERC20) { - to = convertBech32AddressToEthAddress( - config.networkPrefixAddress, - erc20ModuleAccount - ).toLowerCase(); + to = ZERO_ADDRESS; } const amount = e.attributes.find( (attr) => attr.key === EventAttribute.ATTRIBUTE_KEY.AMOUNT @@ -247,6 +461,7 @@ export class Erc20Handler { height: e.block_height, tx_hash: e.tx_hash, evm_tx_id: e.evm_tx_id, + cosmos_tx_id: e.tx_id, }); } catch (e) { logger.error(e); @@ -289,6 +504,7 @@ export class Erc20Handler { height: e.block_height, tx_hash: e.tx_hash, evm_tx_id: e.evm_tx_id, + cosmos_tx_id: e.tx_id, }); } catch (e) { logger.error(e); @@ -316,6 +532,7 @@ export class Erc20Handler { height: e.block_height, tx_hash: e.tx_hash, evm_tx_id: e.evm_tx_id, + cosmos_tx_id: e.tx_id, }); } catch (e) { logger.error(e); diff --git a/src/services/evm/erc20_reindex.ts b/src/services/evm/erc20_reindex.ts new file mode 100644 index 000000000..4838aeca8 --- /dev/null +++ b/src/services/evm/erc20_reindex.ts @@ -0,0 +1,122 @@ +import Moleculer from 'moleculer'; +import { getContract, PublicClient } from 'viem'; +import config from '../../../config.json' assert { type: 'json' }; +import knex from '../../common/utils/db_connection'; +import { AccountBalance, Erc20Activity, Erc20Contract } from '../../models'; +import { Erc20Handler } from './erc20_handler'; +import { convertEthAddressToBech32Address } from './utils'; + +export class Erc20Reindexer { + viemClient: PublicClient; + + logger!: Moleculer.LoggerInstance; + + constructor(viemClient: PublicClient, logger: Moleculer.LoggerInstance) { + this.viemClient = viemClient; + this.logger = logger; + } + + /** + * @description reindex erc20 contract + * @param addresses Contracts address that you want to reindex + * @steps + * - clean database: erc20 AccountBalance + * - re-compute erc20 AccountBalance + */ + async reindex(address: `0x${string}`) { + // stop tracking => if start reindexing, track will be false (although error when reindex) + await Erc20Contract.query() + .patch({ track: false }) + .where('address', address); + // reindex + await knex.transaction(async (trx) => { + const erc20Contract = await Erc20Contract.query() + .transacting(trx) + .joinRelated('evm_smart_contract') + .where('erc20_contract.address', address) + .select('evm_smart_contract.id as evm_smart_contract_id') + .first() + .throwIfNotFound(); + await Erc20Activity.query() + .delete() + .where('erc20_contract_address', address) + .transacting(trx); + await AccountBalance.query() + .delete() + .where('denom', address) + .transacting(trx); + await Erc20Contract.query() + .delete() + .where('address', address) + .transacting(trx); + const contract = getContract({ + address, + abi: Erc20Contract.ABI, + client: this.viemClient, + }); + const [blockHeight, ...contractInfo] = await Promise.all([ + this.viemClient.getBlockNumber(), + contract.read.name().catch(() => Promise.resolve(undefined)), + contract.read.symbol().catch(() => Promise.resolve(undefined)), + contract.read.decimals().catch(() => Promise.resolve(undefined)), + ]); + await Erc20Contract.query() + .insert( + Erc20Contract.fromJson({ + evm_smart_contract_id: erc20Contract.evm_smart_contract_id, + address, + symbol: contractInfo[1], + name: contractInfo[0], + total_supply: '0', + decimal: contractInfo[2], + track: true, + last_updated_height: Number(blockHeight), + }) + ) + .transacting(trx); + const erc20Activities = await Erc20Handler.buildErc20Activities( + 0, + Number(blockHeight), + trx, + this.logger, + [address] + ); + if (erc20Activities.length > 0) { + await knex + .batchInsert( + 'erc20_activity', + erc20Activities, + config.erc20.chunkSizeInsert + ) + .transacting(trx); + } + const erc20ActivitiesInDb = await Erc20Handler.getErc20Activities( + 0, + Number(blockHeight), + trx, + [address] + ); + // get missing Account + const missingAccountsAddress = Array.from( + new Set( + [ + ...erc20ActivitiesInDb + .filter((e) => !e.from_account_id) + .map((e) => e.from), + ...erc20ActivitiesInDb + .filter((e) => !e.to_account_id) + .map((e) => e.to), + ].map((e) => + convertEthAddressToBech32Address(config.networkPrefixAddress, e) + ) as string[] + ) + ); + if (missingAccountsAddress.length > 0) { + throw new Error( + `Missing accounts ${missingAccountsAddress}. You should reindex them` + ); + } + await Erc20Handler.updateErc20AccountsBalance(erc20ActivitiesInDb, trx); + }); + } +} diff --git a/test/unit/services/erc20/erc20.spec.ts b/test/unit/services/erc20/erc20.spec.ts deleted file mode 100644 index 9cb0eef9f..000000000 --- a/test/unit/services/erc20/erc20.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { AfterAll, BeforeAll, Describe, Test } from '@jest-decorated/core'; -import { ServiceBroker } from 'moleculer'; -import knex from '../../../../src/common/utils/db_connection'; -import { - Account, - EVMSmartContract, - EVMTransaction, - Erc20Activity, - Erc20Contract, - EvmEvent, -} from '../../../../src/models'; -import Erc20Service from '../../../../src/services/evm/erc20.service'; - -@Describe('Test erc20 handler') -export default class Erc20Test { - broker = new ServiceBroker({ logger: false }); - - erc20Service = this.broker.createService(Erc20Service) as Erc20Service; - - evmSmartContract = EVMSmartContract.fromJson({ - id: 555, - address: 'ghghdfgdsgre', - creator: 'dfgdfbvxcvxgfds', - created_height: 100, - created_hash: 'cvxcvcxv', - type: EVMSmartContract.TYPES.ERC20, - code_hash: 'dfgdfghf', - }); - - evmSmartContract2 = EVMSmartContract.fromJson({ - id: 666, - address: 'bcvbcvbcv', - creator: 'dfgdfbvxcvxgfds', - created_height: 100, - created_hash: 'xdasfsf', - type: EVMSmartContract.TYPES.PROXY_EIP_1967, - code_hash: 'xcsadf', - }); - - evmTx = EVMTransaction.fromJson({ - id: 11111, - hash: '', - height: 111, - tx_msg_id: 222, - tx_id: 223, - contract_address: '', - index: 1, - }); - - evmEvent = EvmEvent.fromJson({ - id: 888, - tx_id: 1234, - evm_tx_id: this.evmTx.id, - tx_hash: '', - address: '', - block_height: 1, - block_hash: '', - tx_index: 1, - }); - - @BeforeAll() - async initSuite() { - await this.broker.start(); - await knex.raw( - 'TRUNCATE TABLE erc20_contract, account, erc20_activity, evm_smart_contract, evm_event, evm_transaction RESTART IDENTITY CASCADE' - ); - await EVMSmartContract.query().insert([ - this.evmSmartContract, - this.evmSmartContract2, - ]); - await EVMTransaction.query().insert(this.evmTx); - await EvmEvent.query().insert(this.evmEvent); - } - - @AfterAll() - async tearDown() { - await this.broker.stop(); - } - - @Test('test getErc20Activities') - async testGetErc20Activities() { - const fromAccount = Account.fromJson({ - id: 123, - address: 'fgsdgfdfgdfgfsdg', - balances: [], - spendable_balances: [], - type: '', - pubkey: '', - account_number: 1, - sequence: 234, - evm_address: '0x124537cfdxvfsdfgv', - }); - const toAccount = Account.fromJson({ - id: 345, - address: 'xczfsdfsfsdg', - balances: [], - spendable_balances: [], - type: '', - pubkey: '', - account_number: 2, - sequence: 432, - evm_address: '0xghgfhfghfg', - }); - await Account.query().insert([fromAccount, toAccount]); - const erc20Contracts = [ - { - ...Erc20Contract.fromJson({ - id: 444, - evm_smart_contract_id: this.evmSmartContract.id, - total_supply: 123456, - symbol: 'PHO', - address: this.evmSmartContract.address, - decimal: 'fggdfgdgdg', - name: 'Phong', - track: true, - last_updated_height: 200, - }), - activities: [ - { - ...Erc20Activity.fromJson({ - id: 44444, - evm_event_id: this.evmEvent.id, - sender: 'fdgdfgdf', - action: 'transfer', - erc20_contract_address: this.evmSmartContract.address, - amount: '4543', - from: fromAccount.evm_address, - to: toAccount.evm_address, - height: 400, - tx_hash: 'dfghdfhdfhgdf', - evm_tx_id: this.evmTx.id, - }), - }, - { - ...Erc20Activity.fromJson({ - id: 1234, - evm_event_id: this.evmEvent.id, - sender: 'vgcxbvb', - action: 'transfer', - erc20_contract_address: this.evmSmartContract.address, - amount: '666666', - from: fromAccount.evm_address, - to: toAccount.evm_address, - height: 401, - tx_hash: 'dfghdfhdfhgdf', - evm_tx_id: this.evmTx.id, - }), - }, - ], - }, - { - ...Erc20Contract.fromJson({ - id: 445, - evm_smart_contract_id: this.evmSmartContract2.id, - total_supply: 15555, - symbol: 'ABC', - address: this.evmSmartContract2.address, - decimal: 'vbvbgdfg', - name: 'Abc', - track: false, - last_updated_height: 200, - }), - activities: [ - { - ...Erc20Activity.fromJson({ - id: 4444211, - evm_event_id: this.evmEvent.id, - sender: 'fdgdfgdf', - action: 'transfer', - erc20_contract_address: this.evmSmartContract2.address, - amount: '4543', - from: fromAccount.evm_address, - to: toAccount.evm_address, - height: 400, - tx_hash: 'dfghdfhdfhgdf', - evm_tx_id: this.evmTx.id, - }), - }, - ], - }, - ]; - await Erc20Contract.query().insertGraph(erc20Contracts); - const result = await this.erc20Service.getErc20Activities(0, 10000000); - expect(result.length).toEqual(2); - expect(result[0]).toMatchObject(erc20Contracts[0].activities[1]); - expect(result[0].from_account_id).toEqual(fromAccount.id); - expect(result[0].to_account_id).toEqual(toAccount.id); - expect(result[1]).toMatchObject(erc20Contracts[0].activities[0]); - expect(result[1].from_account_id).toEqual(fromAccount.id); - expect(result[1].to_account_id).toEqual(toAccount.id); - } -} diff --git a/test/unit/services/erc20/erc20_handler.spec.ts b/test/unit/services/erc20/erc20_handler.spec.ts deleted file mode 100644 index f1119a634..000000000 --- a/test/unit/services/erc20/erc20_handler.spec.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { fromBase64 } from '@cosmjs/encoding'; -import { AfterAll, BeforeAll, Describe, Test } from '@jest-decorated/core'; -import { ServiceBroker } from 'moleculer'; -import { decodeAbiParameters, toHex } from 'viem'; -import { Dictionary } from 'lodash'; -import { Erc20Activity, EvmEvent } from '../../../../src/models'; -import { - ABI_APPROVAL_PARAMS, - ABI_TRANSFER_PARAMS, - ERC20_ACTION, - Erc20Handler, -} from '../../../../src/services/evm/erc20_handler'; -import { AccountBalance } from '../../../../src/models/account_balance'; - -@Describe('Test erc20 handler') -export default class Erc20HandlerTest { - broker = new ServiceBroker({ logger: false }); - - @BeforeAll() - async initSuite() { - await this.broker.start(); - } - - @AfterAll() - async tearDown() { - await this.broker.stop(); - } - - @Test('test build erc20 transfer activity') - async testBuildErc20TransferActivity() { - const evmEvent = { - id: 872436, - tx_id: 9377483, - evm_tx_id: 6789103, - address: '0xf4dcd1ba7a2d862077a12918b9cf1889568b1fc5', - topic0: - '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', - topic1: - '0x00000000000000000000000089413d5a8601622a03fd63f8aab595a12e65b9c0', - topic2: - '0x0000000000000000000000004b919d8175dba25dbf733e7dcf9241ea7e51943b', - topic3: null, - block_height: 22024821, - tx_hash: - '0x1d646b55ef69dc9cf5e6b025b783c947f36d51c9b4e164895bbfe9e2af8b6e22', - tx_index: 0, - block_hash: - '0x6daa455dda31eb9e09000087bee9540bee9622842d5a423baf82da5b7b534a38', - data: fromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADVdLhb6wpEI='), - sender: 'evmos1fwgemqt4mw39m0mn8e7ulyjpafl9r9pmzyv3hv', - }; - const [from, to, amount] = decodeAbiParameters( - [ - ABI_TRANSFER_PARAMS.FROM, - ABI_TRANSFER_PARAMS.TO, - ABI_TRANSFER_PARAMS.VALUE, - ], - (evmEvent.topic1 + - evmEvent.topic2.slice(2) + - toHex(evmEvent.data).slice(2)) as `0x${string}` - ) as [string, string, bigint]; - const result = Erc20Handler.buildTransferActivity( - EvmEvent.fromJson(evmEvent), - this.broker.logger - ); - expect(result).toMatchObject({ - evm_event_id: evmEvent.id, - sender: evmEvent.sender, - action: ERC20_ACTION.TRANSFER, - erc20_contract_address: evmEvent.address, - amount: amount.toString(), - from: from.toLowerCase(), - to: to.toLowerCase(), - height: evmEvent.block_height, - tx_hash: evmEvent.tx_hash, - evm_tx_id: evmEvent.evm_tx_id, - }); - } - - @Test('test build erc20 approval activity') - async testBuildErc20ApprovalActivity() { - const evmEvent = { - id: 881548, - tx_id: 9381778, - evm_tx_id: 6793335, - address: '0xf4dcd1ba7a2d862077a12918b9cf1889568b1fc5', - topic0: - '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925', - topic1: - '0x000000000000000000000000e57c921f5f3f3a2aade9a462dab70b0cb97ded4d', - topic2: - '0x000000000000000000000000cbd61600b891a738150e68d5a58646321189cf6f', - topic3: null, - block_height: 22033598, - tx_hash: - '0x89dd0093c3c7633276c20be92fd5838f1eca99314a0c6375e9050e5cc82b51c3', - tx_index: 1, - block_hash: - '0x692c859d6254ef6c27fc7accf1131d55351c62a1357fe261d8517e3144cfbebe', - data: fromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='), - sender: 'evmos1u47fy86l8uaz4t0f533d4dctpjuhmm2dh3ezg0', - }; - const result = Erc20Handler.buildApprovalActivity( - EvmEvent.fromJson(evmEvent), - this.broker.logger - ); - const [from, to, amount] = decodeAbiParameters( - [ - ABI_APPROVAL_PARAMS.OWNER, - ABI_APPROVAL_PARAMS.SPENDER, - ABI_APPROVAL_PARAMS.VALUE, - ], - (evmEvent.topic1 + - evmEvent.topic2.slice(2) + - toHex(evmEvent.data).slice(2)) as `0x${string}` - ) as [string, string, bigint]; - expect(result).toMatchObject({ - evm_event_id: evmEvent.id, - sender: evmEvent.sender, - action: ERC20_ACTION.APPROVAL, - erc20_contract_address: evmEvent.address, - amount: amount.toString(), - from: from.toLowerCase(), - to: to.toLowerCase(), - height: evmEvent.block_height, - tx_hash: evmEvent.tx_hash, - evm_tx_id: evmEvent.evm_tx_id, - }); - } - - @Test('test handlerErc20Transfer') - async testHandlerErc20Transfer() { - const erc20Activity = Erc20Activity.fromJson({ - evm_event_id: 1, - sender: 'dafjfjj', - action: ERC20_ACTION.TRANSFER, - erc20_contract_address: 'hsdbjbfbdsfc', - amount: '12345222', - from: 'phamphong1', - to: 'phamphong2', - height: 10000, - tx_hash: 'fghkjghfdkjgbvkdfngkjdf', - evm_tx_id: 1, - from_account_id: 123, - to_account_id: 234, - }); - const [fromKey, toKey] = [ - `${erc20Activity.from_account_id}_${erc20Activity.erc20_contract_address}`, - `${erc20Activity.to_account_id}_${erc20Activity.erc20_contract_address}`, - ]; - const accountBalances: Dictionary = { - [fromKey]: AccountBalance.fromJson({ - denom: erc20Activity.erc20_contract_address, - amount: '998222', - last_updated_height: 1, - }), - [toKey]: AccountBalance.fromJson({ - denom: erc20Activity.erc20_contract_address, - amount: '1111111', - last_updated_height: 1, - }), - }; - const erc20Handler = new Erc20Handler(accountBalances, []); - erc20Handler.handlerErc20Transfer(erc20Activity); - expect(erc20Handler.accountBalances[fromKey]).toMatchObject({ - denom: erc20Activity.erc20_contract_address, - amount: '-11347000', - }); - expect(erc20Handler.accountBalances[toKey]).toMatchObject({ - denom: erc20Activity.erc20_contract_address, - amount: '13456333', - }); - } -} diff --git a/test/unit/services/evm/erc20.spec.ts b/test/unit/services/evm/erc20.spec.ts new file mode 100644 index 000000000..71ff28b6f --- /dev/null +++ b/test/unit/services/evm/erc20.spec.ts @@ -0,0 +1,339 @@ +import { + AfterAll, + BeforeAll, + BeforeEach, + Describe, + Test, +} from '@jest-decorated/core'; +import { ServiceBroker } from 'moleculer'; +import _ from 'lodash'; +import knex from '../../../../src/common/utils/db_connection'; +import { + Account, + AccountBalance, + BlockCheckpoint, + EVMSmartContract, + EVMTransaction, + Erc20Activity, + Erc20Contract, + EvmEvent, +} from '../../../../src/models'; +import Erc20Service from '../../../../src/services/evm/erc20.service'; +import { BULL_JOB_NAME } from '../../../../src/services/evm/constant'; +import { SERVICE } from '../../../../src/common/constant'; +import config from '../../../../config.json' assert { type: 'json' }; +import { convertEthAddressToBech32Address } from '../../../../src/services/evm/utils'; + +async function cleanQueue(erc20Service: Erc20Service) { + await erc20Service + .getQueueManager() + .getQueue(BULL_JOB_NAME.HANDLE_ERC20_CONTRACT) + .drain(true); + await erc20Service + .getQueueManager() + .getQueue(BULL_JOB_NAME.HANDLE_ERC20_ACTIVITY) + .drain(true); + await erc20Service + .getQueueManager() + .getQueue(BULL_JOB_NAME.HANDLE_ERC20_BALANCE) + .drain(true); +} +@Describe('Test erc20 handle balance job') +export class Erc20Test { + broker = new ServiceBroker({ logger: false }); + + erc20Service = this.broker.createService(Erc20Service) as Erc20Service; + + evmSmartContract = EVMSmartContract.fromJson({ + id: 1, + address: '0xE974cC14c93FC6077B0d65F98832B846C5454A0B', + creator: '0x5606b4eA93F696Dd82Ca983BAF5723d00729f127', + created_height: 100, + created_hash: + '0x8c46cf6373f2f6e528b56becf0ce6b460b5d90cf9b0325a136b9b3a820e1e489', + type: EVMSmartContract.TYPES.ERC20, + code_hash: '0xdsf3335453454tsgfsdrtserf43645y4h4tAAvfgfgds', + }); + + evmSmartContract2 = EVMSmartContract.fromJson({ + id: 2, + address: '0x3CB367e7C920Ff15879Bd4CBd708b8c60eB0f537', + creator: '0xa9497CC4F95773A744D408b54dAC724626ee31d2', + created_height: 100, + created_hash: + '0x5bca9ee42c4c32941c58f2a510dae5ff5c6ed848d9a396a8e9e146a166b3a3fc', + type: EVMSmartContract.TYPES.PROXY_EIP_1967, + code_hash: '0xdfskjgdsgfgweruwie4535t3tu34tjkewtgjwe', + }); + + evmTx = EVMTransaction.fromJson({ + id: 11111, + hash: '', + height: 111, + tx_msg_id: 222, + tx_id: 223, + contract_address: '', + index: 1, + }); + + evmEvent = EvmEvent.fromJson({ + id: 888, + tx_id: 1234, + evm_tx_id: this.evmTx.id, + tx_hash: '', + address: '', + block_height: 1, + block_hash: '', + tx_index: 1, + }); + + erc20Contracts = [ + Erc20Contract.fromJson({ + id: 444, + evm_smart_contract_id: this.evmSmartContract.id, + total_supply: 123456, + symbol: 'PHO', + address: this.evmSmartContract.address, + decimal: 'fggdfgdgdg', + name: 'Phong', + track: true, + last_updated_height: 200, + }), + Erc20Contract.fromJson({ + id: 445, + evm_smart_contract_id: this.evmSmartContract2.id, + total_supply: 15555, + symbol: 'ABC', + address: this.evmSmartContract2.address, + decimal: 'vbvbgdfg', + name: 'Abc', + track: false, + last_updated_height: 200, + }), + ]; + + account1 = Account.fromJson({ + id: 1, + address: 'aura1w9vxuke5dz6hyza2j932qgmxltnfxwl7gdfgdg', + balances: [], + spendable_balances: [], + type: '', + pubkey: '', + account_number: 1, + sequence: 234, + evm_address: '0xDF587daaC47ae7B5586E34bCdb23d0b900b18a6C', + }); + + account2 = Account.fromJson({ + id: 2, + address: 'aura1w9vxuke5dz6hyza2j932qgmxltnfxwl7qlx4mg', + balances: [], + spendable_balances: [], + type: '', + pubkey: '', + account_number: 2, + sequence: 432, + evm_address: '0x71586E5B3468B5720BAa9162A02366Fae6933BfE', + }); + + account3 = Account.fromJson({ + id: 3, + address: 'aura888vxuke5dz6hyza2j932qgmxltnfxwl7sfsfs', + balances: [], + spendable_balances: [], + type: '', + pubkey: '', + account_number: 3, + sequence: 432, + evm_address: '0x7c756Cba10Ff2C65016494E8BA37C12a108572b5', + }); + + blockCheckpoints = [ + BlockCheckpoint.fromJson({ + job_name: BULL_JOB_NAME.HANDLE_ERC20_BALANCE, + height: 100, + }), + BlockCheckpoint.fromJson({ + job_name: BULL_JOB_NAME.HANDLE_ERC20_ACTIVITY, + height: 1000, + }), + ]; + + @BeforeAll() + async initSuite() { + await cleanQueue(this.erc20Service); + await this.erc20Service.getQueueManager().stopAll(); + await this.broker.start(); + await knex.raw( + 'TRUNCATE TABLE erc20_contract, account, erc20_activity, evm_smart_contract, evm_event, evm_transaction, account_balance, block_checkpoint RESTART IDENTITY CASCADE' + ); + await EVMSmartContract.query().insert([ + this.evmSmartContract, + this.evmSmartContract2, + ]); + await EVMTransaction.query().insert(this.evmTx); + await EvmEvent.query().insert(this.evmEvent); + await Erc20Contract.query().insert(this.erc20Contracts); + } + + @BeforeEach() + async beforeEach() { + await knex.raw( + 'TRUNCATE TABLE erc20_activity, block_checkpoint, account RESTART IDENTITY CASCADE' + ); + await BlockCheckpoint.query().insert(this.blockCheckpoints); + await Account.query().insert([this.account1, this.account2, this.account3]); + } + + @AfterAll() + async tearDown() { + await cleanQueue(this.erc20Service); + await this.broker.stop(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + } + + @Test('handle erc20 balance success') + async testHandleErc20Balance() { + const erc20Activities = [ + { + id: 1, + evm_event_id: this.evmEvent.id, + sender: '0xDF587daaC47ae7B5586E34bCdb23d0b900b18a6C', + action: 'transfer', + erc20_contract_address: this.evmSmartContract.address, + amount: '4543', + from: this.account1.evm_address, + to: this.account2.evm_address, + height: this.blockCheckpoints[0].height + 1, + tx_hash: + '0x21f905f14a26c5b35e43e1bfbcf8ff395e453d6497a0ce0a0c3cd7814ec0ba03', + evm_tx_id: this.evmTx.id, + }, + { + id: 2, + evm_event_id: this.evmEvent.id, + sender: '0xDF587daaC47ae7B5586E34bCdb23d0b900b18a6C', + action: 'transfer', + erc20_contract_address: this.evmSmartContract.address, + amount: '2211', + from: this.account2.evm_address, + to: this.account3.evm_address, + height: this.blockCheckpoints[0].height + 1, + tx_hash: + '0x21f905f14a26c5b35e43e1bfbcf8ff395e453d6497a0ce0a0c3cd7814ec0ba03', + evm_tx_id: this.evmTx.id, + }, + ]; + await Erc20Activity.query().insert(erc20Activities); + await this.erc20Service.handleErc20Balance(); + const accountBalances = _.keyBy( + await AccountBalance.query(), + (o) => `${o.account_id}_${o.denom}` + ); + expect( + accountBalances[`${this.account1.id}_${this.evmSmartContract.address}`] + .amount + ).toEqual(`-${BigInt(erc20Activities[0].amount).toString()}`); + expect( + accountBalances[`${this.account2.id}_${this.evmSmartContract.address}`] + .amount + ).toEqual( + `${( + BigInt(erc20Activities[0].amount) - BigInt(erc20Activities[1].amount) + ).toString()}` + ); + expect( + accountBalances[`${this.account3.id}_${this.evmSmartContract.address}`] + .amount + ).toEqual(`${BigInt(erc20Activities[1].amount).toString()}`); + } + + @Test('handle erc20 balance with missing account') + async testHandleErc20BalanceWithMissingAccount() { + const missingAccountId = 5; + const missingAccountAddr = '0x5606b4eA93F696Dd82Ca983BAF5723d00729f127'; + const erc20Activities = [ + { + id: 1, + evm_event_id: this.evmEvent.id, + sender: '0xDF587daaC47ae7B5586E34bCdb23d0b900b18a6C', + action: 'transfer', + erc20_contract_address: this.evmSmartContract.address, + amount: '4543', + from: this.account1.evm_address, + to: this.account2.evm_address, + height: this.blockCheckpoints[0].height + 1, + tx_hash: + '0x21f905f14a26c5b35e43e1bfbcf8ff395e453d6497a0ce0a0c3cd7814ec0ba03', + evm_tx_id: this.evmTx.id, + }, + { + id: 2, + evm_event_id: this.evmEvent.id, + sender: '0xDF587daaC47ae7B5586E34bCdb23d0b900b18a6C', + action: 'transfer', + erc20_contract_address: this.evmSmartContract.address, + amount: '2211', + from: missingAccountAddr, + to: this.account3.evm_address, + height: this.blockCheckpoints[0].height + 1, + tx_hash: + '0x21f905f14a26c5b35e43e1bfbcf8ff395e453d6497a0ce0a0c3cd7814ec0ba03', + evm_tx_id: this.evmTx.id, + }, + ]; + await Erc20Activity.query().insert(erc20Activities); + const mockCall = jest + .spyOn(this.erc20Service.broker, 'call') + .mockImplementation(async () => + Promise.resolve( + Account.query().insert( + Account.fromJson({ + id: missingAccountId, + address: 'aura12crtf65n76tdmqk2nqa674er6qrjnuf8h89p22', + balances: [], + spendable_balances: [], + type: '', + pubkey: '', + account_number: 4, + sequence: 2, + evm_address: missingAccountAddr, + }) + ) + ) + ); + await this.erc20Service.handleErc20Balance(); + const accountBalances = _.keyBy( + await AccountBalance.query(), + (o) => `${o.account_id}_${o.denom}` + ); + expect(mockCall).toHaveBeenCalledWith( + SERVICE.V1.HandleAddressService.CrawlNewAccountApi.path, + { + addresses: [ + convertEthAddressToBech32Address( + config.networkPrefixAddress, + missingAccountAddr + ), + ], + } + ); + expect( + accountBalances[`${this.account1.id}_${this.evmSmartContract.address}`] + .amount + ).toEqual(`-${BigInt(erc20Activities[0].amount).toString()}`); + expect( + accountBalances[`${this.account2.id}_${this.evmSmartContract.address}`] + .amount + ).toEqual(`${BigInt(erc20Activities[0].amount).toString()}`); + expect( + accountBalances[`${missingAccountId}_${this.evmSmartContract.address}`] + .amount + ).toEqual(`-${BigInt(erc20Activities[1].amount).toString()}`); + expect( + accountBalances[`${this.account3.id}_${this.evmSmartContract.address}`] + .amount + ).toEqual(`${BigInt(erc20Activities[1].amount).toString()}`); + } +} diff --git a/test/unit/services/evm/erc20_handler.spec.ts b/test/unit/services/evm/erc20_handler.spec.ts new file mode 100644 index 000000000..ba2e248f3 --- /dev/null +++ b/test/unit/services/evm/erc20_handler.spec.ts @@ -0,0 +1,894 @@ +import { fromBase64 } from '@cosmjs/encoding'; +import { + AfterAll, + BeforeAll, + BeforeEach, + Describe, + Test, +} from '@jest-decorated/core'; +import { Dictionary } from 'lodash'; +import { ServiceBroker } from 'moleculer'; +import { decodeAbiParameters, encodeAbiParameters, fromHex, toHex } from 'viem'; +import config from '../../../../config.json' assert { type: 'json' }; +import knex from '../../../../src/common/utils/db_connection'; +import { + Block, + Erc20Activity, + Erc20Contract, + Event, + EventAttribute, + EvmEvent, + EVMSmartContract, + EVMTransaction, + Transaction, +} from '../../../../src/models'; +import { AccountBalance } from '../../../../src/models/account_balance'; +import { ZERO_ADDRESS } from '../../../../src/services/evm/constant'; +import { + ABI_APPROVAL_PARAMS, + ABI_TRANSFER_PARAMS, + ERC20_ACTION, + Erc20Handler, +} from '../../../../src/services/evm/erc20_handler'; + +const evmTransaction = EVMTransaction.fromJson({ + id: 2931, + hash: '0xf15467ec2a25eeef95798d93c2fe9ed8e7c891578b8e1bcc3284105849656c9d', + height: 1, + tx_id: 1612438, + tx_msg_id: 4752908, + contract_address: null, + index: 0, +}); +const evmSmartContract = EVMSmartContract.fromJson({ + id: 1, + address: '0xde47a655a5d9904bd3f7e1a536d8323fbd99993a', + creator: '0x5606b4eA93F696Dd82Ca983BAF5723d00729f127', + created_height: 100, + created_hash: + '0x8c46cf6373f2f6e528b56becf0ce6b460b5d90cf9b0325a136b9b3a820e1e489', + type: EVMSmartContract.TYPES.ERC20, + code_hash: '0xdsf3335453454tsgfsdrtserf43645y4h4tAAvfgfgds', +}); +const erc20Contract = Erc20Contract.fromJson({ + id: 10, + address: evmSmartContract.address, + decimal: '18', + name: 'Wrapped Aura', + symbol: 'WAURA', + total_supply: '0', + track: true, + evm_smart_contract_id: evmSmartContract.id, +}); +const wrapSmartContract = EVMSmartContract.fromJson({ + id: 2, + address: config.erc20.wrapExtensionContract[0], + creator: '0x5606b4eA93F696Dd82Ca983BAF5723d00729f127', + created_height: 100, + created_hash: + '0x8c46cf6373f2f6e528b56becf0ce6b460b5d90cf9b0325a136b9b3a820e1e489', + type: EVMSmartContract.TYPES.ERC20, + code_hash: '0xdsf3335453454tsgfsdrtserf43645y4h4tAAvfgfgds', +}); +const erc20WrapContract = Erc20Contract.fromJson({ + id: 20, + address: wrapSmartContract.address, + decimal: '18', + name: 'Wrapped Aura', + symbol: 'WAURA', + total_supply: '0', + track: true, + evm_smart_contract_id: wrapSmartContract.id, +}); +const erc20ModuleAccount = 'aura1glht96kr2rseywuvhhay894qw7ekuc4q3jyctl'; +@Describe('Test erc20 handler') +export default class Erc20HandlerTest { + broker = new ServiceBroker({ logger: false }); + + @BeforeAll() + async initSuite() { + await this.broker.start(); + await knex.raw( + 'TRUNCATE TABLE evm_transaction, evm_smart_contract, erc20_contract RESTART IDENTITY CASCADE' + ); + await EVMTransaction.query().insert(evmTransaction); + await EVMSmartContract.query().insert([ + evmSmartContract, + wrapSmartContract, + ]); + await Erc20Contract.query().insert([erc20Contract, erc20WrapContract]); + } + + @AfterAll() + async tearDown() { + await this.broker.stop(); + } + + @BeforeEach() + async beforeEach() { + await knex.raw( + 'TRUNCATE TABLE evm_event, transaction, event, event_attribute, block RESTART IDENTITY CASCADE' + ); + } + + @Test('test build erc20 activities (transfer, approval)') + async testBuildErc20Activities() { + const blockHeight = 18983870; + const from = '0x400207c680a1c5d5a86f35f97111afc00f2f1826'; + const to = '0xea780c13a5450ac7c3e6ae4b17a0445998132b15'; + const amount = '45222000'; + const evmEvents = [ + // transfer event + EvmEvent.fromJson({ + id: 1, + address: erc20Contract.address, + block_hash: + '0xed6a2d3c3ac9a2868420c4fdd67240d2d96298fc4272cd31455cd0cdaabf9093', + block_height: blockHeight, + data: fromHex( + encodeAbiParameters([ABI_TRANSFER_PARAMS.VALUE], [amount]), + 'bytes' + ), + evm_tx_id: evmTransaction.id, + topic0: + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + topic1: encodeAbiParameters([ABI_TRANSFER_PARAMS.FROM], [from]), + topic2: encodeAbiParameters([ABI_TRANSFER_PARAMS.TO], [to]), + tx_hash: + '0x8a82a0c8848487d716f10a91f0aefb0526d35bd0f489166cc5141718a4d8aa64', + topic3: null, + tx_id: evmTransaction.id, + tx_index: 0, + }), + // approval event + EvmEvent.fromJson({ + id: 2, + address: erc20Contract.address, + block_hash: + '0x3ad4778e1b3a03f2c4cfe51d3dd6e75ccea2cb5081cb5eab2f83f60a6960d353', + block_height: blockHeight, + data: fromHex( + encodeAbiParameters([ABI_TRANSFER_PARAMS.VALUE], [amount]), + 'bytes' + ), + evm_tx_id: evmTransaction.id, + topic0: + '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925', + topic1: encodeAbiParameters([ABI_TRANSFER_PARAMS.FROM], [from]), + topic2: encodeAbiParameters([ABI_TRANSFER_PARAMS.TO], [to]), + tx_hash: + '0xfc1219232e1dfd380464d0ee843e0ee5b7a587521f2e02cb9957a74f7c57b05c', + topic3: null, + tx_id: evmTransaction.id, + tx_index: 0, + }), + // deposit event but not wrap + EvmEvent.fromJson({ + id: 3, + address: erc20Contract.address, + block_hash: + '0xed6a2d3c3ac9a2868420c4fdd67240d2d96298fc4272cd31455cd0cdaabf9093', + block_height: blockHeight, + data: fromHex( + encodeAbiParameters([ABI_TRANSFER_PARAMS.VALUE], [amount]), + 'bytes' + ), + evm_tx_id: evmTransaction.id, + topic0: + '0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c', + topic1: encodeAbiParameters([ABI_TRANSFER_PARAMS.TO], [to]), + tx_hash: + '0x8a82a0c8848487d716f10a91f0aefb0526d35bd0f489166cc5141718a4d8aa64', + topic2: null, + topic3: null, + tx_id: evmTransaction.id, + tx_index: 0, + }), + ]; + await EvmEvent.query().insert(evmEvents); + await knex.transaction(async (trx) => { + const erc20Activitites = await Erc20Handler.buildErc20Activities( + blockHeight - 1, + blockHeight, + trx, + this.broker.logger + ); + expect(erc20Activitites.length).toEqual(evmEvents.length - 1); + // test build transfer activity + const transferActivity = erc20Activitites[0]; + expect(transferActivity).toMatchObject({ + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: erc20Contract.address, + from, + to, + amount, + }); + // test build approve activity + const approvalActivity = erc20Activitites[1]; + expect(approvalActivity).toMatchObject({ + action: ERC20_ACTION.APPROVAL, + erc20_contract_address: erc20Contract.address, + from, + to, + amount, + }); + }); + } + + @Test('test build wrap erc20 activities (deposit, withdrawl)') + async testBuildWrapErc20Activities() { + const blockHeight = 18983870; + const from = '0x400207c680a1c5d5a86f35f97111afc00f2f1826'; + const to = '0xea780c13a5450ac7c3e6ae4b17a0445998132b15'; + const amount = '45222000'; + const evmEvents = [ + // deposit event + EvmEvent.fromJson({ + id: 1, + address: erc20WrapContract.address, + block_hash: + '0xed6a2d3c3ac9a2868420c4fdd67240d2d96298fc4272cd31455cd0cdaabf9093', + block_height: blockHeight, + data: fromHex( + encodeAbiParameters([ABI_TRANSFER_PARAMS.VALUE], [amount]), + 'bytes' + ), + evm_tx_id: evmTransaction.id, + topic0: + '0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c', + topic1: encodeAbiParameters([ABI_TRANSFER_PARAMS.TO], [to]), + tx_hash: + '0x8a82a0c8848487d716f10a91f0aefb0526d35bd0f489166cc5141718a4d8aa64', + topic2: null, + topic3: null, + tx_id: evmTransaction.id, + tx_index: 0, + }), + // withdrawal event + EvmEvent.fromJson({ + id: 2, + address: erc20WrapContract.address, + block_hash: + '0xed6a2d3c3ac9a2868420c4fdd67240d2d96298fc4272cd31455cd0cdaabf9093', + block_height: blockHeight, + data: fromHex( + encodeAbiParameters([ABI_TRANSFER_PARAMS.VALUE], [amount]), + 'bytes' + ), + evm_tx_id: evmTransaction.id, + topic0: + '0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65', + topic1: encodeAbiParameters([ABI_TRANSFER_PARAMS.FROM], [from]), + tx_hash: + '0x8a82a0c8848487d716f10a91f0aefb0526d35bd0f489166cc5141718a4d8aa64', + topic2: null, + topic3: null, + tx_id: evmTransaction.id, + tx_index: 0, + }), + ]; + await EvmEvent.query().insert(evmEvents); + await knex.transaction(async (trx) => { + const erc20Activitites = await Erc20Handler.buildErc20Activities( + blockHeight - 1, + blockHeight, + trx, + this.broker.logger + ); + expect(erc20Activitites.length).toEqual(evmEvents.length); + // test build deposit activity + const depositActivity = erc20Activitites[0]; + expect(depositActivity).toMatchObject({ + action: ERC20_ACTION.DEPOSIT, + erc20_contract_address: erc20WrapContract.address, + from: ZERO_ADDRESS, + to, + amount, + }); + // test build withdrawal activity + const withdrawalActivity = erc20Activitites[1]; + expect(withdrawalActivity).toMatchObject({ + action: ERC20_ACTION.WITHDRAWAL, + erc20_contract_address: erc20WrapContract.address, + from, + to: ZERO_ADDRESS, + amount, + }); + }); + } + + @Test('test build erc20 activities from cosmos (convertCoin, convertErc20)') + async testBuildConvertCoinErc20Activity() { + const blockHeight = 100; + const from = '0x400207c680a1c5d5a86f35f97111afc00f2f1826'; + const to = '0xea780c13a5450ac7c3e6ae4b17a0445998132b15'; + const amount = '45222000'; + const block = Block.fromJson({ + data: JSON.stringify({ + linkS3: 'https://nft.aurascan.io/rawlog/aura/aura_6322-2/block/7424149', + }), + tx_count: 2, + hash: '152A5BDEE0768D2BAB1E65C726C4B94ACC28FF792E202F4676EFC888B3E22A79', + height: blockHeight, + proposer_address: '39A5D22101441C1B1D93C4F3B72A64681D59B2A0', + time: '2024-07-26T01:00:21.319351+07:00', + }); + const transaction = Transaction.fromJson({ + code: 0, + codespace: '', + data: { + linkS3: + 'https://nft.aurascan.io/rawlog/aura/auradev_1236-2/transaction/20534362/1406F9DDCE529F0E6EB32E07A88E5BC4EE220D3A2AB6D57E89DD12EB1945CC19', + }, + fee: JSON.stringify([ + { + denom: 'uaura', + amount: '6141', + }, + ]), + gas_limit: '2456353', + gas_used: '1775234', + gas_wanted: '2456353', + hash: '1406F9DDCE529F0E6EB32E07A88E5BC4EE220D3A2AB6D57E89DD12EB1945CC19', + height: blockHeight, + id: evmTransaction.id, + index: 0, + memo: 'memo', + timestamp: '2024-07-15T17:08:43.386+07:00', + events: [ + { + id: 3, + tx_msg_index: 0, + type: Event.EVENT_TYPE.CONVERT_COIN, + source: 'TX_EVENT', + block_height: blockHeight, + attributes: [ + { + block_height: blockHeight, + index: 4, + key: EventAttribute.ATTRIBUTE_KEY.ERC20_TOKEN, + tx_id: 505671, + value: erc20Contract.address, + event_id: '1', + }, + { + block_height: blockHeight, + index: 0, + key: EventAttribute.ATTRIBUTE_KEY.SENDER, + tx_id: 505671, + value: erc20ModuleAccount, + event_id: '1', + }, + { + block_height: blockHeight, + index: 1, + key: EventAttribute.ATTRIBUTE_KEY.RECEIVER, + tx_id: 505671, + value: to, + event_id: '1', + }, + { + block_height: blockHeight, + index: 2, + key: EventAttribute.ATTRIBUTE_KEY.AMOUNT, + tx_id: 505671, + value: amount, + event_id: '1', + }, + ], + }, + { + id: 2, + tx_msg_index: 1, + type: Event.EVENT_TYPE.CONVERT_ERC20, + source: 'TX_EVENT', + block_height: blockHeight, + attributes: [ + { + block_height: blockHeight, + index: 0, + key: 'sender', + tx_id: 505657, + value: from, + event_id: '1', + }, + { + block_height: blockHeight, + index: 1, + key: 'receiver', + tx_id: 505657, + value: erc20ModuleAccount, + event_id: '1', + }, + { + block_height: blockHeight, + index: 2, + key: 'amount', + tx_id: 505657, + value: amount, + event_id: '1', + }, + { + block_height: blockHeight, + index: 4, + key: 'erc20_token', + tx_id: 505657, + value: erc20Contract.address, + event_id: '1', + }, + ], + }, + ], + }); + await Block.query().insert(block); + await Transaction.query().insertGraph(transaction); + const evmEvents = [ + // transfer event + EvmEvent.fromJson({ + id: 1, + address: erc20Contract.address, + block_hash: + '0xed6a2d3c3ac9a2868420c4fdd67240d2d96298fc4272cd31455cd0cdaabf9093', + block_height: blockHeight - 1, + data: fromHex( + encodeAbiParameters([ABI_TRANSFER_PARAMS.VALUE], [amount]), + 'bytes' + ), + evm_tx_id: evmTransaction.id, + topic0: + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + topic1: encodeAbiParameters([ABI_TRANSFER_PARAMS.FROM], [from]), + topic2: encodeAbiParameters([ABI_TRANSFER_PARAMS.TO], [to]), + tx_hash: + '0x8a82a0c8848487d716f10a91f0aefb0526d35bd0f489166cc5141718a4d8aa64', + topic3: null, + tx_id: evmTransaction.id - 1, + tx_index: 0, + }), + // transfer event + EvmEvent.fromJson({ + id: 2, + address: erc20Contract.address, + block_hash: + '0xed6a2d3c3ac9a2868420c4fdd67240d2d96298fc4272cd31455cd0cdaabf9093', + block_height: blockHeight + 1, + data: fromHex( + encodeAbiParameters([ABI_TRANSFER_PARAMS.VALUE], [amount]), + 'bytes' + ), + evm_tx_id: evmTransaction.id, + topic0: + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + topic1: encodeAbiParameters([ABI_TRANSFER_PARAMS.FROM], [from]), + topic2: encodeAbiParameters([ABI_TRANSFER_PARAMS.TO], [to]), + tx_hash: + '0x8a82a0c8848487d716f10a91f0aefb0526d35bd0f489166cc5141718a4d8aa64', + topic3: null, + tx_id: evmTransaction.id + 1, + tx_index: 0, + }), + ]; + await EvmEvent.query().insert(evmEvents); + await knex.transaction(async (trx) => { + const erc20Activitites = await Erc20Handler.buildErc20Activities( + blockHeight - 2, + blockHeight + 1, + trx, + this.broker.logger + ); + // test convert coin activity + const convertCoinActivity = erc20Activitites[2]; + expect(convertCoinActivity).toMatchObject({ + from: ZERO_ADDRESS, + to, + amount, + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: erc20Contract.address, + cosmos_event_id: transaction.events[0].id, + }); + // test convert erc20 activity + const convertErc20Activity = erc20Activitites[1]; + expect(convertErc20Activity).toMatchObject({ + from, + to: ZERO_ADDRESS, + amount, + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: erc20Contract.address, + cosmos_event_id: transaction.events[1].id, + }); + // test sort order + const transferActivity1 = erc20Activitites[0]; + expect(transferActivity1).toMatchObject({ + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: erc20Contract.address, + from, + to, + amount, + evm_tx_id: evmEvents[0].evm_tx_id, + cosmos_tx_id: evmEvents[0].tx_id, + }); + const transferActivity2 = erc20Activitites[3]; + expect(transferActivity2).toMatchObject({ + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: erc20Contract.address, + from, + to, + amount, + evm_tx_id: evmEvents[1].evm_tx_id, + cosmos_tx_id: evmEvents[1].tx_id, + }); + }); + } + + @Test('test build erc20 transfer activity') + async testBuildErc20TransferActivity() { + const evmEvent = { + id: 872436, + tx_id: 9377483, + evm_tx_id: 6789103, + address: '0xf4dcd1ba7a2d862077a12918b9cf1889568b1fc5', + topic0: + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + topic1: + '0x00000000000000000000000089413d5a8601622a03fd63f8aab595a12e65b9c0', + topic2: + '0x0000000000000000000000004b919d8175dba25dbf733e7dcf9241ea7e51943b', + topic3: null, + block_height: 22024821, + tx_hash: + '0x1d646b55ef69dc9cf5e6b025b783c947f36d51c9b4e164895bbfe9e2af8b6e22', + tx_index: 0, + block_hash: + '0x6daa455dda31eb9e09000087bee9540bee9622842d5a423baf82da5b7b534a38', + data: fromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADVdLhb6wpEI='), + sender: 'evmos1fwgemqt4mw39m0mn8e7ulyjpafl9r9pmzyv3hv', + }; + const [from, to, amount] = decodeAbiParameters( + [ + ABI_TRANSFER_PARAMS.FROM, + ABI_TRANSFER_PARAMS.TO, + ABI_TRANSFER_PARAMS.VALUE, + ], + (evmEvent.topic1 + + evmEvent.topic2.slice(2) + + toHex(evmEvent.data).slice(2)) as `0x${string}` + ) as [string, string, bigint]; + const result = Erc20Handler.buildTransferActivity( + EvmEvent.fromJson(evmEvent), + this.broker.logger + ); + expect(result).toMatchObject({ + evm_event_id: evmEvent.id, + sender: evmEvent.sender, + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: evmEvent.address, + amount: amount.toString(), + from: from.toLowerCase(), + to: to.toLowerCase(), + height: evmEvent.block_height, + tx_hash: evmEvent.tx_hash, + evm_tx_id: evmEvent.evm_tx_id, + }); + } + + /** + * @input evm event that be approval erc20 + * @result build erc20ApprovalActivity + */ + @Test('test build erc20 approval activity') + async testBuildErc20ApprovalActivity() { + const evmEvent = { + id: 881548, + tx_id: 9381778, + evm_tx_id: 6793335, + address: '0xf4dcd1ba7a2d862077a12918b9cf1889568b1fc5', + topic0: + '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925', + topic1: + '0x000000000000000000000000e57c921f5f3f3a2aade9a462dab70b0cb97ded4d', + topic2: + '0x000000000000000000000000cbd61600b891a738150e68d5a58646321189cf6f', + topic3: null, + block_height: 22033598, + tx_hash: + '0x89dd0093c3c7633276c20be92fd5838f1eca99314a0c6375e9050e5cc82b51c3', + tx_index: 1, + block_hash: + '0x692c859d6254ef6c27fc7accf1131d55351c62a1357fe261d8517e3144cfbebe', + data: fromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='), + sender: 'evmos1u47fy86l8uaz4t0f533d4dctpjuhmm2dh3ezg0', + }; + const result = Erc20Handler.buildApprovalActivity( + EvmEvent.fromJson(evmEvent), + this.broker.logger + ); + const [from, to, amount] = decodeAbiParameters( + [ + ABI_APPROVAL_PARAMS.OWNER, + ABI_APPROVAL_PARAMS.SPENDER, + ABI_APPROVAL_PARAMS.VALUE, + ], + (evmEvent.topic1 + + evmEvent.topic2.slice(2) + + toHex(evmEvent.data).slice(2)) as `0x${string}` + ) as [string, string, bigint]; + expect(result).toMatchObject({ + evm_event_id: evmEvent.id, + sender: evmEvent.sender, + action: ERC20_ACTION.APPROVAL, + erc20_contract_address: evmEvent.address, + amount: amount.toString(), + from: from.toLowerCase(), + to: to.toLowerCase(), + height: evmEvent.block_height, + tx_hash: evmEvent.tx_hash, + evm_tx_id: evmEvent.evm_tx_id, + }); + } + + /** + * @input erc20 transfer activity + * @result from/to account balances be updated + */ + @Test('test handlerErc20Transfer') + async testHandlerErc20Transfer() { + const fromAmount = '4424242424'; + const toAmount = '1123342'; + const totalSupply = '123654'; + const erc20Activity = Erc20Activity.fromJson({ + evm_event_id: 1, + sender: '0x7c756Cba10Ff2C65016494E8BA37C12a108572b5', + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: '0x98605ae21dd3be686337a6d7a8f156d0d8baee92', + amount: '998222', + from: '0x3E665ACfE64628774d3bA8E589Fa8683eD8706C9', + to: '0xD83E708D7FE0E769Af80d990f9241458734808Ac', + height: 10000, + tx_hash: + '0xb97228e533e3af1323d873c9c3e4c0a9b85d95ecd8e98110c8890c9453d2f077', + evm_tx_id: 1, + from_account_id: 123, + to_account_id: 234, + }); + const [fromKey, toKey] = [ + `${erc20Activity.from_account_id}_${erc20Activity.erc20_contract_address}`, + `${erc20Activity.to_account_id}_${erc20Activity.erc20_contract_address}`, + ]; + const accountBalances: Dictionary = { + [fromKey]: AccountBalance.fromJson({ + denom: erc20Activity.erc20_contract_address, + amount: fromAmount, + last_updated_height: 1, + }), + [toKey]: AccountBalance.fromJson({ + denom: erc20Activity.erc20_contract_address, + amount: toAmount, + last_updated_height: 1, + }), + }; + const erc20Contracts = { + [erc20Activity.erc20_contract_address]: Erc20Contract.fromJson({ + evm_smart_contract_id: 1, + total_supply: totalSupply, + symbol: 'ALPHA', + address: erc20Activity.erc20_contract_address, + decimal: '20', + name: 'Alpha Grand Wolf', + track: true, + last_updated_height: 1, + }), + }; + const erc20Handler = new Erc20Handler(accountBalances, [], erc20Contracts); + erc20Handler.handlerErc20Transfer(erc20Activity); + expect(erc20Handler.accountBalances[fromKey]).toMatchObject({ + denom: erc20Activity.erc20_contract_address, + amount: (BigInt(fromAmount) - BigInt(erc20Activity.amount)).toString(), + }); + expect(erc20Handler.accountBalances[toKey]).toMatchObject({ + denom: erc20Activity.erc20_contract_address, + amount: (BigInt(erc20Activity.amount) + BigInt(toAmount)).toString(), + }); + expect( + erc20Contracts[erc20Activity.erc20_contract_address].total_supply + ).toEqual(totalSupply); + } + + @Test('test handlerErc20Transfer when from is zero') + async testHandlerErc20TransferWhenFromIsZero() { + const toAmount = '242423234'; + const totalSupply = '123654'; + const erc20Activity = Erc20Activity.fromJson({ + evm_event_id: 1, + sender: '0x7c756Cba10Ff2C65016494E8BA37C12a108572b5', + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: '0x98605ae21dd3be686337a6d7a8f156d0d8baee92', + amount: '12345222', + from: ZERO_ADDRESS, + to: '0xD83E708D7FE0E769Af80d990f9241458734808Ac', + height: 10000, + tx_hash: + '0xb97228e533e3af1323d873c9c3e4c0a9b85d95ecd8e98110c8890c9453d2f077', + evm_tx_id: 1, + from_account_id: 123, + to_account_id: 234, + }); + const erc20Contracts = { + [erc20Activity.erc20_contract_address]: Erc20Contract.fromJson({ + evm_smart_contract_id: 1, + total_supply: totalSupply, + symbol: 'ALPHA', + address: erc20Activity.erc20_contract_address, + decimal: '20', + name: 'Alpha Grand Wolf', + track: true, + last_updated_height: 1, + }), + }; + const [fromKey, toKey] = [ + `${erc20Activity.from_account_id}_${erc20Activity.erc20_contract_address}`, + `${erc20Activity.to_account_id}_${erc20Activity.erc20_contract_address}`, + ]; + const accountBalances: Dictionary = { + [toKey]: AccountBalance.fromJson({ + denom: erc20Activity.erc20_contract_address, + amount: toAmount, + last_updated_height: 1, + }), + }; + const erc20Handler = new Erc20Handler(accountBalances, [], erc20Contracts); + erc20Handler.handlerErc20Transfer(erc20Activity); + expect(erc20Handler.accountBalances[fromKey]).toBeUndefined(); + expect(erc20Handler.accountBalances[toKey]).toMatchObject({ + denom: erc20Activity.erc20_contract_address, + amount: (BigInt(erc20Activity.amount) + BigInt(toAmount)).toString(), + }); + expect( + erc20Contracts[erc20Activity.erc20_contract_address].total_supply + ).toEqual((BigInt(totalSupply) + BigInt(erc20Activity.amount)).toString()); + } + + @Test('test handlerErc20Transfer when to is zero') + async testHandlerErc20TransferWhenToIsZero() { + const balance = '242423234'; + const erc20Activity = Erc20Activity.fromJson({ + evm_event_id: 1, + sender: '0x7c756Cba10Ff2C65016494E8BA37C12a108572b5', + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: '0x98605ae21dd3be686337a6d7a8f156d0d8baee92', + amount: '12345222', + from: '0xD83E708D7FE0E769Af80d990f9241458734808Ac', + to: ZERO_ADDRESS, + height: 10000, + tx_hash: + '0xb97228e533e3af1323d873c9c3e4c0a9b85d95ecd8e98110c8890c9453d2f077', + evm_tx_id: 1, + from_account_id: 123, + to_account_id: 234, + }); + const [fromKey, toKey] = [ + `${erc20Activity.from_account_id}_${erc20Activity.erc20_contract_address}`, + `${erc20Activity.to_account_id}_${erc20Activity.erc20_contract_address}`, + ]; + const accountBalances: Dictionary = { + [fromKey]: AccountBalance.fromJson({ + denom: erc20Activity.erc20_contract_address, + amount: balance, + last_updated_height: 1, + }), + }; + const totalSupply = '123654'; + const erc20Contracts = { + [erc20Activity.erc20_contract_address]: Erc20Contract.fromJson({ + evm_smart_contract_id: 1, + total_supply: totalSupply, + symbol: 'ALPHA', + address: erc20Activity.erc20_contract_address, + decimal: '20', + name: 'Alpha Grand Wolf', + track: true, + last_updated_height: 1, + }), + }; + const erc20Handler = new Erc20Handler(accountBalances, [], erc20Contracts); + erc20Handler.handlerErc20Transfer(erc20Activity); + expect(erc20Handler.accountBalances[fromKey]).toMatchObject({ + denom: erc20Activity.erc20_contract_address, + amount: (BigInt(balance) - BigInt(erc20Activity.amount)).toString(), + }); + expect(erc20Handler.accountBalances[toKey]).toBeUndefined(); + expect( + erc20Contracts[erc20Activity.erc20_contract_address].total_supply + ).toEqual((BigInt(totalSupply) - BigInt(erc20Activity.amount)).toString()); + } + + @Test('test handlerErc20Transfer when last_updated_height not suitable') + async testHandlerErc20TransferWhenNotHeight() { + const fromAmount = '23442423'; + const toAmount = '32323232'; + const totalSupply = '123456'; + const erc20Activity = Erc20Activity.fromJson({ + evm_event_id: 1, + sender: '0x7c756Cba10Ff2C65016494E8BA37C12a108572b5', + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: '0x98605ae21dd3be686337a6d7a8f156d0d8baee92', + amount: '12345222', + from: '0x3E665ACfE64628774d3bA8E589Fa8683eD8706C9', + to: '0xD83E708D7FE0E769Af80d990f9241458734808Ac', + height: 10000, + tx_hash: + '0xb97228e533e3af1323d873c9c3e4c0a9b85d95ecd8e98110c8890c9453d2f077', + evm_tx_id: 1, + from_account_id: 123, + to_account_id: 234, + }); + const erc20Contracts = { + [erc20Activity.erc20_contract_address]: Erc20Contract.fromJson({ + evm_smart_contract_id: 1, + total_supply: totalSupply, + symbol: 'ALPHA', + address: erc20Activity.erc20_contract_address, + decimal: '20', + name: 'Alpha Grand Wolf', + track: true, + last_updated_height: 1, + }), + }; + const [fromKey, toKey] = [ + `${erc20Activity.from_account_id}_${erc20Activity.erc20_contract_address}`, + `${erc20Activity.to_account_id}_${erc20Activity.erc20_contract_address}`, + ]; + const accountBalances: Dictionary = { + [fromKey]: AccountBalance.fromJson({ + denom: erc20Activity.erc20_contract_address, + amount: fromAmount, + last_updated_height: 10001, + }), + [toKey]: AccountBalance.fromJson({ + denom: erc20Activity.erc20_contract_address, + amount: toAmount, + last_updated_height: 1, + }), + }; + const erc20Handler = new Erc20Handler(accountBalances, [], erc20Contracts); + expect(() => erc20Handler.handlerErc20Transfer(erc20Activity)).toThrow( + `Process erc20 balance: fromAccountBalance ${erc20Activity.from} was updated` + ); + } + + @Test('test handlerErc20Transfer when from/to is erc20 module account') + async testHandlerErc20TransferWhenToIsErc20ModuleAccount() { + const erc20Activity = Erc20Activity.fromJson({ + evm_event_id: 1, + sender: '0x7c756Cba10Ff2C65016494E8BA37C12a108572b5', + action: ERC20_ACTION.TRANSFER, + erc20_contract_address: '0x98605ae21dd3be686337a6d7a8f156d0d8baee92', + amount: '12345222', + from: '0xD83E708D7FE0E769Af80d990f9241458734808Ac', + to: ZERO_ADDRESS, + height: 10000, + tx_hash: + '0xb97228e533e3af1323d873c9c3e4c0a9b85d95ecd8e98110c8890c9453d2f077', + evm_tx_id: 1, + from_account_id: 123, + to_account_id: 234, + }); + const totalSupply = '123654'; + const erc20Contracts = { + [erc20Activity.erc20_contract_address]: Erc20Contract.fromJson({ + evm_smart_contract_id: 1, + total_supply: totalSupply, + symbol: 'ALPHA', + address: erc20Activity.erc20_contract_address, + decimal: '20', + name: 'Alpha Grand Wolf', + track: true, + last_updated_height: 1, + }), + }; + const erc20Handler = new Erc20Handler({}, [], erc20Contracts); + erc20Handler.handlerErc20Transfer(erc20Activity); + expect( + erc20Contracts[erc20Activity.erc20_contract_address].total_supply + ).toEqual((BigInt(totalSupply) - BigInt(erc20Activity.amount)).toString()); + } +} diff --git a/test/unit/services/evm/erc20_reindex.spec.ts b/test/unit/services/evm/erc20_reindex.spec.ts new file mode 100644 index 000000000..ef92dbf97 --- /dev/null +++ b/test/unit/services/evm/erc20_reindex.spec.ts @@ -0,0 +1,224 @@ +import { AfterAll, BeforeAll, Describe, Test } from '@jest-decorated/core'; +import _ from 'lodash'; +import { ServiceBroker } from 'moleculer'; +import { decodeAbiParameters, toHex } from 'viem'; +import knex from '../../../../src/common/utils/db_connection'; +import { getViemClient } from '../../../../src/common/utils/etherjs_client'; +import { + Account, + AccountBalance, + Erc20Activity, + Erc20Contract, + EvmEvent, + EVMSmartContract, + EVMTransaction, +} from '../../../../src/models'; +import { ABI_TRANSFER_PARAMS } from '../../../../src/services/evm/erc20_handler'; +import { Erc20Reindexer } from '../../../../src/services/evm/erc20_reindex'; + +const accounts = [ + Account.fromJson({ + id: 116058, + address: 'aura1az8cmnr4ppfggj0vt4llzdun0ptlkc8jxha27w', + balances: [ + { + denom: 'uaura', + amount: '42981159', + }, + ], + code_hash: null, + evm_address: '0xe88f8dcc7508528449ec5d7ff137937857fb60f2', + pubkey: {}, + sequence: 11, + account_number: 5, + spendable_balances: [ + { + denom: 'uaura', + amount: '42981159', + }, + ], + type: '/cosmos.auth.v1beta1.BaseAccount', + account_balances: [ + { + denom: '0x80b5a32e4f032b2a058b4f29ec95eefeeb87adcd', + amount: '422142', + type: 'ERC20_TOKEN', + }, + { + denom: '0xde47a655a5d9904bd3f7e1a536d8323fbd99993a', + amount: '-10000000000000000000', + type: 'ERC20_TOKEN', + }, + ], + }), + Account.fromJson({ + id: 116059, + address: 'aura1xnjpzgtqztcy9l8vhafmr5zulfn7a8l8sjlhp3', + balances: [ + { + denom: 'uaura', + amount: '32218705', + }, + ], + code_hash: null, + evm_address: '0x34e411216012f042fcecbf53b1d05cfa67ee9fe7', + pubkey: {}, + sequence: 11, + account_number: 5, + spendable_balances: [ + { + denom: 'uaura', + amount: '42981159', + }, + ], + type: '/cosmos.auth.v1beta1.BaseAccount', + account_balances: [], + }), +]; +const evmSmartContract = EVMSmartContract.fromJson({ + id: 1, + address: '0xde47a655a5d9904bd3f7e1a536d8323fbd99993a', + creator: '0x5606b4eA93F696Dd82Ca983BAF5723d00729f127', + created_height: 100, + created_hash: + '0x8c46cf6373f2f6e528b56becf0ce6b460b5d90cf9b0325a136b9b3a820e1e489', + type: EVMSmartContract.TYPES.ERC20, + code_hash: '0xdsf3335453454tsgfsdrtserf43645y4h4tAAvfgfgds', +}); + +const erc20Contract = Erc20Contract.fromJson({ + id: 10, + address: evmSmartContract.address, + decimal: '18', + name: 'Wrapped Aura', + symbol: 'WAURA', + total_supply: '0', + track: true, + activities: [ + { + from: '0x93f8e7ec7e054b476d7de8e6bb096e56cd575beb', + erc20_contract_address: '0xde47a655a5d9904bd3f7e1a536d8323fbd99993a', + height: 7116660, + sender: '0x34e411216012f042fcecbf53b1d05cfa67ee9fe7', + to: '0x34e411216012f042fcecbf53b1d05cfa67ee9fe7', + tx_hash: + '0xf15467ec2a25eeef95798d93c2fe9ed8e7c891578b8e1bcc3284105849656c9d', + action: 'transfer', + amount: '258773093659445577552', + }, + ], + evm_smart_contract_id: evmSmartContract.id, +}); +const evmTransaction = EVMTransaction.fromJson({ + id: 2931, + hash: '0xf15467ec2a25eeef95798d93c2fe9ed8e7c891578b8e1bcc3284105849656c9d', + height: 1, + tx_id: 1612438, + tx_msg_id: 4752908, + contract_address: null, + index: 0, +}); + +@Describe('Test erc20 reindex') +export default class Erc20ReindexTest { + broker = new ServiceBroker({ logger: false }); + + @BeforeAll() + async initSuite() { + await this.broker.start(); + await knex.raw( + 'TRUNCATE TABLE evm_transaction, evm_smart_contract, erc20_contract, account, erc20_activity, account_balance, evm_event RESTART IDENTITY CASCADE' + ); + await EVMTransaction.query().insert(evmTransaction); + await EVMSmartContract.query().insert(evmSmartContract); + await Erc20Contract.query().insertGraph(erc20Contract); + await Account.query().insertGraph(accounts); + } + + @AfterAll() + async tearDown() { + await this.broker.stop(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + } + + @Test('test reindex') + async testReindex() { + const viemClient = getViemClient(); + jest.spyOn(viemClient, 'getBlockNumber').mockResolvedValue(BigInt(123456)); + // Instantiate Erc20Reindexer with the mock + const reindexer = new Erc20Reindexer(viemClient, this.broker.logger); + const event = EvmEvent.fromJson({ + id: 4227, + topic0: + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + topic1: + '0x000000000000000000000000e88f8dcc7508528449ec5d7ff137937857fb60f2', + topic2: + '0x00000000000000000000000034e411216012f042fcecbf53b1d05cfa67ee9fe7', + topic3: null, + tx_hash: + '0xf15467ec2a25eeef95798d93c2fe9ed8e7c891578b8e1bcc3284105849656c9d', + tx_index: 0, + tx_id: 1612438, + data: Buffer.from( + '\\x00000000000000000000000000000000000000000000000e0732b5a508244750' + ), + block_height: 1, + block_hash: + '0x16f84b38d58b8aedbba1e108990f858b09fe8373610659d4c37a67f5c87e09e1', + address: evmSmartContract.address, + evm_tx_id: evmTransaction.id, + }); + await EvmEvent.query().insert(event); + // Call the reindex method + await reindexer.reindex(erc20Contract.address as `0x${string}`); + // Test phase + const erc20Activity = await Erc20Activity.query().first().throwIfNotFound(); + const [from, to, amount] = decodeAbiParameters( + [ + ABI_TRANSFER_PARAMS.FROM, + ABI_TRANSFER_PARAMS.TO, + ABI_TRANSFER_PARAMS.VALUE, + ], + (event.topic1 + + event.topic2.slice(2) + + toHex(event.data).slice(2)) as `0x${string}` + ) as [string, string, bigint]; + // Test new activity had been inserted + expect(erc20Activity).toMatchObject({ + action: 'transfer', + erc20_contract_address: erc20Contract.address, + amount: amount.toString(), + from: from.toLowerCase(), + to: to.toLowerCase(), + }); + const accountBalances = _.keyBy( + await AccountBalance.query(), + (o) => `${o.account_id}_${o.denom}` + ); + // from account balance had been reindexed + expect( + accountBalances[`${accounts[0].id}_${erc20Contract.address}`] + ).toMatchObject({ + denom: erc20Contract.address, + amount: `-${amount.toString()}`, + }); + // to account balance had been reindexed + expect( + accountBalances[`${accounts[1].id}_${erc20Contract.address}`] + ).toMatchObject({ + denom: erc20Contract.address, + amount: amount.toString(), + }); + // from account balance without erc20 had been orinal + expect( + accountBalances[ + `${accounts[0].id}_${accounts[0].account_balances[0].denom}` + ] + ).toMatchObject({ + denom: accounts[0].account_balances[0].denom, + amount: accounts[0].account_balances[0].amount, + }); + } +}