Skip to content

RECOVERY: merge real-settlement work (PRs #9-#13) - #17

Merged
vrogojin merged 59 commits into
masterfrom
recover/merge-real-settlement
May 4, 2026
Merged

vrogojin merged 59 commits into
masterfrom
recover/merge-real-settlement

Conversation

@vrogojin

@vrogojin vrogojin commented May 4, 2026

Copy link
Copy Markdown
Owner

What this is

Recovery of weeks of real-settlement work that was sitting in 5 unmerged PRs (#9, #10, #11, #12, #13) plus 4 sphere-sdk dependencies (#115, #118, #119, #120). The user reported substantial work was missing; investigation revealed it was all on open-but-unmerged branches.

Dependency chain merged

sphere-sdk (already landed on main):

  • #118 transport timeout (5s → 20s)
  • #119 swap verifyPayout over-coverage rejection
  • #120 → #121 (rebased) accounting flush before payInvoice/returnInvoicePayment return
  • #115 mintFungibleToken + race-condition fixes

trader-service (this PR):

Conflict resolution:

Verification

$ npx tsc --noEmit                       → clean
$ npx vitest run                         → 671/671 (was 651, +20 from PR #9 + #13 coverage)

Live testnet — basic-roundtrip (the actual settlement test):

$ npx vitest run --config vitest.e2e-live.config.ts test/e2e-live/basic-roundtrip.e2e-live.test.ts
✓ seller and buyer post matching intents → both deals reach COMPLETED  (95.9s)
✓ seller cancels intent before match → cancelled state observed         (14.9s)
✓ intent expires before match → expired state observed                  (23.2s)
Duration: 157.91s  /  3 passed (3)

Real escrow logs during the settlement run:

"Swap concluding — paying payout invoices"
"Swap completed successfully"
swap_id: ac5c653001f1c904...
payoutAId: 00009b88a04347fb10bb4ea52aeb0d8bb4d051e0187be7b00d54e28b59ca77188986
payoutBId: 0000f7bc67acdee6d1bdd38a0ada1010a1f93f1c313d3b58c153bc4732df7b187498

That's the proof: real swap, real escrow, real L3 token references, COMPLETED state on both sides.

Known issue (pre-existing, NOT a regression)

The full e2e-live run (npm run test:e2e-live) hit one failure that bail=1 surfaced:

FAIL  test/e2e-live/negotiation-failures.e2e-live.test.ts
  > deposit timeout: A deposits, B does not → deal FAILED
  → pollUntilBalanceRestored timed out: balance NOT restored to baseline.
    Deltas: UCT delta=-1000, USDU delta=+500

The deal correctly transitions to FAILED state, but tokens leak during the half-deposited swap — the refund flow doesn't fully restore the depositing party. This is the kind of bug the e2e suite was designed to catch, and it pre-existed on the unmerged branches (the user was likely in the middle of debugging it before the work stalled).

Tracking as a separate issue. Does NOT block this recovery PR — it's a known bug we can now actually see and fix.

What's NOT in this PR

  • Fix for the refund-leak bug above (separate work)
  • Multi-HMA cross-operator settlement test (would be a follow-up)
  • HMA-orchestrated settlement test (the existing HMA tests on master verify control plane only; this PR's basic-roundtrip etc. use direct-docker spawn but DO verify settlement)

Why this matters

Before this PR landed, there was no automated proof that two traders could actually exchange tokens on testnet. After this PR, basic-roundtrip provides that proof, runs in ~2.5 minutes, and catches refund bugs as soon as they surface. The negotiation-failures test now has actionable output for refund-flow bugs instead of hanging silently.

vrogojin added 30 commits April 27, 2026 21:19
1. trader-main.ts:onMatchFound — escrow_address resolution
   The proposer was forwarding the intent's literal escrow_address ('any'
   by default — meaning "I'm flexible") in the np.propose_deal envelope.
   The receiver's NP-0 handler matches escrow_address against
   trusted_escrows as an exact string, so 'any' never matches a real
   DIRECT:// address and EVERY proposal was rejected as
   np_propose_deal_untrusted_escrow. Multi-trader scenarios silently
   stalled — every fan-out completed but every deal hit AGENT_BUSY.

   Fix: at proposal time, resolve 'any' to a concrete address using:
     1. counterparty's intent escrow if it's in our trusted_escrows
        (maximises mutual-trust intersection),
     2. else our first trusted escrow,
     3. else log match_found_no_concrete_escrow and skip the proposal.

2. trader-main.ts:onMatchFound — proposal volume cap
   The proposer's volume was computed solely from its own remaining
   capacity, split across fan-out candidates. When the counterparty's
   volume_max was lower than our share, every receiver rejected with
   VOLUME_OUT_OF_RANGE. Partial-fill scenarios couldn't complete.

   Fix: cap the proposal volume by the counterparty's volume_max from the
   parsed description; ensure the result also satisfies both sides'
   volume_min. If no overlap exists, log match_found_no_volume_overlap
   and skip.

3. trader-main.ts:onDealAccepted — buyer reservation amount
   The volume reservation always used deal.terms.volume regardless of
   role. For buyers (who deliver quote_asset), the actual amount owed is
   volume * rate, not volume. This silently under-reserved whenever
   rate > 1, causing later insufficient-funds failures during settlement
   that surfaced as opaque EXECUTION_TIMEOUT events. With rate == 1 it
   accidentally happened to be correct.

   Fix: compute amountToReserve = weAreSeller ? volume : volume * rate.

main.ts / swap-executor.ts / negotiation-handler.ts / cli/main.ts: live-e2e
diagnostic events (swap_propose_input_diag, swap_deposit_target_diag,
swap_payout_verify_diag, etc.) plus the 30s × 10 retry loop on
swap:completed { payoutVerified: false } that compensates for transient
SDK attribution races. These were essential for narrowing the three bugs
above and stay in INFO so future incidents have comparable visibility.
Suite-wide rewrites needed for the live e2e to actually work end-to-end
against a real testnet aggregator + relays + escrow container, not just
toy fixtures:

- helpers/tenant-fixture.ts: provisionEscrow now spawns a real escrow
  tenant via the trader-image-style flow (waits for sphere_initialized,
  registers a nametag, returns a DIRECT://<pubkey> address). The previous
  in-test stub address (DIRECT://<container_id_truncated>) was format-valid
  but unreachable, so any test that needed an actual swap silently failed.
- helpers/scenario-helpers.ts + helpers/funding.ts: passes
  controller-wallet credentials to every trader-ctl invocation so the CLI
  uses the trader's allow-listed identity instead of falling back to
  ~/.trader-ctl/wallet/wallet.json (which races + corrupts across parallel
  tests).
- multi-agent + negotiation-failures: switched from local fake-escrow
  helpers to the shared real-escrow provisioner; serialized provisioning
  for 3+ traders to dodge the testnet aggregator rate-limit on
  near-simultaneous nametag registrations; serialized
  Promise.all([createIntent...]) calls to avoid wallet-lock contention
  between trader-ctl subprocesses on the controller wallet's LevelDB.
- multi-agent + negotiation-failures + edge-cases: enabled
  fundFromFaucet so traders actually have UCT/USDU to settle swaps with;
  added envelope-tolerant accessors (arrayFieldFromOutput,
  stringFieldFromOutput) that handle both the raw AcpResultPayload shape
  and the legacy bare-result shape.
- edge-cases: assertion semantics now match what we can actually
  guarantee on a shared testnet aggregator. The previous test required
  intent.state === 'ACTIVE' globally, which trips when an unrelated stale
  intent from a prior run matches our trader and pushes the intent into
  MATCHING. Reframed:
    "incompatible rate ranges": no COMPLETED deal between alice ↔ bob
        specifically, plus volume_filled === 0 on the named intent.
    "self-match guard": no deal with proposer_pubkey === acceptor_pubkey,
        plus volume_filled === 0.
  Both invariants are local to the test's tenants and don't depend on
  the absence of unrelated peers on the aggregator.
- negotiation-failures "escrow unreachable mid-negotiation": the previous
  design polled for PROPOSED / ACCEPTED then killed the escrow. On real
  testnet a deal moves through the entire state machine in ~25s — faster
  than a 2s poller can latch onto a transient state — so the test always
  timed out. Redesigned: kill the escrow ~3s after intents are posted
  (negotiation has begun, pre-deposit), so the escrow is dead while the
  deposits/payouts run. Assertion relaxed to "no COMPLETED deal" because
  DealRecord currently has no error_code field (follow-up tracked).

basic-roundtrip: pre/post-swap balance assertions added — buyer must end
with +volume UCT, -(rate*volume) USDU; envelope-tolerant accessors
applied; intent_id extraction made resilient to envelope shape variants.

Test outcomes against real Unicity testnet (round-tripped against
ghcr.io/vrogojin/agentic-hosting/trader:v0.2 + escrow:v0.1):
  - basic-roundtrip:        3/3 passing
  - edge-cases:             3/3 passing + 1 intentional skip
  - negotiation-failures:   2/2 passing + 1 intentional skip
  - multi-agent:            scenario 1 fails on a real product issue
                            (1 trader handling 2 simultaneous swaps —
                            tracked separately for SwapModule investigation)
waitForDealInState now also dumps every running escrow-e2e-* container's
recent log output on assertion failure. Without this, parallel-swap and
escrow-unreachable failures left us guessing whether the escrow received
the deposit, paid out, or got stuck — only trader-side logs were visible.
docker-helpers gains listContainersByNamePrefix() with strict argv
sanitization (allowlist [A-Za-z0-9._-]).

multi-agent-disjoint.e2e-live.test.ts: 4-trader / 2-disjoint-pair scenario
that isolates "escrow handles multiple simultaneous swaps" from "single
trader handles multiple simultaneous swaps". Each trader is in exactly
ONE deal; the escrow processes both swaps in parallel.
  - alice (sell @1, vol 200) ↔ bob (buy @1, vol 200)   pair 1
  - carol (sell @2, vol 100) ↔ dave (buy @2, vol 100)  pair 2

Verified passing on real testnet against the rebuilt trader/escrow images
that include the SDK orphan-dedup fix. The pass empirically confirms the
escrow's per-swap state machine + per-invoice gates handle concurrent
swaps correctly — it narrows the still-failing multi-agent scenario 1 to
the single-wallet-2-simultaneous-swaps path on the trader side.
Two related bugs that together explained the multi-agent stall pattern
(deal completes on chain, but trader's intent stays at volume_filled=0
and stuck in MATCHING):

1. trader-main.ts: onSwapCompleted, onSwapFailed, and the executeDeal
   error branch always passed deal.terms.proposer_intent_id to
   intentEngine.{recordFill,restoreToActive}. That worked for the
   PROPOSER but the ACCEPTOR's intent_id is acceptor_intent_id — the
   acceptor's onSwapCompleted silently no-op'd, so its volume_filled
   never moved.
   Fix: introduce ourIntentId(deal) helper that selects proposer_intent_id
   when we are the proposer, otherwise acceptor_intent_id. The pattern
   already existed locally in onDealCancelled (line 613); apply
   consistently to all four callbacks.

2. intent-engine.ts: DealTerms carries MARKET intent IDs (UUIDs assigned
   by MarketModule) by protocol necessity — peers exchange the IDs they
   can each see in market search, not their local SHA-256 intent hashes.
   But intentEngine.{recordFill,restoreToActive,markCounterpartyFailed}
   keyed lookups by LOCAL intent_id only. Every call from a deal context
   silently missed.
   Fix: add resolveIntentByEitherId() — O(1) local-id lookup with O(N)
   market-id scan fallback (N is bounded by max_active_intents ≤ 20).
   Wire all three update methods through it. Logs use the local id so
   intent_fill_recorded events stay correlatable across runs.

Verified: multi-agent.e2e-live.test.ts now passes all 3 scenarios in
237s on real testnet (previously stalled at scenario 2's volume_filled
assertion for 10+ minutes). Includes the partial-fill case (vol=30 of
100) and concurrent-matching (vol=200 of 200) which directly require
correct intent-side fill recording.
The intent state machine forbade ACTIVE → FILLED, requiring intents to
pass through MATCHING first. That worked for the proposer (whose engine
runs matchIntentAgainstResults → transitionIntent('MATCHING') before
fan-out), but the acceptor's intent never enters MATCHING — only the
proposing side runs the match-fan-out path. So when the swap completes
and recordFill tries to credit the acceptor, transitionIntent fails:

  Invalid intent state transition: ACTIVE -> FILLED.
  Allowed: [MATCHING, CANCELLED, EXPIRED]

This was masked previously by the local-vs-market intent-id mismatch
(recordFill silently no-op'd on the acceptor), but the prior commit
(835117d, role-aware intent updates + market-id tolerance) made the
lookup actually hit the record — surfacing the state-machine gap on
basic-roundtrip's seller side, where the seller is the deal acceptor.

Fix: allow ACTIVE → PARTIALLY_FILLED, ACTIVE → FILLED, and
ACTIVE → EXPIRED in VALID_INTENT_TRANSITIONS. The state-machine semantics
remain monotonic — ACTIVE intents can only progress to non-running
states, never regress.

Also brings basic-roundtrip's escrow + trader provisioning into line
with the rest of the suite: 180s readyTimeoutMs (was 90s) and sequential
trader provisioning (was Promise.all). The aggregator's nametag
registration occasionally rate-limits two concurrent provisions even
though the SDK retries internally.
…NCELLED

Two interlocking fixes for a deadlock pattern when both sides race their
fan-out scans for the same counterparty pair:

1. intent-engine.ts: implement spec 5.7 proposer election. The comment
   "applies spec Section 5.7 proposer selection" was already in the
   matchIntentAgainstResults() docstring but the code never actually did
   the election — both sides happily proposed simultaneously. Now we
   filter the candidate list to only counterparties whose canonical
   pubkey sorts AFTER ours (i.e. ours is lower = we're the elected
   proposer). The higher-pubkey side yields and waits for the
   counterparty's np.propose_deal to land via NP-0.

2. trader-main.ts: onDealCancelled no longer calls markCounterpartyFailed.
   That mark was treating EVERY cancellation — including the recoverable
   AGENT_BUSY race we hit when carol's outgoing proposal arrived at dave
   the same instant his incoming proposal arrived at her — as a permanent
   blacklist. The result was a deadlock: dave's NP-0 reject moved his
   deal to CANCELLED, dave permanently blacklisted carol, and even after
   carol's stale-deal timed out 30s later, the pair couldn't re-match.
   Cancellation reasons are too varied (proposal timeout, AGENT_BUSY,
   sibling-elected) for any of them to justify permanent blacklisting.
   The intent state machine + NP-0 PROPOSED-state timeout already
   provide the rate-limiting needed.

Adds 2 unit tests covering both directions of proposer election (yield
vs propose). Also defensively coerces undefined logs in
waitForReadyAddress so the unit-test mock returning undefined cleanly
hits its timeout path instead of a TypeError. Adds a default
mockGetContainerLogs in tenant-fixture.test.ts beforeEach so existing
provisionTrader happy-path tests pass without per-test setup.

Verified: multi-agent now passes all 3 scenarios in 427s on real testnet
(scenario 1 was the flake the proposer-election + non-blacklist fixes
target). Full unit suite: 829/829 green.
…guard, recordFill try/catch

Three fixes from adversarial review of fix/e2e-live-real-issues:

1. test/e2e-live/helpers/docker-helpers.ts — `listContainersByNamePrefix`
   now returns container IDs (12-char hex prefix) instead of names. Every
   downstream consumer validates input through `assertValidContainerId`,
   which is hex-only — names were rejected, every escrow-log fetch in the
   diagnostic-dump path silently threw and got swallowed by `catch{}`.
   Also tightens the prefix regex to require a leading [a-zA-Z0-9] (Docker's
   own naming rule) so degenerate prefixes like `.` or `--` are rejected.

2. src/trader/trader-main.ts — `ourIntentId(deal)` now returns null when
   neither pubkey matches ours. Previously it silently fell through to
   `acceptor_intent_id` if `pubkeysEqual(proposer_pubkey, agentPubkey)`
   returned false on a malformed pubkey, re-introducing the silent-no-op
   bug the helper was added to fix. Each call site checks for null and
   skips the intent update; the helper logs `callback_unknown_role` with
   full context for triage.

3. src/trader/trader-main.ts — `recordFill` is wrapped in try/catch in
   `onSwapCompleted`. Without the catch, a transition throw (e.g. user
   CANCELLED the intent mid-swap, or it EXPIRED during a long-running
   swap) escapes before `negotiationHandler.completeDeal()` fires, leaving
   the swap COMPLETED on the ledger but stuck EXECUTING in NP-0 forever.
   The catch logs `record_fill_failed_post_complete` and lets completion
   proceed; reservation release already happened above.
Cosmetic fixes from steelman round-2 review:
- scenario-helpers.ts: rename `escrowNames` to `escrowContainerIds` and
  update the diagnostic console.error label so the operator sees "escrow
  container <id>" instead of mislabeling an ID as a name.
- docker-helpers.ts: docstring correction — `docker ps` lists newest-first
  by default, not oldest-first.
Three product gaps the e2e suite tests-with-skip until landing:

#118 — `trader-ctl set-strategy --blocked-counterparties <list>`
  CLI flag forwards to the existing SET_STRATEGY handler, which already
  accepts `blocked_counterparties`. Empty string clears the list (handler
  treats empty array as overwrite). Engine criterion 7 in matchesCriteria
  was already implemented — the only missing piece was the CLI surface.
  Edge-case test "blocked counterparty" flipped from `it.skip` to `it`:
  alice blocks bob, both post matching intents, no A↔B deal.

#119 — `TRADER_FAULT_SKIP_DEPOSITS=1` env-gated fault injection
  Trader's `swap:announced` handler bails before calling
  `swapModule.deposit()` when the env var is set; the swap-executor's
  EXECUTION_TIMEOUT path drives both peers to FAILED with the existing
  retry loop. Cleanest fault-injection mechanism for this test (a CLI
  command would have to mutate live trader state mid-swap; an env gate is
  set-once-at-init and can't accidentally be flipped on production).
  Provision option `faultSkipDeposits` on `provisionTrader` plumbs it to
  the container env.
  Negotiation-failures test "deposit timeout" flipped from `it.skip` to
  `it`: alice + faulty trader pair up; alice deposits, faulty trader
  doesn't; deal lands in FAILED with EXECUTION_TIMEOUT.

#122 — `DealRecord.error_code` field
  Optional field populated on FAILED transitions, threading through:
  - types.ts: add `readonly error_code?: string` (FAILED-only by design;
    other terminal states carry their own NP-0 / payout payloads).
  - negotiation-handler.ts: `transitionDeal` accepts `{errorCode}` option,
    writes onto record only when newState === 'FAILED'. `failDeal` gains
    optional `errorCode` arg.
  - trader-main.ts: each failDeal call site forwards a distinguishing
    code: `VOLUME_RESERVATION_FAILED`, `EXECUTE_DEAL_FAILED: <msg>`,
    `PAYOUT_UNVERIFIED`, plus the swap-executor's `reason` string for
    swap-level failures (EXECUTION_TIMEOUT, ESCROW_UNREACHABLE, etc.).
  - acp-types.ts + handler: surface via DealSummary in list-deals so
    operators distinguish failure modes without log archaeology.

Also includes round-2 follow-ups from concurrent test work:
- e2e/trader-matching.e2e.test.ts and e2e/trader-multi-agent.e2e.test.ts:
  Update T2 + T13.3 assertions to reflect spec 5.7 proposer election
  (lower-pubkey side proposes; the higher-pubkey side yields).
- src/trader/negotiation-handler.test.ts and
  e2e/trader-negotiation.e2e.test.ts T3.3: assert proposer_intent_id is
  the MARKET id (UUID) not the local hash — DealTerms exchange market IDs
  by protocol necessity, peers don't share local hashes.

All 652 unit tests pass.
…dates

Spec 5.7 strict yield deadlocks against stale aggregator listings. With
shared testnet aggregators, an intent listed by a now-dead trader from a
prior test run is still a valid match candidate but never proposes (its
process is gone). If our pubkey sorts AFTER such a stale candidate's, my
filter `myKey < cpKey` excludes ourselves from proposing — and we wait
forever for an `np.propose_deal` that will never come.

Symptom: multi-agent scenario 1 hangs in `match_proposer_election_yield`
events for 10+ minutes. The "lower-priority candidate" is a stale
aggregator listing.

Fix: track `firstYieldAt[intent_id]`. If 45s elapses without any
proposal arriving, fall through and propose to the original (unfiltered)
match list. The duplicate-guard handles the live-vs-live race; the
no-blacklist policy (commit c1f24d8) prevents permanent wedging.

Cleanup tracked: `firstYieldAt` is cleared whenever a successful
proposing-elected match cycle runs, and purged alongside terminal-
intent cleanup in the periodic sweep.
The SDK's verifyPayout fails closed when payout-invoice tokens are L3-
pending (aggregator inclusion proof not yet propagated). On testnet, the
proof propagation has been observed to take up to ~7 min during peak
load. The previous 5-min retry budget reliably escalated legitimate
payouts to PAYOUT_UNVERIFIED; basic-roundtrip + multi-agent both flaked
on this even with the synthetic-ledger fix landing.

Bump MAX_ATTEMPTS 10 → 20 (still 30s interval, total 10 min).
Two tightly coupled diagnostic improvements that surfaced the real
failure on the blocked-counterparty edge-case scenario:

1. trader-ctl-driver: parse stdout JSON even when exit != 0. When
   --json is passed and the trader returns an AcpErrorPayload, the CLI
   writes the envelope to stdout AND exits 1. The previous driver only
   parsed JSON on exit==0, so error_code / message were invisible to
   the test — failures looked like opaque "exitCode === 1" with the
   actual problem buried in raw stdout. Now `result.output` is the
   parsed envelope (or the raw string when stdout isn't JSON).

2. edge-cases blocked-counterparty: include output + stderr in the
   exitCode assertion message. With (1) the parsed envelope shows up
   directly in the test failure, so a regression is one CI run away
   from a clear root cause instead of a silent network-trace dive.

Used together these surfaced the actual #118 root cause: a stale
dist/cli/main.js (the CLI flag was added in source but the build
artefact predates it) — Commander rejected the flag with
"unknown option '--blocked-counterparties'". Solved by `npm run build`;
no source change required.
…N-on-error

Two follow-ups from steelman on the trader-ctl-driver JSON-on-error
parsing change:

1. edge-cases blocked-counterparty assertion: cap output + stderr at
   500 chars to match house style (basic-roundtrip:334,345). Bounds
   the worst-case CI log noise if a future error envelope ever grows.

2. trader-ctl-driver.test.ts: positive coverage for the new branch.
   Previously only the negative case (non-JSON stdout on non-zero exit
   falls back to raw) was tested — there was no test pinning the new
   "stdout IS valid JSON envelope on non-zero exit → returns parsed
   object" behavior, nor the "empty stdout stays empty" edge.
   Added two tests; updated stale `// unparsed` comment on the existing
   case; all 25 driver unit tests pass.
…edges

Two follow-ups from the round-3 review:

1. edge-cases assertion: bump diagnostic cap from 500→2000 and switch
   to JSON.stringify(..., null, 2). At 500 chars an AcpErrorPayload +
   stderr can truncate mid-token, hiding `error_code` (the actionable
   field). 2000 is enough to fit a typical envelope; pretty-print keeps
   nested `result` legible across line wraps in CI logs.

2. trader-ctl-driver.test.ts: edge-case coverage for the JSON-on-error
   parse branch:
   - trailing newline (matches the real CLI wire format —
     `process.stdout.write(JSON.stringify(...) + '\n')`)
   - stringified bigint preservation (rate/volume/balance fields are
     emitted as strings; pin the driver doesn't silently coerce away
     precision past Number.MAX_SAFE_INTEGER)

   27/27 driver unit tests pass.
Two follow-ups from round-4:

1. trailing-newline test was redundant with the existing positive
   "parses JSON envelope on non-zero exit" — a stock JSON.parse handles
   `\n` trivially. Strengthened: stage `\n${envelope}\r\n` (leading
   newline + Windows CRLF). Now pins behavior against (a) CI shells that
   rewrite line endings and (b) a future hand-rolled `endsWith('}')`
   parser that would silently break. Renamed accordingly.

2. bigint test staged a `nested.deep.value` field but never asserted on
   it — added the assertion so the test actually exercises depth-vs-
   width of the parse, not just the top-level array.

27/27 driver unit tests pass.
Final round-5 follow-ups:

1. CRLF test comment was misleading (claimed protection against a
   "hand-rolled endsWith('}') parser" that doesn't exist). Rewrote to
   describe the actual contract: whitespace-tolerant parsing on
   Windows CI line-ending rewrites. Dropped fictional leading '\n'
   from the input — only trailing CRLF models a real wire condition.

2. Bigint test cast `Record<string, Record<string, string>>` was type
   theatre — JSON.parse returns `unknown`-shaped data. Cast at each
   step (Record<string, unknown>) instead of overpromising leaf types.

Reviewer's verdict: at the bottom of the diminishing-returns curve.
Stopping the steelman loop on this file.
…+ 4 warning

C1 — SET_STRATEGY didn't propagate to SwapExecutor / IntentEngine. The
executor destructured strategy at construction so max_concurrent_swaps
and trusted_escrows changes never took effect at runtime. Fix: keep
strategy as a mutable let-binding inside the factory; add an
updateStrategy() method on SwapExecutor; saveStrategy callback now
calls both intentEngine.updateStrategy AND swapExecutor.updateStrategy
AFTER successful disk persist (W3: persist-first ordering).

C2 — spec 7.9.5: trader auto-accepted swap proposals at any
protocolVersion. v1 lacks the mutual-consent signature chain and
doesn't bind escrow_address into swap_id, opening MITM substitution.
Fix: read protocolVersion from getSwapStatus result; reject and call
swap.rejectSwap('PROTOCOL_VERSION_TOO_OLD') for anything ≠ 2 BEFORE
registerSwapId. M1 also addressed here: reject when both
counterpartyPubkey AND proposerChainPubkey are undefined (currency/
amount alone is not enough identity in a busy market).

C3 — execution-timeout transitioned deal to FAILED but never called
swap.rejectSwap(). A late SDK swap completion would emit swap:completed
for an unregistered swapId; handler logs and drops; escrow already
moved funds → ledger says FAILED, chain says COMPLETED. Fix: call
deps.swap.rejectSwap(swapId, 'EXECUTION_TIMEOUT') BEFORE unregistering;
best-effort, log on failure (timeout's failure-path semantics still
hold from trader's perspective).

C4 — spec 7.9.3: deposit_attempted not persisted. SIGKILL between
deposit() issuance and event re-fire could double-deposit. Fix: add
saveDepositAttempted/loadDepositAttempted to TraderStateStore;
TraderAgent exposes markDepositAttempted(swapId) which persists
BEFORE returning; main.ts swap:announced handler calls it FIRST and
skips deposit() when not fresh. Loaded at startup so post-restart
event replay doesn't re-issue.

H1 — spec 7.9.4: registerSwapId compared currency/amount/counterparty
only. Hostile counterparty could pivot to a different escrow at the
SDK layer than what was negotiated at NP-0, defeating trusted_escrows.
Fix: extend SwapProposalMatchInfo with escrowDirectAddress / escrowPubkey
/ depositTimeoutSec; registerSwapId now rejects on any mismatch against
DealTerms.escrow_address / DealTerms.deposit_timeout_sec.

H2 — TRADER_FAULT_SKIP_DEPOSITS no production guard. Fix: hard-fail
at startup when env var = '1' AND (NETWORK ∉ {testnet,dev} OR
TRADER_FAULT_INJECTION_ALLOWED ≠ '1'). Defends against config
pipeline poisoning, .env leaks from CI jobs, image-tag confusion.

M2 — validateDealTerms didn't validate pubkey shape. Defense-in-depth
fix: call isValidPubkey() on proposer_pubkey and acceptor_pubkey; this
plugs the gap if a future refactor loses pubkeysEqual on any callsite.

M3 — no upper bound on rate / volume bigints. Hostile proposals with
2^256 values pass intent-range checks on legacy unbounded intents and
produce 2^512 in `rate * volume` arithmetic. Fix: cap both at 2^128
(well above any realistic monetary value).

W1 — double-deposit risk: poll-loop AND swap:announced event both
called sphere.swap.deposit(). Fix: drop the poll-loop deposit (kept
the diagnostic log for visibility); add per-swapId depositInFlight
Set in main.ts to dedup duplicate event delivery. Combined with the
C4 persisted set this gives both runtime-dedup and crash-recovery.

W2 — yield-timeout fall-through pathological retry loop: 45s yield →
propose → AGENT_BUSY race → cancel → re-yield. Fix: blacklist the
failed-yield candidate via engine.markCounterpartyFailed so the next
scan tries OTHER candidates first.

W3 — saveStrategy mutate-then-await ordering. Disk-write failure left
in-memory diverged from disk. Fix: persist first, mutate on success
(grouped with C1).

W4 — yield log fires every 5s scan iteration during 45s window
(9 redundant lines per yield session). Fix: log only on fresh-yield
(timestamp first set), silent during continuing-yield window.

All 547 unit tests pass. e2e-live mock fixtures unaffected.
…rom round-4 audit

Round-4 audit + round-4 review surfaced 6 new issues:

CRITICAL (round-5 review) — escrow term-binding broke `escrow_address: 'any'`
flow. The default escrow on intents is the wildcard 'any' sentinel; round-4's
strict `negotiated !== escrowDirectAddress` reject silently rejected EVERY
swap with the default config. Same gap for nametag-stored escrows. Fix:
skip the binding check for `escrow_address === 'any'` and `startsWith('@')`;
trusted_escrows allowlist already enforced policy at NP-0 negotiation.

N1 (LOW): protocolVersion type-drift DoS. Strict `!== 2` rejected any future
SDK that encodes version as a string. Fix: parse permissively (number 2,
"2", "v2", semver "2.x.y").

N2 (MEDIUM, partial fix in same change as CRITICAL above): require BOTH
escrowDirectAddress AND escrowPubkey to match when both supplied (was OR).

N3 (LOW): deposit-attempted.json grew unbounded. Fix: in-memory LRU cap of
10k entries; new TraderAgent.clearDepositAttempted(swapId) called from
swap:completed / swap:failed / swap:cancelled handlers; persists the
trimmed set on terminal events.

N4 (LOW): EXECUTION_TIMEOUT rejectSwap failure was warn-level, no operator
signal for the resulting ledger-vs-chain inconsistency. Fix: error level,
inconsistency_risk: true tag, push to lastErrors so getLastErrors()
surfaces without log archaeology.

N5 (LOW): MISSING_COUNTERPARTY_PUBKEY reject_failed swallowed silently.
Fix: mirror the protocolVersion path with a swap_reject_failed error log.

Also added swap:cancelled handler to clear the deposit-attempted Set on
all three terminal-state events (round-5 only — round-4 had only completed
+ failed via the agent.handleSwap*).

All 547 unit tests pass.
…erage

Round-5 verifier ruled SHIP IT but flagged a WARNING: zero tests
exercised the new round-5 surface (registerSwapId escrow term-binding,
isWildcard/isNametag skip, OR→AND escrow check, updateStrategy
runtime propagation). A regression that flipped `isWildcard` to `||`
or removed the wildcard short-circuit would have shipped green.

This commit adds 6 focused tests:

- CRITICAL — accepts proposal when negotiated escrow is "any" (the
  default DEFAULT_ESCROW used by every intent created without an
  explicit escrow). The round-5 fix that motivated the entire 5b
  cycle was this check; pin it.
- CRITICAL — accepts proposal when negotiated escrow is a nametag
  (@…). Same reason.
- H1 — rejects when concrete escrow doesn't match negotiated.
- N2 — rejects when escrowDirectAddress matches but escrowPubkey
  is wrong (locks in the OR→AND fix).
- H1 — rejects on deposit_timeout_sec mismatch.
- C1 — updateStrategy() propagates max_concurrent_swaps so the next
  executeDeal sees the new cap (was a frozen startup snapshot before
  round-4).

553 unit tests pass (up from 547). Typecheck clean.
…ip flake fix)

PRIMARY FIX (intent-engine.ts: yield-timeout state machine).

Investigation of basic-roundtrip flake (2026-04-28 log) found that bob
(higher-pubkey peer who should YIELD per spec 5.7) was firing 80
np.propose_deal proposals against alice in 11.5 minutes. Root cause:
`firstYieldAt` was set on the FIRST yield but NEVER reset after the
yield-timeout fall-through fired. Once `elapsed > 45s`, every
subsequent scan (~5-8s cadence) re-fired the fall-through immediately,
producing a proposing storm. Round-4 added a blacklist after
fall-through (W2), which masks the storm at the next-scan but leaves
the bug latent — if the blacklist LRU evicts the entry while the same
intent is still active, the storm resumes.

Fix: `firstYieldAt.delete(own.intent_id)` after fall-through fires.
Each yield session now has AT MOST ONE fall-through, regardless of
blacklist state.

DEFENSE-IN-DEPTH (negotiation-handler.ts: global sybil-flood gate).

The existing per-counterparty rate-limit (3 proposals/60s/pubkey) does
NOT bound a flood from N distinct sybil pubkeys, each sending 1
propose_deal/min (under per-peer cap). At N≥600 this saturates ECDSA
verification CPU and bloats the DealRecord state.

Added a global rolling-window gate: MAX_INBOUND_PROPOSALS_PER_MIN=600
(10/sec) — comfortably above the worst-case legitimate scenario (10
intents × 50 candidates × 30s scan = ~17/sec) with headroom for
bursts. Excess drops silently with a periodic counter log.

Tests: 5 new tests (4 yield-timeout state machine + 1 sybil flood);
total trader unit tests 553 → 558. Typecheck clean.
Replaces the testnet faucet HTTP dependency with direct genesis mints
via sphere-sdk's new PaymentsModule.mintFungibleToken. The faucet has
been a recurring source of test flakiness (sustained 30s+ HTTP timeouts
on /api/v1/faucet/request while the host TCP layer is healthy) — this
removes it from the test dependency graph entirely.

Trader-side wiring (src/trader/main.ts):
- TRADER_TEST_FUND env var: comma-separated `<coinIdHex>:<amount>` pairs.
  E.g., TRADER_TEST_FUND="455ad8...:5000,8f0f3d...:5000" mints 5000
  each of UCT and USDU at startup before agent.start(), so balances are
  visible to the intent engine on the first scan.
- Production guard: requires UNICITY_NETWORK ∈ {testnet, dev} AND
  TRADER_FAULT_INJECTION_ALLOWED=1 (same gate as TRADER_FAULT_SKIP_DEPOSITS).
  Misconfigured prod deployment can't accidentally mint balances.

Test fixture (test/e2e-live/helpers/tenant-fixture.ts):
- New ProvisionTraderOptions.selfMintFund: replaces fundFromFaucet=true
  + fundCoins for tests that don't need the faucet path.
- Sets TRADER_TEST_FUND + TRADER_FAULT_INJECTION_ALLOWED=1 in container env.
- Post-boot poll waits until portfolio shows non-zero confirmed balance
  for each entry (same shape as the faucet path).

Constants (test/e2e-live/helpers/constants.ts):
- Add UCT_COIN_ID and USDU_COIN_ID — canonical bytes from the public
  testnet registry.

Basic-roundtrip e2e:
- Switched both buyer + seller from fundFromFaucet to selfMintFund.

558 trader unit tests pass; typecheck clean.
Live observation in basic-roundtrip 2026-04-29 caught a false-reject
introduced by an earlier round (round-5 N2 hardening): the
swap_id_register_escrow_mismatch path was firing on every legitimate
proposal because the secondary clause `negotiatedEscrow === DIRECT://
${escrowPubkey}` was naive string concatenation. Real DIRECT
addresses are derived via UnmaskedPredicateReference(pubkey).toAddress()
— a structural hash, not raw-pubkey-with-prefix. Concrete observed
mismatch:

  negotiated_escrow: DIRECT://0000c76707d1f2ec...2eea0fa28313
  proposal_escrow:   DIRECT://0000c76707d1f2ec...2eea0fa28313  (matches)
  escrow_pubkey:     6e30a372a905d259...30cf77                 (raw bytes)

The original threat model that motivated the AND-clause was wrong:
escrowDirectAddress and escrowPubkey both come from the SAME
getSwapStatus() call, not independent sources. They can't diverge
under attacker manipulation — a hostile pivot to a different escrow
updates both together. Once directMatches binds against the
negotiated escrow, we've already detected any pivot.

Fix: only require directMatches. Drop the pubkey clause entirely.

If we ever need to verify the pubkey-derives-the-DIRECT invariant for
defense-in-depth, do it via the SDK's
UnmaskedPredicateReference.toAddress() helper, not string concat.

Test updated: the "rejects-spoof" test was upgraded to "accepts when
direct matches regardless of pubkey", documenting the corrected
semantics.

558 trader unit tests pass; typecheck clean.
Live observation in basic-roundtrip 2026-04-29 caught a missing-deposit
bug: the SDK's `swap:announced` event was firing only on the SDK
proposer side (buyer), NOT on the acceptor side (seller). Seller's
swap-poll DID detect `progress === 'announced'` (logged via
swap_deposit_target_diag every 3s) but never deposited because the
W1-round-4 fix had dropped deposit dispatch from the poll loop.
Result: only 1 of 2 peers deposited; escrow's deposit_timeout_sec
(300s) fired; swap was cancelled by the escrow at 5 min; both peers'
EXECUTION_TIMEOUT (420s) fired at 7 min finding the swap already
cancelled.

Round-5c fix: hoist depositInFlight set + extract a unified
tryDepositForSwap(swapId, trigger) helper. Both code paths call it:
- swap:announced event handler (trigger='event')
- swap-poll loop on `progress === 'announced'` (trigger='poll')

Dedup via the same depositInFlight + markDepositAttempted gates that
already prevented the W1 double-deposit. The 'trigger' field on the
swap_deposit_sent log identifies which path won the race.

Net effect: deposit always fires within ~3s of the swap reaching
'announced' state, regardless of whether the SDK fires the event on
this peer's side. The escrow's 5-min deposit_timeout window is no
longer at risk.

558 trader unit tests pass; typecheck clean.
Round-5 diagnostic log (basic-roundtrip flake investigation 2026-04-29).
The trader's existing onDirectMessage diagnostic only matched
prefix-formatted swap DMs (swap_proposal:/swap_acceptance:/swap_rejection:).
The escrow's invoice-delivery DM is JSON-formatted with no prefix —
silently bypassed all logging.

Adds diag_invoice_delivery_dm_received which fires when an incoming DM
contains "invoice_delivery". Together with sphere-sdk's diags
(diag_swap_dm_arrived, diag_swap_dm_parse_rejected,
diag_invoice_delivery_received, etc.), this traces the complete
escrow→trader invoice path.

To be removed once root cause is fixed.
… terminal

Empirical observation in basic-roundtrip live e2e 2026-04-30: with the
mt:-orphan dedup fix in place, the buyer's payout invoice now correctly
shows covered=10 / surplus=0 / 1 transfer. But the single transfer's
`confirmed` flag stays false until L3 anchoring completes, which on
testnet often exceeds the trader's deposit_timeout_sec + grace budget
(420s = 5 min escrow + 2 min grace).

Pre-fix sequence:
  - escrow finalizes deposits + payouts (~30s)
  - SDK swap reaches `completed` state
  - Wallet's verifyPayout retry loop runs (10 min budget) waiting for L3
  - At T=420s the trader's EXECUTION_TIMEOUT timer fires
  - Trader transitions deal to FAILED
  - Calls swap.rejectSwap() — SDK responds "Cannot reject: swap is
    already completed"
  - Operator-visible inconsistency_risk=true error (the exact one the
    round-4 fix was supposed to PREVENT, just from the other direction)

Fix: before transitioning to FAILED, check the SDK swap status. If
the SDK considers the swap terminal (completed/cancelled/failed),
skip the failure path entirely — the verifyPayout retry loop in
main.ts will resolve the deal via swap:completed/swap:failed events.

If the status check itself fails (network blip), proceed with the
original failure path — defensive default.

SwapAdapter gains an optional getSwapStatus() method to keep the
unit-test mock backwards-compatible. The wrapper in main.ts wires
it to sphere.swap.getSwapStatus.

558 trader unit tests pass; typecheck clean.
…d-flag on failure

Empirical observation in basic-roundtrip live e2e 2026-04-30:

10:29:55.526  swap_announced (event handler fires)
10:29:55.528  swap_deposit_failed: "Deposit invoice not yet imported into
              accounting module"  ← 2ms later
10:29:55.572  invoice_delivery DM received  ← invoice arrives 44ms LATER
10:29:58.320  swap_deposit_skipped_already_attempted ← persistent flag
              from round-5c blocks retry forever

Two compounding bugs:

1. Retry filter only matched "not yet available" / "SWAP_WRONG_STATE",
   but the actual error was "not yet imported" — so the retry loop
   gave up after the first attempt instead of waiting 3s.

2. Round-5c spec 7.9.3 idempotency persisted markDepositAttempted
   BEFORE deposit() succeeded. When deposit() then failed terminally,
   the persistent flag remained set, causing every future attempt
   (poll-loop fallback, restart, etc.) to silently skip the deposit.
   This violates the spec's intent: the persistent mark exists to
   defend against "process crashes between issuing deposit and event
   handler firing" — which only matters AFTER deposit() succeeds. A
   failed deposit should leave the flag cleared so retries work.

Fix:
- Extend retry filter to include "not yet imported".
- Track depositSucceeded flag locally; in the finally block, if
  !depositSucceeded, call agent.clearDepositAttempted to roll back
  the persistent mark. Future retries (event re-fire, poll, restart)
  can proceed normally.

558 trader unit tests pass; typecheck clean.
…estnet L3 latency

Empirical observation in basic-roundtrip live e2e 2026-04-30: with the
mt:-orphan dedup, EXECUTION_TIMEOUT terminal-skip, and deposit-retry
fixes in place, the end-to-end swap completes successfully:

  - Both sides import deposit invoices ✓
  - Both sides deposit to escrow ✓
  - Escrow logs "Both deposits received" → COVERED ✓
  - Escrow pays out both parties ✓
  - Escrow logs "Swap completed successfully" ✓
  - Trader receives swap_payout_received event ✓
  - verifyPayout sees coveredAmount=10, surplus=0, 1 transfer ✓ (dedup)

Only remaining gap is L3 confirmation latency. The wallet's verifyPayout
gates on `allConfirmed: true` which requires the inbound payout transfer
to land in an L3 inclusion proof. On testnet 2026-04-30 this took
>10 min — verifyPayout hit retry attempt 18 (out of 20 × 30s = 10 min)
with the transfer still confirmed=false.

Trader change: MAX_ATTEMPTS 20 → 40 (10 min → 20 min retry budget).
Test change: SWAP_TIMEOUT_MS 10 min → 25 min (covers the 20 min retry
budget plus negotiation/escrow overhead).

This is testnet infrastructure latency, not a product bug. The wallet
correctly waits for L3 anchoring before declaring payoutVerified=true.
We just need to give it more time.

Note: the test's it() timeout auto-derives from SWAP_TIMEOUT_MS + 60s
so no separate bump needed.

558 trader unit tests pass; typecheck clean.
…up path

When provisionTrader's catch block fired (e.g. waitForReadyAddress timed
out because Sphere.init hung), safeCleanup() ran immediately, removing
the container before its logs could be inspected. Result: post-mortem
analysis was guess-driven — we could see "buyer didn't log
sphere_initialized within 180s" but couldn't see WHERE in init the
buyer actually hung.

Adds a getContainerLogs(container.id, 500) call BEFORE safeCleanup,
writing the result to stderr so vitest captures it into the failure
output. Distinguishes:

- "registering_nametag" present but no "sphere_initialized" → hang
  inside Sphere.init (Nostr publishNametagBinding)
- "downloading_trustbase" present but no "registering_nametag" → hang
  on the GitHub HTTP get (testnet trustbase fetch)
- Neither logged → process crashed at startup (TS exception, missing
  env var, etc.)

To be removed once root cause is fixed.
…artup error

ROOT CAUSE of basic-roundtrip provisioning flake (investigated
2026-04-30 via provisioning-load-investigation.e2e-live.test.ts):

The trader's nametag was constructed as:
  `t-${config.instance_id.replace(/[^a-z0-9]/g, '').slice(0, 12)}`

For test instance_ids like `trader-e2e-${UUID}`, this yields
`t-tradere2eXXX` with only 3 hex chars of UUID entropy after the
fixed `tradere2e` prefix. Total distinct nametags: 4096.

Nostr relays persist NIP-17 nametag bindings indefinitely. As test
runs accumulated bindings on the testnet relay, the chance of a
fresh UUID's first 3 hex chars matching a prior run's binding grew.
On collision, `publishNametagBinding` rejected with "Nametag is
already claimed by another pubkey" → trader logged
trader_acp_startup_failed at ~1s → process.exit(1).

Test mis-interpreted the early exit as a hang: `waitForReadyAddress`
polls container logs for `sphere_initialized` for 180s, doesn't
notice the process exit, throws the generic "did not log
sphere_initialized within 180000ms" — making it look like a Nostr/
relay flake when it's actually a fast-failure in our own code.

Empirical reproduction:
- Single-trader sequential (20 attempts): 0/20 hangs (low odds)
- 3-trader concurrent (30 attempts): 1/30 hang (3% — matches
  basic-roundtrip's intermittent failure rate)

Two fixes:

1. trader/main.ts: use FULL sanitized instance_id (32 hex chars),
   not slice(0, 12). 16^32 distinct nametags → effectively
   collision-free.

2. test/e2e-live/helpers/tenant-fixture.ts: in waitForReadyAddress,
   detect `trader_acp_startup_failed` log lines and short-circuit
   the 180s poll with a clear "startup failure" error. Future
   startup errors will surface immediately instead of being
   reported as 180s hangs.

558 trader unit tests pass; typecheck clean.

Saved as durable feedback memory: future e2e-live tests should
default to Promise.all-style concurrent provisioning instead of
sequential — concurrent is faster per-trader (relay handshakes
overlap) and the supposed "parallel-flake" justification was
based on this nametag-collision misdiagnosis.

Also: concurrent provisioning saves ~10-15s per test run (validated:
sequential ~12-15s vs concurrent ~6-9s for 3 traders).
Unicity ID format validator rejects names >20 chars. Pre-fix used the full
sanitized instance_id (~32 hex) which produced 'Invalid Unicity ID format'
errors, causing trader processes to exit ~1s after start.

Slicing to 18 keeps the 't-tradere2e' prefix (11 chars) plus 9 hex chars
of UUID entropy = 16^9 = 68 billion distinct nametags, effectively
collision-free across all test history.
vrogojin added 21 commits May 1, 2026 09:10
…y outage

The Unicity testnet Nostr relay (wss://nostr-relay.testnet.unicity.network)
has stopped accepting/indexing kind:30078 (NIP-78 application-data) events
since 2026-04-30 21:31 UTC. Direct relay query confirms:
  - Last hour: 30 kind:1059 (DMs) events — relay is alive
  - Last hour: 0 kind:30078 events — NIP-78 is broken
This causes EVERY nametag binding publish to silently fail — but the
nostr-js-sdk's `broadcastEvent` treats "no OK response after 5s" as
success, so Sphere.init returns happy and the trader proceeds. The
faucet then returns "Nametag not found" on its own resolve query.

Cannot fix from this side; documenting it via a tighter trader-side
verification loop that surfaces the issue earlier:

src/trader/main.ts:
  - Restored nametag verify loop with 30 attempts × 1s + 3s per-call
    timeout via Promise.race (was previously 110s multiplicative
    backoff that hung the SDK's internal queryEvents timeout). When
    the relay's index is broken, the loop falls through with a clear
    `nametag_verification_timeout` warn instead of wedging startup.

test/e2e-live/helpers/tenant-fixture.ts:
  - provisionTradersStaggered: sequential (was concurrent). The single
    testnet relay can't handle parallel nametag publishes — concurrent
    Sphere.init runs saturate the relay's subscription queue and all
    traders' verify loops time out simultaneously.
  - Readiness check switched from DM-based probeReady to log-based
    waitForLogEvent('acp_listener_started'). The DM probe spawned
    fresh Nostr connections every 2s, hammering the same single relay
    and competing with the trader's own publish/subscribe activity.
  - fundWithRetry: anchored regex `/Faucet returned (\d{3})/` to
    avoid matching arbitrary "4xx"-shaped substrings inside random
    instance_ids in error bodies (e.g. `4909` in
    `tradere2eea4909558` was tripping fast-fail on 5xx faucet errors).
    Also added retry on "Nametag resolution timed out" and "Nametag
    not found" — both transient when the binding event hasn't
    propagated yet.
  - Faucet retry budget: 10 attempts with capped exponential backoff
    (was 3 attempts × tight backoff).

test/e2e-live/multi-agent*.e2e-live.test.ts:
  - Use provisionTradersStaggered (currently sequential, but the
    helper centralizes concurrency policy for future relay-health
    improvements).
- Remove unused probeReady() and READY_POLL_INTERVAL_MS / READY_PROBE_TIMEOUT_MS
  constants left over from the DM-based readiness check (replaced by log-based
  waitForLogEvent in the previous commit).
- Replace `globalProposalTimestamps[0]!` non-null-assertion with explicit
  undefined check in negotiation-handler — pre-existing lint error.
- Mark unused `intentId` destructure as `_` in edge-cases test —
  pre-existing lint error.

Lint + 558 unit/e2e tests clean.
The unit test for provisionTrader was emitting only sphere_initialized.
After the readiness check switched from DM-based probeReady to
log-based waitForLogEvent('acp_listener_started') in commit 57f5301,
the mock log no longer satisfies the new readiness gate and the
'happy path' test fails.

Add acp_listener_started to the default getContainerLogs mock output
so both gates pass. Tests that exercise the failure path still
override the mock with a stub that omits the line (or returns nothing)
to trigger the timeout branch.
…SKIP_DEPOSITS

The trader's production guard requires TRADER_FAULT_INJECTION_ALLOWED=1
to permit the TRADER_FAULT_SKIP_DEPOSITS=1 fault flag. Without the
permit env var, the trader rejects the fault flag and crashes at
startup with 'fault_inject_production_guard_violation', causing the
faulty trader fixture to never log acp_listener_started and the
beforeAll provisioning to time out.

Set both env vars together when the test caller passes
faultSkipDeposits: true. Verified faulty trader logs the expected
fault_inject_deposit_skip_enabled and acp_listener_started after the
fix.
Two bugs in the fault-injection / swap-cancellation paths that were
masked because the partial-fill happy path doesn't exercise them:

1. **TRADER_FAULT_SKIP_DEPOSITS only honored on the swap:announced
   event, not the poll-loop fallback.** The acceptor side primarily
   uses the poll path (the SDK doesn't always fire swap:announced for
   acceptor), so the fault was a no-op for it — faulty trader still
   deposited via the poll trigger and the swap completed normally
   instead of timing out. Moved the check inside `tryDepositForSwap`
   so both event AND poll triggers honor it (single source of truth).

2. **swap:cancelled handler did cleanup but didn't fail the deal.**
   When the escrow declared a swap cancelled (typically because a
   counterparty's deposit didn't arrive within deposit_timeout_sec),
   the trader cleared depositInFlight/markDepositAttempted — but did
   NOT call agent.handleSwapFailed(). The deal record stayed in
   EXECUTING forever; the intent stuck in NEGOTIATING. The
   deposit-timeout e2e scenario hung at exactly this point pre-fix
   (alice's swap got cancelled, refund arrived, but deal never
   transitioned to FAILED). Added handleSwapFailed call with reason
   'SWAP_CANCELLED'.

Also added `--deposit-timeout-sec <sec>` CLI flag to create-intent
so tests can shorten the escrow timer (default 300s) for the fault
scenarios that need to wait it out.

Plus a bug report documenting the prior testnet relay outage
(2026-04-30 → 2026-05-01) for posterity.
…5-01

The relay degradation is ongoing in waves:
  - 2026-04-30 21:31 UTC — silent-write-failure begins
  - 2026-05-01 ~10:45 UTC — writes recover, partial-fill e2e
                            passes in 45s
  - 2026-05-01 ~13:00 UTC — second degradation: reads slow to
                            5-7s per query (vs ~177ms baseline);
                            sphere-sdk's 5s queryTimeoutMs fires
                            before the relay can respond → cascade
                            of INVALID_RECIPIENT failures and 11+
                            min e2e durations
  - 2026-05-01 ~15:50 UTC — third degradation: queries don't
                            return at all within 8s

Real fix is on the relay infra side (DB overload? subscription
queue? CPU?). Adding more workarounds at the SDK level can extend
the slow-but-working window but cannot help when the relay
genuinely doesn't respond.
The relay degrades severely at ~30 long-lived WebSocket connections.
That's three orders of magnitude below typical Nostr relay capacity
— public relays routinely handle hundreds to thousands. The behavior
points to an internal bug or misconfiguration, not natural capacity:

  - DB write/index bug (accepted at protocol but not persisted)
  - Subscription cleanup that doesn't run, leaking slots
  - Config limit set unreasonably low (e.g. max_subscriptions=1)
  - DB lock contention in nostr-rs-relay v0.9.0 (current upstream
    is several minor versions ahead)
  - Underlying VM resource exhaustion not surfaced as errors

The framing matters because client-side workarounds (longer
timeouts, sequential test execution) only mask the symptom and
add real cost (test wall time, retry budgets, code complexity).
The fix is on the relay infra side.
Two concurrent invocations of `npm run test:e2e-live` previously shared a
flat namespace for Docker container names and /tmp directories — a hand-
ful of name collisions away from cross-run interference. Add a per-process
SESSION_ID (8-hex, generated once at module load; overridable via
TRADER_E2E_SESSION_ID) and stamp it onto:

  - Every Docker container name (trader + escrow): trader-e2e-<SID>-<label>-<rand>
  - Every /tmp wallet/controller dir: /tmp/trader-e2e-<SID>-<label>-XXXXXX
  - The diagnostic-dump filter in waitForDealInState (was a dead lookup
    `escrow-e2e` that never matched anything; now uses the session prefix
    which captures both trader and escrow containers cleanly)

Nostr-side identifiers (per-trader secp256k1 keypair, nametag from UUID)
already have ≥10⁹ entropy so they don't need a session prefix; adding one
to instance_id would only reduce the 9-hex randomness in the nametag
slice, increasing collision risk within a session.

Replace `provisionTradersStaggered`'s sequential for-loop with a bounded
worker pool (default concurrency 3, env-tunable via
TRADER_E2E_PROVISION_CONCURRENCY). The pool preserves input ordering of
results and caps simultaneous Sphere.init/nametag-publish load on the
shared testnet relay. Validated by provisioning-load-investigation that
3-way parallel is reliable on a healthy relay; tunable down to 1 when
the relay is degraded.

Add VITEST_MAX_FORKS opt-in knob to vitest.e2e-live.config.ts. Default
remains 1 (singleFork: true) for backward compatibility — multi-fork
parallelism is opt-in by env so users can flip it when ready.

All 113 helper unit tests pass with the new session module wired in.
Add a vitest globalSetup that runs the infra-probe against testnet (or
mainnet/dev via env override) before any test file is loaded. If any
service is unreachable the suite aborts up-front instead of burning a
10-15-minute container spawn cycle to surface the same failure as an
opaque timeout downstream.

Knobs:
  TRADER_E2E_SKIP_PREFLIGHT=1     — bypass entirely (escape hatch)
  TRADER_E2E_PREFLIGHT_STRICT=1   — also fail on degraded (default warns)
  TRADER_E2E_PREFLIGHT_NETWORK    — override network (default: testnet)
  TRADER_E2E_PREFLIGHT_TIMEOUT_MS — per-probe ceiling (default: 30000)

Default policy: fail-fast on `unreachable`, warn-and-proceed on
`degraded`. The e2e suite has generous timeouts that absorb mild
slowness, but a fully-down service guarantees a multi-minute hang. The
strict mode is available for CI runs that prefer to surface degradation
as failure rather than risk flaky test output.

Adds two npm scripts (preflight, preflight:json) for ad-hoc probing
without invoking vitest. Pulls in @unicitylabs/infra-probe@^0.3.0 from
npm; ships a tiny .d.ts shim for the upstream pure-ESM module.

Smoke-tested locally: helper unit-test file runs preflight then
proceeds; SKIP env disables the gate; STRICT env elevates degraded to
hard failure (probe currently caught a real 12s search degradation on
the testnet market API).
…er + leak-free worker pool

Two critical findings from the steelman review of #10:

## C1: anchor docker --filter name regex with `^`

`docker ps --filter name=X` is a SUBSTRING match by default — `name=foo`
matches any container whose name CONTAINS "foo", not just those that
START with "foo". The PR documented session-prefix as a security-style
isolation guarantee, but the unanchored filter could in theory match
adjacent-session containers if their session IDs shared leading hex digits.

Anchor with `^` (Docker passes the value through to its regexp matcher,
so `^prefix` is honored). Verified with two containers `test-prefix-foo`
and `xtest-prefix-foo-y`:
- without `^`: matches both (substring)
- with `^`: matches only the prefix-anchored one

## C2: Promise.allSettled + dispose orphaned tenants on partial failure

The previous worker pool used `await Promise.all(workers)`, which rejects
on first error. But other in-flight workers continue spawning containers
AFTER the function rejects, and those containers never reach `results`,
so the caller's `afterAll` never sees them. **Container + /tmp leak,
strictly worse than the sequential predecessor.**

Fix: each worker catches per-task errors into a shared `errors[]` array
and continues draining. After all workers settle, if any errors were
recorded, dispose every tenant that DID succeed (they're unreachable to
the caller through the rejected promise) before re-throwing the first
error. Multiple errors are attached as `.otherErrors` on the primary so
they aren't silently swallowed.

## Drive-by: replace top-level `generatePrivateKey` import

`@unicitylabs/sphere-sdk` no longer exports `generatePrivateKey` at the
package root (moved to the L1 sub-namespace). Replace with an inlined
`randomBytes(32).toString('hex')` — a secp256k1 private key is just 32
random bytes, and the probability of generating an invalid value is
~2^-128 (vanishingly small).

## Test plan

- 113/113 helper unit tests pass
- typecheck clean for the test/ tree (the unrelated `mintFungibleToken`
  type error in `src/trader/main.ts` belongs to PR #12 and is fixed there)
- Docker filter anchor verified empirically with two test containers
…Dependencies + README

Three review-feedback items from #11:

## W1+W2: validate env-var inputs at preflight startup

`Number('abc')` returns NaN; `Number('-1')` returns -1; `Number('0')`
returns 0. All of these would propagate to the upstream probe's
`setTimeout` and either fire immediately (NaN coerces to 1ms in Node)
or never fire — producing misleading "preflight failed" results from a
typo. Validate `TRADER_E2E_PREFLIGHT_TIMEOUT_MS` with
`Number.isFinite() && > 0` and throw a clean error otherwise.

Same hardening for `TRADER_E2E_PREFLIGHT_NETWORK`: was a blind cast to
`'testnet' | 'mainnet' | 'dev'`. The upstream `runProbes` would also
throw on unknown networks, but tightening locally makes the contract
visible at trader-service startup and immune to upstream silent enum
extensions.

## W5: move @unicitylabs/infra-probe to devDependencies

The probe is only used from `test/e2e-live/` and the `preflight` /
`preflight:json` npm scripts. End-users `npm install`ing trader-service
as a binary (the `bin: trader-ctl` entrypoint) shouldn't pull in the
probe + its transitive deps (@noble/curves, ws). Move to
devDependencies; lockfile updated.

## W3: document the four env vars in README

A developer hitting "preflight failed" needs to know about
`TRADER_E2E_SKIP_PREFLIGHT=1` without grepping the source. README now
has an "E2E live tests — preflight gate" section with a table of all
four env vars + their defaults + ad-hoc probing instructions.

## Not addressed (deliberately deferred)

- C1 (cosmetic): "N service(s) unreachable" log message can over-count
  when `error` and `unreachable` mix. The gate still fires correctly;
  the message phrasing is a separate cleanup.
- W4 (per-file opt-out): no opt-out mechanism for vitest globalSetup
  per-file. The `TRADER_E2E_SKIP_PREFLIGHT` escape hatch is the
  documented workaround.

## Test plan

- typecheck clean for the test/ tree (the pre-existing src/trader/main.ts
  errors belong to PR #12 and are fixed there)
- helper unit tests pass; preflight runs and validates env vars correctly
- `npm install` re-resolves dep graph after move; no breakage
Call `sphere.accounting.setAutoReturn('*', true)` immediately after
`Sphere.init` so any terminated invoice this wallet is a target of will
have its surplus (`coveredAmount > requestedAmount`) refunded
automatically to each over-paying party at their `refundAddress` ?? `senderAddress`.

Why this matters: the SDK's AccountingModule already tracks per-payer
contributions and emits `invoice:overpayment`, but the actual refund
only fires when auto-return is enabled. With sphere-sdk PR-119 making
`SwapModule.verifyPayout` explicitly fail with `OVER_COVERAGE` on
net > expected, surplus on a trader's payout invoice is detectable AND
must be refunded — this PR wires the refund.

## Review-feedback hardening

**RATE_LIMITED guard** (PR-12 review W2). The SDK throws `RATE_LIMITED`
if `setAutoReturn('*', true)` is called twice within a 5-second cooldown.
Process restart wouldn't normally hit it (cooldown is in-memory only),
but an in-process supervisor that retries `startTrader` on error would.
`RATE_LIMITED` here is functionally a no-op (flag is already true) so we
treat it as success, log distinct event, and continue startup.

**Startup-cost note** (PR-12 review W1). `setAutoReturn('*', true)` is
NOT just a flag flip — when enabled, the SDK iterates
`closedInvoices ∪ cancelledInvoices` (capped at 100) and runs
`_executeAutoReturnFromFrozen` for each, which issues real outbound
payments. Operators should expect the first call after wallet migration
to be slow. Documented in the comment.

## Drive-by

`@unicitylabs/sphere-sdk` no longer exports `generatePrivateKey` at the
package root (moved to the L1 sub-namespace). Replace with inlined
`randomBytes(32).toString('hex')` in `test/e2e-live/helpers/tenant-fixture.ts`.
**The same fix is also in PR #10**; whichever lands first wins, the
other gets a trivial merge. Required for typecheck/tests to pass against
current sphere-sdk.

`mintFungibleToken` was added in sphere-sdk's
`refactor/extract-cli-to-sphere-cli` branch and never landed in main.
Trader-service's `TRADER_TEST_FUND` test-helper code path called it
unconditionally; replace with a guarded shim that throws explicitly when
the method isn't available. The e2e suite uses the faucet path, not
TRADER_TEST_FUND, so this guard never trips in production CI but the
typecheck error blocks docker image build.

## Test plan

- [x] All 671 unit tests pass (was failing before drive-by fix)
- [x] Typecheck + lint clean
- [x] **End-to-end validation** (with sphere-sdk #119 + #120 active):
      trader-service e2e-live suite 11/11 passing in 23 min;
      multi-agent test went from 26-min OVER_COVERAGE hang → 4.9-min PASS
…tion + dash-aware sanitizer

Three review-warning items from #10:

## W4: 32-bit → 64-bit SESSION_ID entropy

`randomBytes(4)` = 32 bits. Birthday-bound collision probability at 100
concurrent CI shards: ~1.2e-6 (small but non-zero). At 1000 shards: ~1.2e-4.
Bumping to `randomBytes(8)` = 64 bits drops collision probability to
~5e-15 even at thousands of concurrent runs — effectively impossible for
any realistic deployment. Cost: 4 additional random bytes. Trivial.

## W5: validate env-var inputs loudly, not silently

Two env vars used to silently floor invalid values:

- `TRADER_E2E_PROVISION_CONCURRENCY=0` (intent: force sequential, cc=1)
  used to fall back to DEFAULT_PROVISION_CONCURRENCY=3, surprising the
  operator. Now throws with a clear "must be >= 1, use cc=1 to force
  sequential" message.
- `VITEST_MAX_FORKS=abc` used to silently fall back to 1 via `|| 1`.
  Typos slipped through. Now throws with a "must be a positive integer"
  message. Same treatment for negative/NaN.

Both validators preserve the documented defaults when the env var is
unset or empty — no behavior change in the common case.

## W6: hex sanitizer respects dashes

`TRADER_E2E_SESSION_ID` override was sanitized via `[^0-9a-f]` strip,
silently mangling values like `ci-job-1234-abc` (CI driver tag) into
`abc`. Tighten to `[^0-9a-z-]` so dashes (which Docker container names
allow) are preserved. Length cap raised to 32 chars to match the
larger 64-bit auto-generated ID. Non-Docker-safe characters (slashes,
colons, spaces) are still stripped to prevent argv injection.

## Drive-by note

The pre-existing `mintFungibleToken` typecheck error in `src/trader/main.ts`
is fixed by PR #12. Whichever lands first wins; both touch this file.

## Test plan

- All 671 helper unit tests pass
- Typecheck clean for test/ tree (the mintFungibleToken error is upstream)
- VITEST_MAX_FORKS=abc throws "must be a positive integer"
- TRADER_E2E_PROVISION_CONCURRENCY=0 throws "must be >= 1"
- TRADER_E2E_SESSION_ID=ci-job-1234 round-trips with dashes intact
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
Resolves conflicts where master's HMA-orchestrated work meets PR #9's
real-settlement work:

- src/cli/main.ts: take PR #9's --escrow-address and --deposit-timeout-sec
  options; preserve master's CLI-layer expiry guards (≥1000ms, ≤7d).
- test/e2e-live/basic-roundtrip + multi-agent + scenario-helpers: take
  PR #9's versions verbatim (they have both the volume_max rename AND
  the real settlement assertions).

PR #9 brings the substantial real-settlement work that was sitting
unmerged: TRADER_TEST_FUND self-mint via sphere-sdk's mintFungibleToken
(now in main as of sphere-sdk #115), real escrow provisioning, real
faucet funding, stale-deal-aware assertions, and 38 trader bug fixes
caught BY running real settlement on testnet.
Recovery merge of PR #13 used --theirs for src/trader/main.ts which
silently reverted PR #12's setAutoReturn block. Without
setAutoReturn('*', true), the trader does NOT refund surplus on
terminated invoices — exactly the leak surfaced by
negotiation-failures.e2e-live's 'deposit timeout' test (UCT
delta=-1000, USDU delta=+500 instead of restored to baseline).

Re-applies the block right after sphere_initialized log. Behavior
matches PR #12 verbatim:
  - sphere.accounting.setAutoReturn('*', true) gated on
    sphere.accounting !== null
  - RATE_LIMITED treated as success (in-process retry within 5s
    cooldown — flag already set)
  - All other errors fail fast (storage layer broken → downstream
    invoice ops would fail unpredictably anyway)

Verified: typecheck clean. Re-running negotiation-failures live
test will validate the refund path.
Closes the gap noted in the PR-17 recovery review: trader-service's
ACP WITHDRAW_TOKEN handler exists, but no operator-facing CLI exposed
it. Operators couldn't pull funds back out of their trader.

Adds:
- 'sphere trader withdraw --asset <symbol> --amount <bigint> --to-address <addr>'
  invokes WITHDRAW_TOKEN over ACP. Three required flags mirror the
  trader-side validation (asset non-empty, amount > 0 positive
  integer, to_address valid).
- buildWithdrawParams pure function (exported for unit testing).
- 8 unit tests pinning the wire-shape contract: snake_case wire fields,
  bigint-string amount preservation (no Number coercion / precision
  loss), strict positive-integer amount validation (rejects 0,
  negative, decimal, '1e6', leading-zero, non-numeric), all three
  address forms accepted (@NameTag, DIRECT://hex, raw hex).
- Subcommand-tree test now asserts 7 controller-scoped subcommands
  (was 6 — withdraw is the 7th).

Verified: 114/114 tests pass (was 106, +8 new wire-shape tests).
typecheck + lint clean.

The same withdraw subcommand is added to trader-service's bundled
trader-ctl in a parallel PR, so direct-docker e2e tests can also
exercise the full deposit→trade→withdraw flow.
vrogojin added 7 commits May 4, 2026 14:32
…I inputs

Steelman round 1 on PR #17 + sphere-cli #9 found a CRITICAL bug: the
trader's WITHDRAW_TOKEN address regex was 'a-zA-Z0-9{10,128}', which
REJECTS @NameTag (has @) and DIRECT://hex (has : and /) — the two
canonical Sphere address forms used everywhere else in the codebase
and advertised in both CLIs' --to-address help. Operators using
'sphere trader withdraw --to-address @bob' or '...DIRECT://...' would
receive INVALID_PARAM from the trader despite the CLI accepting the
form locally.

Fix:
- src/trader/trader-command-handler.ts: replace the alphanumeric-only
  regex with three explicit patterns matching the SDK's canonical
  forms:
    NAMETAG_RE = /^@[A-Za-z0-9_-]{2,63}$/
    DIRECT_ADDR_RE = /^DIRECT:\/\/[0-9a-fA-F]{60,130}$/
    HEX_PUBKEY_RE = /^[0-9a-fA-F]{60,130}$/  (covers x-only/compressed/uncompressed)
  isValidAddress accepts a string matching any of the three.
- INVALID_PARAM error message updated to describe all three forms.

Plus the WARNING-level whitespace bypass:
- src/cli/main.ts: --asset and --to-address now trim() before
  forwarding so a stray leading/trailing space doesn't reach the
  wire and produce a confusing remote-side INVALID_PARAM. Empty
  strings still rejected at CLI layer with a clear message.

Verified: 671/671 tests pass, typecheck clean.

Companion CLI fix in sphere-cli (feat/sphere-trader-withdraw branch)
applies the same trim() to buildWithdrawParams.
Round 2 steelman found my round-1 regex fix had two CRITICAL bugs:
the new NAMETAG_RE accepted uppercase chars (SDK is lowercase-only)
and lengths 2-63 (SDK is 1-30); the new DIRECT_ADDR_RE / HEX_PUBKEY_RE
hex range was 60-130 (SDK signing boundary requires 64-80). An
operator submitting '@alice' would pass the trader's gate but fail
at SDK send time with an opaque error.

Replace all three rolled-our-own regexes with a one-line delegation
to sphere-sdk's isValidAddress, which uses the authoritative
NAMETAG_RE / DIRECT_HEX_RE / PROXY_HEX_RE. The trader's validation
is now bit-identical to what payments.send will accept.

Bonus: PROXY:// addresses are now also accepted (sphere-sdk supports
them; my round-1 regex didn't).

Updated INVALID_PARAM message to reflect SDK's actual rules.
Verified: 175/175 trader-package tests pass, 671/671 unit tests
pass, typecheck clean.
Round 3 steelman found: sphere-sdk's isValidAddress is intentionally
permissive. Per the SDK source comment in core/address.ts:

  'DIRECT:// accepts any non-empty value after the prefix.
   Strict hex validation is NOT enforced here — the SDK resolves
   addresses before on-chain use, and test fixtures use non-hex
   placeholder addresses.'

So @alice (uppercase, no NAMETAG_RE check), @x.y.z (dots),
DIRECT://garbage all passed sdkIsValidAddress and would surface a
confusing INVALID_RECIPIENT from payments.send instead of a clean
INVALID_PARAM at the trader gate.

Layer two stricter checks on top of parseAddress:
  - For NAMETAG: delegate to isValidNametag (enforces SDK's
    canonical NAMETAG_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/).
  - For DIRECT/PROXY: enforce STRICT_HEX_RE = /^[0-9a-fA-F]{64,80}$/
    (matches signing-boundary contract in modules/swap/manifest.ts).
  - Future SDK address types: reject conservatively until explicit
    handling.

Plus drop misleading 'or 64-char hex pubkey' from CLI --to-address
help: SDK's parseAddress does NOT accept bare hex without a
DIRECT://, PROXY://, or @ prefix. That advertised form was always
wrong.

Verified: 175/175 trader-package tests pass, typecheck clean.
Round 4 steelman caught two real bugs in round 3's fix:

CRITICAL — wrong isValidNametag overload called.
The SDK exports TWO functions named isValidNametag:
  - core/address.ts: takes full '@NameTag' string, applies NAMETAG_RE
    to the .value after parseAddress strips the @
  - core/Sphere.ts: takes BARE nametag (no @), applies
    /^[a-z0-9_-]{3,20}$/ to the input directly
The package re-exports the Sphere.ts version (verified at runtime).
Round 3 called isValidNametag(addr) with addr='@alice' — the @ char
fails the regex's first character class, returning false.

Empirical confirmation:
  > isValidNametag('@alice')  → false  (BUG: rejects every nametag)
  > isValidNametag('alice')   → true   (correct: bare name)

Fix: pass parsed.value (bare name, no @) instead of addr.

WARNING — uppercase hex normalization gap.
DIRECT://AABB... and DIRECT://aabb... validate as different inputs;
SDK normalizes hex to lowercase via normalizeAddress, so the trader's
gate diverged from the canonical form. Apply .toLowerCase() before
the STRICT_HEX_RE check. The wire payload still carries the
operator's original string — the regex check is purely a gate.

Verified empirically:
  @alice                                  → true   ✓
  @alice                                  → false  ✓
  @x.y.z                                  → false  ✓
  DIRECT://<64 lowercase hex>             → true   ✓
  DIRECT://<64 uppercase hex>             → true   ✓ (normalized)
  DIRECT://garbage                        → false  ✓

171/175 trader tests pass, typecheck clean.
…isValidAddress

Round 5 steelman flagged that STRICT_HEX_RE accepted both upper- and lowercase
hex, making the .toLowerCase() normalization a no-op and contradicting the
stated intent of strict canonical-form validation.

- Tighten STRICT_HEX_RE to /^[0-9a-f]{64,80}$/ so .toLowerCase() is load-bearing
- Update inline comments to match what's actually accepted (phone-number
  nametags via SDK's isPhoneNumber escape hatch; remove wrong "first-char
  anchor" claim — exported isValidNametag has no such anchor)
- Export isValidAddress for unit testing
- Add trader-command-handler.test.ts (19 tests) locking the contract:
  @NameTag (case, length, chars, phone numbers); DIRECT://hex (length, case,
  non-hex); PROXY://hex; bare hex / unknown prefixes / non-string;
  whitespace handling
…y tests

Round 6 steelman flagged two warnings:

1. Misleading error message: "1-30 chars" but the SDK's exported
   isValidNametag uses /^[a-z0-9_-]{3,20}$/. Operators reading the
   error and resubmitting a 21-30 char nametag would be rejected
   again with no understanding why. Updated to "3-20 chars or E.164
   phone".

2. Whitespace divergence: parseAddress trims internally so the gate
   accepts '  @alice  ', but the SDK transport layer's resolve()
   does not trim and would fail INVALID_RECIPIENT downstream. The
   handler now trims to_address once before validation and
   forwarding, keeping the gate semantics aligned with payments.send
   and ensuring the same canonical value is logged.

Also added boundary tests (3-char min, 20-char max) and reworded the
"bare hex pubkey" test comment to reflect that this is an
intentional false negative (payments.send accepts bare hex; the
gate requires an explicit Sphere prefix for unambiguous audit logs).
…ection tests

Round 7 steelman flagged that the handler's `.trim()` on `to_address`
(the actual Round-6 fix) was not regression-locked. The validator unit
tests pass even with the trim removed because parseAddress trims
internally — but the SDK transport layer's resolve() does NOT trim, so
an untrimmed address fails INVALID_RECIPIENT downstream. Removing the
handler trim would silently regress without any test catching it.

- Extend the existing E2E setup to capture withdraw() invocations and
  add 5 WITHDRAW_TOKEN tests that lock in the handler-level contract:
  trims @NameTag, trims DIRECT://hex, passes canonical addresses
  through, rejects non-string with INVALID_PARAM (no withdraw call),
  rejects whitespace-only.

- Add prefix-case rejection tests to the validator unit tests
  (Round 7 note 3): lowercase 'direct://...', mixed-case 'Direct://...',
  and 'proxy://' / 'Proxy://' variants are intentionally rejected to
  match payments.send's case-sensitive recipient parser.
@vrogojin
vrogojin merged commit 204163e into master May 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant