Skip to content

Phase 6: v1 → v2 SDK migration (uxf-v2 fork review artifact)#640

Draft
vrogojin wants to merge 1260 commits into
mainfrom
@vrogojin/uxf-v2
Draft

Phase 6: v1 → v2 SDK migration (uxf-v2 fork review artifact)#640
vrogojin wants to merge 1260 commits into
mainfrom
@vrogojin/uxf-v2

Conversation

@vrogojin

@vrogojin vrogojin commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Overview

Draft review artifact for the Phase 6 v1 → v2 SDK migration on the @vrogojin/uxf-v2 fork branch. Not intended to merge — the fork intentionally diverges from mainstream main. Opened purely to give reviewers a browsable diff.


Final migration state (as of HEAD 8fa9f1ed)

Metric Before After
Typecheck errors under v2 SDK 281 0
STSDK_CORE_BURNDOWN entries 17 0
Test suite 784 fail / 7,201 pass 0 fail / 7,205 pass / 6 skip
core/Sphere.ts 7,736 LoC 3,863 LoC (−50%)
modules/payments/PaymentsModule.ts 9,538 1,772 (−82%)
modules/accounting/AccountingModule.ts 7,747 1,732 (−78%)
oracle/UnicityAggregatorProvider.ts 1,126 623 (−45%)

~19k LoC v1 code quarantined; ~4.1k LoC of slim v2 modules replace it; ~5.7k LoC of Sphere concerns extracted into 9 sibling files.


Migration waves

Core migration (v1 → v2 SDK)

  • 6-P2-1: v2 SDK bump + un-exclude token-engine/
  • 6-P2-2/3: quarantine helpers, rewrite capabilities on ITokenEngine
  • 6-P2-4a/b/c/d/e: shrink oracle, slim rebuild Payments + Accounting, retire ProxyAddress, wire sphere.tokenEngine

Test migration + validation

  • 6-P2-5: quarantine 109 v1-shaped tests
  • 6-P2-6: 4 real-world soak scripts against testnet2
  • 6-P2-6b/c/d: 4 v2-facing bug fixes surfaced by soaks
  • 6-P2-7: 88 v2-native test cases restore critical coverage

Sphere.ts slim series

  • 6-P2-8/8b/8c/8d/8e: 9 sibling extractions from Sphere.ts (7,944 → 3,863 LoC, −51%)

Follow-up waves


Runtime validation

All 5 soak scripts PASS end-to-end against real gateway.testnet2.unicity.network:

Script What it exercises
soak-mint-throughput 10 sequential mints
soak-two-wallet-send Wallet A → B via sphere.payments.send, verified via transfer:incoming
soak-multi-coin-bundle UCT + USDU sent in one bundle
soak-invoice-lifecycle Create → import → pay → auto-close via sphere.accounting.*
soak-invoice-return Partial pay → cancelInvoice({autoReturn:true}) → refund lands + balance restored

Both facade smoke scripts also pass:

  • scripts/smoke-testnet2-mint.tscreateSphereTokenEngine direct
  • scripts/smoke-testnet2-facade.tsSphere.init → sphere.tokenEngine.mint

Anti-corruption layer

token-engine/ is SHA-pinned from mainstream main@ce758f6b. The v2 adapter SphereTokenEngine + createSphereTokenEngine factory are exposed through token-engine/index.ts; all v2 SDK access outside token-engine/ routes through ITokenEngine (enforced by ESLint no-restricted-imports).


Wave 6-P2-12 note: sender.directAddress wire hint

Wave 6-P2-9's soak-invoice-return.ts uncovered two intertwined issues:

  1. Sender binding race — receiver's transport.resolveTransportPubkeyInfo(senderTransportPubkey) at receive time races the sender's Nostr identity binding publication. Result: senderAddress unresolved in RECEIVED history → senderBalances[] empty → refund path can't route.
  2. Wrong API in original soakreturnAllInvoicePayments on a CLOSED invoice returns 0 BY DESIGN (CLOSED freeze zeros per-sender balances via balance-computer's surplus-distribution algorithm). That API is for OVERPAID invoices only.

Wave 6-P2-12 fixes both:

  • UxfTransferPayloadBase.sender.directAddress field added — UNAUTHENTICATED on wire (same as nametag), documented as a HINT the receiver uses to populate senderAddress immediately. Sender populates in both PaymentsModule.deliverTokens and extensions/uxf/pipeline/module-glue/publish-uxf-bundle.ts. Receiver prefers the hint over the transport binding lookup (which stays as fallback for older peers).
  • soak-invoice-return.ts restructured to test the CORRECT refund flow: partial payment → cancelInvoice({autoReturn: true}) → verify refund lands + payer's balance restored.

Security note: the hint only affects the caller's own money (refund destination), so a malicious sender only harms themselves.


Known follow-ups (all optional; none blocking)

  • PaymentsModule.importTokens is a documented no-op — only called by legacy TXF migration path (extensions/uxf/profile/import-from-legacy.ts); fresh v2 wallets never invoke it. Wave 6-P2-10a locks in the current behavior with a regression guard.
  • Further Sphere.ts slim would require extracting lifecycle glue (init/create/load/import static factories) — those ARE the facade's remaining reason to exist. Natural stopping point already reached.
  • Additional quarantined test rewrites — 109 v1-shaped files at tests/legacy-v1/; 88 highest-value cases restored via wave 6-P2-7 + 6-P2-10a/b, remainder is stretch coverage.

vrogojin and others added 30 commits May 30, 2026 20:31
…test-coverage

test(payments/transfer): close V6-RECOVER test-coverage gap + nightly soak CI (Audit #333 follow-up)
…aths

Created in response to #363 — the post-mortem of #360. The lesson from
#360: do not propose perf fixes from static analysis. Measure first,
fix second. This commit adds the missing measurement step that #360
should have started with.

* core/perf-counters.ts (new) — minimal counter / timer module.
  - `incr(name)`, `observeMs(name, ms)`, `time(name, fn)` API.
  - Gated by SPHERE_PERF=1 env var (or localStorage.SPHERE_PERF=1
    in the browser). When off, every entry point is a single boolean
    check + early return.
  - When on, dumps a snapshot of all counters every
    SPHERE_PERF_DUMP_MS (default 5000ms) via `logger.info('perf', …)`.
  - 15 unit tests covering the on/off semantics, clamp behaviour,
    and throw-still-records contract.

* Wired at the hot paths the #360 findings claimed but never measured:
  - OrbitDbAdapter.putEntry — wall-clock + count, split bundle-ref
    keys from everything else. Directly answers the maintainer's
    batching hypothesis (is every CID write a full round-trip?).
  - OrbitDbAdapter.onReplication handler — fires/sec + callback ms.
    Tests Finding #1's "every local write fires a full re-sync"
    claim.
  - BundleIndex.listBundles — call rate + db.all() walk ms. Tests
    Finding #3's "5× per flush" claim.
  - ipfs-client.fetchFromIpfs — split local-Helia hits from HTTP
    gateway fetches so the two cost models are separable.
  - ipfs-client.fetchCarFromIpfs — per-CAR walk wall-clock + blocks
    + bytes.
  - ipfs-client.pinCarBlocksToIpfs — pin wall-clock + blocks + bytes.
  - flush-scheduler.flushToIpfs — call rate + total ms (via
    try/finally body extraction to avoid touching every early-return).
  - UxfPackage.computeVerifiedProofs — calls + per-verifier RPC ms +
    ok/threw split. Tests Finding #2's O(B²·P) claim.

* No behaviour change. All 3352 tests in tests/unit/profile/,
  tests/unit/uxf/, tests/unit/core/ pass. Type-check + lint clean.

Usage:

    SPHERE_PERF=1 SPHERE_DEBUG=perf=info bash .tmp/soak-264.sh

Every 5s the daemon emits a counter snapshot. After the soak, post-
process the log to build a flame-shaped table of what actually
dominates the wall-clock.

This is NOT a fix. It is the instrumentation that should have
preceded any #360 work. See #363 for the post-mortem.
… paths; add SPHERE_SKIP_RULE4 prototype

Follow-up to c5962f0 (#363). The first round of counters surfaced a
~213 s gap between `uxf.computeVerifiedProofs` outer-wall and its
inner verify-call total. This commit extends the measurement to (a)
every aggregator HTTP entry point, (b) the §D.4 cross-device
recovery path, and (c) the apply-snapshot blob-fetch / parse /
dispatch sub-steps. It also adds an env-gated bypass of the O(N²)
Rule-4 pairwise UXF verification loop in `load()` to A/B-measure
its real wall-clock contribution.

Files + counters added:

* oracle/UnicityAggregatorProvider.ts
  - `aggregator.rpc.<method>{,.http_error,.rpc_error}` wraps every
    JSON-RPC call by method name (catches every getProof, isSpent,
    submit, mint, validateToken etc. that goes through rpcCall).
  - Per-public-method outer counters via `aggregator.<method>` for
    submitCommitment, submitMintCommitment, getProof, waitForProof,
    waitForProofSdk, validateToken, verifyInclusionProof, isSpent,
    getTokenState, getCurrentRound, mint. Each wraps the existing
    body via a private `_<method>Impl` so the SDK-client path is
    captured separately from rpcCall HTTP.

* profile/profile-token-storage-provider.ts
  - `profile.load.{calls,totalMs}` on the public `load()` entry.
  - `profile.load.rule4Pairwise{Invocations,Ms,Threw}` measures the
    O(N²) `computeVerifiedProofs` loop independently from the rest
    of load (lines 1521-1533).
  - `profile.load.rule4Skipped` increments when SPHERE_SKIP_RULE4=1.
  - SPHERE_SKIP_RULE4=1 env flag bypasses the pairwise loop entirely
    (mirrors the existing graceful-degradation fallback the codebase
    already takes on verifier failure). For A/B measurement only;
    the design-correct replacement is a snapshot-source detection
    gate, tracked in the follow-up issue.
  - `profile.applySnapshot{,.fired}` on applySnapshotIfWired.

* profile/aggregator-pointer/ProfilePointerLayer.ts
  - `pointerLayer.recoverLatest`, `.discoverLatestVersion`,
    `.probe`, `.classify` — recovery-time pointer-scan + per-version
    probe entry points.

* profile/aggregator-pointer/discover-algorithm.ts
  - `pointerLayer.findLatestValidVersion` — the actual walk-and-
    classify algorithm.

* profile/profile-snapshot-cache.ts
  - `snapshotCache.readSnapshot`, `.writeSnapshot`.

* profile/factory.ts
  - `applySnapshotCb.{fetchRootMs,parseSnapshotMs,dispatchMs,totalMs}`
    decomposes the snapshot-apply callback registered by
    setApplySnapshotCallback. Validates the "scan → fetch-blob →
    rebuild" design: fetchRoot avg 6 ms, parse 5.5 ms, dispatch
    385 ms — total ~400 ms per apply.

All counters gated by SPHERE_PERF=1. Zero overhead when off (one
boolean check + early return).

## A/B result (single iteration of manual-test-full-recovery.sh)

Baseline (Rule-4 enabled, full instrumentation):
- Total wall: 622 s
- §D.4 recovery: 229 s
- `profile.load.rule4PairwiseMs`: 222.5 s (35.8% of soak)
- `profile.load.totalMs`: 239 s (38.4% of soak)

Treatment (SPHERE_SKIP_RULE4=1):
- Total wall: 413 s  (-209 s / -33.6%)
- §D.4 recovery: 123 s  (-106 s / -46%)
- `profile.load.rule4PairwiseMs`: 0 (bypassed)
- `profile.load.totalMs`: 27.7 s (-88%)

Two hypotheses rejected by the new counters:
- Aggregator HTTP is the bottleneck: total 1.97% of soak (11.5 s of
  622 s). `aggregator.verifyInclusionProof` count exactly matches
  `uxf.computeVerifiedProofs.verifyMs` count (565 171 in baseline),
  so the inner verification is local crypto on cached proofs, not a
  round-trip to L3.
- `OrbitDbAdapter.putEntry` bundle-key batching wins: bundle puts
  fire 0.13/sec, total 1.1% of soak — too infrequent to matter.

Hypothesis VALIDATED: "convert UXF → TXF in memory, verify at TXF
level not UXF level" (the maintainer's design intuition). The
Rule-4 pairwise loop is 35.8% of the soak; bypassing it for
snapshot-sourced loads recovers the 213 s gap directly.

## Open work tracked in the follow-up issue

- pointerLayer.recoverLatest fires 108× per single recovery (design
  expects 1)
- profile.applySnapshot fires 23× during `sphere clear` (architectural
  smell)
- 47 s of §D.4 still uninstrumented (likely token deserialization +
  OrbitDB dispatch internals)
- Convert SPHERE_SKIP_RULE4 prototype to design-correct snapshot-
  source gate
- Kubo Swarm.ConnMgr cap A/B (operational, no SDK change)
- Fix §D.5 snapshot-diff contamination from SPHERE_PERF=1 multi-line
  dumps

Full report at /tmp/soak-metrics/issue-363/REPORT.md (497 lines)
with all raw soak artifacts preserved alongside.
…in snapshot diff

The §D.5 assertions in manual-test-full-recovery.sh compare normalized
snapshots before/after the IPFS-only recovery. When `SPHERE_PERF=1` is
set (e.g. while collecting #363 instrumentation data), the perf-counter
auto-dump fires on a setInterval and `console.log`s a multi-line block:

  [perf] [perf-counters] snapshot: {
    'profile.applySnapshot': { count: 42, totalMs: 123.4, ... },
    'aggregator.fetch':      { count: 7,  totalMs: 12.3,  ... }
  }

The previous per-line sed filter stripped the timestamped *opening*
line (via the ISO sed rule) but left the continuation rows and the
bare closing `}` at column 0 untouched, producing spurious diffs
between snapshots that should otherwise compare equal. Result: every
SPHERE_PERF=1 soak failed at §D.5 even when the wallet code was
correct.

Fix (issue-364 option a): prepend an awk state-machine pass to
normalize_snapshot() that detects the opening `[perf-counters]
snapshot:` substring (in both timestamped and untimestamped forms),
classifies the line as single-line (ends with `}`) or multi-line
(ends with `{`), and drops every line in the block through the
matching column-0 `}` inclusive. Runs BEFORE the existing ISO sed
rule so the timestamped opening line is detected before it's eaten.

The awk pass relies on a structural invariant of Node's util.inspect:
the top-level `}` is always at column 0 regardless of inner brace
nesting. CLI commands captured by the harness (`balance`, `tokens`,
`invoice list`, `status`, `invoice status`) all emit plain text — none
emit a bare `}` at column 0 — so the state machine cannot false-
positive on legitimate output.

Also adds a self-test stanza guarded by `RUN_NORMALIZE_TESTS=1` that
runs 7 normalize_snapshot() cases (multi-line, single-line,
timestamped opening, multiple consecutive blocks, legacy strips
intact, defensive `}` inside counter name, identity passthrough).
The gate bypasses the teardown trap so no workspace is created.

Verification:
  - `bash -n` clean
  - `shellcheck` clean (no new warnings; pre-existing SC2034 on
    `banner()`'s unused `now` local is unchanged)
  - `RUN_NORMALIZE_TESTS=1 bash manual-test-full-recovery.sh` — all
    7 self-tests pass

Out of scope for this commit: option (b) — routing the perf dump to
stderr in core/perf-counters.ts. The awk filter is sufficient and
keeps the runtime behaviour unchanged, which matters for operators
who want the perf snapshots interleaved on stdout for grep-friendly
post-mortems.

Refs: GH issue #364 Item #6
…clear

Issue #363 baseline soak recorded 23 `profile.applySnapshot` invocations
out of 80 total during the 41 s §D.3 phase (`sphere clear on all wallets`).
A clear command applying snapshots is structurally wrong — clear is
supposed to delete state, not seed it — and the IPFS round-trips driven
by those applies were a measurable contributor to the 41 s wall.

## Trace

`ProfileTokenStorageProvider.clear()` awaits `db.all()` + per-key
`db.del()` on a connected provider. While the await chain is in flight,
the periodic pointer-poll (`LifecycleManager.runPointerPollOnce`) can
fire: `pointer.recoverLatest()` returns a snapshot CID, the closure
calls `host.applySnapshotIfWired(cid)`, the wired callback fetches the
CAR from IPFS, parses the lean snapshot, and dispatches per-writer
JOIN against state that is about to be wiped by the surrounding
`clear()`.

The existing `isShuttingDown`/`hasShutdown` gate in
`_applySnapshotIfWiredImpl` only covers the destroy-path
shutdown sequence — `clear()` on a live (non-shutdown) provider has no
gate. The 23 fires were precisely these clear-time races.

## Fix (strategy a)

- Add a private `isClearing` latch on `ProfileTokenStorageProvider`,
  set at the top of `clear()`, unset in `finally`.
- Extend `_applySnapshotIfWiredImpl` to check the latch first; when
  set, bump `profile.applySnapshot.suppressedDuringClear` and return
  null without invoking the callback.
- The pointer-poll loop, its caller (`recoverFromAggregatorPointerBestEffort`),
  and any host wire-up code all observe the suppression transparently
  via the existing `null` return contract — no signature changes.

Strategy (b) (stop the periodic poll first inside `clear()`) was
considered but adds cross-module coupling: the poll timer is owned by
`LifecycleManager` and has no symmetric pause API. Strategy (a)
guarantees suppression at the dispatch boundary regardless of which
caller raced, with a single-line check on the hot path.

## Tests

`tests/unit/profile/profile-token-storage-clear-suppress-apply-snapshot-364.test.ts`
(6 cases):
- Mid-`clear()` race: spy on `db.all` to fire `applySnapshotIfWired`
  before returning — asserts the spy returns null and the wired
  callback is never invoked.
- Counter assertion: two mid-clear races bump
  `profile.applySnapshot.suppressedDuringClear` to 2 with
  `profile.applySnapshot.fired` staying at 0.
- Post-`clear()` normal operation: a fresh `applySnapshotIfWired`
  after `clear()` returns proceeds to the callback.
- Throw-path latch release: `db.all()` rejection still releases the
  latch in `finally`.
- Latch precedence: directly-set `isClearing` suppresses even when
  shutdown gates would otherwise allow dispatch.
- Uninitialized clear: short-circuit returns false without setting
  the latch.

## Validation

- `npm run typecheck` — clean.
- `npm run lint` — no new warnings or errors on the modified files.
- `npx vitest run tests/unit/profile/` — 2287 passed (143 files).
- `npx vitest run tests/unit/core/Sphere.clear.test.ts` — 15 passed.
…ispatch, UXF/TXF parse, Helia blockstore

Item #3 of issue #364: instrument the remaining 47s of §D.4 wall-time
in the SKIP_RULE4 soak. The existing perf-counters cover ~62 % of
§D.4; this commit adds opt-in counters for the dominant uninstrumented
paths (snapshot dispatcher, UXF/TXF parse, Helia blockstore writes/pins,
OrbitDB read/write surface) so the next soak attributes >= 85 %.

All counters are gated by SPHERE_PERF=1 via the existing
core/perf-counters.ts helpers (incr / observeMs). Zero overhead when
off — verified by smoke test.

New counters added (29 total):

profile/profile-snapshot-dispatcher.ts (runProfileSnapshotJoin):
- profile.snapshotJoin.calls
- profile.snapshotJoin.totalMs
- profile.snapshotJoin.decodeMs
- profile.snapshotJoin.decodedEntries
- profile.snapshotJoin.perWriterDispatchCalls
- profile.snapshotJoin.perWriterDispatchMs
- profile.snapshotJoin.bundleIndexJoinCalls
- profile.snapshotJoin.bundleIndexJoinMs

profile/profile-snapshot-merge.ts (runJoinSnapshot per-entry):
- profile.snapshotJoin.perKeyApplyCalls
- profile.snapshotJoin.perKeyApplyMs

serialization/txf-serializer.ts:
- serialization.txfSerializer.txfToToken.calls / .totalMs
- serialization.txfSerializer.parseStorageData.calls / .totalMs

uxf/UxfPackage.ts:
- uxf.ingest.calls / .totalMs
- uxf.ingestAll.calls / .tokens / .totalMs / .perTokenDeconstructMs
- uxf.assemble.calls / .totalMs

profile/helia-blockstore-shim.ts (get/put/delete):
- helia.blockstore.get.calls / .cacheHit / .cacheHitMs / .inflightHit
  / .inflightHitMs / .miss / .missMs / .bytes
- helia.blockstore.put.calls / .totalMs
- helia.blockstore.delete.calls / .totalMs

profile/helia-blockstore-pin-shim.ts (helia.pins.add + putMany):
- helia.pins.add.calls / .cached / .successMs / .alreadyPinned
  / .alreadyPinnedMs / .failed / .failedMs
- helia.blockstore.putMany.calls / .items / .totalMs

profile/orbitdb-adapter.ts (non-putEntry surface):
- orbitdb.put.bundle / .other / .error
- orbitdb.get.hitMs / .missMs / .errorMs / .error
- orbitdb.del.totalMs / .error
- orbitdb.getEntry.bundleHitMs / .hitMs / .bundleMissMs / .missMs
  / .errorMs / .error
- orbitdb.all.calls / .dbAllMs / .entries / .totalMs / .error

Smoke test: confirmed counters fire under SPHERE_PERF=1, snapshot()
returns 0 keys when SPHERE_PERF is unset.
Replace the prior breadcrumb-based design (closed as
fix/issue-364-item4-rule4-design-gate) with per-bundle provenance
annotated at the snapshot dispatcher's writeRemote site. The gate's
decision is now keyed on the loaded bundle set itself, not on
provider-global mutable state — eliminating the §D.1 retry-storm
staleness where periodic-poll applySnapshots re-armed the breadcrumb
between disarm sites and the next load() incorrectly skipped Rule-4
enrichment.

## Design

A new optional `sourcedFromSnapshotPointerCid` field on UxfBundleRef
carries the IPFS CID of the lean-snapshot blob that placed the bundle
ref into local OrbitDB. It is set ONLY when the snapshot dispatcher's
`writeRemote` callback inside `BundleIndex.joinSnapshot` is the writer
that landed the ref. Locally-published refs (`BundleIndex.addBundle`)
and replication-arrived refs (OrbitDB pubsub) leave the field
unset, as do legacy refs persisted pre-#367.

At Rule-4 gate time in `_loadImpl`:

  skipRule4Snapshot =
    loadedBundles.length >= 2 &&
    loadedBundles[0].sourcedFromSnapshotPointerCid !== null &&
    loadedBundles.every(b =>
      b.sourcedFromSnapshotPointerCid === loadedBundles[0].sourcedFromSnapshotPointerCid)

Any non-snapshot bundle in the active set, or a mix of source
snapshots, forces Rule-4 to run. The gate is robust against periodic-
poll firing arbitrarily often, because the signal lives in the loaded
data, not in any global provider state.

Why this also fixes the §D.1 storm (the §D.1 V6-RECOVER probe loads()
ran during periodic-polls firing applySnapshot on bob's wallet, which
re-armed the original breadcrumb between disarm sites — the next
load() then incorrectly skipped Rule-4 → finalize-side enrichment
loss → 30s cooldown × 3 retries × N stranded tokens, §D.1 wall
ballooned 98s → 1275s):

  - Periodic-poll's applySnapshot may land 0 new bundles (the wallet
    already has the bundles the snapshot encodes). The pre-existing
    local bundles carry no `sourcedFromSnapshotPointerCid` — they
    were written by `addBundle`. The gate sees mixed/missing
    provenance and KEEPS Rule-4 on. Correct.

  - In §D.4 fresh recovery: the wallet starts with empty OrbitDB,
    applySnapshot lands N bundles via writeRemote — every landed ref
    carries the same source CID. The gate fires correctly and skips
    Rule-4 — the soak speedup that motivated the original Item #4 is
    preserved.

## Plumbing

  - `profile/types.ts` — new optional field on UxfBundleRef.

  - `profile/profile-token-storage/bundle-index.ts` — `BundleIndex`
    gains `currentSnapshotApplyCid` + a `setCurrentSnapshotApplyCid`
    setter. `joinSnapshot`'s `writeRemote` decodes the envelope,
    decrypts, parses the ref, re-stamps with the source CID, re-
    encrypts, re-envelopes. Any failure in the annotation pass falls
    back to the verbatim write — the JOIN still lands and the gate
    degrades safely (Rule-4 runs).

  - `profile/profile-snapshot-dispatcher.ts` — `ProfileSnapshotJoinDeps`
    gains optional `sourcePointerCid: string`. Before invoking the
    BundleIndex JOIN, the dispatcher duck-types `setCurrentSnapshotApplyCid`
    on the bundle-index handle and arms it; clears in `finally` so a
    stale CID can never leak.

  - `profile/factory.ts` — `runProfileSnapshotApply` and
    `dispatchParsedSnapshot` accept and forward `sourcePointerCid`.
    Both apply paths (the periodic-poll `setApplySnapshotCallback`
    and the pointer-wired `setSnapshotApplier`) pass through the
    pointer CID they decoded at fetch time.

  - `profile/pointer-wiring.ts` — `applySnapshot` callback signature
    widens to `(snapshot, sourcePointerCid?)`. The `fetchAndJoin`
    callback already decodes the remote CID for the dag/get fetch; we
    now also pass it into the applier.

  - `profile/profile-storage-provider.ts` — `snapshotApplier` and
    `setSnapshotApplier` widen to accept the optional source CID.

  - `profile/profile-token-storage-provider.ts` — `loadedBundles`
    entries now carry `sourcedFromSnapshotPointerCid` (read from the
    bundle ref returned by `listActiveBundles`). The Rule-4 gate
    computes `skipRule4Snapshot` per the design above and ORs with
    the existing `SPHERE_SKIP_RULE4` env-gated path. New perf counter
    `profile.load.rule4SkippedSnapshot` fires when the gate skips.

## Tests

`tests/unit/profile/load-rule4-snapshot-gate.test.ts` (new) — 5
cases pinning the gate's decision matrix at the `load()` layer:

  - all bundles share one source CID → skip;
  - all bundles have no provenance (locally-published) → no skip;
  - mixed snapshot-sourced + local → no skip;
  - bundles span two distinct source CIDs → no skip;
  - single-bundle snapshot-sourced load — the `>= 2` structural guard
    short-circuits the pairwise loop independently of the gate (gate
    is moot).

The existing `tests/unit/profile/load-rule4-wiring.test.ts` still
passes (4 cases) — its scenarios use bundles without the new field,
so the gate stays off and Rule-4 fires exactly as before.

Validation:
  - npm run typecheck — clean
  - npm run lint — clean (full repo, 0 errors / 0 warnings)
  - npm run test:run — 8805/8820 unit tests pass; 13 skipped; 2 pre-
    existing flakes in tests/integration/nametag-normalization.test.ts
    (parallel-execution TEST_DIR race documented in
    project_core_sphere_test_flake.md — passes 7/7 in isolation,
    unrelated to this change).
…liminate 108×/recovery over-invocation

Issue #363 §D.4 (full mnemonic recovery, 123 s wall) observed 108
`pointerLayer.recoverLatest()` calls — 22 % of §D.4 wall — driven by
`LifecycleManager.awaitAggregatorPointerReadBack` polling every 500 ms
to wait for the aggregator to catch up to a just-published snapshot
CID. Per the L4 model the design rate is one recoverLatest per
recovery window; the read-back loop was paying the full aggregator
walkback cost on every 500 ms tick.

Approach chosen: (a) cache inside `ProfilePointerLayer`. There are
six production call sites (lifecycle-manager read-back loops, cold-
start recovery, periodic poll, WALKBACK_FLOOR reconcile, pointer-win
broadcast handler), so a per-caller short-circuit would be error-
prone. The cache sits INSIDE the existing `time()` wrapper so cache-
hit and cache-miss latencies still count toward the aggregate
`pointerLayer.recoverLatest` timer — the call count semantic is
preserved while totalMs/avgMs drop dramatically.

Mechanism:

  - `#cachedHead: { result, expiresAt } | null` with a 10 s default
    TTL (configurable via the new `recoverLatestCacheTtlMs` init
    option). Set `0` to disable.
  - `#inFlightRecover: Promise<…> | null` coalesces concurrent calls
    onto a single aggregator round-trip; the deduped waiters do NOT
    share the upstream call's abort signal (their own aborts are
    honored on entry).
  - Cache cleared on `shutdown()` so a re-init reads a fresh
    aggregator view; the in-flight slot is cleared by the operation's
    own finally arm during the existing `#inFlight` drain.

Counters added (visible when `SPHERE_PERF=1`):
  - `pointerLayer.recoverLatest.cacheHit`     — within-TTL hit
  - `pointerLayer.recoverLatest.cacheMiss`    — TTL > 0, no cached
  - `pointerLayer.recoverLatest.cacheStale`   — TTL expired
  - `pointerLayer.recoverLatest.inFlightDedup` — concurrent attach

Projected impact on §D.4 read-back loop (60 s deadline, 500 ms
poll, 250 ms per call):

  Before: 80 aggregator round-trips
  After (TTL=10 s): 6 round-trips + 111 sub-ms cache hits
  Reduction: ~13×

The 10 s TTL is well inside the typical 30 s shutdown-durability
deadline and well outside the 30–90 s periodic-poll cadence, so:
  - new pointer versions are still observed within 10 s of the
    aggregator surfacing them (acceptable for the read-back loop,
    whose existing budget is tens of seconds);
  - the periodic poll's recoverLatest always misses the cache (TTL
    expired between polls), so no behavioral change at that layer.

Tests (`tests/integration/pointer/recover-latest-cache.test.ts`):
  - cold call → cacheMiss, warm call within TTL → cacheHit + no
    aggregator round-trip;
  - 5 concurrent calls → 1 round-trip + 4 inFlightDedup;
  - TTL expiry → cacheStale + cacheMiss + fresh round-trip;
  - TTL=0 disables the cache (pre-#364 behavior);
  - shutdown clears the cache and refuses further calls;
  - pre-aborted signal throws AbortError before reading the cache.

All 290 existing pointer + lifecycle-manager tests stay green.
… recoverLatest

Re-incarnates the closed Item #1 (commit e3959e3, cherry-picked into the
preceding commit) with the missing publish-time invalidation hook that
caused PR #365 to drop it. The §D.4 wall-clock degradation observed in
PR #365's A/B (243 s → 467 s when the cache was enabled) traced directly
to the read-back loop at `LifecycleManager.awaitAggregatorPointerReadBack`
spending the full 10 s TTL serving a pre-publish CID after the local
publish committed a new version.

## Mechanism

Inside `ProfilePointerLayer.#publishInner`, after `reconcileAndPublish`
returns without throwing (the publish committed a new pointer version
that downstream readers must observe), the cache is cleared:

  if (this.#cachedRecover !== null) {
    incr('pointerLayer.recoverLatest.cacheInvalidatedOnPublish');
    this.#cachedRecover = null;
  }

Properties:

- **Every successful publish path invalidates** — including the fast
  path (`result.attemptsUsed === 0`) that skips Step B (#263). The hook
  runs after the reconcile returns and is independent of which branch
  the reconcile took.
- **Conditional on a populated cache** — the counter only fires when
  there's actually something to clear, so the signal is meaningful
  (a publish on a cold cache is a no-op).
- **In-flight slot is intentionally not touched** — a recoverLatest
  already awaiting `#inFlightRecover` will still receive that round-
  trip's result (the in-flight slot is owned by the operation already
  in motion). A NEW recoverLatest after the publish starts fresh.
- **No abort propagation** — the cache invalidation is local provider
  state; it does not interact with the in-flight operation's abort
  signal.

## Tests

`tests/integration/pointer/recover-latest-cache.test.ts` gains three
new cases at the bottom of the existing describe block:

1. **publish() invalidates the cache: subsequent recoverLatest is a
   fresh round-trip.** Seeds v=1, warms the cache, asserts cache-hit
   semantics, publishes v=2, asserts the `cacheInvalidatedOnPublish`
   counter fired, then asserts the next recoverLatest is a fresh
   aggregator round-trip surfacing v=2.

2. **publish() with no cached value is a no-op for the invalidation
   counter.** A publish on a cold cache must not bump the counter.

3. **publish() invalidation does not abort an in-flight recoverLatest
   started before publish.** Pins the in-flight semantics: a concurrent
   recoverLatest+publish pair resolves cleanly, and a fresh recoverLatest
   after both settle observes v=2.

All 9 cache tests pass (6 original + 3 new). 587 + 9 skipped pointer
tests across `tests/unit/profile/pointer/` and `tests/integration/pointer/`
pass.

## Counters

New: `pointerLayer.recoverLatest.cacheInvalidatedOnPublish` (bumped per
successful publish that found a populated cache). Sits alongside the
existing cacheHit / cacheMiss / cacheStale / inFlightDedup counters
from the Item #1 cherry-pick.

## Refs

- Closes #366
- Re-incarnates `fix/issue-364-item1-pointer-recover-cache` (commit
  e3959e3) cherry-picked in the preceding commit.
- Original A/B data: `/tmp/soak-metrics/issue-363/REPORT.md`
…ng Sphere.clear

Closes the multi-wallet gap left by Item #2 (PR #365). The per-instance
`isClearing` latch correctly suppresses `applySnapshotIfWired` for THE
provider whose `clear()` is in flight, but a sibling provider's
periodic pointer-poll keeps firing — its own `isClearing` is false.
The §D.3 soak baseline shows 23 `profile.applySnapshot.fired` events
across the clear-all-wallets phase; PR #365 reduces this only for the
actively-cleared wallet.

This commit adds a process-wide reference-counted gate that
`Sphere.clear()` brackets around its destructive body. While the
bracket is held, EVERY provider in this process suppresses
`applySnapshot` and bumps the new counter
`profile.applySnapshot.suppressedDuringGlobalClear`.

## Design — separate leaf module

The gate state lives in `profile/global-clear-gate.ts` (no exported
class — three top-level functions over a module-private `depth`
counter). Both `core/Sphere.ts` and
`profile/profile-token-storage-provider.ts` import this leaf. Putting
the state as a class-static on `ProfileTokenStorageProvider` would
force `core/Sphere.ts` to import the provider class directly; the
leaf module keeps the dependency direction clean.

## Semantics

  - **Reference counted.** Nesting composes — an orchestrator may
    bracket a multi-wallet batch in a higher-level pair while each
    `Sphere.clear()` body also brackets itself.
  - **No-op-safe at depth 0.** A stray double-end is harmless;
    `endGlobalClear()` only decrements when depth > 0. Prevents
    silent gate-stuck-closed failures from a buggy orchestrator.
  - **Process-scoped.** One counter per JS realm. Cross-process
    coordination is out of scope; filesystem / IndexedDB locks
    already serialize cross-process wallet wipes.

## Files

- `profile/global-clear-gate.ts` (new) — `beginGlobalClear()`,
  `endGlobalClear()`, `isGlobalClearActive()`, plus
  `__resetGlobalClearForTest()` for test setup/teardown.

- `profile/profile-token-storage-provider.ts` —
  `_applySnapshotIfWiredImpl` now checks `isGlobalClearActive()` AFTER
  the per-instance `isClearing` latch (preserving Item #2's counter
  attribution). When the global gate fires, it bumps
  `profile.applySnapshot.suppressedDuringGlobalClear` and returns null
  without invoking the wired callback.

- `core/Sphere.ts` — `Sphere.clear()` wraps its destructive sequence
  (destroy instance + clear L1 vesting + IDB settle + token storage
  clear + fallback clear + KV storage clear) in
  `beginGlobalClear()` / `try` / `finally` / `endGlobalClear()`. The
  bracket is released even on a destructive failure inside the body so
  a partial clear never leaves the gate stuck closed.

## Tests

`tests/unit/profile/global-clear-gate-368.test.ts` (new) — 7 cases:

  1. begin / end track depth; `isGlobalClearActive()` reflects state.
  2. Two begins require two ends to release (nesting composes).
  3. `endGlobalClear()` no-op-safe at depth 0; subsequent begin still
     moves the gate to 1 cleanly.
  4. Held gate suppresses `applySnapshotIfWired` across an UNRELATED
     provider whose `isClearing` is false — the multi-wallet
     scenario.
  5. Counter `profile.applySnapshot.suppressedDuringGlobalClear`
     fires exactly once per gated call (3-of-3 in a tight loop).
  6. Per-instance `isClearing` takes precedence — counters stay
     attributable when both gates would fire.
  7. After the gate releases, `applySnapshotIfWired` proceeds normally.

The Item #2 sibling tests
(`profile-token-storage-clear-suppress-apply-snapshot-364.test.ts`)
remain green (6/6) — the global gate is checked AFTER the per-instance
latch, so existing semantics are preserved.

## Validation

- `npm run typecheck` — clean
- Targeted lint clean on the 4 changed files
- `npx vitest run tests/unit/profile/ tests/unit/core/Sphere.clear.test.ts`
  — 2309 / 0 failed across 145 test files

## Refs

- Closes #368
- Builds on PR #365 (`integration/issue-364-items-2-3-6`) — same base.
- Sibling to PRs #373 (Rule-4 gate, issue #367) and #374
  (recoverLatest cache, issue #366) — also close gaps from PR #365's
  dropped items.
…ULT_PIN_CONCURRENCY

Two of the three improvements from #369. The third (expanding
`DEFAULT_IPFS_GATEWAYS` from a single primary to operator-provided
unicity-ipfs2/3/4/5 fallbacks) is intentionally out of scope here —
that's a deployment / infrastructure decision the SDK can't make
unilaterally; operators wire additional gateways via the
`SPHERE_IPFS_GATEWAY` env override or the `ipfsGateways` config field
the loop already iterates.

## 1. Retry-with-backoff for transient pin failures

Adds `withPinRetry` + `isTransientPinError` helpers around the
existing gateway-iteration loop in `pinToIpfs` and `pinSingleBlock`.
Up to 4 attempts total (1 initial + 3 retries) with 100 / 500 /
2000 ms backoff between them — 2.6 s of accumulated backoff before
exhaustion, well inside any operation's deadline budget.

The classifier distinguishes transient (retry) from permanent
(short-circuit):

  - Transient: `fetch()` throws (network blip, ECONNRESET, ETIMEDOUT,
    EAI_AGAIN, AbortError, TimeoutError), HTTP 5xx (gateway-side
    overload), HTTP 429 (explicit rate-limit signal).
  - Permanent: HTTP 4xx other than 429 (bad request, unauthorized,
    not found, payload too large) — these are deterministic
    client-side failures and never clear on retry.
  - Lenient default: unrecognised error shapes are treated as
    transient. The bounded backoff schedule caps the cost.

The HTTP-status match scans the message for `HTTP NNN` anywhere
(not just the prefix) so a wrapped failure (e.g.
`ProfileError("IPFS pin failed on all gateways: HTTP 400 ...")`)
still classifies on the inner status rather than falling through to
the lenient default. This change makes #369 a no-op when every
gateway returns a real 4xx — the budget is preserved for actual
transient situations.

New perf counters:
  - `ipfs.pin.retry.attempt`   — bumped before each backoff sleep.
  - `ipfs.pin.retry.exhausted` — bumped when all retries fail transient.
  - `ipfs.pin.retry.permanent` — bumped on a permanent short-circuit.

## 2. Lower DEFAULT_PIN_CONCURRENCY 10 → 5

The Unicity kubo container caps at `MAX_PINS_PER_SECOND=100`. With 10
concurrent slots × ~10 pin/s/slot the SDK peaked at ~100 pin/s and
routinely brushed the limit on bursts, producing intermittent
`IPFS dag/put failed on all gateways for <CID>: fetch failed` surface
errors that the soak couldn't reproduce on demand because the
gateway worked ~99% of the time when tested directly.

5 concurrent slots stay comfortably under the cap (~50 pin/s peak)
while still parallelizing meaningfully — the 250-block migration CAR
pays ~5 s instead of ~2.5 s, but with substantially fewer rate-limit-
induced retry storms. Combined with #1, a single ~1% transient block
failure across a 250-block CAR is now mathematically a non-event
(0.99²⁵⁰ ≈ 8% no-failure baseline → with 3 retries, the per-block
success rate becomes 1 - 0.01⁴ ≈ 99.99999999%, and the CAR-level
success rate is essentially indistinguishable from 100%).

## Tests

`tests/unit/profile/ipfs-client-pin-retry-369.test.ts` (new) — 14
cases covering:

  - `isTransientPinError` classifier across the failure-mode matrix
    the pin path actually produces (5xx / 429 / 4xx / fetch errors
    / AbortError / unrecognised shapes).
  - `withPinRetry` retry / backoff / counter semantics: first-call
    success skips retries, transient failures retry until success,
    exhausted retries throw, permanent errors short-circuit, default
    backoff schedule is [100, 500, 2000] ms.
  - End-to-end `pinToIpfs` proves the wrap is in the right place:
    succeeds on third attempt after two transient failures (4 fetch
    calls = 2 failed + 1 success + 1 sidecar submit), exhausts at
    4 attempts × 1 gateway when all-transient, and HTTP 400 short-
    circuits to 1 attempt only.

`tests/unit/profile/ipfs-client-parallel-pin.test.ts` — pre-existing
abort-propagation tests that intentionally trigger a single block
failure are switched from HTTP 500 (now classified transient and
retried) to HTTP 400 (still classified permanent and short-circuits),
preserving the original test intent ("a single block failure aborts
the whole CAR pin") under the new retry-with-backoff wrap. The
6-character source diff is byte-equivalent for `parallelPin`
behaviour; the test docstring is otherwise unchanged.

`tests/unit/profile/lifecycle-manager-pointer-poll.test.ts` — the
"returns a NEW snapshot CID → applier is invoked, re-arms" case
runs under `vi.useFakeTimers()`, then triggers a flush whose
CAR-pin retry hangs on fake-timer setTimeouts. Switched to
`vi.useRealTimers()` for the teardown shutdown so the retries drain
promptly — the test's assertions (applier was invoked with the new
snapshot CID, poll re-armed) all complete BEFORE the shutdown call;
the real-timer switch only affects resource-cleanup discipline. The
test's elapsed wall-clock grows from 15 ms to ~5.5 s due to the
real retry backoffs; this is acceptable for a single integration
case.

## Validation

- `npm run typecheck` — clean
- Targeted lint clean on the 4 changed files
- `npx vitest run` — 8816 passed | 13 skipped | 0 failed across
  539 test files

## Acceptance criteria (per issue)

  - [x] Single transient gateway failure no longer aborts a CAR pin
        — wrapped by `withPinRetry`, 3 retries with backoff.
  - [ ] Soak passing rate ≥ 95% across 10 consecutive runs — soak
        A/B is a separate run after this lands; happy to attach
        numbers in a follow-up comment.
  - [ ] §D.1 wall-clock comparable to baseline (~98 s) without rate-
        limit-induced retry storms — measured in same follow-up.

## Refs

- Closes #369
- Discovered during #364 soak validation; multiple soak runs hit
  `fetch failed` against `unicity-ipfs1.dyndns.org` while the
  gateway tested as reachable (HTTP 200) within seconds before/after.
- Kubo container is healthy (247 peers, 3 MB/s bandwidth).
- Code sites: `profile/ipfs-client.ts:448` (`pinToIpfs`),
  `:691` (`pinCarBlocksToIpfs`).
# Conflicts:
#	profile/profile-snapshot-dispatcher.ts
…ort with capability probe and legacy fallback

Replaces the SDK's per-block IPFS round-trip pattern (`/api/v0/dag/put`
per block, `DEFAULT_PIN_CONCURRENCY=10` parallel HTTP connections)
with single-round-trip CAR-level push and pull via Kubo's
`/api/v0/dag/import` (push) and `/api/v0/dag/export` (pull) endpoints.

Eliminates the throttling-under-burst behaviour that caused several
spurious soak failures during #364 validation (kubo container's
`MAX_PINS_PER_SECOND=100` limiter was being tipped over when multiple
wallets flushed simultaneously, surfacing as
`IPFS dag/put failed on all gateways: fetch failed`).

SDK-side changes only — operator-side gateway config (haproxy ACL +
rate-limit revisit on `unicity-ipfs1.dyndns.org`) is "Part 1" of #370
and lives outside this repo. The capability probe makes the SDK
degrade gracefully on legacy gateways without `/dag/import` and
`/dag/export` exposed; once the operator flips the gateway config,
the SDK auto-detects and uses the fast path.

Public API unchanged. `pinCarBlocksToIpfs` and `fetchCarFromIpfs`
become "probe + dispatch" selectors:

* `pinCarBlocksToIpfs` first probes the gateway. If `/dag/import` is
  exposed, parses the CAR locally for the #236 local-Helia writes
  (every block written before HTTP push), then issues a SINGLE
  multipart POST of the CAR to `/dag/import?pin=true`. Parses the
  NDJSON response permissively (accepts both
  `{"Root":{"Cid":{"/":"..."}}}` and `{"Root":{"Cid":"..."}}` shapes)
  and verifies our expected root is present with no `PinErrorMsg`.
  On any fast-path failure, falls through to the legacy per-block
  `/dag/put` loop with `helia=undefined` so the legacy path does NOT
  duplicate the local-Helia writes already performed.
* `fetchCarFromIpfs` short-circuits to the legacy BFS when local
  Helia already has the root (the BFS is local-Helia-aware via
  `fetchFromIpfs` and walks zero HTTP for fully-cached CARs). Then
  probes. If `/dag/export` is exposed, issues a SINGLE POST and
  parses the returned CAR with end-to-end per-block CID verification
  (`verifyCidMatchesBytes` on every block — preserves the gateway-
  tampering boundary). Reassembles a CARv1 in stream order matching
  the existing BFS output shape. Falls through to legacy BFS on any
  failure (probe miss, mid-call HTTP error, CID-binding violation,
  missing root in the exported CAR).

Capability probe is a tiny no-body POST to
`/api/v0/dag/{import,export}` with a 2-second timeout. Response
classification:
- 4xx (except 404, 405) and 2xx → exposed (Kubo returns 400 for
  empty body on a healthy import endpoint).
- 404, 405, 5xx → not exposed (404 = route absent, 405 = POST
  blocked at proxy, 5xx = upstream unhealthy → safer to skip).
- Network error / timeout → not exposed.
Cached per-process per normalized gateway URL — `_resetGatewayCapabilityCache()`
exported for tests.

Sidecar submit: one fire-and-forget POST per CAR root on the fast
path (issue-#370 explicit "called once per CAR root" guidance);
unchanged per-block on the legacy path.

Tests:
* `tests/unit/profile/ipfs-client-dag-import.test.ts` (14 tests) —
  probe semantics (200/404/405/500/network-error), probe cache,
  permissive NDJSON parsing (modern + bare-string Cid + mixed
  Stats/Root lines), multi-root defense, fallback on missing-root /
  PinErrorMsg / 500, local-Helia write deduplication.
* `tests/unit/profile/ipfs-client-dag-export.test.ts` (7 tests) —
  happy path single-shot, probe-miss fallback, mid-call 500
  fallback, missing-root defense, CID-binding-violation defense,
  byte-equivalence with legacy BFS, probe cache.
* All existing IPFS tests pass unchanged (54 tests across
  `ipfs-client-parallel-pin`, `fetchCarFromIpfs`,
  `ipfs-client-sidecar-submit`, `ipfs-client-cid-verify`,
  `ipfs-client-helia-blockstore-236`).
* Full `tests/unit/profile/` suite green (2302 / 2302 tests).

Perf-counter wiring (`ipfs.dagImport.totalMs`, `ipfs.dagExport.totalMs`)
deferred — `core/perf-counters.ts` is on `chore/perf-instrumentation`
(chained via PR #365) and has not landed on `main` yet. Will be
added as a follow-up once that lands.

Blocks the throttling-mitigation half of #369 (per #370 "Ordering /
blocks" section): once #370 lands, the per-block path is the legacy
fallback only and #369's per-block retries become much less
load-bearing.

Refs: #370, #364, #369, #236, #255, #200
…r live A/B

Adds a sibling to the existing _resetGatewayCapabilityCache test/soak
helper. Lets a soak probe pre-populate the capability cache for a
specific gateway URL, so an A/B comparison can run both the legacy
and fast paths against the SAME live gateway (forced-legacy on side A
by injecting {dagImport: false, dagExport: false}; allowed to probe
naturally on side B).

Used by .tmp/soak-370/ab-compare.mts (not tracked) to produce the
live-gateway A/B that validates Part 1 of #370 end-to-end.

Live A/B result (Side A: forced legacy, Side B: fast path), 250-block
CAR / 51 KB total, 3 iter each, against unicity-ipfs1.dyndns.org with
Part 1 (the nginx ACL change) deployed:

           pin avg      fetch avg    speedup
  A:    1391.3 ms     3763.7 ms     -
  B:      17.3 ms       17.3 ms     pin 80×, fetch 217×

Exceeds the #370 acceptance criterion (5-10×) by an order of
magnitude. A second back-to-back run of side A hit
`SocketError: other side closed` mid-/dag/put burst — i.e. kubo's
per-block API surface gets overwhelmed by the 10-connection
parallelism per call, which is the EXACT throttling/capacity failure
mode the fast path eliminates by design (single connection, single
round-trip, single body).
…t-circuit drain

P4 from #275's optimization list, tracked as #378 after #275 closed on
P1+P2 landing. Once `finalizeStrandedReceivedToken` classifies a
stranded receive as permanent (HD-index recovery exhausted or
permanent structural failure), the tokenId is recorded in a
persistent ledger. Subsequent `drainPendingFinalizations` invocations
skip these tokens in <100 ms instead of repeating the 60-s drain
timeout per token. The #275 forensics measured this overhead at
30-60 s per `sphere balance` invocation while a stranded token
exists.

## Files

- `constants.ts` — new `STORAGE_KEYS_ADDRESS.V6_RECOVER_PERMANENT`
  key (`v6_recover_permanent`).

- `modules/payments/PaymentsModule.ts`:
  - **Field** — `v6RecoverPermanent: Map<string, { reason: string;
    ts: number }>` tracks the verdict.
  - **Persistence helpers** — `saveV6RecoverPermanent()` /
    `restoreV6RecoverPermanent()` mirror `saveProofPollingJobs` /
    `restoreProofPollingJobs`: empty map → `storage.remove()` so
    stale lists never survive; malformed payloads log + start empty;
    individual malformed entries are silently skipped.
  - **Mark site** — V6-RECOVER permanent path (`~line 9778`): after
    the existing status='invalid' write, stamp the ledger with the
    canonical `classLabel` reason + `Date.now()`. Defense-in-depth
    above the status flip: a `load()` that re-derives the in-memory
    token map from disk could (depending on storage-layer status-
    merge semantics) restore the original 'submitted' status; the
    persistent ledger key is independent of token bytes and survives
    that round-trip deterministically.
  - **Drain skip** — `hasUnconfirmedOrInflight` (`~line 7877`): a
    token whose tokenId is in the ledger is excluded from the drain
    predicate even if its status reverted to 'submitted' / 'pending'.
  - **Stranded-scan skip** — `recoverStrandedReceivedTokens`
    (`~line 9358`): the V6-RECOVER cold-start scan checks the ledger
    BEFORE re-deriving sender predicates / requestId / commitments,
    so a previously-permanent token doesn't re-pay the multi-second
    probe + finalize cycle on every CLI cold start.
  - **Forced-retry clear** — `receive({ finalize: true })` clears
    the ledger before drain so a stranded token gets one more shot at
    finalization (in case HD-index recovery has since widened). If
    the underlying condition genuinely hasn't cleared the recovery
    path re-derives the verdict and re-stamps the ledger.
  - **Restore on load** — `restoreV6RecoverPermanent` is invoked
    from `load()` AFTER `loadFromStorageData` populates `this.tokens`
    and BEFORE `recoverStrandedReceivedTokens` runs, so the scan
    sees the marker and skips immediately.

## Tests

`tests/unit/modules/PaymentsModule.v6-recover-permanent-378.test.ts`
(new) — 5 cases pinning the ledger's contract at the unit level:

  1. `saveV6RecoverPermanent` + `restoreV6RecoverPermanent` round-trip
     preserves the marker across a simulated process boundary.
  2. Empty-map save clears the underlying storage key (no stale list
     survives).
  3. Malformed entries inside an otherwise-valid payload are
     silently skipped; well-formed entries restore correctly
     (single-entry resilience).
  4. Missing storage key on restore is a no-op.
  5. Non-array payload on restore logs + leaves pre-existing
     in-memory state untouched.

The end-to-end V6-RECOVER finalize path itself is exercised in the
existing integration tests under `tests/integration/payments/
v6-recover-real-sdk-recovery.test.ts`; this commit's new tests pin
only the ledger semantics around it.

## Validation

- `npm run typecheck` — clean
- Targeted lint on the 3 changed files — 0 errors (5 pre-existing
  warnings in `PaymentsModule.ts` unrelated to this change).
- `npx vitest run tests/unit/modules/` — 1779 / 0 failed across 100
  test files.
- `npx vitest run` — 8806 passed | 13 skipped | 1 pre-existing flake
  (`tests/integration/wallet-clear.test.ts:217:26` — known
  parallel-execution `Wallet already exists` flake; passes 14/14 in
  isolation; documented in memory `project_core_sphere_test_flake.md`).

## Acceptance per #378

- [x] Once a token has hit V6-RECOVER permanent, subsequent
  `drainPendingFinalizations` invocations skip it in <100 ms — the
  predicate is an O(1) `Map.has` check.
- [x] Marker survives process restart — `restoreV6RecoverPermanent`
  rebuilds the in-memory map from the persisted KV blob on `load()`.
- [x] Marker is cleared by `Sphere.clear()` — the underlying KV key
  goes with the full wallet wipe (handled by the existing
  `storage.clear()` path, no extra code needed).
- [x] Marker does NOT survive `payments receive --finalize` —
  forced-retry path clears the ledger before drain.
- [x] Unit test exercises a stranded token persisting across two
  distinct `PaymentsModule` instances against the same storage.

## Refs

- Closes #378
- #275 — primary issue, P1 + P2 landed via PR #277. This is P4.
- Forensics: `/tmp/soak-274-c-debug/OPTIMIZATION-FINDINGS.md`.
…restore

The log claimed to be clearing the persisted ledger on malformed payloads,
but the function only logs and returns — the existing unit test
(restoreV6RecoverPermanent on non-array payload clears nothing and logs)
asserts this preserve-in-memory behavior intentionally.

Match the log text to the actual behavior so future readers (and ourselves
in incident review) aren't misled.

Surfaced by adversarial pre-merge review of the perf-stack-with-370-379
integration.
…e, clear suppression, pin retry, V6-RECOVER ledger (#381)

Bundles #371 / #373 / #374 / #375 / #376 / #379 + #365 baseline. D-soak: -34.1% vs baseline. Follow-ups: #382.
… GroupChat #334

`CommunicationsModule.parseMessagesPayload` used to fatal-throw
`ProfileError(CID_REF_UNREADABLE)` when the `messages` KV held a CID-ref
envelope but the wallet was opened without a `cidRefStore` (legacy
factory path). The throw propagated through `Sphere.load`'s
`Promise.allSettled` as "Module load failed" — taking down every other
module's load with it on the live testnet deploy 2026-06-01.

Mirrors the GroupChat fallback added in PR #334 / commit 7421386: a
`[CID_REF_DEGRADE]` warn + empty-state return. NIP-17 relay re-delivery
rehydrates whatever the relay still retains. Also wraps the
`cidRefStore.fetchJson()` call in try/catch so a gateway 404/timeout
during fetch no longer bricks the load either — same recovery strategy.

The previous fatal-throw was added because "silent fallback would mean
silently losing all stored DMs for this address." That's still true,
but a fatal throw also loses every other module's state and is strictly
worse for the user. Warn + empty + relay-rehydrate retains the visibility
without the load-bricking failure mode.

Tests:
  - Converted `throws CID_REF_UNREADABLE …` → `degrades to empty messages …`
  - New: degrades on `cidRefStore.fetchJson()` rejection (gateway failure)
  - 74/74 CommunicationsModule tests pass
… console storm

Live testnet deploy 2026-06-01 logs ~100 visible
  GET https://unicity-ipfs1.dyndns.org/sidecar/blob?cid=… 404
errors per wallet load. The SDK is already silent on 404
(`tryReadFromSidecar` returns null without logging), but Chrome
auto-logs every non-2xx `fetch()` to the console. With ~100 blocks in a
typical Profile snapshot the noise drowns real signal.

Root cause is by design: the sidecar correctly GCs blobs once promoted
to Kubo (`instant_pin_cache._reconcile_once` unlinks the disk blob after
`block_stat` confirms in-kubo). So for any wallet load that walks an
*old* Profile bundle every probe 404s — verified by curling the same
CIDs and getting 200 from the same gateway's `/ipfs/` endpoint.

Fix: per-gateway adaptive cold-cache. After
SIDECAR_COLD_MISS_THRESHOLD (3) consecutive 404s on a gateway, both
`tryReadFromSidecar` and `submitToSidecarBestEffort` short-circuit
*before* issuing the fetch for SIDECAR_COLD_DURATION_MS (60 s). A
successful hit (or 200 submit, which usually means a sibling device
just published) resets the counter and re-arms the fast path
immediately.

Cross-device fast-path preserved: a single hit re-enables probing for
the rest of the session. Steady-state cold gateways collapse from
~100 misses to ~3 misses + 1 re-probe per minute.

503 (cache_full back-pressure) and 413 (oversize) do NOT count as
cold misses — the sidecar is alive in those cases, just refusing this
particular submit. We must keep probing.

`_resetSidecarColdState()` exported for test isolation, mirroring the
existing `_resetGatewayCapabilityCache` pattern.

Tests:
  - submit: short-circuits after N consecutive 404s
  - submit: 200 hit resets the counter
  - submit: 503 does NOT count as a cold miss
  - read: short-circuits after N consecutive 404s
  - read: hit resets the counter
  - re-arms after the cooldown window expires
  - 6/6 new tests pass
fix(communications)(page-freeze): degrade CID_REF_UNREADABLE — mirror GroupChat #334
perf(profile/ipfs): adaptive cold-cache for sidecar probes — kill 404 console storm
Audit #333 C1 was closed at the encrypt() boundary: when the OrbitDB
write path was reachable but no encryption key had been derived,
encrypt() threw and the write was rejected. That defense holds only
during Phase A. After setIdentity attaches the encryption key, any
subsequent write of `mnemonic` / `master_key` / `chain_code` /
`derivation_path` / `base_path` / `derivation_mode` / `wallet_source` /
`current_address_index` would have been encrypted and written to
OrbitDB → replicated to IPFS via the snapshot CAR pin path. Even
encrypted, distributing the seed lowers the threat model from
"attacker must compromise the device" to "attacker must brute-force a
password against an IPFS-pinned ciphertext" — strictly weaker than
what users expect of seed material. The only legitimate cross-device
transport for the seed is the user's own BIP-39 mnemonic backup.

Fix
---
- profile/types.ts:
  - New `IDENTITY_KEYS` set names the identity / seed-material legacy
    keys explicitly.
  - `CACHE_ONLY_KEYS` is widened to fold in `IDENTITY_KEYS`. After
    `translateKey()`, identity writes return `cacheOnly: true`; the
    `set()` flow short-circuits at the localCache step and never
    reaches `writeEnvelope()`.
- profile/profile-storage-provider.ts:
  - Defense-in-depth: a post-translation assertion in `set()`
    fail-closes if any future refactor adds an identity-shaped
    Profile key (`identity.*`) without putting its legacy alias into
    `CACHE_ONLY_KEYS`. The error message points at the right file to
    fix. The old "encrypt() is the catch-all" comment block is
    replaced with one that documents the two-layer defense (cache-
    only routing + post-translation assertion).
- profile/migration.ts:
  - Identity keys are diverted at read time into a separate
    `identityKeys` map and persisted at the LEGACY key name (so the
    cache-only routing kicks in). This keeps the legacy-import path
    working — Sphere.loadIdentityFromStorage reads
    `_storage.get('master_key')` against the Profile localCache and
    finds it under the same legacy name it always lived at — without
    routing any encrypted seed bytes through the OrbitDB OpLog.
  - `MigrationResult.keysMigrated` now counts identity keys too for
    parity with previous behaviour.
- profile/index.ts: export the new `IDENTITY_KEYS` constant.

Tests
-----
- New test file `profile-storage-provider-identity-keys-cache-only.test.ts`:
  - Schema-level contract: every key in IDENTITY_KEYS is also in
    CACHE_ONLY_KEYS, and every `identity.*` mapping in
    PROFILE_KEY_MAPPING has its legacy alias in IDENTITY_KEYS. This
    catches future refactors at test time, not at runtime.
  - Parametrised assertion that `set('<identity-key>', ...)` writes
    to localCache and leaves OrbitDB empty.
  - Defense-in-depth assertion fires on a synthetic `identity.*`
    profile key with no CACHE_ONLY_KEYS entry.
  - Control: a non-identity write still flows through writeEnvelope.

- `profile-storage-provider-c1-plaintext-seed.test.ts`: updated tests
  that asserted the OLD behaviour ("after setIdentity, mnemonic
  lands ENCRYPTED in OrbitDB"). The defense moved from "encrypt() is
  the boundary" to "cache-only is the boundary"; assertions now
  verify the seed never lands in OrbitDB at any point.

- `profile-storage-provider.test.ts`, `integration.test.ts`,
  `migration.test.ts`, `migration-c2-flush-before-cleanup.test.ts`,
  `profile-storage-provider-issue-311.test.ts`: tests that used
  `'mnemonic'` as a generic test fixture for the OrbitDB write/read
  path swap to `'wallet_exists'` (non-identity, non-cache-only).
  Migration assertions verify the legacy key landed in Profile
  localCache (not under `identity.*`).

Behaviour change consumers should know about
--------------------------------------------
- Cross-device "wallet exists by virtue of identity.* being
  replicated" no longer works — Sphere.exists() on a fresh device
  returns false until the user imports the mnemonic, which is the
  correct UX for a non-replicating seed. The `has('wallet_exists')`
  fallback in ProfileStorageProvider still scans for legacy
  `identity.*` keys, so wallets created BEFORE this PR (which still
  have those entries in OrbitDB) keep working unchanged.
- Identity material in legacy IndexedDB is migrated to the Profile
  localCache (IndexedDB, same device). The user-facing experience is
  unchanged.

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

Co-Authored-By: Claude <noreply@anthropic.com>
…o primary

Symptom (2026-06-02): on a wallet that predates the IDENTITY_KEYS ⊂
CACHE_ONLY_KEYS fix, the seed material lives only in the legacy
IndexedDB (wired as `fallbackStorage`). Every boot reads the primary
(Profile localCache), finds nothing, falls back to legacy, succeeds,
and emits one warning per identity key per boot:

  [Sphere] Identity read for "master_key" missing from primary
  storage; consulting fallbackStorage (legacy cached identity).

The wallet works correctly but the warning noise is permanent — the
fallback consult never goes away because nothing ever populates the
primary.

Fix
---
In `Sphere.loadIdentityFromStorage`'s `readIdentityKey` helper, when
the fallback read succeeds, also write the value back to the primary
(`storage.set`). With the IDENTITY_KEYS ⊂ CACHE_ONLY_KEYS routing in
the prior commit, the write lands in the Profile localCache only —
it never reaches OrbitDB → never replicates to IPFS. So the
backfill silences the per-boot warning for legacy wallets WITHOUT
re-introducing the OrbitDB seed-leak the cache-only routing closes.

The backfill is best-effort: if the primary `set` throws (e.g.,
quota / contention), the read has already succeeded — we must not
regress the load just because the backfill couldn't run. Failure is
logged at `debug`.

Tests
-----
New file `tests/unit/core/Sphere.identity-fallback-backfill.test.ts`:
- backfill happens for every identity key the primary lacks;
- backfill failure is swallowed (caller sees the original load-path
  error, not "quota exceeded");
- no backfill or fallback consult on the steady-state post-backfill
  boot (primary already holds the keys).

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

Co-Authored-By: Claude <noreply@anthropic.com>
…des ledger; soak gate

V6-RECOVER permanent-mismatch verdicts were silently downgraded back to
'pending' on every load: `determineTokenStatus` (in txf-serializer) only
encodes `pending`/`confirmed` from the TXF transactions array, so the
application-level `'invalid'` write was lost on the next reload.
`aggregateTokens` then surfaced the unspendable token as unconfirmed
UCT, polluting balance across repeated `payments receive --finalize`
calls. The persistent `v6RecoverPermanent` ledger added in #378 was
correct on disk but never consulted at the balance / re-ingest surfaces.

This commit makes the ledger authoritative without changing the TXF
format:

  * New helper `applyV6RecoverPermanentInvalidStatus()` walks
    `this.tokens` and patches matching entries to `status='invalid'`.
  * Invoked at the end of every `loadFromStorageData` (initial load +
    every `sync()` round-trip) and at the end of `restoreV6RecoverPermanent`
    (handles cold-start ordering where the ledger hydrates AFTER the
    token map).
  * `aggregateTokens` adds a belt-and-braces ledger filter so balance
    NEVER includes a ledgered token, even if a future caller mutates
    status independently.
  * `addToken` honors the ledger on Nostr at-least-once replay —
    incoming tokens whose canonical id matches the verdict are
    persisted as `'invalid'`. We accept the write (return true) so
    the Nostr cursor advances; rejecting would cause perpetual replay.
  * Ledger lookups are canonical-id-first via the new
    `isV6RecoverPermanentToken(token, mapKey?)` helper — resolves via
    `extractTokenIdFromSdkData(sdkData)` with map-key fallback so
    UUID-keyed `addToken` entries still classify correctly.
  * `finalizeStrandedReceivedToken` keys the ledger by canonical
    genesis tokenId (was: map key, ambiguous post-replay) and awaits
    `saveV6RecoverPermanent()` so a process exit immediately after
    the verdict cannot lose the entry.

Tests:
  * tests/unit/modules/PaymentsModule.v6-recover-invalid-status-387.test.ts
    — 16 unit tests covering helper mechanics, getBalance filter,
      restoreV6RecoverPermanent re-application, loadFromStorageData
      round-trip, addToken honoring ledger, and all 4 acceptance
      criteria from #387.
  * tests/integration/payments/v6-recover-invalid-status-387.test.ts
    — 4 e2e tests across the full public `module.load()` lifecycle
      with simulated CLI restarts.
  * Both files fail without the fix (verified via `git stash`) and
    pass with the fix.

Soak hardening (`manual-test-full-recovery.sh`):
  * New `assert_no_unconfirmed_after_finalize` gate invoked at 5
    post-`receive --finalize` sites (§A.1, §A.2, §C.2, §D.4 alice,
    §D.4 bob) — would have caught #387 on the first run.
  * `normalize_snapshot` strips `(+ N unconfirmed)`, `[X+Y tokens]`,
    and `(N tokens)` clauses so existing `assert_diff_empty` calls
    compare CONFIRMED balances only. Unconfirmed pollution is caught
    by the dedicated gate, not masked equally on both sides of a diff.
  * Self-tests T8 + T9 added; all 9 normalize self-tests pass.

Soak run on testnet (779s, ALL GREEN):
  * All 14 ASSERT OK including 5 new #387 gates.
  * Zero address-mismatch / VerificationError / stranded receive
    diagnostics — the fix held end-to-end across `sphere clear` +
    full recovery, the exact scenario from the issue.

Closes #387.
vrogojin added 30 commits July 8, 2026 02:36
…9-quick-wins

Wave 6-P2-9: quick wins — .env.example + soak-invoice-return + docs
…files, 70 cases

Extends coverage over PaymentsModule surfaces that wave-6-P2-5 quarantined
and wave-6-P2-7 left as follow-ups. All tests exercise the slim v2 rebuild
via a fake in-memory ITokenEngine — no network, no SDK, no crypto.

Added:
- tests/unit/modules/payments/__fixtures__/v2-harness.ts (519 LoC)
  Shared harness: fake engine, recording token storage provider, captured
  transport handler, mint helper, buildIncomingTransfer helper.
- PaymentsModule.cascade.v2.test.ts (9 cases) — split change back to
  self, cascade of change-then-send, multi-source greedy planCoinSpend,
  transfer-vs-split routing, sender-owned change output.
- PaymentsModule.finalization.v2.test.ts (10 cases) — confirmed status
  on receive, verify() rejection, receive() + resolveUnconfirmed()
  emptiness, replay idempotence, memo propagation, malformed CBOR
  tolerance.
- PaymentsModule.history.v2.test.ts (10 cases) — SENT/RECEIVED entry
  shape, sort order, defensive copy, addToHistory, storage roundtrip.
- PaymentsModule.tombstone.v2.test.ts (11 cases) — removeToken +
  tombstone accumulation, mergeTombstones dedup, pruneTombstones no-op,
  storage roundtrip.
- PaymentsModule.importExport.v2.test.ts (10 cases) — exportTokens,
  addToken/updateToken/onTokenChange, archived/forked maps, storage
  roundtrip, importTokens() stub flagged as BUG.
- PaymentsModule.proofPolling.v2.test.ts (11 cases) — every install*
  no-op, atomic-return send (no polling loop), detectOrphan/
  importInclusionProof/revalidate stubs, worker abort signal.
- PaymentsModule.uxfAutoIngest.v2.test.ts (9 cases) — onTokenTransfer
  wiring, transport-authenticated senderPubkey, mixed-ownership bundle,
  empty CAR, CID payload deferral, back-to-back events.

Suite delta: 7036 → 7106 (+70 passing). tsc --noEmit clean.

BUG FLAG: importTokens() drops the caller's payload and returns
three empty arrays (documented in importExport.v2.test.ts). If any
consumer relies on it to hydrate a v2 blob into the wallet, that
hydration path is silently broken. Flag for a follow-up wave.
…iles, 86 cases

Wave 6-P2-5 quarantined the entire pointer-layer legacy test suite when the
stsdk-v1 imports could no longer resolve in the v2 harness. This wave
restores the highest-value invariants against the current pointer surface,
mocking the aggregator client rather than hitting real network.

New files (tests/unit/pointer/):
  aggregator-probe.v2.test.ts (21 cases)
    - probeVersion H2 OR-predicate: both/one/neither side OK
    - Rotation vs forgery: TRUST_BASE_STALE / UNTRUSTED_PROOF
    - AbortSignal propagation (PointerProbeAborted)
    - classifyVersion four-way: VALID / SEMANTICALLY_INVALID /
      PROOF_TRANSIENT / CAR_TRANSIENT
    - SDK shape drift → PROTOCOL_ERROR
    - decodeVersionCid ok/transient/semantic
    - isReachable: JsonRpcNetworkError, JsonRpcError treated as reachable

  aggregator-submit.v2.test.ts (30 cases)
    - classifySideResult per-side mapping: SUCCESS, EXISTS, REJECTED,
      429, 5xx, 4xx, -32006, SyntaxError, generic Error
    - combineOutcomes §7.3 rows 1-15 (state machine coverage)
    - Row 4 vs 5 disambiguation via isIdempotentRetryHint
    - Row 6/7 retry_side with committedSideKind='success'/'exists'
    - retry_after cap at 600s

  discover-algorithm.v2.test.ts (17 cases)
    - Phase 1: validV=0 pristine wallet
    - DISCOVERY_OVERFLOW when probes hit hard ceiling
    - Input validation: currentLocalVersion, walkbackLimit,
      initialEpochFloor — all raise PROTOCOL_ERROR
    - Deadline diagnostic (#450): past-deadline message
    - Pre-aborted signal → RETRY_EXHAUSTED
    - computeProbeFingerprint: empty, deterministic, order-independent

  signing.v2.test.ts (12 cases)
    - Shape: 33-byte compressed pubkey, 66-char lowercase hex
    - Determinism across builds; cross-wallet distinctness
    - Signing round-trip: sign + verifyWithPublicKey ok / rejects wrong key
    - T-A8 discipline: createFromSecret differs from raw constructor
    - bytesToHex utility

  publish-recover-roundtrip.v2.test.ts (6 cases)
    - submitPointer → decodeVersionCid round-trip at v=1, 42, 7
    - CID_MAX_BYTES=63 boundary; single-byte CID
    - Cross-version isolation (v=2 unpublished → transient)
    - Cross-wallet isolation (seed B fails on seed A's slot)
    - Sequential publishes at v=1 + v=2 both decode independently

Constraints honored:
  - No source edits (test-only wave)
  - npx tsc --noEmit: 0 errors
  - npx eslint tests/unit/pointer/*.v2.test.ts: 0 errors
  - Full suite: 437 files / 7122 tests pass (was 431 / 7036)
…10a-payments-tests

Wave 6-P2-10a: PaymentsModule v2 test coverage — 7 files, 70 cases
…teways

Wave 6-P2-7's coverage report flagged that the slim receive path REJECTS
`uxf-cid` payloads outright — returning false so the transport retains
the event indefinitely. That drops delivery for any sender using the
CID-by-reference envelope (per §3.3 of the UXF wire spec) since a fatter
consumer never arrives to drain the retention buffer.

Wire the recipient side:
  1. If `deps.cidFetchGateways` is empty/null → keep the reject behavior
     (return false, transport retains). Operators must configure
     gateways to enable CID mode.
  2. Otherwise, `fetchCarByCid` walks the gateway list in order and
     verifies the CAR root CID matches `payload.bundleCid` (existing
     pipeline module, already fully tested).
  3. On successful fetch, parse the CAR with `CarReader.fromBytes`,
     extract the single root block bytes, and feed them through the
     same `extractInlineTokenBlobs` helper the inline `uxf-car` path
     uses — so the downstream isOwnedBy → verify → persist tail is
     bit-identical.
  4. On any fetch failure → return false (retention, matches existing
     transport contract).
  5. On structural CAR/root-block failure after a successful fetch →
     return null (retention) so a re-fetch can retry against a
     healthier gateway.

Gateway URLs are never surfaced to logs — the top-level SphereError
message shape is bounded to `fetchCarByCid: ...` and we truncate
downstream error messages to 200 chars as defense-in-depth against a
gateway URL leaking through a nested cause.

Compilation clean (0 tsc errors). Test coverage lands in the next
commit.
Wave 6-P2-7 flagged two runtime paths in the slim rebuilds:

**Fix 1 audit — UnicityAggregatorProvider.waitForProof.**
The polling loop is correct: it terminates on success, sleeps
`pollInterval` between polls, and throws SphereError(TIMEOUT) on
deadline. No fix required. Adds `waitForProof.v2.test.ts` covering:

  - immediate return when getProof resolves on the first poll
  - proof:received event emission
  - multi-poll cadence with onPoll attempt-counter increments
    (fake timers driving the sleeps)
  - SphereError(TIMEOUT) on deadline
  - propagation of unexpected exceptions from getProof (no silent
    swallow — operators need the diagnostic surface)

**Fix 2 — PaymentsModule uxf-cid receive path.**
Adds `PaymentsModule.uxfCid.v2.test.ts` covering the receive-path
behavior wired in the previous commit:

  - happy path — mocked fetchCarByCid returns a real single-block
    CARv1 (built with `@ipld/car`), root block bytes are the same
    JSON envelope the inline uxf-car path consumes, receive tail
    persists the token and emits `transfer:incoming` with memo +
    senderNametag
  - empty cidFetchGateways → returns false, no fetch attempted
  - undefined cidFetchGateways (never wired) → returns false
  - all-gateways-failed error → returns false (retention)
  - CID-mismatch (surfaces as all-gateways-failed) → returns false
  - truncated CAR (structural failure) → returns false
  - CAR whose root block is not a JSON envelope → returns true (no
    retention; peer sent junk, retry can't fix it)
  - gateway URL redaction: log output stripped of https?://... tokens

Also tightens the URL-redaction in `fetchAndExtractCidBundle` (strip
URL substrings from the raw error message before logging) — the
"never expose gateway URLs" constraint is now enforced with an
assertion.

Updates the pre-existing `PaymentsModule.receive.v2.test.ts` uxf-cid
test description to reflect that the reject behavior now covers only
the "no cidFetchGateways configured" case.

Suite: 434 files, 7049 passed + 6 skipped, 0 failures.
…10b-pointer-tests

Wave 6-P2-10b: Pointer layer v2 test coverage — 5 files, 86 cases
…11-runtime-gaps

Wave 6-P2-11: runtime gap fixes — waitForProof audit + uxf-cid receive
…nt + refund soak

Closes the invoice-return refund path uncovered by wave 6-P2-9's
soak-invoice-return. Two changes:

## 1. UXF payload sender.directAddress wire hint

`extensions/uxf/types/uxf-transfer.ts` — `UxfTransferPayloadBase.sender`
gains an optional `directAddress` field. UNAUTHENTICATED on wire (same
as `nametag`), documented as a HINT the receiver uses to populate the
RECEIVED history entry's `senderAddress` without racing the sender's
Nostr identity binding publication. Security note: only affects the
caller's own money (refund destination), so a malicious sender only
harms themselves.

Sender path: `PaymentsModule.deliverTokens` +
`extensions/uxf/pipeline/module-glue/publish-uxf-bundle.ts` both
populate `sender.directAddress = identity.directAddress` on send.

Receiver path: `PaymentsModule.handleIncomingTransfer` prefers the
wire hint over `transport.resolveTransportPubkeyInfo` (wave 6-P2-6d
fallback). Eliminates the race that previously left `senderBalances[]`
empty when the sender's binding hadn't propagated yet.

## 2. Soak-invoice-return: use cancelInvoice(autoReturn) not
   returnAllInvoicePayments

`scripts/soak-invoice-return.ts` restructured to test the correct
refund flow. Root cause discovered while investigating: on a fully-
COVERED/CLOSED invoice `returnAllInvoicePayments` returns 0 BY DESIGN
— the CLOSED freeze zeros per-sender balances via balance-computer.ts's
surplus-distribution algorithm. That API is for OVERPAID (surplus > 0)
invoices only.

The real refund flow for a normally-paid invoice is
`cancelInvoice({autoReturn: true})` — cancels the invoice AND refunds
all forwarded payments to their senders via the CANCELLED freeze path
which preserves balances.

The soak now: partial payment (500 of 1000) → invoice PARTIAL →
`cancelInvoice({autoReturn:true})` → B receives 500 UCT refund via
`transfer:incoming` → B's balance restored (5000 → 5000, delta 0).

## Validation

All 5 testnet2 soaks PASS end-to-end:
- soak-mint-throughput
- soak-two-wallet-send
- soak-multi-coin-bundle
- soak-invoice-lifecycle
- soak-invoice-return  (new PASS state — was documented gap)

Typecheck 0 errors, test suite 7,205 pass / 6 skip preserved.
…12-sender-directaddress-hint

Wave 6-P2-12: sender.directAddress wire hint + fix invoice-return soak
…shots

Runs each of the 5 soaks N times (default 3) and reports per-soak
wall-clock stats (min / p50 / mean / max) plus per-step counts.

Baseline snapshot (2026-07-08, gateway.testnet2.unicity.network, 3 runs):

  mint-throughput (10 mints)       26.5s / p50 27.8s / max 30.8s
  two-wallet-send                  13.4s / p50 14.1s / max 15.3s
  multi-coin-bundle                23.8s / p50 24.7s / max 26.3s
  invoice-lifecycle                16.2s / p50 16.3s / max 17.0s
  invoice-return                   23.1s / p50 23.3s / max 25.3s

Per-call medians extracted from log-step deltas:

  Sphere.init                       ~500 ms
  sphere.tokenEngine.mint          ~2,741 ms  (v2 atomic submit)
  sphere.accounting.createInvoice  ~3,100 ms  (data-token mint)
  sphere.payments.send             ~8,400 ms  (send + Nostr + IPFS pin)
  sphere.accounting.payInvoice     ~8,500 ms  (routed through send)
  transfer:incoming event fire     ~500 ms

No v1 baseline available (v1 aggregator retired pre-Phase-6), so this
is a v2-only snapshot. Architecturally v2 should be faster: the
migration removed v1's background commitment retry, finalization workers,
and multi-shot proof polling — mint's 2.7s p50 is consistent with what
an atomic-submit design achieves.

Run:
  npx tsx scripts/soak-metrics.ts

Prints a summary table to stderr + JSON blob to stdout for consumption
by regression-tracking scripts.
…13-soak-metrics

Wave 6-P2-13: soak-metrics harness for perf snapshots
Multiple Sphere.init calls within a process (CLI reruns, multi-wallet
soaks, multi-tenant tests) each fetched the trust base JSON from
raw.githubusercontent.com. That URL rate-limits at ~60 req/hr per IP,
so multi-wallet workflows (e.g. trader-roundtrip with 4 wallets +
tenants, or the soak-metrics harness's 15 runs) blow through the limit
and Sphere.init throws.

Fix: process-scoped `Map<url, parsedJson>` in `core/Sphere.ts` shared
across all Sphere instances in the same process. First Sphere.init in
a process fetches; subsequent instances re-use the parsed JSON.

Zero behavior change for single-Sphere programs. Trust bases rarely
change between soak runs on the same day; if a new validator set rotates
in mid-process the cached value would stale, but that's acceptable for
soak / CLI workflows (fix would be a restart) and matches how other
tools handle trust-base rotation.

Validated: facade smoke passes; rate-limit-triggered ensureTokenEngine
throws no longer fire on the second+ Sphere.init.
…14-trust-base-cache

Wave 6-P2-14: process-wide trust-base fetch cache
… nametag mints

Fixes "Could not mint nametag token: Token engine not available" warnings
that fire from background nametag-mint retries during Sphere.init /
switchToAddress on slow networks. Three sites patched:

- core/sphere-addresses.ts postSwitchSync (new-nametag arm + missing-token
  arm) — detached from switchToAddress, races the async trust-base fetch
- core/Sphere.ts load-time nametag-token retry — same rationale

Each site now `await sphere.ensureTokenEngine()` (idempotent) before
calling mintNametag, so the wave 6-P2-4e wiring is fully settled by the
time the background mint fires.

Uncovered while running the trader-roundtrip soak against v2: multi-
wallet init flows in sphere-cli were consistently hitting the race.

Behavior change: none for warm-cache boots. On cold-start (trust base
not yet fetched), background nametag mint waits for the fetch instead
of failing silently and logging a warning. The observed symptom was 2
warnings per trader-roundtrip wallet.
…15-ensure-engine-before-mint

Wave 6-P2-15: await ensureTokenEngine before background nametag mints
…egator API

The runtime `AggregatorClient` returned by `oracle.getAggregatorClient()`
is v2 post 6-P2-4a, but the pointer layer was still typed against v1 and
called `submitCommitment(requestId, transactionHash, authenticator)` —
which does not exist on v2, throwing `TypeError` on every publish. The
error was bucketed as `network_error` and burned through the retry
budget, so trader-roundtrip on v2 was spamming `Pointer publish failed
… AGGREGATOR_POINTER_NETWORK_ERROR`.

## Pointer commitment shape under v2

Wraps the pointer's synthetic per-side commit in a `PointerTransaction`
(new `pointer-transaction.ts`) that implements `ITransaction` with:
- `lockScript` = `EncodedPredicate.fromPredicate(SignaturePredicate.create(signingPubKey))`
- `sourceStateHash` = `DataHash(SHA256, deriveStateHashDigest(v, side))`
- `calculateTransactionHash()` = `DataHash(SHA256, ct)` — the pointer's
  existing SPEC §6 ciphertext hash

Submit builds `SignaturePredicateUnlockScript.create(tx, signer)` which
signs `SHA256(sourceStateHash || transactionHash)`, then calls
`CertificationData.fromTransaction(tx, unlock)` and
`client.submitCertificationRequest(cert)`. Probe derives
`StateId.fromTransaction(tx)` and reads back via
`client.getInclusionProof(stateId)`; verification runs through
`InclusionProofVerificationRule.verify`, which requires a transaction —
we rebuild the pointer transaction with the received
`certificationData.transactionHash` so the rule can complete.

## v1 → v2 status enum mapping

| v1 `SubmitCommitmentStatus`            | v2 `CertificationStatus`                | Pointer bucket        |
|----------------------------------------|-----------------------------------------|-----------------------|
| SUCCESS                                | SUCCESS                                 | success               |
| REQUEST_ID_EXISTS                      | (no direct equivalent — see below)      | exists                |
| AUTHENTICATOR_VERIFICATION_FAILED      | SIGNATURE_VERIFICATION_FAILED           | rejected (row 9)      |
| —                                      | INVALID_SIGNATURE_FORMAT                | rejected (row 9)      |
| —                                      | INVALID_PUBLIC_KEY_FORMAT               | rejected (row 9)      |
| REQUEST_ID_MISMATCH                    | STATE_ID_MISMATCH                       | aggregator_rejected   |
| —                                      | INVALID_SOURCE_STATE_HASH_FORMAT        | aggregator_rejected   |
| —                                      | INVALID_TRANSACTION_HASH_FORMAT         | aggregator_rejected   |
| —                                      | UNSUPPORTED_ALGORITHM                   | aggregator_rejected   |
| —                                      | INVALID_SHARD                           | aggregator_rejected   |
| unknown → protocol_error (fail closed) | unknown → exists (v2 tolerance contract)| exists                |

v2 dropped the explicit "already exists" status — per
`CertificationResponse.d.ts` an unknown status means "the certification
for this state may already exist, probe getInclusionProof". The pointer
maps unknown statuses to `exists` so H2/H3 (marker match +
`isIdempotentRetryHint`) can distinguish idempotent replay (row 4) from
cross-device conflict (row 5) without adding a probe round-trip.

## Signing key derivation change

v2 dropped `SigningService.createFromSecret` — the constructor uses the
32-byte input directly AS the scalar. The pointer's `buildPointerSigner`
now calls `new SigningService(seed)`. Testnet2 wallets are fresh; there
is no wallet on both networks so the derived-pubkey change has no
migration surface. KAT vectors regenerated
(`tests/fixtures/pointer-kat-vectors.json`); the requestId vectors are
replaced with v2 StateId vectors.

## Files migrated

- `extensions/uxf/profile/aggregator-pointer/{signing,aggregator-submit,
  aggregator-probe,publish-algorithm,discover-algorithm,
  reconcile-algorithm,ProfilePointerLayer,trust-base-rotation,
  win-broadcast}.ts` — imports switched from `stsdk-v1/*` to
  `token-engine/sdk`; submit + probe rewritten on the v2 API.
- `extensions/uxf/profile/aggregator-pointer/pointer-transaction.ts` —
  new synthetic `ITransaction` adapter.
- `extensions/uxf/profile/pointer-wiring.ts` — same import switch.
- `token-engine/sdk.ts` — added `InclusionProofVerificationRule` to the
  barrel so the extension can verify inclusion proofs without breaking
  the extension ESLint boundary.

Net LoC: +646 / -715 (≈70 lines smaller). All 7210 unit tests pass;
0 typecheck errors; 0 net new lint errors in the pointer files.

Refs #666 (Phase 6 UXF v2 SDK migration)
…16-pointer-v2-migration

Wave 6-P2-16: migrate Profile pointer layer to v2 aggregator API
…bundles

Wave 6-P2-17 of the Phase-6 v1→v2 migration. Rewrites the Profile
token persistence pipeline to store v2 `SphereToken` blobs wrapped in
a canonical envelope (`SphereTokenPersistenceEntry`) — the same shape
the v2 wire path already ships over Nostr. Deletes the v1 chain
representation from all hot persistence / bundle paths. NO v1
backward compatibility, per the phase-6 mandate.

Root cause the wave fixes:

  1. `PaymentsModule.tokenToTxfEntry` returned a v2 UI `Token` (no
     `.genesis`) — the shape the v2 slim rebuild left behind.
  2. `ProfileTokenStorageProvider.extractTokensFromTxfData` filtered
     those entries out with "no genesis field" and handed an empty
     token list to `UxfPackage.ingestAll`.
  3. The published CAR carried zero tokens, so cross-process CLI
     reads landed on empty state ("No tokens found").

Fixes:

  * `types/txf.ts`: introduce `SphereTokenPersistenceEntry` (canonical
    v2 envelope: `{_sdkVersion, _format, v, network, tokenId, token}`),
    with a structural predicate for downstream shape checks. The v1
    chain type names (`TxfToken`, `TxfGenesis`, `TxfState`,
    `TxfTransaction`, `TxfIntegrity`, `TxfInclusionProof`,
    `TxfAuthenticator`, `TxfMerkleTreePath`, `TxfMerkleStep`,
    `TxfGenesisData`) survive as deprecated `any` aliases only —
    just enough to keep the last v1-shape helpers (accounting
    invoice payload constructor, `serialization/txf-serializer.ts`)
    compiling until their own port.

  * `modules/payments/PaymentsModule.ts`:
     - `tokenToPersistenceEntry(...)` (replaces the old
       `tokenToTxfEntry`) writes the v2 envelope, preferring live
       re-encode from the `SphereToken` when the engine is present.
     - `decodeSdkDataFromEntry(...)` restores the `SphereToken` from
       the persisted envelope via the engine on `load()`.
     - `loadFromStorageData(...)` gates on
       `isSphereTokenPersistenceEntry` — non-v2 entries are logged
       and skipped, not silently accepted.

  * `extensions/uxf/profile/profile-token-storage-provider.ts`:
     - `extractTokensFromTxfData(...)` recognizes the v2 envelope by
       its shape signature. Non-v2 entries are logged and skipped.

  * `extensions/uxf/bundle/UxfPackage.ts`: rewritten to a lean
    v2-only class over a `Map<tokenId, SphereTokenPersistenceEntry>`.
     - `toCar()` emits a single-block IPLD CARv1 whose dag-cbor root
       block carries `{ version: 2, format: 'sphere-uxf-v2',
       createdAt, updatedAt, tokens: [{tokenId, network, ver,
       token}] }`. Entries are sorted by tokenId for deterministic
       CIDs. The single-block CAR is fully compatible with the
       existing `extractCarRootCid` / `pinCarBlocksToIpfs` pipeline.
     - `fromCar()` parses the single root block, verifies the
       `sphere-uxf-v2` format tag, and throws INVALID_PACKAGE on any
       v1 shape.
     - `merge()` is non-clobbering (`this` wins on tokenId collision)
       to preserve the "keep the freshest local state" contract the
       flush-scheduler relies on.
     - Legacy methods that only made sense for the v1 element-pool /
       instance-chain DAG (`verify`, `diff`, `applyDelta`,
       `computeVerifiedProofs`, `consolidateProofs`, `addInstance`,
       `filterTokens`, `tokensByCoinId`, `tokensByTokenType`, `gc`,
       `save`, `toJson`) survive as trivial stubs for source compat.

  * `extensions/uxf/profile/profile-token-storage/flush-scheduler.ts`:
    the per-coin histogram now keys off `envelope.tokenId` (v2)
    instead of the removed `genesis.data.coinData` field.

  * `index.ts`: exports `SphereTokenPersistenceEntry` and
    `isSphereTokenPersistenceEntry`; keeps the deprecated `TxfToken`
    name for one wave of source-compat.

  * `serialization/txf-serializer.ts`: two `any`/type widenings so
    the v1 serializer (still used by the nametag store + accounting
    legacy) keeps compiling under the loose `TxfToken = any` alias.

Test-suite hygiene:

Twenty-eight v1-shape tests (the UxfPackage internals suite, the
v1 conservative-/instant-sender pipelines, the v1 bundle-verifier
/ cid-fetcher / smuggled-roots suites, the byte-identical CAR
regression, the v1-shape profile flush / monotonicity / export /
manifest tests, and `txf-wire-roundtrip`) are moved to
`tests/legacy-v1/**` — the pre-existing v2-migration quarantine
that vitest already excludes.

Verification:

  * `npx tsc --noEmit` — 0 errors.
  * `npx vitest run` — 415 files, 6942 pass / 0 fail / 6 skip.
  * All 5 testnet2 soaks (`mint-throughput`, `two-wallet-send`,
    `multi-coin-bundle`, `invoice-lifecycle`, `invoice-return`) —
    PASSED end-to-end.
  * Cross-process CLI round-trip repro from the wave brief:
    `sphere init` + `sphere faucet 100 UCT` in one process, then
    a fresh `sphere status` + `sphere balance` process reads
    `UCT: 100 (1 token)` off Profile persistence.
…17-profile-v2-persistence

Wave 6-P2-17: v2-only Profile persistence via UXF bundles
…consumers

Wave 6-P2-18 of the Phase-6 v1→v2 SDK migration. Deletes the ten
deprecated v1 chain-shape name aliases that wave 6-P2-17 kept as
one-wave-of-source-compat `any` fallbacks (`TxfToken`, `TxfGenesis`,
`TxfGenesisData`, `TxfState`, `TxfTransaction`, `TxfIntegrity`,
`TxfInclusionProof`, `TxfAuthenticator`, `TxfMerkleTreePath`,
`TxfMerkleStep`) and migrates every non-legacy consumer.

Migration policy:

- v2-envelope-aware code (accounting invoice payload) reaches for
  `SphereTokenPersistenceEntry`.
- v1-shape runtime code (still speaks state-transition-sdk v1 JSON
  until the STSDK v2 swap lands) uses `unknown` at the type boundary
  plus a local minimal interface for the fields it actually reads.
- No cross-file v1 alias re-use remains anywhere in the non-legacy
  tree; each site's minimal interface is scoped to that file.

Files rewritten:

- `types/txf.ts`: aliases removed, docblock rewritten. `TxfStorageData`
  index signature widened to `unknown` so adapters that still speak
  v1 shape compile without the alias.
- `index.ts`: `TxfToken` name dropped from the public re-export.
- `modules/accounting/AccountingModule.ts` + `types.ts`: `createInvoice()`
  now returns a canonical v2 `SphereTokenPersistenceEntry` envelope
  (via `engine.encodeToken`). `importInvoice()` accepts the same
  envelope shape, decodes via `engine.decodeToken`, reads invoice
  bytes via `engine.readTokenData`, and cross-verifies the peer's
  claimed tokenId against `engine.tokenId(sphereToken)`. Rejects a
  value-bearing token. No v1 backward compat.
- `modules/accounting/types.ts`: `CreateInvoiceResult.token` retyped
  to `SphereTokenPersistenceEntry`. `CoinEntry` docblock rewritten
  off the deleted `TxfGenesisData.coinData` reference.
- `modules/payments/PaymentsModule.ts`: archived/forked token maps
  retyped to `Map<string, unknown>`.
- `modules/payments/persistence/codec.ts`: archive host-slot types
  widened to `unknown`; the single genesis-tokenId read narrowed via
  a local `MinimalTxfShape`.
- `modules/payments/tokens/{identity,parse-cache,archive}.ts`:
  local minimal interfaces replace the deleted aliases.
- `modules/payments/import-export/{import,export}.ts`: local
  wire-shape interfaces replace the alias.
- `serialization/txf-serializer.ts`: public entry points
  (`getCurrentStateHash`, `hasMissingNewStateHash`, `txfToToken`,
  `buildTxfStorageData`, `parseTxfStorageData`) take `unknown` at the
  boundary and narrow internally via a local `V1TxfShape`. Storage-
  data option types widened to `Map<string, unknown>`.
- `extensions/uxf/profile/import-from-legacy.ts`: `LegacyTxfToken`
  local interface.
- `extensions/uxf/profile/token-storage-migration.ts`: `MigrationTxfToken`
  local interface.

Verification:

- `npx tsc --noEmit` — 0 errors.
…odeToken into fakes

- Move three v1-shape token/serializer tests into `tests/legacy-v1/**`
  (already excluded from CI): `unit/profile/token-storage-migration`,
  `integration/profile/token-storage-migration`, and
  `unit/serialization/txf-serializer`. Each constructs `TxfToken`
  fixtures by hand; the shape they exercise is gone with the wave 6-P2-18
  alias deletion.
- Replace the `Map<string, TxfToken>` type in
  `tests/unit/modules/payments/PaymentsModule.importExport.v2.test.ts`
  with `Map<string, unknown>` — matches the widened archive / forked
  API surface.
- Extend the fake tokenEngine in the two AccountingModule v2 tests
  (`AccountingModule.lifecycle.v2` and `AccountingModule.coinIdValidation.v2`)
  with an `encodeToken(sphereToken) → TokenBlob` stub so the new
  `SphereTokenPersistenceEntry` envelope wrap succeeds.
- Prose cleanup on two `TxfToken` doc-comment references
  (`modules/swap/dm-protocol.ts`, `modules/payments/import-export/types.ts`)
  now that the alias is gone.

Verification: `npx vitest run` — 412 files, 6849 pass / 0 fail / 6 skip.
…eted TxfToken

Aligns the header comment with the wave-6-P2-18 wire contract: the
soak's `importInvoice` step now speaks the v2
`SphereTokenPersistenceEntry` envelope, not the deleted v1 `TxfToken`
shape.
…18-purge-v1-leaks

Wave 6-P2-18: purge remaining v1 backward-compat leaks
Per the strict "No v1 backward compatibility!" directive, delete the
last remaining v1 boundary — the legacy wallet import/migration flow —
along with every symbol that only exists to serve it.

Deleted files:
- extensions/uxf/profile/import-from-legacy.ts (legacy wallet reader)
- extensions/uxf/profile/token-storage-migration.ts (migrate helper)
- modules/payments/import-export/ (whole directory: v1-shape parser
  and WireTxfToken local interface)
- modules/payments/legacy-v1/PaymentsModule-legacy.ts (dead sibling
  that imported from the deleted import-export directory)

Deleted from PaymentsModule (public API surface):
- importTokens() method
- ImportAddedCode, ImportSkipCode, ImportRejectCode, ImportAdded,
  ImportSkipped, ImportRejected, ImportTokensResult re-exports

Deleted from extensions/uxf/profile/index.ts (public API surface):
- ProfileMigration class
- importLegacyTokens + LegacyImportOptions + LegacyImportResult
- migrateTokenStorage / migrateLegacyToProfile /
  migrateProfileToLegacy + isTokenStorageMigrationComplete +
  clearTokenStorageMigrationMarker +
  TOKEN_STORAGE_MIGRATION_MARKER_VERSION
- MigrationDirection / TokenStorageMigrationOptions /
  TokenStorageMigrationProgress / TokenStorageMigrationCounts /
  TokenStorageMigrationResult / MigrateLegacyToProfileOptions /
  MigrateLegacyToProfileFromSphereOptions /
  MigrateLegacyToProfileFromSphereResult

Deleted from browser factory:
- createBrowserProfileProvidersAuto (auto-wired legacy fallback)
- migrateLegacyToProfileBrowser
- BrowserProfileProviders.fallbackTokenStorage field

Deleted from node factory:
- migrateLegacyToProfileNode

Retained (unrelated intra-profile schema migration):
- migrateInvalidTokensToPerEntryKey (T.1.E; single-blob →
  per-entry-key `invalid.*`; no dependency on v1 wallet layout)

Deleted from Sphere.load:
- `migration.migratedAt` marker probe + Issue #330 warning path.
  The generic `fallbackTokenStorage` optional field survives on
  SphereInitOptions / SphereLoadOptions for callers that need to
  wire their own read-only fallback manually.

Quarantined to tests/legacy-v1/ (wave 6-P2-5 pattern):
- tests/unit/modules/payments/PaymentsModule.importExport.v2.test.ts
- tests/unit/profile/token-storage-migration-from-sphere.test.ts
- tests/unit/profile/migration.test.ts
- tests/unit/profile/migration-c2-flush-before-cleanup.test.ts
- tests/e2e/migrate-to-profile-conservation.test.ts

Surgically trimmed:
- tests/unit/profile/integration.test.ts — the `migration flow`
  describe block referenced ProfileMigration; the block is removed
  and the ProfileMigration import dropped. All other integration
  tests in the file (full lifecycle, multi-device, factory) are
  retained and continue to run.

Net LoC delta: -13,234 (mostly deletions of PaymentsModule-legacy.ts
and the two migration/import files).

tsc --noEmit: 0 errors. Vitest suite: 408/408 files pass; 6794 tests
pass, 6 skipped.

Downstream break flagged for follow-up (not fixed in this PR):
- sphere-cli's `wallet migrate` command in src/legacy/legacy-cli.ts
  imports `importLegacyTokens` from `@unicitylabs/sphere-sdk/profile`.
  When this SDK change lands, sphere-cli's `wallet migrate` will fail
  to build. Track separately.
…19-delete-legacy-import

Wave 6-P2-19: delete legacy v1 import path
… transient errors

Wraps v2 SDK's AggregatorClient with a retrying decorator so
`submitCertificationRequest` transparently retries HTTP 5xx / 429 /
network errors with bounded exponential backoff (~13s total: 500ms +
1500ms + 3500ms + 7500ms). 4xx surfaces immediately (real client
errors).

Trigger: trader-roundtrip on v2 hit `Fatal startup error: Failed to
mint nametag token: Submit failed: {"status":503,...}` — the trader
tenant's Docker init fatally aborted on the first testnet2 gateway 503
during registerNametag. With this wrapper, mint/transfer/split all
tolerate transient gateway 503s and complete once the gateway
recovers.

- New non-pinned `token-engine/retrying-aggregator-client.ts` — duck-
  typed proxy that wraps `submitCertificationRequest`. Everything else
  (getInclusionProof, getLatestBlockNumber) passthrough — those have
  their own polling / retry loops that would compound if double-
  retried.
- Minimal edit to SHA-pinned `factory.ts`: change one line to construct
  StateTransitionClient over the wrapped client. Re-sync note added at
  the header.

Classifier mirrors `core/connectivity.ts::isTransientAggregatorError`
but embedded so token-engine doesn't drag connectivity.ts into the
engine bundle. Parses both `"status":503` (v2 JsonRpc shape) and
`HTTP 503` (URL-mode transports).
…20-retry-transient-agg-errors

Wave 6-P2-20: retry submitCertificationRequest on transient aggregator errors
…ter createInvoice

`sphere.accounting.createInvoice()` returns the invoice as
{invoiceId, token, terms} but never registered it in
`payments.tokens`. In single-process code paths callers cache the
returned `token` and pass it directly to `importInvoice()`, so the
gap doesn't show. But cross-process modules (escrow-service) look up
the token later via `sphere.payments.getToken(invoiceId)` to deliver
its sdkData envelope to a payer — that returns undefined, so the
escrow's `getDepositInvoiceToken()` hits `deliver_deposit_invoice_no_token`
after five 500ms retries and the swap cancels with `SWAP_CANCELLED`.

Fix: after mint + envelope build, construct a v2 UI Token wrapper
(coinId='', amount='0' — invoice is a data token) with sdkData=envelope-JSON
and add it via `deps.payments.addToken()`. Now `payments.getToken(invoiceId)`
returns the sdkData envelope that escrow-service expects.

Non-fatal on registration failure (mint already succeeded; caller
still gets the token via return value).

Uncovered while running trader-roundtrip on v2 with a local
v2-rebuilt escrow tenant — escrow booted, negotiated, but couldn't
deliver deposit invoices.
…21-register-invoice-token

Wave 6-P2-21: register invoice token in payments after createInvoice
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