ZIP-321 standard support — open Vizor from zcash: links (all platforms) - #280
ZIP-321 standard support — open Vizor from zcash: links (all platforms)#280piatoss3612 wants to merge 28 commits into
Conversation
6a12aa5 to
967d0ae
Compare
Mirror the existing desktop com.zcash.wallet/payment_uri channel contract (takePendingUris / ready / onUris, buffer-then-flush) on mobile so a zcash:<address>?amount=... link opens Vizor's send screen prefilled. - Android: VIEW intent-filter (scheme=zcash, DEFAULT+BROWSABLE) on the singleTop MainActivity; capture the launch intent (cold start) in configureFlutterEngine and warm links in onNewIntent, buffering until Dart calls ready. - iOS: this app uses the UIScene lifecycle, so URLs arrive via the scene delegate, not application(_:open:). SceneDelegate forwards cold-start (willConnectTo) and warm (openURLContexts) zcash: URLs to a PaymentUriChannelBridge in AppDelegate. Info.plist registers the zcash URL scheme and sets FlutterDeepLinkingEnabled=false so Flutter's router does not also try to route the link. - PaymentUriService now treats android/iOS as supported platforms.
A zcash: link opened while the wallet is locked routed to /unlock and kept the parsed prefill in _PaymentUriLinkListener, then drained to /send when the wallet unlocked. But the unlock screens unconditionally navigate to /home on success, which ran inside routerRefresh.pauseWhile and overrode the drain's /send — so the payment intent was silently lost on the locked path (every platform; the unlocked/warm path was unaffected). Hoist the pending prefill into paymentUriPrefillProvider. The unlock flow claims it (take()) right after a successful unlock, before the post-unlock work can clear it, and routes to /send with the prefill when one is pending (else /home). _PaymentUriLinkListener now reads and clears the same provider, so the already-unlocked path is unchanged. Both desktop (unlock_screen) and mobile (mobile_unlock_screen) unlock paths are covered. Verified live on Linux: zcash: link while locked -> /unlock -> unlock -> lands on the prefilled Send screen (address + amount) instead of Home.
Drives the ZIP-321 payment-URI feature end to end on the live regtest chain: imports a faucet-funded wallet, injects a zcash:<addr>?amount= link over the com.zcash.wallet/payment_uri channel (the contract all five native runners implement), asserts the send screen is prefilled from the URI (address + amount, not typed), then reviews/confirms a real shielded send. Verifies the recipient account observes the pending receive, the funds mine in, and the sender shows Sent -0.25 Completed. Runner: scripts/e2e/flutter-macos-regtest-payment-uri-send.sh (mirrors the existing macos regtest send runners). Heavy; run only on request.
Regression guard for commit 7054d00: a zcash: link opened while the wallet is locked must survive the unlock screen and land on a prefilled /send (not the default /home). The test imports a faucet-funded wallet, signs out to lock it, injects the URI over com.zcash.wallet/payment_uri while locked, asserts the unlock screen stays up, then unlocks and asserts the send screen is prefilled (address + amount) before driving a real shielded send and verifying the recipient mines it in. Adds stable test keys the flow needs: unlock_password_field, unlock_submit_button (unlock_screen) and sidebar_sign_out_button (app_main_sidebar). Runner: scripts/e2e/flutter-macos-regtest-payment-uri-locked-send.sh.
The mobile router's /send route only read a bare String recipient from state.extra, so a payment URI (which arrives as SendPrefillArgs, like the desktop /send route receives) was dropped entirely on mobile — neither the address nor the amount/memo prefilled. Desktop worked because it uses a separate route set (SendScreen(prefill:)). Unpack SendPrefillArgs into MobileSendScreen's existing initialRecipient/ initialAmount/initialMemo params. MobileSendScreen.initState already lands on the amount step (with the address filled) when an amount is present, and stays on the address step otherwise. Bare-string recipients still work. Covered by a mobile-lane mobile_routes_test case.
Adversarial review of the locked-path fix found a data-loss bug and related races. Fix all four: - #1 (data loss): the unlock screens claimed the parked prefill via take() BEFORE the post-unlock awaits (restoreAfterUnlock / refreshAfterUnlock / startSyncAnyway). If any threw or the screen unmounted, the prefill was already cleared with no recovery — the payment was silently lost, exactly in the cold-launch-into-locked scenario this feature targets. Now claim only AFTER the awaits succeed. - #3 (stale): a parked link left unclaimed could fire as a payment on a much later unrelated unlock. paymentUriPrefillProvider now stamps the park time and takeIfFresh() drops anything older than a 10-minute TTL. - #4 (race): _PaymentUriLinkListener listened to appSecurityProvider and drained on unlock, racing the unlock screen's own navigation. Drop that listener (the unlock screens own post-unlock nav) and have the drain defer while matchedLocation is /unlock, so a link arriving mid-unlock is delivered once by the unlock flow. - #7: a failed parse of a second link no longer clear()s a prefill already parked from an earlier valid link. The wallet-loading listener and the warm (already-unlocked) drain are unchanged.
…view fixes) - #5: a ZIP-321 URI carrying an amount jumps the mobile send flow straight to the amount step, which bypasses the recipient step's address-validity gate (_amountReady never checks the address). If the prefilled address validates as 'invalid', fall back to the recipient step so the error is shown instead of letting the user continue to review/send. Only a definitive 'invalid' triggers the fallback — a transient validation 'error' (e.g. offline) is left alone and re-checked at review/send. - #6: add a PaymentUriService unit test covering the cold-start contract (initialize -> takePendingUris drains the buffered URI -> ready), plus a later onUris push, which the regtest tests (onUris-only) did not exercise.
Codex review (Medium): main.cpp registered the zcash: protocol handler unconditionally on every launch, and payment_uri_protocol.cpp wrote HKCU\...\zcash\shell\open\command without an ownership check — so simply opening Vizor reclaimed the handler from another wallet (or another Vizor channel) the user had selected. The Velopack install/update hooks already register it, making the per-launch register both redundant and aggressive. Add RegisterZcashProtocolHandlerIfUnclaimed(): register only when the handler is unset or already points at this install, reusing the same ownership check UnregisterZcashProtocolHandler already does. Startup calls this variant; install/update hooks keep the unconditional register (the intended moment to claim the handler). Not compile-verified — no local Windows build environment (already flagged in the PR).
Codex review (Low): the new send-domain parser (lib/src/features/send/domain/zip321_payment_request.dart, added in 084c65c) was byte-identical to the pre-existing core parser (lib/src/core/zcash/zip321_payment_request.dart) that address-scan and swap already use. Standards parsing should not drift across two copies. Point app.dart and the send parser test at the core copy and delete the duplicate. The send test's cases now exercise the core parser too (extra coverage). Both parser test suites (send + swap, 17 tests) pass; analyze clean.
A payment-URI deep link makes /send the navigation root, so the amount step's back button called context.pop() with nothing to pop and did nothing (the user was stuck on the amount screen). - amount back now steps to the recipient step. recipient -> amount is a same-route _step change (not a push), so back mirrors it instead of popping the whole /send route; the user can review/edit the prefilled address. - recipient (first step) back pops if possible, else routes to /home, so a deep-link root has somewhere to go. - _routePopAllowed intercepts the amount step (system back gesture also steps to recipient) and only lets recipient pop when there's something to pop. - Use GoRouter.maybeOf(...)?.canPop() instead of the throwing context.canPop() extension so widgetbook galleries that render this screen without a GoRouter don't crash. analyze clean; mobile-lane use-case + routes suites pass (the pre-existing 'recipient focused' failure is unrelated).
…eep link The earlier back-nav fix (e777cb1) was wrong: it made the amount step always do a _step transition. But in the normal route-step flow amount is a pushed /send/amount PAGE (recipient -> amount is context.push via _continueToAmount, not a _step change), so that broke page-pop and two mobile_send_screen_test cases (route pop / pop as pages). The real bug only happens when a payment-URI deep link lands on the amount step of the ROOT /send route (initialAmount makes _step=amount with no page to pop). So: - amount back pops the /send/amount page when there is one (_canPopRoute), else steps back to recipient in place (deep-link root). - recipient back pops if possible, else routes to /home. - _routePopAllowed restored to the original (useRouteSteps || recipient). mobile_send_screen_test green (+26); full mobile lane has only the pre-existing recipient-focused failure; desktop lane +1385 green.
windows/runner/payment_uri_protocol.cpp called the nonexistent ::ShellChangeNotify and did not include <shlobj.h>, leaving SHChangeNotify and SHCNE_ASSOCCHANGED / SHCNF_IDLIST undeclared. The Windows ZIP-321 URL-protocol registration never compiled (there is no Windows build lane in CI to catch it). Use the real Win32 API ::SHChangeNotify and include <shlobj.h>. Verified on a Win11 x64 debug build: the handler registers HKCU\Software\Classes\zcash (URL Protocol marker, DefaultIcon, and shell\open\command = "<exe>" "%1"), and firing `start "" "zcash:...?amount=...&message=..."` launches the app with the full payment URI passed through as argv[1].
869d4bf to
4c4b000
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4c4b0008a0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c36974ee38
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0bef0cf82e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e6d9c10645
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5bddd0d55d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
What & why
Clicking a
zcash:payment URI (zcash:<address>?amount=...&memo=...) opens Vizor with the send flow prefilled. ZIP-321 is the standard Zcash payment-request URI and should behave as an invoice, not as a fundedvizor://payment-linkbearer voucher.Linear: VZR-91
What changed
zcash:URL handling across macOS, Windows, Linux, Android, and iOS using one Flutter channel contract:takePendingUris->ready->onUris./sendafter unlock instead of dropping the user on/home./sendprefill behavior for ZIP-321 links, including amount-step entry when an amount is present./send*flow is active, clearing the pending link and asking the user to finish or cancel the current send first.ACTION_VIEWintents replayed from Recents/history.zcash:intent-filter remains active, but Flutter's built-in deep-link-to-router dispatch is disabled so payment URIs are handled only byMainActivityand the payment URI MethodChannel.zcash:links now restore a minimized Vizor window and make the main Flutter window key/front after receiving the URI.zcash:association instead of only HKCU before deciding whether the scheme is unclaimed.zcash:launches now forward payment URIs to the already-running Vizor instance instead of opening a second interactive process.zcash:links to the primary process instead of opening a second window.Local validation
Latest ZIP-321 memo whitespace follow-up validation at
47e5528a:Earlier macOS window-presentation follow-up validation at
e6d9c106:Earlier Android deep-link follow-up validation at
0bef0cf8:Confirmed generated Android outputs:
{ "applicationId": "com.keplr.vizor", "deeplinkingFlagEnabled": false, "deeplinks": [] }Earlier Windows handoff follow-up validation at
c36974ee:git diff --check fvm flutter test --reporter=compact test/services/payment_uri_service_test.dart test/app_payment_uri_policy_test.dartEarlier post-rebase validation at
4c4b0008:Earlier validation retained from this PR pass:
bash -n scripts/package-linux-appimage.sh fvm flutter testThe full default desktop lane still had one failure that reproduced unchanged on
origin/main:VM / platform smoke status
These are OS lifecycle and URL-association paths, so widget/unit coverage is not enough for release confidence.
967d0ae2: built the Linux debug bundle withfvm flutter build linux --debug, launched with azcash:argv URI, confirmed the Vizor window rendered, confirmed warmzcash:relaunch exits quickly and leaves only the primary process, confirmed normal relaunch does not create a second process, and confirmedxdg-open zcash:...via a temporary user.desktophandler forwards to the existing process. Later post-review commits are covered by focused Flutter tests and Android compile validation.967d0ae2verified debug runner build, handler ownership, unclaimed HKCU/HKCR registration, and OS dispatch of the fullzcash:...amount...memo...URI. User-session smoke for the warm-forwarding change verified protocol launch into a running Vizor instance, singleVizor.exeprocess, unlock, and prefilled/sendwith addressu1zip321destinationand amount0.25. The laterc36974eeforeground/restore follow-up still needs a minimized/covered-window smoke on Windows.zcash:link from another app/browser while unlocked and locked; confirm post-unlock lands on prefilled/send.zcash:link smoke with the mobile form factor build. The Android manifest output now confirms Flutter's built-in deeplink dispatch is disabled while thezcash:intent-filter remains active.e6d9c106. Still needed: re-run cold/warm/locked link smoke, including a minimized-window warm link to confirm the window deminiaturizes and comes front.How to test manually
Mobile builds/runs need the mobile token define:
Example links:
Expected result: the send screen is prefilled with address and amount. For locked-path smoke, lock/sign out first, open the link, unlock, and confirm the app lands on prefilled
/send, not/home. If a link is opened while already inside a send flow, the app should keep the current send intact and show a message asking the user to finish or cancel it first. A ZIP-321 memo containing unsupported control or bidi characters should be rejected instead of changed and sent.Notes for reviewers
The final review/rebase pass added these scoped commits on top of the rebuilt branch:
core: Bound ZIP-321 memo decode sizemobile: Gate ZIP-321 amount prefills on recipient validationdesktop: Respect existing zcash URI handlerscore: Guard payment URI handoff during sendsmobile: Ignore stale Android payment intentstest: Sync mobile send focused preview expectationfix(windows): Forward payment URIs to running instancefix(windows): Present window for forwarded payment URIsfix(android): Disable Flutter deeplink dispatch for payment URIsfix(macos): Present window for payment URI linksfix(zip321): Reject unsupported memo controlsfix(zip321): Preserve uri memo whitespaceNo visible payment-link context UI was added in this PR; the current behavior remains silent prefill plus the normal send review step. Showing ZIP-321
label/messageor an “opened from payment link” affordance should go through design as a follow-up rather than being introduced here.