Skip to content

ZIP-321 standard support — open Vizor from zcash: links (all platforms) - #280

Draft
piatoss3612 wants to merge 28 commits into
mainfrom
rowan/vzr-91
Draft

ZIP-321 standard support — open Vizor from zcash: links (all platforms)#280
piatoss3612 wants to merge 28 commits into
mainfrom
rowan/vzr-91

Conversation

@piatoss3612

@piatoss3612 piatoss3612 commented Jun 23, 2026

Copy link
Copy Markdown
Member

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 funded vizor://payment-link bearer voucher.

Linear: VZR-91

What changed

  • Added native zcash: URL handling across macOS, Windows, Linux, Android, and iOS using one Flutter channel contract: takePendingUris -> ready -> onUris.
  • Added a ZIP-321 parser for address, amount, memo, label, and message. Multi-recipient, binary memo, and custom-asset requests are parsed but remain unsupported.
  • Preserved locked-path behavior: when a link is opened while locked, the app parks the prefill and lands on /send after unlock instead of dropping the user on /home.
  • Added mobile /send prefill behavior for ZIP-321 links, including amount-step entry when an amount is present.
  • Hardened mobile amount-prefilled links so the review step cannot be reached until recipient validation has completed.
  • Hardened ZIP-321 memo parsing:
    • Oversized encoded memos are rejected before base64 decoding.
    • Text memos containing unsupported control or bidi characters are rejected instead of being silently sanitized.
    • Accepted ZIP-321 text memos preserve decoded whitespace through desktop and mobile send proposal paths, including the mobile amount/review route steps.
  • Blocked incoming payment links while an existing /send* flow is active, clearing the pending link and asking the user to finish or cancel the current send first.
  • Hardened Android URL handling:
    • Warm/cold launches ignore stale ACTION_VIEW intents replayed from Recents/history.
    • The Android zcash: intent-filter remains active, but Flutter's built-in deep-link-to-router dispatch is disabled so payment URIs are handled only by MainActivity and the payment URI MethodChannel.
  • Hardened desktop URL handler behavior:
    • macOS warm zcash: links now restore a minimized Vizor window and make the main Flutter window key/front after receiving the URI.
    • Windows startup now checks the effective zcash: association instead of only HKCU before deciding whether the scheme is unclaimed.
    • Windows warm zcash: launches now forward payment URIs to the already-running Vizor instance instead of opening a second interactive process.
    • Windows forwarded links restore/present the existing window before sending the URI into Flutter.
    • Linux uses a single application instance and forwards warm zcash: links to the primary process instead of opening a second window.
  • Synced the mobile Widgetbook focused-recipient expectation with the current single-field focus layer behavior.

Local validation

Latest ZIP-321 memo whitespace follow-up validation at 47e5528a:

git diff --check
fvm dart format lib/src/features/send/models/send_prefill_args.dart lib/src/features/send/screens/send_screen.dart lib/src/core/navigation/mobile_routes.dart lib/src/features/send/screens/mobile/mobile_send_screen.dart test/features/send/send_prefill_args_test.dart test/features/send/send_screen_test.dart test/core/navigation/mobile_routes_test.dart test/features/send/mobile_send_screen_test.dart
fvm flutter analyze
fvm flutter test --reporter=compact test/features/send/send_prefill_args_test.dart test/features/send/send_screen_test.dart
fvm flutter test --reporter=compact --tags mobile --run-skipped --dart-define=VIZOR_FORM_FACTOR=mobile test/core/navigation/mobile_routes_test.dart test/features/send/mobile_send_screen_test.dart
fvm flutter test --reporter=compact test/features/send/send_prefill_args_test.dart test/features/send/zip321_payment_request_test.dart test/features/swap/zip321_payment_request_test.dart test/services/payment_uri_service_test.dart test/app_payment_uri_policy_test.dart

Earlier macOS window-presentation follow-up validation at e6d9c106:

git diff --check
fvm flutter build macos --debug

Earlier Android deep-link follow-up validation at 0bef0cf8:

git diff --check
cd android && ./gradlew :app:processDebugManifest :app:outputDebugAppLinkSettings --console=plain
cd android && ./gradlew :app:compileDebugKotlin --console=plain
jq . build/app/deeplink.json

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

Earlier post-rebase validation at 4c4b0008:

git diff --check
fvm flutter analyze
fvm flutter test test/services/payment_uri_service_test.dart test/app_payment_uri_policy_test.dart
fvm flutter test --reporter=compact test/features/send/send_prefill_args_test.dart test/features/send/zip321_payment_request_test.dart
fvm flutter test --tags mobile --run-skipped --dart-define=VIZOR_FORM_FACTOR=mobile test/widgetbook/mobile_send_use_cases_test.dart test/core/navigation/mobile_routes_test.dart
fvm flutter test --reporter=compact --tags mobile --run-skipped --dart-define=VIZOR_FORM_FACTOR=mobile test/features/send/mobile_send_screen_test.dart
cd android && ./gradlew :app:compileDebugKotlin --console=plain

Earlier validation retained from this PR pass:

bash -n scripts/package-linux-appimage.sh
fvm flutter test

The full default desktop lane still had one failure that reproduced unchanged on origin/main:

test/widgetbook/receive_use_cases_test.dart
receive desktop transparent modal use case renders info modal
expected Size(312, 403), actual Size(312, 516)

VM / platform smoke status

These are OS lifecycle and URL-association paths, so widget/unit coverage is not enough for release confidence.

Platform Status
Linux VM Passed on Ubuntu 24.04 ARM64 VM at 967d0ae2: built the Linux debug bundle with fvm flutter build linux --debug, launched with a zcash: argv URI, confirmed the Vizor window rendered, confirmed warm zcash: relaunch exits quickly and leaves only the primary process, confirmed normal relaunch does not create a second process, and confirmed xdg-open zcash:... via a temporary user .desktop handler forwards to the existing process. Later post-review commits are covered by focused Flutter tests and Android compile validation.
Windows VM/device Passed on Windows 11 Pro x64 VM. Earlier pass at 967d0ae2 verified debug runner build, handler ownership, unclaimed HKCU/HKCR registration, and OS dispatch of the full zcash:...amount...memo... URI. User-session smoke for the warm-forwarding change verified protocol launch into a running Vizor instance, single Vizor.exe process, unlock, and prefilled /send with address u1zip321destination and amount 0.25. The later c36974ee foreground/restore follow-up still needs a minimized/covered-window smoke on Windows.
iOS real device Still needed: open a zcash: link from another app/browser while unlocked and locked; confirm post-unlock lands on prefilled /send.
Android real device Still needed: same cold/warm/locked zcash: link smoke with the mobile form factor build. The Android manifest output now confirms Flutter's built-in deeplink dispatch is disabled while the zcash: intent-filter remains active.
macOS Debug build passes at 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:

fvm flutter run --dart-define=VIZOR_FORM_FACTOR=mobile

Example links:

# replace <UA> with a valid unified address
macOS:    open "zcash:<UA>?amount=0.1&message=Coffee"
iOS sim:  xcrun simctl openurl booted "zcash:<UA>?amount=0.1"
Android:  adb shell am start -a android.intent.action.VIEW -d "zcash:<UA>?amount=0.1"
Linux:    ./build/linux/<arch>/debug/bundle/vizor "zcash:<UA>?amount=0.1"
Windows:  start "" "zcash:<UA>?amount=0.1"

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 size
  • mobile: Gate ZIP-321 amount prefills on recipient validation
  • desktop: Respect existing zcash URI handlers
  • core: Guard payment URI handoff during sends
  • mobile: Ignore stale Android payment intents
  • test: Sync mobile send focused preview expectation
  • fix(windows): Forward payment URIs to running instance
  • fix(windows): Present window for forwarded payment URIs
  • fix(android): Disable Flutter deeplink dispatch for payment URIs
  • fix(macos): Present window for payment URI links
  • fix(zip321): Reject unsupported memo controls
  • fix(zip321): Preserve uri memo whitespace

No 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 / message or an “opened from payment link” affordance should go through design as a follow-up rather than being introduced here.

@piatoss3612 piatoss3612 self-assigned this Jun 23, 2026
@piatoss3612
piatoss3612 force-pushed the rowan/vzr-91 branch 3 times, most recently from 6a12aa5 to 967d0ae Compare June 28, 2026 06:44
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].
@piatoss3612
piatoss3612 marked this pull request as ready for review June 29, 2026 06:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread windows/runner/flutter_window.cpp

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread android/app/src/main/AndroidManifest.xml

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread macos/Runner/MainFlutterWindow.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread lib/src/features/send/models/send_prefill_args.dart Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread lib/src/core/navigation/mobile_routes.dart
@piatoss3612
piatoss3612 marked this pull request as draft July 6, 2026 04:32
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.

1 participant