Repository Public
1import { decodeAbiParameters, decodeFunctionResult, encodeFunctionData, erc20Abi, hexToBigInt, hexToNumber, hexToString, multicall3Abi, numberToHex, pad } from 'viem';2import { getAsset, type NetworkDef } from '@spw/core';3import { UserError } from '../config.ts';4import { hostOf, rpcAny, rpcOne, rpcQuorum, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';56const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';7const hx = (n: bigint | number) => numberToHex(n);89export function evmAdapter(n: NetworkDef): Adapter {10 const call = <T>(m: string, p: unknown[]) => rpcAny<T>(n.rpc, m, p);11 let identity = false;12 /** tokens whose on-chain symbol/decimals do not match the pinned registry: hidden and unsendable */13 const disabled = new Set<string>();14 let tokensChecked = false;15 const MULTICALL3 = '0xcA11bde05977b3631167028862bE2a173976CA11';16 /** One eth_call for many read-only calls (Multicall3.aggregate3, failures allowed per call). */17 async function multi(calls: { target: string; data: `0x${string}` }[]): Promise<({ ok: boolean; data: `0x${string}` })[]> {18 const out: { ok: boolean; data: `0x${string}` }[] = [];19 for (let k = 0; k < calls.length; k += 40) {20 const chunk = calls.slice(k, k + 40);21 const data = encodeFunctionData({ abi: multicall3Abi, functionName: 'aggregate3', args: [chunk.map((c) => ({ target: c.target as `0x${string}`, allowFailure: true, callData: c.data }))] });22 const ret = await call<`0x${string}`>('eth_call', [{ to: MULTICALL3, data }, 'latest']);23 for (const r of decodeFunctionResult({ abi: multicall3Abi, functionName: 'aggregate3', data: ret })) out.push({ ok: r.success, data: r.returnData });24 }25 return out;26 }27 const symbolOf = (d: `0x${string}`): string => { // string, or bytes32 for a few old tokens28 try { return decodeAbiParameters([{ type: 'string' }], d)[0]; } catch { try { return hexToString(d, { size: 32 }); } catch { return ''; } }29 };30 async function verifyTokens(): Promise<void> {31 const dec = encodeFunctionData({ abi: erc20Abi, functionName: 'decimals' }), sym = encodeFunctionData({ abi: erc20Abi, functionName: 'symbol' });32 const rs = await multi(n.tokens.flatMap((t) => [{ target: t.id, data: dec }, { target: t.id, data: sym }]));33 n.tokens.forEach((t, k) => {34 const d = rs[2 * k]!, y = rs[2 * k + 1]!;35 // a token whose contract returns another symbol than the ticker (registry field onchainSymbol, e.g. "USD₮0", or "USDC" for USDC.e) must return exactly that36 const sy = y.ok ? symbolOf(y.data) : '';37 const good = d.ok && y.ok && d.data !== '0x' && hexToNumber(d.data) === t.decimals && (t.onchainSymbol !== undefined ? sy === t.onchainSymbol : sy.replace(/[^\x20-\x7e]/g, '').toUpperCase() === t.symbol.toUpperCase());38 if (good) disabled.delete(t.id); else { disabled.add(t.id); console.error(`[evm] ${n.id}: token ${t.symbol} ${t.id} failed on-chain metadata check — disabled`); }39 });40 tokensChecked = true;41 }42 async function verifyIdentity(): Promise<boolean> {43 try {44 const id = await rpcQuorum<string>(n.rpc, 'eth_chainId', []);45 if (hexToNumber(id as `0x${string}`) !== Number(n.chainRef)) return (identity = false);46 if (!tokensChecked) await verifyTokens();47 return (identity = true);48 } catch { return (identity = false); }49 }50 const live = () => n.tokens.filter((t) => !disabled.has(t.id));51 const finalizedHeight = async (): Promise<number> => {52 try { const b = await call<{ number: `0x${string}` } | null>('eth_getBlockByNumber', ['finalized', false]); if (b) return hexToNumber(b.number); } catch { /* tag unsupported */ }53 return hexToNumber(await call<`0x${string}`>('eth_blockNumber', [])) - n.minConf;54 };55 return {56 async health(): Promise<Health> {57 const endpoints = await Promise.all(n.rpc.map(async (url) => {58 const t = Date.now();59 try { const h = hexToNumber(await rpcOne<`0x${string}`>(url, 'eth_blockNumber', [])); return { url: hostOf(url), ok: true, ms: Date.now() - t, h }; }60 catch { return { url: hostOf(url), ok: false, ms: null, h: null as number | null }; }61 }));62 const ok = endpoints.every((e) => e.ok);63 if (ok && !identity) await verifyIdentity();64 return { network: n.id, ok: ok && identity, readable: endpoints.some((e) => e.ok), identityVerified: identity, height: Math.max(0, ...endpoints.map((e) => e.h ?? 0)) || null,65 endpoints: endpoints.map(({ h: _h, ...e }) => e), checkedAt: new Date().toISOString() };66 },67 async balances(addr) {68 const out: Record<string, bigint> = { native: hexToBigInt(await call<`0x${string}`>('eth_getBalance', [addr, 'latest'])) };69 const toks = live();70 if (!toks.length) return out;71 const rs = await multi(toks.map((t) => ({ target: t.id, data: encodeFunctionData({ abi: erc20Abi, functionName: 'balanceOf', args: [addr as `0x${string}`] }) })));72 toks.forEach((t, k) => { const r = rs[k]!; if (r.ok && r.data.length === 66) out[t.id] = hexToBigInt(r.data); });73 return out;74 },75 disabledAssets: () => [...disabled],76 async prepare(d): Promise<Prepared> {77 if (!identity && !(await verifyIdentity())) throw new UserError('rpc_degraded', 'chain identity could not be verified; transfer refused', 503);78 const asset = getAsset(n.id, d.asset), warnings: string[] = [];79 if (disabled.has(asset.id)) throw new UserError('unsupported_asset', 'this token failed its on-chain identity check and is disabled');80 const nonce = hexToNumber(await rpcQuorum<`0x${string}`>(n.rpc, 'eth_getTransactionCount', [d.from, 'pending']));81 const latest = hexToNumber(await call<`0x${string}`>('eth_getTransactionCount', [d.from, 'latest']));82 if (nonce !== latest) throw new UserError('pending_tx', 'a previous transaction from this wallet is still pending on this network');83 const block = await call<{ baseFeePerGas?: `0x${string}` }>('eth_getBlockByNumber', ['latest', false]);84 if (!block.baseFeePerGas) throw new UserError('rpc_degraded', 'network did not report a base fee', 503);85 const baseFee = hexToBigInt(block.baseFeePerGas);86 let tip = 1_000_000n;87 try { tip = hexToBigInt(await call<`0x${string}`>('eth_maxPriorityFeePerGas', [])); } catch { /* keep floor */ }88 if (n.id === 'polygon' && tip < 30_000_000_000n) tip = 30_000_000_000n; // Polygon PoS enforces a 30 gwei minimum tip89 const maxFeePerGas = baseFee * 2n + tip;90 const code = await call<string>('eth_getCode', [d.to, 'latest']);91 const balances = await this.balances(d.from);92 if (asset.id !== 'native' && balances[asset.id] === undefined) throw new UserError('rpc_degraded', 'token balance could not be read; transfer refused', 503);93 let gas: bigint;94 if (asset.id === 'native') {95 if (code !== '0x') throw new UserError('contract_destination', 'native transfers to contract addresses are not supported');96 gas = 21000n;97 if ((balances.native ?? 0n) < d.amount + gas * maxFeePerGas) throw new UserError('insufficient_funds', `insufficient ${n.native.symbol} for amount plus network fee`);98 } else {99 if (code !== '0x') warnings.push('contract_destination');100 if ((balances[asset.id] ?? 0n) < d.amount) throw new UserError('insufficient_funds', `insufficient ${asset.symbol} balance`);101 const data = encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [d.to as `0x${string}`, d.amount] });102 let est: bigint;103 try { est = hexToBigInt(await call<`0x${string}`>('eth_estimateGas', [{ from: d.from, to: asset.id, data }])); }104 catch (e) { throw new UserError('simulation_failed', `simulation reverted: ${(e as Error).message.slice(0, 160)}`); }105 // simulate the exact call and require the ERC-20 boolean success value106 const ret = await call<`0x${string}`>('eth_call', [{ from: d.from, to: asset.id, data }, 'latest']);107 if (ret !== '0x' && hexToBigInt(ret) !== 1n) throw new UserError('simulation_failed', 'token transfer simulation returned false');108 gas = (est * 13n) / 10n + 5000n;109 if (gas > 200_000n) throw new UserError('simulation_failed', 'unexpectedly high gas estimate for a token transfer');110 if ((balances.native ?? 0n) < gas * maxFeePerGas) throw new UserError('insufficient_gas', `not enough ${n.native.symbol} on ${n.name} to pay the network fee`);111 }112 return { section: { evm: { nonce, gasLimit: gas.toString(), maxFeePerGas: maxFeePerGas.toString(), maxPriorityFeePerGas: tip.toString() } },113 maxFee: gas * maxFeePerGas, feeEstimate: gas * (baseFee + tip), warnings, ttlSec: 180 };114 },115 async broadcast(raw) {116 const rs = await Promise.allSettled(n.rpc.map((u) => rpcOne<string>(u, 'eth_sendRawTransaction', [raw])));117 if (rs.some((r) => r.status === 'fulfilled')) return;118 const msg = String((rs[0] as PromiseRejectedResult).reason?.message ?? 'broadcast failed');119 if (/already known|already imported|ALREADY_EXISTS/i.test(msg)) return;120 throw new Error(msg);121 },122 async track(p): Promise<Tracked> {123 const rc = await call<{ status: `0x${string}`; blockNumber: `0x${string}`; blockHash: string } | null>('eth_getTransactionReceipt', [p.txid]).catch((e) => { if ((e as { rpcCode?: number }).rpcCode !== undefined) return null; throw e; });124 if (!rc) {125 if (p.blockRef) return { state: 'reorged', confirmations: 0, rebroadcast: true };126 const latest = hexToNumber(await call<`0x${string}`>('eth_getTransactionCount', [p.intent.from, 'latest']));127 if (latest > p.intent.evm.nonce && Date.now() / 1000 - p.broadcastAt > 90) return { state: 'replaced', confirmations: 0, error: 'nonce was consumed by another transaction' };128 return { state: 'broadcast', confirmations: 0, rebroadcast: true };129 }130 if (hexToNumber(rc.status) !== 1) return { state: 'failed', confirmations: 0, blockRef: rc.blockHash, error: 'transaction reverted on-chain' };131 const bn = hexToNumber(rc.blockNumber), head = hexToNumber(await call<`0x${string}`>('eth_blockNumber', []));132 const fin = await finalizedHeight();133 return { state: bn <= fin ? 'finalized' : 'confirming', confirmations: Math.max(1, head - bn + 1), blockRef: rc.blockHash };134 },135 async deposits(addr, cursor) {136 const head = hexToNumber(await call<`0x${string}`>('eth_blockNumber', []));137 const fin = await finalizedHeight();138 let from = cursor === null ? head - 3000 : cursor + 1;139 if (head - from > 20000) from = head - 20000; // bounded catch-up after long downtime140 const items: Deposit[] = [];141 const topicTo = pad(addr as `0x${string}`).toLowerCase();142 let done = from - 1;143 for (let a = from, span = 2000, calls = 0; a <= head && calls < 12; calls++) {144 const b = Math.min(head, a + span - 1);145 try {146 const toks = live();147 if (toks.length) {148 const logs = await call<{ address: string; transactionHash: string; logIndex: `0x${string}`; data: `0x${string}`; topics: string[]; blockNumber: `0x${string}`; removed?: boolean }[]>(149 'eth_getLogs', [{ address: toks.map((t) => t.id), topics: [TRANSFER_TOPIC, null, topicTo], fromBlock: hx(a), toBlock: hx(b) }]);150 for (const l of logs) {151 const t = toks.find((x) => x.id.toLowerCase() === l.address.toLowerCase());152 if (t && !l.removed && l.topics.length === 3 && l.data.length === 66)153 items.push({ txid: l.transactionHash, idx: hexToNumber(l.logIndex), asset: t.id, amount: hexToBigInt(l.data), from: '0x' + l.topics[1]!.slice(26), block: hexToNumber(l.blockNumber), time: null });154 }155 }156 done = b; a = b + 1;157 } catch { if (span <= 125) break; span = Math.floor(span / 2); }158 }159 return { items, cursor: Math.max(done, (cursor ?? done)), finalizedHeight: fin };160 },161 };162}163