SPB Git

Repository Public

bridge-sol.ts 54.4 KB 555 lines typescript

1// Solana side of the USDC bridge (Circle CCTP V2, domain 5; docs/swap/providers/cctp.md §13). bridge.ts keeps the state2// machine, the attestation polling and the EVM legs; this file prepares, simulates, relays and tracks the Solana transactions:3//   Solana → EVM : the burn (deposit_for_burn, built by the signer with a one-time event account), then — five days later, on4//                  the owner's request — the reclaim of the event account's rent deposit;5//   EVM → Solana : the mint (receive_message, built by the signer) to the wallet's own USDC token account, which must exist6//                  BEFORE the burn (a small passkey-approved transaction creates it when it does not).7// As everywhere else the API cannot make the signer sign anything but these: it sends an intent or a bridge id, never bytes.8import { createHash, randomBytes, randomUUID } from 'node:crypto';9import type { FastifyInstance } from 'fastify';10import { generateAuthenticationOptions } from '@simplewebauthn/server';11import { z } from 'zod';12import {13  BPF_UPGRADEABLE_LOADER, bridgeOf, buildCctpSolBurnMessage, buildCctpSolMintMessage, buildCctpSolReclaimMessage, buildSolTokenAccountMessage, CCTP_FINALITY_FAST, CCTP_FINALITY_STANDARD, CCTP_SOL_CU, CCTP_SOL_DOMAIN,14  CCTP_SOL_EVENT_ACCOUNT_BYTES, CCTP_SOL_EVENT_RENT, CCTP_SOL_FEE_RECIPIENT, CCTP_SOL_MAX_CU_PRICE, CCTP_SOL_MESSAGE_TRANSMITTER, CCTP_SOL_NONCE_BYTES, CCTP_SOL_NONCE_RENT, CCTP_SOL_PINS, CCTP_SOL_RECLAIM_WINDOW_SEC,15  CCTP_SOL_TOKEN_MESSENGER_MINTER, cctpEnd, cctpSolMintRetryBytes, cctpSolPda, cctpSolReclaimBytes, cctpSolTokenAccount, cctpSolUsdc, checkPolicy, decodeCctpSolEventAccount, decodeSolV1Message, encodeCctpMessage,16  expectedCctpSolSourceMessage, formatUnits, getNetwork, IntentSchema, intentHash, parseUnits, RENT_TOKEN_ACCOUNT, solAddress, SOL_SIGNATURE_FEE, solTokenAccountHash, solV1WireSigned, SYSTEM_PROGRAM, toB64u, toHex,17  TOKEN_PROGRAM, verifyCctpMint, verifyCctpSolBurn, type CctpBridge, type Intent, type NetworkDef, type SolBudget, type SolTokenAccountRequest,18} from '@spw/core';19import { listCreds } from './auth.ts';20import { adapterFor } from './chains/index.ts';21import { rpcAny, rpcQuorum } from './chains/rpc.ts';22import { config, UserError } from './config.ts';23import { audit, sql } from './db.ts';24import { rpcOverriddenNetworks } from './rpc-override.ts';25import { addressFor, signer, type SignerStatus } from './signer-client.ts';26import { broadcastNow, move, view, type ProposalRow } from './transfers.ts';27import { cctpFee, cctpGuards, destinationGas, done, fastAllowance, iris, noGas, save, wait, type BridgeState } from './bridge.ts';2829/** What a bridge row keeps about its Solana side. */30export interface SolBridgeInfo {31  /** EVM → Solana: the wallet's Solana address, its USDC token account (the mint recipient) and the rent of the used-nonce record the mint pays */32  recipient?: string; tokenAccount?: string; nonceRent?: string;33  /** Solana → EVM: the one-time event account generated by the signer, its refundable rent deposit, when it was created and when it can be closed */34  eventAccount?: string; deposit?: string; createdAt?: number; reclaimableAt?: number;35  reclaim?: { state: 'approval' | 'sent' | 'done' | 'failed'; budget: SolBudget; expiresAt: number; txid?: string; raw?: string; sentAt?: number; lastBroadcastAt?: number; error?: string | null; received?: string; doneAt?: number };36}3738const SOL = () => getNetwork('solana');39const now = () => Date.now();40type Acct = { owner: string; executable: boolean; lamports: number; data: [string, string] | { parsed?: { type?: string; info?: Record<string, any> } } } | null;41interface SimValue { err: unknown; logs?: string[] | null; unitsConsumed?: number; fee?: number; preBalances?: number[]; postBalances?: number[]; preTokenBalances?: TokBal[]; postTokenBalances?: TokBal[]; accounts?: ({ owner: string; lamports: number; data: [string, string] } | null)[] | null }42type TokBal = { accountIndex: number; mint: string; owner?: string; uiTokenAmount: { amount: string } };43const b64 = (u: Uint8Array) => Buffer.from(u).toString('base64');44const simError = (s: SimValue) => `${JSON.stringify(s.err).slice(0, 90)} ${(s.logs ?? []).filter((l) => /error|failed|insufficient|exceeded|already in use/i.test(l)).slice(-2).join(' | ').slice(0, 200)}`.trim();45async function simulate(n: NetworkDef, wire: string, o: { sigVerify: boolean; accounts?: string[] }): Promise<SimValue> {46  return (await rpcAny<{ value: SimValue }>(n.rpc, 'simulateTransaction', [wire, { encoding: 'base64', commitment: 'confirmed', sigVerify: o.sigVerify, replaceRecentBlockhash: false, ...(o.accounts ? { accounts: { encoding: 'base64', addresses: o.accounts } } : {}) }])).value;47}48const unsigned = (msg: Uint8Array, signers = 1) => b64(solV1WireSigned(Array.from({ length: signers }, () => new Uint8Array(64)), msg));49/** a throwaway public key that stands in for the signer's one-time event account when the burn is simulated before approval */50const throwawayKey = () => solAddress(new Uint8Array(randomBytes(32)));5152// ---------------------------------------------------------------------------------------------------------------------53// Guards: the two Circle programs are still the reviewed deployments, on every RPC source; USDC is a classic SPL mint;54// Circle's fee recipient is the pinned one; the wallet is a plain, non-denylisted system account.5556let codeCheckedAt = 0;57const changed = (what: string) => new UserError('contract_changed', `${what} on Solana is not the reviewed deployment (Circle may have upgraded it); CCTP transfers with Solana are refused until it is reviewed again`, 503);58export async function cctpSolGuards(n: NetworkDef, me: string): Promise<{ minFee: number }> {59  if ((await rpcQuorum<string>(n.rpc, 'getGenesisHash', [])) !== n.chainRef) throw new UserError('rpc_degraded', 'cluster identity mismatch; transfer refused', 503);60  const info = (a: string, o: Record<string, unknown> = {}) => rpcQuorum<{ value: Acct }>(n.rpc, 'getAccountInfo', [a, { encoding: 'base64', commitment: 'finalized', ...o }], (x, y) => JSON.stringify(x.value) === JSON.stringify(y.value)).then((r) => r.value);61  const bytes = (a: Acct) => (a && Array.isArray(a.data) ? Buffer.from(a.data[0], 'base64') : null);62  for (const [name, pin] of [['the MessageTransmitterV2 program', CCTP_SOL_PINS.transmitter], ['the TokenMessengerMinterV2 program', CCTP_SOL_PINS.messenger]] as const) {63    const prog = await info(pin.program), pd = bytes(prog);64    if (!prog || !pd || prog.owner !== BPF_UPGRADEABLE_LOADER || !prog.executable || pd.length !== 36 || pd.readUInt32LE(0) !== 2 || solAddress(pd.subarray(4, 36)) !== pin.programData) throw changed(name);65    const head = await info(pin.programData, { dataSlice: { offset: 0, length: 45 } }), h = bytes(head);66    if (!head || !h || head.owner !== BPF_UPGRADEABLE_LOADER || h.length !== 45 || h.readUInt32LE(0) !== 3 || Number(h.readBigUInt64LE(4)) !== pin.lastDeploySlot || h[12] !== 1 || solAddress(h.subarray(13, 45)) !== pin.upgradeAuthority) throw changed(name);67  }68  if (!rpcOverriddenNetworks.has(n.id) && now() - codeCheckedAt > 6 * 3600_000) { // 1.2 MB per source: re-hashed at first use and every six hours, like the Jupiter program69    for (const pin of [CCTP_SOL_PINS.transmitter, CCTP_SOL_PINS.messenger]) for (const u of n.rpc) {70      const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(40_000), body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getAccountInfo', params: [pin.programData, { encoding: 'base64', commitment: 'finalized' }] }) })71        .then((x) => x.json() as Promise<{ result?: { value: Acct } }>).catch(() => null);72      const d = bytes(r?.result?.value ?? null);73      if (!d) throw new UserError('rpc_degraded', 'could not read a Circle program to check it; transfer refused', 503);74      if (createHash('sha256').update(d.subarray(45)).digest('hex') !== pin.codeSha256) throw changed('a Circle CCTP program');75    }76    codeCheckedAt = now();77  }78  // USDC must be the classic SPL Token mint the programs were reviewed with (a Token-2022 mint could carry fees or hooks)79  const usdc = cctpSolUsdc();80  const mint = (await rpcQuorum<{ value: Acct }>(n.rpc, 'getAccountInfo', [usdc.id, { encoding: 'jsonParsed', commitment: 'finalized' }], (x, y) => x.value?.owner === y.value?.owner && JSON.stringify((x.value?.data as any)?.parsed?.info?.decimals) === JSON.stringify((y.value?.data as any)?.parsed?.info?.decimals))).value;81  const mi = mint && !Array.isArray(mint.data) ? mint.data.parsed : undefined;82  if (!mint || mint.owner !== TOKEN_PROGRAM || mi?.type !== 'mint' || mi.info?.decimals !== usdc.decimals) throw new UserError('unsupported_asset', 'USDC on Solana is not the classic SPL Token mint that was reviewed; transfer refused', 503);83  // token_messenger state: Circle's fee recipient (the mint instruction names its token account) and the Standard minimum fee84  const tm = bytes(await info(cctpSolPda.tokenMessenger()));85  if (!tm || tm.length < 177) throw new UserError('rpc_degraded', 'could not read the token messenger state; transfer refused', 503);86  if (solAddress(tm.subarray(109, 141)) !== CCTP_SOL_FEE_RECIPIENT) throw changed('Circle\'s fee recipient');87  const w = (await rpcQuorum<{ value: Acct }>(n.rpc, 'getAccountInfo', [me, { encoding: 'base64', commitment: 'confirmed', dataSlice: { offset: 0, length: 0 } }], (a, b) => a.value?.owner === b.value?.owner && a.value?.executable === b.value?.executable)).value;88  if (!w) throw new UserError('destination_gas', 'this wallet holds no SOL yet: receive some SOL first (it pays the network fee and the small deposits Solana asks for)', 422);89  if (w.owner !== SYSTEM_PROGRAM || w.executable) throw new UserError('account_not_plain', 'this wallet address is not a plain system account; transfers are refused', 503);90  if (await info(cctpSolPda.denylist(me), { dataSlice: { offset: 0, length: 0 } })) throw new UserError('denylisted', 'Circle\'s token messenger refuses burns from this address (denylist)', 422);91  return { minFee: tm.readUInt32LE(173) };92}93/** Standard-transfer minimum of the Solana program: amount × min_fee / 10⁷, at least one base unit when the switch is on (0 today). */94export async function solStandardMinFee(n: NetworkDef, amount: bigint): Promise<bigint> {95  const r = await rpcAny<{ value: Acct }>(n.rpc, 'getAccountInfo', [cctpSolPda.tokenMessenger(), { encoding: 'base64', commitment: 'finalized' }]).catch(() => null);96  const d = r?.value && Array.isArray(r.value.data) ? Buffer.from(r.value.data[0], 'base64') : null;97  if (!d || d.length < 177) throw new UserError('rpc_degraded', 'could not read the token messenger state', 503);98  const minFee = BigInt(d.readUInt32LE(173));99  if (minFee === 0n) return 0n;100  const fee = (amount * minFee) / 10_000_000n;101  return fee > 1n ? fee : 1n;102}103104const rents = new Map<number, { at: number; v: bigint }>();105/** Rent-exempt minimum for an account of `bytes`, read live (the rate is a protocol parameter). */106async function rent(n: NetworkDef, bytes: number): Promise<bigint> {107  const c = rents.get(bytes);108  if (c && now() - c.at < 600_000) return c.v;109  const v = BigInt(await rpcAny<number>(n.rpc, 'getMinimumBalanceForRentExemption', [bytes]));110  rents.set(bytes, { at: now(), v });111  return v;112}113async function cuPrice(n: NetworkDef, me: string): Promise<bigint> {114  let price = 5_000n;115  try { const fees = await rpcAny<{ prioritizationFee: number }[]>(n.rpc, 'getRecentPrioritizationFees', [[me]]); const sorted = fees.map((f) => f.prioritizationFee).sort((a, b) => a - b);116    price = BigInt(Math.max(5_000, sorted[Math.floor(sorted.length * 0.75)] ?? 0)); } catch { /* default */ }117  return price > CCTP_SOL_MAX_CU_PRICE ? CCTP_SOL_MAX_CU_PRICE : price;118}119const priorityOf = (limit: number, price: bigint) => (BigInt(limit) * price + 999_999n) / 1_000_000n;120const blockhash = async (n: NetworkDef) => (await rpcAny<{ value: { blockhash: string; lastValidBlockHeight: number } }>(n.rpc, 'getLatestBlockhash', [{ commitment: 'confirmed' }])).value;121const lamportsOf = async (n: NetworkDef, a: string) => BigInt((await rpcAny<{ value: number }>(n.rpc, 'getBalance', [a, { commitment: 'confirmed' }])).value);122123/** The wallet's USDC token account on Solana, as both RPC sources see it. */124export async function solTokenAccount(n: NetworkDef, me: string): Promise<{ address: string; exists: boolean; balance: bigint; rent: bigint }> {125  const address = cctpSolTokenAccount(me);126  const a = (await rpcQuorum<{ value: Acct }>(n.rpc, 'getAccountInfo', [address, { encoding: 'jsonParsed', commitment: 'confirmed' }], (x, y) => (x.value === null) === (y.value === null) && x.value?.owner === y.value?.owner)).value;127  const r = await rent(n, 165);128  if (!a) return { address, exists: false, balance: 0n, rent: r };129  const info = !Array.isArray(a.data) ? a.data.parsed?.info : undefined;130  if (a.owner !== TOKEN_PROGRAM || info?.mint !== cctpSolUsdc().id || info.owner !== me) throw new UserError('account_not_plain', 'the wallet\'s USDC token account on Solana is not an ordinary token account of this wallet; transfers are refused', 503);131  if (info.state !== 'initialized') throw new UserError('account_frozen', 'the wallet\'s USDC token account on Solana is frozen; transfers are refused', 422);132  return { address, exists: true, balance: BigInt(info.tokenAmount?.amount ?? '0'), rent: r };133}134135/** What receiving on Solana needs: SOL for the mint's fee and for the used-nonce record, on top of what must stay in the account. */136export async function solDestination(dst: NetworkDef, me: string): Promise<{ need: bigint; balance: bigint; nonceRent: bigint; tokenAccount: Awaited<ReturnType<typeof solTokenAccount>> }> {137  const [nonceRent, keep, price, lamports, tokenAccount] = await Promise.all([rent(dst, CCTP_SOL_NONCE_BYTES), rent(dst, 0), cuPrice(dst, me), lamportsOf(dst, me), solTokenAccount(dst, me)]);138  if (nonceRent > CCTP_SOL_NONCE_RENT) throw new UserError('rent_changed', 'Solana\'s rent rate is above the reviewed one; transfers to Solana are refused until it is reviewed', 503);139  const need = SOL_SIGNATURE_FEE + priorityOf(MINT_PLAN_CU, price) + nonceRent;140  return { need, balance: lamports > keep ? lamports - keep : 0n, nonceRent, tokenAccount };141}142const MINT_PLAN_CU = 260_000;143144/** Solana-specific lines of a quote: the refundable deposit of a burn on Solana, or the token account a mint on Solana needs. */145export async function solQuoteExtras(n: NetworkDef, dst: NetworkDef, st: SignerStatus) {146  if (n.family === 'sol') {147    const [deposit, price] = await Promise.all([rent(n, CCTP_SOL_EVENT_ACCOUNT_BYTES), cuPrice(n, addressFor(st, n.id))]);148    const fee = 2n * SOL_SIGNATURE_FEE + priorityOf(80_000, price);149    return { role: 'source' as const, deposit: deposit.toString(), depositDisplay: formatUnits(deposit, 9), reclaimAfterSeconds: CCTP_SOL_RECLAIM_WINDOW_SEC, networkFeeDisplay: formatUnits(fee, 9), symbol: 'SOL' };150  }151  if (dst.family === 'sol') {152    const me = addressFor(st, dst.id), t = await solTokenAccount(dst, me), nonceRent = await rent(dst, CCTP_SOL_NONCE_BYTES);153    return { role: 'destination' as const, recipient: me, tokenAccount: t.address, tokenAccountExists: t.exists, tokenAccountRentDisplay: formatUnits(t.rent, 9), nonceRentDisplay: formatUnits(nonceRent, 9), symbol: 'SOL' };154  }155  return null;156}157158// ---------------------------------------------------------------------------------------------------------------------159// Solana → EVM: prepare the burn160161/** What the simulated (or signed) burn does, read from the simulation: throws unless it is exactly the approved burn. */162function checkBurnSimulation(i: Intent, msg: Uint8Array, eventAccount: string, s: SimValue, deposit: bigint): void {163  if (s.err) throw new UserError('simulation_failed', `the burn would fail right now (${simError(s)})`, 422);164  const v = verifyCctpSolBurn(i), keys = decodeSolV1Message(msg).keys, ata = cctpSolTokenAccount(i.from), k = keys.indexOf(ata);165  if (!s.preBalances || !s.postBalances || !s.preTokenBalances || !s.postTokenBalances || s.fee === undefined) throw new UserError('simulation_failed', 'the node did not report the simulated balances; the burn is refused', 503);166  const tok = (t: TokBal[]) => BigInt(t.find((b) => b.accountIndex === k)?.uiTokenAmount.amount ?? '0');167  if (tok(s.preTokenBalances) - tok(s.postTokenBalances) !== v.amount) throw new UserError('simulation_failed', 'the simulated burn does not debit exactly the amount', 422);168  if (BigInt(s.fee) !== v.networkFee) throw new UserError('simulation_failed', 'simulated network fee differs from the computed one', 422);169  if (BigInt(s.preBalances[0]!) - BigInt(s.postBalances[0]!) !== v.networkFee + deposit) throw new UserError('simulation_failed', 'the simulated burn costs the wallet something else than the fee and the event-account deposit', 422);170  for (const b of s.preTokenBalances) if (b.owner === i.from && b.accountIndex !== k && BigInt(s.postTokenBalances.find((x) => x.accountIndex === b.accountIndex)?.uiTokenAmount.amount ?? '0') < BigInt(b.uiTokenAmount.amount)) throw new UserError('simulation_failed', 'simulation shows another account of the wallet losing value', 422);171  // the event account the program created, and the message it stored: it must be the one our own mint check will accept172  const ev = s.accounts?.[0];173  if (!ev || ev.owner !== CCTP_SOL_MESSAGE_TRANSMITTER || BigInt(ev.lamports) !== deposit) throw new UserError('simulation_failed', 'the simulated burn did not create the event account as expected', 422);174  const stored = decodeCctpSolEventAccount(new Uint8Array(Buffer.from(ev.data[0], 'base64')));175  if (stored.rentPayer !== i.from) throw new UserError('simulation_failed', 'the event account does not record the wallet as its rent payer (the deposit could not be reclaimed)', 422);176  const expected = expectedCctpSolSourceMessage(i);177  if (`0x${Buffer.from(stored.message).toString('hex')}` !== encodeCctpMessage(expected)) throw new UserError('simulation_failed', 'the message the burn would emit is not the approved transfer', 422);178  try { verifyCctpMint(bridgeOf(i), encodeCctpMessage({ ...expected, nonce: '01'.repeat(32), finalityThresholdExecuted: expected.minFinalityThreshold }), `0x${'00'.repeat(130)}`); }179  catch (e) { throw new UserError('simulation_failed', `the message the burn would emit would not pass the mint check (${(e as Error).message})`, 422); }180  void eventAccount;181}182183export interface PreparedSolBurn { intent: Intent; fee: bigint; deposit: bigint; networkFee: bigint; unitsConsumed: number; destGasNeed: bigint; simulation: SimValue; message: Uint8Array; eventAccount: string }184/**185 * Prepare a firm burn on Solana for `me`: guards on both networks, balances, destination gas, Circle's fee, and a simulation186 * of the very transaction the signer will build (with a throwaway key in place of its one-time event account). Signs nothing.187 */188export async function prepareSolBurn(o: { n: NetworkDef; dst: NetworkDef; me: string; recipient: string; keyId: string; amount: bigint; speed: 'standard' | 'fast'; policyVersion: number; proposalId: string; feeCeiling: bigint; destFeeCeiling: bigint; ttlSec?: number }): Promise<PreparedSolBurn> {189  const { n, dst, me, amount } = o, end = cctpEnd(dst.id)!, usdc = cctpSolUsdc();190  if (amount <= 0n || amount >= 1n << 64n) throw new UserError('bad_amount', 'amount must be greater than zero');191  await cctpSolGuards(n, me); await cctpGuards(dst, o.recipient);192  const ata = await solTokenAccount(n, me);193  if (!ata.exists || ata.balance < amount) throw new UserError('insufficient_funds', 'insufficient USDC in the wallet\'s token account on Solana');194  // the destination must be able to pay for the mint BEFORE anything is burned195  const dg = await destinationGas(dst, o.recipient);196  if (dg.balance < dg.need) throw noGas(dst, dg.need, dg.balance);197  if (dg.need > o.destFeeCeiling) throw new UserError('policy', `the network fee on ${dst.name} is above the signer's fee ceiling right now; the mint could not be signed`, 422);198  const fee = await cctpFee(n, dst, amount, o.speed);199  if (o.speed === 'fast') {200    if (fee === 0n) throw new UserError('fast_unavailable', 'Circle quotes no Fast fee for this route right now; use Standard', 422);201    const allowance = await fastAllowance();202    if (allowance === null || allowance < amount) throw new UserError('fast_unavailable', 'Circle\'s Fast Transfer allowance does not cover this amount right now; use Standard', 422);203  }204  if (fee >= amount) throw new UserError('bad_amount', 'the amount does not cover Circle\'s fee');205  const deposit = await rent(n, CCTP_SOL_EVENT_ACCOUNT_BYTES);206  if (deposit > CCTP_SOL_EVENT_RENT) throw new UserError('rent_changed', 'Solana\'s rent rate is above the reviewed one; burns on Solana are refused until it is reviewed', 503);207  const [bh, lamports, keep, price0] = await Promise.all([blockhash(n), lamportsOf(n, me), rent(n, 0), cuPrice(n, me)]);208  const short = (fee: bigint) => new UserError('insufficient_gas', `not enough SOL on Solana: the burn needs ${formatUnits(fee, 9)} SOL of fee and a refundable deposit of ${formatUnits(deposit, 9)} SOL (and ${formatUnits(keep, 9)} SOL must remain in the wallet)`);209  if (lamports < 2n * SOL_SIGNATURE_FEE + deposit + keep) throw short(2n * SOL_SIGNATURE_FEE);210  const t0 = Math.floor(now() / 1000), eventAccount = throwawayKey();211  const draft = (limit: number, price: bigint, maxFee: bigint): Intent => IntentSchema.parse({212    v: 1, proposalId: o.proposalId, keyId: o.keyId, network: n.id, chainRef: n.chainRef, asset: usdc.id, symbol: 'USDC', decimals: usdc.decimals, amount: amount.toString(), from: me, to: me,213    maxFee: maxFee.toString(), createdAt: t0, expiresAt: t0 + (o.ttlSec ?? 50), policyVersion: o.policyVersion,214    bridge: { provider: 'cctp', toNetwork: dst.id, toDomain: end.domain, maxFee: fee.toString(), minFinalityThreshold: o.speed === 'fast' ? CCTP_FINALITY_FAST : CCTP_FINALITY_STANDARD,215      messenger: CCTP_SOL_TOKEN_MESSENGER_MINTER, transmitter: CCTP_SOL_MESSAGE_TRANSMITTER, approveGas: '0', recipient: o.recipient },216    sol: { recentBlockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight, createAta: false, computeUnitLimit: limit, computeUnitPrice: price.toString() },217  });218  // first pass at the largest limit: structure and the units really used219  let probe: Uint8Array;220  try { probe = buildCctpSolBurnMessage(draft(CCTP_SOL_CU.burnMax, 0n, 2n * SOL_SIGNATURE_FEE + CCTP_SOL_EVENT_RENT), eventAccount); } catch (e) { throw new UserError('simulation_failed', (e as Error).message, 422); }221  const s1 = await simulate(n, unsigned(probe, 2), { sigVerify: false });222  if (s1.err) throw new UserError('simulation_failed', `the burn would fail right now (${simError(s1)})`, 422);223  const units = s1.unitsConsumed ?? CCTP_SOL_CU.burnMax, limit = Math.max(CCTP_SOL_CU.burnMin, Math.ceil(units * 1.25) + 15_000);224  if (limit > CCTP_SOL_CU.burnMax) throw new UserError('simulation_failed', 'the burn needs more compute than it may use here', 422);225  // priority fee: the recent rate, lowered so that fee + deposit stay under the signer's fee ceiling for Solana226  const room = o.feeCeiling - 2n * SOL_SIGNATURE_FEE - CCTP_SOL_EVENT_RENT;227  if (room < 0n) throw new UserError('fee_ceiling', 'the event-account deposit does not fit under the signer\'s fee ceiling for Solana', 422);228  let price = price0; if (priorityOf(limit, price) > room) price = (room * 1_000_000n) / BigInt(limit);229  const networkFee = 2n * SOL_SIGNATURE_FEE + priorityOf(limit, price), maxFee = networkFee + CCTP_SOL_EVENT_RENT;230  if (lamports < networkFee + deposit + keep) throw short(networkFee);231  const intent = draft(limit, price, maxFee);232  let message: Uint8Array;233  try { message = buildCctpSolBurnMessage(intent, eventAccount); } catch (e) { throw new UserError('simulation_failed', (e as Error).message, 422); } // exactly what the signer will rebuild, with its own event key234  // the exact bytes against the current state: must succeed, debit exactly the amount, create the event account and store the expected message235  const s2 = await simulate(n, unsigned(message, 2), { sigVerify: false, accounts: [eventAccount] });236  checkBurnSimulation(intent, message, eventAccount, s2, deposit);237  return { intent, fee, deposit, networkFee, unitsConsumed: units, destGasNeed: dg.need, simulation: s2, message, eventAccount };238}239240/** POST /api/v1/bridge/proposals with Solana as the source: prepare, then offer the transfer for passkey approval (it covers burn and mint). */241export async function createSolBurnProposal(b: { n: NetworkDef; dst: NetworkDef; amount: string; speed: 'standard' | 'fast' }, idem: string) {242  const { n, dst } = b, end = cctpEnd(dst.id)!, src = cctpEnd(n.id)!;243  let amount: bigint;244  try { amount = parseUnits(b.amount, 6); } catch (e) { throw new UserError('bad_amount', (e as Error).message); }245  if (amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');246  const st = await signer.status(true);247  if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);248  const me = addressFor(st, n.id), recipient = addressFor(st, dst.id);249  const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${n.id} and state in ('approved', 'signed', 'broadcast') limit 1`;250  if (busy) throw new UserError('pending_tx', 'a previous transfer or swap on this network is not confirmed yet; wait for it before starting another', 409);251  const id = randomUUID();252  const ps = await prepareSolBurn({ n, dst, me, recipient, keyId: st.keyId, amount, speed: b.speed, policyVersion: st.policy.version, proposalId: id, feeCeiling: BigInt(st.policy.maxFee[n.id] ?? '0'), destFeeCeiling: BigInt(st.policy.maxFee[dst.id] ?? '0') });253  const { intent } = ps, t0 = intent.createdAt;254  const verdict = checkPolicy(st.policy, intent, BigInt(st.spentToday[`${n.id}:USDC`] ?? '0'), t0, st.halted);255  if (!verdict.ok) throw new UserError('policy', verdict.reason, 422);256  const hash = intentHash(intent);257  const state: BridgeState = { provider: 'cctp', toNetwork: dst.id, fromDomain: CCTP_SOL_DOMAIN, toDomain: end.domain, speed: b.speed, maxFee: ps.fee.toString(), minFinalityThreshold: intent.bridge!.minFinalityThreshold,258    expectedSeconds: b.speed === 'fast' ? src.fastSeconds! : src.standardSeconds, needsApproval: false, simulated: true, destGasNeed: ps.destGasNeed.toString(), phase: 'burn', polls: 0, nextAt: 0,259    sol: { recipient, deposit: ps.deposit.toString() } };260  const [p] = await sql<ProposalRow[]>`insert into proposals (id, idempotency_key, state, kind, network, asset, symbol, decimals, amount, from_addr, to_addr, note, fee_estimate, max_fee, warnings, intent, intent_hash, bridge, expires_at)261    values (${id}, ${idem}, 'pending_approval', 'bridge', ${n.id}, ${intent.asset}, 'USDC', 6, ${amount.toString()}, ${me}, ${me}, ${`bridge → ${dst.name}`}, ${ps.networkFee.toString()}, ${intent.maxFee},262      ${sql.json([])}, ${sql.json(intent as never)}, ${toHex(hash)}, ${sql.json(state as never)}, ${new Date(intent.expiresAt * 1000)})263    on conflict (idempotency_key) do nothing returning *`;264  if (!p) throw new UserError('conflict', 'duplicate request', 409);265  await audit('bridge.prepared', 'owner', { id, from: n.id, to: dst.id, amount: amount.toString(), speed: b.speed, cctpMaxFee: ps.fee.toString(), deposit: ps.deposit.toString(), networkFee: ps.networkFee.toString(), hash: toHex(hash) });266  const creds = await listCreds();267  const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: 45_000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });268  if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');269  return { proposal: view(p), options };270}271272/**273 * After signing: last gate before the burn leaves. The signed transaction must be, byte for byte, the burn of the approved274 * intent with the event account the signer generated (its key is the second signer); a blockhash that is no longer valid275 * means it can never be included (failed, nothing burned); then the signed bytes are simulated with their signatures checked.276 */277export async function advanceSolBurn(p: ProposalRow): Promise<ProposalRow> {278  if (p.kind !== 'bridge' || p.state !== 'signed' || !p.raw || !p.intent?.sol || !p.bridge) return p;279  const n = getNetwork(p.network), intent = p.intent, raw = p.raw, lastValid = p.intent.sol.lastValidBlockHeight, deposit = BigInt(p.bridge.sol?.deposit ?? CCTP_SOL_EVENT_RENT);280  const fail = async (error: string) => { const r = await move(p, 'failed', { error }); await audit('bridge.not_sent', 'system', { id: p.id, reason: error.slice(0, 160) }); return r; };281  const wire = new Uint8Array(Buffer.from(raw, 'base64'));282  let eventAccount: string, msg: Uint8Array;283  try {284    msg = wire.subarray(0, wire.length - 128);285    const d = decodeSolV1Message(msg); eventAccount = d.keys[1]!;286    if (d.numSigners !== 2 || Buffer.compare(Buffer.from(buildCctpSolBurnMessage(intent, eventAccount)), Buffer.from(msg)) !== 0) throw new Error('not the approved burn');287  } catch { return fail('the signer returned a transaction that is not the approved burn; it was not sent'); }288  if (p.bridge.sol?.eventAccount !== eventAccount) { p = await save(p, { ...p.bridge, sol: { ...p.bridge.sol, eventAccount } }); await audit('bridge.event_account', 'system', { id: p.id, eventAccount }); }289  if (!config.broadcast) return broadcastNow(p);290  let sim: SimValue;291  try {292    const height = await rpcAny<number>(n.rpc, 'getBlockHeight', [{ commitment: 'confirmed' }]);293    if (height > lastValid) return await fail('the transfer expired before it could be sent (blockhash too old); nothing was burned');294    sim = await simulate(n, raw, { sigVerify: true, accounts: [eventAccount] });295  } catch (e) { if ((e as { rpcCode?: number }).rpcCode === undefined) return p; return fail(`the node refused the signed burn (${(e as Error).message.slice(0, 100)}); it was not sent`); }296  try { checkBurnSimulation(intent, msg, eventAccount, sim, deposit); } catch (e) { return fail(`${(e as Error).message}; it was not sent`); }297  return broadcastNow(p);298}299300/** Once the burn is confirmed: when the event account was created (the program's own clock), hence when the deposit can be reclaimed. */301export async function solBurnFacts(p: ProposalRow, b: BridgeState): Promise<BridgeState | null> {302  const n = getNetwork(p.network), ev = b.sol?.eventAccount;303  if (!ev) return null;304  const a = (await rpcAny<{ value: Acct }>(n.rpc, 'getAccountInfo', [ev, { encoding: 'base64', commitment: 'confirmed' }])).value;305  if (!a || !Array.isArray(a.data) || a.owner !== CCTP_SOL_MESSAGE_TRANSMITTER) return null;306  const e = decodeCctpSolEventAccount(new Uint8Array(Buffer.from(a.data[0], 'base64')));307  return { ...b, sol: { ...b.sol, deposit: String(a.lamports), createdAt: e.createdAt, reclaimableAt: e.createdAt + CCTP_SOL_RECLAIM_WINDOW_SEC } };308}309310// ---------------------------------------------------------------------------------------------------------------------311// EVM → Solana: the mint312313const usedNonce = async (n: NetworkDef, nonce: string): Promise<boolean> =>314  (await rpcQuorum<{ value: Acct }>(n.rpc, 'getAccountInfo', [cctpSolPda.usedNonce(nonce), { encoding: 'base64', commitment: 'confirmed', dataSlice: { offset: 0, length: 0 } }], (x, y) => (x.value === null) === (y.value === null))).value !== null;315/** Someone else submitted the mint (it is permissionless): its transaction is the one that created the used-nonce record. */316const findMint = async (n: NetworkDef, nonce: string): Promise<string | null> =>317  rpcAny<{ signature: string }[]>(n.rpc, 'getSignaturesForAddress', [cctpSolPda.usedNonce(nonce), { limit: 1 }]).then((r) => r[0]?.signature ?? null, () => null);318319/** Compute budget and lifetime for a mint right now, from a simulation of the signer-built transaction, or why it cannot be sent yet. */320async function mintBudget(p: ProposalRow, b: BridgeState, bridge: CctpBridge): Promise<{ sol: SolBudget } | { hold: string; detail: string }> {321  const dst = getNetwork(b.toNetwork), me = bridge.recipient!;322  const st = await signer.status(), ceiling = BigInt(st.policy.maxFee[dst.id] ?? '0');323  const [bh, price0, lamports, keep, nonceRent] = await Promise.all([blockhash(dst), cuPrice(dst, me), lamportsOf(dst, me), rent(dst, 0), rent(dst, CCTP_SOL_NONCE_BYTES)]);324  if (nonceRent > CCTP_SOL_NONCE_RENT) return { hold: 'contract_changed', detail: 'Solana\'s rent rate is above the reviewed one' };325  const g0: SolBudget = { recentBlockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight, computeUnitLimit: CCTP_SOL_CU.mintMax, computeUnitPrice: '0' };326  let probe; try { probe = buildCctpSolMintMessage(bridge, b.message!, b.attestation!, g0, ceiling); } catch (e) { return { hold: 'mint_would_fail', detail: (e as Error).message }; }327  const s = await simulate(dst, unsigned(probe.msg), { sigVerify: false });328  if (s.err) return { hold: /insufficient (lamports|funds)/i.test(simError(s)) ? 'destination_gas' : 'mint_would_fail', detail: `the mint would fail on ${dst.name} now: ${simError(s).slice(0, 160)}` };329  const limit = Math.min(CCTP_SOL_CU.mintMax, Math.max(CCTP_SOL_CU.mintMin, Math.ceil((s.unitsConsumed ?? CCTP_SOL_CU.mintMax) * 1.2) + 20_000));330  const room = ceiling - SOL_SIGNATURE_FEE - CCTP_SOL_NONCE_RENT;331  if (room < 0n) return { hold: 'fee_above_policy', detail: 'the used-nonce record does not fit under the signer\'s fee ceiling for Solana' };332  let price = price0; if (priorityOf(limit, price) > room) price = (room * 1_000_000n) / BigInt(limit);333  const need = SOL_SIGNATURE_FEE + priorityOf(limit, price) + nonceRent;334  if (lamports < need + keep) return { hold: 'destination_gas', detail: `add SOL on ${dst.name}: ${formatUnits(need, 9)} is needed to receive the USDC (and ${formatUnits(keep, 9)} must remain), the wallet holds ${formatUnits(lamports, 9)}` };335  return { sol: { ...g0, computeUnitLimit: limit, computeUnitPrice: price.toString() } };336}337338/** The second leg when the destination is Solana: same phases and rules as the EVM mint in bridge.ts. */339export async function advanceSolMint(p: ProposalRow, b: BridgeState, bridge: CctpBridge): Promise<ProposalRow> {340  const dst = getNetwork(b.toNetwork), me = bridge.recipient!;341  if (b.phase === 'ready' || b.phase === 'mint_failed') {342    let used: boolean; try { used = await usedNonce(dst, b.nonce!); } catch (e) { return wait(p, b, 'rpc_degraded', (e as Error).message, 20_000); }343    if (used) return done(p, b, 'other', await findMint(dst, b.nonce!)); // the used-nonce record exists on every source: already received, once344    if (b.phase === 'mint_failed') return p; // needs the owner's passkey (retry-mint)345    try { await cctpSolGuards(dst, me); } catch (e) { return wait(p, b, (e as UserError).code ?? 'rpc_degraded', (e as Error).message, 60_000); }346    let ta; try { ta = await solTokenAccount(dst, me); } catch (e) { return wait(p, b, (e as UserError).code ?? 'rpc_degraded', (e as Error).message, 60_000); }347    if (!ta.exists) return wait(p, b, 'token_account_missing', `the wallet's USDC token account on ${dst.name} (${ta.address}) no longer exists; the USDC waits. Create it again, then finish`, 60_000);348    // a Fast attestation expires (on Solana the expiration is a slot): ask Circle for a fresh one, then mint with it349    if (b.expirationBlock && b.expirationBlock !== '0' && !b.mint?.sol) {350      const slot = await rpcAny<number>(dst.rpc, 'getSlot', [{ commitment: 'confirmed' }]).then(BigInt, () => null);351      if (slot !== null && slot >= BigInt(b.expirationBlock)) {352        await iris(`/v2/reattest/0x${b.nonce}`, { method: 'POST' }).catch(() => null);353        await audit('bridge.reattest', 'system', { id: p.id, nonce: b.nonce });354        return save(p, { ...b, phase: 'attesting', expiredHash: b.messageHash, polls: 0, nextAt: now() + 10_000, hold: 'attestation_expired', holdDetail: 'the Fast attestation expired; a new one was requested from Circle' });355      }356    }357    // the transaction parameters are chosen once and stored before the signer is asked: an interrupted attempt repeats the same request358    let sol = b.mint?.sol;359    if (!sol) {360      const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${dst.id} and state in ('approved', 'signed', 'broadcast') limit 1`;361      if (busy) return wait(p, b, 'destination_busy', `another transaction is in flight on ${dst.name}`, 30_000);362      let g; try { g = await mintBudget(p, b, bridge); } catch (e) { return wait(p, b, (e as UserError).code ?? 'rpc_degraded', (e as Error).message, 20_000); }363      if ('hold' in g) return wait(p, b, g.hold, g.detail, g.hold === 'destination_gas' ? 60_000 : 30_000);364      sol = g.sol; p = await save(p, b = { ...b, hold: null, holdDetail: null, mint: { sol } });365    }366    let signed;367    try { signed = await signer.bridgeMint({ proposalId: p.id, message: b.message!, attestation: b.attestation!, sol }); }368    catch (e) {369      const code = (e as UserError).code ?? '';370      if (code === 'signer_unreachable') return wait(p, b, code, (e as Error).message, 15_000);371      return save(p, { ...b, phase: 'mint_failed', hold: code || 'signer_refused', holdDetail: (e as Error).message.slice(0, 300), nextAt: now() + 60_000 });372    }373    p = await save(p, b = { ...b, phase: 'mint_sent', hold: null, holdDetail: null, nextAt: 0, mint: { sol, txid: signed.txid, raw: signed.raw, sentAt: now(), lastBroadcastAt: now(), signatures: b.mint?.signatures ?? 1 } });374    await audit('bridge.mint_signed', 'system', { id: p.id, to: dst.id, txid: signed.txid, messageHash: signed.messageHash, replayed: signed.replayed });375    if (config.broadcast) await adapterFor(dst.id).broadcast(signed.raw).catch(() => {});376  }377378  if (b.phase === 'mint_sent' && b.mint?.txid && b.mint.sol && config.broadcast) {379    const m = b.mint;380    const status = async () => (await rpcAny<{ value: ({ err: unknown; confirmationStatus: string } | null)[] }>(dst.rpc, 'getSignatureStatuses', [[m.txid], { searchTransactionHistory: true }])).value[0];381    let s; try { s = await status(); } catch { return save(p, { ...b, nextAt: now() + 5_000 }); }382    if (s && !s.err) {383      if (s.confirmationStatus === 'processed') return save(p, { ...b, nextAt: now() + 3_000 });384      // what the wallet actually received, from the transaction's own token balance tables385      const ta = cctpSolTokenAccount(me);386      const tx = await rpcAny<{ transaction: { message: { accountKeys: string[] } }; meta: { preTokenBalances?: TokBal[]; postTokenBalances?: TokBal[] } | null } | null>(dst.rpc, 'getTransaction', [m.txid, { encoding: 'json', commitment: 'confirmed', maxSupportedTransactionVersion: 1 }]).catch(() => null);387      let got: bigint | null = null;388      if (tx?.meta?.postTokenBalances) { const k = tx.transaction.message.accountKeys.indexOf(ta), f = (t?: TokBal[]) => BigInt(t?.find((x) => x.accountIndex === k)?.uiTokenAmount.amount ?? '0'); if (k >= 0) got = f(tx.meta.postTokenBalances) - f(tx.meta.preTokenBalances); }389      return done(p, { ...b, minted: (got !== null && got > 0n ? got : BigInt(b.minted ?? '0')).toString() }, 'wallet', m.txid!);390    }391    let used = false; try { used = await usedNonce(dst, b.nonce!); } catch { return save(p, { ...b, nextAt: now() + 10_000 }); }392    if (s?.err) { // our transaction failed: either someone minted first (success) or it must be signed again393      if (used) return done(p, b, 'other', await findMint(dst, b.nonce!));394      await audit('bridge.mint_reverted', 'chain', { id: p.id, txid: m.txid });395      return save(p, { ...b, phase: 'mint_failed', hold: 'mint_reverted', holdDetail: `the mint transaction failed on ${dst.name}; the USDC is still waiting. Approve a new attempt with the passkey` });396    }397    if (used) { // received, and our transaction not visible a moment ago: look again before crediting someone else398      const again = await status().catch(() => undefined);399      if (again === undefined || (again && !again.err)) return save(p, { ...b, nextAt: now() + 3_000 });400      return done(p, b, 'other', await findMint(dst, b.nonce!));401    }402    const height = await rpcAny<number>(dst.rpc, 'getBlockHeight', [{ commitment: 'finalized' }]).catch(() => null);403    if (height !== null && height > m.sol!.lastValidBlockHeight) // a Solana transaction lives about a minute: not included in time, it never will be404      return save(p, { ...b, phase: 'mint_failed', hold: 'mint_expired', holdDetail: `the mint transaction was not included in time on ${dst.name}; the USDC is still waiting. Approve a new attempt with the passkey` });405    if (now() - (m.lastBroadcastAt ?? 0) > 15_000) { await adapterFor(dst.id).broadcast(m.raw!).catch(() => {}); return save(p, { ...b, mint: { ...m, lastBroadcastAt: now() }, nextAt: now() + 4_000 }); }406    return save(p, { ...b, nextAt: now() + 3_000 });407  }408  return p;409}410411/** A mint on Solana that failed or expired can only be signed again with the owner's passkey, over the exact new parameters. */412export async function solMintRetryOptions(p: ProposalRow, b: BridgeState) {413  const g = await mintBudget(p, b, bridgeOf(p.intent!));414  if ('hold' in g) throw new UserError(g.hold, g.detail, 422);415  p = await save(p, { ...b, retry: { sol: g.sol, expiresAt: now() + 50_000 } });416  const challenge = new Uint8Array(createHash('sha256').update(cctpSolMintRetryBytes(p.id, b.messageHash!, g.sol)).digest());417  const creds = await listCreds();418  const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge, timeout: 45_000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });419  return { proposal: view(p), options };420}421422// ---------------------------------------------------------------------------------------------------------------------423// Solana → EVM: the event-account deposit back to the wallet (five days after the burn, passkey)424425/** Worker: reclaim transactions that were sent — confirmed, failed, or expired (the owner can then ask again). */426export async function advanceSolReclaims(): Promise<void> {427  if (!config.broadcast) return;428  const rows = await sql<ProposalRow[]>`select * from proposals where kind = 'bridge' and bridge->'sol'->'reclaim'->>'state' = 'sent' order by created_at`;429  for (const p of rows) { try { await advanceSolReclaim(p); } catch (e) { console.log(`[worker] reclaim ${p.id}: ${(e as Error).message}`); } }430}431export async function advanceSolReclaim(p: ProposalRow): Promise<ProposalRow> {432  const b = p.bridge, r = b?.sol?.reclaim;433  if (!b || !r || r.state !== 'sent' || !r.txid || !config.broadcast) return p;434  const n = getNetwork(p.network), put = (reclaim: NonNullable<SolBridgeInfo['reclaim']>) => save(p, { ...b, sol: { ...b.sol, reclaim } });435  const s = (await rpcAny<{ value: ({ err: unknown; confirmationStatus: string } | null)[] }>(n.rpc, 'getSignatureStatuses', [[r.txid], { searchTransactionHistory: true }])).value[0];436  if (s && !s.err && s.confirmationStatus !== 'processed') {437    const fee = SOL_SIGNATURE_FEE + priorityOf(r.budget.computeUnitLimit, BigInt(r.budget.computeUnitPrice)), received = (BigInt(b.sol!.deposit ?? '0') - fee).toString();438    await audit('bridge.deposit_reclaimed', 'chain', { id: p.id, txid: r.txid, eventAccount: b.sol!.eventAccount, deposit: b.sol!.deposit, received });439    return put({ ...r, state: 'done', raw: undefined, error: null, received, doneAt: now() });440  }441  if (s?.err) return put({ ...r, state: 'failed', raw: undefined, error: `the reclaim transaction failed on-chain (${JSON.stringify(s.err).slice(0, 80)}); the deposit is still there` });442  const height = await rpcAny<number>(n.rpc, 'getBlockHeight', [{ commitment: 'finalized' }]).catch(() => null);443  if (height !== null && height > r.budget.lastValidBlockHeight) return put({ ...r, state: 'failed', raw: undefined, error: 'the reclaim transaction was not included in time; the deposit is still there' });444  if (r.raw && now() - (r.lastBroadcastAt ?? 0) > 15_000) { await adapterFor(n.id).broadcast(r.raw).catch(() => {}); return put({ ...r, lastBroadcastAt: now() }); }445  return p;446}447448interface PendingAta { request: SolTokenAccountRequest; rent: bigint; networkFee: bigint }449const pendingAta = new Map<string, PendingAta>();450451export function bridgeSolRoutes(app: FastifyInstance, load: (id: string) => Promise<ProposalRow>): void {452  // "Récupérer le dépôt": build and simulate the reclaim of the event account of a burn made on Solana, and ask for the passkey.453  app.post('/api/v1/bridge/proposals/:id/reclaim/options', async (req) => {454    let p = await load((req.params as { id: string }).id);455    p = await advanceSolReclaim(p).catch(() => p);456    const b = p.bridge!, s = b.sol, n = getNetwork(p.network);457    if (n.family !== 'sol' || !s?.eventAccount || !p.intent) throw new UserError('bad_state', 'this transfer has no deposit to reclaim', 409);458    if (s.reclaim?.state === 'done') throw new UserError('bad_state', 'the deposit was already reclaimed', 409);459    if (s.reclaim?.state === 'sent') throw new UserError('bad_state', 'a reclaim transaction is already on its way', 409);460    if (!b.message || !b.attestation || !b.messageHash) throw new UserError('reclaim_not_yet', 'the deposit can be reclaimed once Circle has attested the burn, five days after it', 409);461    const facts = s.reclaimableAt ? b : await solBurnFacts(p, b).catch(() => null);462    if (facts && facts !== b) p = await save(p, facts);463    const at = (facts ?? b).sol?.reclaimableAt;464    if (!at) throw new UserError('rpc_degraded', 'the event account could not be read; try again', 503);465    if (now() / 1000 < at) throw new UserError('reclaim_not_yet', `the program releases this deposit five days after the burn: on ${new Date(at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`, 409);466    const st = await signer.status(true);467    if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);468    const me = p.from_addr, bridge = bridgeOf(p.intent!), ceiling = BigInt(st.policy.maxFee[n.id] ?? '0');469    await cctpSolGuards(n, me);470    const ev = (await rpcQuorum<{ value: Acct }>(n.rpc, 'getAccountInfo', [s.eventAccount, { encoding: 'base64', commitment: 'confirmed' }], (x, y) => (x.value === null) === (y.value === null) && x.value?.lamports === y.value?.lamports)).value;471    if (!ev || !Array.isArray(ev.data)) throw new UserError('bad_state', 'the event account no longer exists: its deposit was already returned', 409);472    if (ev.owner !== CCTP_SOL_MESSAGE_TRANSMITTER || decodeCctpSolEventAccount(new Uint8Array(Buffer.from(ev.data[0], 'base64'))).rentPayer !== me) throw new UserError('bad_state', 'this event account does not belong to the wallet', 409);473    const [bh, price0] = await Promise.all([blockhash(n), cuPrice(n, me)]);474    const g0: SolBudget = { recentBlockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight, computeUnitLimit: CCTP_SOL_CU.reclaimMax, computeUnitPrice: '0' };475    let probe; try { probe = buildCctpSolReclaimMessage(bridge, s.eventAccount, b.message, b.attestation, g0, ceiling); } catch (e) { throw new UserError('simulation_failed', (e as Error).message, 422); }476    const sim = await simulate(n, unsigned(probe.msg), { sigVerify: false });477    if (sim.err) throw new UserError(/EventAccountWindowNotExpired|window not expired/i.test((sim.logs ?? []).join(' ')) ? 'reclaim_not_yet' : 'simulation_failed', `the reclaim would fail right now (${simError(sim)})`, 422);478    const limit = Math.min(CCTP_SOL_CU.reclaimMax, Math.max(CCTP_SOL_CU.reclaimMin, Math.ceil((sim.unitsConsumed ?? CCTP_SOL_CU.reclaimMax) * 1.2) + 15_000));479    let price = price0; if (SOL_SIGNATURE_FEE + priorityOf(limit, price) > ceiling) price = ((ceiling - SOL_SIGNATURE_FEE) * 1_000_000n) / BigInt(limit);480    const budget: SolBudget = { ...g0, computeUnitLimit: limit, computeUnitPrice: price.toString() };481    try { buildCctpSolReclaimMessage(bridge, s.eventAccount, b.message, b.attestation, budget, ceiling); } catch (e) { throw new UserError('simulation_failed', (e as Error).message, 422); }482    p = await save(p, { ...(facts ?? b), sol: { ...(facts ?? b).sol, deposit: String(ev.lamports), reclaim: { state: 'approval', budget, expiresAt: now() + 50_000 } } });483    const challenge = new Uint8Array(createHash('sha256').update(cctpSolReclaimBytes(p.id, s.eventAccount, b.messageHash, budget)).digest());484    const creds = await listCreds();485    const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge, timeout: 45_000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });486    return { proposal: view(p), options, deposit: String(ev.lamports), networkFee: (SOL_SIGNATURE_FEE + priorityOf(limit, price)).toString() };487  });488  app.post('/api/v1/bridge/proposals/:id/reclaim', async (req) => {489    const body = z.object({ assertion: z.record(z.string(), z.unknown()) }).parse(req.body);490    let p = await load((req.params as { id: string }).id);491    const b = p.bridge!, r = b.sol?.reclaim, n = getNetwork(p.network);492    if (!r || r.state !== 'approval' || r.expiresAt < now() || !b.message || !b.attestation) throw new UserError('bad_state', 'ask for a new approval first', 409);493    const signed = await signer.bridgeReclaim({ proposalId: p.id, message: b.message, attestation: b.attestation, sol: r.budget, assertion: body.assertion });494    if (signed.eventAccount !== b.sol!.eventAccount) throw new UserError('hash_mismatch', 'the signer answered for another event account', 500);495    if (config.broadcast) { // last gate: the signed bytes must close the account now, signature checked496      const sim = await simulate(n, signed.raw, { sigVerify: true }).catch(() => null);497      if (sim?.err) { p = await save(p, { ...b, sol: { ...b.sol, reclaim: { ...r, state: 'failed', error: `the reclaim would fail now (${simError(sim)}); it was not sent` } } }); return view(p); }498    }499    p = await save(p, { ...b, sol: { ...b.sol, reclaim: { ...r, state: 'sent', txid: signed.txid, raw: signed.raw, sentAt: now(), lastBroadcastAt: now(), error: null } } });500    await audit('bridge.reclaim_signed', 'owner', { id: p.id, txid: signed.txid, eventAccount: signed.eventAccount });501    if (config.broadcast) await adapterFor(n.id).broadcast(signed.raw).catch(() => {});502    return view(p);503  });504505  // The wallet's own USDC token account on Solana: where USDC burned on an EVM network is minted. It must exist before the burn.506  app.get('/api/v1/bridge/solana/token-account', async () => {507    const n = SOL(), st = await signer.status(), me = addressFor(st, n.id), t = await solTokenAccount(n, me);508    return { owner: me, tokenAccount: t.address, exists: t.exists, rent: t.rent.toString(), rentDisplay: formatUnits(t.rent, 9), symbol: 'SOL' };509  });510  app.post('/api/v1/bridge/solana/token-account/options', async () => {511    const n = SOL(), st = await signer.status(true);512    if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);513    const me = addressFor(st, n.id), ceiling = BigInt(st.policy.maxFee[n.id] ?? '0');514    await cctpSolGuards(n, me);515    const t = await solTokenAccount(n, me);516    if (t.exists) throw new UserError('bad_state', 'the wallet already has its USDC token account on Solana', 409);517    if (t.rent > RENT_TOKEN_ACCOUNT) throw new UserError('rent_changed', 'Solana\'s rent rate is above the reviewed one', 503);518    const [bh, price0, lamports, keep] = await Promise.all([blockhash(n), cuPrice(n, me), lamportsOf(n, me), rent(n, 0)]);519    const t0 = Math.floor(now() / 1000), limit = 40_000;520    let price = price0; const room = ceiling - SOL_SIGNATURE_FEE - RENT_TOKEN_ACCOUNT;521    if (room < 0n) throw new UserError('fee_ceiling', 'the token-account deposit does not fit under the signer\'s fee ceiling for Solana', 422);522    if (priorityOf(limit, price) > room) price = (room * 1_000_000n) / BigInt(limit);523    const networkFee = SOL_SIGNATURE_FEE + priorityOf(limit, price);524    const request: SolTokenAccountRequest = { v: 1, requestId: randomUUID(), keyId: st.keyId, network: 'solana', chainRef: n.chainRef, owner: me, mint: cctpSolUsdc().id, tokenAccount: t.address, maxFee: (networkFee + RENT_TOKEN_ACCOUNT).toString(),525      createdAt: t0, expiresAt: t0 + 50, policyVersion: st.policy.version, sol: { recentBlockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight, computeUnitLimit: limit, computeUnitPrice: price.toString() } };526    if (lamports < networkFee + t.rent + keep) throw new UserError('insufficient_gas', `not enough SOL: opening the token account needs ${formatUnits(networkFee, 9)} SOL of fee and a deposit of ${formatUnits(t.rent, 9)} SOL (and ${formatUnits(keep, 9)} SOL must remain in the wallet)`);527    let built; try { built = buildSolTokenAccountMessage(request); } catch (e) { throw new UserError('simulation_failed', (e as Error).message, 422); }528    const sim = await simulate(n, unsigned(built.msg), { sigVerify: false });529    if (sim.err) throw new UserError('simulation_failed', `creating the token account would fail right now (${simError(sim)})`, 422);530    const hash = solTokenAccountHash(request);531    for (const [k, v] of pendingAta) if (v.request.expiresAt * 1000 < now()) pendingAta.delete(k);532    pendingAta.set(request.requestId, { request, rent: t.rent, networkFee });533    await audit('bridge.token_account_prepared', 'owner', { requestId: request.requestId, tokenAccount: t.address, rent: t.rent.toString(), networkFee: networkFee.toString(), hash: toHex(hash) });534    const creds = await listCreds();535    const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: 45_000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });536    if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');537    return { requestId: request.requestId, owner: me, tokenAccount: t.address, rent: t.rent.toString(), rentDisplay: formatUnits(t.rent, 9), networkFee: networkFee.toString(), networkFeeDisplay: formatUnits(networkFee, 9), symbol: 'SOL', expiresAt: request.expiresAt, options };538  });539  app.post('/api/v1/bridge/solana/token-account', async (req) => {540    const body = z.object({ requestId: z.uuid(), assertion: z.record(z.string(), z.unknown()) }).parse(req.body);541    const pend = pendingAta.get(body.requestId), n = SOL();542    if (!pend || pend.request.expiresAt * 1000 < now()) throw new UserError('expired', 'approval window expired; start again', 410);543    pendingAta.delete(body.requestId);544    const signed = await signer.solTokenAccount(pend.request, body.assertion);545    if (signed.hash !== toHex(solTokenAccountHash(pend.request))) throw new UserError('hash_mismatch', 'signer returned a different request hash', 500);546    await audit('bridge.token_account_signed', 'owner', { requestId: body.requestId, tokenAccount: signed.tokenAccount, txid: signed.txid });547    if (config.broadcast) {548      const sim = await simulate(n, signed.raw, { sigVerify: true }).catch(() => null);549      if (sim?.err) throw new UserError('simulation_failed', `creating the token account would fail now (${simError(sim)}); it was not sent`, 422);550      await adapterFor(n.id).broadcast(signed.raw);551    }552    return { txid: signed.txid, explorer: n.explorerTx + signed.txid, tokenAccount: signed.tokenAccount, sent: config.broadcast };553  });554}555
Files main
Menu

Theme