Repository Public
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