fix: queue intents until unlocked and bind send to source account (M10, L10) - #582
Conversation
…0, L10) M10: deep-link and notification intents were consumed by HomeScreen's listeners even while the lock overlay was up, pushing attacker-crafted screens underneath it. Intent handlers now require localAuthProvider.isAuthenticated; intents arriving while locked stay queued in their providers and are drained on unlock. L10: RegularSendStrategy re-resolved the source account at submit time, so an account switch between review and confirm signed from the new account. The strategy now captures the source account at flow start and uses it for fee estimation, balance validation and submission. Intents that navigate or switch the active account are also deferred while a send flow is on the stack, so a notification tap can't hijack an in-flight send.
🔍 Review — M10, L10 (intent gating & send-account binding)Verdict: 🟡 Approve with non-blocking comments The core fixes are correct and well-targeted: intent handlers now require What it does
Strengths
Findings
VerificationStated verification ( 🤖 AI-assisted review generated with Claude Code |
…ting-send-account
| debugPrint('shared address send: active account cannot send regular transfers'); | ||
| return; | ||
| } | ||
| container.read(keystoneSignCacheProvider.notifier).startNewSendSession(); |
There was a problem hiding this comment.
why does a normal send involve the keystone provider?
…ance fallback, send rejection feedback - Single startSendFlow entry point owns sendFlowActiveProvider (now a bool) and the Keystone signing session; screens no longer mutate providers in initState/dispose. Only one send flow can be active; intents arriving mid-send are dropped, not queued. - effectiveBalanceProviderFamily keeps the last fetched balance on refresh errors instead of zeroing out spendable checks (finding 1). - Non-regular active account send attempts now surface a warning toast (finding 2) and log via quantusDebugPrint (finding 5). - Document the deliberate isAuthenticated-only lock gate (finding 3). - Add widget/unit tests for intent gating, send rejection, balance fallback (finding 4).
Resolved shared_address_action_sheet: keep main's SS58 fail-closed check, then the regular-account guard, then startSendFlow (which owns the Keystone session reset). Renamed quantusDebugPrint to quantusPrint after main's L1 logging change.
- Fake SubstrateService for SS58 validation in widget tests (Rust FFI is unavailable under flutter test). - Disable Riverpod auto-retry in the balance-fallback test so a failed fetch reaches its terminal error state. - Drop TestWidgetsFlutterBinding from the plain provider test (it stalls awaited provider futures); mock SharedPreferences directly. - Extra pump for route disposal after popping the send flow.
n13
left a comment
There was a problem hiding this comment.
Review
Checked out the branch locally, ran flutter analyze and the new/changed test files — all 21 tests pass, and the logic holds up under reading. The fixes for M10 (intent gating behind the lock overlay) and L10 (source account captured at flow start) address the audit findings correctly. The startSendFlow() single-entry-point refactor is a genuine improvement: no provider mutation in widget lifecycle, the double-start guard is race-free (read+set with no await in between), and awaiting the route's popped future covers pop/replacement/removal. Test coverage of the lock-queue/drain, in-flight-drop, and account-switch scenarios is exactly the right shape.
Blocking: CI is red
The Analyze check fails on the format step. These four files are not dart format --line-length=120 clean:
lib/features/components/shared_address_action_sheet.dartlib/providers/wallet_providers.dartlib/v2/screens/home/home_screen.darttest/screens/home_intent_gating_test.dart
dart format lib test --line-length=120 in mobile-app fixes it.
Should fix: unused imports
flutter analyze reports two unused imports added by this PR:
lib/v2/screens/send/input_amount_screen.dart:16—send_providers.dartlib/v2/screens/send/select_recipient_screen.dart:25—send_providers.dart
Non-blocking observations
shared_address_action_sheet.dart:75— theactive is! RegularAccountcheck conflates "accounts still loading" (value == null) with "wrong account type". On a cold start, a fast tap on "Send To This Account" would show "Switch to a regular account to send" when really nothing is loaded yet. Very unlikely in practice (home screen gates on account load), but anull → return earlybranch would make it precise._onTransactionIntentis deliberately not gated onsendFlowActiveProvider, so a transaction notification can open a detail sheet over a send flow. That's safe now that the source account is captured, and it neither switches accounts nor starts a send — assuming this is intentional, fine; a one-line comment saying so would help.- The stale-balance-on-refresh-error change in
effectiveBalanceProviderFamilyis a good trade for spendable-balance checks mid-send; errors before any successful fetch still propagate, and the test pins both paths. - Dropping (rather than queuing) payment/shared/proposal intents during an active send is the right call for a security fix; it's logged via
quantusPrint, so support can diagnose "my deep link did nothing".
Verdict: Request changes — trivial
The substance is correct and well-tested. The only blockers are mechanical: run dart format --line-length=120 and delete the two unused imports so CI goes green. The null-vs-wrong-type edge in the shared-address sheet is worth a follow-up but shouldn't hold the merge.
(Posted as a comment because GitHub doesn't allow requesting changes on your own PR.)
n13
left a comment
There was a problem hiding this comment.
Review verdict: ❌ Request changes
The L10 account capture is complete and the targeted tests pass, but this head is not ready to merge because M10 still has a resume-time lock race and CI has two independent failures.
Blocking findings
-
M10 can still consume intents while the app is visually locked during resume.
HomeScreen._isUnlocked checks only localAuthProvider.isAuthenticated. After the app has been backgrounded, LocalAuthController can be in the state isAuthenticated == true and isVisuallyLocked == true. On resume, AppLifecycleManager calls checkAuthentication without awaiting it, and checkAuthentication awaits shouldRequireAuthentication before it clears isAuthenticated. An app-link or notification callback delivered in that window passes the current gate, clears its intent provider, and navigates beneath the overlay; only afterward can authentication flip to false. This recreates the behavior M10 is intended to remove for background sessions that require re-authentication.
Please block intent consumption while isVisuallyLocked is true (or expose an equivalent explicit auth-check-pending state), and drain when the visual lock clears even when isAuthenticated stays true for a grace-period resume. Add a test for the isAuthenticated == true / isVisuallyLocked == true state transitioning to unlocked; the current test only covers isAuthenticated == false.
-
The PR does not pass repository checks.
The GitHub Analyze job fails formatting. The exact local check reports four files requiring dart format:
- lib/features/components/shared_address_action_sheet.dart
- lib/providers/wallet_providers.dart
- lib/v2/screens/home/home_screen.dart
- test/screens/home_intent_gating_test.dart
After that step, flutter analyze also fails on unused send_providers.dart imports in input_amount_screen.dart:16 and select_recipient_screen.dart:25. Remove those imports and rerun formatting/analyze.
Verification
Reviewed head 8d7b57c. The five directly affected test files pass locally: 21/21 tests.
Re-review verdict: Approve ✅Re-checked at All findings from the previous review are resolved:
The new commit also strengthens the M10 fix beyond what was asked: One leftover nit from the first round, still non-blocking: LGTM — good to merge. |
Summary
Addresses findings M10 and L10 of the 2026-07-22 mobile wallet security audit, plus the review findings from the first round.
M10 — intents consumed beneath the lock overlay
Deep-link (
/pay,/account) and notification (transaction, multisig proposal) intents were acted on byHomeScreen's listeners while the lock overlay was up.home_screen.dartrequirelocalAuthProvider.isAuthenticatedbefore consuming.StateProviders and are drained whenisAuthenticatedflips to true. The cold-start retry onactiveAccountProviderdrains all pending intents.L10 — source account re-resolved at submit time
RegularSendStrategyre-resolved the signing account at submit time, so an account switch between review and confirm signed from the new account.RegularSendStrategycaptures the sourceAccountat flow start (matching the other strategies) and uses it for fee estimation, balance validation and submission.effectiveMaxBalanceProviderFamily(accountId)binds spendable-balance validation to the captured account.Send-flow lifecycle — single entry point, one flow at a time
All send flows start through
startSendFlow()(send_providers.dart), the only place that mutates send-flow state:sendFlowActiveProvider(bool) before pushing and clears it by awaiting the route'spoppedfuture — completed on pop, replacement, andpushAndRemoveUntil, so the flag is correct however the flow exits. No provider mutation in widgetinitState/dispose, no post-frame callbacks.startNewSendSession), so entry points no longer touch the keystone cache; a QR cached by an earlier flow (potentially stale nonce) can never be reused.First-round review findings
effectiveBalanceProviderFamilykeeps the last fetched balance on refresh errors instead of zeroing spendable-balance checks; errors before any successful fetch still propagate._isUnlockeddocuments the deliberate choice to gate on authentication only, not the visual privacy overlay.quantusPrint.Merge with main
Merged current main (through #580). Conflict in
shared_address_action_sheet.dartresolved to run main's SS58 fail-closed check first, then the regular-account guard, thenstartSendFlow.Verification
flutter test test/unit test/screens: 266/266 passed.test/fakes.dart.Notes