diff --git a/miner-app/lib/features/withdrawal/claim_rewards_dialog.dart b/miner-app/lib/features/withdrawal/claim_rewards_dialog.dart index 9634ca73..8f41c0dc 100644 --- a/miner-app/lib/features/withdrawal/claim_rewards_dialog.dart +++ b/miner-app/lib/features/withdrawal/claim_rewards_dialog.dart @@ -29,7 +29,7 @@ enum _Screen { input, confirm, progress } class _ClaimRewardsDialogState extends State<_ClaimRewardsDialog> { final _addressController = TextEditingController(); - final _claimService = WormholeClaimService(); + final _claimService = WormholeSendService(); final _walletService = MinerWalletService(); final _settingsService = MinerSettingsService(); diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 3a1d76c5..0e52402f 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -2693,5 +2693,33 @@ "type": "int" } } + }, + "encryptedSendFeeLabel": "Privacy fee", + "@encryptedSendFeeLabel": { + "description": "Fee label for encrypted sends (wormhole volume fee)" + }, + "encryptedSendAmountStep": "Use steps of 0.01 QUAN", + "@encryptedSendAmountStep": { + "description": "Shown when an encrypted send amount is not a multiple of 0.01 QUAN" + }, + "encryptedSendMinimum": "Encrypted sends must move at least 0.1 QUAN", + "@encryptedSendMinimum": { + "description": "Shown when an encrypted send falls below the chain's minimum exit amount" + }, + "encryptedSendProgressTitle": "Sending Privately...", + "@encryptedSendProgressTitle": { + "description": "App bar title while an encrypted send is proving/submitting" + }, + "encryptedSendFailedTitle": "Send Failed", + "@encryptedSendFailedTitle": { + "description": "App bar title when an encrypted send failed" + }, + "encryptedSendCancelledTitle": "Send Cancelled", + "@encryptedSendCancelledTitle": { + "description": "App bar title when an encrypted send was cancelled" + }, + "encryptedSendingLabel": "SENDING", + "@encryptedSendingLabel": { + "description": "Status header label on encrypted send progress" } } diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index 9412cd25..da79e63c 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -610,5 +610,12 @@ "redeemRetry": "Coba Lagi", "redeemClose": "Tutup", "redeemDone": "Selesai", - "redeemSuccessBanner": "{amount} ditukar dalam {count} batch" + "redeemSuccessBanner": "{amount} ditukar dalam {count} batch", + "encryptedSendFeeLabel": "Biaya privasi", + "encryptedSendAmountStep": "Gunakan kelipatan 0,01 QUAN", + "encryptedSendMinimum": "Pengiriman terenkripsi minimal 0,1 QUAN", + "encryptedSendProgressTitle": "Mengirim Secara Privat...", + "encryptedSendFailedTitle": "Pengiriman Gagal", + "encryptedSendCancelledTitle": "Pengiriman Dibatalkan", + "encryptedSendingLabel": "MENGIRIM" } diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 6e91f138..327b3497 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -3451,6 +3451,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{amount} redeemed in {count} batch(es)'** String redeemSuccessBanner(String amount, int count); + + /// Fee label for encrypted sends (wormhole volume fee) + /// + /// In en, this message translates to: + /// **'Privacy fee'** + String get encryptedSendFeeLabel; + + /// Shown when an encrypted send amount is not a multiple of 0.01 QUAN + /// + /// In en, this message translates to: + /// **'Use steps of 0.01 QUAN'** + String get encryptedSendAmountStep; + + /// Shown when an encrypted send falls below the chain's minimum exit amount + /// + /// In en, this message translates to: + /// **'Encrypted sends must move at least 0.1 QUAN'** + String get encryptedSendMinimum; + + /// App bar title while an encrypted send is proving/submitting + /// + /// In en, this message translates to: + /// **'Sending Privately...'** + String get encryptedSendProgressTitle; + + /// App bar title when an encrypted send failed + /// + /// In en, this message translates to: + /// **'Send Failed'** + String get encryptedSendFailedTitle; + + /// App bar title when an encrypted send was cancelled + /// + /// In en, this message translates to: + /// **'Send Cancelled'** + String get encryptedSendCancelledTitle; + + /// Status header label on encrypted send progress + /// + /// In en, this message translates to: + /// **'SENDING'** + String get encryptedSendingLabel; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 64074456..db4b1feb 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -1860,4 +1860,25 @@ class AppLocalizationsEn extends AppLocalizations { String redeemSuccessBanner(String amount, int count) { return '$amount redeemed in $count batch(es)'; } + + @override + String get encryptedSendFeeLabel => 'Privacy fee'; + + @override + String get encryptedSendAmountStep => 'Use steps of 0.01 QUAN'; + + @override + String get encryptedSendMinimum => 'Encrypted sends must move at least 0.1 QUAN'; + + @override + String get encryptedSendProgressTitle => 'Sending Privately...'; + + @override + String get encryptedSendFailedTitle => 'Send Failed'; + + @override + String get encryptedSendCancelledTitle => 'Send Cancelled'; + + @override + String get encryptedSendingLabel => 'SENDING'; } diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index 44250885..2c914fb6 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -1857,4 +1857,25 @@ class AppLocalizationsId extends AppLocalizations { String redeemSuccessBanner(String amount, int count) { return '$amount ditukar dalam $count batch'; } + + @override + String get encryptedSendFeeLabel => 'Biaya privasi'; + + @override + String get encryptedSendAmountStep => 'Gunakan kelipatan 0,01 QUAN'; + + @override + String get encryptedSendMinimum => 'Pengiriman terenkripsi minimal 0,1 QUAN'; + + @override + String get encryptedSendProgressTitle => 'Mengirim Secara Privat...'; + + @override + String get encryptedSendFailedTitle => 'Pengiriman Gagal'; + + @override + String get encryptedSendCancelledTitle => 'Pengiriman Dibatalkan'; + + @override + String get encryptedSendingLabel => 'MENGIRIM'; } diff --git a/mobile-app/lib/providers/wallet_providers.dart b/mobile-app/lib/providers/wallet_providers.dart index 51993ceb..e294316e 100644 --- a/mobile-app/lib/providers/wallet_providers.dart +++ b/mobile-app/lib/providers/wallet_providers.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/legacy.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/remote_config_provider.dart'; import 'package:resonance_network_wallet/providers/pending_multisig_cancellations_provider.dart'; import 'package:resonance_network_wallet/providers/pending_multisig_creations_provider.dart'; import 'package:resonance_network_wallet/providers/pending_multisig_executions_provider.dart'; @@ -69,6 +70,40 @@ final wormholeUtxoServiceProvider = Provider((ref) { return WormholeUtxoService(); }); +/// One encrypted-account service per wallet: caches derived wormhole key pairs +/// and owns the persisted next-index / pending-spend state. +final encryptedAccountServiceProvider = Provider.family((ref, walletIndex) { + return EncryptedAccountService( + walletIndex: walletIndex, + getMnemonic: () => ref.read(settingsServiceProvider).getMnemonic(walletIndex), + ); +}); + +/// Discovered UTXO set + pending change for a wallet's encrypted account. +final encryptedStateProvider = FutureProvider.family((ref, walletIndex) { + return ref.watch(encryptedAccountServiceProvider(walletIndex)).load(); +}); + +final encryptedBalanceProvider = Provider.family, int>((ref, walletIndex) { + return ref.watch(encryptedStateProvider(walletIndex)).whenData((s) => s.balance); +}); + +/// Exact max sendable (inputs net of the wormhole volume fee). +final encryptedSpendableProvider = Provider.family, int>((ref, walletIndex) { + return ref.watch(encryptedStateProvider(walletIndex)).whenData((s) => s.maxSendable); +}); + +bool isEncryptedAccount(BaseAccount? account) => account is Account && account.accountType == AccountType.encrypted; + +/// Backfills the per-wallet encrypted (wormhole) account for every software +/// wallet, gated by the feature flag. Idempotent; returns true if any were +/// added (callers invalidate [accountsProvider] then). +Future ensureEncryptedAccounts(WidgetRef ref) async { + if (!ref.read(remoteConfigProvider).enableEncryptedAccount) return false; + final name = ref.read(l10nProvider).createAccountEncryptedDefaultName; + return ref.read(accountsServiceProvider).ensureEncryptedAccountsForSoftwareWallets(name: name); +} + final isHighSecurityProvider = FutureProvider.family((ref, account) async { final highSecurityService = ref.watch(highSecurityServiceProvider); return await highSecurityService.isHighSecurity(account); @@ -117,7 +152,11 @@ final balanceProviderRaw = Provider>((ref) { if (activeAccount == null) { return AsyncValue.data(BigInt.zero); } - return ref.watch(balanceProviderFamily(activeAccount.account.accountId)); + final account = activeAccount.account; + if (isEncryptedAccount(account)) { + return ref.watch(encryptedBalanceProvider((account as Account).walletIndex)); + } + return ref.watch(balanceProviderFamily(account.accountId)); }, loading: () => const AsyncValue.loading(), error: (err, stack) => AsyncValue.error(err, stack), diff --git a/mobile-app/lib/shared/utils/polling_refresh_scope.dart b/mobile-app/lib/shared/utils/polling_refresh_scope.dart index e7c30b93..1127f7f2 100644 --- a/mobile-app/lib/shared/utils/polling_refresh_scope.dart +++ b/mobile-app/lib/shared/utils/polling_refresh_scope.dart @@ -49,19 +49,29 @@ Set reconciliationAccountIds({ /// Invalidates balance for the active account only. void invalidateActiveAccountBalance(Ref ref) { - final accountId = activeAccountId(ref); - if (accountId == null) return; + final account = ref.read(activeAccountProvider).value?.account; + if (account == null) return; - ref.invalidate(balanceProviderFamily(accountId)); + if (isEncryptedAccount(account)) { + ref.invalidate(encryptedStateProvider((account as Account).walletIndex)); + return; + } + ref.invalidate(balanceProviderFamily(account.accountId)); } /// Invalidates the active account balance and waits for it to reload. Future refreshActiveAccountBalance(Ref ref) async { - final accountId = activeAccountId(ref); - if (accountId == null) return; - - ref.invalidate(balanceProviderFamily(accountId)); - await ref.read(balanceProviderFamily(accountId).future); + final account = ref.read(activeAccountProvider).value?.account; + if (account == null) return; + + if (isEncryptedAccount(account)) { + final provider = encryptedStateProvider((account as Account).walletIndex); + ref.invalidate(provider); + await ref.read(provider.future); + return; + } + ref.invalidate(balanceProviderFamily(account.accountId)); + await ref.read(balanceProviderFamily(account.accountId).future); } /// Invalidates balance for the given account IDs. diff --git a/mobile-app/lib/v2/components/wormhole_progress_steps.dart b/mobile-app/lib/v2/components/wormhole_progress_steps.dart new file mode 100644 index 00000000..9ecec211 --- /dev/null +++ b/mobile-app/lib/v2/components/wormhole_progress_steps.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +/// Step list for wormhole proving flows (mining-rewards redeem and encrypted +/// send), rendering [ClaimProgressItem] updates from [WormholeSendService]. +/// +/// Steps 5 (generating proofs) and 6 (aggregating & submitting) run +/// interleaved batch-by-batch, so their state is driven by their own reported +/// progress rather than the linear current-step cursor: once step 6 is +/// reported it stays engaged (showing batches submitted) even while step 5 +/// keeps advancing for the remaining batches. +class WormholeProgressSteps extends StatelessWidget { + /// (service step id, localized title) in display order. + final List<(int, String)> steps; + final Map stepProgress; + final int currentStep; + final bool done; + final bool cancelled; + final bool hasError; + + /// Optional per-step progress label override (e.g. "12 fetched" for the + /// transfer-fetch step, which has no known total). Return null for the + /// default "completed / total" label. + final String? Function(int step, ClaimProgressItem progress)? progressLabelOverride; + + const WormholeProgressSteps({ + super.key, + required this.steps, + required this.stepProgress, + required this.currentStep, + required this.done, + required this.cancelled, + required this.hasError, + this.progressLabelOverride, + }); + + /// Highest step that has reported progress (steps 5 & 6 interleave, so the + /// linear cursor alone can't tell us which earlier steps are done). + int get _maxStartedStep => stepProgress.keys.fold(currentStep, (m, k) => k > m ? k : m); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: colors.sheetBackground, borderRadius: BorderRadius.circular(12)), + child: Column( + children: [ + for (int i = 0; i < steps.length; i++) ...[ + _buildStepRow(steps[i].$1, steps[i].$2, colors, text), + if (i < steps.length - 1) _buildConnector(steps[i].$1, colors), + ], + ], + ), + ); + } + + Widget _buildStepRow(int step, String title, AppColorsV2 colors, AppTextTheme text) { + final progress = stepProgress[step]; + final isError = !done && hasError && currentStep == step; + final reachedTotal = progress != null && progress.total != null && progress.completed >= progress.total!; + + final bool isCompleted; + final bool isActive; + if (done) { + isCompleted = true; + isActive = false; + } else if (step >= 5) { + isCompleted = reachedTotal; + isActive = progress != null && !reachedTotal && !cancelled && !hasError; + } else { + isCompleted = _maxStartedStep > step; + isActive = !isCompleted && !cancelled && !hasError && currentStep == step; + } + + final Widget icon; + if (isCompleted) { + icon = Container( + width: 28, + height: 28, + decoration: BoxDecoration(color: colors.success, shape: BoxShape.circle), + child: const Icon(Icons.check, color: Colors.white, size: 16), + ); + } else if (isError) { + icon = Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: colors.textError.withValues(alpha: 0.15), + shape: BoxShape.circle, + border: Border.all(color: colors.textError, width: 2), + ), + child: Icon(Icons.close, color: colors.textError, size: 14), + ); + } else if (isActive) { + icon = Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: colors.success.withValues(alpha: 0.12), + shape: BoxShape.circle, + border: Border.all(color: colors.success, width: 2), + ), + child: Padding( + padding: const EdgeInsets.all(5), + child: CircularProgressIndicator(strokeWidth: 2, color: colors.success), + ), + ); + } else { + icon = Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: colors.borderButton.withValues(alpha: 0.3), width: 1.5), + ), + child: Center( + child: Text('$step', style: text.detail?.copyWith(color: colors.textTertiary)), + ), + ); + } + + final titleColor = isCompleted + ? colors.success + : isActive + ? colors.textPrimary + : isError + ? colors.textError + : colors.textTertiary; + + String progressText = ''; + if (progress != null && (isActive || isCompleted)) { + progressText = + progressLabelOverride?.call(step, progress) ?? + (progress.total != null ? '${progress.completed} / ${progress.total}' : ''); + } + + double? progressFraction; + if (isActive && progress != null && progress.total != null && progress.total! > 0) { + progressFraction = (progress.completed / progress.total!).clamp(0.0, 1.0); + } + + return Column( + children: [ + Row( + children: [ + icon, + const SizedBox(width: 14), + Expanded( + child: Text(title, style: text.smallParagraph?.copyWith(color: titleColor)), + ), + if (progressText.isNotEmpty) + Text( + progressText, + style: text.detail?.copyWith( + color: isCompleted ? colors.success : colors.textPrimary, + fontFamily: AppTextTheme.fontFamilySecondary, + ), + ), + ], + ), + if (progressFraction != null) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 42), + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: progressFraction, + backgroundColor: colors.borderButton.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(colors.success), + minHeight: 4, + ), + ), + ), + ], + ], + ); + } + + Widget _buildConnector(int afterStep, AppColorsV2 colors) { + final isCompleted = done ? true : currentStep > afterStep; + return Padding( + padding: const EdgeInsets.only(left: 13), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: 2, + height: 20, + decoration: BoxDecoration( + color: isCompleted ? colors.success.withValues(alpha: 0.4) : colors.borderButton.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(1), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/accounts/accounts_sheet.dart b/mobile-app/lib/v2/screens/accounts/accounts_sheet.dart index 7962d5da..261da76a 100644 --- a/mobile-app/lib/v2/screens/accounts/accounts_sheet.dart +++ b/mobile-app/lib/v2/screens/accounts/accounts_sheet.dart @@ -13,7 +13,6 @@ import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; -import 'package:resonance_network_wallet/providers/remote_config_provider.dart'; import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; @@ -54,10 +53,8 @@ class _AccountsScreenState extends ConsumerState { /// wallet so it shows alongside transparent accounts. Gated by the feature /// flag; persists once, then no-ops on subsequent opens. Future _ensureEncryptedAccounts() async { - if (!ref.read(remoteConfigProvider).enableEncryptedAccount) return; try { - final name = ref.read(l10nProvider).createAccountEncryptedDefaultName; - final created = await ref.read(accountsServiceProvider).ensureEncryptedAccountsForSoftwareWallets(name: name); + final created = await ensureEncryptedAccounts(ref); if (created && mounted) ref.invalidate(accountsProvider); } catch (e, st) { quantusDebugPrint('[AccountsSheet] ensure encrypted accounts failed: $e\n$st'); @@ -266,8 +263,10 @@ class _AccountsScreenState extends ConsumerState { return const SizedBox.shrink(); } - String _balanceText(AppLocalizations l10n, String accountId) { - final balanceAsync = ref.watch(balanceProviderFamily(accountId)); + String _balanceText(AppLocalizations l10n, BaseAccount account) { + final balanceAsync = isEncryptedAccount(account) + ? ref.watch(encryptedBalanceProvider((account as Account).walletIndex)) + : ref.watch(balanceProviderFamily(account.accountId)); final formattingService = ref.watch(numberFormattingServiceProvider); return balanceAsync.when( loading: () => l10n.commonLoading, @@ -284,7 +283,7 @@ class _AccountsScreenState extends ConsumerState { onTap: () => _switchAccount(RegularAccount(account)), leading: AccountBadge.account(account: account, isActive: isActive), title: account.name, - subtitle: _balanceText(l10n, account.accountId), + subtitle: _balanceText(l10n, account), trailing: QuantusIconButton.circular( icon: Icons.edit_outlined, onTap: () => _openAccountMenu(account), @@ -300,7 +299,7 @@ class _AccountsScreenState extends ConsumerState { isHighlighted: isHighlighted, onTap: () => _switchAccount(RegularAccount(account)), leading: AccountBadge.icon(icon: Icons.lock_outline, isActive: isActive), - subtitle: _balanceText(l10n, account.accountId), + subtitle: _balanceText(l10n, account), ); } @@ -318,7 +317,7 @@ class _AccountsScreenState extends ConsumerState { onTap: () => _switchAccount(MultisigDisplayAccount(account)), leading: AccountBadge(name: account.name, isActive: isActive), title: account.name, - subtitle: _balanceText(l10n, account.accountId), + subtitle: _balanceText(l10n, account), tag: MultisigTag(label: l10n.multisigTag), trailing: QuantusIconButton.circular( icon: Icons.edit_outlined, diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index edf286f2..9ea39f1c 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -24,11 +24,13 @@ import 'package:resonance_network_wallet/v2/screens/activity/transaction_detail_ import 'package:resonance_network_wallet/v2/screens/receive/receive_screen.dart'; import 'package:resonance_network_wallet/v2/screens/multisig/multisig_activity_section.dart'; import 'package:resonance_network_wallet/v2/screens/multisig/multisig_proposal_detail_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/send/encrypted_send_strategy.dart'; import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; import 'package:resonance_network_wallet/v2/screens/send/multisig_propose_strategy.dart'; import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart'; import 'package:resonance_network_wallet/v2/screens/send/select_recipient_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; import 'package:resonance_network_wallet/v2/screens/settings/settings_screen.dart'; import 'package:resonance_network_wallet/v2/screens/pos/pos_amount_screen.dart'; import 'package:resonance_network_wallet/v2/screens/swap/swap_screen.dart'; @@ -219,7 +221,7 @@ class _HomeScreenState extends ConsumerState { _buildMultisigActionButtons(l10n, active.account), const SizedBox(height: 40), ], - if (active is RegularAccount) ...[_buildActionButtons(l10n), const SizedBox(height: 40)], + if (active is RegularAccount) ...[_buildActionButtons(l10n, active.account), const SizedBox(height: 40)], if (backupWalletIndex != null) ...[ BackupReminderBanner(walletIndex: backupWalletIndex), const SizedBox(height: 40), @@ -321,8 +323,12 @@ class _HomeScreenState extends ConsumerState { ); } - Widget _buildActionButtons(AppLocalizations l10n) { - final enableSwap = ref.watch(remoteConfigProvider).enableSwap; + Widget _buildActionButtons(AppLocalizations l10n, Account account) { + final isEncrypted = isEncryptedAccount(account); + final enableSwap = !isEncrypted && ref.watch(remoteConfigProvider).enableSwap; + final SendStrategy sendStrategy = isEncrypted + ? EncryptedSendStrategy(account: account) + : const RegularSendStrategy(); final receiveCard = _actionCard( iconAsset: 'assets/v2/action_receive.svg', @@ -336,10 +342,7 @@ class _HomeScreenState extends ConsumerState { label: l10n.homeSend, onTap: () { ref.read(keystoneSignCacheProvider.notifier).startNewSendSession(); - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const SelectRecipientScreen(strategy: RegularSendStrategy())), - ); + Navigator.push(context, MaterialPageRoute(builder: (_) => SelectRecipientScreen(strategy: sendStrategy))); }, ); diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index 30566371..ea842629 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -147,12 +147,30 @@ class _ImportWalletScreenV2State extends ConsumerState { } ref.invalidate(accountsProvider); ref.invalidate(activeAccountProvider); + await _discoverEncryptedAccount(); } catch (e, st) { quantusDebugPrint('error discovering accounts: $e'); TelemetryService().sendError('Error discovering accounts', error: e, stackTrace: st); } } + /// Restores the wallet's encrypted account and warms its wormhole address + /// discovery in the background so UTXOs across receive/change addresses are + /// already found when the user first opens it. + Future _discoverEncryptedAccount() async { + final created = await ensureEncryptedAccounts(ref); + if (!created) return; + ref.invalidate(accountsProvider); + unawaited(() async { + try { + await ref.read(encryptedStateProvider(widget.walletIndex).future); + } catch (e, st) { + quantusDebugPrint('encrypted discovery after import failed: $e'); + TelemetryService().sendError('Encrypted discovery after import failed', error: e, stackTrace: st); + } + }()); + } + @override Widget build(BuildContext context) { final l10n = ref.watch(l10nProvider); diff --git a/mobile-app/lib/v2/screens/receive/receive_screen.dart b/mobile-app/lib/v2/screens/receive/receive_screen.dart index 24e25b26..25b6312e 100644 --- a/mobile-app/lib/v2/screens/receive/receive_screen.dart +++ b/mobile-app/lib/v2/screens/receive/receive_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; @@ -46,9 +47,18 @@ class _ReceiveScreenState extends ConsumerState { try { final account = (await settingsService.getActiveAccount())!; - final checksum = await checksumService.getHumanReadableName(account.account.accountId); + var accountId = account.account.accountId; + // Encrypted accounts rotate deposits to the next unused wormhole address + // (shared linearly with change addresses) so deposits aren't linkable. + final base = account.account; + if (isEncryptedAccount(base)) { + final service = ref.read(encryptedAccountServiceProvider((base as Account).walletIndex)); + accountId = (await service.receiveKeyPair()).address; + } + final checksum = await checksumService.getHumanReadableName(accountId); + if (!mounted) return; setState(() { - _accountId = account.account.accountId; + _accountId = accountId; _checksum = checksum; }); } catch (e) { diff --git a/mobile-app/lib/v2/screens/send/encrypted_send_progress_screen.dart b/mobile-app/lib/v2/screens/send/encrypted_send_progress_screen.dart new file mode 100644 index 00000000..7f9be7f9 --- /dev/null +++ b/mobile-app/lib/v2/screens/send/encrypted_send_progress_screen.dart @@ -0,0 +1,197 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; +import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; +import 'package:resonance_network_wallet/v2/components/wormhole_progress_steps.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_terminal_screen.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +/// Drives an encrypted send: proves the planned spends, submits the batches +/// and then replaces itself with the shared send terminal screen. +/// +/// There is no retry here — after a partial submission the plan is stale +/// (some inputs are spent), so the user re-initiates from Home against the +/// refreshed UTXO set instead. +class EncryptedSendProgressScreen extends ConsumerStatefulWidget { + final Account account; + final WormholeSpendPlan plan; + final String recipientAddress; + final SendTerminalContent terminal; + + const EncryptedSendProgressScreen({ + super.key, + required this.account, + required this.plan, + required this.recipientAddress, + required this.terminal, + }); + + @override + ConsumerState createState() => _EncryptedSendProgressScreenState(); +} + +class _EncryptedSendProgressScreenState extends ConsumerState { + bool _running = true; + bool _cancelled = false; + String? _errorMessage; + int _currentStep = 0; + final Map _stepProgress = {}; + + @override + void initState() { + super.initState(); + _startSend(); + } + + Future _startSend() async { + final service = ref.read(encryptedAccountServiceProvider(widget.account.walletIndex)); + try { + final circuitDir = await CircuitManager.getCircuitDirectory(); + await service.send( + plan: widget.plan, + recipientAddress: widget.recipientAddress, + circuitBinsDir: circuitDir, + onProgress: (progress) { + if (!mounted) return; + setState(() { + _currentStep = progress.step; + _stepProgress[progress.step] = progress; + }); + }, + ); + + ref.invalidate(encryptedStateProvider(widget.account.walletIndex)); + unawaited( + RecentAddressesService() + .addAddress(widget.recipientAddress) + .catchError((Object e) => debugPrint('Failed to save recent address: $e')), + ); + if (!mounted) return; + Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => SendTerminalScreen(content: widget.terminal))); + } on ClaimCancelled { + ref.invalidate(encryptedStateProvider(widget.account.walletIndex)); + if (!mounted) return; + setState(() { + _running = false; + _cancelled = true; + }); + } catch (e) { + // ignore: avoid_print + print('[EncryptedSend] Send failed: $e'); + ref.invalidate(encryptedStateProvider(widget.account.walletIndex)); + if (!mounted) return; + setState(() { + _running = false; + _errorMessage = e.toString(); + }); + } + } + + void _cancel() { + ref.read(encryptedAccountServiceProvider(widget.account.walletIndex)).cancel(); + } + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final text = context.themeText; + final l10n = ref.watch(l10nProvider); + + return PopScope( + canPop: !_running, + child: ScaffoldBase( + appBar: V2AppBar( + title: _errorMessage != null + ? l10n.encryptedSendFailedTitle + : _cancelled + ? l10n.encryptedSendCancelledTitle + : l10n.encryptedSendProgressTitle, + showBackButton: !_running, + ), + mainContent: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + _buildStatusHeader(colors, text, l10n), + const SizedBox(height: 32), + WormholeProgressSteps( + steps: [ + (1, l10n.redeemStepCircuits), + (5, l10n.redeemStepProofs), + (6, l10n.redeemStepAggregate), + ], + stepProgress: _stepProgress, + currentStep: _currentStep, + done: false, + cancelled: _cancelled, + hasError: _errorMessage != null, + ), + if (_errorMessage != null) ...[const SizedBox(height: 24), _buildErrorBanner(colors, text)], + ], + ), + bottomContent: _buildBottomContent(l10n), + ), + ); + } + + Widget _buildStatusHeader(AppColorsV2 colors, AppTextTheme text, AppLocalizations l10n) { + final fmt = ref.watch(numberFormattingServiceProvider); + final amountLabel = fmt.formatBalance(widget.plan.amountPlanck, maxDecimals: 2, addSymbol: true); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: colors.sheetBackground, borderRadius: BorderRadius.circular(12)), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(l10n.encryptedSendingLabel, style: text.receiveLabel?.copyWith(color: colors.textLabel)), + Text(amountLabel, style: text.sendSectionLabel?.copyWith(color: colors.success)), + ], + ), + ); + } + + Widget _buildErrorBanner(AppColorsV2 colors, AppTextTheme text) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.textError.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.textError.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colors.textError, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text(_errorMessage!, style: text.detail?.copyWith(color: colors.textError)), + ), + ], + ), + ); + } + + Widget _buildBottomContent(AppLocalizations l10n) { + if (_running) { + return ScaffoldBaseBottomContent( + child: QuantusButton.simple(label: l10n.redeemCancel, variant: ButtonVariant.secondary, onTap: _cancel), + ); + } + return ScaffoldBaseBottomContent( + child: QuantusButton.simple( + label: l10n.redeemClose, + onTap: () => Navigator.of(context).popUntil((route) => route.isFirst), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/send/encrypted_send_strategy.dart b/mobile-app/lib/v2/screens/send/encrypted_send_strategy.dart new file mode 100644 index 00000000..0a36dca8 --- /dev/null +++ b/mobile-app/lib/v2/screens/send/encrypted_send_strategy.dart @@ -0,0 +1,134 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; +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/v2/components/detail_summary_row.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +/// ZK-private transfer from an encrypted (wormhole) account. Coin selection +/// runs during fee estimation; submission hands the plan to the proving +/// progress screen via [SendNeedsProving]. +class EncryptedSendStrategy extends SendStrategy { + final Account account; + + const EncryptedSendStrategy({required this.account}); + + @override + String? sourceAccountId(WidgetRef ref) => account.accountId; + + @override + SendStrings strings(AppLocalizations l10n) => SendStrings( + flowTitle: l10n.sendTitle, + recipientSectionLabel: l10n.sendSelectRecipientSendTo, + amountRecipientCardLabel: l10n.sendInputAmountSendTo, + feeLabel: l10n.encryptedSendFeeLabel, + feeFetchFailedMessage: l10n.multisigProposeFeeFetchFailed, + reviewButtonLabel: l10n.sendLogicReviewSend, + reviewHeroLabel: l10n.sendReviewSending, + reviewConfirmLabel: l10n.sendReviewConfirm, + ); + + @override + ProviderListenable> get spendableBalanceProvider => + encryptedSpendableProvider(account.walletIndex); + + @override + bool extraBalancesLoading(WidgetRef ref) => false; + + /// The volume fee comes out of the consumed inputs, not on top of the + /// spendable amount ([spendableBalanceProvider] is already net of it). + @override + BigInt feeChargedToBalance(SendFee? fee) => BigInt.zero; + + @override + Future estimateFee(WidgetRef ref, {required String recipient, required BigInt amount}) async { + if (amount <= BigInt.zero) return const EncryptedFee(); + if (amount % wormholeScaleFactor != BigInt.zero) { + return const EncryptedFee(blocker: EncryptedSendBlocker.notQuantized); + } + final state = await ref.read(encryptedStateProvider(account.walletIndex).future); + try { + return EncryptedFee(plan: selectWormholeInputs(utxos: state.utxos, amountPlanck: amount)); + } on InsufficientEncryptedFunds { + return const EncryptedFee(blocker: EncryptedSendBlocker.insufficient); + } on BatchBelowMinimumExit { + return const EncryptedFee(blocker: EncryptedSendBlocker.belowBatchMinimum); + } + } + + @override + String? affordabilityError(WidgetRef ref, SendFee fee, AppLocalizations l10n) { + return switch ((fee as EncryptedFee).blocker) { + null => null, + EncryptedSendBlocker.notQuantized => l10n.encryptedSendAmountStep, + EncryptedSendBlocker.insufficient => l10n.sendLogicInsufficientBalance, + EncryptedSendBlocker.belowBatchMinimum => l10n.encryptedSendMinimum, + }; + } + + @override + List reviewRows( + BuildContext context, + WidgetRef ref, { + required String recipientAddress, + required BigInt amount, + required SendFee fee, + }) { + final l10n = ref.watch(l10nProvider); + final fmt = ref.watch(numberFormattingServiceProvider); + final feeAmount = fee.displayFee; + final valueStyle = context.themeText.transactionDetailRowLabel; + + String amt(BigInt v) => + l10n.commonAmountBalance(fmt.formatBalance(v, smartDecimals: AppConstants.decimals), AppConstants.tokenSymbol); + + return [ + const SizedBox(height: 7), + DetailSummaryRow.review(label: l10n.sendReviewTo, value: recipientAddress.trim(), valueStyle: valueStyle), + const SizedBox(height: 7), + DetailSummaryRow.review(label: l10n.sendReviewAmount, value: amt(amount), valueStyle: valueStyle), + const SizedBox(height: 7), + DetailSummaryRow.review(label: l10n.encryptedSendFeeLabel, value: amt(feeAmount), valueStyle: valueStyle), + const SizedBox(height: 7), + DetailSummaryRow.review(label: l10n.sendReviewYouPay, value: amt(amount + feeAmount), valueStyle: valueStyle), + const SizedBox(height: 7), + ]; + } + + @override + Future submit( + WidgetRef ref, { + required String recipientAddress, + required String recipientChecksum, + required BigInt amount, + required SendFee fee, + required bool isPayMode, + }) async { + final l10n = ref.read(l10nProvider); + final plan = (fee as EncryptedFee).plan; + if (plan == null) { + throw StateError('Encrypted send reached submit without a spend plan'); + } + + final authed = await LocalAuthService().authenticate(localizedReason: l10n.sendReviewAuthReason); + if (!authed) return SendFailed(l10n.sendReviewAuthRequired); + + return SendNeedsProving( + account: account, + plan: plan, + terminal: buildSentTerminalContent( + l10n, + ref.read(numberFormattingServiceProvider), + recipient: recipientAddress.trim(), + checksum: recipientChecksum, + amount: amount, + isPayMode: isPayMode, + ), + ); + } +} 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 a975941e..4181a83c 100644 --- a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -108,7 +108,7 @@ class RegularSendStrategy extends SendStrategy { final regularFee = fee as RegularFee; final recipient = recipientAddress.trim(); final account = (await SettingsService().getActiveRegularAccount())!; - final terminal = _terminal( + final terminal = buildSentTerminalContent( l10n, fmt, recipient: recipient, @@ -146,26 +146,4 @@ class RegularSendStrategy extends SendStrategy { return SendFailed(l10n.sendReviewSubmitFailed); } } - - SendTerminalContent _terminal( - AppLocalizations l10n, - NumberFormattingService fmt, { - required String recipient, - required String checksum, - required BigInt amount, - required bool isPayMode, - }) { - final n = fmt.formatBalance(amount, smartDecimals: 4); - return SendTerminalContent( - title: isPayMode ? l10n.sendPayTitle : l10n.sendTitle, - headline: isPayMode - ? l10n.sendTxSubmittedHeadlinePaid(n, AppConstants.tokenSymbol) - : l10n.sendTxSubmittedHeadlineSent(n, AppConstants.tokenSymbol), - subline: l10n.sendTxSubmittedOnItsWay, - recipientAddress: recipient, - recipientChecksum: checksum, - doneLabel: l10n.sendTxSubmittedDone, - topSpacing: 70, - ); - } } 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 45007f51..8b6c3402 100644 --- a/mobile-app/lib/v2/screens/send/review_send_screen.dart +++ b/mobile-app/lib/v2/screens/send/review_send_screen.dart @@ -11,6 +11,7 @@ import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/components/split_card.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; +import 'package:resonance_network_wallet/v2/screens/send/encrypted_send_progress_screen.dart'; import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_screen.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_terminal_screen.dart'; @@ -87,6 +88,19 @@ class _ReviewSendScreenState extends ConsumerState { ), ), ); + case SendNeedsProving(:final account, :final plan, :final terminal): + setState(() => _submitting = false); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EncryptedSendProgressScreen( + account: account, + plan: plan, + recipientAddress: widget.recipientAddress.trim(), + terminal: terminal, + ), + ), + ); case SendFailed(:final message): setState(() { _submitting = false; diff --git a/mobile-app/lib/v2/screens/send/send_strategy.dart b/mobile-app/lib/v2/screens/send/send_strategy.dart index 82f63097..d2dae855 100644 --- a/mobile-app/lib/v2/screens/send/send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/send_strategy.dart @@ -56,6 +56,22 @@ class ProposeFee extends SendFee { BigInt get displayFee => breakdown.memberCost; } +/// Why an encrypted send can't be built for the entered amount. +enum EncryptedSendBlocker { notQuantized, insufficient, belowBatchMinimum } + +/// Fee for an encrypted (wormhole) send: the in-circuit volume fee plus +/// quantization dust, carried with the coin-selection [plan] that produced it. +/// When the amount can't be planned, [blocker] says why. +class EncryptedFee extends SendFee { + final WormholeSpendPlan? plan; + final EncryptedSendBlocker? blocker; + + const EncryptedFee({this.plan, this.blocker}); + + @override + BigInt get displayFee => plan?.feePlanck ?? BigInt.zero; +} + /// Content for the shared terminal (success) screen. All strings are resolved /// up front so it can be built without a [BuildContext]. class SendTerminalContent { @@ -128,6 +144,16 @@ class SendNeedsHardwareSignature extends SendOutcome { }); } +/// Encrypted send authenticated and planned: hand off to the proving progress +/// screen, which generates the ZK proofs, submits and then shows [terminal]. +class SendNeedsProving extends SendOutcome { + final Account account; + final WormholeSpendPlan plan; + final SendTerminalContent terminal; + + const SendNeedsProving({required this.account, required this.plan, required this.terminal}); +} + /// Submission failed or was not authenticated; show [message] inline. class SendFailed extends SendOutcome { final String message; @@ -135,6 +161,30 @@ class SendFailed extends SendOutcome { const SendFailed(this.message); } +/// Terminal content for a completed transfer, shared by the flows that send a +/// fixed amount to one recipient (regular and encrypted sends). +SendTerminalContent buildSentTerminalContent( + AppLocalizations l10n, + NumberFormattingService fmt, { + required String recipient, + required String checksum, + required BigInt amount, + required bool isPayMode, +}) { + final n = fmt.formatBalance(amount, smartDecimals: 4); + return SendTerminalContent( + title: isPayMode ? l10n.sendPayTitle : l10n.sendTitle, + headline: isPayMode + ? l10n.sendTxSubmittedHeadlinePaid(n, AppConstants.tokenSymbol) + : l10n.sendTxSubmittedHeadlineSent(n, AppConstants.tokenSymbol), + subline: l10n.sendTxSubmittedOnItsWay, + recipientAddress: recipient, + recipientChecksum: checksum, + doneLabel: l10n.sendTxSubmittedDone, + topSpacing: 70, + ); +} + /// Encapsulates everything that differs between the send and multisig-propose /// flows so the recipient, amount, review and terminal screens can be shared. abstract class SendStrategy { diff --git a/mobile-app/lib/v2/screens/settings/redeem_progress_screen.dart b/mobile-app/lib/v2/screens/settings/redeem_progress_screen.dart index b4fef52d..78cf089c 100644 --- a/mobile-app/lib/v2/screens/settings/redeem_progress_screen.dart +++ b/mobile-app/lib/v2/screens/settings/redeem_progress_screen.dart @@ -9,6 +9,7 @@ import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; +import 'package:resonance_network_wallet/v2/components/wormhole_progress_steps.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; @@ -23,7 +24,7 @@ class RedeemProgressScreen extends ConsumerStatefulWidget { } class _RedeemProgressScreenState extends ConsumerState { - WormholeClaimService? _claimService; + WormholeSendService? _claimService; bool _running = true; bool _done = false; bool _cancelled = false; @@ -32,10 +33,6 @@ class _RedeemProgressScreenState extends ConsumerState { final Map _stepProgress = {}; ClaimResult? _result; - /// Highest step that has reported progress (steps 5 & 6 interleave, so the - /// linear cursor alone can't tell us which earlier steps are done). - int get _maxStartedStep => _stepProgress.keys.fold(_currentStep, (m, k) => k > m ? k : m); - @override void initState() { super.initState(); @@ -51,7 +48,7 @@ class _RedeemProgressScreenState extends ConsumerState { if (keyPair.secretHex.isEmpty) throw StateError('Wormhole key pair not available'); final circuitDir = await CircuitManager.getCircuitDirectory(); - _claimService = WormholeClaimService(); + _claimService = WormholeSendService(); final result = await _claimService!.claimRewards( wormholeAddress: keyPair.address, @@ -159,176 +156,21 @@ class _RedeemProgressScreenState extends ConsumerState { } Widget _buildSteps(AppColorsV2 colors, AppTextTheme text, AppLocalizations l10n) { - final steps = [ - (1, l10n.redeemStepCircuits), - (2, l10n.redeemStepTransfers), - (3, l10n.redeemStepNullifiers), - (4, l10n.redeemStepCheckNullifiers), - (5, l10n.redeemStepProofs), - (6, l10n.redeemStepAggregate), - ]; - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: colors.sheetBackground, borderRadius: BorderRadius.circular(12)), - child: Column( - children: [ - for (int i = 0; i < steps.length; i++) ...[ - _buildStepRow(steps[i].$1, steps[i].$2, colors, text, l10n), - if (i < steps.length - 1) _buildConnector(steps[i].$1, colors), - ], - ], - ), - ); - } - - Widget _buildStepRow(int step, String title, AppColorsV2 colors, AppTextTheme text, AppLocalizations l10n) { - final progress = _stepProgress[step]; - final hasError = _errorMessage != null; - final isError = !_done && hasError && _currentStep == step; - - // Steps 5 (generating proofs) and 6 (aggregating & submitting) run - // interleaved batch-by-batch, so their state is driven by their own - // reported progress rather than the linear current-step cursor: once step 6 - // is reported it stays engaged (showing batches submitted) even while step 5 - // keeps advancing for the remaining batches. - final reachedTotal = progress != null && progress.total != null && progress.completed >= progress.total!; - - final bool isCompleted; - final bool isActive; - if (_done) { - isCompleted = true; - isActive = false; - } else if (step >= 5) { - isCompleted = reachedTotal; - isActive = progress != null && !reachedTotal && !_cancelled && !hasError; - } else { - isCompleted = _maxStartedStep > step; - isActive = !isCompleted && !_cancelled && !hasError && _currentStep == step; - } - - final Widget icon; - if (isCompleted) { - icon = Container( - width: 28, - height: 28, - decoration: BoxDecoration(color: colors.success, shape: BoxShape.circle), - child: const Icon(Icons.check, color: Colors.white, size: 16), - ); - } else if (isError) { - icon = Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: colors.textError.withValues(alpha: 0.15), - shape: BoxShape.circle, - border: Border.all(color: colors.textError, width: 2), - ), - child: Icon(Icons.close, color: colors.textError, size: 14), - ); - } else if (isActive) { - icon = Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: colors.success.withValues(alpha: 0.12), - shape: BoxShape.circle, - border: Border.all(color: colors.success, width: 2), - ), - child: Padding( - padding: const EdgeInsets.all(5), - child: CircularProgressIndicator(strokeWidth: 2, color: colors.success), - ), - ); - } else { - icon = Container( - width: 28, - height: 28, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: colors.borderButton.withValues(alpha: 0.3), width: 1.5), - ), - child: Center( - child: Text('$step', style: text.detail?.copyWith(color: colors.textTertiary)), - ), - ); - } - - final titleColor = isCompleted - ? colors.success - : isActive - ? colors.textPrimary - : isError - ? colors.textError - : colors.textTertiary; - - String progressText = ''; - if (progress != null && (isActive || isCompleted)) { - if (step == 2) { - progressText = l10n.redeemFetchedCount(progress.completed); - } else if (progress.total != null) { - progressText = '${progress.completed} / ${progress.total}'; - } - } - - double? progressFraction; - if (isActive && progress != null && progress.total != null && progress.total! > 0) { - progressFraction = (progress.completed / progress.total!).clamp(0.0, 1.0); - } - - return Column( - children: [ - Row( - children: [ - icon, - const SizedBox(width: 14), - Expanded( - child: Text(title, style: text.smallParagraph?.copyWith(color: titleColor)), - ), - if (progressText.isNotEmpty) - Text( - progressText, - style: text.detail?.copyWith( - color: isCompleted ? colors.success : colors.textPrimary, - fontFamily: AppTextTheme.fontFamilySecondary, - ), - ), - ], - ), - if (progressFraction != null) ...[ - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 42), - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - value: progressFraction, - backgroundColor: colors.borderButton.withValues(alpha: 0.2), - valueColor: AlwaysStoppedAnimation(colors.success), - minHeight: 4, - ), - ), - ), - ], + return WormholeProgressSteps( + steps: [ + (1, l10n.redeemStepCircuits), + (2, l10n.redeemStepTransfers), + (3, l10n.redeemStepNullifiers), + (4, l10n.redeemStepCheckNullifiers), + (5, l10n.redeemStepProofs), + (6, l10n.redeemStepAggregate), ], - ); - } - - Widget _buildConnector(int afterStep, AppColorsV2 colors) { - final isCompleted = _done ? true : _currentStep > afterStep; - return Padding( - padding: const EdgeInsets.only(left: 13), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: 2, - height: 20, - decoration: BoxDecoration( - color: isCompleted ? colors.success.withValues(alpha: 0.4) : colors.borderButton.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(1), - ), - ), - ), + stepProgress: _stepProgress, + currentStep: _currentStep, + done: _done, + cancelled: _cancelled, + hasError: _errorMessage != null, + progressLabelOverride: (step, progress) => step == 2 ? l10n.redeemFetchedCount(progress.completed) : null, ); } diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 4e625177..1caa40f5 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -69,7 +69,9 @@ export 'src/services/taskmaster_service.dart'; export 'src/services/senoti_service.dart'; export 'src/services/circuit_manager.dart'; export 'src/services/wormhole_address_manager.dart'; -export 'src/services/wormhole_claim_service.dart'; +export 'src/services/encrypted_account_service.dart'; +export 'src/services/wormhole_coin_selection.dart'; +export 'src/services/wormhole_send_service.dart'; export 'src/services/wormhole_utxo_service.dart'; export 'src/extensions/account_extension.dart'; export 'src/quantus_signing_payload.dart'; diff --git a/quantus_sdk/lib/src/rust/api/wormhole.dart b/quantus_sdk/lib/src/rust/api/wormhole.dart index 11714503..9be7d79b 100644 --- a/quantus_sdk/lib/src/rust/api/wormhole.dart +++ b/quantus_sdk/lib/src/rust/api/wormhole.dart @@ -89,6 +89,8 @@ class ProofInput { final Uint8List positions; final Uint8List exitAccount1; final int outputAmount1; + final Uint8List exitAccount2; + final int outputAmount2; final int volumeFeeBps; final int assetId; @@ -108,6 +110,8 @@ class ProofInput { required this.positions, required this.exitAccount1, required this.outputAmount1, + required this.exitAccount2, + required this.outputAmount2, required this.volumeFeeBps, required this.assetId, }); @@ -129,6 +133,8 @@ class ProofInput { positions.hashCode ^ exitAccount1.hashCode ^ outputAmount1.hashCode ^ + exitAccount2.hashCode ^ + outputAmount2.hashCode ^ volumeFeeBps.hashCode ^ assetId.hashCode; @@ -152,6 +158,8 @@ class ProofInput { positions == other.positions && exitAccount1 == other.exitAccount1 && outputAmount1 == other.outputAmount1 && + exitAccount2 == other.exitAccount2 && + outputAmount2 == other.outputAmount2 && volumeFeeBps == other.volumeFeeBps && assetId == other.assetId; } diff --git a/quantus_sdk/lib/src/rust/frb_generated.dart b/quantus_sdk/lib/src/rust/frb_generated.dart index bfbedc33..7557a8f3 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.dart @@ -999,7 +999,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ProofInput dco_decode_proof_input(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 17) throw Exception('unexpected arr length: expect 17 but see ${arr.length}'); + if (arr.length != 19) throw Exception('unexpected arr length: expect 19 but see ${arr.length}'); return ProofInput( secret: dco_decode_list_prim_u_8_strict(arr[0]), transferCount: dco_decode_u_64(arr[1]), @@ -1016,8 +1016,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { positions: dco_decode_list_prim_u_8_strict(arr[12]), exitAccount1: dco_decode_list_prim_u_8_strict(arr[13]), outputAmount1: dco_decode_u_32(arr[14]), - volumeFeeBps: dco_decode_u_32(arr[15]), - assetId: dco_decode_u_32(arr[16]), + exitAccount2: dco_decode_list_prim_u_8_strict(arr[15]), + outputAmount2: dco_decode_u_32(arr[16]), + volumeFeeBps: dco_decode_u_32(arr[17]), + assetId: dco_decode_u_32(arr[18]), ); } @@ -1210,6 +1212,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_positions = sse_decode_list_prim_u_8_strict(deserializer); var var_exitAccount1 = sse_decode_list_prim_u_8_strict(deserializer); var var_outputAmount1 = sse_decode_u_32(deserializer); + var var_exitAccount2 = sse_decode_list_prim_u_8_strict(deserializer); + var var_outputAmount2 = sse_decode_u_32(deserializer); var var_volumeFeeBps = sse_decode_u_32(deserializer); var var_assetId = sse_decode_u_32(deserializer); return ProofInput( @@ -1228,6 +1232,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { positions: var_positions, exitAccount1: var_exitAccount1, outputAmount1: var_outputAmount1, + exitAccount2: var_exitAccount2, + outputAmount2: var_outputAmount2, volumeFeeBps: var_volumeFeeBps, assetId: var_assetId, ); @@ -1414,6 +1420,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_strict(self.positions, serializer); sse_encode_list_prim_u_8_strict(self.exitAccount1, serializer); sse_encode_u_32(self.outputAmount1, serializer); + sse_encode_list_prim_u_8_strict(self.exitAccount2, serializer); + sse_encode_u_32(self.outputAmount2, serializer); sse_encode_u_32(self.volumeFeeBps, serializer); sse_encode_u_32(self.assetId, serializer); } diff --git a/quantus_sdk/lib/src/services/account_discovery_service.dart b/quantus_sdk/lib/src/services/account_discovery_service.dart index 9a3c4fca..d3679a36 100644 --- a/quantus_sdk/lib/src/services/account_discovery_service.dart +++ b/quantus_sdk/lib/src/services/account_discovery_service.dart @@ -23,24 +23,35 @@ class AccountDiscoveryService { required int walletIndex, int gapLimit = 20, }) async { - final discovered = []; + final addressByIndex = {}; + final used = await discoverUsedIndices( + addressAt: (i) => addressByIndex[i] ??= _hdWalletService.keyPairAtIndex(mnemonic, i).ss58Address, + gapLimit: gapLimit, + ); + return [ + for (final i in used.toList()..sort()) + Account(walletIndex: walletIndex, index: i, name: 'Account ${i + 1}', accountId: addressByIndex[i]!), + ]; + } + + /// Gap-limit scan over an arbitrary address sequence: derives addresses via + /// [addressAt] in batches and returns the indices that exist on-chain, + /// stopping once [gapLimit] consecutive indices are unused. + Future> discoverUsedIndices({ + required String Function(int index) addressAt, + int gapLimit = 20, + }) async { + final used = {}; var consecutiveMissing = 0; var index = 0; while (consecutiveMissing < gapLimit) { - final batch = []; - for (var i = index; i < index + gapLimit; i++) { - final keyPair = _hdWalletService.keyPairAtIndex(mnemonic, i); - batch.add( - Account(walletIndex: walletIndex, index: i, name: 'Account ${i + 1}', accountId: keyPair.ss58Address), - ); - } - - final existingIds = await _findExistingAccountIds(batch.map((a) => a.accountId).toList()); + final batch = {for (var i = index; i < index + gapLimit; i++) i: addressAt(i)}; + final existingIds = await _findExistingAccountIds(batch.values.toList()); - for (final account in batch) { - if (existingIds.contains(account.accountId)) { - discovered.add(account); + for (final entry in batch.entries) { + if (existingIds.contains(entry.value)) { + used.add(entry.key); consecutiveMissing = 0; } else { consecutiveMissing++; @@ -51,7 +62,7 @@ class AccountDiscoveryService { index += gapLimit; } - return discovered; + return used; } Future> _findExistingAccountIds(List accountIds) async { diff --git a/quantus_sdk/lib/src/services/encrypted_account_service.dart b/quantus_sdk/lib/src/services/encrypted_account_service.dart new file mode 100644 index 00000000..0e81b199 --- /dev/null +++ b/quantus_sdk/lib/src/services/encrypted_account_service.dart @@ -0,0 +1,291 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:quantus_sdk/src/services/account_discovery_service.dart'; +import 'package:quantus_sdk/src/services/hd_wallet_service.dart'; +import 'package:quantus_sdk/src/services/substrate_service.dart' show getAccountId32; +import 'package:quantus_sdk/src/services/wormhole_address_manager.dart' show MnemonicGetter; +import 'package:quantus_sdk/src/services/wormhole_coin_selection.dart'; +import 'package:quantus_sdk/src/services/wormhole_send_service.dart'; +import 'package:quantus_sdk/src/services/wormhole_utxo_service.dart'; + +/// Snapshot of an encrypted account: spendable UTXOs across all discovered +/// wormhole addresses, plus change that has been submitted but not yet indexed. +class EncryptedAccountState { + final List utxos; + final BigInt pendingChangePlanck; + + /// Next unused address index — shown as the receive address and allocated + /// as the change address of the next send. + final int nextIndex; + + const EncryptedAccountState({required this.utxos, required this.pendingChangePlanck, required this.nextIndex}); + + BigInt get balance => utxos.fold(BigInt.zero, (sum, u) => sum + u.amount) + pendingChangePlanck; + + /// Max amount sendable right now (post volume fee, excluding pending change). + BigInt get maxSendable => wormholeMaxSendable(utxos); +} + +/// An encrypted account: one linear HD sequence of wormhole addresses +/// (`m/44'/189189189'/0'/0'/n'`) treated as a single pool of funds. +/// +/// Receive and change share the sequence — the next unused index is shown for +/// receiving and consumed as the fresh change address of the next send, so a +/// gap-limit scan (same algorithm as transparent accounts) rediscovers all +/// funds from the mnemonic alone. Spent inputs are excluded via on-chain +/// nullifiers; in-flight sends are bridged by locally persisted pending-spend +/// records until the indexer catches up. +class EncryptedAccountService { + static const Duration _pendingSpendExpiry = Duration(hours: 1); + + final int walletIndex; + final MnemonicGetter _getMnemonic; + final HdWalletService _hdWalletService; + final WormholeUtxoService _utxoService; + final AccountDiscoveryService _discoveryService; + final WormholeSendService _sendService; + + final Map _keyPairs = {}; + Future _stateLock = Future.value(); + + EncryptedAccountService({ + required this.walletIndex, + required MnemonicGetter getMnemonic, + HdWalletService? hdWalletService, + WormholeUtxoService? utxoService, + AccountDiscoveryService? discoveryService, + WormholeSendService? sendService, + }) : _getMnemonic = getMnemonic, + _hdWalletService = hdWalletService ?? HdWalletService(), + _utxoService = utxoService ?? WormholeUtxoService(), + _discoveryService = discoveryService ?? AccountDiscoveryService(hdWalletService ?? HdWalletService()), + _sendService = sendService ?? WormholeSendService(); + + // ignore: avoid_print + static void _log(String msg) => print('[EncryptedAccount] $msg'); + + Future _mnemonic() async { + final mnemonic = await _getMnemonic(); + if (mnemonic == null) throw StateError('No mnemonic for wallet $walletIndex'); + return mnemonic; + } + + WormholeKeyPair _keyPairAtSync(String mnemonic, int index) => + _keyPairs[index] ??= _hdWalletService.deriveWormholeKeyPair(mnemonic: mnemonic, index: index); + + Future keyPairAt(int index) async => _keyPairAtSync(await _mnemonic(), index); + + /// The address to show on the Receive screen: next unused index from the + /// last persisted state (cheap — no network). [load] keeps it current. + Future receiveKeyPair() async => keyPairAt((await _readState()).nextIndex); + + /// Discovers used addresses, fetches their unspent UTXOs, reconciles + /// pending-spend records and persists the refreshed state. + Future load({WormholeProgressCallback? onProgress, IsCancelledCallback? isCancelled}) async { + final sw = Stopwatch()..start(); + final mnemonic = await _mnemonic(); + + final usedIndices = await _discoveryService.discoverUsedIndices( + addressAt: (i) => _keyPairAtSync(mnemonic, i).address, + ); + _log('Discovery: used indices $usedIndices'); + + final scanIndices = {0, ...usedIndices}.toList()..sort(); + final addresses = [ + for (final i in scanIndices) + WormholeAddressInfo( + index: i, + address: _keyPairAtSync(mnemonic, i).address, + secretHex: _keyPairAtSync(mnemonic, i).secretHex, + ), + ]; + + final utxos = await _utxoService.getUnspentUtxos( + addresses: addresses, + onProgress: onProgress, + isCancelled: isCancelled, + ); + + final unspentNullifiers = utxos.map((u) => u.nullifierHex).toSet(); + final usedAddresses = {for (final i in usedIndices) _keyPairAtSync(mnemonic, i).address}; + + final discoveredNext = usedIndices.isEmpty ? 0 : (usedIndices.reduce((a, b) => a > b ? a : b) + 1); + final state = await _mutateState((s) { + final kept = []; + for (final record in s.pendingSpends) { + final allSpent = record.nullifiers.every((n) => !unspentNullifiers.contains(n)); + final changeArrived = record.changeAddress == null || usedAddresses.contains(record.changeAddress); + final age = DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(record.createdAtMs)); + if (allSpent && changeArrived) { + _log('Pending spend confirmed on-chain, pruning (${record.nullifiers.length} nullifiers)'); + } else if (age > _pendingSpendExpiry) { + _log('ERROR: pending spend expired unconfirmed after $age, dropping: ${record.toJson()}'); + } else { + kept.add(record); + } + } + return _FileState( + nextIndex: s.nextIndex > discoveredNext ? s.nextIndex : discoveredNext, + pendingSpends: kept, + ); + }); + + final pendingNullifiers = state.pendingSpends.expand((r) => r.nullifiers).toSet(); + final spendable = utxos.where((u) => !pendingNullifiers.contains(u.nullifierHex)).toList(); + final pendingChange = state.pendingSpends.fold(BigInt.zero, (sum, r) => sum + r.changeAmountPlanck); + + _log( + 'load DONE: ${spendable.length} spendable UTXOs, pendingChange=$pendingChange, ' + 'nextIndex=${state.nextIndex} (${sw.elapsedMilliseconds}ms)', + ); + return EncryptedAccountState( + utxos: spendable, + pendingChangePlanck: pendingChange, + nextIndex: state.nextIndex, + ); + } + + /// Proves and submits a [plan] (from [selectWormholeInputs]) paying + /// [recipientAddress], with change to a fresh address at the next unused + /// index. Per submitted batch, the spent nullifiers (and change, once its + /// batch lands) are persisted so balances stay exact even mid-flight or + /// after a partial failure. + Future send({ + required WormholeSpendPlan plan, + required String recipientAddress, + required String circuitBinsDir, + required ClaimProgressCallback onProgress, + String? rpcUrl, + }) async { + final changeIndex = (await _readState()).nextIndex; + final changeKeyPair = await keyPairAt(changeIndex); + final recipientBytes = Uint8List.fromList(getAccountId32(recipientAddress)); + final changeBytes = Uint8List.fromList(getAccountId32(changeKeyPair.address)); + _log( + 'send: ${plan.inputCount} inputs in ${plan.batches.length} batches, ' + 'amount=${plan.amountPlanck}, change=${plan.changePlanck} -> index $changeIndex', + ); + + final batches = [ + for (final batch in plan.batches) + [ + for (final a in batch) + WormholeLeafSpend( + transfer: a.utxo.transfer, + secret: Uint8List.fromList(hex.decode(a.utxo.owner.secretHex.replaceFirst('0x', ''))), + exitAccount1: recipientBytes, + outputAmount1: a.recipientScaled, + exitAccount2: a.changeScaled > 0 ? changeBytes : null, + outputAmount2: a.changeScaled, + ), + ], + ]; + + return _sendService.sendSpends( + batches: batches, + circuitBinsDir: circuitBinsDir, + onProgress: onProgress, + rpcUrl: rpcUrl, + onBatchSubmitted: (batchIndex, nullifiers) async { + final changeScaled = plan.batches[batchIndex].fold(0, (sum, a) => sum + a.changeScaled); + final hasChange = changeScaled > 0; + await _mutateState( + (s) => _FileState( + nextIndex: hasChange && changeIndex >= s.nextIndex ? changeIndex + 1 : s.nextIndex, + pendingSpends: [ + ...s.pendingSpends, + PendingSpend( + nullifiers: nullifiers, + changeAddress: hasChange ? changeKeyPair.address : null, + changeAmountPlanck: wormholePlanckFromScaled(changeScaled), + createdAtMs: DateTime.now().millisecondsSinceEpoch, + ), + ], + ), + ); + _log('Batch $batchIndex recorded: ${nullifiers.length} nullifiers spent, change=$hasChange'); + }, + ); + } + + void cancel() => _sendService.cancel(); + + // --- Persistent state --- + + Future _stateFile() async { + final dir = await getApplicationSupportDirectory(); + return File('${dir.path}/encrypted_account_w$walletIndex.json'); + } + + Future<_FileState> _readState() async { + final file = await _stateFile(); + if (!await file.exists()) return const _FileState(nextIndex: 0, pendingSpends: []); + return _FileState.fromJson(jsonDecode(await file.readAsString()) as Map); + } + + Future<_FileState> _mutateState(_FileState Function(_FileState) fn) { + final result = _stateLock.then((_) async { + final next = fn(await _readState()); + final file = await _stateFile(); + await file.writeAsString(jsonEncode(next.toJson())); + return next; + }); + _stateLock = result.then((_) {}, onError: (_) {}); + return result; + } +} + +/// A submitted-but-not-yet-indexed spend: its input nullifiers are excluded +/// from the spendable set and its change is counted as pending balance until +/// the indexer confirms both. +class PendingSpend { + final List nullifiers; + final String? changeAddress; + final BigInt changeAmountPlanck; + final int createdAtMs; + + const PendingSpend({ + required this.nullifiers, + required this.changeAddress, + required this.changeAmountPlanck, + required this.createdAtMs, + }); + + factory PendingSpend.fromJson(Map json) => PendingSpend( + nullifiers: (json['nullifiers'] as List).cast(), + changeAddress: json['changeAddress'] as String?, + changeAmountPlanck: BigInt.parse(json['changeAmountPlanck'] as String), + createdAtMs: json['createdAtMs'] as int, + ); + + Map toJson() => { + 'nullifiers': nullifiers, + 'changeAddress': changeAddress, + 'changeAmountPlanck': changeAmountPlanck.toString(), + 'createdAtMs': createdAtMs, + }; +} + +class _FileState { + final int nextIndex; + final List pendingSpends; + + const _FileState({required this.nextIndex, required this.pendingSpends}); + + factory _FileState.fromJson(Map json) => _FileState( + nextIndex: json['nextIndex'] as int, + pendingSpends: (json['pendingSpends'] as List) + .map((e) => PendingSpend.fromJson(e as Map)) + .toList(), + ); + + Map toJson() => { + 'nextIndex': nextIndex, + 'pendingSpends': pendingSpends.map((e) => e.toJson()).toList(), + }; +} diff --git a/quantus_sdk/lib/src/services/wormhole_coin_selection.dart b/quantus_sdk/lib/src/services/wormhole_coin_selection.dart new file mode 100644 index 00000000..201b1515 --- /dev/null +++ b/quantus_sdk/lib/src/services/wormhole_coin_selection.dart @@ -0,0 +1,140 @@ +import 'package:quantus_sdk/src/services/wormhole_utxo_service.dart'; + +/// Wormhole circuit economics, shared by coin selection and the send service. +/// Values must match the chain runtime and the Rust wormhole API. +const int wormholeVolumeFeeBps = 10; + +/// Scaled-down → planck multiplier; matches `SCALE_DOWN_FACTOR` in the Rust +/// wormhole API. Proofs commit to amounts in scaled-down units (0.01 QUAN) and +/// the chain dispatches `outputAmount * scaleFactor` planck. +final BigInt wormholeScaleFactor = BigInt.from(10000000000); + +/// Chain's `MinimumTransferAmount` (0.1 QUAN) in scaled units, enforced per +/// aggregated batch on the total exit amount. +const int wormholeMinBatchExitScaled = 10; + +int wormholeScaledFromPlanck(BigInt planck) => (planck ~/ wormholeScaleFactor).toInt(); + +BigInt wormholePlanckFromScaled(int scaled) => BigInt.from(scaled) * wormholeScaleFactor; + +/// Max total output the circuit allows for a consumed input: +/// `(out1 + out2) * 10000 <= input * (10000 - feeBps)`. +int wormholeNetScaled(int inputScaled) => inputScaled * (10000 - wormholeVolumeFeeBps) ~/ 10000; + +/// One leaf proof's spend: consumes [utxo] entirely, pays [recipientScaled] to +/// the recipient (exit slot 1) and [changeScaled] back to the sender's fresh +/// change address (exit slot 2, zero when unused). +class WormholeLeafAssignment { + final WormholeUtxo utxo; + final int recipientScaled; + final int changeScaled; + + const WormholeLeafAssignment({required this.utxo, required this.recipientScaled, required this.changeScaled}); + + int get exitScaled => recipientScaled + changeScaled; +} + +class WormholeSpendPlan { + /// Leaf assignments grouped into aggregation batches (each one extrinsic). + final List> batches; + final BigInt amountPlanck; + final BigInt changePlanck; + + /// Everything consumed that neither the recipient nor the change receives: + /// the 10 bps volume fee plus sub-0.01-QUAN quantization dust. + final BigInt feePlanck; + + const WormholeSpendPlan({ + required this.batches, + required this.amountPlanck, + required this.changePlanck, + required this.feePlanck, + }); + + int get inputCount => batches.fold(0, (sum, b) => sum + b.length); +} + +sealed class WormholeSelectionException implements Exception { + final String message; + const WormholeSelectionException(this.message); + @override + String toString() => message; +} + +class InsufficientEncryptedFunds extends WormholeSelectionException { + final BigInt maxSendablePlanck; + InsufficientEncryptedFunds(this.maxSendablePlanck) + : super('Insufficient encrypted funds: max sendable is $maxSendablePlanck planck'); +} + +/// An aggregation batch's total exit would fall below the chain's minimum +/// (0.1 QUAN); the amounts are too fragmented to send this way. +class BatchBelowMinimumExit extends WormholeSelectionException { + BatchBelowMinimumExit(int totalScaled) + : super('Batch exit total $totalScaled is below the chain minimum of $wormholeMinBatchExitScaled (0.1 QUAN)'); +} + +/// Maximum amount spendable from [utxos] (sum of per-input nets after the +/// volume fee), in planck. +BigInt wormholeMaxSendable(List utxos) { + final totalScaled = utxos.fold(0, (sum, u) => sum + wormholeNetScaled(wormholeScaledFromPlanck(u.amount))); + return wormholePlanckFromScaled(totalScaled); +} + +/// Selects inputs to send exactly [amountPlanck] (a multiple of 0.01 QUAN) to +/// the recipient, largest-first. Every leaf pays its full net to the recipient +/// except the last, which splits between the recipient remainder and change. +/// Leaves are distributed round-robin (largest exits first) across the minimum +/// number of 7-proof batches so each batch clears the chain's minimum exit. +WormholeSpendPlan selectWormholeInputs({ + required List utxos, + required BigInt amountPlanck, + int maxProofsPerBatch = 7, +}) { + if (amountPlanck <= BigInt.zero) { + throw ArgumentError('amountPlanck must be positive, got $amountPlanck'); + } + if (amountPlanck % wormholeScaleFactor != BigInt.zero) { + throw ArgumentError('amountPlanck must be a multiple of 0.01 QUAN, got $amountPlanck'); + } + final targetScaled = wormholeScaledFromPlanck(amountPlanck); + + final candidates = utxos.where((u) => wormholeNetScaled(wormholeScaledFromPlanck(u.amount)) > 0).toList() + ..sort((a, b) => b.amount.compareTo(a.amount)); + final maxSendable = wormholeMaxSendable(candidates); + if (wormholePlanckFromScaled(targetScaled) > maxSendable) { + throw InsufficientEncryptedFunds(maxSendable); + } + + final assignments = []; + var remaining = targetScaled; + var consumedPlanck = BigInt.zero; + for (final utxo in candidates) { + final net = wormholeNetScaled(wormholeScaledFromPlanck(utxo.amount)); + final pay = net < remaining ? net : remaining; + assignments.add(WormholeLeafAssignment(utxo: utxo, recipientScaled: pay, changeScaled: net - pay)); + consumedPlanck += utxo.amount; + remaining -= pay; + if (remaining == 0) break; + } + + final numBatches = (assignments.length + maxProofsPerBatch - 1) ~/ maxProofsPerBatch; + final byExitDesc = [...assignments]..sort((a, b) => b.exitScaled.compareTo(a.exitScaled)); + final batches = List.generate(numBatches, (_) => []); + for (var i = 0; i < byExitDesc.length; i++) { + batches[i % numBatches].add(byExitDesc[i]); + } + for (final batch in batches) { + final totalScaled = batch.fold(0, (sum, a) => sum + a.exitScaled); + if (totalScaled < wormholeMinBatchExitScaled) throw BatchBelowMinimumExit(totalScaled); + } + + final changeScaled = assignments.fold(0, (sum, a) => sum + a.changeScaled); + final changePlanck = wormholePlanckFromScaled(changeScaled); + return WormholeSpendPlan( + batches: batches, + amountPlanck: amountPlanck, + changePlanck: changePlanck, + feePlanck: consumedPlanck - amountPlanck - changePlanck, + ); +} diff --git a/quantus_sdk/lib/src/services/wormhole_claim_service.dart b/quantus_sdk/lib/src/services/wormhole_send_service.dart similarity index 68% rename from quantus_sdk/lib/src/services/wormhole_claim_service.dart rename to quantus_sdk/lib/src/services/wormhole_send_service.dart index 4dbca269..09b8aefe 100644 --- a/quantus_sdk/lib/src/services/wormhole_claim_service.dart +++ b/quantus_sdk/lib/src/services/wormhole_send_service.dart @@ -9,6 +9,7 @@ import 'package:quantus_sdk/generated/planck/pallets/wormhole.dart' as wormhole_ import 'package:quantus_sdk/src/rust/api/wormhole.dart' as wormhole_ffi; import 'package:quantus_sdk/src/services/network/redundant_endpoint.dart'; import 'package:quantus_sdk/src/services/substrate_service.dart' show getAccountId32; +import 'package:quantus_sdk/src/services/wormhole_coin_selection.dart'; import 'package:quantus_sdk/src/services/wormhole_utxo_service.dart'; class ClaimProgressItem { @@ -42,21 +43,40 @@ class ClaimCancelled implements Exception { String toString() => 'Claim cancelled by user'; } -/// Claims unspent wormhole transfers by generating one ZK leaf proof per -/// transfer, aggregating them into 7-proof batches (the chain's aggregation -/// arity; short batches are padded with `dummy_proof.bin` inside the -/// aggregator) and submitting each aggregate as an unsigned extrinsic. +/// One leaf proof to generate: consumes [transfer] (owned by [secret]) and +/// exits [outputAmount1] to [exitAccount1] plus optionally [outputAmount2] to +/// [exitAccount2] (change). Amounts are in scaled-down units. +class WormholeLeafSpend { + final WormholeTransfer transfer; + final Uint8List secret; + final Uint8List exitAccount1; + final int outputAmount1; + final Uint8List? exitAccount2; + final int outputAmount2; + + const WormholeLeafSpend({ + required this.transfer, + required this.secret, + required this.exitAccount1, + required this.outputAmount1, + this.exitAccount2, + this.outputAmount2 = 0, + }); +} + +/// Spends wormhole transfers by generating one ZK leaf proof per transfer, +/// aggregating them into 7-proof batches (the chain's aggregation arity; short +/// batches are padded with `dummy_proof.bin` inside the aggregator) and +/// submitting each aggregate as an unsigned extrinsic. +/// +/// [claimRewards] is the mining-rewards flow: it discovers unspent transfers +/// for one address and pays everything to a single destination. [sendSpends] +/// takes explicit per-leaf output assignments (recipient + change) prepared by +/// coin selection, for encrypted-account sends. /// /// Shared between the miner app (talks to a local node via [rpcUrl]) and the /// mobile wallet (omits [rpcUrl] and uses the redundant remote endpoints). -class WormholeClaimService { - static const int _volumeFeeBps = 10; - - /// Scaled-down → planck multiplier; matches `SCALE_DOWN_FACTOR` in the Rust - /// wormhole API. The proof commits to amounts in scaled-down units, and the - /// chain dispatches `outputAmount * scaleDownFactor` planck. - static final BigInt _scaleDownFactor = BigInt.from(10000000000); - +class WormholeSendService { static const _stepTitles = { 1: 'Preparing circuits', 2: 'Fetching transfers', @@ -70,13 +90,13 @@ class WormholeClaimService { final RpcEndpointService _rpcEndpoint = RpcEndpointService(); /// Explicit RPC node URL. When null, RPC calls use the redundant remote - /// endpoints ([RpcEndpointService]). Set per claim in [claimRewards]. + /// endpoints ([RpcEndpointService]). Set per operation. String? _rpcUrl; int _requestId = 1; /// Completes when the user cancels. Polled by [_checkCancelled] for cheap - /// chain-level checks and raced against the whole flow in [claimRewards] so - /// cancellation is instantaneous even mid-FFI (in-flight proofs are simply + /// chain-level checks and raced against the whole flow in [_withCancellation] + /// so cancellation is instantaneous even mid-FFI (in-flight proofs are simply /// orphaned — they'll finish in the background and their results discarded). Completer? _cancelCompleter; @@ -94,25 +114,58 @@ class WormholeClaimService { required String circuitBinsDir, required ClaimProgressCallback onProgress, String? rpcUrl, - }) async { + }) { _rpcUrl = rpcUrl; - final cancelCompleter = Completer(); - _cancelCompleter = cancelCompleter; - - try { - final flow = _runClaimFlow( + return _withCancellation( + () => _runClaimFlow( wormholeAddress: wormholeAddress, secretHex: secretHex, destinationAddress: destinationAddress, circuitBinsDir: circuitBinsDir, onProgress: onProgress, + ), + ); + } + + /// Proves and submits pre-assigned spends. [batches] must respect the + /// circuits' aggregation arity (checked against the circuit config). + /// [onBatchSubmitted] is awaited after each batch's extrinsic is accepted, + /// with the spent nullifier hexes — callers persist these to keep local + /// pending-spend state exact even if a later batch fails. + Future sendSpends({ + required List> batches, + required String circuitBinsDir, + required ClaimProgressCallback onProgress, + Future Function(int batchIndex, List nullifierHexes)? onBatchSubmitted, + String? rpcUrl, + }) { + _rpcUrl = rpcUrl; + return _withCancellation(() async { + final maxProofsPerBatch = await _ensureCircuits(circuitBinsDir, onProgress); + for (final batch in batches) { + if (batch.isEmpty || batch.length > maxProofsPerBatch) { + throw StateError('Batch of ${batch.length} spends violates aggregation arity $maxProofsPerBatch'); + } + } + return _proveAndSubmitBatches( + batches: batches, + circuitBinsDir: circuitBinsDir, + onProgress: onProgress, + onBatchSubmitted: onBatchSubmitted, ); - // Race the flow against cancellation. Future.any returns the first to - // complete; the loser's later completion (success or error) is silently - // ignored by Future.any, so abandoned in-flight FFI work won't surface - // as an unhandled async error. + }); + } + + /// Races [flow] against cancellation. Future.any returns the first to + /// complete; the loser's later completion (success or error) is silently + /// ignored by Future.any, so abandoned in-flight FFI work won't surface as + /// an unhandled async error. + Future _withCancellation(Future Function() flow) async { + final cancelCompleter = Completer(); + _cancelCompleter = cancelCompleter; + try { final cancelGuard = cancelCompleter.future.then((_) => throw const ClaimCancelled()); - return await Future.any([flow, cancelGuard]); + return await Future.any([flow(), cancelGuard]); } on WormholeOperationCancelled { throw const ClaimCancelled(); } @@ -127,15 +180,9 @@ class WormholeClaimService { if (_cancelled) throw const ClaimCancelled(); } - Future _runClaimFlow({ - required String wormholeAddress, - required String secretHex, - required String destinationAddress, - required String circuitBinsDir, - required ClaimProgressCallback onProgress, - }) async { + /// Step 1: ensures circuit binaries exist and returns the aggregation arity. + Future _ensureCircuits(String circuitBinsDir, ClaimProgressCallback onProgress) async { _checkCancelled(); - _reportProgress(onProgress, 1, 0); _log('Ensuring circuit binaries at: $circuitBinsDir'); final circuitConfig = @@ -145,6 +192,17 @@ class WormholeClaimService { _log('Circuit binaries ready (num_leaf_proofs=$maxProofsPerBatch)'); _reportProgress(onProgress, 1, 1); _checkCancelled(); + return maxProofsPerBatch; + } + + Future _runClaimFlow({ + required String wormholeAddress, + required String secretHex, + required String destinationAddress, + required String circuitBinsDir, + required ClaimProgressCallback onProgress, + }) async { + final maxProofsPerBatch = await _ensureCircuits(circuitBinsDir, onProgress); _reportProgress(onProgress, 2, 0); final unspent = await _utxoService.getUnspentTransfers( @@ -163,15 +221,38 @@ class WormholeClaimService { _log('Found ${unspent.length} unspent transfers'); _checkCancelled(); - final numTransfers = unspent.length; - final totalBatches = (numTransfers + maxProofsPerBatch - 1) ~/ maxProofsPerBatch; + // A claim pays each leaf's full net (post-fee) amount to the destination. final secretBytes = Uint8List.fromList(hex.decode(secretHex.replaceFirst('0x', ''))); final destinationBytes = Uint8List.fromList(getAccountId32(destinationAddress)); + final spends = [ + for (final transfer in unspent) + WormholeLeafSpend( + transfer: transfer, + secret: secretBytes, + exitAccount1: destinationBytes, + outputAmount1: wormholeNetScaled(wormholeScaledFromPlanck(transfer.amount)), + ), + ]; + final batches = [ + for (var i = 0; i < spends.length; i += maxProofsPerBatch) + spends.sublist(i, (i + maxProofsPerBatch).clamp(0, spends.length)), + ]; + + return _proveAndSubmitBatches(batches: batches, circuitBinsDir: circuitBinsDir, onProgress: onProgress); + } + Future _proveAndSubmitBatches({ + required List> batches, + required String circuitBinsDir, + required ClaimProgressCallback onProgress, + Future Function(int batchIndex, List nullifierHexes)? onBatchSubmitted, + }) async { + final numTransfers = batches.fold(0, (sum, b) => sum + b.length); + final totalBatches = batches.length; _reportProgress(onProgress, 5, 0, total: numTransfers); final txHashes = []; - BigInt netTotal = BigInt.zero; + BigInt recipientTotal = BigInt.zero; int proofsCompleted = 0; final genSw = Stopwatch()..start(); @@ -179,10 +260,10 @@ class WormholeClaimService { // aggregate and submit before moving on. Each submitted batch pays out // immediately instead of waiting for the whole queue to be proven. try { - for (int batchStart = 0; batchStart < numTransfers; batchStart += maxProofsPerBatch) { + for (int batchIndex = 0; batchIndex < totalBatches; batchIndex++) { _checkCancelled(); - final batchEnd = (batchStart + maxProofsPerBatch).clamp(0, numTransfers); - final batchNum = batchStart ~/ maxProofsPerBatch + 1; + final batch = batches[batchIndex]; + final batchNum = batchIndex + 1; // Use the current head (not finalized) as the proof block: the user is // claiming up to the chain tip, and the merkle tree at the finalized head @@ -200,13 +281,14 @@ class WormholeClaimService { final blockHashBytes = Uint8List.fromList(_hexBytes(blockHash)); _log('Batch $batchNum/$totalBatches proof block: #$blockNumber ($blockHash)'); - final proofBytesList = List.filled(batchEnd - batchStart, null); + final proofBytesList = List.filled(batch.length, null); + final nullifierHexes = List.filled(batch.length, null); final futures = >[]; - for (int i = batchStart; i < batchEnd; i++) { - final transfer = unspent[i]; + for (int i = 0; i < batch.length; i++) { + final spend = batch[i]; futures.add( _generateLeafProof( - transfer: transfer, + spend: spend, blockHash: blockHash, blockNumber: blockNumber, parentHash: parentHash, @@ -214,19 +296,18 @@ class WormholeClaimService { extrinsicsRoot: extrinsicsRoot, digest: digest, blockHashBytes: blockHashBytes, - secretBytes: secretBytes, - destinationBytes: destinationBytes, circuitBinsDir: circuitBinsDir, - outputBuffer: proofBytesList, - outputIndex: i - batchStart, + proofBuffer: proofBytesList, + nullifierBuffer: nullifierHexes, + outputIndex: i, onComplete: () { proofsCompleted++; // Plain stdout print (not debugPrint) so it survives in release // builds and is visible from the launching terminal. // ignore: avoid_print print( - '[WormholeClaim] Proof $proofsCompleted/$numTransfers ' - 'leaf=${transfer.leafIndex} (${genSw.elapsedMilliseconds}ms elapsed)', + '[WormholeSend] Proof $proofsCompleted/$numTransfers ' + 'leaf=${spend.transfer.leafIndex} (${genSw.elapsedMilliseconds}ms elapsed)', ); _reportProgress(onProgress, 5, proofsCompleted, total: numTransfers); }, @@ -236,7 +317,7 @@ class WormholeClaimService { final outputs = await Future.wait(futures, eagerError: true); for (final out in outputs) { - netTotal += out; + recipientTotal += out; } _checkCancelled(); @@ -255,34 +336,36 @@ class WormholeClaimService { final txHash = await _submitExtrinsic(aggregated); txHashes.add(txHash); _log('Batch $batchNum accepted by pool: $txHash'); + await onBatchSubmitted?.call(batchIndex, nullifierHexes.cast()); _reportProgress(onProgress, 6, batchNum, total: totalBatches); } } on ClaimCancelled { rethrow; } catch (e) { // Batches submitted before the failure have already paid out; surface - // that instead of presenting the claim as a total failure. The nullifier - // check skips paid transfers on retry. + // that instead of presenting the operation as a total failure. The + // nullifier check skips paid transfers on retry. if (txHashes.isEmpty) rethrow; throw StateError( '${txHashes.length}/$totalBatches batches were submitted and paid out before this ' - 'failure; retry to claim the remaining transfers. Cause: $e', + 'failure; retry to send the remaining transfers. Cause: $e', ); } return ClaimResult( - totalWithdrawn: netTotal, + totalWithdrawn: recipientTotal, transfersProcessed: numTransfers, batchesSubmitted: txHashes.length, txHashes: txHashes, ); } - /// Generates a single leaf proof and writes it to [outputBuffer]. Returns the - /// net (post-fee) output amount this leaf contributes. [onComplete] fires - /// once the proof is written so callers can update progress per-leaf. + /// Generates a single leaf proof and writes it (and its nullifier hex) to + /// the output buffers. Returns the planck amount paid to exit slot 1. + /// [onComplete] fires once the proof is written so callers can update + /// progress per-leaf. Future _generateLeafProof({ - required WormholeTransfer transfer, + required WormholeLeafSpend spend, required String blockHash, required int blockNumber, required List parentHash, @@ -290,13 +373,13 @@ class WormholeClaimService { required List extrinsicsRoot, required List digest, required Uint8List blockHashBytes, - required Uint8List secretBytes, - required Uint8List destinationBytes, required String circuitBinsDir, - required List outputBuffer, + required List proofBuffer, + required List nullifierBuffer, required int outputIndex, void Function()? onComplete, }) async { + final transfer = spend.transfer; final zkProof = await _getZkMerkleProof(transfer.leafIndex, blockHash); final leafData = _toBytes(zkProof['leaf_data']); @@ -313,12 +396,18 @@ class WormholeClaimService { ); final inputAmount = wormhole_ffi.decodeLeafAmount(leafData: leafData); - final outputAmount = wormhole_ffi.wormholeComputeOutputAmount(inputAmount: inputAmount, feeBps: _volumeFeeBps); + final maxOutput = wormholeNetScaled(inputAmount); + if (spend.outputAmount1 + spend.outputAmount2 > maxOutput) { + throw StateError( + 'Leaf ${transfer.leafIndex}: assigned outputs ${spend.outputAmount1}+${spend.outputAmount2} ' + 'exceed net input $maxOutput (input $inputAmount)', + ); + } final wormholeAddressBytes = wormhole_ffi.decodeLeafToAccount(leafData: leafData); final proof = await wormhole_ffi.generateProof( input: wormhole_ffi.ProofInput( - secret: secretBytes, + secret: spend.secret, transferCount: transfer.transferCount, wormholeAddress: wormholeAddressBytes, inputAmount: inputAmount, @@ -331,19 +420,22 @@ class WormholeClaimService { zkTreeRoot: zkRoot, sortedSiblingsFlat: merkle.sortedSiblingsFlat, positions: merkle.positions, - exitAccount1: destinationBytes, - outputAmount1: outputAmount, - volumeFeeBps: _volumeFeeBps, + exitAccount1: spend.exitAccount1, + outputAmount1: spend.outputAmount1, + exitAccount2: spend.exitAccount2 ?? Uint8List(32), + outputAmount2: spend.outputAmount2, + volumeFeeBps: wormholeVolumeFeeBps, assetId: 0, ), proverBinPath: '$circuitBinsDir/prover.bin', commonBinPath: '$circuitBinsDir/common.bin', ); - outputBuffer[outputIndex] = proof.proofBytes; + proofBuffer[outputIndex] = proof.proofBytes; + nullifierBuffer[outputIndex] = '0x${hex.encode(proof.nullifier)}'; onComplete?.call(); - // On-chain dispatch transfers `outputAmount * scaleDownFactor` planck to - // the destination, so this is the exact net contribution per leaf. - return BigInt.from(outputAmount) * _scaleDownFactor; + // On-chain dispatch transfers `outputAmount * scaleFactor` planck to + // each exit account; slot 1 is the recipient's exact contribution. + return wormholePlanckFromScaled(spend.outputAmount1); } /// Submits an unsigned extrinsic via `author_submitExtrinsic` and returns the @@ -430,7 +522,7 @@ class WormholeClaimService { // --- Utilities --- // ignore: avoid_print - static void _log(String msg) => print('[WormholeClaim] $msg'); + static void _log(String msg) => print('[WormholeSend] $msg'); static int _hexToInt(String hexStr) => int.parse(hexStr.replaceFirst('0x', ''), radix: 16); diff --git a/quantus_sdk/lib/src/services/wormhole_utxo_service.dart b/quantus_sdk/lib/src/services/wormhole_utxo_service.dart index 376f24bd..6e68ab82 100644 --- a/quantus_sdk/lib/src/services/wormhole_utxo_service.dart +++ b/quantus_sdk/lib/src/services/wormhole_utxo_service.dart @@ -60,6 +60,27 @@ class WormholeTransfer { 'leafIndex: $leafIndex, transferCount: $transferCount}'; } +/// One HD-derived wormhole address (index in the wormhole derivation sequence) +/// together with the secret needed to compute nullifiers and spend proofs. +class WormholeAddressInfo { + final int index; + final String address; + final String secretHex; + + const WormholeAddressInfo({required this.index, required this.address, required this.secretHex}); +} + +/// An unspent wormhole transfer together with the address that owns it. +class WormholeUtxo { + final WormholeTransfer transfer; + final WormholeAddressInfo owner; + final String nullifierHex; + + const WormholeUtxo({required this.transfer, required this.owner, required this.nullifierHex}); + + BigInt get amount => transfer.amount; +} + typedef WormholeProgressCallback = void Function(int phase, int completed, {int? total}); /// Returns true if the caller wants the in-progress operation to abort. @@ -84,6 +105,9 @@ class WormholeUtxoService { static String _addressHash(Uint8List raw32) => wormhole_ffi.computeAddressHashHex(rawAddress: raw32); + static String _addressHashOf(String ss58Address) => + _addressHash(Uint8List.fromList(getAccountId32(ss58Address))); + static void _throwIfCancelled(IsCancelledCallback? isCancelled) { if (isCancelled?.call() == true) throw const WormholeOperationCancelled(); } @@ -189,15 +213,15 @@ class WormholeUtxoService { // --- GraphQL queries --- Future> _queryTransfers({ - required String toAddress, + required List toAddresses, int limit = _transferPageSize, int offset = 0, int? afterBlock, }) async { const query = r''' -query TransfersToAddress($to: String!, $limit: Int!, $offset: Int!, $afterBlock: Int) { +query TransfersToAddresses($tos: [String!]!, $limit: Int!, $offset: Int!, $afterBlock: Int) { transfers: transfer( - where: { to: { id: {_eq: $to } }, block: { height: {_gt: $afterBlock } } } + where: { to: { id: {_in: $tos } }, block: { height: {_gt: $afterBlock } } } order_by: {block: {height: asc}} limit: $limit offset: $offset @@ -214,7 +238,7 @@ query TransfersToAddress($to: String!, $limit: Int!, $offset: Int!, $afterBlock: }'''; final variables = { - 'to': toAddress, + 'tos': toAddresses, 'limit': limit, 'offset': offset, 'afterBlock': afterBlock ?? 0, @@ -224,7 +248,7 @@ query TransfersToAddress($to: String!, $limit: Int!, $offset: Int!, $afterBlock: _log( '=== TRANSFERS QUERY ===\n' - 'to=$toAddress limit=$limit offset=$offset afterBlock=${afterBlock ?? 0}', + 'to=${toAddresses.length} addresses limit=$limit offset=$offset afterBlock=${afterBlock ?? 0}', ); final sw = Stopwatch()..start(); @@ -265,9 +289,9 @@ query TransfersToAddress($to: String!, $limit: Int!, $offset: Int!, $afterBlock: } Future> _fetchAllTransfers({ - required String toAddress, + required List toAddresses, int? afterBlock, - WormholeProgressCallback? onProgress, + void Function(int fetched)? onFetched, IsCancelledCallback? isCancelled, }) async { final totalSw = Stopwatch()..start(); @@ -278,13 +302,13 @@ query TransfersToAddress($to: String!, $limit: Int!, $offset: Int!, $afterBlock: _throwIfCancelled(isCancelled); pageNum++; final page = await _queryTransfers( - toAddress: toAddress, + toAddresses: toAddresses, limit: _transferPageSize, offset: offset, afterBlock: afterBlock, ); all.addAll(page); - onProgress?.call(1, all.length); + onFetched?.call(all.length); _log( 'Page $pageNum: got ${page.length} transfers, total so far: ${all.length} (${totalSw.elapsedMilliseconds}ms elapsed)', ); @@ -346,7 +370,7 @@ query SpentNullifiers($hashes: [String!]!) { /// Returns a map from nullifier hex to the block height where it was spent. /// Callers are responsible for deciding which entries are reorg-safe to - /// persist (see `getUnspentTransfers`). + /// persist (see `getUnspentUtxos`). Future> _checkNullifiersSpent( List<(String nullifierHex, String nullifierHash)> nullifiers, { WormholeProgressCallback? onProgress, @@ -386,110 +410,150 @@ query SpentNullifiers($hashes: [String!]!) { // --- Public API --- - /// Fetches every wormhole transfer ever sent to [wormholeAddress] and - /// returns them along with the reorg-safe block cutoff used for caching. + /// Fetches every wormhole transfer ever sent to any of [addresses] and + /// returns them grouped by address, along with the reorg-safe block cutoff + /// used for caching. /// - /// `transfers` includes everything up to the current chain head. The on-disk - /// cache only advances to `safeCutoff = currentHeight - reorgDepth` so we - /// never have to rewrite already-persisted entries on a reorg; callers above - /// `safeCutoff` simply aren't cached and are re-queried next time. - Future<({List transfers, int safeCutoff})> getTransfersTo( - String wormholeAddress, { + /// Each address keeps its own on-disk cache; addresses with the same cache + /// height are fetched together in one paginated `_in` query. The cache only + /// advances to `safeCutoff = currentHeight - reorgDepth` so we never have to + /// rewrite already-persisted entries on a reorg; transfers above `safeCutoff` + /// simply aren't cached and are re-queried next time. + Future<({Map> byAddress, int safeCutoff})> getTransfersToMany( + List addresses, { WormholeProgressCallback? onProgress, IsCancelledCallback? isCancelled, }) async { final sw = Stopwatch()..start(); - _log('getTransfersTo START ($wormholeAddress)'); - - final raw = Uint8List.fromList(getAccountId32(wormholeAddress)); - final fullHash = _addressHash(raw); + _log('getTransfersToMany START (${addresses.length} addresses)'); final chainHeight = await _getChainHeight(); final safeCutoff = (chainHeight - _reorgDepth).clamp(0, chainHeight); _log('chainHeight=$chainHeight safeCutoff=$safeCutoff'); - final cache = await _loadTransferCache(fullHash); - _log('Cache: ${cache.transfers.length} transfers up to block ${cache.cachedUpToBlock}'); - if (cache.transfers.isNotEmpty) onProgress?.call(1, cache.transfers.length); + final caches = {}; + var cachedCount = 0; + for (final address in addresses) { + final cache = await _loadTransferCache(_addressHashOf(address)); + caches[address] = cache; + cachedCount += cache.transfers.length; + } + _log('Caches: $cachedCount transfers across ${addresses.length} addresses'); + if (cachedCount > 0) onProgress?.call(1, cachedCount); _throwIfCancelled(isCancelled); // Cache invariant: every entry has blockHeight <= cachedUpToBlock, so a // GraphQL filter of `height_gt: cachedUpToBlock` correctly fetches only - // what we don't already have. Previous code added +1 here, which combined - // with `height_gt` skipped block `cachedUpToBlock + 1` entirely. - final List newTransfers; - if (cache.cachedUpToBlock >= chainHeight) { - _log('Cache is current, no new blocks to query'); - newTransfers = const []; - } else { - _log('Querying transfers after block ${cache.cachedUpToBlock}'); - newTransfers = await _fetchAllTransfers( - toAddress: wormholeAddress, - afterBlock: cache.cachedUpToBlock, - onProgress: onProgress != null - ? (phase, completed, {int? total}) => onProgress(phase, cache.transfers.length + completed, total: total) - : null, + // what we don't already have. Addresses sharing a cache height are batched + // into one query. + final groups = >{}; + for (final address in addresses) { + final upTo = caches[address]!.cachedUpToBlock; + if (upTo >= chainHeight) continue; + groups.putIfAbsent(upTo, () => []).add(address); + } + + final newByAddress = >{for (final a in addresses) a: []}; + var fetchedCount = 0; + for (final entry in groups.entries) { + _throwIfCancelled(isCancelled); + _log('Querying ${entry.value.length} addresses after block ${entry.key}'); + final groupBase = fetchedCount; + final fetched = await _fetchAllTransfers( + toAddresses: entry.value, + afterBlock: entry.key, + onFetched: (n) => onProgress?.call(1, cachedCount + groupBase + n), isCancelled: isCancelled, ); - _log('New transfers: ${newTransfers.length}'); + fetchedCount += fetched.length; + for (final t in fetched) { + final list = newByAddress[t.toId]; + if (list == null) { + throw StateError('Indexer returned transfer to unrequested address ${t.toId}'); + } + list.add(t); + } } - final allTransfers = [...cache.transfers, ...newTransfers]; - onProgress?.call(1, allTransfers.length); - _log('Total transfers: ${allTransfers.length} (${cache.transfers.length} cached + ${newTransfers.length} new)'); + final byAddress = >{}; + for (final address in addresses) { + final all = [...caches[address]!.transfers, ...newByAddress[address]!]; + byAddress[address] = all; + // Cache only the reorg-safe slice; the caller still sees recent + // (above-cutoff) transfers so balances / claims include them. + final safe = all.where((t) => t.blockHeight <= safeCutoff).toList(); + await _saveTransferCache( + _addressHashOf(address), + _TransferCache(cachedUpToBlock: safeCutoff, transfers: safe), + ); + } + onProgress?.call(1, cachedCount + fetchedCount); - // Cache only the reorg-safe slice; the caller still sees recent (above-cutoff) - // transfers so balances / claims include them. - final safeTransfers = allTransfers.where((t) => t.blockHeight <= safeCutoff).toList(); - await _saveTransferCache(fullHash, _TransferCache(cachedUpToBlock: safeCutoff, transfers: safeTransfers)); + _log('getTransfersToMany DONE: ${cachedCount + fetchedCount} transfers (${sw.elapsedMilliseconds}ms)'); + return (byAddress: byAddress, safeCutoff: safeCutoff); + } - _log('getTransfersTo DONE: ${allTransfers.length} transfers (${sw.elapsedMilliseconds}ms)'); - return (transfers: allTransfers, safeCutoff: safeCutoff); + Future<({List transfers, int safeCutoff})> getTransfersTo( + String wormholeAddress, { + WormholeProgressCallback? onProgress, + IsCancelledCallback? isCancelled, + }) async { + final fetched = await getTransfersToMany([wormholeAddress], onProgress: onProgress, isCancelled: isCancelled); + return (transfers: fetched.byAddress[wormholeAddress]!, safeCutoff: fetched.safeCutoff); } - Future> getUnspentTransfers({ - required String wormholeAddress, - required String secretHex, + /// Returns the unspent transfers across all [addresses], each attributed to + /// its owning address (whose secret is needed to spend it). + Future> getUnspentUtxos({ + required List addresses, WormholeProgressCallback? onProgress, IsCancelledCallback? isCancelled, }) async { - _log('getUnspentTransfers($wormholeAddress)'); - final fetched = await getTransfersTo(wormholeAddress, onProgress: onProgress, isCancelled: isCancelled); - final transfers = fetched.transfers; + _log('getUnspentUtxos(${addresses.length} addresses)'); + final fetched = await getTransfersToMany( + addresses.map((a) => a.address).toList(), + onProgress: onProgress, + isCancelled: isCancelled, + ); final safeCutoff = fetched.safeCutoff; - if (transfers.isEmpty) { - _log('getUnspentTransfers: no transfers found'); + final totalTransfers = fetched.byAddress.values.fold(0, (sum, l) => sum + l.length); + if (totalTransfers == 0) { + _log('getUnspentUtxos: no transfers found'); return []; } - final raw = Uint8List.fromList(getAccountId32(wormholeAddress)); - final fullHash = _addressHash(raw); - final cachedSpent = await _loadSpentNullifiers(fullHash); - _log('Nullifier cache: ${cachedSpent.length} known spent'); - final hdWalletService = HdWalletService(); final uncheckedPairs = <(String, String)>[]; - final nullifierToTransfer = {}; - final allSpent = {...cachedSpent}; + final nullifierToUtxo = {}; + final cachedSpentByOwner = >{}; + final allSpent = {}; + int processed = 0; int skipped = 0; - for (int i = 0; i < transfers.length; i++) { - _throwIfCancelled(isCancelled); - final transfer = transfers[i]; - final nullifierHex = hdWalletService.computeNullifier( - secretHex: secretHex, - transferCount: transfer.transferCount, - ); - nullifierToTransfer[nullifierHex] = transfer; - if (cachedSpent.contains(nullifierHex)) { - skipped++; - } else { - final nullifierBytes = hex.decode(nullifierHex.replaceFirst('0x', '')); - final nullifierHash = _addressHash(Uint8List.fromList(nullifierBytes)); - uncheckedPairs.add((nullifierHex, nullifierHash)); + for (final owner in addresses) { + final ownerHash = _addressHashOf(owner.address); + final cachedSpent = await _loadSpentNullifiers(ownerHash); + cachedSpentByOwner[ownerHash] = cachedSpent; + allSpent.addAll(cachedSpent); + + for (final transfer in fetched.byAddress[owner.address]!) { + _throwIfCancelled(isCancelled); + final nullifierHex = hdWalletService.computeNullifier( + secretHex: owner.secretHex, + transferCount: transfer.transferCount, + ); + nullifierToUtxo[nullifierHex] = WormholeUtxo(transfer: transfer, owner: owner, nullifierHex: nullifierHex); + if (cachedSpent.contains(nullifierHex)) { + skipped++; + } else { + final nullifierBytes = hex.decode(nullifierHex.replaceFirst('0x', '')); + final nullifierHash = _addressHash(Uint8List.fromList(nullifierBytes)); + uncheckedPairs.add((nullifierHex, nullifierHash)); + } + processed++; + onProgress?.call(2, processed, total: totalTransfers); } - onProgress?.call(2, i + 1, total: transfers.length); } _log('Computed nullifiers: $skipped cached-spent, ${uncheckedPairs.length} to check'); @@ -498,23 +562,48 @@ query SpentNullifiers($hashes: [String!]!) { // In-memory: every spent nullifier we've seen, including ones in // unfinalized blocks — must not be re-claimed in this call. allSpent.addAll(newSpent.keys); - // Persist: only entries from finalized blocks (height <= safeCutoff). - // Unfinalized ones get re-queried next call so a reorg can correct them. - final newSafe = newSpent.entries.where((e) => e.value <= safeCutoff).map((e) => e.key); - final unfinalizedCount = newSpent.length - newSafe.length; - final toPersist = {...cachedSpent, ...newSafe}; - await _saveSpentNullifiers(fullHash, toPersist); - _log( - 'Nullifier persistence: kept ${toPersist.length} finalized ' - '(skipped $unfinalizedCount above cutoff $safeCutoff)', - ); + // Persist: only entries from finalized blocks (height <= safeCutoff), + // each in its owning address's cache. Unfinalized ones get re-queried + // next call so a reorg can correct them. + var unfinalizedCount = 0; + final toPersistByOwner = >{ + for (final e in cachedSpentByOwner.entries) e.key: {...e.value}, + }; + for (final entry in newSpent.entries) { + if (entry.value > safeCutoff) { + unfinalizedCount++; + continue; + } + final ownerHash = _addressHashOf(nullifierToUtxo[entry.key]!.owner.address); + toPersistByOwner[ownerHash]!.add(entry.key); + } + for (final entry in toPersistByOwner.entries) { + if (entry.value.length != cachedSpentByOwner[entry.key]!.length) { + await _saveSpentNullifiers(entry.key, entry.value); + } + } + _log('Nullifier persistence: skipped $unfinalizedCount above cutoff $safeCutoff'); } - final unspent = nullifierToTransfer.entries.where((e) => !allSpent.contains(e.key)).map((e) => e.value).toList(); - _log('getUnspentTransfers: ${unspent.length} unspent out of ${transfers.length} total'); + final unspent = nullifierToUtxo.entries.where((e) => !allSpent.contains(e.key)).map((e) => e.value).toList(); + _log('getUnspentUtxos: ${unspent.length} unspent out of $totalTransfers total'); return unspent; } + Future> getUnspentTransfers({ + required String wormholeAddress, + required String secretHex, + WormholeProgressCallback? onProgress, + IsCancelledCallback? isCancelled, + }) async { + final utxos = await getUnspentUtxos( + addresses: [WormholeAddressInfo(index: 0, address: wormholeAddress, secretHex: secretHex)], + onProgress: onProgress, + isCancelled: isCancelled, + ); + return utxos.map((u) => u.transfer).toList(); + } + Future getUnspentBalance({ required String wormholeAddress, required String secretHex, diff --git a/quantus_sdk/rust/src/api/wormhole.rs b/quantus_sdk/rust/src/api/wormhole.rs index 85cd9616..95b24e26 100644 --- a/quantus_sdk/rust/src/api/wormhole.rs +++ b/quantus_sdk/rust/src/api/wormhole.rs @@ -42,6 +42,8 @@ pub struct ProofInput { pub positions: Vec, pub exit_account_1: Vec, pub output_amount_1: u32, + pub exit_account_2: Vec, + pub output_amount_2: u32, pub volume_fee_bps: u32, pub asset_id: u32, } @@ -311,11 +313,11 @@ pub fn generate_proof( let public = PublicCircuitInputs { asset_id: input.asset_id, output_amount_1: input.output_amount_1, - output_amount_2: 0, + output_amount_2: input.output_amount_2, volume_fee_bps: input.volume_fee_bps, nullifier: nullifier_bytes, exit_account_1: vec_to_digest(&input.exit_account_1, "exit_account_1")?, - exit_account_2: vec_to_digest(&[0u8; 32], "exit_account_2")?, + exit_account_2: vec_to_digest(&input.exit_account_2, "exit_account_2")?, block_hash: vec_to_digest(&input.block_hash, "block_hash")?, block_number: input.block_number, }; diff --git a/quantus_sdk/rust/src/frb_generated.rs b/quantus_sdk/rust/src/frb_generated.rs index 0369af25..565a0202 100644 --- a/quantus_sdk/rust/src/frb_generated.rs +++ b/quantus_sdk/rust/src/frb_generated.rs @@ -1262,6 +1262,8 @@ impl SseDecode for crate::api::wormhole::ProofInput { let mut var_positions = >::sse_decode(deserializer); let mut var_exitAccount1 = >::sse_decode(deserializer); let mut var_outputAmount1 = ::sse_decode(deserializer); + let mut var_exitAccount2 = >::sse_decode(deserializer); + let mut var_outputAmount2 = ::sse_decode(deserializer); let mut var_volumeFeeBps = ::sse_decode(deserializer); let mut var_assetId = ::sse_decode(deserializer); return crate::api::wormhole::ProofInput { @@ -1280,6 +1282,8 @@ impl SseDecode for crate::api::wormhole::ProofInput { positions: var_positions, exit_account_1: var_exitAccount1, output_amount_1: var_outputAmount1, + exit_account_2: var_exitAccount2, + output_amount_2: var_outputAmount2, volume_fee_bps: var_volumeFeeBps, asset_id: var_assetId, }; @@ -1516,6 +1520,8 @@ impl flutter_rust_bridge::IntoDart for crate::api::wormhole::ProofInput { self.positions.into_into_dart().into_dart(), self.exit_account_1.into_into_dart().into_dart(), self.output_amount_1.into_into_dart().into_dart(), + self.exit_account_2.into_into_dart().into_dart(), + self.output_amount_2.into_into_dart().into_dart(), self.volume_fee_bps.into_into_dart().into_dart(), self.asset_id.into_into_dart().into_dart(), ] @@ -1683,6 +1689,8 @@ impl SseEncode for crate::api::wormhole::ProofInput { >::sse_encode(self.positions, serializer); >::sse_encode(self.exit_account_1, serializer); ::sse_encode(self.output_amount_1, serializer); + >::sse_encode(self.exit_account_2, serializer); + ::sse_encode(self.output_amount_2, serializer); ::sse_encode(self.volume_fee_bps, serializer); ::sse_encode(self.asset_id, serializer); } diff --git a/quantus_sdk/test/services/wormhole_coin_selection_test.dart b/quantus_sdk/test/services/wormhole_coin_selection_test.dart new file mode 100644 index 00000000..98602beb --- /dev/null +++ b/quantus_sdk/test/services/wormhole_coin_selection_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/src/services/wormhole_coin_selection.dart'; +import 'package:quantus_sdk/src/services/wormhole_utxo_service.dart'; + +WormholeUtxo utxo(int scaled) => WormholeUtxo( + transfer: WormholeTransfer( + id: 't$scaled', + blockHeight: 1, + fromId: 'from', + toId: 'to', + amount: wormholePlanckFromScaled(scaled), + toHash: '0x00', + leafIndex: BigInt.from(scaled), + transferCount: BigInt.one, + ), + owner: const WormholeAddressInfo(index: 0, address: 'addr', secretHex: '0x00'), + nullifierHex: '0xn$scaled', +); + +BigInt quan(String v) => wormholePlanckFromScaled((double.parse(v) * 100).round()); + +void main() { + group('selectWormholeInputs', () { + test('plan worked example: 10 QUAN from 1.1 + 5.8 + 4.0', () { + final plan = selectWormholeInputs( + utxos: [utxo(110), utxo(580), utxo(400)], + amountPlanck: quan('10'), + ); + + expect(plan.inputCount, 3); + expect(plan.batches.length, 1); + expect(plan.amountPlanck, quan('10')); + expect(plan.changePlanck, quan('0.87')); + expect(plan.feePlanck, quan('0.03')); + + final recipientTotal = plan.batches[0].fold(0, (s, a) => s + a.recipientScaled); + expect(wormholePlanckFromScaled(recipientTotal), quan('10')); + expect(plan.batches[0].where((a) => a.changeScaled > 0).length, 1); + for (final a in plan.batches[0]) { + final net = wormholeNetScaled(wormholeScaledFromPlanck(a.utxo.amount)); + expect(a.recipientScaled + a.changeScaled, net); + } + }); + + test('splits across batches beyond 7 inputs, change appears once', () { + final plan = selectWormholeInputs( + utxos: List.generate(9, (_) => utxo(200)), + amountPlanck: quan('16'), + ); + + // 200 nets 199; 9 inputs net 17.91 total, 8 inputs net 15.92 < 16. + expect(plan.inputCount, 9); + expect(plan.batches.length, 2); + expect(plan.batches.every((b) => b.length <= 7), isTrue); + expect(plan.batches.expand((b) => b).where((a) => a.changeScaled > 0).length, 1); + for (final batch in plan.batches) { + final exit = batch.fold(0, (s, a) => s + a.exitScaled); + expect(exit, greaterThanOrEqualTo(wormholeMinBatchExitScaled)); + } + expect(plan.changePlanck, quan('1.91')); + }); + + test('insufficient funds reports exact max sendable', () { + final e = throwsA( + isA().having((e) => e.maxSendablePlanck, 'maxSendable', quan('1.98')), + ); + expect(() => selectWormholeInputs(utxos: [utxo(100), utxo(100)], amountPlanck: quan('2')), e); + }); + + test('rejects non-quantized amounts', () { + expect( + () => selectWormholeInputs(utxos: [utxo(1000)], amountPlanck: quan('1') + BigInt.one), + throwsArgumentError, + ); + }); + + test('rejects a batch below the chain minimum exit', () { + expect( + () => selectWormholeInputs(utxos: [utxo(9)], amountPlanck: wormholePlanckFromScaled(8)), + throwsA(isA()), + ); + }); + + test('wormholeMaxSendable sums per-input nets', () { + expect(wormholeMaxSendable([utxo(110), utxo(580), utxo(400)]), quan('10.87')); + }); + }); +}