Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0ff2475
test(e2e-live): HMA-orchestrated full settlement + withdraw
vrogojin May 4, 2026
aafe3eb
fix(test): per-scenario controller wallets to avoid wallet.json race
vrogojin May 4, 2026
5244432
fix(test): serialize sphere-cli within a scenario; keep cross-scenari…
vrogojin May 4, 2026
9300590
fix(test): switch to faucet funding (published trader image lacks sel…
vrogojin May 4, 2026
b320db6
fix(test): faucet uses coin.name not coin.symbol — map UCT/USDU
vrogojin May 4, 2026
26cf9b3
fix(test): use locally-built trader:local image (v0.1 lacks payments.…
vrogojin May 4, 2026
7915ec0
fix(test): use TRADER_TEST_FUND with locally-built trader image
vrogojin May 4, 2026
ef02628
fix(test): balanceOf must check `confirmed` field, not just `amount`
vrogojin May 4, 2026
f099fa5
fix(test): distinct rates per scenario to prevent cross-matching
vrogojin May 4, 2026
b2659a3
test(e2e-live): fund traders via js-faucet DM (replaces TRADER_TEST_F…
vrogojin May 5, 2026
c58a463
fix(test): pass escrow_address — was defaulting to literal 'any'
vrogojin May 5, 2026
5fb50f0
fix(test): use escrow.tenantDirectAddress instead of tenantPubkey
vrogojin May 5, 2026
46bdce5
docs: HMA-SETTLEMENT-DIAGNOSTIC for follow-up session
vrogojin May 5, 2026
ce7ce2c
docs: rule out H1 + add SDK middleware-pipeline architectural note
vrogojin May 5, 2026
8f716ae
test: override escrow image to :local to bypass deployed v0.1 asymmet…
vrogojin May 5, 2026
fb03cda
docs(diagnostic): rounds 14-15 update — instrumented escrow committed…
May 8, 2026
1bea230
feat(test): local-infra Nostr relay + UNICITY_NOSTR_RELAYS support
May 8, 2026
8156719
feat(test): wire local-infra relay end-to-end via global-setup + spaw…
May 8, 2026
a797b90
fix(test): forward UNICITY_NOSTR_RELAYS into sphere-cli subprocesses
May 8, 2026
3afb519
docs(diagnostic): rounds 16-17 — local-infra harness landed; nametag …
May 8, 2026
1a6ecda
fix(test): wire local-relay env across all the wallet entry points
May 8, 2026
66ec6ad
docs(diagnostic): round 19 — local-infra harness is fully working
May 8, 2026
0e686ef
fix(test): switch settlement funding to selfMint; document SDK verify…
vrogojin May 8, 2026
4de58d1
test: shorten settlement timeout to 3min for fast-fail signal
vrogojin May 9, 2026
090a59b
feat(trader): invoice-based withdraw via accounting.payInvoice
vrogojin May 9, 2026
5193e6f
test(hma-settlement): revert funding from TRADER_TEST_FUND to FAUCET_…
vrogojin May 9, 2026
38be0b5
fix(trader): bounded retry in swap:proposal_received to handle DM arr…
vrogojin May 9, 2026
de9f0b7
fix(withdraw): use conservative transferMode through accounting.payIn…
vrogojin May 10, 2026
924e700
docs(diagnostic): Round 23 — conservative transferMode resolves predi…
vrogojin May 10, 2026
21f6397
chore(e2e): adopt escrow v0.2 in HMA-trade-settlement override
vrogojin May 15, 2026
10688f0
Merge PR #21 — chore(e2e): adopt escrow v0.2 in HMA-trade-settlement …
vrogojin May 15, 2026
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
1 change: 1 addition & 0 deletions .sphere-cli/wallet.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
804 changes: 804 additions & 0 deletions docs/HMA-SETTLEMENT-DIAGNOSTIC.md

Large diffs are not rendered by default.

94 changes: 91 additions & 3 deletions src/trader/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,21 @@
const trustbasePath = join(config.data_dir, 'trustbase.json');
writeFileSync(trustbasePath, await tbResponse.text());

// Optional Nostr-relay override. Set `UNICITY_NOSTR_RELAYS` (or
// `SPHERE_NOSTR_RELAYS` as a fallback) to a comma-separated list of
// WebSocket URLs to replace the network preset's relays — used by the
// local-infra e2e harness to point at a Docker-hosted relay when the
// public testnet relay's write path is degraded. Empty/unset → default.
const relayOverride = (() => {
const raw = process.env['UNICITY_NOSTR_RELAYS'] ?? process.env['SPHERE_NOSTR_RELAYS'];
if (!raw) return undefined;
const relays = raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
return relays.length > 0 ? relays : undefined;
})();
if (relayOverride) {
logger.info('nostr_relays_override_active', { relays: relayOverride });
}

// Initialize Sphere wallet with market, swap, and accounting modules
logger.info('initializing_sphere', { network: config.network, data_dir: config.data_dir });
const providers = createNodeProviders({
Expand All @@ -257,6 +272,7 @@
trustBasePath: trustbasePath,
apiKey: resolveApiKey(),
},
...(relayOverride ? { transport: { relays: relayOverride } } : {}),
});

// 2026-04-30 FIX (basic-roundtrip flake investigation): expand the
Expand Down Expand Up @@ -1020,6 +1036,43 @@
market,
swap,
comms: { sendDm: sender.sendDm.bind(sender) },
// Invoice-based withdraw path. When the SDK's accounting module is
// available, expose a narrow facade so the trader's WITHDRAW_TOKEN
// handler can use createInvoice + payInvoice instead of payments.send
// directly. Mirrors the swap-deposit flow and avoids the "Authenticator
// does not match source state predicate" flake on spends of received
// swap-payout tokens.
...(sphere.accounting
? {
accounting: {
createInvoice: async (req: import('./types.js').AccountingCreateInvoiceRequest) => {
const result = await sphere.accounting!.createInvoice({

Check failure on line 1049 in src/trader/main.ts

View workflow job for this annotation

GitHub Actions / typecheck + lint + test + build (22.x)

Forbidden non-null assertion
targets: req.targets.map((t) => ({
address: t.address,
assets: t.assets.map((a) => ({ coin: a.coin as [string, string] })),
})),
...(req.memo !== undefined ? { memo: req.memo } : {}),
});
return {
success: result.success,
...(result.invoiceId !== undefined ? { invoiceId: result.invoiceId } : {}),
...(result.error !== undefined ? { error: result.error } : {}),
};
},
payInvoice: async (
invoiceId: string,
params: import('./types.js').AccountingPayInvoiceParams,
) => {
const result = await sphere.accounting!.payInvoice(invoiceId, params);

Check failure on line 1066 in src/trader/main.ts

View workflow job for this annotation

GitHub Actions / typecheck + lint + test + build (22.x)

Forbidden non-null assertion
return {
id: result.id,
status: String(result.status),
...(result.error !== undefined ? { error: result.error } : {}),
};
},
},
}
: {}),
// subscribeEvent is retained for interface compatibility but swap events
// are ALL handled by direct sphere.on() listeners below (lines 418+).
// This bridge is only needed for non-swap events in the future.
Expand Down Expand Up @@ -1239,7 +1292,23 @@
return;
}

registered = agent.registerSwapId(data.swapId, {
// R23 fix (Pair-2 race): the swap_proposal DM and np.propose_deal
// DM can arrive in either order. When swap_proposal arrives FIRST,
// the np.propose_deal handler is still running (validation →
// transitionDeal('ACCEPTED') → onDealAccepted → executeDeal →
// registerActive); `activeByDealId` is empty so the very first
// registerSwapId call returns false and we'd reject the swap
// even though we're about to accept the deal.
//
// Fix: bounded retry — registerSwapId still cross-checks
// counterparty pubkey, currencies, amounts, escrow address, and
// timeout against negotiated DealTerms, so retrying is safe; we
// only paper over the microsecond-scale ordering hazard. If the
// deal really wasn't accepted (hostile peer, stale state), we
// still reject after the bounded wait.
const REGISTER_MAX_ATTEMPTS = 40;
const REGISTER_BACKOFF_MS = 50;
const registerArgs = {
partyACurrency: s.deal?.partyACurrency,
partyAAmount: s.deal?.partyAAmount,
partyBCurrency: s.deal?.partyBCurrency,
Expand All @@ -1254,13 +1323,32 @@
escrowPubkey: (s as unknown as { escrowPubkey?: string }).escrowPubkey,
depositTimeoutSec: (s.deal as unknown as { timeout?: number; depositTimeoutSec?: number })?.depositTimeoutSec
?? (s.deal as unknown as { timeout?: number })?.timeout,
});
};
for (let attempt = 0; attempt < REGISTER_MAX_ATTEMPTS; attempt++) {
registered = agent.registerSwapId(data.swapId, registerArgs);
if (registered) break;
if (attempt + 1 < REGISTER_MAX_ATTEMPTS) {
await new Promise((r) => setTimeout(r, REGISTER_BACKOFF_MS));
}
}
} catch (err: unknown) {
logger.warn('swap_proposal_status_fetch_failed', {
swap_id: data.swapId,
error: err instanceof Error ? err.message : String(err),
});
registered = agent.registerSwapId(data.swapId);
// R23 fix (legacy fallback): same bounded retry as the
// status-based path above — without it, a transient
// getSwapStatus failure compounds the np.propose_deal /
// swap_proposal arrival-order race.
const REGISTER_MAX_ATTEMPTS = 40;
const REGISTER_BACKOFF_MS = 50;
for (let attempt = 0; attempt < REGISTER_MAX_ATTEMPTS; attempt++) {
registered = agent.registerSwapId(data.swapId);
if (registered) break;
if (attempt + 1 < REGISTER_MAX_ATTEMPTS) {
await new Promise((r) => setTimeout(r, REGISTER_BACKOFF_MS));
}
}
}

if (!registered) {
Expand Down
89 changes: 89 additions & 0 deletions src/trader/trader-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { createCommandHandler } from '../tenant/command-handler.js';
import type { CommandHandler } from '../tenant/command-handler.js';

import type {
AccountingAdapter,
PaymentsAdapter,
MarketAdapter,
MarketSearchResult,
Expand Down Expand Up @@ -60,6 +61,14 @@ export interface TraderMainDeps {
readonly market: MarketAdapter;
readonly swap: SwapAdapter;
readonly comms: { sendDm: (to: string, content: string) => Promise<void> };
/**
* Optional invoice-based withdraw path. When present, WITHDRAW_TOKEN
* routes through accounting.createInvoice + payInvoice instead of
* payments.send directly — the same code path swap deposits use, so
* predicate-handling is well-tested. Optional so unit tests with
* stub adapters don't need to wire this layer.
*/
readonly accounting?: AccountingAdapter;

// Sphere instance controls
readonly subscribeEvent: (eventType: string, handler: (...args: unknown[]) => void) => () => void;
Expand Down Expand Up @@ -145,6 +154,7 @@ export function createTraderAgent(deps: TraderMainDeps): TraderAgent {
market,
swap,
comms,
accounting,
// subscribeEvent is available but unused — all swap events are handled
// by direct sphere.on() listeners in main.ts, not via this bridge.
signMessage,
Expand Down Expand Up @@ -196,6 +206,85 @@ export function createTraderAgent(deps: TraderMainDeps): TraderAgent {
async function withdraw(
params: WithdrawTokenParams,
): Promise<{ transfer_id: string; remaining_balance: bigint }> {
// Invoice-based path (preferred). The trader creates a local invoice
// with a single target = `params.to_address`, then pays it via
// `accounting.payInvoice`. This is the same code path swap deposits
// use, so it inherits the SDK's well-tested handling for invoice-target
// predicates. The recipient sees the inbound transfer with an invoice
// memo and (if their wallet has accounting enabled) auto-imports the
// invoice — matching how swap-payouts are received.
//
// The direct `payments.send` path used to be the implementation but
// can flake with "Authenticator does not match source state predicate"
// when the spend queue picks a token whose source-state predicate
// doesn't match the wallet's main key (typical of swap-payout tokens
// received with a per-transfer salted predicate).
if (accounting !== undefined) {
// The accounting module's createInvoice validates `coin[0]` as a
// SYMBOL (≤20 chars, alphanumeric), not a 64-hex coinId. We resolve
// the symbol to a coinIdHex separately for the post-pay balance
// computation, but pass the symbol to createInvoice itself.
const balances = payments.getAllBalances();
const matched = balances.find((b) => b.symbol === params.asset || b.coinId === params.asset);
if (matched === undefined) {
throw new Error(`withdraw: unknown asset "${params.asset}" — no token in wallet matches`);
}
const symbol = matched.symbol ?? params.asset;
const coinIdHexForBalance = matched.coinId;
const invoice = await accounting.createInvoice({
targets: [{
address: params.to_address,
assets: [{ coin: [symbol, params.amount] }],
}],
memo: `withdraw ${params.amount} ${symbol} → ${params.to_address}`,
});
if (!invoice.success || invoice.invoiceId === undefined) {
throw new Error(`accounting.createInvoice failed: ${invoice.error ?? 'unknown'}`);
}
const payResult = await accounting.payInvoice(invoice.invoiceId, {
targetIndex: 0,
amount: params.amount,
// Conservative mode: the SDK collects the inclusion proof on the
// SENDER's side before delivery, so the recipient receives a
// fully-finalized {sourceToken, transferTx} bundle and can produce
// a 'confirmed' Token immediately bound to its own predicate. This
// mirrors the faucet and escrow's payout flows. The default
// 'instant' mode ships an unconfirmed bundle whose recipient-side
// proof-poll races with any chained spend (e.g. another withdraw or
// a swap deposit) and intermittently surfaces "Authenticator does
// not match source state predicate" errors when the spend queue
// picks a not-yet-finalized token.
transferMode: 'conservative',
});
if (payResult.error !== undefined && payResult.error !== '') {
logger.warn('withdraw_pay_invoice_returned_error', {
asset: params.asset,
amount: params.amount,
to_address: params.to_address,
invoice_id: invoice.invoiceId,
transfer_id: payResult.id,
status: payResult.status,
error: payResult.error,
});
throw new Error(`accounting.payInvoice failed: ${payResult.error}`);
}
logger.info('withdraw_sent_via_invoice', {
asset: params.asset,
amount: params.amount,
to_address: params.to_address,
invoice_id: invoice.invoiceId,
transfer_id: payResult.id,
status: payResult.status,
});
const remaining = payments.getConfirmedBalance(coinIdHexForBalance) - BigInt(params.amount);
return {
transfer_id: payResult.id,
remaining_balance: remaining < 0n ? 0n : remaining,
};
}

// Legacy direct-send path. Kept for unit tests with stub adapters and
// for environments where the accounting module is unavailable.
const sendResult = await payments.send({
coinId: params.asset,
amount: params.amount,
Expand Down
59 changes: 59 additions & 0 deletions src/trader/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,65 @@ export interface PaymentsAdapter {
send(request: SendTokenRequest): Promise<SendTokenResult>;
}

// ---------------------------------------------------------------------------
// AccountingAdapter — narrow abstraction over Sphere SDK AccountingModule
// ---------------------------------------------------------------------------

/**
* Subset of `accounting.createInvoice` request needed by the trader's
* invoice-based withdraw path. Mirrors the SDK's CreateInvoiceRequest
* but stays narrow so tests can inject a recording stub.
*/
export interface AccountingInvoiceTarget {
readonly address: string;
/** Each entry's `coin` is `[coinIdHex, amountStr]`. */
readonly assets: ReadonlyArray<{ readonly coin: readonly [string, string] }>;
}

export interface AccountingCreateInvoiceRequest {
readonly targets: readonly AccountingInvoiceTarget[];
readonly memo?: string;
}

export interface AccountingCreateInvoiceResult {
readonly success: boolean;
readonly invoiceId?: string;
readonly error?: string;
}

export interface AccountingPayInvoiceParams {
readonly targetIndex: number;
readonly assetIndex?: number;
readonly amount?: string;
/**
* Transfer-delivery mode. `'conservative'` collects the inclusion
* proof on the sender's side before delivery; the recipient gets a
* fully-finalized bundle and can spend it immediately. `'instant'`
* (default) ships an unconfirmed bundle that the recipient finalizes
* via background proof-poll. Use `'conservative'` for withdraw flows
* so the recipient's spend doesn't race the proof-poll.
*/
readonly transferMode?: 'instant' | 'conservative';
}

export interface AccountingPayInvoiceResult {
readonly id: string;
readonly status: string;
readonly error?: string;
}

/**
* Narrow facade over `sphere.accounting`. Used by the trader's withdraw
* flow to route value via the invoicing system (create invoice locally
* → pay it via payInvoice) instead of `payments.send` directly. The
* invoicing path is the same one swap deposits use, so it inherits the
* SDK's well-tested predicate-handling for invoice-target predicates.
*/
export interface AccountingAdapter {
createInvoice(request: AccountingCreateInvoiceRequest): Promise<AccountingCreateInvoiceResult>;
payInvoice(invoiceId: string, params: AccountingPayInvoiceParams): Promise<AccountingPayInvoiceResult>;
}

// ---------------------------------------------------------------------------
// MarketAdapter — narrow abstraction over Sphere SDK MarketModule
// ---------------------------------------------------------------------------
Expand Down
65 changes: 61 additions & 4 deletions test/e2e-live/global-setup.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,71 @@
/**
* Vitest globalSetup for the e2e-live suite.
*
* Runs ONCE before any test file. If the preflight throws, vitest aborts
* the entire run before spawning any Docker containers — saving the
* 10-15-minute round trip we'd otherwise eat on a relay outage or
* unreachable aggregator.
* Runs ONCE before any test file. Two responsibilities:
*
* 1. Preflight gate — abort the run before spawning Docker tenants
* if any required testnet service (Nostr relay, L3 Aggregator,
* IPFS, Fulcrum, Market) is unreachable. Saves the 10-15-minute
* round trip we'd otherwise eat on an outage. Bypass:
* `TRADER_E2E_SKIP_PREFLIGHT=1`.
*
* 2. Local infra (opt-in) — when `TRADER_E2E_LOCAL_RELAY=1` is set,
* boot a Docker-hosted Nostr relay (see local-infra/relay.ts)
* and export `UNICITY_NOSTR_RELAYS` so every component that
* reads it (host-manager, escrow, trader, faucet) connects to
* the local relay instead of the public testnet. The relay
* binds to the host on 0.0.0.0:7777; HMA-spawned tenants reach
* it via the Docker bridge gateway IP (auto-discovered).
*
* Tests that need to fan the URL into HMA-spawned tenants read
* `process.env['UNICITY_NOSTR_RELAYS']` and pass it via the
* `env` field on `hostSpawnAsync(...)`. The host-manager's own
* Sphere wallet picks it up automatically because spawnHostManager
* forwards the parent env (or the test sets it on the spawn env).
*
* Local-relay mode SKIPS the preflight (the local relay is
* under our control; gating against the public testnet relay
* would defeat the purpose).
*/

import { runPreflight } from './preflight.js';
import { bootLocalRelay, getLocalRelayUrlForContainers, type RelayHandle } from './local-infra/relay.js';

let relayHandle: RelayHandle | null = null;

export async function setup(): Promise<void> {
if (process.env['TRADER_E2E_LOCAL_RELAY'] === '1') {
console.log('[global-setup] TRADER_E2E_LOCAL_RELAY=1 — booting local Nostr relay…');
relayHandle = await bootLocalRelay({
// Wipe by default so each `npm run test:e2e-live` starts from a clean
// event log. Set TRADER_E2E_LOCAL_RELAY_KEEP=1 to preserve state
// between runs (useful for post-mortem on a failing test).
wipe: process.env['TRADER_E2E_LOCAL_RELAY_KEEP'] !== '1',
timeoutMs: 60_000,
logPrefix: '[global-setup] ',
});
const containerUrl = getLocalRelayUrlForContainers();
process.env['UNICITY_NOSTR_RELAYS'] = containerUrl;
process.env['TRADER_E2E_LOCAL_RELAY_HOST_URL'] = relayHandle.url;
process.env['TRADER_E2E_LOCAL_RELAY_CONTAINER_URL'] = containerUrl;
console.log(
`[global-setup] local relay ready — host: ${relayHandle.url}, ` +
`containers: ${containerUrl}`,
);
console.log('[global-setup] preflight SKIPPED (local relay supersedes testnet gate)');
return;
}
await runPreflight();
}

export async function teardown(): Promise<void> {
if (relayHandle) {
console.log('[global-setup] stopping local Nostr relay…');
try {
await relayHandle.stop({ wipe: false });
} catch (err) {
console.error('[global-setup] relay stop error:', err);
}
relayHandle = null;
}
}
Loading
Loading