diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 50ec9652..4160c486 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 90e954be..89a73456 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 e0a761d9..a4883e51 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 0f3d5ff0..24631601 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 4e24be68..c1aa50c6 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 4e5edaad..6c60343d 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 c3afaa49..86b85634 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 88b54c1d..7be6d875 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 5a6c5268..a8ab1f0a 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 ff503c2d..9817e124 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 f7c854dc..c43262fe 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 34dfe1fd..a82a67f3 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 ea7d80fa..cc60afa1 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 9e598073..812ea951 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 7d94e545..f5534466 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 2075dcde..ca5364d4 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 976975f6..ec9c748c 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 00000000..fb3e09b5 --- /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 50bcc64f..fd466121 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)]);