diff --git a/mobile-app/lib/features/main/screens/accounts_screen.dart b/mobile-app/lib/features/main/screens/accounts_screen.dart index 333179d8..8a376d7b 100644 --- a/mobile-app/lib/features/main/screens/accounts_screen.dart +++ b/mobile-app/lib/features/main/screens/accounts_screen.dart @@ -17,16 +17,18 @@ import 'package:resonance_network_wallet/features/main/screens/add_hardware_acco 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/main/screens/multisig/add_multisig_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/entrusted_account_provider.dart'; +import 'package:resonance_network_wallet/providers/multisig_providers.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; import 'package:resonance_network_wallet/utils/feature_flags.dart'; -enum _WalletMoreAction { createWallet, importWallet, addHardwareWallet } +enum _WalletMoreAction { createWallet, importWallet, addHardwareWallet, addMultisig } class AccountsScreen extends ConsumerStatefulWidget { const AccountsScreen({super.key}); @@ -116,6 +118,7 @@ class _AccountsScreenState extends ConsumerState { if (FeatureFlags.enableKeystoneHardwareWallet) { items.add(Item(value: _WalletMoreAction.addHardwareWallet, label: 'Add hardware wallet')); } + items.add(Item(value: _WalletMoreAction.addMultisig, label: 'Add multisig wallet')); showSelectActionSheet<_WalletMoreAction>(context, items, (item) async { final result = await (switch (item.value) { @@ -137,6 +140,10 @@ class _AccountsScreenState extends ConsumerState { builder: (context) => AddHardwareAccountScreen(walletIndex: nextWalletIndex, isNewWallet: true), ), ), + _WalletMoreAction.addMultisig => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const AddMultisigScreen()), + ), }); if (result == true && mounted) { ref.invalidate(accountsProvider); @@ -246,18 +253,23 @@ class _AccountsScreenState extends ConsumerState { } final grouped = _groupByWallet(accounts); + final multisigSection = _buildMultisigSection(activeDisplayAccount?.account.accountId); + if (grouped.length <= 1) { final walletAccounts = grouped.values.first; return RefreshIndicator( onRefresh: _refreshAccounts, - child: ListView.separated( + child: ListView( padding: const EdgeInsets.symmetric(vertical: 16.0), - itemCount: walletAccounts.length, - separatorBuilder: (context, index) => const SizedBox(height: 25), - itemBuilder: (context, index) { - final account = walletAccounts[index]; - return _buildAccountListItem(account, activeDisplayAccount?.account.accountId, index); - }, + children: [ + ...walletAccounts.asMap().entries.map((entry) { + return Padding( + padding: EdgeInsets.only(top: entry.key > 0 ? 25 : 0), + child: _buildAccountListItem(entry.value, activeDisplayAccount?.account.accountId, entry.key), + ); + }), + multisigSection, + ], ), ); } @@ -292,6 +304,8 @@ class _AccountsScreenState extends ConsumerState { sectionIndex++; } + children.add(multisigSection); + return RefreshIndicator( onRefresh: _refreshAccounts, child: ListView(padding: const EdgeInsets.symmetric(vertical: 16.0), children: children), @@ -601,4 +615,131 @@ class _AccountsScreenState extends ConsumerState { ), ); } + + Widget _buildMultisigSection(String? activeAccountId) { + final multisigAccountsAsync = ref.watch(multisigAccountsProvider); + + return multisigAccountsAsync.when( + data: (multisigAccounts) { + if (multisigAccounts.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 18), + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + 'Multisig Wallets', + style: context.themeText.detail?.copyWith(color: context.themeColors.textPrimary), + ), + ), + ...multisigAccounts.asMap().entries.map((entry) { + final msig = entry.value; + return Padding( + padding: EdgeInsets.only(top: entry.key > 0 ? 25 : 0), + child: _buildMultisigListItem(msig, activeAccountId), + ); + }), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ); + } + + Widget _buildMultisigListItem(MultisigAccount msig, String? activeAccountId) { + final isActive = msig.accountId == activeAccountId; + + return InkWell( + onTap: () async { + await ref.read(activeAccountProvider.notifier).setActiveAccount(MultisigDisplayAccount(msig)); + if (mounted) Navigator.pop(context); + }, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: context.isTablet ? 20 : 8, vertical: 8), + height: context.themeSize.accountListItemHeight, + decoration: ShapeDecoration( + color: isActive ? context.themeColors.surfaceActive : context.themeColors.surface, + shape: RoundedRectangleBorder( + side: BorderSide(width: 1, color: context.themeColors.borderLight), + borderRadius: BorderRadius.circular(5), + ), + ), + child: Row( + children: [ + const SizedBox(width: 24), + Expanded( + child: Consumer( + builder: (context, ref, child) { + return FutureBuilder( + future: _checksumService.getHumanReadableName(msig.accountId), + builder: (context, checksumSnapshot) { + final humanChecksum = checksumSnapshot.data ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + msig.name, + style: context.themeText.smallParagraph?.copyWith( + color: isActive ? Colors.black : Colors.white, + ), + ), + AccountTag(text: 'Multisig', color: context.themeColors.accountTagMultisig), + ], + ), + Text( + humanChecksum, + style: context.themeText.detail?.copyWith( + color: isActive ? context.themeColors.checksumDarker : context.themeColors.checksum, + ), + ), + Text( + context.isTablet + ? msig.accountId + : AddressFormattingService.formatAddress(msig.accountId), + style: context.themeText.tiny?.copyWith( + color: isActive ? context.themeColors.darkGray : context.themeColors.textMuted, + ), + ), + const SizedBox(height: 2), + Text( + '${msig.threshold} of ${msig.signers.length} signers', + style: context.themeText.tiny?.copyWith( + color: isActive + ? context.themeColors.darkGray + : context.themeColors.accountTagMultisig, + ), + ), + ], + ); + }, + ); + }, + ), + ), + ], + ), + ), + Positioned( + top: (context.themeSize.accountListItemHeight / 2) - (context.themeSize.accountListItemLogoWidth / 2), + left: (context.themeSize.accountListItemLogoWidth / 2) * -1, + child: AccountGradientImage( + accountId: msig.accountId, + width: context.themeSize.accountListItemLogoWidth, + height: context.themeSize.accountListItemLogoWidth, + ), + ), + ], + ), + ); + } } diff --git a/mobile-app/lib/features/main/screens/multisig/add_multisig_screen.dart b/mobile-app/lib/features/main/screens/multisig/add_multisig_screen.dart new file mode 100644 index 00000000..33a4b0c5 --- /dev/null +++ b/mobile-app/lib/features/main/screens/multisig/add_multisig_screen.dart @@ -0,0 +1,275 @@ +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/segmented_control.dart'; +import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; +import 'package:resonance_network_wallet/features/main/screens/multisig/create_multisig_screen.dart'; +import 'package:resonance_network_wallet/features/styles/app_colors_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/multisig_providers.dart'; + +enum _AddMode { manual, discover } + +class AddMultisigScreen extends ConsumerStatefulWidget { + const AddMultisigScreen({super.key}); + + @override + ConsumerState createState() => _AddMultisigScreenState(); +} + +class _AddMultisigScreenState extends ConsumerState { + final TextEditingController _addressController = TextEditingController(); + final SubstrateService _substrateService = SubstrateService(); + + _AddMode _mode = _AddMode.manual; + MultisigData? _multisigData; + String? _error; + bool _isLoading = false; + List? _discovered; + + @override + void dispose() { + _addressController.dispose(); + super.dispose(); + } + + Future _lookupAddress() async { + final address = _addressController.text.trim(); + if (address.isEmpty) return; + + if (!_substrateService.isValidSS58Address(address)) { + setState(() => _error = 'Invalid address'); + return; + } + + setState(() { + _isLoading = true; + _error = null; + _multisigData = null; + }); + + try { + final service = ref.read(multisigServiceProvider); + final data = await service.getMultisigData(address); + setState(() { + _multisigData = data; + _error = data == null ? 'No multisig found at this address' : null; + }); + } catch (e) { + setState(() => _error = 'Failed to lookup: $e'); + } finally { + setState(() => _isLoading = false); + } + } + + Future _addManual() async { + if (_multisigData == null) return; + final address = _addressController.text.trim(); + final service = ref.read(multisigServiceProvider); + + final signerAddresses = _multisigData!.signers.map((s) => service.signerToAddress(s)).toList(); + + final account = MultisigAccount( + name: 'Multisig', + accountId: address, + signers: signerAddresses, + threshold: _multisigData!.threshold, + ); + + await service.saveMultisigAccount(account); + ref.invalidate(multisigAccountsProvider); + await ref.read(activeAccountProvider.notifier).setActiveAccount(MultisigDisplayAccount(account)); + if (mounted) Navigator.pop(context, true); + } + + Future _discover() async { + setState(() { + _isLoading = true; + _error = null; + _discovered = null; + }); + + try { + final accounts = ref.read(accountsProvider).value ?? []; + final userIds = accounts.map((a) => a.accountId).toList(); + final service = ref.read(multisigServiceProvider); + final results = await service.discoverMultisigs(userIds); + + setState(() { + _discovered = results; + if (results.isEmpty) _error = 'No multisig wallets found for your accounts'; + }); + } catch (e) { + setState(() => _error = 'Discovery failed: $e'); + } finally { + setState(() => _isLoading = false); + } + } + + Future _addDiscovered(MultisigAccount account) async { + final service = ref.read(multisigServiceProvider); + await service.saveMultisigAccount(account); + ref.invalidate(multisigAccountsProvider); + await ref.read(activeAccountProvider.notifier).setActiveAccount(MultisigDisplayAccount(account)); + if (mounted) Navigator.pop(context, true); + } + + @override + Widget build(BuildContext context) { + return ScaffoldBase( + appBar: WalletAppBar(title: 'Add Multisig Wallet'), + child: Column( + children: [ + const SizedBox(height: 16), + SegmentedControl<_AddMode>( + items: const [ + SegmentedControlItem(value: _AddMode.manual, child: Text('Manual')), + SegmentedControlItem(value: _AddMode.discover, child: Text('Discover')), + ], + selectedValue: _mode, + onSelectionChanged: (value) => setState(() => _mode = value), + ), + const SizedBox(height: 24), + Expanded(child: _mode == _AddMode.manual ? _buildManualTab() : _buildDiscoverTab()), + Button( + label: 'Create New Multisig', + variant: ButtonVariant.glassOutline, + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const CreateMultisigScreen()), + ); + if (result == true && mounted) Navigator.pop(context, true); + }, + ), + const SizedBox(height: 32), + ], + ), + ); + } + + Widget _buildManualTab() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextField( + controller: _addressController, + hintText: 'Enter multisig address', + variant: TextFieldVariant.secondary, + errorMsg: _error, + trailing: IconButton( + icon: const Icon(Icons.search, color: Colors.white54), + onPressed: _isLoading ? null : _lookupAddress, + ), + ), + const SizedBox(height: 16), + if (_isLoading) const Center(child: CircularProgressIndicator(color: Colors.white)), + if (_multisigData != null) ...[ + _buildMultisigInfo(_multisigData!), + const Spacer(), + Button(label: 'Add Multisig Wallet', variant: ButtonVariant.primary, onPressed: _addManual), + const SizedBox(height: 32), + ], + ], + ); + } + + Widget _buildDiscoverTab() { + return Column( + children: [ + if (_discovered == null && !_isLoading) ...[ + Text( + 'Scan the network to find multisig wallets where your accounts are signers.', + style: context.themeText.detail?.copyWith(color: context.themeColors.textMuted), + ), + const SizedBox(height: 24), + Button(label: 'Discover Multisigs', variant: ButtonVariant.glassOutline, onPressed: _discover), + ], + if (_isLoading) + const Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(color: Colors.white), + ), + if (_error != null && !_isLoading) + Padding( + padding: const EdgeInsets.all(16), + child: Text(_error!, style: context.themeText.detail?.copyWith(color: context.themeColors.textMuted)), + ), + if (_discovered != null && _discovered!.isNotEmpty) + Expanded( + child: ListView.separated( + itemCount: _discovered!.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final msig = _discovered![index]; + return _buildDiscoveredItem(msig); + }, + ), + ), + ], + ); + } + + Widget _buildDiscoveredItem(MultisigAccount msig) { + return InkWell( + onTap: () => _addDiscovered(msig), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.themeColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: context.themeColors.borderLight), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AddressFormattingService.formatAddress(msig.accountId), style: context.themeText.smallParagraph), + const SizedBox(height: 4), + Text( + '${msig.threshold} of ${msig.signers.length} signers', + style: context.themeText.detail?.copyWith(color: context.themeColors.accountTagMultisig), + ), + ], + ), + ), + ); + } + + Widget _buildMultisigInfo(MultisigData data) { + final service = ref.read(multisigServiceProvider); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.themeColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: context.themeColors.borderLight), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Threshold: ${data.threshold} of ${data.signers.length}', + style: context.themeText.smallParagraph?.copyWith(color: context.themeColors.accountTagMultisig), + ), + const SizedBox(height: 8), + Text('Signers', style: context.themeText.detail?.copyWith(color: context.themeColors.textMuted)), + const SizedBox(height: 4), + ...data.signers.map( + (s) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + AddressFormattingService.formatAddress(service.signerToAddress(s)), + style: context.themeText.tiny, + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/multisig/call_decoder.dart b/mobile-app/lib/features/main/screens/multisig/call_decoder.dart new file mode 100644 index 00000000..008c4487 --- /dev/null +++ b/mobile-app/lib/features/main/screens/multisig/call_decoder.dart @@ -0,0 +1,54 @@ +import 'package:quantus_sdk/quantus_sdk.dart'; + +class DecodedTransfer { + final String destination; + final BigInt amount; + DecodedTransfer({required this.destination, required this.amount}); +} + +DecodedTransfer? decodeTransferCall(List callBytes) { + try { + if (callBytes.length < 35) return null; + final palletIndex = callBytes[0]; + final callIndex = callBytes[1]; + if (palletIndex != 5 || (callIndex != 0 && callIndex != 3)) return null; + + final addressBytes = callBytes.sublist(3, 35); + final dest = MultisigService().signerToAddress(addressBytes); + final amount = _decodeCompactBigInt(callBytes, 35); + if (amount == null) return null; + + return DecodedTransfer(destination: dest, amount: amount); + } catch (_) { + return null; + } +} + +BigInt? _decodeCompactBigInt(List bytes, int offset) { + try { + if (offset >= bytes.length) return null; + final first = bytes[offset]; + final mode = first & 0x03; + + if (mode == 0) { + return BigInt.from(first >> 2); + } else if (mode == 1) { + if (offset + 1 >= bytes.length) return null; + return BigInt.from(((bytes[offset + 1] << 8) | first) >> 2); + } else if (mode == 2) { + if (offset + 3 >= bytes.length) return null; + final value = (bytes[offset + 3] << 24) | (bytes[offset + 2] << 16) | (bytes[offset + 1] << 8) | first; + return BigInt.from(value >> 2); + } else { + final len = (first >> 2) + 4; + if (offset + len >= bytes.length) return null; + var value = BigInt.zero; + for (int i = 0; i < len; i++) { + value += BigInt.from(bytes[offset + 1 + i]) << (8 * i); + } + return value; + } + } catch (_) { + return null; + } +} diff --git a/mobile-app/lib/features/main/screens/multisig/create_multisig_screen.dart b/mobile-app/lib/features/main/screens/multisig/create_multisig_screen.dart new file mode 100644 index 00000000..f32da584 --- /dev/null +++ b/mobile-app/lib/features/main/screens/multisig/create_multisig_screen.dart @@ -0,0 +1,252 @@ +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/styles/app_colors_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/multisig_providers.dart'; + +class CreateMultisigScreen extends ConsumerStatefulWidget { + const CreateMultisigScreen({super.key}); + + @override + ConsumerState createState() => _CreateMultisigScreenState(); +} + +class _CreateMultisigScreenState extends ConsumerState { + final TextEditingController _signerController = TextEditingController(); + final SubstrateService _substrateService = SubstrateService(); + final MultisigService _multisigService = MultisigService(); + final HumanReadableChecksumService _checksumService = HumanReadableChecksumService(); + + final List _signers = []; + int _threshold = 2; + String? _error; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final accounts = ref.read(accountsProvider).value ?? []; + if (accounts.isNotEmpty) { + setState(() => _signers.add(accounts.first.accountId)); + } + }); + } + + @override + void dispose() { + _signerController.dispose(); + super.dispose(); + } + + void _addSigner() { + final address = _signerController.text.trim(); + if (address.isEmpty) return; + if (!_substrateService.isValidSS58Address(address)) { + setState(() => _error = 'Invalid address'); + return; + } + if (_signers.contains(address)) { + setState(() => _error = 'Already added'); + return; + } + setState(() { + _signers.add(address); + _error = null; + _signerController.clear(); + if (_threshold > _signers.length) _threshold = _signers.length; + }); + } + + Future _create() async { + if (_signers.length < 2) { + setState(() => _error = 'Need at least 2 signers'); + return; + } + + final accounts = ref.read(accountsProvider).value ?? []; + final signerAccount = accounts.firstWhere((a) => _signers.contains(a.accountId), orElse: () => accounts.first); + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final nonce = BigInt.from(DateTime.now().millisecondsSinceEpoch); + + await _multisigService.createMultisig( + signer: signerAccount, + signerAddresses: _signers, + threshold: _threshold, + nonce: nonce, + ); + + final derivedAddress = _multisigService.deriveMultisigAddress( + signerAddresses: _signers, + threshold: _threshold, + nonce: nonce, + ); + + final account = MultisigAccount( + name: 'Multisig', + accountId: derivedAddress, + signers: List.of(_signers), + threshold: _threshold, + ); + + await _multisigService.saveMultisigAccount(account); + ref.invalidate(multisigAccountsProvider); + await ref.read(activeAccountProvider.notifier).setActiveAccount(MultisigDisplayAccount(account)); + if (mounted) Navigator.pop(context, true); + } catch (e) { + setState(() => _error = 'Error, please try again'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return ScaffoldBase( + appBar: WalletAppBar(title: 'Create Multisig'), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text('Signers', style: context.themeText.detail?.copyWith(color: context.themeColors.inputLabel)), + const SizedBox(height: 12), + ..._signers.asMap().entries.map((entry) => _buildSignerItem(entry.key, entry.value)), + const SizedBox(height: 8), + CustomTextField( + controller: _signerController, + hintText: 'Add signer address', + variant: TextFieldVariant.secondary, + errorMsg: _error, + trailing: IconButton( + icon: const Icon(Icons.add, color: Colors.white54), + onPressed: _addSigner, + ), + ), + const SizedBox(height: 24), + Text('Threshold', style: context.themeText.detail?.copyWith(color: context.themeColors.inputLabel)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: List.generate(_signers.length, (i) { + final val = i + 1; + final isSelected = _threshold == val; + return InkWell( + onTap: () => setState(() => _threshold = val), + borderRadius: BorderRadius.circular(8), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isSelected ? context.themeColors.buttonNeutral : context.themeColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? context.themeColors.buttonNeutral : context.themeColors.borderLight, + ), + ), + child: Center( + child: Text( + '$val', + style: context.themeText.smallParagraph?.copyWith( + color: isSelected ? context.themeColors.textSecondary : context.themeColors.textPrimary, + ), + ), + ), + ), + ); + }), + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '$_threshold of ${_signers.length} signers required', + style: context.themeText.tiny?.copyWith(color: context.themeColors.textMuted), + ), + ), + ], + ), + ), + ), + Button( + label: _isLoading ? 'Creating...' : 'Create Multisig', + variant: ButtonVariant.primary, + isLoading: _isLoading, + onPressed: (_signers.length >= 2 && !_isLoading) ? _create : null, + ), + const SizedBox(height: 32), + ], + ), + ); + } + + Widget _buildSignerItem(int index, String address) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + '${index + 1}.', + style: context.themeText.detail?.copyWith(color: context.themeColors.textMuted), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: context.themeColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: context.themeColors.borderLight), + ), + child: FutureBuilder( + future: _checksumService.getHumanReadableName(address), + builder: (context, snap) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AddressFormattingService.formatAddress(address), style: context.themeText.detail), + if (snap.data != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + snap.data!, + style: context.themeText.tiny?.copyWith(color: context.themeColors.checksum), + ), + ), + ], + ); + }, + ), + ), + ), + if (_signers.length > 1) + IconButton( + icon: Icon(Icons.close, size: 18, color: context.themeColors.textMuted), + onPressed: () => setState(() { + _signers.removeAt(index); + if (_threshold > _signers.length) _threshold = _signers.length; + }), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/multisig/multisig_proposals_section.dart b/mobile-app/lib/features/main/screens/multisig/multisig_proposals_section.dart new file mode 100644 index 00000000..4904fb09 --- /dev/null +++ b/mobile-app/lib/features/main/screens/multisig/multisig_proposals_section.dart @@ -0,0 +1,158 @@ +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/main/screens/multisig/call_decoder.dart'; +import 'package:resonance_network_wallet/features/main/screens/multisig/proposal_detail_screen.dart'; +import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; +import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/multisig_providers.dart'; + +class MultisigProposalsSection extends ConsumerWidget { + final String multisigAddress; + + const MultisigProposalsSection({super.key, required this.multisigAddress}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final proposalsAsync = ref.watch(multisigProposalsProvider(multisigAddress)); + final blockNumberAsync = ref.watch(currentBlockNumberProvider); + + return proposalsAsync.when( + data: (proposals) { + if (proposals.isEmpty) return const SizedBox.shrink(); + + final currentBlock = blockNumberAsync.value ?? 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 10.0, bottom: 10.0), + child: Text( + 'Active Proposals', + style: context.themeText.smallParagraph?.copyWith(color: context.themeColors.light), + ), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15), + decoration: BoxDecoration(color: Colors.black.withAlpha(128), borderRadius: BorderRadius.circular(5)), + child: Column( + children: proposals.asMap().entries.map((entry) { + final index = entry.key; + final (proposalId, proposal) = entry.value; + final isExpired = currentBlock > proposal.expiry; + + return Column( + children: [ + if (index > 0) Divider(color: context.themeColors.darkGray, thickness: 0.5, height: 24), + _ProposalListItem( + proposalId: proposalId, + proposal: proposal, + isExpired: isExpired, + multisigAddress: multisigAddress, + ), + ], + ); + }).toList(), + ), + ), + const SizedBox(height: 16), + ], + ); + }, + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)), + ), + error: (_, _) => const SizedBox.shrink(), + ); + } +} + +class _ProposalListItem extends StatelessWidget { + final int proposalId; + final ProposalData proposal; + final bool isExpired; + final String multisigAddress; + + const _ProposalListItem({ + required this.proposalId, + required this.proposal, + required this.isExpired, + required this.multisigAddress, + }); + + @override + Widget build(BuildContext context) { + final numberFormatting = NumberFormattingService(); + final decoded = decodeTransferCall(proposal.call); + + return InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProposalDetailScreen(multisigAddress: multisigAddress, proposalId: proposalId), + ), + ); + }, + child: Row( + children: [ + Image.asset('assets/transaction/send_icon.png', width: 19, height: 19), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Proposal #$proposalId', + style: context.themeText.smallParagraph?.copyWith(fontWeight: FontWeight.w600), + ), + if (decoded != null) + Text.rich( + TextSpan( + children: [ + TextSpan( + text: numberFormatting.formatBalance(decoded.amount), + style: context.themeText.smallParagraph, + ), + TextSpan(text: ' ${AppConstants.tokenSymbol}', style: context.themeText.tiny), + ], + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${proposal.approvals.length} approvals', + style: context.themeText.tiny?.copyWith(color: context.themeColors.textMuted), + ), + if (isExpired) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: context.themeColors.buttonDanger.withAlpha(40), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Expired', + style: context.themeText.tiny?.copyWith(color: context.themeColors.buttonDanger), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/multisig/proposal_detail_screen.dart b/mobile-app/lib/features/main/screens/multisig/proposal_detail_screen.dart new file mode 100644 index 00000000..c261a0ee --- /dev/null +++ b/mobile-app/lib/features/main/screens/multisig/proposal_detail_screen.dart @@ -0,0 +1,293 @@ +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/account_tag.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/wallet_app_bar.dart'; +import 'package:resonance_network_wallet/features/main/screens/multisig/call_decoder.dart'; +import 'package:resonance_network_wallet/features/styles/app_colors_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/multisig_providers.dart'; + +class ProposalDetailScreen extends ConsumerStatefulWidget { + final String multisigAddress; + final int proposalId; + + const ProposalDetailScreen({super.key, required this.multisigAddress, required this.proposalId}); + + @override + ConsumerState createState() => _ProposalDetailScreenState(); +} + +class _ProposalDetailScreenState extends ConsumerState { + final MultisigService _multisigService = MultisigService(); + final HumanReadableChecksumService _checksumService = HumanReadableChecksumService(); + final NumberFormattingService _numberFormatting = NumberFormattingService(); + + bool _isSubmitting = false; + + Future _approve() async { + final activeAccount = ref.read(activeAccountProvider).value; + if (activeAccount == null) return; + + final accounts = ref.read(accountsProvider).value ?? []; + final signerAccount = _findSignerAccount(accounts, activeAccount); + if (signerAccount == null) return; + + setState(() => _isSubmitting = true); + try { + await _multisigService.approve( + signer: signerAccount, + multisigAddress: widget.multisigAddress, + proposalId: widget.proposalId, + ); + ref.invalidate(multisigProposalsProvider(widget.multisigAddress)); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Approve failed: $e'))); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + Future _cancel() async { + final activeAccount = ref.read(activeAccountProvider).value; + if (activeAccount == null) return; + + final accounts = ref.read(accountsProvider).value ?? []; + final signerAccount = _findSignerAccount(accounts, activeAccount); + if (signerAccount == null) return; + + setState(() => _isSubmitting = true); + try { + await _multisigService.cancel( + signer: signerAccount, + multisigAddress: widget.multisigAddress, + proposalId: widget.proposalId, + ); + ref.invalidate(multisigProposalsProvider(widget.multisigAddress)); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cancel failed: $e'))); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + Account? _findSignerAccount(List accounts, DisplayAccount active) { + if (active is! MultisigDisplayAccount) return null; + final msig = active.account; + for (final account in accounts) { + if (msig.signers.contains(account.accountId)) return account; + } + return null; + } + + @override + Widget build(BuildContext context) { + final proposalAsync = ref.watch(multisigProposalsProvider(widget.multisigAddress)); + final multisigDataAsync = ref.watch(activeMultisigDataProvider(widget.multisigAddress)); + final blockNumberAsync = ref.watch(currentBlockNumberProvider); + final activeAccount = ref.watch(activeAccountProvider).value; + final accounts = ref.watch(accountsProvider).value ?? []; + + return ScaffoldBase( + appBar: WalletAppBar(title: 'Proposal #${widget.proposalId}'), + child: proposalAsync.when( + data: (proposals) { + final match = proposals.where((p) => p.$1 == widget.proposalId); + if (match.isEmpty) { + return Center(child: Text('Proposal not found', style: context.themeText.smallParagraph)); + } + + final proposal = match.first.$2; + final currentBlock = blockNumberAsync.value ?? 0; + final isExpired = currentBlock > proposal.expiry; + final multisigData = multisigDataAsync.value; + final signers = multisigData?.signers ?? []; + final threshold = multisigData?.threshold ?? 0; + + final proposerAddress = _multisigService.signerToAddress(proposal.proposer); + final userSignerAccount = activeAccount != null ? _findSignerAccount(accounts, activeAccount) : null; + final isProposer = userSignerAccount != null && userSignerAccount.accountId == proposerAddress; + final hasApproved = + userSignerAccount != null && + proposal.approvals.any((a) => _multisigService.signerToAddress(a) == userSignerAccount.accountId); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + _buildSection( + context, + title: 'Details', + children: [ + _buildRow(context, 'Proposer', AddressFormattingService.formatAddress(proposerAddress)), + _buildRow(context, 'Threshold', '$threshold of ${signers.length} required'), + _buildRow( + context, + 'Expiry', + isExpired ? 'Expired' : 'Block #${proposal.expiry}', + valueColor: isExpired ? context.themeColors.buttonDanger : null, + ), + _buildRow(context, 'Approvals', '${proposal.approvals.length} of $threshold'), + ], + ), + const SizedBox(height: 16), + _buildTransactionSection(context, proposal), + const SizedBox(height: 16), + _buildSection( + context, + title: 'Signers', + children: signers.map((signer) { + final signerAddr = _multisigService.signerToAddress(signer); + final approved = proposal.approvals.any((a) => _multisigService.signerToAddress(a) == signerAddr); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon( + approved ? Icons.check_circle : Icons.radio_button_unchecked, + size: 18, + color: approved ? context.themeColors.buttonSuccess : context.themeColors.textMuted, + ), + const SizedBox(width: 8), + Expanded( + child: FutureBuilder( + future: _checksumService.getHumanReadableName(signerAddr), + builder: (context, snap) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (snap.data != null) + Text( + snap.data!, + style: context.themeText.detail?.copyWith(color: context.themeColors.checksum), + ), + Text( + AddressFormattingService.formatAddress(signerAddr), + style: context.themeText.tiny?.copyWith(color: context.themeColors.textMuted), + ), + ], + ); + }, + ), + ), + if (approved) AccountTag(text: 'Approved', color: context.themeColors.buttonSuccess), + ], + ), + ); + }).toList(), + ), + const SizedBox(height: 32), + if (!isExpired && !hasApproved) + Button( + label: 'Approve', + variant: ButtonVariant.primary, + isLoading: _isSubmitting, + onPressed: _isSubmitting ? null : _approve, + ), + if (!isExpired && hasApproved) + const Button( + label: 'Already Approved', + variant: ButtonVariant.glass, + isDisabled: true, + onPressed: null, + ), + if (isProposer && !isExpired) ...[ + const SizedBox(height: 12), + Button( + label: 'Cancel Proposal', + variant: ButtonVariant.dangerOutline, + isLoading: _isSubmitting, + onPressed: _isSubmitting ? null : _cancel, + ), + ], + if (isExpired) + const Button( + label: 'Proposal Expired', + variant: ButtonVariant.glass, + isDisabled: true, + onPressed: null, + ), + const SizedBox(height: 32), + ], + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator(color: Colors.white)), + error: (e, _) => Center( + child: Text('Error: $e', style: context.themeText.detail?.copyWith(color: context.themeColors.textError)), + ), + ), + ); + } + + Widget _buildSection(BuildContext context, {required String title, required List children}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.themeColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: context.themeColors.borderLight), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: context.themeText.smallParagraph?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + ...children, + ], + ), + ); + } + + Widget _buildRow(BuildContext context, String label, String value, {Color? valueColor}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: context.themeText.detail?.copyWith(color: context.themeColors.textMuted)), + Flexible( + child: Text( + value, + style: context.themeText.detail?.copyWith(color: valueColor), + textAlign: TextAlign.right, + ), + ), + ], + ), + ); + } + + Widget _buildTransactionSection(BuildContext context, ProposalData proposal) { + final decoded = decodeTransferCall(proposal.call); + + return _buildSection( + context, + title: 'Transaction', + children: [ + if (decoded != null) ...[ + _buildRow(context, 'Type', 'Transfer'), + _buildRow( + context, + 'Amount', + '${_numberFormatting.formatBalance(decoded.amount)} ${AppConstants.tokenSymbol}', + ), + _buildRow(context, 'To', AddressFormattingService.formatAddress(decoded.destination)), + ] else + _buildRow(context, 'Type', 'Unknown call (${proposal.call.length} bytes)'), + ], + ); + } +} diff --git a/mobile-app/lib/features/main/screens/multisig/propose_screen.dart b/mobile-app/lib/features/main/screens/multisig/propose_screen.dart new file mode 100644 index 00000000..023c724e --- /dev/null +++ b/mobile-app/lib/features/main/screens/multisig/propose_screen.dart @@ -0,0 +1,333 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/styles/app_colors_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/multisig_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; + +class ProposeScreen extends ConsumerStatefulWidget { + const ProposeScreen({super.key}); + + @override + ConsumerState createState() => _ProposeScreenState(); +} + +class _ProposeScreenState extends ConsumerState { + final TextEditingController _recipientController = TextEditingController(); + final TextEditingController _amountController = TextEditingController(); + final SubstrateService _substrateService = SubstrateService(); + final BalancesService _balancesService = BalancesService(); + final MultisigService _multisigService = MultisigService(); + final NumberFormattingService _numberFormatting = NumberFormattingService(); + final HumanReadableChecksumService _checksumService = HumanReadableChecksumService(); + + String _checkphrase = ''; + String? _addressError; + String? _amountError; + BigInt _networkFee = BigInt.zero; + bool _isFetchingFee = false; + bool _isSubmitting = false; + int _expiryBlocks = 7200; // ~24h at 12s blocks + Timer? _debounce; + + @override + void dispose() { + _recipientController.dispose(); + _amountController.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _recipientController.addListener(_onInputChanged); + _amountController.addListener(_onInputChanged); + } + + void _onInputChanged() { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 500), _fetchFee); + } + + Future _validateAddress(String address) async { + if (address.isEmpty) { + setState(() { + _addressError = null; + _checkphrase = ''; + }); + return; + } + + if (!_substrateService.isValidSS58Address(address)) { + setState(() { + _addressError = 'Invalid address'; + _checkphrase = ''; + }); + return; + } + + final name = await _checksumService.getHumanReadableName(address); + setState(() { + _addressError = null; + _checkphrase = name; + }); + } + + Future _fetchFee() async { + final recipient = _recipientController.text.trim(); + final amountText = _amountController.text.trim(); + + await _validateAddress(recipient); + if (_addressError != null || recipient.isEmpty || amountText.isEmpty) return; + + final amount = _parseAmount(amountText); + if (amount == null || amount == BigInt.zero) return; + + final activeAccount = ref.read(activeAccountProvider).value; + if (activeAccount is! MultisigDisplayAccount) return; + + final accounts = ref.read(accountsProvider).value ?? []; + final signerAccount = _findSigner(accounts, activeAccount.account); + if (signerAccount == null) return; + + setState(() => _isFetchingFee = true); + try { + final call = _balancesService.getBalanceTransferCall(recipient, amount); + final encodedCall = call.encode(); + final currentBlock = await _multisigService.getCurrentBlockNumber(); + final expiry = currentBlock + _expiryBlocks; + + final feeData = await _multisigService.getProposeFee( + signer: signerAccount, + multisigAddress: activeAccount.account.accountId, + encodedCall: encodedCall, + expiry: expiry, + ); + setState(() => _networkFee = feeData.fee); + } catch (_) { + } finally { + if (mounted) setState(() => _isFetchingFee = false); + } + } + + BigInt? _parseAmount(String text) { + try { + final parts = text.split('.'); + final whole = BigInt.parse(parts[0]); + BigInt fraction = BigInt.zero; + if (parts.length > 1) { + final fracStr = parts[1].padRight(12, '0').substring(0, 12); + fraction = BigInt.parse(fracStr); + } + return whole * BigInt.from(10).pow(12) + fraction; + } catch (_) { + return null; + } + } + + Account? _findSigner(List accounts, MultisigAccount msig) { + for (final account in accounts) { + if (msig.signers.contains(account.accountId)) return account; + } + return null; + } + + bool get _isValid { + final recipient = _recipientController.text.trim(); + final amountText = _amountController.text.trim(); + if (recipient.isEmpty || amountText.isEmpty || _addressError != null) return false; + final amount = _parseAmount(amountText); + return amount != null && amount > BigInt.zero; + } + + Future _propose() async { + if (!_isValid) return; + + final activeAccount = ref.read(activeAccountProvider).value; + if (activeAccount is! MultisigDisplayAccount) return; + + final accounts = ref.read(accountsProvider).value ?? []; + final signerAccount = _findSigner(accounts, activeAccount.account); + if (signerAccount == null) return; + + final recipient = _recipientController.text.trim(); + final amount = _parseAmount(_amountController.text.trim())!; + + setState(() => _isSubmitting = true); + try { + final call = _balancesService.getBalanceTransferCall(recipient, amount); + final encodedCall = call.encode(); + final currentBlock = await _multisigService.getCurrentBlockNumber(); + final expiry = currentBlock + _expiryBlocks; + + await _multisigService.propose( + signer: signerAccount, + multisigAddress: activeAccount.account.accountId, + encodedCall: encodedCall, + expiry: expiry, + ); + + ref.invalidate(multisigProposalsProvider(activeAccount.account.accountId)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Proposal submitted'))); + Navigator.pop(context); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Propose failed: $e'))); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + @override + Widget build(BuildContext context) { + final balanceAsync = ref.watch(balanceProvider); + + return ScaffoldBase( + appBar: WalletAppBar(title: 'Propose Transfer'), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + CustomTextField( + controller: _recipientController, + labelText: 'Recipient', + hintText: 'Enter address', + variant: TextFieldVariant.secondary, + errorMsg: _addressError, + trailing: IconButton( + icon: const Icon(Icons.content_paste, color: Colors.white54, size: 20), + onPressed: () async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null) { + _recipientController.text = data!.text!; + } + }, + ), + ), + if (_checkphrase.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _checkphrase, + style: context.themeText.detail?.copyWith(color: context.themeColors.checksum), + ), + ), + const SizedBox(height: 20), + CustomTextField( + controller: _amountController, + labelText: 'Amount', + hintText: '0.00', + variant: TextFieldVariant.secondary, + errorMsg: _amountError, + trailing: balanceAsync.when( + data: (balance) => InkWell( + onTap: () { + _amountController.text = _numberFormatting.formatBalance(balance, addSymbol: false); + }, + child: Text('MAX', style: context.themeText.detail?.copyWith(color: context.themeColors.checksum)), + ), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + ), + const SizedBox(height: 20), + _buildExpirySelector(), + const SizedBox(height: 20), + _buildFeeRow(context), + const SizedBox(height: 32), + Button( + label: _isSubmitting ? 'Submitting...' : 'Propose', + variant: ButtonVariant.primary, + isLoading: _isSubmitting, + onPressed: (_isValid && !_isSubmitting) ? _propose : null, + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildExpirySelector() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Expiry', style: context.themeText.detail?.copyWith(color: context.themeColors.inputLabel)), + const SizedBox(height: 8), + Row( + children: [ + _expiryOption('1 hour', 300), + const SizedBox(width: 8), + _expiryOption('24 hours', 7200), + const SizedBox(width: 8), + _expiryOption('1 week', 50400), + const SizedBox(width: 8), + _expiryOption('2 weeks', 100800), + ], + ), + ], + ); + } + + Widget _expiryOption(String label, int blocks) { + final isSelected = _expiryBlocks == blocks; + return Expanded( + child: InkWell( + onTap: () => setState(() => _expiryBlocks = blocks), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isSelected ? context.themeColors.buttonNeutral : context.themeColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: isSelected ? context.themeColors.buttonNeutral : context.themeColors.borderLight), + ), + child: Center( + child: Text( + label, + style: context.themeText.tiny?.copyWith( + color: isSelected ? context.themeColors.textSecondary : context.themeColors.textPrimary, + ), + ), + ), + ), + ), + ); + } + + Widget _buildFeeRow(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Network Fee', style: context.themeText.detail?.copyWith(color: context.themeColors.textMuted)), + _isFetchingFee + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 1.5, color: Colors.white54), + ) + : Text( + _networkFee > BigInt.zero + ? '${_numberFormatting.formatBalance(_networkFee)} ${AppConstants.tokenSymbol}' + : '--', + style: context.themeText.detail, + ), + ], + ); + } +} diff --git a/mobile-app/lib/features/main/screens/wallet_main/account_details.dart b/mobile-app/lib/features/main/screens/wallet_main/account_details.dart index b17cc5d9..d4899727 100644 --- a/mobile-app/lib/features/main/screens/wallet_main/account_details.dart +++ b/mobile-app/lib/features/main/screens/wallet_main/account_details.dart @@ -11,8 +11,14 @@ import 'package:resonance_network_wallet/shared/extensions/media_query_data_exte class AccountDetails extends ConsumerWidget { final BaseAccount activeAccount; final bool isEntrustedAccount; + final bool isMultisigAccount; - const AccountDetails({super.key, required this.activeAccount, this.isEntrustedAccount = false}); + const AccountDetails({ + super.key, + required this.activeAccount, + this.isEntrustedAccount = false, + this.isMultisigAccount = false, + }); void _showActionSheet(BuildContext context, BaseAccount account) { showAccountCopyActionSheet(context, account); @@ -39,7 +45,8 @@ class AccountDetails extends ConsumerWidget { children: [ if (isEntrustedAccount) AccountTag(text: 'Entrusted Account', color: context.themeColors.accountTagEntrusted), - if (isHighSecurity && !isEntrustedAccount) + if (isMultisigAccount) AccountTag(text: 'Multisig', color: context.themeColors.accountTagMultisig), + if (isHighSecurity && !isEntrustedAccount && !isMultisigAccount) AccountTag(text: 'High Security', color: context.themeColors.accountTagEntrusted), Container( width: double.infinity, diff --git a/mobile-app/lib/features/main/screens/wallet_main/action_button.dart b/mobile-app/lib/features/main/screens/wallet_main/action_button.dart index 2a642579..5d53d2a8 100644 --- a/mobile-app/lib/features/main/screens/wallet_main/action_button.dart +++ b/mobile-app/lib/features/main/screens/wallet_main/action_button.dart @@ -4,7 +4,7 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; -enum ActionType { send, receive, bridge, swap } +enum ActionType { send, receive, bridge, swap, propose } class ActionButton extends StatelessWidget { final ActionType type; @@ -23,12 +23,15 @@ class ActionButton extends StatelessWidget { return 'BRIDGE'; case ActionType.swap: return 'SWAP'; + case ActionType.propose: + return 'PROPOSE'; } } Widget get iconWidget { switch (type) { case ActionType.send: + case ActionType.propose: return Image.asset('assets/transaction/send_icon.png'); case ActionType.receive: return SvgPicture.asset('assets/transaction/receive_icon.svg', width: 19); @@ -42,6 +45,7 @@ class ActionButton extends StatelessWidget { String get frameImagePath { switch (type) { case ActionType.send: + case ActionType.propose: return 'assets/send_btn_decoration.png'; case ActionType.receive: return 'assets/receive_btn_decoration.png'; diff --git a/mobile-app/lib/features/main/screens/wallet_main/wallet_main.dart b/mobile-app/lib/features/main/screens/wallet_main/wallet_main.dart index 786eec7b..4ec74eef 100644 --- a/mobile-app/lib/features/main/screens/wallet_main/wallet_main.dart +++ b/mobile-app/lib/features/main/screens/wallet_main/wallet_main.dart @@ -10,6 +10,8 @@ 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/receive_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/notifications_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/multisig/multisig_proposals_section.dart'; +import 'package:resonance_network_wallet/features/main/screens/multisig/propose_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/send/send_screen.dart'; import 'package:resonance_network_wallet/features/main/screens/wallet_main/account_details.dart'; import 'package:resonance_network_wallet/features/main/screens/wallet_main/action_button.dart'; @@ -153,6 +155,7 @@ class _WalletMainState extends ConsumerState { AccountDetails( activeAccount: activeDisplayAccount.account, isEntrustedAccount: activeDisplayAccount is EntrustedDisplayAccount, + isMultisigAccount: activeDisplayAccount is MultisigDisplayAccount, ), const SizedBox(height: 20), balanceAsync.when( @@ -213,12 +216,35 @@ class _WalletMainState extends ConsumerState { ), ], ) + else if (activeDisplayAccount is MultisigDisplayAccount) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ActionButton( + type: ActionType.propose, + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (context) => const ProposeScreen())); + }, + ), + const SizedBox(width: 33), + ActionButton( + type: ActionType.receive, + onPressed: () { + showReceiveSheet(context); + }, + ), + ], + ) else if (activeDisplayAccount is EntrustedDisplayAccount) const EmergencyButton(), const SizedBox(height: 30), ], ), ), + if (activeDisplayAccount is MultisigDisplayAccount) + SliverToBoxAdapter( + child: MultisigProposalsSection(multisigAddress: activeDisplayAccount.account.accountId), + ), SliverToBoxAdapter( child: HistorySection( allTransactionsAsync: activeAccountTransactionsAsync, diff --git a/mobile-app/lib/features/styles/app_colors_theme.dart b/mobile-app/lib/features/styles/app_colors_theme.dart index e7de2065..05e8c4c6 100644 --- a/mobile-app/lib/features/styles/app_colors_theme.dart +++ b/mobile-app/lib/features/styles/app_colors_theme.dart @@ -43,6 +43,7 @@ class AppColorsTheme extends ThemeExtension { final Color accountTagGuardian; final Color accountTagEntrusted; final Color accountTagHighSecurity; + final Color accountTagMultisig; const AppColorsTheme({ required this.primary, @@ -83,6 +84,7 @@ class AppColorsTheme extends ThemeExtension { required this.accountTagGuardian, required this.accountTagEntrusted, required this.accountTagHighSecurity, + required this.accountTagMultisig, }); const AppColorsTheme.light() @@ -125,6 +127,7 @@ class AppColorsTheme extends ThemeExtension { accountTagGuardian: const Color(0xFF9747FF), accountTagEntrusted: const Color(0xFFFFD541), accountTagHighSecurity: const Color(0xFF4CEDE7), + accountTagMultisig: const Color(0xFF2ECC71), ); const AppColorsTheme.dark() @@ -167,6 +170,7 @@ class AppColorsTheme extends ThemeExtension { accountTagGuardian: const Color(0xFF9747FF), accountTagEntrusted: const Color(0xFFFFD541), accountTagHighSecurity: const Color(0xFF4CEDE7), + accountTagMultisig: const Color(0xFF2ECC71), ); @override @@ -209,6 +213,7 @@ class AppColorsTheme extends ThemeExtension { Color? accountTagGuardian, Color? accountTagEntrusted, Color? accountTagHighSecurity, + Color? accountTagMultisig, }) { return AppColorsTheme( primary: primary ?? this.primary, @@ -248,6 +253,7 @@ class AppColorsTheme extends ThemeExtension { accountTagGuardian: accountTagGuardian ?? this.accountTagGuardian, accountTagEntrusted: accountTagEntrusted ?? this.accountTagEntrusted, accountTagHighSecurity: accountTagHighSecurity ?? this.accountTagHighSecurity, + accountTagMultisig: accountTagMultisig ?? this.accountTagMultisig, ); } @@ -293,6 +299,7 @@ class AppColorsTheme extends ThemeExtension { accountTagEntrusted: Color.lerp(accountTagEntrusted, other.accountTagEntrusted, t) ?? accountTagEntrusted, accountTagHighSecurity: Color.lerp(accountTagHighSecurity, other.accountTagHighSecurity, t) ?? accountTagHighSecurity, + accountTagMultisig: Color.lerp(accountTagMultisig, other.accountTagMultisig, t) ?? accountTagMultisig, ); } } diff --git a/mobile-app/lib/providers/multisig_providers.dart b/mobile-app/lib/providers/multisig_providers.dart new file mode 100644 index 00000000..b17ca23c --- /dev/null +++ b/mobile-app/lib/providers/multisig_providers.dart @@ -0,0 +1,29 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +final multisigServiceProvider = Provider((ref) { + return MultisigService(); +}); + +final multisigAccountsProvider = FutureProvider>((ref) async { + final service = ref.watch(multisigServiceProvider); + return await service.getSavedMultisigAccounts(); +}); + +final activeMultisigDataProvider = FutureProvider.family((ref, address) async { + final service = ref.watch(multisigServiceProvider); + return await service.getMultisigData(address); +}); + +final multisigProposalsProvider = FutureProvider.family, String>(( + ref, + multisigAddress, +) async { + final service = ref.watch(multisigServiceProvider); + return await service.getActiveProposals(multisigAddress); +}); + +final currentBlockNumberProvider = FutureProvider((ref) async { + final service = ref.watch(multisigServiceProvider); + return await service.getCurrentBlockNumber(); +}); diff --git a/mobile-app/lib/shared/utils/account_utils.dart b/mobile-app/lib/utils/account_utils.dart similarity index 100% rename from mobile-app/lib/shared/utils/account_utils.dart rename to mobile-app/lib/utils/account_utils.dart diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 3081c797..db870c6f 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -63,6 +63,11 @@ export 'src/quantus_signing_payload.dart'; export 'src/quantus_payload_parser.dart'; export 'src/models/entrusted_account.dart'; export 'src/models/display_account.dart'; +export 'src/models/multisig_account.dart'; +export 'src/services/multisig_service.dart'; +export 'generated/dirac/types/pallet_multisig/multisig_data.dart'; +export 'generated/dirac/types/pallet_multisig/proposal_data.dart'; +export 'generated/dirac/types/pallet_multisig/proposal_status.dart'; class QuantusSdk { /// Initialise the SDK (loads Rust FFI, etc). diff --git a/quantus_sdk/lib/src/extensions/duration_extension.dart b/quantus_sdk/lib/src/extensions/duration_extension.dart index c4be7ad4..ba029618 100644 --- a/quantus_sdk/lib/src/extensions/duration_extension.dart +++ b/quantus_sdk/lib/src/extensions/duration_extension.dart @@ -1,4 +1,4 @@ -import 'package:quantus_sdk/generated/schrodinger/types/qp_scheduler/block_number_or_timestamp.dart' as qp; +import 'package:quantus_sdk/generated/dirac/types/qp_scheduler/block_number_or_timestamp.dart' as qp; extension DurationToTimestampExtension on Duration { qp.Timestamp get qpTimestamp => qp.Timestamp(BigInt.from(inSeconds) * BigInt.from(1000)); diff --git a/quantus_sdk/lib/src/models/display_account.dart b/quantus_sdk/lib/src/models/display_account.dart index 51da6afa..08caf1df 100644 --- a/quantus_sdk/lib/src/models/display_account.dart +++ b/quantus_sdk/lib/src/models/display_account.dart @@ -1,8 +1,8 @@ import 'package:quantus_sdk/src/models/account.dart'; import 'package:quantus_sdk/src/models/base_account.dart'; import 'package:quantus_sdk/src/models/entrusted_account.dart'; +import 'package:quantus_sdk/src/models/multisig_account.dart'; -// Union type for display accounts sealed class DisplayAccount { const DisplayAccount(); @@ -17,6 +17,8 @@ sealed class DisplayAccount { return RegularAccount.fromJson(json); case 'entrusted': return EntrustedDisplayAccount.fromJson(json); + case 'multisig': + return MultisigDisplayAccount.fromJson(json); default: throw Exception('Unknown display account type: $type'); } @@ -24,6 +26,7 @@ sealed class DisplayAccount { bool get isEntrustedAccount => this is EntrustedDisplayAccount; bool get isRegularAccount => this is RegularAccount; + bool get isMultisigAccount => this is MultisigDisplayAccount; } class RegularAccount extends DisplayAccount { @@ -55,3 +58,18 @@ class EntrustedDisplayAccount extends DisplayAccount { return {'type': 'entrusted', 'account': account.toJson()}; } } + +class MultisigDisplayAccount extends DisplayAccount { + @override + final MultisigAccount account; + const MultisigDisplayAccount(this.account); + + factory MultisigDisplayAccount.fromJson(Map json) { + return MultisigDisplayAccount(MultisigAccount.fromJson(json['account'] as Map)); + } + + @override + Map toJson() { + return {'type': 'multisig', 'account': account.toJson()}; + } +} diff --git a/quantus_sdk/lib/src/models/multisig_account.dart b/quantus_sdk/lib/src/models/multisig_account.dart new file mode 100644 index 00000000..968b9082 --- /dev/null +++ b/quantus_sdk/lib/src/models/multisig_account.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:quantus_sdk/src/models/base_account.dart'; + +@immutable +class MultisigAccount implements BaseAccount { + @override + final String name; + @override + final String accountId; + final List signers; + final int threshold; + + const MultisigAccount({required this.name, required this.accountId, required this.signers, required this.threshold}); + + factory MultisigAccount.fromJson(Map json) { + return MultisigAccount( + name: json['name'] as String, + accountId: json['accountId'] as String, + signers: (json['signers'] as List).cast(), + threshold: json['threshold'] as int, + ); + } + + Map toJson() { + return {'name': name, 'accountId': accountId, 'signers': signers, 'threshold': threshold}; + } + + MultisigAccount copyWith({String? name}) { + return MultisigAccount(name: name ?? this.name, accountId: accountId, signers: signers, threshold: threshold); + } +} diff --git a/quantus_sdk/lib/src/services/multisig_service.dart b/quantus_sdk/lib/src/services/multisig_service.dart new file mode 100644 index 00000000..54f6e71a --- /dev/null +++ b/quantus_sdk/lib/src/services/multisig_service.dart @@ -0,0 +1,256 @@ +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +import 'package:polkadart/polkadart.dart'; +import 'package:polkadart/scale_codec.dart' show ByteInput, ByteOutput, U32Codec, U64Codec; +import 'package:quantus_sdk/generated/dirac/dirac.dart'; +import 'package:quantus_sdk/generated/dirac/types/pallet_multisig/multisig_data.dart'; +import 'package:quantus_sdk/generated/dirac/types/pallet_multisig/proposal_data.dart'; +import 'package:quantus_sdk/generated/dirac/types/pallet_multisig/proposal_status.dart'; +import 'package:quantus_sdk/src/extensions/address_extension.dart'; +import 'package:quantus_sdk/src/models/account.dart'; +import 'package:quantus_sdk/src/models/extrinsic_fee_data.dart'; +import 'package:quantus_sdk/src/models/multisig_account.dart'; +import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; +import 'package:quantus_sdk/src/services/settings_service.dart'; +import 'package:quantus_sdk/src/services/substrate_service.dart'; +import 'package:quantus_sdk/src/services/network/redundant_endpoint.dart'; + +class MultisigService { + static final MultisigService _instance = MultisigService._internal(); + factory MultisigService() => _instance; + MultisigService._internal(); + + final SubstrateService _substrateService = SubstrateService(); + final RpcEndpointService _rpcEndpointService = RpcEndpointService(); + final SettingsService _settingsService = SettingsService(); + + Dirac get _api => Dirac(_substrateService.provider!); + + Uint8List _accountId(String address) => crypto.ss58ToAccountId(s: address); + + Future getMultisigData(String address) async { + return await _api.query.multisig.multisigs(_accountId(address)); + } + + Future getProposal(String multisigAddress, int proposalId) async { + return await _api.query.multisig.proposals(_accountId(multisigAddress), proposalId); + } + + Future> getActiveProposals(String multisigAddress) async { + final data = await getMultisigData(multisigAddress); + if (data == null) return []; + + final results = <(int, ProposalData)>[]; + for (int i = 0; i < data.proposalNonce; i++) { + final proposal = await getProposal(multisigAddress, i); + if (proposal != null && proposal.status == ProposalStatus.active) { + results.add((i, proposal)); + } + } + return results; + } + + Future createMultisig({ + required Account signer, + required List signerAddresses, + required int threshold, + required BigInt nonce, + }) async { + final signerIds = signerAddresses.map((a) => _accountId(a).toList()).toList(); + final call = _api.tx.multisig.createMultisig(signers: signerIds, threshold: threshold, nonce: nonce); + return await _substrateService.submitExtrinsic(signer, call); + } + + Future getCreateMultisigFee({ + required Account signer, + required List signerAddresses, + required int threshold, + required BigInt nonce, + }) async { + final signerIds = signerAddresses.map((a) => _accountId(a).toList()).toList(); + final call = _api.tx.multisig.createMultisig(signers: signerIds, threshold: threshold, nonce: nonce); + return await _substrateService.getFeeForCall(signer, call); + } + + String deriveMultisigAddress({required List signerAddresses, required int threshold, required BigInt nonce}) { + final signerIds = signerAddresses.map((a) => _accountId(a).toList()).toList() + ..sort((a, b) { + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return a[i].compareTo(b[i]); + } + return 0; + }); + + final palletId = const [112, 121, 47, 109, 108, 116, 115, 103]; + + final output = ByteOutput(256); + for (final b in palletId) { + output.pushByte(b); + } + for (final signer in signerIds) { + for (final b in signer) { + output.pushByte(b); + } + } + U32Codec.codec.encodeTo(threshold, output); + U64Codec.codec.encodeTo(nonce, output); + + final hash = Hasher.blake2b256.hash(output.toBytes()); + return AddressExtension.ss58AddressFromBytes(Uint8List.fromList(hash)); + } + + Future propose({ + required Account signer, + required String multisigAddress, + required List encodedCall, + required int expiry, + }) async { + final call = _api.tx.multisig.propose( + multisigAddress: _accountId(multisigAddress), + call: encodedCall, + expiry: expiry, + ); + return await _substrateService.submitExtrinsic(signer, call); + } + + Future approve({required Account signer, required String multisigAddress, required int proposalId}) async { + final call = _api.tx.multisig.approve(multisigAddress: _accountId(multisigAddress), proposalId: proposalId); + return await _substrateService.submitExtrinsic(signer, call); + } + + Future cancel({required Account signer, required String multisigAddress, required int proposalId}) async { + final call = _api.tx.multisig.cancel(multisigAddress: _accountId(multisigAddress), proposalId: proposalId); + return await _substrateService.submitExtrinsic(signer, call); + } + + Future removeExpired({ + required Account signer, + required String multisigAddress, + required int proposalId, + }) async { + final call = _api.tx.multisig.removeExpired(multisigAddress: _accountId(multisigAddress), proposalId: proposalId); + return await _substrateService.submitExtrinsic(signer, call); + } + + Future getProposeFee({ + required Account signer, + required String multisigAddress, + required List encodedCall, + required int expiry, + }) async { + final call = _api.tx.multisig.propose( + multisigAddress: _accountId(multisigAddress), + call: encodedCall, + expiry: expiry, + ); + return await _substrateService.getFeeForCall(signer, call); + } + + Future getApproveFee({ + required Account signer, + required String multisigAddress, + required int proposalId, + }) async { + final call = _api.tx.multisig.approve(multisigAddress: _accountId(multisigAddress), proposalId: proposalId); + return await _substrateService.getFeeForCall(signer, call); + } + + Future getCancelFee({ + required Account signer, + required String multisigAddress, + required int proposalId, + }) async { + final call = _api.tx.multisig.cancel(multisigAddress: _accountId(multisigAddress), proposalId: proposalId); + return await _substrateService.getFeeForCall(signer, call); + } + + Future getCurrentBlockNumber() async { + final result = await _rpcEndpointService.rpcTask((uri) async { + final provider = Provider.fromUri(uri); + return await provider.send('chain_getHeader', []); + }); + return int.parse(result.result['number']); + } + + Future> discoverMultisigs(List userAccountIds) async { + final allMultisigKeys = await _rpcEndpointService.rpcTask((uri) async { + final provider = Provider.fromUri(uri); + final prefix = '0x${hex.encode(_api.query.multisig.multisigsMapPrefix())}'; + return await provider.send('state_getKeys', [prefix]); + }); + + final keys = (allMultisigKeys.result as List?) ?? []; + if (keys.isEmpty) return []; + + final userAccountIdBytes = userAccountIds.map((id) => crypto.ss58ToAccountId(s: id)).toList(); + + final discovered = []; + for (final key in keys) { + final storageBytes = await _rpcEndpointService.rpcTask((uri) async { + final provider = Provider.fromUri(uri); + return await provider.send('state_getStorage', [key]); + }); + + if (storageBytes.result == null) continue; + + final valueHex = (storageBytes.result as String).substring(2); + final valueBytes = Uint8List.fromList(hex.decode(valueHex)); + final multisigData = MultisigData.decode(ByteInput(valueBytes)); + + final isUserSigner = multisigData.signers.any( + (signer) => userAccountIdBytes.any((userId) => _bytesEqual(signer, userId)), + ); + + if (!isUserSigner) continue; + + final keyHex = (key as String).substring(2); + final keyBytes = hex.decode(keyHex); + final accountIdBytes = Uint8List.fromList(keyBytes.sublist(keyBytes.length - 32)); + final address = AddressExtension.ss58AddressFromBytes(accountIdBytes); + + final signerAddresses = multisigData.signers + .map((s) => AddressExtension.ss58AddressFromBytes(Uint8List.fromList(s))) + .toList(); + + discovered.add( + MultisigAccount( + name: 'Multisig ${discovered.length + 1}', + accountId: address, + signers: signerAddresses, + threshold: multisigData.threshold, + ), + ); + } + + return discovered; + } + + bool _bytesEqual(List a, List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + String signerToAddress(List accountId32) { + return AddressExtension.ss58AddressFromBytes(Uint8List.fromList(accountId32)); + } + + Future> getSavedMultisigAccounts() async { + return _settingsService.getMultisigAccounts(); + } + + Future saveMultisigAccount(MultisigAccount account) async { + final existing = await _settingsService.getMultisigAccounts(); + final updated = [...existing.where((a) => a.accountId != account.accountId), account]; + await _settingsService.saveMultisigAccounts(updated); + } + + Future removeMultisigAccount(String accountId) async { + final existing = await _settingsService.getMultisigAccounts(); + final updated = existing.where((a) => a.accountId != accountId).toList(); + await _settingsService.saveMultisigAccounts(updated); + } +} diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index 8fa58ce4..44234ac6 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:quantus_sdk/src/models/account.dart'; import 'package:quantus_sdk/src/models/display_account.dart'; +import 'package:quantus_sdk/src/models/multisig_account.dart'; import 'package:shared_preferences/shared_preferences.dart'; class SettingsService { @@ -17,6 +18,7 @@ class SettingsService { static const String _accountsKey = 'accounts_v4'; static const String _accountsToMigrateKey = 'accounts_to_migrate'; static const String _addressBookKey = 'address_book'; + static const String _multisigAccountsKey = 'multisig_accounts'; static const String _oldAccountsKeyV3 = 'accounts_v3'; static const String _oldAccountsKeyV2 = 'accounts_v2'; @@ -241,6 +243,23 @@ class SettingsService { await saveAddressBook(addressBook); } + // --- Multisig Account Methods --- + + Future> getMultisigAccounts() async { + final jsonStr = _prefs.getString(_multisigAccountsKey); + if (jsonStr == null) return []; + try { + final decoded = jsonDecode(jsonStr) as List; + return decoded.map((e) => MultisigAccount.fromJson(e as Map)).toList(); + } catch (_) { + return []; + } + } + + Future saveMultisigAccounts(List accounts) async { + await _prefs.setString(_multisigAccountsKey, jsonEncode(accounts.map((a) => a.toJson()).toList())); + } + // --- End Multi-Account Methods --- Future getHasWallet() async { diff --git a/quantus_sdk/lib/src/services/substrate_service.dart b/quantus_sdk/lib/src/services/substrate_service.dart index 2899674b..d2c856d2 100644 --- a/quantus_sdk/lib/src/services/substrate_service.dart +++ b/quantus_sdk/lib/src/services/substrate_service.dart @@ -5,7 +5,7 @@ import 'package:bip39_mnemonic/bip39_mnemonic.dart'; import 'package:convert/convert.dart'; import 'package:flutter/foundation.dart'; import 'package:polkadart/polkadart.dart'; -import 'package:quantus_sdk/generated/schrodinger/schrodinger.dart'; +import 'package:quantus_sdk/generated/dirac/dirac.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:quantus_sdk/src/resonance_extrinsic_payload.dart'; import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; @@ -72,7 +72,7 @@ class SubstrateService { final accountInfo = await _rpcEndpointService.rpcTask((uri) async { final provider = Provider.fromUri(uri); - final quantusApi = Schrodinger(provider); + final quantusApi = Dirac(provider); return await quantusApi.query.system.account(accountID); }); @@ -226,7 +226,7 @@ class SubstrateService { final registry = await _rpcEndpointService.rpcTask((uri) async { final provider = Provider.fromUri(uri); - return Schrodinger(provider).registry; + return Dirac(provider).registry; }); final payload = payloadToSign.encode(registry); @@ -304,7 +304,7 @@ class SubstrateService { final registry = await _rpcEndpointService.rpcTask((uri) async { final provider = Provider.fromUri(uri); - return Schrodinger(provider).registry; + return Dirac(provider).registry; }); return UnsignedTransactionData(payloadToSign: payloadToSign, signer: accountIdBytes, registry: registry);