soft3/mudra/audit/quantus-wallet-evidence.md

Quantus wallet recovery, synchronization and query privacy

Research date: 2026-09-12. Scope: current public source, inspected through shallow local clones and primary GitHub pages. No live wallet, account, transaction, Flutter build or timing benchmark was run. Counterexamples below are source-derived, not live reproductions. This report distinguishes wallet/indexer behavior from the cryptographic properties of Wormhole; chain/circuit verification is covered separately.

Pinned sources

Repository Commit Observed commit date
quantus-apps e843b06b49e4c208f7b4a8c603a91f4578780e96 2026-09-11; commit subject App version 1.6.2
quantus-cli b98083bf29a3bee5a121affd723431d3654c3247 2026-09-10; version bump v2.2.2
quersi 7d79352af27a1c91a6d4f6778a94c41657e506c9 2026-08-28
docs 384472d79c3cae193e455f612e5cd9b2d8e98779 2026-09-11

All source links below are pinned to those commits. The application source already integrates Wormhole balance, UTXO discovery, proof generation and sending. It is not CLI-only: wallet providers, send controller. The encrypted-account feature defaults to enabled but can be overridden by remote config: defaults and parsing. This establishes source integration, not an independently tested App Store release.

What restoring a mnemonic actually does

  1. Transparent accounts use m/44'/189189'/account'/0'/scheme'. The trailing index selects ML-DSA-87 or ML-DSA-65. Discovery scans both schemes.
  2. Wormhole uses m/44'/189189189'/0'/branch'/index'; the app scans external branch 0 and change branch 1. It deterministically derives the spending secret and address from the mnemonic. The Rust bridge calls derivation with no optional mnemonic passphrase.
  3. For each branch, discovery starts at index zero and asks Subsquid whether batches of 20 complete addresses exist. It stops after 20 consecutive missing indices; a used index resets that counter. There is no fixed total-index ceiling, but a gap of 20 prevents discovering later funds. Each encrypted-state reload reruns discovery from zero.
  4. It fetches incoming transfer history for discovered addresses, always including external index zero even if discovery reports no use. For each transfer it derives a nullifier from the owner secret and transfer count, queries whether it was spent, and sums unspent values. Change addresses are included in the same reconstruction.
  5. Restored account indices become at least one beyond the highest discovered use. The import UI launches encrypted discovery asynchronously: completing the import screen does not mean the private balance has finished restoring.

Sources: HD paths and nullifiers; Rust mnemonic bridge; discovery loop and query; encrypted reconstruction; background import.

Consequently, the app's settled receive/change funds can be recovered from its mnemonic and complete indexed history, subject to those derivation and gap conventions. A random per-transfer receipt secret is not an additional prerequisite in this app path. The mnemonic is stored using Flutter secure storage; local encrypted-account JSON holds indices and pending-spend metadata. Pending records temporarily hide submitted inputs and add expected change; they are reconciled against indexer observations or expire after one hour. Losing that local state loses pending-transaction bookkeeping, not deterministically derived settled change secrets. Mnemonic storage; pending state and persistence.

This does not establish recovery of every CLI-created Wormhole balance by simply importing that seed into the app. CLI prepared public batches derive rounds starting at 1_000_000, outside app branches 0/1; reconstructing such funds needs the path/workflow information or preserved artifacts. Independent --secret-file credentials are not derivable from an unrelated wallet mnemonic. Conversely, deterministic high-round secrets are not cryptographically lost merely because a proof artifact disappeared if the seed and exact derivation parameters survive. Prepared-batch paths; round derivation; credential resolution.

State update speed and work performed

Transparent balance is an ordinary remote System.Account query returning data.free; it does not require wallet history reconstruction. The inspected path does not verify a storage proof or consensus locally. Public balance RPC.

Encrypted balance reconstruction delegates historical search to Subsquid rather than downloading and replaying all chain blocks on the phone. It maintains per-address disk caches, paginates transfers in pages of 300, and checks nullifiers in groups of 300. Network-scoped cache filenames incorporate the genesis hash. New transfers are fetched after each address's saved block cursor, with an uncached recent tail of 180 blocks. Every reload still derives/scans addresses from zero and recomputes nullifiers over the wallet's complete retained transfer history; cached spent nullifiers avoid repeat spent queries, but currently unspent candidates are queried again. Page sizes and query; pagination and batched spent checks.

For A derived indices inspected, T incoming transfers retained, and U candidates not already cached as spent, source-derived client cost is approximately:

Operation Local work Data requests, excluding retries and per-group overhead
Cold private restore O(A + T), with O(T) retained transfer/UTXO data O(A/20 + T/300) discovery/history, plus O(T/300) spent queries
Warm private reload Still O(A + T) Discovery again; incremental history plus repeated recent tail; O(U/300) spent queries
Public known-address balance One account query Constant number of requests relative to wallet history

These are asymptotic source deductions, not measured latency. Requests are not all parallel: discovery batches and history pages advance sequentially, while external/change discovery runs concurrently. Multiple saved-height groups add history-query overhead.

The foreground global service schedules an active-account refresh every one minute, scheduling the next timer after the preceding poll finishes. It pauses/resumes with app lifecycle and skips offline work. Encrypted balance is invalidated through the same active-account helper. Pull-to-refresh explicitly clears encrypted caches and reruns discovery; account switches also invalidate balances. The inspected encrypted-balance update path is polling, not a verified subscription to every finalized block. Ordinary pending-extrinsic helpers have separate five-second polling, which should not be reported as the private wallet's global refresh cadence. Global timer; encrypted refresh handling; separate pending polling.

No wallet cold-restore, warm-sync or private-send latency benchmark was found in the inspected SDK/mobile tests and docs. Existing stopwatch logs do not supply reproducible measured results. Existing tests cover pagination, cache generation, secret redaction, pending state and sends using mocks; they were inspected but not executed here. Pagination tests; cache tests; encrypted service tests.

Documentation's Wormhole throughput numbers are block-payload estimates, not evidence of phone recovery or proving speed. Mining documentation's minute/hour sync estimates concern a full mining node, not this indexed wallet. Wormhole throughput discussion; full-node sync estimate.

Concrete cursor and reorg limitations

Source-derived skipped-history counterexample: RPC best height is 1000, while the successfully responding indexer has processed only through 700. The wallet calculates safeCutoff=820, queries the indexer, then saves cursor 820 even though the response was incomplete through that height. When the indexer later catches up, transfers at heights 701–820 are excluded by the next block_height > 820 query. Ordinary reload does not repair this omission. No indexer progress watermark gates cursor advancement in this flow. Height and cutoff; cursor grouping, fetch and unconditional save.

Conditions: an included address, a successful response (including an empty result), RPC/indexer lag exceeding the 180-block tail, and successful cache persistence. HTTP/GraphQL exceptions abort fetching, and failed cache writes would not persist the bad cursor. Empty wallets are not automatically protected: external index zero is always queried, and the cursor is saved before totalTransfers == 0 returns. Other addresses omitted by discovery receive no cache yet, so that particular cursor issue does not occur for them until they are queried; discovery can independently miss later indices because of the gap limit. Unconditional external zero; empty-result return after fetch.

The 180-block rule is a confirmation-depth heuristic derived from the best header, not locally verified finality. Recent transfer/spent results are queried again, allowing shallow reorg correction. Older stored spent flags are monotonic and skipped by future checks; there is no saved block-hash ancestry verification or automatic rollback of those flags. A deeper reorg, or a false old spent result from the indexer, can therefore keep a real note hidden. Explicit cache clearing removes both transfer and spent caches and permits reconstruction; pending metadata is preserved. This repair depends on successful file deletion and subsequently complete indexer answers. Spent-cache handling; explicit invalidation.

On-chain privacy is separate from query privacy

The current mobile wallet sends complete derived SS58 address lists to Subsquid during discovery, including unused look-ahead addresses. Transfer discovery also sends complete receive/change address groups. Spent lookup sends complete BLAKE3 nullifier hashes. These are not prefix-only requests. An indexer can associate queried siblings and receive/change branches within a request/session, and can match exact spent hashes to public nullifiers. This does not reveal the secret or itself break the on-chain proof; it leaks wallet-interest and grouping metadata outside that proof's protection. Discovery payload; transfer query and payload, payload variables, spent-hash query.

CLI collect-rewards does use hash prefixes. Its helper takes a length in hex characters, and current callers use 8: 4 bytes / 32 bits, not 4 bits. Hashing is unsalted BLAKE3 of the raw 32 bytes, followed by local exact matching of returned candidates. Against a public candidate dictionary this does not provide a fixed anonymity set: an indexer can precompute the same hashes. Under a uniform-hash assumption the expected unrelated matches among N known candidates is approximately (N−1)/2^32. For a hypothetical million candidates this is about 0.00023, generally leaving a unique match; this is math, not a measurement of Quantus's live address population. Hash definition and units; actual prefix caller; exact filtering and spent prefix checks, spent checks.

There is a deliberate privacy improvement elsewhere: bulk push-notification registration excludes encrypted accounts. That does not remove the direct Subsquid disclosures above. Notification filter.

Trust and what a successful send means

Private balance display trusts indexer completeness, amounts and spent answers; there is no verified inclusion/absence proof during that balance reconstruction. A full node is not run by this wallet path. Spending additionally requests a Merkle witness, decodes the actual leaf amount/address and generates a ZK proof; this provides a different security boundary from merely displaying an indexed balance. A dishonest endpoint can omit data, mislead the display or prevent proving even if chain verification prevents an invalid spend. The app uses RPC best-block/header data and does not establish independently verified consensus in these methods. Proof inputs and amount validation; header and Merkle RPCs.

Sending returns after author_submitExtrinsic accepts the transaction into the pool, explicitly without waiting for inclusion. The UI then marks the send phase succeeded, and local pending state tracks expected change. Pool acceptance is not inclusion or finality and should not be used as a measured settlement latency. Submission contract; UI success transition.

Quersi is the remote feature-config/rates/risk-report service, not the wallet's historical Subsquid indexer or a private-state synchronization protocol. Quersi scope.

Graph