diff --git a/cold-wallet-app/lib/components/animated_ur_qr.dart b/cold-wallet-app/lib/components/animated_ur_qr.dart index 098874324..d3fc00fb7 100644 --- a/cold-wallet-app/lib/components/animated_ur_qr.dart +++ b/cold-wallet-app/lib/components/animated_ur_qr.dart @@ -4,18 +4,19 @@ 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. +/// +/// 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 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(); @@ -24,15 +25,46 @@ class AnimatedUrQr extends StatefulWidget { class _AnimatedUrQrState extends State { Timer? _timer; int _index = 0; + late List _painters; @override void initState() { super.initState(); - if (widget.parts.length > 1) { - _timer = Timer.periodic(widget.interval, (_) { - setState(() => _index = (_index + 1) % widget.parts.length); - }); + _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); + final partsChanged = !identical(widget.parts, oldWidget.parts); + if (partsChanged) { + _painters = _buildPainters(); + _index = 0; } + if (partsChanged || widget.fps != oldWidget.fps || widget.paused != oldWidget.paused) { + _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 @@ -48,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/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..57450ba49 --- /dev/null +++ b/cold-wallet-app/lib/providers/settings_providers.dart @@ -0,0 +1,85 @@ +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; + + // 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; + 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/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/cold-wallet-app/lib/screens/settings_screen.dart b/cold-wallet-app/lib/screens/settings_screen.dart new file mode 100644 index 000000000..371060775 --- /dev/null +++ b/cold-wallet-app/lib/screens/settings_screen.dart @@ -0,0 +1,120 @@ +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), + // 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 (debugging only)', + style: text.smallParagraph?.copyWith(color: colors.textPrimary), + ), + const SizedBox(height: 4), + Text( + '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), + ), + ], + ), + ), + 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..dfc594f0d 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 = encodeUrForQr(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..cabb4b70c 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,30 @@ 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. +/// +/// 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}); + 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 +46,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 +82,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 +98,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/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'; 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/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 = {}; 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..b3a07cf07 100644 --- a/mobile-app/lib/v2/screens/send/keystone_sign_cache.dart +++ b/mobile-app/lib/v2/screens/send/keystone_sign_cache.dart @@ -1,17 +1,42 @@ +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; +/// 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. @@ -119,3 +144,28 @@ 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, DateTime storedAt})> 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, 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'); + 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 065055797..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,9 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; 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'; @@ -37,56 +38,74 @@ 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()); } - 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; - } - } + @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 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; + _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/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/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" 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/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/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/api/wormhole.dart b/quantus_sdk/lib/src/rust/api/wormhole.dart index 9be7d79bd..b94bb0597 100644 --- a/quantus_sdk/lib/src/rust/api/wormhole.dart +++ b/quantus_sdk/lib/src/rust/api/wormhole.dart @@ -6,7 +6,9 @@ import '../frb_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These functions are ignored because they are not marked as `pub`: `all_required_files_exist`, `vec_to_32`, `vec_to_digest` +// These functions are ignored because they are not marked as `pub`: `all_required_files_exist`, `cleanup_stale_circuit_dirs`, `vec_to_32`, `vec_to_digest`, `versioned_bins_dir` + +String zkCircuitsVersion() => RustLib.instance.api.crateApiWormholeZkCircuitsVersion(); String computeAddressHashHex({required List rawAddress}) => RustLib.instance.api.crateApiWormholeComputeAddressHashHex(rawAddress: rawAddress); diff --git a/quantus_sdk/lib/src/rust/frb_generated.dart b/quantus_sdk/lib/src/rust/frb_generated.dart index 2707e1b8c..408a5acfb 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.dart @@ -65,7 +65,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -22852253; + int get rustContentHash => 300623511; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( stem: 'rust_lib_quantus_wallet', @@ -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}); @@ -162,6 +162,8 @@ abstract class RustLibApi extends BaseApi { int crateApiWormholeWormholeComputeOutputAmount({required int inputAmount, required int feeBps}); + String crateApiWormholeZkCircuitsVersion(); + RustArcIncrementStrongCountFnType get rust_arc_increment_strong_count_HdLatticeError; RustArcDecrementStrongCountFnType get rust_arc_decrement_strong_count_HdLatticeError; @@ -451,23 +453,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}) { @@ -880,6 +884,25 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiWormholeWormholeComputeOutputAmountConstMeta => const TaskConstMeta(debugName: 'wormhole_compute_output_amount', argNames: ['inputAmount', 'feeBps']); + @override + String crateApiWormholeZkCircuitsVersion() { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_String, decodeErrorData: null), + constMeta: kCrateApiWormholeZkCircuitsVersionConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiWormholeZkCircuitsVersionConstMeta => + const TaskConstMeta(debugName: 'zk_circuits_version', argNames: []); + RustArcIncrementStrongCountFnType get rust_arc_increment_strong_count_HdLatticeError => wire.rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerHDLatticeError; @@ -924,6 +947,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 +999,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 +1145,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 +1205,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 +1379,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 +1431,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/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..f658e963f 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,38 @@ class SubstrateService { final RpcEndpointService _rpcEndpointService = RpcEndpointService(); final SettingsService _settingsService = SettingsService(); + String? _cachedGenesisHash; + RuntimeVersion? _cachedRuntimeVersion; + 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 { + 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 +93,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,14 +136,16 @@ 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) { + // 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()); } @@ -210,39 +231,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 +288,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 +305,7 @@ class SubstrateService { signer: signerBytes, method: encodedCall, signature: dummySignature, - eraPeriod: 64, + eraPeriod: AppConstants.txMortalEraPeriodBlocks, blockNumber: blockNumber, nonce: nonce, tip: 0, @@ -288,40 +317,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 +358,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 +389,7 @@ class SubstrateService { Provider? get provider { try { - return Provider.fromUri(Uri.parse(_rpcEndpointService.bestEndpointUrl)); + return _rpcEndpointService.providerFor(_rpcEndpointService.bestEndpointUrl); } catch (e) { return null; } 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/rust/src/api/ur.rs b/quantus_sdk/rust/src/api/ur.rs index b63cd8525..6a26ef06b 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), @@ -68,7 +74,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), @@ -80,7 +86,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"); @@ -91,11 +97,26 @@ mod tests { ); } + #[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..8838a4e3f 100644 --- a/quantus_sdk/rust/src/frb_generated.rs +++ b/quantus_sdk/rust/src/frb_generated.rs @@ -39,7 +39,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -22852253; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 300623511; // Section: executor @@ -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) })()) @@ -1094,6 +1095,35 @@ fn wire__crate__api__wormhole__wormhole_compute_output_amount_impl( }, ) } +fn wire__crate__api__wormhole__zk_circuits_version_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "zk_circuits_version", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::wormhole::zk_circuits_version())?; + Ok(output_ok) + })()) + }, + ) +} // Section: related_funcs @@ -1198,6 +1228,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 { @@ -1405,6 +1446,7 @@ fn pde_ffi_dispatcher_sync_impl( rust_vec_len, data_len, ), + 34 => wire__crate__api__wormhole__zk_circuits_version_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -1625,6 +1667,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) { 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'); + } + }); +}