Repository Public
1// THORChain native swap life cycle against scripted THORNode operators and scripted chain adapters (no network): the real API2// routes, worker steps and signer process boundary. Database: postgres:///spw_test_xswap (create it once: `createdb spw_test_xswap`).3// What the real router and a real signed Bitcoin transaction look like is proven separately (scripts/sim-thorchain.mts).4import { createHash } from 'node:crypto';5import { mkdtempSync } from 'node:fs';6import type { Server } from 'node:http';7import { tmpdir } from 'node:os';8import { join } from 'node:path';9import { createTestDb } from './test-db.ts';10import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';11import { NETWORKS, THORNODE_OPERATORS, thorVaultAddress, utxoPaymentTo, type Intent, type ThorVaultWant } from '@spw/core';12import { TestPasskey } from '../../../tests/helpers/passkey.ts';13import { deriveKeys } from '../../isolated-signer/src/keys.ts';14import { createSignerServer } from '../../isolated-signer/src/server.ts';15import { Signer } from '../../isolated-signer/src/signer.ts';16import { Store } from '../../isolated-signer/src/store.ts';17import type { XswapObs, XswapState } from '../src/xswap-state.ts';1819const ORIGIN = 'https://wallet.test', RP = 'wallet.test', SECRET = 'd'.repeat(48);20const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'); // public test vector, no funds21const ME_BTC = keys.addresses.bitcoin!, ME_EVM = keys.addresses.evm, owner = new TestPasskey(RP, ORIGIN);22const KEY = 'thorpub1addwnpepqgwpafz6l99mkevlz7spaezgzcxy5t2896hgm7e6kytqrkmulex4wxjp8az', KEY2 = 'thorpub1addwnpepqvlena222zluk5p3r0s9uey758rd43qedtxf2xalprynuaxp69rscr0jy4h';23const VAULT = thorVaultAddress(KEY, 'bitcoin');24const HOSTS = THORNODE_OPERATORS.map((o) => new URL(o.base).host);2526/** The five operators, scripted. Index = position in THORNODE_OPERATORS. */27const net = {28 down: new Set<number>(), status: {} as Record<number, string>, behind: {} as Record<number, number>, halted: new Set<number>(), since: 900,29 quoteDown: new Set<number>(), expected: {} as Record<number, bigint>, memo: {} as Record<number, string>, minIn: '6000', streaming: 1, extraPool: null as string | null,30 reset() { this.down.clear(); this.status = {}; this.behind = {}; this.halted.clear(); this.since = 900; this.quoteDown = new Set([0]); this.expected = {}; this.memo = {}; this.minIn = '6000'; this.streaming = 1; this.extraPool = null; },31};32function thorFake(u: URL): { status: number; body: unknown } {33 const k = HOSTS.indexOf(u.host), p = u.pathname;34 if (net.down.has(k)) return { status: 503, body: { message: 'down' } };35 if (p.endsWith('/thorchain/lastblock')) return { status: 200, body: [{ chain: 'BTC', thorchain: 1000 - (net.behind[k] ?? 0) }] };36 if (p.endsWith('/thorchain/inbound_addresses')) return { status: 200, body: ['BTC', 'ETH'].map((chain) => ({ chain, pub_key: KEY, address: chain === 'BTC' ? VAULT : thorVaultAddress(KEY, 'ethereum'), ...(chain === 'ETH' ? { router: '0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146' } : {}),37 halted: chain === 'BTC' && net.halted.has(k), global_trading_paused: false, chain_trading_paused: false, gas_rate: chain === 'BTC' ? '3' : '15', dust_threshold: '1000', outbound_fee: '1059' })) };38 if (p.endsWith('/thorchain/vaults/asgard')) return { status: 200, body: [{ pub_key: KEY, status: net.status[k] ?? 'ActiveVault', status_since: net.since, chains: ['BTC', 'ETH'], membership: Array(19).fill('n') }, { pub_key: KEY2, status: 'ActiveVault', status_since: net.since, chains: ['BTC', 'ETH'], membership: Array(18).fill('n') }] };39 if (p.endsWith('/thorchain/pools')) return { status: 200, body: [{ asset: 'BTC.BTC', status: 'Available', short_code: 'b' }, { asset: 'ETH.ETH', status: 'Available', short_code: 'e' }, ...(net.extraPool ? [{ asset: net.extraPool, status: 'Available', short_code: 'e' }] : [])] };40 if (p.endsWith('/thorchain/quote/swap')) {41 if (net.quoteDown.has(k)) return { status: 400, body: { code: 3, message: 'refusing quote on node with stale state: invalid request' } };42 const q = u.searchParams, expected = net.expected[k] ?? 2_119_000n, limit = (expected * BigInt(10_000 - Number(q.get('liquidity_tolerance_bps')))) / 10_000n;43 return { status: 200, body: { memo: net.memo[k] ?? `=:e:${q.get('destination')}:${limit}`, expected_amount_out: expected.toString(), expiry: Math.floor(Date.now() / 1000) + 900, recommended_min_amount_in: net.minIn, dust_threshold: '1000', inbound_address: VAULT,44 max_streaming_quantity: net.streaming, total_swap_seconds: 606, fees: { asset: 'ETH.ETH', affiliate: '0', outbound: '12108', liquidity: '4269', total: '16377', slippage_bps: 19, total_bps: 76 } } };45 }46 return { status: 404, body: {} };47}4849let app: any, sql: any, store: Store, signerSrv: Server, cookie = '', adapters: any, worker: any, xs: typeof import('../src/xswap.ts'), st: typeof import('../src/xswap-state.ts');50const realFetch = globalThis.fetch;51let coin = 0;52const signerVault = { refuse: null as string | null, calls: [] as ThorVaultWant[] };53const chain = { sent: [] as string[], track: { state: 'broadcast', confirmations: 0 } as any, vaultHolds: 7_000_000_000n, prepared: [] as any[] };5455async function api(method: string, path: string, body?: unknown, idem?: string) {56 const r = await app.inject({ method, url: path, headers: { ...(cookie ? { cookie } : {}), ...(method !== 'GET' ? { origin: ORIGIN, 'x-spw-csrf': '1', 'content-type': 'application/json' } : {}), ...(idem ? { 'idempotency-key': idem } : {}) }, payload: body === undefined ? undefined : JSON.stringify(body) });57 return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })(), setCookie: r.headers['set-cookie'] as string | undefined };58}59const row = async (id: string) => (await sql`select * from proposals where id = ${id}`)[0];60const BODY = { fromNetwork: 'bitcoin', fromAsset: 'native', toNetwork: 'ethereum', toAsset: 'native', amount: '0.0006', slippageBps: 100 };61const prepare = (b: Record<string, unknown> = {}) => api('POST', '/api/v1/xswap/proposals', { ...BODY, ...b }, crypto.randomUUID());62async function send() {63 const prep = await prepare(); expect(prep.status, JSON.stringify(prep.json)).toBe(200);64 const id = prep.json.proposal.id as string;65 const ap = await api('POST', `/api/v1/transfers/proposals/${id}/approve`, { assertion: owner.assert(Buffer.from(prep.json.options.challenge, 'base64url')) });66 return { id, prep: prep.json, ap };67}68const OUT = 'AB'.repeat(32);69const deps = (o: { observed?: boolean; out?: 'OUT' | 'REFUND' | null; refundPlanned?: boolean; seen?: any; vaultActive?: boolean | null; agree?: boolean } = {}) => ({70 status: async (inTxid: string) => (o.agree === false ? null : { observed: !!o.observed, refundPlanned: !!o.refundPlanned, outs: o.out ? [{ id: OUT, chain: o.out === 'OUT' ? 'ETH' : 'BTC', to: 'x', memo: `${o.out}:${inTxid}` }] : [] }),71 vaultActive: async () => (o.vaultActive === undefined ? true : o.vaultActive),72 outboundSeen: async () => (o.seen === undefined ? null : o.seen),73});7475beforeAll(async () => {76 globalThis.fetch = (async (input: any, init?: any) => { const u = new URL(String(input instanceof Request ? input.url : input)); if (!HOSTS.includes(u.host)) return realFetch(input, init); const r = thorFake(u); return new Response(JSON.stringify(r.body), { status: r.status, headers: { 'content-type': 'application/json' } }); }) as typeof fetch;77 const dir = mkdtempSync(join(tmpdir(), 'spw-xswap-api-'));78 store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));79 store.state.setupTokenHash = createHash('sha256').update('xswap-setup-token').digest('hex');80 // the signer has its OWN vault view (in production: its own fetches to its own operator list); scripted separately from the API's81 const vaults = { check: async (w: ThorVaultWant) => { signerVault.calls.push(w); if (signerVault.refuse) throw new Error(signerVault.refuse); return { operators: ['a', 'b', 'c'], silent: [], height: 1000, statusSince: 900, gasRate: 3n, dustThreshold: 1000n, outboundFee: 1059n, members: 19 }; } };82 signerSrv = createSignerServer(new Signer(keys, store, { rpId: RP, origin: ORIGIN }, undefined, undefined, undefined, vaults), SECRET).listen(0, '127.0.0.1');83 await new Promise((r) => signerSrv.once('listening', r));84 Object.assign(process.env, { SPW_ORIGIN: ORIGIN, SPW_RP_ID: RP, DATABASE_URL: (await createTestDb('xswap')).url, SPW_SIGNER_SECRET: SECRET, SPW_SIGNER_URL: `http://127.0.0.1:${(signerSrv.address() as { port: number }).port}`, SPW_BROADCAST: 'on', SPW_WORKERS: 'off' });85 const db = await import('../src/db.ts'); sql = db.sql;86 await sql.unsafe('drop schema public cascade; create schema public;'); await db.migrate();87 adapters = (await import('../src/chains/index.ts')).adapters;88 adapters.bitcoin.balances = async (a: string) => ({ native: a === VAULT ? chain.vaultHolds : 200_000n });89 adapters.bitcoin.prepare = async (d: any) => { chain.prepared.push(d); return { section: { btc: { inputs: [{ txid: (++coin).toString(16).padStart(64, 'c'), vout: 0, value: '200000' }], change: (200_000n - d.amount - 700n).toString(), fee: '700' } }, maxFee: 700n, feeEstimate: 700n, warnings: [], ttlSec: 240 }; };90 adapters.bitcoin.broadcast = async (raw: string) => { chain.sent.push(raw); };91 adapters.bitcoin.track = async () => chain.track;92 app = await (await import('../src/server.ts')).buildServer({ swapProviders: [] });93 worker = await import('../src/worker.ts'); xs = await import('../src/xswap.ts'); st = await import('../src/xswap-state.ts');94 const o = (await api('POST', '/api/v1/auth/setup/options', {})).json;95 const s = await api('POST', '/api/v1/auth/setup/verify', { challengeId: o.challengeId, response: owner.register(o.options.challenge), setupToken: 'xswap-setup-token' });96 expect(s.status, JSON.stringify(s.json)).toBe(200); cookie = String(s.setCookie).split(';')[0]!;97});98afterAll(async () => { globalThis.fetch = realFetch; await app?.close(); signerSrv?.close(); await sql?.end(); });99beforeEach(async () => {100 await sql`update proposals set state = 'failed' where state not in ('finalized', 'failed', 'expired', 'rejected', 'replaced')`;101 await sql`update proposals set xswap = jsonb_set(xswap, '{phase}', '"received"') where kind = 'xswap'`;102 store.state.spent = {}; store.save();103 net.reset(); signerVault.refuse = null; signerVault.calls = []; chain.sent = []; chain.track = { state: 'broadcast', confirmations: 0 }; chain.vaultHolds = 7_000_000_000n; chain.prepared = [];104});105106describe('preparation', () => {107 it('is closed without a session, validates the pair and the amount', async () => {108 const saved = cookie; cookie = ''; expect((await prepare()).status).toBe(401); cookie = saved;109 expect((await api('POST', '/api/v1/xswap/proposals', BODY)).json.error).toBe('idempotency');110 for (const [b, code] of [[{ toNetwork: 'bitcoin' }, 'same_network'], [{ fromNetwork: 'dogecoin' }, 'xswap_not_enabled'], [{ toNetwork: 'base' }, 'xswap_not_enabled'], [{ fromNetwork: 'solana' }, 'xswap_not_enabled'],111 [{ amount: '0' }, 'bad_amount'], [{ amount: '0.01' }, 'insufficient_funds'], [{ amount: '0.0009' }, 'policy']] as const) expect((await prepare(b)).json.error, JSON.stringify(b)).toBe(code);112 expect((await prepare({ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' })).status).toBe(400); // there is no recipient field113 expect((await api('GET', '/api/v1/xswap/execution')).json).toMatchObject({ enabled: true, provider: 'thorchain', minOperators: 3, operators: expect.arrayContaining([expect.objectContaining({ host: 'thornode.thorwallet.org' })]) });114 });115 it('commits to the vault key, its locally derived address and the memo THORChain itself expects; shows who confirmed', async () => {116 const r = await prepare(); expect(r.status, JSON.stringify(r.json)).toBe(200);117 const p = r.json.proposal, i = (await row(p.id)).intent as Intent;118 expect(p).toMatchObject({ kind: 'xswap', state: 'pending_approval', network: 'bitcoin', symbol: 'BTC', amount: '60000', to: VAULT, feeEstimate: '700' });119 expect(p.xswap).toMatchObject({ stage: 'review', toNetwork: 'ethereum', toSymbol: 'ETH', vault: VAULT, memo: `=:e:${ME_EVM}:2097810`, operators: ['liquify', 'rorcual', 'ibs', 'thorwallet', 'keplr'], quoteOperators: ['rorcual', 'ibs', 'thorwallet', 'keplr'],120 vaultMembers: 19, churnHeight: 900, expectedOutDisplay: '0.02119', minOutDisplay: '0.0209781', outboundFeeDisplay: '0.00012108', estimatedSeconds: 606, evidenceMultiple: 116666 });121 expect(i.to).toBe(VAULT); expect(i.xswap).toMatchObject({ provider: 'thorchain', vault: VAULT, vaultPubKey: KEY, memo: `=:e:${ME_EVM}:2097810`, limit: '2097810', minOut: '20978100000000000', router: '', approveGas: '0' });122 expect(i.xswap!.expiry - i.createdAt).toBe(900); expect(i.expiresAt - i.createdAt).toBe(120);123 expect(Buffer.from(r.json.options.challenge, 'base64url').toString('hex')).toBe(p.intentHash); // the passkey approves exactly this intent124 expect(chain.prepared[0]).toMatchObject({ to: VAULT, amount: 60_000n, thor: { extraBytes: 9 + 2 + `=:e:${ME_EVM}:2097810`.length, minRate: 3 } }); // THORChain's fee rate, room for the memo, change forced125 });126 it('keeps the LOWEST quote when operators differ a little, and refuses when they differ by more than 1 %', async () => {127 net.expected = { 1: 2_119_000n, 2: 2_110_000n };128 expect((await prepare()).json.proposal.xswap.memo).toBe(`=:e:${ME_EVM}:2088900`);129 net.expected = { 1: 2_119_000n, 2: 2_090_000n };130 expect((await prepare()).json).toMatchObject({ error: 'quotes_disagree' });131 });132 it.each<[string, () => void, number, string]>([133 ['one operator does not list the vault as active', () => { net.status = { 3: 'RetiringVault' }; }, 503, 'vault_unconfirmed'],134 ['one operator is far behind', () => { net.behind = { 1: 80 }; }, 503, 'vault_unconfirmed'],135 ['one operator reports the chain halted', () => { net.halted.add(2); }, 503, 'vault_unconfirmed'],136 ['only two operators answer', () => { net.down = new Set([0, 1, 4]); }, 503, 'vault_unconfirmed'],137 ['only one operator quotes', () => { net.quoteDown = new Set([0, 1, 2, 3]); }, 503, 'provider_unavailable'],138 ['an operator expects another memo (another destination)', () => { net.memo = { 2: '=:e:0x70997970C51812dc3A010C7d01b50e0d17dc79C8:2097810' }; }, 502, 'provider_bad_answer'],139 ['an operator expects a streaming memo', () => { net.memo = { 2: `=:e:${ME_EVM}:2097810/1/0` }; }, 502, 'provider_bad_answer'],140 ['THORChain would stream the amount', () => { net.streaming = 4; }, 422, 'amount_would_stream'],141 ['the amount is below THORChain\'s minimum (a refund could not pay its fee)', () => { net.minIn = '70000'; }, 422, 'below_minimum'],142 ['another pool could be designated by the memo spelling', () => { net.extraPool = 'ETH.ETH-0X1111111111111111111111111111111111111111'; }, 503, 'pool_ambiguous'],143 ['the chain shows the "vault" holding less than 100 times the amount', () => { chain.vaultHolds = 5_000_000n; }, 503, 'vault_evidence'],144 ])('refuses when %s', async (_l, arrange, status, code) => {145 arrange(); const r = await prepare();146 expect(r.status, JSON.stringify(r.json)).toBe(status); expect(r.json.error).toBe(code);147 expect(await sql`select 1 from proposals where kind = 'xswap' and state = 'pending_approval'`).toHaveLength(0);148 });149});150151describe('sending', () => {152 it('signer signs, API re-checks the vault, relays once; one operation in flight on the source network until the outcome is known', async () => {153 const { id, ap } = await send(); expect(ap.status, JSON.stringify(ap.json)).toBe(200);154 expect(ap.json).toMatchObject({ state: 'broadcast', xswap: { stage: 'sent', phase: 'sent' } });155 expect(chain.sent).toHaveLength(1); expect(signerVault.calls).toEqual([{ chain: 'BTC', vaultPubKey: KEY, router: null, amount: '60000' as never }].map((w) => ({ ...w, amount: 60_000n })));156 expect(utxoPaymentTo(chain.sent[0]!, 'bitcoin', VAULT)).toMatchObject({ paid: 60_000n, memo: `=:e:${ME_EVM}:2097810` });157 expect(utxoPaymentTo(chain.sent[0]!, 'bitcoin', ME_BTC).paid).toBe(139_300n);158 expect((await prepare()).json.error).toBe('pending_tx');159 chain.track = { state: 'finalized', confirmations: 6, blockRef: 'b' }; await worker.reconcile();160 expect((await row(id)).state).toBe('finalized');161 expect((await prepare()).json.error).toBe('pending_tx'); // the source is final, the swap is not: still one at a time162 const types = (await sql`select type from audit_events order by seq`).map((r: any) => r.type);163 expect(types).toEqual(expect.arrayContaining(['xswap.prepared', 'transfer.signed', 'transfer.broadcast']));164 expect((await api('GET', '/api/v1/activity')).json.items.find((x: any) => x.id === id)).toMatchObject({ kind: 'swap', xswap: { stage: 'sent' } });165 });166 it('the signer\'s own operators refuse: nothing is signed, nothing is sent', async () => {167 signerVault.refuse = 'THORNode operator ibs does not list this vault; the vault is not confirmed';168 const { id, ap } = await send();169 expect(ap.status).toBe(422); expect(ap.json.error).toBe('signer_vault_unconfirmed'); expect(ap.json.message).toMatch(/does not list this vault/);170 expect((await row(id)).state).toBe('failed'); expect(chain.sent).toHaveLength(0); expect(store.state.spent).toEqual({});171 });172 it('churn between approval and relay: the signed transaction is NOT sent', async () => {173 const prep = await prepare(); const id = prep.json.proposal.id;174 net.status = { 0: 'RetiringVault', 1: 'RetiringVault', 2: 'RetiringVault', 3: 'RetiringVault', 4: 'RetiringVault' }; // the API's view changes; the signer signed a moment before175 const ap = await api('POST', `/api/v1/transfers/proposals/${id}/approve`, { assertion: owner.assert(Buffer.from(prep.json.options.challenge, 'base64url')) });176 expect(ap.json).toMatchObject({ state: 'failed', error: expect.stringMatching(/vault is no longer confirmed.*nothing was sent/) });177 expect(chain.sent).toHaveLength(0);178 });179 it('a new churn height for the same key also stops the relay; unreachable operators only delay it, and the window closes it', async () => {180 let prep = await prepare(); net.since = 950;181 let ap = await api('POST', `/api/v1/transfers/proposals/${prep.json.proposal.id}/approve`, { assertion: owner.assert(Buffer.from(prep.json.options.challenge, 'base64url')) });182 expect(ap.json).toMatchObject({ state: 'failed', error: expect.stringMatching(/churn/) });183 await sql`update proposals set state = 'failed' where id = ${prep.json.proposal.id}`; net.reset(); store.state.spent = {}; store.save();184 prep = await prepare(); const id = prep.json.proposal.id; net.down = new Set([0, 1, 2]);185 ap = await api('POST', `/api/v1/transfers/proposals/${id}/approve`, { assertion: owner.assert(Buffer.from(prep.json.options.challenge, 'base64url')) });186 expect(ap.json.state).toBe('signed'); expect(chain.sent).toHaveLength(0);187 await worker.reconcile(); expect((await row(id)).state).toBe('signed'); // still waiting for three operators188 await sql`update proposals set xswap = jsonb_set(xswap, '{expiry}', ${String(Math.floor(Date.now() / 1000) + 100)}::jsonb) where id = ${id}`; // three minutes before expiry: too late189 net.down.clear(); await worker.reconcile();190 expect(await row(id)).toMatchObject({ state: 'failed', error: expect.stringMatching(/in time; nothing was sent/) }); expect(chain.sent).toHaveLength(0);191 });192 it('a transaction that left the mempool is not relayed again late in its window', async () => {193 const { id } = await send(); expect(chain.sent).toHaveLength(1);194 chain.track = { state: 'broadcast', confirmations: 0, rebroadcast: true };195 await sql`update proposals set last_broadcast_at = now() - interval '2 minutes' where id = ${id}`; await worker.reconcile(); expect(chain.sent).toHaveLength(2); // early: relayed again196 await sql`update proposals set last_broadcast_at = now() - interval '2 minutes', xswap = jsonb_set(xswap, '{expiry}', ${String(Math.floor(Date.now() / 1000) + 60)}::jsonb) where id = ${id}`;197 await worker.reconcile(); expect(chain.sent).toHaveLength(2);198 });199});200201describe('tracking (resumes from the stored row; "received" only from the wallet\'s own chain data)', () => {202 it('sent → observed → outbound → received, each step from a freshly loaded row', async () => {203 const { id } = await send(); chain.track = { state: 'finalized', confirmations: 6, blockRef: 'b' }; await worker.reconcile();204 const step = async (d: ReturnType<typeof deps>) => { await sql`update proposals set xswap = jsonb_set(xswap, '{nextAt}', '0') where id = ${id}`; return xs.advanceXswap(await row(id), d); }; // as after a restart205 expect((await step(deps({ agree: false }))).xswap).toMatchObject({ phase: 'sent', polls: 1 }); // operators disagree or are down: nothing moves206 expect((await step(deps())).xswap).toMatchObject({ phase: 'sent' });207 expect((await step(deps({ observed: true }))).xswap).toMatchObject({ phase: 'observed' });208 expect((await step(deps({ observed: true, out: 'OUT' }))).xswap).toMatchObject({ phase: 'outbound', outTxid: OUT, outKind: 'out' }); // named by THORChain, not yet seen by the wallet209 expect((await api('GET', `/api/v1/transfers/proposals/${id}`)).json).toMatchObject({ xswap: { stage: 'outbound', receivedDisplay: null }, swap: { received: null } });210 expect((await step(deps({ observed: true, out: 'OUT', seen: { amount: 21_100_000_000_000_000n, confirmed: false } }))).xswap!.phase).toBe('outbound'); // unconfirmed is not received211 const done = await step(deps({ observed: true, out: 'OUT', seen: { amount: 21_100_000_000_000_000n, confirmed: true } }));212 expect(done.xswap).toMatchObject({ phase: 'received', received: '21100000000000000', hold: null });213 expect((await api('GET', `/api/v1/transfers/proposals/${id}`)).json).toMatchObject({ xswap: { stage: 'received', receivedDisplay: '0.0211', done: true }, swap: { received: '21100000000000000' } });214 expect((await prepare()).status).toBe(200); // the network is free again215 const types = (await sql`select type from audit_events order by seq`).map((r: any) => r.type);216 expect(types.slice(-3)).toEqual(expect.arrayContaining(['xswap.received'])); expect(types).toEqual(expect.arrayContaining(['xswap.observed', 'xswap.outbound']));217 });218 it('a refund is shown as refunded, never as received', async () => {219 const { id } = await send(); chain.track = { state: 'finalized', confirmations: 6, blockRef: 'b' }; await worker.reconcile();220 const step = async (d: ReturnType<typeof deps>) => { await sql`update proposals set xswap = jsonb_set(xswap, '{nextAt}', '0') where id = ${id}`; return xs.advanceXswap(await row(id), d); };221 await step(deps({ observed: true, refundPlanned: true }));222 expect((await api('GET', `/api/v1/transfers/proposals/${id}`)).json.xswap).toMatchObject({ stage: 'observed', refundPlanned: true });223 const r = await step(deps({ observed: true, out: 'REFUND', seen: { amount: 58_900n, confirmed: true } }));224 expect(r.xswap).toMatchObject({ phase: 'refunded', refunded: '58900', outKind: 'refund' }); expect(r.xswap!.received).toBeUndefined(); expect(r.out_amount).toBeNull();225 expect((await api('GET', `/api/v1/transfers/proposals/${id}`)).json).toMatchObject({ xswap: { stage: 'refunded', refundedDisplay: '0.000589', receivedDisplay: null }, swap: { received: null } });226 });227 it('a row left in "sending" by a process that died after the relay is picked up', async () => {228 const { id } = await send();229 await sql`update proposals set xswap = jsonb_set(xswap, '{phase}', '"sending"') where id = ${id}`;230 expect((await xs.advanceXswap(await row(id), deps({ observed: true }))).xswap!.phase).toBe('observed');231 });232});233234describe('the rules, without I/O', () => {235 const b0 = (o: Partial<XswapState> = {}): XswapState => ({ provider: 'thorchain', toNetwork: 'ethereum', toAsset: 'native', toSymbol: 'ETH', toDecimals: 18, expectedOut: '21190000000000000', minOut: '20978100000000000', limit1e8: '2097810', memo: 'm', vault: VAULT, vaultPubKey: KEY,236 router: null, expiry: 2_000_000_000, outboundFee: '1', liquidityFee: '1', estimatedSeconds: 606, slippageBps: 100, operators: [], quoteOperators: [], churnHeight: 900, vaultMembers: 19, evidence: { holds: '1', multiple: 1, transfersOut: null }, needsApproval: false, simulated: false,237 phase: 'sent', polls: 0, nextAt: 0, sentAt: 1_000_000, ...o });238 const obs = (o: Partial<XswapObs> = {}): XswapObs => ({ now: 1_000_000, sourceState: 'finalized', status: { observed: false, refundPlanned: false, outs: [] }, ...o });239 const IN = 'CD'.repeat(32), out = (memo: string) => [{ id: OUT, chain: 'ETH', to: 'x', memo }];240 it('churn while the source transaction is unconfirmed raises a hold, and it clears when the deposit is observed', () => {241 const a = st.xswapNext(b0(), obs({ sourceState: 'broadcast', vaultActive: false }), IN);242 expect(a.b).toMatchObject({ phase: 'sent', hold: 'vault_churned' }); expect(a.events).toEqual([['xswap.hold', { hold: 'vault_churned' }]]);243 expect(st.xswapNext(a.b, obs({ sourceState: 'broadcast', vaultActive: false }), IN).events).toEqual([]); // said once244 expect(st.xswapNext(b0(), obs({ sourceState: 'broadcast', vaultActive: null }), IN).b.hold ?? null).toBeNull(); // operators unreachable is not a churn245 expect(st.xswapNext(a.b, obs({ status: { observed: true, refundPlanned: false, outs: [] } }), IN).b).toMatchObject({ phase: 'observed', hold: null });246 });247 it('a deposit THORChain never reports, an outbound it never sends, an outbound the wallet never sees: holds, never success, never given up', () => {248 const late = 1_000_000 + 4 * 3600_000;249 expect(st.xswapNext(b0(), obs({ now: late }), IN).b).toMatchObject({ phase: 'sent', hold: 'not_observed' });250 expect(st.xswapNext(b0(), obs({ now: late, status: null }), IN).b).toMatchObject({ phase: 'sent', hold: 'not_observed' });251 expect(st.xswapNext(b0({ phase: 'observed', observedAt: 1_000_000 }), obs({ now: late, status: { observed: true, refundPlanned: false, outs: [] } }), IN).b).toMatchObject({ phase: 'observed', hold: 'outbound_late' });252 const o = st.xswapNext(b0({ phase: 'outbound', outTxid: OUT, outKind: 'out', outboundAt: 1_000_000 }), obs({ now: late, seen: null }), IN).b;253 expect(o).toMatchObject({ phase: 'outbound', hold: 'outbound_not_seen' }); expect(o.nextAt).toBeGreaterThan(late); expect(o.received).toBeUndefined();254 });255 it('the status endpoint alone never makes a swap "received"', () => {256 const r = st.xswapNext(b0({ phase: 'observed', observedAt: 1_000_000 }), obs({ status: { observed: true, refundPlanned: false, outs: out(`OUT:${IN}`) } }), IN);257 expect(r.b.phase).toBe('outbound'); expect(st.xswapTerminal(r.b)).toBe(false);258 expect(st.xswapNext(r.b, obs({ seen: 'mismatch' }), IN).b).toMatchObject({ phase: 'outbound', hold: 'outbound_mismatch' }); // the named transaction does not pay the wallet259 expect(st.xswapNext(r.b, obs({ seen: { amount: 1n, confirmed: true } }), IN).b).toMatchObject({ phase: 'received', received: '1', hold: 'below_minimum' });260 });261 it('an outbound for another deposit is ignored; a refund wins over an OUT of the same deposit', () => {262 const s = (memos: string[]) => st.xswapNext(b0({ phase: 'observed', observedAt: 1_000_000 }), obs({ status: { observed: true, refundPlanned: false, outs: memos.flatMap(out) } }), IN).b;263 expect(s([`OUT:${'EE'.repeat(32)}`]).phase).toBe('observed'); expect(s([`OUT:${IN}`, `REFUND:${IN}`])).toMatchObject({ phase: 'outbound', outKind: 'refund' });264 });265 it('two operators must give the same status; the most advanced agreed one is used', () => {266 const raw = (observed: boolean, outs: any[] = []) => ({ stages: { inbound_observed: { completed: observed } }, out_txs: outs, planned_out_txs: [{ refund: false }] });267 const a = xs.reduceThorStatus(raw(true)), b = xs.reduceThorStatus(raw(true, [{ id: OUT.toLowerCase(), chain: 'ETH', to_address: 'x', memo: `OUT:${IN}` }])), c = xs.reduceThorStatus(raw(false));268 expect(xs.agreeThorStatus([a, b, c])).toBeNull(); expect(xs.agreeThorStatus([a, a, b])).toEqual(a); expect(xs.agreeThorStatus([a, a, b, b])).toEqual(b); expect(xs.agreeThorStatus([])).toBeNull();269 expect(b.outs[0]!.id).toBe(OUT); expect(xs.reduceThorStatus(raw(true, [{ id: '0'.repeat(64), chain: 'THOR', to_address: 'x', memo: 'OUT:x' }])).outs).toEqual([]);270 expect(() => xs.reduceThorStatus({ stages: {} })).toThrow();271 });272});273