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..98857ebc --- /dev/null +++ b/packages/core/src/rpc/substrate/eth-utils.ts @@ -0,0 +1,164 @@ +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). + * 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 SCALE-encoded H160. + */ +export function encodeH160(address: string): HexString { + return registry.createType('H160', address).toHex() +} + +/** + * Encode a 32-byte hash/slot as SCALE-encoded H256. + */ +export function encodeH256(value: string): HexString { + return registry.createType('H256', value).toHex() +} + +/** + * Decode AccountBasic struct: { nonce: U256, balance: U256 }. + */ +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(), + } +} + +/** + * Decode a u64 from SCALE-encoded little-endian bytes. + */ +export function decodeU64LE(hex: HexString): bigint { + return registry.createType('u64', hexToU8a(hex)).toBigInt() +} + +/** + * Decode a U256 from SCALE-encoded little-endian bytes. + */ +export function decodeU256LE(hex: string): bigint { + const input = hex.startsWith('0x') ? hex : '0x' + hex + return registry.createType('u256', hexToU8a(input)).toBigInt() +} + +/** + * Decode a SCALE-encoded Vec and return the data as a hex string. + */ +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). + * + * 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 bytes = hexToU8a(hex) + + // Result enum: 0x00 = Ok, 0x01 = Err + if (bytes[0] !== 0) { + throw new ResponseError(-32603, 'Runtime call failed: dispatch error') + } + + // Decode ExecutionInfoV2 from the bytes after the Result variant byte + const info = registry.createType('EvmExecutionInfoV2', bytes.subarray(1)) + + const exitReason = (info as any).exitReason + const success = exitReason.isSucceed + + return { + success, + returnData: (info as any).value.toHex(), + gasUsed: (info as any).usedGas.effective.toBigInt(), + } +} + +/** + * Encode parameters for EthereumRuntimeRPCApi_call (Frontier API v6). + */ +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 { + 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() +} + +/** + * 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/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>', + }, +}) 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) + }) +})