From d25d2c93d09878dc474a4777da94f48463124503 Mon Sep 17 00:00:00 2001 From: mrq Date: Fri, 13 Mar 2026 15:43:23 +0100 Subject: [PATCH 1/3] added mapping to the ethereum functions --- packages/core/src/rpc/substrate/eth-utils.ts | 314 +++++++++++++++++++ packages/core/src/rpc/substrate/eth.ts | 271 ++++++++++++++++ packages/core/src/rpc/substrate/index.ts | 3 + packages/e2e/src/eth.test.ts | 68 ++++ 4 files changed, 656 insertions(+) create mode 100644 packages/core/src/rpc/substrate/eth-utils.ts create mode 100644 packages/core/src/rpc/substrate/eth.ts create mode 100644 packages/e2e/src/eth.test.ts diff --git a/packages/core/src/rpc/substrate/eth-utils.ts b/packages/core/src/rpc/substrate/eth-utils.ts new file mode 100644 index 00000000..48efe26f --- /dev/null +++ b/packages/core/src/rpc/substrate/eth-utils.ts @@ -0,0 +1,314 @@ +import type { HexString } from '@polkadot/util/types' + +import type { Block } from '../../blockchain/block.js' +import type { Blockchain } from '../../blockchain/index.js' +import type { Context } from '../shared.js' +import { ResponseError } from '../shared.js' + +/** + * Convert a bigint to a minimal Ethereum hex quantity string (no leading zeros). + * E.g. 0n → "0x0", 255n → "0xff" + */ +export function toEthQuantity(n: bigint): string { + if (n === 0n) return '0x0' + return '0x' + n.toString(16) +} + +/** + * Parse an Ethereum hex quantity string to bigint. + */ +export function fromEthQuantity(hex: string): bigint { + return BigInt(hex) +} + +/** + * Encode a 20-byte Ethereum address as a hex string (no length prefix). + */ +export function encodeH160(address: string): HexString { + const clean = address.toLowerCase().replace(/^0x/, '') + if (clean.length !== 40) { + throw new ResponseError(-32602, `Invalid address: expected 20 bytes, got ${clean.length / 2}`) + } + return ('0x' + clean) as HexString +} + +/** + * Encode a 32-byte hash/slot as a hex string (no length prefix). + */ +export function encodeH256(value: string): HexString { + const clean = value.toLowerCase().replace(/^0x/, '') + if (clean.length !== 64) { + throw new ResponseError(-32602, `Invalid H256: expected 32 bytes, got ${clean.length / 2}`) + } + return ('0x' + clean) as HexString +} + +/** + * Encode a bigint as a 32-byte little-endian U256 hex string. + */ +export function encodeU256(value: bigint): HexString { + const bytes = new Uint8Array(32) + let v = value + for (let i = 0; i < 32; i++) { + bytes[i] = Number(v & 0xffn) + v >>= 8n + } + return ('0x' + Buffer.from(bytes).toString('hex')) as HexString +} + +/** + * Decode 8 bytes little-endian to a bigint (u64). + */ +export function decodeU64LE(hex: HexString): bigint { + const clean = hex.replace(/^0x/, '') + let result = 0n + const len = Math.min(clean.length, 16) + for (let i = 0; i < len; i += 2) { + const byte = BigInt(parseInt(clean.slice(i, i + 2), 16)) + result |= byte << (BigInt(i / 2) * 8n) + } + return result +} + +/** + * Decode 32 bytes little-endian to a bigint (U256). + */ +export function decodeU256LE(hex: string, offset = 0): bigint { + const clean = hex.replace(/^0x/, '') + const start = offset * 2 + let result = 0n + for (let i = 0; i < 64; i += 2) { + const byte = BigInt(parseInt(clean.slice(start + i, start + i + 2), 16)) + result |= byte << (BigInt(i / 2) * 8n) + } + return result +} + +/** + * Decode AccountBasic struct: first 32 bytes = nonce (U256 LE), next 32 bytes = balance (U256 LE). + */ +export function decodeAccountBasic(hex: HexString): { nonce: bigint; balance: bigint } { + const clean = hex.replace(/^0x/, '') + const nonce = decodeU256LE(clean, 0) + const balance = decodeU256LE(clean, 32) + return { nonce, balance } +} + +/** + * Decode a SCALE-encoded Vec at a given byte offset. + * Returns the data as a hex string and the total number of bytes consumed. + */ +export function decodeVec(hex: string, byteOffset = 0): { data: string; bytesRead: number } { + const clean = hex.replace(/^0x/, '') + let pos = byteOffset * 2 + + // Decode SCALE compact length + const firstByte = parseInt(clean.slice(pos, pos + 2), 16) + const mode = firstByte & 0x03 + let length: number + let headerSize: number + + if (mode === 0) { + length = firstByte >> 2 + headerSize = 1 + } else if (mode === 1) { + const secondByte = parseInt(clean.slice(pos + 2, pos + 4), 16) + length = ((secondByte << 8) | firstByte) >> 2 + headerSize = 2 + } else if (mode === 2) { + let val = 0 + for (let i = 0; i < 4; i++) { + val |= parseInt(clean.slice(pos + i * 2, pos + i * 2 + 2), 16) << (i * 8) + } + length = val >> 2 + headerSize = 4 + } else { + const upperBits = firstByte >> 2 + const bytesNeeded = upperBits + 4 + let val = 0n + for (let i = 0; i < bytesNeeded; i++) { + val |= BigInt(parseInt(clean.slice(pos + 2 + i * 2, pos + 4 + i * 2), 16)) << (BigInt(i) * 8n) + } + length = Number(val) + headerSize = 1 + bytesNeeded + } + + pos += headerSize * 2 + const data = clean.slice(pos, pos + length * 2) + return { data, bytesRead: headerSize + length } +} + +/** + * SCALE-encode a Vec from raw bytes (hex without 0x prefix). + */ +export function encodeVec(hexBytes: string): string { + const length = hexBytes.length / 2 + return encodeCompact(length) + hexBytes +} + +/** + * SCALE compact encoding of a non-negative integer. + */ +export function encodeCompact(value: number): string { + if (value < 64) { + return (value << 2).toString(16).padStart(2, '0') + } else if (value < 16384) { + const v = (value << 2) | 1 + return v.toString(16).padStart(4, '0').match(/../g)!.reverse().join('') + } else if (value < 1073741824) { + const v = (value << 2) | 2 + return v.toString(16).padStart(8, '0').match(/../g)!.reverse().join('') + } else { + throw new ResponseError(-32603, 'Compact encoding for values >= 2^30 not implemented') + } +} + +/** + * Decode a call result from EthereumRuntimeRPCApi_call (Frontier API v6). + * + * Returns Result>, DispatchError> + * + * ExecutionInfoV2> = { + * exit_reason: ExitReason, // enum: 0=Succeed, 1=Error, 2=Revert, 3=Fatal; each wraps a sub-enum (1 byte) + * value: Vec, // return data + * used_gas: UsedGas, // { standard: U256, effective: U256 } + * weight_info: Option, + * logs: Vec, + * } + */ +export function decodeCallResult(hex: HexString): { + success: boolean + returnData: string + gasUsed: bigint +} { + const clean = hex.replace(/^0x/, '') + + // Result enum: 0x00 = Ok, 0x01 = Err + const resultVariant = parseInt(clean.slice(0, 2), 16) + if (resultVariant !== 0) { + throw new ResponseError(-32603, 'Runtime call failed: dispatch error') + } + + // ExitReason: 1 byte category + 1 byte sub-reason + const exitCategory = parseInt(clean.slice(2, 4), 16) + // skip sub-reason byte + let byteOffset = 3 + + // Decode return data Vec + const vec = decodeVec(clean, byteOffset) + byteOffset += vec.bytesRead + + // UsedGas: { standard: U256 (32 bytes LE), effective: U256 (32 bytes LE) } + // Skip standard gas + byteOffset += 32 + const effectiveGas = decodeU256LE(clean, byteOffset) + + // ExitReason category 0 = Succeed + const success = exitCategory === 0 + + return { + success, + returnData: '0x' + vec.data, + gasUsed: effectiveGas, + } +} + +/** + * Encode parameters for EthereumRuntimeRPCApi_call (Frontier API v6). + * + * fn call(from: H160, to: H160, data: Vec, value: U256, gas_limit: U256, + * max_fee_per_gas: Option, max_priority_fee_per_gas: Option, + * nonce: Option, estimate: bool, + * access_list: Option)>>, + * authorization_list: Option) + */ +export function encodeCallParams(params: { + from?: string + to: string + data?: string + value?: bigint + gasLimit?: bigint + maxFeePerGas?: bigint + accessList?: Array<{ address: string; storageKeys: string[] }> + estimate?: boolean +}): HexString { + let encoded = '' + + // from: H160 (20 bytes) + const from = params.from ? params.from.replace(/^0x/, '').padStart(40, '0') : '0'.repeat(40) + encoded += from + + // to: H160 (20 bytes) + encoded += params.to.replace(/^0x/, '').padStart(40, '0') + + // data: Vec + const data = params.data ? params.data.replace(/^0x/, '') : '' + encoded += encodeVec(data) + + // value: U256 LE (32 bytes) + encoded += encodeU256(params.value ?? 0n).replace(/^0x/, '') + + // gas_limit: U256 LE (32 bytes) + encoded += encodeU256(params.gasLimit ?? 25000000n).replace(/^0x/, '') + + // max_fee_per_gas: Option - None + if (params.maxFeePerGas !== undefined) { + encoded += '01' + encodeU256(params.maxFeePerGas).replace(/^0x/, '') + } else { + encoded += '00' + } + + // max_priority_fee_per_gas: Option - None + encoded += '00' + + // nonce: Option - None (v6 uses u32, not U256) + encoded += '00' + + // estimate: bool + encoded += params.estimate ? '01' : '00' + + // access_list: Option)>> + if (params.accessList && params.accessList.length > 0) { + encoded += '01' + encoded += encodeCompact(params.accessList.length) + for (const entry of params.accessList) { + encoded += entry.address.replace(/^0x/, '').padStart(40, '0') + encoded += encodeCompact(entry.storageKeys.length) + for (const key of entry.storageKeys) { + encoded += key.replace(/^0x/, '').padStart(64, '0') + } + } + } else { + encoded += '00' + } + + // authorization_list: Option - None (v6 addition) + encoded += '00' + + return ('0x' + encoded) as HexString +} + +/** + * Resolve an Ethereum block tag ("latest", "earliest", "pending", or hex number) to a Block. + */ +export async function resolveBlock(context: Context, blockTag?: string): Promise { + if (!blockTag || blockTag === 'latest' || blockTag === 'pending') { + return context.chain.head + } + + if (blockTag === 'earliest') { + const block = await (context.chain as Blockchain).getBlockAt(0) + if (!block) { + throw new ResponseError(-32602, 'Earliest block not found') + } + return block + } + + // Hex block number + const blockNumber = Number(BigInt(blockTag)) + const block = await (context.chain as Blockchain).getBlockAt(blockNumber) + if (!block) { + throw new ResponseError(-32602, `Block ${blockTag} not found`) + } + return block +} diff --git a/packages/core/src/rpc/substrate/eth.ts b/packages/core/src/rpc/substrate/eth.ts new file mode 100644 index 00000000..c88016d8 --- /dev/null +++ b/packages/core/src/rpc/substrate/eth.ts @@ -0,0 +1,271 @@ +import type { HexString } from '@polkadot/util/types' + +import type { Handler } from '../shared.js' +import { ResponseError } from '../shared.js' +import { + decodeAccountBasic, + decodeCallResult, + decodeU64LE, + decodeU256LE, + decodeVec, + encodeCallParams, + encodeH160, + encodeH256, + resolveBlock, + toEthQuantity, +} from './eth-utils.js' + +/** + * Returns the chain ID used for signing replay-protected transactions. + */ +export const eth_chainId: Handler<[], string> = async (context) => { + const block = context.chain.head + const result = await block.call('EthereumRuntimeRPCApi_chain_id', ['0x']) + return toEthQuantity(decodeU64LE(result.result as HexString)) +} + +/** + * Returns the number of the most recent block. + */ +export const eth_blockNumber: Handler<[], string> = async (context) => { + const block = context.chain.head + return toEthQuantity(BigInt(block.number)) +} + +/** + * Returns the balance of the account at the given address. + */ +export const eth_getBalance: Handler<[string, string?], string> = async (context, [address, blockTag]) => { + const block = await resolveBlock(context, blockTag) + const params = encodeH160(address) + const result = await block.call('EthereumRuntimeRPCApi_account_basic', [params]) + const { balance } = decodeAccountBasic(result.result as HexString) + return toEthQuantity(balance) +} + +/** + * Returns the number of transactions sent from an address. + */ +export const eth_getTransactionCount: Handler<[string, string?], string> = async (context, [address, blockTag]) => { + const block = await resolveBlock(context, blockTag) + const params = encodeH160(address) + const result = await block.call('EthereumRuntimeRPCApi_account_basic', [params]) + const { nonce } = decodeAccountBasic(result.result as HexString) + return toEthQuantity(nonce) +} + +/** + * Returns the code at a given address. + */ +export const eth_getCode: Handler<[string, string?], string> = async (context, [address, blockTag]) => { + const block = await resolveBlock(context, blockTag) + const params = encodeH160(address) + const result = await block.call('EthereumRuntimeRPCApi_account_code_at', [params]) + // Result is a Vec + const vec = decodeVec(result.result as string) + return '0x' + vec.data +} + +/** + * Returns the value from a storage position at a given address. + */ +export const eth_getStorageAt: Handler<[string, string, string?], string> = async ( + context, + [address, position, blockTag], +) => { + const block = await resolveBlock(context, blockTag) + // Encode (H160, H256) tuple — address + storage slot, no length prefix + const params = (encodeH160(address) + encodeH256(position).replace(/^0x/, '')) as HexString + const result = await block.call('EthereumRuntimeRPCApi_storage_at', [params]) + // Result is H256 (32 bytes) — return as-is + return result.result +} + +/** + * Executes a new message call immediately without creating a transaction on the block chain. + */ +export const eth_call: Handler<[Record, string?], string> = async (context, [txObject, blockTag]) => { + const block = await resolveBlock(context, blockTag) + + if (!txObject.to) { + throw new ResponseError(-32602, 'Missing required field: to') + } + + const params = encodeCallParams({ + from: txObject.from, + to: txObject.to, + data: txObject.data || txObject.input, + value: txObject.value ? BigInt(txObject.value) : undefined, + gasLimit: txObject.gas ? BigInt(txObject.gas) : undefined, + maxFeePerGas: txObject.maxFeePerGas ? BigInt(txObject.maxFeePerGas) : undefined, + accessList: txObject.accessList, + estimate: false, + }) + + const result = await block.call('EthereumRuntimeRPCApi_call', [params]) + const decoded = decodeCallResult(result.result as HexString) + + if (!decoded.success) { + throw new ResponseError(3, `execution reverted: ${decoded.returnData}`) + } + + return decoded.returnData +} + +/** + * Generates and returns an estimate of how much gas is necessary to allow the transaction to complete. + */ +export const eth_estimateGas: Handler<[Record, string?], string> = async ( + context, + [txObject, blockTag], +) => { + const block = await resolveBlock(context, blockTag) + + if (!txObject.to) { + throw new ResponseError(-32602, 'Missing required field: to') + } + + const params = encodeCallParams({ + from: txObject.from, + to: txObject.to, + data: txObject.data || txObject.input, + value: txObject.value ? BigInt(txObject.value) : undefined, + gasLimit: txObject.gas ? BigInt(txObject.gas) : undefined, + estimate: true, + }) + + const result = await block.call('EthereumRuntimeRPCApi_call', [params]) + const decoded = decodeCallResult(result.result as HexString) + return toEthQuantity(decoded.gasUsed) +} + +/** + * Returns a synthetic Ethereum block object for a given block number or tag. + * Since chopsticks doesn't store full Ethereum blocks, we construct a minimal + * block object from Substrate block data to satisfy wallet queries. + */ +export const eth_getBlockByNumber: Handler<[string, boolean?], Record | null> = async ( + context, + [blockTag, _fullTransactions], +) => { + const block = await resolveBlock(context, blockTag) + if (!block) return null + + const blockNumber = toEthQuantity(BigInt(block.number)) + const blockHash = block.hash + + return { + number: blockNumber, + hash: blockHash, + parentHash: (await block.parentBlock)?.hash ?? '0x' + '00'.repeat(32), + nonce: '0x0000000000000000', + sha3Uncles: '0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347', + logsBloom: '0x' + '00'.repeat(256), + transactionsRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', + stateRoot: '0x' + '00'.repeat(32), + receiptsRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', + miner: '0x' + '00'.repeat(20), + difficulty: '0x0', + totalDifficulty: '0x0', + extraData: '0x', + size: '0x0', + gasLimit: '0x1312d00', + gasUsed: '0x0', + timestamp: '0x0', + transactions: [], + uncles: [], + baseFeePerGas: '0x0', + } +} + +/** + * Returns a synthetic Ethereum block object for a given block hash. + */ +export const eth_getBlockByHash: Handler<[string, boolean?], Record | null> = async ( + context, + [blockHash, _fullTransactions], +) => { + const block = await context.chain.getBlock(blockHash as HexString) + if (!block) return null + + const blockNumber = toEthQuantity(BigInt(block.number)) + + return { + number: blockNumber, + hash: block.hash, + parentHash: (await block.parentBlock)?.hash ?? '0x' + '00'.repeat(32), + nonce: '0x0000000000000000', + sha3Uncles: '0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347', + logsBloom: '0x' + '00'.repeat(256), + transactionsRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', + stateRoot: '0x' + '00'.repeat(32), + receiptsRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', + miner: '0x' + '00'.repeat(20), + difficulty: '0x0', + totalDifficulty: '0x0', + extraData: '0x', + size: '0x0', + gasLimit: '0x1312d00', + gasUsed: '0x0', + timestamp: '0x0', + transactions: [], + uncles: [], + baseFeePerGas: '0x0', + } +} + +/** + * Returns the current gas price in wei. + */ +export const eth_gasPrice: Handler<[], string> = async (context) => { + const block = context.chain.head + const result = await block.call('EthereumRuntimeRPCApi_gas_price', ['0x']) + // Result is U256 LE (32 bytes) + const price = decodeU256LE(result.result as string) + return toEthQuantity(price) +} + +/** + * Returns the current network ID. + */ +export const net_version: Handler<[], string> = async (context) => { + const block = context.chain.head + const result = await block.call('EthereumRuntimeRPCApi_chain_id', ['0x']) + const chainId = decodeU64LE(result.result as HexString) + return chainId.toString() +} + +/** + * Returns the current client version. + */ +export const web3_clientVersion: Handler<[], string> = async () => { + return 'chopsticks/v1' +} + +/** + * Returns an empty array for accounts (no wallet management). + */ +export const eth_accounts: Handler<[], string[]> = async () => { + return [] +} + +/** + * Returns true if the client is syncing. + */ +export const eth_syncing: Handler<[], false> = async () => { + return false +} + +/** + * Returns "1" for mainnet (stub). + */ +export const net_listening: Handler<[], boolean> = async () => { + return true +} + +/** + * Returns the number of peers (always 0 for chopsticks). + */ +export const net_peerCount: Handler<[], string> = async () => { + return '0x0' +} diff --git a/packages/core/src/rpc/substrate/index.ts b/packages/core/src/rpc/substrate/index.ts index 6ba0321d..6ded4aaa 100644 --- a/packages/core/src/rpc/substrate/index.ts +++ b/packages/core/src/rpc/substrate/index.ts @@ -1,6 +1,7 @@ import * as ArchiveRPC from './archive.js' import * as AuthorRPC from './author.js' import * as ChainRPC from './chain.js' +import * as EthRPC from './eth.js' import * as PaymentRPC from './payment.js' import * as StateRPC from './state.js' import * as SystemRPC from './system.js' @@ -8,6 +9,7 @@ import * as SystemRPC from './system.js' export { ArchiveRPC } export { AuthorRPC } export { ChainRPC } +export { EthRPC } export { PaymentRPC } export { StateRPC } export { SystemRPC } @@ -16,6 +18,7 @@ const handlers = { ...ArchiveRPC, ...AuthorRPC, ...ChainRPC, + ...EthRPC, ...PaymentRPC, ...StateRPC, ...SystemRPC, diff --git a/packages/e2e/src/eth.test.ts b/packages/e2e/src/eth.test.ts new file mode 100644 index 00000000..54c2655b --- /dev/null +++ b/packages/e2e/src/eth.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' + +import { env, setupApi, ws } from './helper.js' + +// Hydration is an EVM-compatible chain with Frontier runtime APIs +const hydration = { + endpoint: 'wss://hydration-rpc.n.dwellir.com', + blockHash: '0x' + '00'.repeat(32), // will use latest if not found +} + +// Use a known Hydration block if available, otherwise skip gracefully +describe.skip('eth_* RPC methods', () => { + setupApi({ + endpoint: hydration.endpoint, + }) + + it('eth_chainId returns Hydration chain id', async () => { + const result = await ws.send('eth_chainId', []) + // Hydration chain id is 222222 = 0x3640e + expect(result).toBe('0x3640e') + }) + + it('eth_blockNumber returns a hex number', async () => { + const result = await ws.send('eth_blockNumber', []) + expect(result).toMatch(/^0x[0-9a-f]+$/) + }) + + it('eth_getBalance returns balance for address', async () => { + const result = await ws.send('eth_getBalance', ['0x0000000000000000000000000000000000000000', 'latest']) + expect(result).toMatch(/^0x[0-9a-f]+$/) + }) + + it('eth_getTransactionCount returns nonce', async () => { + const result = await ws.send('eth_getTransactionCount', ['0x0000000000000000000000000000000000000000', 'latest']) + expect(result).toMatch(/^0x[0-9a-f]+$/) + }) + + it('eth_getCode returns code for address', async () => { + const result = await ws.send('eth_getCode', ['0x0000000000000000000000000000000000000000', 'latest']) + expect(typeof result).toBe('string') + expect(result.startsWith('0x')).toBe(true) + }) + + it('eth_gasPrice returns gas price', async () => { + const result = await ws.send('eth_gasPrice', []) + expect(result).toMatch(/^0x[0-9a-f]+$/) + }) + + it('net_version returns chain id as decimal string', async () => { + const result = await ws.send('net_version', []) + expect(result).toBe('222222') + }) + + it('web3_clientVersion returns chopsticks version', async () => { + const result = await ws.send('web3_clientVersion', []) + expect(result).toContain('chopsticks') + }) + + it('eth_accounts returns empty array', async () => { + const result = await ws.send('eth_accounts', []) + expect(result).toEqual([]) + }) + + it('eth_syncing returns false', async () => { + const result = await ws.send('eth_syncing', []) + expect(result).toBe(false) + }) +}) From 93103875d71b7d78ced1f07bc64191b2a3d598f4 Mon Sep 17 00:00:00 2001 From: mrq Date: Sun, 15 Mar 2026 22:05:39 +0100 Subject: [PATCH 2/3] using type registry for encoding --- packages/core/src/rpc/substrate/eth-utils.ts | 256 ++++-------------- .../core/src/rpc/substrate/frontier-types.ts | 73 +++++ 2 files changed, 126 insertions(+), 203 deletions(-) create mode 100644 packages/core/src/rpc/substrate/frontier-types.ts diff --git a/packages/core/src/rpc/substrate/eth-utils.ts b/packages/core/src/rpc/substrate/eth-utils.ts index 48efe26f..98857ebc 100644 --- a/packages/core/src/rpc/substrate/eth-utils.ts +++ b/packages/core/src/rpc/substrate/eth-utils.ts @@ -1,9 +1,12 @@ import type { HexString } from '@polkadot/util/types' +import { hexToU8a } from '@polkadot/util' + import type { Block } from '../../blockchain/block.js' import type { Blockchain } from '../../blockchain/index.js' import type { Context } from '../shared.js' import { ResponseError } from '../shared.js' +import { registry } from './frontier-types.js' /** * Convert a bigint to a minimal Ethereum hex quantity string (no leading zeros). @@ -22,205 +25,87 @@ export function fromEthQuantity(hex: string): bigint { } /** - * Encode a 20-byte Ethereum address as a hex string (no length prefix). + * Encode a 20-byte Ethereum address as SCALE-encoded H160. */ export function encodeH160(address: string): HexString { - const clean = address.toLowerCase().replace(/^0x/, '') - if (clean.length !== 40) { - throw new ResponseError(-32602, `Invalid address: expected 20 bytes, got ${clean.length / 2}`) - } - return ('0x' + clean) as HexString + return registry.createType('H160', address).toHex() } /** - * Encode a 32-byte hash/slot as a hex string (no length prefix). + * Encode a 32-byte hash/slot as SCALE-encoded H256. */ export function encodeH256(value: string): HexString { - const clean = value.toLowerCase().replace(/^0x/, '') - if (clean.length !== 64) { - throw new ResponseError(-32602, `Invalid H256: expected 32 bytes, got ${clean.length / 2}`) - } - return ('0x' + clean) as HexString + return registry.createType('H256', value).toHex() } /** - * Encode a bigint as a 32-byte little-endian U256 hex string. + * Decode AccountBasic struct: { nonce: U256, balance: U256 }. */ -export function encodeU256(value: bigint): HexString { - const bytes = new Uint8Array(32) - let v = value - for (let i = 0; i < 32; i++) { - bytes[i] = Number(v & 0xffn) - v >>= 8n +export function decodeAccountBasic(hex: HexString): { nonce: bigint; balance: bigint } { + const decoded = registry.createType('EvmAccountBasic', hexToU8a(hex)) + return { + nonce: (decoded as any).nonce.toBigInt(), + balance: (decoded as any).balance.toBigInt(), } - return ('0x' + Buffer.from(bytes).toString('hex')) as HexString } /** - * Decode 8 bytes little-endian to a bigint (u64). + * Decode a u64 from SCALE-encoded little-endian bytes. */ export function decodeU64LE(hex: HexString): bigint { - const clean = hex.replace(/^0x/, '') - let result = 0n - const len = Math.min(clean.length, 16) - for (let i = 0; i < len; i += 2) { - const byte = BigInt(parseInt(clean.slice(i, i + 2), 16)) - result |= byte << (BigInt(i / 2) * 8n) - } - return result -} - -/** - * Decode 32 bytes little-endian to a bigint (U256). - */ -export function decodeU256LE(hex: string, offset = 0): bigint { - const clean = hex.replace(/^0x/, '') - const start = offset * 2 - let result = 0n - for (let i = 0; i < 64; i += 2) { - const byte = BigInt(parseInt(clean.slice(start + i, start + i + 2), 16)) - result |= byte << (BigInt(i / 2) * 8n) - } - return result -} - -/** - * Decode AccountBasic struct: first 32 bytes = nonce (U256 LE), next 32 bytes = balance (U256 LE). - */ -export function decodeAccountBasic(hex: HexString): { nonce: bigint; balance: bigint } { - const clean = hex.replace(/^0x/, '') - const nonce = decodeU256LE(clean, 0) - const balance = decodeU256LE(clean, 32) - return { nonce, balance } -} - -/** - * Decode a SCALE-encoded Vec at a given byte offset. - * Returns the data as a hex string and the total number of bytes consumed. - */ -export function decodeVec(hex: string, byteOffset = 0): { data: string; bytesRead: number } { - const clean = hex.replace(/^0x/, '') - let pos = byteOffset * 2 - - // Decode SCALE compact length - const firstByte = parseInt(clean.slice(pos, pos + 2), 16) - const mode = firstByte & 0x03 - let length: number - let headerSize: number - - if (mode === 0) { - length = firstByte >> 2 - headerSize = 1 - } else if (mode === 1) { - const secondByte = parseInt(clean.slice(pos + 2, pos + 4), 16) - length = ((secondByte << 8) | firstByte) >> 2 - headerSize = 2 - } else if (mode === 2) { - let val = 0 - for (let i = 0; i < 4; i++) { - val |= parseInt(clean.slice(pos + i * 2, pos + i * 2 + 2), 16) << (i * 8) - } - length = val >> 2 - headerSize = 4 - } else { - const upperBits = firstByte >> 2 - const bytesNeeded = upperBits + 4 - let val = 0n - for (let i = 0; i < bytesNeeded; i++) { - val |= BigInt(parseInt(clean.slice(pos + 2 + i * 2, pos + 4 + i * 2), 16)) << (BigInt(i) * 8n) - } - length = Number(val) - headerSize = 1 + bytesNeeded - } - - pos += headerSize * 2 - const data = clean.slice(pos, pos + length * 2) - return { data, bytesRead: headerSize + length } + return registry.createType('u64', hexToU8a(hex)).toBigInt() } /** - * SCALE-encode a Vec from raw bytes (hex without 0x prefix). + * Decode a U256 from SCALE-encoded little-endian bytes. */ -export function encodeVec(hexBytes: string): string { - const length = hexBytes.length / 2 - return encodeCompact(length) + hexBytes +export function decodeU256LE(hex: string): bigint { + const input = hex.startsWith('0x') ? hex : '0x' + hex + return registry.createType('u256', hexToU8a(input)).toBigInt() } /** - * SCALE compact encoding of a non-negative integer. + * Decode a SCALE-encoded Vec and return the data as a hex string. */ -export function encodeCompact(value: number): string { - if (value < 64) { - return (value << 2).toString(16).padStart(2, '0') - } else if (value < 16384) { - const v = (value << 2) | 1 - return v.toString(16).padStart(4, '0').match(/../g)!.reverse().join('') - } else if (value < 1073741824) { - const v = (value << 2) | 2 - return v.toString(16).padStart(8, '0').match(/../g)!.reverse().join('') - } else { - throw new ResponseError(-32603, 'Compact encoding for values >= 2^30 not implemented') - } +export function decodeVec(hex: string): { data: string } { + const input = hex.startsWith('0x') ? hex : '0x' + hex + const decoded = registry.createType('Bytes', hexToU8a(input)) + return { data: decoded.toHex().replace(/^0x/, '') } } /** * Decode a call result from EthereumRuntimeRPCApi_call (Frontier API v6). * - * Returns Result>, DispatchError> - * - * ExecutionInfoV2> = { - * exit_reason: ExitReason, // enum: 0=Succeed, 1=Error, 2=Revert, 3=Fatal; each wraps a sub-enum (1 byte) - * value: Vec, // return data - * used_gas: UsedGas, // { standard: U256, effective: U256 } - * weight_info: Option, - * logs: Vec, - * } + * The response is Result>, DispatchError>. + * We manually check the Result variant byte, then decode ExecutionInfoV2. */ export function decodeCallResult(hex: HexString): { success: boolean returnData: string gasUsed: bigint } { - const clean = hex.replace(/^0x/, '') + const bytes = hexToU8a(hex) // Result enum: 0x00 = Ok, 0x01 = Err - const resultVariant = parseInt(clean.slice(0, 2), 16) - if (resultVariant !== 0) { + if (bytes[0] !== 0) { throw new ResponseError(-32603, 'Runtime call failed: dispatch error') } - // ExitReason: 1 byte category + 1 byte sub-reason - const exitCategory = parseInt(clean.slice(2, 4), 16) - // skip sub-reason byte - let byteOffset = 3 - - // Decode return data Vec - const vec = decodeVec(clean, byteOffset) - byteOffset += vec.bytesRead + // Decode ExecutionInfoV2 from the bytes after the Result variant byte + const info = registry.createType('EvmExecutionInfoV2', bytes.subarray(1)) - // UsedGas: { standard: U256 (32 bytes LE), effective: U256 (32 bytes LE) } - // Skip standard gas - byteOffset += 32 - const effectiveGas = decodeU256LE(clean, byteOffset) - - // ExitReason category 0 = Succeed - const success = exitCategory === 0 + const exitReason = (info as any).exitReason + const success = exitReason.isSucceed return { success, - returnData: '0x' + vec.data, - gasUsed: effectiveGas, + returnData: (info as any).value.toHex(), + gasUsed: (info as any).usedGas.effective.toBigInt(), } } /** * Encode parameters for EthereumRuntimeRPCApi_call (Frontier API v6). - * - * fn call(from: H160, to: H160, data: Vec, value: U256, gas_limit: U256, - * max_fee_per_gas: Option, max_priority_fee_per_gas: Option, - * nonce: Option, estimate: bool, - * access_list: Option)>>, - * authorization_list: Option) */ export function encodeCallParams(params: { from?: string @@ -232,60 +117,25 @@ export function encodeCallParams(params: { accessList?: Array<{ address: string; storageKeys: string[] }> estimate?: boolean }): HexString { - let encoded = '' - - // from: H160 (20 bytes) - const from = params.from ? params.from.replace(/^0x/, '').padStart(40, '0') : '0'.repeat(40) - encoded += from - - // to: H160 (20 bytes) - encoded += params.to.replace(/^0x/, '').padStart(40, '0') - - // data: Vec - const data = params.data ? params.data.replace(/^0x/, '') : '' - encoded += encodeVec(data) - - // value: U256 LE (32 bytes) - encoded += encodeU256(params.value ?? 0n).replace(/^0x/, '') - - // gas_limit: U256 LE (32 bytes) - encoded += encodeU256(params.gasLimit ?? 25000000n).replace(/^0x/, '') - - // max_fee_per_gas: Option - None - if (params.maxFeePerGas !== undefined) { - encoded += '01' + encodeU256(params.maxFeePerGas).replace(/^0x/, '') - } else { - encoded += '00' - } - - // max_priority_fee_per_gas: Option - None - encoded += '00' - - // nonce: Option - None (v6 uses u32, not U256) - encoded += '00' - - // estimate: bool - encoded += params.estimate ? '01' : '00' - - // access_list: Option)>> - if (params.accessList && params.accessList.length > 0) { - encoded += '01' - encoded += encodeCompact(params.accessList.length) - for (const entry of params.accessList) { - encoded += entry.address.replace(/^0x/, '').padStart(40, '0') - encoded += encodeCompact(entry.storageKeys.length) - for (const key of entry.storageKeys) { - encoded += key.replace(/^0x/, '').padStart(64, '0') - } - } - } else { - encoded += '00' - } - - // authorization_list: Option - None (v6 addition) - encoded += '00' - - return ('0x' + encoded) as HexString + const accessList = params.accessList + ? params.accessList.map((entry) => [entry.address, entry.storageKeys]) + : undefined + + const encoded = registry.createType('EvmCallParams', { + from: params.from ?? '0x' + '00'.repeat(20), + to: params.to, + data: params.data ?? '0x', + value: params.value ?? 0n, + gasLimit: params.gasLimit ?? 25000000n, + maxFeePerGas: params.maxFeePerGas, + maxPriorityFeePerGas: undefined, + nonce: undefined, + estimate: params.estimate ?? false, + accessList: accessList, + authorizationList: undefined, + }) + + return encoded.toHex() } /** diff --git a/packages/core/src/rpc/substrate/frontier-types.ts b/packages/core/src/rpc/substrate/frontier-types.ts new file mode 100644 index 00000000..940980bb --- /dev/null +++ b/packages/core/src/rpc/substrate/frontier-types.ts @@ -0,0 +1,73 @@ +import { TypeRegistry } from '@polkadot/types' + +// Singleton registry with Frontier EVM types +export const registry = new TypeRegistry() +registry.register({ + EvmAccountBasic: { nonce: 'u256', balance: 'u256' }, + EvmExitSucceed: { _enum: ['Stopped', 'Returned', 'Suicided'] }, + EvmExitError: { + _enum: { + StackUnderflow: null, + StackOverflow: null, + InvalidJump: null, + InvalidRange: null, + DesignatedInvalid: null, + CallTooDeep: null, + CreateCollision: null, + CreateContractLimit: null, + OutOfOffset: null, + OutOfGas: null, + OutOfFund: null, + PCUnderflow: null, + CreateEmpty: null, + Other: 'Text', + MaxNonce: null, + InvalidCode: 'u8', + }, + }, + EvmExitRevert: { _enum: ['Reverted'] }, + EvmExitFatal: { + _enum: { + NotSupported: null, + UnhandledInterrupt: null, + CallErrorAsFatal: 'EvmExitError', + Other: 'Text', + }, + }, + EvmExitReason: { + _enum: { + Succeed: 'EvmExitSucceed', + Error: 'EvmExitError', + Revert: 'EvmExitRevert', + Fatal: 'EvmExitFatal', + }, + }, + EvmUsedGas: { standard: 'u256', effective: 'u256' }, + EvmWeightInfo: { + refTimeLimit: 'Option', + proofSizeLimit: 'Option', + refTimeUsage: 'Option', + proofSizeUsage: 'Option', + }, + EvmLog: { address: 'H160', topics: 'Vec', data: 'Bytes' }, + EvmExecutionInfoV2: { + exitReason: 'EvmExitReason', + value: 'Bytes', + usedGas: 'EvmUsedGas', + weightInfo: 'Option', + logs: 'Vec', + }, + EvmCallParams: { + from: 'H160', + to: 'H160', + data: 'Bytes', + value: 'u256', + gasLimit: 'u256', + maxFeePerGas: 'Option', + maxPriorityFeePerGas: 'Option', + nonce: 'Option', + estimate: 'bool', + accessList: 'Option)>>', + authorizationList: 'Option>', + }, +}) From 67d75ccc28cb820d54d635f78221561dd80f4ff8 Mon Sep 17 00:00:00 2001 From: lolmcshizz Date: Fri, 22 May 2026 23:27:36 +0000 Subject: [PATCH 3/3] Override RelayParentOffset to bypass parachain-system check in hydradx config The cumulus relay-parent-descendants verification (added for async backing) panics when chopsticks fabricates the parachain inherent against current Hydration runtime. Setting Parameters::RelayParentOffsetOverride = true short-circuits the check via Hydration's runtime knob, so dry-runs against mainnet runtime complete and emit a proper diff. Only applies when running with --config=./configs/hydradx.yml (path-based). The shorthand --config=hydradx still fetches AcalaNetwork's copy from GitHub which doesn't have this entry. --- configs/hydradx.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/configs/hydradx.yml b/configs/hydradx.yml index baf71fd2..f393fd0f 100644 --- a/configs/hydradx.yml +++ b/configs/hydradx.yml @@ -217,3 +217,11 @@ import-storage: - free: '100000000000000000000000' TechnicalCommittee: Members: ["5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"] + # Skip the cumulus relay-parent-descendants verification by toggling + # Hydration's `RelayParentOffsetOverride` parameter to true. Without this, + # chopsticks panics with `InvalidStateRoot`/`InvalidSignature` in + # cumulus/pallets/parachain-system/src/lib.rs:657 when finalizing a block + # because the mocked descendant relay headers don't match what the runtime + # expects under async backing. See pallet-parameters in hydration-node. + Parameters: + RelayParentOffsetOverride: true