diff --git a/.gitignore b/.gitignore index 022f26f2..0c7f51fb 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,5 @@ app.*.symbols /mobile-app/android/.kotlin /mobile-app/android/.idea mobile-app/.env +/rust-transaction-parser/target +/.cursor diff --git a/mobile-app/lib/features/components/button.dart b/mobile-app/lib/features/components/button.dart index cf666842..0a6901a4 100644 --- a/mobile-app/lib/features/components/button.dart +++ b/mobile-app/lib/features/components/button.dart @@ -4,7 +4,7 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; -enum ButtonVariant { transparent, neutral, primary, success, danger, glass, glassOutline } +enum ButtonVariant { transparent, neutral, primary, success, danger, glass, glassOutline, dangerOutline } class Button extends StatelessWidget { final String label; @@ -140,6 +140,23 @@ class Button extends StatelessWidget { ); break; + case ButtonVariant.dangerOutline: + buttonWidget = Container( + width: width, + padding: padding, + decoration: ShapeDecoration( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(buttonRadius), + side: BorderSide(color: disabled ? disabledBtnColor : context.themeColors.buttonDanger, width: 1), + ), + ), + child: Center( + child: Text(label, style: effectiveTextStyle.copyWith(color: context.themeColors.buttonDanger)), + ), + ); + break; + case ButtonVariant.success: buttonWidget = Container( width: width, diff --git a/mobile-app/lib/features/components/select_action_sheet.dart b/mobile-app/lib/features/components/select_action_sheet.dart index ad3efc66..c7ae9d83 100644 --- a/mobile-app/lib/features/components/select_action_sheet.dart +++ b/mobile-app/lib/features/components/select_action_sheet.dart @@ -28,8 +28,8 @@ class _SelectActionSheetState extends State> { children: widget.items.map((item) { return InkWell( onTap: () { - widget.onSelect(item); Navigator.pop(context); + Future.microtask(() => widget.onSelect(item)); }, child: Container( padding: const EdgeInsets.symmetric(vertical: 16), diff --git a/mobile-app/lib/features/main/screens/account_settings_screen.dart b/mobile-app/lib/features/main/screens/account_settings_screen.dart index b9e1ca53..e9db52c0 100644 --- a/mobile-app/lib/features/main/screens/account_settings_screen.dart +++ b/mobile-app/lib/features/main/screens/account_settings_screen.dart @@ -1,11 +1,18 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/providers/account_associations_providers.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/account_gradient_image.dart'; +import 'package:resonance_network_wallet/features/components/app_modal_bottom_sheet.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; import 'package:resonance_network_wallet/features/components/copy_icon.dart'; import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; import 'package:resonance_network_wallet/features/components/sphere.dart'; import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; import 'package:resonance_network_wallet/features/main/screens/create_account_screen.dart'; @@ -15,7 +22,7 @@ import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; -class AccountSettingsScreen extends StatefulWidget { +class AccountSettingsScreen extends ConsumerStatefulWidget { final Account account; final String balance; final String checksumName; @@ -23,10 +30,10 @@ class AccountSettingsScreen extends StatefulWidget { const AccountSettingsScreen({super.key, required this.account, required this.balance, required this.checksumName}); @override - State createState() => _AccountSettingsScreenState(); + ConsumerState createState() => _AccountSettingsScreenState(); } -class _AccountSettingsScreenState extends State { +class _AccountSettingsScreenState extends ConsumerState { void _editAccountName() { Navigator.push( context, @@ -39,6 +46,99 @@ class _AccountSettingsScreenState extends State { }); } + Widget _buildDisconnectWalletButton() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Button( + label: 'Disconnect Wallet', + onPressed: _showDisconnectConfirmation, + variant: ButtonVariant.dangerOutline, + ), + ); + } + + void _showDisconnectConfirmation() { + showAppModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 35, vertical: 16), + decoration: ShapeDecoration( + color: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.topRight, + child: IconButton( + icon: Icon(Icons.close, size: context.themeSize.overlayCloseIconSize), + onPressed: () => Navigator.of(context).pop(), + ), + ), + const SizedBox(height: 10), + Text('Disconnect Wallet?', style: context.themeText.mediumTitle), + const SizedBox(height: 13), + Text( + 'This will remove this account from your wallet. If this is the last account for this hardware wallet, the wallet connection will be removed.', + style: context.themeText.smallParagraph, + ), + const SizedBox(height: 28), + Button( + variant: ButtonVariant.danger, + label: 'Disconnect', + onPressed: () async { + Navigator.of(context).pop(); + await _disconnectWallet(); + }, + ), + const SizedBox(height: 16), + Center( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Cancel', + style: context.themeText.smallParagraph?.copyWith(decoration: TextDecoration.underline), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + Future _disconnectWallet() async { + try { + final accountsService = AccountsService(); + await accountsService.removeAccount(widget.account); + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + ref.invalidate(accountAssociationsProvider); + ref.invalidate(balanceProviderFamily(widget.account.accountId)); + + if (mounted) { + Navigator.of(context).pop(true); // Return true to indicate change + } + } catch (e) { + print('Failed to disconnect: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to disconnect: $e'))); + } + } + } + @override Widget build(BuildContext context) { return ScaffoldBase( @@ -67,6 +167,9 @@ class _AccountSettingsScreenState extends State { _buildAddressSection(), const SizedBox(height: 20), _buildSecuritySection(), + const SizedBox(height: 20), + if (widget.account.accountType == AccountType.keystone) _buildDisconnectWalletButton(), + const SizedBox(height: 30), ], ), ], @@ -146,23 +249,26 @@ class _AccountSettingsScreenState extends State { return _buildSettingCard( child: Padding( padding: const EdgeInsets.only(top: 10.0, left: 10.0, bottom: 10.0, right: 18.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox( - width: context.isTablet ? 550 : 251, - child: Text( - context.isTablet - ? widget.account.accountId - : AddressFormattingService.splitIntoChunks(widget.account.accountId).join(' '), - style: context.themeText.smallParagraph, + child: InkWell( + onTap: () => ClipboardExtensions.copyTextWithSnackbar(context, widget.account.accountId), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: context.isTablet ? 550 : 251, + child: Text( + context.isTablet + ? widget.account.accountId + : AddressFormattingService.splitIntoChunks(widget.account.accountId).join(' '), + style: context.themeText.smallParagraph, + ), ), - ), - InkWell( - child: const CopyIcon(), - onTap: () => ClipboardExtensions.copyTextWithSnackbar(context, widget.account.accountId), - ), - ], + InkWell( + child: const CopyIcon(), + onTap: () => ClipboardExtensions.copyTextWithSnackbar(context, widget.account.accountId), + ), + ], + ), ), ), ); diff --git a/mobile-app/lib/features/main/screens/accounts_screen.dart b/mobile-app/lib/features/main/screens/accounts_screen.dart index cbcdd3fe..73fef1de 100644 --- a/mobile-app/lib/features/main/screens/accounts_screen.dart +++ b/mobile-app/lib/features/main/screens/accounts_screen.dart @@ -5,17 +5,25 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/account_gradient_image.dart'; import 'package:resonance_network_wallet/features/components/button.dart'; import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/components/select.dart'; +import 'package:resonance_network_wallet/features/components/select_action_sheet.dart'; import 'package:resonance_network_wallet/features/components/sphere.dart'; import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; import 'package:resonance_network_wallet/features/main/screens/account_settings_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/add_hardware_account_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/create_account_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/create_wallet_and_backup_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/import_wallet_screen.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/services/feature_flags.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; +enum _WalletMoreAction { createWallet, importWallet, addHardwareWallet } + class AccountsScreen extends ConsumerStatefulWidget { const AccountsScreen({super.key}); @@ -28,13 +36,55 @@ class _AccountsScreenState extends ConsumerState { final NumberFormattingService _formattingService = NumberFormattingService(); bool _isCreatingAccount = false; + int? _selectedWalletIndex; + + bool _isHardwareWallet(List accounts) { + return accounts.isNotEmpty && accounts.every((a) => a.accountType == AccountType.keystone); + } + + int _nextWalletIndex(List accounts) { + if (accounts.isEmpty) return 0; + final maxIndex = accounts.map((a) => a.walletIndex).reduce((a, b) => a > b ? a : b); + return maxIndex + 1; + } + + Map> _groupByWallet(List accounts) { + final grouped = >{}; + for (final a in accounts) { + grouped.putIfAbsent(a.walletIndex, () => []).add(a); + } + for (final entry in grouped.entries) { + entry.value.sort((a, b) => a.index.compareTo(b.index)); + } + return Map.fromEntries(grouped.entries.toList()..sort((a, b) => a.key.compareTo(b.key))); + } + + String _walletLabel(int walletIndex, List accounts) { + if (_isHardwareWallet(accounts)) return 'Hardware Wallet'; + return 'Wallet ${walletIndex + 1}'; + } Future _createNewAccount() async { setState(() { _isCreatingAccount = true; }); try { - await Navigator.push(context, MaterialPageRoute(builder: (context) => const CreateAccountScreen())); + final accounts = ref.read(accountsProvider).value ?? []; + int selectedWallet = getSelectedWalletIndex(accounts); + final grouped = _groupByWallet(accounts); + final selectedWalletAccounts = grouped[selectedWallet] ?? const []; + + if (_isHardwareWallet(selectedWalletAccounts)) { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => AddHardwareAccountScreen(walletIndex: selectedWallet)), + ); + } else { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => CreateAccountScreen(walletIndex: selectedWallet)), + ); + } // Providers will automatically refresh when a new account is added } finally { if (mounted) { @@ -45,6 +95,52 @@ class _AccountsScreenState extends ConsumerState { } } + int getSelectedWalletIndex(List accounts) { + final selectedWallet = _selectedWalletIndex ?? (accounts.isNotEmpty ? accounts.first.walletIndex : 0); + return selectedWallet; + } + + Future _openWalletMoreActions() async { + final accounts = ref.read(accountsProvider).value ?? []; + final nextWalletIndex = _nextWalletIndex(accounts); + + final items = [ + Item(value: _WalletMoreAction.createWallet, label: 'Create new wallet'), + Item(value: _WalletMoreAction.importWallet, label: 'Import wallet'), + ]; + + if (FeatureFlags.isFeatureEnabled('keystone_hardware_wallet')) { + items.add(Item(value: _WalletMoreAction.addHardwareWallet, label: 'Add hardware wallet')); + } + + showSelectActionSheet<_WalletMoreAction>(context, items, (item) async { + final result = await (switch (item.value) { + _WalletMoreAction.createWallet => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CreateWalletAndBackupScreen(walletIndex: nextWalletIndex, popOnComplete: true), + ), + ), + _WalletMoreAction.importWallet => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ImportWalletScreen(walletIndex: nextWalletIndex, popOnComplete: true), + ), + ), + _WalletMoreAction.addHardwareWallet => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AddHardwareAccountScreen(walletIndex: nextWalletIndex, isNewWallet: true), + ), + ), + }); + if (result == true && mounted) { + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + } + }); + } + @override Widget build(BuildContext context) { return ScaffoldBase( @@ -56,14 +152,18 @@ class _AccountsScreenState extends ConsumerState { ), const Positioned(left: -40, bottom: 0, child: Sphere(variant: 7, size: 240.681)), ], - appBar: WalletAppBar(title: 'Your Accounts'), + appBar: WalletAppBar( + title: 'Your Accounts', + actions: [IconButton(onPressed: _openWalletMoreActions, icon: const Icon(Icons.more_horiz))], + ), child: Column( children: [ + // _buildWalletSelector(), Expanded(child: _buildAccountsList()), Button( variant: ButtonVariant.glassOutline, - label: 'Create New Account', + label: _walletActionLabel(), onPressed: _isCreatingAccount ? null : _createNewAccount, ), @@ -73,6 +173,14 @@ class _AccountsScreenState extends ConsumerState { ); } + String _walletActionLabel() { + final accounts = ref.watch(accountsProvider).value ?? []; + final grouped = _groupByWallet(accounts); + final selectedWallet = getSelectedWalletIndex(accounts); + final selectedAccounts = grouped[selectedWallet] ?? const []; + return _isHardwareWallet(selectedAccounts) ? 'Add Hardware Account' : 'Add Account'; + } + Widget _buildAccountsList() { final accountsAsync = ref.watch(accountsProvider); final activeAccountAsync = ref.watch(activeAccountProvider); @@ -98,16 +206,60 @@ class _AccountsScreenState extends ConsumerState { child: Text('Failed to load active account: $error', style: const TextStyle(color: Colors.white70)), ), data: (activeAccount) { - return ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 16.0), - itemCount: accounts.length, - separatorBuilder: (context, index) => const SizedBox(height: 25), - itemBuilder: (context, index) { - final account = accounts[index]; + if (_selectedWalletIndex == null) { + final initial = activeAccount?.walletIndex ?? accounts.first.walletIndex; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _selectedWalletIndex == null) setState(() => _selectedWalletIndex = initial); + }); + } + + final grouped = _groupByWallet(accounts); + if (grouped.length <= 1) { + final walletAccounts = grouped.values.first; + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 16.0), + itemCount: walletAccounts.length, + separatorBuilder: (context, index) => const SizedBox(height: 25), + itemBuilder: (context, index) { + final account = walletAccounts[index]; + final bool isActive = account.accountId == activeAccount?.accountId; + return _buildAccountListItem(account, isActive, index); + }, + ); + } + + final selectedWallet = _selectedWalletIndex ?? grouped.keys.first; + final children = []; + var sectionIndex = 0; + for (final entry in grouped.entries) { + final walletIndex = entry.key; + final walletAccounts = entry.value; + + if (sectionIndex > 0) children.add(const SizedBox(height: 18)); + children.add( + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + _walletLabel(walletIndex, walletAccounts), + style: context.themeText.detail?.copyWith( + color: walletIndex == selectedWallet + ? context.themeColors.textPrimary + : context.themeColors.textMuted, + ), + ), + ), + ); + + for (var i = 0; i < walletAccounts.length; i++) { + if (i > 0) children.add(const SizedBox(height: 25)); + final account = walletAccounts[i]; final bool isActive = account.accountId == activeAccount?.accountId; - return _buildAccountListItem(account, isActive, index); - }, - ); + children.add(_buildAccountListItem(account, isActive, i)); + } + sectionIndex++; + } + + return ListView(padding: const EdgeInsets.symmetric(vertical: 16.0), children: children); }, ); }, diff --git a/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart b/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart new file mode 100644 index 00000000..df483542 --- /dev/null +++ b/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart @@ -0,0 +1,154 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; +import 'package:resonance_network_wallet/features/components/custom_text_field.dart'; +import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/qr_scanner_screen.dart'; +import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; + +class AddHardwareAccountScreen extends ConsumerStatefulWidget { + const AddHardwareAccountScreen({super.key, required this.walletIndex, this.isNewWallet = false}); + + final int walletIndex; + final bool isNewWallet; + + @override + ConsumerState createState() => _AddHardwareAccountScreenState(); +} + +class _AddHardwareAccountScreenState extends ConsumerState { + final _name = TextEditingController(text: 'Keystone Wallet'); + final _address = TextEditingController(); + + final _accountsService = AccountsService(); + final _settingsService = SettingsService(); + final _substrateService = SubstrateService(); + + bool _isSaving = false; + String? _error; + + Future _scanQRCode() async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const QRScannerScreen(), fullscreenDialog: true), + ); + if (result != null && mounted) { + _address.text = result.trim(); + if (_error != null) setState(() => _error = null); + } + } + + void _fillDebugAddress() { + _address.text = 'qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG'; + if (_error != null) setState(() => _error = null); + } + + Future _save() async { + final name = _name.text.trim(); + final address = _address.text.trim(); + + if (name.isEmpty) { + setState(() => _error = 'Name is required'); + return; + } + if (!_substrateService.isValidSS58Address(address)) { + setState(() => _error = 'Invalid address'); + return; + } + + setState(() { + _isSaving = true; + _error = null; + }); + + try { + final nextIndex = await _settingsService.getNextFreeAccountIndex(widget.walletIndex); + final account = Account( + walletIndex: widget.walletIndex, + index: nextIndex, + name: name, + accountId: address, + accountType: AccountType.keystone, + ); + await _accountsService.addAccount(account); + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + @override + void dispose() { + _name.dispose(); + _address.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final title = widget.isNewWallet ? 'Add Hardware Wallet' : 'Add Hardware Account'; + return ScaffoldBase( + appBar: WalletAppBar(title: title), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 20), + Text(title, style: context.themeText.smallTitle), + const SizedBox(height: 16), + CustomTextField( + controller: _name, + labelText: 'NAME', + hintText: widget.isNewWallet ? 'Hardware Wallet' : 'Account', + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, + ), + const SizedBox(height: 16), + CustomTextField( + controller: _address, + labelText: 'ADDRESS', + hintText: 'SS58 address', + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Button(variant: ButtonVariant.neutral, label: 'Scan QR Code', onPressed: _scanQRCode), + ), + if (kDebugMode) ...[ + const SizedBox(width: 12), + Expanded( + child: Button(variant: ButtonVariant.neutral, label: 'Debug Fill', onPressed: _fillDebugAddress), + ), + ], + ], + ), + if (_error != null) ...[ + const SizedBox(height: 10), + Text(_error!, style: context.themeText.tiny?.copyWith(color: Colors.red)), + ], + const Spacer(), + Button( + variant: ButtonVariant.primary, + label: widget.isNewWallet ? 'Add Hardware Wallet' : 'Add Hardware Account', + onPressed: _isSaving ? null : _save, + isLoading: _isSaving, + ), + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/create_account_screen.dart b/mobile-app/lib/features/main/screens/create_account_screen.dart index 41d477d4..61ee487c 100644 --- a/mobile-app/lib/features/main/screens/create_account_screen.dart +++ b/mobile-app/lib/features/main/screens/create_account_screen.dart @@ -17,8 +17,9 @@ import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions. class CreateAccountScreen extends ConsumerStatefulWidget { final Account? accountToEdit; + final int walletIndex; - const CreateAccountScreen({super.key, this.accountToEdit}); + const CreateAccountScreen({super.key, this.accountToEdit, this.walletIndex = 0}); @override ConsumerState createState() => _CreateAccountScreenState(); @@ -77,7 +78,7 @@ class _CreateAccountScreenState extends ConsumerState { _isLoading = true; }); try { - final account = await _accountsService.createNewAccount(walletIndex: 0); + final account = await _accountsService.createNewAccount(walletIndex: widget.walletIndex); final checkphrase = await _checksumService.getHumanReadableName(account.accountId); if (mounted) { diff --git a/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart b/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart index 048e2e5a..62d610f4 100644 --- a/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart +++ b/mobile-app/lib/features/main/screens/create_wallet_and_backup_screen.dart @@ -23,7 +23,10 @@ import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; class CreateWalletAndBackupScreen extends ConsumerStatefulWidget { - const CreateWalletAndBackupScreen({super.key}); + const CreateWalletAndBackupScreen({super.key, this.walletIndex = 0, this.popOnComplete = false}); + + final int walletIndex; + final bool popOnComplete; @override CreateWalletAndBackupScreenState createState() => CreateWalletAndBackupScreenState(); @@ -101,18 +104,17 @@ class CreateWalletAndBackupScreenState extends ConsumerState[]; // Extract data or empty list - if (accounts.isEmpty) { + final hasRootForWallet = accounts.any((a) => a.walletIndex == widget.walletIndex && a.index == 0); + if (!hasRootForWallet) { await _accountsService.addAccount( - Account(walletIndex: walletIndex, index: 0, name: _accountName.value.text, accountId: _address), + Account(walletIndex: widget.walletIndex, index: 0, name: _accountName.value.text, accountId: _address), ); try { - // this is more like a shortcut - it will happen anyway any time we try to log in. _referralService.submitAddressToBackend(); } catch (e) { print('Failed to submit address to backend: $e'); @@ -122,6 +124,10 @@ class CreateWalletAndBackupScreenState extends ConsumerState ImportWalletScreenState(); @@ -39,7 +42,7 @@ class ImportWalletScreenState extends ConsumerState { try { final discoveredAccounts = await _accountDiscoveryService.discoverAccounts( mnemonic: mnemonic, - walletIndex: walletIndex, + walletIndex: widget.walletIndex, ); final existingAccountsSet = (await _accountsService.getAccounts()).map((e) => e.accountId).toSet(); @@ -99,6 +102,10 @@ class ImportWalletScreenState extends ConsumerState { _settingsService.setExistingUserSeenPromoVideo(); if (context.mounted && mounted) { + if (widget.popOnComplete) { + Navigator.of(context).pop(true); + return; + } Navigator.pushAndRemoveUntil( context, MaterialPageRoute( @@ -195,7 +202,7 @@ class ImportWalletScreenState extends ConsumerState { Button( variant: ButtonVariant.primary, label: 'Import Wallet', - onPressed: () => _importWallet(walletIndex: 0), + onPressed: () => _importWallet(walletIndex: widget.walletIndex), isLoading: _isLoading, ), SizedBox(height: context.themeSize.bottomButtonSpacing), diff --git a/mobile-app/lib/features/main/screens/select_wallet_for_recovery_phrase_screen.dart b/mobile-app/lib/features/main/screens/select_wallet_for_recovery_phrase_screen.dart new file mode 100644 index 00000000..99b90824 --- /dev/null +++ b/mobile-app/lib/features/main/screens/select_wallet_for_recovery_phrase_screen.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; +import 'package:resonance_network_wallet/features/main/screens/show_recovery_phrase_screen.dart'; +import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; +import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; + +class SelectWalletForRecoveryPhraseScreen extends ConsumerStatefulWidget { + const SelectWalletForRecoveryPhraseScreen({super.key}); + + @override + ConsumerState createState() => _SelectWalletForRecoveryPhraseScreenState(); +} + +class _SelectWalletForRecoveryPhraseScreenState extends ConsumerState { + String _walletLabel(int walletIndex) { + return 'Wallet ${walletIndex + 1}'; + } + + @override + Widget build(BuildContext context) { + final accountsAsync = ref.watch(accountsProvider); + + return ScaffoldBase( + appBar: WalletAppBar(title: 'Select Wallet'), + child: accountsAsync.when( + loading: () => Center(child: CircularProgressIndicator(color: context.themeColors.circularLoader)), + error: (error, _) => Center( + child: Text( + 'Failed to load wallets: $error', + style: context.themeText.smallParagraph?.copyWith(color: Colors.white70), + ), + ), + data: (accounts) { + final walletIndices = getNonHardwareWalletIndices(accounts); + + if (walletIndices.isEmpty) { + return Center( + child: Text( + 'No wallets with recovery phrases found.', + style: context.themeText.smallParagraph?.copyWith(color: Colors.white70), + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 18), + itemCount: walletIndices.length, + separatorBuilder: (context, index) => const SizedBox(height: 22), + itemBuilder: (context, index) { + final walletIndex = walletIndices[index]; + return _buildWalletItem(walletIndex); + }, + ); + }, + ), + ); + } + + Widget _buildWalletItem(int walletIndex) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => ShowRecoveryPhraseScreen(walletIndex: walletIndex)), + ); + }, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: context.isTablet ? 16 : 12, horizontal: 18), + decoration: ShapeDecoration( + color: context.themeColors.buttonGlass, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(_walletLabel(walletIndex), style: context.themeText.smallParagraph), + Icon(Icons.arrow_forward_ios, size: context.themeSize.settingMenuIconSize), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart b/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart index 16031de4..e0975ebc 100644 --- a/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart +++ b/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart @@ -73,7 +73,6 @@ class _QRScannerScreenState extends State { for (final barcode in barcodes) { if (barcode.rawValue != null) { _hasScanned = true; // Set flag before popping - print('Popping QR scanner with: ${barcode.rawValue}'); Navigator.pop(context, barcode.rawValue); break; } diff --git a/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart b/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart index cba74151..3f5b0ff2 100644 --- a/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart +++ b/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart @@ -1,18 +1,33 @@ import 'dart:ui'; +import 'package:convert/convert.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:quantus_sdk/generated/schrodinger/types/qp_scheduler/block_number_or_timestamp.dart' as qp; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/button.dart'; import 'package:resonance_network_wallet/features/main/screens/navbar.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart'; +import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; -enum SendOverlayState { confirm, progress, complete } +enum SendOverlayState { confirm, progress, complete, hardwareSign, hardwareScan } + +String encodePayloadAsUr(List payload) { + final urParts = encodeUr(data: payload); + if (urParts.isEmpty) { + throw Exception('Failed to encode UR: empty result'); + } + return urParts.first; +} class SendConfirmationOverlay extends ConsumerStatefulWidget { final BigInt amount; @@ -42,6 +57,25 @@ class SendConfirmationOverlayState extends ConsumerState _collectedUrParts = {}; + + int? _getTotalFragmentCount() { + if (_collectedUrParts.isEmpty) return null; + + for (final part in _collectedUrParts) { + final match = RegExp(r'/(\d+)-(\d+)/').firstMatch(part); + if (match != null) { + final total = int.tryParse(match.group(2) ?? ''); + if (total != null && total > 0) return total; + } + } + return null; + } void goHome() { if (!mounted) return; @@ -59,6 +93,10 @@ class SendConfirmationOverlayState extends ConsumerState _startHardwareFlow(Account account) async { + setState(() { + currentState = SendOverlayState.hardwareSign; + _hardwareAccount = account; + _hardwareUnsignedData = null; + _isHardwareSubmitting = false; + _hasScannedSignature = false; + _collectedUrParts.clear(); + }); + + final substrateService = SubstrateService(); + final unsignedData = await substrateService.getUnsignedTransactionPayload(account, _buildRuntimeCall()); + if (!mounted) return; + + setState(() { + _hardwareUnsignedData = unsignedData; + _isSending = false; + }); + } + + void _goToHardwareScanStep() { + setState(() { + currentState = SendOverlayState.hardwareScan; + _hasScannedSignature = false; + _isHardwareSubmitting = false; + _collectedUrParts.clear(); + }); + } + + Future _onHardwareSignatureScanned(List signatureQRParts) async { + if (_isHardwareSubmitting) return; + final unsignedData = _hardwareUnsignedData; + final account = _hardwareAccount; + if (unsignedData == null || account == null) return; + + setState(() { + _isHardwareSubmitting = true; + }); + + await _processHardwareSignature(signatureQRParts, unsignedData, account); + } + + Future _simulateHardwareSignature() async { + final unsignedData = _hardwareUnsignedData; + final account = _hardwareAccount; + if (unsignedData == null || account == null) return; + + try { + final debugWallet = await account.getKeypair(); + final signature = signMessage(keypair: debugWallet, message: unsignedData.encodedPayloadToSign); + final signatureWithPublicKey = Uint8List(signature.length + debugWallet.publicKey.length); + signatureWithPublicKey.setAll(0, signature); + signatureWithPublicKey.setAll(signature.length, debugWallet.publicKey); + // printKatValues(unsignedData, signatureWithPublicKey); + await _onHardwareSignatureScanned(['0x${hex.encode(signatureWithPublicKey)}']); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = 'Simulation failed: $e'; + _hasScannedSignature = false; + _isHardwareSubmitting = false; + _collectedUrParts.clear(); + }); + } + } + + Future _handleLocalWalletTransaction(Account account) async { + final submissionService = ref.read(transactionSubmissionServiceProvider); + + debugPrint('Attempting balance transfer...'); + debugPrint(' Recipient: ${widget.recipientAddress}'); + debugPrint(' Amount (BigInt): ${widget.amount}'); + debugPrint(' Fee: ${widget.fee}'); + debugPrint(' Reversible time: ${widget.reversibleTimeSeconds}'); + + if (widget.reversibleTimeSeconds <= 0) { + await submissionService.balanceTransfer( + account, + widget.recipientAddress, + widget.amount, + widget.fee, + widget.blockHeight, + ); + } else { + await submissionService.scheduleReversibleTransferWithDelaySeconds( + account: account, + recipientAddress: widget.recipientAddress, + amount: widget.amount, + delaySeconds: widget.reversibleTimeSeconds, + feeEstimate: widget.fee, + blockHeight: widget.blockHeight, + ); + } + } + Widget _buildConfirmState() { final formattedAmount = _formattingService.formatBalance(widget.amount); final formattedFee = _formattingService.formatBalance(widget.fee); @@ -482,6 +617,375 @@ class SendConfirmationOverlayState extends ConsumerState setState(() => currentState = SendOverlayState.hardwareSign), + child: SizedBox( + width: context.themeSize.overlayCloseIconSize, + height: context.themeSize.overlayCloseIconSize, + child: Icon(Icons.arrow_back, color: Colors.white, size: context.themeSize.overlayCloseIconSize), + ), + ), + GestureDetector( + onTap: widget.onClose, + child: SizedBox( + width: context.themeSize.overlayCloseIconSize, + height: context.themeSize.overlayCloseIconSize, + child: Icon(Icons.close, color: Colors.white, size: context.themeSize.overlayCloseIconSize), + ), + ), + ], + ), + ), + const SizedBox(height: 28), + + // Hardware wallet icon and title + Column( + children: [ + Center( + child: Image.asset( + 'assets/transaction/send_icon.png', + width: context.isTablet ? 101 : 61, + height: context.isTablet ? 92 : 52, + ), + ), + const SizedBox(height: 17), + Text('SCAN SIGNATURE', textAlign: TextAlign.center, style: context.themeText.largeTitle), + ], + ), + const SizedBox(height: 28), + + if (unsignedData == null) + SizedBox( + height: 320, + child: Center(child: CircularProgressIndicator(color: context.themeColors.primary)), + ) + else if (_isHardwareSubmitting) + SizedBox( + height: 320, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: context.themeColors.primary), + const SizedBox(height: 16), + Text('Submitting...', style: context.themeText.paragraph), + ], + ), + ), + ) + else + SizedBox( + height: 320, + child: Stack( + children: [ + MobileScanner( + controller: _signatureScannerController, + onDetect: (capture) { + debugPrint('QR Scanner: onDetect called'); + if (_hasScannedSignature || _isHardwareSubmitting) { + debugPrint('QR Scanner: Already scanned or submitting, ignoring'); + return; + } + debugPrint('QR Scanner: Processing ${capture.barcodes.length} barcode(s)'); + for (final barcode in capture.barcodes) { + final v = barcode.rawValue; + debugPrint( + 'QR Scanner: Raw value: ${v?.substring(0, v.length > 100 ? 100 : v.length)}${v != null && v.length > 100 ? '...' : ''}', + ); + if (v == null) { + debugPrint('QR Scanner: Null value, skipping'); + continue; + } + + if (v.startsWith('UR:')) { + debugPrint('QR Scanner: UR code detected'); + final wasNew = _collectedUrParts.add(v); + debugPrint('QR Scanner: Was new part: $wasNew, Total parts: ${_collectedUrParts.length}'); + if (wasNew) { + final total = _getTotalFragmentCount(); + debugPrint('QR Scanner: Total fragments: $total'); + setState(() {}); + final isComplete = isCompleteUr(urParts: _collectedUrParts.toList()); + debugPrint('QR Scanner: Is complete: $isComplete'); + if (isComplete) { + debugPrint('QR Scanner: All parts collected, processing signature'); + _hasScannedSignature = true; + _onHardwareSignatureScanned(_collectedUrParts.toList()); + } + } else { + debugPrint('QR Scanner: Duplicate part, ignoring'); + } + } else { + debugPrint('QR Scanner: Non-UR code detected, ignoring'); + } + break; + } + }, + ), + Container( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Color(0xFF0CE6ED), width: 2), + ), + ), + margin: const EdgeInsets.all(50), + ), + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_collectedUrParts.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + () { + final scanned = _collectedUrParts.length; + final total = _getTotalFragmentCount(); + if (total != null) { + return 'Scanned $scanned of $total fragments'; + } + return 'Scanned $scanned fragment${scanned == 1 ? '' : 's'}...'; + }(), + textAlign: TextAlign.center, + style: context.themeText.paragraph?.copyWith( + color: context.themeColors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + _collectedUrParts.isEmpty + ? 'Position the QR code within the frame' + : 'Keep scanning until all parts are collected', + textAlign: TextAlign.center, + style: context.themeText.paragraph?.copyWith( + color: context.themeColors.textPrimary.useOpacity(0.8), + ), + ), + ], + ), + ), + if (AppConstants.debugHardwareWallet) + Positioned( + bottom: 56, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: _simulateHardwareSignature, + style: TextButton.styleFrom( + backgroundColor: Colors.red.useOpacity(0.7), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: const Text('DEBUG: SIMULATE SIGNATURE'), + ), + ), + ), + ], + ), + ), + + const Spacer(), + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + _errorMessage!, + style: context.themeText.detail?.copyWith(color: context.themeColors.textError), + textAlign: TextAlign.center, + ), + ), + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ), + ); + } + + Future _processHardwareSignature( + List signatureQRParts, + UnsignedTransactionData unsignedData, + Account account, + ) async { + try { + Uint8List signatureBytes; + + if (signatureQRParts.isNotEmpty && signatureQRParts.first.startsWith('UR:')) { + try { + signatureBytes = decodeUr(urParts: signatureQRParts); + } catch (e) { + throw Exception('Invalid UR format: $e'); + } + } else { + throw Exception('Invalid signature format'); + } + + // print('signatureSize: ${hex.encode(signatureBytes)}'); + // print('sig PK hash: ${hex.encode(const Blake2bHasher(32).hash(signatureBytes))}'); + + final expectedTotalSize = signatureSize + publicKeySize; + + if (signatureBytes.length != expectedTotalSize) { + throw Exception('Invalid signature length: expected $expectedTotalSize bytes, got ${signatureBytes.length}'); + } + + final signature = signatureBytes.sublist(0, signatureSize); + final publicKey = signatureBytes.sublist(signatureSize); + + final substrateService = SubstrateService(); + final submissionService = ref.read(transactionSubmissionServiceProvider); + final pendingTx = PendingTransactionEvent( + tempId: 'pending_${DateTime.now().millisecondsSinceEpoch}', + from: account.accountId, + to: widget.recipientAddress, + amount: widget.amount, + timestamp: DateTime.now(), + transactionState: TransactionState.created, + fee: widget.fee, + blockNumber: widget.blockHeight, + ); + + ref.read(pendingTransactionsProvider.notifier).add(pendingTx); + + Future submissionBuilder() async { + return await substrateService.submitExtrinsicWithExternalSignature(unsignedData, signature, publicKey); + } + + RecentAddressesService().addAddress(widget.recipientAddress); + + TelemetryService().sendEvent('send_transfer_hardware'); + await submissionService.submitAndTrackTransaction(submissionBuilder, pendingTx); + + if (mounted) { + setState(() { + currentState = SendOverlayState.complete; + _isSending = false; + _isHardwareSubmitting = false; + }); + } + } catch (e) { + print('Hardware signature processing failed: $e'); + if (mounted) { + setState(() { + _errorMessage = 'Signature processing failed: ${e.toString()}'; + _isHardwareSubmitting = false; + _hasScannedSignature = false; + _collectedUrParts.clear(); + }); + } + } + } + @override Widget build(BuildContext context) { Widget content; @@ -495,6 +999,12 @@ class SendConfirmationOverlayState extends ConsumerState { setState(() { _networkFee = estimatedFee.fee; - _blockHeight = estimatedFee.extrinsicData.blockNumber; + _blockHeight = estimatedFee.blockNumber; _isFetchingFee = false; _hasAmountError = SendScreenLogic.hasAmountError( amount: _amount, @@ -349,7 +349,7 @@ class SendScreenState extends ConsumerState { // we keep track of block number so we can set it on pending transactions setState(() { - _blockHeight = estimatedFee.extrinsicData.blockNumber; + _blockHeight = estimatedFee.blockNumber; }); final maxSendableAmount = SendScreenLogic.calculateMaxSendableAmount( diff --git a/mobile-app/lib/features/main/screens/settings_screen.dart b/mobile-app/lib/features/main/screens/settings_screen.dart index 37110d98..76afbc5d 100644 --- a/mobile-app/lib/features/main/screens/settings_screen.dart +++ b/mobile-app/lib/features/main/screens/settings_screen.dart @@ -12,6 +12,7 @@ import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart import 'package:resonance_network_wallet/features/main/screens/accounts_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/authentication_settings_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/notifications_settings_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/select_wallet_for_recovery_phrase_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/show_recovery_phrase_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/welcome_screen.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; @@ -22,6 +23,7 @@ import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart'; import 'package:resonance_network_wallet/services/referral_service.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; +import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -90,6 +92,27 @@ class _SettingsScreenState extends ConsumerState { ); } + void _navigateToRecoveryPhrase() { + final accountsAsync = ref.read(accountsProvider); + accountsAsync.whenData((accounts) { + final walletIndices = getNonHardwareWalletIndices(accounts); + + if (walletIndices.isEmpty) { + showTopSnackBar(context, title: 'No Wallets', message: 'No wallets with recovery phrases found.'); + return; + } + + if (walletIndices.length == 1) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => ShowRecoveryPhraseScreen(walletIndex: walletIndices.first)), + ); + } else { + Navigator.push(context, MaterialPageRoute(builder: (context) => const SelectWalletForRecoveryPhraseScreen())); + } + }); + } + @override Widget build(BuildContext context) { return ScaffoldBase( @@ -157,7 +180,7 @@ class _SettingsScreenState extends ConsumerState { ListItem( title: 'Show Recovery Phrase', onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const ShowRecoveryPhraseScreen())); + _navigateToRecoveryPhrase(); }, ), const SizedBox(height: 22), diff --git a/mobile-app/lib/services/feature_flags.dart b/mobile-app/lib/services/feature_flags.dart index 3aedcf33..96a050f3 100644 --- a/mobile-app/lib/services/feature_flags.dart +++ b/mobile-app/lib/services/feature_flags.dart @@ -7,12 +7,15 @@ class FeatureFlags { FeatureFlags._internal(); static const bool enableTestButtons = false; // Only show in debug mode + static const bool showKeystoneHardwareWallet = false; // turn keystone hw wallet on and off /// Instance method for provider usage bool isEnabled(String featureName) { switch (featureName) { case 'test_buttons': return enableTestButtons; + case 'keystone_hardware_wallet': + return showKeystoneHardwareWallet; default: return false; } diff --git a/mobile-app/lib/services/transaction_submission_service.dart b/mobile-app/lib/services/transaction_submission_service.dart index 8f690bd0..ff336016 100644 --- a/mobile-app/lib/services/transaction_submission_service.dart +++ b/mobile-app/lib/services/transaction_submission_service.dart @@ -53,7 +53,7 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_transfer'); // D. Submit and track the transaction - await _submitAndTrack(submissionBuilder, pendingTx); + await submitAndTrackTransaction(submissionBuilder, pendingTx); } Future scheduleReversibleTransferWithDelaySeconds({ @@ -90,7 +90,7 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_reversible'); - await _submitAndTrack(submissionBuilder, pending, maxRetries: maxRetries); + await submitAndTrackTransaction(submissionBuilder, pending, maxRetries: maxRetries); } PendingTransactionEvent createPendingTransaction({ @@ -123,7 +123,7 @@ class TransactionSubmissionService { /// waiting. /// Handles retries in the background for 'invalid' status. /// submissionBuilder: Function that creates fresh submission on each retry - Future _submitAndTrack( + Future submitAndTrackTransaction( Future Function() submissionBuilder, PendingTransactionEvent pendingTx, { int maxRetries = 3, diff --git a/mobile-app/lib/shared/utils/account_utils.dart b/mobile-app/lib/shared/utils/account_utils.dart new file mode 100644 index 00000000..885aec18 --- /dev/null +++ b/mobile-app/lib/shared/utils/account_utils.dart @@ -0,0 +1,11 @@ +import 'package:quantus_sdk/quantus_sdk.dart'; + +List getNonHardwareWalletIndices(List accounts) { + final nonHardwareWalletIndices = {}; + for (final account in accounts) { + if (account.accountType != AccountType.keystone) { + nonHardwareWalletIndices.add(account.walletIndex); + } + } + return nonHardwareWalletIndices.toList()..sort(); +} diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index 01cf71df..a8992a56 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: decimal: hex: + base32: ^2.0.0 ss58: bip39_mnemonic: intl: @@ -38,10 +39,10 @@ dependencies: human_checksum: git: url: https://github.com/Quantus-Network/human-checkphrase.git - ref: v1.0.0 + ref: v1.1.0 path: dart provider: ^6.1.5 - polkadart: ^0.7.1 + polkadart: ^0.7.3 share_plus: ^12.0.1 flutter_riverpod: ^2.6.1 riverpod_annotation: ^2.6.1 diff --git a/mobile-app/test/widget/send_screen_widget_test.dart b/mobile-app/test/widget/send_screen_widget_test.dart index 4ee6a14d..27af0645 100644 --- a/mobile-app/test/widget/send_screen_widget_test.dart +++ b/mobile-app/test/widget/send_screen_widget_test.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -13,7 +11,6 @@ import 'package:resonance_network_wallet/features/main/screens/send/send_screen. import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import '../extensions.dart'; - // Generate the mocks @GenerateMocks([ SettingsService, @@ -59,10 +56,7 @@ void main() { when(mockChecksumService.getHumanReadableName(any)).thenAnswer((_) async => 'Alice'); // --- 4. Balances/Fee Stubs --- - final dummyFeeData = ExtrinsicFeeData( - fee: BigInt.from(1000000), - extrinsicData: ExtrinsicData(blockNumber: 100, nonce: 1, blockHash: '0xHash', payload: Uint8List(0)), - ); + final dummyFeeData = ExtrinsicFeeData(fee: BigInt.from(1000000), blockHash: '0xHash', blockNumber: 100); when(mockBalancesService.getBalanceTransferFee(any, any, any)).thenAnswer((_) async => dummyFeeData); diff --git a/mobile-app/test/widget/send_screen_widget_test.mocks.dart b/mobile-app/test/widget/send_screen_widget_test.mocks.dart index c316c054..4aa933d0 100644 --- a/mobile-app/test/widget/send_screen_widget_test.mocks.dart +++ b/mobile-app/test/widget/send_screen_widget_test.mocks.dart @@ -4,10 +4,10 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i3; -import 'dart:typed_data' as _i5; +import 'dart:typed_data' as _i6; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i6; +import 'package:mockito/src/dummies.dart' as _i5; import 'package:polkadart/polkadart.dart' as _i8; import 'package:quantus_sdk/generated/schrodinger/types/pallet_reversible_transfers/high_security_account_data.dart' as _i9; @@ -42,12 +42,16 @@ class _FakeExtrinsicData_2 extends _i1.SmartFake implements _i2.ExtrinsicData { _FakeExtrinsicData_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeBalances_3 extends _i1.SmartFake implements _i2.Balances { - _FakeBalances_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +class _FakeUnsignedTransactionData_3 extends _i1.SmartFake implements _i2.UnsignedTransactionData { + _FakeUnsignedTransactionData_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeReversibleTransfers_4 extends _i1.SmartFake implements _i2.ReversibleTransfers { - _FakeReversibleTransfers_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +class _FakeBalances_4 extends _i1.SmartFake implements _i2.Balances { + _FakeBalances_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeReversibleTransfers_5 extends _i1.SmartFake implements _i2.ReversibleTransfers { + _FakeReversibleTransfers_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } /// A class which mocks [SettingsService]. @@ -149,13 +153,19 @@ class MockSettingsService extends _i1.Mock implements _i2.SettingsService { as _i3.Future<_i4.Account?>); @override - _i3.Future<_i4.Account?> getAccount(int? index) => - (super.noSuchMethod(Invocation.method(#getAccount, [index]), returnValue: _i3.Future<_i4.Account?>.value()) + _i3.Future<_i4.Account?> getAccount({required int? walletIndex, required int? index}) => + (super.noSuchMethod( + Invocation.method(#getAccount, [], {#walletIndex: walletIndex, #index: index}), + returnValue: _i3.Future<_i4.Account?>.value(), + ) as _i3.Future<_i4.Account?>); @override - _i3.Future getNextFreeAccountIndex() => - (super.noSuchMethod(Invocation.method(#getNextFreeAccountIndex, []), returnValue: _i3.Future.value(0)) + _i3.Future getNextFreeAccountIndex(int? walletIndex) => + (super.noSuchMethod( + Invocation.method(#getNextFreeAccountIndex, [walletIndex]), + returnValue: _i3.Future.value(0), + ) as _i3.Future); @override @@ -168,10 +178,18 @@ class MockSettingsService extends _i1.Mock implements _i2.SettingsService { (super.noSuchMethod(Invocation.method(#isWalletLoggedOut, []), returnValue: _i3.Future.value(false)) as _i3.Future); + @override + String getMnemonicKey(int? walletIndex) => + (super.noSuchMethod( + Invocation.method(#getMnemonicKey, [walletIndex]), + returnValue: _i5.dummyValue(this, Invocation.method(#getMnemonicKey, [walletIndex])), + ) + as String); + @override _i3.Future setMnemonic(String? mnemonic, int? walletIndex) => (super.noSuchMethod( - Invocation.method(#setMnemonic, [mnemonic]), + Invocation.method(#setMnemonic, [mnemonic, walletIndex]), returnValue: _i3.Future.value(), returnValueForMissingStub: _i3.Future.value(), ) @@ -179,7 +197,7 @@ class MockSettingsService extends _i1.Mock implements _i2.SettingsService { @override _i3.Future getMnemonic(int? walletIndex) => - (super.noSuchMethod(Invocation.method(#getMnemonic, []), returnValue: _i3.Future.value()) + (super.noSuchMethod(Invocation.method(#getMnemonic, [walletIndex]), returnValue: _i3.Future.value()) as _i3.Future); @override @@ -219,6 +237,14 @@ class MockSettingsService extends _i1.Mock implements _i2.SettingsService { void setLastSuccessfulAuthTime(DateTime? time) => super.noSuchMethod(Invocation.method(#setLastSuccessfulAuthTime, [time]), returnValueForMissingStub: null); + @override + void setLastPausedTime(DateTime? time) => + super.noSuchMethod(Invocation.method(#setLastPausedTime, [time]), returnValueForMissingStub: null); + + @override + void cleanLastPausedTime() => + super.noSuchMethod(Invocation.method(#cleanLastPausedTime, []), returnValueForMissingStub: null); + @override void setAuthTimeout(int? timeoutDurationInMinutes) => super.noSuchMethod( Invocation.method(#setAuthTimeout, [timeoutDurationInMinutes]), @@ -319,11 +345,11 @@ class MockSubstrateService extends _i1.Mock implements _i2.SubstrateService { } @override - _i3.Future getFee(_i5.Uint8List? signedExtrinsic) => + _i3.Future getFee(_i6.Uint8List? signedExtrinsic) => (super.noSuchMethod( Invocation.method(#getFee, [signedExtrinsic]), returnValue: _i3.Future.value( - _i6.dummyValue(this, Invocation.method(#getFee, [signedExtrinsic])), + _i5.dummyValue(this, Invocation.method(#getFee, [signedExtrinsic])), ), ) as _i3.Future); @@ -333,7 +359,7 @@ class MockSubstrateService extends _i1.Mock implements _i2.SubstrateService { (super.noSuchMethod( Invocation.method(#queryUserBalance, []), returnValue: _i3.Future.value( - _i6.dummyValue(this, Invocation.method(#queryUserBalance, [])), + _i5.dummyValue(this, Invocation.method(#queryUserBalance, [])), ), ) as _i3.Future); @@ -343,7 +369,7 @@ class MockSubstrateService extends _i1.Mock implements _i2.SubstrateService { (super.noSuchMethod( Invocation.method(#queryBalance, [address]), returnValue: _i3.Future.value( - _i6.dummyValue(this, Invocation.method(#queryBalance, [address])), + _i5.dummyValue(this, Invocation.method(#queryBalance, [address])), ), ) as _i3.Future); @@ -367,23 +393,52 @@ class MockSubstrateService extends _i1.Mock implements _i2.SubstrateService { as _i3.Future<_i2.ExtrinsicFeeData>); @override - _i3.Future<_i5.Uint8List> submitExtrinsic(_i4.Account? account, _i2.RuntimeCall? call, {int? maxRetries = 3}) => + _i3.Future<_i6.Uint8List> submitExtrinsic(_i4.Account? account, _i2.RuntimeCall? call, {int? maxRetries = 3}) => (super.noSuchMethod( Invocation.method(#submitExtrinsic, [account, call], {#maxRetries: maxRetries}), - returnValue: _i3.Future<_i5.Uint8List>.value(_i5.Uint8List(0)), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) - as _i3.Future<_i5.Uint8List>); + as _i3.Future<_i6.Uint8List>); @override - _i3.Future<_i2.ExtrinsicData> getExtrinsicPayload(_i4.Account? account, _i2.RuntimeCall? call) => + _i3.Future<_i2.ExtrinsicData> getExtrinsicPayload( + _i4.Account? account, + _i2.RuntimeCall? call, { + bool? isSigned = true, + }) => (super.noSuchMethod( - Invocation.method(#getExtrinsicPayload, [account, call]), + Invocation.method(#getExtrinsicPayload, [account, call], {#isSigned: isSigned}), returnValue: _i3.Future<_i2.ExtrinsicData>.value( - _FakeExtrinsicData_2(this, Invocation.method(#getExtrinsicPayload, [account, call])), + _FakeExtrinsicData_2( + this, + Invocation.method(#getExtrinsicPayload, [account, call], {#isSigned: isSigned}), + ), ), ) as _i3.Future<_i2.ExtrinsicData>); + @override + _i3.Future<_i2.UnsignedTransactionData> getUnsignedTransactionPayload(_i4.Account? account, _i2.RuntimeCall? call) => + (super.noSuchMethod( + Invocation.method(#getUnsignedTransactionPayload, [account, call]), + returnValue: _i3.Future<_i2.UnsignedTransactionData>.value( + _FakeUnsignedTransactionData_3(this, Invocation.method(#getUnsignedTransactionPayload, [account, call])), + ), + ) + as _i3.Future<_i2.UnsignedTransactionData>); + + @override + _i3.Future<_i6.Uint8List> submitExtrinsicWithExternalSignature( + _i2.UnsignedTransactionData? unsignedData, + _i6.Uint8List? signature, + _i6.Uint8List? publicKey, + ) => + (super.noSuchMethod( + Invocation.method(#submitExtrinsicWithExternalSignature, [unsignedData, signature, publicKey]), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) + as _i3.Future<_i6.Uint8List>); + @override _i3.Future logout() => (super.noSuchMethod( @@ -398,7 +453,7 @@ class MockSubstrateService extends _i1.Mock implements _i2.SubstrateService { (super.noSuchMethod( Invocation.method(#generateMnemonic, []), returnValue: _i3.Future.value( - _i6.dummyValue(this, Invocation.method(#generateMnemonic, [])), + _i5.dummyValue(this, Invocation.method(#generateMnemonic, [])), ), ) as _i3.Future); @@ -408,10 +463,10 @@ class MockSubstrateService extends _i1.Mock implements _i2.SubstrateService { (super.noSuchMethod(Invocation.method(#isValidSS58Address, [address]), returnValue: false) as bool); @override - String bytesToHex(_i5.Uint8List? bytes) => + String bytesToHex(_i6.Uint8List? bytes) => (super.noSuchMethod( Invocation.method(#bytesToHex, [bytes]), - returnValue: _i6.dummyValue(this, Invocation.method(#bytesToHex, [bytes])), + returnValue: _i5.dummyValue(this, Invocation.method(#bytesToHex, [bytes])), ) as String); @@ -441,7 +496,7 @@ class MockHumanReadableChecksumService extends _i1.Mock implements _i2.HumanRead (super.noSuchMethod( Invocation.method(#getHumanReadableName, [address], {#upperCase: upperCase}), returnValue: _i3.Future.value( - _i6.dummyValue( + _i5.dummyValue( this, Invocation.method(#getHumanReadableName, [address], {#upperCase: upperCase}), ), @@ -462,12 +517,12 @@ class MockBalancesService extends _i1.Mock implements _i2.BalancesService { } @override - _i3.Future<_i5.Uint8List> balanceTransfer(_i4.Account? account, String? targetAddress, BigInt? amount) => + _i3.Future<_i6.Uint8List> balanceTransfer(_i4.Account? account, String? targetAddress, BigInt? amount) => (super.noSuchMethod( Invocation.method(#balanceTransfer, [account, targetAddress, amount]), - returnValue: _i3.Future<_i5.Uint8List>.value(_i5.Uint8List(0)), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) - as _i3.Future<_i5.Uint8List>); + as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.ExtrinsicFeeData> getBalanceTransferFee(_i4.Account? account, String? targetAddress, BigInt? amount) => @@ -486,7 +541,7 @@ class MockBalancesService extends _i1.Mock implements _i2.BalancesService { _i2.Balances getBalanceTransferCall(String? targetAddress, BigInt? amount) => (super.noSuchMethod( Invocation.method(#getBalanceTransferCall, [targetAddress, amount]), - returnValue: _FakeBalances_3(this, Invocation.method(#getBalanceTransferCall, [targetAddress, amount])), + returnValue: _FakeBalances_4(this, Invocation.method(#getBalanceTransferCall, [targetAddress, amount])), ) as _i2.Balances); } @@ -500,19 +555,19 @@ class MockReversibleTransfersService extends _i1.Mock implements _i2.ReversibleT } @override - _i3.Future<_i5.Uint8List> setHighSecurity({ + _i3.Future<_i6.Uint8List> setHighSecurity({ required _i4.Account? account, required _i4.Account? guardian, required _i7.BlockNumberOrTimestamp? delay, }) => (super.noSuchMethod( Invocation.method(#setHighSecurity, [], {#account: account, #guardian: guardian, #delay: delay}), - returnValue: _i3.Future<_i5.Uint8List>.value(_i5.Uint8List(0)), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) - as _i3.Future<_i5.Uint8List>); + as _i3.Future<_i6.Uint8List>); @override - _i3.Future<_i5.Uint8List> scheduleReversibleTransfer({ + _i3.Future<_i6.Uint8List> scheduleReversibleTransfer({ required _i4.Account? account, required String? recipientAddress, required BigInt? amount, @@ -523,12 +578,12 @@ class MockReversibleTransfersService extends _i1.Mock implements _i2.ReversibleT #recipientAddress: recipientAddress, #amount: amount, }), - returnValue: _i3.Future<_i5.Uint8List>.value(_i5.Uint8List(0)), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) - as _i3.Future<_i5.Uint8List>); + as _i3.Future<_i6.Uint8List>); @override - _i3.Future<_i5.Uint8List> scheduleReversibleTransferWithDelay({ + _i3.Future<_i6.Uint8List> scheduleReversibleTransferWithDelay({ required _i4.Account? account, required String? recipientAddress, required BigInt? amount, @@ -543,9 +598,9 @@ class MockReversibleTransfersService extends _i1.Mock implements _i2.ReversibleT #delay: delay, #onStatus: onStatus, }), - returnValue: _i3.Future<_i5.Uint8List>.value(_i5.Uint8List(0)), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) - as _i3.Future<_i5.Uint8List>); + as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.ExtrinsicFeeData> getReversibleTransferWithDelayFeeEstimate({ @@ -583,7 +638,7 @@ class MockReversibleTransfersService extends _i1.Mock implements _i2.ReversibleT ) => (super.noSuchMethod( Invocation.method(#getReversibleTransferCall, [recipientAddress, amount, delay]), - returnValue: _FakeReversibleTransfers_4( + returnValue: _FakeReversibleTransfers_5( this, Invocation.method(#getReversibleTransferCall, [recipientAddress, amount, delay]), ), @@ -591,7 +646,7 @@ class MockReversibleTransfersService extends _i1.Mock implements _i2.ReversibleT as _i2.ReversibleTransfers); @override - _i3.Future<_i5.Uint8List> scheduleReversibleTransferWithDelaySeconds({ + _i3.Future<_i6.Uint8List> scheduleReversibleTransferWithDelaySeconds({ required _i4.Account? account, required String? recipientAddress, required BigInt? amount, @@ -606,28 +661,28 @@ class MockReversibleTransfersService extends _i1.Mock implements _i2.ReversibleT #delaySeconds: delaySeconds, #onStatus: onStatus, }), - returnValue: _i3.Future<_i5.Uint8List>.value(_i5.Uint8List(0)), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) - as _i3.Future<_i5.Uint8List>); + as _i3.Future<_i6.Uint8List>); @override - _i3.Future<_i5.Uint8List> cancelReversibleTransfer({ + _i3.Future<_i6.Uint8List> cancelReversibleTransfer({ required _i4.Account? account, required List? transactionId, }) => (super.noSuchMethod( Invocation.method(#cancelReversibleTransfer, [], {#account: account, #transactionId: transactionId}), - returnValue: _i3.Future<_i5.Uint8List>.value(_i5.Uint8List(0)), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) - as _i3.Future<_i5.Uint8List>); + as _i3.Future<_i6.Uint8List>); @override - _i3.Future<_i5.Uint8List> executeTransfer({required _i4.Account? account, required List? transactionId}) => + _i3.Future<_i6.Uint8List> executeTransfer({required _i4.Account? account, required List? transactionId}) => (super.noSuchMethod( Invocation.method(#executeTransfer, [], {#account: account, #transactionId: transactionId}), - returnValue: _i3.Future<_i5.Uint8List>.value(_i5.Uint8List(0)), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) - as _i3.Future<_i5.Uint8List>); + as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i9.HighSecurityAccountData?> getAccountReversibilityConfig(String? address) => @@ -696,7 +751,7 @@ class MockNumberFormattingService extends _i1.Mock implements _i2.NumberFormatti [balance], {#maxDecimals: maxDecimals, #addThousandsSeparators: addThousandsSeparators, #addSymbol: addSymbol}, ), - returnValue: _i6.dummyValue( + returnValue: _i5.dummyValue( this, Invocation.method( #formatBalance, diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 05e98bdc..132f6652 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -20,6 +20,7 @@ export 'src/models/account_associations.dart'; export 'src/models/event_type.dart'; export 'src/models/extrinsic_data.dart'; export 'src/models/extrinsic_fee_data.dart'; +export 'src/models/unsigned_transaction_data.dart'; export 'src/models/miner_reward_event.dart'; export 'src/models/miner_stats.dart'; export 'src/models/opted_in_position.dart'; @@ -33,6 +34,7 @@ export 'src/models/raid_quest.dart'; // note we have to hide some things here because they're exported by substrate service // 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/services/account_discovery_service.dart'; export 'src/services/accounts_service.dart'; export 'src/services/address_formatting_service.dart'; @@ -51,6 +53,9 @@ export 'src/services/reversible_transfers_service.dart'; export 'src/services/settings_service.dart'; export 'src/services/substrate_service.dart'; export 'src/services/taskmaster_service.dart'; +export 'src/extensions/account_extension.dart'; +export 'src/quantus_signing_payload.dart'; +export 'src/quantus_payload_parser.dart'; class QuantusSdk { /// Initialise the SDK (loads Rust FFI, etc). diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index 5ef05ef9..3119b43a 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -1,5 +1,6 @@ class AppConstants { static const globalDebug = false; + static const String appName = 'Quantus Wallet'; static const String tokenSymbol = 'QU'; // fetch this from chain eventually static const String shareUrl = 'https://linktr.ee/quantusnetwork'; @@ -52,4 +53,8 @@ class AppConstants { // Default sheet height in percentage of screen height static const double sendingSheetHeightFraction = 0.72; + + // This starts the hardware wallet flow using a soft wallet - quite useful for debugging + // hardware wallet flow without using a hardware wallet. + static const debugHardwareWallet = false; } diff --git a/quantus_sdk/lib/src/constants/feature_flags.dart b/quantus_sdk/lib/src/constants/feature_flags.dart deleted file mode 100644 index 5528cba0..00000000 --- a/quantus_sdk/lib/src/constants/feature_flags.dart +++ /dev/null @@ -1,3 +0,0 @@ -class FeatureFlags { - static const bool showKeystoneHardwareWallet = true; -} diff --git a/quantus_sdk/lib/src/models/extrinsic_fee_data.dart b/quantus_sdk/lib/src/models/extrinsic_fee_data.dart index 004ac35e..e43bbdcf 100644 --- a/quantus_sdk/lib/src/models/extrinsic_fee_data.dart +++ b/quantus_sdk/lib/src/models/extrinsic_fee_data.dart @@ -1,7 +1,6 @@ -import 'package:quantus_sdk/src/models/extrinsic_data.dart'; - class ExtrinsicFeeData { BigInt fee; - ExtrinsicData extrinsicData; - ExtrinsicFeeData({required this.fee, required this.extrinsicData}); + String blockHash; + int blockNumber; + ExtrinsicFeeData({required this.fee, required this.blockHash, required this.blockNumber}); } diff --git a/quantus_sdk/lib/src/models/unsigned_transaction_data.dart b/quantus_sdk/lib/src/models/unsigned_transaction_data.dart new file mode 100644 index 00000000..016835dc --- /dev/null +++ b/quantus_sdk/lib/src/models/unsigned_transaction_data.dart @@ -0,0 +1,21 @@ +import 'dart:typed_data'; + +import 'package:polkadart/polkadart.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +class UnsignedTransactionData { + final QuantusSigningPayload payloadToSign; + final Uint8List signer; + final dynamic registry; + + Uint8List get encodedPayloadRaw => payloadToSign.encodeRaw(registry); + + Uint8List get encodedPayloadToSign { + final payloadEncoded = encodedPayloadRaw; + // print('payloadEncoded Size: ${payloadEncoded.length}'); + // payloadEncoded Size: 119 for a normal transfer - the 256 case may be rare. + return payloadEncoded.length > 256 ? const Blake2bHasher(32).hash(payloadEncoded) : payloadEncoded; + } + + UnsignedTransactionData({required this.payloadToSign, required this.signer, required this.registry}); +} diff --git a/quantus_sdk/lib/src/quantus_payload_parser.dart b/quantus_sdk/lib/src/quantus_payload_parser.dart new file mode 100644 index 00000000..ef3afa5c --- /dev/null +++ b/quantus_sdk/lib/src/quantus_payload_parser.dart @@ -0,0 +1,193 @@ +/// A parser for Quantus blockchain transaction payloads. +/// +/// This parser extracts human-readable transaction information from SCALE-encoded +/// payloads, specifically designed for hardware wallets that need to display +/// transaction details to users before signing. +/// +/// Supported transaction types: +/// - Balance transfers (pallet index 3) +/// - Reversible transfers (pallet index 12) +/// +/// Usage: +/// ```dart +/// final payload = signingPayload.encodeRaw(registry); +/// final txInfo = QuantusPayloadParser.parsePayload(payload); +/// if (txInfo != null) { +/// print(txInfo); // Shows formatted transaction details +/// } +/// ``` +library; + +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +import 'package:polkadart/scale_codec.dart'; +import 'package:ss58/ss58.dart'; +import 'package:quantus_sdk/src/constants/app_constants.dart'; + +class TransactionInfo { + final String toAddress; + final BigInt amount; + final bool isReversible; + final int? reversibleTimeframe; // in blocks or milliseconds + + TransactionInfo({ + required this.toAddress, + required this.amount, + required this.isReversible, + this.reversibleTimeframe, + }); + + @override + String toString() { + final amountStr = (amount / BigInt.from(10).pow(10)).toStringAsFixed(4); + return ''' +Transaction Details: + To Address: $toAddress + Amount: $amountStr QUS + Reversible: $isReversible + ${isReversible && reversibleTimeframe != null ? 'Reversible Timeframe: $reversibleTimeframe blocks' : ''} +'''; + } +} + +class QuantusPayloadParser { + static String bytesToSs58(Uint8List bytes) { + final address = Address(prefix: AppConstants.ss58prefix, pubkey: bytes); + return address.encode(); + } + + static TransactionInfo? parsePayload(Uint8List payload) { + try { + final input = Input.fromBytes(payload); + + // Read pallet index (first byte) + final palletIndex = U8Codec.codec.decode(input); + + // Read the call data (remaining bytes) + final callData = input.readBytes(input.remainingLength ?? 0); + + if (palletIndex == 2) { + // Balances pallet + return _parseBalancesCall(callData); + } else if (palletIndex == 13) { + // ReversibleTransfers pallet + return _parseReversibleTransfersCall(callData); + } + + // Unknown pallet + return null; + } catch (e) { + print('Error parsing payload: $e'); + return null; + } + } + + static TransactionInfo? _parseBalancesCall(Uint8List callData) { + try { + final input = Input.fromBytes(callData); + final callIndex = U8Codec.codec.decode(input); + + if (callIndex == 0) { + // transfer_allow_death + final dest = _parseMultiAddress(input); + final amount = CompactBigIntCodec.codec.decode(input); + return TransactionInfo(toAddress: dest, amount: amount, isReversible: false); + } else if (callIndex == 3) { + // transfer_keep_alive + final dest = _parseMultiAddress(input); + final amount = CompactBigIntCodec.codec.decode(input); + return TransactionInfo(toAddress: dest, amount: amount, isReversible: false); + } + } catch (e) { + print('Error parsing balances call: $e'); + } + return null; + } + + static TransactionInfo? _parseReversibleTransfersCall(Uint8List callData) { + try { + final input = Input.fromBytes(callData); + final callIndex = U8Codec.codec.decode(input); + + if (callIndex == 3) { + // schedule_transfer + final dest = _parseMultiAddress(input); + final amount = U128Codec.codec.decode(input); + return TransactionInfo( + toAddress: dest, + amount: amount, + isReversible: true, + reversibleTimeframe: null, // Uses configured delay + ); + } else if (callIndex == 4) { + // schedule_transfer_with_delay + final dest = _parseMultiAddress(input); + final amount = U128Codec.codec.decode(input); + final delay = _parseBlockNumberOrTimestamp(input); + return TransactionInfo(toAddress: dest, amount: amount, isReversible: true, reversibleTimeframe: delay); + // } else if (callIndex == 5) { + // // schedule_asset_transfer + // final assetId = U32Codec.codec.decode(input); + // final dest = _parseMultiAddress(input); + // final amount = U128Codec.codec.decode(input); + // return TransactionInfo( + // toAddress: dest, + // amount: amount, + // isReversible: true, + // reversibleTimeframe: null, // Uses configured delay + // ); + // } else if (callIndex == 6) { + // // schedule_asset_transfer_with_delay + // final assetId = U32Codec.codec.decode(input); + // final dest = _parseMultiAddress(input); + // final amount = U128Codec.codec.decode(input); + // final delay = _parseBlockNumberOrTimestamp(input); + // return TransactionInfo(toAddress: dest, amount: amount, isReversible: true, reversibleTimeframe: delay); + } + } catch (e) { + print('Error parsing reversible transfers call: $e'); + } + return null; + } + + static String _parseMultiAddress(Input input) { + final addressType = U8Codec.codec.decode(input); + + switch (addressType) { + case 0: // Id(AccountId) + final accountId = input.readBytes(32); + return bytesToSs58(accountId); + case 1: // Index(Compact) + final index = CompactBigIntCodec.codec.decode(input); + return 'Index($index)'; + case 2: // Raw(Vec) + final length = CompactBigIntCodec.codec.decode(input); + final raw = input.readBytes(length.toInt()); + return 'Raw(0x${hex.encode(raw)})'; + case 3: // Address32([u8; 32]) + final address32 = input.readBytes(32); + return bytesToSs58(address32); + case 4: // Address20([u8; 20]) + final address20 = input.readBytes(20); + return '0x${hex.encode(address20)}'; + default: + throw Exception('Unknown MultiAddress type: $addressType'); + } + } + + static int? _parseBlockNumberOrTimestamp(Input input) { + final variant = U8Codec.codec.decode(input); + + if (variant == 0) { + // BlockNumber(u32) + return U32Codec.codec.decode(input); + } else if (variant == 1) { + // Timestamp(u64) + final timestamp = U64Codec.codec.decode(input); + return timestamp.toInt(); + } + + return null; + } +} diff --git a/quantus_sdk/lib/src/quantus_signing_payload.dart b/quantus_sdk/lib/src/quantus_signing_payload.dart new file mode 100644 index 00000000..a4d6bfbc --- /dev/null +++ b/quantus_sdk/lib/src/quantus_signing_payload.dart @@ -0,0 +1,146 @@ +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +import 'package:polkadart/extrinsic/signed_extensions/signed_extensions_abstract.dart'; +import 'package:polkadart/polkadart.dart'; +import 'package:polkadart/scale_codec.dart'; + +class QuantusSigningPayload extends SigningPayload { + /// + /// Create a new instance of [SigningPayload] + /// + /// For adding assetId or other custom signedExtensions to the payload, use [customSignedExtensions] with key 'assetId' with its mapped value. + const QuantusSigningPayload({ + required super.method, + required super.specVersion, + required super.transactionVersion, + required super.genesisHash, + required super.blockHash, + required super.blockNumber, + required super.eraPeriod, + required super.nonce, + required super.tip, + super.metadataHash, + super.customSignedExtensions, + }) : super(); + + // This code is 1:1 the same as the original SigningPayload.encode, but we don't hash the result so the HW wallet can parse and display the call correctly. + // Based on Polkadart v0.7.3 SigningPayload.encode() + Uint8List encodeRaw(dynamic registry) { + if (customSignedExtensions.isNotEmpty && registry is! Registry) { + throw Exception( + 'Custom signed extensions are not supported on this registry. Please use registry from `runtimeMetadata.chainInfo.scaleCodec.registry`.', + ); + } + final ByteOutput tempOutput = ByteOutput(); + + tempOutput.write(method); + + final ByteOutput output = ByteOutput(); + + late final SignedExtensions signedExtensions; + if (usesChargeAssetTxPayment(registry)) { + signedExtensions = SignedExtensions.assetHubSignedExtensions; + } else { + signedExtensions = SignedExtensions.substrateSignedExtensions; + } + + final encodedMap = toEncodedMap(registry); + + late List signedExtensionKeys; + + // + // + // Do the keys preparation of signedExtensions + { + if (registry.getSignedExtensionTypes() is Map) { + // Usage here for the Registry from the polkadart_scale_codec + signedExtensionKeys = (registry.getSignedExtensionTypes() as Map>).keys.toList(); + } else { + // Usage here for the generated lib from the polkadart_cli + signedExtensionKeys = (registry.getSignedExtensionTypes() as List).cast(); + } + } + + // + // Traverse through the signedExtension keys and encode the payload + for (final extension in signedExtensionKeys) { + final (payload, found) = signedExtensions.signedExtension(extension, encodedMap); + if (found) { + if (payload.isNotEmpty) { + tempOutput.write(hex.decode(payload)); + } + } else { + if (registry.getSignedExtensionTypes() is List) { + // This method call is from polkadot cli and not from the Reigstry of the polkadart_scale_codec. + continue; + } + // Most probably, it is a custom signed extension. + // check if this signed extension is NullCodec or not! + final signedExtensionMap = registry.getSignedExtensionTypes(); + print(signedExtensionMap); + if (signedExtensionMap[extension] != null && + signedExtensionMap[extension] is! NullCodec && + signedExtensionMap[extension].hashCode != NullCodec.codec.hashCode) { + if (customSignedExtensions.containsKey(extension) == false) { + // throw exception as this is encodable key and we need this key to be present in customSignedExtensions + throw Exception('Key `$extension` is missing in customSignedExtensions.'); + } + signedExtensionMap[extension].encodeTo(customSignedExtensions[extension], tempOutput); + } + } + } + + late List additionalSignedExtensionKeys; + { + // + // Do the keys preparation of signedExtensions + if (registry.getSignedExtensionTypes() is Map) { + // Usage here for the Registry from the polkadart_scale_codec + additionalSignedExtensionKeys = (registry.getAdditionalSignedExtensionTypes() as Map>) + .keys + .toList(); + } else { + // Usage here for the generated lib from the polkadart_cli + additionalSignedExtensionKeys = (registry.getSignedExtensionExtra() as List).cast(); + } + } + + // + // Traverse through the additionalSignedExtension keys and encode the payload + for (final extension in additionalSignedExtensionKeys) { + final (payload, found) = signedExtensions.additionalSignedExtension(extension, encodedMap); + if (found) { + if (payload.isNotEmpty) { + tempOutput.write(hex.decode(payload)); + } + } else { + // Most probably, it is a custom signed extension. + // check if this signed extension is NullCodec or not! + if (registry.getSignedExtensionTypes() is List) { + // This method call is from polkadot cli and not from the Registry of the polkadart_scale_codec. + continue; + } + final additionalSignedExtensionMap = registry.getAdditionalSignedExtensionTypes(); + if (additionalSignedExtensionMap[extension] != null && + additionalSignedExtensionMap[extension] is! NullCodec && + additionalSignedExtensionMap[extension].hashCode != NullCodec.codec.hashCode) { + if (customSignedExtensions.containsKey(extension) == false) { + // throw exception as this is encodable key and we need this key to be present in customSignedExtensions + throw Exception('Key `$extension` is missing in customSignedExtensions.'); + } + additionalSignedExtensionMap[extension].encodeTo(customSignedExtensions[extension], tempOutput); + } + } + } + + output.write(tempOutput.toBytes()); + final payloadEncoded = output.toBytes(); + + // This is the only difference between the original SigningPayload and the QuantusSigningPayload.encodeRaw. + // We don't hash the result so the HW wallet can parse and display the call correctly. + return payloadEncoded; + // See rust code: https://github.com/paritytech/polkadot-sdk/blob/e349fc9ef8354eea1bafc1040c20d6fe3189e1ec/substrate/primitives/runtime/src/generic/unchecked_extrinsic.rs#L253 + // return payloadEncoded.length > 256 ? Blake2bHasher(32).hash(payloadEncoded) : payloadEncoded; + } +} diff --git a/quantus_sdk/lib/src/rust/api/crypto.dart b/quantus_sdk/lib/src/rust/api/crypto.dart index b6078503..0224a262 100644 --- a/quantus_sdk/lib/src/rust/api/crypto.dart +++ b/quantus_sdk/lib/src/rust/api/crypto.dart @@ -45,6 +45,15 @@ Keypair crystalCharlie() => RustLib.instance.api.crateApiCryptoCrystalCharlie(); Uint8List deriveHdPath({required List seed, required String path}) => RustLib.instance.api.crateApiCryptoDeriveHdPath(seed: seed, path: path); +int get publicKeySize => + RustLib.instance.api.crateApiCryptoPublicKeyBytes().toInt(); // these are ussize and anyway small + +int get secretKeySize => + RustLib.instance.api.crateApiCryptoSecretKeyBytes().toInt(); // these are ussize and anyway small + +int get signatureSize => + RustLib.instance.api.crateApiCryptoSignatureBytes().toInt(); // these are ussize and anyway small + // Rust type: RustOpaqueMoi> abstract class HdLatticeError implements RustOpaqueInterface {} diff --git a/quantus_sdk/lib/src/rust/api/ur.dart b/quantus_sdk/lib/src/rust/api/ur.dart new file mode 100644 index 00000000..0e00aa71 --- /dev/null +++ b/quantus_sdk/lib/src/rust/api/ur.dart @@ -0,0 +1,13 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +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); + +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 f9e6e10f..f36ec283 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.dart @@ -4,6 +4,7 @@ // ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field import 'api/crypto.dart'; +import 'api/ur.dart'; import 'dart:async'; import 'dart:convert'; import 'frb_generated.dart'; @@ -62,7 +63,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.11.1'; @override - int get rustContentHash => 390198677; + int get rustContentHash => 1692591137; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( stem: 'rust_lib_resonance_network_wallet', @@ -78,8 +79,12 @@ abstract class RustLibApi extends BaseApi { Keypair crateApiCryptoCrystalCharlie(); + Uint8List crateApiUrDecodeUr({required List urParts}); + Uint8List crateApiCryptoDeriveHdPath({required List seed, required String path}); + List crateApiUrEncodeUr({required List data}); + Keypair crateApiCryptoGenerateDerivedKeypair({required String mnemonicStr, required String path}); Keypair crateApiCryptoGenerateKeypair({required String mnemonicStr}); @@ -88,6 +93,12 @@ abstract class RustLibApi extends BaseApi { Future crateApiCryptoInitApp(); + bool crateApiUrIsCompleteUr({required List urParts}); + + BigInt crateApiCryptoPublicKeyBytes(); + + BigInt crateApiCryptoSecretKeyBytes(); + void crateApiCryptoSetDefaultSs58Prefix({required int prefix}); Uint8List crateApiCryptoSignMessage({required Keypair keypair, required List message, U8Array32? entropy}); @@ -98,6 +109,8 @@ abstract class RustLibApi extends BaseApi { U8Array32? entropy, }); + BigInt crateApiCryptoSignatureBytes(); + Uint8List crateApiCryptoSs58ToAccountId({required String s}); String crateApiCryptoToAccountId({required Keypair obj}); @@ -179,6 +192,25 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiCryptoCrystalCharlieConstMeta => const TaskConstMeta(debugName: 'crystal_charlie', argNames: []); + @override + Uint8List crateApiUrDecodeUr({required List urParts}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_String(urParts, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 4)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: sse_decode_String), + constMeta: kCrateApiUrDecodeUrConstMeta, + argValues: [urParts], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiUrDecodeUrConstMeta => const TaskConstMeta(debugName: 'decode_ur', argNames: ['urParts']); + @override Uint8List crateApiCryptoDeriveHdPath({required List seed, required String path}) { return handler.executeSync( @@ -187,7 +219,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_prim_u_8_loose(seed, serializer); sse_encode_String(path, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 4)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 5)!; }, codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: null), constMeta: kCrateApiCryptoDeriveHdPathConstMeta, @@ -200,6 +232,25 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiCryptoDeriveHdPathConstMeta => const TaskConstMeta(debugName: 'derive_hd_path', argNames: ['seed', 'path']); + @override + List crateApiUrEncodeUr({required List data}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_prim_u_8_loose(data, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 6)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_list_String, decodeErrorData: sse_decode_String), + constMeta: kCrateApiUrEncodeUrConstMeta, + argValues: [data], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiUrEncodeUrConstMeta => const TaskConstMeta(debugName: 'encode_ur', argNames: ['data']); + @override Keypair crateApiCryptoGenerateDerivedKeypair({required String mnemonicStr, required String path}) { return handler.executeSync( @@ -208,7 +259,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(mnemonicStr, serializer); sse_encode_String(path, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 5)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 7)!; }, codec: SseCodec( decodeSuccessData: sse_decode_keypair, @@ -232,7 +283,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(mnemonicStr, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 6)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8)!; }, codec: SseCodec(decodeSuccessData: sse_decode_keypair, decodeErrorData: null), constMeta: kCrateApiCryptoGenerateKeypairConstMeta, @@ -252,7 +303,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_prim_u_8_loose(seed, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 7)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 9)!; }, codec: SseCodec(decodeSuccessData: sse_decode_keypair, decodeErrorData: null), constMeta: kCrateApiCryptoGenerateKeypairFromSeedConstMeta, @@ -271,7 +322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 10, port: port_); }, codec: SseCodec(decodeSuccessData: sse_decode_unit, decodeErrorData: null), constMeta: kCrateApiCryptoInitAppConstMeta, @@ -283,6 +334,64 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiCryptoInitAppConstMeta => const TaskConstMeta(debugName: 'init_app', argNames: []); + @override + bool crateApiUrIsCompleteUr({required List urParts}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_String(urParts, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_bool, decodeErrorData: null), + constMeta: kCrateApiUrIsCompleteUrConstMeta, + argValues: [urParts], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiUrIsCompleteUrConstMeta => + const TaskConstMeta(debugName: 'is_complete_ur', argNames: ['urParts']); + + @override + BigInt crateApiCryptoPublicKeyBytes() { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_usize, decodeErrorData: null), + constMeta: kCrateApiCryptoPublicKeyBytesConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiCryptoPublicKeyBytesConstMeta => + const TaskConstMeta(debugName: 'public_key_bytes', argNames: []); + + @override + BigInt crateApiCryptoSecretKeyBytes() { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_usize, decodeErrorData: null), + constMeta: kCrateApiCryptoSecretKeyBytesConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiCryptoSecretKeyBytesConstMeta => + const TaskConstMeta(debugName: 'secret_key_bytes', argNames: []); + @override void crateApiCryptoSetDefaultSs58Prefix({required int prefix}) { return handler.executeSync( @@ -290,7 +399,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_u_16(prefix, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 9)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!; }, codec: SseCodec(decodeSuccessData: sse_decode_unit, decodeErrorData: null), constMeta: kCrateApiCryptoSetDefaultSs58PrefixConstMeta, @@ -312,7 +421,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_box_autoadd_keypair(keypair, serializer); sse_encode_list_prim_u_8_loose(message, serializer); sse_encode_opt_u_8_array_32(entropy, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 10)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!; }, codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: null), constMeta: kCrateApiCryptoSignMessageConstMeta, @@ -338,7 +447,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_box_autoadd_keypair(keypair, serializer); sse_encode_list_prim_u_8_loose(message, serializer); sse_encode_opt_u_8_array_32(entropy, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!; }, codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: null), constMeta: kCrateApiCryptoSignMessageWithPubkeyConstMeta, @@ -351,6 +460,25 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiCryptoSignMessageWithPubkeyConstMeta => const TaskConstMeta(debugName: 'sign_message_with_pubkey', argNames: ['keypair', 'message', 'entropy']); + @override + BigInt crateApiCryptoSignatureBytes() { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_usize, decodeErrorData: null), + constMeta: kCrateApiCryptoSignatureBytesConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiCryptoSignatureBytesConstMeta => + const TaskConstMeta(debugName: 'signature_bytes', argNames: []); + @override Uint8List crateApiCryptoSs58ToAccountId({required String s}) { return handler.executeSync( @@ -358,7 +486,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(s, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!; }, codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: null), constMeta: kCrateApiCryptoSs58ToAccountIdConstMeta, @@ -378,7 +506,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_box_autoadd_keypair(obj, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!; }, codec: SseCodec(decodeSuccessData: sse_decode_String, decodeErrorData: null), constMeta: kCrateApiCryptoToAccountIdConstMeta, @@ -404,7 +532,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_box_autoadd_keypair(keypair, serializer); sse_encode_list_prim_u_8_loose(message, serializer); sse_encode_list_prim_u_8_loose(signature, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!; }, codec: SseCodec(decodeSuccessData: sse_decode_bool, decodeErrorData: null), constMeta: kCrateApiCryptoVerifyMessageConstMeta, @@ -466,6 +594,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + List dco_decode_list_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_String).toList(); + } + @protected List dco_decode_list_prim_u_8_loose(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -557,6 +691,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return Keypair(publicKey: var_publicKey, secretKey: var_secretKey); } + @protected + List sse_decode_list_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_String(deserializer)); + } + return ans_; + } + @protected List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -661,6 +807,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_strict(self.secretKey, serializer); } + @protected + void sse_encode_list_String(List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_String(item, serializer); + } + } + @protected void sse_encode_list_prim_u_8_loose(List 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 e4b696f4..3e6e1fed 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.io.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.io.dart @@ -4,6 +4,7 @@ // ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field import 'api/crypto.dart'; +import 'api/ur.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:ffi' as ffi; @@ -41,6 +42,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Keypair dco_decode_keypair(dynamic raw); + @protected + List dco_decode_list_String(dynamic raw); + @protected List dco_decode_list_prim_u_8_loose(dynamic raw); @@ -87,6 +91,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Keypair sse_decode_keypair(SseDeserializer deserializer); + @protected + List sse_decode_list_String(SseDeserializer deserializer); + @protected List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); @@ -138,6 +145,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_keypair(Keypair self, SseSerializer serializer); + @protected + void sse_encode_list_String(List self, SseSerializer serializer); + @protected void sse_encode_list_prim_u_8_loose(List 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 abddbfae..82ebb48d 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.web.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.web.dart @@ -7,6 +7,7 @@ // ignore_for_file: argument_type_not_assignable import 'api/crypto.dart'; +import 'api/ur.dart'; import 'dart:async'; import 'dart:convert'; import 'frb_generated.dart'; @@ -43,6 +44,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Keypair dco_decode_keypair(dynamic raw); + @protected + List dco_decode_list_String(dynamic raw); + @protected List dco_decode_list_prim_u_8_loose(dynamic raw); @@ -89,6 +93,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Keypair sse_decode_keypair(SseDeserializer deserializer); + @protected + List sse_decode_list_String(SseDeserializer deserializer); + @protected List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); @@ -140,6 +147,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_keypair(Keypair self, SseSerializer serializer); + @protected + void sse_encode_list_String(List self, SseSerializer serializer); + @protected void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); diff --git a/quantus_sdk/lib/src/services/accounts_service.dart b/quantus_sdk/lib/src/services/accounts_service.dart index 0d285a1f..e66c7e0e 100644 --- a/quantus_sdk/lib/src/services/accounts_service.dart +++ b/quantus_sdk/lib/src/services/accounts_service.dart @@ -23,7 +23,7 @@ class AccountsService { if (mnemonic == null) { throw Exception('Mnemonic not found. Cannot create new account.'); } - final nextIndex = await _settingsService.getNextFreeAccountIndex(); + final nextIndex = await _settingsService.getNextFreeAccountIndex(walletIndex); final keypair = HdWalletService().keyPairAtIndex(mnemonic, nextIndex); final newAccount = Account( walletIndex: walletIndex, diff --git a/quantus_sdk/lib/src/services/chain_history_service.dart b/quantus_sdk/lib/src/services/chain_history_service.dart index 8c86477d..2ff990d5 100644 --- a/quantus_sdk/lib/src/services/chain_history_service.dart +++ b/quantus_sdk/lib/src/services/chain_history_service.dart @@ -723,8 +723,9 @@ query SearchPendingTransaction( final transferData = eventJson['transfer'] as Map; transaction = TransferEvent.fromJson(transferData); } + final block = transaction.blockNumber; - print('Found 1 matching transactions for pending transaction'); + print('Found 1 matching transactions for pending transaction at block $block'); return transaction; } catch (e, stackTrace) { print('Error searching for pending transaction: $e'); diff --git a/quantus_sdk/lib/src/services/hd_wallet_service.dart b/quantus_sdk/lib/src/services/hd_wallet_service.dart index 9b0c862b..001c172f 100644 --- a/quantus_sdk/lib/src/services/hd_wallet_service.dart +++ b/quantus_sdk/lib/src/services/hd_wallet_service.dart @@ -13,7 +13,7 @@ class HdWalletService { Keypair _deriveHDWallet({required String mnemonic, int account = 0, int change = 0, int addressIndex = 0}) { // m/44'/189189'/0'/0/0 final derivationPath = "m/44'/189189'/$account'/$change/$addressIndex"; - print('derivationPath: $derivationPath'); + // print('derivationPath: $derivationPath'); return crypto.generateDerivedKeypair(mnemonicStr: mnemonic, path: derivationPath); } diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index 7ae41ea0..0661acb0 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -20,6 +20,7 @@ class SettingsService { static const String _oldAccountsKeyV2 = 'accounts_v2'; static const String _oldAccountsKeyV1 = 'accounts'; static const String _activeAccountIndexKey = 'active_account_index'; + static const String _activeAccountIdKey = 'active_account_id'; // Local authentication keys static const String _isLocalAuthEnabledKey = 'is_local_auth_enabled'; @@ -47,7 +48,9 @@ class SettingsService { final accountsJson = _prefs.getString(_accountsKey); if (accountsJson != null) { final decoded = jsonDecode(accountsJson) as List; - return decoded.map((e) => Account.fromJson(e)).toList()..sort((a, b) => a.index.compareTo(b.index)); + return decoded.map((e) => Account.fromJson(e)).toList()..sort( + (a, b) => a.walletIndex != b.walletIndex ? a.walletIndex.compareTo(b.walletIndex) : a.index.compareTo(b.index), + ); } // Migration for existing single-account users final oldAccountId = _prefs.getString('account_id'); @@ -94,7 +97,9 @@ class SettingsService { Future addAccount(Account account) async { final accounts = await getAccounts(); // Check for duplicates by index or accountId before adding - if (!accounts.any((a) => a.index == account.index || a.accountId == account.accountId)) { + if (!accounts.any( + (a) => (a.walletIndex == account.walletIndex && a.index == account.index) || a.accountId == account.accountId, + )) { accounts.add(account); await saveAccounts(accounts); if (accounts.length == 1) { @@ -108,7 +113,7 @@ class SettingsService { Future updateAccount(Account account) async { final accounts = await getAccounts(); - final index = accounts.indexWhere((a) => a.index == account.index); + final index = accounts.indexWhere((a) => a.accountId == account.accountId); if (index != -1) { accounts[index] = account; await saveAccounts(accounts); @@ -120,50 +125,67 @@ class SettingsService { if (accounts.length == 1) { throw Exception('Cant remove last account!'); } - if (account.index == 0) { - throw Exception("Can't remove the root account"); + if (account.accountId == await _getActiveAccountId()) { + await _setActiveAccountId(accounts[0].accountId); } - if (account.index == _getActiveAccountIndex()) { - _setActiveAccountIndex(accounts[0].index); - } - accounts.removeWhere((a) => a.index == account.index); + accounts.removeWhere((a) => a.accountId == account.accountId); await saveAccounts(accounts); } Future setActiveAccount(Account account) async { - final accountExists = await getAccount(account.index); - if (accountExists != null) { - _setActiveAccountIndex(account.index); + final exists = (await getAccounts()).any((a) => a.accountId == account.accountId); + if (exists) { + await _setActiveAccountId(account.accountId); } else { throw Exception('Account index does not exist'); } } + Future _getActiveAccountId() async { + final id = _prefs.getString(_activeAccountIdKey); + if (id != null && id.isNotEmpty) return id; + + final legacyIndex = _getActiveAccountIndex(); + final accounts = await getAccounts(); + if (accounts.isEmpty) return null; + final legacyAccount = accounts.firstWhere( + (a) => a.walletIndex == 0 && a.index == legacyIndex, + orElse: () => accounts.first, + ); + + await _setActiveAccountId(legacyAccount.accountId); + return legacyAccount.accountId; + } + int _getActiveAccountIndex() { return _prefs.getInt(_activeAccountIndexKey) ?? 0; } - void _setActiveAccountIndex(int index) { - final oldIndex = _getActiveAccountIndex(); - if (index != oldIndex) { - _prefs.setInt(_activeAccountIndexKey, index); + Future _setActiveAccountId(String accountId) async { + final oldId = _prefs.getString(_activeAccountIdKey); + if (oldId != accountId) { + await _prefs.setString(_activeAccountIdKey, accountId); } } Future getActiveAccount() async { - final activeIndex = _getActiveAccountIndex(); - return getAccount(activeIndex); + final activeAccountId = await _getActiveAccountId(); + final accounts = await getAccounts(); + final ix = accounts.indexWhere((a) => a.accountId == activeAccountId); + return ix != -1 ? accounts[ix] : (accounts.isNotEmpty ? accounts.first : null); } - Future getAccount(int index) async { + Future getAccount({required int walletIndex, required int index}) async { final accounts = await getAccounts(); - final ix = accounts.indexWhere((a) => a.index == index); + final ix = accounts.indexWhere((a) => a.walletIndex == walletIndex && a.index == index); return ix != -1 ? accounts[ix] : null; } - Future getNextFreeAccountIndex() async { + Future getNextFreeAccountIndex(int walletIndex) async { final accounts = await getAccounts(); - final maxIndex = accounts.map((a) => a.index).reduce((a, b) => a > b ? a : b); + final walletAccounts = accounts.where((a) => a.walletIndex == walletIndex && a.index >= 0).toList(); + if (walletAccounts.isEmpty) return 0; + final maxIndex = walletAccounts.map((a) => a.index).reduce((a, b) => a > b ? a : b); return maxIndex + 1; } diff --git a/quantus_sdk/lib/src/services/substrate_service.dart b/quantus_sdk/lib/src/services/substrate_service.dart index a4e7f210..c0f3327c 100644 --- a/quantus_sdk/lib/src/services/substrate_service.dart +++ b/quantus_sdk/lib/src/services/substrate_service.dart @@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart'; import 'package:polkadart/polkadart.dart'; import 'package:quantus_sdk/generated/schrodinger/schrodinger.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:quantus_sdk/src/extensions/account_extension.dart'; import 'package:quantus_sdk/src/resonance_extrinsic_payload.dart'; import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; import 'package:ss58/ss58.dart'; @@ -40,6 +39,9 @@ class SubstrateService { }); print('getFee: $result'); + if (result.error != null) { + throw Exception('RPC Error: ${result.error}'); + } final partialFeeString = result.result['partialFee'] as String; final partialFee = BigInt.parse(partialFeeString); print('partialFee: $partialFee'); @@ -96,9 +98,11 @@ class SubstrateService { } Future getFeeForCall(Account account, RuntimeCall call) async { - final extrinsic = await getExtrinsicPayload(account, call); + // We use a dummy signature for fee estimation to avoid prompting for password/device. + // The node needs a properly formatted signed extrinsic to estimate fees, even if the signature is invalid. + final extrinsic = await getExtrinsicPayload(account, call, isSigned: false); final fee = await getFee(extrinsic.payload); - return ExtrinsicFeeData(fee: fee, extrinsicData: extrinsic); + return ExtrinsicFeeData(fee: fee, blockHash: extrinsic.blockHash, blockNumber: extrinsic.blockNumber); } /// Submit a fully formatted extrinsic for block inclusion. @@ -146,13 +150,7 @@ class SubstrateService { throw Exception('Failed to submit extrinsic after $maxRetries retries.'); } - Future getExtrinsicPayload(Account account, RuntimeCall call) async { - final mnemonic = await account.getMnemonic(); - if (mnemonic == null) { - throw Exception('Mnemonic not found for signing.'); - } - final senderWallet = HdWalletService().keyPairAtIndex(mnemonic, account.index); - + 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); @@ -162,7 +160,7 @@ class SubstrateService { _getGenesisHash(), _getBlockNumber(), _getBlockHash(), - _getNextAccountNonce(senderWallet), + _getNextAccountNonceFromAddress(account.accountId), ]); final [specVersion, transactionVersion] = [runtimeVersion.specVersion, runtimeVersion.transactionVersion]; @@ -187,26 +185,111 @@ class SubstrateService { final payload = payloadToSign.encode(registry); - final signature = crypto.signMessage(keypair: senderWallet, message: payload); - final signatureWithPublicKeyBytes = _combineSignatureAndPubkey(signature, senderWallet.publicKey); + if (isSigned) { + final mnemonic = await account.getMnemonic(); + if (mnemonic == null) { + throw Exception('Mnemonic not found for signing.'); + } + final senderWallet = HdWalletService().keyPairAtIndex(mnemonic, account.index); + + final signature = crypto.signMessage(keypair: senderWallet, message: payload); + final signatureWithPublicKeyBytes = _combineSignatureAndPubkey(signature, senderWallet.publicKey); + + final extrinsic = ResonanceExtrinsicPayload( + signer: Uint8List.fromList(senderWallet.addressBytes), + method: encodedCall, + signature: signatureWithPublicKeyBytes, + eraPeriod: 64, + blockNumber: blockNumber, + nonce: nonce, + tip: 0, + ).encodeResonance(registry, ResonanceSignatureType.resonance); + + return ExtrinsicData(payload: extrinsic, blockNumber: blockNumber, blockHash: blockHash, nonce: nonce); + } else { + // Use a dummy signature for fee estimation + // 7219 is the size of the Dilithium signature + public key + final dummySignature = Uint8List(7219); + final signerBytes = getAccountId32(account.accountId); + + final extrinsic = ResonanceExtrinsicPayload( + signer: signerBytes, + method: encodedCall, + signature: dummySignature, + eraPeriod: 64, + blockNumber: blockNumber, + nonce: nonce, + tip: 0, + ).encodeResonance(registry, ResonanceSignatureType.resonance); + + return ExtrinsicData(payload: extrinsic, blockNumber: blockNumber, blockHash: blockHash, nonce: nonce); + } + } - final extrinsic = ResonanceExtrinsicPayload( - signer: Uint8List.fromList(senderWallet.addressBytes), + 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 encodedCall = call.encode(); + + final payloadToSign = QuantusSigningPayload( method: encodedCall, - signature: signatureWithPublicKeyBytes, - eraPeriod: 64, + specVersion: specVersion, + transactionVersion: transactionVersion, + genesisHash: genesisHash, + blockHash: blockHash, blockNumber: blockNumber, + eraPeriod: 64, nonce: nonce, tip: 0, - ).encodeResonance(registry, ResonanceSignatureType.resonance); + ); + + final registry = await _rpcEndpointService.rpcTask((uri) async { + final provider = Provider.fromUri(uri); + return Schrodinger(provider).registry; + }); + + return UnsignedTransactionData(payloadToSign: payloadToSign, signer: accountIdBytes, registry: registry); + } + + Future submitExtrinsicWithExternalSignature( + UnsignedTransactionData unsignedData, + Uint8List signature, + Uint8List publicKey, + ) async { + final signatureWithPublicKeyBytes = _combineSignatureAndPubkey(signature, publicKey); + + final payload = unsignedData.payloadToSign; + + final extrinsic = ResonanceExtrinsicPayload( + signer: unsignedData.signer, + method: payload.method, + signature: signatureWithPublicKeyBytes, + eraPeriod: payload.eraPeriod, + blockNumber: payload.blockNumber, + nonce: payload.nonce, + tip: payload.tip, + ).encodeResonance(unsignedData.registry, ResonanceSignatureType.resonance); - return ExtrinsicData(payload: extrinsic, blockNumber: blockNumber, blockHash: blockHash, nonce: nonce); + return await _submitExtrinsic(extrinsic); } - Future _getNextAccountNonce(Keypair senderWallet) async { + Future _getNextAccountNonceFromAddress(String address) async { final nonceResult = await _rpcEndpointService.rpcTask((uri) async { final provider = Provider.fromUri(uri); - return await provider.send('system_accountNextIndex', [senderWallet.ss58Address]); + return await provider.send('system_accountNextIndex', [address]); }); return int.parse(nonceResult.result.toString()); } diff --git a/quantus_sdk/lib/src/services/taskmaster_service.dart b/quantus_sdk/lib/src/services/taskmaster_service.dart index 835c94bb..6e2d200b 100644 --- a/quantus_sdk/lib/src/services/taskmaster_service.dart +++ b/quantus_sdk/lib/src/services/taskmaster_service.dart @@ -147,7 +147,6 @@ class TaskmasterService { final SettingsService _settingsService = SettingsService(); final HdWalletService _hd = HdWalletService(); - final _mainAccountIndex = 0; TokenInfo? _tokenInfo; String? get accessToken => _tokenInfo?.accessToken; bool get isLoggedIn => _tokenInfo != null && !_tokenInfo!.isExpired; @@ -415,7 +414,7 @@ class TaskmasterService { } Future getMainAccount() async { - final account = await _settingsService.getAccount(_mainAccountIndex); + final account = await _settingsService.getAccount(walletIndex: 0, index: 0); if (account == null) { throw Exception('No main account - this method should probably not be called when logged out'); } diff --git a/quantus_sdk/pubspec.lock b/quantus_sdk/pubspec.lock index e6709ab0..34253e89 100644 --- a/quantus_sdk/pubspec.lock +++ b/quantus_sdk/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: "direct main" description: name: bip39_mnemonic - sha256: e280b785d40dda3f70186d5a51126c5d3ae2c3b9a1bafdbd20a94b50c8253fb3 + sha256: dd6bdfc2547d986b2c00f99bba209c69c0b6fa5c1a185e1f728998282f1249d5 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "4.0.1" boolean_selector: dependency: transitive description: @@ -366,10 +366,10 @@ packages: dependency: transitive description: name: hashlib_codecs - sha256: "8cea9ccafcfeaa7324d2ae52c61c69f7ff71f4237507a018caab31b9e416e3b1" + sha256: "0e1a17c47792fd131a9bf49b811c394b22516287746ee14cd0b0c22a34136699" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "3.0.1" http: dependency: "direct main" description: @@ -390,8 +390,8 @@ packages: dependency: "direct main" description: path: dart - ref: "v1.0.0" - resolved-ref: fa788c17e08ae463f8f3a9690e2ecdc4d0b8565f + ref: "v1.1.0" + resolved-ref: "6eb6ffbddab6f7ebc00cd79a4c36b2da23bb8347" url: "https://github.com/Quantus-Network/human-checkphrase.git" source: git version: "0.1.0" @@ -596,18 +596,18 @@ packages: dependency: transitive description: name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" url: "https://pub.dev" source: hosted - version: "3.9.1" + version: "4.0.0" polkadart: dependency: "direct main" description: name: polkadart - sha256: b2369eeb33ee155dcdf2a7af18de8961037b9cc28640e200f1475cea497bc3ab + sha256: c91901620ba4b0de1f80fe406d509a404f3ed3fde059e7483a1a597ee4ab96da url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.3" polkadart_cli: dependency: "direct main" description: @@ -620,18 +620,18 @@ packages: dependency: "direct main" description: name: polkadart_keyring - sha256: e99a93c845466dfb53e23bf650bd6ccd772d7cefe7c7ff2c93c6e86d387e5215 + sha256: cb1b9733bfbd603d73410ca68e808b0921e24291cdc02ade18907980c5c6872c url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.1" polkadart_scale_codec: dependency: transitive description: name: polkadart_scale_codec - sha256: "50138c2f0c8f99779c1bf35c1fc44483ccf4a23d7bf625d925e3a9a790d02630" + sha256: "07044bf15d5c02ee79984b2696dcf25c598563eca12e70bc9ff45dd7948d93d5" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.6.0" process: dependency: transitive description: @@ -699,10 +699,10 @@ packages: dependency: transitive description: name: secp256k1_ecdsa - sha256: "50bca61d6ad872829f6bd05c17a96441c1bcfe456ed5bf8cba710ceac86db5f1" + sha256: e2215e3351ad24b603d856a55814ddc90bc158f3ff25efecde4ab318a71a04a7 url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.2" shared_preferences: dependency: "direct main" description: @@ -776,10 +776,10 @@ packages: dependency: transitive description: name: sr25519 - sha256: "122c930a933da6af018ec4a2ccdb853a7d0eb1a0891bfb03467e366b56f86f12" + sha256: "38c840abe245d4e777f1b7593d8f72ae463801c8ef9012a00d2d244f3a944fe3" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.1" ss58: dependency: "direct main" description: @@ -824,18 +824,18 @@ packages: dependency: transitive description: name: substrate_bip39 - sha256: ba880015808079804f40a0fde8c5bff0315ec6792fe3a4281a704e408f1c6bdb + sha256: "8103aafb10df3b489feaadfc8874dbe2d8474f3deab0383657b4fa5af22fcb39" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.1" substrate_metadata: dependency: transitive description: name: substrate_metadata - sha256: "6f9e9e3e1078c0c143dd63824043370b9df582d34c431a759253ce2f9cbb7f13" + sha256: "252a4a00a23b7da4982d7d2ac5154b6164fd71b3eb0a113faa0ce7983b4e0035" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" sync_http: dependency: transitive description: diff --git a/quantus_sdk/pubspec.yaml b/quantus_sdk/pubspec.yaml index 0d5ce25e..e10906b9 100644 --- a/quantus_sdk/pubspec.yaml +++ b/quantus_sdk/pubspec.yaml @@ -42,7 +42,7 @@ dependencies: human_checksum: git: url: https://github.com/Quantus-Network/human-checkphrase.git - ref: v1.0.0 + ref: v1.1.0 path: dart # Generate Polkadart bindings diff --git a/quantus_sdk/rust/Cargo.lock b/quantus_sdk/rust/Cargo.lock index 1f8e33ab..55c17ea6 100644 --- a/quantus_sdk/rust/Cargo.lock +++ b/quantus_sdk/rust/Cargo.lock @@ -27,6 +27,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "aead" version = "0.5.2" @@ -323,7 +329,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d193de1f7487df1914d3a568b772458861d33f9c54249612cc2893d6915054" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.13.0", ] [[package]] @@ -332,6 +338,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" +[[package]] +name = "bitcoin-private" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73290177011694f38ec25e165d0387ab7ea749a4b81cd4c80dae5988229f7a57" + +[[package]] +name = "bitcoin_hashes" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7066118b13d4b20b23645932dfb3a81ce7e29f95726c2036fa33cd7b092501" +dependencies = [ + "bitcoin-private", +] + [[package]] name = "bitcoin_hashes" version = "0.13.0" @@ -422,6 +443,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ + "sha2 0.10.9", "tinyvec", ] @@ -534,6 +556,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "core2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239fa3ae9b63c2dc74bd3fa852d4792b8b305ae64eeede946265b6af62f1fff3" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -543,6 +574,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -868,6 +923,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "expander" version = "2.2.1" @@ -877,12 +942,18 @@ dependencies = [ "blake2", "file-guard", "fs-err", - "prettyplease", + "prettyplease 0.2.36", "proc-macro2", "quote", "syn 2.0.104", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "ff" version = "0.13.1" @@ -921,6 +992,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flutter_rust_bridge" version = "2.11.1" @@ -1096,6 +1173,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "getrandom_or_panic" version = "0.0.3" @@ -1168,6 +1257,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1201,6 +1296,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "impl-codec" version = "0.7.1" @@ -1359,6 +1463,27 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libflate" +version = "1.3.0" +source = "git+https://github.com/KeystoneHQ/libflate.git?tag=1.3.1#e6236f7417b9bd34dbbd4b3c821be10299c44a73" +dependencies = [ + "adler32", + "core2", + "crc32fast", + "libflate_lz77", +] + +[[package]] +name = "libflate_lz77" +version = "1.2.0" +source = "git+https://github.com/KeystoneHQ/libflate.git?tag=1.3.1#e6236f7417b9bd34dbbd4b3c821be10299c44a73" +dependencies = [ + "core2", + "hashbrown 0.13.2", + "rle-decode-fast", +] + [[package]] name = "libsecp256k1" version = "0.7.2" @@ -1405,6 +1530,18 @@ dependencies = [ "libsecp256k1-core", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "lock_api" version = "0.4.13" @@ -1475,6 +1612,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "minicbor" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7005aaf257a59ff4de471a9d5538ec868a21586534fff7f85dd97d4043a6139" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1154809406efdb7982841adb6311b3d095b46f78342dd646736122fe6b19e267" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1492,9 +1649,15 @@ checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.59.0", ] +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + [[package]] name = "nam-tiny-hderive" version = "0.3.1-nam.0" @@ -1732,7 +1895,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e69bf016dc406eff7d53a7d3f7cf1c2e72c82b9088aac1118591e36dd2cd3e9" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.13.0", "rand 0.8.5", "rand_core 0.6.4", "serde", @@ -1818,6 +1981,58 @@ dependencies = [ "password-hash", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1935,6 +2150,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + [[package]] name = "prettyplease" version = "0.2.36" @@ -1977,6 +2202,60 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" +dependencies = [ + "bytes", + "heck", + "itertools 0.10.5", + "lazy_static", + "log", + "multimap", + "petgraph", + "prettyplease 0.1.25", + "prost", + "prost-types", + "regex", + "syn 1.0.109", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" +dependencies = [ + "prost", +] + [[package]] name = "qp-poseidon" version = "1.0.1" @@ -2053,6 +2332,18 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "quantus_ur" +version = "0.1.0" +source = "git+https://github.com/Quantus-Network/quantus_ur.git?tag=1.1.0#672d6a40170c3ab66bcd9c12966ff072e27cc4ac" +dependencies = [ + "hex", + "minicbor", + "ur", + "ur-parse-lib", + "ur-registry", +] + [[package]] name = "quote" version = "1.0.40" @@ -2062,6 +2353,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "radium" version = "0.7.0" @@ -2114,7 +2411,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", ] [[package]] @@ -2123,6 +2420,15 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -2206,6 +2512,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "rust_lib_resonance_network_wallet" version = "0.1.0" @@ -2216,6 +2528,7 @@ dependencies = [ "qp-poseidon", "qp-rusty-crystals-dilithium", "qp-rusty-crystals-hdwallet", + "quantus_ur", "sp-core 35.0.0", ] @@ -2240,6 +2553,32 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.59.0", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -2479,6 +2818,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.10" @@ -2954,6 +3299,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.59.0", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -3272,6 +3630,47 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "ur" +version = "0.3.0" +source = "git+https://github.com/KeystoneHQ/ur-rs?tag=0.3.3#81b8bb3b6b3a823128489c81ffee5bb4001ba2ae" +dependencies = [ + "bitcoin_hashes 0.12.0", + "crc", + "minicbor", + "phf", + "rand_xoshiro", +] + +[[package]] +name = "ur-parse-lib" +version = "0.2.0" +source = "git+https://github.com/KeystoneHQ/keystone-sdk-rust?tag=0.0.52#12c4d08dad2e0fb7b7dd05c4c11540dccc2f63bc" +dependencies = [ + "hex", + "ur", + "ur-registry", +] + +[[package]] +name = "ur-registry" +version = "0.1.1" +source = "git+https://github.com/KeystoneHQ/keystone-sdk-rust?tag=0.0.52#12c4d08dad2e0fb7b7dd05c4c11540dccc2f63bc" +dependencies = [ + "bs58", + "core2", + "hex", + "libflate", + "minicbor", + "paste", + "prost", + "prost-build", + "prost-types", + "serde", + "thiserror 1.0.69", + "ur", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3322,6 +3721,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -3403,6 +3811,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3425,7 +3845,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -3434,6 +3854,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.59.0" @@ -3443,6 +3869,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3516,6 +3951,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "wyz" version = "0.5.1" diff --git a/quantus_sdk/rust/Cargo.toml b/quantus_sdk/rust/Cargo.toml index 557782a7..8c0b4b0f 100644 --- a/quantus_sdk/rust/Cargo.toml +++ b/quantus_sdk/rust/Cargo.toml @@ -16,6 +16,7 @@ flutter_rust_bridge = "=2.11.1" hex = "0.4.3" nam-tiny-hderive = "0.3.1-nam.0" sp-core = "35.0.0" +quantus_ur = { git = "https://github.com/Quantus-Network/quantus_ur.git", tag = "1.1.0" } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] } diff --git a/quantus_sdk/rust/src/api/crypto.rs b/quantus_sdk/rust/src/api/crypto.rs index 9a6cfbde..543eec0b 100644 --- a/quantus_sdk/rust/src/api/crypto.rs +++ b/quantus_sdk/rust/src/api/crypto.rs @@ -138,6 +138,21 @@ pub fn derive_hd_path(seed: Vec, path: String) -> Vec { return ext.secret().to_vec(); } +#[flutter_rust_bridge::frb(sync)] +pub fn public_key_bytes() -> usize { + ml_dsa_87::PUBLICKEYBYTES +} + +#[flutter_rust_bridge::frb(sync)] +pub fn secret_key_bytes() -> usize { + ml_dsa_87::SECRETKEYBYTES +} + +#[flutter_rust_bridge::frb(sync)] +pub fn signature_bytes() -> usize { + ml_dsa_87::SIGNBYTES +} + #[flutter_rust_bridge::frb(init)] pub fn init_app() { // Default utilities - feel free to customize diff --git a/quantus_sdk/rust/src/api/mod.rs b/quantus_sdk/rust/src/api/mod.rs index 274f0edc..0db98d21 100644 --- a/quantus_sdk/rust/src/api/mod.rs +++ b/quantus_sdk/rust/src/api/mod.rs @@ -1 +1,2 @@ pub mod crypto; +pub mod ur; \ No newline at end of file diff --git a/quantus_sdk/rust/src/api/ur.rs b/quantus_sdk/rust/src/api/ur.rs new file mode 100644 index 00000000..a8dbbc19 --- /dev/null +++ b/quantus_sdk/rust/src/api/ur.rs @@ -0,0 +1,104 @@ +/// UR API for parsing QR codes in the ur:.. standard +/// +use quantus_ur::{decode_bytes, encode_bytes, 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 +// of segments. +// For example if you have 3 segments, and the scanner scans all 3 but doesn't succeed, subsequent parts +// are sent with strange numbers like /412-3/ which are encoded with pieces of the previous segments so that +// the algorithm recovers faster than just repeating the segments over and over. This is described in the UR +// standard. FYI. +#[flutter_rust_bridge::frb(sync)] +pub fn decode_ur(ur_parts: Vec) -> Result, String> { + decode_bytes(&ur_parts).map_err(|e| e.to_string()) +} + +#[flutter_rust_bridge::frb(sync)] +pub fn encode_ur(data: Vec) -> Result, String> { + encode_bytes(&data).map_err(|e| e.to_string()) +} + +#[flutter_rust_bridge::frb(sync)] +pub fn is_complete_ur(ur_parts: Vec) -> bool { + is_complete(&ur_parts) +} +#[cfg(test)] +mod tests { + use super::*; + use hex; + + #[test] + fn test_single_part_roundtrip() { + 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"); + assert_eq!(encoded_parts.len(), 1, "Should be single part"); + + let decoded_bytes = decode_ur(encoded_parts).expect("Decoding failed"); + assert_eq!(decoded_bytes, payload_bytes); + } + + #[test] + fn test_multi_part_roundtrip() { + 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"); + assert!(encoded_parts.len() > 1, "Should be multiple parts"); + + let decoded_bytes = decode_ur(encoded_parts).expect("Decoding failed"); + assert_eq!(decoded_bytes, payload_bytes); + } + + #[test] + 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"); + + assert!(is_complete_ur(encoded_parts), "Single part should be complete"); + } + + #[test] + 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"); + + assert!(is_complete_ur(encoded_parts), "All parts should be complete"); + } + + #[test] + 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"); + + assert!(encoded_parts.len() > 1, "Should have multiple parts"); + + let incomplete_parts = vec![encoded_parts[0].clone()]; + assert!(!is_complete_ur(incomplete_parts), "Incomplete parts should return false"); + } + + #[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"); + + assert!(encoded_parts.len() > 1, "Should be multiple parts"); + + let mut scrambled_parts = encoded_parts.clone(); + scrambled_parts.reverse(); + let mid = scrambled_parts.len() / 2; + scrambled_parts.swap(0, mid); + + let decoded_bytes = decode_ur(scrambled_parts.clone()).expect("Decoding failed"); + assert_eq!(decoded_bytes, payload_bytes, "Decoding should work regardless of part order"); + + assert!(is_complete_ur(scrambled_parts), "Scrambled parts should still be complete"); + } + +} diff --git a/quantus_sdk/rust/src/frb_generated.rs b/quantus_sdk/rust/src/frb_generated.rs index ae8733cf..71038b4c 100644 --- a/quantus_sdk/rust/src/frb_generated.rs +++ b/quantus_sdk/rust/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 390198677; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1692591137; // Section: executor @@ -133,6 +133,36 @@ fn wire__crate__api__crypto__crystal_charlie_impl( }, ) } +fn wire__crate__api__ur__decode_ur_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: "decode_ur", + 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); + let api_ur_parts = >::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::ur::decode_ur(api_ur_parts)?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__crypto__derive_hd_path_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -165,6 +195,36 @@ fn wire__crate__api__crypto__derive_hd_path_impl( }, ) } +fn wire__crate__api__ur__encode_ur_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: "encode_ur", + 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); + let api_data = >::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::ur::encode_ur(api_data)?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__crypto__generate_derived_keypair_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -293,6 +353,94 @@ fn wire__crate__api__crypto__init_app_impl( }, ) } +fn wire__crate__api__ur__is_complete_ur_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: "is_complete_ur", + 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); + let api_ur_parts = >::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::ur::is_complete_ur(api_ur_parts))?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__crypto__public_key_bytes_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: "public_key_bytes", + 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::crypto::public_key_bytes())?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__crypto__secret_key_bytes_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: "secret_key_bytes", + 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::crypto::secret_key_bytes())?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__crypto__set_default_ss58_prefix_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -397,6 +545,35 @@ fn wire__crate__api__crypto__sign_message_with_pubkey_impl( }, ) } +fn wire__crate__api__crypto__signature_bytes_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: "signature_bytes", + 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::crypto::signature_bytes())?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__crypto__ss58_to_account_id_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -550,6 +727,18 @@ impl SseDecode for crate::api::crypto::Keypair { } } +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -623,7 +812,7 @@ fn pde_ffi_dispatcher_primary_impl( ) { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 8 => wire__crate__api__crypto__init_app_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__crypto__init_app_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -639,16 +828,22 @@ fn pde_ffi_dispatcher_sync_impl( 1 => wire__crate__api__crypto__crystal_alice_impl(ptr, rust_vec_len, data_len), 2 => wire__crate__api__crypto__crystal_bob_impl(ptr, rust_vec_len, data_len), 3 => wire__crate__api__crypto__crystal_charlie_impl(ptr, rust_vec_len, data_len), - 4 => wire__crate__api__crypto__derive_hd_path_impl(ptr, rust_vec_len, data_len), - 5 => wire__crate__api__crypto__generate_derived_keypair_impl(ptr, rust_vec_len, data_len), - 6 => wire__crate__api__crypto__generate_keypair_impl(ptr, rust_vec_len, data_len), - 7 => wire__crate__api__crypto__generate_keypair_from_seed_impl(ptr, rust_vec_len, data_len), - 9 => wire__crate__api__crypto__set_default_ss58_prefix_impl(ptr, rust_vec_len, data_len), - 10 => wire__crate__api__crypto__sign_message_impl(ptr, rust_vec_len, data_len), - 11 => wire__crate__api__crypto__sign_message_with_pubkey_impl(ptr, rust_vec_len, data_len), - 12 => wire__crate__api__crypto__ss58_to_account_id_impl(ptr, rust_vec_len, data_len), - 13 => wire__crate__api__crypto__to_account_id_impl(ptr, rust_vec_len, data_len), - 14 => wire__crate__api__crypto__verify_message_impl(ptr, rust_vec_len, data_len), + 4 => wire__crate__api__ur__decode_ur_impl(ptr, rust_vec_len, data_len), + 5 => wire__crate__api__crypto__derive_hd_path_impl(ptr, rust_vec_len, data_len), + 6 => wire__crate__api__ur__encode_ur_impl(ptr, rust_vec_len, data_len), + 7 => wire__crate__api__crypto__generate_derived_keypair_impl(ptr, rust_vec_len, data_len), + 8 => wire__crate__api__crypto__generate_keypair_impl(ptr, rust_vec_len, data_len), + 9 => wire__crate__api__crypto__generate_keypair_from_seed_impl(ptr, rust_vec_len, data_len), + 11 => wire__crate__api__ur__is_complete_ur_impl(ptr, rust_vec_len, data_len), + 12 => wire__crate__api__crypto__public_key_bytes_impl(ptr, rust_vec_len, data_len), + 13 => wire__crate__api__crypto__secret_key_bytes_impl(ptr, rust_vec_len, data_len), + 14 => wire__crate__api__crypto__set_default_ss58_prefix_impl(ptr, rust_vec_len, data_len), + 15 => wire__crate__api__crypto__sign_message_impl(ptr, rust_vec_len, data_len), + 16 => wire__crate__api__crypto__sign_message_with_pubkey_impl(ptr, rust_vec_len, data_len), + 17 => wire__crate__api__crypto__signature_bytes_impl(ptr, rust_vec_len, data_len), + 18 => wire__crate__api__crypto__ss58_to_account_id_impl(ptr, rust_vec_len, data_len), + 19 => wire__crate__api__crypto__to_account_id_impl(ptr, rust_vec_len, data_len), + 20 => wire__crate__api__crypto__verify_message_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -729,6 +924,16 @@ impl SseEncode for crate::api::crypto::Keypair { } } +impl SseEncode for Vec { + // 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.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + impl SseEncode for Vec { // 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/generate_keys_test.dart b/quantus_sdk/test/generate_keys_test.dart index 07ca9887..024257a9 100644 --- a/quantus_sdk/test/generate_keys_test.dart +++ b/quantus_sdk/test/generate_keys_test.dart @@ -1,4 +1,8 @@ +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:polkadart/polkadart.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -78,5 +82,26 @@ void main() { expect(accountId1, knownAccountHdIndex0); expect(accountId2, knownAccountHdIndex1); }); + test('test for known values2', () { + const mnemonic1 = 'human snow truck virus now jaguar wall brisk shoe craft gravity diesel'; + + const knownAccountId = 'qzmuHhD8p2dvndwkbjw5htDvaptyis5rEVc7v5BmqR3pfQ7QN'; // schroedinger chain spec + final keypair = HdWalletService().keyPairAtIndex(mnemonic1, 0); + final accountId = toAccountId(obj: keypair); + expect(accountId, knownAccountId); + + // this is a real scale encoded payload. + const hexPayload = + '0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e5a77ae1c95817ee664cf733fafa7baa8e6244b396a54e57a5bc414b24c52800600'; + final payload = hex.decode(hexPayload); + final signature = signMessage(keypair: keypair, message: payload); + final isValid = verifyMessage(keypair: keypair, message: payload, signature: signature); + expect(isValid, true); + print('signature: ${hex.encode(signature)}'); + final hashedSignature = const Blake2bHasher(32).hash(signature); + final hashedPayload = const Blake2bHasher(32).hash(Uint8List.fromList(payload)); + print('hashedSignature: ${hex.encode(hashedSignature)}'); + print('hashedPayload: ${hex.encode(hashedPayload)}'); + }); }); } diff --git a/quantus_sdk/test/quantus_payload_parser_test.dart b/quantus_sdk/test/quantus_payload_parser_test.dart new file mode 100644 index 00000000..4524932d --- /dev/null +++ b/quantus_sdk/test/quantus_payload_parser_test.dart @@ -0,0 +1,164 @@ +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +void main() { + group('QuantusPayloadParser', () { + test('parses balance transfer', () { + // Create a mock balance transfer payload + // Pallet index 2 (Balances), call index 0 (transfer_allow_death) + final payload = Uint8List.fromList([ + 2, // pallet index + 0, // call index + 0, // MultiAddress::Id + ...List.filled(32, 1), // mock account ID (32 bytes) + 0x0b, 0x00, 0xa0, 0x72, 0x4e, 0x18, 0x09, // Compact encoded amount (10000000000000) + ]); + + final result = QuantusPayloadParser.parsePayload(payload); + + expect(result, isNotNull); + expect(result!.toAddress, startsWith('qz')); + expect(result.amount, BigInt.from(10000000000000)); + expect(result.isReversible, false); + }); + + test('parses real world balance transfer (0.9 QUAN)', () { + // Test with real world value as follows + // final hexPayload = '020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd185012800007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e3d3e081c6e3599f8ae31d404d9f087f50c25b4e08c35712e23470a60da5799ca00'; + // final expectedAmount = (BigInt): 900000000000 + // final expectedTargetAddress = 'qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86'; + + // Real world hex payload from production + final hexPayload = + '020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd185012800007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e3d3e081c6e3599f8ae31d404d9f087f50c25b4e08c35712e23470a60da5799ca00'; + final expectedTargetAddress = 'qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86'; + final expectedAmount = BigInt.from(900000000000); + final payload = Uint8List.fromList(hex.decode(hexPayload)); + + final result = QuantusPayloadParser.parsePayload(payload); + + expect(result, isNotNull); + expect(result!.amount, expectedAmount); + expect(result.isReversible, false); + expect(result.reversibleTimeframe, null); + expect(result.toAddress, expectedTargetAddress); + }); + + // flutter: Showing confirmation for amount (BigInt): 1440000000000 + // Reverisble transfer to qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG + // delay 5 minutes = 300 seconds. + // flutter: KAT raw encoded payload: 0d04007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0040b0464f010000000000000000000001e093040000000000d5010c00007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118efeebb9b31159a679a1e49ccc34d363b5d4a00b836ad4f85cbba8c6274ac2566800 + test('Real world reversible transfer (1.44 QUAN, delay 5 minutes)', () { + final hexPayload = + '0d04007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0040b0464f010000000000000000000001e093040000000000d5010c00007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118efeebb9b31159a679a1e49ccc34d363b5d4a00b836ad4f85cbba8c6274ac2566800'; + final expectedTargetAddress = 'qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG'; + final expectedAmount = BigInt.from(1440000000000); + final expectedReversibleTimeframe = 5 * 60 * 1000; // 5 minutes in millisecond + final payload = Uint8List.fromList(hex.decode(hexPayload)); + + final result = QuantusPayloadParser.parsePayload(payload); + + expect(result, isNotNull); + expect(result!.amount, expectedAmount); + expect(result.isReversible, true); + expect(result.reversibleTimeframe, expectedReversibleTimeframe); + expect(result.toAddress, expectedTargetAddress); + }); + + test('parses reversible transfer', () { + // Create a mock reversible transfer payload + // Pallet index 13 (ReversibleTransfers), call index 3 (schedule_transfer) + final payload = Uint8List.fromList([ + 13, // pallet index + 3, // call index + 0, // MultiAddress::Id + ...List.filled(32, 2), // mock account ID (32 bytes) + 0x00, + 0xa0, + 0x72, + 0x4e, + 0x18, + 0x09, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, // amount (16 bytes, little endian) - 10000000000000 as u128 + ]); + + final result = QuantusPayloadParser.parsePayload(payload); + + expect(result, isNotNull); + expect(result!.toAddress, startsWith('qz')); + expect(result.amount, BigInt.from(10000000000000)); + expect(result.isReversible, true); + expect(result.reversibleTimeframe, null); // Uses configured delay + }); + + test('parses reversible transfer with custom delay', () { + // Create a mock reversible transfer with delay payload + // Pallet index 13 (ReversibleTransfers), call index 4 (schedule_transfer_with_delay) + final payload = Uint8List.fromList([ + 13, // pallet index + 4, // call index + 0, // MultiAddress::Id + ...List.filled(32, 3), // mock account ID (32 bytes) + 0x00, + 0xa0, + 0x72, + 0x4e, + 0x18, + 0x09, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, // amount (16 bytes, little endian) - 10000000000000 as u128 + 0, // BlockNumber variant + 100, 0, 0, 0, // delay: 100 blocks + ]); + + final result = QuantusPayloadParser.parsePayload(payload); + + expect(result, isNotNull); + expect(result!.toAddress, startsWith('qz')); + expect(result.amount, BigInt.from(10000000000000)); + expect(result.isReversible, true); + expect(result.reversibleTimeframe, 100); + }); + + test('returns null for unknown pallet', () { + final payload = Uint8List.fromList([99, 0]); // Unknown pallet index 99 + final result = QuantusPayloadParser.parsePayload(payload); + expect(result, null); + }); + + test('TransactionInfo toString formats correctly', () { + final tx = TransactionInfo( + toAddress: '0x01010101010101010101010101010101010101010101010101010101010101', + amount: BigInt.from(10000000000000), // 1000 QUS with 10 decimals + isReversible: true, + reversibleTimeframe: 7200, + ); + + final output = tx.toString(); + expect(output, contains('To Address: 0x01010101010101010101010101010101010101010101010101010101010101')); + expect(output, contains('Amount: 1000.0000 QUS')); + expect(output, contains('Reversible: true')); + expect(output, contains('Reversible Timeframe: 7200 blocks')); + }); + }); +} diff --git a/quantus_sdk/test/services/settings_service_test.dart b/quantus_sdk/test/services/settings_service_test.dart index 258904c2..842127c6 100644 --- a/quantus_sdk/test/services/settings_service_test.dart +++ b/quantus_sdk/test/services/settings_service_test.dart @@ -174,7 +174,7 @@ void main() { await settingsService.saveAccounts([account1, account3]); // Indices 0 and 2 // Act - final nextIndex = await settingsService.getNextFreeAccountIndex(); + final nextIndex = await settingsService.getNextFreeAccountIndex(0); // Assert expect(nextIndex, 3); diff --git a/rust-transaction-parser/Cargo.toml b/rust-transaction-parser/Cargo.toml new file mode 100644 index 00000000..39acd38f --- /dev/null +++ b/rust-transaction-parser/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "quantus-transaction-parser" +version = "0.1.0" +edition = "2021" + +[dependencies] +parity-scale-codec = { version = "3.6", default-features = false, features = ["derive", "full"] } +ss58 = "0.0.3" +base58 = "0.2" +blake2 = "0.10" +hex = "0.4" \ No newline at end of file diff --git a/rust-transaction-parser/src/lib.rs b/rust-transaction-parser/src/lib.rs new file mode 100644 index 00000000..6ece0a3f --- /dev/null +++ b/rust-transaction-parser/src/lib.rs @@ -0,0 +1,180 @@ +use parity_scale_codec::{Decode, Compact}; +use std::fmt; + +#[derive(Debug, PartialEq)] +pub struct TransactionInfo { + pub to_address: String, + pub amount: u128, + pub is_reversible: bool, + pub reversible_timeframe: Option, +} + +impl fmt::Display for TransactionInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let amount_str = format!("{:.4}", self.amount as f64 / 10_f64.powi(10)); + write!(f, "Transaction Details:\n To Address: {}\n Amount: {} QUS\n Reversible: {}", + self.to_address, amount_str, self.is_reversible)?; + + if self.is_reversible && self.reversible_timeframe.is_some() { + write!(f, "\n Reversible Timeframe: {} milliseconds ", self.reversible_timeframe.unwrap())?; + } + + Ok(()) + } +} + +pub struct QuantusPayloadParser; + +impl QuantusPayloadParser { + pub fn bytes_to_ss58(bytes: &[u8]) -> String { + const SS58_PREFIX: u16 = 189; // Quantus SS58 prefix + + if bytes.len() != 32 { + panic!("AccountId32 must be 32 bytes"); + } + + let mut account_id_bytes = [0u8; 32]; + account_id_bytes.copy_from_slice(bytes); + + ss58::encode(&account_id_bytes, ss58::Ss58AddressFormat::Custom(SS58_PREFIX)) + } + + pub fn parse_payload(payload: &[u8]) -> Result { + let mut input = &payload[..]; + + // Read pallet index (first byte) + let pallet_index: u8 = Decode::decode(&mut input).map_err(|e| e.to_string())?; + + // Read the call data (remaining bytes) + let call_data = input; + + match pallet_index { + 2 => Self::parse_balances_call(call_data), // Balances pallet + 13 => Self::parse_reversible_transfers_call(call_data), // ReversibleTransfers pallet + _ => Err("Unknown pallet".to_string()), // Unknown pallet + } + } + + fn parse_balances_call(call_data: &[u8]) -> Result { + let mut input = call_data; + + // Read call index + let call_index: u8 = Decode::decode(&mut input).map_err(|e| e.to_string())?; + + match call_index { + 0 | 3 => { // transfer_allow_death or transfer_keep_alive + let dest = Self::parse_multi_address(&mut input)?; + let amount: Compact = Decode::decode(&mut input).map_err(|e| e.to_string())?; + Ok(TransactionInfo { + to_address: dest, + amount: amount.0, + is_reversible: false, + reversible_timeframe: None, + }) + } + _ => Err(format!("Balances: Unsupported call index {}", call_index)), + } + } + + fn parse_reversible_transfers_call(call_data: &[u8]) -> Result { + let mut input = call_data; + + // Read call index + let call_index: u8 = Decode::decode(&mut input).map_err(|e| e.to_string())?; + + match call_index { + 3 => { // schedule_transfer + let dest = Self::parse_multi_address(&mut input)?; + let amount: u128 = Decode::decode(&mut input).map_err(|e| e.to_string())?; + Ok(TransactionInfo { + to_address: dest, + amount, + is_reversible: true, + reversible_timeframe: None, // Uses configured delay + }) + } + 4 => { // schedule_transfer_with_delay + let dest = Self::parse_multi_address(&mut input)?; + let amount: u128 = Decode::decode(&mut input).map_err(|e| e.to_string())?; + let delay = Self::parse_block_number_or_timestamp(&mut input)?; + Ok(TransactionInfo { + to_address: dest, + amount, + is_reversible: true, + reversible_timeframe: Some(delay), + }) + } + _ => Err(format!("ReversibleTransfers: Unsupported call index {}", call_index)), + } + } + + fn parse_multi_address(input: &mut &[u8]) -> Result { + let address_type: u8 = Decode::decode(input).map_err(|e| e.to_string())?; + + match address_type { + 0 => { // Id(AccountId) + let account_id: [u8; 32] = Decode::decode(input).map_err(|e| e.to_string())?; + Ok(Self::bytes_to_ss58(&account_id)) + } + 1 => Err("Index(Compact) MultiAddress type 1 is not supported".to_string()), + 2 => Err("Raw(Vec) MultiAddress type 2 is not supported".to_string()), + 3 => Err("Address32([u8; 32]) MultiAddress type 3 is not supported".to_string()), + 4 => Err("Address20([u8; 20]) MultiAddress type 4 is not supported".to_string()), + _ => Err(format!("Unknown multi address type: {}", address_type)), + } + } + + fn parse_block_number_or_timestamp(input: &mut &[u8]) -> Result { + let variant: u8 = Decode::decode(input).map_err(|e| e.to_string())?; + + match variant { + 0 => Err("Block numbers are not supported for delayed transactions".to_string()), + 1 => { // Timestamp(u64) + let timestamp: u64 = Decode::decode(input).map_err(|e| e.to_string())?; + Ok(timestamp) + } + _ => Err(format!("Unknown time variant: {}", variant)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hex; + + #[test] + fn test_parse_real_world_balance_transfer() { + let hex_payload = "020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd185012800007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e3d3e081c6e3599f8ae31d404d9f087f50c25b4e08c35712e23470a60da5799ca00"; + let payload = hex::decode(hex_payload).unwrap(); + let expected_address = "qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86"; + let expected_amount = 900000000000u128; + + let result = QuantusPayloadParser::parse_payload(&payload); + + assert!(result.is_ok()); + let tx = result.unwrap(); + assert_eq!(tx.amount, expected_amount); + assert_eq!(tx.is_reversible, false); + assert_eq!(tx.reversible_timeframe, None); + assert_eq!(tx.to_address, expected_address); + } + + #[test] + fn test_parse_real_world_reversible_transfer() { + let hex_payload = "0d04007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0040b0464f010000000000000000000001e093040000000000d5010c00007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118efeebb9b31159a679a1e49ccc34d363b5d4a00b836ad4f85cbba8c6274ac2566800"; + let payload = hex::decode(hex_payload).unwrap(); + let expected_address = "qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG"; + let expected_amount = 1440000000000u128; + let expected_delay = 300000u64; // 5 minutes in milliseconds + + let result = QuantusPayloadParser::parse_payload(&payload); + + assert!(result.is_ok()); + let tx = result.unwrap(); + assert_eq!(tx.amount, expected_amount); + assert_eq!(tx.is_reversible, true); + assert_eq!(tx.reversible_timeframe, Some(expected_delay)); + assert_eq!(tx.to_address, expected_address); + } +} \ No newline at end of file