SPB Git

Repository Public

transfers.ts 20.4 KB 242 lines typescript

1import { randomUUID } from 'node:crypto';2import type { FastifyInstance } from 'fastify';3import { generateAuthenticationOptions } from '@simplewebauthn/server';4import { z } from 'zod';5import {6  assertTransition, buildBtcTx, buildEvmTx, buildSolMessage, checkPolicy, formatUnits, getAsset, getNetwork, IntentSchema, intentHash,7  normalizeAddress, parseUnits, pluginFor, toB64u, toHex, type Intent, type ProposalState,8} from '@spw/core';9import { adapterFor } from './chains/index.ts';10import { simulateSol } from './chains/sol.ts';11import { config, UserError } from './config.ts';12import { audit, sql } from './db.ts';13import { listCreds } from './auth.ts';14import { addressFor, signer } from './signer-client.ts';15import type { BridgeState } from './bridge.ts';16import { xswapView, type XswapState } from './xswap-state.ts';1718export interface ProposalRow {19  id: string; state: ProposalState; version: number; network: string; asset: string; symbol: string; decimals: number; amount: string;20  from_addr: string; to_addr: string; note: string; memo: string | null; aux: Record<string, unknown> | null;21  kind: 'transfer' | 'swap' | 'bridge' | 'xswap' | 'gmx' /* v8: a GMX order action, followed by derivatives/gmx-exec.ts */; xswap?: XswapState | null; bridge: BridgeState | null; swap: { provider: string; toAsset: string; toSymbol: string; toDecimals: number; expectedOut: string; minOut: string; slippageBps: number; needsApproval: boolean; simulated: boolean } | null;22  pre_raw: string | null; pre_txid: string | null; pre_state: string | null; out_amount: string | null; fee_estimate: string | null; max_fee: string | null; warnings: string[];23  intent: Intent | null; intent_hash: string | null; raw: string | null; txid: string | null; block_ref: string | null; confirmations: number; error: string | null;24  created_at: Date; updated_at: Date; expires_at: Date | null; broadcast_at: Date | null; last_broadcast_at: Date | null; finalized_at: Date | null;25}2627export function view(p: ProposalRow) {28  const n = getNetwork(p.network);29  return {30    id: p.id, state: p.state, network: p.network, networkName: n.name, asset: p.asset, symbol: p.symbol, decimals: p.decimals,31    amount: p.amount, amountDisplay: formatUnits(BigInt(p.amount), p.decimals), from: p.from_addr, to: p.to_addr, note: p.note, memo: p.memo,32    feeEstimate: p.fee_estimate, maxFee: p.max_fee, feeSymbol: n.native.symbol, feeDecimals: n.native.decimals,33    feeDisplay: p.fee_estimate ? formatUnits(BigInt(p.fee_estimate), n.native.decimals) : null,34    maxFeeDisplay: p.max_fee ? formatUnits(BigInt(p.max_fee), n.native.decimals) : null,35    warnings: p.warnings, intentHash: p.intent_hash, txid: p.txid, explorer: p.txid ? n.explorerTx + p.txid : null,36    confirmations: p.confirmations, error: p.error, createdAt: p.created_at, updatedAt: p.updated_at, expiresAt: p.expires_at,37    broadcastAt: p.broadcast_at, finalizedAt: p.finalized_at,38    kind: p.kind ?? 'transfer',39    swap: p.swap ? { provider: p.swap.provider, toAsset: p.swap.toAsset, toSymbol: p.swap.toSymbol, slippageBps: p.swap.slippageBps, needsApproval: p.swap.needsApproval, simulated: p.swap.simulated,40      expectedOut: p.swap.expectedOut, expectedOutDisplay: formatUnits(BigInt(p.swap.expectedOut), p.swap.toDecimals), minOut: p.swap.minOut, minOutDisplay: formatUnits(BigInt(p.swap.minOut), p.swap.toDecimals),41      received: p.out_amount, receivedDisplay: p.out_amount ? formatUnits(BigInt(p.out_amount), p.swap.toDecimals) : null,42      approvalTxid: p.pre_txid, approvalState: p.pre_raw ? (p.pre_state ?? 'pending') : null,43      /** provider-specific facts shown on the review sheet (Solana: licence label, venues, priority fee, token-account rent) */44      detail: (p.swap as { detail?: Record<string, unknown> }).detail ?? null } : null,45    bridge: p.bridge ? bridgeView(p, p.bridge) : null,46    xswap: p.xswap ? xswapView(p, p.xswap) : null,47  };48}4950/** A cross-chain transfer as shown to the owner: route, Circle's fee, progress of the second leg. Message bytes stay server-side. */51function bridgeView(p: ProposalRow, b: BridgeState) {52  const d = getNetwork(b.toNetwork);53  // one word for the whole transfer: the burn follows the proposal state, then the mint has its own progress54  const stage = ['failed', 'expired', 'rejected', 'replaced'].includes(p.state) ? 'failed' : p.state === 'pending_approval' ? 'review'55    : b.phase === 'burn' ? 'burning' : b.phase === 'attesting' ? 'attesting' : b.phase === 'ready' ? 'ready' : b.phase === 'mint_sent' ? 'minting' : b.phase === 'mint_failed' ? 'mint_failed' : 'done';56  return { provider: b.provider, toNetwork: b.toNetwork, toNetworkName: d.name, speed: b.speed, expectedSeconds: b.expectedSeconds, stage, phase: b.phase,57    cctpMaxFee: b.maxFee, cctpMaxFeeDisplay: formatUnits(BigInt(b.maxFee), p.decimals), minReceiveDisplay: formatUnits(BigInt(p.amount) - BigInt(b.maxFee), p.decimals),58    feeExecutedDisplay: b.feeExecuted !== undefined ? formatUnits(BigInt(b.feeExecuted), p.decimals) : null, received: b.phase === 'done' ? (p.out_amount ?? b.minted ?? null) : null,59    receivedDisplay: b.phase === 'done' && (p.out_amount ?? b.minted) ? formatUnits(BigInt((p.out_amount ?? b.minted)!), p.decimals) : null,60    destGasSymbol: d.native.symbol, destGasNeedDisplay: formatUnits(BigInt(b.destGasNeed), d.native.decimals), needsApproval: b.needsApproval, simulated: b.simulated,61    approvalTxid: p.pre_txid, approvalState: p.pre_raw ? (p.pre_state ?? 'pending') : null,62    hold: b.hold ?? null, holdDetail: b.holdDetail ?? null, irisStatus: b.irisStatus ?? null, delayReason: b.delayReason ?? null, polls: b.polls, attested: !!b.message,63    mintTxid: b.mintTxid ?? b.mint?.txid ?? null, mintExplorer: (b.mintTxid ?? b.mint?.txid) ? d.explorerTx + (b.mintTxid ?? b.mint!.txid) : null, mintedBy: b.mintedBy ?? null,64    canFinish: !['failed', 'review', 'done', 'burning'].includes(stage), needsPasskey: b.phase === 'mint_failed',65    sol: b.sol ? solBridgeView(p, b) : null };66}6768/** v8 — the Solana side of a Solana ↔ EVM transfer: the refundable event-account deposit and its reclaim, or the token account credited. */69function solBridgeView(p: ProposalRow, b: BridgeState) {70  const s = b.sol!, sn = getNetwork('solana'), r = s.reclaim, source = getNetwork(p.network).family === 'sol';71  const burned = ['confirming', 'finalized', 'reorged'].includes(p.state);72  // when the deposit comes back: five days after the event account was created (before the burn is mined: an estimate from now)73  const reclaimableAt = s.reclaimableAt ?? (source && !['failed', 'expired', 'rejected', 'replaced'].includes(p.state) ? Math.floor(Date.now() / 1000) + 5 * 86400 : null);74  const reclaimState = !source || !s.deposit ? null : r?.state === 'done' ? 'done' : r?.state === 'sent' ? 'sent' : !burned || !s.eventAccount ? 'pending'75    : s.reclaimableAt && Date.now() / 1000 >= s.reclaimableAt && b.message ? (r?.state === 'failed' ? 'failed' : 'available') : 'locked';76  return { role: source ? 'source' : 'destination', symbol: sn.native.symbol, recipient: s.recipient ?? null, tokenAccount: s.tokenAccount ?? null,77    nonceRentDisplay: s.nonceRent ? formatUnits(BigInt(s.nonceRent), 9) : null,78    eventAccount: s.eventAccount ?? null, deposit: s.deposit ?? null, depositDisplay: s.deposit ? formatUnits(BigInt(s.deposit), 9) : null,79    reclaimableAt: reclaimableAt ? new Date(reclaimableAt * 1000).toISOString() : null, reclaimEstimated: !s.reclaimableAt, reclaimState,80    reclaimError: r?.state === 'failed' ? (r.error ?? null) : null, reclaimTxid: r?.txid ?? null, reclaimExplorer: r?.txid ? sn.explorerTx + r.txid : null,81    reclaimedDisplay: r?.received ? formatUnits(BigInt(r.received), 9) : null };82}8384/** Optimistic state change: only succeeds from the expected state and version. */85export async function move(p: ProposalRow, to: ProposalState, patch: Record<string, unknown> = {}): Promise<ProposalRow> {86  assertTransition(p.state, to);87  const [r] = await sql<ProposalRow[]>`update proposals set ${sql({ ...patch, state: to, version: p.version + 1, updated_at: new Date() } as never)}88    where id = ${p.id} and state = ${p.state} and version = ${p.version} returning *`;89  if (!r) throw new UserError('conflict', 'proposal changed concurrently; reload', 409);90  await audit(`transfer.${to}`, 'system', { id: p.id, network: p.network, symbol: p.symbol, amount: p.amount, to: p.to_addr, txid: r.txid, hash: r.intent_hash, error: r.error });91  return r;92}9394const load = async (id: string): Promise<ProposalRow> => {95  if (!z.uuid().safeParse(id).success) throw new UserError('not_found', 'proposal not found', 404);96  const [p] = await sql<ProposalRow[]>`select * from proposals where id = ${id}`;97  if (!p) throw new UserError('not_found', 'proposal not found', 404);98  return p;99};100101async function destinationWarnings(network: string, to: string): Promise<string[]> {102  const w: string[] = [];103  const known = await sql<{ address: string }[]>`select address from address_book where network = ${network}104    union select to_addr from proposals where network = ${network} and state in ('finalized','confirming','broadcast')`;105  if (!known.some((k) => k.address === to)) {106    w.push('new_destination');107    const sig = (a: string) => a.slice(0, 6).toLowerCase() + a.slice(-4).toLowerCase();108    if (known.some((k) => k.address !== to && sig(k.address) === sig(to))) w.push('address_poisoning_suspected');109  }110  return w;111}112113const Create = z.strictObject({ network: z.string().max(20), asset: z.string().max(80), to: z.string().min(1).max(130), amount: z.string().min(1).max(80), note: z.string().max(80).default(''), memo: z.string().max(120).optional() });114115export function transferRoutes(app: FastifyInstance): void {116  // draft + validation + simulation + policy pre-check117  app.post('/api/v1/transfers/proposals', async (req) => {118    const idem = String(req.headers['idempotency-key'] ?? '');119    if (!/^[A-Za-z0-9-]{16,64}$/.test(idem)) throw new UserError('idempotency', 'Idempotency-Key header required');120    const [dup] = await sql<ProposalRow[]>`select * from proposals where idempotency_key = ${idem}`;121    if (dup) return view(dup);122    const b = Create.parse(req.body);123    const n = getNetwork(b.network);124    const asset = (() => { try { return getAsset(n.id, b.asset); } catch { throw new UserError('unsupported_asset', 'asset is not supported on this network'); } })();125    let to: string, amount: bigint;126    try { to = normalizeAddress(n.id, b.to); } catch (e) { throw new UserError('bad_address', `invalid ${n.name} address: ${(e as Error).message}`); }127    try { amount = parseUnits(b.amount, asset.decimals); } catch (e) { throw new UserError('bad_amount', (e as Error).message); }128    if (amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');129    let memo: string | undefined;130    if (b.memo !== undefined && b.memo !== '') {131      if (!n.memo) throw new UserError('bad_memo', `${n.name} transfers do not carry a memo`);132      if (n.memo === 'uint32' && !/^(0|[1-9]\d{0,9})$/.test(b.memo)) throw new UserError('bad_memo', 'the destination tag must be a whole number');133      if (n.memo === 'uint32' && Number(b.memo) > 0xffffffff) throw new UserError('bad_memo', 'destination tag out of range');134      memo = b.memo;135    }136    const st = await signer.status(true);137    const from = addressFor(st, n.id);138    if (to === from) throw new UserError('bad_address', 'destination is this wallet');139    const prep = await adapterFor(n.id).prepare({ asset: asset.id, from, to, amount, memo });140    const now = Math.floor(Date.now() / 1000);141    const probe = { network: n.id, symbol: asset.symbol, amount: amount.toString(), maxFee: prep.maxFee.toString(), to, policyVersion: st.policy.version, createdAt: now, expiresAt: now + 30 } as Intent;142    const verdict = checkPolicy(st.policy, probe, BigInt(st.spentToday[`${n.id}:${asset.symbol}`] ?? '0'), now, st.halted);143    if (!verdict.ok) throw new UserError('policy', verdict.reason, 422);144    const warnings = [...prep.warnings, ...(await destinationWarnings(n.id, to))];145    const id = randomUUID();146    const [p] = await sql<ProposalRow[]>`insert into proposals (id, idempotency_key, state, network, asset, symbol, decimals, amount, from_addr, to_addr, note, memo, fee_estimate, max_fee, warnings, expires_at)147      values (${id}, ${idem}, 'draft', ${n.id}, ${asset.id}, ${asset.symbol}, ${asset.decimals}, ${amount.toString()}, ${from}, ${to}, ${b.note}, ${memo ?? null}, ${prep.feeEstimate.toString()}, ${prep.maxFee.toString()}, ${sql.json(warnings)}, now() + interval '15 minutes') returning *`;148    await audit('transfer.draft', 'owner', { id, network: n.id, symbol: asset.symbol, amount: amount.toString(), to });149    return view(p!);150  });151152  app.get('/api/v1/transfers/proposals/:id', async (req) => view(await load((req.params as { id: string }).id)));153  app.get('/api/v1/transfers/:id/status', async (req) => { const v = view(await load((req.params as { id: string }).id)); return { id: v.id, state: v.state, txid: v.txid, confirmations: v.confirmations, error: v.error, explorer: v.explorer }; });154155  // freeze the exact chain parameters, re-simulate, and issue the approval challenge = intent hash156  app.post('/api/v1/transfers/proposals/:id/request-approval', async (req) => {157    let p = await load((req.params as { id: string }).id);158    if (p.state !== 'draft' && p.state !== 'pending_approval') throw new UserError('bad_state', `proposal is ${p.state}`, 409);159    if (p.kind === 'swap') throw new UserError('bad_state', 'a swap is prepared once; start a new one', 409);160    if (p.kind === 'bridge') throw new UserError('bad_state', 'a cross-chain transfer is prepared once; start a new one', 409);161    if (p.kind === 'xswap') throw new UserError('bad_state', 'a cross-chain swap is prepared once; start a new one', 409);162    const n = getNetwork(p.network), st = await signer.status(true);163    if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);164    // One transfer in flight per network: a second one prepared before the first is confirmed would reuse the same165    // nonce / sequence / coins, and on some chains (Sui) could even lock those coins until the epoch ends.166    const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${p.network} and id <> ${p.id} and state in ('approved', 'signed', 'broadcast') limit 1`;167    if (busy) throw new UserError('pending_tx', 'a previous transfer on this network is not confirmed yet; wait for it before sending another', 409);168    const prep = await adapterFor(n.id).prepare({ asset: p.asset, from: p.from_addr, to: p.to_addr, amount: BigInt(p.amount), memo: p.memo ?? undefined });169    const now = Math.floor(Date.now() / 1000);170    const intent = IntentSchema.parse({171      v: 1, proposalId: p.id, keyId: st.keyId, network: n.id, chainRef: n.chainRef, asset: p.asset, symbol: p.symbol, decimals: p.decimals,172      amount: p.amount, from: p.from_addr, to: p.to_addr, maxFee: prep.maxFee.toString(), createdAt: now, expiresAt: now + prep.ttlSec,173      policyVersion: st.policy.version, ...(p.memo ? { memo: p.memo } : {}), ...prep.section,174    });175    // The API builds the same bytes the signer will rebuild; unsupported shapes fail here, before approval.176    try {177      if (n.family === 'evm') buildEvmTx(intent); else if (n.family === 'btc') buildBtcTx(intent, prep.aux);178      else if (n.family === 'sol') { buildSolMessage(intent); await simulateSol(n, intent); }179      else pluginFor(n).build(intent, n, prep.aux);180    }181    catch (e) { if (e instanceof UserError) throw e; throw new UserError('simulation_failed', (e as Error).message); }182    const verdict = checkPolicy(st.policy, intent, BigInt(st.spentToday[`${n.id}:${p.symbol}`] ?? '0'), now, st.halted);183    if (!verdict.ok) throw new UserError('policy', verdict.reason, 422);184    const hash = intentHash(intent);185    const patch = { aux: prep.aux ? sql.json(prep.aux as never) : null, intent: sql.json(intent as never), intent_hash: toHex(hash), max_fee: prep.maxFee.toString(), fee_estimate: prep.feeEstimate.toString(), expires_at: new Date(intent.expiresAt * 1000) };186    if (p.state === 'draft') p = await move(p, 'pending_approval', patch);187    else { // re-issue: a new immutable version replaces the previous challenge188      const [r] = await sql<ProposalRow[]>`update proposals set ${sql({ ...patch, version: p.version + 1, updated_at: new Date() } as never)} where id = ${p.id} and state = 'pending_approval' and version = ${p.version} returning *`;189      if (!r) throw new UserError('conflict', 'proposal changed concurrently; reload', 409);190      p = r;191    }192    const creds = await listCreds();193    const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: prep.ttlSec * 1000,194      allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });195    if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');196    return { proposal: view(p), intent, options };197  });198199  // The HTTP call is not the approval: the passkey assertion over the intent hash is, and only the signer judges it.200  app.post('/api/v1/transfers/proposals/:id/approve', async (req) => {201    const body = z.object({ assertion: z.record(z.string(), z.unknown()) }).parse(req.body);202    let p = await load((req.params as { id: string }).id);203    if (p.state !== 'pending_approval' || !p.intent) throw new UserError('bad_state', `proposal is ${p.state}`, 409);204    if (p.expires_at && p.expires_at.getTime() < Date.now()) { await move(p, 'expired', { error: 'approval window expired' }); throw new UserError('expired', 'approval window expired; start again', 410); }205    // one transfer or swap in flight per network, checked again at the moment of approval206    const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${p.network} and id <> ${p.id} and state in ('approved', 'signed', 'broadcast') limit 1`;207    if (busy) throw new UserError('pending_tx', 'a previous transfer or swap on this network is not confirmed yet', 409);208    // a cross-chain mint being signed or sent on this network uses the same nonce space209    const [minting] = await sql<{ id: string }[]>`select id from proposals where kind = 'bridge' and bridge->>'toNetwork' = ${p.network} and bridge->>'phase' = 'mint_sent' and state in ('confirming', 'finalized', 'reorged') limit 1`;210    if (minting) throw new UserError('pending_tx', 'a cross-chain transfer is being received on this network; wait for it', 409);211    p = await move(p, 'approved');212    let signed: { raw: string; txid: string; hash: string; pre?: { raw: string; txid: string } };213    try { signed = await signer.sign(p.intent, body.assertion, p.aux ?? undefined); }214    catch (e) { await move(p, 'failed', { error: (e as Error).message.slice(0, 300) }); throw e; }215    if (signed.hash !== p.intent_hash) { await move(p, 'failed', { error: 'signer hash mismatch' }); throw new UserError('hash_mismatch', 'signer returned a different intent hash', 500); }216    p = await move(p, 'signed', { raw: signed.raw, txid: signed.txid, ...(signed.pre ? { pre_raw: signed.pre.raw, pre_txid: signed.pre.txid } : {}) });217    if (p.kind === 'swap') { const { advanceSwap } = await import('./swap-exec.ts'); return view(await advanceSwap(p)); }218    if (p.kind === 'bridge') { const { advanceBridgeBurn } = await import('./bridge.ts'); return view(await advanceBridgeBurn(p)); }219    if (p.kind === 'xswap') { const { advanceXswapSend } = await import('./xswap.ts'); return view(await advanceXswapSend(p)); }220    return view(await broadcastNow(p));221  });222223  app.post('/api/v1/transfers/proposals/:id/reject', async (req) => {224    const p = await load((req.params as { id: string }).id);225    if (p.state !== 'draft' && p.state !== 'pending_approval') throw new UserError('bad_state', `proposal is ${p.state}`, 409);226    return view(await move(p, 'rejected', { error: 'rejected by owner' }));227  });228}229230/** Relay signed bytes. A returned hash is not success: state stays "broadcast" until the chain confirms. */231export async function broadcastNow(p: ProposalRow): Promise<ProposalRow> {232  if (!p.raw) return p;233  if (!config.broadcast) return move(p, 'broadcast', { broadcast_at: new Date(), last_broadcast_at: new Date(), error: 'broadcast disabled (test mode)' });234  try {235    await adapterFor(p.network).broadcast(p.raw);236    return await move(p, 'broadcast', { broadcast_at: new Date(), last_broadcast_at: new Date(), error: null });237  } catch (e) {238    await sql`update proposals set error = ${'broadcast attempt failed: ' + (e as Error).message.slice(0, 200)}, updated_at = now() where id = ${p.id}`;239    return (await sql<ProposalRow[]>`select * from proposals where id = ${p.id}`)[0]!;240  }241}242
Files main
Menu

Theme