diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1d2d902..0beed37 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -42,3 +42,13 @@ jobs: --no-capture env: RUST_LOG: debug + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Run client E2E tests + run: | + cd clients + npm install + npm run test:e2e diff --git a/clients/package.json b/clients/package.json index ece6a13..147599c 100644 --- a/clients/package.json +++ b/clients/package.json @@ -19,9 +19,7 @@ "clean": "rm -rf dist", "prepublishOnly": "npm run clean && npm run build", "test:unit": "tsx --test tests/unit.test.ts", - "test:basic": "tsx tests/basic.test.ts", - "test:flows": "tsx tests/flows.test.ts", - "test:sponsored": "tsx tests/sponsored.test.ts", + "test:e2e": "tsx --test tests/e2e/flows.e2e.test.ts", "test": "npm run test:unit" }, "keywords": [ diff --git a/clients/tests/basic.test.ts b/clients/tests/basic.test.ts deleted file mode 100644 index 4e3179f..0000000 --- a/clients/tests/basic.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createClient, http } from 'viem'; -import { privateKeyToAccount, sign } from 'viem/accounts'; -import { createEvnodeClient } from '../src/index.ts'; - -const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; -const EXECUTOR_KEY = normalizeKey( - process.env.EXECUTOR_PRIVATE_KEY ?? process.env.PRIVATE_KEY, -); -const TO_ADDRESS = process.env.TO_ADDRESS as `0x${string}` | undefined; - -if (!EXECUTOR_KEY) { - throw new Error('Missing EXECUTOR_PRIVATE_KEY or PRIVATE_KEY'); -} - -function normalizeKey(key?: string): `0x${string}` | undefined { - if (!key) return undefined; - return key.startsWith('0x') ? (key as `0x${string}`) : (`0x${key}` as `0x${string}`); -} - -const client = createClient({ - transport: http(RPC_URL), -}); - -const executorAccount = privateKeyToAccount(EXECUTOR_KEY); - -const executor = { - address: executorAccount.address, - signHash: async (hash: `0x${string}`) => sign({ hash, privateKey: EXECUTOR_KEY }), -}; - -const evnode = createEvnodeClient({ - client, - executor, -}); - -async function main() { - const to = TO_ADDRESS ?? executorAccount.address; - const hash = await evnode.send({ - calls: [ - { - to, - value: 0n, - data: '0x', - }, - ], - }); - - console.log('submitted tx:', hash); - - const receipt = await pollReceipt(hash); - if (receipt) { - console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); - } else { - console.log('receipt not found yet'); - } -} - -async function pollReceipt(hash: `0x${string}`) { - for (let i = 0; i < 12; i += 1) { - const receipt = await client.request({ - method: 'eth_getTransactionReceipt', - params: [hash], - }); - if (receipt) return receipt as { status: `0x${string}`; blockNumber: `0x${string}` }; - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - return null; -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/clients/tests/e2e/coordinator.ts b/clients/tests/e2e/coordinator.ts new file mode 100644 index 0000000..5a5b646 --- /dev/null +++ b/clients/tests/e2e/coordinator.ts @@ -0,0 +1,201 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { keccak256, type Hex } from 'viem'; + +export interface CoordinatorOptions { + rpcUrl: string; + engineUrl: string; + jwtSecret: string; // hex-encoded 32 bytes (no 0x prefix) + pollIntervalMs?: number; + feeRecipient?: Hex; + gasLimit?: number; +} + +export class Coordinator { + private rpcUrl: string; + private engineUrl: string; + private jwtSecret: Buffer; + private pollIntervalMs: number; + private feeRecipient: Hex; + private gasLimit: number; + + private parentHash: Hex = '0x0000000000000000000000000000000000000000000000000000000000000000'; + private blockNumber: bigint = 0n; + private timestamp: bigint = 0n; + + private running = false; + private pollTimer: ReturnType | null = null; + private seenTxs = new Set(); + + constructor(opts: CoordinatorOptions) { + this.rpcUrl = opts.rpcUrl; + this.engineUrl = opts.engineUrl; + this.jwtSecret = Buffer.from(opts.jwtSecret, 'hex'); + this.pollIntervalMs = opts.pollIntervalMs ?? 200; + this.feeRecipient = opts.feeRecipient ?? '0x0000000000000000000000000000000000000000'; + this.gasLimit = opts.gasLimit ?? 30_000_000; + } + + async start(): Promise { + const latestBlock = await this.rpcCall(this.rpcUrl, 'eth_getBlockByNumber', ['latest', false]); + this.parentHash = latestBlock.hash; + this.blockNumber = BigInt(latestBlock.number); + this.timestamp = BigInt(latestBlock.timestamp); + this.running = true; + this.poll(); + } + + stop(): void { + this.running = false; + if (this.pollTimer) { + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + } + + getBlockNumber(): bigint { + return this.blockNumber; + } + + private poll(): void { + if (!this.running) return; + + this.pollOnce() + .catch((err) => { + console.error('[coordinator] poll error:', err); + }) + .finally(() => { + if (this.running) { + this.pollTimer = setTimeout(() => this.poll(), this.pollIntervalMs); + } + }); + } + + private async pollOnce(): Promise { + const rawTxs: Hex[] = await this.rpcCall(this.rpcUrl, 'txpoolExt_getTxs', []); + + const newTxs: Hex[] = []; + for (const rawTx of rawTxs) { + const txHash = keccak256(rawTx); + if (!this.seenTxs.has(txHash)) { + this.seenTxs.add(txHash); + newTxs.push(rawTx); + } + } + + if (newTxs.length > 0) { + await this.mineBlock(newTxs); + } + } + + private async mineBlock(txs: Hex[]): Promise { + const newTimestamp = this.timestamp + 12n; + const prevRandao = '0x' + randomBytes(32).toString('hex') as Hex; + + const forkchoiceState = { + headBlockHash: this.parentHash, + safeBlockHash: this.parentHash, + finalizedBlockHash: this.parentHash, + }; + + const payloadAttributes = { + timestamp: '0x' + newTimestamp.toString(16), + prevRandao, + suggestedFeeRecipient: this.feeRecipient, + withdrawals: [], + parentBeaconBlockRoot: '0x0000000000000000000000000000000000000000000000000000000000000000', + transactions: txs, + gasLimit: this.gasLimit, + }; + + // Step 1: FCU with payload attributes -> get payloadId + const fcuResult = await this.engineCall('engine_forkchoiceUpdatedV3', [ + forkchoiceState, + payloadAttributes, + ]); + const payloadId = fcuResult.payloadId; + if (!payloadId) { + throw new Error('No payloadId returned from forkchoiceUpdated'); + } + + // Step 2: getPayload -> get execution payload + const payloadEnvelope = await this.engineCall('engine_getPayloadV3', [payloadId]); + const executionPayload = payloadEnvelope.executionPayload; + + // Step 3: newPayload -> validate + const newPayloadStatus = await this.engineCall('engine_newPayloadV3', [ + executionPayload, + [], + '0x0000000000000000000000000000000000000000000000000000000000000000', + ]); + if (newPayloadStatus.status !== 'VALID') { + throw new Error(`newPayload returned status: ${newPayloadStatus.status}`); + } + + // Step 4: FCU to finalize new head + const newBlockHash = executionPayload.blockHash; + await this.engineCall('engine_forkchoiceUpdatedV3', [ + { + headBlockHash: newBlockHash, + safeBlockHash: newBlockHash, + finalizedBlockHash: newBlockHash, + }, + null, + ]); + + // Update internal state + this.parentHash = newBlockHash; + this.blockNumber = BigInt(executionPayload.blockNumber); + this.timestamp = BigInt(executionPayload.timestamp); + } + + private async rpcCall(url: string, method: string, params: unknown[]): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }); + const json = await res.json(); + if (json.error) { + throw new Error(`RPC ${method}: ${json.error.message ?? JSON.stringify(json.error)}`); + } + return json.result; + } + + private async engineCall(method: string, params: unknown[]): Promise { + const token = this.createJwt(); + const res = await fetch(this.engineUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }); + const json = await res.json(); + if (json.error) { + throw new Error(`Engine ${method}: ${json.error.message ?? JSON.stringify(json.error)}`); + } + return json.result; + } + + private createJwt(): string { + const header = { alg: 'HS256', typ: 'JWT' }; + const now = Math.floor(Date.now() / 1000); + const payload = { iat: now, exp: now + 3600 }; + + const b64Header = base64url(JSON.stringify(header)); + const b64Payload = base64url(JSON.stringify(payload)); + const unsigned = `${b64Header}.${b64Payload}`; + + const signature = createHmac('sha256', this.jwtSecret) + .update(unsigned) + .digest(); + + return `${unsigned}.${base64url(signature)}`; + } +} + +function base64url(input: string | Buffer): string { + const buf = typeof input === 'string' ? Buffer.from(input) : input; + return buf.toString('base64url'); +} diff --git a/clients/tests/e2e/flows.e2e.test.ts b/clients/tests/e2e/flows.e2e.test.ts new file mode 100644 index 0000000..c78bc66 --- /dev/null +++ b/clients/tests/e2e/flows.e2e.test.ts @@ -0,0 +1,158 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { createClient, hexToBigInt, http, type Hex, toHex } from 'viem'; +import { privateKeyToAccount, sign } from 'viem/accounts'; +import { randomBytes } from 'node:crypto'; +import { createEvnodeClient, type Call } from '../../src/index.ts'; +import { setupTestNode, type TestContext } from './setup.ts'; + +// Hardhat account #0 — pre-funded in genesis.json +const EXECUTOR_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as const; +const TRANSFER_AMOUNT = BigInt('1000000000000000'); // 0.001 ETH +const SPONSOR_FUND_WEI = BigInt('10000000000000000'); // 0.01 ETH +const RECEIPT_TIMEOUT_MS = 30_000; + +describe('flows e2e', { timeout: 120_000 }, () => { + let ctx: TestContext; + let rpcUrl: string; + + before(async () => { + ctx = await setupTestNode(); + rpcUrl = ctx.rpcUrl; + }); + + after(async () => { + if (ctx) await ctx.cleanup(); + }); + + function makeClient() { + const client = createClient({ transport: http(rpcUrl) }); + const executorAccount = privateKeyToAccount(EXECUTOR_KEY); + const executor = { + address: executorAccount.address, + signHash: async (hash: Hex) => sign({ hash, privateKey: EXECUTOR_KEY }), + }; + return { client, executorAccount, executor }; + } + + it('unsponsored single call', async () => { + const { client, executorAccount, executor } = makeClient(); + const evnode = createEvnodeClient({ client, executor }); + + const balanceBefore = await getBalance(client, executorAccount.address); + + const hash = await evnode.send({ + calls: [{ to: executorAccount.address, value: 0n, data: '0x' }], + }); + + const receipt = await waitForReceipt(client, hash, RECEIPT_TIMEOUT_MS); + assert.equal(receipt.status, '0x1', 'tx should succeed'); + + const balanceAfter = await getBalance(client, executorAccount.address); + assert.ok(balanceBefore > balanceAfter, 'executor should have spent gas'); + }); + + it('unsponsored batch (two transfers)', async () => { + const { client, executorAccount, executor } = makeClient(); + const evnode = createEvnodeClient({ client, executor }); + + const recipient1 = privateKeyToAccount(toHex(randomBytes(32)) as `0x${string}`).address; + const recipient2 = privateKeyToAccount(toHex(randomBytes(32)) as `0x${string}`).address; + + const recipient1Before = await getBalance(client, recipient1); + const recipient2Before = await getBalance(client, recipient2); + + const hash = await evnode.send({ + calls: [ + { to: recipient1, value: TRANSFER_AMOUNT, data: '0x' }, + { to: recipient2, value: TRANSFER_AMOUNT, data: '0x' }, + ], + }); + + const receipt = await waitForReceipt(client, hash, RECEIPT_TIMEOUT_MS); + assert.equal(receipt.status, '0x1', 'batch tx should succeed'); + + const recipient1After = await getBalance(client, recipient1); + const recipient2After = await getBalance(client, recipient2); + assert.equal(recipient1After - recipient1Before, TRANSFER_AMOUNT, 'recipient1 should receive exact amount'); + assert.equal(recipient2After - recipient2Before, TRANSFER_AMOUNT, 'recipient2 should receive exact amount'); + }); + + it('sponsored single call', async () => { + await runSponsoredTest([{ to: '0x0000000000000000000000000000000000000001', value: 0n, data: '0x' }]); + }); + + it('sponsored batch', async () => { + await runSponsoredTest([ + { to: '0x0000000000000000000000000000000000000001', value: 0n, data: '0x' }, + { to: '0x0000000000000000000000000000000000000002', value: 0n, data: '0x' }, + ]); + }); + + async function runSponsoredTest(calls: Call[]) { + const { client, executorAccount, executor } = makeClient(); + + // Create fresh sponsor + const sponsorKey = toHex(randomBytes(32)) as `0x${string}`; + const sponsorAccount = privateKeyToAccount(sponsorKey); + const sponsor = { + address: sponsorAccount.address, + signHash: async (hash: Hex) => sign({ hash, privateKey: sponsorKey }), + }; + + const evnodeUnsponsored = createEvnodeClient({ client, executor }); + const evnodeSponsored = createEvnodeClient({ client, executor, sponsor }); + + // Fund the sponsor + const fundHash = await evnodeUnsponsored.send({ + calls: [{ to: sponsorAccount.address, value: SPONSOR_FUND_WEI, data: '0x' }], + }); + const fundReceipt = await waitForReceipt(client, fundHash, RECEIPT_TIMEOUT_MS); + assert.equal(fundReceipt.status, '0x1', 'funding tx should succeed'); + + // Get balances before sponsored tx + const executorBalanceBefore = await getBalance(client, executorAccount.address); + const sponsorBalanceBefore = await getBalance(client, sponsorAccount.address); + + // Execute sponsored tx + const intent = await evnodeSponsored.createIntent({ calls }); + const hash = await evnodeSponsored.sponsorAndSend({ intent }); + + const receipt = await waitForReceipt(client, hash, RECEIPT_TIMEOUT_MS); + assert.equal(receipt.status, '0x1', 'sponsored tx should succeed'); + + // Verify sponsor paid gas, executor did not + const executorBalanceAfter = await getBalance(client, executorAccount.address); + const sponsorBalanceAfter = await getBalance(client, sponsorAccount.address); + + assert.equal(executorBalanceBefore, executorBalanceAfter, 'executor balance should not change'); + assert.ok(sponsorBalanceBefore > sponsorBalanceAfter, 'sponsor should have paid gas'); + } +}); + +// --- helpers --- + +async function getBalance(client: any, address: `0x${string}`): Promise { + const hex = await client.request({ + method: 'eth_getBalance', + params: [address, 'latest'], + }); + return hexToBigInt(hex as Hex); +} + +async function waitForReceipt( + client: any, + hash: Hex, + timeoutMs: number, +): Promise<{ status: Hex; blockNumber: Hex }> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const receipt = await client.request({ + method: 'eth_getTransactionReceipt', + params: [hash], + }); + if (receipt) return receipt as { status: Hex; blockNumber: Hex }; + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`Receipt not found for ${hash} within ${timeoutMs}ms`); +} diff --git a/clients/tests/e2e/setup.ts b/clients/tests/e2e/setup.ts new file mode 100644 index 0000000..69706ef --- /dev/null +++ b/clients/tests/e2e/setup.ts @@ -0,0 +1,135 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { Coordinator } from './coordinator.ts'; + +const RPC_PORT = 8545; +const ENGINE_PORT = 8551; + +export interface TestContext { + rpcUrl: string; + coordinator: Coordinator; + cleanup: () => Promise; +} + +export async function setupTestNode(): Promise { + const repoRoot = resolve(import.meta.dirname, '..', '..', '..'); + + // 1. Create temp dir + const tmpDir = await mkdtemp(join(tmpdir(), 'ev-reth-e2e-')); + + // 2. Generate JWT secret + const jwtSecret = randomBytes(32).toString('hex'); + const jwtPath = join(tmpDir, 'jwt.hex'); + await writeFile(jwtPath, jwtSecret); + + // 3. Find ev-reth binary + const releaseBin = join(repoRoot, 'target', 'release', 'ev-reth'); + const debugBin = join(repoRoot, 'target', 'debug', 'ev-reth'); + let binaryPath: string; + if (existsSync(releaseBin)) { + binaryPath = releaseBin; + } else if (existsSync(debugBin)) { + binaryPath = debugBin; + } else { + throw new Error( + `ev-reth binary not found. Run 'make build' or 'make build-dev' first.\n` + + `Checked: ${releaseBin}\n ${debugBin}`, + ); + } + + // 4. Spawn ev-reth + const genesisPath = join(repoRoot, 'crates', 'tests', 'assets', 'genesis.json'); + const dataDir = join(tmpDir, 'data'); + + const child = spawn(binaryPath, [ + 'node', + '--chain', genesisPath, + '--datadir', dataDir, + '--http', + '--http.port', String(RPC_PORT), + '--http.api', 'eth,net,web3', + '--authrpc.port', String(ENGINE_PORT), + '--authrpc.jwtsecret', jwtPath, + '--log.stdout.filter', 'error', + ], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // Forward stderr for debugging if needed + child.stderr?.on('data', (data: Buffer) => { + const msg = data.toString().trim(); + if (msg) console.error('[ev-reth]', msg); + }); + + // 5. Wait for RPC to be ready + const rpcUrl = `http://127.0.0.1:${RPC_PORT}`; + await waitForRpc(rpcUrl, child); + + // 6. Create and start coordinator + const engineUrl = `http://127.0.0.1:${ENGINE_PORT}`; + const coordinator = new Coordinator({ + rpcUrl, + engineUrl, + jwtSecret, + pollIntervalMs: 200, + }); + await coordinator.start(); + + // 7. Return context + const cleanup = async () => { + coordinator.stop(); + await killProcess(child); + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + }; + + return { rpcUrl, coordinator, cleanup }; +} + +async function waitForRpc(rpcUrl: string, child: ChildProcess): Promise { + const timeoutMs = 30_000; + const intervalMs = 500; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + // Check if process died + if (child.exitCode !== null) { + throw new Error(`ev-reth exited with code ${child.exitCode} before RPC was ready`); + } + + try { + const res = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] }), + }); + const json = await res.json(); + if (json.result) return; + } catch { + // not ready yet + } + + await new Promise((r) => setTimeout(r, intervalMs)); + } + + throw new Error(`ev-reth RPC did not become ready within ${timeoutMs}ms`); +} + +async function killProcess(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + + child.kill('SIGTERM'); + + const exited = await Promise.race([ + new Promise((resolve) => child.on('exit', () => resolve(true))), + new Promise((resolve) => setTimeout(() => resolve(false), 5_000)), + ]); + + if (!exited && child.exitCode === null) { + child.kill('SIGKILL'); + await new Promise((resolve) => child.on('exit', () => resolve())); + } +} diff --git a/clients/tests/flows.test.ts b/clients/tests/flows.test.ts deleted file mode 100644 index 3c2c8b7..0000000 --- a/clients/tests/flows.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { createClient, hexToBigInt, http, type Hex, toHex, formatEther } from 'viem'; -import { privateKeyToAccount, sign } from 'viem/accounts'; -import { randomBytes } from 'crypto'; -import { createEvnodeClient, type Call } from '../src/index.ts'; - -const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; -const EXECUTOR_KEY = normalizeKey( - process.env.EXECUTOR_PRIVATE_KEY ?? process.env.PRIVATE_KEY, -); -const TO_ADDRESS = process.env.TO_ADDRESS as `0x${string}` | undefined; -const SPONSOR_FUND_WEI = BigInt(process.env.SPONSOR_FUND_WEI ?? '10000000000000000'); // 0.01 ETH - -if (!EXECUTOR_KEY) { - throw new Error('Missing EXECUTOR_PRIVATE_KEY/PRIVATE_KEY'); -} - -const client = createClient({ transport: http(RPC_URL) }); -const executorAccount = privateKeyToAccount(EXECUTOR_KEY); - -const executor = { - address: executorAccount.address, - signHash: async (hash: Hex) => sign({ hash, privateKey: EXECUTOR_KEY }), -}; - -// For unsponsored txs, we use executor-only client -const evnodeUnsponsored = createEvnodeClient({ - client, - executor, -}); - -async function main() { - const to = TO_ADDRESS ?? executorAccount.address; - - console.log('executor', executorAccount.address); - console.log(''); - - // Run all flows - await runUnsponsoredFlow('unsponsored-single', [call(to)]); - await runUnsponsoredBatchFlow(); - await runSponsoredFlow('sponsored-single', [call(to)]); - await runSponsoredFlow('sponsored-batch', [call(to), call(to)]); -} - -async function runUnsponsoredFlow(name: string, calls: Call[]) { - console.log(`\n== ${name} ==`); - - const executorBalanceBefore = await getBalance(executorAccount.address); - console.log('executor balance before:', formatEther(executorBalanceBefore), 'ETH'); - - const hash = await evnodeUnsponsored.send({ calls }); - console.log('submitted tx:', hash); - - const receipt = await pollReceipt(hash); - if (receipt) { - console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); - - const executorBalanceAfter = await getBalance(executorAccount.address); - const executorSpent = executorBalanceBefore - executorBalanceAfter; - console.log('executor balance after:', formatEther(executorBalanceAfter), 'ETH'); - console.log('executor spent (gas):', formatEther(executorSpent), 'ETH'); - } else { - console.log('receipt not found yet'); - } -} - -const TRANSFER_AMOUNT = BigInt('1000000000000000'); // 0.001 ETH - -async function runUnsponsoredBatchFlow() { - console.log('\n== unsponsored-batch =='); - - // Create 2 random recipient addresses - const recipient1Key = toHex(randomBytes(32)) as `0x${string}`; - const recipient2Key = toHex(randomBytes(32)) as `0x${string}`; - const recipient1 = privateKeyToAccount(recipient1Key).address; - const recipient2 = privateKeyToAccount(recipient2Key).address; - - console.log('recipient1:', recipient1); - console.log('recipient2:', recipient2); - - // Get balances before - const executorBalanceBefore = await getBalance(executorAccount.address); - const recipient1Before = await getBalance(recipient1); - const recipient2Before = await getBalance(recipient2); - - console.log('\n1. Balances before:'); - console.log(' executor:', formatEther(executorBalanceBefore), 'ETH'); - console.log(' recipient1:', formatEther(recipient1Before), 'ETH'); - console.log(' recipient2:', formatEther(recipient2Before), 'ETH'); - - // Send batch: transfer 0.001 ETH to each recipient - console.log('\n2. Sending batch (0.001 ETH to each recipient)...'); - const hash = await evnodeUnsponsored.send({ - calls: [ - { to: recipient1, value: TRANSFER_AMOUNT, data: '0x' }, - { to: recipient2, value: TRANSFER_AMOUNT, data: '0x' }, - ], - }); - console.log('submitted tx:', hash); - - const receipt = await pollReceipt(hash); - if (receipt) { - console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); - - // Get balances after - const executorBalanceAfter = await getBalance(executorAccount.address); - const recipient1After = await getBalance(recipient1); - const recipient2After = await getBalance(recipient2); - - console.log('\n3. Balances after:'); - console.log(' executor:', formatEther(executorBalanceAfter), 'ETH'); - console.log(' recipient1:', formatEther(recipient1After), 'ETH'); - console.log(' recipient2:', formatEther(recipient2After), 'ETH'); - - // Verify transfers - const executorSpent = executorBalanceBefore - executorBalanceAfter; - const recipient1Received = recipient1After - recipient1Before; - const recipient2Received = recipient2After - recipient2Before; - const totalTransferred = TRANSFER_AMOUNT * 2n; - const gasSpent = executorSpent - totalTransferred; - - console.log('\n4. Verification:'); - console.log(' executor total spent:', formatEther(executorSpent), 'ETH'); - console.log(' gas cost:', formatEther(gasSpent), 'ETH'); - console.log(' recipient1 received:', formatEther(recipient1Received), 'ETH'); - console.log(' recipient2 received:', formatEther(recipient2Received), 'ETH'); - - if (recipient1Received === TRANSFER_AMOUNT && recipient2Received === TRANSFER_AMOUNT) { - console.log('\n✓ VERIFIED: Both recipients received exactly 0.001 ETH'); - } else { - console.log('\n✗ UNEXPECTED: Transfer amounts do not match'); - } - } else { - console.log('receipt not found yet'); - } -} - -async function runSponsoredFlow(name: string, calls: Call[]) { - console.log(`\n== ${name} ==`); - - // Create a fresh sponsor for each sponsored test - const sponsorKey = toHex(randomBytes(32)) as `0x${string}`; - const sponsorAccount = privateKeyToAccount(sponsorKey); - - console.log('sponsor address:', sponsorAccount.address); - console.log('sponsor key:', sponsorKey); - - const sponsor = { - address: sponsorAccount.address, - signHash: async (hash: Hex) => sign({ hash, privateKey: sponsorKey }), - }; - - // Create evnode client with this sponsor - const evnodeSponsored = createEvnodeClient({ - client, - executor, - sponsor, - }); - - // Step 1: Fund the sponsor - console.log('\n1. Funding sponsor with', formatEther(SPONSOR_FUND_WEI), 'ETH...'); - const fundingHash = await evnodeUnsponsored.send({ - calls: [{ to: sponsorAccount.address, value: SPONSOR_FUND_WEI, data: '0x' }], - }); - console.log('funding tx:', fundingHash); - - const fundingReceipt = await pollReceipt(fundingHash); - if (!fundingReceipt) { - console.log('ERROR: funding tx not mined'); - return; - } - console.log('funding tx mined in block:', fundingReceipt.blockNumber); - - // Step 2: Get balances before sponsored tx - const executorBalanceBefore = await getBalance(executorAccount.address); - const sponsorBalanceBefore = await getBalance(sponsorAccount.address); - - console.log('\n2. Balances before sponsored tx:'); - console.log(' executor:', formatEther(executorBalanceBefore), 'ETH'); - console.log(' sponsor:', formatEther(sponsorBalanceBefore), 'ETH'); - - // Step 3: Execute sponsored tx - console.log('\n3. Executing sponsored tx...'); - const intent = await evnodeSponsored.createIntent({ calls }); - const hash = await evnodeSponsored.sponsorAndSend({ intent }); - console.log('submitted tx:', hash); - - const receipt = await pollReceipt(hash); - if (receipt) { - console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); - - // Step 4: Get balances after and verify - const executorBalanceAfter = await getBalance(executorAccount.address); - const sponsorBalanceAfter = await getBalance(sponsorAccount.address); - - const executorDiff = executorBalanceBefore - executorBalanceAfter; - const sponsorDiff = sponsorBalanceBefore - sponsorBalanceAfter; - - console.log('\n4. Balances after sponsored tx:'); - console.log(' executor:', formatEther(executorBalanceAfter), 'ETH'); - console.log(' sponsor:', formatEther(sponsorBalanceAfter), 'ETH'); - - console.log('\n5. Balance changes:'); - console.log(' executor spent:', formatEther(executorDiff), 'ETH'); - console.log(' sponsor spent:', formatEther(sponsorDiff), 'ETH (should be gas cost)'); - - // Verify sponsor paid gas - if (sponsorDiff > 0n && executorDiff === 0n) { - console.log('\n✓ VERIFIED: Sponsor paid gas, executor paid nothing'); - } else if (sponsorDiff > 0n) { - console.log('\n✓ Sponsor paid gas:', formatEther(sponsorDiff), 'ETH'); - if (executorDiff > 0n) { - console.log(' (executor also spent some, possibly from value transfer in calls)'); - } - } else { - console.log('\n✗ UNEXPECTED: Sponsor did not pay gas'); - } - } else { - console.log('receipt not found yet'); - } -} - -function call(to: `0x${string}`): Call { - return { to, value: 0n, data: '0x' }; -} - -async function getBalance(address: `0x${string}`): Promise { - const balanceHex = await client.request({ - method: 'eth_getBalance', - params: [address, 'latest'], - }); - return hexToBigInt(balanceHex as Hex); -} - -async function pollReceipt(hash: Hex) { - for (let i = 0; i < 20; i += 1) { - const receipt = await client.request({ - method: 'eth_getTransactionReceipt', - params: [hash], - }); - if (receipt) return receipt as { status: Hex; blockNumber: Hex }; - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - return null; -} - -function normalizeKey(key?: string): `0x${string}` | '' | undefined { - if (!key) return undefined; - return key.startsWith('0x') ? (key as `0x${string}`) : (`0x${key}` as `0x${string}`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/clients/tests/sponsored.test.ts b/clients/tests/sponsored.test.ts deleted file mode 100644 index a44e260..0000000 --- a/clients/tests/sponsored.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { createClient, hexToBigInt, http, type Hex, toHex } from 'viem'; -import { privateKeyToAccount, sign } from 'viem/accounts'; -import { randomBytes } from 'crypto'; -import { createEvnodeClient } from '../src/index.ts'; - -const RPC_URL = process.env.RPC_URL ?? 'http://localhost:8545'; -const EXECUTOR_KEY = normalizeKey( - process.env.EXECUTOR_PRIVATE_KEY ?? process.env.PRIVATE_KEY, -); -const SPONSOR_KEY = normalizeKey(process.env.SPONSOR_PRIVATE_KEY ?? ''); -const TO_ADDRESS = process.env.TO_ADDRESS as `0x${string}` | undefined; -const AUTO_SPONSOR = - process.env.AUTO_SPONSOR === '1' || process.env.AUTO_SPONSOR === 'true'; -const FUND_SPONSOR = - process.env.FUND_SPONSOR === '1' || process.env.FUND_SPONSOR === 'true'; -const SPONSOR_MIN_BALANCE_WEI = BigInt(process.env.SPONSOR_MIN_BALANCE_WEI ?? '0'); -const SPONSOR_FUND_WEI = BigInt(process.env.SPONSOR_FUND_WEI ?? '10000000000000000'); - -if (!EXECUTOR_KEY) { - throw new Error('Missing EXECUTOR_PRIVATE_KEY/PRIVATE_KEY'); -} - -const autoSponsorKey = AUTO_SPONSOR ? toHex(randomBytes(32)) : undefined; -const sponsorKey = (SPONSOR_KEY || autoSponsorKey || EXECUTOR_KEY) as `0x${string}`; -const client = createClient({ transport: http(RPC_URL) }); - -const executorAccount = privateKeyToAccount(EXECUTOR_KEY); -const sponsorAccount = privateKeyToAccount(sponsorKey); - -const evnode = createEvnodeClient({ - client, - executor: { - address: executorAccount.address, - signHash: async (hash: Hex) => sign({ hash, privateKey: EXECUTOR_KEY }), - }, - sponsor: { - address: sponsorAccount.address, - signHash: async (hash: Hex) => sign({ hash, privateKey: sponsorKey }), - }, -}); - -async function main() { - const to = TO_ADDRESS ?? executorAccount.address; - console.log('executor', executorAccount.address); - console.log('sponsor', sponsorAccount.address); - if (autoSponsorKey) { - console.log('auto sponsor key', sponsorKey); - } - - await maybeFundSponsor(); - const intent = await evnode.createIntent({ - calls: [{ to, value: 0n, data: '0x' }], - }); - const hash = await evnode.sponsorAndSend({ intent }); - console.log('submitted sponsored tx:', hash); - const receipt = await pollReceipt(hash); - if (receipt) { - console.log('receipt status:', receipt.status, 'block:', receipt.blockNumber); - } else { - console.log('receipt not found yet'); - } -} - -async function maybeFundSponsor() { - if (sponsorAccount.address === executorAccount.address) return; - if (!FUND_SPONSOR && SPONSOR_MIN_BALANCE_WEI === 0n) return; - const balanceHex = await client.request({ - method: 'eth_getBalance', - params: [sponsorAccount.address, 'latest'], - }); - const balance = hexToBigInt(balanceHex as Hex); - if (!FUND_SPONSOR && balance >= SPONSOR_MIN_BALANCE_WEI) return; - const target = SPONSOR_MIN_BALANCE_WEI > 0n ? SPONSOR_MIN_BALANCE_WEI : SPONSOR_FUND_WEI; - const amount = target > balance ? target - balance : SPONSOR_FUND_WEI; - if (amount <= 0n) return; - console.log('funding sponsor with', amount.toString(), 'wei'); - const hash = await evnode.send({ - calls: [{ to: sponsorAccount.address, value: amount, data: '0x' }], - }); - const receipt = await pollReceiptWithTimeout(hash, 30); - if (!receipt) throw new Error('sponsor funding tx not mined'); -} - -async function pollReceipt(hash: Hex) { - for (let i = 0; i < 15; i += 1) { - const receipt = await client.request({ - method: 'eth_getTransactionReceipt', - params: [hash], - }); - if (receipt) return receipt as { status: Hex; blockNumber: Hex }; - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - return null; -} - -async function pollReceiptWithTimeout(hash: Hex, attempts: number) { - for (let i = 0; i < attempts; i += 1) { - const receipt = await client.request({ - method: 'eth_getTransactionReceipt', - params: [hash], - }); - if (receipt) return receipt as { status: Hex; blockNumber: Hex }; - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - return null; -} - -function normalizeKey(key?: string): `0x${string}` | '' | undefined { - if (!key) return undefined; - return key.startsWith('0x') ? (key as `0x${string}`) : (`0x${key}` as `0x${string}`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -});