Skip to content

v0.8.0 — UXF Inter-Wallet Transfer Protocol + Profile-layer hardening

Latest

Choose a tag to compare

@vrogojin vrogojin released this 29 May 18:06
· 199 commits to main since this release
af41bea

178 PRs promoted from integration/all-fixes to main.

  • Release PR: #345
  • Tag points at: af41bea6fc36
  • Period covered: ~4 months of accumulated profile-layer, UXF, recovery, and connectivity work since the previous release lineage (PRs #128#130#303#342).

⚠️ Breaking: UXF wire-shape default flip

Default sends now emit UXF v1.0 bundles (senderUxf=true). Older SDK receivers cannot decode them. Pin a shared SDK version across senders/receivers during the transition, or pass explicit features: { senderUxf: false } to fall back to the legacy single-token TXF wire shape. See docs/uxf/UXF-TRANSFER-CUTOVER-RUNBOOK.md for the runbook.


Changed (BREAKING — wire-shape default flip)

  • UXF feature flags now default-ON (T.8.D part 1 of 2 — production cutover, NO legacy code path removal). All four UXF feature flags moved from default false → default true in PaymentsModuleConfig.features:
    • senderUxfpayments.send({transferMode:'instant'}) (the public default) now routes through the new UXF instant-sender; conservative-mode also routes through the UXF orchestrator.
    • recipientUxf — incoming UXF v1.0 bundles enqueue onto the bounded ingest worker pool when one is installed (no behavior change otherwise — pool is null until bootstrap wires it).
    • recipientLegacyAdapter — inbound legacy wire shapes (Sphere TXF / V6 / V5 / SDK legacy) are adapted to UXF-shaped DispositionRecords and routed through the disposition writer when a runner is installed. REQUIRED ON for cross-version interop with old senders.
    • recoveryWorker — sending-recovery worker installs and starts on initialize() when a republish hook has been wired (no behavior change otherwise — worker is null until bootstrap installs it).
    • Cross-version interop caveat: a sender with senderUxf: true emits UXF v1.0 wire shapes (uxf-cid / uxf-car); a receiver running an older SDK without UXF ingest CANNOT decode them. Pin a shared SDK version across senders/receivers during the transition, OR pass explicit features: { senderUxf: false } to fall back to the legacy single-token TXF wire shape. Testnet soak is recommended before mainnet rollout. See docs/uxf/UXF-TRANSFER-CUTOVER-RUNBOOK.md for the operator runbook and back-out procedure.
    • Legacy code path removal is T.8.D part 2 of 2 (deferred until soak validation completes). Until then, every flag's false value remains a fully-supported escape hatch.

Added

  • SPHERE_IPFS_GATEWAY env override — single URL or comma-separated list that replaces DEFAULT_IPFS_GATEWAYS at module-init. Honored by NETWORKS[*].ipfsGateways, getIpfsGatewayUrls(), and IpfsStorageProvider. Lets e2e suites survive testnet IPFS gateway outages (#154) by pointing at a public/alternate gateway. Node-only; gated on typeof process so it's a no-op in browser bundles. BUILTIN_IPFS_GATEWAYS is exported (from the package root) as the static (pre-override) default for consumers that need to compare. (6 unit tests in tests/unit/constants.ipfs-gateway-env.test.ts.)
  • UXF Inter-Wallet Transfer Protocol — spec + implementation (51 of 52 plan tasks landed across 12 waves; T.8.D part 1 of 2 — flag-flip production cutover — landed in this release; T.8.D part 2 of 2 — legacy code path removal — gated on testnet soak. See docs/uxf/UXF-TRANSFER-PROTOCOL.md for the canonical spec and docs/uxf/UXF-TRANSFER-IMPL-PLAN.md for task breakdown):
    • payments.importInclusionProof(tokenId, proofBytes, options) — operator escape hatch with 10 sub-cases (1, 2, 3, 4a, 4b, 5, 6, 7, 8, 9 per spec §6.3). options.allowInvalidOverride: true flips a _invalid token back to valid (case 5) or re-queues the K-1 remaining txs (case 6); cases 8/9 short-circuit even with override if the supplied proof doesn't pass verification. Override path stamps overrideAppliedAt / overrideAppliedBy audit-trail fields on the manifest entry (sticky across CRDT merges) and emits transfer:override-applied.
    • payments.revalidateCascadedChildren(parentTokenId) — transitively re-evaluates dispositions for tokens whose splitParent chain leads back to a previously-cascaded parent (e.g. after operator override unblocks a parent). Bounded depth (MAX_CHAIN_DEPTH=64); per-call-stack visited-set defends against corrupted manifest cycles.
    • 13 new transfer:* events on SphereEventMap: transfer:submitted (instant-mode publish ack, distinct from transfer:confirmed), transfer:cascade-risk-warning (pending source produces freshly-minted child), transfer:cascade-failed (downstream notification on hard-fail), transfer:trustbase-warning (first NOT_AUTHENTICATED, refresh-and-retry), transfer:security-alert (§6.3 forbidden case OR sustained NOT_AUTHENTICATED post-refresh), transfer:proof-superseded (newer proof replaces attached proof per BFT round, W16), transfer:override-applied (importInclusionProof override fired), transfer:operator-alert ('client-error' reason path, C13), transfer:fetch-failed (CID gateway-walking exhausted, W13 — NO disposition record written), transfer:ingest-queue-full (worker pool back-pressure), transfer:capability-warning (peer's wireProtocols / assetKinds mismatch outbound — informational only, no auto-coercion).
    • ConnectHost IntentSchemaVersionconnect/host/ConnectHost.ts now passes a 4th argument schemaVersion: 'uxf-1' | 'legacy' to the onIntent callback (default 'legacy'; 'uxf-1' triggered by explicit params.schemaVersion, non-empty additionalAssets[], or bundle envelope fields). Backward-compatible — 3-argument integrators keep working. Pure detector exported as detectIntentSchemaVersion(). See docs/uxf/CONNECT-HOST-MIGRATION-NOTE.md for cross-repo migration (agentsphere, sphere, openclaw-unicity).
    • Multi-asset sendTransferRequest.additionalAssets?: AdditionalAsset[] discriminated union ({kind:'coin', coinId, amount} | {kind:'nft', tokenId}) enables multi-coin and mixed coin+NFT transfers in a single payments.send() call. Per-kind validation: distinct coinIds incl. primary; distinct NFT tokenIds; coin amounts > 0. Forward-compat: receivers reject unrecognized kind with UNKNOWN_ASSET_KIND. See UXF-TRANSFER-PROTOCOL §4.1 and docs/INTEGRATION.md.
    • Multi-asset sendTransferRequest.additionalAssets?: AdditionalAsset[] discriminated union ({kind:'coin', coinId, amount} | {kind:'nft', tokenId}) enables multi-coin and mixed coin+NFT transfers in a single payments.send() call. Per-kind validation: distinct coinIds incl. primary; distinct NFT tokenIds; coin amounts > 0. Forward-compat: receivers reject unrecognized kind with UNKNOWN_ASSET_KIND. See UXF-TRANSFER-PROTOCOL §4.1 and docs/INTEGRATION.md.
    • Canonical NFT model — NFT = token with empty/null coinData (after zero-amount pruning); coin = non-empty. Class-disjoint at the protocol level. NFT transfers are whole-token (no split, tokenId preserved). Coin tokens cannot satisfy NFT targets even on tokenId match → INSUFFICIENT_BALANCE reason='nft-not-owned'. See UXF-TRANSFER-PROTOCOL §4.1.
    • Chain mode opt-inTransferRequest.allowPendingTokens?: boolean (default false). When true, the source-token selector may spill over to pending tokens after exhausting valid ones. Strict ordering: finalized-first, then pending-by-age. See UXF-TRANSFER-PROTOCOL §2.3 + §2.5.
    • confirmNftPending flag — required true when allowPendingTokens: true AND any NFT target's source has unfinalized predecessor txs. Prevents accidental cascade of irrecoverable NFT identity (NFT_PENDING_REQUIRES_CONFIRMATION rejection without it). See UXF-TRANSFER-PROTOCOL §4.1 cascade-asymmetry warning.
    • Identity-binding capability hints — optional wireProtocols: string[] and assetKinds: string[] for forward-compat. Informational only — receivers still apply the strict UNKNOWN_ASSET_KIND reject rule. See UXF-TRANSFER-PROTOCOL §10.4.
    • Bundle ingest concurrency — recipient runs a MAX_INGEST_WORKERS = 16 default worker pool with a bounded ingest queue. DoS defense against rogue long-running bundles. Per-tokenId mutex coordinates cross-bundle conflicts. See UXF-TRANSFER-PROTOCOL §5.0.
    • _audit collection — NEW (Wave T.3). Multi-representation aware: keyed by ${addr}.audit.${tokenId}.${observedTokenContentHash}. Stores NOT_OUR_CURRENT_STATE and UNSPENDABLE_BY_US dispositions distinct from cryptographically broken tokens (which stay in _invalid, also widened to multi-representation key). See UXF-TRANSFER-PROTOCOL §5.4.
    • Periodic rescans — two orthogonal scanners promoted to in-scope (design summary): profile-pointer rescan (default 30s, detects sibling-instance updates) and per-token spent-state rescan (default 5 min/token, detects off-record spends). See UXF-TRANSFER-PROTOCOL §12.3.
    • Transfer error model — canonical against @unicitylabs/state-transition-sdk: REQUEST_ID_MISMATCH at submit = double-spend signal; sustained PATH_NOT_INCLUDED past POLLING_WINDOW (default 30 min) = oracle rejected; PATH_INVALID / NOT_AUTHENTICATED retry up to MAX_PROOF_ERROR_RETRIES. Threat model: aggregator faulty-not-hostile; explicit threat boundary in §9.4.1.
    • Most-recent-proof rule — same requestId + same value can have multiple valid proofs across BFT rounds; canonicalize by latest BFT round (supersedes the considered-and-rejected lex-min-CID rule for proofs; lex-min bundleCid still governs divergent-chain tie-breaks per UXF-TRANSFER-PROTOCOL §5.3 [D-conflict]). Two proofs for same requestId with different values → transfer:security-alert (single-spend violation; out-of-scope hostile path). See UXF-TRANSFER-PROTOCOL §6.3.
    • Outbox CRDT invariants — three-tier state partition (active / soft-terminal / hard-terminal); Lamport clock with max(local, observed)+1 rule; overrideApplied sticky flag for operator-import override stickiness; two-set commitmentRequestIds (outstanding + completed) preventing finalized-then-re-added re-submission. See UXF-TRANSFER-PROTOCOL §7.1.
    • Operator escape hatchespayments.importInclusionProof(tokenId, proofBytes, {allowInvalidOverride?}) with 10-case enumeration (cases 1, 2, 3, 4a, 4b, 5, 6, 7, 8, 9); revalidateCascadedChildren(parentTokenId) (transitive). See UXF-TRANSFER-PROTOCOL §6.3 + §6.1.1.
  • cacheMessages option for CommunicationsModulecommunications: { cacheMessages: false } in SphereInitOptions disables DM caching in memory and storage. Messages still flow through onDirectMessage() handlers and message:dm events, but are never stored. Useful for anonymous/ephemeral agents (e.g. LLM bots) that only need streaming DM reception. sendDM() still works but doesn't cache the sent message. Deduplication is skipped when caching is disabled.
  • Message signingsignMessage(), verifySignedMessage(), hashSignMessage() crypto functions for secp256k1 ECDSA with recoverable signatures (Bitcoin-like double-SHA256 with Sphere Signed Message:\n prefix). Sphere.signMessage(message) instance method encapsulates private key access. SIGNING_ERROR added to SphereErrorCode. SphereInstance interface in ConnectHost extended with signMessage. 22 unit tests covering signing, verification, round-trips, tampering detection, and edge cases.
  • Centralized loggerlogger singleton with debug/warn/error levels, globalThis-based state sharing across tsup bundles, per-tag control (logger.setTagDebug('Nostr', true)), and custom handler support
  • SphereError with typed error codes — All SDK methods throw SphereError with a typed .code field (SphereErrorCode). 15 error codes: NOT_INITIALIZED, ALREADY_INITIALIZED, INVALID_CONFIG, INVALID_IDENTITY, INSUFFICIENT_BALANCE, INVALID_RECIPIENT, TRANSFER_FAILED, STORAGE_ERROR, TRANSPORT_ERROR, AGGREGATOR_ERROR, VALIDATION_ERROR, NETWORK_ERROR, TIMEOUT, DECRYPTION_ERROR, MODULE_NOT_AVAILABLE
  • isSphereError() type guard — Helper function for typed error handling in catch blocks
  • Silent failure logging — All previously silent .catch(() => {}), empty catch blocks, and timeout-based silent failures now log via logger.warn (operational issues) or logger.debug (expected/non-critical)
  • 20 unit tests for logger module
  • IPNS push-based sync via WebSocketIpnsSubscriptionClient connects to /ws/ipns on IPFS gateways for real-time IPNS update notifications, with exponential backoff reconnection (5s→60s capped) and 30s keepalive pings
  • Fallback HTTP polling — When WebSocket is unavailable, the IPFS provider automatically polls for IPNS changes at a configurable interval (default: 90s)
  • Auto-sync on importSphere.import() automatically syncs with all registered token storage providers after initialization to recover tokens from IPFS
  • Debounced auto-sync on remote updatesPaymentsModule subscribes to storage:remote-updated events from token storage providers and performs a debounced (500ms) sync, emitting a new sync:remote-update sphere event
  • storage:remote-updated storage event type — New event emitted by IpfsStorageProvider when a remote IPNS change is detected via WebSocket push or HTTP polling
  • sync:remote-update sphere event — New top-level event with { providerId, name, sequence, cid, added, removed } payload, emitted after a push-triggered sync completes
  • WebSocket factory injection in platform factoriescreateNodeIpfsStorageProvider() and createBrowserIpfsStorageProvider() now automatically inject platform-appropriate WebSocket factories
  • IpfsHttpClient.getGateways() — New public accessor returning configured gateway URLs
  • IpfsStorageConfig extensions — New optional fields: createWebSocket, wsUrl, fallbackPollIntervalMs, syncDebounceMs
  • IpnsUpdateEvent type — Exported from impl/shared/ipfs for consumers
  • 24 unit tests for IpnsSubscriptionClient covering subscribe, message handling, reconnection, keepalive, fallback polling, and disconnect

Fixed

  • IPFS token recovery via TXF mergemergeTxfData() now recognizes individual token entries (token-* keys) stored via saveToken(), not just _-prefixed TXF keys; previously IPFS sync returned added: 0 because merge couldn't find tokens in the blob
  • TXF parser handles individual file formatparseTxfStorageData() now extracts tokens from { token, meta } wrapper format used by IPFS individual token storage
  • Sync coalescingPaymentsModule.sync() now coalesces concurrent calls, preventing race conditions when multiple syncs overlap

Changed

  • Types — DEFAULT_IPFS_GATEWAYS widened to readonly string[] (previously readonly ['https://unicity-ipfs1.dyndns.org'] literal tuple). Required to accommodate the new SPHERE_IPFS_GATEWAY env override (see Added). No runtime behavior change. Consumers relying on the literal-tuple type should switch to BUILTIN_IPFS_GATEWAYS (which retains the as const shape).
  • All throw new Error() in production code replaced with throw new SphereError() — zero plain errors remaining
  • All console.log/warn/error in production code replaced with logger.debug/warn/error — console output controlled by debug flag
  • logger.warn() and logger.error() are always shown regardless of debug flag; logger.debug() is hidden when debug=false
  • PaymentsModule.updateTokenStorageProviders() now re-subscribes to storage events when providers change
  • PaymentsModule.destroy() now cleans up storage event subscriptions and debounce timers
  • IpfsStorageProvider.shutdown() now disconnects the subscription client