Skip to content

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

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

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

Conversation

@piatoss3612

Copy link
Copy Markdown
Member

What & why

Clicking a zcash: payment link (zcash:<address>?amount=...&memo=...) opens Vizor with the send screen prefilled (address, amount, memo). ZIP-321 is the standard Zcash payment-request URI, interoperable with other wallets (Zashi, Ywallet, …).

Distinct from a funded vizor://payment-link (a bearer voucher that embeds a mnemonic). This is the standard invoice — "pay me at this address."

Linear: VZR-91

What changed

  • Native deep-linking on all 5 platforms — macOS / Windows / Linux / Android / iOS share one channel contract (com.zcash.wallet/payment_uri, buffer-then-flush: takePendingUrisreadyonUris).
    • iOS uses the UIScene lifecycle, so it's handled in SceneDelegate (willConnectTo cold / openURLContexts warm), not application(_:open:), with FlutterDeepLinkingEnabled=false.
  • ZIP-321 parser — address / amount / memo / label / message; multi-recipient, binary memo, and custom-asset requests are parsed but marked unsupported.
  • Locked path — a link opened while the wallet is locked parks the prefill and lands on the prefilled /send after unlock instead of /home. (Hardened after review: claim only after the post-unlock work succeeds, 10-min TTL, no drain↔unlock race.)
  • Mobile multi-step send/send unpacks the prefill into MobileSendScreen: the amount step (with the address filled) when an amount is present, or a fallback to the address step on an invalid address.

How to test

Mobile builds/runs need --dart-define=VIZOR_FORM_FACTOR=mobile.

Manual (link → prefill):

# 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"

→ the send screen should be prefilled with the address + amount.

Locked path: lock the wallet (sidebar → Sign out), open the link, enter your password on the unlock screen → you should land on the prefilled send screen, not /home.

Automated:

# unit: cold-start channel contract (takePendingUris → ready → onUris)
fvm flutter test test/services/payment_uri_service_test.dart

# mobile route unpacks SendPrefillArgs
fvm flutter test test/core/navigation/mobile_routes_test.dart \
  --tags mobile --run-skipped --dart-define=VIZOR_FORM_FACTOR=mobile

# ZIP-321 parser
fvm flutter test test/features/send/zip321_payment_request_test.dart

# regtest E2E — real shielded sends (needs Docker, heavy)
scripts/e2e/flutter-macos-regtest-payment-uri-send.sh          # warm path
scripts/e2e/flutter-macos-regtest-payment-uri-locked-send.sh   # locked path

⚠️ Environments NOT yet tested (please review carefully)

Environment Status
Windows No local build env — not even compiled. Code is the original branch + static review only; needs a real build/run.
iOS ⚠️ Simulator build ✓ + zcash: scheme registration ✓ only. The actual mobile-form-factor link → prefill → Send flow is unverified (mobile /send prefill is covered only by the widget routing test).
Android ⚠️ Debug APK build ✓ + merged manifest intent-filter ✓ only. Real-device prefill flow unverified.
Linux / Windows (already running) ⚠️ Cold-start link works. A link clicked while the app is already running spawns a second instance (single-instance routing not implemented — VZR-91 follow-up).

The regtest E2E runs on the desktop lane, so it does not cover the mobile /send prefill flow on a device (only the widget routing test does).

Verified: macOS live end-to-end · Linux (UTM) live (locked / no-wallet / unlock→send branches) · regtest E2E (warm + locked) with real shielded sends mining in.

Review

A 6-dimension multi-agent adversarial review confirmed 8 findings → all fixed (incl. a locked-path data-loss bug; 3 false-positives refuted). See the VZR-91 comment for details.

Out of scope (follow-up)

  • Windows/Linux single-instance routing — deliver a link to the already-running instance instead of spawning a new process. Tracked as a follow-up on VZR-91.

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.
@piatoss3612 piatoss3612 self-assigned this Jun 23, 2026
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).
@piatoss3612 piatoss3612 force-pushed the rowan/vzr-91 branch 2 times, most recently from 78689af to e777cb1 Compare June 23, 2026 08:43
…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].
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