Skip to content

feat: encrypted account send via ZK proofs#561

Open
n13 wants to merge 18 commits into
mainfrom
feat/encrypted-account-send-clean
Open

feat: encrypted account send via ZK proofs#561
n13 wants to merge 18 commits into
mainfrom
feat/encrypted-account-send-clean

Conversation

@n13

@n13 n13 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add encrypted (wormhole) send flow with ZK proof generation, coin selection, and multi-batch submission
  • New EncryptedSendStrategy integrating with the shared send UI via SendStrategy abstraction
  • Wormhole coin selection algorithm with batch splitting for large UTXO sets
  • Progress screen with step-by-step ZK proof generation feedback
  • Account discovery service for gap-limit scanning of encrypted indices
  • Refactor wormhole_claim_servicewormhole_send_service to support both claims and sends

Test plan

  • Send from encrypted account with sufficient balance completes successfully
  • Insufficient encrypted funds shows appropriate blocker
  • Batch below minimum exit threshold shows blocker
  • Progress steps display correctly during proof generation
  • Coin selection unit tests pass (wormhole_coin_selection_test.dart)
  • Cancellation mid-proof returns to previous screen cleanly

n13 added 7 commits July 10, 2026 18:10
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 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes

I found two blocking correctness issues in the encrypted-send lifecycle:

  1. 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, onBatchSubmitted persists nullifiers/change specifically to bridge the period before the indexer reports them. But pull-to-refresh and app-resume call refreshActiveAccountBalance, which calls discardCachedState() and deletes that same state file. If the indexer is still behind, the next load() exposes the just-spent inputs as spendable again and can also roll back/reuse nextIndex. Please clear only the chain-derived transfer/nullifier caches here and retain pending spends plus the monotonic address index until normal reconciliation/expiry.

  2. Keep cancellation scoped to the operation that was cancelled (quantus_sdk/lib/src/services/wormhole_send_service.dart:163-168). Future.any returns ClaimCancelled while 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-blocking prefer_const_constructors infos
  • 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 .env asset

@n13

n13 commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review — feat: encrypted account send via ZK proofs

Verdict: Request Changes

This is a strong PR with clean architecture — the SendStrategy abstraction, shared WormholeProgressSteps widget, and coin-selection-with-batch-splitting are well designed. I found two blocking correctness issues (concurring with the self-review) plus a few additional observations.


Blocking Issues

1. Cancellation token is service-level mutable state — race on overlapping operations

WormholeSendService._cancelCompleter is a single mutable field shared across all operations on the same instance. The pattern is:

Future<ClaimResult> _withCancellation(Future<ClaimResult> Function() flow) async {
  final cancelCompleter = Completer<void>();
  _cancelCompleter = cancelCompleter; // ← service-level
  ...
  return await Future.any([flow(), cancelGuard]);
}

Future.any returns on ClaimCancelled immediately, but the orphaned FFI proof flow keeps running. If the user re-enters the send screen and starts a new send before that flow finishes, _withCancellation replaces _cancelCompleter. The old flow's _checkCancelled() now checks the new completer (which is not cancelled) and can aggregate/submit after the UI already reported cancellation.

Fix: Pass an operation-scoped cancellation token (e.g. a per-call Completer or monotonic sequence number) through the flow, or serialize operations with a lock that prevents a new send until the previous flow is confirmed dead.

2. discardCachedState() deletes pending-spend records — pull-to-refresh can expose spent inputs

refreshActiveAccountBalanceservice.discardCachedState() deletes the entire encrypted_account_w$N.json including pendingSpends and the monotonic nextIndex:

Future<void> discardCachedState() async {
  await WormholeUtxoService.clearAllCaches();
  final file = await _stateFile();
  if (await file.exists()) await file.delete();
}

If the indexer hasn't caught up:

  • Spent inputs reappear in the spendable set (their nullifiers are no longer locally excluded).
  • nextIndex resets, so a fresh send could derive the same change address as one already in the pool.
  • Pending change balance disappears (cosmetic, but confusing).

Fix: Preserve pendingSpends and nextIndex through a refresh. Only clear the wormhole transfer/nullifier GraphQL caches (the "derived-from-chain" part). Reconcile pending spends normally during load() — they expire via _pendingSpendExpiry or when the indexer confirms them.


Additional Observations

3. _readState() is not protected by _stateLock

receiveKeyPair() and send() call _readState() outside the _stateLock chain. A concurrent load() (which calls _mutateState) could be mid-write, causing a torn read. Consider routing all reads through the lock, or using _mutateState with an identity transform when you only need to read.

4. Indonesian localization strings left in English

Several app_localizations_id.dart encrypted-send step strings are still English:

String get encryptedSendStepPreparing => 'Preparing';
String get encryptedSendStepGathering => 'Gathering funds';
String get encryptedSendStepSecuring => 'Securing transaction';
...

These should be translated or marked as TODOs for the translation pass.

5. enableEncryptedAccount default flipped to true

remote_config_model.dart changes the fallback from false to true. This enables encrypted accounts for all clients even without a remote config update — intentional for dev, but double-check this is desired for production builds hitting this branch.

6. Coin-selection boundary tests

The test suite is good (6 tests, covers key scenarios). Consider adding a test for exactly 7 inputs (the boundary of a single batch) and for the "send max" case where every input's full net is consumed with zero change.


Positives

  • SendStrategy / EncryptedSendStrategy plugs cleanly into the existing flow without duplicating UI
  • WormholeProgressSteps extracted as a shared widget → DRY with the redeem progress screen
  • buildSentTerminalContent lifted from RegularSendStrategy → reusable by both strategies
  • AccountDiscoveryService.discoverUsedIndices generalized for encrypted address scanning — nice factoring
  • WormholeUtxoService.getTransfersToMany batches queries by cache height → fewer round trips for multi-address discovery
  • Proper logout cleanup: both Riverpod invalidation and on-disk file deletion
  • Unit tests for coin selection cover the core invariants (fee arithmetic, batch splitting, error cases)

Once the two blocking issues are addressed, this is ready to ship.

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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 _operationDone is completed by _withCancellation's finally. Because _withCancellation awaits Future.any, that finally runs immediately when the cancellation guard wins; it does not wait for the orphaned flow() / FFI proof work to finish. The UI can therefore return from cancel(), allow a new send, and replace _cancelCompleter. The old flow's later _checkCancelled() and UTXO isCancelled: () => _cancelled calls then read the new operation's incomplete shared completer and can continue to aggregate or submit. _opId currently 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 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change the footer text, they cannot leave the app

Comment thread mobile-app/lib/v2/screens/send/encrypted_send_strategy.dart
active.account.accountId,
colors,
l10n,
isPrivate: isEncryptedAccount(active.account),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

force reload should clear all caches, that's the point

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make sure force reload clears all caches

Comment thread quantus_sdk/lib/src/services/encrypted_account_service.dart
bool get showPrivateSendNotice => true;

@override
String? sourceAccountId(WidgetRef ref) => account.accountId;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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', () {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok will add missing tests

WormholeProgressSteps(
steps: [
(1, l10n.encryptedSendStepPreparing),
(2, l10n.encryptedSendStepGathering),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make sure our progress UX actually shows what we're doing!

Comment thread quantus_sdk/lib/src/models/remote_config_model.dart

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. Self-send guard only checks index 0EncryptedSendStrategy.sourceAccountId returns account.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.

  2. _readState() outside _stateLocksend() reads nextIndex outside the lock to allocate the change address. A concurrent onBatchSubmitted call (from the same send) could bump nextIndex between the read and usage. Low practical risk in single-isolate Dart but would be race-free if routed through _mutateState.

  3. Indonesian localization strings — Several encryptedSendStep* entries in app_localizations_id.dart are 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

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes

GitHub will record this as a comment because the authenticated reviewer account owns the PR.

At head 5f61718, the earlier pending-spend refresh, progress-footer, derived-address self-send, encrypted activity summary, and Indonesian localization findings are fixed. The coin-selection and dual-exit wiring look coherent, mobile analysis is clean, the focused SDK suite passes 22/22, and the GitHub Analyze check is green.

I still see two blocking lifecycle issues:

  1. Logout does not quiesce encrypted-account work before wiping the session (mobile-app/lib/services/logout_service.dart:39-58, quantus_sdk/lib/src/services/substrate_service.dart:391-398, quantus_sdk/lib/src/services/encrypted_account_service.dart:137-191, 238-258, 305-313). Invalidating Riverpod providers does not cancel an already-running load() or send(). Both retain the old mnemonic-derived material; a delayed load() can write _FileState after logout, and a delayed submitted batch can call onBatchSubmitted and recreate encrypted_account_w{N}.json after clearAllPersistedState(). That lets old-wallet state leak into the next session at the same wallet index, and a send already past authentication can continue after logout. Please cancel and await every live encrypted operation before clearing settings/caches, and gate every post-await persistence callback with a session-generation token. Add a delayed-load/delayed-send versus logout regression test.

  2. Cancellation is still service-global rather than operation-local (quantus_sdk/lib/src/services/wormhole_send_service.dart:97-112, 169-205, 232-239). _withCancellation creates a local cancelCompleter, but _checkCancelled() and the UTXO callback read _cancelled, which dereferences the current service-level _cancelCompleter. _opId only protects cleanup. If a new operation starts on the same service before the old FFI flow reaches a checkpoint, it replaces the completer and the cancelled flow can resume aggregation/submission. Awaiting cancel() in the encrypted progress screen mitigates one caller, but it does not make the shared service safe and the miner/redeem callers do not await cancellation. Thread an immutable per-operation context/token through the flow and have cancel() await the flow it actually cancelled. Add the unresolved-proof / cancel / start-second-op regression test requested in the previous review.

Before merge, I would also address these repository-policy and user-safety items:

  • EncryptedSendProgressScreen owns the send state machine and launches it from initState (mobile-app/lib/v2/screens/send/encrypted_send_progress_screen.dart:44-98). Quantus guidance requires state in providers and widgets to be renderers; this lifecycle ownership is also what makes disposal/logout hard to control. Move the operation/controller state into an auto-disposed provider and cancel/await it on disposal/session teardown.
  • The copy Activity not visible on chain (mobile-app/lib/l10n/app_en.arb:2810) overstates the privacy guarantee. Wormhole hides source linkage, but exits still create observable recipient/amount state changes. Describe the actual guarantee instead of claiming the activity is absent from chain.
  • The new progress screen hardcodes colors and text styles and uses withValues(alpha:) (mobile-app/lib/v2/screens/send/encrypted_send_progress_screen.dart:194-216, 224-239, 285-299). The design-system rules require context.colors.*, context.themeText.*, and .useOpacity().

Validation at 5f617181a34b2da46933c4c461eb82d7e956c827:

  • GitHub Analyze: pass
  • mobile-app: flutter analyze — no issues
  • quantus_sdk: focused coin-selection + encrypted-account-service tests — 22/22 passed

return _FileState.fromJson(jsonDecode(await file.readAsString()) as Map<String, dynamic>);
}

Future<_FileState> _mutateState(_FileState Function(_FileState) fn) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] Logout still races with in-flight encrypted-account mutations. SubstrateService.logout() calls EncryptedAccountService.clearAllPersistedState() (and wipes mnemonic/settings), but any concurrent _mutateState from load() / send() onBatchSubmitted keeps running on the old EncryptedAccountService instance. That path only does file I/O (no mnemonic check) and can recreate encrypted_account_w{N}.json after the wipe with the previous session’s nextIndex / pendingSpends. A subsequent wallet at the same walletIndex then inherits phantom pending change or a wrong next index. Riverpod invalidation after logout does not stop already-scheduled async work.

Suggestion: Add a generation/session token or disposed flag set by clearAllPersistedState / logout; every _mutateState must re-check it after acquiring _stateLock and before writeAsString. Prefer failing the write over resurrecting cleared state. Optionally cancel() in-flight sends and refuse new load/send after dispose.

try {
final cancelGuard = cancelCompleter.future.then<ClaimResult>((_) => throw const ClaimCancelled());
return await Future.any([flow, cancelGuard]);
return await Future.any([flowFuture, cancelGuard]);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug] Cancellation still fails open around submit. _withCancellation uses Future.any([flowFuture, cancelGuard]), so the caller receives ClaimCancelled as soon as cancel() completes the completer, while flowFuture continues until the next _checkCancelled(). There is no cancel check inside Future.wait proof generation or _submitExtrinsic. A cancel after the pre-submit _checkCancelled() (line 344) still submits the batch and may run onBatchSubmitted. EncryptedSendProgressScreen treats ClaimCancelled as non-success (only invalidates state; _cancel marks the UI cancelled), so the user can see “Send Cancelled” after funds actually left. Late cancel on the final batch is the worst case: full send succeeds, UI says cancelled.

Suggestion: Do not surface cancel to the UI via a racing Future.any alone. Either (a) only check cancel between batches and have cancel() await the true flow result, mapping “cancelled after N batches submitted” to a partial/success outcome, or (b) capture an op-local cancel flag and, when cancel wins the race, still await flowFuture and if txHashes is non-empty report partial/complete success instead of pure cancel. At minimum, re-check cancel immediately before _submitExtrinsic and document that in-flight submit cannot be aborted.

required ClaimProgressCallback onProgress,
String? rpcUrl,
}) async {
final changeIndex = (await _readState()).nextIndex;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Change-address allocation still does final changeIndex = (await _readState()).nextIndex outside _stateLock. Concurrent _mutateState writers (send batch persistence vs load() reconciliation after pull-to-refresh / provider invalidation) can interleave with this unlocked read. Writes are not atomic (direct writeAsString), so unlocked _readState can also observe a torn JSON file and fail the send. Index allocation is not reserved until a change-bearing batch’s onBatchSubmitted runs, so two overlapping sends could also pick the same change index.

Suggestion: Allocate and reserve changeIndex inside _mutateState at the start of send (bump or mark reserved under the lock). Route all state reads that inform writes through the lock; consider write-temp-then-rename for the state file.

bool get showPrivateSendNotice => true;

@override
String? sourceAccountId(WidgetRef ref) => account.accountId;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Self-send protection still only compares the recipient to account.accountId, which for encrypted accounts is the wormhole address at HD index 0 (AccountsService.createEncryptedAccount). Deposits/change use later indices shown on Receive (receiveKeyPair()nextIndex). Pasting your current receive address or an older change address is allowed, burning the privacy fee for a self-transfer with no warning.

Suggestion: For encrypted strategy, treat any derived wormhole address in 0..nextIndex (or known _keyPairs / discovered indices) as self-send, or resolve the recipient against the wallet’s wormhole address set before enabling Continue.

required bool isPayMode,
}) async {
final l10n = ref.read(l10nProvider);
final plan = (fee as EncryptedFee).plan;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Submit uses the EncryptedFee.plan frozen at fee-estimate time on the amount screen with no revalidation. UTXO spendability can change before confirm (background invalidation/load, another partial send’s pending spends, long dwell on review). The progress screen then proves a stale plan; failures surface only after expensive proving, and there is no refresh of coin selection immediately before auth/prove.

Suggestion: On confirm (or at the start of EncryptedSendProgressScreen), re-load encrypted state and rebuild/verify the plan (same amount + still-unspent nullifiers). If inputs are gone, fail fast with a clear “balances changed, re-enter amount” error before circuit work.

… send state

Blocking issues:

- WormholeSendService: cancellation is now operation-local. Each claim/send
  runs with an immutable WormholeOperation context (cancel token + rpcUrl)
  threaded through the whole flow; a second operation on the same service can
  no longer detach or un-cancel an in-flight flow. cancel() awaits the
  operation it actually cancelled. The caller always receives the flow's true
  outcome (no Future.any race): a cancel landing after batches were submitted
  returns a partial ClaimResult(cancelled: true) with the real totals instead
  of ClaimCancelled, so the UI can never claim "cancelled" while funds moved.
  Extra cancel checkpoints before proof generation and immediately before
  submission; in-flight submission is documented as non-abortable.

- EncryptedAccountService: logout now quiesces encrypted work before wiping
  state. Instances register in a live set; dispose() refuses new work,
  cancels/awaits the in-flight send and drains queued mutations; disposeAll()
  runs at the start of SubstrateService.logout (before settings/caches are
  cleared) and inside clearAllPersistedState(). Every _mutateState re-checks
  the disposed gate under the state lock before writing, so a delayed load()/
  onBatchSubmitted can never recreate encrypted_account_w{N}.json after
  logout. Provider invalidation also disposes via ref.onDispose.

- Change-index allocation now happens under the state lock with an in-memory
  reservation set (two overlapping sends get distinct change addresses), all
  reads that inform writes go through the lock, and the state file is written
  temp-then-rename so readers can never observe a torn file.

Repository-policy / user-safety items:

- The encrypted send state machine moved out of the widget into an
  auto-disposed Notifier provider (encryptedSendControllerProvider) that owns
  revalidation, proving, submission, cancellation and phase transitions;
  EncryptedSendProgressScreen is now a renderer. Disposal cancels the
  underlying operation.

- The spend plan is revalidated at the start of the progress flow: encrypted
  state is re-loaded and every planned input checked against the fresh
  unspent set, failing fast with a dedicated "balance changed" error before
  any circuit work.

- privateSendSubtitle no longer overstates the privacy guarantee (source
  linkage is hidden; recipient/amount state changes remain observable).

- Progress screen uses context.colors.* / context.themeText.* / useOpacity()
  instead of hardcoded colors, inline text styles and withValues(alpha:).

- Redeem screen and miner claim dialog surface partial-cancel results as
  cancelled-with-partial-totals instead of full success.

Regression tests: delayed-send/delayed-load vs logout, refuse-after-dispose,
overlapping-send change-index allocation, and unresolved-flow / cancel /
second-operation cancellation semantics. SDK suite 133/133, mobile 218/218,
analyzers clean on quantus_sdk, mobile-app, miner-app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@n13

n13 commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review findings in 29647d5:

1. Logout vs in-flight encrypted work (blocking)EncryptedAccountService instances now register in a live set; dispose() refuses new work, cancels and awaits the in-flight send, and drains queued state mutations. SubstrateService.logout() calls disposeAll() before clearing settings/caches, and clearAllPersistedState() quiesces too. Every _mutateState re-checks the disposed gate under the state lock before writing, so a delayed load()/onBatchSubmitted can no longer recreate encrypted_account_w{N}.json after logout. Provider invalidation also disposes via ref.onDispose. Regression tests: delayed-send-vs-logout, delayed-load-vs-dispose, refuse-after-dispose.

2. Service-global cancellation (blocking) — cancellation is now operation-local: an immutable WormholeOperation context (cancel token + rpcUrl) is threaded through the entire flow, so a second operation on the same service can neither un-cancel nor resume an older flow, and cancel() awaits the flow it actually cancelled. The Future.any race is gone — callers always get the flow's true outcome. A cancel landing after batches were submitted returns a partial ClaimResult(cancelled: true) with the real totals (never "cancelled" while funds moved; a cancel after the last batch reports success). Added checkpoints before proof generation and immediately before submit, with in-flight submission documented as non-abortable. Regression test covers unresolved-flow / cancel / start-second-op. The redeem screen and miner claim dialog now surface partial-cancel results as cancelled-with-partial-totals.

3. Change-index allocation under lock — allocation happens inside the state lock with an in-memory reservation set (overlapping sends get distinct change addresses, covered by a test); all reads that inform writes are routed through the lock; the state file is written temp-then-rename.

4. Widget-owned state machine — the send operation moved into an auto-disposed Notifier provider (encryptedSendControllerProvider) owning revalidation, proving, submission, cancellation and phase transitions; EncryptedSendProgressScreen is now a renderer, and provider disposal cancels the underlying operation.

5. Stale-plan submission — the plan is revalidated at the start of the progress flow: encrypted state is re-loaded and every planned input checked against the fresh unspent set, failing fast with a dedicated "balance changed, re-enter amount" error before any circuit work.

6. CopyprivateSendSubtitle now reads "Hides the link between your account and the recipient" (en + id), matching the actual guarantee.

7. Design system — the progress screen uses context.colors.*, context.themeText.* and .useOpacity() throughout; no hardcoded colors/styles or withValues(alpha:) remain.

Validation at 29647d5: quantus_sdk 133/133 tests, mobile-app 218/218 tests, flutter analyze clean on quantus_sdk, mobile-app and miner-app. (The only failure anywhere is miner-app's pre-existing fully-commented-out widget_test.dart stub, untouched by this change.)

🤖 Generated with Claude Code

@n13

n13 commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Ok now ready for review by others

Comment thread miner-app/lib/features/withdrawal/claim_rewards_dialog.dart
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants