SPB Git

Repository Public

xswap.ts 36.6 KB 417 lines typescript

1// Native cross-chain swaps through THORChain (docs/swap/providers/thorchain.md): one transfer of the source asset to the2// current Asgard vault with the memo `=:ASSET:DEST:LIMIT`, always from the wallet to the wallet's own address.3// The API prepares, cross-checks, relays and tracks. It cannot make the signer pay another vault: the signer rebuilds the memo4// and the transaction itself and asks its own THORNode operators whether the vault is genuine (services/isolated-signer/src/vaults.ts).5import { createHash, randomUUID } from 'node:crypto';6import type { FastifyInstance } from 'fastify';7import { generateAuthenticationOptions } from '@simplewebauthn/server';8import { decodeEventLog, encodeFunctionData, erc20Abi, hexToBigInt, hexToNumber, numberToHex, pad, toEventSelector } from 'viem';9import { z } from 'zod';10import {11  buildThorMemo, buildXswapBtcTx, buildXswapEvmTxs, checkPolicy, checkThorVault, fetchThorViews, formatUnits, fromThorAmount, getNetwork, IntentSchema, intentHash, parseUnits, pickThorVault,12  THOR_ASSETS, THOR_EXPIRY_SECONDS, THOR_MIN_OPERATORS, THOR_ROUTER_ABI, THOR_ROUTERS, thorAsset, THORNODE_OPERATORS, thorOpReturnSize, thorTxId, thorVaultAddress, toB64u, toHex, toThorAmount,13  utxoPaymentTo, verifyXswap, xswapDepositData, type Intent, type NetworkDef, type ThorAnswer, type ThorAsset, type ThorGet, type ThorVaultVerdict, type ThorVaultWant,14} from '@spw/core';15import { listCreds } from './auth.ts';16import { sourceOf } from './chains/btc.ts';17import { adapterFor } from './chains/index.ts';18import { rpcAny, rpcOne, rpcQuorum } from './chains/rpc.ts';19import { config, UserError } from './config.ts';20import { audit, sql } from './db.ts';21import { addressFor, signer } from './signer-client.ts';22import { broadcastNow, move, view, type ProposalRow } from './transfers.ts';23import { xswapNext, xswapTerminal, type OutboundSeen, type ThorStatus, type XswapObs, type XswapState } from './xswap-state.ts';2425/** Second line of defence: the vault must hold, on-chain, at least this many times the amount in the source asset. */26export const EVIDENCE_MULTIPLE = 100n;27/** Quotes of two operators must agree on the expected output within this. */28const QUOTE_TOLERANCE_BPS = 100n;29const ZERO = '0x0000000000000000000000000000000000000000';30const TRANSFER_OUT = toEventSelector('TransferOut(address,address,address,uint256,string)');31const sha = (hexStr: string) => createHash('sha256').update(Buffer.from(hexStr.slice(2), 'hex')).digest('hex');32const eq = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();33const now = () => Date.now();3435export const thorGet: ThorGet = async (url) => {36  // one attempt, eight seconds: the operators are asked in parallel and a slow one simply does not count37  const r = await fetch(url, { headers: { accept: 'application/json', 'x-client-id': 'sp-wallet' }, signal: AbortSignal.timeout(8000), redirect: 'error' });38  const t = await r.text();39  if (t.length > 600_000) throw new Error('response too large');40  let j: unknown; try { j = JSON.parse(t); } catch { throw new Error(`non-JSON answer (HTTP ${r.status})`); }41  if (!r.ok) throw Object.assign(new Error(typeof (j as { message?: unknown })?.message === 'string' ? (j as { message: string }).message.slice(0, 160) : `HTTP ${r.status}`), { status: r.status });42  return j;43};4445// ---------------------------------------------------------------------------------------------------------------------46// Quotes: asked from every operator with the wallet's own addresses; at least two must answer and agree.4748const D = z.string().regex(/^(0|[1-9]\d{0,38})$/);49const QuoteSchema = z.looseObject({ memo: z.string().min(5).max(250), expected_amount_out: D, expiry: z.number().int().positive(), recommended_min_amount_in: D, dust_threshold: D.optional(), router: z.string().optional(),50  inbound_address: z.string().max(160), max_streaming_quantity: z.number().int().nonnegative().optional(), total_swap_seconds: z.number().int().nonnegative().optional(),51  fees: z.looseObject({ asset: z.string().max(120), affiliate: D, outbound: D, liquidity: D }) });52export type ThorQuote = z.infer<typeof QuoteSchema>;53export const toleranceLimit = (expected: bigint, bps: number) => (expected * BigInt(10_000 - bps)) / 10_000n;5455export interface AgreedQuote { operators: string[]; expected: bigint; limit: bigint; outboundFee: bigint; liquidityFee: bigint; minAmountIn: bigint; seconds: number | null; memo: string }56/**57 * Compare the operators' quotes with what this wallet builds. Each quote's own `memo` must be, byte for byte, the memo this58 * wallet writes for that quote's numbers (same asset spelling, same destination, limit = expected less the tolerance): this59 * proves THORChain reads our memo as the swap we mean. The limit kept is the one of the LOWEST quote.60 */61export function agreeQuotes(quotes: { operator: string; quote: ThorQuote }[], dst: ThorAsset, destination: string, bps: number, router: string | null): AgreedQuote {62  if (quotes.length < 2) throw new UserError('provider_unavailable', `fewer than two THORNode operators returned a quote (${quotes.length}); nothing was prepared`, 503);63  let lo: bigint | null = null, hi = 0n, outboundFee = 0n, liquidityFee = 0n, minIn = 0n, seconds: number | null = null;64  for (const { operator, quote: q } of quotes) {65    const bad = (why: string) => new UserError('provider_bad_answer', `THORNode operator ${operator}: ${why}; nothing was prepared`, 502);66    const expected = BigInt(q.expected_amount_out);67    if (q.fees.asset !== dst.notation) throw bad('fees are not in the output asset');68    if (BigInt(q.fees.affiliate) !== 0n) throw bad('quote carries an affiliate fee');69    if ((q.max_streaming_quantity ?? 1) > 1) throw new UserError('amount_would_stream', 'THORChain would split this amount into a streaming swap, which this wallet does not execute', 422);70    if (router === null ? q.router !== undefined : !q.router || !eq(q.router, router)) throw bad('quote names another router');71    const limit = toleranceLimit(expected, bps);72    if (limit <= 0n) throw new UserError('no_route', 'the amount is too small for this pair', 422);73    if (buildThorMemo(dst, destination, limit) !== q.memo) throw bad(`the memo THORChain expects (${q.memo.slice(0, 90)}) is not the memo this wallet builds`);74    if (lo === null || expected < lo) lo = expected; if (expected > hi) hi = expected;75    if (BigInt(q.fees.outbound) > outboundFee) outboundFee = BigInt(q.fees.outbound);76    if (BigInt(q.fees.liquidity) > liquidityFee) liquidityFee = BigInt(q.fees.liquidity);77    if (BigInt(q.recommended_min_amount_in) > minIn) minIn = BigInt(q.recommended_min_amount_in);78    if (q.total_swap_seconds !== undefined && (seconds === null || q.total_swap_seconds > seconds)) seconds = q.total_swap_seconds;79  }80  if ((hi - lo!) * 10_000n > lo! * QUOTE_TOLERANCE_BPS) throw new UserError('quotes_disagree', 'THORNode operators disagree on the price by more than 1 %; nothing was prepared', 503);81  const limit = toleranceLimit(lo!, bps);82  return { operators: quotes.map((q) => q.operator), expected: lo!, limit, outboundFee, liquidityFee, minAmountIn: minIn, seconds, memo: buildThorMemo(dst, destination, limit) };83}8485export async function fetchQuotes(src: ThorAsset, dst: ThorAsset, amount1e8: bigint, destination: string, bps: number, get: ThorGet = thorGet): Promise<{ operator: string; quote: ThorQuote }[]> {86  const q = new URLSearchParams({ from_asset: src.notation, to_asset: dst.notation, amount: amount1e8.toString(), destination, liquidity_tolerance_bps: String(bps) });87  const ask = (ops: typeof THORNODE_OPERATORS) => Promise.all(ops.map(async (o) => {88    try { const p = QuoteSchema.safeParse(await get(`${o.base}/thorchain/quote/swap?${q}`)); return p.success ? { operator: o.id, quote: p.data } : { operator: o.id, error: 'malformed quote' }; }89    catch (e) { return { operator: o.id, error: (e as Error).message }; }90  }));91  let rs = await ask(THORNODE_OPERATORS);92  const good = (x: typeof rs) => x.flatMap((r) => ('quote' in r ? [r as { operator: string; quote: ThorQuote }] : []));93  // a node that lags answers "refusing quote on node with stale state" within milliseconds, a busy one times out: those (only) are asked once more94  if (good(rs).length < 2) { const again = rs.filter((r) => 'error' in r && /stale state|timeout|aborted|fetch failed/i.test(r.error as string)).map((r) => THORNODE_OPERATORS.find((o) => o.id === r.operator)!);95    if (again.length) { const second = await ask(again); rs = rs.map((r) => second.find((s) => s.operator === r.operator && 'quote' in s) ?? r); } }96  const ok = good(rs);97  if (ok.length < 2) {98    const why = rs.flatMap((r) => ('error' in r ? [r.error as string] : []));99    const refusal = why.find((m) => /dust|min swap amount|not enough asset|halt|paused|not available|less than price limit|too small/i.test(m));100    if (refusal) throw new UserError('no_route', `THORChain cannot do this swap right now: ${refusal.slice(0, 140)}`, 422);101  }102  return ok;103}104105/** The pool behind the memo spelling must be the pinned one: Available, and the only pool with that chain and ticker. */106async function poolGuard(a: ThorAsset, get: ThorGet): Promise<void> {107  const Pools = z.array(z.looseObject({ asset: z.string().max(140), status: z.string().max(30), short_code: z.string().max(10).optional() })).max(400);108  let seen = 0;109  const all = await Promise.all(THORNODE_OPERATORS.map((o) => get(`${o.base}/thorchain/pools`).then((j) => Pools.parse(j), () => null)));110  for (const pools of all) {111    if (!pools) continue;112    const own = pools.filter((p) => p.asset === a.notation);113    if (own.length !== 1 || own[0]!.status !== 'Available') throw new UserError('pool_unavailable', `the THORChain pool ${a.notation.slice(0, 20)} is not available right now`, 422);114    const same = a.memo.includes('.') ? pools.filter((p) => p.asset === a.memo || p.asset.startsWith(`${a.memo}-`)) : pools.filter((p) => p.short_code === a.memo);115    if (same.length !== 1 || same[0]!.asset !== a.notation) throw new UserError('pool_ambiguous', `THORChain now has another pool that the memo spelling ${a.memo} could designate; swaps to this asset are refused until reviewed`, 503);116    seen += 1;117  }118  if (seen >= 2) return;119  throw new UserError('provider_unavailable', 'THORChain pools could not be read from two operators', 503);120}121122// ---------------------------------------------------------------------------------------------------------------------123// Vault: the API's own unanimity check (the signer repeats it independently), then what the chain itself shows.124125export async function vaultCheck(want: ThorVaultWant, get: ThorGet = thorGet): Promise<ThorVaultVerdict> { return checkThorVault(await fetchThorViews(get), want); }126127/** What the wallet's own chain data sources say about the vault. Fakeable by someone who parks that much capital: a second line, not a proof. */128async function vaultEvidence(n: NetworkDef, src: ThorAsset, vault: string, amount: bigint): Promise<XswapState['evidence']> {129  let holds: bigint, transfersOut: number | null = null;130  if (n.family === 'evm') {131    const router = THOR_ROUTERS[n.id]!.address;132    holds = src.asset === 'native' ? hexToBigInt(await rpcQuorum<`0x${string}`>(n.rpc, 'eth_getBalance', [vault, 'latest'], (a, b) => hexToBigInt(a) * 2n > hexToBigInt(b) && hexToBigInt(b) * 2n > hexToBigInt(a)))133      : hexToBigInt(await rpcQuorum<`0x${string}`>(n.rpc, 'eth_call', [{ to: router, data: encodeFunctionData({ abi: THOR_ROUTER_ABI, functionName: 'vaultAllowance', args: [vault as `0x${string}`, src.asset as `0x${string}`] }) }, 'latest'], (a, b) => hexToBigInt(a) * 2n > hexToBigInt(b) && hexToBigInt(b) * 2n > hexToBigInt(a)));134    try { // recent outbound transfers the router recorded for this vault (TransferOut is emitted with msg.sender = the vault)135      const head = hexToNumber(await rpcAny<`0x${string}`>(n.rpc, 'eth_blockNumber', []));136      const logs = await rpcAny<unknown[]>(n.rpc, 'eth_getLogs', [{ address: router, fromBlock: numberToHex(head - 3000), toBlock: 'latest', topics: [TRANSFER_OUT, pad(vault as `0x${string}`).toLowerCase()] }]);137      transfersOut = logs.length;138    } catch { transfersOut = null; }139  } else holds = (await adapterFor(n.id).balances(vault)).native ?? 0n;140  if (holds < amount * EVIDENCE_MULTIPLE) throw new UserError('vault_evidence', `the address the THORNode operators name as vault holds less than ${EVIDENCE_MULTIPLE} times the amount on ${n.name}; this does not look like an Asgard vault and the swap is refused`, 503);141  return { holds: holds.toString(), multiple: Number(holds / amount > 1_000_000n ? 1_000_000n : holds / amount), transfersOut };142}143144async function routerGuards(n: NetworkDef, me: string): Promise<void> {145  const pin = THOR_ROUTERS[n.id];146  if (!pin) throw new UserError('xswap_not_enabled', `THORChain swaps from ${n.name} are not enabled`, 422);147  if (sha(await rpcQuorum<string>(n.rpc, 'eth_getCode', [pin.address, 'latest'])) !== pin.codeSha256) throw new UserError('router_changed', 'the THORChain router bytecode is not the reviewed one; swaps are refused', 503);148  if ((await rpcQuorum<string>(n.rpc, 'eth_getCode', [me, 'latest'])) !== '0x') throw new UserError('account_has_code', 'this wallet address carries code (delegation); swaps are refused', 503);149}150151// ---------------------------------------------------------------------------------------------------------------------152// Preparation153154const Create = z.strictObject({ fromNetwork: z.string().max(24), fromAsset: z.string().max(80), toNetwork: z.string().max(24), toAsset: z.string().max(80), amount: z.string().min(1).max(60), slippageBps: z.number().int().min(1).max(300).default(100) });155const GAS = { approve: 70_000n, depositUnsimulated: 160_000n };156157export function xswapRoutes(app: FastifyInstance): void {158  app.get('/api/v1/xswap/execution', async () => ({ enabled: true, provider: 'thorchain', minOperators: THOR_MIN_OPERATORS, operators: THORNODE_OPERATORS.map((o) => ({ id: o.id, host: new URL(o.base).host })),159    assets: THOR_ASSETS.map((a) => ({ network: a.network, asset: a.asset, symbol: a.symbol, source: a.source })), routers: Object.fromEntries(Object.entries(THOR_ROUTERS).map(([k, v]) => [k, v.address])) }));160161  app.post('/api/v1/xswap/proposals', async (req) => {162    const idem = String(req.headers['idempotency-key'] ?? '');163    if (!/^[A-Za-z0-9-]{16,64}$/.test(idem)) throw new UserError('idempotency', 'Idempotency-Key header required');164    const b = Create.parse(req.body);165    let src: ThorAsset, dst: ThorAsset;166    try { src = thorAsset(b.fromNetwork, b.fromAsset); dst = thorAsset(b.toNetwork, b.toAsset); } catch { throw new UserError('xswap_not_enabled', 'this pair cannot be swapped through THORChain by this wallet', 422); }167    if (!src.source) throw new UserError('xswap_not_enabled', `${src.symbol} cannot be the input of a THORChain swap in this wallet`, 422);168    if (src.network === dst.network) throw new UserError('same_network', 'choose assets on two different networks');169    const n = getNetwork(src.network), dn = getNetwork(dst.network);170    let amount: bigint;171    try { amount = parseUnits(b.amount, src.decimals); } catch (e) { throw new UserError('bad_amount', (e as Error).message); }172    if (src.decimals > 8) amount -= amount % 10n ** BigInt(src.decimals - 8); // THORChain credits 8 decimals: nothing finer is sent173    if (amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');174    const amount1e8 = toThorAmount(amount, src.decimals);175    const st = await signer.status(true);176    if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);177    const me = addressFor(st, n.id), dest = addressFor(st, dn.id);178    const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${n.id} and (state in ('approved', 'signed', 'broadcast')179      or (kind = 'xswap' and state in ('confirming', 'finalized', 'reorged') and coalesce(xswap->>'phase', '') not in ('received', 'refunded'))) limit 1`;180    if (busy) throw new UserError('pending_tx', 'a previous transfer or swap on this network is not finished yet; wait for it before starting another', 409);181182    // 1. the vault: named by the operators, confirmed by all that answer, its address derived here from its key183    const answers: ThorAnswer[] = await fetchThorViews(thorGet);184    const vaultPubKey = pickThorVault(answers, src.chain);185    if (!vaultPubKey) throw new UserError('provider_unavailable', 'no THORNode operator could be reached; nothing was prepared', 503);186    const router = n.family === 'evm' ? THOR_ROUTERS[n.id]?.address ?? null : null;187    if (n.family === 'evm' && !router) throw new UserError('xswap_not_enabled', `THORChain swaps from ${n.name} are not enabled`, 422);188    const want: ThorVaultWant = { chain: src.chain, vaultPubKey, router, amount: amount1e8 };189    let verdict: ThorVaultVerdict;190    try { verdict = checkThorVault(answers, want); } catch (e) { throw new UserError('vault_unconfirmed', (e as Error).message, 503); }191    // the destination chain must be running too, or the outbound would wait192    const dstBlocked = answers.flatMap((a) => ('view' in a ? a.view.inbound.filter((x) => x.chain === dst.chain && (x.halted || x.paused)) : []));193    if (dstBlocked.length) throw new UserError('chain_halted', `THORChain reports ${dn.name} halted or paused; nothing was prepared`, 422);194    let vault: string;195    try { vault = thorVaultAddress(vaultPubKey, n.id); } catch (e) { throw new UserError('provider_bad_answer', `vault key refused: ${(e as Error).message}`, 502); }196    if (eq(vault, me)) throw new UserError('provider_bad_answer', 'the vault is the wallet itself', 502);197198    // 2. the price: at least two operators, agreeing, each naming exactly the memo this wallet builds199    await poolGuard(src, thorGet); await poolGuard(dst, thorGet);200    const agreed = agreeQuotes(await fetchQuotes(src, dst, amount1e8, dest, b.slippageBps, thorGet), dst, dest, b.slippageBps, router);201    if (amount1e8 < agreed.minAmountIn) throw new UserError('below_minimum', `THORChain's minimum for this swap is ${formatUnits(fromThorAmount(agreed.minAmountIn, src.decimals), src.decimals)} ${src.symbol}: below it, a refund could not pay its own fee and the amount would be lost`, 422);202    const minOut = fromThorAmount(agreed.limit, dst.decimals), expectedOut = fromThorAmount(agreed.expected, dst.decimals);203    if (minOut <= 0n) throw new UserError('no_route', 'the amount is too small for this pair', 422);204205    // 3. second line: what the chain itself shows about that address206    const evidence = await vaultEvidence(n, src, vault, amount);207208    // 4. the source transaction209    const t0 = Math.floor(now() / 1000), id = randomUUID();210    const bal = await adapterFor(n.id).balances(me);211    const have = bal[src.asset === 'native' ? 'native' : src.asset];212    if (have === undefined) throw new UserError('rpc_degraded', 'balance could not be read; swap refused', 503);213    if (have < amount) throw new UserError('insufficient_funds', `insufficient ${src.symbol} balance`);214    const x = { provider: 'thorchain' as const, toNetwork: dn.id, toAsset: dst.asset, minOut: minOut.toString(), limit: agreed.limit.toString(), memo: agreed.memo, vault, vaultPubKey, router: router ?? '', expiry: t0 + THOR_EXPIRY_SECONDS };215    const base = { v: 1, proposalId: id, keyId: st.keyId, network: n.id, chainRef: n.chainRef, asset: src.asset, symbol: src.symbol, decimals: src.decimals, amount: amount.toString(), from: me, to: vault, createdAt: t0, expiresAt: t0 + 120, policyVersion: st.policy.version };216    let intent: Intent, aux: Record<string, unknown> | undefined, needApprove = false, simulated = false, feeEstimate: bigint;217    if (n.family === 'btc') {218      // fee rate: the higher of the wallet's own estimate and THORChain's recommendation; an under-priced inbound could still be unconfirmed at the next churn219      const prep = await adapterFor(n.id).prepare({ asset: 'native', from: me, to: vault, amount, thor: { extraBytes: thorOpReturnSize(agreed.memo), minRate: verdict.gasRate === null ? 0 : Number(verdict.gasRate) } });220      intent = IntentSchema.parse({ ...base, maxFee: prep.maxFee.toString(), xswap: { ...x, approveGas: '0' }, ...prep.section });221      aux = prep.aux; feeEstimate = prep.feeEstimate;222      try { buildXswapBtcTx(intent, dest, aux); } catch (e) { throw new UserError('simulation_failed', (e as Error).message, 422); } // exactly what the signer will rebuild223    } else {224      await routerGuards(n, me);225      const allowance = src.asset === 'native' ? 0n : hexToBigInt(await rpcAny<`0x${string}`>(n.rpc, 'eth_call', [{ to: src.asset, data: encodeFunctionData({ abi: erc20Abi, functionName: 'allowance', args: [me as `0x${string}`, router as `0x${string}`] }) }, 'latest']));226      needApprove = src.asset !== 'native' && allowance < amount;227      const nonce = hexToNumber(await rpcQuorum<`0x${string}`>(n.rpc, 'eth_getTransactionCount', [me, 'pending']));228      if (nonce !== hexToNumber(await rpcAny<`0x${string}`>(n.rpc, 'eth_getTransactionCount', [me, 'latest']))) throw new UserError('pending_tx', 'a previous transaction from this address is still pending on this network', 409);229      const block = await rpcAny<{ baseFeePerGas?: `0x${string}` }>(n.rpc, 'eth_getBlockByNumber', ['latest', false]);230      if (!block.baseFeePerGas) throw new UserError('rpc_degraded', 'network did not report a base fee', 503);231      const baseFee = hexToBigInt(block.baseFeePerGas);232      let tip = 1_000_000n; try { tip = hexToBigInt(await rpcAny<`0x${string}`>(n.rpc, 'eth_maxPriorityFeePerGas', [])); } catch { /* floor */ }233      // the higher of the wallet's own ceiling and THORChain's recommended gas rate (gwei)234      let maxFeePerGas = baseFee * 2n + tip; const thorRate = (verdict.gasRate ?? 0n) * 1_000_000_000n; if (thorRate > maxFeePerGas) maxFeePerGas = thorRate;235      const draft = (gas: bigint): Intent => IntentSchema.parse({ ...base, maxFee: ((gas + (needApprove ? GAS.approve : 0n)) * maxFeePerGas).toString(), xswap: { ...x, approveGas: (needApprove ? GAS.approve : 0n).toString() },236        evm: { nonce, gasLimit: gas.toString(), maxFeePerGas: maxFeePerGas.toString(), maxPriorityFeePerGas: (tip > maxFeePerGas ? maxFeePerGas : tip).toString() } });237      let gas = GAS.depositUnsimulated;238      if (!needApprove) { // the node's own estimate is also the simulation; with an approval pending the deposit is simulated once it is mined239        const est = await rpcAny<`0x${string}`>(n.rpc, 'eth_estimateGas', [{ from: me, to: router, data: xswapDepositData(draft(gas), dest), value: numberToHex(src.asset === 'native' ? amount : 0n) }])240          .then(hexToBigInt, (e: Error) => { throw new UserError('simulation_failed', `the deposit would fail right now: ${e.message.slice(0, 120)}`, 422); });241        gas = (est * 13n) / 10n + 20_000n; simulated = true;242      }243      intent = draft(gas); feeEstimate = (gas + (needApprove ? GAS.approve : 0n)) * (baseFee + tip);244      try { buildXswapEvmTxs(intent, dest); } catch (e) { throw new UserError('simulation_failed', (e as Error).message, 422); }245      if ((bal.native ?? 0n) < BigInt(intent.maxFee) + (src.asset === 'native' ? amount : 0n)) throw new UserError('insufficient_gas', `not enough ${n.native.symbol} on ${n.name} to pay the network fee`);246    }247    const policy = checkPolicy(st.policy, intent, BigInt(st.spentToday[`${n.id}:${src.symbol}`] ?? '0'), t0, st.halted);248    if (!policy.ok) throw new UserError('policy', policy.reason, 422);249    const hash = intentHash(intent);250    const state: XswapState = { provider: 'thorchain', toNetwork: dn.id, toAsset: dst.asset, toSymbol: dst.symbol, toDecimals: dst.decimals, expectedOut: expectedOut.toString(), minOut: minOut.toString(), limit1e8: agreed.limit.toString(),251      memo: agreed.memo, vault, vaultPubKey, router, expiry: x.expiry, outboundFee: fromThorAmount(agreed.outboundFee, dst.decimals).toString(), liquidityFee: fromThorAmount(agreed.liquidityFee, dst.decimals).toString(),252      estimatedSeconds: agreed.seconds, slippageBps: b.slippageBps, operators: verdict.operators, quoteOperators: agreed.operators, churnHeight: verdict.statusSince, vaultMembers: verdict.members, evidence,253      needsApproval: needApprove, simulated, phase: 'sending', polls: 0, nextAt: 0 };254    const swap = { provider: 'thorchain', toAsset: dst.asset, toSymbol: dst.symbol, toDecimals: dst.decimals, expectedOut: expectedOut.toString(), minOut: minOut.toString(), slippageBps: b.slippageBps, needsApproval: needApprove, simulated };255    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, aux, swap, xswap, expires_at)256      values (${id}, ${idem}, 'pending_approval', 'xswap', ${n.id}, ${src.asset}, ${src.symbol}, ${src.decimals}, ${amount.toString()}, ${me}, ${vault}, ${`swap → ${dst.symbol} (${dn.name})`}, ${feeEstimate.toString()}, ${intent.maxFee},257        ${sql.json(needApprove ? ['swap_needs_approval'] : [])}, ${sql.json(intent as never)}, ${toHex(hash)}, ${aux ? sql.json(aux as never) : null}, ${sql.json(swap)}, ${sql.json(state as never)}, ${new Date(intent.expiresAt * 1000)})258      on conflict (idempotency_key) do nothing returning *`;259    if (!p) throw new UserError('conflict', 'duplicate request', 409);260    await audit('xswap.prepared', 'owner', { id, from: `${n.id}:${src.symbol}`, to: `${dn.id}:${dst.symbol}`, amount: amount.toString(), minOut: minOut.toString(), vault, vaultPubKey, memo: agreed.memo, operators: verdict.operators, quoteOperators: agreed.operators, churnHeight: verdict.statusSince, hash: toHex(hash) });261    const creds = await listCreds();262    const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: 110_000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });263    if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');264    return { proposal: view(p), options };265  });266267  // Look now instead of waiting for the worker. Safe at any time, any number of times; it signs nothing.268  app.post('/api/v1/xswap/proposals/:id/refresh', async (req) => {269    const id = (req.params as { id: string }).id;270    const [p] = z.uuid().safeParse(id).success ? await sql<ProposalRow[]>`select * from proposals where id = ${id} and kind = 'xswap'` : [];271    if (!p) throw new UserError('not_found', 'swap not found', 404);272    if (p.state === 'signed') return view(await advanceXswapSend(p));273    return view(await advanceXswap(p, liveDeps, true));274  });275}276277// ---------------------------------------------------------------------------------------------------------------------278// After signing: last gates, then relay. Nothing is sent once the vault is in doubt or the validity window is nearly over.279280const save = async (p: ProposalRow, b: XswapState): Promise<ProposalRow> => (await sql<ProposalRow[]>`update proposals set xswap = ${sql.json(b as never)}, updated_at = now() where id = ${p.id} returning *`)[0]!;281const wantOf = (p: ProposalRow): { want: ThorVaultWant; dest: string } => { const dest = (p.intent!.xswap!.memo.split(':')[2] ?? ''); return { want: verifyXswap(p.intent!, dest).want, dest }; };282/** A UTXO inbound is never (re)broadcast in the last minutes of its window: it must confirm while the vault it pays is the one that was confirmed. */283export const xswapMayBroadcast = (p: { xswap?: XswapState | null }, nowMs = Date.now()): boolean => !!p.xswap && nowMs / 1000 < p.xswap.expiry - 180;284285export async function advanceXswapSend(p: ProposalRow, check: (w: ThorVaultWant) => Promise<ThorVaultVerdict> = vaultCheck): Promise<ProposalRow> {286  if (p.kind !== 'xswap' || p.state !== 'signed' || !p.raw || !p.intent?.xswap || !p.xswap) return p;287  const n = getNetwork(p.network), b = p.xswap;288  const fail = (error: string) => move(p, 'failed', { error });289  const sent = async (q: ProposalRow) => (q.state === 'broadcast' ? save(q, { ...b, phase: 'sent', sentAt: now(), nextAt: 0 }) : q);290  if (!config.broadcast) return sent(await broadcastNow(p));291  if (!xswapMayBroadcast(p)) return fail('the swap could not be sent in time; nothing was sent to the vault');292  // the vault must still be the confirmed active vault of the same churn, on a chain that is not halted293  const { want, dest } = wantOf(p);294  try { const v = await check(want); if (v.statusSince !== b.churnHeight) throw new Error('the vault set changed (churn) since the swap was prepared'); }295  catch (e) {296    if (/^only \d+ THORNode/.test((e as Error).message)) return p; // operators unreachable: try again on the next tick, until the window closes297    return fail(`the vault is no longer confirmed (${(e as Error).message.slice(0, 160)}); nothing was sent to it`);298  }299  if (n.family === 'evm') {300    const router = THOR_ROUTERS[n.id]!.address;301    if (p.pre_raw && p.pre_state !== 'confirmed') {302      await adapterFor(p.network).broadcast(p.pre_raw).catch(() => {});303      const rc = await rpcAny<{ status: `0x${string}` } | null>(n.rpc, 'eth_getTransactionReceipt', [p.pre_txid]).catch(() => null);304      if (!rc) { if (p.pre_state !== 'sent') await sql`update proposals set pre_state = 'sent', updated_at = now() where id = ${p.id}`; return p; }305      if (hexToNumber(rc.status) !== 1) return fail('the token approval reverted; nothing was sent to the vault');306      await sql`update proposals set pre_state = 'confirmed', updated_at = now() where id = ${p.id}`;307      await audit('xswap.approval_confirmed', 'chain', { id: p.id, txid: p.pre_txid });308    }309    const sim = await rpcAny<string>(n.rpc, 'eth_call', [{ from: p.from_addr, to: router, data: xswapDepositData(p.intent, dest), value: numberToHex(p.asset === 'native' ? BigInt(p.amount) : 0n) }, 'latest'])310      .then(() => null, (e: Error & { rpcCode?: number }) => (e.rpcCode !== undefined ? e.message : 'unreachable'));311    if (sim === 'unreachable') return p;312    if (sim !== null) return fail(`the deposit would revert now (${sim.slice(0, 100)}); it was not sent${p.pre_raw ? '. The exact-amount approval of the router stays in place; only this wallet can use it' : ''}`);313  }314  return sent(await broadcastNow(p));315}316317// ---------------------------------------------------------------------------------------------------------------------318// Tracking: THORChain's status (two operators agreeing) says where to look; only the wallet's own chain adapters say "received".319320const StatusSchema = z.looseObject({321  planned_out_txs: z.array(z.looseObject({ refund: z.boolean().optional() })).max(20).nullish(),322  out_txs: z.array(z.looseObject({ id: z.string().max(80), chain: z.string().max(12), to_address: z.string().max(160), memo: z.string().max(300).optional() })).max(20).nullish(),323  stages: z.looseObject({ inbound_observed: z.looseObject({ completed: z.boolean() }) }),324});325/** Reduce one operator's answer; out transactions without a real id (native THORChain) are ignored. */326export function reduceThorStatus(j: unknown): ThorStatus {327  const s = StatusSchema.parse(j);328  return { observed: s.stages.inbound_observed.completed, refundPlanned: (s.planned_out_txs ?? []).some((x) => x.refund === true),329    outs: (s.out_txs ?? []).filter((x) => /^[0-9A-Fa-f]{64}$/.test(x.id) && !/^0+$/.test(x.id)).map((x) => ({ id: x.id.toUpperCase(), chain: x.chain, to: x.to_address, memo: x.memo ?? '' })).sort((a, b) => a.id.localeCompare(b.id)) };330}331/** The most advanced status that at least two operators give identically, or null. */332export function agreeThorStatus(all: ThorStatus[]): ThorStatus | null {333  const groups = new Map<string, { n: number; s: ThorStatus }>();334  for (const s of all) { const k = JSON.stringify(s); const g = groups.get(k); if (g) g.n += 1; else groups.set(k, { n: 1, s }); }335  const ok = [...groups.values()].filter((g) => g.n >= 2).map((g) => g.s);336  ok.sort((a, b) => b.outs.length - a.outs.length || Number(b.observed) - Number(a.observed));337  return ok[0] ?? null;338}339340export interface XswapDeps {341  status(inTxid: string): Promise<ThorStatus | null>;342  vaultActive(p: ProposalRow): Promise<boolean | null>;343  outboundSeen(p: ProposalRow, b: XswapState): Promise<OutboundSeen>;344}345346/** The outbound (or refund) transaction, read with the wallet's own data sources: it must pay the wallet the expected asset and carry THORChain's memo for this swap. */347async function outboundSeen(p: ProposalRow, b: XswapState): Promise<OutboundSeen> {348  const refund = b.outKind === 'refund', n = getNetwork(refund ? p.network : b.toNetwork), asset = refund ? p.asset : b.toAsset;349  const me = refund ? p.from_addr : addressFor(await signer.status(), b.toNetwork), memo = `${refund ? 'REFUND' : 'OUT'}:${thorTxId(p.txid!)}`, id = b.outTxid!.toLowerCase();350  if (n.family === 'evm') {351    const router = THOR_ROUTERS[n.id]?.address; if (!router) return null;352    let agreed: bigint | 'mismatch' | undefined, minHead = Infinity, block = 0;353    for (const url of n.rpc) { // every RPC source must show the same thing354      const rc = await rpcOne<{ status: `0x${string}`; blockNumber: `0x${string}`; logs: { address: string; topics: [`0x${string}`, ...`0x${string}`[]]; data: `0x${string}` }[] } | null>(url, 'eth_getTransactionReceipt', [`0x${id}`]).catch(() => undefined);355      if (!rc) return null; // not there (yet) or source down: look again later356      let got: bigint | 'mismatch' = 'mismatch';357      if (hexToNumber(rc.status) === 1) for (const l of rc.logs) { if (!eq(l.address, router)) continue;358        try { const ev = decodeEventLog({ abi: THOR_ROUTER_ABI, data: l.data, topics: l.topics });359          if (ev.eventName === 'TransferOut' && eq(ev.args.to, me) && eq(ev.args.asset, asset === 'native' ? ZERO : asset) && ev.args.memo === memo) got = (got === 'mismatch' ? 0n : got) + ev.args.amount; } catch { /* another event */ } }360      if (agreed !== undefined && agreed !== got) return 'mismatch';361      agreed = got; block = hexToNumber(rc.blockNumber);362      minHead = Math.min(minHead, await rpcOne<`0x${string}`>(url, 'eth_blockNumber', []).then(hexToNumber, () => 0));363    }364    if (agreed === undefined) return null;365    return agreed === 'mismatch' ? 'mismatch' : { amount: agreed, confirmed: minHead - block + 1 >= n.minConf };366  }367  if (n.family !== 'btc') return null;368  const sources = n.rpc.map(sourceOf);369  let raw: string | null = null;370  for (const s of sources) { try { raw = await s.rawTx(id); break; } catch { /* next source */ } }371  if (!raw) return null;372  let pay; try { pay = utxoPaymentTo(raw, n.id, me); } catch { return 'mismatch'; }373  if (pay.txid !== id || pay.paid <= 0n || pay.memo !== memo) return 'mismatch';374  let confirmed = false;375  for (const s of sources) { try { const stt = await s.status(id); if (stt.found && stt.confirmed) { confirmed = true; break; } } catch { /* next source */ } }376  return { amount: pay.paid, confirmed };377}378379export const liveDeps: XswapDeps = {380  async status(inTxid) {381    const rs = await Promise.all(THORNODE_OPERATORS.map((o) => thorGet(`${o.base}/thorchain/tx/status/${inTxid}`).then(reduceThorStatus, () => null)));382    return agreeThorStatus(rs.filter((x): x is ThorStatus => x !== null));383  },384  async vaultActive(p) {385    try { await vaultCheck(wantOf(p).want); return true; }386    catch (e) { return /^only \d+ THORNode/.test((e as Error).message) ? null : false; }387  },388  outboundSeen,389};390391export async function advanceXswap(p: ProposalRow, deps: XswapDeps = liveDeps, manual = false): Promise<ProposalRow> {392  if (p.kind !== 'xswap' || !p.xswap || !p.txid || !['broadcast', 'confirming', 'finalized', 'reorged'].includes(p.state)) return p;393  let b = p.xswap;394  if (xswapTerminal(b)) return p;395  if (b.phase === 'sending') b = { ...b, phase: 'sent', sentAt: b.sentAt ?? now() }; // signed and relayed by an earlier process396  if (!manual && b.nextAt > now()) return p;397  const inTxid = thorTxId(p.txid);398  const obs: XswapObs = { now: now(), sourceState: p.state, status: await deps.status(inTxid).catch(() => null) };399  if (b.phase === 'sent' && p.state === 'broadcast' && !obs.status?.observed) obs.vaultActive = await deps.vaultActive(p).catch(() => null);400  let r = xswapNext(b, obs, inTxid);401  if (r.b.phase === 'outbound') { // the outbound is named: look for it ourselves, in the same pass402    const seen = await deps.outboundSeen(p, r.b).catch(() => null);403    const r2 = xswapNext(r.b, { ...obs, seen }, inTxid);404    r = { b: r2.b, events: [...r.events, ...r2.events] };405  }406  if (r.b.phase === 'received' && r.b.received) await sql`update proposals set out_amount = ${r.b.received} where id = ${p.id}`;407  const saved = await save(p, r.b);408  for (const [type, data] of r.events) await audit(type, type === 'xswap.hold' ? 'system' : 'chain', { id: p.id, ...data });409  return saved;410}411412/** Worker tick: every cross-chain swap whose source transaction is out and whose outcome is not known yet. */413export async function advanceXswaps(): Promise<void> {414  const rows = await sql<ProposalRow[]>`select * from proposals where kind = 'xswap' and state in ('broadcast', 'confirming', 'finalized', 'reorged') and coalesce(xswap->>'phase', '') not in ('received', 'refunded') order by created_at`;415  for (const p of rows) { try { await advanceXswap(p); } catch (e) { console.log(`[worker] xswap ${p.id}: ${(e as Error).message}`); } }416}417
Files main
Menu

Theme