Skip to content

Commit 5ca439f

Browse files
committed
feat: add monero_wallet: URI scanning for wallet restoration
closes cypherstack#1261
1 parent 243015b commit 5ca439f

5 files changed

Lines changed: 342 additions & 1 deletion

File tree

lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
*
99
*/
1010

11+
import 'dart:async';
12+
1113
import 'package:dropdown_button2/dropdown_button2.dart';
1214
import 'package:flutter/material.dart';
1315
import 'package:flutter/services.dart';
@@ -16,13 +18,17 @@ import 'package:flutter_svg/svg.dart';
1618
import 'package:logger/logger.dart';
1719
import 'package:tuple/tuple.dart';
1820

21+
import '../../../../notifications/show_flush_bar.dart';
1922
import '../../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart';
23+
import '../../../../providers/providers.dart';
2024
import '../../../../providers/ui/verify_recovery_phrase/mnemonic_word_count_state_provider.dart';
2125
import '../../../../themes/stack_colors.dart';
2226
import '../../../../utilities/assets.dart';
27+
import '../../../../utilities/barcode_scanner_interface.dart';
2328
import '../../../../utilities/constants.dart';
2429
import '../../../../utilities/format.dart';
2530
import '../../../../utilities/logger.dart';
31+
import '../../../../utilities/monero_wallet_uri.dart';
2632
import '../../../../utilities/text_styles.dart';
2733
import '../../../../utilities/util.dart';
2834
import '../../../../wallets/crypto_currency/crypto_currency.dart';
@@ -34,6 +40,7 @@ import '../../../../widgets/custom_buttons/blue_text_button.dart';
3440
import '../../../../widgets/date_picker/date_picker.dart';
3541
import '../../../../widgets/desktop/desktop_app_bar.dart';
3642
import '../../../../widgets/desktop/desktop_scaffold.dart';
43+
import '../../../../widgets/desktop/secondary_button.dart';
3744
import '../../../../widgets/expandable.dart';
3845
import '../../../../widgets/icon_widgets/x_icon.dart';
3946
import '../../../../widgets/rounded_white_container.dart';
@@ -170,6 +177,79 @@ class _RestoreOptionsViewState extends ConsumerState<RestoreOptionsView> {
170177
}
171178
}
172179

180+
/// Scan a monero_wallet: QR code and route to the appropriate restore view.
181+
Future<void> _scanWalletUri() async {
182+
try {
183+
final qrResult = await ref.read(pBarcodeScanner).scan(context: context);
184+
final rawContent = qrResult.rawContent ?? "";
185+
186+
final walletUri = MoneroWalletUriData.parse(rawContent);
187+
if (walletUri == null) {
188+
if (mounted) {
189+
unawaited(
190+
showFloatingFlushBar(
191+
type: FlushBarType.warning,
192+
message: "Not a valid monero_wallet: URI",
193+
context: context,
194+
),
195+
);
196+
}
197+
return;
198+
}
199+
200+
if (walletUri.isSeedRestore) {
201+
if (mounted) {
202+
final words = walletUri.seed!.split(' ');
203+
await Navigator.of(context).pushNamed(
204+
RestoreWalletView.routeName,
205+
arguments: (
206+
walletName: walletName,
207+
coin: coin,
208+
seedWordsLength: words.length,
209+
restoreBlockHeight: walletUri.height ?? 0,
210+
mnemonicPassphrase: "",
211+
initialMnemonic: words,
212+
),
213+
);
214+
}
215+
} else if (walletUri.isViewKeyRestore) {
216+
if (mounted) {
217+
await Navigator.of(context).pushNamed(
218+
RestoreViewOnlyWalletView.routeName,
219+
arguments: (
220+
walletName: walletName,
221+
coin: coin,
222+
restoreBlockHeight: walletUri.height ?? 0,
223+
initialAddress: walletUri.address,
224+
initialViewKey: walletUri.privateViewKey,
225+
),
226+
);
227+
}
228+
}
229+
} on PlatformException catch (e, s) {
230+
if (mounted) {
231+
try {
232+
await checkCamPermDeniedMobileAndOpenAppSettings(
233+
context,
234+
logging: Logging.instance,
235+
);
236+
} catch (e, s) {
237+
Logging.instance.e(
238+
"Failed to check cam permissions",
239+
error: e,
240+
stackTrace: s,
241+
);
242+
}
243+
} else {
244+
Logging.instance.e(
245+
"Wallet URI qr scan failed: $e",
246+
error: e,
247+
stackTrace: s,
248+
);
249+
}
250+
}
251+
}
252+
173253
Future<void> chooseDate() async {
174254
// check and hide keyboard
175255
if (FocusScope.of(context).hasFocus) {
@@ -355,6 +435,17 @@ class _RestoreOptionsViewState extends ConsumerState<RestoreOptionsView> {
355435
chooseMnemonicLength: chooseMnemonicLength,
356436
),
357437
if (!isDesktop) const Spacer(flex: 3),
438+
if (coin is Monero || coin is Wownero)
439+
Padding(
440+
padding: EdgeInsets.only(
441+
top: isDesktop ? 16 : 8,
442+
bottom: isDesktop ? 16 : 8,
443+
),
444+
child: SecondaryButton(
445+
label: "Scan wallet URI",
446+
onPressed: _scanWalletUri,
447+
),
448+
),
358449
SizedBox(height: isDesktop ? 32 : 12),
359450
RestoreOptionsNextButton(
360451
isDesktop: isDesktop,

lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ class RestoreViewOnlyWalletView extends ConsumerStatefulWidget {
4949
required this.coin,
5050
required this.restoreBlockHeight,
5151
this.clipboard = const ClipboardWrapper(),
52+
this.initialAddress,
53+
this.initialViewKey,
5254
});
5355

5456
static const routeName = "/restoreViewOnlyWallet";
@@ -58,6 +60,13 @@ class RestoreViewOnlyWalletView extends ConsumerStatefulWidget {
5860
final int restoreBlockHeight;
5961
final ClipboardInterface clipboard;
6062

63+
/// Optional pre-populated address (e.g. from a monero_wallet: URI scan).
64+
final String? initialAddress;
65+
66+
/// Optional pre-populated private view key
67+
/// (e.g. from a monero_wallet: URI scan).
68+
final String? initialViewKey;
69+
6170
@override
6271
ConsumerState<RestoreViewOnlyWalletView> createState() =>
6372
_RestoreViewOnlyWalletViewState();
@@ -326,6 +335,20 @@ class _RestoreViewOnlyWalletViewState
326335
} else if (widget.coin is CryptonoteCurrency) {
327336
_walletType = ViewOnlyWalletType.cryptonote;
328337
}
338+
339+
// Pre-populate fields if initial values are provided (e.g. from a
340+
// monero_wallet: URI scan).
341+
if (widget.initialAddress != null) {
342+
addressController.text = widget.initialAddress!;
343+
}
344+
if (widget.initialViewKey != null) {
345+
viewKeyController.text = widget.initialViewKey!;
346+
}
347+
if (widget.initialAddress != null || widget.initialViewKey != null) {
348+
_enableRestoreButton =
349+
addressController.text.isNotEmpty &&
350+
viewKeyController.text.isNotEmpty;
351+
}
329352
}
330353

331354
@override

lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import '../../../themes/stack_colors.dart';
3232
import '../../../utilities/address_utils.dart';
3333
import '../../../utilities/assets.dart';
3434
import '../../../utilities/barcode_scanner_interface.dart';
35+
import '../../../utilities/monero_wallet_uri.dart';
3536
import '../../../utilities/clipboard_interface.dart';
3637
import '../../../utilities/constants.dart';
3738
import '../../../utilities/custom_text_selection_controls.dart';
@@ -68,6 +69,7 @@ import '../add_token_view/edit_wallet_tokens_view.dart';
6869
import '../select_wallet_for_token_view.dart';
6970
import '../verify_recovery_phrase_view/verify_recovery_phrase_view.dart';
7071
import 'confirm_recovery_dialog.dart';
72+
import 'restore_view_only_wallet_view.dart';
7173
import 'sub_widgets/restore_failed_dialog.dart';
7274
import 'sub_widgets/restore_succeeded_dialog.dart';
7375
import 'sub_widgets/restoring_dialog.dart';
@@ -81,6 +83,7 @@ class RestoreWalletView extends ConsumerStatefulWidget {
8183
required this.mnemonicPassphrase,
8284
required this.restoreBlockHeight,
8385
this.clipboard = const ClipboardWrapper(),
86+
this.initialMnemonic,
8487
});
8588

8689
static const routeName = "/restoreWallet";
@@ -93,6 +96,10 @@ class RestoreWalletView extends ConsumerStatefulWidget {
9396

9497
final ClipboardInterface clipboard;
9598

99+
/// Optional pre-populated mnemonic words
100+
/// (e.g. from a monero_wallet: URI scan).
101+
final List<String>? initialMnemonic;
102+
96103
@override
97104
ConsumerState<RestoreWalletView> createState() => _RestoreWalletViewState();
98105
}
@@ -163,6 +170,15 @@ class _RestoreWalletViewState extends ConsumerState<RestoreWalletView> {
163170
// _focusNodes.add(FocusNode());
164171
}
165172

173+
// Pre-populate mnemonic words if provided (e.g. from a
174+
// monero_wallet: URI scan).
175+
if (widget.initialMnemonic != null &&
176+
widget.initialMnemonic!.isNotEmpty) {
177+
WidgetsBinding.instance.addPostFrameCallback((_) {
178+
_clearAndPopulateMnemonic(widget.initialMnemonic!);
179+
});
180+
}
181+
166182
super.initState();
167183
}
168184

@@ -613,7 +629,49 @@ class _RestoreWalletViewState extends ConsumerState<RestoreWalletView> {
613629
try {
614630
final qrResult = await ref.read(pBarcodeScanner).scan(context: context);
615631

616-
final results = AddressUtils.decodeQRSeedData(qrResult.rawContent ?? "");
632+
final rawContent = qrResult.rawContent ?? "";
633+
634+
// Try parsing as a monero_wallet: URI first (for Monero/Wownero coins).
635+
if (widget.coin is Monero || widget.coin is Wownero) {
636+
final walletUri = MoneroWalletUriData.parse(rawContent);
637+
if (walletUri != null) {
638+
if (walletUri.isSeedRestore) {
639+
final words = walletUri.seed!.split(' ');
640+
if (words.isNotEmpty) {
641+
_clearAndPopulateMnemonic(words);
642+
Logging.instance.i(
643+
"mnemonic populated from monero_wallet: URI",
644+
);
645+
}
646+
return;
647+
} else if (walletUri.isViewKeyRestore) {
648+
if (mounted) {
649+
unawaited(
650+
showFloatingFlushBar(
651+
type: FlushBarType.info,
652+
message:
653+
"View-key wallet URI detected. Opening view-only restore.",
654+
context: context,
655+
),
656+
);
657+
await Navigator.of(context).pushNamed(
658+
RestoreViewOnlyWalletView.routeName,
659+
arguments: (
660+
walletName: widget.walletName,
661+
coin: widget.coin,
662+
restoreBlockHeight: walletUri.height ?? 0,
663+
initialAddress: walletUri.address,
664+
initialViewKey: walletUri.privateViewKey,
665+
),
666+
);
667+
}
668+
return;
669+
}
670+
}
671+
}
672+
673+
// Fall back to the existing JSON-encoded mnemonic QR format.
674+
final results = AddressUtils.decodeQRSeedData(rawContent);
617675

618676
if (results["mnemonic"] != null) {
619677
final list = (results["mnemonic"] as List)

lib/route_generator.dart

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,28 @@ class RouteGenerator {
16411641
return _routeError("${settings.name} invalid args: ${args.toString()}");
16421642

16431643
case RestoreWalletView.routeName:
1644+
if (args
1645+
is ({
1646+
String walletName,
1647+
CryptoCurrency coin,
1648+
int seedWordsLength,
1649+
int restoreBlockHeight,
1650+
String mnemonicPassphrase,
1651+
List<String>? initialMnemonic,
1652+
})) {
1653+
return getRoute(
1654+
shouldUseMaterialRoute: useMaterialPageRoute,
1655+
builder: (_) => RestoreWalletView(
1656+
walletName: args.walletName,
1657+
coin: args.coin,
1658+
seedWordsLength: args.seedWordsLength,
1659+
restoreBlockHeight: args.restoreBlockHeight,
1660+
mnemonicPassphrase: args.mnemonicPassphrase,
1661+
initialMnemonic: args.initialMnemonic,
1662+
),
1663+
settings: RouteSettings(name: settings.name),
1664+
);
1665+
}
16441666
if (args is Tuple5<String, CryptoCurrency, int, int, String>) {
16451667
return getRoute(
16461668
shouldUseMaterialRoute: useMaterialPageRoute,
@@ -1657,6 +1679,26 @@ class RouteGenerator {
16571679
return _routeError("${settings.name} invalid args: ${args.toString()}");
16581680

16591681
case RestoreViewOnlyWalletView.routeName:
1682+
if (args
1683+
is ({
1684+
String walletName,
1685+
CryptoCurrency coin,
1686+
int restoreBlockHeight,
1687+
String? initialAddress,
1688+
String? initialViewKey,
1689+
})) {
1690+
return getRoute(
1691+
shouldUseMaterialRoute: useMaterialPageRoute,
1692+
builder: (_) => RestoreViewOnlyWalletView(
1693+
walletName: args.walletName,
1694+
coin: args.coin,
1695+
restoreBlockHeight: args.restoreBlockHeight,
1696+
initialAddress: args.initialAddress,
1697+
initialViewKey: args.initialViewKey,
1698+
),
1699+
settings: RouteSettings(name: settings.name),
1700+
);
1701+
}
16601702
if (args
16611703
is ({
16621704
String walletName,

0 commit comments

Comments
 (0)