Skip to content

feat: UXF module + Profile storage — content-addressable token packaging with OrbitDB#105

Open
vrogojin wants to merge 72 commits intomainfrom
feature/uxf-packaging-format
Open

feat: UXF module + Profile storage — content-addressable token packaging with OrbitDB#105
vrogojin wants to merge 72 commits intomainfrom
feature/uxf-packaging-format

Conversation

@vrogojin
Copy link
Copy Markdown
Contributor

@vrogojin vrogojin commented Apr 9, 2026

Summary

Introduces two new modules for the sphere-sdk:

UXF (Universal eXchange Format) — @unicitylabs/sphere-sdk/uxf

Content-addressable packaging format for Unicity token materials. Tokens are recursively deconstructed into a shared DAG of elements, enabling deep deduplication (~67% size reduction for typical wallets). Key features:

  • 12 element types with content-addressed hashing (dag-cbor + SHA-256)
  • Deconstruction/reassembly — ITokenJson ↔ DAG element pool round-trip
  • Instance chains — alternative representations (consolidated proofs, re-encoded elements) linked via predecessor hashes
  • Multi-format serialization — JSON for debugging, CARv1 for IPFS-native bundles
  • Integrity verification — hash checks during reassembly, cycle detection, instance chain validation
  • 14 source files, 268 tests (all passing)

Profile — @unicitylabs/sphere-sdk/profile

OrbitDB-backed wallet storage layer implementing the existing StorageProvider and TokenStorageProvider interfaces. Provides IPFS-first persistence with automatic multi-device conflict resolution. Key features:

  • OrbitDB Merkle-CRDT — automatic conflict resolution via append-only OpLog
  • Multi-bundle token inventory — each device writes its own UXF bundle CID (tokens.bundle.{CID}), merged on read via UxfPackage.merge()
  • AES-256-GCM encryption — all OrbitDB values encrypted with HKDF-derived shared key
  • 6-step legacy migration — sync old IPFS → transform → persist → verify → cleanup
  • Write-behind buffer — debounced 2s flush coalesces rapid saves
  • Standalone factoriescreateBrowserProfileProviders() / createNodeProfileProviders() (no upstream file modifications)
  • 11 source files, 110 tests (all passing)

Documentation (20 files, ~350 KB)

Complete specification suite: formal spec (CDDL/JSON Schema), architecture, design decisions, implementation plans, domain constraints, test specifications, SDK storage inventory, IPFS/OrbitDB research.

Stats

  • 70 files changed, 30,466 insertions
  • 378 tests (21 test files), all passing
  • 19 steelman review rounds, all SHIP IT
  • New dependencies: @ipld/dag-cbor, @ipld/car (dependencies); @orbitdb/core, helia (optional peerDependencies)
  • 3 new entry points: ./uxf, ./profile, ./profile/browser, ./profile/node

Test plan

  • npx tsc --noEmit — zero type errors
  • npm run build — all entry points build (uxf + profile/index + profile/browser + profile/node)
  • npx vitest run tests/unit/uxf/ — 268 tests passing
  • npx vitest run tests/unit/profile/ — 110 tests passing
  • Existing SDK tests unaffected (no upstream file modifications except type-only re-exports in index.ts)
  • OrbitDB live integration test (requires @orbitdb/core installed — deferred to follow-up)

Follow-up work (not in this PR)

  • Consolidation engine (Phase 2 — currently logs warning when bundle count > 3)
  • Nostr-OrbitDB replication bridge (Phase 2 — currently uses standard libp2p PubSub)
  • Live OrbitDB integration tests (requires @orbitdb/core + helia installed)
  • Sphere app migration to use ProfileStorageProvider

vrogojin added 26 commits March 26, 2026 15:03
Initial documentation suite for the Universal eXchange Format (UXF),
a content-addressable packaging format for Unicity token materials.

Documents:
- TASK.md: requirements and acceptance criteria
- SPECIFICATION.md: formal spec with CDDL, JSON Schema, 13 element types
- ARCHITECTURE.md: module structure, TypeScript types, API surface
- DESIGN-DECISIONS.md: 14 binding decisions resolving design trade-offs
- REVIEW.md: adversarial architecture review findings
- IPFS-RESEARCH.md: IPLD/dag-cbor/CAR state-of-art research
- TOKEN-ANALYSIS.md: byte-level token field analysis and dedup estimates
addToken was a redundant alias for ingest. Single entry point
for adding tokens is now ingest() / ingestAll().
…IONS

Resolves all 22 cross-document contradictions found by steelman review.

Critical fixes:
- Remove SmtPathSegment as separate element; inline in SmtPath (Decision 5)
- Change canonical type from TxfToken to ITokenJson throughout (Decision 1)
- Replace hand-written CBOR with @ipld/dag-cbor references (Decision 3)
- Standardize hash canonical form as 4-key map, not positional array
- Align instance chain index structure between SPEC and ARCH
- Fix TransferTransaction children (sourceState/destinationState)

Other fixes:
- TokenRoot: remove tokenType/coinData (live in MintTransactionData)
- Predicate: make opaque (single raw bytes field)
- TokenState: predicate inlined as leaf, not child ref
- MintTransactionData: coinData inlined as leaf
- CAR: standardize on CARv1 with single envelope root
- Reassembly: add mandatory hash verification + cycle detection
- Add version mapping (semantics:1 = state-transition-sdk v2.0)
- Make instanceChainIndex required in JSON Schema
- Add CYCLE_DETECTED error code
- Add Phase 2 annotation to consolidateProofs
- Document mutable API model
Critical:
- Add version field to TokenRoot in SPEC (was in ARCH only)
- Collapse destination-state into token-state (no separate type)
- Fix worked example element count (22, not 23; remove Phase 2 types)

Warnings:
- Fix type field encoding: uint in hash computation, not string
- Fix nametag reassembly: use root hash, not manifest lookup
- Fix timestamp units: seconds consistently (not ms)
- Fix envelope version: string "1.0.0" (not number)
- Fix section numbering in SPEC (9.4 before 9.5)
- Clarify Phase 1 scope for Predicate/TokenCoinData (inline, not separate)
- Standardize element type count at 12
- Rename merkle-tree-path to smt-path in ARCH

Notes:
- Fix CDDL appendix naming consistency
- Populate genesis destination state with actual data
IMPLEMENTATION-PLAN.md: 17 work units across 6 layers with dependency
graph and parallelization strategy (10 agents, 5 waves).

DOMAIN-CONSTRAINTS.md: field-by-field ITokenJson→UXF mapping, hex/binary
conversion rules, state derivation algorithms, nametag handling, and
12 critical implementation pitfalls.
5 critical fixes:
- Split token reason field: Uint8Array (not string) for ISplitMintReasonJson
- Reject placeholder/_pendingFinalization sentinel tokens
- Track nametag refs in transfer transaction data for round-trip fidelity
- Main barrel exports UXF types only (not runtime) to avoid @ipld/dag-cbor dep
- Use ITokenJson sub-types, not TxfToken sub-types in deconstruction

11 warning fixes:
- merge() re-hashes incoming elements before insertion
- Hex lowercase normalization in deconstruction
- UxfElement.children includes null in type union
- Functional API labeled as mutating (not pure)
- prepareContentForHashing for hex→bytes before CBOR encoding
- message field handling in TransferTransactionData
- Explicit TransactionDataContent fields (recipient, salt, etc.)
- SmtPath path values are decimal bigint strings, not hex
- Document Phase 1 accepts null inclusionProof (diverges from domain constraints)
- diff/applyDelta marked as Phase 1 LOW priority
- NOT_IMPLEMENTED error code verified present
Complete implementation of the Universal eXchange Format (UXF) module:

Core (Wave 1):
- types.ts: 12 element types, branded ContentHash, all interfaces
- errors.ts: UxfError with 12 error codes
- hash.ts: SHA-256 over deterministic dag-cbor canonical form
- element-pool.ts: content-addressed Map store with GC

Algorithms (Wave 2-3):
- instance-chain.ts: chain management, selection strategies, merge
- deconstruct.ts: ITokenJson → DAG decomposition (12 pitfalls handled)
- assemble.ts: DAG → ITokenJson reassembly with hash verification + cycle detection
- verify.ts: full package integrity verification
- diff.ts: package delta computation and application

Serialization (Wave 3):
- json.ts: JSON round-trip serialization
- ipld.ts: CIDv1 computation, IPLD blocks, CARv1 export/import

Integration (Wave 4-5):
- UxfPackage.ts: fluent API wrapping all subsystems
- storage-adapters.ts: InMemoryUxfStorage, KvUxfStorageAdapter
- index.ts: barrel exports
- Build config: tsup entry point, package.json exports

Dependencies: @ipld/dag-cbor, @ipld/car, multiformats
Security:
- hexToBytes: validate input (even length, valid hex chars)
- applyDelta: re-hash incoming elements before insertion
- importFromCar: verify element hashes after CBOR decoding
- Add recursion depth limit (100) for nametag traversal in
  deconstruct and assemble to prevent stack overflow DoS

Correctness:
- Preserve null state.data and null SmtPath step.data (was coerced to
  empty string, changing hashes and losing null semantics)
- Fix assembleTokenAtState false CYCLE_DETECTED when stateIndex=0
  (genesis visited twice; now uses direct resolution for destinationState)
- Fix JSON reason Uint8Array round-trip (hex→Uint8Array on deserialize)
- Add hex lowercase normalization during JSON deserialization
- Fix rebuildInstanceChains in ipld.ts to handle branching chains
  (was Map<hash, hash>, now Map<hash, hash[]>)

Maintenance:
- ElementPool: add public toMap()/fromMap() methods
- UxfPackage: use ElementPool.fromMap() instead of private field cast
TEST-SPECIFICATION.md: 240 test cases across 14 test files covering
all 14 source modules — unit tests, edge cases, round-trips, error
paths, and integration flows.

TEST-FIXTURES-SPEC.md: 6 mock tokens (A-F) with shared elements,
edge case tokens, expected element counts, incremental dedup math,
and round-trip normalization rules.
- Fix token-state count in TEST-FIXTURES-SPEC: 12→9 (was double-counting shared states)
- Fix normalization note: state.data null is preserved faithfully (not coerced to "")
- Fix StateContent.data type: string → string | null (matches runtime behavior)
- Update assembleState return type and callers to propagate null data
Test coverage for the complete UXF module:

Foundation (T1):
- errors.test.ts (7 tests): error construction, codes, instanceof
- types.test.ts (25 tests): contentHash validation, type IDs, constants
- hash.test.ts (22 tests): determinism, hex/bytes conversion, BigInt paths
- element-pool.test.ts (19 tests): CRUD, dedup, GC, toMap/fromMap
- instance-chain.test.ts (34 tests): chains, selection strategies, merge

Core algorithms (T2):
- deconstruct.test.ts (23 tests): element counts, dedup, nametags, edge cases
- assemble.test.ts (18 tests): round-trip, historical, error paths, depth limits

Serialization & verification (T2):
- verify.test.ts (16 tests): integrity, cycles, orphans, chain validation
- diff.test.ts (12 tests): delta computation, application, verification
- json.test.ts (18 tests): round-trip, format, hex normalization
- ipld.test.ts (14 tests): CID, IPLD blocks, CAR round-trip
- storage-adapters.test.ts (10 tests): InMemory, KV adapter

Integration (T3):
- UxfPackage.test.ts (38 tests): full class API coverage
- integration.test.ts (9 tests): end-to-end flows with all 6 mock tokens

Also includes tests/fixtures/uxf-mock-tokens.ts with 6 mock tokens,
shared constants, edge case tokens, and expected dedup counts.
The verifier's DFS used a flat visited-set which flagged content-
deduplicated DAG diamonds (same element referenced by two sibling
paths) as cycles. Replaced with path-based cycle detection:
- pathStack tracks ancestors (back-edge = true cycle)
- visited tracks fully-explored nodes (skip without error)

This fixes verify() returning valid=false for structurally valid
packages with content-addressed dedup (e.g., token-state shared
between genesis.destinationState and token-root.state).

Test cleanup: removed all CYCLE_DETECTED filters from 6 test files.
Tests now assert result.valid===true directly. The verify.test.ts
"cycle in DAG" test now creates an actual cycle (back-edge).

All 268 tests pass.
Introduces the UXF Profile concept: a key-value table representing the
complete persistent state of a Sphere user wallet.

Key design decisions (pending approval):
- IPFS is source of truth; local storage is transient cache
- Profile stored as IPLD DAG, addressed via IPNS
- Token inventory referenced by CID to UXF CAR file
- Lazy loading: only cache accessed tokens locally
- Full backward compatibility with StorageProvider/TokenStorageProvider
- Encryption at rest via wallet-derived key (AES-256-GCM)
- Maps all 40+ existing storage keys to Profile schema
- Covers all SDK flows: send, receive, DMs, nametags, swaps, accounting

Includes 6 open questions for architectural review.
SDK-STORAGE-INVENTORY.md: complete mapping of all 40+ storage keys
across StorageProvider and TokenStorageProvider, with criticality
levels, data shapes, sizes, and per-address scoping.

IPFS-KV-RESEARCH.md: state-of-art (2024-2025) research covering
OrbitDB, Helia, IPNS, w3name, CAR files, WNFS, browser storage
(IndexedDB/OPFS), Node.js options (lmdb-js/SQLite/LevelDB),
sync patterns, and lazy loading strategies.
Key architectural changes per review feedback:

1. OrbitDB as profile KV store (replaces raw IPLD DAG):
   - Merkle-CRDT OpLog for automatic conflict resolution
   - keyvalue database type with last-writer-wins per key
   - Replication via libp2p PubSub (real-time) + DHT (cold sync)
   - No manual merge logic needed

2. Multiple UXF bundle CIDs (replaces single tokens.inventoryCid):
   - tokens.bundles stores array of UxfBundleRef
   - Each device appends its own bundle CID — no conflict
   - Reading merges all active bundles via UxfPackage.merge()
   - Content-addressed dedup ensures no duplication
   - Background consolidation merges bundles lazily
   - Superseded bundles kept for safety period before deletion

Updated sections: 1 (overview), 2.3 (multi-bundle model), 3 (OrbitDB
persistence), 4 (OrbitDB database), 5.3 (token storage flows),
7.1-7.3 (operation flows). Resolved questions 3 and 5. Added 3 new
open questions about bundle size, replication, and safety period.
Decisions:
1. Cache-only keys (prices, registry) → local storage only, not OrbitDB
2. No encryption in Phase 1 — preserves content-addressed dedup across devices
3. Silent auto-migration: sync old IPFS → transform → persist OrbitDB → verify → cleanup
4. Sphere app WalletRepository migration → separate follow-up task
5. Accept OrbitDB bundle size — replaces custom IPFS sync code
6. Nostr relay only for replication — no Voyager
7. Remove superseded bundles from Profile only — no IPFS unpinning (GC deferred)

Key changes to UxfBundleRef:
- deleteAfter → removeFromProfileAfter (profile-only cleanup)
- Remove 'pending-deletion' status (lifecycle: active → superseded → removed)
- Old CIDs remain pinned on IPFS indefinitely

Added Section 7.6: detailed 6-step migration flow with mandatory sanity check.
Updated Section 9: no encryption, OrbitDB access control only.
Updated Section 4.4: Nostr as sole replication channel.
Critical fixes:
- Replace tokens.bundles array with per-key tokens.bundle.{CID} pattern
  (OrbitDB LWW overwrites arrays — per-key avoids conflict entirely)
- Add HKDF-derived shared encryption (AES-256-GCM) for all OrbitDB
  values — same key from mnemonic on all devices preserves CID dedup
  for CAR files via deterministic IV
- Add missing storage keys: mintOutbox, invalidTokens,
  invalidatedNametags, tombstones (per-address)
- Encrypt DM content before OrbitDB storage (preserves NIP-17 privacy)
- Add two-phase commit for consolidation crash recovery

Warning fixes:
- OpLog growth management (snapshots, batching, growth estimates)
- Nostr-OrbitDB bridge marked Phase 2 (not "thin adapter")
- Browser runtime constraints documented (WebRTC, bundle size)
- Concurrent consolidation guard (5-min TTL lock)
- IPNS expiry fallback in migration (skip if failed)
- Fix historical CID unpinning (only last known CID)
- Vesting cache protected during migration cleanup
- wallet_exists fast-path via local storage flag
- 20-bundle performance cap with degraded mode
- CAR fetch failure handling with pinning verification
- Strengthened migration sanity check (per-token verification)
- Migration phase tracking for crash recovery
Section 7.5 (Nametag Registration) was the only operation flow that
didn't show the explicit UxfPackage→toCar→pin→CID→OrbitDB chain.
Now consistent with Sections 7.1 (send) and 7.2 (receive).

All token references in the Profile now go through UXF bundle CIDs:
- Save: ingest → toCar → pin → db.put('tokens.bundle.{CID}', ref)
- Load: list bundle keys → fetch CARs → merge → assemble
- Nametag: ingest → toCar → pin → db.put('tokens.bundle.{CID}', ref)
PROFILE-IMPLEMENTATION-PLAN.md: 15 work units across 5 layers with
maximum parallelization (3 parallel workers per layer).

Layers:
- L0: types, encryption, OrbitDB adapter (parallel)
- L1: ProfileStorageProvider, ProfileTokenStorageProvider, local cache (parallel)
- L2: bundle manager, TxfToken adapter, CAR pinning (parallel)
- L3: migration engine, consolidation engine, sync coordinator (parallel)
- L4: factory functions, barrel exports, build config

PROFILE-INTEGRATION-POINTS.md: complete analysis of SDK storage
touchpoints — every storage call in Sphere.ts, PaymentsModule.ts,
factory functions. 8 backward compatibility risks with mitigations.
6-section migration touchpoint inventory.
Structural simplification per code review:
- Merge TxfAdapter, BundleManager, CAR Pinning, SyncCoordinator into
  WU-P05 (ProfileTokenStorageProvider) as private helpers
- Defer ConsolidationEngine and LocalCacheLayer to Phase 2
- Merge BarrelExports + BuildConfig into single WU-P14
- Reduce from 5 layers to 3 layers, 15 WUs to 8 WUs

Security fixes per audit:
- Remove deterministic IV for CAR encryption (use random IV always)
- Document encryption bootstrap sequence (password → mnemonic → key)

API fixes:
- setIdentity() is synchronous (OrbitDB deferred to connect())
- Write-behind buffer with 2s debounce for save()
- storage:remote-updated event emission on OrbitDB replication
- OrbitDB access controller with write restriction
- Standalone factory functions (no upstream file modifications)
- Complete key mapping (dynamic patterns, IPFS exclusion, reverse)
- Proper sync() contract returning SyncResult
- Migration: accounting/swap keys, nametag tokens, forked tokens
- @orbitdb/core as peerDependency with optional:true
- Platform-specific entry points (browser.ts, node.ts)
…torage

Complete implementation of the Profile storage layer:

Layer 0 (Foundation):
- types.ts: ProfileConfig, UxfBundleRef, key mappings, 9 error codes
- errors.ts: ProfileError class
- encryption.ts: HKDF key derivation + AES-256-GCM encrypt/decrypt
- orbitdb-adapter.ts: dynamic-import OrbitDB wrapper with access control

Layer 1 (Storage Providers):
- profile-storage-provider.ts: StorageProvider impl with key translation,
  local cache + OrbitDB dual-write, encryption, reverse key mapping
- profile-token-storage-provider.ts: TokenStorageProvider impl with
  multi-bundle model, write-behind buffer (2s debounce), UXF CAR
  pin/fetch, OrbitDB replication events, inline bundle management

Layer 2 (Integration):
- migration.ts: 6-step legacy→Profile migration with crash recovery
- factory.ts: shared provider wiring logic
- browser.ts: createBrowserProfileProviders() (standalone)
- node.ts: createNodeProfileProviders() (standalone)
- index.ts: barrel exports (types + runtime)

Build: 3 tsup entry points (profile/index, profile/browser, profile/node)
Deps: @orbitdb/core + helia as peerDependencies (optional: true)
No upstream files modified (factories are standalone)
Critical:
- Remove duplicate types from orbitdb-adapter.ts (import from errors/types)
- Remove private key leak in derivePublicKeyShort fallback (throw instead)
- Replace require() with dynamic import() for ESM compatibility

High:
- Fix address ID mismatch: add computeAddressId() utility, compute in
  setIdentity(), return short ID from getAddressId()
- Extract archived-* and _forked_* tokens in save path (was silently dropping)
- Fix createForAddress() to accept and propagate addressId parameter
- Add HKDF salt ('sphere-profile-v1') for proper domain separation
- Track lastPinnedCid for flush retry (don't re-pin on OrbitDB failure)
- Clean up replication listeners in close() before clearing set
- Guard encryptionKey with null checks (throw PROFILE_NOT_INITIALIZED)
Coverage: errors (7), encryption (12), OrbitDB adapter (14),
ProfileStorageProvider (22), ProfileTokenStorageProvider (28),
migration (16), integration (10).
Foundation (19 tests):
- errors.test.ts (7): construction, codes, instanceof, cause
- encryption.test.ts (12): HKDF, AES-GCM round-trip, IV uniqueness, tamper

Providers (66 tests):
- orbitdb-adapter.test.ts (14): CRUD, prefix filter, replication, lifecycle
- profile-storage-provider.test.ts (22): key translation, cache-only, encryption
- profile-token-storage-provider.test.ts (30): write-behind, multi-bundle,
  sync, replication events, history, createForAddress, flush retry

Migration + Integration (25 tests):
- migration.test.ts (16): 6-step flow, _sent merge, nametag/forked extraction,
  phase tracking, crash recovery, sanity check, edge cases
- integration.test.ts (9): full lifecycle, multi-device, factory wiring
@MastaP MastaP requested a review from Copilot April 9, 2026 13:46
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds new UXF and Profile modules to sphere-sdk, including OrbitDB-backed profile storage, encryption utilities, platform factories, package export entry points, and extensive unit tests/documentation.

Changes:

  • Introduces OrbitDB adapter + Profile provider wiring (browser/node/shared factory) with optional OrbitDB/Helia peer dependencies.
  • Implements Profile encryption/error types and key mapping/types for Profile persistence.
  • Adds a large battery of Profile unit tests and UXF/Profile documentation/spec material.

Reviewed changes

Copilot reviewed 38 out of 70 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tests/unit/profile/profile-storage-provider.test.ts Adds unit tests for key mapping, cache vs OrbitDB behavior, encryption, and special cases.
tests/unit/profile/orbitdb-adapter.test.ts Adds tests for a mock ProfileDatabase contract (named as OrbitDbAdapter tests).
tests/unit/profile/migration.test.ts Adds unit tests covering legacy → Profile migration phases and edge cases.
tests/unit/profile/integration.test.ts Adds end-to-end mock integration tests for Profile providers + migration + factory behavior.
tests/unit/profile/errors.test.ts Adds unit tests for ProfileError formatting and codes.
tests/unit/profile/encryption.test.ts Adds unit tests for HKDF/AES-GCM encryption helpers.
profile/types.ts Adds Profile/OrbitDB config types, key mapping constants, and computeAddressId.
profile/orbitdb-adapter.ts Adds dynamic-import OrbitDB/Helia adapter implementing ProfileDatabase.
profile/encryption.ts Adds HKDF-derived key + AES-256-GCM encryption for OrbitDB values.
profile/errors.ts Adds structured ProfileError and error codes.
profile/factory.ts Adds shared factory wiring ProfileStorageProvider + ProfileTokenStorageProvider.
profile/browser.ts Adds browser factory using IndexedDB storage as local cache.
profile/node.ts Adds Node.js factory using file storage as local cache.
profile/index.ts Adds Profile public API barrel exports.
package.json Adds new exports (./uxf, ./profile/*) + IPLD dependencies + optional peer deps for OrbitDB/Helia.
index.ts Re-exports UXF/Profile types from the root barrel (type-only).
docs/uxf/* Adds UXF/Profile specifications, research, design decisions, and test plans.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread profile/types.ts
Comment on lines +151 to +153
* from the wallet master key via HKDF.
*
* profileEncryptionKey = HKDF(masterKey, "uxf-profile-encryption", 32)
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HKDF derivation description is inconsistent with profile/encryption.ts (which uses a domain-specific salt "sphere-profile-v1" plus PROFILE_HKDF_INFO = "uxf-profile-encryption"). Update this comment to reflect the actual derivation inputs (salt + info) so readers don’t implement an incompatible key derivation.

Suggested change
* from the wallet master key via HKDF.
*
* profileEncryptionKey = HKDF(masterKey, "uxf-profile-encryption", 32)
* from the wallet master key via HKDF using the fixed salt
* "sphere-profile-v1" and the HKDF info string "uxf-profile-encryption".
*
* profileEncryptionKey = HKDF(masterKey, salt="sphere-profile-v1", info="uxf-profile-encryption", 32)

Copilot uses AI. Check for mistakes.
Comment on lines +383 to +389
try {
const hashModule: any = await import('@noble/hashes/sha256' as string);
const hash: Uint8Array = hashModule.sha256(hexToBytes(privateKeyHex));
return bytesToHex(hash).slice(0, 16);
} catch {
// Both mandatory dependencies are missing
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback dynamic import path @noble/hashes/sha256 is likely incorrect/incompatible with how the repo imports Noble hashes elsewhere (e.g. @noble/hashes/sha2.js). If this import fails at runtime, derivePublicKeyShort() will throw even when hashes are installed, breaking OrbitDB connect in environments where the curves import fails. Align this import with the project’s working Noble entrypoint (e.g. import from @noble/hashes/sha2.js and use sha256).

Copilot uses AI. Check for mistakes.
Comment on lines +202 to +229
const allEntries = await this.db.all();

// allEntries may be an object, a Map, or an async iterable depending
// on the OrbitDB version. Handle each case.
if (allEntries && typeof allEntries[Symbol.asyncIterator] === 'function') {
for await (const entry of allEntries) {
const entryKey: string = entry.key ?? entry[0];
const entryValue = entry.value ?? entry[1];
if (prefix && !entryKey.startsWith(prefix)) {
continue;
}
result.set(entryKey, coerceToUint8Array(entryValue));
}
} else if (allEntries instanceof Map) {
for (const [entryKey, entryValue] of allEntries) {
if (prefix && !entryKey.startsWith(prefix)) {
continue;
}
result.set(entryKey, coerceToUint8Array(entryValue));
}
} else if (typeof allEntries === 'object' && allEntries !== null) {
for (const [entryKey, entryValue] of Object.entries(allEntries)) {
if (prefix && !entryKey.startsWith(prefix)) {
continue;
}
result.set(entryKey, coerceToUint8Array(entryValue as Uint8Array | Record<string, number>));
}
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all() handles async iterables, Map, and plain objects, but not synchronous iterables (Symbol.iterator). If OrbitDB returns a sync iterable (common pattern for collections), this will currently fall through and return an empty result. Add a Symbol.iterator branch (between async iterator and Map) to support sync iterables.

Copilot uses AI. Check for mistakes.
if (config.directory) {
heliaOptions.directory = config.directory;
}

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OrbitDbConfig includes bootstrapPeers and enablePubSub, but connect() currently ignores them when building Helia/libp2p options. This makes those public config fields misleading (callers can set them with no effect). Either wire them through to Helia/libp2p configuration (preferred) or remove them from OrbitDbConfig until supported.

Suggested change
if (Array.isArray(config.bootstrapPeers) && config.bootstrapPeers.length > 0) {
throw new ProfileError(
'UNSUPPORTED_ORBITDB_CONFIG',
'OrbitDbConfig.bootstrapPeers is not yet supported by OrbitDbAdapter.connect()',
);
}
if (config.enablePubSub === true) {
throw new ProfileError(
'UNSUPPORTED_ORBITDB_CONFIG',
'OrbitDbConfig.enablePubSub is not yet supported by OrbitDbAdapter.connect()',
);
}

Copilot uses AI. Check for mistakes.

// ---------- ProfileDatabase implementation ----------

async connect(config: OrbitDbConfig): Promise<void> {
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OrbitDbAdapter is newly introduced and has substantial behavior (dynamic import error handling, connection lifecycle cleanup, replication subscription, all() coercion). The added tests/unit/profile/orbitdb-adapter.test.ts currently only tests an in-memory mock DB and does not exercise OrbitDbAdapter at all. Add unit tests covering connect() failure modes, put/get/del/all, onReplication() subscription/unsubscribe, and close() idempotency/cleanup (using dynamic import mocks as the file header suggests).

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +124
function createMockCache(): StorageProvider & { _store: Map<string, string>; _trackedAddresses: TrackedAddressEntry[] } {
const store = new Map<string, string>();
let trackedAddresses: TrackedAddressEntry[] = [];

return {
id: 'mock-cache',
name: 'Mock Cache',
type: 'local' as const,
description: 'In-memory mock cache',
async connect() {},
async disconnect() {},
isConnected() {
return true;
},
getStatus() {
return 'connected' as const;
},
setIdentity(_identity: FullIdentity) {},
async get(key: string) {
return store.get(key) ?? null;
},
async set(key: string, value: string) {
store.set(key, value);
},
async remove(key: string) {
store.delete(key);
},
async has(key: string) {
return store.has(key);
},
async keys(prefix?: string) {
const all = Array.from(store.keys());
if (!prefix) return all;
return all.filter((k) => k.startsWith(prefix));
},
async clear(prefix?: string) {
if (!prefix) {
store.clear();
} else {
for (const k of store.keys()) {
if (k.startsWith(prefix)) store.delete(k);
}
}
},
async saveTrackedAddresses(entries: TrackedAddressEntry[]) {
trackedAddresses = entries;
},
async loadTrackedAddresses() {
return trackedAddresses;
},
_store: store,
_trackedAddresses: trackedAddresses,
} as StorageProvider & { _store: Map<string, string>; _trackedAddresses: TrackedAddressEntry[] };
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_trackedAddresses is initialized as a snapshot and won’t reflect later saveTrackedAddresses() calls (which update the closed-over trackedAddresses variable). This makes test mutations like (cache as any)._trackedAddresses = undefined misleading and can mask cache-miss behaviors. Consider exposing _trackedAddresses via a getter/setter tied to the internal trackedAddresses variable (or drop the _trackedAddresses field entirely and only use saveTrackedAddresses/loadTrackedAddresses).

Copilot uses AI. Check for mistakes.
Comment thread profile/browser.ts
Comment on lines +80 to +88

// Create IndexedDB provider as the local cache
const localCache = createIndexedDBStorageProvider();

// Build the full ProfileConfig from network defaults + overrides
const profileConfig: ProfileConfig = {
orbitDb: {
privateKey: '', // Set later via setIdentity()
...(config.profileConfig?.orbitDb ?? {}),
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OrbitDbConfig.privateKey is defined as a required field, but the browser factory constructs it with an empty string placeholder. This makes the config shape internally inconsistent and risks connecting to/deriving a DB identity from invalid key material if connect() is called before setIdentity(). A clearer contract would be either: (1) make orbitDb.privateKey optional in ProfileConfig and require setIdentity() prior to connect(), or (2) have connect() explicitly fail fast with a ProfileError when the private key is missing/empty.

Suggested change
// Create IndexedDB provider as the local cache
const localCache = createIndexedDBStorageProvider();
// Build the full ProfileConfig from network defaults + overrides
const profileConfig: ProfileConfig = {
orbitDb: {
privateKey: '', // Set later via setIdentity()
...(config.profileConfig?.orbitDb ?? {}),
const privateKey = config.profileConfig?.orbitDb?.privateKey;
if (typeof privateKey !== 'string' || privateKey.trim() === '') {
throw new Error(
'Browser Profile providers require a non-empty orbitDb.privateKey in profileConfig before initialization.',
);
}
// Create IndexedDB provider as the local cache
const localCache = createIndexedDBStorageProvider();
// Build the full ProfileConfig from network defaults + overrides
const profileConfig: ProfileConfig = {
orbitDb: {
...(config.profileConfig?.orbitDb ?? {}),
privateKey,

Copilot uses AI. Check for mistakes.
Comment thread profile/node.ts
Comment on lines +89 to +92
// Build the full ProfileConfig from network defaults + overrides
const profileConfig: ProfileConfig = {
orbitDb: {
privateKey: '', // Set later via setIdentity()
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the browser factory: OrbitDbConfig.privateKey is required but initialized to ''. This should be modeled explicitly in the types and/or enforced at runtime (e.g., require setIdentity() and reject connect() until a valid private key is available), otherwise consumers can end up with a deterministic-but-wrong OrbitDB identity/dbName.

Suggested change
// Build the full ProfileConfig from network defaults + overrides
const profileConfig: ProfileConfig = {
orbitDb: {
privateKey: '', // Set later via setIdentity()
const orbitDbPrivateKey = config.profileConfig?.orbitDb?.privateKey;
if (typeof orbitDbPrivateKey !== 'string' || orbitDbPrivateKey.trim() === '') {
throw new Error(
'createNodeProfileProviders requires profileConfig.orbitDb.privateKey to be set to a non-empty value before connecting to OrbitDB.',
);
}
// Build the full ProfileConfig from network defaults + overrides
const profileConfig: ProfileConfig = {
orbitDb: {
privateKey: orbitDbPrivateKey,

Copilot uses AI. Check for mistakes.
Consolidation engine (profile/consolidation.ts):
- 10-step consolidation flow: merge active bundles → pin → supersede
- Concurrent guard (5-min TTL pending key)
- Crash recovery on startup
- Expired bundle cleanup (profile-only, no IPFS unpinning)
- Configurable retention period (default 7 days, min 24h)

OrbitDB adapter fixes (found by live integration test):
- Fix @noble/curves import: secp256k1 → secp256k1.js (v2.x ESM)
- Fix @noble/hashes import: sha256 → sha2.js (v2.x rename)
- Add @chainsafe/libp2p-gossipsub for OrbitDB v3 Sync
- Fix all() to handle OrbitDB v3 array return format

Live integration test (tests/integration/orbitdb-adapter.test.ts):
- 17 tests with real OrbitDB v3 + Helia v6
- put/get/del/all round-trip, prefix filtering, replication
- Validates the adapter works with actual OrbitDB (not mocks)

Doc updates:
- SPECIFICATION.md: Appendix E (Multi-Bundle Protocol)
- ARCHITECTURE.md: Section 9 (Profile Module Integration)
@vrogojin
Copy link
Copy Markdown
Contributor Author

vrogojin commented Apr 9, 2026

Update: Follow-up items completed

Added since PR creation:

  1. Consolidation engine (profile/consolidation.ts) — full 10-step merge flow with concurrent guard, crash recovery, and expired bundle cleanup. No longer Phase 2 deferred.

  2. Live OrbitDB integration test (tests/integration/orbitdb-adapter.test.ts) — 17 tests with real OrbitDB v3 + Helia v6. Found and fixed 4 bugs in the adapter:

    • @noble/curves/secp256k1secp256k1.js (ESM v2.x)
    • @noble/hashes/sha256sha2.js (v2.x rename)
    • Missing @chainsafe/libp2p-gossipsub for OrbitDB v3 Sync
    • all() didn't handle OrbitDB v3 array return format
  3. Spec/Arch doc updates — SPECIFICATION.md Appendix E (Multi-Bundle Protocol), ARCHITECTURE.md Section 9 (Profile Module Integration)

  4. Main merged — up to date with v0.6.14

Updated stats:

  • 395 tests (378 unit + 17 integration), all passing
  • ~35K lines added across 75+ files
  • Consolidation engine no longer deferred

Remaining follow-up (separate PRs):

  • Nostr-OrbitDB replication bridge
  • Sphere app WalletRepository migration

vrogojin added 30 commits April 18, 2026 16:16
…-format

# Conflicts:
#	index.ts
#	package-lock.json
Per PROFILE-ARCHITECTURE.md Q2 decision: element payloads pinned to IPFS
are now unencrypted. Confidentiality is provided by the OrbitDB KV layer,
which continues to encrypt bundle refs and operational state with a
per-wallet key derived from HKDF.

Rationale: identical token pools produced by different wallets must hash
to the same CID so that cross-user content-addressed dedup works. If two
devices encrypt the same UXF bundle with different keys, dedup breaks.

Changes:
- profile/profile-token-storage-provider.ts: remove encrypt/decrypt on
  CAR bytes in flushToIpfs() and load(); update docstrings.
- profile/consolidation.ts: remove encrypt/decrypt on CAR bytes in the
  consolidate() fetch/merge/pin loop; update flow docstring.
- tests: update fixtures to feed unencrypted CARs to the IPFS mock;
  rewrite "CAR files are encrypted before pinning" as "CAR files are
  pinned unencrypted (content-addressed dedup)" with an inverted
  assertion.

All 378 UXF+Profile tests pass. tsc --noEmit passes.
…/history

Per user direction (Q1 decision refinement): _tombstones, _sent, and
_history are per-device local state, never replicated between Sphere
instances sharing the wallet. Rationale: sync errors or rogue instances
would otherwise poison these authoritative ledgers everywhere at once.
The token pool itself remains the source of truth.

Architecture:
- Synced to OrbitDB (authoritative across instances):
    outbox, invalid, mintOutbox, invalidatedNametags
- Local cache only (per-device, never replicated):
    tombstones, sent, history
- Cached in the injected StorageProvider under keys
    deriver.{addressId}.{tombstones|sent|history}

Changes:
- New profile/deriver.ts: pure functions to rebuild derived caches from
  archived tokens in a TxfStorageDataBase (best-effort fallback for
  fresh-install / cache-corruption recovery). Oracle-based spent-checks
  left as a future enhancement layered on manifest derivation (task #27).
- ProfileTokenStorageProvider: accept optional localCache: StorageProvider.
  Split writeOperationalState → writeOrbitOperationalState +
  writeLocalDerivedCache; matching split for reads. On load, rebuild from
  pool if local cache is empty and archived tokens are available.
- Factory wires cacheStorage through as the local cache.

Limitations noted in deriver.ts docstring:
  UXF round-trip in Profile storage currently strips the 'archived-'
  prefix (tokens become _<hexTokenId>); rebuild-from-pool therefore
  yields empty when the data has already round-tripped. PaymentsModule
  continues to write through to the cache imperatively, so steady-state
  is correct; only fresh-install recovery is limited until task #27
  adds predicate-based status derivation.

Tests:
- New tests/unit/profile/deriver.test.ts (14 tests) — pure-function
  coverage for tombstones/sent/history derivation.
- Updated profile-token-storage-provider tests:
  * tombstones/sent no longer expected in OrbitDB on save
  * derived cache fields asserted in the mock local store
  * readOperationalState test seeds both OrbitDB (outbox) and
    local cache (tombstones/sent)
  * new test: full save→local-cache round-trip for derived keys
  * new test: load() triggers rebuild fallback when cache is empty

Full suite: 394 UXF+Profile tests pass; 199 PaymentsModule tests pass;
tsc --noEmit clean.
PROFILE-ARCHITECTURE §10.4 specifies JOIN as "longest valid chain per
tokenId, sibling-preserve on divergence". This is already done
structurally by UxfPackage.merge() (which calls mergeInstanceChains per
Decision 6 in uxf/instance-chain.ts).

This commit:
- Replaces the terse merge comment with an explicit JOIN contract at
  the call site in ProfileTokenStorageProvider.load(), including a
  forward reference to task #27 for oracle-based conflict resolution.
- Adds a unit test proving that same-id tokens from two bundles plus
  bundle-unique tokens all survive the merged load — no dropped state.

Oracle-based status flagging (valid/conflicting/invalid) is the
wallet-layer token manifest derivation, scoped to task #27.
Per PROFILE-ARCHITECTURE.md §10.2.2 / §10.11, the wallet-level *token
manifest* is a derived map from tokenId → { rootHash, status,
conflictingHeads?, invalidReason? }. This commit ships the structural
half of that derivation:

- **status: 'valid'**        when a single chain head is reachable
- **status: 'conflicting'**  when the instance-chain index holds sibling
                             heads sharing a common ancestor (the
                             divergence-preserving output of JOIN)
- **status: 'pending' / 'invalid'**  reserved for the oracle-aware layer

The structural pass is pure and synchronous — no network calls. The
oracle-aware pass will post-process the output with aggregator
non-inclusion checks (see §10.6 Steps 3-4) and is a follow-up task.

Files:
- profile/token-manifest.ts      — pure deriver + types + conflict helper
- profile/index.ts                — public exports
- profile/profile-token-storage-provider.ts
    · invokes the deriver after JOIN during load()
    · caches result; new public method getTokenManifest()
    · wrapped in try/catch so deriver bugs cannot sink a storage load
- docs/uxf/PROFILE-ARCHITECTURE.md
    · renamed conflictingRoots → conflictingHeads throughout (matches
      implementation semantics: these are chain heads, not genesis
      roots)

Tests (10 new):
- tests/unit/profile/token-manifest.test.ts (6) — hand-built chain
  index fixtures, covers single/sibling/multi-sibling cases
- tests/unit/profile/token-manifest-integration.test.ts (3) — against
  the real UxfPackage with real token fixtures

Full suite: 404 UXF+Profile tests pass; tsc --noEmit clean.
…he hardening

Fixes findings from /steelman adversarial review of the session's 4 commits.

## Critical

**1. CID content-address verification on fetched CAR bytes**
  `profile/ipfs-client.ts` now recomputes sha256(bytes) and compares
  against the multihash digest in the requested CID before returning.
  Without CAR encryption, this is the sole authentication boundary
  against a malicious or compromised IPFS gateway. Exported as
  `verifyCidMatchesBytes()` for reuse.

  Applies transitively to `consolidate()` (which also uses fetchFromIpfs).
  sha2-256 is the only multihash code accepted; anything else is
  rejected as unsupported.

**2. Manifest JOIN: cross-token hash-collision over-match**
  `profile/token-manifest.ts collectHeads()` previously treated ANY
  hash overlap as a sibling signal, so two different tokens sharing a
  common mid-chain element (e.g. a shared predicate) could be flagged
  as each other's conflicts. Post-fix, relatedness is tail-anchored:
  only chains whose tail hash equals the primary chain's tail are
  considered siblings.

## Warnings

**3. Rebuild-race dedup**
  Concurrent load() calls that both hit the empty-cache path no longer
  race each other. A singleton `rebuildPromise` deduplicates — second
  caller awaits the first's promise.

**4. Partial-write atomicity**
  `writeLocalDerivedCache` now persists `{tombstones, sent, history}`
  into a single atomic key `deriver.{addressId}.all` instead of three
  separate writes. Eliminates the "one field written, disk full before
  the next" window. The read path prefers the atomic key but falls
  back to the legacy per-key layout so pre-atomic caches keep working
  until the next write heals them.

**5. lastPinnedCid invalidation on overwrite**
  `save()` now clears `lastPinnedCid` whenever `pendingData` is
  replaced with different data. Prevents a retry after a partially
  failed flush from re-registering an OLD CID with NEW token data.

**6. Surface silent swallows via storage:error events**
  `writeLocalDerivedCache` and `readLocalJson` now emit
  `storage:error` events in addition to logging, and
  `writeLocalDerivedCache` returns a boolean so callers can react.

## Tests (8 new, 412 total)

- `tests/unit/profile/ipfs-client-cid-verify.test.ts` (7 tests)
    · verifyCidMatchesBytes accepts match
    · rejects tampered / truncated bytes
    · rejects unparseable CIDs
    · rejects non-sha256 multihashes
    · fetchFromIpfs end-to-end verifies mismatch / accepts match
- `tests/unit/profile/token-manifest.test.ts` — new regression test:
    · "does NOT mis-attribute sibling heads across tokens that share
       a mid-chain hash" — asserts the tail-anchor fix.

All prior test fixtures updated to use real content-addressed CIDs
(`cidForBytes(bytes)` helper) so the CID verification path is exercised
on every fetch call. The new single-key `deriver.{addressId}.all`
layout replaces the three per-key reads/writes in fixtures.

Full suite: 412 UXF+Profile tests pass; 199 PaymentsModule tests pass;
tsc --noEmit clean.
…n invalidation, cache hardening

Round-2 steelman on commit 21176ce surfaced 2 CRITICAL findings that
the first round had introduced while fixing the previous round.
Recursion pays.

## Critical

**1. Multi-gateway fallback broken for CID-mismatch (regression from 21176ce)**
  `fetchFromIpfs` wrapped the whole gateway-body in `catch (err) { if
  (err instanceof ProfileError) throw err; }`. The newly-added
  `verifyCidMatchesBytes` also throws ProfileError on mismatch, so a
  single tampering gateway would hard-abort the request instead of
  falling through to the next gateway. That turned a defense into a
  DoS vector.

  Fix: split verification into its own try/catch. CID mismatch →
  record as lastError, continue to next gateway. Size-limit errors
  remain fatal (they are per-request, not per-gateway). New test
  proves first-gateway-tampered → second-gateway-good succeeds.

**2. lastPinnedCid reference-identity check was insufficient**
  The previous fix cleared `lastPinnedCid` only when `pendingData !==
  data`. But `flushToIpfs` on failure re-queues the SAME reference,
  so a caller that mutates in place and re-saves satisfies
  `pendingData === data`, skips the clear, and the next flush
  re-registers the stale CID against mutated content.

  Fix: any new `save()` unconditionally clears `lastPinnedCid`. The
  one-extra-pin cost on legitimate retries is trivial; correctness
  of "the pinned CID always matches the currently flushed bytes" is
  worth it. Test renamed + updated to assert the new invariant.

## Warnings

**3. load() during in-flight flush returned stale snapshot**
  Between `this.pendingData = null` and the OrbitDB bundle-ref write,
  `load()` would read the pre-flush state and miss the in-flight
  bundle. Fixed by awaiting `flushPromise` before reading OrbitDB.

**4. Legacy per-key entries never cleaned after atomic migration**
  On the first successful atomic-key write, best-effort delete the
  three legacy keys (`deriver.{addr}.tombstones|sent|history`).
  Guarded by a `legacyKeysCleaned` flag so subsequent saves don't
  repeat the cleanup.

**5. storage:error event flood on cache corruption**
  `readLocalDerivedCache` could emit up to 4 events per call (1
  atomic + 3 legacy fallback) when the whole cache was corrupt.
  Rewritten to collect failed keys locally and emit at most one
  aggregate event per call.

**6. rebuildPromise result shared by reference across awaiters**
  Two concurrent load() callers would receive the same
  `{tombstones, sent, history}` object — if one mutated an array,
  the other saw the mutation. Fixed by shallow-cloning per awaiter.

**7. writeLocalDerivedCache return value was ignored**
  The boolean existed but no caller consumed it. `flushToIpfs` now
  logs a degraded-state warning on false so reliability telemetry
  captures it; `rebuildDerivedCacheInner` still awaits unconditionally.

## Tests (2 new, 414 total)

- ipfs-client-cid-verify.test.ts:
  · "multi-gateway fallback tries the next gateway when the first
     returns tampered bytes" — directly proves critical #1 fix
  · "fails with a clear error when ALL gateways return tampered bytes"

All prior tests still pass; one was renamed and inverted to assert
the new `lastPinnedCid` invariant ("save() after OrbitDB-write
failure re-pins").

tsc --noEmit clean.
Send/receive between wallets uses TXF objects as the wire format
(PaymentsModule.ts:1247 et al — outgoing payload is
\`JSON.stringify(sdkToken.toJSON())\`, incoming parser feeds the same
JSON into \`SdkToken.fromJSON\`). The UXF storage layer deconstructs
tokens into content-addressed elements and reassembles on load; for
compatibility with TXF-only peers (older Sphere, third-party wallets)
every wire-format field MUST survive that round-trip.

This suite adds the explicit compat gate so any future change that
silently drops or alters a TXF field fails here instead of at a user's
wallet 3 months later.

Coverage (8 tests, all passing):

- TOKEN_A/B/C ingest → assemble preserves:
  genesis.data {tokenId, tokenType, coinData, tokenData, salt,
                recipient, recipientDataHash, reason}
  genesis.inclusionProof {authenticator, merkleTreePath,
                          transactionHash, unicityCertificate}
  state {predicate, data}
  every transaction's {sourceState, destinationState, data,
                       inclusionProof, recipient}
- NAMETAG_ALICE: tokenData (the hex-encoded name) survives → nametag
  resolution keeps working after bundles go through UxfPackage.
- Full CAR round-trip (ingest → toCar → fromCar → assemble): this is
  what ProfileTokenStorageProvider executes; every field above is
  re-checked end-to-end.
- Rebuilt token is JSON.stringify-able and reparses to the same shape
  — this is exactly what the send path feeds into sendTokenTransfer.
- tokenToTxf / txfToToken helpers (used by legacy send paths and
  IndexedDB/File storage) continue to round-trip wire-compatible data.

Verification:
- 8 new tests pass
- PaymentsModule suite: 199 passing (unchanged)
- Serialization suite: 106 passing (unchanged)

Net conclusion: the UXF storage layer does not alter the wire format.
Profile-backed wallets can still send to and receive from TXF-only
peers, and can still use the legacy TXF serialization helpers.
Adds explicit CLI commands for offline token file transfer / backup.
The underlying building blocks (UxfPackage.toCar/fromCar, tokenToTxf /
txfToToken) already existed — this commit wires them up as first-class
PaymentsModule helpers and CLI commands.

## Commands

**tokens-export <file> [options]**
  - Format auto-detected from extension:
      .uxf or .car  → UXF CAR (content-addressable, new)
      .txf or .json → TXF JSON array (legacy, backward-compatible)
  - --format uxf|txf|json   Override detection
  - --coin <symbol>         Filter by coin (accepts symbol or coinId hex)
  - --ids <id1,id2,...>     Filter by local token IDs
  - --include-unconfirmed   Include provisional tokens

**tokens-import <file> [--format auto|uxf|txf|json]**
  - Auto-detect by content (JSON starts with '[' or '{'; CAR starts
    with a varint byte not in the printable range)
  - Reports: added / skipped / rejected with per-token reasons

## SDK surface

Two new PaymentsModule methods:

    exportTokens(options?): Array<{ localId, genesisTokenId, txf }>
    importTokens(txfTokens): { added, skipped, rejected }

- exportTokens filters by ids / coinId / includeUnconfirmed, extracts
  the TxfToken from each Token's sdkData via the existing tokenToTxf
  helper, and returns triples.
- importTokens delegates dedup to addToken() (which enforces the
  tombstone + (tokenId, stateHash) guard): already-owned tokens are
  skipped, previously-spent tokens are skipped, malformed inputs are
  rejected with a reason. Each import assigns a fresh local UUID so
  imported tokens don't collide with existing wallet IDs.

## Tests (12 new, 3000 total passing)

tests/unit/modules/PaymentsModule.importExport.test.ts:
- exportTokens: empty wallet, default (confirmed only), coinId filter,
  ids filter, includeUnconfirmed, TXF field preservation.
- importTokens: adds all, idempotence (re-import skipped), tombstone
  rejection (imported token that was previously spent is skipped),
  malformed input rejection (missing tokenId, missing state),
  export→import round-trip between two wallets.

CLI smoke tests verified:
- `tokens-export` with no args prints usage
- `tokens-import` with no args prints usage
- `help` lists the new commands under "FILE I/O"

Full unit suite: 3000/3000 passing. tsc --noEmit clean.
New wallets initialised through the CLI now use the OrbitDB-backed
Profile storage by default — content-addressed UXF element pool on IPFS
plus encrypted OrbitDB KV for operational state. Existing wallets with
file-based artefacts on disk are detected and continue using legacy.

## Behaviour

Resolution logic (runs once on first CLI invocation per dataDir,
result cached in config.storageMode):

  1. Explicit --legacy / --profile on \`init\` wins.
  2. config.storageMode already set → honour it (mode is locked per
     wallet; migration is a separate workflow).
  3. Legacy wallet.json present in dataDir → legacy (upgrade path for
     pre-setting users).
  4. Fresh wallet + @orbitdb/core + helia importable → profile.
  5. Profile deps missing → fall back to legacy with a one-line note.

Once resolved, the mode is persisted to .sphere-cli/config.json so all
subsequent commands stay consistent. \`clear\` resets it so a fresh
re-init can pick again.

## CLI surface

**init** gains two mutually-exclusive flags:

  --legacy    Force file-based JSON wallet + IPNS sync (pre-UXF format)
  --profile   Force OrbitDB Profile (errors out if deps missing)

Re-running \`init\` on a dataDir whose committed mode conflicts with an
explicit flag exits with a clear error — no silent clobbering.

**status** now prints \`Storage: profile|legacy|(auto-detect)\` so users
can see which backend their wallet is on.

**clear** tears down the correct backend based on the stored mode
(Profile adapter for profile wallets, file providers for legacy) and
then drops config.storageMode so the next init is fresh.

## Wiring

getSphere() branches on the resolved mode:

- profile → createNodeProfileProviders (storage + tokenStorage) +
           createNodeProviders for transport/oracle/market/groupChat/
           L1/price (with ipfs tokenSync disabled — OrbitDB
           replicates state).
- legacy  → createNodeProviders as before.

## Tests

Full unit suite: 3000/3000 passing. Typecheck clean.

Mode resolution is tested indirectly through existing wallet-lifecycle
flows. A dedicated helper-level test would require mocking fs + dynamic
imports; deferred since the logic is small and well-commented.
New Profile (OrbitDB) storage mode is now the CLI default; the runtime
libraries it needs must be installed by default, not relegated to
optional/peer. This commit migrates them to direct deps so \`npm install\`
is enough to get Profile working.

Moved to dependencies:
  - @orbitdb/core       (OrbitDB KV database)
  - helia               (IPFS node runtime)
  - @libp2p/bootstrap   (peer discovery; used by orbitdb-adapter)
  - @libp2p/crypto      (IPNS key derivation — legacy IPFS sync)
  - @libp2p/peer-id     (peer identity)
  - ipns                (IPNS record management)
  - multiformats        (CID parsing / sha256-hashed content address)

Retired peer/optional declarations; only \`ws\` remains as an optional
peer for the Node transport.

Verification:
  - npm install clean
  - node -e "require('@orbitdb/core'); require('helia')" → OK
  - tsc --noEmit clean
  - Full unit suite: 3000/3000 passing
Two new test files covering the CLI storage-mode machinery and the
legacy↔profile storage equivalence at the PaymentsModule level.

## cli/storage-mode.ts (extracted)

Moved the storage-mode resolver out of cli/index.ts into its own
module with dependency injection so it is unit-testable without
touching the real filesystem or node_modules. The CLI now imports
\`resolveStorageMode\` + the default probes from this module; behaviour
is unchanged. New option: \`onExplicitProfileMissing: 'exit' | 'throw'\`
lets tests exercise the failure path without calling process.exit().

## tests/unit/cli/storage-mode.test.ts (9 tests)

Covers every branch of the resolution state machine:
  - explicit --profile / --legacy wins and is persisted
  - deps-missing --profile throws (when onExplicitProfileMissing=throw)
  - no-op persist when explicit matches existing config
  - committed config.storageMode is honoured without probing
  - existing legacy wallet.json on disk → legacy
  - pristine + deps available → profile (new default)
  - pristine + deps missing → legacy + notify
  - repeat resolutions: persist is not double-called

## tests/unit/modules/PaymentsModule.dual-mode.test.ts (11 tests)

Parameterised describe.each over two in-memory TokenStorageProvider
implementations:

  - legacy-style (TxfStorageDataBase saved verbatim, like FileTokenStorage)
  - profile-style (real UxfPackage.toCar / fromCar round-trip, like
    ProfileTokenStorageProvider but without OrbitDB/IPFS I/O)

For each backend:
  - addToken persists and getTokens sees them
  - exportTokens emits wire-compatible TXF preserving every field
  - import → export round-trip on a fresh wallet
  - tombstone-on-remove blocks re-import

Plus 3 cross-mode tests proving send/receive TXF wire compat:
  - legacy wallet exports → profile wallet accepts (and owns)
  - profile wallet exports → legacy wallet accepts (and owns)
  - UXF CAR round-trip preserves every wire-format field exactly

This is the explicit coverage gate: any change that breaks storage
backend swappability or wire-format preservation will fail here.

Verification:
  - Full unit suite: 3020/3020 passing (+20 new)
  - No stderr noise on the profile UXF round-trip path
  - tsc --noEmit clean
## E2E CLI (tests/e2e/cli-storage-modes.sh)

New bash script covering the CLI storage-mode machinery end-to-end.
Seven subtests, gated sensibly between network-free and testnet-requiring:

Network-free (run unconditionally):
  1. \`help init\` documents --legacy + --profile flags
  2. \`config\` on a fake-legacy dataDir shows no storageMode before
     the resolver runs

Network-gated (require E2E_NETWORK=1 + testnet access):
  3. \`init --legacy\` persists storageMode=legacy, creates wallet.json,
     status prints "Storage: legacy"
  4. \`init --profile\` persists storageMode=profile, creates orbitdb/
     directory, status prints "Storage: profile"
  5. Re-init with mismatched mode is rejected with a clear error
  6. \`clear\` resets storageMode for next init
  7. tokens-export works in both modes

Pass count is printed as 2/2 in the default environment (network-free).

## Docs

docs/QUICKSTART-CLI.md:
  - New "Storage Mode (profile vs legacy)" subsection under
    "First-Time Wallet Initialization" explaining defaults,
    detection rules, and the --legacy/--profile flags
  - "Where Data is Stored" now shows both legacy and profile layouts
  - "Show Wallet Status" example updated with the new "Storage:" line
  - "Delete Wallet Data" clarifies clear is mode-aware and resets
    storageMode
  - New "Offline Token Transfer (export / import to file)" section
    covering tokens-export / tokens-import with both formats

docs/API.md:
  - PaymentsModule: new entries for exportTokens() / importTokens()
    with full option/return types and semantics

CLAUDE.md:
  - Dependencies section lists the newly direct OrbitDB/Helia deps
    so contributors know Profile mode is built-in, not optional

## Verification

  - 3020/3020 unit tests passing (one occasional flaky AccountingModule
    test unrelated to this change)
  - tsc --noEmit clean
  - bash tests/e2e/cli-storage-modes.sh: 2/2 network-free passes
  - \`completions bash\` output includes tokens-export, tokens-import,
    --legacy, --profile, --format, --coin, --ids, --all,
    --include-unconfirmed
…afety

Round-3 steelman flagged a silent-corruption path in the storage-mode
resolver plus several practical issues in the import command.

## Critical

**1. Profile vs legacy detection collision (silent data corruption)**
  ProfileTokenStorageProvider uses a FileStorageProvider for its local
  cache, which writes \`{dataDir}/wallet.json\` — exactly the file
  legacyWalletProbe inspects. So a Profile-populated dir would falsely
  match the legacy probe whenever config.storageMode was absent
  (corrupt config, hand-edit, post-clear crash). Combined with #2
  this could let \`init --legacy\` overwrite encrypted Profile data
  with a fresh legacy wallet.

  Fix: introduce \`defaultProfileWalletProbe\` that looks for the
  Profile-specific \`{dataDir}/orbitdb/\` directory. Disk-state
  detection now disambiguates (Profile takes precedence) and the
  explicit-mode branch refuses to clobber the wrong shape.

**2. Non-atomic clear leaves stale storageMode**
  clear() previously deleted wallet data BEFORE unsetting
  config.storageMode. A crash between the two left the config pointing
  at gone data.

  Fix: reset config.storageMode FIRST (a single-key write is atomic at
  the FS level), THEN delete the data. Worst case after crash: the
  config has no committed mode, the next init re-detects from disk
  (or defaults to profile on a pristine dir).

**3. Mismatch check bypass when config.storageMode absent**
  The init handler's pre-existing mismatch check fires only when
  config.storageMode is recorded. Combined with #1, hand-deleting
  config but keeping disk artefacts let \`init --legacy\` succeed
  against a Profile dir.

  Fix: the resolver itself now refuses to commit a mode that
  contradicts disk-state, regardless of config. Tested with two
  regression cases.

**4. Probe accepts the wrong version of @orbitdb/core / helia**
  Module-load probe succeeded if the package was installed at all,
  even with an incompatible major. Runtime then failed with a cryptic
  \`Could not resolve createOrbitDB\`.

  Fix: after loading, verify the named exports (createOrbitDB,
  createHelia) exist. Returns a clear "incompatible version installed"
  reason on mismatch.

## High

**5. tokens-import has no size guard**
  fs.readFileSync on a malicious / mistyped path could OOM Node.
  Fix: stat the file first, refuse > 100MB.

**6. tokens-import returns exit 0 on partial failure**
  Rejected entries were reported but ignored by scripts/CI.
  Fix: set process.exitCode = 2 when result.rejected.length > 0.

**7. Empty-file import gave a confusing "Failed to parse UXF CAR"**
  Fix: check bytes.length === 0 explicitly and emit a clear error.

## Warnings

**8. Corrupt config.storageMode value not validated**
  Hand-edited "gibberish" was returned verbatim, then mismatched
  downstream string equality.
  Fix: validate against the known enum and fall through to
  auto-detection with a notify().

**9. Dead try/catch around resolveCoin() in tokens-export**
  resolveCoin actually exits, never throws, making the catch
  unreachable. The misleading "fall back to literal coinId" comment
  contradicted reality.
  Fix: explicit hex64 detection upfront, otherwise let resolveCoin's
  own error path fire.

**10. Unused @libp2p/interface devDep**
  Zero in-tree references. Dropped.

## Tests (+4 new, 3024/3024 passing)

tests/unit/cli/storage-mode.test.ts gains four regression cases:
  - --legacy refused when orbitdb/ exists on disk
  - --profile refused when only wallet.json (no orbitdb/) on disk
  - disk auto-detect: orbitdb/ wins over wallet.json
  - corrupt config.storageMode value falls through to detection

Verification:
  - tsc --noEmit clean
  - 3024/3024 unit tests passing
  - bash tests/e2e/cli-storage-modes.sh: 2/2 network-free
Adds the user-facing migration path from a legacy (file-based) Sphere
CLI wallet to the new Profile (OrbitDB) backend. Conceptually identical
to "import a TXF wallet file" — the source happens to be a live legacy
TokenStorageProvider instead of a file.

## Semantics (per user direction)

  - Explicit: invoked via the CLI command, never auto-run.
  - Non-destructive: legacy storage is preserved by default. Pass
    --delete-legacy to remove it after a successful zero-rejection
    import.
  - Re-runnable: every run produces the joint inventory of legacy +
    Profile, with addToken's tombstone + (tokenId, stateHash) dedup
    gating duplicates.
  - Identity-verified: refuses to migrate when legacy and Profile
    do not share the same encrypted-mnemonic blob. --no-verify
    overrides for advanced cases (same mnemonic, different password
    or salt).
  - Token statuses recalculated automatically by the Profile load
    path's structural manifest deriver and local cache deriver.

## SDK surface

profile/import-from-legacy.ts — \`importLegacyTokens(legacyStorage,
targetPayments, options)\` returns \`{ success, tokensFound,
tokensAdded, tokensSkipped, tokensRejected, rejections, durationMs }\`.

profile/index.ts:
  - exports \`importLegacyTokens\` + types
  - marks the existing \`ProfileMigration\` class as @deprecated
    (kept for backwards compat — new code should use the import API)

profile/migration.ts:
  - JSDoc @deprecated tag on the ProfileMigration class with a
    code example pointing at the new path

## CLI command

\`migrate-to-profile --legacy-dir <path> [--legacy-tokens <path>]
                  [--dry-run] [--delete-legacy] [--no-verify]\`

  - Refuses if current dataDir is in legacy mode.
  - Identity check via encrypted-mnemonic blob comparison.
  - Calls \`importLegacyTokens\` with the legacy file storage providers.
  - Reports counts; sets \`process.exitCode = 2\` on partial failure.
  - Cleanup only on \`--delete-legacy\` AND zero rejections AND not
    a dry-run.

Help registry, autocompletion, and \`printUsage\` updated.

## Docs

  - QUICKSTART-CLI.md: new "Migrate a Legacy Wallet to Profile" section
    with the recommended workflow, properties list, and sample output.
  - PROFILE-ARCHITECTURE.md §7.6: prepended a clear note about the
    new import-based model; moved the legacy 6-step flow under a
    "deprecated" subsection.

## Tests (+7 new, 3031/3031 passing)

tests/unit/profile/import-from-legacy.test.ts:
  - empty source → success with zero counts
  - extracts active / archived / forked tokens, skips operational keys
  - read-only contract: only load() called on legacy provider
  - idempotence: re-run with same source yields zero added
  - joint inventory: legacy adds on top of pre-existing Profile tokens
  - dry-run: reports counts without mutating target
  - load failure → success=false, no exception thrown

tsc --noEmit clean. CLI smoke (\`help migrate-to-profile\`, missing-args
usage, completions bash) verified.
…suse, same-dir guard

Round-4 steelman flagged two ship-blocking issues plus several supporting
correctness gaps in the migration feature.

## Critical (fixed)

**1. \`Sphere.clear\` destroys the running Profile sphere.**
  \`Sphere.clear({ storage: legacyStorage, ... })\` unconditionally
  awaits \`Sphere.instance.destroy()\` (core/Sphere.ts:1179-1183) — but
  the only \`Sphere.instance\` in our process IS the current Profile
  sphere we're migrating into. The trailing \`syncAfterWrite(sphere)\`
  + \`closeSphere()\` would then run against a destroyed instance,
  potentially losing the just-imported tokens.

  Fix: replace \`Sphere.clear\` with direct \`legacyTokenStorage.clear()\`
  + \`legacyStorage.clear()\` calls. These wipe ONLY the legacy data,
  leaving the running Profile untouched.

**2. State regression via addToken on legacy import.**
  PaymentsModule.addToken's CASE 2 (line 3893) silently ARCHIVES the
  current state of any token whose genesis tokenId already exists in
  the wallet, then installs the incoming state — regardless of which
  is newer. A legacy import carrying an OLDER state of a token Profile
  has already moved would silently regress Profile's state.

  Fix: extend \`importTokens\` with \`{ skipExistingGenesis: true }\`
  option. importLegacyTokens always passes it. Skip-with-clear-reason
  whenever a genesis tokenId already exists, regardless of stateHash.
  The wallet's authoritative current state is preserved.

  This also fixes the related "fork de-forking" path: \`_forked_*\`
  entries (which by definition share a genesis with active tokens)
  are now skipped via the same gate.

## High (fixed)

**3. Forked entries still passed through importTokens.**
  Fix: \`extractTxfTokensFromStorageData\` now routes \`_forked_*\`
  entries to a separate counter (\`forksSkipped\`) and excludes them
  from the import set. CLI surfaces the count and refuses
  \`--delete-legacy\` when forks were present (manual handling).

**4. Same-dir migration could wipe the current Profile.**
  \`--legacy-dir\` was passed verbatim to FileStorageProvider; if it
  resolved to the current Profile dataDir, a successful
  \`--delete-legacy\` would remove the Profile's wallet.json + tokens.
  Fix: \`path.resolve\` both legacy paths upfront and refuse when
  either equals the current Profile's dataDir or tokensDir.

**5. \`process.exit(1)\` bypassed cleanup \`finally\`.**
  Seven exit sites inside the try block leaked legacy file handles.
  Fix: route every error path through local \`earlyExitCode\` /
  \`earlyExitReason\` variables; exit() only after the finally has
  run disconnect/shutdown.

**6. No Profile-flush before legacy cleanup.**
  Cleanup ran immediately after \`importTokens\` returned, but the
  Profile's write-behind buffer might not have flushed to OrbitDB +
  IPFS yet. A crash in this window would lose both copies.
  Fix: \`await sphere.payments.waitForPendingOperations(); await
  sphere.payments.sync()\` BEFORE the legacy clear.

## Warnings (fixed)

**7. Path joining bug on Windows / multiple trailing slashes.**
  \`legacyDir.replace(/\\/$/, '') + '/tokens'\` is wrong for backslashes.
  Fix: \`path.join(legacyDir, 'tokens')\`.

**8. Silent rejection truncation.**
  \`rejections.slice(0, 100)\` lost rejection reasons silently.
  Fix: new \`rejectionsTruncated: boolean\` field; CLI prints
  "(... N more rejection reasons truncated)".

## Tests (+2 new, 3033/3033 passing)

tests/unit/profile/import-from-legacy.test.ts:
  - "strict mode: pre-existing genesis tokenId is preserved (no state
    regression)" — directly proves the addToken-CASE-2 gap is plugged.
  - "rejectionsTruncated flag is set when more than 100 rejections" —
    documents the truncation contract.
  - Updated "extracts active and archived tokens; counts forks
    separately" — reflects the new fork-skip behaviour.
  - All result-shape assertions extended with the two new fields.

PaymentsModule.importTokens gains \`{ skipExistingGenesis }\` option
documented in the JSDoc with the rationale.

Verification: tsc --noEmit clean; full suite 3033/3033.
Closes the two previously-deferred items from steelman round 4.

## proper-lockfile on the legacy dataDir during migration

Adds \`proper-lockfile\` as a runtime dep and uses it in the CLI's
\`migrate-to-profile\` command to prevent concurrent CLI processes (or
any cooperating tool) from mutating the legacy source under us
mid-import.

  - Lock target: \`{legacyDir}/wallet.json\` (the canonical legacy
    artifact); falls back to the dir if no wallet.json exists, in
    which case the identity-blob check below rejects the migration.
  - Retries: up to 10x with exponential-ish backoff (200ms→2s).
  - Stale lock release: 60s — recovers from killed processes that
    didn't clean up.
  - Released in the finally AFTER all storage providers have been
    disconnected, so the next process sees a fully closed source.

Acquire failure prints a clear "another sphere-cli is running on this
dataDir" hint with instructions for handling stuck locks.

## Granular skip codes in importTokens

\`PaymentsModule.importTokens\` now does pre-checks BEFORE delegating
to addToken so each per-token outcome carries a stable enum code
suitable for switch-on-code logic in CLI / scripting / UI:

Added:
  - 'added'         : token was new
  - 'state-replaced': lenient mode — prior state archived, new state
                      now authoritative

Skipped:
  - 'duplicate'     : exact (tokenId, stateHash) match already owned
  - 'tombstoned'    : (tokenId, stateHash) was previously spent
  - 'genesis-exists': strict mode — tokenId owned at a different state
  - 'unknown'       : addToken returned false despite the pre-checks

Rejected:
  - 'malformed'     : missing tokenId / state / genesis / etc.
  - 'add-failed'    : addToken threw

New types exported from PaymentsModule:
  - ImportAddedCode / ImportSkipCode / ImportRejectCode
  - ImportAdded / ImportSkipped / ImportRejected
  - ImportTokensResult

The legacy importLegacyTokens helper aggregates skipped entries into
\`skippedByCode\` for at-a-glance diagnostics.

The CLI's \`tokens-import\` and \`migrate-to-profile\` commands now print
per-code breakdowns instead of opaque "skipped: N (already owned /
tombstoned)" text.

## Tests (+3 new, 3036/3036 passing)

  - "reports duplicate skip code for an exact (tokenId, stateHash) match"
  - "reports genesis-exists skip code in strict mode for a different state"
  - "lenient mode: same-genesis import marks added entry as state-replaced"

Existing tombstone-skip test extended to assert \`code === 'tombstoned'\`.
All previously-existing assertions remain backward-compatible since
\`reason\` and \`genesisTokenId\` fields preserved.

tsc --noEmit clean. CLI smoke verified.
…d ImportAdded

Round-5 steelman returned WARNINGS (no CRITICAL). Addressing the
high/medium findings and leaving the low/docs-only items deferred.

## High (fixed)

**Lock coverage bypassed by --legacy-tokens override.**
The lock was only on \`{legacyDir}/wallet.json\`. If the user pointed
\`--legacy-tokens\` at a DIFFERENT directory, the real data path went
unprotected. Fixed: when \`legacyTokensDir\` is outside \`legacyDir\`,
acquire a second lock at that path as well; both are released
together in the finally.

**Lock path explicitly inside the legacy dataDir.**
Previously proper-lockfile created \`<target>.lock\` as a sibling of
\`wallet.json\` — which for \`legacyDir = /etc/sphere\` would try to
write \`/etc/sphere/wallet.json.lock\` in a potentially unwritable
parent. Fixed: \`lockfilePath: path.join(legacyDir, '.migrate.lock')\`
(and symmetric for the tokens lock). Lock lives inside the dataDir
where we know the user has write access.

**fs.existsSync check moved out of the lock try.**
Nonexistent dataDirs now produce "Legacy dataDir does not exist"
directly instead of the opaque "Could not acquire lock" from
proper-lockfile.

## Medium (fixed)

**state-replaced semantic smear.**
\`addToken\`'s CASE 1 (pre-existing token was 'spent' or 'invalid')
returns true via the same code path as CASE 2 (live state replaced).
Labeling both as 'state-replaced' misled UIs. Split into two codes:
  - 'state-replaced'        : overwrote a LIVE (confirmed/submitted)
                              state of the same tokenId
  - 'stale-record-replaced' : overwrote a dead record (spent/invalid);
                              no user-visible state was lost

The pre-check scan now captures the matched entry's status so the
outcome code can be chosen correctly.

**ImportAdded is a discriminated union.**
\`note\` was optional but only set for 'state-replaced'. Consumers
doing \`switch(e.code)\` had to use \`!\` assertions. Refactored
ImportAdded into a union:
  - { code: 'added'; ... }
  - { code: 'state-replaced' | 'stale-record-replaced'; note: string }
Now \`note\` is structurally required where it applies, and absent
where it doesn't.

**'unknown' skip now logs at warn level.**
This bucket only fires when the pre-check pattern disagrees with
addToken's own guards — either a TOCTOU race against a Nostr-
delivered transfer or a new guard pattern we didn't enumerate. Log
it so operators can correlate with transport activity.

**Exhaustiveness check in skippedByCode aggregation.**
If a future addition to the union introduces a new ImportSkipCode,
the aggregator in importLegacyTokens now falls through to 'unknown'
instead of silently dropping the count.

## Low (fixed)

**Dead \`as string\` cast removed** on the proper-lockfile dynamic
import — it was a holdover from optional-peer-dep days; proper-lockfile
is now a direct runtime dep.

**Release-failure warning includes lockfile path** so the user can
\`rm\` it if cleanup silently fails.

**Lock acquire error message documents SIGKILL recovery window** and
the exact lockfile path to remove manually.

**Migrate-to-profile comment clarifies advisory-only semantics.**
The lock protects against concurrent migrate-to-profile invocations
but NOT against concurrent \`send\`/\`receive\` on the legacy wallet
(those don't acquire it). Documented inline.

## Tests (+1 new, 3037/3037 passing)

tests/unit/modules/PaymentsModule.importExport.test.ts:
  - "lenient mode: replacing a SPENT token record is labelled
    stale-record-replaced, not state-replaced" — proves the CASE 1
    disambiguation is working.
  - Existing state-replaced test extended to type-narrow via the
    new discriminated union.

## Deferred

- Making all CLI commands acquire the same lock (scope expansion —
  would need a CLI-wide lock primitive; documented as advisory-only
  for now).
- Pending-mint stateHash edge case (documented in JSDoc for
  importTokens; very unusual in practice).
- Mixed-code ordering + 'unknown'-synthesis tests (follow-up).

Verification: tsc --noEmit clean; full suite 3037/3037.
Closes the deferred "pending-mint edge case" from the importTokens
steelman. A pending-mint token (post-submit, pre-aggregator-proof)
has no stateHash — the previous importTokens pre-checks relied on
\`incomingStateHash && ...\` which short-circuited on '', letting
two imports of the same pending token churn through addToken's
CASE 3 (archive + replace by local UUID) and produce misleading
'state-replaced' entries.

## Fix

Introduced two helpers in PaymentsModule:

  pendingMintDedupKey(txf)
      → 'pending-' + sha256(tokenId|tokenType|salt|recipient|
                             tokenData|recipientDataHash)
      Deterministic per-genesis fallback identifier. Prefixed so
      it can never collide with a real 64-hex stateHash.

  effectiveDedupKey(txf)
      → real stateHash if present, else pending-mint fallback

importTokens' pre-check now compares incoming vs existing tokens
via effectiveDedupKey. Two imports of the same pending-mint
genesis collide as 'duplicate' cleanly; pending + finalized of
the same genesis correctly land as distinct states.

## Tests (+2 new, 3039/3039 passing)

tests/unit/modules/PaymentsModule.importExport.test.ts:

  - "pending-mint tokens deduplicate correctly via genesis fallback
     key" — proves two imports of the same pending token collide
     as 'duplicate' instead of churning to 'state-replaced'.

  - "pending + finalized with the same genesis are NOT duplicates
     (different states)" — sanity check that the fallback prefix
     prevents collision between real and synthetic stateHashes.

Verification: tsc --noEmit clean; full suite 3039/3039.
Round-6 steelman caught a HIGH regression introduced by the pending-mint
fix plus two MEDIUM correctness gaps.

## HIGH (fixed)

**1. effectiveDedupKey read only the genesis hash, not the current state hash.**
   The rest of PaymentsModule — tombstone checks, addToken's CASE
   decisions, parseSdkDataCached — uses getCurrentStateHash, which
   prefers lastTx.newStateHash over the genesis hash. For ANY token
   that has been transferred (i.e., has transactions), the genesis
   stateHash is constant but the current stateHash advances per
   transfer. By keying only on genesis, my previous fix would
   collide TWO DIFFERENT LIVE STATES of the same token as
   "duplicate" and silently drop valid state updates on re-import.

   This is worse than the pending-mint bug I originally set out to
   fix. Replacing the stateHash read with getCurrentStateHash(txf)
   inside effectiveDedupKey closes it.

**2. Redundant JSON.parse in the scan loop.**
   The existing-tokens scan called JSON.parse(existing.sdkData) per
   comparison, bypassing the module's parseSdkDataCached helper.
   O(N × M) parses on bulk imports. Rewrote the scan to:
     - Use extractStateHashFromSdkData (cached) as the primary path.
     - Fall back to one-shot JSON.parse only for pending-state
       existing tokens (rare — the fast path hits first in steady
       state).

## MEDIUM (fixed)

**3. Pipe-delimited fallback hash is fragile.**
   JSON.stringify over a fixed field list is canonical, preserves
   null/undefined/'' distinctions, and is robust against arbitrary
   characters in any future field. Replaced the pipe-join.

**4. null/undefined/'' ambiguity in the fallback.**
   `d.recipient ?? ''` collapsed null and undefined to the same
   empty string, making them indistinguishable. JSON.stringify
   preserves the distinction (null stays null, undefined drops the
   field altogether — we explicitly use `?? null` to make it
   explicit and stable).

## LOW (fixed)

**5. Strict mode refused pending→finalized upgrades.**
   A legacy wallet migrated while a mint was in flight leaves a
   pending record in the Profile. Re-running `migrate-to-profile`
   after finalization would refuse the finalized state with
   'genesis-exists'. Added an exception: strict mode allows the
   upgrade when the existing is pending and the incoming is
   finalized. Downgrades and finalized↔finalized conflicts still
   refuse correctly.

## Tests (+3 new, 3042/3042 passing)

tests/unit/modules/PaymentsModule.importExport.test.ts:

  - "transacted tokens dedup on CURRENT state (lastTx.newStateHash),
     not genesis" — proves the HIGH #1 regression is closed for
     tokens with transactions.

  - "strict mode allows pending→finalized upgrade (pending existing,
     finalized incoming)" — proves the LOW #5 exception works.

  - "strict mode still refuses finalized→pending downgrades and
     finalized↔finalized conflicts" — proves the exception doesn't
     leak into unwanted directions.

  - buildTxf fixture extended to accept a transactions array so
     getCurrentStateHash-based fixtures can be constructed.

tsc --noEmit clean; full suite 3042/3042.
## wallet list / wallet current now display storage mode

Each profile's storage backend (legacy TXF vs OrbitDB Profile) is
detected on-the-fly by probing the on-disk artefacts:
  - \`{dataDir}/orbitdb/\` exists → 'profile'
  - \`{dataDir}/wallet.json\` exists and non-empty → 'legacy'
  - neither → '(not initialised)'

Example output:
    Wallet Profiles:
    ──────────────────────────────────────────────
      legacy-profile
        Network: testnet
        DataDir: ./.sphere-cli-prof1
        Storage: legacy
      profile-profile
        Network: testnet
        DataDir: ./.sphere-cli-prof2
        Storage: profile
      uninit-profile
        Network: testnet
        DataDir: ./.sphere-cli-prof3
        Storage: (not initialised)

Factored into a shared \`detectProfileStorageMode\` helper reused by
\`wallet current\` for consistency.

## Latent bug: switchToProfile left stale storageMode in config

When the user ran \`wallet use B\` after operating profile A (say A was
Profile mode, B is legacy), \`switchToProfile\` copied \`dataDir\`,
\`tokensDir\`, \`network\` into the active config but DID NOT clear the
previous \`storageMode\`. The resolver honours committed
\`config.storageMode\` (step 2 of \`resolveStorageMode\`), so the next
\`init\` / \`send\` / etc. on profile B would silently use A's mode —
attempting to open B's legacy files as a Profile wallet (or vice
versa).

Fixed by \`delete config.storageMode\` in \`switchToProfile\` so the
resolver re-detects from B's dataDir on the next invocation. Profile
B then picks its correct mode from disk artefacts or (for a pristine
profile) from the default.

Typecheck clean; full unit suite 3042/3042 still passing; manual
smoke test with three pre-populated profile dirs verified the three
distinct Storage lines render correctly.
…ish states

Round-7 steelman on the wallet-list storage display returned WARNINGS;
fixing all.

## Medium (fixed)

**Duplicate probe logic.**
\`detectProfileStorageMode\` re-implemented what
\`defaultLegacyWalletProbe\` + \`defaultProfileWalletProbe\` from
\`cli/storage-mode.ts\` already do. Any future addition to the
resolver's probes (e.g. an alternative Profile marker) would silently
diverge from \`wallet list\` / \`wallet current\`. Refactored to
delegate — zero local detection logic, single source of truth.

## Low (fixed)

**Ambiguous "(not initialised)".**
Previously, a dataDir that didn't exist and a dataDir that existed
but had no markers were both displayed identically. Now distinguished:
  - 'missing'         → dataDir does not exist   → "(not initialised)"
  - 'uninitialised'   → dir exists, no markers   → "(empty)" or
                                                   "{mode} (pending)"
                                                   if committed
  - 'legacy' / 'profile' → markers found         → "{mode}"

**Corrupt committed storageMode displayed verbatim.**
The fallback to \`config.storageMode\` for the active profile could
print a hand-edited garbage value. Extracted into
\`renderStorageLabel\` which validates the value against the known
enum and shows "(invalid: \\\"<value>\\\")" for anything else.

## Verification

Manual smoke test with five profiles exercises every branch:
  - legacy artefact           → 'legacy'
  - profile artefact          → 'profile'
  - empty dir                 → '(empty)'
  - missing dir               → '(not initialised)'
  - empty dir + active with
    committed storageMode     → 'profile (pending)'

tsc --noEmit clean; full unit suite 3042/3042 passing.

## Deferred (documented in review)

- Behaviour change test for \`switchToProfile\` (delete of storageMode)
  — no existing test asserted the old carry-over, so no regression
  risk; tested manually in commit b8c1fe6.
- Atomic config writes via tmp+rename — pre-existing, out of scope.
Sphere.init() calls storage.connect() twice: first before identity is
known (to detect wallet-exists), then again after setIdentity().
The previous ProfileStorageProvider.connect() returned early when
status was already 'connected', leaving OrbitDB un-attached. Module
load then hit ensureConnected() and threw:

    ProfileError: [PROFILE:PROFILE_NOT_INITIALIZED]
    OrbitDB adapter is not connected

Reported from real CLI usage: `npm run cli -- init --nametag babaika13`.

Changes:

* ProfileStorageProvider.connect() — rewritten as two phases:
    A. idempotent base connection (local cache), runs once
    B. lazy OrbitDB attach — fires on every connect() call until the
       database is open, so the second call following setIdentity()
       completes the work.

* isConnected() — now returns false in the half-attached state
  (local cache up, OrbitDB still pending). This is what makes
  Sphere.initializeProviders()'s guard re-invoke connect().

* PaymentsModule.doLoad() address guard — accepts three
  representations: L1 bech32, chain pubkey, and Profile short ID
  (DIRECT_{first6}_{last6}) written by ProfileTokenStorageProvider.
  Lazy-imports computeAddressId to keep profile/ out of non-profile
  builds. Fixes the address-mismatch warning that surfaced once
  the connect bug was fixed.

* tests/unit/profile/profile-storage-provider.test.ts — 4 new
  regression tests under describe('two-phase connect'):
    - pre-identity connect then post-identity connect → attach
    - idempotence after attach
    - isConnected() returns FALSE between the two calls
    - connect() without orbitDb config is still a valid success

Why existing tests missed this: unit tests fake dbConnected=true in a
suite-wide beforeEach, bypassing the connect path; the E2E CLI
`init --profile` path is gated behind E2E_NETWORK=1 and not run in
CI; the integration test is network-gated too. The new unit tests
construct a fresh, truly-unconnected mock DB to observe the attach
sequence and would have caught this.

Verification: 3046/3046 unit tests pass; CLI `init --no-nostr`
reproduces clean (no PROFILE_NOT_INITIALIZED, no address-mismatch
warning, identity prints cleanly).
…ressId

Follows up commit 5f1fc85 which introduced the two-phase connect but had
several issues surfaced by `/steelman`:

Critical fixes:

* `profile/profile-storage-provider.ts` — serialize concurrent connect()
  calls via an in-flight `connectPromise`. Without it, two parallel
  callers could both observe `!dbConnected` and race into
  `db.connect()`, creating two Helia instances contending on the same
  directory lock. The OrbitDB adapter's self-idempotence only guarded
  post-success, not in-flight calls.

* `profile/profile-storage-provider.ts` — split `dbStatus` from base
  `status`. A Phase-B failure now flips only `dbStatus='error'` and
  leaves the base status='connected' (the local cache is still up).
  Previously, Phase-B failure poisoned the base status, causing a
  defensive `disconnect()` to tear down a working cache and lying to
  callers about what was actually broken.

Warning fixes:

* `profile/profile-storage-provider.ts` — snapshot identity at entry of
  `doConnect()`, not after Phase A's await. Prevents a mid-flight
  `setIdentity()` from swapping the private key between the
  needsAttach check and the adapter call.

* `profile/profile-storage-provider.ts` — `disconnect()` now awaits any
  in-flight `connect()` before tearing down, preventing a leaked
  Helia instance if disconnect races against an in-progress attach.

* `profile/profile-storage-provider.ts` — `isConnected()` now gates on
  `options.orbitDb` presence, not `identity`. A provider configured
  with OrbitDB but without an identity now correctly reports false so
  writes aren't silently routed to cache-only and never backfilled.

* `profile/types.ts` + `profile/profile-storage-provider.ts` — canonical
  `computeAddressId` in `profile/types.ts` now accepts both `DIRECT://`
  and the degenerate `DIRECT:` prefix. Deleted the duplicate private
  implementation in `profile-storage-provider.ts` and imported from
  `./types.js`. Previously the two divergent implementations could have
  produced different address IDs for the same input.

* `modules/payments/PaymentsModule.ts` — replaced the dynamic
  `await import('../../profile/types.js')` with a static import of
  `computeAddressId`. The lazy import's stated goal (keep profile/ out
  of non-profile builds) was false: `profile/types.ts` has zero runtime
  imports and is already inlined into every bundle. The dynamic form
  introduced silent-failure risk in consumer builds lacking matching
  bundler rules, causing Profile-mode data to be rejected with a
  misleading "address mismatch" warning.

* `modules/payments/PaymentsModule.ts` — hoisted `currentProfileShortId`
  computation outside the per-provider loop; dropped the `currentL1 &&`
  gate that could bypass the guard when L1 was empty-string; improved
  the rejection warning to show all three accepted forms.

* `profile/errors.ts` — `ProfileError` now passes `cause` via the
  ES2022 options bag to `super(message, { cause })`. Standard error-
  chain tooling (Node's default formatter, Sentry, pino-pretty) can
  now walk `.cause` to the original stack.

New regression tests:

* `tests/unit/profile/profile-storage-provider.test.ts` — 6 new tests
  under `describe('two-phase connect')` covering:
    - concurrent connect() calls dedupe (1 db.connect, not 4)
    - Phase B failure doesn't poison base status
    - Phase B failure permits retry
    - identity snapshot at entry (mid-flight setIdentity is contained)
    - connect→disconnect→connect cycle reconnects both phases
    - isConnected() returns FALSE post-pre-identity-connect when
      orbitDb is configured (the invariant Sphere.init depends on)

* `tests/unit/modules/PaymentsModule.address-guard.test.ts` — new file
  with 6 tests for the extended address guard:
    - accepts L1 bech32, chain pubkey, Profile short ID
    - rejects unrelated short IDs and unrelated L1 addresses
    - warning message includes all three accepted forms

Verification: 3290/3291 unit + integration tests pass; the single
failure is a pre-existing CI timeout flake in invoice-parse-memo
CLI-029 that passes in isolation both with and without these changes.
Follows up 9f05c49 which fixed the original connect race but introduced
or left open several issues surfaced by a recursive `/steelman`.

Critical fixes:

* `profile/profile-storage-provider.ts` — disconnect/connect race:
  if disconnect() was awaiting the in-flight connectPromise, a
  concurrent connect() saw the same promise still set, awaited it,
  and returned "connected" just as disconnect() was closing the DB.
  Subsequent writes hit a dying OrbitDB. Fix: new `disconnectPromise`
  latch; connect() waits for it to drain before starting a fresh
  attach. disconnect() is now itself deduplicated via the same
  pattern.

Warning fixes:

* `profile/profile-storage-provider.ts` — `dbConnected` is now a
  derived getter (`dbStatus === 'attached'`). Previously both a field
  and a mirror; 15 call sites read `this.dbConnected`, and any future
  transition updating only one source would silently route writes to
  cache-only. The existing test beforeEaches that directly assigned
  `dbConnected = true` now assign `dbStatus = 'attached'` instead —
  same effect, single source of truth.

* `profile/profile-storage-provider.ts` — inlined the Phase B guard
  so TypeScript's control-flow narrowing propagates
  `identityAtStart !== null` and `orbitDbConfig !== null` into the
  branch body. Removed the fragile `!` non-null assertions.

* `profile/profile-storage-provider.ts` — new `dbStatus='fatal'`
  terminal state. `ORBITDB_NOT_INSTALLED` (package missing) is
  marked fatal on first attempt; subsequent `connect()` calls no-op
  instead of re-throwing on every startup hop. Transient errors
  continue to retry as before.

* `profile/profile-storage-provider.ts` — `setIdentity()` now warns
  via `logger.warn` when called with a different `chainPubkey`
  while OrbitDB is attached. Captures the attached pubkey at
  attach time so the check is cheap. Writes encrypted under the new
  key would otherwise be silently rejected by OrbitDB's
  AccessController (initialized with the old key); the warning
  gives operators a breadcrumb to diagnose the misuse.

* `tests/unit/modules/PaymentsModule.address-guard.test.ts` — replaced
  the fragile `vi.spyOn(console, 'warn')` with a structural
  `logger.configure({ handler })`. Captures by (level, tag, message)
  tuple so a future message rewording doesn't silently pass the
  test as "warned=false".

New regression tests:

* `tests/unit/profile/profile-storage-provider.test.ts` — 3 new tests:
    - concurrent disconnect+connect: asserts close() is invoked
      exactly once AND the reconnect succeeds after teardown drains
    - fatal Phase B failures are sticky: first attempt throws,
      second connect() resolves without retry
    - setIdentity() warning on chainPubkey swap while attached

Deferred from this commit (noted in review):
  - Bounded timeout on `disconnect()` awaiting connectPromise
    (needs AbortSignal plumbing through the adapter; non-trivial).
  - Deferred-pattern test fixture for concurrency (marginal gain).
  - Applying the options-bag `cause` pattern to `SphereError` and
    `IpfsError` for consistency (separate commit, sibling classes).

Verification: 3275/3275 unit + integration tests pass (excluding the
pre-existing `accounting-cli.test.ts` CI-timeout flake that was
flagged in 9f05c49 as unrelated and passes in isolation).
Mirrors the legacy TXF E2E trio (ipfs-sync, ipfs-token-persistence,
ipfs-multi-device-sync) so the new Profile stack gets the same level
of real-infra coverage the legacy IPNS flow has today.

Each test runs against:
  - Helia IPFS node (in-process, libp2p + gossipsub, bootstrapped to
    DEFAULT_IPFS_BOOTSTRAP_PEERS)
  - OrbitDB keyvalue database (via OrbitDbAdapter)
  - Unicity IPFS HTTP gateway (https://unicity-ipfs1.dyndns.org) for
    CAR pin + fetch
  - Nostr testnet relay (token delivery, scoped tests)
  - Aggregator testnet (oracle)

Files:

* `tests/e2e/profile-helpers.ts` — `makeProfileProviders()` that
  composes Profile's storage + tokenStorage with the legacy factory's
  transport/oracle/price/l1. `unwrapProfileProviders()` exposes the
  typed Profile instances for tests that need to inspect internals.
  Re-exports all shared helpers from `./helpers`.

* `tests/e2e/profile-sync.test.ts` — low-level mirror of
  `ipfs-sync.test.ts`. Three tests using `ProfileStorageProvider` +
  `ProfileTokenStorageProvider` directly:
    1. KV set/get round-trip via real Helia/OrbitDB
    2. CAR pin + fetch through the live Unicity gateway
    3. Fresh instance (same wallet key) recovers inventory via the
       OrbitDB OpLog + CAR CID chain

* `tests/e2e/profile-token-persistence.test.ts` — high-level mirror of
  `ipfs-token-persistence.test.ts`. Three Sphere.init-level tests:
    1. Create wallet with Profile providers, faucet, receive over
       Nostr, publish state to Profile
    2. Wipe local data, re-import from mnemonic with a NO-OP
       transport, verify tokens recovered ONLY via Profile
       (OrbitDB + IPFS) — syncAdded > 0 proves the Profile layer
       actually delivered them
    3. Recovered tokens are spendable (Profile-recovered state
       produces valid transferable tokens)

* `tests/e2e/profile-multi-device-sync.test.ts` — mirror of
  `ipfs-multi-device-sync.test.ts`. Three cross-device tests:
    1. Device A publishes multi-coin inventory to Profile
    2. Device B (fresh temp dir, same mnemonic, no-op Nostr)
       recovers the entire inventory through Profile alone
    3. Device C full recovery (Profile + real Nostr merge) converges
       to the same inventory

Picked up automatically by `vitest.e2e.config.ts` and the existing
`npm run test:e2e` script — no config changes needed.

These tests are opt-in (excluded from `npm test`) because they take
minutes per run and require live network access to multiple Unicity
testnet services.
Helia's `libp2pDefaults()` includes `webRTC()` and `webRTCDirect()` in
the default transports list, and listen addresses for
`/udp/0/webrtc-direct`. On Node these are backed by
`@libp2p/webrtc` → `node-datachannel`, which:

  * is a polyfill of the browser WebRTC API, not a first-class Node
    implementation;
  * emits `DataChannel is closed` errors during shutdown that
    surface as unhandled exceptions in vitest;
  * is not reachable peer-to-peer from a Node process without an
    external signalling service (useless in practice here).

The adapter now filters WebRTC-family transports and their listen
addresses before handing the libp2p config to Helia, but ONLY in
Node (browser environments still need WebRTC as their sole direct-
dial transport).

Identification: each transport factory's `.toString()` exposes its
constructor name (e.g. `new WebRTCTransport(...)`). The filter
matches on that substring. Unusual but portable across libp2p
versions that don't set `[Symbol.toStringTag]` on the factory.

Also: dropped the low-level cross-instance recovery test from
`profile-sync.test.ts`. Two fresh ephemeral Helia nodes cannot
discover each other with only IPFS gateways as bootstrap peers
(no DHT or rendezvous). Cross-instance replication is exercised
end-to-end at the Sphere.init() level in the other two e2e files.

Verification: `npm run test:e2e tests/e2e/profile-sync.test.ts`
passes 2/2 in 1.4s with no unhandled errors; previously the same
suite ran 140s and printed two libdatachannel uncaught exceptions.
…c v2

Design docs for replacing the IPNS snapshot stopgap with a
privacy-preserving pointer layer anchored in the Unicity aggregator
SMT. For every OpLog version the wallet publishes two commitments
(sides A and B) whose signed `transactionHash` fields carry an
XOR-blinded split of the current OpLog CID, discoverable via
exponential-then-binary search over the version space.

Design decisions captured:

* Not tokenised. A tokenised state-transition design cannot be
  re-entered from the mnemonic alone on a fresh device (you'd need
  to know the token's current state hash before you can query),
  defeating the core recovery goal. The two-leaf plain-commitment
  scheme is retained deliberately.

* All SDK-covered operations use state-transition-sdk primitives —
  SigningService (secp256k1 only; Ed25519 removed), DataHasher,
  DataHash, RequestId.createFromImprint (uses stateHash.imprint,
  not the raw digest — the 2-byte algorithm tag matters),
  Authenticator.create, SubmitCommitmentRequest, AggregatorClient,
  InclusionProof.verify(trustBase, requestId). Only non-SDK
  primitives in the scheme are HKDF-SHA256 (via @noble/hashes,
  reusing the pattern from impl/shared/ipfs/ipns-key-derivation.ts)
  and bytewise XOR.

Reviewer findings applied (from five parallel reviews):

Critical:
  - Ed25519 replaced with secp256k1 everywhere (aggregator rejects
    anything else in commitment_validator.go).
  - RequestId formula corrected to hash pubkey || algoTag(2B) ||
    digest(32B) — 67-byte preimage matching SDK behaviour.
  - Crash-retry OTP reuse closed by persisting (v, H(cid)) to local
    storage before computing the XOR payload; refuse to reuse v with
    a different plaintext.
  - Four arch↔spec contradictions reconciled (signing algo, length
    encoding via 1-byte prefix, both-sides-probe discovery,
    partial-publish retry at same v with deterministic bytes).
  - Trustless InclusionProof.verify with a RootTrustBase is now
    mandatory; TOFU explicitly accepted for first-boot recovery.
  - Aggregator-unreachable-at-recovery no longer silently produces
    empty state: publish is blocked until a verified exclusion
    proof or a successful remote merge.

Warnings:
  - HKDF-Expand subkey separation for signingSeed / xorSeed / padSeed.
  - Privacy goal G2 downgraded to "pseudonymous per wallet" with
    signingPubKey clustering disclosed as known leakage.
  - Retry backoff includes random jitter (BASE × 2^n × U(0.5,1.5)).
  - Consistency model stated explicitly: per-wallet linearizable
    under BFT aggregator; OpLog contents remain CRDT.
  - Deterministic HKDF-derived padding replaces CSPRNG so crashed
    retries reproduce byte-identical submissions.
  - DISCOVERY_PARALLELISM lowered to 1 (binary search is serial;
    A/B parallelism within each probe unchanged).
  - Observability events specified (pointer.publish.*,
    pointer.discover.*, pointer.recover.*, pointer.conflict.*).
  - Alternatives table now covers IPNS, OrbitDB-live-peer-only,
    tokenised-state-chain, and centralised pinning.

Editorial:
  - Single canonical open-questions list with owners.
  - Stale "to be written" markers removed.
  - Migration touches: profile-ipns.ts deletion, ipnsSnapshot flag
    rename, test-file cleanup.

Status: v2 draft. Ready for security auditor, aggregator team,
Unicity architect, and SDK team sign-off. Implementation PR
follows once the first test vector bytes are computed and
checksum-committed per SPEC §14.
…ation + review fixes

v2 was REJECTED by the doc-reviewer and security-auditor for the same
class of bug that got v1 rejected: the two docs disagreed on byte-level
formulas. Three parallel steelman reviewers on v2 surfaced five
critical divergences; v3 reconciles them and applies a dozen warnings.

Canonical formulas (now identical in both docs):

  stateHashDigest = SHA-256(xorSeed || [side] || be32(v) || "state")
  xorKey          = SHA-256(xorSeed || [side] || be32(v) || "xor")      // bare SHA-256, not HKDF-Expand
  padBytes_v      = HKDF-Expand(padSeed, be32(v) || "pad", 63-cidLen)   // shared across sides
  signingService  = SigningService.createFromSecret(signingSeed)         // SDK static; SHA-256 normalizes
  requestId       = RequestId.createFromImprint(signingPubKey, stateHash)
  authenticator   = Authenticator.create(signingService, transactionHash, stateHash)

Critical fixes applied:

  F1 .proof → .inclusionProof (spec §8.1, §8.5) — SDK's
     InclusionProofResponse field is `inclusionProof`, not `proof`.
     Four call-sites corrected; a literal transcription of v2 would
     have crashed at runtime on the first recovery probe.

  F2 §7.3 outcome matrix (EXISTS, EXISTS) — split into:
       - idempotent-replay (marker match) → persist + clear + succeed
       - genuine conflict (marker absent/mismatch) → §9 reconciliation
     v2 wrongly treated all (EXISTS, EXISTS) as conflict, which would
     loop on every crash-recovery-replay.

  F3 §7.1 pending_version discipline — six sub-invariants:
       mutex (per-wallet MUTEX_KEY), per-wallet key scoping
       (PENDING_VERSION_KEY includes hex(signingPubKey)), durability
       (fsync/transaction.oncomplete), rollback-safe `v` selection
       (max(v, prev.v)+1 when prev.v >= v), full 32-byte cidHash
       (no truncation), marker-clear atomicity.

  F4 §10.2 BLOCKED state — formalized:
       persistent flag BLOCKED_FLAG_KEY (per-wallet), categorical-
       error SET conditions, strict CLEAR conditions (verified
       exclusion at v=1 OR successful recoverLatest + merge),
       user-originated-write = entry.signedBy == localSigningPubKey
       (NOT replication / Nostr ingest), optional per-call override
       gated behind capability flag.

  SigningService.createFromSecret everywhere (not `new SigningService`).
  The raw constructor would produce a different signingPubKey for
  the same seed, breaking interoperability.

Warnings addressed:

  F5 async `DataHasher.digest()` — footnote at §4 header covers all
     subsequent pseudocode.
  F7 TOFU multi-mirror cross-check — RECOMMENDED on first boot;
     new error AGGREGATOR_POINTER_TRUST_BASE_DIVERGENCE.
  F8 Retry-rejected ciphertexts MUST be zeroized and not logged;
     explicit secret-value list (pointerSecret, signingSeed, xorSeed,
     padSeed, signing private key) documented as log-forbidden.
  F9 First canonical test vector inputs frozen (key=0x01×32;
     CIDv1-raw of "hello world" computed to 36 bytes); derived
     values remain O-1 until implementation-PR merge.
  F10 Open items: O-1 demoted to impl-PR-blocker; O-5 (override
     decision, non-blocking), O-6 (mirror list, blocking) added.
  F11 `isPublishBlocked(): boolean` added to ProfilePointerLayer.

Arch↔spec alignment verified:
  - stateHash preimage uses xorSeed in both docs (arch §4.3, spec §4.4)
  - xorKey uses bare SHA-256 (arch §4.1/§9.3, spec §4.5)
  - padding is shared across sides with "pad" suffix (both §4.5/§4.6)
  - SigningService.createFromSecret everywhere (7 total occurrences)
  - Constant names aligned (PUBLISH_BACKOFF_*, no RETRY_ infix)
  - Discovery init seeded from localVersion (arch §10.5, spec §8.2)
  - BLOCKED state discipline cross-referenced
  - Open-questions routed to spec §15.1 as single source of truth

Status: Draft v3. Ready for security auditor, aggregator team, Unicity
architect, SDK team re-review. Test-vector byte computation (O-1)
remains the single implementation-PR blocker.
…findings

Three parallel steelman reviewers on v3:
  - byte-level consistency auditor: APPROVE (arch↔spec formulas match)
  - SDK fidelity: APPROVE (all SDK primitives verified against source)
  - end-to-end attack: REJECT (3 new criticals + 4 warnings)

This commit closes the 3 criticals + 4 warnings plus editorial
alignments. The underlying two-leaf XOR-blinded scheme is unchanged;
v3.1 is purely a hardening patch.

Criticals closed:

  C1 Marker version-jump clamp. A malicious process with filesystem
     write access could inject a valid-looking marker with
     `previousEntry.v = 2^31 - 2`, triggering `v := 2^31 - 1` on
     next publish and bricking the wallet on the following attempt
     (`v = 2^31` exceeds `VERSION_MAX`). Added `MARKER_MAX_JUMP =
     1024` constant + clamp in §7.1.4 — markers exceeding the gap
     are treated as `AGGREGATOR_POINTER_MARKER_CORRUPT`.

  C2 Retry-window ciphertext zeroization. §11.11 previously required
     zeroization only on `REQUEST_ID_EXISTS`. Network-retry loops
     hold ciphertext in memory for up to ~8s across the retry
     budget, exposing an OTP differential to memory-dump attackers.
     Added §11.11(a′) requiring either per-retry re-derivation or
     `MAX_CT_RESIDENT_MS = 500` cap, with mandatory zeroization
     between retry attempts.

  C3 Fresh-install MITM bypass of BLOCKED. On a fresh mnemonic
     re-import with a MITM'd network, §10.2 BLOCKED never sets
     (no user-originated OpLog writes yet), so an attacker's
     faked `RootTrustBase` under single-mirror TOFU would silently
     take over. Promoted §8.4 multi-mirror cross-check from
     RECOMMENDED to MANDATORY (≥ MIN_MIRROR_COUNT = 2 independent
     mirrors). Added §10.2.6: fresh-install cold-start producing a
     corrupt XOR-decoded payload MUST SET BLOCKED even with zero
     user-originated writes.

Warnings closed:

  C4 CAR size + fetch-time caps: MAX_CAR_BYTES = 100 MiB,
     MAX_CAR_FETCH_MS = 60 s, new error codes
     AGGREGATOR_POINTER_CAR_TOO_LARGE / _FETCH_TIMEOUT.

  C5 CAR-unavailable explicit state (§10.7): pointer recovery that
     verifies a CID but cannot fetch the CAR MUST NOT silently
     advance `localVersion`; wallet enters
     `AGGREGATOR_POINTER_CAR_UNAVAILABLE` with explicit
     `acceptCarLoss(version)` override API.

  C6 `originated` metadata tag (§10.2.3): replaces the ambiguous
     `signedBy == localSigningPubKey` heuristic with an explicit
     enum {'user', 'system', 'replicated'} that OpLog writers
     stamp at write time. Fail-closed default for missing tags.

  C7 Probe-sequence fingerprint disclosure (§11.10 bullet 10):
     acknowledges the deterministic discovery-probe pattern as a
     stronger correlation signal than signingPubKey clustering;
     v2 mitigations (randomized Phase 1, decoy probes, anonymity
     set) documented as future work.

  C8 Canonical test-vector runtime rejection (§14.1, §11.12):
     prominent WARNING block on the §14.1 canonical key
     (`0x01 × 32`); `Profile.init()` MUST reject this key in
     non-test networks via a mandatory denylist check.

API additions (§13): `acceptCarLoss(version)`, `clearPendingMarker()`,
`getProbeFingerprint()`. `isPublishBlocked()` signature made
`Promise<boolean>`.

New events (arch §13): `pointer:recover_car_unavailable`,
`pointer:car_loss_accepted`, `pointer:marker_cleared`.

Editorial alignments (arch↔spec):
  - `AGGREGATOR_POINTER_PROOF_INVALID` → `AGGREGATOR_POINTER_UNTRUSTED_PROOF` in arch
  - BLOCKED SET verb aligned ("been attempted and failed")
  - Symbol naming `padBytes_V` → `paddingBytes_v` in arch
  - `findLatestVersion` call-site arity corrected (4 args)
  - `localSigningPubKey` disambiguated as chain-key pubkey
  - Async-await convention footnote expanded to cover
    `createFromSecret`, `createFromImprint/create`,
    `Authenticator.create`, `AggregatorClient` methods
  - `DataHasher(X) ≡ new DataHasher(X)` pseudocode convention noted

Open items: O-6 (mirror URL list) promoted to BLOCKING spec sign-off
because C3 now mandates multi-mirror.

Status: Draft v3.1. Ready for another review round by security
auditor + aggregator team + Unicity architect + SDK team. Test-vector
byte computation (O-1) and mirror URL finalization (O-6) are the
remaining implementation-PR blockers.
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.

2 participants