Skip to content

Money-critical: a pending delivery is stranded on intent completion, and a trust-base rotation freezes the fleet #768

Description

@MastaP

Two independent defects on the money path, both found while investigating the mainnet enablement (#764). Neither is caused by that work; both predate it and both become more consequential now that mainnet is live.

1. A certified-but-undelivered token is stranded when the intent completes

modules/payments-v2/machine/TransferMachine.ts:205-209 computes deliveryPending and then calls closeTail(...) on the clean path without consulting it. closeTail (:540-548) calls completeIntent(transferId, payload) unconditionally.

The recipient's certified token bytes exist in exactly one place at that moment: the local delivery journal (TransferMachine.ts:329-337, blobHex, written into the scoped KV at stores.ts:114). Resume only walks server-open intents, so a completed one is never revisited.

Consequences, all silent:

  • Any change to the network string, the dataDir, or the browser profile abandons that KV, and the spent token is gone with it.
  • Sphere.clear() does the same — it calls storage.clear() with no prefix (core/Sphere.ts:1184).
  • There is no error surface and nothing server-side to recover from: the sender's intent is closed, the recipient never received a deposit.

The asymmetry is the problem — the spend is on chain and durable, the delivery obligation is local and disposable.

2. A root-node rotation freezes every deployed wallet, with no runtime recovery

No verification rule in the pinned state-transition-sdk@3.0.1 reads epoch or epochStartRound. UnicityCertificateVerifier checks networkId plus quorum signatures against trustBase.rootNodes only. On a rotation every signature returns "No root node defined" (UnicitySealQuorumSignaturesVerificationRule), quorum fails, and every inclusion proof is rejected — receive drops all incoming tokens.

The trust base is a compile-time literal (assets/trustbase.ts) reached through a single importer. OracleProvider has no refresh member (oracle/oracle-provider.ts:28-41) and initialize() caches once (oracle/UnicityAggregatorProvider.ts:136-150). So recovery requires an npm release reaching every consumer.

Mainnet's rotation budget is exactly one node. It runs 4 root nodes at quorum 3, so one can rotate out invisibly; the second breaks the entire fleet at once.

It also fails in the worst available way: token-engine/certification-outcome.ts matches only TRANSACTION_HASH_MISMATCH, so INVALID_TRUSTBASE falls through to ProofUnconfirmedErrorCERTIFICATION_UNCONFIRMED, i.e. keep-open — after the spend has already been submitted (SphereTokenEngine.ts:331 precedes :338). And modules/payments-v2/machine/resume.ts:206 emits transfer:attention only for SPLIT_CHECKPOINT_LOST and CHECKPOINT_TRUSTBASE_MISMATCH, so the intent retries forever with no operator surface, sources reserved, no attempt budget.

An undocumented mitigation does exist and is worth knowing: buildTokenEngine reads getTrustBaseJson() fresh (core/Sphere.ts:3837), so oracle.initialize(newJson) followed by sphere.setOracleApiKey(sameKey) swaps the trust base live. It is shaped like an API-key change, which is why nobody would find it.

Suggested first step

Cheap and separable: give INVALID_TRUSTBASE its own branch in certification-outcome.ts that still keeps the intent open (correct — the spend may be committed) but carries a distinct code, so resume.ts can raise transfer:attention. Roughly ten lines, and it converts a silent permanent freeze into something an operator can see.

Also worth writing down: the rotation budget is one node, and a rotation is a lockstep flag day with wallet-api, which verifies deposits against its own pinned trust base.


Evidence gathered 2026-09-01/02 against main at the mainnet enablement merge. File:line references were accurate then; verify before acting.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions