diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 7edc8839..12be6de4 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -268,6 +268,14 @@ "@addAccountMenuMultisigSubtitle": { "description": "Add multisig menu row subtitle" }, + "addAccountMenuDiscoverMultisigTitle": "Discover Multisig", + "@addAccountMenuDiscoverMultisigTitle": { + "description": "Discover multisig menu row title" + }, + "addAccountMenuDiscoverMultisigSubtitle": "Find multisigs where your accounts are signers", + "@addAccountMenuDiscoverMultisigSubtitle": { + "description": "Discover multisig menu row subtitle" + }, "multisigTag": "MULTISIG", "@multisigTag": { @@ -281,6 +289,10 @@ "@multisigAddTitle": { "description": "Create multisig screen app bar title" }, + "multisigDiscoverTitle": "Discover Multisig", + "@multisigDiscoverTitle": { + "description": "Discover multisig screen app bar title" + }, "multisigCreateSubtitle": "Give this multisig a name you'll recognize. You can change it anytime.", "@multisigCreateSubtitle": { "description": "Subtitle under multisig name field" @@ -374,10 +386,6 @@ "@multisigDone": { "description": "Done button on multisig flow completion screens" }, - "multisigAddPasteAddressSection": "Paste Multisig Address", - "@multisigAddPasteAddressSection": { - "description": "Section label for manual multisig address entry" - }, "multisigAddDiscoveredTitle": "Discovered for you", "@multisigAddDiscoveredTitle": { "description": "Section title for on-chain discovered multisigs" @@ -386,14 +394,6 @@ "@multisigAddDiscoveredSubtitle": { "description": "Helper text under discovered multisigs section" }, - "multisigAddAddressHint": "Multisig SS58 address", - "@multisigAddAddressHint": { - "description": "Hint for multisig address text field" - }, - "multisigAddFromAddressButton": "Add From Address", - "@multisigAddFromAddressButton": { - "description": "Primary button to add multisig from pasted address" - }, "multisigAddButton": "Add", "@multisigAddButton": { "description": "Add button on discovered multisig row" diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index f8274306..2d3d0685 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -68,10 +68,13 @@ "addAccountMenuImportSubtitle": "Gunakan recovery phrase untuk mengimpor", "addAccountMenuMultisigTitle": "Buat Multisig", "addAccountMenuMultisigSubtitle": "Siapkan alamat bersama dengan beberapa penandatangan", + "addAccountMenuDiscoverMultisigTitle": "Temukan Multisig", + "addAccountMenuDiscoverMultisigSubtitle": "Cari multisig di mana akun Anda adalah penandatangan", "multisigTag": "MULTISIG", "multisigProposeTitle": "Ajukan", "multisigAddTitle": "Buat Multisig", + "multisigDiscoverTitle": "Temukan Multisig", "multisigCreateSubtitle": "Berikan nama multisig yang mudah Anda kenali. Anda bisa mengubahnya kapan saja.", "multisigCreateButton": "Buat", "multisigCreateCreatingButton": "Membuat", @@ -92,11 +95,8 @@ "multisigCreatePredictedAddressLabel": "ALAMAT MULTISIG", "multisigCreatePredictedAddressPlaceholder": "Tambahkan penandatangan untuk melihat alamat", "multisigDone": "Selesai", - "multisigAddPasteAddressSection": "Tempel Alamat Multisig", "multisigAddDiscoveredTitle": "Ditemukan untuk Anda", "multisigAddDiscoveredSubtitle": "Multisig di chain di mana salah satu akun Anda adalah penandatangan", - "multisigAddAddressHint": "Alamat SS58 multisig", - "multisigAddFromAddressButton": "Tambah Dari Alamat", "multisigAddButton": "Tambah", "multisigAddedButton": "Ditambahkan", "multisigAddNoneFound": "Tidak ada multisig ditemukan.", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index d13e8695..9288ce0a 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -446,6 +446,18 @@ abstract class AppLocalizations { /// **'Set up a shared address with multiple signers'** String get addAccountMenuMultisigSubtitle; + /// Discover multisig menu row title + /// + /// In en, this message translates to: + /// **'Discover Multisig'** + String get addAccountMenuDiscoverMultisigTitle; + + /// Discover multisig menu row subtitle + /// + /// In en, this message translates to: + /// **'Find multisigs where your accounts are signers'** + String get addAccountMenuDiscoverMultisigSubtitle; + /// Badge label for multisig accounts /// /// In en, this message translates to: @@ -464,6 +476,12 @@ abstract class AppLocalizations { /// **'Create Multisig'** String get multisigAddTitle; + /// Discover multisig screen app bar title + /// + /// In en, this message translates to: + /// **'Discover Multisig'** + String get multisigDiscoverTitle; + /// Subtitle under multisig name field /// /// In en, this message translates to: @@ -584,12 +602,6 @@ abstract class AppLocalizations { /// **'Done'** String get multisigDone; - /// Section label for manual multisig address entry - /// - /// In en, this message translates to: - /// **'Paste Multisig Address'** - String get multisigAddPasteAddressSection; - /// Section title for on-chain discovered multisigs /// /// In en, this message translates to: @@ -602,18 +614,6 @@ abstract class AppLocalizations { /// **'Multisigs on chain where one of your accounts is a signer'** String get multisigAddDiscoveredSubtitle; - /// Hint for multisig address text field - /// - /// In en, this message translates to: - /// **'Multisig SS58 address'** - String get multisigAddAddressHint; - - /// Primary button to add multisig from pasted address - /// - /// In en, this message translates to: - /// **'Add From Address'** - String get multisigAddFromAddressButton; - /// Add button on discovered multisig row /// /// In en, this message translates to: diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index ca4bd1c1..0e7ebe2f 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -196,6 +196,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addAccountMenuMultisigSubtitle => 'Set up a shared address with multiple signers'; + @override + String get addAccountMenuDiscoverMultisigTitle => 'Discover Multisig'; + + @override + String get addAccountMenuDiscoverMultisigSubtitle => 'Find multisigs where your accounts are signers'; + @override String get multisigTag => 'MULTISIG'; @@ -205,6 +211,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get multisigAddTitle => 'Create Multisig'; + @override + String get multisigDiscoverTitle => 'Discover Multisig'; + @override String get multisigCreateSubtitle => 'Give this multisig a name you\'ll recognize. You can change it anytime.'; @@ -270,21 +279,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get multisigDone => 'Done'; - @override - String get multisigAddPasteAddressSection => 'Paste Multisig Address'; - @override String get multisigAddDiscoveredTitle => 'Discovered for you'; @override String get multisigAddDiscoveredSubtitle => 'Multisigs on chain where one of your accounts is a signer'; - @override - String get multisigAddAddressHint => 'Multisig SS58 address'; - - @override - String get multisigAddFromAddressButton => 'Add From Address'; - @override String get multisigAddButton => 'Add'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index 06a7770c..0e845e47 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -197,6 +197,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get addAccountMenuMultisigSubtitle => 'Siapkan alamat bersama dengan beberapa penandatangan'; + @override + String get addAccountMenuDiscoverMultisigTitle => 'Temukan Multisig'; + + @override + String get addAccountMenuDiscoverMultisigSubtitle => 'Cari multisig di mana akun Anda adalah penandatangan'; + @override String get multisigTag => 'MULTISIG'; @@ -206,6 +212,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get multisigAddTitle => 'Buat Multisig'; + @override + String get multisigDiscoverTitle => 'Temukan Multisig'; + @override String get multisigCreateSubtitle => 'Berikan nama multisig yang mudah Anda kenali. Anda bisa mengubahnya kapan saja.'; @@ -272,21 +281,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get multisigDone => 'Selesai'; - @override - String get multisigAddPasteAddressSection => 'Tempel Alamat Multisig'; - @override String get multisigAddDiscoveredTitle => 'Ditemukan untuk Anda'; @override String get multisigAddDiscoveredSubtitle => 'Multisig di chain di mana salah satu akun Anda adalah penandatangan'; - @override - String get multisigAddAddressHint => 'Alamat SS58 multisig'; - - @override - String get multisigAddFromAddressButton => 'Tambah Dari Alamat'; - @override String get multisigAddButton => 'Tambah'; diff --git a/mobile-app/lib/providers/multisig_providers.dart b/mobile-app/lib/providers/multisig_providers.dart index 1996ccdf..f96453f6 100644 --- a/mobile-app/lib/providers/multisig_providers.dart +++ b/mobile-app/lib/providers/multisig_providers.dart @@ -26,9 +26,9 @@ class MultisigAccountsNotifier extends StateNotifier add(MultisigAccount account) async { await _settingsService.addMultisigAccount(account); - state.whenData((current) { - state = AsyncValue.data([...current, account]); - }); + + final current = state.value ?? []; + state = AsyncValue.data([...current, account]); } Future updateName(MultisigAccount account, String name) async { @@ -37,16 +37,16 @@ class MultisigAccountsNotifier extends StateNotifier a.accountId == updated.accountId ? updated : a).toList()); - }); + + final current = state.value ?? []; + state = AsyncValue.data(current.map((a) => a.accountId == updated.accountId ? updated : a).toList()); } Future remove(String accountId) async { await _settingsService.removeMultisigAccount(accountId); - state.whenData((current) { - state = AsyncValue.data(current.where((a) => a.accountId != accountId).toList()); - }); + + final current = state.value ?? []; + state = AsyncValue.data(current.where((a) => a.accountId != accountId).toList()); } MultisigAccount? byAccountId(String accountId) { @@ -55,6 +55,10 @@ class MultisigAccountsNotifier extends StateNotifier throw Exception('Multisig $accountId not found'), ); } + + void reset() { + state = const AsyncValue.data([]); + } } final multisigAccountsProvider = StateNotifierProvider>>(( @@ -67,18 +71,21 @@ final multisigAccountsProvider = StateNotifierProvider>((ref) async { final service = ref.watch(multisigServiceProvider); final accountsAsync = ref.watch(accountsProvider); - final accounts = accountsAsync.value ?? []; + + final List accounts; + switch (accountsAsync) { + case AsyncData(:final value): + accounts = value; + case AsyncError(:final error, :final stackTrace): + Error.throwWithStackTrace(error, stackTrace); + case AsyncLoading(): + accounts = await ref.read(accountsServiceProvider).getAccounts(); + } + final ids = accounts.map((a) => a.accountId).toList(); return service.discoverForUser(ids); }); -final multisigLookupProvider = FutureProvider.autoDispose.family((ref, address) async { - final service = ref.watch(multisigServiceProvider); - final accountsAsync = ref.watch(accountsProvider); - final ids = (accountsAsync.value ?? []).map((a) => a.accountId).toList(); - return service.lookupByAddress(address, ids); -}); - final multisigOpenProposalsProvider = FutureProvider.autoDispose.family, MultisigAccount>(( ref, msig, diff --git a/mobile-app/lib/services/logout_service.dart b/mobile-app/lib/services/logout_service.dart index 7e052689..bce8bfa7 100644 --- a/mobile-app/lib/services/logout_service.dart +++ b/mobile-app/lib/services/logout_service.dart @@ -6,6 +6,7 @@ import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/mining_rewards_provider.dart'; +import 'package:resonance_network_wallet/providers/multisig_providers.dart'; import 'package:resonance_network_wallet/providers/pending_multisig_creations_provider.dart'; import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart'; import 'package:resonance_network_wallet/providers/remote_config_provider.dart'; @@ -30,6 +31,8 @@ class LogoutService { _ref.invalidate(miningRewardsProvider); _ref.read(accountsProvider.notifier).reset(); _ref.read(activeAccountProvider.notifier).reset(); + _ref.read(multisigAccountsProvider.notifier).reset(); + _ref.invalidate(discoveredMultisigsProvider); _ref.read(accountAssociationsProvider.notifier).reset(); await _ref.read(selectedAppLocaleProvider.notifier).reset(); await _ref.read(selectedFiatCurrencyProvider.notifier).reset(); diff --git a/mobile-app/lib/v2/screens/accounts/add_account_menu_screen.dart b/mobile-app/lib/v2/screens/accounts/add_account_menu_screen.dart index 4139c4fa..9a0ff9ce 100644 --- a/mobile-app/lib/v2/screens/accounts/add_account_menu_screen.dart +++ b/mobile-app/lib/v2/screens/accounts/add_account_menu_screen.dart @@ -3,12 +3,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/multisig_providers.dart'; import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/accounts/create_account_screen.dart'; import 'package:resonance_network_wallet/v2/screens/import/import_wallet_screen.dart'; import 'package:resonance_network_wallet/v2/screens/multisig/add_multisig_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/multisig/discover_multisig_screen.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; @@ -35,6 +37,11 @@ class _AddAccountMenuScreenState extends ConsumerState { Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AddMultisigScreen())); } + void _onDiscoverMultisig() { + ref.invalidate(discoveredMultisigsProvider); + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DiscoverMultisigScreen())); + } + @override Widget build(BuildContext context) { final l10n = ref.watch(l10nProvider); @@ -67,6 +74,17 @@ class _AddAccountMenuScreenState extends ConsumerState { const SizedBox(height: 16), Divider(color: colors.toasterBackground, height: 1), const SizedBox(height: 24), + _AddMenuRow( + icon: Icons.radar_outlined, + title: l10n.addAccountMenuDiscoverMultisigTitle, + subtitle: l10n.addAccountMenuDiscoverMultisigSubtitle, + onTap: _onDiscoverMultisig, + colors: colors, + text: context.themeText, + ), + const SizedBox(height: 16), + Divider(color: colors.toasterBackground, height: 1), + const SizedBox(height: 24), _AddMenuRow( icon: Icons.save_alt, title: l10n.addAccountMenuImportTitle, diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index 344dff62..869325f1 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -105,6 +105,8 @@ class _ImportWalletScreenV2State extends ConsumerState { } } + /// Discovers on-chain HD accounts only. Multisigs are added manually via + /// Add Account → Discover Multisig. Future _discoverAccounts(String mnemonic) async { try { final discovered = await _discoveryService.discoverAccounts(mnemonic: mnemonic, walletIndex: widget.walletIndex); diff --git a/mobile-app/lib/v2/screens/multisig/discover_multisig_screen.dart b/mobile-app/lib/v2/screens/multisig/discover_multisig_screen.dart new file mode 100644 index 00000000..59d899bb --- /dev/null +++ b/mobile-app/lib/v2/screens/multisig/discover_multisig_screen.dart @@ -0,0 +1,250 @@ +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/skeleton.dart'; +import 'package:resonance_network_wallet/providers/l10n_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/toaster_extensions.dart'; +import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +class DiscoverMultisigScreen extends ConsumerStatefulWidget { + const DiscoverMultisigScreen({super.key}); + + @override + ConsumerState createState() => _DiscoverMultisigScreenState(); +} + +class _DiscoverMultisigScreenState extends ConsumerState { + final _addingIds = {}; + + Future _addMultisig(MultisigAccount account) async { + if (_addingIds.contains(account.accountId)) return; + + final l10n = ref.read(l10nProvider); + final savedCount = ref.read(multisigAccountsProvider).value?.length ?? 0; + final toAdd = account.copyWith(name: l10n.multisigCreateDefaultName(savedCount + 1)); + + setState(() => _addingIds.add(account.accountId)); + try { + await ref.read(multisigAccountsProvider.notifier).add(toAdd); + if (!mounted) return; + context.showSuccessToaster(message: l10n.multisigCreateReadyToast); + } catch (e) { + if (mounted) { + context.showErrorToaster(message: l10n.multisigAddFailed(e.toString())); + } + } finally { + if (mounted) { + setState(() => _addingIds.remove(account.accountId)); + } + } + } + + List _sortedDiscovered(List discovered, Set savedIds) { + return [...discovered]..sort((a, b) { + final aAdded = savedIds.contains(a.accountId); + final bAdded = savedIds.contains(b.accountId); + if (aAdded == bAdded) return 0; + return aAdded ? 1 : -1; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = ref.watch(l10nProvider); + final colors = context.colors; + final text = context.themeText; + final discoveredAsync = ref.watch(discoveredMultisigsProvider); + final savedIds = (ref.watch(multisigAccountsProvider).value ?? []).map((a) => a.accountId).toSet(); + + return ScaffoldBase( + appBar: V2AppBar(title: l10n.multisigDiscoverTitle), + mainContent: discoveredAsync.when( + loading: () => const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Skeleton(height: 14, width: 160), + SizedBox(height: 8), + Skeleton(height: 12), + SizedBox(height: 24), + Skeleton(height: 72), + SizedBox(height: 12), + Skeleton(height: 72), + ], + ), + error: (error, _) => _DiscoverError( + message: l10n.multisigAddDiscoverFailed(error.toString()), + retryLabel: l10n.homeActivityRetry, + onRetry: () => ref.invalidate(discoveredMultisigsProvider), + colors: colors, + text: text, + ), + data: (discovered) { + final sorted = _sortedDiscovered(discovered, savedIds); + if (sorted.isEmpty) { + return Center( + child: Text( + l10n.multisigAddNoneFound, + style: text.smallParagraph?.copyWith(color: colors.textSecondary), + textAlign: TextAlign.center, + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.multisigAddDiscoveredTitle, style: text.receiveLabel?.copyWith(color: colors.textLabel)), + const SizedBox(height: 8), + Text(l10n.multisigAddDiscoveredSubtitle, style: text.detail?.copyWith(color: colors.textTertiary)), + const SizedBox(height: 24), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sorted.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final account = sorted[index]; + final isAdded = savedIds.contains(account.accountId); + final isAdding = _addingIds.contains(account.accountId); + + return _DiscoverMultisigRow( + account: account, + isAdded: isAdded, + isAdding: isAdding, + addLabel: l10n.multisigAddButton, + addedLabel: l10n.multisigAddedButton, + thresholdLabel: l10n.multisigThresholdOf(account.threshold, account.signers.length), + colors: colors, + text: text, + onAdd: () => _addMultisig(account), + ); + }, + ), + ], + ); + }, + ), + ); + } +} + +class _DiscoverError extends StatelessWidget { + const _DiscoverError({ + required this.message, + required this.retryLabel, + required this.onRetry, + required this.colors, + required this.text, + }); + + final String message; + final String retryLabel; + final VoidCallback onRetry; + final AppColorsV2 colors; + final AppTextTheme text; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(message, style: text.detail?.copyWith(color: colors.textError)), + const SizedBox(height: 16), + QuantusButton.simple(label: retryLabel, variant: ButtonVariant.secondary, onTap: onRetry), + ], + ); + } +} + +class _DiscoverMultisigRow extends ConsumerStatefulWidget { + const _DiscoverMultisigRow({ + required this.account, + required this.isAdded, + required this.isAdding, + required this.addLabel, + required this.addedLabel, + required this.thresholdLabel, + required this.colors, + required this.text, + required this.onAdd, + }); + + final MultisigAccount account; + final bool isAdded; + final bool isAdding; + final String addLabel; + final String addedLabel; + final String thresholdLabel; + final AppColorsV2 colors; + final AppTextTheme text; + final VoidCallback onAdd; + + @override + ConsumerState<_DiscoverMultisigRow> createState() => _DiscoverMultisigRowState(); +} + +class _DiscoverMultisigRowState extends ConsumerState<_DiscoverMultisigRow> { + String? _checksum; + + @override + void initState() { + super.initState(); + + ref.read(humanReadableChecksumServiceProvider).getHumanReadableName(widget.account.accountId).then((name) { + if (mounted) setState(() => _checksum = name); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = ref.watch(l10nProvider); + final address = AddressFormattingService.formatAddress(widget.account.accountId); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: widget.colors.surfaceDeep, borderRadius: BorderRadius.circular(14)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _checksum ?? l10n.commonLoading, + style: widget.text.detail?.copyWith(color: context.colors.checksum), + ), + const SizedBox(height: 4), + Text( + address, + style: widget.text.smallParagraph?.copyWith( + color: widget.colors.textPrimary, + fontFamily: AppTextTheme.fontFamilySecondary, + ), + ), + const SizedBox(height: 4), + Text(widget.thresholdLabel, style: widget.text.detail?.copyWith(color: widget.colors.textTertiary)), + ], + ), + ), + const SizedBox(width: 12), + QuantusButton.simple( + label: widget.isAdded ? widget.addedLabel : widget.addLabel, + variant: widget.isAdded ? ButtonVariant.secondary : ButtonVariant.primary, + isDisabled: widget.isAdded, + isLoading: widget.isAdding, + onTap: widget.isAdded ? null : widget.onAdd, + width: null, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ], + ), + ); + } +} diff --git a/quantus_sdk/lib/src/services/multisig_graphql.dart b/quantus_sdk/lib/src/services/multisig_graphql.dart index 03bcff2d..84464083 100644 --- a/quantus_sdk/lib/src/services/multisig_graphql.dart +++ b/quantus_sdk/lib/src/services/multisig_graphql.dart @@ -51,4 +51,32 @@ $indexerFields } } '''; + + /// Fields for discovering multisigs where local accounts are signers. + static const String discoverFields = _coreFields; + + /// Builds a query for multisigs where any of [accountIds] appears in + /// `signers` (Hasura `String[]` `_contains` per account, combined with + /// `_or` when there are multiple wallet accounts). + static String buildDiscoverQuery(List accountIds) { + if (accountIds.isEmpty) { + throw ArgumentError.value(accountIds, 'accountIds', 'Must not be empty'); + } + + final whereClause = accountIds.length == 1 + ? '{signers: {_contains: ["${_escapeGraphqlString(accountIds.first)}"]}}' + : '{_or: [${accountIds.map((id) => '{signers: {_contains: ["${_escapeGraphqlString(id)}"]}}').join(', ')}]}'; + + return ''' + query DiscoverMultisigs { + multisig(where: $whereClause) { +$discoverFields + } + } + '''; + } + + static String _escapeGraphqlString(String value) { + return value.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); + } } diff --git a/quantus_sdk/lib/src/services/multisig_service.dart b/quantus_sdk/lib/src/services/multisig_service.dart index 9d70797e..35e92b1d 100644 --- a/quantus_sdk/lib/src/services/multisig_service.dart +++ b/quantus_sdk/lib/src/services/multisig_service.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:quantus_sdk/generated/planck/pallets/multisig.dart' show Txs; import 'package:quantus_sdk/generated/planck/types/quantus_runtime/runtime_call.dart'; import 'package:quantus_sdk/src/models/account.dart'; +import 'package:quantus_sdk/src/models/json_dynamic_parse.dart'; import 'package:quantus_sdk/src/models/multisig_account.dart'; import 'package:quantus_sdk/src/models/multisig_create_submission.dart'; import 'package:quantus_sdk/src/models/multisig_proposal.dart'; @@ -33,33 +34,37 @@ class MultisigService { } Future> discoverForUser(List myAccountIds) async { - debugPrint('[MultisigService] discoverForUser stub, my accounts: ${myAccountIds.length}'); - await Future.delayed(const Duration(milliseconds: 600)); if (myAccountIds.isEmpty) return []; - final me = myAccountIds.first; - return _dummyMultisigs(me); - } - Future lookupByAddress(String address, List myAccountIds) async { - debugPrint('[MultisigService] lookupByAddress stub: $address'); - await Future.delayed(const Duration(milliseconds: 400)); - if (myAccountIds.isEmpty) { - throw Exception('No local accounts; cannot determine member account'); + final requestBody = {'query': MultisigGraphql.buildDiscoverQuery(myAccountIds)}; + final response = await _graphQlEndpointService.post(body: jsonEncode(requestBody)); + + if (response.statusCode != 200) { + throw Exception('GraphQL request failed with status: ${response.statusCode}. Body: ${response.body}'); } - final me = myAccountIds.first; - return MultisigAccount( - name: 'Multisig', - accountId: address, - signers: [ - me, - _dummySigner('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'), - _dummySigner('5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'), - ], - threshold: 2, - nonce: BigInt.from(42), - myMemberAccountId: me, - creator: me, - ); + + final responseBody = jsonDecode(response.body) as Map; + if (responseBody['errors'] != null) { + throw Exception('GraphQL errors: ${responseBody['errors']}'); + } + + final records = parseMultisigDiscoverData(responseBody['data'] as Map?); + final seen = {}; + final results = []; + var index = 0; + + for (final record in records) { + final address = stringFromJson(record['id']); + if (!seen.add(address)) continue; + + final myMember = resolveMyMemberAccountId(record, myAccountIds); + if (myMember == null) continue; + + index++; + results.add(multisigAccountFromIndexerRecord(record, myMemberAccountId: myMember, name: 'Multisig $index')); + } + + return results; } /// Predicts the on-chain multisig address for the given signers and threshold. @@ -161,6 +166,49 @@ class MultisigService { return record; } + /// Parses `multisig` list from a discover-query `data` payload. + static List> parseMultisigDiscoverData(Map? data) { + final raw = data?['multisig']; + if (raw is! List) return []; + return raw.whereType>().toList(); + } + + /// Maps an indexer multisig record to a local [MultisigAccount]. + static MultisigAccount multisigAccountFromIndexerRecord( + Map record, { + required String myMemberAccountId, + required String name, + }) { + final address = stringFromJson(record['id']); + final creator = nestedAccountId(record['creator']); + final signersRaw = record['signers']; + final signers = signersRaw is List ? signersRaw.map((e) => e.toString()).toList() : []; + + final rawThreshold = record['threshold'] as int?; + final threshold = rawThreshold != null && rawThreshold >= 1 ? rawThreshold : 1; + + return MultisigAccount( + name: name, + accountId: address, + signers: signers, + threshold: threshold, + nonce: bigIntFromJson(record['nonce']), + myMemberAccountId: myMemberAccountId, + creator: creator.isEmpty ? null : creator, + ); + } + + /// First [myAccountIds] entry that appears in indexer [record] signers. + static String? resolveMyMemberAccountId(Map record, List myAccountIds) { + final signersRaw = record['signers']; + if (signersRaw is! List) return null; + final signers = signersRaw.map((e) => e.toString()).toSet(); + for (final id in myAccountIds) { + if (signers.contains(id)) return id; + } + return null; + } + /// Validates [signers] and [threshold] for multisig operations. /// /// [minSigners] is the minimum signer count for the operation: prediction @@ -248,41 +296,6 @@ class MultisigService { return _dummyCurrentBlock + (deltaSeconds ~/ _avgBlockTimeSeconds); } - String _dummySigner(String fallback) => fallback; - - List _dummyMultisigs(String me) { - return [ - MultisigAccount( - name: 'Treasury Multisig', - accountId: '5MultisigTreasury000000000000000000000000000000000', - signers: [ - me, - '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', - '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty', - '5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy', - '5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw', - ], - threshold: 3, - nonce: BigInt.from(1), - myMemberAccountId: me, - creator: me, - ), - MultisigAccount( - name: 'Ops 2-of-3', - accountId: '5MultisigOpsTeam0000000000000000000000000000000000', - signers: [ - me, - '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', - '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty', - ], - threshold: 2, - nonce: BigInt.from(2), - myMemberAccountId: me, - creator: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', - ), - ]; - } - List _dummyOpenProposals(MultisigAccount msig) { final now = DateTime.now(); final mePending = msig.myMemberAccountId; diff --git a/quantus_sdk/test/multisig_service_test.dart b/quantus_sdk/test/multisig_service_test.dart index d9ba478b..9db8acc8 100644 --- a/quantus_sdk/test/multisig_service_test.dart +++ b/quantus_sdk/test/multisig_service_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:quantus_sdk/src/models/multisig_account.dart'; import 'package:quantus_sdk/src/models/multisig_create_submission.dart'; +import 'package:quantus_sdk/src/services/multisig_graphql.dart'; import 'package:quantus_sdk/src/services/multisig_service.dart'; void main() { @@ -147,10 +148,80 @@ void main() { }); group('MultisigService discover mapping', () { + const signerA = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; + const signerB = '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'; + const multisigAddress = '5TestMultisig'; + + const indexerRecord = { + 'id': multisigAddress, + 'threshold': 2, + 'nonce': '3', + 'signers': [signerA, signerB], + 'creator': {'id': signerA}, + }; + test('discoverForUser returns empty list for no accounts', () async { final result = await MultisigService().discoverForUser([]); expect(result, isEmpty); }); + + test('parseMultisigDiscoverData returns empty list when data is null', () { + expect(MultisigService.parseMultisigDiscoverData(null), isEmpty); + }); + + test('parseMultisigDiscoverData parses multisig list', () { + final parsed = MultisigService.parseMultisigDiscoverData({ + 'multisig': [indexerRecord], + }); + expect(parsed, hasLength(1)); + expect(parsed.first['id'], multisigAddress); + }); + + test('multisigAccountFromIndexerRecord maps fields', () { + final account = MultisigService.multisigAccountFromIndexerRecord( + indexerRecord, + myMemberAccountId: signerB, + name: 'Team Multisig', + ); + + expect(account.name, 'Team Multisig'); + expect(account.accountId, multisigAddress); + expect(account.signers, [signerA, signerB]); + expect(account.threshold, 2); + expect(account.nonce, BigInt.from(3)); + expect(account.myMemberAccountId, signerB); + expect(account.creator, signerA); + }); + + test('resolveMyMemberAccountId prefers first matching local account', () { + expect(MultisigService.resolveMyMemberAccountId(indexerRecord, [signerB, signerA]), signerB); + }); + + test('resolveMyMemberAccountId returns null when user is not a signer', () { + expect(MultisigService.resolveMyMemberAccountId(indexerRecord, ['5ExternalSigner']), isNull); + }); + }); + + group('MultisigGraphql.buildDiscoverQuery', () { + const addrA = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; + const addrB = '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'; + + test('throws when accountIds is empty', () { + expect(() => MultisigGraphql.buildDiscoverQuery([]), throwsArgumentError); + }); + + test('uses single _contains clause for one account', () { + final query = MultisigGraphql.buildDiscoverQuery([addrA]); + expect(query, contains('{signers: {_contains: ["$addrA"]}}')); + expect(query, isNot(contains('_or'))); + }); + + test('uses _or of _contains clauses for multiple accounts', () { + final query = MultisigGraphql.buildDiscoverQuery([addrA, addrB]); + expect(query, contains('_or')); + expect(query, contains('{signers: {_contains: ["$addrA"]}}')); + expect(query, contains('{signers: {_contains: ["$addrB"]}}')); + }); }); group('MultisigAccount', () {