From 54adbe84afb44442fe36a2555c576baf49da3f93 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 6 Aug 2026 12:35:05 +0800 Subject: [PATCH 1/6] faster QR code in cold wallet --- .../lib/components/animated_ur_qr.dart | 37 ++++-- .../lib/components/confirm_dialog.dart | 27 +++++ .../lib/components/qr_tuning_controls.dart | 91 +++++++++++++++ .../lib/providers/settings_providers.dart | 81 +++++++++++++ cold-wallet-app/lib/screens/home_screen.dart | 27 +---- .../lib/screens/settings_screen.dart | 109 ++++++++++++++++++ .../lib/screens/sign_transaction_screen.dart | 77 +++++++++++-- .../lib/widgets/connectivity_guard.dart | 56 +++++++++ cold-wallet-app/pubspec.lock | 2 +- cold-wallet-app/pubspec.yaml | 3 +- quantus_sdk/lib/src/rust/api/ur.dart | 3 +- quantus_sdk/lib/src/rust/frb_generated.dart | 55 ++++++++- .../lib/src/rust/frb_generated.io.dart | 18 +++ .../lib/src/rust/frb_generated.web.dart | 18 +++ quantus_sdk/rust/Cargo.lock | 2 +- quantus_sdk/rust/Cargo.toml | 2 +- quantus_sdk/rust/src/api/ur.rs | 36 ++++-- quantus_sdk/rust/src/frb_generated.rs | 32 ++++- 18 files changed, 609 insertions(+), 67 deletions(-) create mode 100644 cold-wallet-app/lib/components/confirm_dialog.dart create mode 100644 cold-wallet-app/lib/components/qr_tuning_controls.dart create mode 100644 cold-wallet-app/lib/providers/settings_providers.dart create mode 100644 cold-wallet-app/lib/screens/settings_screen.dart diff --git a/cold-wallet-app/lib/components/animated_ur_qr.dart b/cold-wallet-app/lib/components/animated_ur_qr.dart index 098874324..7dfb05e96 100644 --- a/cold-wallet-app/lib/components/animated_ur_qr.dart +++ b/cold-wallet-app/lib/components/animated_ur_qr.dart @@ -4,18 +4,15 @@ import 'package:flutter/material.dart'; import 'package:qr_flutter/qr_flutter.dart'; /// Renders a UR payload as a QR code. A multi-part UR is animated by cycling -/// through its fragments with a [Timer] (no post-frame callbacks). +/// through its fragments with a [Timer] (no post-frame callbacks). Reacts to +/// [fps], [paused] and [parts] changes so the animation can be tuned live. class AnimatedUrQr extends StatefulWidget { final List parts; - final Duration interval; + final int fps; + final bool paused; final double size; - const AnimatedUrQr({ - super.key, - required this.parts, - this.interval = const Duration(milliseconds: 200), - this.size = 280, - }); + const AnimatedUrQr({super.key, required this.parts, required this.fps, this.paused = false, this.size = 280}); @override State createState() => _AnimatedUrQrState(); @@ -28,13 +25,29 @@ class _AnimatedUrQrState extends State { @override void initState() { super.initState(); - if (widget.parts.length > 1) { - _timer = Timer.periodic(widget.interval, (_) { - setState(() => _index = (_index + 1) % widget.parts.length); - }); + _restartTimer(); + } + + @override + void didUpdateWidget(AnimatedUrQr oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.parts.length != oldWidget.parts.length) _index = 0; + if (widget.fps != oldWidget.fps || + widget.paused != oldWidget.paused || + widget.parts.length != oldWidget.parts.length) { + _restartTimer(); } } + void _restartTimer() { + _timer?.cancel(); + _timer = null; + if (widget.paused || widget.parts.length <= 1) return; + _timer = Timer.periodic(Duration(milliseconds: (1000 / widget.fps).round()), (_) { + setState(() => _index = (_index + 1) % widget.parts.length); + }); + } + @override void dispose() { _timer?.cancel(); diff --git a/cold-wallet-app/lib/components/confirm_dialog.dart b/cold-wallet-app/lib/components/confirm_dialog.dart new file mode 100644 index 000000000..708fe105c --- /dev/null +++ b/cold-wallet-app/lib/components/confirm_dialog.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:quantus_cold_wallet/theme/app_colors.dart'; + +Future showConfirmDialog( + BuildContext context, { + required String title, + required String message, + required String confirmLabel, +}) async { + final colors = context.colors; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.sheetBackground, + title: Text(title), + content: Text(message), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(confirmLabel, style: TextStyle(color: colors.error)), + ), + ], + ), + ); + return confirmed == true; +} diff --git a/cold-wallet-app/lib/components/qr_tuning_controls.dart b/cold-wallet-app/lib/components/qr_tuning_controls.dart new file mode 100644 index 000000000..52d433419 --- /dev/null +++ b/cold-wallet-app/lib/components/qr_tuning_controls.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_cold_wallet/providers/settings_providers.dart'; +import 'package:quantus_cold_wallet/theme/app_colors.dart'; +import 'package:quantus_cold_wallet/theme/app_text_styles.dart'; + +/// FPS and bytes-per-frame sliders bound to [coldSettingsProvider], shared by +/// the settings screen and the signature view so tuning behaves identically. +class QrTuningControls extends ConsumerWidget { + const QrTuningControls({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(coldSettingsProvider); + final controller = ref.read(coldSettingsProvider.notifier); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TuningSlider( + label: 'Frame rate', + valueText: '${settings.qrFps} FPS', + value: settings.qrFps.toDouble(), + min: ColdSettings.minQrFps.toDouble(), + max: ColdSettings.maxQrFps.toDouble(), + divisions: ColdSettings.maxQrFps - ColdSettings.minQrFps, + onChanged: (v) => controller.setQrFps(v.round()), + ), + const SizedBox(height: 8), + _TuningSlider( + label: 'Bytes per frame', + valueText: '${settings.qrBytes} bytes', + value: settings.qrBytes.toDouble(), + min: ColdSettings.minQrBytes.toDouble(), + max: ColdSettings.maxQrBytes.toDouble(), + divisions: (ColdSettings.maxQrBytes - ColdSettings.minQrBytes) ~/ 25, + onChanged: (v) => controller.setQrBytes(v.round()), + ), + ], + ); + } +} + +class _TuningSlider extends StatelessWidget { + final String label; + final String valueText; + final double value; + final double min; + final double max; + final int divisions; + final ValueChanged onChanged; + + const _TuningSlider({ + required this.label, + required this.valueText, + required this.value, + required this.min, + required this.max, + required this.divisions, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: text.detail?.copyWith(color: colors.textSecondary)), + Text(valueText, style: text.detail?.copyWith(color: colors.textPrimary)), + ], + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: colors.accentOrange, + inactiveTrackColor: colors.borderButton, + thumbColor: colors.accentOrange, + overlayColor: colors.accentOrange.withValues(alpha: 0.15), + trackHeight: 2, + ), + child: Slider(value: value, min: min, max: max, divisions: divisions, onChanged: onChanged), + ), + ], + ); + } +} diff --git a/cold-wallet-app/lib/providers/settings_providers.dart b/cold-wallet-app/lib/providers/settings_providers.dart new file mode 100644 index 000000000..3227d7b45 --- /dev/null +++ b/cold-wallet-app/lib/providers/settings_providers.dart @@ -0,0 +1,81 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +@immutable +class ColdSettings { + static const minQrFps = 5; + static const maxQrFps = 50; + static const defaultQrFps = 15; + static const minQrBytes = 300; + static const maxQrBytes = 1500; + static const defaultQrBytes = 1100; + + final bool wifiOverrideEnabled; + final int qrFps; + final int qrBytes; + + const ColdSettings({this.wifiOverrideEnabled = false, this.qrFps = defaultQrFps, this.qrBytes = defaultQrBytes}); + + ColdSettings copyWith({bool? wifiOverrideEnabled, int? qrFps, int? qrBytes}) => ColdSettings( + wifiOverrideEnabled: wifiOverrideEnabled ?? this.wifiOverrideEnabled, + qrFps: qrFps ?? this.qrFps, + qrBytes: qrBytes ?? this.qrBytes, + ); +} + +class ColdSettingsController extends Notifier { + static const _wifiOverrideKey = 'wifi_lock_override'; + static const _qrFpsKey = 'qr_fps'; + static const _qrBytesKey = 'qr_bytes_per_frame'; + + @override + ColdSettings build() { + _load(); + return const ColdSettings(); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + state = ColdSettings( + wifiOverrideEnabled: prefs.getBool(_wifiOverrideKey) ?? false, + qrFps: (prefs.getInt(_qrFpsKey) ?? ColdSettings.defaultQrFps).clamp(ColdSettings.minQrFps, ColdSettings.maxQrFps), + qrBytes: (prefs.getInt(_qrBytesKey) ?? ColdSettings.defaultQrBytes).clamp( + ColdSettings.minQrBytes, + ColdSettings.maxQrBytes, + ), + ); + } + + Future setWifiOverrideEnabled(bool enabled) async { + state = state.copyWith(wifiOverrideEnabled: enabled); + if (!enabled) ref.read(wifiLockOverriddenProvider.notifier).set(false); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_wifiOverrideKey, enabled); + } + + Future setQrFps(int fps) async { + state = state.copyWith(qrFps: fps); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_qrFpsKey, fps); + } + + Future setQrBytes(int bytes) async { + state = state.copyWith(qrBytes: bytes); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_qrBytesKey, bytes); + } +} + +final coldSettingsProvider = NotifierProvider(ColdSettingsController.new); + +/// Session-only: armed from the connectivity guard after the user confirms the +/// override warning; disarmed when the persistent toggle is switched off. +class WifiLockOverridden extends Notifier { + @override + bool build() => false; + + void set(bool value) => state = value; +} + +final wifiLockOverriddenProvider = NotifierProvider(WifiLockOverridden.new); diff --git a/cold-wallet-app/lib/screens/home_screen.dart b/cold-wallet-app/lib/screens/home_screen.dart index ecfbd0a8a..1410f3071 100644 --- a/cold-wallet-app/lib/screens/home_screen.dart +++ b/cold-wallet-app/lib/screens/home_screen.dart @@ -4,6 +4,7 @@ import 'package:quantus_cold_wallet/components/scaffold_base.dart'; import 'package:quantus_cold_wallet/components/v2_app_bar.dart'; import 'package:quantus_cold_wallet/providers/wallet_providers.dart'; import 'package:quantus_cold_wallet/screens/scan_transaction_screen.dart'; +import 'package:quantus_cold_wallet/screens/settings_screen.dart'; import 'package:quantus_cold_wallet/screens/show_key_screen.dart'; import 'package:quantus_cold_wallet/theme/app_colors.dart'; import 'package:quantus_cold_wallet/theme/app_text_styles.dart'; @@ -11,28 +12,6 @@ import 'package:quantus_cold_wallet/theme/app_text_styles.dart'; class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); - Future _confirmReset(BuildContext context, WidgetRef ref) async { - final colors = context.colors; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: colors.sheetBackground, - title: const Text('Reset wallet?'), - content: const Text( - 'This erases the encrypted key from this device. You can only restore it with your recovery phrase.', - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text('Reset', style: TextStyle(color: colors.error)), - ), - ], - ), - ); - if (confirmed == true) await ref.read(walletControllerProvider.notifier).wipe(); - } - @override Widget build(BuildContext context, WidgetRef ref) { final colors = context.colors; @@ -46,8 +25,8 @@ class HomeScreen extends ConsumerWidget { child: Icon(Icons.lock_outline, color: colors.textPrimary, size: 22), ), trailing: GestureDetector( - onTap: () => _confirmReset(context, ref), - child: Icon(Icons.more_horiz, color: colors.textPrimary, size: 22), + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())), + child: Icon(Icons.settings_outlined, color: colors.textPrimary, size: 22), ), ), mainContent: Column( diff --git a/cold-wallet-app/lib/screens/settings_screen.dart b/cold-wallet-app/lib/screens/settings_screen.dart new file mode 100644 index 000000000..8d0cb752e --- /dev/null +++ b/cold-wallet-app/lib/screens/settings_screen.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_cold_wallet/components/confirm_dialog.dart'; +import 'package:quantus_cold_wallet/components/qr_tuning_controls.dart'; +import 'package:quantus_cold_wallet/components/scaffold_base.dart'; +import 'package:quantus_cold_wallet/components/v2_app_bar.dart'; +import 'package:quantus_cold_wallet/providers/settings_providers.dart'; +import 'package:quantus_cold_wallet/providers/wallet_providers.dart'; +import 'package:quantus_cold_wallet/theme/app_colors.dart'; +import 'package:quantus_cold_wallet/theme/app_text_styles.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + Future _confirmReset(BuildContext context, WidgetRef ref) async { + final confirmed = await showConfirmDialog( + context, + title: 'Reset wallet?', + message: 'This erases the encrypted key from this device. You can only restore it with your recovery phrase.', + confirmLabel: 'Reset', + ); + if (!confirmed) return; + await ref.read(walletControllerProvider.notifier).wipe(); + if (context.mounted) Navigator.popUntil(context, (r) => r.isFirst); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.colors; + final text = context.themeText; + final settings = ref.watch(coldSettingsProvider); + + return ScaffoldBase( + appBar: const V2AppBar(title: 'Settings'), + mainContent: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + Text('SECURITY', style: text.transactionDetailRowLabel?.copyWith(color: colors.textLabel)), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Wi-Fi Lock Override', style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 4), + Text( + 'Shows an Override button on the network lock screen so it can be dismissed for testing. ' + 'Never use this with a wallet that holds real funds.', + style: text.detail?.copyWith(color: colors.textSecondary), + ), + ], + ), + ), + const SizedBox(width: 16), + Switch( + value: settings.wifiOverrideEnabled, + activeTrackColor: colors.accentOrange, + onChanged: (v) => ref.read(coldSettingsProvider.notifier).setWifiOverrideEnabled(v), + ), + ], + ), + const SizedBox(height: 24), + Divider(color: colors.borderButton), + const SizedBox(height: 16), + Text('SIGNATURE QR', style: text.transactionDetailRowLabel?.copyWith(color: colors.textLabel)), + const SizedBox(height: 8), + Text( + 'How the animated signature QR is displayed. Higher values transfer faster but are harder to scan.', + style: text.detail?.copyWith(color: colors.textSecondary), + ), + const SizedBox(height: 16), + const QrTuningControls(), + const SizedBox(height: 24), + Divider(color: colors.borderButton), + const SizedBox(height: 16), + Text('DANGER ZONE', style: text.transactionDetailRowLabel?.copyWith(color: colors.textLabel)), + const SizedBox(height: 8), + GestureDetector( + onTap: () => _confirmReset(context, ref), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Reset wallet', style: text.smallParagraph?.copyWith(color: colors.error)), + const SizedBox(height: 4), + Text( + 'Erase the encrypted key from this device.', + style: text.detail?.copyWith(color: colors.textSecondary), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: colors.textMuted, size: 22), + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} diff --git a/cold-wallet-app/lib/screens/sign_transaction_screen.dart b/cold-wallet-app/lib/screens/sign_transaction_screen.dart index e44efee30..1209a3c2e 100644 --- a/cold-wallet-app/lib/screens/sign_transaction_screen.dart +++ b/cold-wallet-app/lib/screens/sign_transaction_screen.dart @@ -8,9 +8,11 @@ import 'package:quantus_cold_wallet/components/animated_ur_qr.dart'; import 'package:quantus_cold_wallet/components/call_detail_view.dart'; import 'package:quantus_cold_wallet/components/detail_row.dart'; import 'package:quantus_cold_wallet/components/quantus_button.dart'; +import 'package:quantus_cold_wallet/components/qr_tuning_controls.dart'; import 'package:quantus_cold_wallet/components/scaffold_base.dart'; import 'package:quantus_cold_wallet/components/scaffold_base_bottom_content.dart'; import 'package:quantus_cold_wallet/components/v2_app_bar.dart'; +import 'package:quantus_cold_wallet/providers/settings_providers.dart'; import 'package:quantus_cold_wallet/providers/wallet_providers.dart'; import 'package:quantus_cold_wallet/theme/app_colors.dart'; import 'package:quantus_cold_wallet/theme/app_text_styles.dart'; @@ -35,7 +37,10 @@ class _SignTransactionScreenState extends ConsumerState { ParsedPayload? _parsed; String? _parseError; - List? _signatureUr; + Uint8List? _signed; + List? _urParts; + int? _urPartsBytes; + bool _qrPaused = false; bool _signing = false; bool _reviewedToEnd = false; bool _showRawPayload = false; @@ -101,10 +106,9 @@ class _SignTransactionScreenState extends ConsumerState { keypair: keypair, message: QuantusSigningPayload.signablePayload(widget.payload), ); - final parts = encodeUr(data: signed); setState(() { _signing = false; - _signatureUr = parts; + _signed = signed; }); } catch (e) { setState(() { @@ -117,7 +121,7 @@ class _SignTransactionScreenState extends ConsumerState { @override Widget build(BuildContext context) { if (_parseError != null) return _errorView(context, _parseError!); - if (_signatureUr != null) return _signatureView(context, _signatureUr!); + if (_signed != null) return _signatureView(context, _signed!); return _reviewView(context, _parsed!); } @@ -383,9 +387,40 @@ class _SignTransactionScreenState extends ConsumerState { ); } - Widget _signatureView(BuildContext context, List parts) { + /// Pauses the animation and opens the tuning sheet; resumes when it closes. + Future _pauseAndTune() async { + setState(() => _qrPaused = true); final colors = context.colors; final text = context.themeText; + await showModalBottomSheet( + context: context, + backgroundColor: colors.sheetBackground, + builder: (_) => Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 40), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('QR display options', style: text.smallTitle?.copyWith(color: colors.textPrimary)), + const SizedBox(height: 16), + const QrTuningControls(), + ], + ), + ), + ); + if (mounted) setState(() => _qrPaused = false); + } + + Widget _signatureView(BuildContext context, Uint8List signed) { + final colors = context.colors; + final text = context.themeText; + final settings = ref.watch(coldSettingsProvider); + + if (_urParts == null || _urPartsBytes != settings.qrBytes) { + _urParts = encodeUr(data: signed, maxFragmentLength: settings.qrBytes); + _urPartsBytes = settings.qrBytes; + } + final parts = _urParts!; return ScaffoldBase( appBar: const V2AppBar(title: 'Signature', showBackButton: false), @@ -400,14 +435,40 @@ class _SignTransactionScreenState extends ConsumerState { textAlign: TextAlign.center, ), const SizedBox(height: 24), - Center(child: AnimatedUrQr(parts: parts)), - const SizedBox(height: 16), - if (parts.length > 1) + Center( + child: AnimatedUrQr(parts: parts, fps: settings.qrFps, paused: _qrPaused), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${parts.length} ${parts.length == 1 ? 'frame' : 'frames'} · ${settings.qrFps} FPS · ' + '${settings.qrBytes} bytes', + style: text.detail?.copyWith(color: colors.textMuted), + ), + if (parts.length > 1) ...[ + const SizedBox(width: 12), + GestureDetector( + onTap: _pauseAndTune, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration(color: colors.surfaceDeep, shape: BoxShape.circle), + child: Icon(Icons.pause_rounded, size: 20, color: colors.textPrimary), + ), + ), + ], + ], + ), + if (parts.length > 1) ...[ + const SizedBox(height: 16), Text( 'Animated QR — keep both devices steady until the hot wallet finishes scanning.', style: text.detail?.copyWith(color: colors.textMuted), textAlign: TextAlign.center, ), + ], ], ), ), diff --git a/cold-wallet-app/lib/widgets/connectivity_guard.dart b/cold-wallet-app/lib/widgets/connectivity_guard.dart index 2bd56ec33..107bbbd02 100644 --- a/cold-wallet-app/lib/widgets/connectivity_guard.dart +++ b/cold-wallet-app/lib/widgets/connectivity_guard.dart @@ -3,7 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:quantus_cold_wallet/components/base_background.dart'; +import 'package:quantus_cold_wallet/components/confirm_dialog.dart'; +import 'package:quantus_cold_wallet/components/quantus_button.dart'; import 'package:quantus_cold_wallet/providers/connectivity_provider.dart'; +import 'package:quantus_cold_wallet/providers/settings_providers.dart'; import 'package:quantus_cold_wallet/theme/app_colors.dart'; import 'package:quantus_cold_wallet/theme/app_text_styles.dart'; @@ -11,9 +14,25 @@ import 'package:quantus_cold_wallet/theme/app_text_styles.dart'; /// must stay air-gapped, so this overlays everything and only clears once the /// device reports it is offline. Fails closed: while connectivity is unknown /// it stays blocked. +/// +/// When the Wi-Fi Lock Override setting is enabled, an Override button lets the +/// lock be dismissed for the session after a warning; while dismissed a banner +/// stays on screen so an online signer is never mistaken for an offline one. class ConnectivityGuard extends ConsumerWidget { const ConnectivityGuard({super.key}); + Future _confirmOverride(BuildContext context, WidgetRef ref) async { + final confirmed = await showConfirmDialog( + context, + title: 'Override network lock?', + message: + 'This device is connected to a network. A cold wallet is only safe while fully offline — override the ' + 'lock for testing only, never with a wallet that holds real funds.', + confirmLabel: 'Override', + ); + if (confirmed) ref.read(wifiLockOverriddenProvider.notifier).set(true); + } + @override Widget build(BuildContext context, WidgetRef ref) { if (kDebugMode) return const SizedBox.shrink(); @@ -22,6 +41,9 @@ class ConnectivityGuard extends ConsumerWidget { final isOnline = status.maybeWhen(data: (s) => s == NetworkStatus.online, orElse: () => true); if (!isOnline) return const SizedBox.shrink(); + if (ref.watch(wifiLockOverriddenProvider)) return _overriddenBanner(context, ref); + + final overrideAvailable = ref.watch(coldSettingsProvider.select((s) => s.wifiOverrideEnabled)); final colors = context.colors; final text = context.themeText; @@ -55,6 +77,14 @@ class ConnectivityGuard extends ConsumerWidget { style: text.detail?.copyWith(color: colors.textMuted), textAlign: TextAlign.center, ), + if (overrideAvailable) ...[ + const SizedBox(height: 32), + QuantusButton.simple( + label: 'Override for testing', + variant: ButtonVariant.secondary, + onTap: () => _confirmOverride(context, ref), + ), + ], ], ), ), @@ -63,4 +93,30 @@ class ConnectivityGuard extends ConsumerWidget { ), ); } + + /// Thin strip over the status bar area: keeps the online state loudly visible + /// without covering the app. Tapping it re-arms the lock. + Widget _overriddenBanner(BuildContext context, WidgetRef ref) { + final colors = context.colors; + final text = context.themeText; + final topInset = MediaQuery.paddingOf(context).top; + + return Positioned( + top: 0, + left: 0, + right: 0, + child: GestureDetector( + onTap: () => ref.read(wifiLockOverriddenProvider.notifier).set(false), + child: Container( + color: colors.error, + padding: EdgeInsets.only(top: topInset > 0 ? topInset : 8, bottom: 4), + child: Text( + 'Online — lock overridden. Tap to re-lock.', + style: text.detail?.copyWith(color: colors.textPrimary), + textAlign: TextAlign.center, + ), + ), + ), + ); + } } diff --git a/cold-wallet-app/pubspec.lock b/cold-wallet-app/pubspec.lock index 4e3d07e16..a4d047f46 100644 --- a/cold-wallet-app/pubspec.lock +++ b/cold-wallet-app/pubspec.lock @@ -944,7 +944,7 @@ packages: source: hosted version: "0.6.3" shared_preferences: - dependency: transitive + dependency: "direct main" description: name: shared_preferences sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf diff --git a/cold-wallet-app/pubspec.yaml b/cold-wallet-app/pubspec.yaml index fccbd2691..74990632c 100644 --- a/cold-wallet-app/pubspec.yaml +++ b/cold-wallet-app/pubspec.yaml @@ -2,7 +2,7 @@ name: quantus_cold_wallet description: "Quantus Cold Wallet - air-gapped ML-DSA transaction signer" publish_to: "none" -version: 1.0.1+2 +version: 1.1.0+3 environment: sdk: ">=3.8.0 <4.0.0" @@ -23,6 +23,7 @@ dependencies: # State management flutter_riverpod: ^3.3.1 + shared_preferences: ^2.5.5 # UI qr_flutter: ^4.1.0 diff --git a/quantus_sdk/lib/src/rust/api/ur.dart b/quantus_sdk/lib/src/rust/api/ur.dart index b2fbe43db..332648030 100644 --- a/quantus_sdk/lib/src/rust/api/ur.dart +++ b/quantus_sdk/lib/src/rust/api/ur.dart @@ -8,6 +8,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; Uint8List decodeUr({required List urParts}) => RustLib.instance.api.crateApiUrDecodeUr(urParts: urParts); -List encodeUr({required List data}) => RustLib.instance.api.crateApiUrEncodeUr(data: data); +List encodeUr({required List data, int? maxFragmentLength}) => + RustLib.instance.api.crateApiUrEncodeUr(data: data, maxFragmentLength: maxFragmentLength); bool isCompleteUr({required List urParts}) => RustLib.instance.api.crateApiUrIsCompleteUr(urParts: urParts); diff --git a/quantus_sdk/lib/src/rust/frb_generated.dart b/quantus_sdk/lib/src/rust/frb_generated.dart index 2707e1b8c..c0cdf4c45 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.dart @@ -106,7 +106,7 @@ abstract class RustLibApi extends BaseApi { WormholeResult crateApiCryptoDeriveWormhole({required String mnemonicStr, required String path}); - List crateApiUrEncodeUr({required List data}); + List crateApiUrEncodeUr({required List data, int? maxFragmentLength}); Future crateApiWormholeEnsureCircuitBinaries({required String binsDir}); @@ -451,23 +451,25 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { const TaskConstMeta(debugName: 'derive_wormhole', argNames: ['mnemonicStr', 'path']); @override - List crateApiUrEncodeUr({required List data}) { + List crateApiUrEncodeUr({required List data, int? maxFragmentLength}) { return handler.executeSync( SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_prim_u_8_loose(data, serializer); + sse_encode_opt_box_autoadd_u_32(maxFragmentLength, serializer); return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!; }, codec: SseCodec(decodeSuccessData: sse_decode_list_String, decodeErrorData: sse_decode_String), constMeta: kCrateApiUrEncodeUrConstMeta, - argValues: [data], + argValues: [data, maxFragmentLength], apiImpl: this, ), ); } - TaskConstMeta get kCrateApiUrEncodeUrConstMeta => const TaskConstMeta(debugName: 'encode_ur', argNames: ['data']); + TaskConstMeta get kCrateApiUrEncodeUrConstMeta => + const TaskConstMeta(debugName: 'encode_ur', argNames: ['data', 'maxFragmentLength']); @override Future crateApiWormholeEnsureCircuitBinaries({required String binsDir}) { @@ -924,6 +926,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dco_decode_proof_input(raw); } + @protected + int dco_decode_box_autoadd_u_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + @protected Keypair dco_decode_keypair(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -970,6 +978,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + int? dco_decode_opt_box_autoadd_u_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_box_autoadd_u_32(raw); + } + @protected U8Array32? dco_decode_opt_u_8_array_32(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1110,6 +1124,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_proof_input(deserializer)); } + @protected + int sse_decode_box_autoadd_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_u_32(deserializer)); + } + @protected Keypair sse_decode_keypair(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1164,6 +1184,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return MerkleProcessed(sortedSiblingsFlat: var_sortedSiblingsFlat, positions: var_positions); } + @protected + int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_box_autoadd_u_32(deserializer)); + } else { + return null; + } + } + @protected U8Array32? sse_decode_opt_u_8_array_32(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1327,6 +1358,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_proof_input(self, serializer); } + @protected + void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_u_32(self, serializer); + } + @protected void sse_encode_keypair(Keypair self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1373,6 +1410,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_strict(self.positions, serializer); } + @protected + void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_u_32(self, serializer); + } + } + @protected void sse_encode_opt_u_8_array_32(U8Array32? self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs diff --git a/quantus_sdk/lib/src/rust/frb_generated.io.dart b/quantus_sdk/lib/src/rust/frb_generated.io.dart index 25aa4e273..130c3e175 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.io.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.io.dart @@ -44,6 +44,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ProofInput dco_decode_box_autoadd_proof_input(dynamic raw); + @protected + int dco_decode_box_autoadd_u_32(dynamic raw); + @protected Keypair dco_decode_keypair(dynamic raw); @@ -62,6 +65,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected MerkleProcessed dco_decode_merkle_processed(dynamic raw); + @protected + int? dco_decode_opt_box_autoadd_u_32(dynamic raw); + @protected U8Array32? dco_decode_opt_u_8_array_32(dynamic raw); @@ -117,6 +123,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ProofInput sse_decode_box_autoadd_proof_input(SseDeserializer deserializer); + @protected + int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); + @protected Keypair sse_decode_keypair(SseDeserializer deserializer); @@ -135,6 +144,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected MerkleProcessed sse_decode_merkle_processed(SseDeserializer deserializer); + @protected + int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer); + @protected U8Array32? sse_decode_opt_u_8_array_32(SseDeserializer deserializer); @@ -195,6 +207,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_box_autoadd_proof_input(ProofInput self, SseSerializer serializer); + @protected + void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); + @protected void sse_encode_keypair(Keypair self, SseSerializer serializer); @@ -213,6 +228,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_merkle_processed(MerkleProcessed self, SseSerializer serializer); + @protected + void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer); + @protected void sse_encode_opt_u_8_array_32(U8Array32? self, SseSerializer serializer); diff --git a/quantus_sdk/lib/src/rust/frb_generated.web.dart b/quantus_sdk/lib/src/rust/frb_generated.web.dart index fcd2a54c3..980032129 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.web.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.web.dart @@ -46,6 +46,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ProofInput dco_decode_box_autoadd_proof_input(dynamic raw); + @protected + int dco_decode_box_autoadd_u_32(dynamic raw); + @protected Keypair dco_decode_keypair(dynamic raw); @@ -64,6 +67,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected MerkleProcessed dco_decode_merkle_processed(dynamic raw); + @protected + int? dco_decode_opt_box_autoadd_u_32(dynamic raw); + @protected U8Array32? dco_decode_opt_u_8_array_32(dynamic raw); @@ -119,6 +125,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ProofInput sse_decode_box_autoadd_proof_input(SseDeserializer deserializer); + @protected + int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); + @protected Keypair sse_decode_keypair(SseDeserializer deserializer); @@ -137,6 +146,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected MerkleProcessed sse_decode_merkle_processed(SseDeserializer deserializer); + @protected + int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer); + @protected U8Array32? sse_decode_opt_u_8_array_32(SseDeserializer deserializer); @@ -197,6 +209,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_box_autoadd_proof_input(ProofInput self, SseSerializer serializer); + @protected + void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); + @protected void sse_encode_keypair(Keypair self, SseSerializer serializer); @@ -215,6 +230,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_merkle_processed(MerkleProcessed self, SseSerializer serializer); + @protected + void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer); + @protected void sse_encode_opt_u_8_array_32(U8Array32? self, SseSerializer serializer); diff --git a/quantus_sdk/rust/Cargo.lock b/quantus_sdk/rust/Cargo.lock index 9307a4cd0..790b48025 100644 --- a/quantus_sdk/rust/Cargo.lock +++ b/quantus_sdk/rust/Cargo.lock @@ -3083,7 +3083,7 @@ dependencies = [ [[package]] name = "quantus_ur" version = "0.1.0" -source = "git+https://github.com/Quantus-Network/quantus_ur.git?tag=1.4.0#f3929da023e9c8081b8e9944b5b665871980fa79" +source = "git+https://github.com/Quantus-Network/quantus_ur.git?tag=1.6.0#0b9bcc586a6ccb845c4d476ffa0880b0478b284a" dependencies = [ "hex", "keystone-ur", diff --git a/quantus_sdk/rust/Cargo.toml b/quantus_sdk/rust/Cargo.toml index b30406b61..d55e1090a 100644 --- a/quantus_sdk/rust/Cargo.toml +++ b/quantus_sdk/rust/Cargo.toml @@ -17,7 +17,7 @@ hex = "0.4.3" sp-core = { version = "43.0.0", default-features = true } parity-scale-codec = { version = "3.7.5", default-features = false, features = ["std"] } sp-runtime = { version = "48.0.0", default-features = false, features = ["std"] } -quantus_ur = { git = "https://github.com/Quantus-Network/quantus_ur.git", tag = "1.4.0" } +quantus_ur = { git = "https://github.com/Quantus-Network/quantus_ur.git", tag = "1.6.0" } # ZK proof generation (aligned with quantus-cli / chain) anyhow = "1.0" diff --git a/quantus_sdk/rust/src/api/ur.rs b/quantus_sdk/rust/src/api/ur.rs index a8dbbc19a..791790c60 100644 --- a/quantus_sdk/rust/src/api/ur.rs +++ b/quantus_sdk/rust/src/api/ur.rs @@ -1,6 +1,6 @@ /// UR API for parsing QR codes in the ur:.. standard /// -use quantus_ur::{decode_bytes, encode_bytes, is_complete}; +use quantus_ur::{decode_bytes, encode_bytes, encode_bytes_with_options, is_complete}; // Note decode_ur takes the list of QR Codes in any order and assembles them correctly. // It also deals with the weird elements that are created in the UR standard when we exceed the number @@ -14,9 +14,15 @@ pub fn decode_ur(ur_parts: Vec) -> Result, String> { decode_bytes(&ur_parts).map_err(|e| e.to_string()) } +// max_fragment_length is the payload bytes per QR frame; None uses the +// quantus_ur default (200). #[flutter_rust_bridge::frb(sync)] -pub fn encode_ur(data: Vec) -> Result, String> { - encode_bytes(&data).map_err(|e| e.to_string()) +pub fn encode_ur(data: Vec, max_fragment_length: Option) -> Result, String> { + match max_fragment_length { + Some(len) => encode_bytes_with_options(&data, len as usize), + None => encode_bytes(&data), + } + .map_err(|e| e.to_string()) } #[flutter_rust_bridge::frb(sync)] @@ -33,7 +39,7 @@ mod tests { let hex_payload = "0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000007400000002000000"; let payload_bytes = hex::decode(hex_payload).expect("Hex decode failed"); - let encoded_parts = encode_ur(payload_bytes.clone()).expect("Encoding failed"); + let encoded_parts = encode_ur(payload_bytes.clone(), None).expect("Encoding failed"); assert_eq!(encoded_parts.len(), 1, "Should be single part"); let decoded_bytes = decode_ur(encoded_parts).expect("Decoding failed"); @@ -45,7 +51,7 @@ mod tests { let hex_payload = "0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000007400000002000000".repeat(10); let payload_bytes = hex::decode(&hex_payload).expect("Hex decode failed"); - let encoded_parts = encode_ur(payload_bytes.clone()).expect("Encoding failed"); + let encoded_parts = encode_ur(payload_bytes.clone(), None).expect("Encoding failed"); assert!(encoded_parts.len() > 1, "Should be multiple parts"); let decoded_bytes = decode_ur(encoded_parts).expect("Decoding failed"); @@ -56,7 +62,7 @@ mod tests { fn test_is_complete_single_part() { let hex_payload = "0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000007400000002000000"; let payload_bytes = hex::decode(hex_payload).expect("Hex decode failed"); - let encoded_parts = encode_ur(payload_bytes).expect("Encoding failed"); + let encoded_parts = encode_ur(payload_bytes, None).expect("Encoding failed"); assert!(is_complete_ur(encoded_parts), "Single part should be complete"); } @@ -65,7 +71,7 @@ mod tests { fn test_is_complete_multi_part_complete() { let hex_payload = "0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000007400000002000000".repeat(10); let payload_bytes = hex::decode(&hex_payload).expect("Hex decode failed"); - let encoded_parts = encode_ur(payload_bytes).expect("Encoding failed"); + let encoded_parts = encode_ur(payload_bytes, None).expect("Encoding failed"); assert!(is_complete_ur(encoded_parts), "All parts should be complete"); } @@ -74,7 +80,7 @@ mod tests { fn test_is_complete_multi_part_incomplete() { let hex_payload = "0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000007400000002000000".repeat(10); let payload_bytes = hex::decode(&hex_payload).expect("Hex decode failed"); - let encoded_parts = encode_ur(payload_bytes).expect("Encoding failed"); + let encoded_parts = encode_ur(payload_bytes, None).expect("Encoding failed"); assert!(encoded_parts.len() > 1, "Should have multiple parts"); @@ -82,11 +88,23 @@ mod tests { assert!(!is_complete_ur(incomplete_parts), "Incomplete parts should return false"); } + #[test] + fn test_fragment_length_controls_part_count() { + let payload_bytes = vec![0xABu8; 3000]; + + let small = encode_ur(payload_bytes.clone(), Some(300)).expect("Encoding failed"); + let large = encode_ur(payload_bytes.clone(), Some(1500)).expect("Encoding failed"); + assert!(small.len() > large.len(), "Smaller fragments should produce more parts"); + + assert_eq!(decode_ur(small).expect("Decoding failed"), payload_bytes); + assert_eq!(decode_ur(large).expect("Decoding failed"), payload_bytes); + } + #[test] fn test_multi_part_out_of_order() { let hex_payload = "0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000007400000002000000".repeat(10); let payload_bytes = hex::decode(&hex_payload).expect("Hex decode failed"); - let encoded_parts = encode_ur(payload_bytes.clone()).expect("Encoding failed"); + let encoded_parts = encode_ur(payload_bytes.clone(), None).expect("Encoding failed"); assert!(encoded_parts.len() > 1, "Should be multiple parts"); diff --git a/quantus_sdk/rust/src/frb_generated.rs b/quantus_sdk/rust/src/frb_generated.rs index dc1ac8963..7b0f6041f 100644 --- a/quantus_sdk/rust/src/frb_generated.rs +++ b/quantus_sdk/rust/src/frb_generated.rs @@ -470,9 +470,10 @@ fn wire__crate__api__ur__encode_ur_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_data = >::sse_decode(&mut deserializer); + let api_max_fragment_length = >::sse_decode(&mut deserializer); deserializer.end(); transform_result_sse::<_, String>((move || { - let output_ok = crate::api::ur::encode_ur(api_data)?; + let output_ok = crate::api::ur::encode_ur(api_data, api_max_fragment_length)?; Ok(output_ok) })()) }, @@ -657,15 +658,15 @@ fn wire__crate__api__wormhole__generate_proof_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_input = ::sse_decode(&mut deserializer); - let api_prover_bin_path = ::sse_decode(&mut deserializer); - let api_common_bin_path = ::sse_decode(&mut deserializer); + let api__prover_bin_path = ::sse_decode(&mut deserializer); + let api__common_bin_path = ::sse_decode(&mut deserializer); deserializer.end(); move |context| { transform_result_sse::<_, String>((move || { let output_ok = crate::api::wormhole::generate_proof( api_input, - api_prover_bin_path, - api_common_bin_path, + api__prover_bin_path, + api__common_bin_path, )?; Ok(output_ok) })()) @@ -1198,6 +1199,17 @@ impl SseDecode for crate::api::wormhole::MerkleProcessed { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for Option<[u8; 32]> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1625,6 +1637,16 @@ impl SseEncode for crate::api::wormhole::MerkleProcessed { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option<[u8; 32]> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { From 0e216fc94badb215fd30b6ba0c3ebfde2f6c73e4 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 6 Aug 2026 13:28:05 +0800 Subject: [PATCH 2/6] speed up keystone send flow and overall send flow --- .../v2/screens/send/keystone_sign_cache.dart | 24 +++ .../v2/screens/send/keystone_sign_screen.dart | 32 +--- .../screens/send/regular_send_strategy.dart | 30 +++- .../v2/screens/send/review_send_screen.dart | 27 +++ .../lib/v2/screens/send/send_providers.dart | 15 ++ .../lib/v2/screens/send/send_strategy.dart | 6 + .../lib/src/constants/app_constants.dart | 4 + .../network/keep_alive_http_provider.dart | 48 ++++++ .../services/network/redundant_endpoint.dart | 24 ++- .../lib/src/services/substrate_service.dart | 162 +++++++++--------- 10 files changed, 255 insertions(+), 117 deletions(-) create mode 100644 quantus_sdk/lib/src/services/network/keep_alive_http_provider.dart diff --git a/mobile-app/lib/v2/screens/send/keystone_sign_cache.dart b/mobile-app/lib/v2/screens/send/keystone_sign_cache.dart index b51cca9e5..15cc9d82e 100644 --- a/mobile-app/lib/v2/screens/send/keystone_sign_cache.dart +++ b/mobile-app/lib/v2/screens/send/keystone_sign_cache.dart @@ -1,5 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; /// Blocks of safety margin before mortal era expiry when treating cache as stale. const int keystoneSignCacheEraSafetyMarginBlocks = 2; @@ -119,3 +121,25 @@ class KeystoneSignCacheNotifier extends StateNotifier { final keystoneSignCacheProvider = StateNotifierProvider( (ref) => KeystoneSignCacheNotifier(), ); + +/// Builds the unsigned payload and its UR frames, serving a fresh cache entry +/// when one exists and storing the result under [cacheKey]. The review screen +/// prefetches through this so the QR screen usually renders instantly; the QR +/// screen itself calls it as the fallback when nothing was prefetched. +Future<({UnsignedTransactionData unsignedData, List urParts})> ensureKeystoneSignPayload( + WidgetRef ref, { + required Account account, + required RuntimeCall Function() buildCall, + KeystoneSignCacheKey? cacheKey, +}) async { + final cache = ref.read(keystoneSignCacheProvider.notifier); + if (cacheKey != null) { + final cached = cache.lookup(cacheKey); + if (cached != null) return (unsignedData: cached.unsignedData, urParts: cached.urParts); + } + final unsigned = await ref.read(substrateServiceProvider).getUnsignedTransactionPayload(account, buildCall()); + final parts = encodeUr(data: unsigned.encodedPayloadRaw); + if (parts.isEmpty) throw Exception('Failed to encode transaction payload as UR'); + if (cacheKey != null) cache.store(key: cacheKey, unsignedData: unsigned, urParts: parts); + return (unsignedData: unsigned, urParts: parts); +} diff --git a/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart b/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart index 065055797..0445da291 100644 --- a/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart +++ b/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/v2/components/animated_ur_qr.dart'; @@ -46,35 +45,18 @@ class _KeystoneSignScreenState extends ConsumerState { } Future _prepare() async { - final cacheKey = widget.session.cacheKey; - if (cacheKey != null) { - final cached = ref.read(keystoneSignCacheProvider.notifier).lookup(cacheKey); - if (cached != null) { - if (!mounted) return; - setState(() { - _unsignedData = cached.unsignedData; - _urParts = cached.urParts; - }); - return; - } - } - try { - final substrate = ref.read(substrateServiceProvider); - final unsigned = await substrate.getUnsignedTransactionPayload( - widget.session.account, - widget.session.buildCall(), + final payload = await ensureKeystoneSignPayload( + ref, + account: widget.session.account, + buildCall: widget.session.buildCall, + cacheKey: widget.session.cacheKey, ); - final parts = encodeUr(data: unsigned.encodedPayloadRaw); - if (parts.isEmpty) throw Exception('Failed to encode transaction payload as UR'); - if (cacheKey != null) { - ref.read(keystoneSignCacheProvider.notifier).store(key: cacheKey, unsignedData: unsigned, urParts: parts); - } TelemetryService().sendEvent('${widget.session.telemetryPrefix}_payload_ready'); if (!mounted) return; setState(() { - _unsignedData = unsigned; - _urParts = parts; + _unsignedData = payload.unsignedData; + _urParts = payload.urParts; }); } catch (error) { quantusPrint('Keystone payload preparation failed: $error'); diff --git a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart index 06b47d9b9..2326f060d 100644 --- a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -68,6 +68,26 @@ class RegularSendStrategy extends SendStrategy { @override String? affordabilityError(WidgetRef ref, SendFee fee, AppLocalizations l10n) => null; + bool get _signsWithHardware => account.accountType == AccountType.keystone || AppConstants.debugHardwareWallet; + + RuntimeCall _transferCall(WidgetRef ref, String recipient, BigInt amount) => + ref.read(balancesServiceProvider).getBalanceTransferCall(recipient, amount); + + KeystoneSignCacheKey _hardwareCacheKey(String recipient, BigInt amount) => + KeystoneSignCacheKey.fromSendParams(accountId: account.accountId, recipientAddress: recipient, amount: amount); + + @override + Future prefetchSignPayload(WidgetRef ref, {required String recipientAddress, required BigInt amount}) async { + if (!_signsWithHardware) return; + final recipient = recipientAddress.trim(); + await ensureKeystoneSignPayload( + ref, + account: account, + buildCall: () => _transferCall(ref, recipient, amount), + cacheKey: _hardwareCacheKey(recipient, amount), + ); + } + @override List reviewRows( BuildContext context, @@ -125,22 +145,18 @@ class RegularSendStrategy extends SendStrategy { // Keystone (hardware) accounts sign off-device: hand off to the QR flow // instead of signing locally. The debug flag forces this path for testing. - if (account.accountType == AccountType.keystone || AppConstants.debugHardwareWallet) { + if (_signsWithHardware) { return SendNeedsHardwareSignature( session: KeystoneSigningSession( account: account, - buildCall: () => ref.read(balancesServiceProvider).getBalanceTransferCall(recipient, amount), + buildCall: () => _transferCall(ref, recipient, amount), primaryDetail: l10n.commonAmountBalance( fmt.formatBalance(amount, smartDecimals: 4), AppConstants.tokenSymbol, ), secondaryDetail: recipient, tertiaryDetail: recipientChecksum, - cacheKey: KeystoneSignCacheKey.fromSendParams( - accountId: account.accountId, - recipientAddress: recipient, - amount: amount, - ), + cacheKey: _hardwareCacheKey(recipient, amount), telemetryPrefix: 'send_transfer_hardware', submitSigned: (ref, {required unsignedData, required signature, required publicKey}) async { final hash = await ref diff --git a/mobile-app/lib/v2/screens/send/review_send_screen.dart b/mobile-app/lib/v2/screens/send/review_send_screen.dart index 94af127c6..b5fc32c30 100644 --- a/mobile-app/lib/v2/screens/send/review_send_screen.dart +++ b/mobile-app/lib/v2/screens/send/review_send_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; @@ -45,6 +47,31 @@ class ReviewSendScreen extends ConsumerStatefulWidget { class _ReviewSendScreenState extends ConsumerState { bool _submitting = false; String? _errorMessage; + Timer? _prefetchTimer; + + @override + void initState() { + super.initState(); + // Warm hardware-signing payloads while the user reviews, and keep them + // warm: a cache hit is a no-op, so the periodic tick only refetches once + // the mortal-era window has expired the cached entry. + _prefetchSignPayload(); + _prefetchTimer = Timer.periodic(const Duration(seconds: 30), (_) => _prefetchSignPayload()); + } + + @override + void dispose() { + _prefetchTimer?.cancel(); + super.dispose(); + } + + void _prefetchSignPayload() { + unawaited( + widget.strategy + .prefetchSignPayload(ref, recipientAddress: widget.recipientAddress.trim(), amount: widget.amount) + .catchError((Object e) => quantusPrint('Keystone payload prefetch failed: $e')), + ); + } Future _toggleFlip() async { await ref.read(isCurrencyFlippedProvider.notifier).toggle(); diff --git a/mobile-app/lib/v2/screens/send/send_providers.dart b/mobile-app/lib/v2/screens/send/send_providers.dart index d7868d365..c7c7e826b 100644 --- a/mobile-app/lib/v2/screens/send/send_providers.dart +++ b/mobile-app/lib/v2/screens/send/send_providers.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; @@ -26,6 +28,19 @@ Future startSendFlow(BuildContext context, {required Widget screen}) async return; } container.read(keystoneSignCacheProvider.notifier).startNewSendSession(); + // Warm the runtime-version cache (5 min TTL) so payload builds later in the + // flow skip that round trip. + unawaited( + container + .read(substrateServiceProvider) + .getRuntimeVersion() + .then( + (_) {}, + onError: (Object e) { + quantusPrint('Runtime version prefetch failed: $e'); + }, + ), + ); sendFlow.state = true; try { await Navigator.push(context, MaterialPageRoute(builder: (_) => screen)); diff --git a/mobile-app/lib/v2/screens/send/send_strategy.dart b/mobile-app/lib/v2/screens/send/send_strategy.dart index bf447c2e8..096ca11ac 100644 --- a/mobile-app/lib/v2/screens/send/send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/send_strategy.dart @@ -244,6 +244,12 @@ abstract class SendStrategy { required SendFee fee, }); + /// Called while the user is on the review screen (and periodically until it + /// closes). Strategies that hand off to hardware signing warm the Keystone + /// sign cache here so the QR screen renders instantly. No-op for flows that + /// sign locally. Uses `ref.read`. + Future prefetchSignPayload(WidgetRef ref, {required String recipientAddress, required BigInt amount}) async {} + /// Authenticates and submits. Uses `ref.read`. Never navigates. Future submit( WidgetRef ref, { diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index e6ff3813e..747a26929 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -53,6 +53,10 @@ class AppConstants { /// Average Quantus block time in seconds (~12s). Used for mortal-era TTL and block↔time estimates. static const int avgBlockTimeSeconds = 12; + /// Mortal era length for signed transactions, in blocks. Must be a power of + /// two (the era encoding rounds up); 16 blocks ≈ 3.2 minutes at 12s blocks. + static const int txMortalEraPeriodBlocks = 16; + // Digits of precision static const int decimals = 12; static const int ss58prefix = 189; diff --git a/quantus_sdk/lib/src/services/network/keep_alive_http_provider.dart b/quantus_sdk/lib/src/services/network/keep_alive_http_provider.dart new file mode 100644 index 000000000..315573198 --- /dev/null +++ b/quantus_sdk/lib/src/services/network/keep_alive_http_provider.dart @@ -0,0 +1,48 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:polkadart/polkadart.dart'; + +/// JSON-RPC over HTTP with connection reuse. +/// +/// polkadart's `HttpProvider.send` uses the top-level `http.post`, which +/// creates a new client — and therefore a fresh TCP+TLS handshake — for every +/// request. This provider keeps one [http.Client] so repeated RPCs to the same +/// endpoint reuse pooled keep-alive connections. +class KeepAliveHttpProvider extends Provider { + KeepAliveHttpProvider(this.url); + + final Uri url; + final http.Client _client = http.Client(); + int _sequence = 0; + + @override + Future send(String method, List params) async { + final response = await _client.post( + url, + body: jsonEncode({'id': (++_sequence).toString(), 'jsonrpc': '2.0', 'method': method, 'params': params}), + headers: {'Content-Type': 'application/json'}, + ); + final data = jsonDecode(response.body); + return RpcResponse(id: int.tryParse(data['id'].toString()) ?? -1, result: data['result'], error: data['error']); + } + + @override + Future subscribe( + String method, + List params, { + FutureOr Function(String subscription)? onCancel, + }) { + throw Exception('KeepAliveHttpProvider does not support subscriptions'); + } + + @override + Future connect() => Future.value(); + + @override + Future disconnect() => Future.value(); + + @override + bool isConnected() => true; +} diff --git a/quantus_sdk/lib/src/services/network/redundant_endpoint.dart b/quantus_sdk/lib/src/services/network/redundant_endpoint.dart index acba6fa98..6b6df7354 100644 --- a/quantus_sdk/lib/src/services/network/redundant_endpoint.dart +++ b/quantus_sdk/lib/src/services/network/redundant_endpoint.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:http/http.dart' as http; +import 'package:polkadart/polkadart.dart' show Provider; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_sdk/src/services/network/keep_alive_http_provider.dart'; import 'package:quantus_sdk/src/utils/print.dart'; import 'package:quantus_sdk/src/utils/timing.dart'; @@ -35,14 +37,32 @@ class RpcEndpointService extends RedundantEndpointService { String get bestEndpointUrl => endpoints.first.url; + final Map _providers = {}; + + /// One provider per endpoint for the app's lifetime, so every RPC reuses the + /// same keep-alive connection instead of paying a fresh TLS handshake. + Provider providerFor(String url) => _providers.putIfAbsent(url, () { + final uri = Uri.parse(url); + if (uri.scheme == 'http' || uri.scheme == 'https') return KeepAliveHttpProvider(uri); + return Provider.fromUri(uri); + }); + Future rpcTask(Future Function(Uri uri) task) async { return _executeTask((url) => task(Uri.parse(url))); } + + /// Like [rpcTask], but hands the task the endpoint's shared [Provider]. + Future providerTask(Future Function(Provider provider) task) async { + return _executeTask((url) => task(providerFor(url))); + } } class RedundantEndpointService { final List endpoints; + /// Shared client so plain HTTP calls reuse keep-alive connections too. + final http.Client _httpClient = http.Client(); + RedundantEndpointService({required this.endpoints}); Map _mergedHeaders(Map? headers) { @@ -107,12 +127,12 @@ class RedundantEndpointService { } Future get(String path, {Map? headers}) async { - return _executeTask((url) => http.get(Uri.parse('$url$path'), headers: _mergedHeaders(headers))); + return _executeTask((url) => _httpClient.get(Uri.parse('$url$path'), headers: _mergedHeaders(headers))); } Future post({String? path, Map? headers, String? body}) async { return _executeTask( - (url) => http.post(Uri.parse('$url${(path ?? '')}'), body: body, headers: _mergedHeaders(headers)), + (url) => _httpClient.post(Uri.parse('$url${(path ?? '')}'), body: body, headers: _mergedHeaders(headers)), ); } } diff --git a/quantus_sdk/lib/src/services/substrate_service.dart b/quantus_sdk/lib/src/services/substrate_service.dart index 978deb82a..8f70e0c25 100644 --- a/quantus_sdk/lib/src/services/substrate_service.dart +++ b/quantus_sdk/lib/src/services/substrate_service.dart @@ -9,7 +9,7 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:quantus_sdk/src/resonance_extrinsic_payload.dart'; import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; import 'package:quantus_sdk/src/utils/timing.dart'; -import 'package:ss58/ss58.dart'; +import 'package:ss58/ss58.dart' hide Registry; import 'package:quantus_sdk/src/extensions/address_extension.dart'; import 'package:quantus_sdk/src/utils/print.dart'; @@ -30,14 +30,32 @@ class SubstrateService { final RpcEndpointService _rpcEndpointService = RpcEndpointService(); final SettingsService _settingsService = SettingsService(); + String? _cachedGenesisHash; + RuntimeVersion? _cachedRuntimeVersion; + DateTime? _runtimeVersionFetchedAt; + static const _runtimeVersionMaxAge = Duration(minutes: 5); + + /// Runtime version only changes on runtime upgrades, so it is cached briefly. + /// Send flows prefetch it on entry so payload builds hit the cache. + Future getRuntimeVersion() async { + final cached = _cachedRuntimeVersion; + final fetchedAt = _runtimeVersionFetchedAt; + if (cached != null && fetchedAt != null && DateTime.now().difference(fetchedAt) < _runtimeVersionMaxAge) { + return cached; + } + final version = await _rpcEndpointService.providerTask((provider) => StateApi(provider).getRuntimeVersion()); + _cachedRuntimeVersion = version; + _runtimeVersionFetchedAt = DateTime.now(); + return version; + } + Future getFee(Uint8List signedExtrinsic) async { try { final hexEncodedSignedExtrinsic = bytesToHex(signedExtrinsic); - final result = await _rpcEndpointService.rpcTask((uri) async { - final provider = Provider.fromUri(uri); - return await provider.send('payment_queryInfo', [hexEncodedSignedExtrinsic, null]); - }); + final result = await _rpcEndpointService.providerTask( + (provider) => provider.send('payment_queryInfo', [hexEncodedSignedExtrinsic, null]), + ); if (result.error != null) { throw Exception('RPC Error: ${result.error}'); @@ -69,15 +87,10 @@ class SubstrateService { final accountID = crypto.ss58ToAccountId(s: address); final totalSw = Stopwatch()..start(); - final accountInfo = await _rpcEndpointService.rpcTask((uri) async { - final setupSw = Stopwatch()..start(); - final provider = Provider.fromUri(uri); - final quantusApi = Planck(provider); - printTiming('queryBalance setup $uri', setupSw.elapsedMilliseconds); - + final accountInfo = await _rpcEndpointService.providerTask((provider) async { final callSw = Stopwatch()..start(); - final result = await quantusApi.query.system.account(accountID); - printTiming('queryBalance call $uri', callSw.elapsedMilliseconds); + final result = await Planck(provider).query.system.account(accountID); + printTiming('queryBalance call', callSw.elapsedMilliseconds); return result; }); @@ -117,11 +130,9 @@ class SubstrateService { Future _submitExtrinsic(Uint8List extrinsic) async { final params = ['0x${hex.encode(extrinsic)}']; - final response = await _rpcEndpointService.rpcTask((uri) async { - quantusPrint('submitExtrinsic to $uri'); - final provider = Provider.fromUri(uri); - return await provider.send('author_submitExtrinsic', params); - }); + final response = await _rpcEndpointService.providerTask( + (provider) => provider.send('author_submitExtrinsic', params), + ); quantusPrint('submitExtrinsic response: ${response.result}'); if (response.error != null) { @@ -210,39 +221,47 @@ class SubstrateService { return isRetry && (message.contains('outdated') || message.contains('stale')); } - Future getExtrinsicPayload(Account account, RuntimeCall call, {bool isSigned = true}) async { - final [runtimeVersion, genesisHash, blockNumber, blockHash, nonce] = await Future.wait([ - _rpcEndpointService.rpcTask((uri) async { - final provider = Provider.fromUri(uri); - final stateApi = StateApi(provider); - return await stateApi.getRuntimeVersion(); - }), + /// Everything chain-dependent a signing payload needs, fetched in one + /// parallel round trip. Genesis hash and runtime version come from cache + /// when fresh, so usually only header, block hash and nonce hit the network. + Future<({RuntimeVersion runtimeVersion, dynamic genesisHash, int blockNumber, dynamic blockHash, int nonce})> + _getSigningContext(String accountId) async { + final [runtimeVersion, genesisHash, blockNumber, blockHash, nonce] = await Future.wait([ + getRuntimeVersion(), _getGenesisHash(), _getBlockNumber(), _getBlockHash(), - _getNextAccountNonceFromAddress(account.accountId), + _getNextAccountNonceFromAddress(accountId), ]); + return ( + runtimeVersion: runtimeVersion as RuntimeVersion, + genesisHash: genesisHash, + blockNumber: blockNumber as int, + blockHash: blockHash, + nonce: nonce as int, + ); + } - final [specVersion, transactionVersion] = [runtimeVersion.specVersion, runtimeVersion.transactionVersion]; + Future getExtrinsicPayload(Account account, RuntimeCall call, {bool isSigned = true}) async { + final ctx = await _getSigningContext(account.accountId); + final blockNumber = ctx.blockNumber; + final blockHash = ctx.blockHash; + final nonce = ctx.nonce; final encodedCall = call.encode(); final payloadToSign = SigningPayload( method: encodedCall, - specVersion: specVersion, - transactionVersion: transactionVersion, - genesisHash: genesisHash, + specVersion: ctx.runtimeVersion.specVersion, + transactionVersion: ctx.runtimeVersion.transactionVersion, + genesisHash: ctx.genesisHash, blockHash: blockHash, blockNumber: blockNumber, - eraPeriod: 64, + eraPeriod: AppConstants.txMortalEraPeriodBlocks, nonce: nonce, tip: 0, ); - final registry = await _rpcEndpointService.rpcTask((uri) async { - final provider = Provider.fromUri(uri); - return Planck(provider).registry; - }); - + final registry = Registry(); final payload = payloadToSign.encode(registry); if (isSigned) { @@ -259,7 +278,7 @@ class SubstrateService { signer: Uint8List.fromList(senderWallet.addressBytes), method: encodedCall, signature: signatureWithPublicKeyBytes, - eraPeriod: 64, + eraPeriod: AppConstants.txMortalEraPeriodBlocks, blockNumber: blockNumber, nonce: nonce, tip: 0, @@ -276,7 +295,7 @@ class SubstrateService { signer: signerBytes, method: encodedCall, signature: dummySignature, - eraPeriod: 64, + eraPeriod: AppConstants.txMortalEraPeriodBlocks, blockNumber: blockNumber, nonce: nonce, tip: 0, @@ -288,40 +307,22 @@ class SubstrateService { Future getUnsignedTransactionPayload(Account account, RuntimeCall call) async { final accountIdBytes = crypto.ss58ToAccountId(s: account.accountId); - - final [runtimeVersion, genesisHash, blockNumber, blockHash, nonce] = await Future.wait([ - _rpcEndpointService.rpcTask((uri) async { - final provider = Provider.fromUri(uri); - final stateApi = StateApi(provider); - return await stateApi.getRuntimeVersion(); - }), - _getGenesisHash(), - _getBlockNumber(), - _getBlockHash(), - _getNextAccountNonceFromAddress(account.accountId), - ]); - - final [specVersion, transactionVersion] = [runtimeVersion.specVersion, runtimeVersion.transactionVersion]; + final ctx = await _getSigningContext(account.accountId); final encodedCall = call.encode(); final payloadToSign = QuantusSigningPayload( method: encodedCall, - specVersion: specVersion, - transactionVersion: transactionVersion, - genesisHash: genesisHash, - blockHash: blockHash, - blockNumber: blockNumber, - eraPeriod: 64, - nonce: nonce, + specVersion: ctx.runtimeVersion.specVersion, + transactionVersion: ctx.runtimeVersion.transactionVersion, + genesisHash: ctx.genesisHash, + blockHash: ctx.blockHash, + blockNumber: ctx.blockNumber, + eraPeriod: AppConstants.txMortalEraPeriodBlocks, + nonce: ctx.nonce, tip: 0, ); - final registry = await _rpcEndpointService.rpcTask((uri) async { - final provider = Provider.fromUri(uri); - return Planck(provider).registry; - }); - - return UnsignedTransactionData(payloadToSign: payloadToSign, signer: accountIdBytes, registry: registry); + return UnsignedTransactionData(payloadToSign: payloadToSign, signer: accountIdBytes, registry: Registry()); } Future submitExtrinsicWithExternalSignature( @@ -347,34 +348,29 @@ class SubstrateService { } Future _getNextAccountNonceFromAddress(String address) async { - final nonceResult = await _rpcEndpointService.rpcTask((uri) async { - final provider = Provider.fromUri(uri); - return await provider.send('system_accountNextIndex', [address]); - }); + final nonceResult = await _rpcEndpointService.providerTask( + (provider) => provider.send('system_accountNextIndex', [address]), + ); return int.parse(nonceResult.result.toString()); } Future _getBlockHash() async { - final result = await _rpcEndpointService.rpcTask((uri) async { - final provider = Provider.fromUri(uri); - return await provider.send('chain_getBlockHash', []); - }); + final result = await _rpcEndpointService.providerTask((provider) => provider.send('chain_getBlockHash', [])); return result.result.replaceAll('0x', ''); } + /// Immutable per chain, so fetched once per app run. Future _getGenesisHash() async { - final result = await _rpcEndpointService.rpcTask((uri) async { - final provider = Provider.fromUri(uri); - return await provider.send('chain_getBlockHash', [0]); - }); - return result.result.replaceAll('0x', ''); + final cached = _cachedGenesisHash; + if (cached != null) return cached; + final result = await _rpcEndpointService.providerTask((provider) => provider.send('chain_getBlockHash', [0])); + final hash = result.result.replaceAll('0x', '') as String; + _cachedGenesisHash = hash; + return hash; } Future _getBlockNumber() async { - final blockHeader = await _rpcEndpointService.rpcTask((uri) async { - final provider = Provider.fromUri(uri); - return await provider.send('chain_getHeader', []); - }); + final blockHeader = await _rpcEndpointService.providerTask((provider) => provider.send('chain_getHeader', [])); return int.parse(blockHeader.result['number']); } @@ -383,7 +379,7 @@ class SubstrateService { Provider? get provider { try { - return Provider.fromUri(Uri.parse(_rpcEndpointService.bestEndpointUrl)); + return _rpcEndpointService.providerFor(_rpcEndpointService.bestEndpointUrl); } catch (e) { return null; } From f918bd07a3fa1989e6969b651583b653a49d47e0 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 6 Aug 2026 13:34:56 +0800 Subject: [PATCH 3/6] faster display and fast scanning of QR codes --- .../lib/components/animated_ur_qr.dart | 36 ++++++++++++------- .../lib/screens/scan_transaction_screen.dart | 8 ++++- .../v2/components/animated_qr_scanner.dart | 8 ++++- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/cold-wallet-app/lib/components/animated_ur_qr.dart b/cold-wallet-app/lib/components/animated_ur_qr.dart index 7dfb05e96..d3fc00fb7 100644 --- a/cold-wallet-app/lib/components/animated_ur_qr.dart +++ b/cold-wallet-app/lib/components/animated_ur_qr.dart @@ -6,6 +6,10 @@ import 'package:qr_flutter/qr_flutter.dart'; /// Renders a UR payload as a QR code. A multi-part UR is animated by cycling /// through its fragments with a [Timer] (no post-frame callbacks). Reacts to /// [fps], [paused] and [parts] changes so the animation can be tuned live. +/// +/// Every frame's QR is encoded once up front: encoding a dense QR takes long +/// enough that doing it inside build caps the real frame rate well below the +/// requested one. Painting a precomputed module matrix keeps ticks cheap. class AnimatedUrQr extends StatefulWidget { final List parts; final int fps; @@ -21,20 +25,35 @@ class AnimatedUrQr extends StatefulWidget { class _AnimatedUrQrState extends State { Timer? _timer; int _index = 0; + late List _painters; @override void initState() { super.initState(); + _painters = _buildPainters(); _restartTimer(); } + List _buildPainters() => widget.parts + .map( + (part) => QrPainter.withQr( + qr: QrCode.fromData(data: part, errorCorrectLevel: QrErrorCorrectLevel.L), + gapless: true, + eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black), + dataModuleStyle: const QrDataModuleStyle(dataModuleShape: QrDataModuleShape.square, color: Colors.black), + ), + ) + .toList(); + @override void didUpdateWidget(AnimatedUrQr oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.parts.length != oldWidget.parts.length) _index = 0; - if (widget.fps != oldWidget.fps || - widget.paused != oldWidget.paused || - widget.parts.length != oldWidget.parts.length) { + final partsChanged = !identical(widget.parts, oldWidget.parts); + if (partsChanged) { + _painters = _buildPainters(); + _index = 0; + } + if (partsChanged || widget.fps != oldWidget.fps || widget.paused != oldWidget.paused) { _restartTimer(); } } @@ -61,14 +80,7 @@ class _AnimatedUrQrState extends State { height: widget.size, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), - child: QrImageView( - data: widget.parts[_index], - errorCorrectionLevel: QrErrorCorrectLevel.L, - version: QrVersions.auto, - backgroundColor: Colors.white, - eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black), - dataModuleStyle: const QrDataModuleStyle(dataModuleShape: QrDataModuleShape.square, color: Colors.black), - ), + child: CustomPaint(painter: _painters[_index]), ); } } diff --git a/cold-wallet-app/lib/screens/scan_transaction_screen.dart b/cold-wallet-app/lib/screens/scan_transaction_screen.dart index 685386f99..c965e0458 100644 --- a/cold-wallet-app/lib/screens/scan_transaction_screen.dart +++ b/cold-wallet-app/lib/screens/scan_transaction_screen.dart @@ -18,7 +18,13 @@ class ScanTransactionScreen extends StatefulWidget { } class _ScanTransactionScreenState extends State { - final MobileScannerController _controller = MobileScannerController(); + // Unrestricted: the default DetectionSpeed.normal enforces a 250ms timeout + // between detections, capping an animated QR at ~4 frames/second. Duplicate + // deliveries are cheap — parts dedupe through the set below. + final MobileScannerController _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.unrestricted, + formats: const [BarcodeFormat.qrCode], + ); final Set _parts = {}; final Set _seenSeq = {}; final RegExp _seqPattern = RegExp(r'/(\d+)-(\d+)/'); diff --git a/mobile-app/lib/v2/components/animated_qr_scanner.dart b/mobile-app/lib/v2/components/animated_qr_scanner.dart index 13528da2c..86ace9089 100644 --- a/mobile-app/lib/v2/components/animated_qr_scanner.dart +++ b/mobile-app/lib/v2/components/animated_qr_scanner.dart @@ -64,7 +64,13 @@ class AnimatedQrScanner extends StatefulWidget { } class _AnimatedQrScannerState extends State { - final MobileScannerController _controller = MobileScannerController(); + // Unrestricted: the default DetectionSpeed.normal enforces a 250ms timeout + // between detections, capping an animated QR at ~4 frames/second. Duplicate + // deliveries are cheap — parts dedupe through the set below. + final MobileScannerController _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.unrestricted, + formats: const [BarcodeFormat.qrCode], + ); final Set _parts = {}; final Set _seenSequenceIndexes = {}; From 56edbcb836710369fc03528c5c975309f7c24a9e Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 6 Aug 2026 13:35:49 +0800 Subject: [PATCH 4/6] 1.5.10 build 124 for testing fast scanning --- mobile-app/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index e0222fce2..b60de87fc 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -2,7 +2,7 @@ name: resonance_network_wallet description: A Flutter wallet for the Quantus blockchain. publish_to: "none" -version: 1.5.9+123 +version: 1.5.10+124 environment: sdk: ">=3.8.0 <4.0.0" From 9ced530789f77f602cc0ec2c101de1f78e352f85 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 10 Aug 2026 12:55:50 +0800 Subject: [PATCH 5/6] Update version.g.dart --- mobile-app/lib/generated/version.g.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile-app/lib/generated/version.g.dart b/mobile-app/lib/generated/version.g.dart index 7e6c0d702..bc5041083 100644 --- a/mobile-app/lib/generated/version.g.dart +++ b/mobile-app/lib/generated/version.g.dart @@ -1,2 +1,2 @@ -const appVersion = '1.5.9'; -const appBuildNumber = '123'; +const appVersion = '1.5.10'; +const appBuildNumber = '124'; From d4d69d1ae3c90e5c3a74539af01b6655511b1a19 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Tue, 11 Aug 2026 13:48:06 +0800 Subject: [PATCH 6/6] Address review: QR frame capacity, era expiry guards, cache invalidation - Bound animated-QR fragments by encoded UR string length against the 2,953-char version-40 QR capacity (encodeUrForQr), lower the cold wallet slider max to 1400 bytes, and add a native regression test sweeping every fragment setting with the real 7,219-byte ML-DSA payload. - Never carry a nearly expired mortal-era payload through the Keystone screens: cached payloads now expire once less than a 10-block round-trip reserve remains, the sign screen regenerates the displayed QR when its reserve is consumed and refuses to advance stale, and submission checks the live block height against the era window with a dedicated error message. - Clear the cached genesis hash and runtime version when the chain rejects a submitted extrinsic, so a runtime upgrade inside the cache window recovers on the next payload build. - Keep the cold wallet Wi-Fi lock override in release builds intentionally (pre-app-store release testing) and document that in code and in the setting copy, which now says debugging-only and unsafe. Co-Authored-By: Claude Fable 5 --- .../lib/providers/settings_providers.dart | 6 ++- .../lib/screens/settings_screen.dart | 17 +++++-- .../lib/screens/sign_transaction_screen.dart | 2 +- .../lib/widgets/connectivity_guard.dart | 5 ++ mobile-app/lib/l10n/app_en.arb | 4 ++ mobile-app/lib/l10n/app_id.arb | 1 + mobile-app/lib/l10n/app_localizations.dart | 6 +++ mobile-app/lib/l10n/app_localizations_en.dart | 4 ++ mobile-app/lib/l10n/app_localizations_id.dart | 4 ++ .../v2/screens/send/keystone_sign_cache.dart | 46 +++++++++++++++---- .../v2/screens/send/keystone_sign_screen.dart | 37 +++++++++++++++ .../send/keystone_signature_scan_screen.dart | 17 ++++++- .../test/unit/keystone_sign_cache_test.dart | 14 +++++- quantus_sdk/lib/quantus_sdk.dart | 1 + .../lib/src/services/substrate_service.dart | 10 ++++ quantus_sdk/lib/src/utils/ur_qr.dart | 28 +++++++++++ quantus_sdk/pubspec.lock | 16 +++++++ quantus_sdk/pubspec.yaml | 1 + quantus_sdk/test/ur_qr_frame_test.dart | 35 ++++++++++++++ 19 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 quantus_sdk/lib/src/utils/ur_qr.dart create mode 100644 quantus_sdk/test/ur_qr_frame_test.dart diff --git a/cold-wallet-app/lib/providers/settings_providers.dart b/cold-wallet-app/lib/providers/settings_providers.dart index 3227d7b45..57450ba49 100644 --- a/cold-wallet-app/lib/providers/settings_providers.dart +++ b/cold-wallet-app/lib/providers/settings_providers.dart @@ -8,7 +8,11 @@ class ColdSettings { static const maxQrFps = 50; static const defaultQrFps = 15; static const minQrBytes = 300; - static const maxQrBytes = 1500; + + // 1400 payload bytes encode to ~2870 UR chars, under the 2953-char capacity + // of a version-40 byte-mode QR at error correction L; encodeUrForQr measures + // the final strings and shrinks fragments if they would still overflow. + static const maxQrBytes = 1400; static const defaultQrBytes = 1100; final bool wifiOverrideEnabled; diff --git a/cold-wallet-app/lib/screens/settings_screen.dart b/cold-wallet-app/lib/screens/settings_screen.dart index 8d0cb752e..371060775 100644 --- a/cold-wallet-app/lib/screens/settings_screen.dart +++ b/cold-wallet-app/lib/screens/settings_screen.dart @@ -39,17 +39,28 @@ class SettingsScreen extends ConsumerWidget { const SizedBox(height: 8), Text('SECURITY', style: text.transactionDetailRowLabel?.copyWith(color: colors.textLabel)), const SizedBox(height: 8), + // INTENTIONAL: this override ships in RELEASE builds. The cold wallet is + // not yet distributed through app stores, and testing release builds on + // real devices without it means toggling Wi-Fi/airplane mode for every + // flow. The lock stays fail-closed by default: the Override button only + // exists behind this persistent opt-in, an override lasts one session, + // and a red banner stays on screen while it is active. Do not "fix" this + // by compiling it out of release builds; revisit only when the app ships + // to app-store users. Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Wi-Fi Lock Override', style: text.smallParagraph?.copyWith(color: colors.textPrimary)), + Text( + 'Wi-Fi Lock Override (debugging only)', + style: text.smallParagraph?.copyWith(color: colors.textPrimary), + ), const SizedBox(height: 4), Text( - 'Shows an Override button on the network lock screen so it can be dismissed for testing. ' - 'Never use this with a wallet that holds real funds.', + 'For debugging only — shows an Override button on the network lock screen so it can be ' + 'dismissed while testing. Not safe: never use this with a wallet that holds real funds.', style: text.detail?.copyWith(color: colors.textSecondary), ), ], diff --git a/cold-wallet-app/lib/screens/sign_transaction_screen.dart b/cold-wallet-app/lib/screens/sign_transaction_screen.dart index 1209a3c2e..dfc594f0d 100644 --- a/cold-wallet-app/lib/screens/sign_transaction_screen.dart +++ b/cold-wallet-app/lib/screens/sign_transaction_screen.dart @@ -417,7 +417,7 @@ class _SignTransactionScreenState extends ConsumerState { final settings = ref.watch(coldSettingsProvider); if (_urParts == null || _urPartsBytes != settings.qrBytes) { - _urParts = encodeUr(data: signed, maxFragmentLength: settings.qrBytes); + _urParts = encodeUrForQr(data: signed, maxFragmentLength: settings.qrBytes); _urPartsBytes = settings.qrBytes; } final parts = _urParts!; diff --git a/cold-wallet-app/lib/widgets/connectivity_guard.dart b/cold-wallet-app/lib/widgets/connectivity_guard.dart index 107bbbd02..cabb4b70c 100644 --- a/cold-wallet-app/lib/widgets/connectivity_guard.dart +++ b/cold-wallet-app/lib/widgets/connectivity_guard.dart @@ -18,6 +18,11 @@ import 'package:quantus_cold_wallet/theme/app_text_styles.dart'; /// When the Wi-Fi Lock Override setting is enabled, an Override button lets the /// lock be dismissed for the session after a warning; while dismissed a banner /// stays on screen so an online signer is never mistaken for an offline one. +/// +/// The override is INTENTIONALLY available in release builds: the cold wallet +/// is not yet distributed through app stores, and release builds are tested on +/// real devices where re-toggling radios for every flow costs hours. Do not +/// flag or compile it out — see the note on the Settings screen toggle. class ConnectivityGuard extends ConsumerWidget { const ConnectivityGuard({super.key}); diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 088fd9556..2ad8b356a 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -1573,6 +1573,10 @@ "@keystoneScanError": { "description": "Error when decoding or submitting the scanned signature fails" }, + "keystoneScanExpired": "The transaction expired before it could be submitted. Go back and scan the new QR code with your device.", + "@keystoneScanExpired": { + "description": "Error when the mortal era window closed before the signed transaction was submitted" + }, "keystoneRejectTitle": "Don't approve this transaction", "@keystoneRejectTitle": { "description": "Headline on the Keystone mismatch help screen" diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index a340f5e8a..527a3c76f 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -326,6 +326,7 @@ "keystoneScanScanning": "{count} bingkai dipindai", "keystoneScanSubmitting": "Mengirim transaksi...", "keystoneScanError": "Tidak dapat membaca tanda tangan. Silakan coba lagi.", + "keystoneScanExpired": "Transaksi kedaluwarsa sebelum sempat dikirim. Kembali dan pindai kode QR baru dengan perangkat Anda.", "sendLogicCantSelfTransfer": "Tidak Bisa Transfer ke Diri Sendiri", "sendLogicEnterAmount": "Masukkan Jumlah", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 16aa9cf8c..0213cdcc9 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -2078,6 +2078,12 @@ abstract class AppLocalizations { /// **'Couldn\'t read the signature. Please try again.'** String get keystoneScanError; + /// Error when the mortal era window closed before the signed transaction was submitted + /// + /// In en, this message translates to: + /// **'The transaction expired before it could be submitted. Go back and scan the new QR code with your device.'** + String get keystoneScanExpired; + /// Headline on the Keystone mismatch help screen /// /// In en, this message translates to: diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 57eaef551..cb9202dd1 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -1119,6 +1119,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get keystoneScanError => 'Couldn\'t read the signature. Please try again.'; + @override + String get keystoneScanExpired => + 'The transaction expired before it could be submitted. Go back and scan the new QR code with your device.'; + @override String get keystoneRejectTitle => 'Don\'t approve this transaction'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index 0d0579b2f..d0d5c3d49 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -1116,6 +1116,10 @@ class AppLocalizationsId extends AppLocalizations { @override String get keystoneScanError => 'Tidak dapat membaca tanda tangan. Silakan coba lagi.'; + @override + String get keystoneScanExpired => + 'Transaksi kedaluwarsa sebelum sempat dikirim. Kembali dan pindai kode QR baru dengan perangkat Anda.'; + @override String get keystoneRejectTitle => 'Don\'t approve this transaction'; diff --git a/mobile-app/lib/v2/screens/send/keystone_sign_cache.dart b/mobile-app/lib/v2/screens/send/keystone_sign_cache.dart index 15cc9d82e..b3a07cf07 100644 --- a/mobile-app/lib/v2/screens/send/keystone_sign_cache.dart +++ b/mobile-app/lib/v2/screens/send/keystone_sign_cache.dart @@ -3,17 +3,40 @@ import 'package:flutter_riverpod/legacy.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; -/// Blocks of safety margin before mortal era expiry when treating cache as stale. -const int keystoneSignCacheEraSafetyMarginBlocks = 2; +/// Blocks of era lifetime that must still remain when a payload is served or +/// kept on screen. After the QR is displayed the user still has to scan it +/// with the device, verify, approve, and scan the signature back, so a payload +/// anywhere near expiry must never enter that round trip. +const int keystoneSignEraReserveBlocks = 10; -/// Max age for a cached Keystone payload derived from its mortal era period. +/// Blocks left before era expiry below which a signed payload is refused at +/// submission: it would likely expire while propagating. +const int keystoneSignSubmitEraMarginBlocks = 2; + +/// Thrown when a signed payload reaches submission too close to era expiry. +class KeystoneEraExpiredException implements Exception { + final int currentBlock; + final int expiryBlock; + + const KeystoneEraExpiredException({required this.currentBlock, required this.expiryBlock}); + + @override + String toString() => 'Keystone payload era expired: block $currentBlock, era ends at block $expiryBlock'; +} + +/// Max age for a Keystone payload derived from its mortal era period: expired +/// once less than [keystoneSignEraReserveBlocks] of era lifetime remain. Duration keystoneSignCacheMaxAge(QuantusSigningPayload payload) { if (payload.eraPeriod == 0) { return const Duration(days: 1); } - final eraSeconds = payload.eraPeriod * AppConstants.avgBlockTimeSeconds; - final safetySeconds = keystoneSignCacheEraSafetyMarginBlocks * AppConstants.avgBlockTimeSeconds; - return Duration(seconds: eraSeconds - safetySeconds); + final usableBlocks = payload.eraPeriod - keystoneSignEraReserveBlocks; + if (usableBlocks <= 0) { + throw StateError( + 'Era period ${payload.eraPeriod} is not longer than the $keystoneSignEraReserveBlocks-block reserve', + ); + } + return Duration(seconds: usableBlocks * AppConstants.avgBlockTimeSeconds); } /// Returns true when [entry] is older than the mortal era validity window. @@ -126,7 +149,7 @@ final keystoneSignCacheProvider = StateNotifierProvider urParts})> ensureKeystoneSignPayload( +Future<({UnsignedTransactionData unsignedData, List urParts, DateTime storedAt})> ensureKeystoneSignPayload( WidgetRef ref, { required Account account, required RuntimeCall Function() buildCall, @@ -135,11 +158,14 @@ Future<({UnsignedTransactionData unsignedData, List urParts})> ensureKey final cache = ref.read(keystoneSignCacheProvider.notifier); if (cacheKey != null) { final cached = cache.lookup(cacheKey); - if (cached != null) return (unsignedData: cached.unsignedData, urParts: cached.urParts); + if (cached != null) { + return (unsignedData: cached.unsignedData, urParts: cached.urParts, storedAt: cached.storedAt); + } } final unsigned = await ref.read(substrateServiceProvider).getUnsignedTransactionPayload(account, buildCall()); final parts = encodeUr(data: unsigned.encodedPayloadRaw); if (parts.isEmpty) throw Exception('Failed to encode transaction payload as UR'); - if (cacheKey != null) cache.store(key: cacheKey, unsignedData: unsigned, urParts: parts); - return (unsignedData: unsigned, urParts: parts); + final storedAt = DateTime.now(); + if (cacheKey != null) cache.store(key: cacheKey, unsignedData: unsigned, urParts: parts, storedAt: storedAt); + return (unsignedData: unsigned, urParts: parts, storedAt: storedAt); } diff --git a/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart b/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart index 0445da291..160073836 100644 --- a/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart +++ b/mobile-app/lib/v2/screens/send/keystone_sign_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; @@ -36,15 +38,40 @@ class KeystoneSignScreen extends ConsumerStatefulWidget { class _KeystoneSignScreenState extends ConsumerState { UnsignedTransactionData? _unsignedData; List? _urParts; + DateTime? _payloadStoredAt; + Timer? _freshnessTimer; + bool _preparing = false; String? _error; @override void initState() { super.initState(); _prepare(); + _freshnessTimer = Timer.periodic(const Duration(seconds: 5), (_) => _refreshIfStale()); + } + + @override + void dispose() { + _freshnessTimer?.cancel(); + super.dispose(); + } + + /// True once the payload's era reserve is consumed: the remaining lifetime + /// no longer covers a full device scan/verify/sign/submit round trip. + bool get _payloadStale { + final unsignedData = _unsignedData; + final storedAt = _payloadStoredAt; + if (unsignedData == null || storedAt == null) return false; + return DateTime.now().difference(storedAt) >= keystoneSignCacheMaxAge(unsignedData.payloadToSign); + } + + void _refreshIfStale() { + if (_payloadStale) _prepare(); } Future _prepare() async { + if (_preparing) return; + _preparing = true; try { final payload = await ensureKeystoneSignPayload( ref, @@ -57,18 +84,28 @@ class _KeystoneSignScreenState extends ConsumerState { setState(() { _unsignedData = payload.unsignedData; _urParts = payload.urParts; + _payloadStoredAt = payload.storedAt; }); } catch (error) { quantusPrint('Keystone payload preparation failed: $error'); TelemetryService().sendError('Keystone payload preparation failed', error: error); if (!mounted) return; setState(() => _error = ref.read(l10nProvider).keystoneSignError); + } finally { + _preparing = false; } } Future _goToVerify() async { final unsignedData = _unsignedData; if (unsignedData == null) return; + // Never carry a nearly expired payload into the verify/sign/submit steps — + // rebuild the QR instead, so the device has to rescan a fresh payload. + if (_payloadStale) { + quantusPrint('Keystone payload stale on advance, regenerating'); + _prepare(); + return; + } final result = await Navigator.push( context, MaterialPageRoute( diff --git a/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart b/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart index d5f674ecd..1768610e6 100644 --- a/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart +++ b/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart @@ -33,6 +33,18 @@ class _KeystoneSignatureScanScreenState extends ConsumerState _ensureEraNotExpired() async { + final payload = widget.unsignedData.payloadToSign; + if (payload.eraPeriod == 0) return; + final currentBlock = await SubstrateService().getCurrentBlockNumber(); + final expiryBlock = payload.blockNumber + payload.eraPeriod; + if (currentBlock >= expiryBlock - keystoneSignSubmitEraMarginBlocks) { + throw KeystoneEraExpiredException(currentBlock: currentBlock, expiryBlock: expiryBlock); + } + } + Future _submit(List parts) async { final bytes = decodeUr(urParts: parts); final signatureSize = signatureBytes().toInt(); @@ -41,6 +53,8 @@ class _KeystoneSignatureScanScreenState extends ConsumerState _error = ref.read(l10nProvider).keystoneScanError); + final l10n = ref.read(l10nProvider); + setState(() => _error = error is KeystoneEraExpiredException ? l10n.keystoneScanExpired : l10n.keystoneScanError); } Future> _simulateSignature() async { diff --git a/mobile-app/test/unit/keystone_sign_cache_test.dart b/mobile-app/test/unit/keystone_sign_cache_test.dart index 25db13aad..5e46509b7 100644 --- a/mobile-app/test/unit/keystone_sign_cache_test.dart +++ b/mobile-app/test/unit/keystone_sign_cache_test.dart @@ -4,7 +4,7 @@ import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dar import '../fakes.dart'; -/// Mortal era validity for Keystone payloads (eraPeriod 64, ~12s blocks, 2-block margin). +/// Mortal era validity for Keystone payloads: the era minus the round-trip reserve. Duration _mortalEraMaxCacheAge(QuantusSigningPayload payload) => keystoneSignCacheMaxAge(payload); void main() { @@ -104,6 +104,18 @@ void main() { }); }); + group('keystoneSignCacheMaxAge', () { + test('leaves the full round-trip reserve of era lifetime unused', () { + final payload = makeUnsignedTransactionData().payloadToSign; + final expectedSeconds = (payload.eraPeriod - keystoneSignEraReserveBlocks) * AppConstants.avgBlockTimeSeconds; + expect(keystoneSignCacheMaxAge(payload), Duration(seconds: expectedSeconds)); + }); + + test('the live era period keeps a positive usable window', () { + expect(AppConstants.txMortalEraPeriodBlocks, greaterThan(keystoneSignEraReserveBlocks)); + }); + }); + group('stale mortal era payload', () { late KeystoneSignCacheNotifier notifier; diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 2e7fa322d..c11614bb1 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -46,6 +46,7 @@ export 'src/models/raid_stats.dart'; // should probably expise all of crypto.dart through substrateservice instead export 'src/rust/api/crypto.dart' hide crystalAlice, crystalCharlie, crystalBob; export 'src/rust/api/ur.dart'; +export 'src/utils/ur_qr.dart'; export 'src/rust/api/wormhole.dart'; export 'src/services/account_discovery_service.dart'; export 'src/services/accounts_service.dart'; diff --git a/quantus_sdk/lib/src/services/substrate_service.dart b/quantus_sdk/lib/src/services/substrate_service.dart index 8f70e0c25..f658e963f 100644 --- a/quantus_sdk/lib/src/services/substrate_service.dart +++ b/quantus_sdk/lib/src/services/substrate_service.dart @@ -35,6 +35,12 @@ class SubstrateService { DateTime? _runtimeVersionFetchedAt; static const _runtimeVersionMaxAge = Duration(minutes: 5); + void _clearChainCaches() { + _cachedGenesisHash = null; + _cachedRuntimeVersion = null; + _runtimeVersionFetchedAt = null; + } + /// Runtime version only changes on runtime upgrades, so it is cached briefly. /// Send flows prefetch it on entry so payload builds hit the cache. Future getRuntimeVersion() async { @@ -136,6 +142,10 @@ class SubstrateService { quantusPrint('submitExtrinsic response: ${response.result}'); if (response.error != null) { + // A rejected extrinsic can mean a runtime upgrade landed while the cached + // spec/genesis was still considered fresh — drop the caches so the next + // payload is built against re-fetched chain state. + _clearChainCaches(); throw Exception(response.error.toString()); } diff --git a/quantus_sdk/lib/src/utils/ur_qr.dart b/quantus_sdk/lib/src/utils/ur_qr.dart new file mode 100644 index 000000000..91c296404 --- /dev/null +++ b/quantus_sdk/lib/src/utils/ur_qr.dart @@ -0,0 +1,28 @@ +import 'package:quantus_sdk/src/rust/api/ur.dart'; + +/// Character capacity of a version-40 QR code in byte mode at error +/// correction L — the hard ceiling for a single UR frame string. +const int maxUrQrFrameChars = 2953; + +/// Encodes [data] as UR parts that each fit in a single QR code. +/// +/// [maxFragmentLength] bounds the payload bytes per frame, but the QR limit is +/// on the encoded string: bytewords doubles every byte and the UR header adds +/// more, so an in-range byte setting can still yield an oversized frame. The +/// encoded parts are measured and the fragment size lowered until every frame +/// fits [maxUrQrFrameChars]. +List encodeUrForQr({required List data, required int maxFragmentLength}) { + var fragmentLength = maxFragmentLength; + while (true) { + final parts = encodeUr(data: data, maxFragmentLength: fragmentLength); + var longest = 0; + for (final part in parts) { + if (part.length > longest) longest = part.length; + } + if (longest <= maxUrQrFrameChars) return parts; + final actualFragment = (data.length / parts.length).ceil(); + final next = actualFragment - ((longest - maxUrQrFrameChars) / 2).ceil(); + if (next < 1) throw StateError('UR frame of $longest chars cannot fit a QR code'); + fragmentLength = next < fragmentLength ? next : fragmentLength - 1; + } +} diff --git a/quantus_sdk/pubspec.lock b/quantus_sdk/pubspec.lock index cb6da31d4..9ad456d4e 100644 --- a/quantus_sdk/pubspec.lock +++ b/quantus_sdk/pubspec.lock @@ -688,6 +688,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct dev" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" quiver: dependency: transitive description: diff --git a/quantus_sdk/pubspec.yaml b/quantus_sdk/pubspec.yaml index 175722fb6..002de93c2 100644 --- a/quantus_sdk/pubspec.yaml +++ b/quantus_sdk/pubspec.yaml @@ -49,6 +49,7 @@ dev_dependencies: integration_test: sdk: flutter flutter_lints: ^6.0.0 + qr_flutter: ^4.1.0 # DO NOT UPDATE polkadart_cli - must match polkadart 0.7.x. See note above. polkadart_cli: ^0.7.1 diff --git a/quantus_sdk/test/ur_qr_frame_test.dart b/quantus_sdk/test/ur_qr_frame_test.dart new file mode 100644 index 000000000..10f60c0a1 --- /dev/null +++ b/quantus_sdk/test/ur_qr_frame_test.dart @@ -0,0 +1,35 @@ +@Tags(['native']) +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_sdk/src/rust/frb_generated.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await RustLib.init(); + }); + + // Regression: the ML-DSA signature-plus-public-key payload (7,219 bytes) at + // large fragment settings used to produce UR frames longer than a version-40 + // QR can hold, throwing QrInputTooLongException after signing. + test('signature payload fits QR frames at every fragment setting', () { + final payloadSize = (signatureBytes() + publicKeyBytes()).toInt(); + expect(payloadSize, 7219); + final data = List.generate(payloadSize, (i) => i % 256); + + for (var fragment = 300; fragment <= 1500; fragment += 25) { + final parts = encodeUrForQr(data: data, maxFragmentLength: fragment); + var longest = ''; + for (final part in parts) { + expect(part.length, lessThanOrEqualTo(maxUrQrFrameChars), reason: 'fragment setting $fragment'); + if (part.length > longest.length) longest = part; + } + QrCode.fromData(data: longest, errorCorrectLevel: QrErrorCorrectLevel.L); + expect(decodeUr(urParts: parts), equals(data), reason: 'fragment setting $fragment'); + } + }); +}