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 ProofUnconfirmedError — CERTIFICATION_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.
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-209computesdeliveryPendingand then callscloseTail(...)on the clean path without consulting it.closeTail(:540-548) callscompleteIntent(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 atstores.ts:114). Resume only walks server-open intents, so a completed one is never revisited.Consequences, all silent:
dataDir, or the browser profile abandons that KV, and the spent token is gone with it.Sphere.clear()does the same — it callsstorage.clear()with no prefix (core/Sphere.ts:1184).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.1readsepochorepochStartRound.UnicityCertificateVerifierchecksnetworkIdplus quorum signatures againsttrustBase.rootNodesonly. 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.OracleProviderhas no refresh member (oracle/oracle-provider.ts:28-41) andinitialize()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.tsmatches onlyTRANSACTION_HASH_MISMATCH, soINVALID_TRUSTBASEfalls through toProofUnconfirmedError—CERTIFICATION_UNCONFIRMED, i.e. keep-open — after the spend has already been submitted (SphereTokenEngine.ts:331precedes:338). Andmodules/payments-v2/machine/resume.ts:206emitstransfer:attentiononly forSPLIT_CHECKPOINT_LOSTandCHECKPOINT_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:
buildTokenEnginereadsgetTrustBaseJson()fresh (core/Sphere.ts:3837), sooracle.initialize(newJson)followed bysphere.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_TRUSTBASEits own branch incertification-outcome.tsthat still keeps the intent open (correct — the spend may be committed) but carries a distinct code, soresume.tscan raisetransfer: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
mainat the mainnet enablement merge. File:line references were accurate then; verify before acting.