Skip to content

chore: merge integration/all-fixes into main (50-commit rollup)#34

Merged
vrogojin merged 51 commits into
mainfrom
integration/all-fixes
Jun 5, 2026
Merged

chore: merge integration/all-fixes into main (50-commit rollup)#34
vrogojin merged 51 commits into
mainfrom
integration/all-fixes

Conversation

@vrogojin
Copy link
Copy Markdown
Contributor

@vrogojin vrogojin commented Jun 5, 2026

Why

integration/all-fixes has been the rolling dev line for sphere-cli
since the original CLI extraction. It is 50 commits ahead of main
and accumulates every PR merged during the post-extraction stabilisation
window. main has fallen out of date — features that the soak suite
(manual-test-full-recovery.sh, manual-test-accounting-roundtrip.sh,
manual-test-roundtrip-391.sh in the sphere-sdk repo) depend on
(notably sphere invoice deliver) exist on integration/all-fixes
but not on main.

Re-aligning main to integration/all-fixes so that future PRs branch
from a main that the soaks can validate against, and so that
PR #33 (canonical UX) can rebase onto a working baseline.

What lands

50 commits spanning ~29 files (+6033 / -89). Highlights:

Features

Fixes

Test infrastructure (#156 series — PRs #13#14)

  • New integration tests covering: wallet lifecycle, profile, state,
    multiaddress, assets, daemon, faucet, group, invoice, l1, market,
    migrate, nametag, send, swap (offline + Docker-escrow e2e).
  • test/integration/local-infra/ Docker-compose harness with relay
    and escrow stubs.

CI

  • Pinned sphere-sdk SHA bumped to a reachable commit.

Risk

Rollup of 50 commits that have each landed via their own reviewed PRs
(merge commits visible in git log). No new code introduced by this
rollup PR itself — it's strictly a fast-forward / merge of already-reviewed
changes.

The 573-line touch in src/legacy/legacy-cli.ts will conflict with the
parallel PR #33 (canonical UX). Plan: merge this rollup first, then
rebase #33 onto the new main and resolve.

Test plan

Vladimir Rogojin and others added 30 commits May 15, 2026 14:42
Replaces coverage previously held by sphere-sdk's deleted placeholder
`tests/integration/cli/uxf-transfer.test.ts` (sphere-sdk issue #156).
That file pinned the in-tree `cli/index.ts` source which no longer
exists post-extraction; this is a real subprocess integration test
against the same surface in its new home.

Pins:
  1. `sphere payments help send` lists --instant / --conservative /
     positionals — the help-grep half of the old test.
  2. `sphere payments send` with no args exits non-zero with the
     documented usage line.
  3. `sphere payments send <addr> 0.001 UCT --instant` from an empty
     wallet exercises arg-parse → Sphere.init → payments.send →
     insufficient-funds error → non-zero CLI exit. The full path is
     covered without needing a funded fixture.
  4. `sphere payments send <addr> 0.001 UCT --conservative` likewise,
     proving the `transferMode` flag reaches the SDK without tripping
     the mutual-exclusion guard.
  5. `sphere payments send <addr> 0.001 UCT --instant --conservative`
     trips the mutual-exclusion guard fast (pre-init), exits non-zero.
  6. Funded transfer (gated on E2E_FUNDED_MNEMONIC) — imports a
     pre-funded testnet wallet, sends 0.001 UCT to a fresh recipient,
     polls receiver balance for receipt. Opt-in to avoid faucet/drain
     burden on every test runner.

Suite runs in ~10s end-to-end against the public testnet (1 funded
test skipped without the env var). Verified locally with the latest
sphere-cli build linked against sphere-sdk @ branch
fix/156-cli-test-coverage.
Adds cli-invoice.integration.test.ts (29 tests, 5s offline + 110s e2e):
- 14 offline help-shape pins, one per invoice subcommand (create, list,
  status, pay, close, cancel, return, receipts, notices, auto-return,
  transfers, export, import, parse-memo). Asserts documented flags and
  positionals so a help-text drift is caught immediately.
- 10 offline arg-validation pins (8 subcommands × no-id usage exit +
  parse-memo no-memo + one helper test). These exercise the arg-check
  paths that run BEFORE getSphere() in legacy-cli.ts dispatch, keeping
  the suite offline-safe.
- 5 e2e lifecycle tests against real testnet: empty-wallet list, create,
  list-after-create, status (OPEN), close (→ CLOSED). Pins the namespace
  bridge, accounting module wiring, prefix-resolution, and the OPEN →
  CLOSED state transition end-to-end.

Bug fix surfaced by the new tests: invoice-create and invoice-return
were passing the 64-char hex coinId from `resolveCoin().coinId` to
AccountingModule, which validates coinId as `/^[A-Za-z0-9]+$/` with
length ≤20 — i.e. it expects the human-readable symbol (UCT, USDU, ...),
not the hex token-type id that `payments.send` uses. Switched to
`resolveCoin().symbol` for both call sites; resolveCoin still validates
that the symbol is known.

Companion fix in sphere-sdk (commit 6f957af on fix/156-cli-test-coverage)
addresses the null-dueDate-treated-as-EXPIRED issue that the same e2e
lifecycle test discovered while asserting state === 'OPEN'.
Add cli-nametag.integration.test.ts covering the four nametag CLI
subcommands (register / info / my / sync) that lost binary-level coverage
when the in-tree sphere-sdk CLI was extracted. SDK-layer coverage exists
for the underlying registerNametag + transport binding plumbing; this
file pins the CLI plumbing — namespace bridge, arg parsing, help text —
that sits between the user and the SDK.

Three layers, same shape as cli-invoice.integration.test.ts:

1. Help-shape pins (offline, 4 tests) — `payments help <legacy>` for
   nametag / nametag-info / my-nametag / nametag-sync. Pins the usage
   line + a small set of must-match regexes.

2. Arg-validation pins (offline, 3 tests) — `sphere nametag`,
   `sphere nametag register`, `sphere nametag info` with no name. These
   exit non-zero with a "Usage: ..." hint before any wallet load
   because legacy-cli.ts checks args[1] before getSphere() in both the
   `nametag` (~2592) and `nametag-info` (~2619) cases.

3. End-to-end lifecycle (network, 6 tests) — real testnet wallet,
   real Nostr relay, real aggregator. Drives:
     a. `nametag my` on fresh wallet → "No nametag registered"
     b. `nametag info <ghost>` → "not found"
     c. `nametag register <random>` → on-chain mint + Nostr publish
     d. `nametag my` → returns the registered name
     e. `nametag info <registered>` → returns binding record
     f. `nametag sync` → re-publishes the binding
   Each run mints a fresh `it_<8hex>` nametag to avoid collisions.

13 tests total, all green. Offline subset runs in <2s; e2e in ~28s
against testnet. Gated by SKIP_INTEGRATION=1 like the sibling suites.
Add cli-l1.integration.test.ts pinning the only L1 (ALPHA blockchain)
command exposed by this CLI: `payments l1-balance`. SDK-layer coverage
for L1 balance / Fulcrum / vesting lives in sphere-sdk
`tests/unit/l1/*.test.ts`. What this file pins is the CLI plumbing:
legacy-CLI dispatch, the human-readable output format that wallet
scripts grep.

Scope note: `l1-send`, `l1-history`, and `l1-receive` are NOT exposed
as CLI verbs (only `l1-balance` is wired through legacy-cli.ts ~2168).
The full L1 API is available via `sphere.payments.l1` at the SDK
layer. Memory snapshot for #156 previously listed l1-send as a gap;
correcting that: no L1 send CLI exists to test.

Two layers:

1. Help-shape pin (offline) — `payments help l1-balance` returns the
   legacy block with the usage line, "ALPHA" symbol mention, and the
   "Fulcrum" connection hint (load-bearing for ops / network policy).

2. End-to-end pin (network) — Fresh testnet wallet → run
   `payments l1-balance` → assert the 3-line output block:
     "L1 (ALPHA) Balance:"
     "Confirmed: <number> ALPHA"
     "Unconfirmed: <number> ALPHA"
   A fresh wallet has zero balance; assertion is purely on line
   structure, not on a specific numeric value.

The "L1 module not available" error path in legacy-cli.ts is
deliberately NOT pinned — Sphere.init() creates the L1 module by
default, so that path is unreachable through this CLI's normal init.
Pinning unreachable error paths produces brittle tests.

2 tests, both green: offline <500ms, e2e ~1s against testnet.
…erage

Add cli-faucet.integration.test.ts pinning the testnet-faucet CLI
surface across all three command aliases (topup / top-up / faucet).
No SDK-layer coverage exists for this — the faucet client is
implemented entirely inside the legacy-CLI handler (~line 2942), so
this file is the only layer that pins it.

Three layers:

1. Help-shape pins (offline, 3 tests) — `payments help <alias>` for
   each of topup / top-up / faucet. All three HELP_TEXT entries
   (~lines 597-636) live independently; pinning all three catches a
   refactor that drops one alias's doc without updating dispatch.

2. No-nametag dispatch pins (network, wallet init only, 3 tests) —
   Asserts that running each alias on a fresh wallet (no nametag)
   exits non-zero with "No nametag registered" stderr BEFORE any
   faucet HTTP call. Proves:
     a. namespace registration is asymmetric (only `faucet` is
        registered top-level via LEGACY_NAMESPACES; topup / top-up
        are reachable via `payments <alias>`)
     b. all three names land in the same fall-through case label
     c. the precondition fires before the HTTP round-trip so a
        broken/rate-limited faucet doesn't mask a wallet-setup error

3. Live faucet round-trip (opt-in, E2E_RUN_FAUCET=1, 1 test) —
   Registers a fresh `it_<hex>` nametag, requests 1 UCT from the
   faucet, asserts the "Received 1 unicity" success line (pins the
   UCT → unicity symbol-to-faucet-name resolution at ~line 2996).
   Gated because the faucet has rate limits + drain protection +
   external service flakiness; consumes real testnet tokens.

Verified live faucet round-trip succeeds against
faucet.unicity.network — 4 tests run by default + 1 opt-in gated.
Offline ~1s, default e2e ~5s, with-live-faucet ~36s.
Add cli-multiaddress.integration.test.ts pinning the four multi-address
commands (addresses / switch / hide / unhide) plus, critically, the
TOKEN ISOLATION INVARIANT across HD-derived addresses.

The CLI extraction left two distinct things uncovered:

  A) CLI plumbing for the multi-address commands (namespace bridge,
     arg parsing, help text).

  B) The security-critical guarantee that tokens belonging to address
     #N never leak to address #M after a switch. A regression here
     would mean a user who switched to a fresh address could
     accidentally spend tokens that belong to a different HD branch.

The architectural mechanism is per-address token storage: Node.js
FileTokenStorageProvider keeps a separate `tokens/<addressId>/`
subdirectory per tracked address. `sphere.payments.getTokens()` reads
from the storage bound to the currently-active address — so as long
as the directory split is honoured, isolation holds.

Four layers of pins:

1. Help-shape pins (offline, 4 tests) — `payments help <cmd>` for
   each of addresses/switch/hide/unhide. HELP_TEXT keys ~707-735.

2. Arg-validation pins (offline, 4 tests) — switch/hide/unhide with
   no <index>, plus `switch abc` (non-numeric guard at ~line 2545).
   All exit non-zero with "Usage: ..." or "Invalid index" before
   getSphere().

3. Stateful local lifecycle (network-light, 7 tests) — fresh wallet
   shows only #0 → switch 1 creates+activates #1 with a DIFFERENT
   directAddress (HD-derivation isolation pin #1) → on-disk
   tokens/ has exactly 2 distinct DIRECT_<...> subdirs (storage
   isolation pin #2) → hide/unhide round-trip → switch back to #0
   restores the original directAddress exactly (state-preservation
   pin #3, no cross-pollination of identity material).

4. Token isolation invariant (opt-in, E2E_RUN_FAUCET=1, 3 tests) —
   the gold-standard funded leak proof:
     a. faucet 1 UCT at #0; beforeAll polls until UCT lands locally
     b. payments tokens at #0 → UCT visible
     c. switch to #1 → payments tokens shows "No tokens found"
        (THE LEAK TEST — would flip red on cross-address visibility)
     d. switch back to #0 → UCT still there, untouched
   Gated because on-chain nametag mint (~20s) plus faucet (~5s)
   plus the polling loop add ~60-90s on top of the default suite.

Verified with E2E_RUN_FAUCET=1: all 18 tests green in ~111s. The
funded leak proof confirms the isolation invariant holds in practice,
not just in the directory-layout pin.

Implementation note: faucet delivery is async — the faucet API
returns success when the gift-wrap is queued on the relay, not when
the wallet has finalized it into local storage. beforeAll polls
`payments tokens` (with sync) up to 3 times until UCT appears at #0,
then tests use --no-sync for fast per-address reads.
Add cli-wallet-profile.integration.test.ts pinning the five wallet
profile-management subcommands (list / use / create / current / delete)
plus the CROSS-PROFILE ISOLATION INVARIANT.

Note on scope: this fills the "token export/import" gap from the #156
follow-up plan. The CLI has no token-level export/import command — the
closest analogues are `parse-wallet` / `wallet-info` which exist in
legacy-cli.ts but are unreachable through the new namespace dispatch
(dead code). What the CLI DOES expose is profile-level wallet
management, and that surface had zero e2e coverage post-extraction
(cli-wallet.integration.test.ts only covers `wallet init`).

The isolation concern is stronger than the HD-address case pinned in
cli-multiaddress: profiles hold INDEPENDENT MNEMONICS. A leak between
profiles could mean signing transactions with the wrong key or losing
access to a profile entirely. Architectural mechanism: `wallet create
<name>` writes a profile with `dataDir = ./.sphere-cli-<name>` and
flips config.json's active dataDir pointer. `getSphere()` reads from
the current pointer.

Four layers of pins:

1. Help-shape pins (offline, 6 tests) — `payments help "wallet"`,
   `"wallet list"`, `"wallet use"`, `"wallet create"`, `"wallet
   current"`, `"wallet delete"`. Multi-word HELP_TEXT keys passed as
   a single argv element (commander preserves them).

2. Arg-validation pins (offline, 5 tests) — `wallet use/create/delete`
   without `<name>` (handlers check profileName before disk write).
   `wallet create '!invalid'` rejects bad charset (~line 1849 guard
   prevents path-traversal-like names). `wallet bogus-sub` exits 1
   via the default `Unknown wallet subcommand` block.

3. CRUD lifecycle (offline, 11 tests) — empty store → create alice →
   duplicate-create rejects → current shows alice → create bob
   auto-switches → list shows both with → marker on bob → use alice
   → use unknown rejects → delete alice (current) refused → delete
   bob succeeds → list no longer shows bob → delete unknown rejects.

4. Cross-profile isolation (network, 3 tests) — init in profile alice
   captures directAddrAlice; init in profile bob captures
   directAddrBob; expect ≠ alice; both per-profile wallet.json files
   exist as separate paths; switch back to alice → `sphere status`
   shows alice's directAddress EXACTLY (not bob's).

All 25 tests green in ~11s total. The isolation suite added ~4s on
top of the 7s offline tier — much faster than expected because fresh
profiles don't carry sync state.

Implementation note: `sphere status` prints human-readable output
("Direct Addr:   DIRECT://..."), not JSON. The isolation pin matches
that format directly; `wallet init` emits JSON which is matched by
its own regex in the per-profile init steps.
…/verify-balance)

Add cli-wallet-state.integration.test.ts covering four wallet-state
inspection / validation commands that lost binary-level coverage when
the in-tree sphere-sdk CLI was extracted:

  - `payments history`        — local transaction history
  - `payments sync`            — pull remote storage into local state
  - `payments receive`         — finalize incoming gift-wraps
  - `payments verify-balance` — validate tokens against aggregator
                                 (spent-token detection)

SDK-layer coverage for each underlying operation exists in
sphere-sdk's PaymentsModule + TokenValidator tests. What this file
pins is the CLI plumbing — exit codes, output shape — that
wallet-management scripts rely on.

Two layers:

1. Help-shape pins (offline, 4 tests) — `payments help <name>` for
   each command, asserting documented flags + positionals.

2. Fresh-wallet lifecycle (network, 4 tests) — brand-new testnet
   wallet → each command exits 0 with the expected empty-state output:
     - history → "Transaction History (last 10):" + "No transactions found"
     - sync → exit 0 (no specific output, load-bearing exit code)
     - receive → exit 0 (no in-flight gift-wraps)
     - verify-balance → "Valid tokens: 0" + "Spent tokens: 0"
   Catches the "empty wallet" regression class where 0-token paths
   inadvertently rely on a non-empty precondition.

Implementation gotcha pinned: `verify-balance` is NOT a top-level
command — same asymmetric registration as `topup`/`top-up` (only
`faucet` is bare top-level). Reachable only via `payments
verify-balance`. Test uses the working form explicitly.

8 tests, all green: offline ~1s, full e2e ~60s. Per-address /
per-profile isolation is already pinned comprehensively by
cli-multiaddress.integration.test.ts and
cli-wallet-profile.integration.test.ts — this file deliberately
focuses on the command surfaces themselves, not re-running isolation
proofs.
Add cli-assets.integration.test.ts covering the two CLI commands that
surface the global TokenRegistry: `payments assets` (list) and
`payments asset-info` (per-asset details).

SDK-layer coverage for TokenRegistry caching / auto-refresh / race-
safe load lives in sphere-sdk's tests/unit/registry/TokenRegistry.test.ts.
What this file pins is the CLI layer: dispatch, multi-strategy lookup,
output shape.

Three layers:

1. Help-shape pins (offline, 2 tests) — `payments help assets`
   (asserts `--type` filter + fungible/nft keywords) and `payments
   help asset-info` (asserts `<symbol|name|coinId>` multi-strategy
   positional).

2. Arg-validation pin (offline, 1 test) — `payments asset-info`
   without identifier exits 1 with usage hint BEFORE getSphere().

3. Network registry queries (4 tests) — fresh testnet wallet drives:
     a. `assets` lists at least UCT (proves remote registry fetch +
        column-aligned table header)
     b. `assets --type fungible` filters out NFTs (no "non-fungible"
        in output)
     c. `asset-info UCT` returns Symbol/Kind=fungible/Coin ID hex/Network
        — pins the symbol-strategy branch of the lookup
     d. `asset-info <bogus>` exits 1 with "Asset not found" — pins
        the negative path (all 3 lookup strategies failed)

Implementation gotcha pinned: `assets` and `asset-info` are NOT
top-level commands (asymmetric registration, same as topup /
verify-balance). Reachable only via `payments assets` / `payments
asset-info`. Test uses the working form explicitly.

7 tests, all green: offline ~1s, full e2e ~5s.
…nic round-trip

Add cli-wallet-lifecycle.integration.test.ts filling the remaining
wallet-management gaps that cli-wallet.integration.test.ts (only
covers `wallet init` + `wallet status`) and cli-wallet-profile
(only covers profile CRUD list/use/create/current/delete) leave
uncovered:

  - `clear`     — destructive wipe + --yes confirmation-guard bypass
  - `config`    — show / set network / dataDir / tokensDir
  - `init --mnemonic` — explicit deterministic import round-trip

The most security-critical pin is `clear`'s confirmation guard.
Without it, a user could accidentally wipe their wallet keys (no
recovery without the backed-up mnemonic). The guard demands literal
"yes" stdin input; --yes / -y bypass for scripted contexts. Both
paths pinned:

  - `clear` with stdin "no\n" → "Aborted." + wallet survives
    (re-verified via `status` still reporting the original
    directAddress)
  - `clear --yes` → wipe succeeds + `status` reports "No wallet
    found"

The BIP-39 determinism round-trip is the strongest integration-level
proof of wallet-recovery correctness:
  1. wallet init (with SPHERE_ALLOW_MNEMONIC_NON_TTY=1) → captures
     mnemonic + directAddress_A from stdout
  2. clear --yes → wallet wiped
  3. wallet init --mnemonic <captured> → directAddress_B
  4. EXPECT directAddress_A === directAddress_B

Any regression in BIP-39 → seed → HD-derivation → secp256k1 →
bech32 along the entire wallet-recovery pipeline flips this red.
SDK-level coverage exists for each individual step; this is the
only end-to-end CLI pin.

Three layers:

1. Help-shape (offline, 4 tests) — init/status/clear/config blocks.

2. Config get/set (local, no network, 3 tests) — `config` shows JSON,
   `config set network dev` mutates + persists, `config set bogus
   value` rejects with "Unknown config key" + valid-key hint.

3. Init / clear / re-init round-trip (network, 3 tests) — described
   above. Wallet init emits mnemonic to stdout when
   SPHERE_ALLOW_MNEMONIC_NON_TTY=1 (test-harness opt-in documented
   at legacy-cli.ts ~line 1675).

10 tests, all green: offline ~2s, full e2e ~5.6s.

Finding: Sphere generates 12-word BIP-39 mnemonics, not 24 as some
older docs/comments suggest. The mnemonic regex accepts BIP-39's full
valid range (12, 15, 18, 21, 24 words) anchored to a stdout line.
10 new test files, ~120 tests, ~2500 lines. Pins HD-address isolation, cross-profile isolation, clear confirmation guard, BIP-39 determinism. Fixes invoice-create/return coinId bug.
Pins the binary-level CLI plumbing between users and SwapModule for all
8 swap-* commands: namespace bridge, arg parsing, help-text shape, and
pre-getSphere() validation paths.

  - Help-shape pins for 7 commands (Usage line + per-flag regex)
  - swap-ping HELP_TEXT gap pin (no help entry; locks current behaviour)
  - Arg-validation pins: 6 swap-* commands that require args[1] before
    wallet load; refactor moving the check below getSphere() flips red
  - swap-propose multi-flag guard: missing flags, partial flags,
    out-of-range --timeout (60-86400 sec)

18 tests, all offline, ~4.7s total. No infrastructure dependencies.
Live swap lifecycle (Docker escrow + funded wallets) ships in a
follow-up commit on this branch.

Refs #156
… + faucet)

Adds end-to-end coverage of the swap CLI surface against a real escrow.
Pairs with the offline tier in 744afa7 to give complete pin coverage of
the sphere-cli ↔ SwapModule ↔ escrow protocol path.

What lands:
  - test/integration/local-infra/docker-compose.yml — local NIP-29 relay
    (port 7778; reserved for group/market follow-up — swap uses public
    testnet relay)
  - test/integration/local-infra/relay.ts — relay lifecycle helper,
    ported from /home/vrogojin/trader-service/test/e2e-live/local-infra
  - test/integration/local-infra/escrow.ts — escrow container spawn +
    log-poll for `sphere_initialized` direct address. Image defaults to
    `escrow:local-uxf` (must be locally built against
    integration/all-fixes — see docstring). Override with
    SPHERE_CLI_ESCROW_IMAGE.
  - test/integration/helpers.ts — `createSphereEnv` now accepts
    `{ extraEnv }` for callers that need to inject env vars (e.g.
    SPHERE_NOSTR_RELAYS).
  - test/integration/cli-swap-e2e.integration.test.ts — gated by
    E2E_RUN_SWAP=1. Two scenarios on the default tier:
      1. swap-ping → "Escrow is online"
      2. swap-propose → swap-list on counterparty → swap-cancel →
         confirm absent from default open list
    Plus a stretch full-settlement scenario behind E2E_RUN_SWAP_FULL=1
    (known fragility: deposit-conclude often stalls because bob's
    --deposit --no-wait submission may not complete before alice's
    300s status budget; tracking as follow-up).

Why public testnet relay instead of local: adding a local relay to the
wallet's SPHERE_NOSTR_RELAYS list (alongside testnet) caused the faucet
gift-wrap to never land in the wallet's inbox. Root cause unclear —
under investigation. The simpler architecture (everyone on testnet
relay) gives a working 2.5-min default e2e tier.

Why the local image: ghcr.io/vrogojin/agentic-hosting/escrow:v0.1
(2026-04-25) predates UXF protocol (PR #105), swap race fixes (#115),
verifyPayout OVER_COVERAGE (#119), getTokenIdsForInvoice exposure
(#128). Building against integration/all-fixes was required.

Tests:
  - Default tier: 2 scenarios pass (~2.5 min)
  - Skipped without E2E_RUN_SWAP=1 (default CI fast tier)

Refs #156
Expands the 49-line cli-crypto.integration.test.ts to a 37-test
table-driven suite covering all 12 crypto/util commands with
HELP_TEXT entries:
  generate-key, validate-key, hex-to-wif, derive-pubkey,
  derive-address, base58-encode, base58-decode,
  to-smallest, to-human, format, encrypt, decrypt

Three layers, all offline (~10s total):

  1. Help-shape pins for all 12 (Usage line + per-flag regexes).
  2. Arg-validation pins: 11 commands pre-validate args[1] before
     getSphere(). Bare invocation → usage hint + non-zero exit.
  3. Behaviour pins:
       - generate-key emits pubkey + alpha1 address; secrets hidden
       - --unsafe-print SECURITY guard: refuses non-TTY (prevents
         leaking the freshly-minted privkey into vitest log buffers)
       - validate-key true/false JSON output shape + exit code
       - hex-to-wif deterministic WIF for stable test privkey
       - derive-pubkey is deterministic (literal pin)
       - derive-address is deterministic AND index-sensitive
       - base58-encode/decode roundtrip ("Hello" ↔ 9Ajdvzr)
       - to-smallest/to-human roundtrip via 8-decimal default
       - encrypt → OpenSSL "U2FsdGVkX1" magic header
       - decrypt JSON-quoted ciphertext → plaintext
       - decrypt wrong-pw does NOT yield original plaintext
         (CLI exits 0 — CryptoJS AES-CBC has no HMAC; pin documents
         current behaviour and catches pathological keystream collisions)

Refs #156
Adds 2 e2e tests pinning the init-time nametag registration in
cli-wallet-lifecycle:
  - `sphere init --nametag it_<hex>` returns identity JSON with the
    nametag field populated (proves Sphere.init's nametag option
    minted on-chain)
  - `sphere nametag my` confirms the binding persisted locally
    (proves the registerNametag → storage write path)

The combined flow differs from `init` then `nametag register` in
its failure mode: a mid-mint failure can leave wallet stored but
nametag unregistered. Sphere.init handles this defensively; we pin
the happy path so a regression that drops nametag persistence
during init becomes visible.

Note: `payments validate` was in the follow-up gap list but does
NOT actually exist as a command — only `verify-balance` does (which
cli-wallet-state already pins). No action needed there.

Refs #156
Adds 26 tests pinning the namespace-bridge → dispatcher glue for the
two remaining unaddressed CLI namespaces:

  group (9 commands, 17 tests):
    create / list / my / join / leave / send / messages / members / info
    — help-shape + arg-validation pins for the NIP-29 group chat surface.

  market (5 commands, 9 tests):
    post / search / my / close / feed
    — help-shape + arg-validation pins for the P2P bulletin-board surface.
    Including market-post's two-step pre-getSphere() guard (description
    positional, then --type required flag).

Live e2e tiers deliberately deferred:
  - group: would need a NIP-29-capable local relay (the unicity-tokens-
    relay used by the swap suite is generic nostr-rs-relay; not
    confirmed to handle NIP-29 moderation events). SDK-level coverage
    exists in sphere-sdk tests/relay/groupchat-relay.test.ts.
  - market: would need long-form NIP-23 relay + broadcast network.
    SDK-level coverage covers the module mechanics.

The offline tier here is enough to catch the failure modes that hurt
users most: a refactor renames a flag (silent break), or moves the
arg check below getSphere() (turning "did I type the command right?"
into a 10-second wallet load). Live roundtrips are SDK-team territory.

Refs #156
Addresses code-reviewer feedback before merging into integration/all-fixes.

BLOCKER fix:
  - escrow.ts:175 — UNICITY_RELAYS → UNICITY_NOSTR_RELAYS. The escrow
    service's acp-adapter reads UNICITY_NOSTR_RELAYS (with
    SPHERE_NOSTR_RELAYS fallback). The old var name was silently
    ignored, so the container fell back to network defaults regardless
    of opts.relayUrl. Worked today only because the e2e suite targets
    the public testnet relay (which is also the network default);
    pointing at a local Nostr relay would have failed silently.

IMPORTANT fixes:
  - escrow.ts:168 — UNICITY_MANAGER_DIRECT_ADDRESS now uses
    `DIRECT://<pubkey>` form, not raw pubkey hex. The current escrow
    code only checks for non-empty, but a future routing change would
    dereference it as a transport address; the placeholder needs to
    be syntactically correct.
  - helpers.ts — MaxListenersExceededWarning from accumulating exit
    handlers (3 per re-evaluation × 14 test files = 42 vs default
    limit of 10). Guard against re-registration via a global symbol
    so the three process.once handlers fire at most once per worker
    regardless of how many test files import this module.

NIT fixes:
  - escrow.ts materializeWalletDir — 0700 chmod on the wallet dir
    + mkdir mode hardening. Matches the helpers.ts pattern. Paranoia
    for non-POSIX platforms where mkdtemp may inherit DACLs.
  - cli-swap-e2e.integration.test.ts — added inline comment on the
    full-settlement describe.skipIf block documenting why the tier is
    gated, the known failure mode, and likely fix paths.
  - cli-crypto.integration.test.ts — replaced "known shortcoming"
    wording with explicit TODO(security) marker that flags the
    decrypt-wrong-pw-exits-0 path as a CLI defect to be addressed in
    a separate PR (move to AES-GCM with backward-compat shim).

Test plan:
  - Typecheck: clean
  - Offline tier: 162 passing / 55 skipped / 0 failed (44s)
  - E2E_RUN_SWAP=1: ping + propose/list/cancel pass (~2.2 min)
  - Sigint/sigterm cleanup for the escrow Docker container is NOT
    addressed in this commit (reviewer's lowest-severity nit; the
    container is visible via `docker ps` and easy to clean up
    manually).

Refs #156
…group + market

Fills the remaining CLI test gaps from PR #13's follow-up comment on
issue #156. 6 commits, +1,827 lines, +89 tests.

Coverage delta:
  - swap        — 18 offline + 3 e2e (2 default + 1 stretch-gated)
  - crypto/util — 37 offline (expanded from 3)
  - init --nametag — 2 e2e
  - group       — 17 offline
  - market      — 9 offline

Infrastructure additions:
  - test/integration/local-infra/ (docker-compose.yml, relay.ts,
    escrow.ts) for swap e2e + reserved for group/market e2e
  - helpers.ts: createSphereEnv accepts { extraEnv }; MaxListeners
    guard fixes 11-listener warning

Review fixes (PR #14):
  - escrow.ts UNICITY_RELAYS → UNICITY_NOSTR_RELAYS (silent ignore bug)
  - UNICITY_MANAGER_DIRECT_ADDRESS now uses DIRECT:// prefix
  - Wallet dir 0700 hardening matches helpers.ts pattern
  - Inline docs on swap full-settlement fragility
  - TODO(security) marker on decrypt-wrong-pw bug

Critical caveat for downstream: ghcr.io/vrogojin/agentic-hosting/escrow:v0.1
is stale vs integration/all-fixes; the swap e2e tier requires a locally
built escrow:local-uxf image (build steps in escrow.ts docstring). A
future agentic-hosting v0.2 publish would remove this requirement.

Test status:
  - Offline tier: 162/162 passing
  - swap e2e default: 2/2 passing (~2.2 min)
  - swap e2e stretch: gated, known fragile
Published 2026-05-16:
  ghcr.io/vrogojin/agentic-hosting/escrow:v0.2
  digest sha256:311903b6f98b33a63791bf79db6522a66d118588ba56fcf6e56654ed6670ebac

Composition: escrow-service @ d427e5d (master + fix/conservative-
payout-mode HEAD) + uxf sphere-sdk @ 3a575cd (integration/all-fixes
HEAD). Removes the requirement that every developer / CI runner
locally build escrow:local-uxf before running the swap e2e suite.

Verified: E2E_RUN_SWAP=1 npm run test:integration -- cli-swap-e2e
passes against the published image (2/2 default tier, ~2.3 min).

SPHERE_CLI_ESCROW_IMAGE env var still lets you override with a
locally-built dev image (build steps in the escrow.ts docstring).

Follow-up: formalize the publish path so future v0.3 goes via the
escrow-service GitHub release workflow (currently pins SPHERE_SDK_SHA
to a Apr 9 commit; needs bump to 3a575cd). Tracked separately.

Refs #156
Replaces escrow:local-uxf with ghcr.io/vrogojin/agentic-hosting/escrow:v0.2
as the default. Removes the developer-rebuild requirement for swap e2e.
Override via SPHERE_CLI_ESCROW_IMAGE still available.

v0.2 composition documented in escrow.ts docstring: escrow-service@d427e5d
+ uxf sphere-sdk@3a575cd (integration/all-fixes), digest
sha256:311903b6f98b33a63791bf79db6522a66d118588ba56fcf6e56654ed6670ebac.

Verified: E2E_RUN_SWAP=1 default tier 2/2 passing against the published
image (~2.3 min).
#163 item 1. The full-settlement tier (E2E_RUN_SWAP_FULL=1) was
gated behind "known fragile" because bob's
`swap accept --deposit --no-wait` returned after SUBMITTING the
deposit, not after on-chain CONFIRMATION. Alice then ran her own
`swap deposit` synchronously, but by the time we started polling
alice's status for `completed`, bob's deposit was often still
in-flight — escrow can only conclude after seeing both, so settlement
stalled at `depositing`, exhausting the 300s budget.

Restructured:
- bob accepts WITHOUT --deposit (just announces the swap).
- alice + bob both run `swap deposit` IN PARALLEL via the new
  `runSphereAsync` helper. Each command BLOCKS until its deposit
  is confirmed on-chain (the SDK waits for inclusion proof + escrow
  ack), so when Promise.all resolves both deposits are definitely
  observable to the escrow.
- Status poll budget bumped 300s → 600s as a defensive safety net.
- Outer test timeout 600s → 900s to accommodate slower testnet days.

Added `runSphereAsync` to helpers.ts — a Promise-based variant of
`runSphere` using `child_process.spawn`. Same `SpawnSyncReturns` shape
so existing assertions work unchanged. Same SIGKILL-on-timeout
semantics as the sync wrapper.

Verified:
- npx tsc --noEmit clean
- npx eslint test/integration/{helpers,cli-swap-e2e.integration.test}.ts clean
- SKIP_INTEGRATION=1 npx vitest run … cli-swap-e2e — file loads, tests
  correctly skipped (3 tests / 2 skipped due to gate)
- Live E2E_RUN_SWAP_FULL=1 run pending; documented in PR follow-up.
…s root cause

3 live runs (parallel + sequential variants, 600s budget) all failed
with the same pattern: escrow's payments-module profile manifest
update fires `[PerTokenMutex] bounded-hold ... manifest CID rewrite
CAS failure: cas-mismatch` after both deposits arrive (sequential
deposits at ~50s apart). Swap stalls at `PARTIAL_DEPOSIT` →
`invoice:covered with unconfirmed deposits — waiting for aggregator
confirmation` and never advances.

Filed separately as a sphere-sdk issue — unblocking this e2e tier
depends on the escrow image picking up that fix (escrow:v0.3+).

Kept in this PR (independently useful):
- `runSphereAsync` helper for parallel CLI invocations.
- Wait-for-announced poll loop (prevents alice's `swap deposit` from
  racing its own 60s event-wait against escrow's invoice-delivery DM).
- Sequential deposit ordering (bob `accept --deposit --no-wait` then
  alice `swap deposit`).
- Budget bumps 300s → 600s + outer 600s → 900s for when the escrow
  bug lands.

Updated inline docstring with the full finding so the next person
picking up this test understands the actual blocker.

Test remains gated behind `E2E_RUN_SWAP_FULL=1` (it does not pass
yet — the escrow CAS-mismatch must be fixed upstream first).
The escrow:v0.3 image (published 2026-05-21) bundles sphere-sdk PR #196
which resolves the issue #195 root cause of the full-settlement hang:

  1. Placeholder manifest entry CAS-mismatch in the recipient finalization
     worker's poll callback (eliminates the `[PerTokenMutex] bounded-hold
     ... manifest CID rewrite CAS failure: cas-mismatch` operator-dashboard
     noise on every inbound deposit).
  2. Missing `transfer:confirmed` emit in the recipient dispositionWriter
     VALID branch (AccountingModule now re-fires `invoice:covered` with
     `confirmed: true` — the signal the escrow swap orchestrator gates
     on to advance past PARTIAL_DEPOSIT).

Verification (2026-05-21, against published `ghcr.io/vrogojin/agentic-hosting/escrow:v0.3`):

  Test Files  1 passed (1)
  Tests       3 passed (3)
   ✓ swap ping                                       1947ms
   ✓ propose + cancel                                36622ms
   ✓ full deposit settlement (E2E_RUN_SWAP_FULL=1)  131147ms
  Duration  266.02s

Full settlement reaches `completed` in 131s — comfortably under the
600s polling budget kept from the prior amendment.

Changes:
  - `test/integration/local-infra/escrow.ts`: bump default
    `ESCROW_IMAGE` from v0.2 → v0.3. Refreshed the in-source docstring
    with the v0.3 composition (PR #196 callout + v0.2-is-now-stale note).
    Override mechanism (`SPHERE_CLI_ESCROW_IMAGE`) unchanged.
  - `test/integration/cli-swap-e2e.integration.test.ts`: updated the
    file-level gate-comment v0.1 reference → v0.3, and rewrote the
    full-settlement section's investigation docstring to reflect the
    resolved state (the comment block previously documented the
    bug-still-open state and the rationale for keeping the polished
    test infrastructure as defensive code).
…ttlement

test(swap): #163 — fix flaky full-settlement e2e via parallel deposits
`sphere invoice create --target @bob-tag --asset "1000000 UCT"` now
resolves the @NameTag (or chain pubkey, or alpha1 address) to the
canonical `DIRECT://` address before calling `AccountingModule.create
Invoice`. Symmetric with `payments send --recipient @nametag` which
already accepts these forms.

Why resolve at the CLI layer:

  - `AccountingModule.createInvoice` validates `target.address.starts
    With('DIRECT://')` (modules/accounting/AccountingModule.ts:906)
    and throws `INVOICE_INVALID_ADDRESS` otherwise. This is correct
    SDK behaviour — invoice terms cryptographically bind the
    recipient identity, so the canonical DIRECT:// form is what
    gets signed and shipped.
  - The CLI is the right layer to translate user-facing identifiers
    (@NameTag, chain pubkey, alpha1) into canonical addresses. The
    same pattern is already in `dm-history`
    (legacy-cli.ts:3108) and in `payments send --recipient`.
  - Resolution happens once at create-time; the resolved DIRECT://
    address is what's persisted in the invoice's signed terms, so
    a later nametag rename does NOT invalidate the invoice (which
    is the correct semantic).

Before this fix:

  $ sphere invoice create --target @bob-tag --asset "1000000 UCT"
  Error: Invalid target address: must be DIRECT:// format

After:

  $ sphere invoice create --target @bob-tag --asset "1000000 UCT"
  Invoice created:
  { ... "address": "DIRECT://0000..." ... }

Validated against live testnet during issue-223 cross-process manual
recovery test (see sphere-sdk's
manual-test-full-recovery.sh + walkthrough doc on PR #222 / branch
docs/issue-218-full-recovery-manual-test).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…gration/all-fixes tip

The previous pinned SHA `86468103a` was the tip of
`refactor/extract-cli-to-sphere-cli` at the time the workflow was added.
That branch has since been deleted from the public sphere-sdk repo.
The commit still exists in the GitHub repo's object database but is
no longer reachable via any branch tip, so a default `git clone` does
not fetch it and the subsequent `git checkout --detach $SHA` fails with
`fatal: unable to read tree`. PR #17's CI started failing for this
reason — symptom unrelated to PR contents.

Fix: re-pin to `02cb4550fac` (the tip of `integration/all-fixes` after
PR #225, the cross-process UXF delivery fix). That branch contains the
same CLI-consumed type exports (`CreateInvoiceRequest`, `PayInvoice
Params`, `InvoiceRequestedAsset`, encrypt/decrypt helpers, ...) that
the original pin provided. Verified locally:

  grep -E 'CreateInvoiceRequest|PayInvoiceParams|InvoiceRequestedAsset' \
    sphere-sdk/index.ts
    CreateInvoiceRequest,
    InvoiceRequestedAsset,
    PayInvoiceParams,

Defense-in-depth: add `git fetch origin "$SPHERE_SDK_SHA" || true`
before checkout so the workflow keeps working when integration/all-
fixes advances past this commit (the SHA stays pinned for supply-chain
integrity, but the explicit fetch picks it up via the object database
even if it's no longer on a branch tip).

Both `ci.yml` and `integration-nightly.yml` updated together so a
nightly run stays hermetic with PR CI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Surfaces the SDK's new `accounting.deliverInvoice()` API at the CLI.
Packages a previously-minted invoice into a UXF bundle and ships it
to every non-self target via NIP-17 DM, so payers' wallets can
discover the invoice without out-of-band coordination.

Usage:
  sphere invoice deliver <id-or-prefix> [--to <recipient>...] [--memo <text>]

Behaviour:
  - Prefix-matches the invoiceId against the local ledger (same lookup
    pattern as invoice-pay).
  - Default recipients: every non-self DIRECT:// target in the invoice
    terms (multi-HD self-skip honoured by the SDK).
  - --to is repeatable for explicit recipient override (@NameTag,
    DIRECT://, chain pubkey — same resolver pool as `payments send`).
  - --memo decorates the DM envelope (display-only, not part of the
    bundle hash).

Output:
  - JSON `DeliverInvoiceResult` with `{ invoiceId, sent, failed,
    skippedSelf, recipients[] }` for scripting.
  - Exit code 0 on full success, 2 on partial failure (any recipient
    failed). Operators can grep `recipients[].error` for the cause.

Companion to integration/all-fixes commit 90a5cc3 on sphere-sdk.
The manual-test-full-recovery.sh §C calls this between
`invoice create` and Bob's `invoice pay` to validate end-to-end.
…iver

legacy-cli.ts wraps `process.exit` to schedule an async teardown
(destroys the Sphere instance, closes Nostr relays, IPFS handles)
before the real exit. The wrapper returns `undefined as never`, so
synchronous control flow continues past `process.exit(N)` until the
async teardown's `.finally(() => originalExit(code))` fires later.

Without an explicit `return` after `process.exit(1)`, the catch in
invoice-deliver's main flow logged the SDK error correctly but then
the next statements ran: `console.log('Invoice delivery result:')`
printed `undefined`, then `result.failed > 0` crashed with
"Cannot read properties of undefined (reading 'failed')" — clobbering
the original SDK error message in the operator's terminal.

Surface caught during manual-test-full-recovery.sh §C.1b live testnet
run (sphere-sdk log /tmp/manual-cli-test-226-v3.log). Mirror fix
applied for the `result.failed > 0` partial-failure exit so the
post-result code is symmetric.

Same pattern needed by every handler in this file that has post-catch
work — kept the comment block explanatory so a future audit catches
the rest.
Vladimir Rogojin and others added 21 commits May 23, 2026 12:14
`sphere daemon start --detach` returned exit 0 with a PID, but the
forked child died between fork() and any useful work — leaving a stale
PID file and an empty daemon.log. Two compounding causes:

1. The parent forked with `stdio: 'ignore'`, which strips the IPC
   channel that child_process.fork() normally establishes. The child's
   `process.connected` was therefore false from the start.

2. The child unconditionally called `process.disconnect()` (guarded only
   by `if (process.disconnect)`, which is always truthy because the
   function exists regardless of channel state). With no live IPC
   channel, disconnect throws "IPC channel is not open". The throw
   bubbled up to legacy-cli's catch, which called `console.error`
   (already redirected to an unflushed WriteStream) and `process.exit(1)`.
   The child died silently — the WriteStream's pending writes never
   flushed because the underlying fs.open hadn't completed.

Fix:

* `detachDaemon` now opens the log file in the parent and inherits the
  fd to the child's stdout AND stderr (`stdio: ['ignore', logFd, logFd,
  'ipc']`). Any future startup failure — crash, uncaught exception,
  raw stderr emission — is captured at the OS level before any
  Node-level streaming machinery is required. 'ipc' is kept because
  child_process.fork() throws "Forked processes must have an IPC
  channel" without it; the channel exists solely to satisfy fork's
  contract (no messages flow over it).

* `runDaemon`'s child-side disconnect is now guarded on
  `process.connected`, so it correctly no-ops if the channel was already
  torn down (e.g. parent exited first) and only disconnects when
  there's a live channel to release.

* `log()` no longer double-writes in forked mode (the redirected
  `console.log` already forwards to the same WriteStream that `log`
  writes to directly). Pre-existing bug, exposed only because the
  daemon now actually runs.

Reproduction from the issue (`mkdir … && wallet create … && init …
&& daemon start --detach …`) now reports "Daemon is running (PID X)"
after sleep 3, with a non-empty daemon.log containing
"Daemon running. Waiting for events." — matching acceptance criteria.
New integration suite per acceptance criteria. Mirrors the pattern of
test/integration/cli-wallet-lifecycle.integration.test.ts (offline
help-shape layer + network lifecycle layer skipped by SKIP_INTEGRATION).

Two layers:

  1. Help-shape (offline, 3 pins) — `payments help daemon`,
     `daemon start`, `daemon status`. Pins the documented flag surface
     (--detach, --event, --action, --log, --pid) so a refactor that
     drops a flag from the help registry fails red.

  2. Detach lifecycle (network, 1 pin) — end-to-end:
       start --detach     →  exit 0 with "Daemon started in background"
       sleep 6s + status  →  "Daemon is running (PID X)"
                             (was: "Daemon is not running (stale PID
                              file, process X)")
       log file           →  non-empty, contains
                             "Daemon running. Waiting for events."
       stop               →  "Daemon stopped"
       post-stop status   →  "Daemon is not running"
       PID file           →  removed

afterEach calls `daemon stop` defensively so a mid-test failure cannot
leak a forked daemon holding open Nostr WebSocket connections against
the testnet relay.

Why this is an integration test (not unit / mocked): the bug fixed by
issue #19 was inside child_process.fork() + process.disconnect()
semantics that only manifest when an actual node process is forked with
the actual stdio + IPC-channel configuration. A unit test mocking fork
would not catch a recurrence.
Self-review follow-up. The parent's child.disconnect() closes the IPC
channel at the OS layer, but the child's JS-level 'disconnect' event
(which flips process.connected to false) is delivered async. There's a
narrow microtask window where the child reads process.connected as true
while the underlying channel is already torn down, in which case the
disconnect() call throws "IPC channel is not open" — the exact failure
mode this PR fixes, just triggered by a different cause.

Wrap the child-side disconnect in try/catch. Swallowing is correct: the
goal state (channel closed) already holds either way.

In practice the race window is small (child needs ~hundreds of ms to
load and reach the disconnect call, by which time the event handler
has run) and integration tests have not hit it, but the defensive
guard removes a theoretical flake source from production deployments.
…e-exit-19

fix(daemon)(#19): keep --detach child alive past process.disconnect()
… stop

`legacy-cli.ts`'s `main()` wraps `process.exit` to destroy the Sphere
instance (Nostr relays, IPFS handles, SQLite) before the real exit.
Cleanup is async, so the wrapper used to schedule `inst.destroy()
.finally(originalExit)` and `return undefined as never`. That left the
calling line of code to continue executing past `process.exit(N)`.

The shape that surfaced this was `invoice-status`:

  if (matched.length === 0) {
    console.error('No invoice found matching prefix: ...');
    process.exit(1);              // wrapper schedules destroy, returns
  }
  const invoiceId = matched[0].invoiceId;   // ← matched[0] undefined

…which crashed with `Cannot read properties of undefined (reading
'invoiceId')`. Every other `invoice-*` handler (close, cancel, pay,
return, receipts, notices, transfers, export) shares the same shape,
and there are ~180 `process.exit(N)` call sites across this file that
all depend on synchronous termination.

The wrapper now throws an `ExitSignal` sentinel synchronously when a
Sphere instance is loaded. The outer try/catch in `main()` detects
`ExitSignal`, awaits `closeSphere()`, and forwards the code through
the original (non-wrapped) `process.exit` so the catch is not re-
entered. When no instance is loaded (early arg-validation paths), the
wrapper falls straight through to `originalExit`, matching the
previous synchronous behaviour for help / usage exits.

ExitSignal deliberately does not extend `Error` so inner
`catch (err)` blocks that filter on `err instanceof Error` do not
classify it as a normal error worth logging. Every inner catch in
this file either re-calls `process.exit(N)` (which re-throws an
ExitSignal that propagates correctly) or sits over a try body with
no `process.exit` (so ExitSignal can never reach it) — audited via
the catch-block sweep in `src/legacy/legacy-cli.ts`.

Regression pins in `test/integration/cli-invoice.integration.test.ts`
(lifecycle block, gated by `integrationSkip`):

- `invoice status <unknown-prefix>` → exit 1 + clean error message,
  no `Cannot read properties of undefined` / `TypeError` anywhere.
- Companion `it.each` for `close` / `cancel` / `pay` — same shape,
  catches a wrapper regression surfacing on any of these handlers.

Manual repro (matches issue body) on testnet:

  sphere wallet create alice
  sphere wallet use alice
  sphere init --network testnet --nametag inv-crash-xxx
  sphere invoice status 00005eb450a21d54f6d77b3c352a26a7539cc453ccdb1d1928dcdb6a0a266ca31e82
  → No invoice found matching prefix: …
  → exit 1, no stack trace ✓

The prior bf40221 fix (`fix(invoice)(#226)`) added an explicit
`return` after the catch-block `process.exit(1)` in invoice-deliver.
That `return` is now unreachable (the throw propagates first) but
left in place as a defensive marker — removing it widens this PR's
scope unnecessarily.

Related: #19 / #20 (daemon detach) surfaced this bug indirectly by
unblocking manual-test-full-recovery.sh §B → §C.4, where peer2-alice
hit `invoice status` for an invoice it had never received. The
cross-device invoice sync gap (peer2 not seeing peer1's invoice) is
the SDK-side follow-up tracked separately; this commit only fixes
the CLI crash that was masking it.
… guard

The defensive `return` in invoice-deliver's catch dates from #226 when
the `process.exit` wrapper returned `undefined as never` and required
explicit returns at every call site. With #21's wrapper rewrite the
ExitSignal throw propagates first, so the comment's "wrapper returns
undefined" wording is now wrong and would confuse a future reviewer
auditing why the `return` is there.

Keep the `return` itself — defensive marker against a wrapper
regression that reintroduces the fall-through. Rewrite the comment to
describe the current ExitSignal-based mechanism and the regression
class it guards against.

No behaviour change. Pure documentation drift cleanup.
fix(cli)(#21): throw ExitSignal from process.exit wrapper so handlers stop
Replace the deprecated IpfsStorageProvider bootstrap (IPNS-based
last-writer-wins sync) with createNodeProfileProviders (OrbitDB +
aggregator pointer + IPFS CAR). Wraps the multi-device data-loss
window flagged during #223 §D.4 validation.

Phase 1 of the migration plan:

  - New shared helper `src/shared/sphere-providers.ts` exposing
    `buildSphereProviders()` (merges createNodeProviders' transport/
    oracle/etc. with createNodeProfileProviders' storage/tokenStorage)
    and `detectWalletKind()` (pure filesystem read — orbitdb/ marker
    classifies profile vs legacy wallets).
  - `getSphere()` in src/legacy/legacy-cli.ts now boots Profile and
    short-circuits with EX_TEMPFAIL (75) + a clear `sphere wallet
    migrate` prompt when a legacy on-disk layout is detected. The
    `--mnemonic` seeding paths are exempt from the gate so wallet
    recovery against an existing dataDir still works.
  - `clear` command picks the provider bundle by detected kind so a
    legacy-only wallet doesn't have to spin up OrbitDB just to wipe
    empty Profile state.
  - `src/host/sphere-init.ts` adopts the combined providers and the
    same legacy guard — host commands cannot operate against a
    pre-migration wallet without misrouting.
  - New `sphere wallet migrate [--apply]` subcommand. Default is a
    strictly side-effect-free dry-run that uses ONLY the legacy
    provider bundle (no Profile boot, no orbitdb/ created), counting
    legacy tokens via sphere-sdk's `importLegacyTokens(... dryRun:
    true)`. `--apply` boots Profile and runs the non-destructive
    import; legacy files stay on disk.

Tests:

  - 6 unit tests for detectWalletKind (fresh / legacy / profile /
    edge cases).
  - 2 dispatch-table tests for `sphere wallet migrate` routing.
  - 6 end-to-end integration tests in cli-wallet-migrate.integration
    .test.ts driving the full lifecycle against real testnet:
    init → simulate legacy by `rm -rf orbitdb/` → gate trips with
    exit 75 → dry-run reports inventory without recreating orbitdb/
    → --apply restores Profile path → subsequent commands no longer
    trip the gate. ~9s wall-clock on testnet.

Deferred to Phase 2/3 (per the phased PR plan):

  - manual-test-full-recovery.sh §D.4 validation.
  - Migrate src/pointer/sphere-init.ts to use the shared helper
    (still uses its own dynamic-import shim).
  - Optional `--archive` flag on `wallet migrate` to move legacy
    data into ./.sphere-cli/legacy-backup/.
  - Integration test for `importLegacyTokens` actually moving N>0
    tokens across the boundary (current e2e covers wiring only —
    fabricating valid TxfToken files is its own task).
Self-review caught a dead helper. The migrate command in
src/legacy/legacy-cli.ts calls createNodeProviders directly; nothing
else imported buildLegacyOnlyProviders. Per CLAUDE.md guidance against
unused abstractions, remove it now — a future PR can re-add a focused
helper when an actual caller needs one.
…iders

feat(cli)(#23): bootstrap Profile providers; prompt on legacy wallets
Both commands need the IPFS / Profile pointer pull, not just the Nostr
inbox. 'nostr' mode skipped that pull, so on a fresh device or after a
wipe `invoice-status` reported "No invoice found matching prefix" and
`invoice-list` returned an empty set — even when the invoice had been
minted by another peer and was reachable on-chain.

The other invoice commands (deliver, close, cancel, pay, return,
receipts, notices, auto-return, transfers, export) already used 'full'.
This aligns the two read-side commands with the rest.

Companion sphere-sdk fix is #230 — once that lands, the receiver-side
AccountingModule.invoiceTermsCache will also refresh on sync:completed
and the full cross-device §C.4 flow will work end-to-end.

Refs sphere-cli#24, sphere-sdk#230, sphere-sdk#223
…s-sync-mode

fix(cli)(#24): invoice-status and invoice-list use 'full' sync mode
…older

The `sphere wallet use <name>` flow constructs a FileStorageProvider
whose `connect()` writes an empty `{}` JSON to wallet.json as a side
effect — BEFORE any wallet data exists. PR #25's `detectWalletKind`
classified that placeholder as `legacy` and tripped the migrate gate
on every fresh wallet, blocking `sphere init` for first-time users.

Caught by `manual-test-full-recovery.sh §1`: peer1-alice `sphere init`
exited 75 immediately with "Legacy wallet detected" instead of
proceeding to mint the nametag.

Fix: parse `wallet.json` and classify an empty top-level object as
`fresh`. A wallet.json with any key is still treated as `legacy`
(real wallet data → migrate triage). An unparseable / non-object
file is also conservatively routed through `legacy` so a corrupted
or unrecognized file isn't silently clobbered by a Profile boot.

Tests (5 new in `src/shared/sphere-providers.test.ts`):
  • empty `{}` placeholder → fresh
  • `{}` with whitespace → fresh
  • single key (`{"mnemonic":"..."}`) → legacy (unchanged)
  • unparseable garbage → legacy (conservative)
  • array shape `[]` → legacy (unexpected shape)

All 119 unit tests pass. Typecheck clean. No new lint warnings.

Refs sphere-cli#23, PR #25.
…ty-placeholder

fix(cli)(#23): detectWalletKind ignores empty `{}` wallet.json placeholder
…lock

The daemon parks the event loop forever with OrbitDB / Helia open;
LevelDB takes a POSIX advisory file lock (fcntl(F_SETLK)) on
<dataDir>/orbitdb/<dbAddress>/_index/LOCK and on
<dataDir>/datastore/LOCK. A sibling CLI in the same dataDir hits
LEVEL_LOCKED -> 'Database is not open', and the bounded retry from
sphere-sdk PR #246 can never succeed (the contention isn't transient).

This short-term gate detects the live-daemon case in getSphere() and
exits with EX_TEMPFAIL, telling the operator to 'sphere daemon stop'
first. Skipped when our own PID owns the PID file (daemon-start
calling back into getSphere is the legitimate owner). Bypassed for
daemon stop/status (which don't go through getSphere).

The proper fix is a daemon-as-broker IPC surface (sphere-sdk #247
long-term: Unix domain socket at <dataDir>/.sphere-cli/daemon.sock,
RemoteOrbitDbAdapter mirroring the OrbitDbAdapter interface). Until
then, this stops the script-level cascade observed at §C.4 in
manual-test-full-recovery.sh.

Exports readPidFile and isDaemonProcessAlive from daemon.ts so
legacy-cli.ts can reuse them without duplication.
fix(cli)(sphere-sdk#247): refuse CLI when a sphere daemon holds the OrbitDB lock

The daemon parks the event loop forever with OrbitDB / Helia open;
LevelDB takes a POSIX advisory file lock (fcntl(F_SETLK)) on
<dataDir>/orbitdb/<dbAddress>/_index/LOCK and on <dataDir>/datastore/LOCK.
A sibling CLI in the same dataDir hits LEVEL_LOCKED -> 'Database is
not open', and the bounded retry from sphere-sdk PR #246 can never
succeed (the contention isn't transient).

Short-term gate in getSphere(): detects the live-daemon case and exits
with EX_TEMPFAIL telling the operator to 'sphere daemon stop' first.
Skipped when our own PID owns the PID file (daemon start callback into
getSphere is the legitimate owner). Bypassed for daemon stop/status
(don't go through getSphere).

The long-term fix is a daemon-as-broker IPC surface (sphere-sdk #247
follow-up: Unix domain socket + RemoteOrbitDbAdapter).
Residual #2 of the §D `manual-test-full-recovery.sh` ALL GREEN
campaign. The `sphere wallet use <name>` subcommand printed its
confirmation lines

  ✓ Switched to wallet profile: <name>
    Nametag:  <tag>
    L1 Addr:  <alpha1...>

via `console.log` (stdout). The harness captures `sphere balance >
file` snapshots; some snapshot blocks bracket the `wallet use`
invocation outside the redirect (peer2: `sphere wallet use alice ;
sphere balance > file`), others inside a subshell (peer1: `( …
sphere wallet use alice && sphere balance ) > file`). The two
flows yield different captured-stdout content for the same logical
operation, so the resulting peer1-vs-peer2 diff failed assertion
even though both wallets had identical balances.

Fix: route the entire confirmation block (success and `(wallet not
initialized in this profile)` fallback) through `console.error`.
Errors (usage hint, profile-not-found) were already on stderr, so
this brings the success path in line with them. Behaviour for human
operators is unchanged — terminal sessions still see the banner;
only `>` / `|` stdout pipelines are now unaffected by it.

Side-benefit: any future shell tooling that pipes
`sphere wallet use <name> | …` no longer has to filter the banner
out of the consumed stream.

Tests
  * `test/integration/cli-wallet-profile.integration.test.ts` —
    the "`wallet use alice` switches the active profile" assertion
    now expects the banner on `r.stderr` (with a negative match on
    `r.stdout`) per the new contract.
  * Full integration suite: 25 / 25 passed.
  * Full default unit suite: 119 / 119 passed.

Refs sphere-sdk#282
…tderr

fix(cli)(sphere-sdk#282): route `wallet use` confirmation to STDERR
…uildSphereProviders (#31)

Closes the CLI half of sphere-sdk issue #394.

`buildSphereProviders` now imports `createUxfCarPublisher` +
`DEFAULT_IPFS_GATEWAYS` from `@unicitylabs/sphere-sdk/impl/nodejs`
(re-exported on the SDK side as part of #394) and exposes:
- `publishToIpfs` — outgoing UXF CID-delivery callback wired from
  `createUxfCarPublisher(ipfsGateways)`.
- `cidFetchGateways` — recipient-side fetch list so `uxf-cid` bundles
  resolve correctly on arrival.

Both flow through `SphereProvidersBundle` and into `Sphere.init`:
- `src/host/sphere-init.ts` adds explicit pass-through (the call site
  unpacks named fields, not a spread).
- `src/legacy/legacy-cli.ts` is untouched — its `Sphere.init` already
  spreads `...initProviders`, which inherits the new fields
  automatically. Same for the migration call site at line ~2240.

Crucially, this does NOT re-enable the deprecated
`IpfsStorageProvider` (deprecated for wallet token storage; replaced
by Profile). The UXF bundle publisher is a separate concern that
survives the deprecation. We avoid the coupling by importing
`createUxfCarPublisher` directly rather than passing
`tokenSync.ipfs.enabled: true` to `createNodeProviders`.

New config field `SphereProvidersConfig.ipfsGateways` lets callers
override the gateway list (defaults to `DEFAULT_IPFS_GATEWAYS` which
honors the `SPHERE_IPFS_GATEWAY` env override). Pass an empty array
to disable the publisher entirely (sends > RELAY_SAFE_CAP_BYTES
will then fail at the SDK's `INLINE_CAR_TOO_LARGE` pre-flight).

Verified end-to-end via the round-trip soak at
sphere-sdk:manual-test-roundtrip-391.sh with STRICT_CID_DELIVERY=1:
4-hop A→B→A→B→A succeeded, balance reconciliation passed
(alice -0.5 UCT, bob +0.5 UCT), no DUPLICATE_BUNDLE_MEMBERSHIP, no
INLINE_CAR_TOO_LARGE. With sphere-sdk #394b's 512 KiB cap the
realistic 121 KB bundle stays inline so CID delivery isn't actually
exercised here; for >512 KiB bundles the publisher path is the same
mechanism, end-to-end testing pending soak coverage for that range.

Pairs with sphere-sdk PR (branch feat/issue-394-cid-delivery-wiring).

Co-authored-by: Vladimir Rogojin <vrogojin@blockyinnovations.com>
The previous pin (02cb4550, sphere-sdk integration/all-fixes after
PR #225) predates sphere-sdk PR #394 ("automated CID delivery"), so
CI typecheck failed with:

  src/legacy/legacy-cli.ts: Property 'deliverInvoice' does not
    exist on type 'AccountingModule'.
  src/shared/sphere-providers.ts: Module has no exported member
    'createUxfCarPublisher' / 'DEFAULT_IPFS_GATEWAYS' /
    'PublishToIpfsCallback'.

These four symbols are all present on the current sphere-sdk main
tip (3f3dadf, "merge: PR #395 #394 automated CID delivery re-enabled
+ 512 KiB inline cap + demo playbook"). Bumping the pin unblocks
typecheck.

Bumping to main also avoids the recurrence of "unable to read tree"
that hit the earlier 86468103a pin: integration tips get rebased
away when sub-PRs are squash-merged into main, but commits on main
itself stay reachable.

Verified: `npx tsc --noEmit` clean against sphere-sdk @ 3f3dadf.
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