From 338c27ad55926f0467e40f6661e53c12131b48e9 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Tue, 8 Sep 2026 11:18:05 +0800 Subject: [PATCH] feat(multisig): Keystone signing for multisig creation and proposals Creating a multisig and proposing a transfer only signed locally, so a Keystone (cold) member had no path: submitExtrinsic needs a local keypair. Approve, execute and cancel already routed Keystone signers through the QR flow; this brings the remaining two calls in line. - Add multisig: a Keystone creator skips biometric auth and goes through the shared KeystoneSignScreen; the signed extrinsic is submitted via the same pending-creation record and poller as the local path. The single preflight result (draft + fee) now feeds both paths instead of running the preflight twice. - Propose: MultisigProposeStrategy returns SendNeedsHardwareSignature for a Keystone member, mirroring RegularSendStrategy, and prefetches the sign payload on the review screen (the fee now reaches prefetch so the signed call uses the reviewed expiry block). proposeTransfer shares one submit-and-track path with the new external-signature variant. - SendNeedsHardwareSignature carries terminalForHash so each strategy decides its terminal; proposals keep no explorer link, like the local path. - KeystoneSigningSession gains primaryLabel/secondaryLabel so the sign and verify screens can caption non-transfer calls (ACTION / MULTISIG ADDRESS). - One Account.signsWithHardware helper replaces the two copies in the action sheet and regular send strategy. - Tests: propose strategy hardware outcome; pumpRef helper shared in fakes. --- mobile-app/lib/l10n/app_en.arb | 16 ++ mobile-app/lib/l10n/app_id.arb | 1 + mobile-app/lib/l10n/app_localizations.dart | 12 ++ mobile-app/lib/l10n/app_localizations_en.dart | 8 + mobile-app/lib/l10n/app_localizations_id.dart | 8 + .../services/multisig_submission_service.dart | 168 +++++++++--------- .../transaction_submission_service.dart | 110 +++++++----- .../lib/shared/utils/account_utils.dart | 6 + .../screens/multisig/add_multisig_screen.dart | 92 ++++++---- .../multisig_action_confirm_sheet.dart | 7 +- .../v2/screens/send/keystone_sign_screen.dart | 7 +- .../send/keystone_signing_session.dart | 7 + .../screens/send/keystone_verify_screen.dart | 4 +- .../send/multisig_propose_strategy.dart | 137 +++++++++++--- .../screens/send/regular_send_strategy.dart | 16 +- .../v2/screens/send/review_send_screen.dart | 18 +- .../lib/v2/screens/send/send_strategy.dart | 14 +- mobile-app/test/fakes.dart | 21 +++ .../unit/multisig_propose_strategy_test.dart | 60 +++++++ .../test/unit/regular_send_strategy_test.dart | 16 -- 20 files changed, 495 insertions(+), 233 deletions(-) create mode 100644 mobile-app/test/unit/multisig_propose_strategy_test.dart diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 50ec96527..4160c486d 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -446,6 +446,18 @@ } } }, + "multisigCreateKeystoneAction": "Create {count} of {total} multisig", + "@multisigCreateKeystoneAction": { + "description": "What a Keystone creator is signing, shown on the Keystone sign and verify screens", + "placeholders": { + "count": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "multisigCreatePredictedAddressLabel": "MULTISIG ADDRESS", "@multisigCreatePredictedAddressLabel": { "description": "Label for predicted multisig address preview" @@ -1470,6 +1482,10 @@ "@keystoneSignInstruction": { "description": "Instruction on the Keystone sign screen" }, + "keystoneSignActionLabel": "ACTION", + "@keystoneSignActionLabel": { + "description": "Label for a non-transfer action on the Keystone sign and verify screens" + }, "keystoneSignYouAreSigning": "YOU ARE SIGNING", "@keystoneSignYouAreSigning": { "description": "Section label above the transaction details on the Keystone sign screen" diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index 90e954be8..89a73456c 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -95,6 +95,7 @@ "multisigCreateInvalidSigner": "Masukkan alamat penandatangan yang valid.", "multisigCreateThresholdLabel": "AMBANG BATAS", "multisigCreateThresholdValue": "{count} dari {total}", + "multisigCreateKeystoneAction": "Buat multisig {count} dari {total}", "multisigCreatePredictedAddressLabel": "ALAMAT MULTISIG", "multisigCreatePredictedAddressPlaceholder": "Tambahkan penandatangan untuk melihat alamat", "multisigDone": "Selesai", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index e0a761d9e..a4883e519 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -668,6 +668,12 @@ abstract class AppLocalizations { /// **'{count} of {total}'** String multisigCreateThresholdValue(int count, int total); + /// What a Keystone creator is signing, shown on the Keystone sign and verify screens + /// + /// In en, this message translates to: + /// **'Create {count} of {total} multisig'** + String multisigCreateKeystoneAction(int count, int total); + /// Label for predicted multisig address preview /// /// In en, this message translates to: @@ -1964,6 +1970,12 @@ abstract class AppLocalizations { /// **'Open your Keystone and scan this QR code to load the transaction.'** String get keystoneSignInstruction; + /// Label for a non-transfer action on the Keystone sign and verify screens + /// + /// In en, this message translates to: + /// **'ACTION'** + String get keystoneSignActionLabel; + /// Section label above the transaction details on the Keystone sign screen /// /// In en, this message translates to: diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 0f3d5ff0e..24631601c 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -325,6 +325,11 @@ class AppLocalizationsEn extends AppLocalizations { return '$count of $total'; } + @override + String multisigCreateKeystoneAction(int count, int total) { + return 'Create $count of $total multisig'; + } + @override String get multisigCreatePredictedAddressLabel => 'MULTISIG ADDRESS'; @@ -1031,6 +1036,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get keystoneSignInstruction => 'Open your Keystone and scan this QR code to load the transaction.'; + @override + String get keystoneSignActionLabel => 'ACTION'; + @override String get keystoneSignYouAreSigning => 'YOU ARE SIGNING'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index 4e24be68a..c1aa50c61 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -327,6 +327,11 @@ class AppLocalizationsId extends AppLocalizations { return '$count dari $total'; } + @override + String multisigCreateKeystoneAction(int count, int total) { + return 'Buat multisig $count dari $total'; + } + @override String get multisigCreatePredictedAddressLabel => 'ALAMAT MULTISIG'; @@ -1034,6 +1039,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get keystoneSignInstruction => 'Open your Keystone and scan this QR code to load the transaction.'; + @override + String get keystoneSignActionLabel => 'ACTION'; + @override String get keystoneSignYouAreSigning => 'YOU ARE SIGNING'; diff --git a/mobile-app/lib/services/multisig_submission_service.dart b/mobile-app/lib/services/multisig_submission_service.dart index 4e5edaad5..6c60343d2 100644 --- a/mobile-app/lib/services/multisig_submission_service.dart +++ b/mobile-app/lib/services/multisig_submission_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:convert/convert.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,6 +11,10 @@ import 'package:resonance_network_wallet/services/multisig_creation_polling_serv import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; +/// Everything a creation needs once on-chain checks have passed: the draft +/// account (with the resolved nonce) and the network fee it was checked with. +typedef MultisigCreationPreflight = ({MultisigAccount draft, BigInt networkFee}); + class MultisigSubmissionService { MultisigSubmissionService(this._ref); @@ -20,88 +25,7 @@ class MultisigSubmissionService { /// Throws [MultisigAlreadyExistsException] if the predicted address already /// exists, or [MultisigInsufficientBalanceException] if the creator cannot /// afford pallet fee + network fee. - Future preflightMultisigCreation({ - required List signers, - required int threshold, - required Account creator, - BigInt? nonce, - }) async { - await _runCreationPreflight(name: '', signers: signers, threshold: threshold, creator: creator, nonce: nonce); - } - - /// Preflight on-chain state, then submit and track creation. - /// - /// Awaits acceptance of the creation extrinsic by the chain before - /// completing; indexer polling then continues in the background. Throws - /// [MultisigAlreadyExistsException] if the predicted address already exists, - /// or [MultisigInsufficientBalanceException] if the creator cannot afford - /// the total creation cost. Rethrows on submission failure so callers can - /// surface the error instead of optimistically navigating away. - Future startMultisigCreation({ - required String name, - required List signers, - required int threshold, - required Account creator, - BigInt? nonce, - }) async { - final preflight = await _runCreationPreflight( - name: name, - signers: signers, - threshold: threshold, - creator: creator, - nonce: nonce, - ); - - final draft = preflight.draft; - final networkFee = preflight.networkFee; - - TelemetryService().sendEvent('multisig_create_started'); - await _ref - .read(pendingMultisigCreationsProvider.notifier) - .add(PendingMultisigCreationEvent.fromDraft(draft, networkFee: networkFee), draft); - - await _submitAndTrack(creator: creator, signers: signers, threshold: threshold, nonce: draft.nonce, draft: draft); - } - - Future _submitAndTrack({ - required Account creator, - required List signers, - required int threshold, - required BigInt nonce, - required MultisigAccount draft, - }) async { - final service = _ref.read(multisigServiceProvider); - try { - quantusPrint('[MultisigSubmission] submitting creation for ${draft.accountId}'); - - final hashBytes = await service.submitCreateMultisigExtrinsic( - creator: creator, - signers: signers, - threshold: threshold, - nonce: nonce, - ); - final extrinsicHash = '0x${hex.encode(hashBytes)}'; - quantusPrint('[MultisigSubmission] submitted $extrinsicHash'); - - unawaited( - _ref.read(pendingMultisigCreationsProvider.notifier).updateExtrinsicHash(draft.accountId, extrinsicHash), - ); - - final submittedAt = _ref.read(pendingMultisigCreationsProvider.notifier).recordFor(draft.accountId)?.submittedAt; - _ref.read(multisigCreationPollingServiceProvider).startPolling(draft, submittedAt: submittedAt); - } catch (e, stackTrace) { - // Retries live in SubstrateService.submitExtrinsic (same signed bytes); - // avoid outer retries here because each attempt re-signs with a fresh - // nonce and can double-submit if a prior submit already landed. - quantusPrint('[MultisigSubmission] submit failed: $e'); - quantusPrint('Stack trace: $stackTrace'); - TelemetryService().sendError('multisig_create_submit_failed', error: e); - removePendingMultisigCreation(_ref, draft.accountId); - rethrow; - } - } - - Future<({MultisigAccount draft, BigInt networkFee})> _runCreationPreflight({ + Future preflightMultisigCreation({ required String name, required List signers, required int threshold, @@ -133,10 +57,7 @@ class MultisigSubmissionService { final networkFee = await _ref .read(substrateServiceProvider) - .getFeeForCall( - creator, - service.buildCreateMultisigCall(signers: signers, threshold: threshold, nonce: effectiveNonce), - ) + .getFeeForCall(creator, buildCreateCall(draft)) .then((data) => data.fee); final totalCost = MultisigCreationDraftFields.fromDraft(draft, networkFee: networkFee).totalCost; @@ -147,6 +68,81 @@ class MultisigSubmissionService { return (draft: draft, networkFee: networkFee); } + + /// The `create_multisig` call for [draft]; also the Keystone unsigned payload. + RuntimeCall buildCreateCall(MultisigAccount draft) => _ref + .read(multisigServiceProvider) + .buildCreateMultisigCall(signers: draft.signers, threshold: draft.threshold, nonce: draft.nonce); + + /// Signs the creation with [creator]'s local key, submits it and tracks it. + /// + /// Awaits acceptance of the extrinsic by the chain before completing; indexer + /// polling then continues in the background. Rethrows on submission failure + /// so callers can surface the error instead of optimistically navigating away. + Future startMultisigCreation({required MultisigCreationPreflight preflight, required Account creator}) { + final draft = preflight.draft; + return _submitAndTrack( + preflight, + telemetryEvent: 'multisig_create_started', + submit: () => _ref + .read(multisigServiceProvider) + .submitCreateMultisigExtrinsic( + creator: creator, + signers: draft.signers, + threshold: draft.threshold, + nonce: draft.nonce, + ), + ); + } + + /// Submits a creation signed off-device (Keystone) and tracks it. + Future submitExternallySignedMultisigCreation({ + required MultisigCreationPreflight preflight, + required UnsignedTransactionData unsignedData, + required Uint8List signatureWithPublicKey, + }) { + return _submitAndTrack( + preflight, + telemetryEvent: 'multisig_create_hardware', + submit: () => _ref + .read(substrateServiceProvider) + .submitExtrinsicWithExternalSignature(unsignedData, signatureWithPublicKey), + ); + } + + Future _submitAndTrack( + MultisigCreationPreflight preflight, { + required String telemetryEvent, + required Future Function() submit, + }) async { + final draft = preflight.draft; + final pending = _ref.read(pendingMultisigCreationsProvider.notifier); + + TelemetryService().sendEvent(telemetryEvent); + await pending.add(PendingMultisigCreationEvent.fromDraft(draft, networkFee: preflight.networkFee), draft); + + try { + quantusPrint('[MultisigSubmission] submitting creation for ${draft.accountId}'); + + final extrinsicHash = '0x${hex.encode(await submit())}'; + quantusPrint('[MultisigSubmission] submitted $extrinsicHash'); + + unawaited(pending.updateExtrinsicHash(draft.accountId, extrinsicHash)); + + final submittedAt = pending.recordFor(draft.accountId)?.submittedAt; + _ref.read(multisigCreationPollingServiceProvider).startPolling(draft, submittedAt: submittedAt); + return extrinsicHash; + } catch (e, stackTrace) { + // Retries live in SubstrateService.submitExtrinsic (same signed bytes); + // avoid outer retries here because each attempt re-signs with a fresh + // nonce and can double-submit if a prior submit already landed. + quantusPrint('[MultisigSubmission] submit failed: $e'); + quantusPrint('Stack trace: $stackTrace'); + TelemetryService().sendError('multisig_create_submit_failed', error: e); + removePendingMultisigCreation(_ref, draft.accountId); + rethrow; + } + } } final multisigSubmissionServiceProvider = Provider((ref) { diff --git a/mobile-app/lib/services/transaction_submission_service.dart b/mobile-app/lib/services/transaction_submission_service.dart index c3afaa497..86b856346 100644 --- a/mobile-app/lib/services/transaction_submission_service.dart +++ b/mobile-app/lib/services/transaction_submission_service.dart @@ -154,17 +154,64 @@ class TransactionSubmissionService { /// indexer polling then continues in the background. Rethrows on submission /// failure so callers can surface the error instead of optimistically /// navigating away. - Future proposeTransfer({ + Future proposeTransfer({ required MultisigAccount msig, required Account signer, required String recipient, required BigInt amount, required int expiryBlock, required ProposeFeeBreakdown feeBreakdown, + }) { + return _submitAndTrackProposal( + msig: msig, + proposerId: signer.accountId, + recipient: recipient, + amount: amount, + expiryBlock: expiryBlock, + feeBreakdown: feeBreakdown, + telemetryEvent: 'multisig_propose', + submit: () => _ref + .read(multisigServiceProvider) + .propose(msig: msig, signer: signer, recipient: recipient, amount: amount, expiryBlock: expiryBlock), + ); + } + + /// Proposes a transfer using a signature produced off-device (Keystone). + Future proposeTransferWithExternalSignature({ + required MultisigAccount msig, + required Account signer, + required String recipient, + required BigInt amount, + required int expiryBlock, + required ProposeFeeBreakdown feeBreakdown, + required UnsignedTransactionData unsignedData, + required Uint8List signatureWithPublicKey, + }) { + return _submitAndTrackProposal( + msig: msig, + proposerId: signer.accountId, + recipient: recipient, + amount: amount, + expiryBlock: expiryBlock, + feeBreakdown: feeBreakdown, + telemetryEvent: 'multisig_propose_hardware', + submit: () => SubstrateService().submitExtrinsicWithExternalSignature(unsignedData, signatureWithPublicKey), + ); + } + + Future _submitAndTrackProposal({ + required MultisigAccount msig, + required String proposerId, + required String recipient, + required BigInt amount, + required int expiryBlock, + required ProposeFeeBreakdown feeBreakdown, + required String telemetryEvent, + required Future Function() submit, }) async { final pending = PendingMultisigProposalEvent.create( msig: msig, - proposerId: signer.accountId, + proposerId: proposerId, recipient: recipient, amount: amount, expiryBlock: expiryBlock, @@ -174,17 +221,25 @@ class TransactionSubmissionService { ); addPendingMultisigProposal(_ref, pending); + TelemetryService().sendEvent(telemetryEvent); - TelemetryService().sendEvent('multisig_propose'); + try { + final hashBytes = await submit(); + final extrinsicHash = '0x${hex.encode(hashBytes)}'; + quantusPrint('[Propose] submitted: $extrinsicHash'); - await _submitProposal( - msig: msig, - signer: signer, - recipient: recipient, - amount: amount, - expiryBlock: expiryBlock, - pending: pending, - ); + updatePendingMultisigProposal(_ref, pending.id, extrinsicHash: extrinsicHash); + final updated = findPendingMultisigProposal(_ref, pending.id) ?? pending.copyWith(extrinsicHash: extrinsicHash); + _ref.read(multisigProposalPollingServiceProvider).startPolling(msig, updated); + return extrinsicHash; + } catch (e, stackTrace) { + // Retries live in SubstrateService.submitExtrinsic; avoid outer retries + // here because each attempt fetches a fresh nonce and can duplicate + // deposit-reserving proposals if a prior submit already landed. + quantusPrint('[Propose] submit failed: $e\n$stackTrace'); + removePendingMultisigProposal(_ref, pending.id); + rethrow; + } } /// Submits a multisig proposal approval and tracks it optimistically. @@ -448,39 +503,6 @@ class TransactionSubmissionService { } } - Future _submitProposal({ - required MultisigAccount msig, - required Account signer, - required String recipient, - required BigInt amount, - required int expiryBlock, - required PendingMultisigProposalEvent pending, - }) async { - try { - final service = _ref.read(multisigServiceProvider); - final hashBytes = await service.propose( - msig: msig, - signer: signer, - recipient: recipient, - amount: amount, - expiryBlock: expiryBlock, - ); - final extrinsicHash = '0x${hex.encode(hashBytes)}'; - quantusPrint('[Propose] submitted: $extrinsicHash'); - - updatePendingMultisigProposal(_ref, pending.id, extrinsicHash: extrinsicHash); - final updated = findPendingMultisigProposal(_ref, pending.id) ?? pending.copyWith(extrinsicHash: extrinsicHash); - _ref.read(multisigProposalPollingServiceProvider).startPolling(msig, updated); - } catch (e, stackTrace) { - // Retries live in SubstrateService.submitExtrinsic; avoid outer retries - // here because each attempt fetches a fresh nonce and can duplicate - // deposit-reserving proposals if a prior submit already landed. - quantusPrint('[Propose] submit failed: $e\n$stackTrace'); - removePendingMultisigProposal(_ref, pending.id); - rethrow; - } - } - PendingTransactionEvent createPendingTransaction({ required String from, required String to, diff --git a/mobile-app/lib/shared/utils/account_utils.dart b/mobile-app/lib/shared/utils/account_utils.dart index 88b54c1d1..7be6d8756 100644 --- a/mobile-app/lib/shared/utils/account_utils.dart +++ b/mobile-app/lib/shared/utils/account_utils.dart @@ -14,6 +14,12 @@ int walletIndexForActiveAccount(List accounts, DisplayAccount? activeDi return accounts.isNotEmpty ? accounts.first.walletIndex : 0; } +/// Keystone accounts sign off-device via the QR flow. The debug flag forces +/// that path for testing. +extension AccountSigning on Account { + bool get signsWithHardware => accountType == AccountType.keystone || AppConstants.debugHardwareWallet; +} + List getNonHardwareWalletIndices(List accounts) { final nonHardwareWalletIndices = {}; for (final account in accounts) { diff --git a/mobile-app/lib/v2/screens/multisig/add_multisig_screen.dart b/mobile-app/lib/v2/screens/multisig/add_multisig_screen.dart index 5a6c52684..a8ab1f0a0 100644 --- a/mobile-app/lib/v2/screens/multisig/add_multisig_screen.dart +++ b/mobile-app/lib/v2/screens/multisig/add_multisig_screen.dart @@ -11,10 +11,14 @@ import 'package:resonance_network_wallet/providers/l10n_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/wallet_providers.dart'; +import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/services/local_auth_service.dart'; import 'package:resonance_network_wallet/services/multisig_submission_service.dart'; import 'package:resonance_network_wallet/v2/screens/accounts/accounts_navigation.dart'; +import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; +import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/keystone_signing_session.dart'; class AddMultisigScreen extends ConsumerStatefulWidget { const AddMultisigScreen({super.key}); @@ -203,32 +207,29 @@ class _AddMultisigScreenState extends ConsumerState { final submissionService = ref.read(multisigSubmissionServiceProvider); + final MultisigCreationPreflight preflight; try { - await submissionService.preflightMultisigCreation( + preflight = await submissionService.preflightMultisigCreation( + name: _accountName.text.trim(), signers: _allSigners, threshold: _threshold, creator: creator, nonce: nonce, ); } on MultisigAlreadyExistsException { - if (mounted) { - context.showErrorToaster(message: l10n.multisigCreateAlreadyExists); - } - if (mounted) setState(() => _isLoading = false); + _failCreation(l10n.multisigCreateAlreadyExists); return; } on MultisigInsufficientBalanceException { - if (mounted) { - context.showErrorToaster(message: l10n.multisigCreateInsufficientBalance); - } - if (mounted) setState(() => _isLoading = false); + _failCreation(l10n.multisigCreateInsufficientBalance); return; } catch (e) { quantusPrint('[AddMultisigScreen] preflight error: $e'); + _failCreation(l10n.multisigCreateErrorCouldNotCreate); + return; + } - if (mounted) { - context.showErrorToaster(message: l10n.multisigCreateErrorCouldNotCreate); - } - if (mounted) setState(() => _isLoading = false); + if (creator.signsWithHardware) { + await _createWithHardware(creator, preflight); return; } @@ -239,34 +240,57 @@ class _AddMultisigScreenState extends ConsumerState { } try { - await submissionService.startMultisigCreation( - name: _accountName.text.trim(), - signers: _allSigners, - threshold: _threshold, - creator: creator, - nonce: nonce, - ); + await submissionService.startMultisigCreation(preflight: preflight, creator: creator); if (!mounted) return; - returnToAccountsScreen(context, ref, highlightAccountId: _predictedAddress!); - } on MultisigAlreadyExistsException { - if (mounted) { - context.showErrorToaster(message: l10n.multisigCreateAlreadyExists); - } - } on MultisigInsufficientBalanceException { - if (mounted) { - context.showErrorToaster(message: l10n.multisigCreateInsufficientBalance); - } + returnToAccountsScreen(context, ref, highlightAccountId: preflight.draft.accountId); } catch (e) { quantusPrint('[AddMultisigScreen] createMultisig error: $e'); - if (mounted) { - context.showErrorToaster(message: l10n.multisigCreateErrorCouldNotCreate); - } - } finally { - if (mounted) setState(() => _isLoading = false); + _failCreation(l10n.multisigCreateErrorCouldNotCreate); } } + void _failCreation(String message) { + if (!mounted) return; + context.showErrorToaster(message: message); + setState(() => _isLoading = false); + } + + /// Keystone creators sign off-device: the shared QR flow signs and submits + /// the creation, then we return to Accounts exactly like the local path. + Future _createWithHardware(Account creator, MultisigCreationPreflight preflight) async { + final l10n = ref.read(l10nProvider); + final draft = preflight.draft; + final checksum = await _checksumService.getHumanReadableName(draft.accountId); + if (!mounted) return; + + final session = KeystoneSigningSession( + account: creator, + buildCall: () => ref.read(multisigSubmissionServiceProvider).buildCreateCall(draft), + primaryLabel: l10n.keystoneSignActionLabel, + primaryDetail: l10n.multisigCreateKeystoneAction(draft.threshold, draft.signers.length), + secondaryLabel: l10n.multisigCreatePredictedAddressLabel, + secondaryDetail: draft.accountId, + tertiaryDetail: checksum, + cacheKey: KeystoneSignCacheKey.forExtrinsic(accountId: creator.accountId, identity: 'create|${draft.accountId}'), + telemetryPrefix: 'multisig_create_hardware', + submitSigned: (ref, {required unsignedData, required signatureWithPublicKey}) => ref + .read(multisigSubmissionServiceProvider) + .submitExternallySignedMultisigCreation( + preflight: preflight, + unsignedData: unsignedData, + signatureWithPublicKey: signatureWithPublicKey, + ), + ); + + setState(() => _isLoading = false); + final hash = await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => KeystoneSignScreen(session: session))); + if (!mounted || hash == null) return; + returnToAccountsScreen(context, ref, highlightAccountId: draft.accountId); + } + @override Widget build(BuildContext context) { final l10n = ref.watch(l10nProvider); diff --git a/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart b/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart index ff503c2d8..9817e124c 100644 --- a/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart +++ b/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart @@ -10,6 +10,7 @@ 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/services/local_auth_service.dart'; +import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/v2/components/decoded_call_view.dart'; import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; @@ -225,10 +226,6 @@ class _MultisigActionConfirmSheetState extends ConsumerState _loadNetworkFee() async { try { final fee = await widget.estimateFee(ref, _requireSigner(), _callBytes); @@ -258,7 +255,7 @@ class _MultisigActionConfirmSheetState extends ConsumerState { Text(l10n.keystoneSignYouAreSigning, style: text.labelData.copyWith(color: colors.textContent)), const SizedBox(height: 12), if (session.primaryDetail != null) - DetailSummaryRow(label: l10n.sendReviewAmount.toUpperCase(), value: session.primaryDetail!), + DetailSummaryRow( + label: (session.primaryLabel ?? l10n.sendReviewAmount).toUpperCase(), + value: session.primaryDetail!, + ), if (session.primaryDetail != null && session.secondaryDetail != null) const Padding(padding: EdgeInsets.symmetric(vertical: 8), child: MenuDivider()), if (secondary != null) DetailSummaryRow( - label: l10n.sendReviewTo.toUpperCase(), + label: (session.secondaryLabel ?? l10n.sendReviewTo).toUpperCase(), value: displaySecondary!, checkphrase: session.tertiaryDetail, monospace: true, diff --git a/mobile-app/lib/v2/screens/send/keystone_signing_session.dart b/mobile-app/lib/v2/screens/send/keystone_signing_session.dart index f7c854dcc..c43262fea 100644 --- a/mobile-app/lib/v2/screens/send/keystone_signing_session.dart +++ b/mobile-app/lib/v2/screens/send/keystone_signing_session.dart @@ -20,10 +20,15 @@ typedef KeystoneSignatureSubmitter = /// /// Transfers, multisig actions, and future runtime calls configure this session /// and share the same QR display and signature scanner screens. +/// +/// [primaryLabel] and [secondaryLabel] caption the details on the sign and +/// verify screens; they default to the transfer labels (amount / to). class KeystoneSigningSession { final Account account; final RuntimeCall Function() buildCall; + final String? primaryLabel; final String? primaryDetail; + final String? secondaryLabel; final String? secondaryDetail; final String? tertiaryDetail; final KeystoneSignCacheKey? cacheKey; @@ -34,7 +39,9 @@ class KeystoneSigningSession { required this.account, required this.buildCall, required this.submitSigned, + this.primaryLabel, this.primaryDetail, + this.secondaryLabel, this.secondaryDetail, this.tertiaryDetail, this.cacheKey, diff --git a/mobile-app/lib/v2/screens/send/keystone_verify_screen.dart b/mobile-app/lib/v2/screens/send/keystone_verify_screen.dart index 34dfe1fd2..a82a67f35 100644 --- a/mobile-app/lib/v2/screens/send/keystone_verify_screen.dart +++ b/mobile-app/lib/v2/screens/send/keystone_verify_screen.dart @@ -91,7 +91,7 @@ class KeystoneVerifyScreen extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (session.primaryDetail != null) ...[ - Text(l10n.sendReviewAmount.toUpperCase(), style: labelStyle), + Text((session.primaryLabel ?? l10n.sendReviewAmount).toUpperCase(), style: labelStyle), const SizedBox(height: 16), Text(session.primaryDetail!, style: text.amountHero.copyWith(color: colors.textContent)), const SizedBox(height: 24), @@ -99,7 +99,7 @@ class KeystoneVerifyScreen extends ConsumerWidget { const SizedBox(height: 24), ], if (address != null) ...[ - Text(l10n.sendReviewTo.toUpperCase(), style: labelStyle), + Text((session.secondaryLabel ?? l10n.sendReviewTo).toUpperCase(), style: labelStyle), const SizedBox(height: 16), if (checksum != null) AddressCheckphraseWithInitial(recipientChecksum: checksum, recipientAddress: address) diff --git a/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart b/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart index ea7d80fab..cc60afa1f 100644 --- a/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart +++ b/mobile-app/lib/v2/screens/send/multisig_propose_strategy.dart @@ -11,8 +11,11 @@ import 'package:resonance_network_wallet/providers/multisig_providers.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/local_auth_service.dart'; import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; +import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/v2/components/multisig_expiry_value.dart'; +import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; +import 'package:resonance_network_wallet/v2/screens/send/keystone_signing_session.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; /// Proposal cost for a recipient. The network fee is still a chain estimate, @@ -165,6 +168,56 @@ class MultisigProposeStrategy extends SendStrategy { ]; } + Account _signer(WidgetRef ref) { + final signer = ref + .read(accountsProvider) + .value + ?.firstWhere( + (a) => a.accountId == msig.myMemberAccountId, + orElse: () => throw Exception('Member account not found in local wallet'), + ); + if (signer == null) throw Exception('No signer account available'); + return signer; + } + + RuntimeCall _proposeCall( + WidgetRef ref, { + required String recipient, + required BigInt amount, + required int expiryBlock, + }) => ref + .read(multisigServiceProvider) + .buildProposeTransferCall(msig: msig, recipient: recipient, amount: amount, expiryBlock: expiryBlock); + + KeystoneSignCacheKey _hardwareCacheKey( + Account signer, { + required String recipient, + required BigInt amount, + required int expiryBlock, + }) => KeystoneSignCacheKey.forExtrinsic( + accountId: signer.accountId, + identity: 'propose|${msig.accountId}|$recipient|$amount|$expiryBlock', + ); + + @override + Future prefetchSignPayload( + WidgetRef ref, { + required String recipientAddress, + required BigInt amount, + required SendFee fee, + }) async { + final signer = _signer(ref); + if (!signer.signsWithHardware) return; + final recipient = recipientAddress.trim(); + final expiryBlock = (fee as ProposeFee).breakdown.expiryBlock; + await ensureKeystoneSignPayload( + ref, + account: signer, + buildCall: () => _proposeCall(ref, recipient: recipient, amount: amount, expiryBlock: expiryBlock), + cacheKey: _hardwareCacheKey(signer, recipient: recipient, amount: amount, expiryBlock: expiryBlock), + ); + } + @override Future submit( WidgetRef ref, { @@ -177,50 +230,86 @@ class MultisigProposeStrategy extends SendStrategy { final l10n = ref.read(l10nProvider); final fmt = ref.read(numberFormattingServiceProvider); final breakdown = (fee as ProposeFee).breakdown; + final recipient = recipientAddress.trim(); + final terminal = _terminal(l10n, fmt, recipient: recipient, checksum: recipientChecksum, amount: amount); + + final Account signer; + try { + signer = _signer(ref); + } catch (e, st) { + quantusPrint('Propose signer error: $e $st'); + return SendFailed(l10n.multisigProposeSubmitFailed); + } + + // Keystone members sign off-device: hand off to the QR flow, which submits + // the proposal once the signature is scanned back. + if (signer.signsWithHardware) { + return SendNeedsHardwareSignature( + session: KeystoneSigningSession( + account: signer, + buildCall: () => _proposeCall(ref, recipient: recipient, amount: amount, expiryBlock: breakdown.expiryBlock), + primaryDetail: l10n.commonAmountBalance( + fmt.formatBalance(amount, smartDecimals: 4), + AppConstants.tokenSymbol, + ), + secondaryDetail: recipient, + tertiaryDetail: recipientChecksum, + cacheKey: _hardwareCacheKey(signer, recipient: recipient, amount: amount, expiryBlock: breakdown.expiryBlock), + telemetryPrefix: 'multisig_propose_hardware', + submitSigned: (ref, {required unsignedData, required signatureWithPublicKey}) async { + final hash = await ref + .read(transactionSubmissionServiceProvider) + .proposeTransferWithExternalSignature( + msig: msig, + signer: signer, + recipient: recipient, + amount: amount, + expiryBlock: breakdown.expiryBlock, + feeBreakdown: breakdown, + unsignedData: unsignedData, + signatureWithPublicKey: signatureWithPublicKey, + ); + _afterProposed(ref, recipient); + return hash; + }, + ), + terminalForHash: (_) => terminal, + ); + } final authed = await LocalAuthService().authenticate(localizedReason: l10n.multisigProposeAuthReason); if (!authed) return SendFailed(l10n.multisigProposeAuthRequired); try { - final signer = ref - .read(accountsProvider) - .value - ?.firstWhere( - (a) => a.accountId == msig.myMemberAccountId, - orElse: () => throw Exception('Member account not found in local wallet'), - ); - if (signer == null) throw Exception('No signer account available'); - await ref .read(transactionSubmissionServiceProvider) .proposeTransfer( msig: msig, signer: signer, - recipient: recipientAddress, + recipient: recipient, amount: amount, expiryBlock: breakdown.expiryBlock, feeBreakdown: breakdown, ); - - unawaited( - RecentAddressesService() - .addAddress(recipientAddress.trim()) - .catchError((Object e) => quantusPrint('Failed to save recent address: $e')), - ); - - ref.invalidate(multisigOpenProposalsProvider(msig)); - ref.invalidate(multisigPastProposalsProvider(msig)); - ref.invalidate(multisigCurrentBlockProvider); - - return SendSubmitted( - _terminal(l10n, fmt, recipient: recipientAddress, checksum: recipientChecksum, amount: amount), - ); + _afterProposed(ref, recipient); + return SendSubmitted(terminal); } catch (e, st) { quantusPrint('Propose submit error: $e $st'); return SendFailed(l10n.multisigProposeSubmitFailed); } } + void _afterProposed(WidgetRef ref, String recipient) { + unawaited( + RecentAddressesService() + .addAddress(recipient) + .catchError((Object e) => quantusPrint('Failed to save recent address: $e')), + ); + ref.invalidate(multisigOpenProposalsProvider(msig)); + ref.invalidate(multisigPastProposalsProvider(msig)); + ref.invalidate(multisigCurrentBlockProvider); + } + SendTerminalContent _terminal( AppLocalizations l10n, NumberFormattingService fmt, { diff --git a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart index 9e598073c..812ea951d 100644 --- a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -9,6 +9,7 @@ import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/local_auth_service.dart'; import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; +import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/shared/utils/url_utils.dart'; import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; @@ -88,8 +89,6 @@ class RegularSendStrategy extends SendStrategy { @override String? affordabilityError(WidgetRef ref, SendFee fee, AppLocalizations l10n) => null; - bool get _signsWithHardware => account.accountType == AccountType.keystone || AppConstants.debugHardwareWallet; - RuntimeCall _transferCall(WidgetRef ref, String recipient, BigInt amount) => ref.read(balancesServiceProvider).getBalanceTransferCall(recipient, amount); @@ -97,8 +96,13 @@ class RegularSendStrategy extends SendStrategy { KeystoneSignCacheKey.fromSendParams(accountId: account.accountId, recipientAddress: recipient, amount: amount); @override - Future prefetchSignPayload(WidgetRef ref, {required String recipientAddress, required BigInt amount}) async { - if (!_signsWithHardware) return; + Future prefetchSignPayload( + WidgetRef ref, { + required String recipientAddress, + required BigInt amount, + required SendFee fee, + }) async { + if (!account.signsWithHardware) return; final recipient = recipientAddress.trim(); await ensureKeystoneSignPayload( ref, @@ -164,7 +168,7 @@ class RegularSendStrategy extends SendStrategy { // Keystone (hardware) accounts sign off-device: hand off to the QR flow // instead of signing locally. The debug flag forces this path for testing. - if (_signsWithHardware) { + if (account.signsWithHardware) { return SendNeedsHardwareSignature( session: KeystoneSigningSession( account: account, @@ -197,7 +201,7 @@ class RegularSendStrategy extends SendStrategy { return hash; }, ), - terminal: terminal, + terminalForHash: (hash) => terminal.copyWith(explorerUrl: explorerImmediateTransactionUrl(hash)), ); } diff --git a/mobile-app/lib/v2/screens/send/review_send_screen.dart b/mobile-app/lib/v2/screens/send/review_send_screen.dart index 7d94e5458..f5534466b 100644 --- a/mobile-app/lib/v2/screens/send/review_send_screen.dart +++ b/mobile-app/lib/v2/screens/send/review_send_screen.dart @@ -9,7 +9,6 @@ import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; import 'package:resonance_network_wallet/shared/constants/e2e_keys.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; -import 'package:resonance_network_wallet/shared/utils/url_utils.dart'; import 'package:resonance_network_wallet/v2/components/address_checkphrase_with_initial.dart'; import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; import 'package:resonance_network_wallet/v2/components/split_card.dart'; @@ -64,7 +63,12 @@ class _ReviewSendScreenState extends ConsumerState { void _prefetchSignPayload() { unawaited( widget.strategy - .prefetchSignPayload(ref, recipientAddress: widget.recipientAddress.trim(), amount: widget.amount) + .prefetchSignPayload( + ref, + recipientAddress: widget.recipientAddress.trim(), + amount: widget.amount, + fee: widget.fee, + ) .catchError((Object e) => quantusPrint('Keystone payload prefetch failed: $e')), ); } @@ -103,20 +107,14 @@ class _ReviewSendScreenState extends ConsumerState { _errorMessage = null; }); Navigator.push(context, MaterialPageRoute(builder: (_) => SendTerminalScreen(content: terminal))); - case SendNeedsHardwareSignature(:final session, :final terminal): + case SendNeedsHardwareSignature(:final session, :final terminalForHash): setState(() => _submitting = false); final hash = await Navigator.push( context, MaterialPageRoute(builder: (_) => KeystoneSignScreen(session: session)), ); if (!mounted || hash == null) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - SendTerminalScreen(content: terminal.copyWith(explorerUrl: explorerImmediateTransactionUrl(hash))), - ), - ); + Navigator.push(context, MaterialPageRoute(builder: (_) => SendTerminalScreen(content: terminalForHash(hash)))); case SendNeedsProving(:final account, :final plan, :final amount, :final terminal): setState(() => _submitting = false); Navigator.push( diff --git a/mobile-app/lib/v2/screens/send/send_strategy.dart b/mobile-app/lib/v2/screens/send/send_strategy.dart index 2075dcde2..ca5364d43 100644 --- a/mobile-app/lib/v2/screens/send/send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/send_strategy.dart @@ -129,12 +129,13 @@ class SendSubmitted extends SendOutcome { } /// The source account signs off-device (Keystone): hand off to the hardware QR -/// flow, which broadcasts and then shows [terminal]. +/// flow, which broadcasts and then shows [terminalForHash] built from the +/// submitted extrinsic hash. class SendNeedsHardwareSignature extends SendOutcome { final KeystoneSigningSession session; - final SendTerminalContent terminal; + final SendTerminalContent Function(String extrinsicHash) terminalForHash; - const SendNeedsHardwareSignature({required this.session, required this.terminal}); + const SendNeedsHardwareSignature({required this.session, required this.terminalForHash}); } /// Encrypted send authenticated and planned: hand off to the proving progress @@ -251,7 +252,12 @@ abstract class SendStrategy { /// closes). Strategies that hand off to hardware signing warm the Keystone /// sign cache here so the QR screen renders instantly. No-op for flows that /// sign locally. Uses `ref.read`. - Future prefetchSignPayload(WidgetRef ref, {required String recipientAddress, required BigInt amount}) async {} + Future prefetchSignPayload( + WidgetRef ref, { + required String recipientAddress, + required BigInt amount, + required SendFee fee, + }) async {} /// Authenticates and submits. Uses `ref.read`. Never navigates. Future submit( diff --git a/mobile-app/test/fakes.dart b/mobile-app/test/fakes.dart index 976975f6b..ec9c748c1 100644 --- a/mobile-app/test/fakes.dart +++ b/mobile-app/test/fakes.dart @@ -1,5 +1,8 @@ import 'dart:typed_data'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/local_auth_provider.dart'; @@ -122,3 +125,21 @@ UnsignedTransactionData makeUnsignedTransactionData() { registry: Object(), ); } + +/// Pumps a bare [ProviderScope] and returns a [WidgetRef] bound to it, for +/// exercising code that takes a `WidgetRef` outside a real screen. +Future pumpRef(WidgetTester tester, {List overrides = const []}) async { + late WidgetRef widgetRef; + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: Consumer( + builder: (context, ref, _) { + widgetRef = ref; + return const SizedBox(); + }, + ), + ), + ); + return widgetRef; +} diff --git a/mobile-app/test/unit/multisig_propose_strategy_test.dart b/mobile-app/test/unit/multisig_propose_strategy_test.dart new file mode 100644 index 000000000..fb3e09b59 --- /dev/null +++ b/mobile-app/test/unit/multisig_propose_strategy_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/send/multisig_propose_strategy.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; + +import '../fakes.dart'; + +void main() { + final keystone = makeAccount(3, accountType: AccountType.keystone); + final other = makeAccount(2); + final msig = MultisigAccount( + name: 'Msig', + accountId: 'qzmsig${'x' * 40}', + signers: [keystone.accountId, other.accountId], + threshold: 2, + nonce: BigInt.zero, + myMemberAccountId: keystone.accountId, + ); + final fee = ProposeFee( + ProposeFeeBreakdown( + networkFee: BigInt.from(10), + deposit: BigInt.from(20), + creationFee: BigInt.from(30), + expiryBlock: 1000, + ), + ); + + Future submit(WidgetTester tester, List accounts) async { + final ref = await pumpRef( + tester, + overrides: [ + settingsServiceProvider.overrideWithValue(FakeSettingsService()), + accountsProvider.overrideWith((ref) => AccountsNotifier(AccountsService(), initialAccounts: accounts)), + ], + ); + return MultisigProposeStrategy(msig: msig).submit( + ref, + recipientAddress: other.accountId, + recipientChecksum: 'checksum', + amount: BigInt.from(1000), + fee: fee, + isPayMode: false, + ); + } + + testWidgets('a keystone member is handed to the signing session instead of signing locally', (tester) async { + final outcome = await submit(tester, [keystone, other]); + + expect(outcome, isA()); + final hardware = outcome as SendNeedsHardwareSignature; + expect(hardware.session.account.accountId, keystone.accountId); + expect(hardware.terminalForHash('0xabc').explorerUrl, isNull); + }); + + testWidgets('fails before signing when the member account is not in the wallet', (tester) async { + expect(await submit(tester, [other]), isA()); + }); +} diff --git a/mobile-app/test/unit/regular_send_strategy_test.dart b/mobile-app/test/unit/regular_send_strategy_test.dart index 50bcc64f2..fd4661216 100644 --- a/mobile-app/test/unit/regular_send_strategy_test.dart +++ b/mobile-app/test/unit/regular_send_strategy_test.dart @@ -16,22 +16,6 @@ void main() { final captured = makeAccount(1); final other = makeAccount(2); - Future pumpRef(WidgetTester tester, {List overrides = const []}) async { - late WidgetRef widgetRef; - await tester.pumpWidget( - ProviderScope( - overrides: overrides, - child: Consumer( - builder: (context, ref, _) { - widgetRef = ref; - return const SizedBox(); - }, - ), - ), - ); - return widgetRef; - } - testWidgets('stays bound to the captured account after the active account switches', (tester) async { final settings = FakeSettingsService(activeAccount: RegularAccount(other)); final ref = await pumpRef(tester, overrides: [settingsServiceProvider.overrideWithValue(settings)]);