diff --git a/src/trader/main.ts b/src/trader/main.ts index 09333e6..9d62093 100644 --- a/src/trader/main.ts +++ b/src/trader/main.ts @@ -1497,7 +1497,22 @@ export async function startTrader(): Promise { logger.warn('test_fund_invalid_amount', { entry }); continue; } - const result = await sphere.payments.mintFungibleToken(coinIdHex, amount); + // mintFungibleToken was added in sphere-sdk's `refactor/extract-cli-to-sphere-cli` + // branch which has not landed in main. Guarded shim — the e2e suite uses the + // faucet path, not TRADER_TEST_FUND, so this guard never trips in production CI. + type MintFungibleApi = { + mintFungibleToken?: ( + coinId: string, + amount: bigint, + ) => Promise<{ success: boolean; tokenId: string; error?: string }>; + }; + const paymentsApi = sphere.payments as unknown as MintFungibleApi; + if (!paymentsApi.mintFungibleToken) { + throw new Error( + 'TRADER_TEST_FUND requires sphere-sdk with mintFungibleToken (only on refactor/extract-cli-to-sphere-cli branch).', + ); + } + const result = await paymentsApi.mintFungibleToken(coinIdHex, amount); if (!result.success) { logger.error('test_fund_mint_failed', { coin_id: coinIdHex.slice(0, 16) + '...', diff --git a/test/e2e-live/basic-roundtrip.e2e-live.test.ts b/test/e2e-live/basic-roundtrip.e2e-live.test.ts index 17bb0d9..f6f1f29 100644 --- a/test/e2e-live/basic-roundtrip.e2e-live.test.ts +++ b/test/e2e-live/basic-roundtrip.e2e-live.test.ts @@ -31,6 +31,10 @@ import { runTraderCtl } from './helpers/trader-ctl-driver.js'; import { getControllerWallet } from './helpers/tenant-fixture.js'; import { getContainerLogs } from './helpers/docker-helpers.js'; import { TESTNET, UCT_COIN_ID, USDU_COIN_ID } from './helpers/constants.js'; +import { + snapshotPortfolio, + expectBalanceUnchanged, +} from './helpers/portfolio-assertions.js'; // --------------------------------------------------------------------------- // Shared fixtures: provisioned ONCE per file to amortize faucet rate-limits @@ -317,6 +321,15 @@ describe('Basic round-trip trading', () => { } it('seller cancels intent before match → cancelled state observed', async () => { + // Audit Claim 5b: cancel-before-match must not move tokens. Snapshot + // before / cancel intent / snapshot after / assert unchanged. The window + // between create and cancel is ~1s and the intent uses an unusual rate + // (5) that won't match standard market peers — match probability is + // negligible. If a parallel testnet peer DOES happen to fill in that + // window, this assertion fires; the operator should investigate via the + // emitted delta message. + const sellerBalBefore = await snapshotPortfolio(seller.address); + // Seller alone — no matching buyer intent posted in this scenario. const create = await authedTraderCtl( 'create-intent', @@ -379,9 +392,21 @@ describe('Basic round-trip trading', () => { { timeout: 60_000, interval: 2_000 }, ) .toBe(true); + + // Cancel was successful and no swap occurred — balance must be unchanged. + const sellerBalAfter = await snapshotPortfolio(seller.address); + expectBalanceUnchanged(sellerBalBefore, sellerBalAfter); }, 5 * 60_000); it('intent expires before match → expired state observed', async () => { + // Audit Claim 5b: expire-without-match must not move tokens. Same + // shared-aggregator caveat as the cancel test — the intent uses an + // unusual rate (7) and the expiry window is short (15s), so match + // probability is low but non-zero. The emitted delta message on + // failure tells the operator whether spurious testnet noise is + // responsible. + const sellerBalBefore = await snapshotPortfolio(seller.address); + // Short expiry, no matching counterparty intent → engine should mark EXPIRED. const expiryMs = 15_000; const create = await authedTraderCtl( @@ -432,5 +457,9 @@ describe('Basic round-trip trading', () => { { timeout: expiryMs * 4 + 30_000, interval: 2_000 }, ) .toBe(true); + + // Intent expired without matching — balance must be unchanged. + const sellerBalAfter = await snapshotPortfolio(seller.address); + expectBalanceUnchanged(sellerBalBefore, sellerBalAfter); }, 5 * 60_000); }); diff --git a/test/e2e-live/edge-cases.e2e-live.test.ts b/test/e2e-live/edge-cases.e2e-live.test.ts index 0a8373b..9fd6c71 100644 --- a/test/e2e-live/edge-cases.e2e-live.test.ts +++ b/test/e2e-live/edge-cases.e2e-live.test.ts @@ -337,6 +337,15 @@ describe('Edge cases', () => { 45_000, // quiet window — at least one full scan_interval cycle ); expect(stillUnmatched).toBe(true); + + // Note: a strict `expectBalanceUnchanged` would be incorrect here. The + // testnet aggregator is shared with other concurrent tests / live peers, + // and Alice's `sell at 100-200` could legitimately match an unrelated + // peer's `buy at >= 100` during the quiet window. The intent-level + // assertion above (volume_filled === 0 on the named intents inside + // intentsRemainUnmatched) is the right granularity: it proves the named + // intents stayed unmatched while accepting that Alice's wallet may + // legitimately have moved through a parallel match. }, 5 * 60_000, ); diff --git a/test/e2e-live/helpers/constants.ts b/test/e2e-live/helpers/constants.ts index 8669a98..b39f7ed 100644 --- a/test/e2e-live/helpers/constants.ts +++ b/test/e2e-live/helpers/constants.ts @@ -30,6 +30,17 @@ export const IPFS_GATEWAY = 'https://unicity-ipfs1.dyndns.org'; */ export const FAUCET_URL = 'https://faucet.unicity.network/api/v1/faucet/request'; +/** + * Testnet Market API endpoint — the intent database that traders post to + * and search against for counterparty discovery. Pinned here (rather than + * relying on the trader image's hard-coded default) so that: + * 1. Tests can assert which Market API is being exercised, and + * 2. The infra-probe preflight gate verifies this exact endpoint. + * + * Matches `@unicitylabs/sphere-sdk` constants `DEFAULT_MARKET_API_URL`. + */ +export const MARKET_API_URL = 'https://market-api.unicity.network'; + /** * Canonical CoinId bytes from the public testnet registry * (https://raw.githubusercontent.com/unicitynetwork/unicity-ids/refs/heads/main/unicity-ids.testnet.json). @@ -71,6 +82,7 @@ export const TESTNET: TestnetConstants = { AGGREGATOR_URL, IPFS_GATEWAY, FAUCET_URL, + MARKET_API_URL, TRADER_IMAGE, ESCROW_IMAGE, DEFAULT_TIMEOUT_MS, diff --git a/test/e2e-live/helpers/contracts.ts b/test/e2e-live/helpers/contracts.ts index 2b41274..9ac9b83 100644 --- a/test/e2e-live/helpers/contracts.ts +++ b/test/e2e-live/helpers/contracts.ts @@ -176,6 +176,8 @@ export interface TestnetConstants { readonly IPFS_GATEWAY: string; /** Faucet endpoint. */ readonly FAUCET_URL: string; + /** Market API endpoint (intent database for counterparty discovery). */ + readonly MARKET_API_URL: string; /** Default trader image (matches templates.json shortcut). */ readonly TRADER_IMAGE: string; /** Default escrow image. */ diff --git a/test/e2e-live/helpers/portfolio-assertions.ts b/test/e2e-live/helpers/portfolio-assertions.ts new file mode 100644 index 0000000..d661986 --- /dev/null +++ b/test/e2e-live/helpers/portfolio-assertions.ts @@ -0,0 +1,235 @@ +/** + * portfolio-assertions — pre/post balance snapshots and delta assertions. + * + * Originally inlined in `basic-roundtrip.e2e-live.test.ts:127-170` and asserted + * only there. Audit (May 2026) flagged that every other test file checked + * `state === 'COMPLETED'` or `state === 'FAILED'` only — a regression that + * left state machines green but skipped token transfer (or stranded tokens + * after a failure) would silently pass. Extracted here so every scenario can + * verify actual on-chain balance changes. + * + * Public surface: + * - `getPortfolio(tenant)` — raw portfolio JSON via trader-ctl + * - `balanceOf(portfolio, symbol)` — smallest-units bigint per coin + * - `snapshotPortfolio(tenant)` — compact { UCT: bigint, USDU: bigint } map + * - `expectBalanceDelta(before, after, expected)` — assert per-coin deltas + * - `expectBalanceUnchanged(before, after, tolerance?)` — no swap occurred + * - `pollUntilBalanceRestored(tenant, baseline, opts?)` — wait for refund + * after an unhappy-path failure (testnet refunds take time to propagate) + */ + +import { expect } from 'vitest'; + +import { runTraderCtl } from './trader-ctl-driver.js'; +import { getControllerWallet } from './tenant-fixture.js'; +import { TESTNET } from './constants.js'; + +// ============================================================================= +// Raw portfolio access +// ============================================================================= + +/** Symbols we exercise in the e2e suite. Extend as new coins are added. */ +export type TrackedCoin = 'UCT' | 'USDU'; +const TRACKED_COINS: ReadonlyArray = ['UCT', 'USDU']; + +/** Compact balance snapshot keyed by coin symbol. Values in smallest units. */ +export type Portfolio = Record; + +/** + * Extract a coin's confirmed balance (in smallest units) from the trader-ctl + * portfolio JSON. Returns 0n if the coin isn't present. + * + * GET_PORTFOLIO emits each balance as + * { asset: , available, total, confirmed, unconfirmed } + * with `asset` set to the SDK-known symbol when available (e.g. 'UCT', + * 'USDU'), falling back to the raw coinId hash. We match by symbol. + */ +export function balanceOf(portfolio: unknown, coinSymbol: string): bigint { + if (typeof portfolio !== 'object' || portfolio === null) return 0n; + const obj = portfolio as Record; + const balances = + (obj['balances'] ?? (obj['result'] as Record | undefined)?.['balances']) as + | Array> + | undefined; + if (!Array.isArray(balances)) return 0n; + for (const b of balances) { + const asset = b['asset'] as string | undefined; + if (asset === coinSymbol) { + return BigInt(String(b['confirmed'] ?? '0')); + } + } + return 0n; +} + +/** Run trader-ctl `portfolio` and return the raw JSON. Throws on non-zero exit. */ +export async function getPortfolio(tenantAddress: string): Promise { + const controller = await getControllerWallet(); + const result = await runTraderCtl('portfolio', [], { + tenant: tenantAddress, + timeoutMs: 10_000, + json: true, + dataDir: controller.dataDir, + tokensDir: controller.tokensDir, + }); + if (result.exitCode !== 0) { + throw new Error( + `portfolio query failed for ${tenantAddress}: exit ${result.exitCode} | ` + + `stderr: ${result.stderr || ''} | ` + + `output: ${JSON.stringify(result.output)?.slice(0, 200)}`, + ); + } + return result.output; +} + +// ============================================================================= +// Compact snapshots +// ============================================================================= + +/** + * Snapshot a tenant's confirmed balances for the tracked coins. + * + * Use when you need to compare pre/post balances. The compact `Portfolio` + * shape lets you write `before.UCT - after.UCT === expected` directly without + * navigating the trader-ctl JSON envelope. + */ +export async function snapshotPortfolio(tenantAddress: string): Promise { + const raw = await getPortfolio(tenantAddress); + const snap: Partial = {}; + for (const coin of TRACKED_COINS) { + snap[coin] = balanceOf(raw, coin); + } + return snap as Portfolio; +} + +// ============================================================================= +// Delta assertions (happy path) +// ============================================================================= + +/** + * Assert that each coin's balance changed by exactly `expected[coin]`. + * + * `expected` values are signed bigints — positive = received, negative = paid. + * Coins not in `expected` are NOT checked (use `expectBalanceUnchanged` if you + * need a stronger "nothing else moved" assertion). + * + * Common pattern: + * ```ts + * const before = await snapshotPortfolio(buyer.address); + * // ... swap happens ... + * const after = await snapshotPortfolio(buyer.address); + * expectBalanceDelta(before, after, { UCT: +volume, USDU: -(rate * volume) }); + * ``` + */ +export function expectBalanceDelta( + before: Portfolio, + after: Portfolio, + expected: Partial>, +): void { + for (const [coin, expectedDelta] of Object.entries(expected) as Array<[TrackedCoin, bigint]>) { + const actual = after[coin] - before[coin]; + expect( + actual, + `${coin}: expected delta ${expectedDelta.toString()}, got ${actual.toString()} ` + + `(before=${before[coin].toString()}, after=${after[coin].toString()})`, + ).toBe(expectedDelta); + } +} + +// ============================================================================= +// "Nothing moved" assertions (rejected scenarios) +// ============================================================================= + +/** + * Assert that no tracked-coin balance changed. Used when a scenario should + * NOT cause any token movement — incompatible rates, blocked counterparty, + * cancelled-before-match, expired intent. A regression that accidentally + * moved tokens here would otherwise pass silently. + * + * `tolerance` allows for testnet noise (e.g., dust from a parallel test on + * the shared aggregator). Default is 0 — strictly unchanged. + */ +export function expectBalanceUnchanged( + before: Portfolio, + after: Portfolio, + tolerance: bigint = 0n, +): void { + for (const coin of TRACKED_COINS) { + const delta = after[coin] - before[coin]; + const absDelta = delta < 0n ? -delta : delta; + expect( + absDelta <= tolerance, + `${coin}: expected no change (tolerance=${tolerance.toString()}), ` + + `got delta=${delta.toString()} ` + + `(before=${before[coin].toString()}, after=${after[coin].toString()})`, + ).toBe(true); + } +} + +// ============================================================================= +// Refund-propagation poll (unhappy-path balance restoration) +// ============================================================================= + +export interface PollBalanceRestoredOpts { + /** Polling interval. Default: 5s. */ + intervalMs?: number; + /** Total timeout. Default: SWAP_TIMEOUT_MS (25 min) — testnet refunds via + * escrow auto-return + L3 finality can take many minutes. */ + timeoutMs?: number; + /** Per-coin tolerance for "restored". Default: 0n (strict). */ + tolerance?: bigint; +} + +/** + * After an unhappy-path failure, poll the tenant's portfolio until balances + * return to the supplied baseline (within tolerance) or the timeout elapses. + * + * Use when a scenario deposited tokens but should fail and refund. The escrow's + * `closeInvoice({autoReturn: true})` and the trader's own `setAutoReturn` need + * time on testnet — verifying balance restoration is the only way to confirm + * "all assets returned to original owners" (Claim 5b in the e2e audit). The + * weaker `state === 'FAILED'` check is necessary but not sufficient. + * + * @returns The final snapshot. + * @throws If timeout elapses without restoration. The error message includes + * per-coin deltas so the operator can see which coin is stuck. + */ +export async function pollUntilBalanceRestored( + tenantAddress: string, + baseline: Portfolio, + opts: PollBalanceRestoredOpts = {}, +): Promise { + const intervalMs = opts.intervalMs ?? 5_000; + const timeoutMs = opts.timeoutMs ?? TESTNET.SWAP_TIMEOUT_MS; + const tolerance = opts.tolerance ?? 0n; + const deadline = Date.now() + timeoutMs; + + let last: Portfolio | null = null; + while (Date.now() < deadline) { + last = await snapshotPortfolio(tenantAddress); + let restored = true; + for (const coin of TRACKED_COINS) { + const delta = last[coin] - baseline[coin]; + const absDelta = delta < 0n ? -delta : delta; + if (absDelta > tolerance) { + restored = false; + break; + } + } + if (restored) return last; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + // Timed out — produce a useful error message + const final = last ?? (await snapshotPortfolio(tenantAddress)); + const deltas = TRACKED_COINS.map( + (coin) => + `${coin}: baseline=${baseline[coin].toString()} ` + + `final=${final[coin].toString()} ` + + `delta=${(final[coin] - baseline[coin]).toString()}`, + ).join(', '); + throw new Error( + `pollUntilBalanceRestored timed out after ${timeoutMs}ms for ${tenantAddress}: ` + + `balance NOT restored to baseline. Deltas: ${deltas}. ` + + `(This indicates assets were not returned to the original owner — see Claim 5b.)`, + ); +} diff --git a/test/e2e-live/helpers/tenant-fixture.ts b/test/e2e-live/helpers/tenant-fixture.ts index a9631b4..cd8e6d4 100644 --- a/test/e2e-live/helpers/tenant-fixture.ts +++ b/test/e2e-live/helpers/tenant-fixture.ts @@ -23,12 +23,19 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { randomUUID } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import { - generatePrivateKey, getPublicKey, Sphere, } from '@unicitylabs/sphere-sdk'; + +// `@unicitylabs/sphere-sdk` no longer exports `generatePrivateKey` at the +// package root (moved to the L1 sub-namespace). A secp256k1 private key is +// just 32 random bytes — inline here so this fixture compiles against current +// sphere-sdk regardless of which adjacent PR lands first. +function generatePrivateKey(): string { + return randomBytes(32).toString('hex'); +} import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs'; import type { diff --git a/test/e2e-live/multi-agent-disjoint.e2e-live.test.ts b/test/e2e-live/multi-agent-disjoint.e2e-live.test.ts index 01d7400..6c1e399 100644 --- a/test/e2e-live/multi-agent-disjoint.e2e-live.test.ts +++ b/test/e2e-live/multi-agent-disjoint.e2e-live.test.ts @@ -40,6 +40,10 @@ import { waitForDealInState } from './helpers/scenario-helpers.js'; import { runTraderCtl } from './helpers/trader-ctl-driver.js'; import { getControllerWallet } from './helpers/tenant-fixture.js'; import { TESTNET } from './helpers/constants.js'; +import { + snapshotPortfolio, + expectBalanceDelta, +} from './helpers/portfolio-assertions.js'; async function runAuthedTraderCtl( cmd: string, @@ -214,6 +218,15 @@ describe('Multi-agent disjoint pairs', () => { await cancelActiveIntents(t); } + // Snapshot balances — Audit Claim 4: with two pairs swapping through + // the same escrow, exact deltas are computable: + // Pair 1 (rate=1, vol=200): alice -200 UCT +200 USDU; bob +200 UCT -200 USDU + // Pair 2 (rate=2, vol=100): carol -100 UCT +200 USDU; dave +100 UCT -200 USDU + const aliceBalBefore = await snapshotPortfolio(alice.address); + const bobBalBefore = await snapshotPortfolio(bob.address); + const carolBalBefore = await snapshotPortfolio(carol.address); + const daveBalBefore = await snapshotPortfolio(dave.address); + // Pair 1 — alice (sell @ 1) ↔ bob (buy @ 1), 200 UCT // Pair 2 — carol (sell @ 2) ↔ dave (buy @ 2), 100 UCT // Posting sequentially (not Promise.all) — parallel trader-ctl invocations @@ -270,6 +283,20 @@ describe('Multi-agent disjoint pairs', () => { expect(aliceDeal['deal_id']).toBe(bobDeal['deal_id']); expect(carolDeal['deal_id']).toBe(daveDeal['deal_id']); expect(aliceDeal['deal_id']).not.toBe(carolDeal['deal_id']); + + // Wait for inbound payouts to finalize (15s receive loop + margin). + await new Promise((resolve) => setTimeout(resolve, 8_000)); + + // Exact balance assertions — both pairs swapped through the same escrow + // with no cross-contamination. + const aliceBalAfter = await snapshotPortfolio(alice.address); + const bobBalAfter = await snapshotPortfolio(bob.address); + const carolBalAfter = await snapshotPortfolio(carol.address); + const daveBalAfter = await snapshotPortfolio(dave.address); + expectBalanceDelta(aliceBalBefore, aliceBalAfter, { UCT: -200n, USDU: 200n }); + expectBalanceDelta(bobBalBefore, bobBalAfter, { UCT: 200n, USDU: -200n }); + expectBalanceDelta(carolBalBefore, carolBalAfter, { UCT: -100n, USDU: 200n }); + expectBalanceDelta(daveBalBefore, daveBalAfter, { UCT: 100n, USDU: -200n }); }, TESTNET.SWAP_TIMEOUT_MS + 120_000, ); diff --git a/test/e2e-live/multi-agent.e2e-live.test.ts b/test/e2e-live/multi-agent.e2e-live.test.ts index 448a277..1836c9e 100644 --- a/test/e2e-live/multi-agent.e2e-live.test.ts +++ b/test/e2e-live/multi-agent.e2e-live.test.ts @@ -23,6 +23,11 @@ import { import { waitForDealInState } from './helpers/scenario-helpers.js'; import { runTraderCtl } from './helpers/trader-ctl-driver.js'; import { getControllerWallet } from './helpers/tenant-fixture.js'; +import { + snapshotPortfolio, + expectBalanceDelta, + type Portfolio, +} from './helpers/portfolio-assertions.js'; /** * Wrapper that auto-attaches controller-wallet credentials so the trader @@ -253,6 +258,15 @@ describe('Multi-agent trading', () => { volumeMax: 200n, }); + // Snapshot balances before — Audit Claim 4: state==='COMPLETED' alone is + // insufficient evidence that real tokens moved. Conservation invariant: + // sum of (post - pre) UCT across all 3 traders must be 0; same for USDU. + // Plus: each trader's balance MUST have changed (a no-op regression + // would leave all three unchanged). + const aliceBalBefore = await snapshotPortfolio(alice.address); + const bobBalBefore = await snapshotPortfolio(bob.address); + const carolBalBefore = await snapshotPortfolio(carol.address); + // Each trader must observe at least one COMPLETED deal. const [aliceDeal, bobDeal, carolDeal] = await Promise.all([ waitForDealInState(alice, 'COMPLETED', TESTNET.SWAP_TIMEOUT_MS), @@ -262,6 +276,38 @@ describe('Multi-agent trading', () => { expect(aliceDeal['state']).toBe('COMPLETED'); expect(bobDeal['state']).toBe('COMPLETED'); expect(carolDeal['state']).toBe('COMPLETED'); + + // Wait for payments.receive() to finalize inbound payouts (15s loop). + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + const aliceBalAfter = await snapshotPortfolio(alice.address); + const bobBalAfter = await snapshotPortfolio(bob.address); + const carolBalAfter = await snapshotPortfolio(carol.address); + + // Conservation: total UCT delta = 0; total USDU delta = 0. + // Tokens move between wallets; they don't appear or disappear. + const totalUctDelta = + (aliceBalAfter.UCT - aliceBalBefore.UCT) + + (bobBalAfter.UCT - bobBalBefore.UCT) + + (carolBalAfter.UCT - carolBalBefore.UCT); + const totalUsduDelta = + (aliceBalAfter.USDU - aliceBalBefore.USDU) + + (bobBalAfter.USDU - bobBalBefore.USDU) + + (carolBalAfter.USDU - carolBalBefore.USDU); + expect(totalUctDelta, 'conservation: sum of UCT deltas across 3 traders must be 0').toBe(0n); + expect(totalUsduDelta, 'conservation: sum of USDU deltas across 3 traders must be 0').toBe(0n); + + // No-op detection: every trader participated in at least one deal, so + // at least one coin's balance must have changed for each. + const aliceChanged = + aliceBalAfter.UCT !== aliceBalBefore.UCT || aliceBalAfter.USDU !== aliceBalBefore.USDU; + const bobChanged = + bobBalAfter.UCT !== bobBalBefore.UCT || bobBalAfter.USDU !== bobBalBefore.USDU; + const carolChanged = + carolBalAfter.UCT !== carolBalBefore.UCT || carolBalAfter.USDU !== carolBalBefore.USDU; + expect(aliceChanged, 'Alice participated in a COMPLETED deal but her balance did not change').toBe(true); + expect(bobChanged, 'Bob participated in COMPLETED deals but his balance did not change').toBe(true); + expect(carolChanged, 'Carol participated in a COMPLETED deal but her balance did not change').toBe(true); }, TESTNET.SWAP_TIMEOUT_MS + 60_000, ); @@ -273,6 +319,11 @@ describe('Multi-agent trading', () => { await cancelActiveIntents(t); } + // Snapshot balances — Alice should sell exactly 30 UCT for 30 USDU + // (rate=1, volume=30); Bob should buy exactly 30 UCT for 30 USDU. + const aliceBalBefore = await snapshotPortfolio(alice.address); + const bobBalBefore = await snapshotPortfolio(bob.address); + const aliceIntentId = await createIntent(alice, { direction: 'sell', rateMin: 1n, @@ -319,6 +370,18 @@ describe('Multi-agent trading', () => { { timeout: TESTNET.SWAP_TIMEOUT_MS, interval: 2_000 }, ) .toMatchObject({ volumeFilled: 30n, volumeMax: 100n }); + + // Wait for inbound payouts to finalize (15s receive loop + margin). + await new Promise((resolve) => setTimeout(resolve, 8_000)); + + // Audit Claim 4 + 5a(c): exact balance deltas must reflect the partial + // fill. Alice sold 30 UCT at rate=1 → -30 UCT, +30 USDU. Bob bought + // 30 UCT at rate=1 → +30 UCT, -30 USDU. The intent-ledger assertion + // above is bookkeeping — these checks verify real on-chain movement. + const aliceBalAfter = await snapshotPortfolio(alice.address); + const bobBalAfter = await snapshotPortfolio(bob.address); + expectBalanceDelta(aliceBalBefore, aliceBalAfter, { UCT: -30n, USDU: 30n }); + expectBalanceDelta(bobBalBefore, bobBalAfter, { UCT: 30n, USDU: -30n }); }, TESTNET.SWAP_TIMEOUT_MS + 120_000, ); @@ -330,6 +393,17 @@ describe('Multi-agent trading', () => { await cancelActiveIntents(t); } + // Snapshot balances BEFORE the swap so we can verify conservation + + // exact deltas after the winner is determined. + const aliceBalBefore = await snapshotPortfolio(alice.address); + const bobBalBefore = await snapshotPortfolio(bob.address); + const carolBalBefore = await snapshotPortfolio(carol.address); + const buyerBalBefores: Record = { + [bob.address]: bobBalBefore, + [carol.address]: carolBalBefore, + }; + const aliceBefore = aliceBalBefore; + // alice sells; bob and carol both want to buy. Per spec 5.7 the // deterministic proposer election picks ONE counterparty per fan-out // round, so alice's intent must end up filled exactly once @@ -391,6 +465,7 @@ describe('Multi-agent trading', () => { (i) => BigInt(String(i['volume_filled'] ?? '0')) === 200n, ); const loser = bobFilled ? carol : bob; + const winner = bobFilled ? bob : carol; const loserIntents = await runAuthedTraderCtl( 'list-intents', [], @@ -407,6 +482,20 @@ describe('Multi-agent trading', () => { const filled = BigInt(String(i['volume_filled'] ?? '0')); expect(filled).not.toBe(200n); } + + // Audit Claim 4 + 5a: verify exact deltas now that we know who won. + // Wait for inbound payouts to finalize (15s receive loop + margin). + await new Promise((resolve) => setTimeout(resolve, 8_000)); + const aliceBalAfter = await snapshotPortfolio(alice.address); + const winnerBalAfter = await snapshotPortfolio(winner.address); + const loserBalAfter = await snapshotPortfolio(loser.address); + + // Alice (seller) — sold 200 UCT for 200 USDU at rate=1. + expectBalanceDelta(aliceBefore, aliceBalAfter, { UCT: -200n, USDU: 200n }); + // Winner — bought 200 UCT for 200 USDU. + expectBalanceDelta(buyerBalBefores[winner.address]!, winnerBalAfter, { UCT: 200n, USDU: -200n }); + // Loser — never traded. Balance unchanged. + expectBalanceDelta(buyerBalBefores[loser.address]!, loserBalAfter, { UCT: 0n, USDU: 0n }); }, TESTNET.SWAP_TIMEOUT_MS + 120_000, ); diff --git a/test/e2e-live/negotiation-failures.e2e-live.test.ts b/test/e2e-live/negotiation-failures.e2e-live.test.ts index d6d6e05..06fb796 100644 --- a/test/e2e-live/negotiation-failures.e2e-live.test.ts +++ b/test/e2e-live/negotiation-failures.e2e-live.test.ts @@ -24,6 +24,11 @@ import { } from './helpers/scenario-helpers.js'; import { runTraderCtl } from './helpers/trader-ctl-driver.js'; import { getControllerWallet } from './helpers/tenant-fixture.js'; +import { + snapshotPortfolio, + expectBalanceUnchanged, + pollUntilBalanceRestored, +} from './helpers/portfolio-assertions.js'; /** * Wrapper that auto-attaches controller-wallet credentials so the trader @@ -230,6 +235,12 @@ describe('Negotiation failures', () => { await cancelActiveIntents(alice); await cancelActiveIntents(bob); + // Audit Claim 5b: untrusted-escrow rejection must happen BEFORE deposit, + // so neither Alice nor Bob should have moved any tokens. Snapshot before, + // assert unchanged after the test confirms no COMPLETED deal. + const aliceBalBefore = await snapshotPortfolio(alice.address); + const bobBalBefore = await snapshotPortfolio(bob.address); + // Repoint Bob at the untrusted escrow ONLY for this test. Alice still // only trusts the original. Bob will create his intent advertising // untrustedEscrow, which Alice's intent-engine must reject when it @@ -293,6 +304,14 @@ describe('Negotiation failures', () => { ['--trusted-escrows', trustedEscrow.address], { tenant: bob.address, json: true }, ).catch(() => undefined); + + // No deposit should have occurred — both balances must be unchanged. + // The intent engine rejects the deal pre-acceptance when it sees the + // untrusted escrow address, so payInvoice is never called on either side. + const aliceBalAfter = await snapshotPortfolio(alice.address); + const bobBalAfter = await snapshotPortfolio(bob.address); + expectBalanceUnchanged(aliceBalBefore, aliceBalAfter); + expectBalanceUnchanged(bobBalBefore, bobBalAfter); }, 10 * 60_000, ); @@ -303,6 +322,18 @@ describe('Negotiation failures', () => { await cancelActiveIntents(alice); await cancelActiveIntents(faultyTrader); + // Audit Claim 5b — THE CANONICAL "did Alice get her tokens back?" case. + // Alice DOES deposit her tokens to the escrow. The faulty counterparty + // intentionally skips its deposit. The escrow's deposit_timeout fires; + // the deal goes FAILED. The user's claim — "all assets returned to + // their original owners on unhappy paths" — requires Alice's deposit to + // be refunded by the escrow's auto-return-on-cancel mechanism. This was + // historically asserted only as `state === 'FAILED' && error_code !== ''`, + // which a regression that stranded Alice's tokens would silently pass. + // We now poll until balance restoration to verify the refund actually + // propagates back to Alice. + const aliceBalBefore = await snapshotPortfolio(alice.address); + // Pair alice (deposits normally) with `faultyTrader` (TRADER_FAULT_SKIP_DEPOSITS=1 // — receives swap:announced but skips swapModule.deposit()). The deal // must land in FAILED on alice's side. The exact error_code depends on @@ -344,6 +375,27 @@ describe('Negotiation failures', () => { // signal, not a test invariant. const errorCode = String(failed['error_code'] ?? ''); expect(errorCode).not.toBe(''); + + // CLAIM 5b ASSERTION: Alice's tokens must be returned to her. + // + // The deal is now FAILED. Alice's deposit (if she made one — depending on + // proposer-election) is held by the escrow. The escrow's + // closeInvoice({autoReturn:true}) on the deposit invoice (escrow-service + // commit b97a84b) plus Alice's own setAutoReturn flag (trader-service + // PR #12) should refund the deposit back to her wallet within a few + // testnet round-trips. Poll until balance returns to baseline. + // + // If this assertion fires, it indicates a real product bug: Alice's + // tokens are stranded after an unhappy-path deal. That's exactly the + // class of regression Claim 5b was designed to catch — the historical + // `state === 'FAILED'` check would have hidden it. + await pollUntilBalanceRestored(alice.address, aliceBalBefore, { + // Allow up to the test budget minus a margin for the FAILED transition + // to land. The 11-min outer testTimeout means we have ~5 min headroom + // after EXECUTION_TIMEOUT fires. + timeoutMs: 5 * 60_000, + intervalMs: 5_000, + }); }, 11 * 60_000, ); @@ -354,6 +406,24 @@ describe('Negotiation failures', () => { await cancelActiveIntents(alice); await cancelActiveIntents(bob); + // Audit Claim 5b: if either trader deposited before the escrow died, + // those tokens must come back. With the escrow process killed, the + // escrow's deposit-invoice auto-return cannot fire — the only paths + // for refund are: + // (a) the trader's own swap-cancel path returning tokens + // client-side (would require the trader to have direct refund + // authority, which it doesn't — the escrow holds the funds), OR + // (b) escrow restart + crash-recovery firing closeInvoice with + // autoReturn (we don't restart the escrow in this scenario, so + // this path is closed). + // If neither (a) nor (b) is achievable in production, this scenario + // produces stranded tokens — which is itself a product gap the user's + // Claim 5b is designed to surface. We snapshot before, observe the + // FAILED state, and try to verify balance restoration. If restoration + // doesn't happen within the budget, that's a real signal. + const aliceBalBefore = await snapshotPortfolio(alice.address); + const bobBalBefore = await snapshotPortfolio(bob.address); + // Make sure Bob is back to trusting the original escrow. await runAuthedTraderCtl( 'set-strategy', @@ -409,8 +479,31 @@ describe('Negotiation failures', () => { deals.some((d) => String(d['state']) === 'COMPLETED'), ).toBe(false); } + + // CLAIM 5b ASSERTION: Alice's and Bob's tokens must be returned. + // + // KNOWN LIMITATION: with the escrow process killed mid-negotiation, the + // escrow's auto-return-on-cancel cannot fire. If either trader deposited + // before the kill, their deposit is currently stranded — there is no + // crash-recovery escrow restart in this scenario. This is a real + // product gap (the system has no client-side recovery for "escrow died + // holding my deposit"). Per the user's Claim 5b, all failure paths + // must refund — so this assertion is designed to surface the gap. + // + // We use a generous timeout but fail loud if balances don't restore. + // If this assertion fires repeatedly, the right product fix is one of: + // (1) Add escrow auto-recovery restart in this test fixture, OR + // (2) Add client-side refund-from-stuck-escrow path in trader-service. + await pollUntilBalanceRestored(alice.address, aliceBalBefore, { + timeoutMs: 4 * 60_000, + intervalMs: 5_000, + }); + await pollUntilBalanceRestored(bob.address, bobBalBefore, { + timeoutMs: 4 * 60_000, + intervalMs: 5_000, + }); }, - 10 * 60_000, + 14 * 60_000, // bumped from 10 to absorb the dual balance polls (8 min worst case) ); }); diff --git a/test/e2e-live/surplus-refund.e2e-live.test.ts b/test/e2e-live/surplus-refund.e2e-live.test.ts new file mode 100644 index 0000000..17e5167 --- /dev/null +++ b/test/e2e-live/surplus-refund.e2e-live.test.ts @@ -0,0 +1,98 @@ +/** + * Live E2E — Surplus refund (Audit Claim 5a(d)). + * + * Verifies the trader's wiring to the SDK's auto-return-on-overpay mechanism: + * `sphere.accounting.setAutoReturn('*', true)` is called at startup, so that + * any payout invoice targeting this trader's wallet refunds surplus + * (`coveredAmount > requestedAmount`) back to the over-paying party. + * + * ## Scope of this test + * + * This test confirms the trader's STARTUP WIRING by reading the + * `accounting_auto_return_enabled` log line that PR #12 emits immediately + * after `setAutoReturn('*', true)` succeeds. If the wiring is removed (e.g. + * a regression that drops the call), this test fails. + * + * ## Known gap (deliberately deferred) + * + * A full end-to-end "over-pay → refund propagates back to original payer" + * test requires either: + * (a) a trader fault-injection knob to deposit MORE than the required + * amount on a real swap (the deposit invoice would then accumulate + * surplus, the escrow's `closeInvoice({autoReturn:true})` would refund + * it, and the over-paying trader's balance would restore minus the + * legitimate swap amount), OR + * (b) a separate SDK-level e2e that creates an invoice via the controller's + * Sphere SDK directly, pays X+Y into it, closes it, and verifies the + * refund — bypassing the trader entirely. + * + * Both are larger fixtures than this commit. The wiring assertion below is + * the minimum-viable proof that the auto-return MECHANISM is enabled on the + * trader; the SDK's own unit tests cover the mechanism's correctness. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + provisionTrader, + type ProvisionedTenant, +} from './helpers/tenant-fixture.js'; +import { getContainerLogs } from './helpers/docker-helpers.js'; +import { TESTNET, UCT_COIN_ID, USDU_COIN_ID } from './helpers/constants.js'; + +describe('Surplus refund — trader auto-return wiring', () => { + let trader: ProvisionedTenant; + const cleanups: Array<() => Promise> = []; + + beforeAll(async () => { + trader = await provisionTrader({ + label: 'surplus-trader', + image: TESTNET.TRADER_IMAGE, + relayUrls: [...TESTNET.RELAYS], + trustedEscrows: [], + // Self-mint funding — gives the trader real on-chain UCT/USDU at startup. + // Not strictly required for the wiring check but keeps the fixture + // consistent with the other suites should this test grow into a true + // end-to-end refund flow. + selfMintFund: [ + { coinIdHex: UCT_COIN_ID, amount: 1000n }, + { coinIdHex: USDU_COIN_ID, amount: 1000n }, + ], + }); + cleanups.push(() => trader.dispose()); + }, 120_000); + + afterAll(async () => { + for (const fn of cleanups) { + try { + await fn(); + } catch (err) { + console.error('[surplus-refund afterAll] cleanup error:', err); + } + } + }, 120_000); + + it( + 'trader emits accounting_auto_return_enabled at startup (Claim 5a(d) wiring)', + async () => { + const logs = await getContainerLogs(trader.container.id, { lines: 500 }); + + // PR #12 added `logger.info('accounting_auto_return_enabled')` immediately + // after `sphere.accounting.setAutoReturn('*', true)` resolves. Search for + // the structured log line in either pino-format JSON or plain emit. + const hasEvent = + logs.includes('"event":"accounting_auto_return_enabled"') || + logs.includes('accounting_auto_return_enabled') || + // Idempotent restart path — RATE_LIMITED swallowed; treat as wiring-OK. + logs.includes('"event":"accounting_auto_return_already_enabled"'); + + expect( + hasEvent, + `trader did not emit accounting_auto_return_enabled — auto-return wiring is ` + + `missing or broken. Without this log line, surplus on the trader's payout ` + + `invoices will NOT refund to the original payer (Audit Claim 5a(d)). Logs ` + + `tail (last 500 lines): ${logs.slice(-3000)}`, + ).toBe(true); + }, + 60_000, + ); +}); diff --git a/vitest.e2e-live.config.ts b/vitest.e2e-live.config.ts index bfcae6c..a244060 100644 --- a/vitest.e2e-live.config.ts +++ b/vitest.e2e-live.config.ts @@ -1,20 +1,20 @@ /** * Live e2e test configuration — opt-in via `npm run test:e2e-live`. * - * IMPORTANT: These tests are NOT runnable in trader-service standalone. - * They depend on the Host Manager Agent (HMA) — `createHostManager`, - * `hm.spawn` over HMCP-0, the Dockerode adapter, and the agentic-hosting - * tenant template registry — none of which live in this repo. + * Tests run against REAL Unicity testnet infrastructure (Nostr relay at + * `wss://nostr-relay.testnet.unicity.network`, L3 aggregator at + * `goggregator-test.unicity.network`, IPFS gateway, Market API). The + * infra-probe preflight gate aborts the run if any of those services + * are unreachable so a 10-15-minute container-spawn cycle isn't wasted + * on a known-down service. * - * The tests are preserved here at the same shape they had in the - * agentic-hosting `pre-trader-cut-v1` tag so that they can be ported to - * (or run from) the agentic-hosting repository's nightly integration CI - * once the host-manager half of the stack is decoupled too. See - * `test/e2e-live/README.md` for the full rationale and the runbook for - * the manual scenarios these tests describe. - * - * Running this config in trader-service today will fail at module - * resolution — the missing host-manager source files are the signal. + * Trader and escrow containers are spawned **directly via the local + * Docker daemon** — NOT through the Host Manager Agent (HMA) / HMCP-0. + * The trader-ctl driver talks to each tenant over Sphere DM. This + * matches the production architecture where agentic-hosting only + * orchestrates lifecycle; trading happens controller ↔ tenant directly. + * See `test/e2e-live/helpers/contracts.ts` and + * `test/e2e-live/helpers/tenant-fixture.ts` for the rationale. */ import { defineConfig } from 'vitest/config';