diff --git a/CLAUDE.md b/CLAUDE.md index 402f164..b3cb5b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,17 +4,19 @@ Deno 2.x test suite that validates Ethereum JSON-RPC compatibility for Polkadot' ## Quick Reference -| Command | What it does | -| ---------------------------- | -------------------------------------------------------------------- | -| `deno task test:evm` | Build + test EVM bytecode on Revive (auto-starts dev-node + eth-rpc) | -| `deno task test:pvm` | Build + test PVM bytecode on Revive (auto-starts dev-node + eth-rpc) | -| `deno task test:geth` | Build + test EVM bytecode on Geth (reference) | -| `deno task test:evm:manual` | EVM tests without auto-starting services | -| `deno task test:pvm:manual` | PVM tests without auto-starting services | -| `deno task test:update` | Update snapshots from Geth | -| `deno task build` | Compile contracts (solc + resolc) | -| `deno task build --solcOnly` | Compile contracts (solc only, faster) | -| `deno task lint` | Format check + lint | +| Command | What it does | +| ------------------------------- | -------------------------------------------------------------------- | +| `deno task test:evm` | Build + test EVM bytecode on Revive (auto-starts dev-node + eth-rpc) | +| `deno task test:pvm` | Build + test PVM bytecode on Revive (auto-starts dev-node + eth-rpc) | +| `deno task test:geth` | Build + test EVM bytecode on Geth (reference) | +| `deno task test:evm:manual` | EVM tests without auto-starting services | +| `deno task test:pvm:manual` | PVM tests without auto-starting services | +| `deno task test:westend` | Build + test against live Westend runtime (auto-starts services) | +| `deno task test:westend:manual` | Westend tests without auto-starting services | +| `deno task test:update` | Update snapshots from Geth | +| `deno task build` | Compile contracts (solc + resolc) | +| `deno task build --solcOnly` | Compile contracts (solc only, faster) | +| `deno task lint` | Format check + lint | ## Architecture @@ -26,16 +28,18 @@ Deno 2.x test suite that validates Ethereum JSON-RPC compatibility for Polkadot' ## Environment Variables -| Variable | Purpose | Default | -| ----------------------- | ----------------------- | --------------------------------------------- | -| `USE_BYTECODE` | `evm` or `pvm` | — | -| `START_REVIVE_DEV_NODE` | Auto-start dev-node | — | -| `START_ETH_RPC` | Auto-start eth-rpc | — | -| `START_GETH` | Auto-start geth | — | -| `REVIVE_DEV_NODE_PATH` | Path to dev-node binary | `~/polkadot-sdk/target/debug/revive-dev-node` | -| `ETH_RPC_PATH` | Path to eth-rpc binary | `~/polkadot-sdk/target/debug/eth-rpc` | -| `GETH_PATH` | Path to geth binary | `geth` | -| `RPC_PORT` | JSON-RPC port | `8545` | +| Variable | Purpose | Default | +| ----------------------- | ----------------------------- | --------------------------------------------- | +| `USE_BYTECODE` | `evm` or `pvm` | — | +| `START_REVIVE_DEV_NODE` | Auto-start dev-node | — | +| `START_ETH_RPC` | Auto-start eth-rpc | — | +| `START_GETH` | Auto-start geth | — | +| `REVIVE_DEV_NODE_PATH` | Path to dev-node binary | `~/polkadot-sdk/target/debug/revive-dev-node` | +| `ETH_RPC_PATH` | Path to eth-rpc binary | `~/polkadot-sdk/target/debug/eth-rpc` | +| `GETH_PATH` | Path to geth binary | `geth` | +| `RPC_PORT` | JSON-RPC port | `8545` | +| `USE_LIVE_RUNTIME` | Download live Westend runtime | — | +| `WESTEND_RPC_URL` | Westend Asset Hub RPC URL | `https://westend-asset-hub-rpc.polkadot.io` | ## CI Jobs (polkadot-sdk) diff --git a/deno.json b/deno.json index 6a37677..a48ed98 100644 --- a/deno.json +++ b/deno.json @@ -18,6 +18,8 @@ "test:update": "deno task build --solcOnly && USE_BYTECODE=evm START_GETH=true deno test -P -- --update", "test:polkadot": "USE_BYTECODE=evm START_ASSET_HUB_WESTEND=true START_ETH_RPC=true deno test -P src/polkadot-tests.ts", "test:polkadot:manual": "USE_BYTECODE=evm deno test -P src/polkadot-tests.ts", + "test:westend": "deno task build --solcOnly && USE_BYTECODE=evm START_ASSET_HUB_WESTEND=true USE_LIVE_RUNTIME=true START_ETH_RPC=true deno test -P src/westend-tests.ts", + "test:westend:manual": "USE_BYTECODE=evm deno test -P src/westend-tests.ts", "lint": "deno fmt --check && deno task build && deno check && deno lint" }, "test": { diff --git a/src/test-setup.ts b/src/test-setup.ts index e2d80e3..5dedaa7 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -1,6 +1,8 @@ -import { killProcessOnPort, waitForHealth } from './util.ts' +import { hexToBytes, toHex } from 'viem/utils' +import { getRpcUrl, killProcessOnPort, waitForHealth } from './util.ts' let processes: Deno.ChildProcess[] = [] +let tmpDirs: string[] = [] let setupComplete = false async function detectAndSetPlatform(url: string) { @@ -76,7 +78,12 @@ export async function setupTests() { `${Deno.env.get('HOME')}/polkadot-sdk` const omniNode = Deno.env.get('OMNI_NODE_PATH') ?? `${sdkDir}/target/release/polkadot-omni-node` - const chainSpec = await buildAssetHubWestendSpec(sdkDir, omniNode) + const useLiveRuntime = !!Deno.env.get('USE_LIVE_RUNTIME') + const chainSpec = await buildAssetHubWestendSpec( + sdkDir, + omniNode, + useLiveRuntime, + ) const nodeArgs = [ '--dev', @@ -142,13 +149,11 @@ export async function setupTests() { } // Detect and set the PLATFORM variable - const port = Deno.env.get('RPC_PORT') ?? '8545' - const url = `http://localhost:${port}` - await detectAndSetPlatform(url) + await detectAndSetPlatform(getRpcUrl()) } export function cleanupTests() { - if (processes.length === 0) { + if (processes.length === 0 && tmpDirs.length === 0) { return } console.log('🔌 Shutting down servers...') @@ -159,7 +164,15 @@ export function cleanupTests() { // Process might already be dead } } + for (const dir of tmpDirs) { + try { + Deno.removeSync(dir, { recursive: true }) + } catch { + // Directory might already be gone + } + } processes = [] + tmpDirs = [] } // --------------------------------------------------------------------------- @@ -200,14 +213,59 @@ async function runCommand( return new TextDecoder().decode(output.stdout) } +async function downloadLiveRuntime( + tmpDir: string, + rpcUrl: string, +): Promise { + const codeStorageKey = toHex(':code') + console.log(`📦 Downloading runtime from ${rpcUrl} ...`) + const resp = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(120_000 /* 2 min */), + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'state_getStorage', + params: [codeStorageKey], + id: 1, + }), + }) + if (!resp.ok) { + throw new Error( + `RPC request failed: ${resp.status} ${resp.statusText}`, + ) + } + const { result } = (await resp.json()) as { result: string } + if (!result || result === '0x') { + throw new Error('Failed to fetch runtime wasm from live network') + } + const runtimeCode = hexToBytes(result as `0x${string}`) + const path = `${tmpDir}/westend-asset-hub-runtime.wasm` + await Deno.writeFile(path, runtimeCode) + console.log( + `📦 Runtime downloaded (${ + (runtimeCode.length / 1024 / 1024).toFixed(1) + } MB)`, + ) + return path +} + async function buildAssetHubWestendSpec( sdkDir: string, omniNode: string, + useLiveRuntime = false, ): Promise { - const runtime = - `${sdkDir}/target/release/wbuild/asset-hub-westend-runtime/asset_hub_westend_runtime.compact.compressed.wasm` - const basePath = '/tmp/ah-westend-base.json' - const rawPath = '/tmp/ah-westend-raw.json' + const tmpDir = await Deno.makeTempDir({ prefix: 'ah-westend-' }) + tmpDirs.push(tmpDir) + const runtime = useLiveRuntime + ? await downloadLiveRuntime( + tmpDir, + Deno.env.get('WESTEND_RPC_URL') ?? + 'https://westend-asset-hub-rpc.polkadot.io', + ) + : `${sdkDir}/target/release/wbuild/asset-hub-westend-runtime/asset_hub_westend_runtime.compact.compressed.wasm` + const basePath = `${tmpDir}/ah-westend-base.json` + const rawPath = `${tmpDir}/ah-westend-raw.json` // Step 1: Generate base chain spec (writes to file via --chain-spec-path) console.log('📋 Generating asset-hub-westend chain spec ...') @@ -234,6 +292,15 @@ async function buildAssetHubWestendSpec( baseSpec.genesis.runtimeGenesis ??= {} baseSpec.genesis.runtimeGenesis.patch ??= {} baseSpec.genesis.runtimeGenesis.patch.sudo = { key: ALICE_SS58 } + // Fund alith (0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac) so eth_accounts[0] has balance. + const ALITH_SS58 = '5HYRCKHYJN9z5xUtfFkyMj4JUhsAwWyvuU8vKB1FcnYTf9ZQ' + const balances = baseSpec.genesis.runtimeGenesis.patch.balances?.balances ?? + [] + balances.push([ALITH_SS58, Number.MAX_SAFE_INTEGER]) + baseSpec.genesis.runtimeGenesis.patch.balances = { + ...baseSpec.genesis.runtimeGenesis.patch.balances, + balances, + } await Deno.writeTextFile(basePath, JSON.stringify(baseSpec, null, 2)) // Step 2: Convert to raw format diff --git a/src/util.ts b/src/util.ts index 6d10777..e6b947f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -90,29 +90,29 @@ function getEnvName(): string { export const jsonRpcErrors: JsonRpcError[] = [] -export type Env = Awaited> -export async function getEnv() { +export function getRpcUrl() { const port = Deno.env.get('RPC_PORT') ?? '8545' - const url = `http://localhost:${port}` - const name = getEnvName() + return `http://localhost:${port}` +} - const id = await (async (): Promise => { - const resp = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'eth_chainId', - id: 1, - }), - }) - const { result } = (await resp.json()) as { result: Hex } - return hexToNumber(result) - })() +async function fetchChainId(url: string): Promise { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_chainId', + id: 1, + }), + }) + const { result } = (await resp.json()) as { result: Hex } + return hexToNumber(result) +} - const chain = defineChain({ +function createChain(id: number, name: string, url: string) { + return defineChain({ id, name, nativeCurrency: { @@ -127,8 +127,10 @@ export async function getEnv() { }, testnet: true, }) +} - const transport = http(url, { +function createTransport(url: string) { + return http(url, { onFetchResponse: async (response) => { const raw = await response.clone().json() if (raw.error) { @@ -136,6 +138,16 @@ export async function getEnv() { } }, }) +} + +export type Env = Awaited> +export async function getEnv() { + const url = getRpcUrl() + const name = getEnvName() + const id = await fetchChainId(url) + const chain = createChain(id, name, url) + const transport = createTransport(url) + const publicClient = createPublicClient({ chain, transport }) const waitForTransactionReceiptExtension = (client: { getTransactionReceipt: (args: { @@ -252,8 +264,6 @@ export async function getEnv() { } } - const publicClient = createPublicClient({ chain, transport }) - const debugClient = createClient({ chain, transport, diff --git a/src/westend-tests.ts b/src/westend-tests.ts new file mode 100644 index 0000000..f0ba88e --- /dev/null +++ b/src/westend-tests.ts @@ -0,0 +1,12 @@ +// Westend live runtime smoke tests — validates eth-rpc requests +// against the live Westend asset-hub runtime. +// Run with: deno task test:westend + +import { cleanupTests, setupTests } from './test-setup.ts' + +await setupTests() +await import('./westend.test.ts') + +globalThis.addEventListener('unload', () => { + cleanupTests() +}) diff --git a/src/westend.test.ts b/src/westend.test.ts new file mode 100644 index 0000000..b7fbce9 --- /dev/null +++ b/src/westend.test.ts @@ -0,0 +1,132 @@ +import { type Hex, hexToBigInt, hexToNumber, parseEther } from 'viem' +import { sanitizeOpts as opts } from './util.ts' +import { expect } from '@std/expect' +import { TesterAbi } from '../codegen/abi/Tester.ts' +import { EventExampleAbi } from '../codegen/abi/EventExample.ts' +import { env, getEventExampleAddr, getTesterAddr } from './deploy_contracts.ts' + +const untypedRequest = (method: string, params?: unknown[]) => + // deno-lint-ignore no-explicit-any + env.publicClient.request({ method, params } as any) + +Deno.test('westend: eth_accounts and eth_chainId', opts, async () => { + const addresses = (await untypedRequest('eth_accounts')) as string[] + expect(addresses.length).toBeGreaterThanOrEqual(1) + expect(addresses[0].toLowerCase()).toEqual( + env.serverWallet.account.address.toLowerCase(), + ) + + const res = await env.publicClient.request({ + method: 'eth_chainId', + }) + expect(hexToNumber(res)).toEqual(env.chain.id) +}) + +Deno.test( + 'westend: eth_gasPrice, eth_maxPriorityFeePerGas, eth_feeHistory', + opts, + async () => { + const gasPrice = await env.publicClient.request({ + method: 'eth_gasPrice', + }) + expect(hexToBigInt(gasPrice) > 0n).toBe(true) + + const maxPriority = await untypedRequest( + 'eth_maxPriorityFeePerGas', + ) as Hex + expect(hexToBigInt(maxPriority) >= 0n).toBe(true) + + const feeHistory = await env.publicClient.getFeeHistory({ + blockCount: 4, + blockTag: 'latest', + rewardPercentiles: [25, 75], + }) + expect(feeHistory.gasUsedRatio.length).toBeGreaterThan(0) + expect(feeHistory.baseFeePerGas).toBeTruthy() + }, +) + +Deno.test( + 'westend: eth_getBlockByNumber and eth_getBlockByHash', + opts, + async () => { + const latest = await env.publicClient.getBlock({ + blockTag: 'latest', + }) + expect(latest).toBeTruthy() + expect(latest.hash).toBeTruthy() + + const byHash = await env.publicClient.getBlock({ + blockHash: latest.hash!, + }) + expect(byHash.number).toEqual(latest.number) + expect(byHash.timestamp).toEqual(latest.timestamp) + }, +) + +Deno.test('westend: eth_getCode and eth_getStorageAt', opts, async () => { + const address = await getTesterAddr() + + const code = await env.publicClient.getCode({ address }) + expect(code).toBeTruthy() + expect(code !== '0x').toBe(true) + + const storage = await env.serverWallet.getStorageAt({ + address, + slot: '0x01', + }) + expect(typeof storage).toEqual('string') + expect(storage !== '0x').toBe(true) +}) + +Deno.test('westend: eth_call reads contract state', opts, async () => { + const value = await env.serverWallet.readContract({ + address: await getTesterAddr(), + abi: TesterAbi, + functionName: 'value', + }) + expect(value).toEqual(42n) +}) + +Deno.test( + 'westend: eth_estimateGas, eth_sendTransaction, eth_getTransactionByHash', + opts, + async () => { + const txParams = { + to: env.serverWallet.account.address, + value: parseEther('0.01'), + } as const + + const gas = await env.accountWallet.estimateGas(txParams) + expect(gas > 0n).toBe(true) + + const hash = await env.accountWallet.sendTransaction(txParams) + const receipt = await env.serverWallet.waitForTransactionReceipt(hash) + expect(receipt.status).toEqual('success') + + const tx = await env.serverWallet.getTransaction({ hash }) + expect(tx).toBeTruthy() + expect(tx.hash).toEqual(hash) + }, +) + +Deno.test( + 'westend: eth_getLogs returns events from contract call', + opts, + async () => { + const address = await getEventExampleAddr() + const { request } = await env.serverWallet.simulateContract({ + address, + abi: EventExampleAbi, + functionName: 'triggerEvent', + }) + const hash = await env.serverWallet.writeContract(request) + const receipt = await env.serverWallet.waitForTransactionReceipt(hash) + const logs = await env.publicClient.getLogs({ + address, + blockHash: receipt.blockHash, + }) + expect(logs.length).toBeGreaterThanOrEqual(1) + expect(logs[0].transactionHash).toEqual(hash) + }, +)