SPB Git

Repository Public

xswap-state.ts 8.4 KB 90 lines typescript

1// State of a native cross-chain swap (THORChain) after its source transaction is signed, and the pure rules that move it.2// No I/O here: services/api/src/xswap.ts gathers the observations and stores the result. Every step is decided only by what3// is stored and what was observed, so a swap resumes after a restart, hours later, exactly where it was.4import { formatUnits, getNetwork } from '@spw/core';56export type XswapPhase = 'sending' | 'sent' | 'observed' | 'outbound' | 'received' | 'refunded';7export interface XswapState {8  provider: 'thorchain'; toNetwork: string; toAsset: string; toSymbol: string; toDecimals: number;9  expectedOut: string; minOut: string; limit1e8: string; memo: string; vault: string; vaultPubKey: string; router: string | null; expiry: number;10  /** fees THORChain takes from the output, destination base units (already deducted from expectedOut and from the minimum) */11  outboundFee: string; liquidityFee: string; estimatedSeconds: number | null; slippageBps: number;12  /** who confirmed the vault when the swap was prepared (the signer asks its own operators again before signing) */13  operators: string[]; quoteOperators: string[]; churnHeight: number; vaultMembers: number;14  /** second line: what the wallet's own chain data sources say the vault holds, in the source asset (base units), and recent router activity */15  evidence: { holds: string; multiple: number; transfersOut: number | null };16  needsApproval: boolean; simulated: boolean;17  phase: XswapPhase; hold?: string | null; holdDetail?: string | null; polls: number; nextAt: number;18  sentAt?: number; observedAt?: number; outboundAt?: number; doneAt?: number; refundPlanned?: boolean;19  outTxid?: string; outKind?: 'out' | 'refund'; received?: string; refunded?: string;20}21export const xswapTerminal = (b: XswapState) => b.phase === 'received' || b.phase === 'refunded';2223/** What the THORNode operators agree on for the inbound transaction (at least two identical answers), reduced to what matters. */24export interface ThorStatus { observed: boolean; refundPlanned: boolean; outs: { id: string; chain: string; to: string; memo: string }[] }25/** What the wallet's OWN chain adapters say about the outbound transaction THORChain named. */26export type OutboundSeen = { amount: bigint; confirmed: boolean } | 'mismatch' | null;27export interface XswapObs {28  now: number; /** state of the source transaction */ sourceState: string;29  /** null = the operators did not agree or did not answer */ status: ThorStatus | null;30  /** only looked at while the source transaction is unconfirmed: false = the vault is no longer confirmed as active */ vaultActive?: boolean | null;31  seen?: OutboundSeen;32}33const LATE = { observe: 2 * 3600_000, outbound: 3 * 3600_000, seen: 3 * 3600_000 };34const backoff = (polls: number) => Math.min(300_000, 10_000 * Math.ceil((polls + 1) / 6));35export type XswapEvent = [type: string, data: Record<string, unknown>];3637/**38 * One step. Success ('received') is reached only through `seen`: the wallet's own adapters found the outbound transaction on39 * the destination chain, paying the wallet the expected asset with THORChain's OUT memo for this very swap. A refund is40 * 'refunded', never 'received'. Nothing here ever gives up: an overdue step raises a hold for the owner and keeps looking.41 */42export function xswapNext(b0: XswapState, o: XswapObs, inTxid: string): { b: XswapState; events: XswapEvent[] } {43  let b = { ...b0 }; const events: XswapEvent[] = [];44  if (xswapTerminal(b) || b.phase === 'sending') return { b, events };45  const hold = (code: string, detail: string) => { if (b.hold !== code) events.push(['xswap.hold', { hold: code }]); b = { ...b, hold: code, holdDetail: detail }; };46  const clear = () => { b = { ...b, hold: null, holdDetail: null }; };47  if (b.phase === 'sent') {48    if (o.status?.observed) { b = { ...b, phase: 'observed', observedAt: o.now, polls: 0 }; clear(); events.push(['xswap.observed', {}]); }49    else if (o.sourceState === 'broadcast' && o.vaultActive === false) hold('vault_churned', 'the vault is no longer confirmed as active and the transaction is not confirmed yet. It cannot be cancelled; THORChain still watches a retiring vault, but this is not guaranteed');50    else if (o.sourceState !== 'broadcast' && o.now - (b.sentAt ?? o.now) > LATE.observe) hold('not_observed', 'THORChain has not reported this deposit yet. Nothing is marked as received until the wallet sees the outbound transaction itself');51    else if (b.hold === 'vault_churned' && o.vaultActive) clear();52  }53  if (b.phase === 'observed' && o.status) {54    if (o.status.refundPlanned && !b.refundPlanned) b = { ...b, refundPlanned: true };55    const want = `:${inTxid}`, out = o.status.outs.find((x) => /^REFUND:/.test(x.memo) && x.memo.endsWith(want)) ?? o.status.outs.find((x) => /^OUT:/.test(x.memo) && x.memo.endsWith(want));56    if (out) { b = { ...b, phase: 'outbound', outTxid: out.id, outKind: out.memo.startsWith('REFUND:') ? 'refund' : 'out', outboundAt: o.now, polls: 0 }; clear(); events.push(['xswap.outbound', { txid: out.id, kind: b.outKind }]); }57    else if (o.now - (b.observedAt ?? o.now) > Math.max(LATE.outbound, (b.estimatedSeconds ?? 0) * 4000)) hold('outbound_late', 'THORChain has observed the deposit but has not sent the outbound transaction yet');58  }59  if (b.phase === 'outbound' && o.seen !== undefined) {60    if (o.seen === 'mismatch') hold('outbound_mismatch', 'the outbound transaction THORChain names does not pay this wallet what was expected; it is not counted as received');61    else if (o.seen && o.seen.confirmed) {62      const amount = o.seen.amount.toString();63      if (b.outKind === 'refund') { b = { ...b, phase: 'refunded', refunded: amount, doneAt: o.now }; clear(); events.push(['xswap.refunded', { txid: b.outTxid, amount }]); }64      else {65        b = { ...b, phase: 'received', received: amount, doneAt: o.now }; clear(); events.push(['xswap.received', { txid: b.outTxid, amount, minOut: b.minOut }]);66        if (o.seen.amount < BigInt(b.minOut)) hold('below_minimum', 'the amount received is below the approved minimum');67      }68    } else if (o.now - (b.outboundAt ?? o.now) > LATE.seen) hold('outbound_not_seen', 'THORChain reports an outbound transaction that the wallet\'s own data sources do not show as confirmed yet');69    else if (b.hold === 'outbound_mismatch' && o.seen === null) clear();70  }71  if (!xswapTerminal(b)) b = { ...b, polls: b.polls + 1, nextAt: o.now + backoff(b.polls) };72  return { b, events };73}7475/** A cross-chain swap as shown to the owner. */76export function xswapView(p: { state: string; network: string; decimals: number; txid: string | null; pre_raw: string | null; pre_txid: string | null; pre_state: string | null }, b: XswapState) {77  const d = getNetwork(b.toNetwork), s = getNetwork(p.network);78  const stage = ['failed', 'expired', 'rejected', 'replaced'].includes(p.state) ? 'failed' : p.state === 'pending_approval' ? 'review' : ['approved', 'signed'].includes(p.state) || b.phase === 'sending' ? 'sending' : b.phase;79  const out = (v: string | undefined, dec: number) => (v !== undefined ? formatUnits(BigInt(v), dec) : null);80  return { provider: b.provider, toNetwork: b.toNetwork, toNetworkName: d.name, toSymbol: b.toSymbol, stage, phase: b.phase,81    expectedOutDisplay: formatUnits(BigInt(b.expectedOut), b.toDecimals), minOutDisplay: formatUnits(BigInt(b.minOut), b.toDecimals),82    outboundFeeDisplay: formatUnits(BigInt(b.outboundFee), b.toDecimals), liquidityFeeDisplay: formatUnits(BigInt(b.liquidityFee), b.toDecimals), estimatedSeconds: b.estimatedSeconds, slippageBps: b.slippageBps,83    vault: b.vault, vaultExplorer: s.explorerAddr + b.vault, memo: b.memo, router: b.router, operators: b.operators, quoteOperators: b.quoteOperators, vaultMembers: b.vaultMembers, churnHeight: b.churnHeight,84    evidenceMultiple: b.evidence.multiple, evidenceTransfersOut: b.evidence.transfersOut, needsApproval: b.needsApproval, simulated: b.simulated,85    approvalTxid: p.pre_txid, approvalState: p.pre_raw ? (p.pre_state ?? 'pending') : null,86    hold: b.hold ?? null, holdDetail: b.holdDetail ?? null, refundPlanned: !!b.refundPlanned,87    outTxid: b.outTxid ?? null, outKind: b.outKind ?? null, outExplorer: b.outTxid ? (b.outKind === 'refund' ? s : d).explorerTx + (getNetwork((b.outKind === 'refund' ? s : d).id).family === 'evm' ? `0x${b.outTxid.toLowerCase()}` : b.outTxid.toLowerCase()) : null,88    receivedDisplay: out(b.received, b.toDecimals), refundedDisplay: out(b.refunded, p.decimals), done: xswapTerminal(b) };89}90
Files main
Menu

Theme