SPB Git

Repository Public

bridge-sol.test.ts 42.8 KB 444 lines typescript

1// Solana ↔ EVM USDC (Circle CCTP V2) life cycle through the REAL API routes, worker steps and signer process boundary, against2// a scripted Solana cluster, a scripted EVM chain and a scripted attestation service (no network). The scripted cluster3// DECODES the transactions it receives (v1 messages, signatures checked) and plays the three Circle instructions the way the4// source describes them; what the real programs do with the same bytes is proven by mainnet simulation5// (scripts/sim-solana-cctp.mts). Database: one per run (test-db.ts, label bridge_sol).6import { createHash, createPublicKey, verify as edVerify } from 'node:crypto';7import { mkdtempSync, readFileSync } from 'node:fs';8import { createServer, type Server } from 'node:http';9import { tmpdir } from 'node:os';10import { join } from 'node:path';11import { createTestDb } from './test-db.ts';12import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';13import { base58 } from '@scure/base';14import { decodeFunctionData, encodeAbiParameters, keccak256, numberToHex, pad, parseTransaction, toFunctionSelector, type Hex } from 'viem';15import {16  ATA_PROGRAM, BPF_UPGRADEABLE_LOADER, CCTP_ABI, CCTP_ENABLED, CCTP_MESSAGE_TRANSMITTER, CCTP_SOL_DISC, CCTP_SOL_EVENT_RENT, CCTP_SOL_FEE_RECIPIENT, CCTP_SOL_MESSAGE_TRANSMITTER, CCTP_SOL_NONCE_RENT, CCTP_SOL_PINS,17  CCTP_SOL_TOKEN_MESSENGER_MINTER, CCTP_TOKEN_MESSENGER, CCTP_TOKEN_MINTER, cctpBytes32, cctpEnd, cctpRecipient32, cctpSolBytes32, cctpSolPda, cctpSolTokenAccount, decodeCctpMessage, decodeCctpSolEventAccount, decodeSolV1Message,18  EIP1967_IMPLEMENTATION_SLOT, encodeCctpMessage, NETWORKS, SYSTEM_PROGRAM, TOKEN_PROGRAM, type CctpMessage, type Intent,19} from '@spw/core';20import { TestPasskey } from '../../../tests/helpers/passkey.ts';21import { deriveKeys } from '../../isolated-signer/src/keys.ts';22import { createSignerServer } from '../../isolated-signer/src/server.ts';23import { Signer } from '../../isolated-signer/src/signer.ts';24import { Store } from '../../isolated-signer/src/store.ts';2526const ORIGIN = 'https://wallet.test', RP = 'wallet.test', SECRET = 'q'.repeat(48), DB_LABEL = 'bridge_sol';27const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'), ME = keys.addresses.evm, ME_SOL = keys.addresses.solana!;28const owner = new TestPasskey(RP, ORIGIN);29const CODE = JSON.parse(readFileSync(new URL('./fixtures/cctp-code.json', import.meta.url), 'utf8')) as Record<string, string>;30const usdc = (n: string) => NETWORKS[n]!.tokens.find((t) => t.symbol === 'USDC')!.id;31const USDC_SOL = usdc('solana'), MY_ATA = cctpSolTokenAccount(ME_SOL);32const SEL = { allowance: '0xdd62ed3e', used: toFunctionSelector('usedNonces(bytes32)'), minFee: toFunctionSelector('getMinFeeAmount(uint256)'), burn: '0x8e0250ee', receive: '0x57ecfd28' };33const TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';34const RENT: Record<number, number> = { 428: Number(CCTP_SOL_EVENT_RENT), 9: Number(CCTP_SOL_NONCE_RENT), 165: 1_488_440, 0: 650_240 };35const hx = (b: Uint8Array) => Buffer.from(b).toString('hex'), pk = (s: string) => Buffer.from(base58.decode(s));36const clock = { skew: 0 }; // moves the signer's and the scripted cluster's clocks together3738/** The scripted EVM chain of services/api/test/bridge.test.ts (source and destination of a CCTP transfer). */39class Chain {40  head = 1000; nonce = 0; native = 10n ** 17n; allowance = 0n; pending: { hash: string; raw: string }[] = []; receipts = new Map<string, any>(); used = new Set<string>(); implSlot: Record<string, string>; logs: { message: Hex }[] = [];41  constructor(public id: string) { const p = CCTP_ENABLED[id]!; this.implSlot = { [CCTP_TOKEN_MESSENGER.toLowerCase()]: p.messengerImpl.address, [CCTP_MESSAGE_TRANSMITTER.toLowerCase()]: p.transmitterImpl.address }; }42  mine(status = 1, blocks = 20) {43    for (const t of this.pending) {44      const tx = parseTransaction(t.raw as Hex); this.head += 1; this.nonce = Math.max(this.nonce, tx.nonce! + 1);45      const logs: any[] = [];46      if (status === 1 && tx.to?.toLowerCase() === CCTP_MESSAGE_TRANSMITTER.toLowerCase()) {47        const [message] = decodeFunctionData({ abi: CCTP_ABI, data: tx.data! }).args as [Hex, Hex], m = decodeCctpMessage(message);48        this.used.add(m.nonce);49        logs.push({ address: usdc(this.id), topics: [TRANSFER, pad('0x0', { size: 32 }), `0x${m.body.mintRecipient}`], data: numberToHex(m.body.amount - m.body.feeExecuted, { size: 32 }) });50      }51      this.receipts.set(t.hash, { status: numberToHex(status), blockNumber: numberToHex(this.head), blockHash: keccak256(t.hash as Hex), transactionHash: t.hash, logs });52    }53    this.pending = []; this.head += blocks;54  }55  rpc(method: string, p: any[]): unknown {56    const err = (message: string) => { throw Object.assign(new Error(message), { rpc: true }); };57    switch (method) {58      case 'eth_chainId': return numberToHex(Number(NETWORKS[this.id]!.chainRef));59      case 'eth_blockNumber': return numberToHex(this.head);60      case 'eth_getBlockByNumber': return { number: numberToHex(this.head), baseFeePerGas: '0x989680' };61      case 'eth_maxPriorityFeePerGas': return '0xf4240';62      case 'eth_getBalance': return numberToHex(this.native);63      case 'eth_getTransactionCount': return numberToHex(p[1] === 'pending' ? this.nonce + this.pending.length : this.nonce);64      case 'eth_getCode': { const a = String(p[0]).toLowerCase();65        if (a === CCTP_TOKEN_MESSENGER.toLowerCase() || a === CCTP_MESSAGE_TRANSMITTER.toLowerCase()) return CODE.proxy;66        if (a === CCTP_TOKEN_MINTER.toLowerCase()) return CODE.minter;67        return CODE[a] ?? '0x'; }68      case 'eth_getStorageAt': return p[1] === EIP1967_IMPLEMENTATION_SLOT ? pad((this.implSlot[String(p[0]).toLowerCase()] ?? '0x0') as Hex, { size: 32 }).toLowerCase() : pad('0x0', { size: 32 });69      case 'eth_estimateGas': case 'eth_call': { const d = String(p[0].data), sel = d.slice(0, 10);70        if (sel === SEL.allowance) return numberToHex(this.allowance, { size: 32 });71        if (sel === SEL.used) return numberToHex(this.used.has(d.slice(10, 74)) ? 1 : 0, { size: 32 });72        if (sel === SEL.minFee) return err('execution reverted');73        if (sel === SEL.burn) return this.allowance < 1n ? err('execution reverted: ERC20: transfer amount exceeds allowance') : method === 'eth_call' ? '0x' : '0x1a000';74        if (sel === SEL.receive) return method === 'eth_call' ? encodeAbiParameters([{ type: 'bool' }], [true]) : '0x2a000';75        return err('execution reverted'); }76      case 'eth_sendRawTransaction': { const hash = keccak256(p[0] as Hex); if (!this.pending.some((x) => x.hash === hash) && !this.receipts.has(hash)) this.pending.push({ hash, raw: p[0] });77        const tx = parseTransaction(p[0] as Hex); if (tx.to?.toLowerCase() === usdc(this.id).toLowerCase()) this.allowance = 10n ** 30n;78        return hash; }79      case 'eth_getTransactionReceipt': return this.receipts.get(String(p[0])) ?? null;80      case 'eth_getLogs': return [];81      default: return err(`unscripted ${method}`);82    }83  }84}8586/** A scripted Solana cluster: system accounts, USDC token accounts, the accounts Circle's programs create — and the three instructions. */87class Sol {88  height = 5000; slot = 9000; lamports = new Map<string, bigint>(); tokens = new Map<string, { owner: string; amount: bigint; state: string }>();89  accts = new Map<string, { owner: string; lamports: bigint; data: Buffer }>(); pending: { sig: string; wire: Uint8Array }[] = []; txs = new Map<string, { err: unknown; slot: number; keys: string[]; pre: any[]; post: any[] }>();90  nonceTx = new Map<string, string>(); pin = { slot: { ...{ t: CCTP_SOL_PINS.transmitter.lastDeploySlot, m: CCTP_SOL_PINS.messenger.lastDeploySlot } }, feeRecipient: CCTP_SOL_FEE_RECIPIENT }; drop = false; sent: string[] = []; sigChecks = 0;91  now = () => Math.floor(Date.now() / 1000) + clock.skew;92  private tokBal(keys: string[]) { return keys.flatMap((k, n) => { const t = this.tokens.get(k); return t ? [{ accountIndex: n, mint: USDC_SOL, owner: t.owner, programId: TOKEN_PROGRAM, uiTokenAmount: { amount: t.amount.toString(), decimals: 6 } }] : []; }); }93  private lam(k: string): bigint { return this.lamports.get(k) ?? this.accts.get(k)?.lamports ?? (this.tokens.has(k) ? 1_488_440n : 0n); }94  /** run a transaction. `commit` false = simulation (state restored). */95  run(wire: Uint8Array, o: { sigVerify: boolean; commit: boolean; want?: string[] }) {96    const n = wire[1]!, msg = wire.subarray(0, wire.length - 64 * n), d = decodeSolV1Message(msg), payer = d.payer;97    if (o.sigVerify) for (let k = 0; k < n; k++) { this.sigChecks++;98      const key = createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), pk(d.keys[k]!)]), format: 'der', type: 'spki' });99      if (!edVerify(null, msg, key, wire.subarray(msg.length + 64 * k, msg.length + 64 * (k + 1)))) return { err: 'SignatureFailure', logs: [], keys: d.keys }; }100    const snap = { lamports: new Map(this.lamports), tokens: new Map([...this.tokens].map(([k, v]) => [k, { ...v }])), accts: new Map(this.accts) };101    const pre = d.keys.map((k) => Number(this.lam(k))), preTok = this.tokBal(d.keys), fee = 5000n * BigInt(n) + d.priorityFeeLamports, logs: string[] = [];102    let err: unknown = null, units = 0;103    const pay = (v: bigint) => { const b = this.lamports.get(payer) ?? 0n; if (b < v) throw { InstructionError: [0, { Custom: 1 }], log: 'Transfer: insufficient lamports' }; this.lamports.set(payer, b - v); };104    try {105      if ((this.lamports.get(payer) ?? 0n) < fee) throw 'InsufficientFundsForFee';106      this.lamports.set(payer, this.lamports.get(payer)! - fee);107      for (const ix of d.instructions) {108        const disc = hx(ix.data.subarray(0, 8)), a = ix.accounts, data = Buffer.from(ix.data);109        if (ix.program === CCTP_SOL_TOKEN_MESSENGER_MINTER && disc === CCTP_SOL_DISC.depositForBurn) {110          units += 41_763; logs.push('Program log: Instruction: DepositForBurn');111          const amount = data.readBigUInt64LE(8), domain = data.readUInt32LE(16), t = this.tokens.get(a[3]!);112          if (a.length !== 18 || a[0] !== payer || a[1] !== payer || !d.keys.slice(0, n).includes(a[11]!) || a[10] !== USDC_SOL || a[7] !== cctpSolPda.remoteTokenMessenger(domain)) throw { InstructionError: [0, { Custom: 3012 }], log: 'bad accounts' };113          if (!t || t.owner !== a[0] || t.amount < amount) throw { InstructionError: [0, { Custom: 1 }], log: 'Error: insufficient funds' };114          t.amount -= amount; pay(CCTP_SOL_EVENT_RENT);115          const message = encodeCctpMessage({ version: 1, sourceDomain: 5, destinationDomain: domain, nonce: '0'.repeat(64), sender: cctpSolBytes32(CCTP_SOL_TOKEN_MESSENGER_MINTER), recipient: cctpBytes32(CCTP_TOKEN_MESSENGER), destinationCaller: data.subarray(52, 84).toString('hex'),116            minFinalityThreshold: data.readUInt32LE(92), finalityThresholdExecuted: 0, body: { version: 1, burnToken: cctpSolBytes32(a[10]!), mintRecipient: data.subarray(20, 52).toString('hex'), amount, messageSender: cctpSolBytes32(a[0]!), maxFee: data.readBigUInt64LE(84), feeExecuted: 0n, expirationBlock: 0n } });117          const mb = Buffer.from(message.slice(2), 'hex'), head = Buffer.alloc(52); Buffer.from(CCTP_SOL_DISC.messageSentAccount, 'hex').copy(head); pk(payer).copy(head, 8); head.writeBigInt64LE(BigInt(this.now()), 40); head.writeUInt32LE(mb.length, 48);118          this.accts.set(a[11]!, { owner: CCTP_SOL_MESSAGE_TRANSMITTER, lamports: CCTP_SOL_EVENT_RENT, data: Buffer.concat([head, mb]) });119        } else if (ix.program === CCTP_SOL_MESSAGE_TRANSMITTER && disc === CCTP_SOL_DISC.receiveMessage) {120          units += 190_000; logs.push('Program log: Instruction: ReceiveMessage');121          const ml = data.readUInt32LE(8), m = decodeCctpMessage(`0x${data.subarray(12, 12 + ml).toString('hex')}`), nonceAcct = cctpSolPda.usedNonce(m.nonce), t = this.tokens.get(a[15]!);122          if (a.length !== 20 || a[4] !== nonceAcct || a[13] !== cctpSolPda.tokenPair(m.sourceDomain, m.body.burnToken) || a[15] !== base58.encode(Buffer.from(m.body.mintRecipient, 'hex'))) throw { InstructionError: [0, { Custom: 3012 }], log: 'bad accounts' };123          if (this.accts.has(nonceAcct)) throw { InstructionError: [0, { Custom: 0 }], log: `Allocate: account Address { address: ${nonceAcct}, base: None } already in use` };124          if (!t) throw { InstructionError: [0, { Custom: 3012 }], log: 'AnchorError caused by account: recipient_token_account. Error Code: AccountNotInitialized' };125          pay(CCTP_SOL_NONCE_RENT); this.accts.set(nonceAcct, { owner: CCTP_SOL_MESSAGE_TRANSMITTER, lamports: CCTP_SOL_NONCE_RENT, data: Buffer.alloc(9, 1) });126          t.amount += m.body.amount - m.body.feeExecuted; this.nonceTx.set(nonceAcct, base58.encode(wire.subarray(msg.length, msg.length + 64)));127        } else if (ix.program === CCTP_SOL_MESSAGE_TRANSMITTER && disc === CCTP_SOL_DISC.reclaimEventAccount) {128          units += 145_227; logs.push('Program log: Instruction: ReclaimEventAccount');129          const ev = this.accts.get(a[2]!);130          if (!ev || decodeCctpSolEventAccount(ev.data).rentPayer !== a[0] || a[0] !== payer) throw { InstructionError: [0, { Custom: 2003 }], log: 'A raw constraint was violated' };131          if (this.now() < decodeCctpSolEventAccount(ev.data).createdAt + 5 * 86400) throw { InstructionError: [0, { Custom: 6033 }], log: 'Program log: AnchorError thrown. Error Code: EventAccountWindowNotExpired. Error Number: 6033.' };132          this.accts.delete(a[2]!); this.lamports.set(payer, this.lamports.get(payer)! + ev.lamports);133        } else if (ix.program === ATA_PROGRAM && data.length === 1 && data[0] === 1) {134          units += 17_917;135          if (a[1] !== cctpSolTokenAccount(a[2]!) || a[3] !== USDC_SOL || a[0] !== payer) throw { InstructionError: [0, { Custom: 0 }], log: 'bad token account' };136          if (!this.tokens.has(a[1]!)) { pay(1_488_440n); this.tokens.set(a[1]!, { owner: a[2]!, amount: 0n, state: 'initialized' }); }137        } else throw { InstructionError: [0, 'ProgramNotScripted'], log: `unscripted program ${ix.program}` };138      }139    } catch (e) { err = typeof e === 'string' ? e : { InstructionError: (e as any).InstructionError }; if ((e as any).log) logs.push((e as any).log); }140    const failedState = () => { Object.assign(this, { tokens: snap.tokens, accts: snap.accts }); this.lamports = snap.lamports; if (err !== 'InsufficientFundsForFee' && o.commit) this.lamports.set(payer, (this.lamports.get(payer) ?? 0n) - fee); };141    if (err) failedState();142    const out = { err, logs, unitsConsumed: units, fee: Number(fee), keys: d.keys, preBalances: pre, postBalances: d.keys.map((k) => Number(this.lam(k))), preTokenBalances: preTok, postTokenBalances: this.tokBal(d.keys),143      accounts: (o.want ?? []).map((w) => { const x = this.accts.get(w); return x ? { owner: x.owner, lamports: Number(x.lamports), executable: false, data: [x.data.toString('base64'), 'base64'] } : null; }) };144    if (!o.commit && !err) { this.lamports = snap.lamports; this.tokens = snap.tokens; this.accts = snap.accts; }145    return out;146  }147  mine(blocks = 40) {148    for (const t of this.pending) { if (this.drop) continue; this.slot += 1; const r = this.run(t.wire, { sigVerify: true, commit: true }); this.txs.set(t.sig, { err: r.err, slot: this.slot, keys: r.keys, pre: (r as any).preTokenBalances ?? [], post: (r as any).postTokenBalances ?? [] }); }149    this.pending = []; this.height += blocks; this.slot += blocks;150  }151  info(addr: string, o: any = {}): unknown {152    const slice = (b: Buffer) => { const s = o.dataSlice ? b.subarray(o.dataSlice.offset, o.dataSlice.offset + o.dataSlice.length) : b; return [s.toString('base64'), 'base64']; };153    for (const [pin, slot] of [[CCTP_SOL_PINS.transmitter, this.pin.slot.t], [CCTP_SOL_PINS.messenger, this.pin.slot.m]] as const) {154      if (addr === pin.program) { const b = Buffer.alloc(36); b.writeUInt32LE(2); pk(pin.programData).copy(b, 4); return { owner: BPF_UPGRADEABLE_LOADER, executable: true, lamports: 1, data: slice(b) }; }155      if (addr === pin.programData) { const b = Buffer.alloc(45 + 8); b.writeUInt32LE(3); b.writeBigUInt64LE(BigInt(slot), 4); b[12] = 1; pk(pin.upgradeAuthority).copy(b, 13); return { owner: BPF_UPGRADEABLE_LOADER, executable: false, lamports: 1, data: slice(b) }; }156    }157    if (addr === USDC_SOL) return { owner: TOKEN_PROGRAM, executable: false, lamports: 1, data: o.encoding === 'jsonParsed' ? { program: 'spl-token', parsed: { type: 'mint', info: { decimals: 6, freezeAuthority: 'x' } } } : slice(Buffer.alloc(82)) };158    if (addr === cctpSolPda.tokenMessenger()) { const b = Buffer.alloc(185); b.writeUInt32LE(1, 104); pk(this.pin.feeRecipient).copy(b, 109); return { owner: CCTP_SOL_TOKEN_MESSENGER_MINTER, executable: false, lamports: 1, data: slice(b) }; }159    const t = this.tokens.get(addr);160    if (t) return { owner: TOKEN_PROGRAM, executable: false, lamports: 1_488_440, data: o.encoding === 'jsonParsed' ? { program: 'spl-token', parsed: { type: 'account', info: { mint: USDC_SOL, owner: t.owner, state: t.state, tokenAmount: { amount: t.amount.toString(), decimals: 6 } } } } : slice(Buffer.alloc(165)) };161    const x = this.accts.get(addr);162    if (x) return { owner: x.owner, executable: false, lamports: Number(x.lamports), data: slice(x.data) };163    if (this.lamports.has(addr)) return { owner: SYSTEM_PROGRAM, executable: false, lamports: Number(this.lamports.get(addr)), data: slice(Buffer.alloc(0)) };164    return null;165  }166  rpc(method: string, p: any[]): unknown {167    switch (method) {168      case 'getGenesisHash': return NETWORKS.solana!.chainRef;169      case 'getAccountInfo': return { value: this.info(p[0], p[1]) };170      case 'getMinimumBalanceForRentExemption': return RENT[p[0] as number] ?? 9_999_999;171      case 'getRecentPrioritizationFees': return [];172      case 'getLatestBlockhash': return { value: { blockhash: base58.encode(createHash('sha256').update(`bh${this.height}`).digest()), lastValidBlockHeight: this.height + 150 } };173      case 'getBalance': return { value: Number(this.lamports.get(p[0]) ?? 0n) };174      case 'getBlockHeight': return this.height;175      case 'getSlot': return this.slot;176      case 'simulateTransaction': return { value: this.run(new Uint8Array(Buffer.from(p[0], 'base64')), { sigVerify: !!p[1]?.sigVerify, commit: false, want: p[1]?.accounts?.addresses }) };177      case 'sendTransaction': { const wire = new Uint8Array(Buffer.from(p[0], 'base64')), nSig = wire[1]!, sig = base58.encode(wire.subarray(wire.length - 64 * nSig, wire.length - 64 * (nSig - 1)));178        const check = this.run(wire, { sigVerify: true, commit: false }); if (check.err === 'SignatureFailure') throw new Error('Transaction signature verification failure');179        if (!this.pending.some((x) => x.sig === sig) && !this.txs.has(sig)) { this.pending.push({ sig, wire }); this.sent.push(sig); } return sig; }180      case 'getSignatureStatuses': return { value: (p[0] as string[]).map((s) => { const t = this.txs.get(s); return t ? { err: t.err, confirmationStatus: 'finalized', confirmations: null, slot: t.slot } : null; }) };181      case 'getTransaction': { const t = this.txs.get(p[0]); return t ? { transaction: { message: { accountKeys: t.keys } }, meta: { err: t.err, preTokenBalances: t.pre, postTokenBalances: t.post } } : null; }182      case 'getSignaturesForAddress': { const s = this.nonceTx.get(p[0]); return s ? [{ signature: s }] : []; }183      default: throw new Error(`unscripted ${method}`);184    }185  }186}187188const chain = new Chain('base'), sol = new Sol();189const iris: { messages: Map<string, unknown> } = { messages: new Map() };190let fake: Server, signerSrv: Server, app: any, sql: any, worker: any, bridge: any, adapters: any, cookie = '', store: Store;191async function api(method: string, path: string, body?: unknown, idem?: string) {192  const r = await app.inject({ method, url: path, headers: { ...(cookie ? { cookie } : {}), ...(method !== 'GET' ? { origin: ORIGIN, 'x-spw-csrf': '1', 'content-type': 'application/json' } : {}), ...(idem ? { 'idempotency-key': idem } : {}) }, payload: body === undefined ? undefined : JSON.stringify(body) });193  return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })(), setCookie: r.headers['set-cookie'] as string | undefined };194}195const row = async (id: string) => (await sql`select * from proposals where id = ${id}`)[0];196const getP = async (id: string) => (await api('GET', `/api/v1/transfers/proposals/${id}`)).json;197const assert = (options: any) => owner.assert(Buffer.from(options.challenge, 'base64url'));198const tick = async () => { await worker.reconcile(); await sql`update proposals set bridge = jsonb_set(bridge, '{nextAt}', '0') where kind = 'bridge'`; await bridge.advanceBridges(); };199const asCircle = (m: CctpMessage, nonce: string): Hex => encodeCctpMessage({ ...m, nonce, finalityThresholdExecuted: m.minFinalityThreshold });200const attest = (txid: string, message: Hex) => iris.messages.set(txid, { messages: [{ status: 'complete', message, attestation: `0x${'33'.repeat(130)}`, cctpVersion: 2, delayReason: null }] });201async function prepare(body: unknown) {202  const prep = await api('POST', '/api/v1/bridge/proposals', body, crypto.randomUUID());203  expect(prep.status, JSON.stringify(prep.json)).toBe(200);204  return prep.json as { proposal: any; options: any };205}206const approve = async (prep: { proposal: any; options: any }) => { const ap = await api('POST', `/api/v1/transfers/proposals/${prep.proposal.id}/approve`, { assertion: assert(prep.options) }); expect(ap.status, JSON.stringify(ap.json)).toBe(200); return ap.json; };207208beforeAll(async () => {209  const DB = (await createTestDb(DB_LABEL)).name;   // v8: unique per checkout and per run (test-db.ts)210  fake = createServer((req, res) => {211    let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {212      const send = (code: number, o: unknown) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };213      const url = new URL(req.url!, 'http://x');214      if (url.pathname.startsWith('/rpc/')) { const j = JSON.parse(body), target = url.pathname.slice(5) === 'solana' ? sol : chain;215        try { send(200, { jsonrpc: '2.0', id: j.id, result: target.rpc(j.method, j.params) }); } catch (e) { send(200, { jsonrpc: '2.0', id: j.id, error: { code: 3, message: (e as Error).message } }); } return; }216      if (url.pathname.startsWith('/v2/burn/USDC/fees/')) return send(200, [{ finalityThreshold: 1000, minimumFee: 1 }, { finalityThreshold: 2000, minimumFee: 0 }]);217      if (url.pathname === '/v2/fastBurn/USDC/allowance') return send(200, { allowance: 5e7 });218      if (url.pathname.startsWith('/v2/messages/')) { const m = iris.messages.get(url.searchParams.get('transactionHash') ?? ''); return m ? send(200, m) : send(404, { error: 'Message not found for provided parameters' }); }219      if (url.pathname.startsWith('/v2/reattest/')) return send(200, { message: 'ok' });220      send(404, {});221    });222  }).listen(0, '127.0.0.1');223  await new Promise((r) => fake.once('listening', r));224  const base = `http://127.0.0.1:${(fake.address() as { port: number }).port}`;225  const dir = mkdtempSync(join(tmpdir(), 'spw-bridge-sol-api-'));226  store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));227  store.state.setupTokenHash = createHash('sha256').update('bridge-sol-setup').digest('hex');228  signerSrv = createSignerServer(new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => Math.floor(Date.now() / 1000) + clock.skew), SECRET).listen(0, '127.0.0.1');229  await new Promise((r) => signerSrv.once('listening', r));230  Object.assign(process.env, { SPW_ORIGIN: ORIGIN, SPW_RP_ID: RP, DATABASE_URL: `postgres:///${DB}`, SPW_SIGNER_SECRET: SECRET, SPW_SIGNER_URL: `http://127.0.0.1:${(signerSrv.address() as { port: number }).port}`,231    SPW_BROADCAST: 'on', SPW_WORKERS: 'off', SPW_IRIS_OVERRIDE: base, SPW_RPC_OVERRIDE: JSON.stringify({ base: [`${base}/rpc/base`, `${base}/rpc/base`], solana: [`${base}/rpc/solana`, `${base}/rpc/solana`] }) });232  const warn = console.warn; console.warn = () => {};233  const db = await import('../src/db.ts'); sql = db.sql;234  await sql.unsafe('drop schema public cascade; create schema public;'); await db.migrate();235  await import('../src/rpc-override.ts');236  adapters = (await import('../src/chains/index.ts')).adapters;237  adapters.base.balances = async () => ({ native: chain.native, [usdc('base')]: 100_000000n });238  app = await (await import('../src/server.ts')).buildServer({ swapProviders: [] });239  worker = await import('../src/worker.ts'); bridge = await import('../src/bridge.ts');240  console.warn = warn;241  const o = (await api('POST', '/api/v1/auth/setup/options', {})).json;242  const s = await api('POST', '/api/v1/auth/setup/verify', { challengeId: o.challengeId, response: owner.register(o.options.challenge), setupToken: 'bridge-sol-setup' });243  expect(s.status, JSON.stringify(s.json)).toBe(200); cookie = String(s.setCookie).split(';')[0]!;244});245afterAll(async () => { await app?.close(); signerSrv?.close(); fake?.close(); await sql?.end(); });246beforeEach(async () => {247  await sql`update proposals set state = 'failed' where state not in ('finalized', 'failed', 'expired', 'rejected', 'replaced')`;248  await sql`update proposals set bridge = jsonb_set(bridge, '{phase}', '"done"') where kind = 'bridge'`;249  store.state.spent = {}; store.save();250  chain.mine(); chain.native = 10n ** 17n; chain.allowance = 0n; sol.mine(); sol.drop = false; sol.sent = [];251  sol.pin = { slot: { t: CCTP_SOL_PINS.transmitter.lastDeploySlot, m: CCTP_SOL_PINS.messenger.lastDeploySlot }, feeRecipient: CCTP_SOL_FEE_RECIPIENT };252  sol.lamports.set(ME_SOL, 50_000_000n); sol.tokens.set(MY_ATA, { owner: ME_SOL, amount: 100_000000n, state: 'initialized' });253});254255describe('Solana → EVM', () => {256  it('quote: both speeds with Circle\'s documented times for Solana, the refundable deposit, and the gas needed on the destination', async () => {257    const q = (await api('GET', '/api/v1/bridge/quote?from=solana&to=base&amount=10')).json;258    expect(q.options.map((o: any) => [o.speed, o.available, o.feeDisplay, o.seconds])).toEqual([['standard', true, '0', 17], ['fast', true, '0.001', 1]]);259    expect(q.solana).toMatchObject({ role: 'source', depositDisplay: '0.00282448', reclaimAfterSeconds: 432000, symbol: 'SOL' });260    expect(q.destinationGas).toMatchObject({ symbol: 'ETH', enough: true });261    expect((await api('GET', '/api/v1/bridge/execution')).json.networks).toContain('solana');262  });263  it('the whole transfer: burn built and co-signed by the signer, attestation, EVM mint to the wallet\'s own address — then, five days later, the deposit', async () => {264    const lam0 = sol.lamports.get(ME_SOL)!;265    const prep = await prepare({ fromNetwork: 'solana', toNetwork: 'base', amount: '10', speed: 'standard' });266    expect(prep.proposal).toMatchObject({ network: 'solana', state: 'pending_approval', kind: 'bridge', warnings: [], bridge: { stage: 'review', toNetwork: 'base', speed: 'standard', expectedSeconds: 17, simulated: true, needsApproval: false,267      sol: { role: 'source', depositDisplay: '0.00282448', reclaimState: 'pending', recipient: ME } } });268    expect(BigInt(prep.proposal.maxFee)).toBeLessThanOrEqual(3_000_000n); // fee + deposit inside the signer's fee ceiling for Solana269    const intent = (await row(prep.proposal.id)).intent as Intent;270    expect(intent.bridge).toMatchObject({ toNetwork: 'base', toDomain: 6, recipient: ME, messenger: CCTP_SOL_TOKEN_MESSENGER_MINTER, transmitter: CCTP_SOL_MESSAGE_TRANSMITTER, approveGas: '0' }); expect(intent.from).toBe(ME_SOL);271    const checks0 = sol.sigChecks;272    const ap = await approve(prep);273    expect(ap.state).toBe('broadcast');274    const ev = store.state.bridges![prep.proposal.id]!.sol!.eventAccount;275    expect((await row(prep.proposal.id)).bridge.sol.eventAccount).toBe(ev); // read back from the signed bytes and compared with the rebuilt burn276    expect(sol.pending).toHaveLength(1); expect(sol.sigChecks - checks0).toBeGreaterThanOrEqual(4); // both signatures checked at the last gate and on receipt277    const d = decodeSolV1Message(sol.pending[0]!.wire.subarray(0, sol.pending[0]!.wire.length - 128));278    expect(d.keys.slice(0, 2)).toEqual([ME_SOL, ev]); expect(d.instructions).toHaveLength(1);279    sol.mine(); await tick();280    let p = await getP(prep.proposal.id);281    expect(p.state).toBe('finalized'); expect(p.bridge.stage).toBe('attesting');282    expect(sol.tokens.get(MY_ATA)!.amount).toBe(90_000000n); expect(lam0 - sol.lamports.get(ME_SOL)!).toBe(BigInt(prep.proposal.maxFee)); // amount burned; fee + deposit paid283    expect(p.bridge.sol).toMatchObject({ eventAccount: ev, reclaimState: 'locked', reclaimEstimated: false });284    // Circle attests the message the program stored285    const stored = decodeCctpSolEventAccount(sol.accts.get(ev)!.data), m = decodeCctpMessage(`0x${hx(stored.message)}`);286    expect(m.body.messageSender).toBe(cctpSolBytes32(ME_SOL)); expect(m.body.mintRecipient).toBe(cctpBytes32(ME));287    // an answer that is not this burn is not used288    attest(p.txid, asCircle({ ...m, body: { ...m.body, mintRecipient: cctpBytes32('0x70997970C51812dc3A010C7d01b50e0d17dc79C8') } }, '77'.repeat(32)));289    await tick(); p = await getP(prep.proposal.id);290    expect(p.bridge).toMatchObject({ stage: 'attesting', hold: 'message_mismatch' });291    attest(p.txid, asCircle(m, '78'.repeat(32)));292    await tick(); p = await getP(prep.proposal.id);293    expect(p.bridge.stage).toBe('minting');294    const mint = parseTransaction(chain.pending[0]!.raw as Hex);295    expect(mint.to!.toLowerCase()).toBe(CCTP_MESSAGE_TRANSMITTER.toLowerCase());296    chain.mine(); await tick(); p = await getP(prep.proposal.id);297    expect(p.bridge).toMatchObject({ stage: 'done', mintedBy: 'wallet', received: '10000000' });298    expect(store.state.bridges![prep.proposal.id]!.mint!.signatures).toBe(1);299300    // the deposit: not before five days (the API says when), then with the passkey301    const early = await api('POST', `/api/v1/bridge/proposals/${prep.proposal.id}/reclaim/options`, {});302    expect(early.status).toBe(409); expect(early.json.error).toBe('reclaim_not_yet');303    clock.skew = 5 * 86400 + 600; // the signer's and the cluster's clocks; the API's own notion comes from the event account304    await sql`update proposals set bridge = jsonb_set(bridge, '{sol,reclaimableAt}', ${String(Math.floor(Date.now() / 1000) - 60)}::jsonb) where id = ${prep.proposal.id}`;305    p = await getP(prep.proposal.id); expect(p.bridge.sol.reclaimState).toBe('available');306    const ro = await api('POST', `/api/v1/bridge/proposals/${prep.proposal.id}/reclaim/options`, {});307    expect(ro.status, JSON.stringify(ro.json)).toBe(200); expect(ro.json.deposit).toBe('2824480');308    const bad = await api('POST', `/api/v1/bridge/proposals/${prep.proposal.id}/reclaim`, { assertion: owner.assert(new Uint8Array(32)) });309    expect(bad.status).toBe(422); expect(bad.json.error).toBe('signer_bad_approval');310    const lam1 = sol.lamports.get(ME_SOL)!;311    const rc = await api('POST', `/api/v1/bridge/proposals/${prep.proposal.id}/reclaim`, { assertion: assert(ro.json.options) });312    expect(rc.status, JSON.stringify(rc.json)).toBe(200); expect(rc.json.bridge.sol.reclaimState).toBe('sent');313    const rd = decodeSolV1Message(sol.pending[0]!.wire.subarray(0, sol.pending[0]!.wire.length - 64));314    expect(rd.instructions[0]!.accounts).toEqual([ME_SOL, cctpSolPda.messageTransmitter(), ev]);315    sol.mine(); await tick(); p = await getP(prep.proposal.id);316    expect(p.bridge.sol).toMatchObject({ reclaimState: 'done', reclaimTxid: rc.json.bridge.sol.reclaimTxid });317    expect(sol.accts.has(ev)).toBe(false); expect(sol.lamports.get(ME_SOL)! - lam1).toBe(CCTP_SOL_EVENT_RENT - 5000n - BigInt(rd.priorityFeeLamports));318    expect((await api('POST', `/api/v1/bridge/proposals/${prep.proposal.id}/reclaim/options`, {})).json.error).toBe('bad_state');319    clock.skew = 0;320    const audit = (await api('GET', '/api/v1/security/audit?limit=200')).json;321    expect(audit.intact).toBe(true);322    expect(audit.events.map((e: any) => e.type)).toEqual(expect.arrayContaining(['bridge.prepared', 'bridge.event_account', 'bridge.burned', 'bridge.attested', 'bridge.mint_signed', 'bridge.done', 'bridge.reclaim_signed', 'bridge.deposit_reclaimed']));323  });324  it('refuses BEFORE any approval: not enough USDC, not enough SOL for fee + deposit, no gas on the destination, a changed program, a changed fee recipient, a Token-2022 or frozen account', async () => {325    const post = (amount = '10') => api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'solana', toNetwork: 'base', amount, speed: 'standard' }, crypto.randomUUID());326    expect((await post('1000')).json.error).toBe('insufficient_funds');327    expect((await post('60')).json.error).toBe('policy');328    sol.lamports.set(ME_SOL, 2_000_000n);329    expect((await post()).json).toMatchObject({ error: 'insufficient_gas', message: expect.stringMatching(/refundable deposit of 0\.00282448 SOL/) });330    sol.lamports.set(ME_SOL, 50_000_000n); chain.native = 0n;331    expect((await post()).json).toMatchObject({ error: 'destination_gas', message: expect.stringMatching(/Nothing was burned/) });332    chain.native = 10n ** 17n; sol.pin.slot.m += 1;333    expect((await post()).json).toMatchObject({ error: 'contract_changed', message: expect.stringMatching(/TokenMessengerMinterV2/) });334    sol.pin.slot.m -= 1; sol.pin.feeRecipient = ME_SOL;335    expect((await post()).json).toMatchObject({ error: 'contract_changed', message: expect.stringMatching(/fee recipient/) });336    sol.pin.feeRecipient = CCTP_SOL_FEE_RECIPIENT; sol.tokens.get(MY_ATA)!.state = 'frozen';337    expect((await post()).json.error).toBe('account_frozen');338    sol.tokens.get(MY_ATA)!.state = 'initialized';339    expect(store.state.bridges?.[''] ?? null).toBeNull(); expect(sol.sent).toEqual([]);340    expect((await post()).status).toBe(200);341  });342  it('a burn that was not included in time fails without anything burned', async () => {343    const prep = await prepare({ fromNetwork: 'solana', toNetwork: 'base', amount: '5', speed: 'fast' });344    expect(prep.proposal.bridge).toMatchObject({ speed: 'fast', expectedSeconds: 1, cctpMaxFee: '500' });345    sol.drop = true; await approve(prep); sol.mine(400); await tick();346    const p = await getP(prep.proposal.id);347    expect(p.state).toBe('failed'); expect(p.error).toMatch(/expired before inclusion/); expect(sol.tokens.get(MY_ATA)!.amount).toBe(100_000000n);348  });349});350351describe('EVM → Solana', () => {352  const start = async (amount = '10') => prepare({ fromNetwork: 'base', toNetwork: 'solana', amount, speed: 'standard' });353  async function burned(amount = '10') {354    const prep = await start(amount); await approve(prep);355    for (let k = 0; k < 4 && (await getP(prep.proposal.id)).state !== 'finalized'; k++) { chain.mine(); await worker.reconcile(); }356    const intent = (await row(prep.proposal.id)).intent as Intent, src = cctpEnd('base')!, dst = cctpEnd('solana')!;357    const message: CctpMessage = { version: 1, sourceDomain: 6, destinationDomain: 5, nonce: createHash('sha256').update(prep.proposal.id).digest('hex'), sender: src.messenger, recipient: dst.messenger, destinationCaller: '0'.repeat(64), minFinalityThreshold: 2000, finalityThresholdExecuted: 2000,358      body: { version: 1, burnToken: src.usdc, mintRecipient: cctpRecipient32(dst, ME_SOL), amount: BigInt(intent.amount), messageSender: cctpBytes32(ME), maxFee: 0n, feeExecuted: 0n, expirationBlock: 0n } };359    return { id: prep.proposal.id as string, intent, message, txid: (await getP(prep.proposal.id)).txid as string };360  }361  it('refuses to burn toward a token account that does not exist; creating it is a small passkey-approved transaction; then the transfer goes through', async () => {362    sol.tokens.delete(MY_ATA);363    const q = (await api('GET', '/api/v1/bridge/quote?from=base&to=solana&amount=10')).json;364    expect(q.solana).toMatchObject({ role: 'destination', recipient: ME_SOL, tokenAccount: MY_ATA, tokenAccountExists: false, tokenAccountRentDisplay: '0.00148844', nonceRentDisplay: '0.00069596' });365    expect(q.destinationGas).toMatchObject({ symbol: 'SOL', enough: true });366    const no = await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'solana', amount: '10', speed: 'standard' }, crypto.randomUUID());367    expect(no.status).toBe(422); expect(no.json).toMatchObject({ error: 'token_account_missing', message: expect.stringMatching(/Nothing was burned/) });368    expect(chain.pending).toEqual([]);369    // create it370    expect((await api('GET', '/api/v1/bridge/solana/token-account')).json).toMatchObject({ owner: ME_SOL, tokenAccount: MY_ATA, exists: false });371    const o = (await api('POST', '/api/v1/bridge/solana/token-account/options', {})).json;372    expect(o).toMatchObject({ tokenAccount: MY_ATA, rentDisplay: '0.00148844', symbol: 'SOL' });373    expect((await api('POST', '/api/v1/bridge/solana/token-account', { requestId: o.requestId, assertion: owner.assert(new Uint8Array(32)) })).json.error).toBe('signer_bad_approval');374    const o2 = (await api('POST', '/api/v1/bridge/solana/token-account/options', {})).json, lam = sol.lamports.get(ME_SOL)!;375    const made = await api('POST', '/api/v1/bridge/solana/token-account', { requestId: o2.requestId, assertion: assert(o2.options) });376    expect(made.status, JSON.stringify(made.json)).toBe(200); expect(made.json).toMatchObject({ sent: true, tokenAccount: MY_ATA });377    expect((await api('POST', '/api/v1/bridge/solana/token-account', { requestId: o2.requestId, assertion: assert(o2.options) })).status).toBe(410); // one use378    sol.mine();379    expect(sol.tokens.get(MY_ATA)).toMatchObject({ owner: ME_SOL, amount: 0n }); expect(lam - sol.lamports.get(ME_SOL)!).toBe(1_488_440n + BigInt(o2.networkFee));380    expect((await api('POST', '/api/v1/bridge/solana/token-account/options', {})).json.error).toBe('bad_state'); // it exists now381    // and the transfer382    const prep = await start();383    expect(prep.proposal.bridge).toMatchObject({ stage: 'review', toNetwork: 'solana', destGasSymbol: 'SOL', sol: { role: 'destination', recipient: ME_SOL, tokenAccount: MY_ATA, nonceRentDisplay: '0.00069596' } });384    const intent = (await row(prep.proposal.id)).intent as Intent;385    expect(intent.bridge).toMatchObject({ toNetwork: 'solana', toDomain: 5, recipient: ME_SOL }); expect(intent.to).toBe(ME);386  });387  it('the whole transfer: EVM burn naming the wallet\'s token account, attestation, the Solana mint built by the signer, received amount read from the chain', async () => {388    const lam0 = sol.lamports.get(ME_SOL)!, b = await burned();389    const burn = parseTransaction((await row(b.id)).raw as Hex), args = decodeFunctionData({ abi: CCTP_ABI, data: burn.data! }).args as readonly unknown[];390    expect(args[1]).toBe(5); expect(args[2]).toBe(`0x${cctpSolBytes32(MY_ATA)}`);391    await tick(); expect((await getP(b.id)).bridge.stage).toBe('attesting');392    // an attested message that would credit the wallet ADDRESS (not its token account) is not used393    attest(b.txid, encodeCctpMessage({ ...b.message, body: { ...b.message.body, mintRecipient: cctpSolBytes32(ME_SOL) } }));394    await tick(); expect((await getP(b.id)).bridge.hold).toBe('message_mismatch');395    attest(b.txid, encodeCctpMessage(b.message));396    await tick(); let p = await getP(b.id);397    expect(p.bridge.stage).toBe('minting'); expect(sol.pending).toHaveLength(1);398    const d = decodeSolV1Message(sol.pending[0]!.wire.subarray(0, sol.pending[0]!.wire.length - 64));399    expect(d.numSigners).toBe(1); expect(d.payer).toBe(ME_SOL); expect(d.instructions[0]!.accounts[15]).toBe(MY_ATA); expect(d.computeUnitLimit).toBe(248_000); // 190 000 simulated × 1.2 + 20 000400    await tick(); expect((await getP(b.id)).bridge.stage).toBe('minting'); // not included yet: waits, signs nothing more401    sol.mine(); await tick(); p = await getP(b.id);402    expect(p.bridge).toMatchObject({ stage: 'done', mintedBy: 'wallet', received: '10000000', mintTxid: sol.sent.at(-1) });403    expect(sol.tokens.get(MY_ATA)!.amount).toBe(110_000000n); expect(lam0 - sol.lamports.get(ME_SOL)!).toBe(5000n + d.priorityFeeLamports + CCTP_SOL_NONCE_RENT);404    expect(store.state.bridges![b.id]!.mint!.signatures).toBe(1);405    // "finish" on a finished transfer does nothing406    expect((await api('POST', `/api/v1/bridge/proposals/${b.id}/finish`, {})).json.bridge.stage).toBe('done'); expect(sol.pending).toEqual([]);407  });408  it('a mint that was not included in time: the single automatic signature is spent, the next one needs the passkey', async () => {409    const b = await burned('7'); attest(b.txid, encodeCctpMessage(b.message));410    sol.drop = true; await tick(); expect((await getP(b.id)).bridge.stage).toBe('minting');411    sol.mine(400); await tick(); let p = await getP(b.id);412    expect(p.bridge).toMatchObject({ stage: 'mint_failed', hold: 'mint_expired', needsPasskey: true }); expect(sol.tokens.get(MY_ATA)!.amount).toBe(100_000000n);413    sol.drop = false; await tick(); expect((await getP(b.id)).bridge.stage).toBe('mint_failed'); // nothing happens without the owner414    const o = await api('POST', `/api/v1/bridge/proposals/${b.id}/retry-mint/options`, {});415    expect(o.status, JSON.stringify(o.json)).toBe(200);416    const r = await api('POST', `/api/v1/bridge/proposals/${b.id}/retry-mint`, { assertion: assert(o.json.options) });417    expect(r.status, JSON.stringify(r.json)).toBe(200); expect(r.json.bridge.stage).toBe('minting');418    sol.mine(); await tick(); p = await getP(b.id);419    expect(p.bridge).toMatchObject({ stage: 'done', received: '7000000' }); expect(store.state.bridges![b.id]!.mint!.signatures).toBe(2);420  });421  it('already received (someone else submitted the public message): recorded as done, nothing signed', async () => {422    const b = await burned('3'); const message = encodeCctpMessage(b.message); attest(b.txid, message);423    sol.accts.set(cctpSolPda.usedNonce(b.message.nonce), { owner: CCTP_SOL_MESSAGE_TRANSMITTER, lamports: CCTP_SOL_NONCE_RENT, data: Buffer.alloc(9, 1) }); sol.nonceTx.set(cctpSolPda.usedNonce(b.message.nonce), 'ThirdPartySignature');424    await tick(); const p = await getP(b.id);425    expect(p.bridge).toMatchObject({ stage: 'done', mintedBy: 'other', mintTxid: 'ThirdPartySignature', received: '3000000' });426    expect(store.state.bridges![b.id]!.mint).toBeUndefined(); expect(sol.pending).toEqual([]);427  });428  it('waits, with the reason, when SOL is missing or the token account has gone; refuses to prepare without SOL', async () => {429    const b = await burned('4'); attest(b.txid, encodeCctpMessage(b.message));430    sol.lamports.set(ME_SOL, 700_000n);431    await tick(); let p = await getP(b.id);432    expect(p.bridge).toMatchObject({ stage: 'ready', hold: 'destination_gas' }); expect(sol.pending).toEqual([]);433    sol.lamports.set(ME_SOL, 50_000_000n); sol.tokens.delete(MY_ATA);434    await tick(); p = await getP(b.id);435    expect(p.bridge).toMatchObject({ stage: 'ready', hold: 'token_account_missing' });436    sol.tokens.set(MY_ATA, { owner: ME_SOL, amount: 0n, state: 'initialized' });437    await tick(); sol.mine(); await tick();438    expect((await getP(b.id)).bridge).toMatchObject({ stage: 'done', received: '4000000' });439    sol.lamports.set(ME_SOL, 1_000_000n);440    const no = await api('POST', '/api/v1/bridge/proposals', { fromNetwork: 'base', toNetwork: 'solana', amount: '10', speed: 'standard' }, crypto.randomUUID());441    expect(no.status).toBe(422); expect(no.json).toMatchObject({ error: 'destination_gas', message: expect.stringMatching(/SOL on Solana/) });442  });443});444
Files main
Menu

Theme