feat: encrypted account send via ZK proofs#561
Conversation
Make encrypted (wormhole) accounts spendable: one HD sequence of wormhole addresses acts as a single UTXO pool with coin selection, per-send fresh change addresses, rotating receive addresses and gap-limit recovery. Generalizes WormholeClaimService into WormholeSendService (claim stays as a wrapper) and packages the flow as an EncryptedSendStrategy.
Fix many user interface items Fix logout bugs Fix stale cache bugs
n13
left a comment
There was a problem hiding this comment.
Verdict: Request changes
I found two blocking correctness issues in the encrypted-send lifecycle:
-
Preserve pending-spend state across refreshes (
mobile-app/lib/shared/utils/polling_refresh_scope.dart:75,quantus_sdk/lib/src/services/encrypted_account_service.dart:92-100). After pool acceptance,onBatchSubmittedpersists nullifiers/change specifically to bridge the period before the indexer reports them. But pull-to-refresh and app-resume callrefreshActiveAccountBalance, which callsdiscardCachedState()and deletes that same state file. If the indexer is still behind, the nextload()exposes the just-spent inputs as spendable again and can also roll back/reusenextIndex. Please clear only the chain-derived transfer/nullifier caches here and retain pending spends plus the monotonic address index until normal reconciliation/expiry. -
Keep cancellation scoped to the operation that was cancelled (
quantus_sdk/lib/src/services/wormhole_send_service.dart:163-168).Future.anyreturnsClaimCancelledwhile the proof flow keeps running, and its later_checkCancelled()calls read the mutable service-level_cancelCompleter. If the user closes the cancelled screen and starts another send before the orphaned FFI proof finishes, the second call replaces_cancelCompleter; the first flow now sees not-cancelled and can aggregate/submit after the UI already reported cancellation. Please capture/pass an operation-local cancellation token, or serialize operations, so an abandoned flow remains cancelled permanently.
Validation at head c650e7b88c1c967bebf61833277bbc72153265d2:
- SDK
flutter analyze --no-pub: clean - Mobile
flutter analyze: two non-blockingprefer_const_constructorsinfos - Focused wormhole coin-selection tests: 6/6 passed
- Rust library tests: 13/13 passed
- Full SDK suite: 97 tests passed; 3 setup failures because the local Flutter runner could not load
rust_lib_quantus_wallet.framework - Focused mobile tests could not build because the isolated worktree lacks the ignored
.envasset
Code Review — feat: encrypted account send via ZK proofsVerdict: Request Changes This is a strong PR with clean architecture — the Blocking Issues1. Cancellation token is service-level mutable state — race on overlapping operations
Future<ClaimResult> _withCancellation(Future<ClaimResult> Function() flow) async {
final cancelCompleter = Completer<void>();
_cancelCompleter = cancelCompleter; // ← service-level
...
return await Future.any([flow(), cancelGuard]);
}
Fix: Pass an operation-scoped cancellation token (e.g. a per-call 2.
|
n13
left a comment
There was a problem hiding this comment.
Verdict: Request changes
The pending-spend refresh issue from the previous review is fixed: refresh now preserves the persisted pending spends and monotonic nextIndex while clearing address-specific chain caches.
One blocking correctness issue remains:
- Cancellation still is not scoped to the cancelled operation (
quantus_sdk/lib/src/services/wormhole_send_service.dart:106-113,quantus_sdk/lib/src/services/wormhole_send_service.dart:163-182,quantus_sdk/lib/src/services/wormhole_send_service.dart:193-195).cancel()now awaits_operationDone, but_operationDoneis completed by_withCancellation'sfinally. Because_withCancellationawaitsFuture.any, thatfinallyruns immediately when the cancellation guard wins; it does not wait for the orphanedflow()/ FFI proof work to finish. The UI can therefore return fromcancel(), allow a new send, and replace_cancelCompleter. The old flow's later_checkCancelled()and UTXOisCancelled: () => _cancelledcalls then read the new operation's incomplete shared completer and can continue to aggregate or submit._opIdcurrently only guards cleanup and is never consulted by_checkCancelled, so it does not prevent this. Please give each flow an immutable operation-local token and pass/check that token throughout the flow (including the UTXO callback), or truly serialize operations by retaining/awaiting the underlying flow future before allowing another operation. Add a regression test that cancels during an unresolved proof, starts a second operation, completes the first proof, and verifies the first operation never reaches aggregation/submission.
Validation at head 46659e348be1767ee287876f7e30fbac6dbb3027:
- SDK
flutter analyze --no-pub: clean - Focused wormhole coin-selection tests: 6/6 passed
- GitHub reports no checks for this branch
n13
left a comment
There was a problem hiding this comment.
Review by Grok 4.5
Summary
This PR wires encrypted-account (wormhole) sends end-to-end: coin selection with volume fee / change, dual-exit ZK proofs via FRB, batch submit, Riverpod balance state, and a shared proving progress UI. The core spend math and FFI surface look coherent and are covered by solid coin-selection unit tests. Dominant risks are lifecycle races (logout / dispose while proving), UX promises that are not implemented (notifications, full private activity), and stale plans / nextIndex allocation that are not revalidated at submit time.
Verdict
REQUEST_CHANGES (posted as COMMENT because GitHub disallows requesting changes on your own PR)
Detail
REQUEST_CHANGES
Block on the logout/dispose race that can rewrite encrypted-account disk state after session wipe, and on the progress footer promising notifications that are never scheduled. Other items can follow as follow-ups but should not ship silently.
Issue counts by severity
- bugs: 4
- suggestions: 4
- nits: 2
| _startSend(); | ||
| } | ||
|
|
||
| Future<void> _startSend() async { |
There was a problem hiding this comment.
[bug] _startSend captures EncryptedAccountService and runs proving/submit with no dispose cancellation. Logout clears wormhole caches + encrypted_account_w*.json (SubstrateService.logout / EncryptedAccountService.clearAllPersistedState) and only invalidates Riverpod providers (logout_service.dart), without calling cancel(). The in-flight send keeps the old service alive; onBatchSubmitted can recreate encrypted_account_w{N}.json after wipe, leaking pending nullifiers/change into the next session at the same wallet index. Secrets/mnemonic-derived material already held in memory also continue to be used post-logout.
Suggestion: On logout (and preferably in EncryptedSendProgressScreen.dispose), await encryptedAccountService.cancel() for every live wallet (or a process-wide send cancel registry) before clearing disk; make onBatchSubmitted / _mutateState no-op if a session generation token has changed; optionally refuse to persist after logout.
There was a problem hiding this comment.
users are supposed to either wait for the send to go through or cancel the send
Action items:
- Make sure users are kept on the send page until complete or canceled
- if canceled, make sure we await the successful cancellation
| ), | ||
| const SizedBox(height: 32), | ||
| Text( | ||
| _canceling ? l10n.commonCanceling : l10n.encryptedSendProgressFooter, |
There was a problem hiding this comment.
[bug] Footer copy (encryptedSendProgressFooter) tells the user they can leave the app and will be notified when the private send completes. This flow never creates a PendingTransactionEvent, never starts pending-tx polling, and never posts a local/remote notification on success/failure. Proving also runs only while the isolate lives—OS kill mid-prove drops unsubmitted work with no recovery UX beyond a later balance refresh. PopScope(canPop: !_running) further prevents leaving the route while running (only backgrounding the app).
Suggestion: Either implement pending-state + notification on pool acceptance / finality (mirroring regular sends), or change the copy to match reality (must keep app open; no notification). Prefer recording submitted batch hashes and notifying when remaining work finishes or fails.
There was a problem hiding this comment.
change the footer text, they cannot leave the app
| active.account.accountId, | ||
| colors, | ||
| l10n, | ||
| isPrivate: isEncryptedAccount(active.account), |
There was a problem hiding this comment.
[bug] Private activity labels (isPrivate: isEncryptedAccount(...)) are applied to whatever GraphQL history is already loaded for active.account.accountId. Encrypted account identity is wormhole index 0 only (AccountsService.createEncryptedAccount), while receive/change rotate across HD indices. Deposits to the receive address and most private spends will not appear under index 0’s transfer history, so “Private Sent/Received” UI is largely cosmetic and activity remains empty or wrong for real encrypted usage.
Suggestion: Build encrypted activity from wormhole transfers / pending spends across discovered HD addresses (or a dedicated indexer view), not transparent accountId history alone. Until then, hide the activity list or show an explicit empty state explaining private activity is not listed.
There was a problem hiding this comment.
We can show total deposits and total spends, or else just show a private activity label.
Solution: When we have established total balance, in that process we should have all data to show total received and total sent, so lets only show two items, total received, and total sent
Total received is across all wormhole addresses, total spent is the total of all nullifers, we already gather this information when calculating balance, so should be easy to put there, no additional calls needed.
| /// so the next [load] re-queries from chain. Preserves pending-spend records | ||
| /// and nextIndex — those are only pruned by [load]'s reconciliation or by | ||
| /// the 1-hour expiry, never by a refresh. | ||
| Future<void> discardCachedState() async { |
There was a problem hiding this comment.
[suggestion] discardCachedState only clears caches for addresses already present in _keyPairs. After provider recreation (logout/login, process restart, first encryptedAccountServiceProvider read), _keyPairs is empty, so pull-to-refresh / refreshActiveAccountBalance becomes a no-op discard and relies entirely on existing on-disk transfer/nullifier caches. That is usually OK, but it cannot force a full cache drop when the in-memory key cache is cold.
Suggestion: Persist known address indices (or scan nextIndex + pending change addresses from _FileState) and clear caches for those even when _keyPairs is empty; or clear all wormhole caches for the wallet on force reload.
There was a problem hiding this comment.
force reload should clear all caches, that's the point
There was a problem hiding this comment.
make sure force reload clears all caches
| bool get showPrivateSendNotice => true; | ||
|
|
||
| @override | ||
| String? sourceAccountId(WidgetRef ref) => account.accountId; |
There was a problem hiding this comment.
[suggestion] Self-send guard uses account.accountId (wormhole index 0 only). Users can still target their current receive address (nextIndex) or other HD wormhole addresses. That is wasteful (volume fee) and reduces privacy by linking addresses; depending on chain exit semantics it may also be surprising.
Suggestion: Treat all derived wormhole addresses for the wallet (at least 0..nextIndex plus pending change addresses) as self-send destinations, or clearly allow “move to own address” as an explicit consolidation action.
There was a problem hiding this comment.
This is true. Treat all derived wormhole addresses for the wallet as self address.
| BigInt quan(String v) => wormholePlanckFromScaled((double.parse(v) * 100).round()); | ||
|
|
||
| void main() { | ||
| group('selectWormholeInputs', () { |
There was a problem hiding this comment.
[suggestion] Test coverage stops at pure coin selection. There are no tests for pending-spend reconciliation, onBatchSubmitted partial-batch persistence, dual-exit WormholeLeafSpend construction, logout/clear races, or encrypted fee blockers in the UI strategy.
Suggestion: Add unit tests for EncryptedAccountService state machine (pending prune/expiry, change index bump) and for WormholeSendService.sendSpends batch callback ordering; widget/strategy tests for quantization / insufficient / below-minimum blockers.
There was a problem hiding this comment.
ok will add missing tests
| WormholeProgressSteps( | ||
| steps: [ | ||
| (1, l10n.encryptedSendStepPreparing), | ||
| (2, l10n.encryptedSendStepGathering), |
There was a problem hiding this comment.
[nit] Progress UI always lists steps 2–3 (“Gathering funds”, “Securing transaction”), but sendSpends only runs step 1 then jumps to 4–6. Those steps auto-complete when step 4 starts (WormholeProgressSteps via _maxStartedStep), which is slightly misleading during encrypted send (fine for redeem/claim).
Suggestion: Drive the step list from the operation type (claim vs send) or omit discovery steps for sendSpends.
There was a problem hiding this comment.
make sure our progress UX actually shows what we're doing!
n13
left a comment
There was a problem hiding this comment.
Review at head 2827618 — updated
Previous blocking items (pending-spend refresh, footer text, progress steps) are fixed. One remaining correctness issue found and patched in this review.
Fix applied: cancellation state transition race
_startSend() was catching ClaimCancelled and setting _running = false immediately when Future.any picked up the cancel guard — but the underlying FFI flow was still running at that point. Since PopScope(canPop: !_running), this allowed the user to navigate away before the operation had truly stopped.
Fix: _startSend() no longer transitions UI state on ClaimCancelled. Instead, _cancel() owns the transition — it awaits cancel() (which blocks until _operationDone resolves, i.e. the FFI work actually finishes), then sets _running = false / _cancelled = true. The screen stays in the "Canceling…" state with canPop: false until the operation has truly stopped.
This eliminates the theoretical race where a second operation could start while the first was still winding down.
Non-blocking observations (no changes made)
-
Self-send guard only checks index 0 —
EncryptedSendStrategy.sourceAccountIdreturnsaccount.accountId(wormhole index 0 only). The user can enter their current receive address (nextIndex) without triggering the self-send warning. Per PR discussion, all derived wormhole addresses for the wallet should be treated as self. -
_readState()outside_stateLock—send()readsnextIndexoutside the lock to allocate the change address. A concurrentonBatchSubmittedcall (from the same send) could bumpnextIndexbetween the read and usage. Low practical risk in single-isolate Dart but would be race-free if routed through_mutateState. -
Indonesian localization strings — Several
encryptedSendStep*entries inapp_localizations_id.dartare still English. Not blocking.
Validation
- SDK
flutter analyze --no-pub: clean - Focused wormhole coin-selection tests: 6/6 passed
- Encrypted account service tests: 3/3 passed
Summary
EncryptedSendStrategyintegrating with the shared send UI viaSendStrategyabstractionwormhole_claim_service→wormhole_send_serviceto support both claims and sendsTest plan
wormhole_coin_selection_test.dart)