Skip to content

feat: HMA-orchestrated trade-settlement live test + invoice-based withdraw - #19

Open
vrogojin wants to merge 31 commits into
masterfrom
feat/hma-trade-settlement-live
Open

vrogojin wants to merge 31 commits into
masterfrom
feat/hma-trade-settlement-live

Conversation

@vrogojin

@vrogojin vrogojin commented May 9, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds test/e2e-live/hma-trade-settlement.e2e-live.test.ts — a full HMA-orchestrated e2e covering spawn → trade → settle → withdraw, with it.concurrent 2-pair concurrency.
  • Switches settlement funding to selfMint via the TRADER_TEST_FUND env passthrough (replaces the flaky FAUCET_REQUEST path).
  • Routes the trader's WITHDRAW_TOKEN handler through accounting.createInvoice + accounting.payInvoice — the same code path swap deposits use — via a new AccountingAdapter facade in src/trader/types.ts. main.ts wires sphere.accounting through to the agent. The settlement timeout was tightened from 8 min to 3 min for a faster fail signal.

Test plan

TRADER_E2E_LOCAL_RELAY=1 npm run test:e2e-live -- test/e2e-live/hma-trade-settlement.e2e-live.test.ts

Current outcome:

  • Pair-1 reliably passes the full spawn → trade → settle → withdraw flow.
  • Pair-2 hits a separate concurrent-settlement race that's still being investigated (tracked in the diagnostic doc on this branch).

Dependencies

  • Requires the SDK fixes that unblocked the invoice-based settlement path (linkable separately).
  • Requires the sphere-cli feat/trader-withdraw-cli PR — the e2e helpers shell out to sphere trader withdraw, which was missing as a registered subcommand until that PR.

The HMA-SETTLEMENT-DIAGNOSTIC doc on this branch is being updated in parallel by another agent; any further doc changes will land in a follow-up commit.

vrogojin and others added 29 commits May 4, 2026 18:20
Closes the architectural loop: this is the canonical proof that
operators can launch HMA, spawn agents through it over Sphere DM,
trade between independent traders, settle on testnet, and withdraw —
the user-stated goal that no prior live test fully covered.

Prior tests cover slices:
- basic-roundtrip: settles correctly, but uses direct docker run
- hma-orchestrated: spawns through HMA, but doesn't trade
- hma-trade-flow: spawns + posts/cancels intents through HMA, but
  explicitly stops short of settlement
- this file: spawns + funds + trades + settles + withdraws — all
  through HMA over Sphere DMs

Two scenarios run concurrently via vitest it.concurrent on a single
shared HMA (matches production: one HMA per host, multi-tenant).
Within each scenario, every fan-outable step uses Promise.all with
*Async helpers so concurrency actually overlaps:

  - hostSpawnAsync × 3 (escrow + 2 traders) — parallel
  - setStrategyAsync × 2 — parallel
  - portfolioAsync × 2 (snapshots) — parallel
  - createIntentAsync × 2 (matching) — parallel
  - waitForDealInStateAsync × 2 (COMPLETED) — parallel
  - withdrawAsync × 1 (one trader withdraws to controller)

The sync helpers (runSphere → spawnSync) block the event loop so
Promise.all over them serializes — added async variants only for the
helpers needed here, kept the sync surface small.

Self-funding via TRADER_TEST_FUND env passthrough through HMA
(validatePayloadEnv at agentic-hosting/src/host-manager/manager.ts:97
allows TRADER_TEST_FUND — it's not in FORBIDDEN_ENV_KEYS and doesn't
start with UNICITY_). Avoids the recurring testnet faucet flakiness
that basic-roundtrip's commit history documents.

Performance target on healthy testnet: 5-8 minutes total (HMA boot
~10s; 6 concurrent spawns ~30-60s; settlement wait ~3-5 min dominates;
2 concurrent withdraws ~5-10s).

Files:
- test/e2e-live/helpers/sphere-trader.ts: add withdraw + 5 *Async
  helpers (setStrategyAsync, createIntentAsync, portfolioAsync,
  listDealsAsync, waitForDealInStateAsync, withdrawAsync)
- test/e2e-live/helpers/hma-spawn.ts: add hostSpawnAsync
- test/e2e-live/hma-trade-settlement.e2e-live.test.ts: NEW

Verified: 698 unit/integration tests still pass. Type check + lint clean.
Round 1 of running hma-trade-settlement.e2e-live failed within seconds
with two distinct wallet.json races:

  Pair-1: "No wallet found in /tmp/.../.sphere-cli"
  Pair-2: "ENOENT: rename '.sphere-cli/wallet.json.tmp' -> 'wallet.json'"

Both scenarios were sharing one cliHome and issuing concurrent
sphere-cli invocations. Each invocation persists incoming-DM state
to .sphere-cli/wallet.json via the SDK's atomic temp+rename
(FileStorageProvider.save → fs.renameSync). When two parallel calls
write at once, one's tmp gets renamed away before the other's
rename observes it, corrupting both.

Fix: bootstrap one controller wallet per scenario in its own
cliHome dir. HMA's AUTHORIZED_CONTROLLERS env var accepts a
comma-separated list of pubkeys (config.ts:52), so a single HMA
authorizes all scenarios' controllers in one go — matching the
real multi-tenant production topology.

Each scenario now uses its own cliHome (no wallet.json contention)
and its own DIRECT:// address as the withdraw destination. Aborted
the earlier "shared cliHome with retry" approach because retries
would only mask the race; the SDK's wallet store isn't designed for
concurrent writers.

SCENARIO_COUNT constant exposed so future steelman rounds can bump
it to 4-8 to stress-test the relay/aggregator/HMA further.
…o parallel

Round 2: per-scenario controller wallets fixed cross-scenario
contention but the SECOND failure mode (within-scenario races
between escrow + alice + bob hostSpawnAsync calls in Promise.all)
remained. Each call writes DM dedup state to .sphere-cli/wallet.json
via the SDK's atomic temp+rename — three concurrent writers in the
same dir corrupt each other.

Tried parallel spawn within a scenario; got two distinct races:
  Pair-1: "No wallet exists and no mnemonic provided" (Sphere.init
          reading wallet.json mid-write — the file was renamed away
          underneath it by another spawn's atomic update)
  Pair-2: "ENOENT: rename wallet.json.tmp -> wallet.json" (one spawn
          renamed away the temp file the other was about to rename)

Fix: serialize sphere-cli calls within a scenario (drop Promise.all
over spawn/set-strategy/portfolio/create-intent/wait/portfolio-after).
Cross-scenario parallelism via vitest it.concurrent is preserved —
that's where the wall-time savings actually come from, because the
3-5 min settlement-wait dominates each scenario and runs in true
parallel across scenarios.

Wall-time impact: serializing the 3 spawns within a scenario adds
2 × ~5-15s of DM round-trip vs parallel. Negligible against the
settlement dominator.

A future PR can revisit within-scenario parallelism by giving each
parallel sphere-cli invocation its own cliHome with a copied
wallet.json (same identity, separate file storage). Out of scope
for the goal-completion test.

The file header documents the parallelism contract honestly so
future contributors don't try to "optimize" it back to broken
parallelism.
…f-mint)

Round 3 surfaced "TRADER_TEST_FUND requires sphere-sdk with
mintFungibleToken (only on refactor/extract-cli-to-sphere-cli
branch). Use the faucet path instead, or rebuild with the SDK
feature branch."

The published trader image (ghcr.io/vrogojin/agentic-hosting/trader:v0.1)
that HMA's templates.json points to was built against an older
sphere-sdk that doesn't have mintFungibleToken. The TRADER_TEST_FUND
env var is recognized but errors at trader startup before acp.hello
is sent — HMA observes "did not send hello within 60000ms" and
reports hm.spawn_failed.

basic-roundtrip works around this by building its trader image
locally (with the latest SDK) and running docker directly. HMA
tests use the published image, so funding has to happen via the
external faucet.

Switching to faucet-fund-and-wait:
- Drop TRADER_TEST_FUND / TRADER_FAULT_INJECTION_ALLOWED env vars
- After alice/bob spawn, hit FAUCET_URL with each tenant's nametag
- Poll portfolio every 5s until balance arrives (typically ~30-60s
  on testnet — payments.receive() runs on a 15s cycle inside the
  trader plus aggregator confirmation)
- Use trade-volume × 2 as funding amount (headroom for the post-
  trade withdraw step)

Trader balances are asymmetric now (alice gets USDU only, bob gets
UCT only), matching their trade roles. Post-trade delta assertions
unchanged: alice +UCT / -USDU, bob mirror.

Note: a future PR rebuilding the trader image at ghcr.io with the
latest sphere-sdk would let us swap back to selfMint and avoid the
faucet flake risk this re-introduces.
Round 4 surfaced "Coin not found: USDU" from the testnet faucet.
Probing the faucet directly:

  POST /api/v1/faucet/request {coin: "UCT"}     → Coin not found: UCT
  POST /api/v1/faucet/request {coin: "unicity"} → Nametag not found

So the faucet looks up coins by the `name` field of
/api/v1/faucet/coins, not by `symbol`:

  coins[].symbol "UCT"  → coins[].name "unicity"     → faucet OK
  coins[].symbol "USDU" → coins[].name "unicity-usd" → faucet OK

Adding a small FAUCET_COIN_NAME map and converting before the
fundWallet call. The map is colocated in the test (5 lines, two
entries) rather than pushed into helpers/funding.ts because the
inverse mapping isn't well-known here — the symbol set the test
uses (UCT, USDU) is the same set the test asserts in portfolio
deltas, so having the mapping near both keeps the surface obvious
to a future contributor adding a third asset.
…receive)

Round 5 surfaced the deeper issue: the published trader image at
ghcr.io/.../trader:v0.1 not only lacks mintFungibleToken (Round 3)
but also fails to surface faucet-deposited balance in portfolio.
Trader logs from a Round-5 container show ZERO payment-receive
events from spawn through 2 minutes of running — no
"payment_received" / "deposit_received" / inventory updates. The
faucet returns 200 OK with a tx_id, but the trader's portfolio
stays empty.

Test now overrides the trader-agent template's `image` field to
point to `ghcr.io/.../trader:local` (built via the Dockerfile in
this repo with current sphere-sdk + trader code). The shared
agentic-hosting/config/templates.json is not modified — the
override is test-local via spawnHostManager's templatesPath param.

Build the local image before running this test:
  cd /home/vrogojin && \
    docker build -f trader-service/Dockerfile \
      -t ghcr.io/vrogojin/agentic-hosting/trader:local .

Operators will need to rebuild + re-publish the trader image at
ghcr.io to use HMA in production trading. Tracked in file header.
Round 6 surfaced that the testnet faucet is silently broken: it
returns 200 OK + tx_id but the deposit never surfaces in the
trader's portfolio (verified by polling for 120s and checking
trader-side container logs — zero payment-receive events). Same
flakiness basic-roundtrip's commit history documents.

The locally-rebuilt trader image (trader:local) embeds the current
sphere-sdk which DOES include mintFungibleToken (verified at
sphere-sdk/dist/index.js:12237). So TRADER_TEST_FUND self-mint at
trader startup works on the local image — the constraint that
forced us to faucet (round 3) was specific to v0.1's older SDK.

This commit:
- Removes faucet helper (faucetFundAndWait + FAUCET_COIN_NAME
  mapping + fundWallet import)
- Re-introduces TRADER_TEST_FUND env (5000 each of UCT + USDU per
  trader) passed through HMA's validatePayloadEnv passthrough to
  the trader container at spawn
- Adds a 5s post-spawn delay so the trader's first portfolio query
  reflects the post-mint balance
- Documents the trader image build requirement in the file header
Round 7 progressed past spawn + set-strategy and trader logs
confirmed both UCT and USDU mints succeeded ("test_fund_mint_succeeded"
×2 per trader). But the balance sanity check failed with
"expected 0 to be greater than 0" because balanceOf's first loop
returned `BigInt(String(b.amount ?? '0'))` → 0n WHEN `b.amount` is
undefined — the short-circuit on `?? '0'` prevented the fallback
loop from ever reading `b.confirmed`.

The trader's GET_PORTFOLIO emits each balance as
  { asset, available, total, confirmed, unconfirmed }
with no `amount` field. So balanceOf was returning 0n for every
balance despite the mint succeeding.

Fix: single-pass loop, prefer `confirmed`, fall back to `amount`
then `available`. Returns 0n only if NONE are present.
Round 8 progressed all the way to settlement-wait but timed out
with both scenarios stuck — pair-1 saw 29 deals (1 FAILED + 27
CANCELLED + 1 PROPOSED), pair-2 saw 3 (FAILED, CANCELLED, ACCEPTED).
The thrash loop happened because both scenarios posted UCT/USDU
intents at rate=1, so the matcher saw cross-scenario pairs as
valid matches. When pair-1's alice tried to negotiate with pair-2's
bob, the trusted_escrows mismatch forced the deal to FAILED, but
the matching engine just kept retrying.

Fix: pass per-scenario `tradeRate` into runSettlementScenario.
Pair-1 uses rate=1, pair-2 uses rate=3 (non-adjacent so no
rate-fuzzing ever overlaps). The matcher's rate-overlap check
prevents cross-scenario matches at the search-result level, before
negotiation.
…UND)

The trade-settlement test was rounds 8-9 stuck in a FAILED/CANCELLED
loop despite spawn + balance assertions passing. Hypothesis: tokens
self-issued by the trader (TRADER_TEST_FUND mint) confuse the swap
protocol because issuer==sender is unusual in production. With
FAUCET-funded tokens the issuer is a separate agent — closer to
real-world settlement.

Also replaces the broken public-faucet HTTP path (returns 200 OK +
tx_id but deposit never surfaces, verified across rounds 5-6 — and
documented again in basic-roundtrip's commit history).

Now uses the js-faucet agent that spawns alongside escrow + traders:

  beforeAll:
    - bootstrap controller wallets (per-scenario, sphere-cli)
    - inject `faucet-agent` template (image: faucet:local)
    - inject `trader-agent` template (image: trader:local)
    - boot HMA with all controllers authorized
    - spawn ONE shared faucet (open auth — no need per scenario)
    - bootstrap in-process FaucetClient (Sphere wallet that signs
      and encrypts FAUCET_REQUEST DMs to the faucet's pubkey)

  per scenario:
    - spawn escrow + alice + bob (no TRADER_TEST_FUND env)
    - FAUCET_REQUEST batch (UCT + USDU, 5000 each) → alice
    - FAUCET_REQUEST batch (UCT + USDU, 5000 each) → bob
    - poll each trader's portfolio until confirmed >= 5000 of each
    - set-strategy / post intents / wait COMPLETED / withdraw

New helper: test/e2e-live/helpers/faucet-client.ts (~190 lines).
  - bootstrap an in-process Sphere wallet
  - subscribe to inbox; capture acp.result/acp.error by command_id
  - sendDM(faucetPubkey, FAUCET_REQUEST envelope), wait for ack
  - return deliveries (asset, coin_id, amount, token_id, transfer_id)

Pre-flight (operator must do before running):
  cd /home/vrogojin && docker build \
    -f js-faucet/Dockerfile \
    -t ghcr.io/unicitynetwork/agentic-hosting/faucet:local .

Verified: 698 unit tests still pass; type-check clean for both
src/ and test/ contexts.

Whether this also fixes the FAILED/CANCELLED settlement loop is the
question the next live round answers.
Round 10 with faucet funding killed the spam-loop (29 deals → 2)
but settlement still failed: traders sent `status` query to escrow,
got "Swap not found" error, deal CANCELLED. Escrow log showed ZERO
swap.announce_received events despite ping/pong working — i.e.
the swap.announce DM never reached the escrow.

Root cause: trader-service/src/trader/intent-engine.ts:836:
  const escrowAddress = params.escrow_address ?? DEFAULT_ESCROW;
  // DEFAULT_ESCROW = 'any'  (intent-engine.ts:92)

When the test omits escrow_address from create-intent, the
intent (and resulting deal terms) carries the literal string
'any'. The swap-executor then tries to route swap.announce to
'any', which the SDK's transport layer can't resolve to a real
peer. The escrow never gets the announce; subsequent status
queries from the trader hit a swap that doesn't exist on the
escrow's side.

Fix lands in two repos:

(1) sphere-cli — add `--escrow-address` flag to
    `sphere trader create-intent`. The flag was missing entirely
    so callers had no way to override 'any'. Updated:
    - src/trader/trader-commands.ts: CreateIntentOpts +
      buildCreateIntentParams + commander definition
    - rebuilt dist/

(2) trader-service test:
    - helpers/sphere-trader.ts: CreateIntentOpts + forward to
      `--escrow-address` argv
    - hma-trade-settlement test: pass `escrowAddress:
      escrow.tenantPubkey` for both alice and bob's intents

Verified: 698 unit tests still pass; type-check clean. Whether
this finally fixes the FAILED/CANCELLED settlement loop is the
next live round's question.
Round 11 progressed: pair-2 reached ACCEPTED for the first time
(3 deals: FAILED, CANCELLED, ACCEPTED) — the escrow_address fix
landed correctly. But the deal still failed at swap registration
with a clearer error in the trader log:

  "swap_id_register_escrow_mismatch":
    negotiated_escrow:       02219b272c88584a4129d770edf5c08bcb5054d3c08496157c021da77cab8625a1
    proposal_escrow:         DIRECT://00002b89215d8b32323f083ceb154329115d3137c0c5325410d2cc0b0d5a111eb0ad3148b2ce
    proposal_escrow_pubkey:  219b272c88584a4129d770edf5c08bcb5054d3c08496157c021da77cab8625a1

The trader's swap-executor (line 714) checks:
  negotiatedEscrow === match.escrowDirectAddress

`negotiatedEscrow` = `terms.escrow_address` (what we passed to
create-intent — chain pubkey form, 02-prefixed 66 chars)
`match.escrowDirectAddress` = the DIRECT://hex address (a structural
hash via UnmaskedPredicateReference, NOT the pubkey).

These are fundamentally different forms. The check expects them
EQUAL, so the intent's escrow_address must be set to the
DIRECT://-form to match what the SDK derives during proposal.

Also updates `setStrategy(trustedEscrows: [...])` to use the same
DIRECT:// form — the negotiation-handler's trusted check at
negotiation-handler.ts:1084 does direct string equality
(`trustedEscrows.includes(terms.escrow_address)`), so both sides
must match. This was implicitly working before only because both
sides were 'any'.

Same fix could land in basic-roundtrip's createMatchingIntents
helper (probably what makes the direct-docker version work — they
must already pass directAddress somewhere) but that's out of scope
for this commit.
Round-by-round summary of the hma-trade-settlement debugging
sessions, the bugs we found and fixed, the remaining symptom
(trader doesn't process escrow's invoice_delivery DM despite the
escrow logs reporting successful send), and three concrete
hypotheses with bisect plans for the next session:

  H1: ACP listener consumes the DM before swap module sees it
  H2: payments.receive() loop races with swap module's DM dispatch
  H3: HMA-spawned container has higher relay-subscription latency

H1 is the most likely candidate; the most direct test is to
temporarily comment out the ACP listener's sphere.on('message:dm')
subscription and re-run. If settlement completes, that's the bug.

Includes file refs for the trader code paths involved, exact
reproduce-steps for the failing scenario, and key log lines to
grep for in trader/escrow container output.
Investigated sphere-sdk's event dispatch. Incoming DMs go through TWO
INDEPENDENT paths (dist/index.js:13910-13918):

  1. emitEvent("message:dm") — generic event bus (sphere.on subscribes)
  2. iterates dmHandlers Set — onDirectMessage(handler) registers here.
     PaymentsModule (line 18816) and SwapModule (line 24212) both
     subscribe via this set.

Both fire unconditionally for every DM. Neither preempts the other —
they're independent channels. The ACP listener (event bus) cannot
block the SwapModule (dmHandlers) from receiving a DM. So H1 (ACP
listener consuming the invoice_delivery before SwapModule sees it)
is wrong.

Updated debugging plan focuses on:
  - what gets through to SwapModule.handleIncomingDM
  - SDK-internal silent-reject paths (sig check, swap-id-not-found,
    protocol-version mismatch, dedup)
  - transport-layer hole (relay subscription / NIP-17 decryption)

Also added an architectural-follow-up note: even though the dual-path
dispatch isn't the cause of THIS bug, the lack of propagation control
(no `consume vs pass through` semantics) is a future-bug source as
more consumers attach. A koa-compose-style middleware chain
(use(handler, priority) with next() semantics) in sphere-sdk's
CommunicationsModule would let consumers declaratively filter /
consume / pass DMs. ~40 lines of inline impl, no new runtime dep.
…ric bug

A focused investigation agent traced round-12 escrow logs and found:

  diag_invoice_delivery_attempt   party=A   ✓ logged
  diag_outbound_dm_sending invoice_delivery → A   ✓ logged
  diag_outbound_dm_sent invoice_delivery → A      ✓ logged
  diag_invoice_delivery_complete  party=A   ✓ logged

  diag_invoice_delivery_attempt   party=B   ✓ logged
  [ no diag_outbound_dm_sending invoice_delivery → B ]
  diag_invoice_delivery_complete  party=B   ✓ logged anyway

The deployed `escrow:v0.1` image's `deliverDepositInvoice` function
exits "normally" for party B without actually invoking
`sphere.communications.sendDM` — only one recipient receives the
invoice. Trader stalls at ACCEPTED waiting for the invoice that
never arrives. Asymmetry repeats deterministically across multiple
swaps; basic-roundtrip works direct-docker because it only asserts
the buyer's side.

The current escrow-service source (`message-handler.ts:202-253`)
LOOKS correct — both A's and B's reply() calls are awaited with
the same code path. The deployed image was built from an unsynced
source commit (the JS at /app/dist/sphere/message-handler.js
doesn't match HEAD). Rebuilding escrow:local from current source
and overriding the image in the test should clear the regression.

Build instructions added to the test header. After this, expect
hma-trade-settlement to finally COMPLETE both scenarios.

Independent of this fix, an upstream PR against escrow-service to
harden deliverDepositInvoice (use Promise.allSettled and log
rejection reasons) so a similar build-time defect can never silently
re-occur is filed in the diagnostic doc as a follow-up item.
The testnet relay (wss://nostr-relay.testnet.unicity.network) has had
two write-path outages in 4 days: read path works (connect+subscribe
OK) but every publish-kind:* times out, so DM-based settlement tests
stall before the first DM ships. Pattern is intermittent; recovery
unpredictable. Local relay infra removes the dependency.

Mirrors the harness uxf already uses
(/home/vrogojin/uxf/tests/e2e/local-infra/), adapted for trader-service
multi-container topology:

  test/e2e-live/local-infra/docker-compose.yml
    - container_name: trader-e2e-relay (avoid uxf collision)
    - port bind: 0.0.0.0:7777:8080 — HMA-spawned tenants run in
      their own Docker containers; they cannot reach host loopback
      so we expose on all interfaces and connect via the host bridge
      gateway IP. Relay image pin: ghcr.io/unicitynetwork/unicity-tokens-relay:sha-1e1b544

  test/e2e-live/local-infra/relay.ts
    - bootLocalRelay() / stopRelay() lifecycle
    - getLocalRelayUrlForContainers() returns the URL HMA-spawned
      tenants should connect to (`ws://<bridge-gateway-ip>:7777`).
      Falls back to host.docker.internal if `docker network inspect`
      fails — Docker Desktop resolves this automatically; Linux
      Docker needs --add-host=host.docker.internal:host-gateway on
      the HMA-spawned containers (separate plumbing required).

Service-side support for UNICITY_NOSTR_RELAYS env override (mirrors
the existing js-faucet pattern at acp-adapter/main.ts:122-128):

  trader-service: src/trader/main.ts:250-265
  escrow-service: src/acp-adapter/main.ts:101-117 (committed
                  separately; see escrow-service repo)
  agentic-hosting: src/host-manager/main.ts:457-474 (committed
                   separately; see agentic-hosting repo)

Each service reads UNICITY_NOSTR_RELAYS (with SPHERE_NOSTR_RELAYS
fallback) and passes the relay list to createNodeProviders'
transport config when set; falls through to the network preset's
defaults when unset. No behavioral change when the env var is
absent.

Out of scope (next step):
  - global-setup.ts wiring to boot the relay when
    TRADER_E2E_LOCAL_RELAY=1 is set, then propagate the bridge URL
    to the HMA + spawned tenants via env passthrough
  - testing the full stack against the local relay
  - opt-in via the hma-trade-settlement test
…n env

Closes the local-infra harness: when TRADER_E2E_LOCAL_RELAY=1 is set,
the test now boots a Docker-hosted Nostr relay AND propagates the
relay URL to every component that does Sphere DMs:

  global-setup.ts:
    - boots local-infra/relay (uxf-style, container trader-e2e-relay)
    - sets process.env['UNICITY_NOSTR_RELAYS'] to the bridge-gateway URL
      so HMA-spawned tenants can reach it
    - skips the testnet preflight (local relay supersedes the gate)
    - teardown stops the container; wipe-on-boot by default
      (TRADER_E2E_LOCAL_RELAY_KEEP=1 to preserve event log for
      post-mortem)

  helpers/manager-process.ts:
    - forward UNICITY_NOSTR_RELAYS / SPHERE_NOSTR_RELAYS into the
      HMA's spawn env (the helper otherwise builds an isolated env
      and would have dropped the override)

  hma-trade-settlement.e2e-live.test.ts:
    - read process.env['UNICITY_NOSTR_RELAYS'] at beforeAll, stash
      onto SuiteState.spawnEnv
    - inject spawnEnv into every hostSpawnAsync (escrow, alice, bob,
      faucet) so the spawned containers all hit the local relay

Same env-override pattern works for both modes: with TRADER_E2E_LOCAL_RELAY
unset, spawnEnv is empty {} and tenants fall through to the network
preset's default relay (testnet), preserving the existing test
behavior.

Run against local relay:
  TRADER_E2E_LOCAL_RELAY=1 npm run test:e2e-live -- \
    test/e2e-live/hma-trade-settlement.e2e-live.test.ts

When the testnet relay is healthy (no current outage), the test
still runs against testnet by omitting the env var.

Linux Docker note: getLocalRelayUrlForContainers() runs
`docker network inspect bridge` to discover the gateway IP
(typically 172.17.0.1) — works from both host and containers.
Falls back to host.docker.internal which Docker Desktop resolves
automatically; recent Linux Docker also supports it via
--add-host=host.docker.internal:host-gateway (HMA's docker-adapter
would need to forward that flag if the gateway-IP detection ever
fails, which it shouldn't on a standard install).
Round 16 (TRADER_E2E_LOCAL_RELAY=1) booted the local relay and the
manager registered correctly, but `sphere host spawn` failed with
'Unicity ID not found: @m-e2elive...'. Cause: helpers/sphere-cli.ts
buildEnv() builds a sanitized env (PATH/HOME/UNICITY_API_KEY/CI/
FORCE_COLOR only) and dropped the relay override, so sphere-cli
queried testnet for the manager's nametag while the manager was
registered only on the local relay.

Also patched sphere-cli upstream (host/sphere-init.ts and
legacy/legacy-cli.ts) to read UNICITY_NOSTR_RELAYS / SPHERE_NOSTR_RELAYS
in the same pattern as trader-service / escrow-service / agentic-hosting
/ js-faucet. Both inits now respect the env when set.
Round 18 progressed massively (534× kind:1059 + 10× kind:30078 on the
local relay) but stalled on FAUCET_REQUEST timeout because the
in-process FaucetClient was built with testnet defaults and never saw
the local-relay override.

Three patches collected here:

  1. helpers/faucet-client.ts — read UNICITY_NOSTR_RELAYS /
     SPHERE_NOSTR_RELAYS and pass transport.relays to
     createNodeProviders. The Sphere wallet that signs+sends
     FAUCET_REQUEST DMs from the test process now hits the local relay.

  2. helpers/manager-process.ts (provisionManagerWallet) — same
     pattern. The pre-creation step that generates the manager's
     wallet + publishes the nametag now respects the override.
     Without this, the nametag binding event was published to
     testnet (when the test process had no override pickup), and
     the HMA binary loaded the existing wallet on launch
     (wallet_created: false) → never re-published to the local
     relay → sphere-cli's queryPubkeyByNametag returned 'not found'.

  3. helpers/manager-process.ts (UNICITY_HEALTH_PORT) — default
     to 0 (OS-assigned ephemeral port) instead of fixed 19401.
     Tests don't probe this port, and a fixed default EADDRINUSEs
     when a prior run leaks an HMA process. Mirror of the same
     change already on js-faucet's manager-process.ts.

Test runs HMA→escrow→faucet→2 traders in parallel scenarios. With
all local images rebuilt (escrow:local with current source,
trader:local with current sphere-sdk + relay override, faucet:local
with relay override) settlement traffic flowed end-to-end on the
Docker-hosted local relay. The TRADER_E2E_LOCAL_RELAY=1 mode now
actually works.
End-to-end run of hma-trade-settlement against the Docker-hosted
local Nostr relay. Spawn (HMA + escrow + faucet + 2 traders) ✓
funding via FAUCET_REQUEST ✓ matched intents ✓ ACCEPTED deals ✓
escrow's invoice delivery to BOTH parties ✓ trader's invoice
import ✓ trader's deposit sent ✓.

Final stop: swap-protocol settlement bug —
'[Accounting] Direction mismatch: transport memo says
return_cancelled, on-chain says forward' → swap_cancelled.
That's an independent layer; deterministically reproducible
against the local relay, no longer blocked by testnet outages.

The asymmetric invoice-delivery bug (rounds 11-12) is GONE in
escrow:local from current source — every swap shows full
deliver_deposit_invoice_{enter,sending,sent} for both parties.
…Payout wall

Round 20: replaces FAUCET_REQUEST funding in hma-trade-settlement.e2e-live
with TRADER_TEST_FUND injected via HMA's --env passthrough. The faucet
path delivered tokens whose source-state predicate the trader's
swap-deposit signing key couldn't match, causing every deposit attempt
to fail with "Ownership verification failed: Authenticator does not
match source state predicate" (round 19). selfMint mints with the
trader's own predicate, mirroring basic-roundtrip's known-working
mechanism.

Result with selfMint: the swap protocol completes end-to-end on the
escrow side — announces, invoices delivered, both deposits verified,
"invoice:covered", payouts paid, "Swap completed successfully". The
trader receives the payout transfer (10 UCT) and the SDK marks swap
progress as `completed`. But verifyPayout enters its fail-closed
branch (sphere-sdk/modules/swap/SwapModule.ts:1997-2003) because
getTokenIdsForInvoice(payoutInvoiceId) returns an empty Set: the
synthetic-ledger reverse-index population at AccountingModule.ts:5755
isn't firing for swap payouts. Test times out at 8 min without
reaching COMPLETED.

This shifts the remaining gap out of HMA / relay / faucet land and
into the SDK's instant-mode tokenInvoiceMap population. Round 20 of
the diagnostic doc covers reproducer, log excerpts, and the three
hypotheses to bisect next.
Settlement on local-relay completes in ~30-60s end-to-end (selfMint →
match → deposits verify → payouts paid → verifyPayout). The previous
8-min budget existed because of the testnet-only era and gave painful
slow-fail on the reverse-index bug now fixed by sphere-sdk
fix/swap-deps-reverse-index. 3min gives 2× headroom over typical
runs while failing fast on regressions.
Replaces the direct payments.send code path inside WITHDRAW_TOKEN with
a createInvoice → payInvoice flow — the same path swap deposits use,
so it inherits the SDK's well-tested predicate-handling and avoids the
"Authenticator does not match source state predicate" flake on spends
of received swap-payout tokens.

- New AccountingAdapter interface in src/trader/types.ts (narrow facade
  over sphere.accounting, just createInvoice + payInvoice).
- main.ts wires sphere.accounting through to the agent when present.
- Legacy direct-send path retained for unit tests with stub adapters.
…REQUEST

Switches the funding path back to FAUCET_REQUEST DMs against the
shared js-faucet agent (the production-realistic path) now that
js-faucet's conservative-transferMode fix and escrow's matching fix
let recipients spend the delivered tokens without hitting the
"Authenticator does not match source state predicate" race.

Recipient address resolution: send to the trader's @NameTag (canonical
identity per project guidelines). The previous attempt at DIRECT://
<pubkey> hit "No binding event found" because the SDK's
resolveAddressInfo queries by hashed address and the trader's binding
publishes the L3-predicate-derived directAddress, not the bare
DIRECT://<pubkey> string. @NameTag goes through queryPubkeyByNametag
which is the path the trader registers via Sphere.init(nametag=…).

Verified end-to-end: both Pair-1 (rate=1) and Pair-2 (rate=3) reach
deal COMPLETED + withdraw verified in ~140-175s each.
…ival-order race

Pair-2 of HMA-trade-settlement.e2e-live was failing with deals stuck at
ACCEPTED → CANCELLED while Pair-1 (same code, different rate) reliably
passed. Investigation traced the failure to a two-DM race in the
proposer-side swap initiation:

When a counterparty accepts a match, negotiation-handler sends
np.propose_deal to us, then swap-executor.executeDeal() calls
swap.proposeSwap() (sphere-sdk SwapModule) which sends an independent
swap_proposal DM. These are two separate Nostr events with no causal
ordering. On the receiver side:

  - sphere-sdk fires swap:proposal_received synchronously from the NIP-17
    receive path → main.ts immediately calls agent.registerSwapId().
  - registerSwapId looks up activeByDealId for an entry with swapId=null.
    That entry is created by swap-executor.executeDeal()'s registerActive(),
    which is reached only AFTER negotiation-handler.handleProposeDeal
    finishes its long async pipeline (validate → intent lookup → terms
    check → transitionDeal('ACCEPTED') → reply DM → onDealAccepted →
    executeDeal).

In Pair-1, np.propose_deal arrived ~106ms before swap_proposal — the
heavier handler completed in time and registerSwapId saw the deal. In
Pair-2, swap_proposal arrived just 21ms before NP-0 finished
registering, so registerSwapId returned false → swap rejected with
NO_LIVE_NP0_DEAL → deal stuck ACCEPTED with no path forward.

Fix: bounded retry in main.ts swap:proposal_received handler.
registerSwapId is called up to 40 times with 50ms backoff (~2s total
wait) before falling through to rejectSwap. Applied symmetrically to
both the status-based path and the legacy fallback path. Safe because
registerSwapId still cross-checks counterparty pubkey, currencies,
amounts, escrow address, and timeout against negotiated DealTerms;
retrying only papers over the microsecond-scale ordering hazard. A
genuinely hostile peer still gets rejected after the bounded wait.

Plus docs/HMA-SETTLEMENT-DIAGNOSTIC.md updated with rounds 21-22 + final
SDK changes summary.

Verified: HMA-trade-settlement.e2e-live both Pair-1 and Pair-2 reach
deal COMPLETED + withdraw verified concurrently.
…voice

Withdraw was racing recipient-side proof-poll on the sender's swap-payout
tokens. The default 'instant' transferMode delivers an unconfirmed
{sourceToken, transferTx} bundle whose finalization completes
asynchronously after receipt. When the trader's spend queue picked a
not-yet-finalized token (e.g., the swap payout that just arrived) for
the withdraw transfer, it produced "Authenticator does not match source
state predicate" because the recipient's confirmed Token wasn't yet
bound to its predicate.

Switch withdraw's accounting.payInvoice call to transferMode:
'conservative'. The SDK now collects the inclusion proof on the SENDER
(controller) side before delivery, so the controller's wallet receives
a fully-finalized bundle and produces a 'confirmed' Token immediately
bound to its own predicate.

This mirrors the faucet's funding flow and the escrow's swap-payout
flow, which both already use 'conservative' on their direct
payments.send paths. The trader's invoiced withdraw was the only
forwarding flow still using the default.

Requires the corresponding sphere-sdk change that exposes transferMode
on PayInvoiceParams (test/all-fixes-r23 branch, commit 4e77b2f).
@vrogojin

Copy link
Copy Markdown
Owner Author

Round 23 — RESOLVED ✓

Latest e2e run (2026-05-10): both Pair-1 and Pair-2 PASS end-to-end.

✓ test/e2e-live/hma-trade-settlement.e2e-live.test.ts (2 tests) 169s
  ✓ Pair-1: full spawn → trade → settle → withdraw via HMA (rate=1)  153s
  ✓ Pair-2: parallel scenario settles on the same HMA at distinct rate (rate=3)  155s

Final fix

The trader's invoiced withdraw was the only forwarding flow still using transferMode: 'instant'. Switching to 'conservative' makes the SDK collect the inclusion proof on the sender's side before delivery, so the controller's wallet receives a fully-finalized {sourceToken, transferTx} bundle and produces a 'confirmed' Token immediately bound to its own predicate. Chained spends are then race-free and the "Authenticator does not match source state predicate" flake is gone.

Companion PRs (must merge alongside)

Side fixes

  • 38be0b5: bounded retry in swap:proposal_received resolves the two-DM arrival-order race that previously caused Pair-2 deals to flake ACCEPTED → CANCELLED.

Full writeup in docs/HMA-SETTLEMENT-DIAGNOSTIC.md Round 23 section.

vrogojin added 2 commits May 16, 2026 00:21
Two coordinated changes:

  1. constants.ts ESCROW_IMAGE: v0.1 → v0.2 (mirrors master bump from
     PR #20). Adds composition note in the docstring.

  2. hma-trade-settlement.e2e-live.test.ts templates override: was
     forcing `ghcr.io/vrogojin/agentic-hosting/escrow:local` with a
     comment blaming the published v0.1 asymmetric deliverDepositInvoice
     bug. Switched to ESCROW_IMAGE (= v0.2) so the HMA-spawned escrow
     runs the same code as the direct-Docker-spawned escrow in
     basic-roundtrip — and devs/CI no longer need to docker-build
     `escrow:local` before running this test.

Why the override remains (vs deleting it entirely):
agentic-hosting's config/templates.json still pins escrow:v0.1 on its
own release cadence. Until that templates.json bumps, we override here
so the HMA-spawned escrow picks up v0.2's:
  - deliverDepositInvoice fix (round-19 evidence confirms gone)
  - Conservative transferMode for swap payouts
  - sphere-sdk UXF protocol PRs (#105, #115, #119, #128,
    #146/147/149/152) + all payments/* faucet-flow regression fixes

v0.2 digest:
  sha256:311903b6f98b33a63791bf79db6522a66d118588ba56fcf6e56654ed6670ebac

Typecheck: clean against tsconfig.json + tsconfig.test.json.
…override

Bumps constants.ts ESCROW_IMAGE v0.1 → v0.2 on the feature branch
(matches master PR #20) AND replaces the hma-trade-settlement.e2e-live
templates override from `escrow:local` to `ESCROW_IMAGE`. Removes the
docker-build dependency for this test — devs/CI no longer need to
locally build escrow:local before running it.

Round-19 evidence (HMA-SETTLEMENT-DIAGNOSTIC.md) confirms the v0.1
asymmetric deliverDepositInvoice bug is GONE in current source =
what we shipped as v0.2. v0.2 digest:
sha256:311903b6f98b33a63791bf79db6522a66d118588ba56fcf6e56654ed6670ebac.

Typecheck clean against both tsconfig.json and tsconfig.test.json.
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