diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b16277de9..dc5c94703 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -26,10 +26,22 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/keplr/vizor/MainActivity.kt b/android/app/src/main/kotlin/com/keplr/vizor/MainActivity.kt
index a81c44524..0b45161c6 100644
--- a/android/app/src/main/kotlin/com/keplr/vizor/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/keplr/vizor/MainActivity.kt
@@ -11,6 +11,9 @@ import io.flutter.plugin.common.MethodChannel
// FlutterFragmentActivity: BiometricPrompt requires a FragmentActivity host.
class MainActivity : FlutterFragmentActivity() {
private lateinit var deviceOwnerAuthHandler: DeviceOwnerAuthHandler
+ private var paymentUriChannel: MethodChannel? = null
+ private val pendingPaymentUris = mutableListOf()
+ private var paymentUriDartReady = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -64,6 +67,29 @@ class MainActivity : FlutterFragmentActivity() {
else -> result.notImplemented()
}
}
+
+ paymentUriChannel = MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ PAYMENT_URI_CHANNEL
+ ).apply {
+ setMethodCallHandler { call, result ->
+ when (call.method) {
+ "takePendingUris" -> {
+ val uris = pendingPaymentUris.toList()
+ pendingPaymentUris.clear()
+ result.success(uris)
+ }
+ "ready" -> {
+ paymentUriDartReady = true
+ flushPendingPaymentUris()
+ result.success(null)
+ }
+ else -> result.notImplemented()
+ }
+ }
+ }
+ // A zcash: link that cold-starts Vizor arrives as the launch intent.
+ capturePaymentUri(intent)
}
/** REJECT is the platform's error haptic; older APIs report
@@ -111,9 +137,35 @@ class MainActivity : FlutterFragmentActivity() {
}
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ // singleTop launchMode: a zcash: link tapped while Vizor is already
+ // running is delivered here instead of through a fresh launch intent.
+ setIntent(intent)
+ capturePaymentUri(intent)
+ }
+
+ private fun capturePaymentUri(intent: Intent?) {
+ if (intent == null || intent.action != Intent.ACTION_VIEW) return
+ if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return
+ val data = intent.data ?: return
+ if (!"zcash".equals(data.scheme, ignoreCase = true)) return
+ pendingPaymentUris.add(intent.dataString ?: data.toString())
+ flushPendingPaymentUris()
+ }
+
+ private fun flushPendingPaymentUris() {
+ if (!paymentUriDartReady || pendingPaymentUris.isEmpty()) return
+ val channel = paymentUriChannel ?: return
+ val uris = pendingPaymentUris.toList()
+ pendingPaymentUris.clear()
+ channel.invokeMethod("onUris", uris)
+ }
+
companion object {
private const val CAMERA_PERMISSION_CHANNEL = "com.zcash.wallet/camera_permission"
private const val HAPTICS_CHANNEL = "com.zcash.wallet/haptics"
private const val PRIVACY_SHIELD_CHANNEL = "com.zcash.wallet/privacy_shield"
+ private const val PAYMENT_URI_CHANNEL = "com.zcash.wallet/payment_uri"
}
}
diff --git a/dev/open_payment_uri.sh b/dev/open_payment_uri.sh
new file mode 100755
index 000000000..5af9c79f3
--- /dev/null
+++ b/dev/open_payment_uri.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+uri='zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez'
+uri+='?amount=0.12345678'
+uri+='&memo=Q1AtQzZDREI3NzU'
+uri+='&message=Thank%20you%20for%20your%20purchase'
+
+case "$(uname -s)" in
+ Darwin)
+ open "$uri"
+ ;;
+ Linux)
+ xdg-open "$uri"
+ ;;
+ MINGW*|MSYS*|CYGWIN*)
+ cmd.exe /c start "" "$uri"
+ ;;
+ *)
+ printf 'Unsupported platform for opening payment URI\n' >&2
+ exit 1
+ ;;
+esac
diff --git a/dev/payment_uri_demo.html b/dev/payment_uri_demo.html
new file mode 100644
index 000000000..6385c0d06
--- /dev/null
+++ b/dev/payment_uri_demo.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+ Vizor Payment URI Demo
+
+
+
+
+
+ Open in Vizor
+
+
+
+ zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=0.12345678&memo=Q1AtQzZDREI3NzU&message=Thank%20you%20for%20your%20purchase
+
+
+
+
diff --git a/integration_test/payment_uri_prefill_test.dart b/integration_test/payment_uri_prefill_test.dart
new file mode 100644
index 000000000..478bf7ee2
--- /dev/null
+++ b/integration_test/payment_uri_prefill_test.dart
@@ -0,0 +1,160 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:go_router/go_router.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:zcash_wallet/app.dart';
+import 'package:zcash_wallet/src/app_bootstrap.dart';
+import 'package:zcash_wallet/src/core/config/rpc_endpoint_config.dart';
+import 'package:zcash_wallet/src/core/theme/app_theme.dart';
+import 'package:zcash_wallet/src/features/send/models/send_prefill_args.dart';
+import 'package:zcash_wallet/src/features/send/screens/send_screen.dart';
+import 'package:zcash_wallet/src/providers/account_models.dart';
+import 'package:zcash_wallet/src/providers/sync_provider.dart';
+
+const _accountUuid = '550e8400-e29b-41d4-a716-446655440000';
+const _address =
+ 'ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez';
+const _prefill = SendPrefillArgs(
+ id: 'payment-uri-e2e',
+ source: 'zcash-uri',
+ address: _address,
+ amountText: '0.12345678',
+ memoText: 'CP-C6CDB775',
+);
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ setUpAll(() async {
+ await initializeZcashWalletRuntime();
+ });
+
+ testWidgets('payment URI prefill survives send route refresh', (
+ WidgetTester tester,
+ ) async {
+ await tester.binding.setSurfaceSize(const Size(1512, 982));
+ addTearDown(() async {
+ await tester.binding.setSurfaceSize(null);
+ });
+
+ final router = GoRouter(
+ initialLocation: '/send',
+ initialExtra: _prefill,
+ routes: [
+ GoRoute(
+ path: '/send',
+ builder: (_, state) {
+ final extra = state.extra;
+ return SendScreen(prefill: extra is SendPrefillArgs ? extra : null);
+ },
+ ),
+ GoRoute(path: '/home', builder: (_, _) => const Text('home route')),
+ ],
+ );
+
+ await tester.pumpWidget(_harness(router));
+
+ await _waitForFieldText(
+ tester,
+ const ValueKey('send_address_field'),
+ _address,
+ description: 'payment URI address prefill',
+ );
+ expect(
+ _fieldText(tester, const ValueKey('send_amount_field')),
+ '0.12345678',
+ );
+ expect(
+ _fieldText(tester, const ValueKey('send_memo_field')),
+ 'CP-C6CDB775',
+ );
+
+ router.go('/send');
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 250));
+
+ expect(_fieldText(tester, const ValueKey('send_address_field')), _address);
+ expect(
+ _fieldText(tester, const ValueKey('send_amount_field')),
+ '0.12345678',
+ );
+ expect(
+ _fieldText(tester, const ValueKey('send_memo_field')),
+ 'CP-C6CDB775',
+ );
+ });
+}
+
+Widget _harness(GoRouter router) {
+ return ProviderScope(
+ overrides: [
+ appBootstrapProvider.overrideWithValue(_bootstrap),
+ syncProvider.overrideWith(() => _FakeSyncNotifier(_syncState)),
+ ],
+ child: MaterialApp.router(
+ routerConfig: router,
+ builder: (_, child) => AppTheme(data: AppThemeData.light, child: child!),
+ ),
+ );
+}
+
+Future _waitForFieldText(
+ WidgetTester tester,
+ Key key,
+ String expected, {
+ required String description,
+ Duration timeout = const Duration(seconds: 10),
+}) async {
+ final deadline = DateTime.now().add(timeout);
+ while (DateTime.now().isBefore(deadline)) {
+ if (tester.any(find.byKey(key)) && _fieldText(tester, key) == expected) {
+ return;
+ }
+ await tester.pump(const Duration(milliseconds: 100));
+ }
+ fail('Timed out waiting for $description.');
+}
+
+String _fieldText(WidgetTester tester, Key key) {
+ final editable = find.descendant(
+ of: find.byKey(key),
+ matching: find.byType(EditableText),
+ );
+ return tester.widget(editable).controller.text;
+}
+
+final _bootstrap = AppBootstrapState(
+ initialLocation: '/send',
+ initialAccountState: const AccountState(
+ accounts: [AccountInfo(uuid: _accountUuid, name: 'Account 1', order: 0)],
+ activeAccountUuid: _accountUuid,
+ activeAddress: 'u1paymenturiprefillwalletaddress',
+ ),
+ initialSyncSnapshot: AppSyncSnapshot.empty,
+ network: 'main',
+ rpcEndpointConfig: defaultRpcEndpointConfig('main'),
+ themeMode: ThemeMode.system,
+ privacyModeEnabled: false,
+ isPasswordConfigured: true,
+ isUnlocked: true,
+ passwordRotationRecoveryFailed: false,
+);
+
+final _syncState = SyncState(
+ accountUuid: _accountUuid,
+ hasAccountScopedData: true,
+ scannedHeight: 1,
+ chainTipHeight: 1,
+ spendableBalance: BigInt.zero,
+ totalBalance: BigInt.zero,
+);
+
+class _FakeSyncNotifier extends SyncNotifier {
+ _FakeSyncNotifier(this.initialState);
+
+ final SyncState initialState;
+
+ @override
+ Future build() async => initialState;
+}
diff --git a/integration_test/regtest_payment_uri_locked_send_test.dart b/integration_test/regtest_payment_uri_locked_send_test.dart
new file mode 100644
index 000000000..8b5fafc66
--- /dev/null
+++ b/integration_test/regtest_payment_uri_locked_send_test.dart
@@ -0,0 +1,700 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:zcash_wallet/app.dart';
+import 'package:zcash_wallet/src/core/config/network_config.dart';
+import 'package:zcash_wallet/src/core/storage/app_secure_store.dart';
+import 'package:zcash_wallet/src/core/storage/wallet_paths.dart';
+import 'package:zcash_wallet/src/core/widgets/app_button.dart';
+import 'package:zcash_wallet/src/providers/account_models.dart';
+import 'package:zcash_wallet/src/rust/api/sync.dart' as rust_sync;
+import 'package:zcash_wallet/src/rust/api/wallet.dart' as rust_wallet;
+
+// End-to-end regtest coverage for the ZIP-321 payment-URI LOCKED path — the
+// regression guard for delivering a parked prefill to /send after unlock
+// instead of the default /home. Imports a faucet-funded wallet, signs out to
+// lock it, injects a `zcash:?amount=...` link over the
+// `com.zcash.wallet/payment_uri` MethodChannel while locked, then unlocks and
+// asserts the wallet lands on the prefilled send screen (address + amount) and
+// that the resulting shielded send mines on the live regtest network.
+
+const _network = String.fromEnvironment(
+ 'ZCASH_E2E_NETWORK',
+ defaultValue: 'regtest',
+);
+const _lightwalletdUrl = String.fromEnvironment(
+ 'ZCASH_E2E_LIGHTWALLETD_URL',
+ defaultValue: 'http://127.0.0.1:9067',
+);
+const _zcashdRpcUrl = String.fromEnvironment(
+ 'ZCASH_E2E_ZCASHD_RPC_URL',
+ defaultValue: 'http://127.0.0.1:18232',
+);
+const _zcashdRpcUser = 'zcash';
+const _zcashdRpcPassword = 'zcash';
+const _accountsKey = 'zcash_accounts';
+const _paymentUriChannel = 'com.zcash.wallet/payment_uri';
+const _firstMnemonic =
+ 'winter shiver fetch refuse absurd mail pistol eight market lounge manual '
+ 'roast miracle ethics found child scare curve congress renew salute pig '
+ 'better used';
+const _secondMnemonic =
+ 'return try reason flat civil wolf dwarf announce toddler uphold equip '
+ 'range neck proof gauge east rifle swim tray twin venue fossil will '
+ 'version';
+const _password = 'Vizor123!';
+final _currencyTicker = kZcashDefaultCurrencyTicker;
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ setUpAll(() async {
+ await initializeZcashWalletRuntime();
+ });
+
+ testWidgets(
+ 'a zcash: payment URI opened while locked survives unlock and sends',
+ (tester) async {
+ addTearDown(() async {
+ await _cleanupE2eWalletState();
+ });
+
+ await _cleanupE2eWalletState();
+
+ _log('pumping app');
+ await tester.pumpWidget(await buildBootstrappedZcashWalletApp());
+
+ await _importFirstWallet(tester);
+ await _waitForBalance(tester, shielded: '1.25');
+
+ await _openAddAccountFlow(tester);
+ await _importAdditionalWallet(tester);
+ await _waitForHome(tester);
+
+ _log('copying second account shielded address');
+ final secondAddress = await _copyActiveShieldedAddress(tester);
+ expect(secondAddress, startsWith('uregtest1'));
+ final secondAccountUuid = await _accountUuidAtOrder(1);
+
+ await _openWallet(tester);
+ await _switchAccount(tester, 0);
+ await _waitForBalance(tester, shielded: '1.25');
+ await _waitForMempoolObserver();
+
+ // The heart of this test: a zcash: URI opened while the wallet is locked
+ // must survive the unlock screen and land on a prefilled send screen
+ // (regression guard for the locked-path fix), then send for real.
+ await _sendViaLockedPaymentUri(tester, secondAddress, '0.25');
+
+ await _openWallet(tester);
+ await _switchAccount(tester, 1);
+ await _waitForHistoryEntry(
+ tester,
+ accountUuid: secondAccountUuid,
+ txKind: 'receiving',
+ displayAmount: BigInt.from(25_000_000),
+ pending: true,
+ );
+ _log('second account observed the incoming payment-URI transaction');
+
+ await _mineRegtestBlocks(10);
+
+ await _openWallet(tester);
+ await _waitForBalance(
+ tester,
+ shielded: '0.25',
+ timeout: const Duration(minutes: 4),
+ );
+ _log('second account received the payment-URI funds');
+
+ await _openWallet(tester);
+ await _switchAccount(tester, 0);
+ await _expectActivityRow(
+ tester,
+ const ValueKey('home_desktop_activity_row_0'),
+ title: 'Sent',
+ amount: '-0.25 $_currencyTicker',
+ status: 'Completed',
+ );
+ _log('first account sent activity matched');
+ },
+ timeout: const Timeout(Duration(minutes: 10)),
+ );
+}
+
+/// Locks the wallet, opens a `zcash:` link while locked, unlocks, and asserts
+/// the parked prefill is delivered to the prefilled send screen (not the
+/// default /home) before driving the send to completion.
+Future _sendViaLockedPaymentUri(
+ WidgetTester tester,
+ String address,
+ String amount,
+) async {
+ // Lock the wallet first, then open the link while locked.
+ await _signOut(tester);
+
+ final uri = 'zcash:$address?amount=$amount';
+ _log('injecting payment URI while locked: $uri');
+ await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
+ _paymentUriChannel,
+ const StandardMethodCodec().encodeMethodCall(
+ MethodCall('onUris', [uri]),
+ ),
+ (_) {},
+ );
+ await tester.pump(const Duration(milliseconds: 250));
+
+ // The link must not bypass the lock screen: we stay on /unlock with the
+ // prefill parked.
+ expect(
+ tester.any(find.byKey(const ValueKey('unlock_password_field'))),
+ isTrue,
+ reason:
+ 'a payment URI opened while locked must keep the unlock screen showing',
+ );
+
+ // Unlock. The unlock flow must claim the parked prefill and route to the
+ // prefilled /send screen instead of the default /home (regression guard for
+ // the locked-path fix).
+ await _enterText(
+ tester,
+ const ValueKey('unlock_password_field'),
+ _password,
+ );
+ await _tapAppButton(
+ tester,
+ const ValueKey('unlock_submit_button'),
+ timeout: const Duration(minutes: 1),
+ );
+
+ await _pumpUntil(
+ tester,
+ () =>
+ _editableTextEquals(
+ tester,
+ const ValueKey('send_address_field'),
+ address,
+ ) &&
+ _editableTextEquals(
+ tester,
+ const ValueKey('send_amount_field'),
+ amount,
+ ),
+ description:
+ 'unlock to deliver the parked payment URI to the prefilled send screen',
+ timeout: const Duration(minutes: 1),
+ );
+ _log('locked-path: unlock delivered the prefill to the send screen');
+
+ await _tapAppButton(
+ tester,
+ const ValueKey('send_review_button'),
+ timeout: const Duration(minutes: 1),
+ );
+ await _tapAppButton(
+ tester,
+ const ValueKey('send_confirm_button'),
+ timeout: const Duration(minutes: 1),
+ );
+ await _pumpUntil(
+ tester,
+ () => tester.any(find.byKey(const ValueKey('send_status_completed'))),
+ description: 'send status to succeed',
+ timeout: const Duration(minutes: 4),
+ );
+ _log('payment-URI send succeeded');
+}
+
+/// Locks the wallet via the sidebar "Sign out" action and waits for the unlock
+/// screen. `_handleSignOut` calls `securityNotifier.lock()` and routes to
+/// /unlock with no confirmation dialog.
+Future _signOut(WidgetTester tester) async {
+ _log('signing out to lock the wallet');
+ await _tapWidget(tester, const ValueKey('sidebar_sign_out_button'));
+ await _pumpUntil(
+ tester,
+ () => tester.any(find.byKey(const ValueKey('unlock_password_field'))),
+ description: 'unlock screen to show after sign out',
+ timeout: const Duration(minutes: 1),
+ );
+ _log('wallet locked; unlock screen shown');
+}
+
+Future _importFirstWallet(WidgetTester tester) async {
+ _log('importing first wallet');
+ await _tapAppButton(tester, const ValueKey('welcome_import_wallet_button'));
+ await _enterText(
+ tester,
+ const ValueKey('import_mnemonic_first_word_field'),
+ _firstMnemonic,
+ );
+ await _tapAppButton(tester, const ValueKey('import_secret_submit_button'));
+ await _tapAppButton(tester, const ValueKey('import_birthday_skip_button'));
+ await _tapAppButton(
+ tester,
+ const ValueKey('unknown_birthday_confirm_button'),
+ );
+ await _enterText(
+ tester,
+ const ValueKey('set_password_password_field'),
+ _password,
+ );
+ await _enterText(
+ tester,
+ const ValueKey('set_password_confirm_field'),
+ _password,
+ );
+ await _tapAppButton(tester, const ValueKey('set_password_submit_button'));
+ await _waitForHome(tester);
+ _log('first wallet imported');
+}
+
+Future _importAdditionalWallet(WidgetTester tester) async {
+ _log('importing second wallet');
+ await _tapAppButton(tester, const ValueKey('welcome_import_wallet_button'));
+ await _enterText(
+ tester,
+ const ValueKey('import_mnemonic_first_word_field'),
+ _secondMnemonic,
+ );
+ await _tapAppButton(tester, const ValueKey('import_secret_submit_button'));
+ await _tapAppButton(tester, const ValueKey('import_birthday_skip_button'));
+ await _tapAppButton(
+ tester,
+ const ValueKey('unknown_birthday_confirm_button'),
+ );
+ await _waitForHome(tester);
+ _log('second wallet imported');
+}
+
+Future _openAddAccountFlow(WidgetTester tester) async {
+ _log('opening add-account flow');
+ await _tapWidget(tester, const ValueKey('sidebar_accounts_button'));
+ await _tapWidget(tester, const ValueKey('sidebar_accounts_add'));
+ await _pumpUntil(
+ tester,
+ () =>
+ tester.any(find.byKey(const ValueKey('welcome_import_wallet_button'))),
+ description: 'add-account welcome import button',
+ );
+}
+
+Future _copyActiveShieldedAddress(WidgetTester tester) async {
+ await _tapReceiveButton(tester);
+ await _pumpUntil(
+ tester,
+ () => tester.any(
+ find.byKey(const ValueKey('receive_copy_shielded_address_button')),
+ ),
+ description: 'shielded receive copy button',
+ );
+ await _tapWidget(
+ tester,
+ const ValueKey('receive_copy_shielded_address_button'),
+ );
+ final data = await Clipboard.getData('text/plain');
+ final address = data?.text?.trim() ?? '';
+ if (address.isEmpty) {
+ fail('Shielded address was not copied to the clipboard.');
+ }
+ return address;
+}
+
+Future _mineRegtestBlocks(int blocks) async {
+ _log('mining $blocks regtest blocks');
+
+ final before = await _zcashdRpc('getblockcount');
+ await _zcashdRpc>('generate', [blocks]);
+ final targetHeight = before + blocks;
+ final deadline = DateTime.now().add(const Duration(seconds: 30));
+
+ while (DateTime.now().isBefore(deadline)) {
+ final lightwalletdHeight = await rust_wallet.getLatestBlockHeight(
+ lightwalletdUrl: _lightwalletdUrl,
+ );
+ if (lightwalletdHeight.toInt() >= targetHeight) {
+ _log('lightwalletd reached mined height $targetHeight');
+ return;
+ }
+ await Future.delayed(const Duration(seconds: 1));
+ }
+
+ throw StateError('Timed out waiting for lightwalletd height $targetHeight.');
+}
+
+Future _zcashdRpc(
+ String method, [
+ List