Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/trader/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1497,7 +1497,22 @@ export async function startTrader(): Promise<void> {
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) + '...',
Expand Down
29 changes: 29 additions & 0 deletions test/e2e-live/basic-roundtrip.e2e-live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
});
9 changes: 9 additions & 0 deletions test/e2e-live/edge-cases.e2e-live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
12 changes: 12 additions & 0 deletions test/e2e-live/helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -71,6 +82,7 @@ export const TESTNET: TestnetConstants = {
AGGREGATOR_URL,
IPFS_GATEWAY,
FAUCET_URL,
MARKET_API_URL,
TRADER_IMAGE,
ESCROW_IMAGE,
DEFAULT_TIMEOUT_MS,
Expand Down
2 changes: 2 additions & 0 deletions test/e2e-live/helpers/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
235 changes: 235 additions & 0 deletions test/e2e-live/helpers/portfolio-assertions.ts
Original file line number Diff line number Diff line change
@@ -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<TrackedCoin> = ['UCT', 'USDU'];

/** Compact balance snapshot keyed by coin symbol. Values in smallest units. */
export type Portfolio = Record<TrackedCoin, bigint>;

/**
* 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: <symbol-or-coinId>, 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<string, unknown>;
const balances =
(obj['balances'] ?? (obj['result'] as Record<string, unknown> | undefined)?.['balances']) as
| Array<Record<string, unknown>>
| 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<unknown> {
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 || '<empty>'} | ` +
`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<Portfolio> {
const raw = await getPortfolio(tenantAddress);
const snap: Partial<Portfolio> = {};
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<Record<TrackedCoin, bigint>>,
): 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<Portfolio> {
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.)`,
);
}
11 changes: 9 additions & 2 deletions test/e2e-live/helpers/tenant-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading