SPB Git

Repository Public

fork-swap-kyber.mts 12.5 KB 122 lines typescript

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
Files main
Menu

Theme