Repository Public
1// End-to-end swap execution on a LOCAL FORK of Base mainnet (anvil). No real funds, nothing reaches the real network:2// a real KyberSwap route and calldata for today's chain state, verified and signed by the real Signer with a passkey3// approval, then executed on the fork. Proves: the signer's checks accept genuine calldata, the signed transactions are4// valid, and the wallet really receives at least the approved minimum.5import { spawn } from 'node:child_process';6import { createHash, randomUUID } from 'node:crypto';7import { mkdtempSync } from 'node:fs';8import { tmpdir } from 'node:os';9import { join } from 'node:path';10import { encodeFunctionData, erc20Abi, formatUnits, hexToBigInt, numberToHex, parseUnits } from 'viem';11import { buildEvmSwapTxs, intentHash, KYBER_ENABLED, KYBER_NATIVE, KYBER_ROUTER, NETWORKS, swapDataHash, type Intent } from '@spw/core';12import { generateMnemonic } from '@scure/bip39';13import { wordlist } from '@scure/bip39/wordlists/english.js';14import { TestPasskey } from '../tests/helpers/passkey.ts';15import { deriveKeys } from '../services/isolated-signer/src/keys.ts';16import { Signer } from '../services/isolated-signer/src/signer.ts';17import { Store } from '../services/isolated-signer/src/store.ts';1819const FORK = 'http://127.0.0.1:8546', UP = process.env.BASE_RPC ?? 'https://mainnet.base.org';20// A fresh throwaway key for every run. The public BIP-39 test mnemonic must NOT be used here: its address carries an EIP-770221// delegation on mainnet (a sweeper), so any coin it receives is forwarded away and a swap to it can never succeed.22const keys = deriveKeys(generateMnemonic(wordlist, 128));23const ME = keys.addresses.evm, USDC = NETWORKS.base.tokens.find((t) => t.symbol === 'USDC')!.id;24const WHALE = process.env.USDC_WHALE ?? '0xF977814e90dA44bFA03b6295A0616a897441aceC';25// classic on-chain AMMs only: proprietary market makers price from oracles refreshed every block, which a frozen fork cannot reproduce26const SOURCES = process.env.KYBER_SOURCES ?? 'uniswapv3,uniswap,uniswap-v4,aerodrome,aerodrome-cl,pancake-v3,sushiswap,sushiswap-v3,baseswap,alien-base-cl';27let id = 0;28const rpc = async <T>(method: string, params: unknown[] = []): Promise<T> => { const r = await fetch(FORK, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }) }); const j = await r.json() as any; if (j.error) throw new Error(`${method}: ${j.error.message}`); return j.result; };29const bal = async (token: string) => token === 'native' ? hexToBigInt(await rpc<`0x${string}`>('eth_getBalance', [ME, 'latest']))30 : hexToBigInt(await rpc<`0x${string}`>('eth_call', [{ to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'balanceOf', args: [ME as `0x${string}`] }) }, 'latest']));31const mine = async (raw: string) => { const h = await rpc<string>('eth_sendRawTransaction', [raw]); for (let k = 0; k < 600; k++) { const rc = await rpc<any>("eth_getTransactionReceipt", [h]); if (rc) return rc; await new Promise((r) => setTimeout(r, 200)); } throw new Error('no receipt'); };32const kyber = async (path: string, init?: RequestInit) => { const r = await fetch('https://aggregator-api.kyberswap.com/base/api/v1' + path, { ...init, headers: { 'x-client-id': 'swap-router', 'content-type': 'application/json' } }); const j = await r.json() as any; if (j.code !== 0) throw new Error(`kyber ${path.split('?')[0]}: ${j.code} ${j.message}`); return j.data; };3334const anvil = spawn('anvil', ['--fork-url', UP, '--port', '8546', '--chain-id', '8453', '--silent'], { stdio: 'ignore' });35const stop = () => anvil.kill();36process.on('exit', stop);37try {38 for (let k = 0; k < 60; k++) { try { await rpc('eth_chainId'); break; } catch { await new Promise((r) => setTimeout(r, 250)); } }39 console.log('fork of Base at block', Number(await rpc<string>('eth_blockNumber')), '— router code', KYBER_ENABLED.base ? 'pinned' : 'NOT pinned');40 const code = await rpc<string>('eth_getCode', [KYBER_ROUTER, 'latest']);41 const codeHash = createHash('sha256').update(Buffer.from(code.slice(2), 'hex')).digest('hex');42 if (codeHash !== KYBER_ENABLED.base!.codeSha256) throw new Error(`router bytecode hash ${codeHash} is not the pinned one`);43 console.log('router bytecode matches the pinned hash');44 if ((await rpc<string>('eth_getCode', [ME, 'latest'])) !== '0x') throw new Error('test address has code');45 // fund the test wallet on the fork only46 await rpc('anvil_setBalance', [ME, numberToHex(parseUnits('1', 18))]);47 await rpc('anvil_impersonateAccount', [WHALE]); await rpc('anvil_setBalance', [WHALE, numberToHex(parseUnits('1', 18))]);48 await rpc('eth_sendTransaction', [{ from: WHALE, to: USDC, data: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [ME as `0x${string}`, 100_000000n] }) }]);49 await rpc('anvil_stopImpersonatingAccount', [WHALE]);50 console.log(`funded on the fork: ${formatUnits(await bal('native'), 18)} ETH, ${formatUnits(await bal(USDC), 6)} USDC`);5152 const dir = mkdtempSync(join(tmpdir(), 'spw-fork-'));53 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));54 store.state.setupTokenHash = createHash('sha256').update('t').digest('hex');55 const signer = new Signer(keys, store, { rpId: 'wallet.test', origin: 'https://wallet.test' });56 const pk = new TestPasskey('wallet.test', 'https://wallet.test');57 await signer.enroll({ credential: pk.credential(), setupToken: 't' });5859 async function swap(label: string, fromAsset: string, toAsset: string, amount: bigint, slippageBps: number) {60 const src = fromAsset === 'native' ? NETWORKS.base.native : NETWORKS.base.tokens.find((t) => t.id === fromAsset)!;61 const dst = toAsset === 'native' ? NETWORKS.base.native : NETWORKS.base.tokens.find((t) => t.id === toAsset)!;62 const tIn = fromAsset === 'native' ? KYBER_NATIVE : fromAsset, tOut = toAsset === 'native' ? KYBER_NATIVE : toAsset;63 // on-chain liquidity only: off-chain signed orders (RFQ, limit orders) can expire or be filled by someone else before inclusion64 const q = await kyber(`/routes?tokenIn=${tIn}&tokenOut=${tOut}&amountIn=${amount}&excludeRFQSources=true&includedSources=${SOURCES}`);65 const b = await kyber('/route/build', { method: 'POST', body: JSON.stringify({ routeSummary: q.routeSummary, sender: ME, recipient: ME, slippageTolerance: slippageBps, deadline: Math.floor(Date.now() / 1000) + 600, source: 'swap-router' }) });66 const expected = BigInt(b.amountOut), minOut = (expected * BigInt(10_000 - slippageBps)) / 10_000n - 1n;67 const now = Math.floor(Date.now() / 1000), nonce = Number(await rpc<string>('eth_getTransactionCount', [ME, 'latest']));68 const block = await rpc<any>('eth_getBlockByNumber', ['latest', false]);69 const maxFeePerGas = hexToBigInt(block.baseFeePerGas) * 2n + 1_000_000n, approveGas = fromAsset === 'native' ? 0n : 70_000n;70 // the node's own estimate when it can be run (native input); the provider's figure with a wide margin otherwise71 const est = fromAsset === 'native' ? await rpc<`0x${string}`>('eth_estimateGas', [{ from: ME, to: KYBER_ROUTER, data: b.data, value: numberToHex(amount) }]).then(hexToBigInt, (e) => { console.log(` (estimateGas failed: ${(e as Error).message})`); return 0n; }) : 0n;72 const gasWanted = est > 0n ? (est * 13n) / 10n + 30_000n : BigInt(b.gas) * 2n + 100_000n, gas = gasWanted > 2_000_000n ? 2_000_000n : gasWanted;73 console.log(` ${label}: provider gas ${b.gas}, node estimate ${est || 'n/a'}, limit ${gas}, route ${q.routeSummary.route.flat().map((h: any) => h.exchange).join('>')}`);74 const intent: Intent = { v: 1, proposalId: randomUUID(), keyId: keys.keyId, network: 'base', chainRef: '8453', asset: fromAsset, symbol: src.symbol, decimals: src.decimals, amount: amount.toString(),75 from: ME, to: ME, maxFee: ((gas + approveGas) * maxFeePerGas).toString(), createdAt: now, expiresAt: now + 120, policyVersion: store.policy.version,76 swap: { provider: 'kyberswap', toAsset, minOut: minOut.toString(), router: b.routerAddress, dataHash: swapDataHash(b.data), approveGas: approveGas.toString() },77 evm: { nonce, gasLimit: gas.toString(), maxFeePerGas: maxFeePerGas.toString(), maxPriorityFeePerGas: '1000000' } };78 buildEvmSwapTxs(intent, b.data); // the same check the API runs before asking for approval79 const before = { src: await bal(fromAsset), dst: await bal(toAsset) };80 const signed = await signer.sign(intent, pk.assert(intentHash(intent)), { swapData: b.data });81 let gasCost = 0n;82 if (signed.pre) { const rc = await mine(signed.pre.raw); if (rc.status !== '0x1') throw new Error('approval reverted'); gasCost += hexToBigInt(rc.gasUsed) * hexToBigInt(rc.effectiveGasPrice) + (rc.l1Fee ? hexToBigInt(rc.l1Fee) : 0n); }83 const rc = await mine(signed.raw);84 if (rc.status !== '0x1') {85 // replay as a call at the same block to read the revert reason86 const why = await rpc<string>('eth_call', [{ from: ME, to: KYBER_ROUTER, data: b.data, value: fromAsset === 'native' ? numberToHex(amount) : '0x0', gas: numberToHex(gas) }, 'latest']).then(() => 'call succeeds now (state moved)', (e) => (e as Error).message);87 throw new Error(`${label}: swap reverted on the fork — gasUsed ${hexToBigInt(rc.gasUsed)} of ${gas}; ${why}`);88 }89 gasCost += hexToBigInt(rc.gasUsed) * hexToBigInt(rc.effectiveGasPrice) + (rc.l1Fee ? hexToBigInt(rc.l1Fee) : 0n); // Base also charges an L1 data fee90 const after = { src: await bal(fromAsset), dst: await bal(toAsset) };91 const spent = before.src - after.src - (fromAsset === 'native' ? gasCost : 0n), got = after.dst - before.dst + (toAsset === 'native' ? gasCost : 0n);92 const allowance = fromAsset === 'native' ? 0n : hexToBigInt(await rpc<`0x${string}`>('eth_call', [{ to: fromAsset, data: encodeFunctionData({ abi: erc20Abi, functionName: 'allowance', args: [ME as `0x${string}`, KYBER_ROUTER as `0x${string}`] }) }, 'latest']));93 console.log(`${label}: spent ${formatUnits(spent, src.decimals)} ${src.symbol}, received ${formatUnits(got, dst.decimals)} ${dst.symbol} (quoted ${formatUnits(expected, dst.decimals)}, approved minimum ${formatUnits(minOut, dst.decimals)}), gas used ${hexToBigInt(rc.gasUsed)}${signed.pre ? ' + approval' : ''}, leftover allowance ${allowance}`);94 // token input: exactly the approved amount. Native input: the amount plus gas, whose accounting on a forked rollup is only95 // approximate (the L1 data fee is not itemised in the fork's receipt) — allow a millionth of a coin.96 const slack = fromAsset === 'native' ? 10n ** 12n : 0n;97 if (spent < amount || spent > amount + slack) throw new Error(`${label}: spent ${spent}, expected ${amount}`);98 if (got < minOut) throw new Error(`${label}: received less than the approved minimum`);99 if (allowance !== 0n) throw new Error(`${label}: an allowance was left behind`);100 return { intent, data: b.data as string };101 }102 await swap('ETH -> USDC ', 'native', USDC, parseUnits('0.01', 18), 50);103 const last = await swap('USDC -> ETH ', USDC, 'native', 25_000000n, 50);104 const link = NETWORKS.base.tokens.find((t) => t.symbol === 'LINK');105 if (link) await swap('USDC -> LINK', USDC, link.id, 10_000000n, 100);106107 // negative checks with genuine calldata: the signer must refuse what the owner did not approve108 const refuse = async (what: string, mut: (i: Intent) => Intent, data = last.data) => {109 const i = mut({ ...last.intent, proposalId: randomUUID() });110 try { await signer.sign(i, pk.assert(intentHash(i)), { swapData: data }); throw new Error('SIGNED'); }111 catch (e) { if ((e as Error).message === 'SIGNED') throw new Error(`signer accepted: ${what}`); console.log(`refused (${what}): ${(e as Error).message}`); }112 };113 await refuse('higher minimum than the calldata guarantees', (i) => ({ ...i, swap: { ...i.swap!, minOut: (BigInt(i.swap!.minOut) * 2n).toString() } }));114 await refuse('another output token', (i) => ({ ...i, swap: { ...i.swap!, toAsset: link?.id ?? USDC } }));115 await refuse('another input amount', (i) => ({ ...i, amount: '26000000' }));116 await refuse('calldata swapped after approval', (i) => i, last.data.slice(0, -2) + (last.data.endsWith('00') ? '01' : '00'));117 await refuse('unpinned router', (i) => ({ ...i, swap: { ...i.swap!, router: '0x000000000000000000000000000000000000dEaD' } }));118 await refuse('output to another address', (i) => ({ ...i, to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' }));119 await refuse('network where swaps are not enabled', (i) => ({ ...i, network: 'ethereum', chainRef: '1', asset: NETWORKS.ethereum.tokens.find((t) => t.symbol === 'USDC')!.id }));120 console.log('\nFORK TEST OK — nothing was sent to the real network');121} finally { stop(); }122