diff --git a/db/migrations/19_evm_transactions.sql b/db/migrations/19_evm_transactions.sql new file mode 100644 index 00000000..fed418d0 --- /dev/null +++ b/db/migrations/19_evm_transactions.sql @@ -0,0 +1,22 @@ +-- Support for indexing Ethereum transactions submitted through `revive.ethTransact`. +-- +-- The `evm_transactions` and `evm_account_mappings` tables, along with the `EvmCallKindEnum` type, +-- are created by the node from `schema.graphql`. Only the two pre-existing tables need altering. + +alter table "extrinsics" add column if not exists "eth_address" text; +alter table "extrinsics" add column if not exists "eth_tx_hash" text; + +create index if not exists data_extrinsic_eth_address on extrinsics (eth_address); +create index if not exists data_extrinsic_eth_tx_hash on extrinsics (eth_tx_hash); + +-- Every account indexed before this change was a substrate key. Ethereum keys were never +-- attributed, so there is nothing to reclassify. +alter table "accounts" add column if not exists "key_type" text; +update "accounts" set "key_type" = 'substrate' where "key_type" is null; +alter table "accounts" alter column "key_type" set not null; + +-- `evm_address` needs keccak256 to derive for a substrate key, which postgres has no built in for. +-- Existing rows are left null and are populated as they are next updated. +alter table "accounts" add column if not exists "evm_address" text; + +create index if not exists data_account_evm_address on accounts (evm_address); diff --git a/package.json b/package.json index 33103bc7..17a2fc51 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "project.yaml" ], "dependencies": { + "@ethereumjs/rlp": "^5.0.2", "@polkadot/api": "^16.5.2", "@polkadot/types-support": "^16.5.2", "@polymeshassociation/polymesh-types": "^7.4.0", diff --git a/schema.graphql b/schema.graphql index c34437c1..fd9f4eef 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1806,9 +1806,90 @@ type Extrinsic @entity { success: Int! @index(unique: false) nonce: Int extrinsicHash: String @index(unique: false) + """ + Checksummed Ethereum address of the key that signed this `revive.eth_transact` extrinsic. `null` for every other extrinsic. + + The equivalent SS58 address, formed by padding this address with 12 `0xEE` bytes, is stored in `address` + """ + ethAddress: String @index(unique: false) + "The keccak256 of the RLP payload, i.e. the transaction hash an Ethereum wallet reports" + ethTxHash: String @index(unique: false) specVersionId: Int! } +""" +The `revive` call an `eth_transact` extrinsic is transformed into by the runtime +""" +enum EvmCallKindEnum { + "`revive.eth_call` - a call to a contract or precompile" + call + "`revive.eth_instantiate_with_code` - a contract deployment" + instantiate + "`revive.eth_substrate_call` - a native runtime call, dispatched by targeting the `modlpy/paddr` address" + substrateCall +} + +""" +An Ethereum transaction submitted through `revive.ethTransact`, decoded from the RLP payload of the extrinsic. + +The chain includes these as unsigned extrinsics, so the signer, the dispatched call and whether the transaction reverted are all recovered by the indexer +""" +type EvmTransaction @entity { + "Same ID as the `Extrinsic` that carried this transaction" + id: ID! + extrinsic: Extrinsic! + block: Block! @index(unique: false) + "The keccak256 of the RLP payload, i.e. the transaction hash an Ethereum wallet reports" + ethTxHash: String! @index(unique: false) + "0 for legacy, 1 for EIP-2930, 2 for EIP-1559" + ethTxType: Int! + callKind: EvmCallKindEnum! + "Checksummed Ethereum address that signed the transaction" + fromEthAddress: String! @index(unique: false) + "SS58 encoding of the `0xEE` padded account the runtime dispatched the call as" + fromAddress: String! @index(unique: false) + "`null` for a contract deployment" + toEthAddress: String @index(unique: false) + "Address of the deployed contract, taken from `revive.Instantiated`" + contractAddress: String @index(unique: false) + value: String! + ethNonce: BigInt! + gasLimit: String! + "Only set for legacy and EIP-2930 transactions" + gasPrice: String + "Only set for EIP-1559 transactions" + maxFeePerGas: String + "Only set for EIP-1559 transactions" + maxPriorityFeePerGas: String + chainId: String + "Hex encoded calldata. For a `substrateCall` this is the SCALE encoded runtime call" + input: String! + "An Ethereum transaction always succeeds at the extrinsic level. This flags the ones that reverted" + reverted: Boolean! @index(unique: false) + revertReason: String + datetime: Date! + createdBlock: Block! + updatedBlock: Block! +} + +""" +Mapping between an Ethereum address and its original `AccountId32`, registered with `revive.mapAccount` +""" +type EvmAccountMapping @entity { + "Same ID as `evmAddress`" + id: ID! + "Checksummed Ethereum address the account is reachable at inside `pallet_revive`. Same value as `id`" + evmAddress: String! + "SS58 address of the account that registered the mapping" + address: String! @index(unique: true) + account: Account + "`false` once the mapping has been removed with `revive.unmapAccount`" + mapped: Boolean! + datetime: Date! + createdBlock: Block! + updatedBlock: Block! +} + """ Information of a chain state transition on. For most use cases a more specific entity should be queried """ @@ -1883,6 +1964,10 @@ Before an Account can sign most Extrinsics it must first be attached to an Ident type Account @entity { id: ID! address: String! @index(unique: true) + "`substrate` for a regular key, `ethereum` for the `0xEE` padded account of an Ethereum key" + keyType: String! + "Checksummed Ethereum address this account is reachable at inside `pallet_revive`" + evmAddress: String @index(unique: false) identity: Identity permissions: Permissions eventId: EventIdEnum! diff --git a/scripts/backfill/eth-transact-senders.ts b/scripts/backfill/eth-transact-senders.ts new file mode 100644 index 00000000..60ba650f --- /dev/null +++ b/scripts/backfill/eth-transact-senders.ts @@ -0,0 +1,297 @@ +/** + * Backfills the senders of historical `revive.ethTransact` extrinsics. + * + * These extrinsics are unsigned - the sender exists only inside the Ethereum style signature + * carried by their call argument - so rows indexed before sender attribution was added have + * `address = null`. The raw RLP payloads are still stored in `extrinsics.params_txt`, which makes + * history recoverable offline, without touching the chain. + * + * For every affected row this script recovers the signer from the payload, then + * - sets `extrinsics.address` to the SS58 encoding of the `0xEE` padded Ethereum derived account, + * alongside its `eth_address` and `eth_tx_hash` + * - upserts an `accounts` row for that address (`key_type = 'ethereum'`), so joins against + * `extrinsics.address` behave. Existing rows are reclassified rather than duplicated + * + * The stored `module_id`/`call_id`/`params_txt`/`success` values are deliberately left untouched: + * they stay consistent with the historical event rows built from them. + * + * Usage (from the repo root): + * yarn ts-node scripts/backfill/eth-transact-senders.ts [--apply] + * [--db-host=h] [--db-port=p] [--db-user=u] [--db-pass=p] [--db-name=d] + * [--batch-size=N] [--ss58-format=N] [--limit=N] + * + * Defaults to a dry run, which prints would-be updates and a summary without writing anything. + * Connection details fall back to the DB_* environment variables used by `db/utils.ts`. + */ +import { hexToU8a } from '@polkadot/util'; +import { DataSource } from 'typeorm'; + +// ambient `logger` declaration for this script and the utils it imports +import type {} from './globals'; +// `src/utils/ethTransaction.ts` reports failures through the sandbox injected global `logger` +(globalThis as any).logger = console; + +import { getPostgresDataSource } from '../../db/utils'; +import { ss58FromEthAddress } from '../../src/utils/eth'; +import { decodeEthTransaction, ethTxHash, recoverEthSender } from '../../src/utils/ethTransaction'; + +interface Args { + apply: boolean; + batchSize: number; + dbHost?: string; + dbName?: string; + dbPass?: string; + dbPort?: number; + dbUser?: string; + limit?: number; + ss58Format: number; +} + +const parseArgs = (): Args => { + const args: Args = { apply: false, batchSize: 500, ss58Format: 12 }; + + for (const arg of process.argv.slice(2)) { + const [key, value] = arg.replace(/^--/, '').split('='); + + switch (key) { + case 'apply': + args.apply = true; + break; + case 'batch-size': + args.batchSize = Number(value); + break; + case 'db-host': + args.dbHost = value; + break; + case 'db-name': + args.dbName = value; + break; + case 'db-pass': + args.dbPass = value; + break; + case 'db-port': + args.dbPort = Number(value); + break; + case 'db-user': + args.dbUser = value; + break; + case 'limit': + args.limit = Number(value); + break; + case 'ss58-format': + args.ss58Format = Number(value); + break; + default: + throw new Error(`Unknown argument "${arg}"`); + } + } + + return args; +}; + +/** Extracts the RLP payload hex from the `{"payload": "0x..."}` JSON stored in `params_txt` */ +const extractPayload = (paramsTxt: string | null): string | undefined => { + if (!paramsTxt) { + return undefined; + } + + try { + const { payload } = JSON.parse(paramsTxt); + + return typeof payload === 'string' && payload.startsWith('0x') ? payload : undefined; + } catch { + return undefined; + } +}; + +interface Recovered { + ethAddress: string; + ethTxHash: string; + id: string; + ss58: string; +} + +const recoverBatch = async ( + postgres: DataSource, + args: Args, + lastId: string +): Promise<{ + failed: number; + lastScannedId: string; + recovered: Recovered[]; + scannedCount: number; +}> => { + const rows: { id: string; params_txt: string | null }[] = await postgres.query( + `select id, params_txt + from extrinsics + where module_id = 'revive' + and call_id = 'eth_transact' + and address is null + and id > $1 + order by id + limit $2`, + [lastId, args.batchSize] + ); + + const recovered: Recovered[] = []; + let failed = 0; + let lastScannedId = lastId; + + for (const { id, params_txt } of rows) { + lastScannedId = id; + + const payload = extractPayload(params_txt); + const tx = payload ? decodeEthTransaction(hexToU8a(payload)) : undefined; + const from = tx ? recoverEthSender(tx) : undefined; + + if (!payload || !tx || !from) { + // malformed payloads are skipped exactly like the forward mapping handler skips them + logger.warn(`Unable to recover the sender of ${id}, skipping`); + failed += 1; + + continue; + } + + recovered.push({ + id, + ss58: ss58FromEthAddress(from, args.ss58Format), + ethAddress: from, + ethTxHash: ethTxHash(hexToU8a(payload)), + }); + } + + return { + failed, + lastScannedId, + recovered, + scannedCount: rows.length, + }; +}; + +/** + * Repairs one extrinsic row and its account row. + * + * The account upsert reclassifies an existing row, or inserts one when the key was never attached + * to an Identity and was therefore never indexed. An inserted row carries no identity or + * permissions links, mirroring how the forward path leaves unattached keys alone + */ +const applyRecovery = async ( + postgres: DataSource, + { ethAddress, ethTxHash, id, ss58 }: Recovered +): Promise => + postgres.transaction(async manager => { + await manager.query( + `update extrinsics + set address = $1, eth_address = $2, eth_tx_hash = $3 + where id = $4`, + [ss58, ethAddress, ethTxHash, id] + ); + + const enrichment = await manager.query( + `update accounts + set key_type = 'ethereum', evm_address = $2 + where id = $1 + and (key_type is distinct from 'ethereum' or evm_address is distinct from $2)`, + [ss58, ethAddress] + ); + + if (enrichment[1] > 0) { + return 1; + } + + // `event_id` is the hashed Postgres enum; `'AccountCreated'` is an existing label of it + const insertion = await manager.query( + `insert into accounts + (id, address, key_type, evm_address, event_id, datetime, created_block_id, + updated_block_id) + select $1, $1, 'ethereum', $2, 'AccountCreated', b.datetime, e.block_id, e.block_id + from extrinsics e + join blocks b on b.id = e.block_id + where e.id = $1 + and b.datetime is not null + on conflict (id) do nothing`, + [ss58, ethAddress] + ); + + return insertion[1]; + }); + +const main = async (): Promise => { + const args = parseArgs(); + + const postgres = + args.dbHost || args.dbName || args.dbPass || args.dbPort || args.dbUser + ? await new DataSource({ + type: 'postgres', + host: args.dbHost ?? process.env.DB_HOST, + port: args.dbPort ?? Number(process.env.DB_PORT ?? 5432), + username: args.dbUser ?? process.env.DB_USER, + password: args.dbPass ?? process.env.DB_PASS, + database: args.dbName ?? process.env.DB_DATABASE, + name: 'postgres-backfill', + }).initialize() + : await getPostgresDataSource(); + + try { + const [{ count }] = await postgres.query( + `select count(*)::int as count + from extrinsics + where module_id = 'revive' + and call_id = 'eth_transact' + and address is null` + ); + console.log(`Found ${count} revive.eth_transact extrinsics without an address`); + + if (Number(count) === 0) { + return; + } + + let scanned = 0; + let failed = 0; + let updated = 0; + let accountsUpserted = 0; + let lastId = ''; + let exhausted = false; + + while (!exhausted && !(args.limit && scanned >= args.limit)) { + const { + failed: batchFailed, + lastScannedId, + recovered, + scannedCount, + } = await recoverBatch(postgres, args, lastId); + + scanned += scannedCount; + failed += batchFailed; + + if (args.apply) { + for (const row of recovered) { + updated += 1; + accountsUpserted += await applyRecovery(postgres, row); + } + } else if (recovered.length) { + console.log('Would update:'); + recovered.forEach(({ id, ss58 }) => console.log(` ${id} -> ${ss58}`)); + } + + lastId = lastScannedId; + exhausted = scannedCount < args.batchSize; + } + + console.log( + `Done. Scanned ${scanned} rows, recovered ${scanned - failed}, failed ${failed}. ` + + (args.apply + ? `Updated ${updated} extrinsics and ${accountsUpserted} accounts.` + : 'Dry run, nothing written. Pass --apply to write.') + ); + } finally { + await postgres.destroy(); + } +}; + +main() + .then(() => process.exit(0)) + .catch(e => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/backfill/globals.d.ts b/scripts/backfill/globals.d.ts new file mode 100644 index 00000000..939dda91 --- /dev/null +++ b/scripts/backfill/globals.d.ts @@ -0,0 +1,18 @@ +/** + * Ambient declaration for the SubQuery injected `logger` global that this script's imports rely + * on. + * + * `tsconfig.json` only maps SubQuery's globals onto `src/**`, so code running through ts-node + * declares what it uses itself. At runtime the value only exists inside the SubQuery node; the + * backfill shims it before importing any module that touches it + */ +declare global { + const logger: { + debug: (message: string) => void; + error: (message: string) => void; + info: (message: string) => void; + warn: (message: string) => void; + }; +} + +export {}; diff --git a/src/mappings/entities/block/mapExtrinsic.ts b/src/mappings/entities/block/mapExtrinsic.ts index d08e2925..61f0d3aa 100644 --- a/src/mappings/entities/block/mapExtrinsic.ts +++ b/src/mappings/entities/block/mapExtrinsic.ts @@ -1,7 +1,17 @@ import { SubstrateExtrinsic } from '@subql/types'; -import { CallIdEnum, Extrinsic, ModuleIdEnum } from '../../../types'; -import { camelToSnakeCase, padId } from '../../../utils'; +import { CallIdEnum, EvmTransaction, Extrinsic, ModuleIdEnum } from '../../../types'; +import { + ResolvedEthTransact, + camelToSnakeCase, + evmAddressFromSs58, + getOrCreateAccount, + getSignerAddress, + isEthTransact, + padId, + resolveEthTransact, +} from '../../../utils'; import { toEnum } from '../common'; +import { upsertEvmAccountMapping } from '../revive/mapEvmAccountMapping'; export function createExtrinsic(extrinsic: SubstrateExtrinsic): Extrinsic { const blockId = padId(extrinsic.block.block.header.number.toString()); @@ -13,7 +23,7 @@ export function createExtrinsic(extrinsic: SubstrateExtrinsic): Extrinsic { const moduleId = extrinsic.extrinsic.method.section.toLowerCase(); const callId = camelToSnakeCase(extrinsic.extrinsic.method.method); - return Extrinsic.create({ + const created = Extrinsic.create({ id: extrinsicId, blockId, extrinsicIdx, @@ -31,4 +41,119 @@ export function createExtrinsic(extrinsic: SubstrateExtrinsic): Extrinsic { extrinsicHash: extrinsic.extrinsic.hash.toJSON(), specVersionId: extrinsic.block.specVersion, }); + + const resolved = isEthTransact(extrinsic) ? resolveEthTransact(extrinsic) : undefined; + + if (resolved) { + /** + * `eth_transact` is included as an unsigned extrinsic wrapping an Ethereum transaction, so + * `signed` stays 0. The attribution and the call that actually ran are recovered from the + * payload and recorded as if they had been dispatched directly + */ + created.address = resolved.fromAddress; + created.ethAddress = resolved.fromEthAddress; + created.ethTxHash = resolved.ethTxHash; + created.moduleId = toEnum(ModuleIdEnum, resolved.moduleId, ModuleIdEnum.unknown); + created.moduleIdText = resolved.moduleId; + created.callId = toEnum(CallIdEnum, resolved.callId, CallIdEnum.unknown); + created.callIdText = resolved.callId; + created.paramsTxt = resolved.paramsTxt; + created.nonce = Number(resolved.tx.nonce); + created.success = extrinsic.success && !resolved.reverted ? 1 : 0; + } + + return created; } + +const createEvmTransaction = ( + extrinsic: SubstrateExtrinsic, + resolved: ResolvedEthTransact +): Promise => { + const blockId = padId(extrinsic.block.block.header.number.toString()); + const extrinsicId = `${blockId}/${padId(extrinsic.idx.toString())}`; + const { tx } = resolved; + + return EvmTransaction.create({ + id: extrinsicId, + extrinsicId, + blockId, + ethTxHash: resolved.ethTxHash, + ethTxType: tx.txType, + callKind: resolved.callKind, + fromEthAddress: resolved.fromEthAddress, + fromAddress: resolved.fromAddress, + toEthAddress: tx.to, + contractAddress: resolved.contractAddress, + value: tx.value.toString(), + ethNonce: tx.nonce, + gasLimit: tx.gasLimit.toString(), + gasPrice: tx.gasPrice?.toString(), + maxFeePerGas: tx.maxFeePerGas?.toString(), + maxPriorityFeePerGas: tx.maxPriorityFeePerGas?.toString(), + chainId: tx.chainId?.toString(), + input: tx.data, + reverted: resolved.reverted, + revertReason: resolved.revertReason, + datetime: extrinsic.block.timestamp, + createdBlockId: blockId, + updatedBlockId: blockId, + }).save(); +}; + +/** + * `revive.mapAccount` and `revive.unmapAccount` emit no event of their own, so the mapping they + * maintain can only be picked up from the extrinsic itself + */ +const handleEvmAccountMapping = async (extrinsic: SubstrateExtrinsic): Promise => { + const address = getSignerAddress(extrinsic); + const evmAddress = evmAddressFromSs58(address, extrinsic.extrinsic.registry.chainSS58); + + if (!evmAddress) { + return; + } + + return upsertEvmAccountMapping({ + evmAddress, + address, + mapped: camelToSnakeCase(extrinsic.extrinsic.method.method) === 'map_account', + datetime: extrinsic.block.timestamp, + blockId: padId(extrinsic.block.block.header.number.toString()), + }); +}; + +const isAccountMappingCall = (extrinsic: SubstrateExtrinsic): boolean => { + if (extrinsic.extrinsic.method.section !== 'revive' || !extrinsic.success) { + return false; + } + const callId = camelToSnakeCase(extrinsic.extrinsic.method.method); + + return callId === 'map_account' || callId === 'unmap_account'; +}; + +/** + * Persists the extrinsic, along with the decoded Ethereum transaction it carried and any EVM + * account mapping it registered + */ +export const handleExtrinsic = async (extrinsic: SubstrateExtrinsic): Promise => { + await createExtrinsic(extrinsic).save(); + + if (isEthTransact(extrinsic)) { + const resolved = resolveEthTransact(extrinsic); + + if (resolved) { + // `EvmTransaction` references the extrinsic, so it has to be written after it + await createEvmTransaction(extrinsic, resolved); + + /** + * Only indexes the sender once it is attached to an Identity, as with every other account + */ + await getOrCreateAccount( + resolved.fromAddress, + padId(extrinsic.block.block.header.number.toString()), + extrinsic.block.timestamp + ); + } + } else if (isAccountMappingCall(extrinsic)) { + await handleEvmAccountMapping(extrinsic); + } +}; diff --git a/src/mappings/entities/identities/mapIdentities.ts b/src/mappings/entities/identities/mapIdentities.ts index 92a9857e..2f8095ad 100644 --- a/src/mappings/entities/identities/mapIdentities.ts +++ b/src/mappings/entities/identities/mapIdentities.ts @@ -21,6 +21,7 @@ import { getTextValue, meshPortfolioToAssetHolder, } from '../../../utils'; +import { getAccountKeyType } from '../../../utils/accounts'; import { Attributes, extractArgs } from './../common'; import { createPortfolio, getPortfolio } from './mapPortfolio'; @@ -70,10 +71,14 @@ export const createPermissions = async ( updatedBlockId: blockId, }).save(); -export const createAccount = async (args: Attributes, blockId: string): Promise => +export const createAccount = async ( + args: Omit, 'keyType' | 'evmAddress'>, + blockId: string +): Promise => Account.create({ id: args.address, ...args, + ...getAccountKeyType(args.address), createdBlockId: blockId, updatedBlockId: blockId, }).save(); diff --git a/src/mappings/entities/index.ts b/src/mappings/entities/index.ts index adaf0172..6136eeab 100644 --- a/src/mappings/entities/index.ts +++ b/src/mappings/entities/index.ts @@ -13,6 +13,7 @@ export * from './assets/mapCorporateActions'; export * from './events/mapEvent'; export * from './externalAgents/mapExternalAgentAction'; export * from './block/mapExtrinsic'; +export * from './revive/mapEvmAccountMapping'; export * from './identities/mapIdentities'; export * from './multiSig/mapMultiSig'; export * from './multiSig/mapMultiSigProposal'; diff --git a/src/mappings/entities/revive/mapEvmAccountMapping.ts b/src/mappings/entities/revive/mapEvmAccountMapping.ts new file mode 100644 index 00000000..2f971777 --- /dev/null +++ b/src/mappings/entities/revive/mapEvmAccountMapping.ts @@ -0,0 +1,57 @@ +import { ethereumEncode } from '@polkadot/util-crypto'; +import { Account, EvmAccountMapping } from '../../../types'; + +interface UpsertArgs { + /** the H160 `pallet_revive` addresses the account by, in any casing */ + evmAddress: string; + /** SS58 address of the account that registered the mapping */ + address: string; + mapped: boolean; + datetime: Date; + blockId: string; +} + +/** + * Records the `H160 -> AccountId32` mapping maintained by `revive.mapAccount`. + * + * Neither `revive.mapAccount` nor `revive.unmapAccount` emits an event, and the pallet's genesis + * config seeds the map directly, so this is driven from the extrinsic and from genesis state + * rather than from an event handler + */ +export const upsertEvmAccountMapping = async ({ + evmAddress, + address, + mapped, + datetime, + blockId, +}: UpsertArgs): Promise => { + // stored checksummed so it can be joined against `Account.evmAddress`, which is case sensitive + const id = ethereumEncode(evmAddress); + + /** + * Accounts are only indexed once they are attached to an Identity, so the relation is left + * unset for a key that has not been seen yet + */ + const account = await Account.get(address); + const existing = await EvmAccountMapping.get(id); + + if (existing) { + existing.address = address; + existing.accountId = account?.id; + existing.mapped = mapped; + existing.updatedBlockId = blockId; + + return existing.save(); + } + + return EvmAccountMapping.create({ + id, + evmAddress: id, + address, + accountId: account?.id, + mapped, + datetime, + createdBlockId: blockId, + updatedBlockId: blockId, + }).save(); +}; diff --git a/src/mappings/mappingHandlers.ts b/src/mappings/mappingHandlers.ts index d2bb116a..534ff81d 100644 --- a/src/mappings/mappingHandlers.ts +++ b/src/mappings/mappingHandlers.ts @@ -4,7 +4,7 @@ import { mapExternalAgentAction } from './entities'; import { mapBlock } from './entities/block/mapBlock'; import mapChainUpgrade from './entities/block/mapChainUpgrade'; import { handleToolingEvent } from './entities/events/mapEvent'; -import { createExtrinsic } from './entities/block/mapExtrinsic'; +import { handleExtrinsic } from './entities/block/mapExtrinsic'; import mapSubqueryVersion from './entities/block/mapSubqueryVersion'; import genesisHandler from './migrations/genesisHandler'; @@ -54,8 +54,7 @@ export async function handleEvent(substrateEvent: SubstrateEvent): Promise if (substrateEvent?.extrinsic?.idx > lastEventIdx) { lastEventIdx = substrateEvent?.extrinsic?.idx; - const extrinsic = createExtrinsic(substrateEvent.extrinsic); - promises.push(extrinsic.save()); + promises.push(handleExtrinsic(substrateEvent.extrinsic)); } const event = handleToolingEvent(substrateEvent); diff --git a/src/mappings/migrations/genesisHandler.ts b/src/mappings/migrations/genesisHandler.ts index b4a1af71..80bfb345 100644 --- a/src/mappings/migrations/genesisHandler.ts +++ b/src/mappings/migrations/genesisHandler.ts @@ -18,6 +18,7 @@ import { createMultiSigAdmin, createMultiSigSigner, } from '../entities/multiSig/mapMultiSig'; +import { upsertEvmAccountMapping } from '../entities/revive/mapEvmAccountMapping'; const genesisBlock = padId('0'); type DidWithAccount = { did: string; accountId: string }; @@ -228,6 +229,40 @@ const handleMultiSigs = async (): Promise => { await Promise.all(multiSigInserts); }; +/** + * This method adds the `H160 -> AccountId32` mappings present in the genesis block + * + * `pallet_revive`'s genesis config seeds `OriginalAccount` directly through its `mapped_accounts` + * field, so these mappings exist without a `revive.mapAccount` extrinsic ever being dispatched and + * would otherwise be invisible to the indexer + */ +const handleEvmAccountMappings = async (datetime: Date): Promise => { + // the revive pallet only exists from the 8.x chain onwards + if (!api.query.revive?.originalAccount) { + return; + } + + const entries = await api.query.revive.originalAccount.entries(); + + await Promise.all( + entries.map( + ([ + { + args: [rawEvmAddress], + }, + rawAddress, + ]) => + upsertEvmAccountMapping({ + evmAddress: rawEvmAddress.toString(), + address: rawAddress.toString(), + mapped: true, + datetime, + blockId: genesisBlock, + }) + ) + ); +}; + /** * This adds in all the entries which are present in the genesisBlock */ @@ -239,5 +274,8 @@ export default async (): Promise => { await Promise.all([insertGenesisBlock(datetime), handleGenesisDids(datetime), handleMultiSigs()]); + // runs last so that it can link to the Accounts created above + await handleEvmAccountMappings(datetime); + logger.info('Applied genesis migrations'); }; diff --git a/src/utils/accounts.ts b/src/utils/accounts.ts index 1b2deb4b..b201b2cd 100644 --- a/src/utils/accounts.ts +++ b/src/utils/accounts.ts @@ -2,8 +2,10 @@ import { decodeAddress, encodeAddress } from '@polkadot/keyring'; import { Codec } from '@polkadot/types/types'; import { u8aToHex } from '@polkadot/util'; import { createIdentity, createPermissions } from '../mappings/entities/identities/mapIdentities'; +import { Attributes } from '../mappings/entities/common'; import { Account, EventIdEnum, Identity } from '../types'; import { getFirstKeyFromJson, getFirstValueFromJson } from './common'; +import { evmAddressFromSs58, isEthDerivedAddress } from './eth'; export const serializeAccount = (item: Codec): string | undefined => { const s = item.toString(); @@ -18,6 +20,21 @@ export const getAccountKey = (item: string, ss58Format?: number): string => { return encodeAddress(item.toString(), ss58Format); }; +/** + * Classifies an account as belonging to a substrate or an Ethereum key, and resolves the H160 + * `pallet_revive` addresses it by + */ +export const getAccountKeyType = ( + address: string +): Pick, 'keyType' | 'evmAddress'> => { + const ss58Format = api.registry.chainSS58; + + return { + keyType: isEthDerivedAddress(address, ss58Format) ? 'ethereum' : 'substrate', + evmAddress: evmAddressFromSs58(address, ss58Format), + }; +}; + export const getOrCreateAccount = async ( address: string, blockId: string, @@ -65,6 +82,7 @@ export const getOrCreateAccount = async ( identityId: did, permissionsId: address, address, + ...getAccountKeyType(address), createdBlockId: blockId, updatedBlockId: blockId, }); diff --git a/src/utils/eth.ts b/src/utils/eth.ts new file mode 100644 index 00000000..c1ed75ee --- /dev/null +++ b/src/utils/eth.ts @@ -0,0 +1,87 @@ +import { decodeAddress, encodeAddress } from '@polkadot/keyring'; +import { hexToU8a } from '@polkadot/util'; +import { ethereumEncode, keccakAsU8a } from '@polkadot/util-crypto'; + +/** + * `pallet_revive` derives a substrate account for an Ethereum key by right padding the 20 byte + * address with 12 `0xEE` bytes. See `AccountId32Mapper::to_fallback_account_id` + */ +export const ETH_ACCOUNT_SUFFIX = new Uint8Array(12).fill(0xee); + +/** + * The address `pallet_revive` reserves for calls into the substrate runtime. + * + * Computed with `PalletId(*b"py/paddr").into_account_truncating()`. An Ethereum wallet dispatches a + * `RuntimeCall` by sending a zero value transaction to this address with the SCALE encoded call as + * its calldata + */ +export const RUNTIME_PALLETS_ADDR = '0x6d6f646c70792f70616464720000000000000000'; + +const ACCOUNT_ID_LENGTH = 32; +const H160_LENGTH = 20; +const KECCAK_ADDRESS_OFFSET = 12; + +const tryDecodeAddress = (address: string, ss58Format?: number): Uint8Array | undefined => { + try { + return decodeAddress(address, false, ss58Format); + } catch { + return undefined; + } +}; + +/** + * Converts an Ethereum address into the SS58 account `pallet_revive` dispatches it as + */ +export const ss58FromEthAddress = (h160: string, ss58Format?: number): string => { + const accountId = new Uint8Array(ACCOUNT_ID_LENGTH); + accountId.set(hexToU8a(h160), 0); + accountId.set(ETH_ACCOUNT_SUFFIX, H160_LENGTH); + + return encodeAddress(accountId, ss58Format); +}; + +/** + * Returns true when the given address is an Ethereum key's `0xEE` padded account + */ +export const isEthDerivedAddress = (address: string, ss58Format?: number): boolean => { + const decoded = tryDecodeAddress(address, ss58Format); + + if (decoded?.length !== ACCOUNT_ID_LENGTH) { + return false; + } + + return decoded.subarray(H160_LENGTH).every(byte => byte === 0xee); +}; + +/** + * Recovers the checksummed Ethereum address from a `0xEE` padded SS58 account + * + * @throws if the address is not Ethereum derived + */ +export const ethAddressFromSs58 = (address: string, ss58Format?: number): string => { + if (!isEthDerivedAddress(address, ss58Format)) { + throw new Error(`"${address}" is not an Ethereum derived address`); + } + + return ethereumEncode(decodeAddress(address, false, ss58Format).subarray(0, H160_LENGTH)); +}; + +/** + * Returns the checksummed H160 that `pallet_revive` addresses the given account by. + * + * Mirrors `AccountId32Mapper::to_address` - Ethereum derived accounts drop their `0xEE` padding, + * every other account is hashed and truncated + */ +export const evmAddressFromSs58 = (address: string, ss58Format?: number): string | undefined => { + const decoded = tryDecodeAddress(address, ss58Format); + + if (decoded?.length !== ACCOUNT_ID_LENGTH) { + return undefined; + } + + if (decoded.subarray(H160_LENGTH).every(byte => byte === 0xee)) { + return ethereumEncode(decoded.subarray(0, H160_LENGTH)); + } + + return ethereumEncode(keccakAsU8a(decoded).subarray(KECCAK_ADDRESS_OFFSET)); +}; diff --git a/src/utils/ethExtrinsic.ts b/src/utils/ethExtrinsic.ts new file mode 100644 index 00000000..e7530b56 --- /dev/null +++ b/src/utils/ethExtrinsic.ts @@ -0,0 +1,209 @@ +import { GenericCall } from '@polkadot/types'; +import { ethereumEncode } from '@polkadot/util-crypto'; +import { SubstrateExtrinsic } from '@subql/types'; +import { EvmCallKindEnum } from '../types'; +import { camelToSnakeCase } from './common'; +import { RUNTIME_PALLETS_ADDR, ss58FromEthAddress } from './eth'; +import { DecodedEthTx, decodeEthTransaction, ethTxHash, recoverEthSender } from './ethTransaction'; + +export interface ResolvedEthTransact { + tx: DecodedEthTx; + ethTxHash: string; + /** checksummed address of the Ethereum key that signed the transaction */ + fromEthAddress: string; + /** SS58 encoding of the `0xEE` padded account the runtime dispatches the call as */ + fromAddress: string; + callKind: EvmCallKindEnum; + /** the pallet that is actually dispatched, normalised for `ModuleIdEnum` */ + moduleId: string; + /** the call that is actually dispatched, normalised for `CallIdEnum` */ + callId: string; + paramsTxt: string; + reverted: boolean; + revertReason?: string; + /** address of the deployed contract, taken from `revive.Instantiated` */ + contractAddress?: string; +} + +/** + * `revive.eth_transact` is included in the block as a bare extrinsic carrying the RLP encoded + * Ethereum transaction. The runtime transforms it into `revive.eth_call`, + * `revive.eth_instantiate_with_code` or `revive.eth_substrate_call` while checking the extrinsic, + * so the dispatched call only exists in memory and has to be recovered from the payload + */ +export const isEthTransact = (extrinsic?: SubstrateExtrinsic): boolean => + extrinsic?.extrinsic.method.section === 'revive' && + camelToSnakeCase(extrinsic.extrinsic.method.method) === 'eth_transact'; + +/** + * The Ethereum transaction envelope, for the calls that have no runtime call to describe. + * + * `input` is deliberately left out. It is the contract's init code for a deployment, which can run + * to hundreds of kilobytes, and `extrinsics.params_txt` is additionally materialised into the + * `params` jsonb column by `db/compat.sql`. `EvmTransaction.input` holds it once, under the same id + */ +const extractEthTxParams = (tx: DecodedEthTx) => + JSON.stringify({ + to: tx.to ?? null, + value: tx.value.toString(), + gasLimit: tx.gasLimit.toString(), + nonce: tx.nonce.toString(), + }); + +const resolveDispatchedCall = ( + extrinsic: SubstrateExtrinsic, + tx: DecodedEthTx +): Pick => { + if (tx.to?.toLowerCase() === RUNTIME_PALLETS_ADDR) { + try { + /** + * The calldata is passed as hex rather than bytes. The registry belongs to the host realm, + * so a `Uint8Array` built in the sandbox would not pass its own `instanceof` check + */ + const call = extrinsic.extrinsic.registry.createType( + 'Call', + tx.data + ) as unknown as GenericCall; + + return { + callKind: EvmCallKindEnum.substrateCall, + moduleId: call.section.toLowerCase(), + callId: camelToSnakeCase(call.method), + paramsTxt: JSON.stringify((call.toHuman() as any).args), + }; + } catch (e) { + logger.error(`Unable to decode the runtime call of an eth_transact extrinsic: ${e.message}`); + + return { + callKind: EvmCallKindEnum.substrateCall, + moduleId: 'revive', + callId: 'eth_substrate_call', + paramsTxt: extractEthTxParams(tx), + }; + } + } + + if (tx.to === undefined) { + return { + callKind: EvmCallKindEnum.instantiate, + moduleId: 'revive', + callId: 'eth_instantiate_with_code', + paramsTxt: extractEthTxParams(tx), + }; + } + + return { + callKind: EvmCallKindEnum.call, + moduleId: 'revive', + callId: 'eth_call', + paramsTxt: extractEthTxParams(tx), + }; +}; + +/** + * An Ethereum transaction always completes successfully at the extrinsic level, since even a + * reverted call has to store its receipt. `revive.EthExtrinsicRevert` is the only signal that it + * actually failed + */ +const extractOutcome = ( + extrinsic: SubstrateExtrinsic +): Pick => { + let reverted = false; + let revertReason: string; + let contractAddress: string; + + extrinsic.events.forEach(({ event }) => { + if (event.section !== 'revive') { + return; + } + if (event.method === 'EthExtrinsicRevert') { + reverted = true; + revertReason = JSON.stringify(event.data[0]?.toHuman()); + } + if (event.method === 'Instantiated') { + /** + * `H160.toString()` renders lower cased hex. Every other address this indexer stores is + * EIP-55 checksummed, and equality filters are case sensitive, so it has to be normalised + * for a query to be able to join this against `Account.evmAddress` or `toEthAddress` + */ + const raw = event.data[1]?.toString(); + contractAddress = raw ? ethereumEncode(raw) : undefined; + } + }); + + return { reverted, revertReason, contractAddress }; +}; + +let memoBlockId = ''; +const memo = new Map(); + +/** + * Decodes the Ethereum transaction wrapped by a `revive.eth_transact` extrinsic. + * + * Results are memoized per block, since recovering the signer is comparatively expensive and this + * is called once for the extrinsic and again for every event it emitted + */ +export const resolveEthTransact = ( + extrinsic: SubstrateExtrinsic +): ResolvedEthTransact | undefined => { + const blockId = extrinsic.block.block.header.number.toString(); + + if (memoBlockId !== blockId) { + memoBlockId = blockId; + memo.clear(); + } + + if (memo.has(extrinsic.idx)) { + return memo.get(extrinsic.idx); + } + + const resolved = decodeEthTransact(extrinsic); + memo.set(extrinsic.idx, resolved); + + return resolved; +}; + +const decodeEthTransact = (extrinsic: SubstrateExtrinsic): ResolvedEthTransact | undefined => { + const [payload] = extrinsic.extrinsic.args; + + if (!payload) { + return undefined; + } + + /** + * Normalise the bytes into this realm as they cross the sandbox boundary. `u8aToU8a`, which + * every `@polkadot/util-crypto` helper runs its input through, does not recognise a foreign + * `Uint8Array` and silently produces the wrong bytes for it + */ + const raw = Uint8Array.from(payload.toU8a(true)); + const tx = decodeEthTransaction(raw); + + if (!tx) { + logger.error( + `Unable to decode the payload of the eth_transact extrinsic at ${extrinsic.block.block.header.number.toString()}/${ + extrinsic.idx + }` + ); + return undefined; + } + + const fromEthAddress = recoverEthSender(tx); + + if (!fromEthAddress) { + logger.error( + `Unable to recover the signer of the eth_transact extrinsic at ${extrinsic.block.block.header.number.toString()}/${ + extrinsic.idx + }` + ); + return undefined; + } + + return { + tx, + ethTxHash: ethTxHash(raw), + fromEthAddress, + fromAddress: ss58FromEthAddress(fromEthAddress, extrinsic.extrinsic.registry.chainSS58), + ...resolveDispatchedCall(extrinsic, tx), + ...extractOutcome(extrinsic), + }; +}; diff --git a/src/utils/ethTransaction.ts b/src/utils/ethTransaction.ts new file mode 100644 index 00000000..222cfbd6 --- /dev/null +++ b/src/utils/ethTransaction.ts @@ -0,0 +1,262 @@ +import { RLP } from '@ethereumjs/rlp'; +import { u8aConcat, u8aToHex } from '@polkadot/util'; +import { ethereumEncode, keccakAsHex, keccakAsU8a, secp256k1Recover } from '@polkadot/util-crypto'; + +export const ETH_TX_TYPE_LEGACY = 0; +export const ETH_TX_TYPE_EIP2930 = 1; +export const ETH_TX_TYPE_EIP1559 = 2; + +export interface DecodedEthTx { + /** 0 = legacy, 1 = EIP-2930, 2 = EIP-1559. EIP-4844/7702 are rejected by the chain */ + txType: 0 | 1 | 2; + chainId?: bigint; + nonce: bigint; + /** `undefined` for a contract deployment */ + to?: string; + value: bigint; + /** hex encoded calldata. For a runtime call this is a SCALE encoded `RuntimeCall` */ + data: string; + gasLimit: bigint; + gasPrice?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + /** keccak256 of the signing payload, i.e. the message the signature covers */ + sigHash: Uint8Array; + signature: Uint8Array; + recovery: number; +} + +const SIGNATURE_COMPONENT_LENGTH = 32; +const H160_LENGTH = 20; + +/** + * Mappings run inside a vm2 sandbox, where an `instanceof Uint8Array` check against the bundle's + * own realm does not hold for values that crossed the boundary. `ArrayBuffer.isView` inspects the + * internal slot instead, so it works across realms + */ +const isBytes = (item: unknown): item is Uint8Array => ArrayBuffer.isView(item); + +const toBigInt = (item: Uint8Array): bigint => { + if (item.length === 0) { + return BigInt(0); + } + return BigInt(u8aToHex(item)); +}; + +const toAddress = (item: Uint8Array): string | undefined => + item.length === H160_LENGTH ? ethereumEncode(item) : undefined; + +const padSignatureComponent = (item: Uint8Array): Uint8Array => { + const padded = new Uint8Array(SIGNATURE_COMPONENT_LENGTH); + padded.set(item, SIGNATURE_COMPONENT_LENGTH - item.length); + return padded; +}; + +/** + * The hash an Ethereum wallet reports for a transaction, and the hash `pallet_revive` uses when + * building the ethereum block. See `evm/block_hash/block_builder.rs` + */ +export const ethTxHash = (payload: Uint8Array): string => keccakAsHex(Uint8Array.from(payload)); + +const decodeLegacy = (payload: Uint8Array): DecodedEthTx | undefined => { + const fields = RLP.decode(payload); + + if (!Array.isArray(fields) || fields.length !== 9 || !fields.every(isBytes)) { + return undefined; + } + + const [nonce, gasPrice, gasLimit, to, value, data, v, r, s] = fields as Uint8Array[]; + + const rawV = toBigInt(v); + const unsigned = [nonce, gasPrice, gasLimit, to, value, data]; + + let chainId: bigint | undefined; + let recovery: number; + let sigHash: Uint8Array; + + if (rawV === BigInt(27) || rawV === BigInt(28)) { + // pre EIP-155, the signature covers the transaction fields only + recovery = Number(rawV) - 27; + sigHash = keccakAsU8a(RLP.encode(unsigned)); + } else if (rawV >= BigInt(35)) { + chainId = (rawV - BigInt(35)) / BigInt(2); + recovery = Number((rawV - BigInt(35)) % BigInt(2)); + sigHash = keccakAsU8a(RLP.encode([...unsigned, chainId, new Uint8Array(), new Uint8Array()])); + } else { + return undefined; + } + + return { + txType: ETH_TX_TYPE_LEGACY, + chainId, + nonce: toBigInt(nonce), + to: toAddress(to), + value: toBigInt(value), + data: u8aToHex(data), + gasLimit: toBigInt(gasLimit), + gasPrice: toBigInt(gasPrice), + sigHash, + signature: u8aConcat(padSignatureComponent(r), padSignatureComponent(s)), + recovery, + }; +}; + +const decodeEip2930 = (payload: Uint8Array): DecodedEthTx | undefined => { + const fields = RLP.decode(payload.subarray(1)); + + if (!Array.isArray(fields) || fields.length !== 11) { + return undefined; + } + + const [chainId, nonce, gasPrice, gasLimit, to, value, data, accessList, yParity, r, s] = fields; + + if (![chainId, nonce, gasPrice, gasLimit, to, value, data, yParity, r, s].every(isBytes)) { + return undefined; + } + + const unsigned = [chainId, nonce, gasPrice, gasLimit, to, value, data, accessList]; + + return { + txType: ETH_TX_TYPE_EIP2930, + chainId: toBigInt(chainId as Uint8Array), + nonce: toBigInt(nonce as Uint8Array), + to: toAddress(to as Uint8Array), + value: toBigInt(value as Uint8Array), + data: u8aToHex(data as Uint8Array), + gasLimit: toBigInt(gasLimit as Uint8Array), + gasPrice: toBigInt(gasPrice as Uint8Array), + sigHash: keccakAsU8a(u8aConcat(new Uint8Array([ETH_TX_TYPE_EIP2930]), RLP.encode(unsigned))), + signature: u8aConcat( + padSignatureComponent(r as Uint8Array), + padSignatureComponent(s as Uint8Array) + ), + recovery: Number(toBigInt(yParity as Uint8Array)), + }; +}; + +const decodeEip1559 = (payload: Uint8Array): DecodedEthTx | undefined => { + const fields = RLP.decode(payload.subarray(1)); + + if (!Array.isArray(fields) || fields.length !== 12) { + return undefined; + } + + const [ + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + to, + value, + data, + accessList, + yParity, + r, + s, + ] = fields; + + const scalars = [ + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + to, + value, + data, + yParity, + r, + s, + ]; + + if (!scalars.every(isBytes)) { + return undefined; + } + + const unsigned = [ + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + to, + value, + data, + accessList, + ]; + + return { + txType: ETH_TX_TYPE_EIP1559, + chainId: toBigInt(chainId as Uint8Array), + nonce: toBigInt(nonce as Uint8Array), + to: toAddress(to as Uint8Array), + value: toBigInt(value as Uint8Array), + data: u8aToHex(data as Uint8Array), + gasLimit: toBigInt(gasLimit as Uint8Array), + maxFeePerGas: toBigInt(maxFeePerGas as Uint8Array), + maxPriorityFeePerGas: toBigInt(maxPriorityFeePerGas as Uint8Array), + sigHash: keccakAsU8a(u8aConcat(new Uint8Array([ETH_TX_TYPE_EIP1559]), RLP.encode(unsigned))), + signature: u8aConcat( + padSignatureComponent(r as Uint8Array), + padSignatureComponent(s as Uint8Array) + ), + recovery: Number(toBigInt(yParity as Uint8Array)), + }; +}; + +/** + * Decodes the RLP payload of a `revive.ethTransact` extrinsic. + * + * Returns `undefined` for anything that cannot be decoded, including the EIP-4844 and EIP-7702 + * transaction types, which the runtime rejects before they can be included in a block + */ +export const decodeEthTransaction = (rawPayload: Uint8Array): DecodedEthTx | undefined => { + if (rawPayload.length === 0) { + return undefined; + } + + /** + * Mappings run inside a vm2 sandbox, so the bytes handed to us belong to a different realm than + * the bundled RLP codec, whose own `instanceof Uint8Array` checks would reject them. Copying + * into a local array makes every value derived from it, including the decoded fields that are + * re-encoded to rebuild the signing payload, safe to pass back in + */ + const payload = Uint8Array.from(rawPayload); + + try { + const firstByte = payload[0]; + + // EIP-2718 typed transactions use a type identifier in [0x00, 0x7f] + if (firstByte > 0x7f) { + return decodeLegacy(payload); + } + if (firstByte === ETH_TX_TYPE_EIP2930) { + return decodeEip2930(payload); + } + if (firstByte === ETH_TX_TYPE_EIP1559) { + return decodeEip1559(payload); + } + + return undefined; + } catch (e) { + logger.error(`Unable to RLP decode an Ethereum transaction: ${e.message}`); + return undefined; + } +}; + +/** + * Recovers the checksummed Ethereum address that signed the transaction + */ +export const recoverEthSender = (tx: DecodedEthTx): string | undefined => { + if (tx.recovery !== 0 && tx.recovery !== 1) { + return undefined; + } + + try { + return ethereumEncode(secp256k1Recover(tx.sigHash, tx.signature, tx.recovery)); + } catch (e) { + logger.error(`Unable to recover the signer of an Ethereum transaction: ${e.message}`); + return undefined; + } +}; diff --git a/src/utils/events.ts b/src/utils/events.ts index 076fca54..9534547e 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -2,6 +2,7 @@ import { HandlerArgs, toEnum } from '../mappings/entities/common'; import { CallIdEnum, EventIdEnum, ModuleIdEnum } from '../types'; import { JSONStringifyExceptStringAndNull, camelToSnakeCase, padId } from './common'; +import { isEthTransact, resolveEthTransact } from './ethExtrinsic'; export const extractEventArg = (arg: any, exists: boolean) => { if (arg !== undefined && arg !== null && arg?.value != null) { @@ -56,7 +57,13 @@ export const getEventParams = (args: HandlerArgs): EventParams => { let callId: CallIdEnum | undefined; let callIdText: string | undefined; if (extrinsic) { - callIdText = camelToSnakeCase(extrinsic.extrinsic.method.method); + /** + * An `eth_transact` extrinsic is a wrapper, so the call it was transformed into is used + * instead. It is memoized per extrinsic, so resolving it once per event is cheap + */ + const resolved = isEthTransact(extrinsic) ? resolveEthTransact(extrinsic) : undefined; + + callIdText = resolved?.callId ?? camelToSnakeCase(extrinsic.extrinsic.method.method); callId = toEnum(CallIdEnum, callIdText, CallIdEnum.unknown); } diff --git a/src/utils/index.ts b/src/utils/index.ts index 58aafeb0..86330408 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,9 @@ export * from './claims'; export * from './assets'; export * from './accounts'; export * from './distributions'; +export * from './eth'; +export * from './ethExtrinsic'; +export * from './ethTransaction'; export * from './events'; export * from './multisigs'; export * from './portfolios'; diff --git a/tests/unit/eth.test.ts b/tests/unit/eth.test.ts new file mode 100644 index 00000000..9787c2f0 --- /dev/null +++ b/tests/unit/eth.test.ts @@ -0,0 +1,71 @@ +import { + ethAddressFromSs58, + evmAddressFromSs58, + isEthDerivedAddress, + ss58FromEthAddress, +} from '../../src/utils/eth'; + +/** + * Vectors are mirrored from `@polymeshassociation/eth-signing-manager` and the Polymesh SDK's + * `src/utils/eth.ts`. If any of these drift the three implementations no longer agree on which + * account an Ethereum key acts as + */ +const ETH_ADDRESS = '0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac'; +const SS58_ADDRESS = '5HYRCKHYJN9z5xUtfFkyMj4JUhsAwWyvuU8vKB1FcnYTf9ZQ'; +const ALICE = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; + +describe('ss58FromEthAddress', () => { + it('should pad an Ethereum address with 0xEE bytes', () => { + expect(ss58FromEthAddress(ETH_ADDRESS, 42)).toEqual(SS58_ADDRESS); + }); + + it('should encode with the given SS58 format', () => { + expect(ss58FromEthAddress(ETH_ADDRESS, 12)).not.toEqual(SS58_ADDRESS); + expect(ethAddressFromSs58(ss58FromEthAddress(ETH_ADDRESS, 12), 12)).toEqual(ETH_ADDRESS); + }); + + it('should accept a lower cased address and return a checksummed one', () => { + expect(ethAddressFromSs58(ss58FromEthAddress(ETH_ADDRESS.toLowerCase(), 42), 42)).toEqual( + ETH_ADDRESS + ); + }); +}); + +describe('isEthDerivedAddress', () => { + it('should detect 0xEE padded accounts', () => { + expect(isEthDerivedAddress(SS58_ADDRESS, 42)).toBe(true); + }); + + it('should return false for a regular substrate account', () => { + expect(isEthDerivedAddress(ALICE, 42)).toBe(false); + }); + + it('should return false for an undecodable address', () => { + expect(isEthDerivedAddress('not an address', 42)).toBe(false); + }); +}); + +describe('ethAddressFromSs58', () => { + it('should strip the 0xEE padding', () => { + expect(ethAddressFromSs58(SS58_ADDRESS, 42)).toEqual(ETH_ADDRESS); + }); + + it('should throw for a non Ethereum derived address', () => { + expect(() => ethAddressFromSs58(ALICE, 42)).toThrow('is not an Ethereum derived address'); + }); +}); + +describe('evmAddressFromSs58', () => { + it('should strip the padding for an Ethereum derived account', () => { + expect(evmAddressFromSs58(SS58_ADDRESS, 42)).toEqual(ETH_ADDRESS); + }); + + it('should hash a regular substrate account', () => { + // keccak256()[12..], matching `AccountId32Mapper::to_address` + expect(evmAddressFromSs58(ALICE, 42)).toEqual('0x9621DDe636dE098B43Efb0fA9b61fAcFE328F99D'); + }); + + it('should return undefined for an undecodable address', () => { + expect(evmAddressFromSs58('not an address', 42)).toBeUndefined(); + }); +}); diff --git a/tests/unit/ethExtrinsic.test.ts b/tests/unit/ethExtrinsic.test.ts new file mode 100644 index 00000000..3f968096 --- /dev/null +++ b/tests/unit/ethExtrinsic.test.ts @@ -0,0 +1,186 @@ +import { hexToU8a } from '@polkadot/util'; +import { SubstrateExtrinsic } from '@subql/types'; +import { EvmCallKindEnum } from '../../src/types'; +import { isEthTransact, resolveEthTransact } from '../../src/utils/ethExtrinsic'; + +const FROM_ETH = '0x2c7536E3605D9C16a7a3D7b1898e529396a65c23'; +/** `FROM_ETH` padded with 12 `0xEE` bytes and SS58 encoded with the Polymesh prefix */ +const FROM_SS58 = '2DTD2fX3yEHNTRURNtwZS9r9hqzFUNmzoMK1t9WWMauH9wkc'; + +const fixtures = { + runtimeCall: + '0x02f87083190d5a07808477359400825208946d6f646c70792f7061646472000000000000000080871a0400d1070000c080a01ad9580e471e8fd43d716a5a772b0b3191282b2dc425fcefd0eb774980462976a005d32e0074b587c871dd0fe6a8e53572798570d2146410790e0d7ea19f94961b', + deploy: + '0x02f85983190d5a07808477359400825208808084deadbeefc001a0678b44834b3ccc478253db225af42f9fc25d27e18453338d164b3ce62816aa4fa01c8f9951ee084d5e707172f7c5bce88b578db99d730c4d19e972aa4c487adc0e', + contractCall: + '0x02f86e83190d5a0780847735940082520894111111111111111111111111111111111111111182303983abcdefc001a0e1b9d314aef5057b2802806da131c5ba18badfac2d8fa4566833dddc6612de41a077ac570d496b96b87039d116fff59777299856720536ba683e6a6014be3b0f6d', +}; + +interface MockOptions { + section?: string; + method?: string; + payload?: string; + events?: { section: string; method: string; data: any[] }[]; + /** the call `registry.createType('Call', ...)` resolves the runtime call calldata to */ + innerCall?: { section: string; method: string; args: Record }; + blockNumber?: number; + idx?: number; +} + +const mockExtrinsic = ({ + section = 'revive', + method = 'ethTransact', + payload = fixtures.runtimeCall, + events = [], + innerCall, + blockNumber = 1000, + idx = 1, +}: MockOptions = {}): SubstrateExtrinsic => { + const registry = { + chainSS58: 12, + createType: (): unknown => { + if (!innerCall) { + throw new Error('unable to decode'); + } + return { + section: innerCall.section, + method: innerCall.method, + toHuman: () => ({ args: innerCall.args }), + }; + }, + }; + + return { + idx, + success: true, + events: events.map(({ section: s, method: m, data }) => ({ + event: { section: s, method: m, data }, + })), + block: { + block: { header: { number: { toString: () => `${blockNumber}` } } }, + }, + extrinsic: { + registry, + method: { section, method }, + args: [{ toU8a: () => hexToU8a(payload) }], + }, + } as unknown as SubstrateExtrinsic; +}; + +describe('isEthTransact', () => { + it('should identify a revive.ethTransact extrinsic', () => { + expect(isEthTransact(mockExtrinsic())).toBe(true); + }); + + it('should reject other revive calls and other pallets', () => { + expect(isEthTransact(mockExtrinsic({ method: 'ethCall' }))).toBe(false); + expect(isEthTransact(mockExtrinsic({ section: 'utility', method: 'batch' }))).toBe(false); + expect(isEthTransact(undefined)).toBe(false); + }); +}); + +describe('resolveEthTransact', () => { + it('should attribute the transaction to the signing Ethereum key', () => { + const resolved = resolveEthTransact(mockExtrinsic({ innerCall: RUNTIME_CALL })); + + expect(resolved.fromEthAddress).toEqual(FROM_ETH); + expect(resolved.fromAddress).toEqual(FROM_SS58); + expect(resolved.ethTxHash).toEqual( + '0xc5a3bbd37bc19ccfc6d8956f68fd13cfa397a7ed40b956c9414e2e9ad4707500' + ); + }); + + it('should normalise a runtime call to the pallet and call it dispatches', () => { + const resolved = resolveEthTransact(mockExtrinsic({ innerCall: RUNTIME_CALL })); + + expect(resolved.callKind).toEqual(EvmCallKindEnum.substrateCall); + expect(resolved.moduleId).toEqual('asset'); + expect(resolved.callId).toEqual('issue'); + expect(resolved.paramsTxt).toEqual(JSON.stringify(RUNTIME_CALL.args)); + }); + + it('should fall back to eth_substrate_call when the runtime call cannot be decoded', () => { + const resolved = resolveEthTransact(mockExtrinsic({ idx: 2 })); + + expect(resolved.callKind).toEqual(EvmCallKindEnum.substrateCall); + expect(resolved.moduleId).toEqual('revive'); + expect(resolved.callId).toEqual('eth_substrate_call'); + }); + + it('should resolve a contract deployment', () => { + const resolved = resolveEthTransact( + mockExtrinsic({ + idx: 3, + payload: fixtures.deploy, + events: [{ section: 'revive', method: 'Instantiated', data: [FROM_ETH, CONTRACT_ADDRESS] }], + }) + ); + + expect(resolved.callKind).toEqual(EvmCallKindEnum.instantiate); + expect(resolved.moduleId).toEqual('revive'); + expect(resolved.callId).toEqual('eth_instantiate_with_code'); + expect(resolved.contractAddress).toEqual(CONTRACT_ADDRESS); + expect(resolved.tx.to).toBeUndefined(); + }); + + it('should resolve a contract call', () => { + const resolved = resolveEthTransact(mockExtrinsic({ idx: 4, payload: fixtures.contractCall })); + + expect(resolved.callKind).toEqual(EvmCallKindEnum.call); + expect(resolved.moduleId).toEqual('revive'); + expect(resolved.callId).toEqual('eth_call'); + expect(resolved.reverted).toBe(false); + expect(resolved.revertReason).toBeUndefined(); + }); + + it('should flag a reverted transaction', () => { + const resolved = resolveEthTransact( + mockExtrinsic({ + idx: 5, + payload: fixtures.contractCall, + events: [ + { + section: 'revive', + method: 'EthExtrinsicRevert', + data: [{ toHuman: () => ({ Module: { index: '80', error: '0x0c000000' } }) }], + }, + ], + }) + ); + + expect(resolved.reverted).toBe(true); + expect(resolved.revertReason).toEqual( + JSON.stringify({ Module: { index: '80', error: '0x0c000000' } }) + ); + }); + + it('should memoize per extrinsic and evict when the block changes', () => { + const createType = jest.fn().mockReturnValue({ + section: 'asset', + method: 'issue', + toHuman: () => ({ args: {} }), + }); + const extrinsic = mockExtrinsic({ idx: 6, innerCall: RUNTIME_CALL }); + (extrinsic.extrinsic.registry as any).createType = createType; + + resolveEthTransact(extrinsic); + resolveEthTransact(extrinsic); + expect(createType).toHaveBeenCalledTimes(1); + + const nextBlock = mockExtrinsic({ idx: 6, blockNumber: 1001, innerCall: RUNTIME_CALL }); + (nextBlock.extrinsic.registry as any).createType = createType; + resolveEthTransact(nextBlock); + expect(createType).toHaveBeenCalledTimes(2); + }); + + it('should return undefined for an undecodable payload', () => { + expect(resolveEthTransact(mockExtrinsic({ idx: 7, payload: '0xdeadbeef' }))).toBeUndefined(); + }); +}); + +const CONTRACT_ADDRESS = '0x9621DDe636dE098B43Efb0fA9b61fAcFE328F99D'; +const RUNTIME_CALL = { + section: 'asset', + method: 'issue', + args: { asset_id: '0x1234', amount: '1,000' }, +}; diff --git a/tests/unit/ethTransaction.test.ts b/tests/unit/ethTransaction.test.ts new file mode 100644 index 00000000..12fa0261 --- /dev/null +++ b/tests/unit/ethTransaction.test.ts @@ -0,0 +1,121 @@ +import { hexToU8a } from '@polkadot/util'; +import { decodeEthTransaction, ethTxHash, recoverEthSender } from '../../src/utils/ethTransaction'; + +/** + * All fixtures are signed by the well known key + * `0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318` and were produced with + * `viem`/`ethers`, so recovering this address proves the signing payload is reconstructed correctly + */ +const FROM = '0x2c7536E3605D9C16a7a3D7b1898e529396a65c23'; +const RUNTIME_PALLETS_ADDR = '0x6D6f646c70792F70616464720000000000000000'; +const CHAIN_ID = BigInt(1641818); + +/** SCALE encoded runtime call used as the calldata of the runtime call fixtures */ +const RUNTIME_CALL_DATA = '0x1a0400d1070000'; + +const fixtures = { + /** legacy, pre EIP-155 (v = 28, no chain id) */ + preEip155: + '0xf86a07843b9aca00825208946d6f646c70792f7061646472000000000000000080871a0400d10700001ca0fc002ddd590397eb57d80accad2187139a588efec1c0e1690f204630df6a9f9da07555591dd0cdf2c438a8d7c09aadf3a1315d581867d14be2875b3289a6fc8754', + /** legacy, EIP-155 protected */ + legacy: + '0xf86d07843b9aca00825208946d6f646c70792f7061646472000000000000000080871a0400d107000083321ad7a09cccde7ed1a91a9ccb998fda4e84905a535c04038e89c238e8cd01dd179afd79a039f778f34a3fe7dee7e904339f1717cbb230499a9b3a809c0f90a2940792357a', + /** EIP-2930 with an empty access list */ + eip2930: + '0x01f86f83190d5a07843b9aca00825208946d6f646c70792f7061646472000000000000000080871a0400d1070000c001a0061b3f7b36bfe94ac9efac1f84c1396d20b7d142f7b67057f847b15315bad211a0650fedb72ed5a3212585008fb0d35d8d873d53964321ebd8e13374ffdabb34e8', + /** EIP-2930 with a populated access list, which must be re-encoded verbatim */ + eip2930AccessList: + '0x01f8a883190d5a07843b9aca00825208946d6f646c70792f7061646472000000000000000080871a0400d1070000f838f7941111111111111111111111111111111111111111e1a0222222222222222222222222222222222222222222222222222222222222222280a0033705865ce8025fa872a1f08012f758da539edc676322c57790be03293f8337a07cfda1455f125dcfd331cfcce0989a74061747e53bdadfc95e9aa9925fc920dd', + /** EIP-1559 */ + eip1559: + '0x02f87083190d5a07808477359400825208946d6f646c70792f7061646472000000000000000080871a0400d1070000c080a01ad9580e471e8fd43d716a5a772b0b3191282b2dc425fcefd0eb774980462976a005d32e0074b587c871dd0fe6a8e53572798570d2146410790e0d7ea19f94961b', + /** EIP-1559 contract deployment, `to` is absent */ + deploy: + '0x02f85983190d5a07808477359400825208808084deadbeefc001a0678b44834b3ccc478253db225af42f9fc25d27e18453338d164b3ce62816aa4fa01c8f9951ee084d5e707172f7c5bce88b578db99d730c4d19e972aa4c487adc0e', + /** EIP-1559 contract call carrying a non zero value */ + contract: + '0x02f86e83190d5a0780847735940082520894111111111111111111111111111111111111111182303983abcdefc001a0e1b9d314aef5057b2802806da131c5ba18badfac2d8fa4566833dddc6612de41a077ac570d496b96b87039d116fff59777299856720536ba683e6a6014be3b0f6d', +}; + +describe('decodeEthTransaction + recoverEthSender', () => { + it.each(Object.entries(fixtures))('should recover the signer of the %s fixture', (_, raw) => { + const tx = decodeEthTransaction(hexToU8a(raw)); + + expect(tx).toBeDefined(); + expect(recoverEthSender(tx)).toEqual(FROM); + }); + + it('should decode a pre EIP-155 legacy transaction without a chain id', () => { + const tx = decodeEthTransaction(hexToU8a(fixtures.preEip155)); + + expect(tx.txType).toEqual(0); + expect(tx.chainId).toBeUndefined(); + expect(tx.gasPrice).toEqual(BigInt(1000000000)); + }); + + it('should decode a legacy transaction', () => { + const tx = decodeEthTransaction(hexToU8a(fixtures.legacy)); + + expect(tx.txType).toEqual(0); + expect(tx.chainId).toEqual(CHAIN_ID); + expect(tx.nonce).toEqual(BigInt(7)); + expect(tx.to).toEqual(RUNTIME_PALLETS_ADDR); + expect(tx.value).toEqual(BigInt(0)); + expect(tx.data).toEqual(RUNTIME_CALL_DATA); + expect(tx.gasLimit).toEqual(BigInt(21000)); + expect(tx.gasPrice).toEqual(BigInt(1000000000)); + expect(tx.maxFeePerGas).toBeUndefined(); + }); + + it('should decode an EIP-2930 transaction', () => { + const tx = decodeEthTransaction(hexToU8a(fixtures.eip2930)); + + expect(tx.txType).toEqual(1); + expect(tx.chainId).toEqual(CHAIN_ID); + expect(tx.gasPrice).toEqual(BigInt(1000000000)); + expect(tx.data).toEqual(RUNTIME_CALL_DATA); + }); + + it('should decode an EIP-1559 transaction', () => { + const tx = decodeEthTransaction(hexToU8a(fixtures.eip1559)); + + expect(tx.txType).toEqual(2); + expect(tx.chainId).toEqual(CHAIN_ID); + expect(tx.maxFeePerGas).toEqual(BigInt(2000000000)); + expect(tx.maxPriorityFeePerGas).toEqual(BigInt(0)); + expect(tx.gasPrice).toBeUndefined(); + }); + + it('should leave `to` undefined for a contract deployment', () => { + const tx = decodeEthTransaction(hexToU8a(fixtures.deploy)); + + expect(tx.to).toBeUndefined(); + expect(tx.data).toEqual('0xdeadbeef'); + }); + + it('should decode a value bearing contract call', () => { + const tx = decodeEthTransaction(hexToU8a(fixtures.contract)); + + expect(tx.to).toEqual('0x1111111111111111111111111111111111111111'); + expect(tx.value).toEqual(BigInt(12345)); + expect(tx.data).toEqual('0xabcdef'); + }); + + it('should return undefined for unsupported and malformed payloads', () => { + // EIP-4844 and EIP-7702 are rejected by the runtime before inclusion + expect(decodeEthTransaction(hexToU8a('0x03c0'))).toBeUndefined(); + expect(decodeEthTransaction(hexToU8a('0x04c0'))).toBeUndefined(); + expect(decodeEthTransaction(new Uint8Array())).toBeUndefined(); + expect(decodeEthTransaction(hexToU8a('0xdeadbeef'))).toBeUndefined(); + // a legacy transaction with an invalid `v` + expect(decodeEthTransaction(hexToU8a('0xc9808080808080808080'))).toBeUndefined(); + }); +}); + +describe('ethTxHash', () => { + it('should be the keccak256 of the raw payload', () => { + expect(ethTxHash(hexToU8a(fixtures.eip1559))).toEqual( + '0xc5a3bbd37bc19ccfc6d8956f68fd13cfa397a7ed40b956c9414e2e9ad4707500' + ); + }); +}); diff --git a/tests/unit/mapExtrinsic.test.ts b/tests/unit/mapExtrinsic.test.ts new file mode 100644 index 00000000..faf4d677 --- /dev/null +++ b/tests/unit/mapExtrinsic.test.ts @@ -0,0 +1,326 @@ +import { hexToU8a } from '@polkadot/util'; +import { SubstrateExtrinsic } from '@subql/types'; +import { createExtrinsic, handleExtrinsic } from '../../src/mappings/entities/block/mapExtrinsic'; + +/** signed by 0x4c0883a6...2318, whose address is `FROM_ETH` */ +const FROM_ETH = '0x2c7536E3605D9C16a7a3D7b1898e529396a65c23'; +const FROM_SS58 = '2DTD2fX3yEHNTRURNtwZS9r9hqzFUNmzoMK1t9WWMauH9wkc'; + +const payloads = { + /** EIP-1559 to the `modlpy/paddr` runtime pallets address, nonce 7 */ + runtimeCall: + '0x02f87083190d5a07808477359400825208946d6f646c70792f7061646472000000000000000080871a0400d1070000c080a01ad9580e471e8fd43d716a5a772b0b3191282b2dc425fcefd0eb774980462976a005d32e0074b587c871dd0fe6a8e53572798570d2146410790e0d7ea19f94961b', + /** EIP-1559 contract deployment carrying `0xdeadbeef` as init code */ + deploy: + '0x02f85983190d5a07808477359400825208808084deadbeefc001a0678b44834b3ccc478253db225af42f9fc25d27e18453338d164b3ce62816aa4fa01c8f9951ee084d5e707172f7c5bce88b578db99d730c4d19e972aa4c487adc0e', +}; + +const INNER_CALL = { section: 'asset', method: 'issue', args: { asset_id: '0x1234' } }; + +interface MockOptions { + section?: string; + method?: string; + payload?: string; + events?: { section: string; method: string; data: any[] }[]; + success?: boolean; + innerCall?: { section: string; method: string; args: Record }; + idx?: number; + blockNumber?: number; + signer?: string; + ss58Format?: number; +} + +/** + * `resolveEthTransact` memoizes per `block/extrinsicIdx`, which uniquely identifies an extrinsic on + * chain but not across tests, so each mock gets its own index + */ +let nextIdx = 1; + +const mockExtrinsic = ({ + section = 'revive', + method = 'ethTransact', + payload = payloads.runtimeCall, + events = [], + success = true, + innerCall, + idx = nextIdx++, + blockNumber = 4242, + signer = '', + ss58Format = 12, +}: MockOptions = {}): SubstrateExtrinsic => + ({ + idx, + success, + events: events.map(({ section: s, method: m, data }) => ({ + event: { section: s, method: m, data }, + })), + block: { + specVersion: 8000000, + timestamp: new Date('2026-01-01T00:00:00Z'), + block: { header: { number: { toString: () => `${blockNumber}` } } }, + }, + extrinsic: { + length: 128, + isSigned: signer !== '', + signer: { isEmpty: signer === '', toString: () => signer }, + nonce: { toNumber: () => 99 }, + hash: { toJSON: () => '0xsubstratehash' }, + registry: { + chainSS58: ss58Format, + createType: (): unknown => { + if (!innerCall) { + throw new Error('unable to decode'); + } + return { + section: innerCall.section, + method: innerCall.method, + toHuman: () => ({ args: innerCall.args }), + }; + }, + }, + method: { section, method }, + args: [{ toU8a: () => hexToU8a(payload) }], + toHuman: () => ({ method: { args: { payload } } }), + }, + } as unknown as SubstrateExtrinsic); + +describe('createExtrinsic', () => { + it('should leave a regular extrinsic untouched', () => { + const extrinsic = createExtrinsic( + mockExtrinsic({ section: 'balances', method: 'transferWithMemo', signer: 'someAddress' }) + ); + + expect(extrinsic.moduleId).toEqual('balances'); + expect(extrinsic.callId).toEqual('transfer_with_memo'); + expect(extrinsic.signed).toEqual(1); + expect(extrinsic.signedbyAddress).toEqual(1); + expect(extrinsic.address).toEqual('someAddress'); + expect(extrinsic.nonce).toEqual(99); + expect(extrinsic.ethAddress).toBeUndefined(); + expect(extrinsic.ethTxHash).toBeUndefined(); + }); + + it('should attribute an eth_transact to the signing Ethereum key while staying unsigned', () => { + const extrinsic = createExtrinsic(mockExtrinsic({ innerCall: INNER_CALL })); + + expect(extrinsic.address).toEqual(FROM_SS58); + expect(extrinsic.ethAddress).toEqual(FROM_ETH); + expect(extrinsic.ethTxHash).toEqual( + '0xc5a3bbd37bc19ccfc6d8956f68fd13cfa397a7ed40b956c9414e2e9ad4707500' + ); + // the extrinsic really is unsigned at the substrate layer + expect(extrinsic.signed).toEqual(0); + expect(extrinsic.signedbyAddress).toEqual(0); + }); + + it('should normalise a runtime call to the pallet and call that was dispatched', () => { + const extrinsic = createExtrinsic(mockExtrinsic({ innerCall: INNER_CALL })); + + expect(extrinsic.moduleId).toEqual('asset'); + expect(extrinsic.moduleIdText).toEqual('asset'); + expect(extrinsic.callId).toEqual('issue'); + expect(extrinsic.callIdText).toEqual('issue'); + expect(extrinsic.paramsTxt).toEqual(JSON.stringify(INNER_CALL.args)); + }); + + it('should use the Ethereum nonce rather than the (always zero) extrinsic nonce', () => { + expect(createExtrinsic(mockExtrinsic({ innerCall: INNER_CALL })).nonce).toEqual(7); + }); + + it('should omit the init code from paramsTxt, since EvmTransaction stores it', () => { + const extrinsic = createExtrinsic(mockExtrinsic({ payload: payloads.deploy })); + + expect(extrinsic.callId).toEqual('eth_instantiate_with_code'); + expect(JSON.parse(extrinsic.paramsTxt)).toEqual({ + to: null, + value: '0', + gasLimit: '21000', + nonce: '7', + }); + expect(extrinsic.paramsTxt).not.toContain('deadbeef'); + }); + + it('should mark a reverted transaction as unsuccessful despite the extrinsic succeeding', () => { + const extrinsic = createExtrinsic( + mockExtrinsic({ + innerCall: INNER_CALL, + success: true, + events: [ + { + section: 'revive', + method: 'EthExtrinsicRevert', + data: [{ toHuman: () => ({ Module: { index: '26', error: '0x01000000' } }) }], + }, + ], + }) + ); + + expect(extrinsic.success).toEqual(0); + }); + + it('should keep success 1 when nothing reverted', () => { + expect(createExtrinsic(mockExtrinsic({ innerCall: INNER_CALL })).success).toEqual(1); + }); + + it('should fall back to the raw extrinsic when the payload cannot be decoded', () => { + const extrinsic = createExtrinsic(mockExtrinsic({ payload: '0xdeadbeef' })); + + // `revive.eth_transact` surviving normalisation is the sentinel for a failed decode + expect(extrinsic.moduleId).toEqual('revive'); + expect(extrinsic.callId).toEqual('eth_transact'); + expect(extrinsic.address).toBeNull(); + expect(extrinsic.ethTxHash).toBeUndefined(); + }); +}); + +describe('handleExtrinsic', () => { + const DID = '0x0100000000000000000000000000000000000000000000000000000000000000'; + + beforeEach(() => { + (globalThis as any).store = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + }; + // `getOrCreateAccount` consults the key record before indexing an account + (globalThis as any).api.query = { + identity: { keyRecords: jest.fn().mockResolvedValue({ isEmpty: true }) }, + }; + Object.assign((globalThis as any).api, { registry: { chainSS58: 12 } }); + }); + + const saved = (entity: string) => + (globalThis as any).store.set.mock.calls + .filter(([name]: [string]) => name === entity) + .map(([, , props]: [string, string, any]) => props); + + it('should save an EvmTransaction alongside the extrinsic', async () => { + const extrinsic = mockExtrinsic({ payload: payloads.deploy }); + const expectedId = `0000004242/${`${extrinsic.idx}`.padStart(10, '0')}`; + + await handleExtrinsic(extrinsic); + + expect(saved('Extrinsic')).toHaveLength(1); + + const [evmTransaction] = saved('EvmTransaction'); + expect(evmTransaction).toMatchObject({ + id: expectedId, + extrinsicId: expectedId, + callKind: 'instantiate', + ethTxType: 2, + fromEthAddress: FROM_ETH, + fromAddress: FROM_SS58, + toEthAddress: undefined, + value: '0', + ethNonce: BigInt(7), + chainId: '1641818', + input: '0xdeadbeef', + reverted: false, + }); + }); + + it('should not save an EvmTransaction when the payload cannot be decoded', async () => { + await handleExtrinsic(mockExtrinsic({ payload: '0xdeadbeef' })); + + expect(saved('Extrinsic')).toHaveLength(1); + expect(saved('EvmTransaction')).toHaveLength(0); + }); + + it('should not index an account for a sender with no on-chain key record', async () => { + await handleExtrinsic(mockExtrinsic({ innerCall: INNER_CALL })); + + expect(saved('Account')).toHaveLength(0); + }); + + it('should index the recovered sender as an Ethereum account when it holds a key record', async () => { + (globalThis as any).api.query.identity.keyRecords = jest.fn().mockResolvedValue({ + isEmpty: false, + toJSON: () => ({ primaryKey: DID }), + }); + + await handleExtrinsic(mockExtrinsic({ innerCall: INNER_CALL })); + + expect(saved('Account')).toMatchObject([ + { + id: FROM_SS58, + address: FROM_SS58, + identityId: DID, + keyType: 'ethereum', + evmAddress: FROM_ETH, + }, + ]); + }); + + it('should record a successful revive.mapAccount', async () => { + await handleExtrinsic( + mockExtrinsic({ + section: 'revive', + method: 'mapAccount', + signer: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + ss58Format: 42, + }) + ); + + expect(saved('EvmAccountMapping')).toMatchObject([ + { + // keccak256()[12..], as `AddressMapper::to_address` derives it + id: '0x9621DDe636dE098B43Efb0fA9b61fAcFE328F99D', + address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + mapped: true, + }, + ]); + }); + + it('should record revive.unmapAccount as no longer mapped', async () => { + await handleExtrinsic( + mockExtrinsic({ + section: 'revive', + method: 'unmapAccount', + signer: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + ss58Format: 42, + }) + ); + + expect(saved('EvmAccountMapping')[0]).toMatchObject({ mapped: false }); + }); + + it('should update an existing mapping rather than creating a second row', async () => { + const id = '0x9621DDe636dE098B43Efb0fA9b61fAcFE328F99D'; + // as if seeded from the genesis `mapped_accounts` config + (globalThis as any).store.get = jest.fn().mockResolvedValue({ + id, + evmAddress: id, + address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + mapped: true, + datetime: new Date(0), + createdBlockId: '0000000000', + updatedBlockId: '0000000000', + }); + + await handleExtrinsic( + mockExtrinsic({ + section: 'revive', + method: 'unmapAccount', + signer: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + ss58Format: 42, + }) + ); + + expect(saved('EvmAccountMapping')).toMatchObject([ + { + id, + mapped: false, + // the genesis creation block is preserved, only the update block moves + createdBlockId: '0000000000', + updatedBlockId: '0000004242', + }, + ]); + }); + + it('should ignore a failed mapping call', async () => { + await handleExtrinsic( + mockExtrinsic({ section: 'revive', method: 'mapAccount', signer: 'x', success: false }) + ); + + expect(saved('EvmAccountMapping')).toHaveLength(0); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8f5ff5aa..df60a96c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1629,6 +1629,15 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/rlp@npm:^5.0.2": + version: 5.0.2 + resolution: "@ethereumjs/rlp@npm:5.0.2" + bin: + rlp: bin/rlp.cjs + checksum: 10c0/56162eaee96dd429f0528a9e51b453398546d57f26057b3e188f2aa09efe8bd430502971c54238ca9cc42af41b0a3f137cf67b9e020d52bc83caca043d64911b + languageName: node + linkType: hard + "@ethersproject/abi@npm:5.8.0, @ethersproject/abi@npm:^5.8.0": version: 5.8.0 resolution: "@ethersproject/abi@npm:5.8.0" @@ -14760,6 +14769,7 @@ __metadata: "@babel/preset-env": "npm:^7.24.4" "@commitlint/cli": "npm:^17.8.1" "@commitlint/config-conventional": "npm:^17.8.1" + "@ethereumjs/rlp": "npm:^5.0.2" "@polkadot/api": "npm:^16.5.2" "@polkadot/typegen": "npm:^16.5.2" "@polkadot/types-support": "npm:^16.5.2"