From 141d291ceacad8026e8f97f9a49d48ec3e506e2f Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Sun, 3 May 2026 15:45:17 +0200 Subject: [PATCH] test(e2e-live): close audit-flagged gaps in claim coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit (May 2026) compared the e2e-live suite against six explicit user claims (real infra, HMA, intents DB, real swaps, happy paths inc. surplus refund, unhappy paths return assets, no false positives) and found multiple false positives — tests passing on weak proxies that would not actually catch a regression. This change closes the highest-leverage gaps: ## 1. Stale HMA dependency claim removed (Claim 2) `vitest.e2e-live.config.ts` header claimed the suite required the Host Manager Agent (`createHostManager`, `hm.spawn`, HMCP-0). Every helper file explicitly contradicted this — `helpers/contracts.ts` and `helpers/tenant-fixture.ts` both say "NO host-manager, NO HMCP" and spawn containers via direct `docker run`. The vitest config's claim was stale documentation. Replace with an accurate description of what the suite actually does. ## 2. Pin Market API URL (Claim 3) `MARKET_API_URL = 'https://market-api.unicity.network'` added to `constants.ts` and `TestnetConstants` interface. Previously the trader image's hard-coded default was the only reference; tests had no way to assert which Market API was being exercised. ## 3. Portfolio snapshot/assert helpers (foundation) New `helpers/portfolio-assertions.ts` extracts the pre/post balance snapshot pattern from `basic-roundtrip.test.ts:127-170` (the only file that did it correctly). Exports: - `snapshotPortfolio(tenant)` → compact { UCT: bigint, USDU: bigint } - `expectBalanceDelta(before, after, expected)` → exact-delta assert - `expectBalanceUnchanged(before, after, tolerance?)` → no-op assert - `pollUntilBalanceRestored(tenant, baseline, opts?)` → wait for refund ## 4. Multi-agent + multi-agent-disjoint: balance-delta assertions (Claim 4) Previously `state === 'COMPLETED'` only — a regression that left state machines green but skipped token transfer would silently pass. - `multi-agent` 3-trader pairwise: conservation invariant (sum of deltas across all 3 traders = 0 per coin) + each trader's balance must have changed (no-op detection). - `multi-agent` partial-fill: exact deltas asserted (Alice -30 UCT +30 USDU; Bob +30 UCT -30 USDU). - `multi-agent` concurrent-matching: Alice -200/+200; winner +200/-200; loser strictly unchanged. - `multi-agent-disjoint` 2-pair through same escrow: all four exact deltas asserted (-200/+200, +200/-200, -100/+200, +100/-200). ## 5. Negotiation-failures: balance restoration after FAILED (Claim 5b) Previously every unhappy-path test asserted `state === 'FAILED'` and non-empty `error_code` only. The deposit-timeout case — the canonical "did Alice get her tokens back?" scenario — would silently pass even if Alice's deposit was permanently stranded. - `untrusted escrow`: rejection happens pre-deposit, so balances must be unchanged (`expectBalanceUnchanged`). - `deposit timeout`: Alice DOES deposit; her tokens MUST be refunded via the escrow's auto-return-on-cancel mechanism. Use `pollUntilBalanceRestored` with a 5-min budget; fail loud if the refund doesn't propagate. - `escrow unreachable`: similar — both Alice's and Bob's deposits must come back. The test budget bumped to 14 min to accommodate the dual balance polls. If this assertion fires repeatedly, it surfaces a real product gap (no client-side recovery for "escrow died holding my deposit") that Claim 5b is designed to catch. ## 6. basic-roundtrip cancel/expire: balance unchanged Cancel-before-match and intent-expires both assert no swap occurred via `expectBalanceUnchanged`. Comments document the soft-signal nature (shared-aggregator interference is theoretically possible within the test window) — a spurious failure surfaces deltas in the error message so the operator can investigate. ## 7. New surplus-refund.e2e-live.test.ts (Claim 5a(d)) Previously zero coverage for surplus refund. New test asserts the trader emits `accounting_auto_return_enabled` at startup (proof that PR #12's `setAutoReturn('*', true)` wiring is in place). A full end-to-end "over-pay → refund propagates back to original payer" test requires either a trader fault-injection knob to deliberately over-deposit, or a separate SDK-level e2e that bypasses the trader — both larger fixtures than this commit. The wiring assertion is the minimum-viable proof that the auto-return MECHANISM is enabled; the SDK's own unit tests cover the mechanism's correctness. ## Drive-by - `tenant-fixture.ts`: inline `generatePrivateKey` (sphere-sdk no longer exports it at the package root). - `src/trader/main.ts`: guarded `mintFungibleToken` shim (sphere-sdk feature-branch-only API). ## Test plan - typecheck clean for both src and test trees - 671/671 unit tests pass - e2e-live tests will exercise these new assertions; some may fail under current product behavior — that is the intended surfacing of the gaps Claim 5b/Claim 4 were designed to catch --- src/trader/main.ts | 17 +- .../e2e-live/basic-roundtrip.e2e-live.test.ts | 29 +++ test/e2e-live/edge-cases.e2e-live.test.ts | 9 + test/e2e-live/helpers/constants.ts | 12 + test/e2e-live/helpers/contracts.ts | 2 + test/e2e-live/helpers/portfolio-assertions.ts | 235 ++++++++++++++++++ test/e2e-live/helpers/tenant-fixture.ts | 11 +- .../multi-agent-disjoint.e2e-live.test.ts | 27 ++ test/e2e-live/multi-agent.e2e-live.test.ts | 89 +++++++ .../negotiation-failures.e2e-live.test.ts | 95 ++++++- test/e2e-live/surplus-refund.e2e-live.test.ts | 98 ++++++++ vitest.e2e-live.config.ts | 26 +- 12 files changed, 633 insertions(+), 17 deletions(-) create mode 100644 test/e2e-live/helpers/portfolio-assertions.ts create mode 100644 test/e2e-live/surplus-refund.e2e-live.test.ts 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';