SPB Git

Repository Public

evm.ts 11.8 KB 163 lines typescript

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

Theme