SPB Git

Repository Public

stellar.test.ts 17.2 KB 240 lines typescript

1// Stellar adapter against two scripted Horizon instances (no network): agreement rules, fail-closed paths, tracking and deposits.2import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';3import { NETWORKS, pluginFor, type Intent } from '@spw/core';4import { deriveKeys } from '../../isolated-signer/src/keys.ts';5import type { Adapter } from '../src/chains/rpc.ts';67const n = NETWORKS.stellar, [A, B] = n.rpc as [string, string];8const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');9const ME = keys.addresses.stellar!, DEST = 'GDVSYYTUAJ3ACHTPQNSTQBDQ4LDHQCMNY4FCEQH5TJUMSSLWQSTG42MV', NEW = 'GBFPWBTN4AXHPWPTQVQBP4KRZ2YVYYOGRMV2PEYL2OBPPJDP7LECEVHR';10const NOW = 1_800_000_000;1112interface Fake {13  down: boolean; passphrase: string; ledger: number; closedAt: number; baseReserve: number; p90: string;14  accounts: Record<string, any>; txs: Record<string, any>; payments: any[]; submit: (tx: string) => { status: number; body: any }; posts: string[]; raw404: boolean; seen: string[];15}16const acct = (id: string, over: Record<string, unknown> = {}) => ({ sequence: '1000', subentry_count: 0, num_sponsoring: 0, num_sponsored: 0, thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 },17  signers: [{ key: id, weight: 1, type: 'ed25519_public_key' }], balances: [{ asset_type: 'credit_alphanum4', balance: '9.0000000' }, { asset_type: 'native', balance: '100.0000000', selling_liabilities: '0.0000000' }], data: {}, ...over });18const fresh = (): Fake => ({ down: false, passphrase: n.chainRef, ledger: 5000, closedAt: NOW - 3, baseReserve: 5_000_000, p90: '100', accounts: { [ME]: acct(ME), [DEST]: acct(DEST) }, txs: {}, payments: [],19  submit: () => ({ status: 201, body: { tx_status: 'PENDING' } }), posts: [], raw404: false, seen: [] });20let h: Record<string, Fake>;21const iso = (s: number) => new Date(s * 1000).toISOString().replace('.000', '');22const notFound = { type: 'https://stellar.org/horizon-errors/not_found', title: 'Resource Missing', status: 404 };2324async function fakeFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {25  const url = new URL(String(input)), f = h[url.origin]!;26  const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });27  if (!f || f.down) throw new TypeError('fetch failed');28  f.seen.push(url.pathname + url.search);29  const miss = () => (f.raw404 ? new Response('<html>not here</html>', { status: 404 }) : json(404, notFound));30  if (init?.method === 'POST') {31    const tx = new URLSearchParams(String(init.body)).get('tx')!;32    f.posts.push(tx);33    const r = f.submit(tx);34    return json(r.status, r.body);35  }36  const p = url.pathname;37  if (p === '/') return json(200, { network_passphrase: f.passphrase, history_latest_ledger: f.ledger, history_latest_ledger_closed_at: iso(f.closedAt), core_latest_ledger: f.ledger });38  if (p === '/ledgers') return json(200, { _embedded: { records: [{ sequence: f.ledger, closed_at: iso(f.closedAt), base_fee_in_stroops: 100, base_reserve_in_stroops: f.baseReserve }] } });39  if (p === '/fee_stats') return json(200, { last_ledger_base_fee: '100', fee_charged: { p90: f.p90 } });40  let m = /^\/accounts\/([A-Z0-9]+)$/.exec(p);41  if (m) return f.accounts[m[1]!] ? json(200, f.accounts[m[1]!]) : miss();42  m = /^\/transactions\/([0-9a-f]{64})$/.exec(p);43  if (m) return f.txs[m[1]!] ? json(200, f.txs[m[1]!]) : miss();44  if (/^\/accounts\/[A-Z0-9]+\/payments$/.test(p)) {45    const cur = url.searchParams.get('cursor');46    const recs = f.payments.filter((o) => !cur || BigInt(o.id) > BigInt(cur));47    return json(200, { _embedded: { records: url.searchParams.get('order') === 'desc' ? [...recs].reverse() : recs } });48  }49  return json(500, {});50}5152let a: Adapter;53beforeAll(async () => {54  Object.assign(process.env, { SPW_ORIGIN: 'https://wallet.test', SPW_RP_ID: 'wallet.test', DATABASE_URL: 'postgres:///unused', SPW_SIGNER_SECRET: 'k'.repeat(48) });55  vi.useFakeTimers({ toFake: ['Date'] });56  vi.setSystemTime(NOW * 1000);57  vi.stubGlobal('fetch', fakeFetch);58  a = (await import('../src/chains/stellar.ts')).stellarAdapter(n);59});60afterAll(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });61beforeEach(() => { h = { [A]: fresh(), [B]: fresh() }; });6263const prep = (over: Partial<{ to: string; amount: bigint; memo: string; asset: string }> = {}) => a.prepare({ asset: 'native', from: ME, to: DEST, amount: 10_000_000n, ...over });64const code = async (p: Promise<unknown>) => { try { await p; } catch (e) { return (e as { code?: string }).code ?? `threw: ${(e as Error).message}`; } return 'ok'; };6566describe('stellar adapter: prepare', () => {67  it('agrees on both sources and returns the exact intent section', async () => {68    h[B]!.ledger = 5001; h[B]!.p90 = '5056'; // one ledger apart, different fee statistics: still fine69    const r = await prep();70    expect(r.section).toEqual({ x: { op: 'payment', sequence: '1001', fee: '10112', maxTime: String(NOW + 180), memoType: 'none' } });71    expect([r.maxFee, r.feeEstimate, r.ttlSec, r.warnings]).toEqual([10_112n, 10_112n, 120, []]);72    // the prepared section builds with the signer's plugin73    const intent: Intent = { v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: keys.keyId, network: 'stellar', chainRef: n.chainRef, asset: 'native', symbol: 'XLM', decimals: 7, amount: '10000000',74      from: ME, to: DEST, maxFee: r.maxFee.toString(), createdAt: NOW, expiresAt: NOW + r.ttlSec, policyVersion: 4, ...(r.section as { x: Record<string, string> }) };75    expect(() => pluginFor(n).build(intent, n)).not.toThrow();76  });77  it('chooses CreateAccount only when both sources say the destination does not exist', async () => {78    const r = await prep({ to: NEW, amount: 10_000_000n, memo: '77' });79    expect((r.section as any).x).toMatchObject({ op: 'createAccount', memoType: 'id' });80    expect(r.warnings).toEqual(['memo_numeric_id', 'creates_account']);81    expect(await code(prep({ to: NEW, amount: 9_999_999n }))).toBe('below_reserve');82    h[B]!.accounts[NEW] = acct(NEW);83    expect(await code(prep({ to: NEW }))).toBe('rpc_disagreement');84  });85  it('fee bid is floored and capped', async () => {86    expect((await prep()).maxFee).toBe(1_000n);87    h[A]!.p90 = '250000';88    const r = await prep();89    expect([r.maxFee, r.warnings]).toEqual([200_000n, ['network_congested']]);90  });91  it('fails closed when a source is down, lies about identity, answers oddly or disagrees', async () => {92    h[B]!.down = true; expect(await code(prep())).toBe('rpc_degraded');93    h[B] = { ...fresh(), passphrase: 'Test SDF Network ; September 2015' }; expect(await code(prep())).toBe('rpc_degraded');94    h[B] = { ...fresh(), raw404: true }; expect(await code(prep({ to: NEW }))).toBe('rpc_degraded'); // a 404 that is not Horizon's is not "account missing"95    h[B] = fresh(); h[B]!.accounts[ME] = acct(ME, { sequence: '1001' }); expect(await code(prep())).toBe('rpc_disagreement');96    h[B] = fresh(); h[B]!.accounts[ME].balances[1].balance = '99.9999999'; expect(await code(prep())).toBe('rpc_disagreement');97    h[B] = fresh(); h[B]!.baseReserve = 10_000_000; expect(await code(prep())).toBe('rpc_disagreement');98    h[B] = fresh(); h[B]!.ledger = 5007; expect(await code(prep())).toBe('rpc_disagreement');99    h[B] = fresh(); h[B]!.closedAt = NOW - 61; expect(await code(prep())).toBe('rpc_degraded'); // stale source or wrong local clock100    h[B] = fresh(); h[A]!.closedAt = NOW + 61; h[B]!.closedAt = NOW + 61; expect(await code(prep())).toBe('rpc_degraded');101    h[A] = fresh(); h[B] = fresh(); h[A]!.accounts[ME].sequence = '12x'; expect(await code(prep())).toBe('rpc_degraded');102    h[A] = fresh(); expect(await code(prep())).toBe('ok');103  });104  it('checks the spendable balance after the reserve, liabilities and fee', async () => {105    const set = (over: Record<string, unknown>, native: Record<string, string>) => { for (const u of [A, B]) h[u]!.accounts[ME] = acct(ME, { ...over, balances: [{ asset_type: 'native', selling_liabilities: '0.0000000', ...native }] }); };106    // reserve = (2 + 3 subentries + 2 sponsoring - 1 sponsored) × 0.5 = 3 XLM; fee 0.0001107    set({ subentry_count: 3, num_sponsoring: 2, num_sponsored: 1 }, { balance: '4.0001000' });108    expect(await code(prep({ amount: 10_000_000n }))).toBe('ok');109    expect(await code(prep({ amount: 10_000_001n }))).toBe('insufficient_funds');110    set({}, { balance: '2.0001000', selling_liabilities: '0.0000001' });111    expect(await code(prep({ amount: 10_000_000n }))).toBe('insufficient_funds');112    for (const u of [A, B]) delete h[u]!.accounts[ME];113    expect(await code(prep())).toBe('insufficient_funds'); // source account not activated114  });115  it('refuses multi-signature sources, missing required memos, bad memos, tokens and bad addresses', async () => {116    for (const u of [A, B]) h[u]!.accounts[ME] = acct(ME, { thresholds: { low_threshold: 1, med_threshold: 2, high_threshold: 2 } });117    expect(await code(prep())).toBe('unsupported_account');118    for (const u of [A, B]) h[u]!.accounts[ME] = acct(ME, { signers: [{ key: ME, weight: 0 }, { key: DEST, weight: 1 }] });119    expect(await code(prep())).toBe('unsupported_account');120    for (const u of [A, B]) { h[u]!.accounts[ME] = acct(ME); h[u]!.accounts[DEST] = acct(DEST, { data: { 'config.memo_required': 'MQ==' } }); }121    expect(await code(prep())).toBe('memo_required');122    expect(await code(prep({ memo: '12345' }))).toBe('ok');123    expect(await code(prep({ memo: 'x'.repeat(29) }))).toBe('bad_memo');124    expect(await code(prep({ asset: 'USDC' }))).toBe('unsupported_asset');125    expect(await code(prep({ to: ME }))).toBe('bad_address');126    expect(await code(prep({ to: DEST.toLowerCase() }))).toBe('bad_address');127    expect(await code(prep({ amount: 0n }))).toBe('bad_amount');128  });129});130131describe('stellar adapter: track', () => {132  const txid = 'ab'.repeat(32), maxTime = NOW + 100;133  const track = () => a.track({ txid, intent: { x: { maxTime: String(maxTime) } }, blockRef: null, broadcastAt: NOW });134  const rec = (successful: boolean, ledger = 4999) => ({ hash: txid, successful, ledger });135  it('finalized only when every reachable source shows a successful inclusion', async () => {136    h[A]!.txs[txid] = rec(true);137    expect(await track()).toEqual({ state: 'confirming', confirmations: 1, blockRef: '4999' }); // the other source has not ingested it yet138    h[B]!.txs[txid] = rec(true);139    expect(await track()).toEqual({ state: 'finalized', confirmations: 2, blockRef: '4999' });140    h[B]!.down = true;141    expect((await track()).state).toBe('finalized');142    h[B] = fresh(); h[B]!.txs[txid] = rec(true, 4998);143    await expect(track()).rejects.toThrow(/disagree/);144    h[B]!.txs[txid] = { ...rec(true), hash: 'cd'.repeat(32) };145    await expect(track()).rejects.toThrow(/bad transaction record/);146  });147  it('an on-chain failure is reported only when both sources agree', async () => {148    h[A]!.txs[txid] = rec(false); h[B]!.txs[txid] = rec(false);149    expect(await track()).toMatchObject({ state: 'failed', blockRef: '4999' });150    h[B]!.down = true;151    expect((await track()).state).toBe('confirming');152    h[B] = fresh(); h[B]!.txs[txid] = rec(true);153    await expect(track()).rejects.toThrow(/disagree/);154  });155  it('a hash alone is never success; expiry needs both sources past the time bound', async () => {156    expect(await track()).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: true });157    for (const u of [A, B]) h[u]!.closedAt = maxTime + 20; // exactly at the margin: not yet158    vi.setSystemTime((maxTime + 25) * 1000);159    expect(await track()).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: false });160    h[A]!.closedAt = maxTime + 21;161    expect((await track()).state).toBe('broadcast'); // one source has not closed a late-enough ledger162    h[B]!.closedAt = maxTime + 21;163    expect(await track()).toMatchObject({ state: 'failed', confirmations: 0 });164    h[B]!.down = true;165    expect((await track()).state).toBe('broadcast'); // never "failed" on a single source166    h[B] = fresh(); h[B]!.closedAt = maxTime + 30; h[B]!.txs[txid] = rec(true);167    expect((await track()).state).toBe('confirming'); // late-ingesting source: included after all168    // every source reads its ledger position before looking the transaction up169    for (const u of [A, B]) expect(h[u]!.seen.slice(-2)).toEqual(['/', `/transactions/${txid}`]);170    vi.setSystemTime(NOW * 1000);171    await expect(a.track({ txid, intent: { evm: {} }, blockRef: null, broadcastAt: NOW })).rejects.toThrow(/time bound/);172  });173});174175describe('stellar adapter: broadcast', () => {176  const intent: Intent = { v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: keys.keyId, network: 'stellar', chainRef: n.chainRef, asset: 'native', symbol: 'XLM', decimals: 7, amount: '10000000',177    from: ME, to: DEST, maxFee: '1000', createdAt: NOW, expiresAt: NOW + 120, policyVersion: 4, x: { op: 'payment', sequence: '1001', fee: '1000', maxTime: String(NOW + 180), memoType: 'none' } };178  const signed = pluginFor(n).sign(intent, n, keys.handle(n));179  const badSeq = { status: 400, body: { tx_status: 'ERROR', hash: signed.txid, errorResultXdr: 'AAAAAAAAAGT////7AAAAAA==' } }; // txBAD_SEQ180  it('sends the exact envelope to every source; pending or duplicate is accepted', async () => {181    h[A]!.submit = () => ({ status: 201, body: { tx_status: 'PENDING', hash: signed.txid } });182    h[B]!.submit = () => ({ status: 409, body: { tx_status: 'DUPLICATE', hash: signed.txid } });183    await a.broadcast(signed.raw);184    expect([h[A]!.posts, h[B]!.posts]).toEqual([[signed.raw], [signed.raw]]);185    h[A]!.down = true;186    await a.broadcast(signed.raw);187  });188  it('a refusal is an error unless the ledger already contains the transaction', async () => {189    for (const u of [A, B]) h[u]!.submit = () => badSeq;190    await expect(a.broadcast(signed.raw)).rejects.toThrow(/tx_bad_seq/);191    h[B]!.txs[signed.txid] = { hash: signed.txid, successful: true, ledger: 4999 };192    await a.broadcast(signed.raw); // already known193    h[B]!.txs = {};194    for (const u of [A, B]) h[u]!.submit = () => ({ status: 503, body: { tx_status: 'TRY_AGAIN_LATER', hash: signed.txid } });195    await expect(a.broadcast(signed.raw)).rejects.toThrow(/busy/);196    for (const u of [A, B]) h[u]!.submit = () => ({ status: 201, body: { tx_status: 'PENDING', hash: 'cd'.repeat(32) } });197    await expect(a.broadcast(signed.raw)).rejects.toThrow(/another transaction/);198    for (const u of [A, B]) h[u]!.submit = () => ({ status: 400, body: { title: 'Transaction Malformed', status: 400 } });199    await expect(a.broadcast(signed.raw)).rejects.toThrow(/Malformed/);200    await expect(a.broadcast('AAAA')).rejects.toThrow(/envelope/);201  });202});203204describe('stellar adapter: balances, deposits, health', () => {205  const op = (ledger: number, txOrder: number, idx: number, o: Record<string, unknown>) => { const id = ((BigInt(ledger) << 32n) | (BigInt(txOrder) << 12n) | BigInt(idx)).toString();206    return { id, paging_token: id, transaction_successful: true, transaction_hash: id.padStart(64, '0'), created_at: iso(NOW - 100), ...o }; };207  it('lists incoming native payments and account creations only', async () => {208    const pay = { type: 'payment', asset_type: 'native', from: DEST, to: ME, amount: '1.5000000' };209    h[A]!.payments = [210      op(4000, 1, 0, { type: 'create_account', funder: DEST, account: ME, starting_balance: '2.0000000' }),211      op(4500, 1, 0, pay), op(4500, 1, 3, { ...pay, amount: '0.0000001' }),212      op(4600, 2, 0, { ...pay, asset_type: 'credit_alphanum4', asset_code: 'USDC' }), // not XLM213      op(4700, 1, 0, { ...pay, from: ME, to: DEST }), // outgoing214      op(4800, 1, 0, { ...pay, transaction_successful: false }),215      op(4900, 1, 0, { type: 'create_account', funder: ME, account: DEST, starting_balance: '2.0000000' }), // we created someone else216      op(4950, 7, 1, { type: 'path_payment_strict_send', asset_type: 'native', from: DEST, to: ME, amount: '3.0000000' }),217      op(4990, 1, 0, { ...pay, amount: '7.0000000' }),218    ];219    const first = await a.deposits(ME, null);220    expect(first.items.map((d) => [d.block, d.idx, d.amount, d.from, d.asset])).toEqual([[4990, 0, 70_000_000n, DEST, 'native'], [4500, 3, 1n, DEST, 'native'], [4500, 0, 15_000_000n, DEST, 'native'], [4000, 0, 20_000_000n, DEST, 'native']]);221    expect([first.cursor, first.finalizedHeight, first.items[0]!.time]).toEqual([4998, 5000, NOW - 100]);222    const next = await a.deposits(ME, 4500);223    expect(next.items.map((d) => d.block)).toEqual([4990]);224    expect(h[A]!.seen.at(-1)).toContain(`cursor=${(4501n << 32n).toString()}`);225    h[A]!.down = true; // failover; an account that does not exist yet has no history226    expect(await a.deposits(NEW, 4998)).toEqual({ items: [], cursor: 4998, finalizedHeight: 5000 });227  });228  it('balances and health', async () => {229    expect(await a.balances(ME)).toEqual({ native: 1_000_000_000n });230    expect(await a.balances(NEW)).toEqual({ native: 0n });231    expect(await a.health()).toMatchObject({ ok: true, readable: true, identityVerified: true, height: 5000 });232    h[B]!.passphrase = 'Test SDF Network ; September 2015';233    expect((await a.health()).endpoints.map((e) => e.ok)).toEqual([true, false]);234    h[B] = fresh(); h[B]!.closedAt = NOW - 301;235    expect((await a.health()).ok).toBe(false);236    h[A]!.down = true; h[B]!.down = true;237    expect(await a.health()).toMatchObject({ ok: false, readable: false });238  });239});240
Files main
Menu

Theme