diff --git a/cold-wallet-app/lib/components/call_detail_view.dart b/cold-wallet-app/lib/components/call_detail_view.dart index 66e231517..c660eb68d 100644 --- a/cold-wallet-app/lib/components/call_detail_view.dart +++ b/cold-wallet-app/lib/components/call_detail_view.dart @@ -69,12 +69,12 @@ class _CallFieldView extends ConsumerWidget { note: note, ); - case AmountField(:final label, :final planck, :final assetId, :final note): + case AmountField(:final label, :final token, :final assetId, :final note): return DetailRow( label: label, value: assetId == null - ? '${_formatPlanck(planck)} ${AppConstants.tokenSymbol}' - : '$planck raw units of asset #$assetId', + ? '${NumberFormattingService().formatAmount(token)} ${AppConstants.tokenSymbol}' + : '$token raw units of asset #$assetId', note: assetId == null ? note : [ @@ -127,9 +127,6 @@ class _CallFieldView extends ConsumerWidget { ); } } - - String _formatPlanck(BigInt planck) => - NumberFormattingService().formatBalance(planck, smartDecimals: 4, maxDecimals: AppConstants.decimals); } /// An address plus its checkphrase, so the signer can verify it out loud rather diff --git a/cold-wallet-app/lib/debug/debug_payloads.dart b/cold-wallet-app/lib/debug/debug_payloads.dart index 303b1c6c6..b3f52bdad 100644 --- a/cold-wallet-app/lib/debug/debug_payloads.dart +++ b/cold-wallet-app/lib/debug/debug_payloads.dart @@ -23,7 +23,7 @@ class DebugPayloads { return _withExtensions( const balances_pallet.Txs().transferAllowDeath( dest: _address(AppConstants.debugTestAddress), - value: BigInt.from(1500000000000), // 1.5 QUAN + value: BigInt.from(1500000000000), // 1.5 tokens ), ); } @@ -33,7 +33,7 @@ class DebugPayloads { static Uint8List multisigApproveTransfer() { final inner = const balances_pallet.Txs().transferAllowDeath( dest: _address(AppConstants.debugTestAddress), - value: BigInt.from(4200000000000), // 4.2 QUAN + value: BigInt.from(4200000000000), // 4.2 tokens ); return _withExtensions( const multisig_pallet.Txs().approve(multisigAddress: _debugMultisigAccount, proposalId: 12, call: inner.encode()), diff --git a/cold-wallet-app/lib/screens/sign_transaction_screen.dart b/cold-wallet-app/lib/screens/sign_transaction_screen.dart index a2112a662..e44efee30 100644 --- a/cold-wallet-app/lib/screens/sign_transaction_screen.dart +++ b/cold-wallet-app/lib/screens/sign_transaction_screen.dart @@ -114,9 +114,6 @@ class _SignTransactionScreenState extends ConsumerState { } } - String _formatAmount(BigInt planck) => - NumberFormattingService().formatBalance(planck, smartDecimals: 4, maxDecimals: AppConstants.decimals); - @override Widget build(BuildContext context) { if (_parseError != null) return _errorView(context, _parseError!); @@ -200,7 +197,10 @@ class _SignTransactionScreenState extends ConsumerState { DetailRow(label: 'Runtime', value: 'spec ${ext.specVersion}, tx version ${ext.transactionVersion}'), DetailRow(label: 'Nonce', value: '${ext.nonce}'), DetailRow(label: 'Era', value: '${ext.era}'), - DetailRow(label: 'Tip', value: '${_formatAmount(ext.tip)} ${AppConstants.tokenSymbol}'), + DetailRow( + label: 'Tip', + value: '${NumberFormattingService().formatAmount(ext.tip)} ${AppConstants.tokenSymbol}', + ), DetailRow(label: 'Genesis hash', value: '0x${hex.encode(ext.genesisHash)}', monospace: true), DetailRow(label: 'Block hash', value: '0x${hex.encode(ext.blockHash)}', monospace: true), DetailRow( @@ -319,7 +319,7 @@ class _SignTransactionScreenState extends ConsumerState { text: TextSpan( children: [ TextSpan( - text: _formatAmount(summary.amount), + text: NumberFormattingService().formatAmount(summary.amount), style: text.transactionDetailAmountPrimary?.copyWith(color: colors.textPrimary), ), TextSpan( diff --git a/cold-wallet-app/test/transaction_signing_test.dart b/cold-wallet-app/test/transaction_signing_test.dart index 823a92b34..6e83b4f07 100644 --- a/cold-wallet-app/test/transaction_signing_test.dart +++ b/cold-wallet-app/test/transaction_signing_test.dart @@ -9,7 +9,7 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:quantus_cold_wallet/debug/debug_payloads.dart'; void main() { - // The 0.1 QUAN keystone transfer call with a Planck extension suffix (era 5501 = + // The 0.1 token keystone transfer call with a Planck extension suffix (era 5501 = // period 64 phase 21, nonce 0, tip 0, spec 131, tx version 2, metadata None), so the // cold wallet is verified against the exact byte layout the hot wallet produces. const planckHex = @@ -56,7 +56,7 @@ void main() { expect(destination.kind, ValueKind.address); expect(destination.value, startsWith('qz')); final amount = parsed.call.fields.whereType().firstWhere((f) => f.label == 'Amount'); - expect(amount.planck, BigInt.parse('100000000000')); // 0.1 QUAN at 12 decimals + expect(amount.token, BigInt.parse('100000000000')); // 0.1 token at 12 decimals expect(parsed.call.summary?.amount, BigInt.parse('100000000000')); expect(parsed.network, 'Planck'); expect(parsed.extensions.era.toString(), '64 blocks'); @@ -93,7 +93,7 @@ void main() { .call; expect(approved.call, 'transfer_allow_death'); expect( - approved.fields.whereType().firstWhere((f) => f.label == 'Amount').planck, + approved.fields.whereType().firstWhere((f) => f.label == 'Amount').token, BigInt.from(4200000000000), ); // Hero amount comes from the authorised transfer. diff --git a/miner-app/lib/features/miner/miner_balance_card.dart b/miner-app/lib/features/miner/miner_balance_card.dart index a5ee0f9dc..7d17db739 100644 --- a/miner-app/lib/features/miner/miner_balance_card.dart +++ b/miner-app/lib/features/miner/miner_balance_card.dart @@ -113,7 +113,7 @@ class _MinerBalanceCardState extends State { _log.i('Fetching unspent balance for $address ...'); try { final balance = await _utxoService.getUnspentBalance(wormholeAddress: address, secretHex: secretHex); - _log.i('Unspent balance: $balance planck (${_formatter.formatBalance(balance, addSymbol: true)})'); + _log.i('Unspent balance: $balance token units (${_formatter.formatBalance(balance, addSymbol: true)})'); if (!mounted) return; setState(() { _balance = balance; diff --git a/miner-app/lib/features/withdrawal/claim_rewards_dialog.dart b/miner-app/lib/features/withdrawal/claim_rewards_dialog.dart index f1c815429..a09830cc4 100644 --- a/miner-app/lib/features/withdrawal/claim_rewards_dialog.dart +++ b/miner-app/lib/features/withdrawal/claim_rewards_dialog.dart @@ -67,8 +67,8 @@ class _ClaimRewardsDialogState extends State<_ClaimRewardsDialog> { super.dispose(); } - String _formatBalance(BigInt planck) => - _balanceFormatter.formatBalance(planck, smartDecimals: 4, addThousandsSeparators: false); + String _formatBalance(BigInt amount) => + _balanceFormatter.formatBalance(amount, smartDecimals: 4, addThousandsSeparators: false); bool _validateAddress(String address) { final trimmed = address.trim(); @@ -229,7 +229,7 @@ class _ClaimRewardsDialogState extends State<_ClaimRewardsDialog> { children: [ Text('Claimable Balance', style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.7))), Text( - '${_formatBalance(widget.balance)} QUAN', + '${_formatBalance(widget.balance)} ${AppConstants.tokenSymbol}', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, @@ -308,7 +308,7 @@ class _ClaimRewardsDialogState extends State<_ClaimRewardsDialog> { ], ), const SizedBox(height: 24), - _confirmRow('Amount', '${_formatBalance(widget.balance)} QUAN'), + _confirmRow('Amount', '${_formatBalance(widget.balance)} ${AppConstants.tokenSymbol}'), const SizedBox(height: 12), _confirmRow('Destination', _addressController.text.trim(), mono: true), const SizedBox(height: 12), diff --git a/miner-app/lib/src/services/chain_rpc_client.dart b/miner-app/lib/src/services/chain_rpc_client.dart index 3c0683498..864abb53a 100644 --- a/miner-app/lib/src/services/chain_rpc_client.dart +++ b/miner-app/lib/src/services/chain_rpc_client.dart @@ -183,7 +183,7 @@ class ChainRpcClient { /// /// [address] should be an SS58-encoded address. /// [accountIdHex] can be provided if already known (32 bytes as hex without 0x prefix). - /// Returns the free balance in planck (smallest unit), or null if the query fails. + /// Returns the free balance in token units (smallest unit), or null if the query fails. Future getAccountBalance(String address, {String? accountIdHex}) async { try { // Build the storage key for System::Account(address) diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 6a1bc2ed9..afa44316a 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -261,9 +261,14 @@ "@homeActivityEmptyTitle": { "description": "Empty state title in home activity section" }, - "homeActivityEmptyMessage": "Your activity will appear here once you send or receive QUAN.", + "homeActivityEmptyMessage": "Your activity will appear here once you send or receive {tokenSymbol}.", "@homeActivityEmptyMessage": { - "description": "Empty state message in home activity section" + "description": "Empty state message in home activity section", + "placeholders": { + "tokenSymbol": { + "type": "String" + } + } }, "accountsSheetTitle": "Accounts", @@ -2249,9 +2254,14 @@ "@settingsMiningStatRedeemable": { "description": "Redeemable rewards stat label" }, - "settingsMiningQuanEarned": "QUAN EARNED", - "@settingsMiningQuanEarned": { - "description": "QUAN earned stat label" + "settingsMiningTokenEarned": "{tokenSymbol} EARNED", + "@settingsMiningTokenEarned": { + "description": "Token earned stat label", + "placeholders": { + "tokenSymbol": { + "type": "String" + } + } }, "settingsMiningViewTelemetry": "View Telemetry ↗", "@settingsMiningViewTelemetry": { @@ -2424,7 +2434,7 @@ "@swapGetQuote": { "description": "Get quote button on swap screen" }, - "swapRateLabel": "1 QUAN = {amount} {symbol}", + "swapRateLabel": "1 {tokenSymbol} = {amount} {symbol}", "@swapRateLabel": { "description": "Exchange rate display", "placeholders": { @@ -2433,15 +2443,21 @@ }, "symbol": { "type": "String" + }, + "tokenSymbol": { + "type": "String" } } }, - "swapRateZero": "1 QUAN = 0 {symbol}", + "swapRateZero": "1 {tokenSymbol} = 0 {symbol}", "@swapRateZero": { "description": "Exchange rate when amount is zero", "placeholders": { "symbol": { "type": "String" + }, + "tokenSymbol": { + "type": "String" } } }, @@ -2539,12 +2555,15 @@ "@swapDepositCompleteTitle": { "description": "Title when swap is complete" }, - "swapDepositCompleteBody": "Your swap for {amount} QUAN is complete.", + "swapDepositCompleteBody": "Your swap for {amount} {tokenSymbol} is complete.", "@swapDepositCompleteBody": { "description": "Body when swap is complete", "placeholders": { "amount": { "type": "String" + }, + "tokenSymbol": { + "type": "String" } } }, @@ -2767,13 +2786,23 @@ "@encryptedSendFeeLabel": { "description": "Fee label for encrypted sends (wormhole volume fee)" }, - "encryptedSendAmountStep": "Use steps of 0.01 QUAN", + "encryptedSendAmountStep": "Use steps of 0.01 {tokenSymbol}", "@encryptedSendAmountStep": { - "description": "Shown when an encrypted send amount is not a multiple of 0.01 QUAN" + "description": "Shown when an encrypted send amount is not a multiple of 0.01 token", + "placeholders": { + "tokenSymbol": { + "type": "String" + } + } }, - "encryptedSendMinimum": "Encrypted sends must move at least 0.1 QUAN", + "encryptedSendMinimum": "Encrypted sends must move at least 0.1 {tokenSymbol}", "@encryptedSendMinimum": { - "description": "Shown when an encrypted send falls below the chain's minimum exit amount" + "description": "Shown when an encrypted send falls below the chain's minimum exit amount", + "placeholders": { + "tokenSymbol": { + "type": "String" + } + } }, "encryptedSendProgressTitle": "Private Send", "@encryptedSendProgressTitle": { diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index c7d587d43..8c266a9f4 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -65,7 +65,7 @@ "homeActivityErrorLoading": "Gagal memuat transaksi", "homeActivityRetry": "Coba Lagi", "homeActivityEmptyTitle": "Belum Ada Transaksi", - "homeActivityEmptyMessage": "Aktivitas Anda akan muncul di sini setelah Anda mengirim atau menerima QUAN.", + "homeActivityEmptyMessage": "Aktivitas Anda akan muncul di sini setelah Anda mengirim atau menerima {tokenSymbol}.", "accountsSheetTitle": "Akun", "accountsSheetFailedLoadAccounts": "Gagal memuat akun.", @@ -517,7 +517,7 @@ "settingsMiningStatTestnetRewards": "HADIAH TESTNET", "settingsMiningStatRedeemed": "DITUKAR", "settingsMiningStatRedeemable": "DAPAT DITUKAR", - "settingsMiningQuanEarned": "QUAN DIHASILKAN", + "settingsMiningTokenEarned": "{tokenSymbol} DIHASILKAN", "settingsMiningViewTelemetry": "Lihat Telemetri ↗", "settingsMiningNoDataTitle": "Belum ada data mining", "settingsMiningNoDataBody": "Siapkan node mining Quantus untuk mulai mendapatkan hadiah.", @@ -558,8 +558,8 @@ "swapSlippageTolerance": "Toleransi Slippage", "swapRate": "Kurs", "swapGetQuote": "Dapatkan Penawaran", - "swapRateLabel": "1 QUAN = {amount} {symbol}", - "swapRateZero": "1 QUAN = 0 {symbol}", + "swapRateLabel": "1 {tokenSymbol} = {amount} {symbol}", + "swapRateZero": "1 {tokenSymbol} = 0 {symbol}", "swapTokenPickerTitle": "Pilih Token", "swapTokenPickerLoadError": "Gagal memuat token", @@ -579,7 +579,7 @@ "swapDepositProcessingTitle": "Memproses Swap", "swapDepositProcessingBody": "Ini mungkin memakan waktu beberapa menit...", "swapDepositCompleteTitle": "Swap Selesai", - "swapDepositCompleteBody": "Swap Anda untuk {amount} QUAN telah selesai.", + "swapDepositCompleteBody": "Swap Anda untuk {amount} {tokenSymbol} telah selesai.", "swapDepositTestnetBanner": "HANYA DEMO - KAMI MASIH DI TESTNET", "swapDemoOnly": "Hanya Demo", "swapDemoOnlyBody": "Tidak ada swap sungguhan yang dilakukan.", @@ -630,8 +630,8 @@ "redeemDone": "Selesai", "redeemSuccessBanner": "{amount} ditukar dalam {count} batch", "encryptedSendFeeLabel": "Biaya privasi", - "encryptedSendAmountStep": "Gunakan kelipatan 0,01 QUAN", - "encryptedSendMinimum": "Pengiriman terenkripsi minimal 0,1 QUAN", + "encryptedSendAmountStep": "Gunakan kelipatan 0,01 {tokenSymbol}", + "encryptedSendMinimum": "Pengiriman terenkripsi minimal 0,1 {tokenSymbol}", "encryptedSendProgressTitle": "Mengirim Secara Privat...", "encryptedSendFailedTitle": "Pengiriman Gagal", "encryptedSendCancelledTitle": "Pengiriman Dibatalkan", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 2efd167c0..b78067146 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -437,8 +437,8 @@ abstract class AppLocalizations { /// Empty state message in home activity section /// /// In en, this message translates to: - /// **'Your activity will appear here once you send or receive QUAN.'** - String get homeActivityEmptyMessage; + /// **'Your activity will appear here once you send or receive {tokenSymbol}.'** + String homeActivityEmptyMessage(String tokenSymbol); /// Title of the accounts bottom sheet /// @@ -2948,11 +2948,11 @@ abstract class AppLocalizations { /// **'REDEEMABLE'** String get settingsMiningStatRedeemable; - /// QUAN earned stat label + /// Token earned stat label /// /// In en, this message translates to: - /// **'QUAN EARNED'** - String get settingsMiningQuanEarned; + /// **'{tokenSymbol} EARNED'** + String settingsMiningTokenEarned(String tokenSymbol); /// Link to mining telemetry /// @@ -3173,14 +3173,14 @@ abstract class AppLocalizations { /// Exchange rate display /// /// In en, this message translates to: - /// **'1 QUAN = {amount} {symbol}'** - String swapRateLabel(String amount, String symbol); + /// **'1 {tokenSymbol} = {amount} {symbol}'** + String swapRateLabel(String amount, String symbol, String tokenSymbol); /// Exchange rate when amount is zero /// /// In en, this message translates to: - /// **'1 QUAN = 0 {symbol}'** - String swapRateZero(String symbol); + /// **'1 {tokenSymbol} = 0 {symbol}'** + String swapRateZero(String symbol, String tokenSymbol); /// Title on token picker sheet /// @@ -3281,8 +3281,8 @@ abstract class AppLocalizations { /// Body when swap is complete /// /// In en, this message translates to: - /// **'Your swap for {amount} QUAN is complete.'** - String swapDepositCompleteBody(String amount); + /// **'Your swap for {amount} {tokenSymbol} is complete.'** + String swapDepositCompleteBody(String amount, String tokenSymbol); /// Testnet demo banner on deposit screen /// @@ -3560,17 +3560,17 @@ abstract class AppLocalizations { /// **'Privacy fee'** String get encryptedSendFeeLabel; - /// Shown when an encrypted send amount is not a multiple of 0.01 QUAN + /// Shown when an encrypted send amount is not a multiple of 0.01 token /// /// In en, this message translates to: - /// **'Use steps of 0.01 QUAN'** - String get encryptedSendAmountStep; + /// **'Use steps of 0.01 {tokenSymbol}'** + String encryptedSendAmountStep(String tokenSymbol); /// 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; + /// **'Encrypted sends must move at least 0.1 {tokenSymbol}'** + String encryptedSendMinimum(String tokenSymbol); /// App bar title while an encrypted send is proving/submitting /// diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 0a70745ea..1e874a085 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -211,7 +211,9 @@ class AppLocalizationsEn extends AppLocalizations { String get homeActivityEmptyTitle => 'No Transactions Yet'; @override - String get homeActivityEmptyMessage => 'Your activity will appear here once you send or receive QUAN.'; + String homeActivityEmptyMessage(String tokenSymbol) { + return 'Your activity will appear here once you send or receive $tokenSymbol.'; + } @override String get accountsSheetTitle => 'Accounts'; @@ -1579,7 +1581,9 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsMiningStatRedeemable => 'REDEEMABLE'; @override - String get settingsMiningQuanEarned => 'QUAN EARNED'; + String settingsMiningTokenEarned(String tokenSymbol) { + return '$tokenSymbol EARNED'; + } @override String get settingsMiningViewTelemetry => 'View Telemetry ↗'; @@ -1699,13 +1703,13 @@ class AppLocalizationsEn extends AppLocalizations { String get swapGetQuote => 'Get a Quote'; @override - String swapRateLabel(String amount, String symbol) { - return '1 QUAN = $amount $symbol'; + String swapRateLabel(String amount, String symbol, String tokenSymbol) { + return '1 $tokenSymbol = $amount $symbol'; } @override - String swapRateZero(String symbol) { - return '1 QUAN = 0 $symbol'; + String swapRateZero(String symbol, String tokenSymbol) { + return '1 $tokenSymbol = 0 $symbol'; } @override @@ -1763,8 +1767,8 @@ class AppLocalizationsEn extends AppLocalizations { String get swapDepositCompleteTitle => 'Swap Complete'; @override - String swapDepositCompleteBody(String amount) { - return 'Your swap for $amount QUAN is complete.'; + String swapDepositCompleteBody(String amount, String tokenSymbol) { + return 'Your swap for $amount $tokenSymbol is complete.'; } @override @@ -1916,10 +1920,14 @@ class AppLocalizationsEn extends AppLocalizations { String get encryptedSendFeeLabel => 'Privacy fee'; @override - String get encryptedSendAmountStep => 'Use steps of 0.01 QUAN'; + String encryptedSendAmountStep(String tokenSymbol) { + return 'Use steps of 0.01 $tokenSymbol'; + } @override - String get encryptedSendMinimum => 'Encrypted sends must move at least 0.1 QUAN'; + String encryptedSendMinimum(String tokenSymbol) { + return 'Encrypted sends must move at least 0.1 $tokenSymbol'; + } @override String get encryptedSendProgressTitle => 'Private Send'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index c11b66fba..7d48cc059 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -206,7 +206,9 @@ class AppLocalizationsId extends AppLocalizations { String get homeActivityEmptyTitle => 'Belum Ada Transaksi'; @override - String get homeActivityEmptyMessage => 'Aktivitas Anda akan muncul di sini setelah Anda mengirim atau menerima QUAN.'; + String homeActivityEmptyMessage(String tokenSymbol) { + return 'Aktivitas Anda akan muncul di sini setelah Anda mengirim atau menerima $tokenSymbol.'; + } @override String get accountsSheetTitle => 'Akun'; @@ -1577,7 +1579,9 @@ class AppLocalizationsId extends AppLocalizations { String get settingsMiningStatRedeemable => 'DAPAT DITUKAR'; @override - String get settingsMiningQuanEarned => 'QUAN DIHASILKAN'; + String settingsMiningTokenEarned(String tokenSymbol) { + return '$tokenSymbol DIHASILKAN'; + } @override String get settingsMiningViewTelemetry => 'Lihat Telemetri ↗'; @@ -1697,13 +1701,13 @@ class AppLocalizationsId extends AppLocalizations { String get swapGetQuote => 'Dapatkan Penawaran'; @override - String swapRateLabel(String amount, String symbol) { - return '1 QUAN = $amount $symbol'; + String swapRateLabel(String amount, String symbol, String tokenSymbol) { + return '1 $tokenSymbol = $amount $symbol'; } @override - String swapRateZero(String symbol) { - return '1 QUAN = 0 $symbol'; + String swapRateZero(String symbol, String tokenSymbol) { + return '1 $tokenSymbol = 0 $symbol'; } @override @@ -1761,8 +1765,8 @@ class AppLocalizationsId extends AppLocalizations { String get swapDepositCompleteTitle => 'Swap Selesai'; @override - String swapDepositCompleteBody(String amount) { - return 'Swap Anda untuk $amount QUAN telah selesai.'; + String swapDepositCompleteBody(String amount, String tokenSymbol) { + return 'Swap Anda untuk $amount $tokenSymbol telah selesai.'; } @override @@ -1914,10 +1918,14 @@ class AppLocalizationsId extends AppLocalizations { String get encryptedSendFeeLabel => 'Biaya privasi'; @override - String get encryptedSendAmountStep => 'Gunakan kelipatan 0,01 QUAN'; + String encryptedSendAmountStep(String tokenSymbol) { + return 'Gunakan kelipatan 0,01 $tokenSymbol'; + } @override - String get encryptedSendMinimum => 'Pengiriman terenkripsi minimal 0,1 QUAN'; + String encryptedSendMinimum(String tokenSymbol) { + return 'Pengiriman terenkripsi minimal 0,1 $tokenSymbol'; + } @override String get encryptedSendProgressTitle => 'Mengirim Secara Privat...'; diff --git a/mobile-app/lib/models/fiat_currency.dart b/mobile-app/lib/models/fiat_currency.dart index 8a4142b0a..b0c329091 100644 --- a/mobile-app/lib/models/fiat_currency.dart +++ b/mobile-app/lib/models/fiat_currency.dart @@ -1,6 +1,6 @@ -/// Fiat currencies the app can convert QUAN amounts into. +/// Fiat currencies the app can convert token amounts into. /// -/// QUAN itself is not listed here — it is always the native side. +/// The token itself is not listed here — it is always the native side. /// Adding a new currency only requires a new enum case here and a matching /// rate in [ExchangeRateService]. No widget or provider changes are needed. enum FiatCurrency { diff --git a/mobile-app/lib/providers/currency_display_provider.dart b/mobile-app/lib/providers/currency_display_provider.dart index b18ac9c0d..222cb909b 100644 --- a/mobile-app/lib/providers/currency_display_provider.dart +++ b/mobile-app/lib/providers/currency_display_provider.dart @@ -172,10 +172,10 @@ class SelectedFiatCurrencyNotifier extends StateNotifier { // Currency flip provider // --------------------------------------------------------------------------- -/// Whether fiat is shown as the primary (large) display and QUAN secondary. +/// Whether fiat is shown as the primary (large) display and the token secondary. /// -/// false → primary = QUAN, secondary = fiat (default) -/// true → primary = fiat, secondary = QUAN +/// false → primary = token, secondary = fiat (default) +/// true → primary = fiat, secondary = token /// /// To toggle from the swap button: /// ref.read(isCurrencyFlippedProvider.notifier).toggle(); @@ -265,10 +265,10 @@ final balanceDisplayProvider = Provider>((ref) xRate, fmt, _hiddenAmountText, - quanDecimals: 3, + tokenDecimals: 3, isFlipped: isFlipped, isHidden: isHidden, - withQuanSymbol: false, + withTokenSymbol: false, localeConfig: localeConfig, ); return AsyncValue.data(data); @@ -285,8 +285,8 @@ final txAmountDisplayProvider = CurrencyDisplayState Function( BigInt, { required bool isSend, - int quanDecimals, - bool withQuanSymbol, + int tokenDecimals, + bool withTokenSymbol, bool withSignPrefix, String? customHiddenText, }) @@ -301,9 +301,9 @@ final txAmountDisplayProvider = return ( BigInt amount, { required bool isSend, - bool withQuanSymbol = true, + bool withTokenSymbol = true, bool withSignPrefix = true, - int quanDecimals = 2, + int tokenDecimals = 2, String? customHiddenText, }) { final hiddenText = customHiddenText ?? _hiddenAmountText; @@ -315,9 +315,9 @@ final txAmountDisplayProvider = xRate, fmt, hiddenText, - quanDecimals: quanDecimals, + tokenDecimals: tokenDecimals, isHidden: isHidden, - withQuanSymbol: withQuanSymbol, + withTokenSymbol: withTokenSymbol, isFlipped: isFlipped, localeConfig: localeConfig, ); @@ -326,7 +326,7 @@ final txAmountDisplayProvider = data = data.copyWith(primaryAmount: withSignPrefix ? '$prefix${data.primaryAmount}' : data.primaryAmount); } - if (!withQuanSymbol && isFlipped && !isHidden) { + if (!withTokenSymbol && isFlipped && !isHidden) { data = data.copyWith(secondaryAmount: '${data.secondaryAmount} ${AppConstants.tokenSymbol}'); } @@ -347,7 +347,7 @@ String _toFiatNumeric( ExchangeRateService xRate, { required LocaleNumberConfig localeConfig, }) { - final fiatValue = xRate.quanRawToFiat(rawBalance, fiat, AppConstants.decimals); + final fiatValue = xRate.tokenToFiat(rawBalance, fiat, AppConstants.decimals); final canonical = fiatValue.toStringAsFixed(fiat.decimals); return localeConfig.localize(canonical); @@ -359,18 +359,18 @@ CurrencyDisplayState _toFiatDisplayState( ExchangeRateService xRate, NumberFormattingService fmt, String hiddenText, { - required int quanDecimals, + required int tokenDecimals, required bool isFlipped, required bool isHidden, - required bool withQuanSymbol, + required bool withTokenSymbol, required LocaleNumberConfig localeConfig, }) { - final quanFormatted = fmt.formatBalance(amount, smartDecimals: quanDecimals, addSymbol: withQuanSymbol); + final tokenFormatted = fmt.formatBalance(amount, smartDecimals: tokenDecimals, addSymbol: withTokenSymbol); final fiatFormatted = selectedFiat.format(_toFiatNumeric(amount, selectedFiat, xRate, localeConfig: localeConfig)); CurrencyDisplayState data = CurrencyDisplayState( - primaryAmount: isFlipped ? fiatFormatted : quanFormatted, - secondaryAmount: isFlipped ? quanFormatted : fiatFormatted, + primaryAmount: isFlipped ? fiatFormatted : tokenFormatted, + secondaryAmount: isFlipped ? tokenFormatted : fiatFormatted, isFlipped: isFlipped, selectedFiat: selectedFiat, ); diff --git a/mobile-app/lib/providers/encrypted_send_provider.dart b/mobile-app/lib/providers/encrypted_send_provider.dart index 0f1e25fa4..33ce795bb 100644 --- a/mobile-app/lib/providers/encrypted_send_provider.dart +++ b/mobile-app/lib/providers/encrypted_send_provider.dart @@ -17,7 +17,7 @@ enum EncryptedSendPhase { /// Cancel requested; waiting for the operation to reach a safe stop. canceling, - /// Stopped before completion. [EncryptedSendState.submittedRecipientPlanck] + /// Stopped before completion. [EncryptedSendState.submittedRecipientToken] /// is non-zero when some batches had already paid out. cancelled, @@ -42,15 +42,15 @@ class EncryptedSendState { /// Amount already paid to the recipient by batches submitted before a /// cancellation (zero for a clean cancel). - final BigInt submittedRecipientPlanck; + final BigInt submittedRecipientToken; EncryptedSendState({ required this.phase, this.currentStep = 0, this.stepProgress = const {}, this.errorMessage, - BigInt? submittedRecipientPlanck, - }) : submittedRecipientPlanck = submittedRecipientPlanck ?? BigInt.zero; + BigInt? submittedRecipientToken, + }) : submittedRecipientToken = submittedRecipientToken ?? BigInt.zero; bool get isRunning => phase == EncryptedSendPhase.running || phase == EncryptedSendPhase.canceling; @@ -59,13 +59,13 @@ class EncryptedSendState { int? currentStep, Map? stepProgress, String? errorMessage, - BigInt? submittedRecipientPlanck, + BigInt? submittedRecipientToken, }) => EncryptedSendState( phase: phase ?? this.phase, currentStep: currentStep ?? this.currentStep, stepProgress: stepProgress ?? this.stepProgress, errorMessage: errorMessage ?? this.errorMessage, - submittedRecipientPlanck: submittedRecipientPlanck ?? this.submittedRecipientPlanck, + submittedRecipientToken: submittedRecipientToken ?? this.submittedRecipientToken, ); } @@ -104,10 +104,10 @@ class EncryptedSendController extends Notifier { _service = service; try { - // The plan pays exactly its own amountPlanck — refuse to prove a plan + // The plan pays exactly its own amountToken — refuse to prove a plan // that doesn't match the amount the user confirmed at review. - if (plan.amountPlanck != amount) { - throw StateError('Encrypted send plan amount ${plan.amountPlanck} does not match confirmed amount $amount'); + if (plan.amountToken != amount) { + throw StateError('Encrypted send plan amount ${plan.amountToken} does not match confirmed amount $amount'); } // The plan was frozen at fee-estimate time; UTXO spendability can have @@ -142,7 +142,7 @@ class EncryptedSendController extends Notifier { if (result.cancelled) { // Cancelled after some batches had already paid out: report the // partial outcome, never a clean "cancelled". - state = state.copyWith(phase: EncryptedSendPhase.cancelled, submittedRecipientPlanck: result.totalWithdrawn); + state = state.copyWith(phase: EncryptedSendPhase.cancelled, submittedRecipientToken: result.totalWithdrawn); return; } diff --git a/mobile-app/lib/providers/wallet_providers.dart b/mobile-app/lib/providers/wallet_providers.dart index 768f0ced2..1a9f263be 100644 --- a/mobile-app/lib/providers/wallet_providers.dart +++ b/mobile-app/lib/providers/wallet_providers.dart @@ -100,11 +100,11 @@ final encryptedSpendableProvider = Provider.family, int>((ref }); final encryptedTotalReceivedProvider = Provider.family, int>((ref, walletIndex) { - return ref.watch(encryptedStateProvider(walletIndex)).whenData((s) => s.totalReceivedPlanck); + return ref.watch(encryptedStateProvider(walletIndex)).whenData((s) => s.totalReceivedToken); }); final encryptedTotalSpentProvider = Provider.family, int>((ref, walletIndex) { - return ref.watch(encryptedStateProvider(walletIndex)).whenData((s) => s.totalSpentPlanck); + return ref.watch(encryptedStateProvider(walletIndex)).whenData((s) => s.totalSpentToken); }); bool isEncryptedAccount(BaseAccount? account) => account is Account && account.accountType == AccountType.encrypted; @@ -293,7 +293,7 @@ final walletOriginProvider = Provider.family((ref, walletInd return ref.watch(settingsServiceProvider).getWalletOrigin(walletIndex); }); -/// 0.0001 QUAN in raw units; dust below this doesn't warrant a backup nudge. +/// 0.0001 tokens in smallest units; dust below this doesn't warrant a backup nudge. final _backupNudgeBalanceThreshold = BigInt.from(10).pow(AppConstants.decimals - 4); /// Wallet index needing a recovery phrase backup reminder, or null when none. diff --git a/mobile-app/lib/services/exchange_rate_service.dart b/mobile-app/lib/services/exchange_rate_service.dart index 511d9bcf8..54d8b3156 100644 --- a/mobile-app/lib/services/exchange_rate_service.dart +++ b/mobile-app/lib/services/exchange_rate_service.dart @@ -1,12 +1,12 @@ import 'package:decimal/decimal.dart'; import 'package:resonance_network_wallet/models/fiat_currency.dart'; -/// Provides QUAN → fiat exchange rates. +/// Provides token → fiat exchange rates. /// /// Constructed with a live [rates] map (ISO-4217 code → value in that currency /// per 1 USD). Falls back to [fallbackRates] for any code not present. /// -/// [quanToUsdRate] defaults to `1` (1 QUAN = 1 USD). Wire a dedicated QUAN +/// [tokenToUsdRate] defaults to `1` (1 token = 1 USD). Wire a dedicated tokens /// price feed into this field when one becomes available. class ExchangeRateService { /// Static rates used before any live or cached data is available (e.g. on @@ -23,11 +23,11 @@ class ExchangeRateService { }; final Map _rates; - final Decimal quanToUsdRate; + final Decimal tokenToUsdRate; - ExchangeRateService({required Map rates, Decimal? quanToUsdRate}) + ExchangeRateService({required Map rates, Decimal? tokenToUsdRate}) : _rates = rates, - quanToUsdRate = quanToUsdRate ?? Decimal.one; + tokenToUsdRate = tokenToUsdRate ?? Decimal.one; /// Returns the exchange rate for [fiat] (units per 1 USD). Decimal getRate(FiatCurrency fiat) { @@ -37,32 +37,32 @@ class ExchangeRateService { return rate; } - /// Converts [quanAmount] to [fiat] using the current rates. - Decimal convert(Decimal quanAmount, FiatCurrency fiat) { - final result = (quanAmount * quanToUsdRate * getRate(fiat)); + /// Converts [tokenAmount] to [fiat] using the current rates. + Decimal convert(Decimal tokenAmount, FiatCurrency fiat) { + final result = (tokenAmount * tokenToUsdRate * getRate(fiat)); // Round to fiat precision to ensure stable round-trips return Decimal.parse(result.toStringAsFixed(fiat.decimals)); } - /// Converts a raw QUAN [BigInt] (scaled by 10^[quanDecimals]) to a fiat [Decimal]. + /// Converts a raw tokens [BigInt] (scaled by 10^[tokenDecimals]) to a fiat [Decimal]. /// /// Centralises the scale-factor arithmetic so both display providers and the /// send screen share a single, testable conversion path. - Decimal quanRawToFiat(BigInt rawQuan, FiatCurrency fiat, int quanDecimals) { - final scaleFactor = BigInt.from(10).pow(quanDecimals); - final quanDecimal = (Decimal.fromBigInt(rawQuan) / Decimal.fromBigInt(scaleFactor)).toDecimal(); - return convert(quanDecimal, fiat); + Decimal tokenToFiat(BigInt tokenAmount, FiatCurrency fiat, int tokenDecimals) { + final scaleFactor = BigInt.from(10).pow(tokenDecimals); + final tokenDecimal = (Decimal.fromBigInt(tokenAmount) / Decimal.fromBigInt(scaleFactor)).toDecimal(); + return convert(tokenDecimal, fiat); } - /// Converts a [fiatAmount] back to raw QUAN [BigInt] scaled by 10^[quanDecimals]. + /// Converts a [fiatAmount] back to raw tokens [BigInt] scaled by 10^[tokenDecimals]. /// - /// Uses the inverse of [convert]: fiat / (quanToUsdRate × rate). + /// Uses the inverse of [convert]: fiat / (tokenToUsdRate × rate). /// Returns [BigInt.zero] when the effective rate is zero. - BigInt fiatToQuanRaw(Decimal fiatAmount, FiatCurrency fiat, int quanDecimals) { - final effectiveRate = quanToUsdRate * getRate(fiat); + BigInt fiatToToken(Decimal fiatAmount, FiatCurrency fiat, int tokenDecimals) { + final effectiveRate = tokenToUsdRate * getRate(fiat); if (effectiveRate == Decimal.zero) return BigInt.zero; - final scaleFactor = Decimal.fromBigInt(BigInt.from(10).pow(quanDecimals)); - final quanDecimal = (fiatAmount / effectiveRate).toDecimal(scaleOnInfinitePrecision: quanDecimals); - return (quanDecimal * scaleFactor).toBigInt(); + final scaleFactor = Decimal.fromBigInt(BigInt.from(10).pow(tokenDecimals)); + final tokenDecimal = (fiatAmount / effectiveRate).toDecimal(scaleOnInfinitePrecision: tokenDecimals); + return (tokenDecimal * scaleFactor).toBigInt(); } } diff --git a/mobile-app/lib/services/pos_service.dart b/mobile-app/lib/services/pos_service.dart index 7af8a22bc..9462dda03 100644 --- a/mobile-app/lib/services/pos_service.dart +++ b/mobile-app/lib/services/pos_service.dart @@ -24,9 +24,9 @@ class PosService { return uri.toString(); } - PosPaymentRequest createPaymentRequest({required String accountId, required BigInt amountPlanck}) { + PosPaymentRequest createPaymentRequest({required String accountId, required BigInt amountToken}) { final refId = generateRefId(); - final wireAmount = _formattingService.formatWireAmount(amountPlanck); + final wireAmount = _formattingService.formatWireAmount(amountToken); final url = buildPaymentUrl(accountId: accountId, amount: wireAmount, refId: refId); return PosPaymentRequest(paymentUrl: url, refId: refId, amount: wireAmount); } diff --git a/mobile-app/lib/shared/utils/amount_input_logic.dart b/mobile-app/lib/shared/utils/amount_input_logic.dart index d176daa6e..72203c55e 100644 --- a/mobile-app/lib/shared/utils/amount_input_logic.dart +++ b/mobile-app/lib/shared/utils/amount_input_logic.dart @@ -30,49 +30,49 @@ class AmountInputLogic { required this.formattingService, }); - /// Converts a raw QUAN [BigInt] to a fiat input string using the current + /// Converts a token amount [BigInt] to a fiat input string using the current /// exchange rate and selected fiat currency, formatted for the user's locale. - String quanToFiatString(BigInt quanAmount) { - if (quanAmount == BigInt.zero) return ''; - final fiatValue = exchangeRateService.quanRawToFiat(quanAmount, selectedFiat, AppConstants.decimals); + String tokenToFiatString(BigInt tokenAmount) { + if (tokenAmount == BigInt.zero) return ''; + final fiatValue = exchangeRateService.tokenToFiat(tokenAmount, selectedFiat, AppConstants.decimals); final canonical = fiatValue.toStringAsFixed(selectedFiat.decimals); return localeConfig.localize(canonical, addGroupingSeparators: false); } /// Parses a locale-formatted fiat input string and returns the equivalent - /// raw QUAN [BigInt] scaled by [AppConstants.decimals]. + /// token amount [BigInt] scaled by [AppConstants.decimals]. /// /// Throws [InvalidNumberInputException] when [fiatText] cannot be parsed. - BigInt fiatStringToQuan(String fiatText) { + BigInt fiatStringToToken(String fiatText) { if (fiatText.isEmpty) return BigInt.zero; final fiatDecimal = localeConfig.parseDecimal(fiatText); - return exchangeRateService.fiatToQuanRaw(fiatDecimal, selectedFiat, AppConstants.decimals); + return exchangeRateService.fiatToToken(fiatDecimal, selectedFiat, AppConstants.decimals); } - /// Parses a QUAN amount string. - BigInt parseQuanAmount(String text) { + /// Parses a token amount string. + BigInt parseTokenAmount(String text) { if (text.isEmpty) return BigInt.zero; return formattingService.parseAmount(text) ?? BigInt.zero; } - /// Formats a QUAN amount for display in an input field. - String formatQuanAmount(BigInt amount) { + /// Formats a token amount for display in an input field. + String formatTokenAmount(BigInt amount) { if (amount == BigInt.zero) return ''; return formattingService.formatBalance(amount, smartDecimals: AppConstants.decimals, addThousandsSeparators: false); } - /// Returns the new input string and amount when toggling between QUAN and Fiat. + /// Returns the new input string and amount when toggling between tokens and Fiat. ToggledInputResult getToggledInput({required bool wasFlipped, required BigInt currentAmount}) { if (wasFlipped) { - // Fiat -> QUAN: The user was looking at a fiat amount. + // Fiat -> tokens: The user was looking at a fiat amount. // We already have currentAmount which was calculated from that fiat amount. - final text = formatQuanAmount(currentAmount); + final text = formatTokenAmount(currentAmount); return ToggledInputResult(text: text, amount: currentAmount); } else { - // QUAN -> Fiat: re-parse amount from the rounded fiat string so + // tokens -> Fiat: re-parse amount from the rounded fiat string so // the displayed value and amount stay in sync. - final text = quanToFiatString(currentAmount); - final newAmount = currentAmount == BigInt.zero ? BigInt.zero : fiatStringToQuan(text); + final text = tokenToFiatString(currentAmount); + final newAmount = currentAmount == BigInt.zero ? BigInt.zero : fiatStringToToken(text); return ToggledInputResult(text: text, amount: newAmount); } } @@ -80,9 +80,9 @@ class AmountInputLogic { /// Handles amount change and returns the updated BigInt amount. BigInt onAmountChanged({required String value, required bool isFlipped}) { if (isFlipped) { - return fiatStringToQuan(value); + return fiatStringToToken(value); } else { - return parseQuanAmount(value); + return parseTokenAmount(value); } } } diff --git a/mobile-app/lib/v2/components/amount_display_with_conversion.dart b/mobile-app/lib/v2/components/amount_display_with_conversion.dart index 1729fc1b4..23143a979 100644 --- a/mobile-app/lib/v2/components/amount_display_with_conversion.dart +++ b/mobile-app/lib/v2/components/amount_display_with_conversion.dart @@ -12,7 +12,7 @@ class AmountDisplayWithConversion extends StatelessWidget { final CrossAxisAlignment alignment; final bool colorizeAmount; final Color? amountColor; - final bool useQuanLogo; + final bool useTokenLogo; const AmountDisplayWithConversion({ super.key, @@ -21,7 +21,7 @@ class AmountDisplayWithConversion extends StatelessWidget { this.alignment = CrossAxisAlignment.center, this.colorizeAmount = false, this.amountColor, - this.useQuanLogo = false, + this.useTokenLogo = false, }); @override @@ -30,14 +30,14 @@ class AmountDisplayWithConversion extends StatelessWidget { final colors = context.colors; final primaryAmountColor = amountColor ?? (colorizeAmount ? colors.success : colors.textPrimary); - final quanLogoPrimarySize = 32.0; + final tokenLogoPrimarySize = 32.0; final secondaryAmountColor = colors.textTertiary; final secondaryAmountBaseStyle = text.paragraph?.copyWith( color: secondaryAmountColor, fontFamily: AppTextTheme.fontFamilySecondary, ); - final quanLogoSecondarySize = 12.0; + final tokenLogoSecondarySize = 12.0; final MainAxisAlignment mainAxisAlignment = switch (alignment) { CrossAxisAlignment.center => MainAxisAlignment.center, @@ -50,11 +50,11 @@ class AmountDisplayWithConversion extends StatelessWidget { Row( mainAxisAlignment: mainAxisAlignment, children: [ - if (useQuanLogo && !amountDisplay.isFlipped) ...[ + if (useTokenLogo && !amountDisplay.isFlipped) ...[ SvgPicture.asset( 'assets/v2/uppercase_q.svg', - width: quanLogoPrimarySize, - height: quanLogoPrimarySize, + width: tokenLogoPrimarySize, + height: tokenLogoPrimarySize, colorFilter: ColorFilter.mode(context.colors.textPrimary, BlendMode.srcIn), ), const SizedBox(width: 4), @@ -66,7 +66,7 @@ class AmountDisplayWithConversion extends StatelessWidget { text: amountDisplay.primaryAmount, style: text.conversionAmountPrimary?.copyWith(color: primaryAmountColor), ), - if (!useQuanLogo && !amountDisplay.isFlipped) ...[ + if (!useTokenLogo && !amountDisplay.isFlipped) ...[ const TextSpan(text: ' '), TextSpan( text: AppConstants.tokenSymbol, @@ -85,12 +85,12 @@ class AmountDisplayWithConversion extends StatelessWidget { Row( mainAxisAlignment: mainAxisAlignment, children: [ - if (useQuanLogo && amountDisplay.isFlipped) ...[ + if (useTokenLogo && amountDisplay.isFlipped) ...[ Text('≈ ', style: secondaryAmountBaseStyle), SvgPicture.asset( 'assets/v2/uppercase_q.svg', - width: quanLogoSecondarySize, - height: quanLogoSecondarySize, + width: tokenLogoSecondarySize, + height: tokenLogoSecondarySize, colorFilter: ColorFilter.mode(secondaryAmountColor, BlendMode.srcIn), ), const SizedBox(width: 2), diff --git a/mobile-app/lib/v2/components/decoded_call_view.dart b/mobile-app/lib/v2/components/decoded_call_view.dart index bbdd605c0..bf15ed812 100644 --- a/mobile-app/lib/v2/components/decoded_call_view.dart +++ b/mobile-app/lib/v2/components/decoded_call_view.dart @@ -63,10 +63,10 @@ class DecodedCallView extends ConsumerWidget { ), ); - case AmountField(:final label, :final planck, :final assetId, :final note): + case AmountField(:final label, :final token, :final assetId, :final note): final value = assetId == null - ? ref.watch(txAmountDisplayProvider)(planck, isSend: true).primaryAmount - : '$planck (asset #$assetId, raw units)'; + ? ref.watch(txAmountDisplayProvider)(token, isSend: true).primaryAmount + : '$token (asset #$assetId, raw units)'; return Padding( padding: const EdgeInsets.only(top: 6), child: Column( @@ -148,9 +148,9 @@ class DecodedCallHeadline { /// Either the recipient or the pallet, whichever this call has. String? get secondary => recipient ?? palletSubtitle; - /// [amountText] formats a native planck amount for the current locale and + /// [amountText] formats a native token amount for the current locale and /// currency display; injected so this stays independent of Riverpod. - static DecodedCallHeadline of(DecodedCall call, {required String Function(BigInt planck) amountText}) { + static DecodedCallHeadline of(DecodedCall call, {required String Function(BigInt token) amountText}) { final summary = call.summary; if (summary == null) { return DecodedCallHeadline( diff --git a/mobile-app/lib/v2/components/proposal_list_tile.dart b/mobile-app/lib/v2/components/proposal_list_tile.dart index 3b102ed9a..07e0385ae 100644 --- a/mobile-app/lib/v2/components/proposal_list_tile.dart +++ b/mobile-app/lib/v2/components/proposal_list_tile.dart @@ -18,7 +18,7 @@ class ProposalListTile extends ConsumerWidget { /// The proposal's decoded call, when the indexer supplied its bytes. /// /// Proposals are not always transfers, so when this is present the row names - /// the actual call instead of rendering a non-transfer as "0 QUAN to ''". + /// the actual call instead of rendering a non-transfer as "0 tokens to ''". final DecodedCall? call; const ProposalListTile({ @@ -40,7 +40,7 @@ class ProposalListTile extends ConsumerWidget { final decoded = call; final headline = decoded == null ? null - : DecodedCallHeadline.of(decoded, amountText: (planck) => formatAmount(planck, isSend: true).primaryAmount); + : DecodedCallHeadline.of(decoded, amountText: (token) => formatAmount(token, isSend: true).primaryAmount); final amountText = headline?.primary ?? formatAmount(amount, isSend: true).primaryAmount; final recipient = headline == null ? (recipientAddress.isEmpty ? null : AddressFormattingService.formatAddress(recipientAddress)) diff --git a/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart index f55ae3ee8..393ce6541 100644 --- a/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart +++ b/mobile-app/lib/v2/screens/activity/transaction_detail_sheet.dart @@ -183,7 +183,7 @@ class _AmountSection extends ConsumerWidget { final amount = ref.watch(txAmountDisplayProvider)( displayAmount, isSend: true, - withQuanSymbol: false, + withTokenSymbol: false, customHiddenText: '-----', ); diff --git a/mobile-app/lib/v2/screens/home/activity_section.dart b/mobile-app/lib/v2/screens/home/activity_section.dart index 0de8705c6..8ddb2d0c6 100644 --- a/mobile-app/lib/v2/screens/home/activity_section.dart +++ b/mobile-app/lib/v2/screens/home/activity_section.dart @@ -152,7 +152,7 @@ class _ActivitySectionState extends ConsumerState { ConstrainedBox( constraints: const BoxConstraints(maxWidth: 240), child: Text( - l10n.homeActivityEmptyMessage, + l10n.homeActivityEmptyMessage(AppConstants.tokenSymbol), textAlign: TextAlign.center, style: text.smallParagraph?.copyWith(color: colors.txItemIconDefault), ), diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 82d19d889..3889e260f 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -345,7 +345,7 @@ class _HomeScreenState extends ConsumerState { amountDisplay: display, onFlip: _toggleFlip, alignment: CrossAxisAlignment.center, - useQuanLogo: true, + useTokenLogo: true, ); }, loading: () => const Column( diff --git a/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart b/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart index 951d15063..2f4f555eb 100644 --- a/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart +++ b/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart @@ -291,11 +291,11 @@ class _MultisigActionConfirmSheetState extends ConsumerState _confirmWithHardware(Account signer, AppLocalizations l10n) async { final fmt = ref.read(numberFormattingServiceProvider); // Take the QR screen's headline from the decoded call, so it agrees with what - // this sheet showed — and does not claim "0 QUAN" for a non-transfer proposal. + // this sheet showed — and does not claim "0 tokens" for a non-transfer proposal. final headline = DecodedCallHeadline.of( _decodedProposalCall ?? _fallbackTransferCall, - amountText: (planck) => l10n.commonAmountBalance( - fmt.formatBalance(planck, smartDecimals: AppConstants.decimals), + amountText: (token) => l10n.commonAmountBalance( + fmt.formatBalance(token, smartDecimals: AppConstants.decimals), AppConstants.tokenSymbol, ), ); @@ -354,8 +354,8 @@ class _MultisigActionConfirmSheetState extends ConsumerState l10n.commonAmountBalance( - fmt.formatBalance(planck, smartDecimals: AppConstants.decimals), + amountText: (token) => l10n.commonAmountBalance( + fmt.formatBalance(token, smartDecimals: AppConstants.decimals), AppConstants.tokenSymbol, ), ); diff --git a/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart b/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart index c0c0d0f87..e49f7b778 100644 --- a/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart +++ b/mobile-app/lib/v2/screens/multisig/multisig_proposal_detail_sheet.dart @@ -696,7 +696,7 @@ class _AmountSection extends ConsumerWidget { ); } - final amount = ref.watch(txAmountDisplayProvider)(proposal.amount, isSend: true, withQuanSymbol: false); + final amount = ref.watch(txAmountDisplayProvider)(proposal.amount, isSend: true, withTokenSymbol: false); return AmountDisplayWithConversion(amountDisplay: amount); } diff --git a/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart b/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart index be1ed6c51..d0f80997f 100644 --- a/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart +++ b/mobile-app/lib/v2/screens/pos/pos_amount_screen.dart @@ -60,7 +60,7 @@ class _PosAmountScreenState extends ConsumerState { void _onCharge() { if (_amount <= BigInt.zero) return; - Navigator.push(context, MaterialPageRoute(builder: (_) => PosQrScreen(amountPlanck: _amount))); + Navigator.push(context, MaterialPageRoute(builder: (_) => PosQrScreen(amountToken: _amount))); } Future _toggleFlip() async { @@ -99,9 +99,9 @@ class _PosAmountScreenState extends ConsumerState { final display = ref.watch(txAmountDisplayProvider)( _amount, withSignPrefix: false, - quanDecimals: 4, + tokenDecimals: 4, isSend: true, - withQuanSymbol: false, + withTokenSymbol: false, ); final symbolStyle = text.transactionDetailAmountSymbol?.copyWith(color: colors.textPrimary); diff --git a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart index 0aa20569c..3a61080a4 100644 --- a/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart +++ b/mobile-app/lib/v2/screens/pos/pos_qr_screen.dart @@ -27,8 +27,8 @@ import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; class PosQrScreen extends ConsumerStatefulWidget { - final BigInt amountPlanck; - const PosQrScreen({super.key, required this.amountPlanck}); + final BigInt amountToken; + const PosQrScreen({super.key, required this.amountToken}); @override ConsumerState createState() => _PosQrScreenState(); @@ -58,8 +58,8 @@ class _PosQrScreenState extends ConsumerState { final active = ref.read(activeAccountProvider).value; if (active == null) return; - if (widget.amountPlanck <= BigInt.zero) { - quantusPrint('[PosQr] ERROR: invalid amount planck ${widget.amountPlanck}'); + if (widget.amountToken <= BigInt.zero) { + quantusPrint('[PosQr] ERROR: invalid token amount ${widget.amountToken}'); if (mounted) setState(() => _watchError = l10n.posQrInvalidAmount); return; } @@ -69,15 +69,15 @@ class _PosQrScreenState extends ConsumerState { _watchError = null; }); - quantusPrint('[PosQr] watching address=${active.account.accountId} expected=${widget.amountPlanck} planck'); + quantusPrint('[PosQr] watching address=${active.account.accountId} expected=${widget.amountToken} token units'); _txWatch.watch( address: active.account.accountId, onTransfer: (tx) { quantusPrint('[PosQr] onTransfer from=${tx.from} amount=${tx.amount} hash=${tx.txHash}'); if (_isPaid) return; final received = BigInt.tryParse(tx.amount); - if (received != widget.amountPlanck) { - quantusPrint('[PosQr] amount mismatch (received=$received expected=${widget.amountPlanck}), ignoring'); + if (received != widget.amountToken) { + quantusPrint('[PosQr] amount mismatch (received=$received expected=${widget.amountToken}), ignoring'); return; } @@ -86,7 +86,7 @@ class _PosQrScreenState extends ConsumerState { tempId: 'pending_recv_${DateTime.now().millisecondsSinceEpoch}', from: tx.from, to: active.account.accountId, - amount: widget.amountPlanck, + amount: widget.amountToken, timestamp: DateTime.now(), transactionState: TransactionState.pending, isReversible: false, @@ -163,10 +163,10 @@ class _PosQrScreenState extends ConsumerState { final accountAsync = ref.watch(activeAccountProvider); final formattingService = ref.watch(numberFormattingServiceProvider); final display = ref.watch(txAmountDisplayProvider)( - widget.amountPlanck, + widget.amountToken, withSignPrefix: false, isSend: false, - quanDecimals: 4, + tokenDecimals: 4, ); return ScaffoldBase( @@ -180,7 +180,7 @@ class _PosQrScreenState extends ConsumerState { if (active == null) return Center(child: Text(l10n.posQrNoActiveAccount)); _request ??= PosService( formattingService: formattingService, - ).createPaymentRequest(accountId: active.account.accountId, amountPlanck: widget.amountPlanck); + ).createPaymentRequest(accountId: active.account.accountId, amountToken: widget.amountToken); if (_isPaid) _buildPaidContent(l10n, appLocale.numberFormatLocale, colors, text, display.primaryAmount); return _buildQrContent(l10n, _request!, colors, text, display); }, 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 index 59afdd851..5e18f72f6 100644 --- a/mobile-app/lib/v2/screens/send/encrypted_send_progress_screen.dart +++ b/mobile-app/lib/v2/screens/send/encrypted_send_progress_screen.dart @@ -31,7 +31,7 @@ class EncryptedSendProgressScreen extends ConsumerStatefulWidget { final WormholeSpendPlan plan; /// The amount the user confirmed at review; the controller refuses to prove - /// a plan whose amountPlanck differs. + /// a plan whose amountToken differs. final BigInt amount; final String recipientAddress; final SendTerminalContent terminal; @@ -134,9 +134,9 @@ class _EncryptedSendProgressScreenState extends ConsumerState BigInt.zero) ...[ + if (cancelled && send.submittedRecipientToken > BigInt.zero) ...[ const SizedBox(height: 24), - _buildPartialCancelNotice(colors, text, l10n, send.submittedRecipientPlanck), + _buildPartialCancelNotice(colors, text, l10n, send.submittedRecipientToken), ], ], ), @@ -147,7 +147,7 @@ class _EncryptedSendProgressScreenState extends ConsumerState null, - EncryptedSendBlocker.notQuantized => l10n.encryptedSendAmountStep, + EncryptedSendBlocker.notQuantized => l10n.encryptedSendAmountStep(AppConstants.tokenSymbol), EncryptedSendBlocker.insufficient => l10n.sendLogicInsufficientBalance, - EncryptedSendBlocker.belowBatchMinimum => l10n.encryptedSendMinimum, + EncryptedSendBlocker.belowBatchMinimum => l10n.encryptedSendMinimum(AppConstants.tokenSymbol), }; } @@ -128,10 +128,10 @@ class EncryptedSendStrategy extends SendStrategy { if (plan == null) { throw StateError('Encrypted send reached submit without a spend plan'); } - // The plan is frozen at estimate time and its amountPlanck is what the + // The plan is frozen at estimate time and its amountToken is what the // recipient is provably paid — it must match the confirmed amount. - if (plan.amountPlanck != amount) { - throw StateError('Encrypted send plan amount ${plan.amountPlanck} does not match confirmed amount $amount'); + if (plan.amountToken != amount) { + throw StateError('Encrypted send plan amount ${plan.amountToken} does not match confirmed amount $amount'); } final authed = await LocalAuthService().authenticate(localizedReason: l10n.sendReviewAuthReason); diff --git a/mobile-app/lib/v2/screens/send/input_amount_screen.dart b/mobile-app/lib/v2/screens/send/input_amount_screen.dart index 311ad20e1..373c617aa 100644 --- a/mobile-app/lib/v2/screens/send/input_amount_screen.dart +++ b/mobile-app/lib/v2/screens/send/input_amount_screen.dart @@ -77,12 +77,12 @@ class _InputAmountScreenState extends ConsumerState { _amountFocus.addListener(_onAmountFocusChanged); if (widget.initialAmount != null && widget.initialAmount!.isNotEmpty) { final formattingService = ref.read(numberFormattingServiceProvider); - final planck = widget.isPayMode + final token = widget.isPayMode ? formattingService.parseWireAmount(widget.initialAmount!) ?? BigInt.zero - : _amountInputLogic.parseQuanAmount(widget.initialAmount!); - if (planck > BigInt.zero) { - _amount = planck; - _amountController.text = _amountInputLogic.formatQuanAmount(planck); + : _amountInputLogic.parseTokenAmount(widget.initialAmount!); + if (token > BigInt.zero) { + _amount = token; + _amountController.text = _amountInputLogic.formatTokenAmount(token); } } if (widget.recipientChecksum != null) { @@ -187,7 +187,7 @@ class _InputAmountScreenState extends ConsumerState { } } - /// Converts a raw QUAN [BigInt] to a fiat input string using the current + /// Converts a token amount [BigInt] to a fiat input string using the current /// exchange rate and selected fiat currency, formatted for the user's locale. void _setMax() { final spendable = ref.read(widget.strategy.spendableBalanceProvider).value ?? BigInt.zero; @@ -197,8 +197,8 @@ class _InputAmountScreenState extends ConsumerState { ); final isFlipped = ref.read(isCurrencyFlippedProvider); _amountController.text = isFlipped - ? _amountInputLogic.quanToFiatString(max) - : _amountInputLogic.formatQuanAmount(max); + ? _amountInputLogic.tokenToFiatString(max) + : _amountInputLogic.formatTokenAmount(max); setState(() => _amount = max); _invalidateFee(); _refreshFee(); @@ -214,7 +214,7 @@ class _InputAmountScreenState extends ConsumerState { _amountController.text = result.text; _amount = result.amount; }); - // The flip can change the planck amount (fiat rounding), so the plan must + // The flip can change the token amount (fiat rounding), so the plan must // be re-estimated for the new amount. _invalidateFee(); _refreshFee(); @@ -392,9 +392,9 @@ class _InputAmountScreenState extends ConsumerState { final display = ref.watch(txAmountDisplayProvider)( _amount, withSignPrefix: false, - quanDecimals: 4, + tokenDecimals: 4, isSend: true, - withQuanSymbol: false, + withTokenSymbol: false, ); final symbolStyle = text.transactionDetailAmountSymbol?.copyWith(color: colors.textPrimary); @@ -424,7 +424,7 @@ class _InputAmountScreenState extends ConsumerState { final symbolWidget = Text(isFlipped ? selectedFiat.symbol : AppConstants.tokenSymbol, style: symbolStyle); // For prefix fiat currencies (e.g. $, Rp) place symbol before the field; - // for suffix currencies and QUAN keep it after. + // for suffix currencies and the token symbol keep it after. final List primaryRowChildren = isPrefixFiat ? [symbolWidget, const SizedBox(width: 8), inputField] : [inputField, const SizedBox(width: 8), symbolWidget]; 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 35cc9a627..94af127c6 100644 --- a/mobile-app/lib/v2/screens/send/review_send_screen.dart +++ b/mobile-app/lib/v2/screens/send/review_send_screen.dart @@ -126,8 +126,8 @@ class _ReviewSendScreenState extends ConsumerState { widget.amount, isSend: true, withSignPrefix: false, - withQuanSymbol: false, - quanDecimals: 4, + withTokenSymbol: false, + tokenDecimals: 4, ); return ScaffoldBase( diff --git a/mobile-app/lib/v2/screens/send/send_strategy.dart b/mobile-app/lib/v2/screens/send/send_strategy.dart index a0b1d8f37..bf447c2e8 100644 --- a/mobile-app/lib/v2/screens/send/send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/send_strategy.dart @@ -70,7 +70,7 @@ class EncryptedFee extends SendFee { const EncryptedFee({this.plan, this.blocker}); @override - BigInt get displayFee => plan?.feePlanck ?? BigInt.zero; + BigInt get displayFee => plan?.feeToken ?? BigInt.zero; } /// Content for the shared terminal (success) screen. All strings are resolved @@ -141,7 +141,7 @@ 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]. /// [amount] is the confirmed amount; the controller re-checks it against -/// [plan] before proving, since the plan pays exactly its own amountPlanck. +/// [plan] before proving, since the plan pays exactly its own amountToken. class SendNeedsProving extends SendOutcome { final Account account; final WormholeSpendPlan plan; diff --git a/mobile-app/lib/v2/screens/settings/mining_rewards_screen.dart b/mobile-app/lib/v2/screens/settings/mining_rewards_screen.dart index 6c90e0788..47970de60 100644 --- a/mobile-app/lib/v2/screens/settings/mining_rewards_screen.dart +++ b/mobile-app/lib/v2/screens/settings/mining_rewards_screen.dart @@ -70,7 +70,7 @@ class _WithRewards extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = ref.watch(l10nProvider); final numberFmt = ref.watch(numberFormattingServiceProvider); - final quanEarned = numberFmt.formatBalance(data.planckRewards, smartDecimals: 2, addSymbol: true); + final tokenEarned = numberFmt.formatBalance(data.planckRewards, smartDecimals: 2, addSymbol: true); final redeemedRewards = numberFmt.formatBalance(data.redeemedRewards, smartDecimals: 2, addSymbol: true); final redeemableRewards = numberFmt.formatBalance(data.redeemableRewards, smartDecimals: 2, addSymbol: true); @@ -92,7 +92,7 @@ class _WithRewards extends ConsumerWidget { ), right: _MiningStatCell( label: l10n.settingsMiningStatTestnetRewards, - value: quanEarned, + value: tokenEarned, valueColor: colors.accentOrange, ), ), @@ -174,7 +174,7 @@ class _NoRewards extends StatelessWidget { isLoading: isLoading, ), bottomChild: _StatColumn( - label: l10n.settingsMiningQuanEarned, + label: l10n.settingsMiningTokenEarned(AppConstants.tokenSymbol), value: '0.00', valueColor: colors.textTertiary, isLoading: isLoading, diff --git a/mobile-app/lib/v2/screens/swap/deposit_screen.dart b/mobile-app/lib/v2/screens/swap/deposit_screen.dart index 58f53fc4b..323725aa2 100644 --- a/mobile-app/lib/v2/screens/swap/deposit_screen.dart +++ b/mobile-app/lib/v2/screens/swap/deposit_screen.dart @@ -263,7 +263,7 @@ class _DepositScreenState extends ConsumerState { Text(l10n.swapDepositCompleteTitle, style: text.smallTitle?.copyWith(color: colors.textPrimary, fontSize: 20)), const SizedBox(height: 12), Text( - l10n.swapDepositCompleteBody(amount), + l10n.swapDepositCompleteBody(amount, AppConstants.tokenSymbol), style: text.paragraph?.copyWith(color: colors.textSecondary), textAlign: TextAlign.center, ), diff --git a/mobile-app/lib/v2/screens/swap/swap_screen.dart b/mobile-app/lib/v2/screens/swap/swap_screen.dart index 028318e55..3cbb42a2f 100644 --- a/mobile-app/lib/v2/screens/swap/swap_screen.dart +++ b/mobile-app/lib/v2/screens/swap/swap_screen.dart @@ -41,7 +41,7 @@ class _SwapScreenState extends ConsumerState { String _rateLabel(AppLocalizations l10n) { final val = 1 / _rate; - if (val == 0) return l10n.swapRateZero(_fromToken.symbol); + if (val == 0) return l10n.swapRateZero(_fromToken.symbol, AppConstants.tokenSymbol); final decimals = val >= 100 ? 2 : val >= 1 @@ -53,7 +53,7 @@ class _SwapScreenState extends ConsumerState { : 10; var formatted = val.toStringAsFixed(decimals).replaceAll(RegExp(r'0+$'), ''); if (formatted.endsWith('.')) formatted = formatted.substring(0, formatted.length - 1); - return l10n.swapRateLabel(formatted, _fromToken.symbol); + return l10n.swapRateLabel(formatted, _fromToken.symbol, AppConstants.tokenSymbol); } @override @@ -370,7 +370,7 @@ class _SwapScreenState extends ConsumerState { TokenIcon(token: _swapService.getQuToken(), size: 25, networkBadgeSize: 10), const SizedBox(width: 8), Text( - 'QUAN', + AppConstants.tokenSymbol, style: text.smallParagraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w600), ), ], diff --git a/mobile-app/test/unit/amount_input_logic_test.dart b/mobile-app/test/unit/amount_input_logic_test.dart index 85a04294e..d2d618d0f 100644 --- a/mobile-app/test/unit/amount_input_logic_test.dart +++ b/mobile-app/test/unit/amount_input_logic_test.dart @@ -26,37 +26,37 @@ void main() { ); } - test('quanToFiatString converts correctly', () { + test('tokenToFiatString converts correctly', () { final logic = createLogic(); - final amount = BigInt.from(1000000000000); // 1.0 QUAN - expect(logic.quanToFiatString(amount), '1.00'); + final amount = BigInt.from(1000000000000); // 1.0 tokens + expect(logic.tokenToFiatString(amount), '1.00'); }); - test('fiatStringToQuan parses correctly', () { + test('fiatStringToToken parses correctly', () { final logic = createLogic(); - final result = logic.fiatStringToQuan('1.00'); + final result = logic.fiatStringToToken('1.00'); expect(result, BigInt.from(1000000000000)); }); - test('getToggledInput handles QUAN -> Fiat toggle', () { + test('getToggledInput handles tokens -> Fiat toggle', () { final logic = createLogic(); - final amount = BigInt.from(1500000000000); // 1.5 QUAN + final amount = BigInt.from(1500000000000); // 1.5 tokens final result = logic.getToggledInput(wasFlipped: false, currentAmount: amount); expect(result.text, '1.50'); expect(result.amount, BigInt.from(1500000000000)); }); - test('getToggledInput handles Fiat -> QUAN toggle', () { + test('getToggledInput handles Fiat -> tokens toggle', () { final logic = createLogic(); - final amount = BigInt.from(1500000000000); // 1.5 QUAN + final amount = BigInt.from(1500000000000); // 1.5 tokens final result = logic.getToggledInput(wasFlipped: true, currentAmount: amount); expect(result.text, '1.5'); expect(result.amount, amount); }); - test('onAmountChanged handles QUAN input', () { + test('onAmountChanged handles token input', () { final logic = createLogic(); final result = logic.onAmountChanged(value: '1.5', isFlipped: false); expect(result, BigInt.from(1500000000000)); @@ -68,14 +68,14 @@ void main() { expect(result, BigInt.from(1500000000000)); }); - test('quanToFiatString returns empty string for zero', () { + test('tokenToFiatString returns empty string for zero', () { final logic = createLogic(); - expect(logic.quanToFiatString(BigInt.zero), ''); + expect(logic.tokenToFiatString(BigInt.zero), ''); }); - test('formatQuanAmount returns empty string for zero', () { + test('formatTokenAmount returns empty string for zero', () { final logic = createLogic(); - expect(logic.formatQuanAmount(BigInt.zero), ''); + expect(logic.formatTokenAmount(BigInt.zero), ''); }); }); } diff --git a/mobile-app/test/unit/exchange_rate_service_test.dart b/mobile-app/test/unit/exchange_rate_service_test.dart index 614e1bada..7bd51b848 100644 --- a/mobile-app/test/unit/exchange_rate_service_test.dart +++ b/mobile-app/test/unit/exchange_rate_service_test.dart @@ -4,7 +4,7 @@ import 'package:resonance_network_wallet/models/fiat_currency.dart'; import 'package:resonance_network_wallet/services/exchange_rate_service.dart'; void main() { - // 1 QUAN = 1 USD; 1 USD = 3.97 MYR, 17334 IDR (zero-decimal currency). + // 1 token = 1 USD; 1 USD = 3.97 MYR, 17334 IDR (zero-decimal currency). final rates = {'USD': Decimal.parse('1'), 'MYR': Decimal.parse('3.97'), 'IDR': Decimal.parse('17334')}; late ExchangeRateService service; @@ -26,98 +26,98 @@ void main() { }); group('ExchangeRateService.convert', () { - test('converts 1 QUAN to USD correctly (1 QUAN = 1 USD)', () { + test('converts 1 token to USD correctly (1 token = 1 USD)', () { expect(service.convert(Decimal.one, FiatCurrency.usd), Decimal.one); }); - test('converts 1 QUAN to MYR (1 QUAN = 4 MYR)', () { + test('converts 1 token to MYR (1 token = 4 MYR)', () { expect(service.convert(Decimal.one, FiatCurrency.myr), Decimal.parse('3.97')); }); - test('converts 0.5 QUAN to MYR (0.5 × 3.97 = 1.99)', () { + test('converts 0.5 tokens to MYR (0.5 × 3.97 = 1.99)', () { expect(service.convert(Decimal.parse('0.5'), FiatCurrency.myr), Decimal.parse('1.99')); }); - test('applies quanToUsdRate when set', () { - final serviceWith2xRate = ExchangeRateService(rates: rates, quanToUsdRate: Decimal.parse('2')); - // 1 QUAN × 2 USD/QUAN × 3.97 MYR/USD = 7.94 MYR + test('applies tokenToUsdRate when set', () { + final serviceWith2xRate = ExchangeRateService(rates: rates, tokenToUsdRate: Decimal.parse('2')); + // 1 token × 2 USD/token × 3.97 MYR/USD = 7.94 MYR expect(serviceWith2xRate.convert(Decimal.one, FiatCurrency.myr), Decimal.parse('7.94')); }); }); - group('ExchangeRateService.quanRawToFiat', () { + group('ExchangeRateService.tokenToFiat', () { // 12 decimal places (AppConstants.decimals) - const quanDecimals = 12; - final oneQuan = BigInt.from(10).pow(quanDecimals); // 1.000000000000 QUAN + const tokenDecimals = 12; + final oneToken = BigInt.from(10).pow(tokenDecimals); // 1.000000000000 tokens - test('1 QUAN raw → 1.00 USD', () { - expect(service.quanRawToFiat(oneQuan, FiatCurrency.usd, quanDecimals), Decimal.one); + test('1 token → 1.00 USD', () { + expect(service.tokenToFiat(oneToken, FiatCurrency.usd, tokenDecimals), Decimal.one); }); - test('1 QUAN raw → 3.97 MYR', () { - expect(service.quanRawToFiat(oneQuan, FiatCurrency.myr, quanDecimals), Decimal.parse('3.97')); + test('1 token → 3.97 MYR', () { + expect(service.tokenToFiat(oneToken, FiatCurrency.myr, tokenDecimals), Decimal.parse('3.97')); }); - test('0.5 QUAN raw → 1.99 MYR', () { - final halfQuan = BigInt.from(5) * BigInt.from(10).pow(quanDecimals - 1); - expect(service.quanRawToFiat(halfQuan, FiatCurrency.myr, quanDecimals), Decimal.parse('1.99')); + test('0.5 tokens → 1.99 MYR', () { + final halfToken = BigInt.from(5) * BigInt.from(10).pow(tokenDecimals - 1); + expect(service.tokenToFiat(halfToken, FiatCurrency.myr, tokenDecimals), Decimal.parse('1.99')); }); - test('zero QUAN raw → zero fiat', () { - expect(service.quanRawToFiat(BigInt.zero, FiatCurrency.usd, quanDecimals), Decimal.zero); + test('zero tokens → zero fiat', () { + expect(service.tokenToFiat(BigInt.zero, FiatCurrency.usd, tokenDecimals), Decimal.zero); }); }); - group('ExchangeRateService.fiatToQuanRaw', () { - const quanDecimals = 12; - final oneQuan = BigInt.from(10).pow(quanDecimals); + group('ExchangeRateService.fiatToToken', () { + const tokenDecimals = 12; + final oneToken = BigInt.from(10).pow(tokenDecimals); - test('1 USD → 1 QUAN raw', () { - expect(service.fiatToQuanRaw(Decimal.one, FiatCurrency.usd, quanDecimals), oneQuan); + test('1 USD → 1 token', () { + expect(service.fiatToToken(Decimal.one, FiatCurrency.usd, tokenDecimals), oneToken); }); - test('3.97 MYR → 1 QUAN raw', () { - expect(service.fiatToQuanRaw(Decimal.parse('3.97'), FiatCurrency.myr, quanDecimals), oneQuan); + test('3.97 MYR → 1 token', () { + expect(service.fiatToToken(Decimal.parse('3.97'), FiatCurrency.myr, tokenDecimals), oneToken); }); - test('1.985 MYR → 0.5 QUAN raw', () { - final halfQuan = BigInt.from(5) * BigInt.from(10).pow(quanDecimals - 1); - expect(service.fiatToQuanRaw(Decimal.parse('1.985'), FiatCurrency.myr, quanDecimals), halfQuan); + test('1.985 MYR → 0.5 tokens', () { + final halfToken = BigInt.from(5) * BigInt.from(10).pow(tokenDecimals - 1); + expect(service.fiatToToken(Decimal.parse('1.985'), FiatCurrency.myr, tokenDecimals), halfToken); }); - test('zero fiat → zero QUAN raw', () { - expect(service.fiatToQuanRaw(Decimal.zero, FiatCurrency.usd, quanDecimals), BigInt.zero); + test('zero fiat → zero tokens', () { + expect(service.fiatToToken(Decimal.zero, FiatCurrency.usd, tokenDecimals), BigInt.zero); }); - test('quanRawToFiat and fiatToQuanRaw are inverses for clean-divisor rates', () { - const quanDecimals = 12; - final original = BigInt.from(1_000_000_000_000); // 1.0 QUAN - final fiatValue = service.quanRawToFiat(original, FiatCurrency.myr, quanDecimals); - final roundTripped = service.fiatToQuanRaw(fiatValue, FiatCurrency.myr, quanDecimals); + test('tokenToFiat and fiatToToken are inverses for clean-divisor rates', () { + const tokenDecimals = 12; + final original = BigInt.from(1_000_000_000_000); // 1.0 tokens + final fiatValue = service.tokenToFiat(original, FiatCurrency.myr, tokenDecimals); + final roundTripped = service.fiatToToken(fiatValue, FiatCurrency.myr, tokenDecimals); expect(roundTripped, original); }); test('round-trip is stable for non-clean-divisor rates (anchors to fiat precision)', () { - // 3.971 doesn't cleanly divide 1.5 QUAN's fiat value. + // 3.971 doesn't cleanly divide 1.5 tokens's fiat value. // By rounding the intermediate fiat value to fiat.decimals (2), - // we ensure that the round-tripped QUAN value is the canonical QUAN + // we ensure that the round-tripped tokens value is the canonical tokens // representation of that specific fiat amount. - const quanDecimals = 12; + const tokenDecimals = 12; final lossyService = ExchangeRateService(rates: {'MYR': Decimal.parse('3.971')}); - final original = BigInt.from(1_500_000_000_000); // 1.5 QUAN + final original = BigInt.from(1_500_000_000_000); // 1.5 tokens // 1.5 * 3.971 = 5.9565 -> rounded to 5.96 MYR - final fiatValue = lossyService.quanRawToFiat(original, FiatCurrency.myr, quanDecimals); + final fiatValue = lossyService.tokenToFiat(original, FiatCurrency.myr, tokenDecimals); expect(fiatValue, Decimal.parse('5.96')); - final roundTripped = lossyService.fiatToQuanRaw(fiatValue, FiatCurrency.myr, quanDecimals); + final roundTripped = lossyService.fiatToToken(fiatValue, FiatCurrency.myr, tokenDecimals); // Subsequent round-trips from this fiatValue should be identical - final secondFiat = lossyService.quanRawToFiat(roundTripped, FiatCurrency.myr, quanDecimals); - final secondQUAN = lossyService.fiatToQuanRaw(secondFiat, FiatCurrency.myr, quanDecimals); + final secondFiat = lossyService.tokenToFiat(roundTripped, FiatCurrency.myr, tokenDecimals); + final secondToken = lossyService.fiatToToken(secondFiat, FiatCurrency.myr, tokenDecimals); expect(secondFiat, fiatValue); - expect(secondQUAN, roundTripped); + expect(secondToken, roundTripped); }); }); diff --git a/mobile-app/test/unit/locale_number_handling_test.dart b/mobile-app/test/unit/locale_number_handling_test.dart index 521a56add..fd36a11bc 100644 --- a/mobile-app/test/unit/locale_number_handling_test.dart +++ b/mobile-app/test/unit/locale_number_handling_test.dart @@ -538,12 +538,12 @@ void main() { final idService = NumberFormattingService(localeConfig: LocaleNumberConfig.commaDecimal); final scaleFactor = BigInt.from(10).pow(NumberFormattingService.decimals); - test('Indonesian user inputs 1.000 intending Rp 1000 (not 1.000 QUAN)', () { + test('Indonesian user inputs 1.000 intending Rp 1000 (not 1.000 tokens)', () { final parsed = idService.parseAmount('1.000'); expect(parsed, scaleFactor * BigInt.from(1000)); }); - test('US user inputs 1.000 intending 1 QUAN with trailing zeros', () { + test('US user inputs 1.000 intending 1 token with trailing zeros', () { final parsed = usService.parseAmount('1.000'); expect(parsed, scaleFactor * BigInt.one); }); diff --git a/mobile-app/test/unit/pos_service_test.dart b/mobile-app/test/unit/pos_service_test.dart index 774592b92..da757d41e 100644 --- a/mobile-app/test/unit/pos_service_test.dart +++ b/mobile-app/test/unit/pos_service_test.dart @@ -7,9 +7,9 @@ void main() { test('createPaymentRequest embeds canonical dot-decimal wire amount', () { final formattingService = NumberFormattingService(localeConfig: LocaleNumberConfig.commaDecimal); final service = PosService(formattingService: formattingService); - final amountPlanck = BigInt.parse('1500000000000'); + final amountToken = BigInt.parse('1500000000000'); - final request = service.createPaymentRequest(accountId: 'account123', amountPlanck: amountPlanck); + final request = service.createPaymentRequest(accountId: 'account123', amountToken: amountToken); expect(request.amount, '1.5'); expect(request.paymentUrl, contains('amount=1.5')); diff --git a/quantus_sdk/lib/src/chain/decoded_call.dart b/quantus_sdk/lib/src/chain/decoded_call.dart index b534de470..b6a628093 100644 --- a/quantus_sdk/lib/src/chain/decoded_call.dart +++ b/quantus_sdk/lib/src/chain/decoded_call.dart @@ -1,7 +1,7 @@ /// Display model for a decoded runtime call. /// /// Deliberately UI-agnostic and free of locale/formatting concerns: amounts stay -/// as raw planck [BigInt]s and addresses as ss58 strings, so the same tree can be +/// as smallest-unit [BigInt]s and addresses as ss58 strings, so the same tree can be /// rendered by the hot wallet's proposal sheets and by the air-gapped cold /// wallet's signing screen. /// @@ -52,16 +52,16 @@ class ValueField extends CallField { const ValueField(super.label, this.value, {this.kind = ValueKind.text, this.note}); } -/// A balance parameter. Kept as raw planck so the renderer owns formatting. +/// A balance parameter. Kept as a smallest-unit [BigInt] so the renderer owns formatting. class AmountField extends CallField { - final BigInt planck; + final BigInt token; /// Asset id for non-native balances; null means the native token. final int? assetId; final String? note; - const AmountField(super.label, this.planck, {this.assetId, this.note}); + const AmountField(super.label, this.token, {this.assetId, this.note}); } /// A call nested inside this one: a multisig proposal's inner call, a batch diff --git a/quantus_sdk/lib/src/models/multisig_proposal.dart b/quantus_sdk/lib/src/models/multisig_proposal.dart index 030100eb2..fce2f5ba3 100644 --- a/quantus_sdk/lib/src/models/multisig_proposal.dart +++ b/quantus_sdk/lib/src/models/multisig_proposal.dart @@ -40,7 +40,7 @@ class MultisigProposal { /// Balances transfer recipient, or empty when not a transfer. final String recipient; - /// Balances transfer amount in planck, or zero when not a transfer. + /// Balances transfer amount in token units, or zero when not a transfer. final BigInt amount; /// SCALE-encoded inner call as indexed, when available. diff --git a/quantus_sdk/lib/src/models/multisig_proposal_approved_event.dart b/quantus_sdk/lib/src/models/multisig_proposal_approved_event.dart index a03bc984b..10c5b8997 100644 --- a/quantus_sdk/lib/src/models/multisig_proposal_approved_event.dart +++ b/quantus_sdk/lib/src/models/multisig_proposal_approved_event.dart @@ -83,7 +83,7 @@ class MultisigProposalApprovedEvent extends TransactionEvent { : int.tryParse(approvalsCountRaw?.toString() ?? '') ?? proposal?.approvalCount ?? 0; final block = jsonMapOrNull(approved['block']); - final feeRaw = approved['fee']; + final feeToken = approved['fee']; return MultisigProposalApprovedEvent( id: accountEventId ?? stringFromJson(approved['id']), @@ -93,7 +93,7 @@ class MultisigProposalApprovedEvent extends TransactionEvent { amount: amount, proposalId: proposalId, approvalsCount: approvalsCount, - fee: feeRaw != null ? bigIntFromJson(feeRaw) : null, + fee: feeToken != null ? bigIntFromJson(feeToken) : null, timestamp: accountEventTimestamp ?? dateTimeFromJson(approved['timestamp']), blockNumber: blockHeightFromJsonMap(block), blockHash: blockHashFromJsonMap(block), diff --git a/quantus_sdk/lib/src/models/multisig_proposal_cancelled_event.dart b/quantus_sdk/lib/src/models/multisig_proposal_cancelled_event.dart index f0d5c5af3..73c4b2110 100644 --- a/quantus_sdk/lib/src/models/multisig_proposal_cancelled_event.dart +++ b/quantus_sdk/lib/src/models/multisig_proposal_cancelled_event.dart @@ -84,7 +84,7 @@ class MultisigProposalCancelledEvent extends TransactionEvent { } final block = jsonMapOrNull(cancelled['block']); - final feeRaw = cancelled['fee']; + final feeToken = cancelled['fee']; return MultisigProposalCancelledEvent( id: accountEventId ?? stringFromJson(cancelled['id']), @@ -93,7 +93,7 @@ class MultisigProposalCancelledEvent extends TransactionEvent { recipient: recipient, amount: amount, proposalId: proposalId, - fee: feeRaw != null ? bigIntFromJson(feeRaw) : null, + fee: feeToken != null ? bigIntFromJson(feeToken) : null, timestamp: accountEventTimestamp ?? dateTimeFromJson(cancelled['timestamp']), blockNumber: blockHeightFromJsonMap(block), blockHash: blockHashFromJsonMap(block), diff --git a/quantus_sdk/lib/src/models/multisig_proposal_created_event.dart b/quantus_sdk/lib/src/models/multisig_proposal_created_event.dart index 855cccfdf..4116a1140 100644 --- a/quantus_sdk/lib/src/models/multisig_proposal_created_event.dart +++ b/quantus_sdk/lib/src/models/multisig_proposal_created_event.dart @@ -93,7 +93,7 @@ class MultisigProposalCreatedEvent extends TransactionEvent { } final block = jsonMapOrNull(created['block']); - final feeRaw = created['fee'] ?? proposalJson?['creation_network_fee'] ?? proposalJson?['creationNetworkFee']; + final feeToken = created['fee'] ?? proposalJson?['creation_network_fee'] ?? proposalJson?['creationNetworkFee']; final signerCount = proposal?.signerCount ?? _signerCountFromProposalJson(proposalJson); final palletFee = burnedPalletFeeOverride ?? @@ -108,7 +108,7 @@ class MultisigProposalCreatedEvent extends TransactionEvent { amount: amount, palletFee: palletFee, deposit: bigIntFromJson(created['deposit']), - fee: feeRaw != null ? bigIntFromJson(feeRaw) : null, + fee: feeToken != null ? bigIntFromJson(feeToken) : null, timestamp: accountEventTimestamp ?? dateTimeFromJson(created['timestamp']), blockNumber: blockHeightFromJsonMap(block), blockHash: blockHashFromJsonMap(block), diff --git a/quantus_sdk/lib/src/models/multisig_proposal_executed_event.dart b/quantus_sdk/lib/src/models/multisig_proposal_executed_event.dart index 328f33ad4..bea4ce8ca 100644 --- a/quantus_sdk/lib/src/models/multisig_proposal_executed_event.dart +++ b/quantus_sdk/lib/src/models/multisig_proposal_executed_event.dart @@ -91,7 +91,7 @@ class MultisigProposalExecutedEvent extends TransactionEvent { final approvers = approversRaw is List ? approversRaw.map((e) => e.toString()).toList() : []; final block = jsonMapOrNull(executed['block']); - final feeRaw = executed['fee']; + final feeToken = executed['fee']; final result = executed['result']?.toString() ?? ''; return MultisigProposalExecutedEvent( @@ -103,7 +103,7 @@ class MultisigProposalExecutedEvent extends TransactionEvent { proposalId: proposalId, approvers: approvers, result: result, - fee: feeRaw != null ? bigIntFromJson(feeRaw) : null, + fee: feeToken != null ? bigIntFromJson(feeToken) : null, timestamp: accountEventTimestamp ?? dateTimeFromJson(executed['timestamp']), blockNumber: blockHeightFromJsonMap(block), blockHash: blockHashFromJsonMap(block), diff --git a/quantus_sdk/lib/src/services/encrypted_account_service.dart b/quantus_sdk/lib/src/services/encrypted_account_service.dart index 612fd82a1..0fc6784ba 100644 --- a/quantus_sdk/lib/src/services/encrypted_account_service.dart +++ b/quantus_sdk/lib/src/services/encrypted_account_service.dart @@ -19,37 +19,52 @@ typedef MnemonicGetter = Future Function(); /// wormhole addresses, plus change that has been submitted but not yet indexed. class EncryptedAccountState { final List utxos; - final BigInt pendingChangePlanck; - final BigInt totalReceivedPlanck; - final BigInt totalSpentPlanck; + final BigInt pendingChangeToken; + final BigInt totalReceivedToken; - /// Next unused address index — shown as the receive address and allocated - /// as the change address of the next send. + /// Slice of [totalReceivedToken] that arrived on change-branch addresses. + final BigInt changeReceivedToken; + final BigInt totalSpentToken; + + /// Next unused external index — shown as the receive address. final int nextIndex; + /// Next unused change-branch index — allocated as the change address of the + /// next send. + final int nextChangeIndex; + const EncryptedAccountState({ required this.utxos, - required this.pendingChangePlanck, - required this.totalReceivedPlanck, - required this.totalSpentPlanck, + required this.pendingChangeToken, + required this.totalReceivedToken, + required this.changeReceivedToken, + required this.totalSpentToken, required this.nextIndex, + required this.nextChangeIndex, }); - BigInt get balance => utxos.fold(BigInt.zero, (sum, u) => sum + u.amount) + pendingChangePlanck; + BigInt get balance => utxos.fold(BigInt.zero, (sum, u) => sum + u.amount) + pendingChangeToken; + + /// Externally received funds only: change outputs return to the change + /// branch and are excluded, so the indexed (non-pending) balance equals + /// `incomingToken + changeReceivedToken - totalSpentToken`. + BigInt get incomingToken => totalReceivedToken - changeReceivedToken; /// 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. +/// An encrypted account: two HD sequences of wormhole addresses treated as a +/// single pool of funds — an external branch (`m/44'/189189189'/0'/0'/n'`) +/// whose next unused index is shown for receiving, and a change branch +/// (`m/44'/189189189'/0'/1'/n'`) whose next unused index is consumed as the +/// fresh change address of each send. Both branches are gap-limit scanned +/// (same algorithm as transparent accounts) so all funds are rediscovered +/// from the mnemonic alone, and keeping change off the external branch lets +/// externally received funds be reported separately from returning change. +/// Spent inputs are excluded via on-chain nullifiers; in-flight sends are +/// bridged by locally persisted pending-spend records until the indexer +/// catches up. /// /// Secret hygiene (M11): wormhole key pairs are never cached — every use /// re-derives from the mnemonic and the result is dropped as soon as the @@ -138,11 +153,13 @@ class EncryptedAccountService { return mnemonic; } - /// Derives the key pair at [index] on demand. Never cached (M11): the - /// returned pair carries the spendable secret as an immutable String, so - /// callers must use it immediately and let it go out of scope. - WormholeKeyPair _deriveKeyPair(String mnemonic, int index) => - _hdWalletService.deriveWormholeKeyPair(mnemonic: mnemonic, index: index); + /// Derives the key pair at [index] on the external or change branch on + /// demand. Never cached (M11): the returned pair carries the spendable + /// secret as an immutable String, so callers must use it immediately and + /// let it go out of scope. + WormholeKeyPair _deriveKeyPair(String mnemonic, int index, {bool isChange = false}) => isChange + ? _hdWalletService.deriveWormholeChangeAddressKeyPair(mnemonic: mnemonic, index: index) + : _hdWalletService.deriveWormholeKeyPair(mnemonic: mnemonic, index: index); Future keyPairAt(int index) async => _deriveKeyPair(await _mnemonic(), index); @@ -151,14 +168,18 @@ class EncryptedAccountService { Future receiveKeyPair() async => keyPairAt((await _readStateLocked()).nextIndex); /// Whether [address] is one of this wallet's derived wormhole addresses — - /// indices `0..nextIndex` cover every address ever shown for receiving or - /// allocated for change. Used to block self-sends from the encrypted account. + /// external indices `0..nextIndex` and change indices `0..nextChangeIndex` + /// cover every address ever shown for receiving or allocated for change. + /// Used to block self-sends from the encrypted account. Future ownsAddress(String address) async { - final nextIndex = (await _readStateLocked()).nextIndex; + final state = await _readStateLocked(); final mnemonic = await _mnemonic(); - for (int i = 0; i <= nextIndex; i++) { + for (int i = 0; i <= state.nextIndex; i++) { if (_deriveKeyPair(mnemonic, i).address == address) return true; } + for (int i = 0; i <= state.nextChangeIndex; i++) { + if (_deriveKeyPair(mnemonic, i, isChange: true).address == address) return true; + } return false; } @@ -172,7 +193,10 @@ class EncryptedAccountService { // the secret half of each pair is discarded immediately). final state = await _readStateLocked(); final mnemonic = await _mnemonic(); - final addresses = [for (int i = 0; i <= state.nextIndex; i++) _deriveKeyPair(mnemonic, i).address]; + final addresses = [ + for (int i = 0; i <= state.nextIndex; i++) _deriveKeyPair(mnemonic, i).address, + for (int i = 0; i <= state.nextChangeIndex; i++) _deriveKeyPair(mnemonic, i, isChange: true).address, + ]; if (addresses.isNotEmpty) { await WormholeUtxoService.clearCachesForAddresses(addresses); } @@ -194,19 +218,26 @@ class EncryptedAccountService { final sw = Stopwatch()..start(); final mnemonic = await _mnemonic(); - final usedIndices = await _discoveryService.discoverUsedIndices( - addressAt: (i) => _deriveKeyPair(mnemonic, i).address, - ); - _log('Discovery: used indices $usedIndices'); + final [usedIndices, usedChangeIndices] = await Future.wait([ + _discoveryService.discoverUsedIndices(addressAt: (i) => _deriveKeyPair(mnemonic, i).address), + _discoveryService.discoverUsedIndices(addressAt: (i) => _deriveKeyPair(mnemonic, i, isChange: true).address), + ]); + _log('Discovery: used indices $usedIndices, used change indices $usedChangeIndices'); + + WormholeAddressInfo infoAt(int i, {bool isChange = false}) { + final keyPair = _deriveKeyPair(mnemonic, i, isChange: isChange); + return WormholeAddressInfo(index: i, isChange: isChange, address: keyPair.address, secretHex: keyPair.secretHex); + } final scanIndices = {0, ...usedIndices}.toList()..sort(); + final changeScanIndices = usedChangeIndices.toList()..sort(); // Secrets live only inside this list for the duration of the UTXO fetch // (needed there for nullifier computation); the returned UTXOs carry no // secrets and this list is dropped when load() returns. - final addresses = scanIndices.map((i) { - final keyPair = _deriveKeyPair(mnemonic, i); - return WormholeAddressInfo(index: i, address: keyPair.address, secretHex: keyPair.secretHex); - }).toList(); + final addresses = [ + for (final i in scanIndices) infoAt(i), + for (final i in changeScanIndices) infoAt(i, isChange: true), + ]; final utxoResult = await _utxoService.getUnspentUtxos( addresses: addresses, @@ -215,9 +246,16 @@ class EncryptedAccountService { ); final unspentNullifiers = utxoResult.utxos.map((u) => u.nullifierHex).toSet(); - final usedAddresses = {for (final i in usedIndices) _deriveKeyPair(mnemonic, i).address}; - - final discoveredNext = usedIndices.isEmpty ? 0 : (usedIndices.reduce((a, b) => a > b ? a : b) + 1); + // Change-branch entries are all discovered-used by construction; external + // entries include index 0 even when unused, so filter those. + final usedAddresses = { + for (final a in addresses) + if (a.isChange || usedIndices.contains(a.index)) a.address, + }; + + int nextAfter(Set used) => used.isEmpty ? 0 : (used.reduce((a, b) => a > b ? a : b) + 1); + final discoveredNext = nextAfter(usedIndices); + final discoveredNextChange = nextAfter(usedChangeIndices); final state = await _mutateState((s) { final kept = []; for (final record in s.pendingSpends) { @@ -232,23 +270,29 @@ class EncryptedAccountService { kept.add(record); } } - return _FileState(nextIndex: s.nextIndex > discoveredNext ? s.nextIndex : discoveredNext, pendingSpends: kept); + return _FileState( + nextIndex: s.nextIndex > discoveredNext ? s.nextIndex : discoveredNext, + nextChangeIndex: s.nextChangeIndex > discoveredNextChange ? s.nextChangeIndex : discoveredNextChange, + pendingSpends: kept, + ); }); final pendingNullifiers = state.pendingSpends.expand((r) => r.nullifiers).toSet(); final spendable = utxoResult.utxos.where((u) => !pendingNullifiers.contains(u.nullifierHex)).toList(); - final pendingChange = state.pendingSpends.fold(BigInt.zero, (sum, r) => sum + r.changeAmountPlanck); + final pendingChange = state.pendingSpends.fold(BigInt.zero, (sum, r) => sum + r.changeAmountToken); _log( 'load DONE: ${spendable.length} spendable UTXOs, pendingChange=$pendingChange, ' - 'nextIndex=${state.nextIndex} (${sw.elapsedMilliseconds}ms)', + 'nextIndex=${state.nextIndex}, nextChangeIndex=${state.nextChangeIndex} (${sw.elapsedMilliseconds}ms)', ); return EncryptedAccountState( utxos: spendable, - pendingChangePlanck: pendingChange, - totalReceivedPlanck: utxoResult.totalReceivedPlanck, - totalSpentPlanck: utxoResult.totalSpentPlanck, + pendingChangeToken: pendingChange, + totalReceivedToken: utxoResult.totalReceivedToken, + changeReceivedToken: utxoResult.changeReceivedToken, + totalSpentToken: utxoResult.totalSpentToken, nextIndex: state.nextIndex, + nextChangeIndex: state.nextChangeIndex, ); } @@ -273,18 +317,18 @@ class EncryptedAccountService { final secretBuffers = []; try { final mnemonic = await _mnemonic(); - final changeKeyPair = _deriveKeyPair(mnemonic, changeIndex); + final changeKeyPair = _deriveKeyPair(mnemonic, changeIndex, isChange: true); 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', + 'amount=${plan.amountToken}, change=${plan.changeToken} -> change index $changeIndex', ); // UTXOs carry no secrets (see WormholeUtxoService.getUnspentUtxos), so - // each input's secret is re-derived from its owner's HD index. - Uint8List secretAt(int ownerIndex) { - final keyPair = _deriveKeyPair(mnemonic, ownerIndex); + // each input's secret is re-derived from its owner's HD branch and index. + Uint8List secretAt(WormholeAddressInfo owner) { + final keyPair = _deriveKeyPair(mnemonic, owner.index, isChange: owner.isChange); final secret = Uint8List.fromList(hex.decode(keyPair.secretHex.replaceFirst('0x', ''))); secretBuffers.add(secret); return secret; @@ -296,7 +340,7 @@ class EncryptedAccountService { for (final a in batch) WormholeLeafSpend( transfer: a.utxo.transfer, - secret: secretAt(a.utxo.owner.index), + secret: secretAt(a.utxo.owner), exitAccount1: recipientBytes, outputAmount1: a.recipientScaled, exitAccount2: a.changeScaled > 0 ? changeBytes : null, @@ -315,13 +359,14 @@ class EncryptedAccountService { final hasChange = changeScaled > 0; await _mutateState( (s) => _FileState( - nextIndex: hasChange && changeIndex >= s.nextIndex ? changeIndex + 1 : s.nextIndex, + nextIndex: s.nextIndex, + nextChangeIndex: hasChange && changeIndex >= s.nextChangeIndex ? changeIndex + 1 : s.nextChangeIndex, pendingSpends: [ ...s.pendingSpends, PendingSpend( nullifiers: nullifiers, changeAddress: hasChange ? changeKeyPair.address : null, - changeAmountPlanck: wormholePlanckFromScaled(changeScaled), + changeAmountToken: wormholeTokenFromScaled(changeScaled), createdAtMs: DateTime.now().millisecondsSinceEpoch, ), ], @@ -334,8 +379,9 @@ class EncryptedAccountService { for (final secret in secretBuffers) { secret.fillRange(0, secret.length, 0); } - // By now either a change-bearing batch bumped the persisted nextIndex - // past the reservation, or the send failed and the index is free again. + // By now either a change-bearing batch bumped the persisted + // nextChangeIndex past the reservation, or the send failed and the + // index is free again. _reservedChangeIndices.remove(changeIndex); } } @@ -377,7 +423,7 @@ class EncryptedAccountService { Future<_FileState> _readState() async { final file = await _stateFile(); - if (!await file.exists()) return const _FileState(nextIndex: 0, pendingSpends: []); + if (!await file.exists()) return const _FileState(nextIndex: 0, nextChangeIndex: 0, pendingSpends: []); return _FileState.fromJson(jsonDecode(await file.readAsString()) as Map); } @@ -392,10 +438,10 @@ class EncryptedAccountService { Future<_FileState> _readStateLocked() => _withStateLock(_readState); - /// Claims the change index for a new send: the persisted next unused index, - /// skipping indices already reserved by other in-flight sends. + /// Claims the change-branch index for a new send: the persisted next unused + /// change index, skipping indices already reserved by other in-flight sends. Future _reserveChangeIndex() => _withStateLock(() async { - var index = (await _readState()).nextIndex; + var index = (await _readState()).nextChangeIndex; while (_reservedChangeIndices.contains(index)) { index++; } @@ -424,39 +470,46 @@ class EncryptedAccountService { class PendingSpend { final List nullifiers; final String? changeAddress; - final BigInt changeAmountPlanck; + final BigInt changeAmountToken; final int createdAtMs; const PendingSpend({ required this.nullifiers, required this.changeAddress, - required this.changeAmountPlanck, + required this.changeAmountToken, 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), + // 'changeAmountPlanck' is the key used by files written before the + // planck -> token rename; fall back so old pending spends still load. + changeAmountToken: BigInt.parse((json['changeAmountToken'] ?? json['changeAmountPlanck']) as String), createdAtMs: json['createdAtMs'] as int, ); Map toJson() => { 'nullifiers': nullifiers, 'changeAddress': changeAddress, - 'changeAmountPlanck': changeAmountPlanck.toString(), + 'changeAmountToken': changeAmountToken.toString(), 'createdAtMs': createdAtMs, }; } class _FileState { final int nextIndex; + final int nextChangeIndex; final List pendingSpends; - const _FileState({required this.nextIndex, required this.pendingSpends}); + const _FileState({required this.nextIndex, required this.nextChangeIndex, required this.pendingSpends}); factory _FileState.fromJson(Map json) => _FileState( nextIndex: json['nextIndex'] as int, + // Absent in files written before change moved to its own branch; those + // wallets' old change addresses live on the external branch and stay + // covered by nextIndex. + nextChangeIndex: json['nextChangeIndex'] as int? ?? 0, pendingSpends: (json['pendingSpends'] as List) .map((e) => PendingSpend.fromJson(e as Map)) .toList(), @@ -464,6 +517,7 @@ class _FileState { Map toJson() => { 'nextIndex': nextIndex, + 'nextChangeIndex': nextChangeIndex, 'pendingSpends': pendingSpends.map((e) => e.toJson()).toList(), }; } diff --git a/quantus_sdk/lib/src/services/hd_wallet_service.dart b/quantus_sdk/lib/src/services/hd_wallet_service.dart index 69798846c..8866f8e5d 100644 --- a/quantus_sdk/lib/src/services/hd_wallet_service.dart +++ b/quantus_sdk/lib/src/services/hd_wallet_service.dart @@ -64,14 +64,17 @@ class HdWalletService { return _deriveHDWallet(mnemonic: mnemonic, account: index); } - crypto.WormholeResult deriveWormhole(String mnemonic, {int account = 0, int change = 0, int addressIndex = 0}) { + crypto.WormholeResult _deriveWormhole(String mnemonic, {int account = 0, int change = 0, int addressIndex = 0}) { final path = "m/44'/189189189'/$account'/$change'/$addressIndex'"; return crypto.deriveWormhole(mnemonicStr: mnemonic, path: path); } /// Derive the wormhole key pair at HD index `index` (account=0, change=0). WormholeKeyPair deriveWormholeKeyPair({required String mnemonic, int index = 0}) => - WormholeKeyPair.fromResult(deriveWormhole(mnemonic, addressIndex: index)); + WormholeKeyPair.fromResult(_deriveWormhole(mnemonic, addressIndex: index)); + + WormholeKeyPair deriveWormholeChangeAddressKeyPair({required String mnemonic, int index = 0}) => + WormholeKeyPair.fromResult(_deriveWormhole(mnemonic, change: 1, addressIndex: index)); /// Compute the on-chain wormhole address for a rewards preimage (first_hash hex). String preimageToAddress(String preimageHex) => crypto.firstHashToAddress(firstHashHex: preimageHex); diff --git a/quantus_sdk/lib/src/services/number_formatting_service.dart b/quantus_sdk/lib/src/services/number_formatting_service.dart index b407de456..8b833d37c 100644 --- a/quantus_sdk/lib/src/services/number_formatting_service.dart +++ b/quantus_sdk/lib/src/services/number_formatting_service.dart @@ -23,6 +23,10 @@ class NumberFormattingService { /// /// Example: 1234500000000 -> "1.2345" (smartDecimals = 4, US locale) /// Example: 100000000 -> "0.0001" (smartDecimals = 2, extended) + /// Standard token-amount display: 4 smart decimals, extending to the + /// chain's full precision for smaller amounts. + String formatAmount(BigInt amount) => formatBalance(amount, smartDecimals: 4, maxDecimals: AppConstants.decimals); + String formatBalance( BigInt balance, { int smartDecimals = 4, diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index cbeee4fb1..50189d552 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'dart:math'; + import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; +import 'package:quantus_sdk/src/constants/app_constants.dart'; import 'package:shared_preferences/shared_preferences.dart'; enum SwapStatus { pending, depositing, processing, complete, failed, expired } @@ -102,10 +104,10 @@ class SwapService { SwapToken(symbol: 'ETH', name: 'Ethereum', network: 'Ethereum'), SwapToken(symbol: 'BTC', name: 'Bitcoin', network: 'Bitcoin', decimals: 8), SwapToken(symbol: 'SOL', name: 'Solana', network: 'Solana', decimals: 9), - SwapToken(symbol: 'QUAN', name: 'Quantus', network: 'Quantus'), + _quToken, ]; - static const _quToken = SwapToken(symbol: 'QUAN', name: 'Quantus', network: 'Quantus'); + static const _quToken = SwapToken(symbol: AppConstants.tokenSymbol, name: 'Quantus', network: 'Quantus'); Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async { final now = DateTime.now(); @@ -127,7 +129,7 @@ class SwapService { } } catch (_) {} - final fallback = availableTokens.where((t) => t.symbol != 'QUAN').take(limit).toList(); + final fallback = availableTokens.where((t) => t.symbol != AppConstants.tokenSymbol).take(limit).toList(); _cachedFromTokens = fallback; _cachedFromTokensAt = now; return fallback; @@ -180,7 +182,7 @@ class SwapService { return 60000.0; case 'SOL': return 150.0; - case 'QUAN': + case AppConstants.tokenSymbol: return 1.0; default: return 0.0; @@ -205,7 +207,7 @@ class SwapService { if (symbolRaw is! String || symbolRaw.isEmpty) continue; if (blockchainRaw is! String || blockchainRaw.isEmpty) continue; final symbol = symbolRaw.toUpperCase(); - if (symbol == 'QUAN') continue; + if (symbol == AppConstants.tokenSymbol) continue; final price = (priceRaw as num?)?.toDouble() ?? 0; if (price <= 0) continue; final decimals = (decimalsRaw as num?)?.toInt() ?? 18; diff --git a/quantus_sdk/lib/src/services/taskmaster_service.dart b/quantus_sdk/lib/src/services/taskmaster_service.dart index e2b12a9bc..8105ee830 100644 --- a/quantus_sdk/lib/src/services/taskmaster_service.dart +++ b/quantus_sdk/lib/src/services/taskmaster_service.dart @@ -178,7 +178,7 @@ class TaskmasterService { if (mnemonic == null) { throw Exception('Mnemonic not found.'); } - final address = HdWalletService().deriveWormhole(mnemonic).address; + final address = _hd.deriveWormholeKeyPair(mnemonic: mnemonic).address; return address; } diff --git a/quantus_sdk/lib/src/services/wormhole_coin_selection.dart b/quantus_sdk/lib/src/services/wormhole_coin_selection.dart index 201b15158..cbb30c566 100644 --- a/quantus_sdk/lib/src/services/wormhole_coin_selection.dart +++ b/quantus_sdk/lib/src/services/wormhole_coin_selection.dart @@ -4,18 +4,18 @@ import 'package:quantus_sdk/src/services/wormhole_utxo_service.dart'; /// 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. +/// Scaled-down → token multiplier; matches `SCALE_DOWN_FACTOR` in the Rust +/// wormhole API. Proofs commit to amounts in scaled-down units (0.01 tokens) and +/// the chain dispatches `outputAmount * scaleFactor` token units. final BigInt wormholeScaleFactor = BigInt.from(10000000000); -/// Chain's `MinimumTransferAmount` (0.1 QUAN) in scaled units, enforced per +/// Chain's `MinimumTransferAmount` (0.1 token) in scaled units, enforced per /// aggregated batch on the total exit amount. const int wormholeMinBatchExitScaled = 10; -int wormholeScaledFromPlanck(BigInt planck) => (planck ~/ wormholeScaleFactor).toInt(); +int wormholeScaledFromToken(BigInt token) => (token ~/ wormholeScaleFactor).toInt(); -BigInt wormholePlanckFromScaled(int scaled) => BigInt.from(scaled) * wormholeScaleFactor; +BigInt wormholeTokenFromScaled(int scaled) => BigInt.from(scaled) * wormholeScaleFactor; /// Max total output the circuit allows for a consumed input: /// `(out1 + out2) * 10000 <= input * (10000 - feeBps)`. @@ -37,18 +37,18 @@ class WormholeLeafAssignment { class WormholeSpendPlan { /// Leaf assignments grouped into aggregation batches (each one extrinsic). final List> batches; - final BigInt amountPlanck; - final BigInt changePlanck; + final BigInt amountToken; + final BigInt changeToken; /// 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; + /// the 10 bps volume fee plus sub-0.01-tokens quantization dust. + final BigInt feeToken; const WormholeSpendPlan({ required this.batches, - required this.amountPlanck, - required this.changePlanck, - required this.feePlanck, + required this.amountToken, + required this.changeToken, + required this.feeToken, }); int get inputCount => batches.fold(0, (sum, b) => sum + b.length); @@ -62,58 +62,58 @@ sealed class WormholeSelectionException implements Exception { } class InsufficientEncryptedFunds extends WormholeSelectionException { - final BigInt maxSendablePlanck; - InsufficientEncryptedFunds(this.maxSendablePlanck) - : super('Insufficient encrypted funds: max sendable is $maxSendablePlanck planck'); + final BigInt maxSendableToken; + InsufficientEncryptedFunds(this.maxSendableToken) + : super('Insufficient encrypted funds: max sendable is $maxSendableToken token units'); } /// 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. +/// (0.1 token); 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)'); + : super('Batch exit total $totalScaled is below the chain minimum of $wormholeMinBatchExitScaled (0.1 token)'); } /// Maximum amount spendable from [utxos] (sum of per-input nets after the -/// volume fee), in planck. +/// volume fee), in token units. BigInt wormholeMaxSendable(List utxos) { - final totalScaled = utxos.fold(0, (sum, u) => sum + wormholeNetScaled(wormholeScaledFromPlanck(u.amount))); - return wormholePlanckFromScaled(totalScaled); + final totalScaled = utxos.fold(0, (sum, u) => sum + wormholeNetScaled(wormholeScaledFromToken(u.amount))); + return wormholeTokenFromScaled(totalScaled); } -/// Selects inputs to send exactly [amountPlanck] (a multiple of 0.01 QUAN) to +/// Selects inputs to send exactly [amountToken] (a multiple of 0.01 tokens) 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, + required BigInt amountToken, int maxProofsPerBatch = 7, }) { - if (amountPlanck <= BigInt.zero) { - throw ArgumentError('amountPlanck must be positive, got $amountPlanck'); + if (amountToken <= BigInt.zero) { + throw ArgumentError('amountToken must be positive, got $amountToken'); } - if (amountPlanck % wormholeScaleFactor != BigInt.zero) { - throw ArgumentError('amountPlanck must be a multiple of 0.01 QUAN, got $amountPlanck'); + if (amountToken % wormholeScaleFactor != BigInt.zero) { + throw ArgumentError('amountToken must be a multiple of 0.01 tokens, got $amountToken'); } - final targetScaled = wormholeScaledFromPlanck(amountPlanck); + final targetScaled = wormholeScaledFromToken(amountToken); - final candidates = utxos.where((u) => wormholeNetScaled(wormholeScaledFromPlanck(u.amount)) > 0).toList() + final candidates = utxos.where((u) => wormholeNetScaled(wormholeScaledFromToken(u.amount)) > 0).toList() ..sort((a, b) => b.amount.compareTo(a.amount)); final maxSendable = wormholeMaxSendable(candidates); - if (wormholePlanckFromScaled(targetScaled) > maxSendable) { + if (wormholeTokenFromScaled(targetScaled) > maxSendable) { throw InsufficientEncryptedFunds(maxSendable); } final assignments = []; var remaining = targetScaled; - var consumedPlanck = BigInt.zero; + var consumedToken = BigInt.zero; for (final utxo in candidates) { - final net = wormholeNetScaled(wormholeScaledFromPlanck(utxo.amount)); + final net = wormholeNetScaled(wormholeScaledFromToken(utxo.amount)); final pay = net < remaining ? net : remaining; assignments.add(WormholeLeafAssignment(utxo: utxo, recipientScaled: pay, changeScaled: net - pay)); - consumedPlanck += utxo.amount; + consumedToken += utxo.amount; remaining -= pay; if (remaining == 0) break; } @@ -130,11 +130,11 @@ WormholeSpendPlan selectWormholeInputs({ } final changeScaled = assignments.fold(0, (sum, a) => sum + a.changeScaled); - final changePlanck = wormholePlanckFromScaled(changeScaled); + final changeToken = wormholeTokenFromScaled(changeScaled); return WormholeSpendPlan( batches: batches, - amountPlanck: amountPlanck, - changePlanck: changePlanck, - feePlanck: consumedPlanck - amountPlanck - changePlanck, + amountToken: amountToken, + changeToken: changeToken, + feeToken: consumedToken - amountToken - changeToken, ); } diff --git a/quantus_sdk/lib/src/services/wormhole_send_service.dart b/quantus_sdk/lib/src/services/wormhole_send_service.dart index 428a0bd32..30c152e18 100644 --- a/quantus_sdk/lib/src/services/wormhole_send_service.dart +++ b/quantus_sdk/lib/src/services/wormhole_send_service.dart @@ -276,7 +276,7 @@ class WormholeSendService { transfer: transfer, secret: secretBytes, exitAccount1: destinationBytes, - outputAmount1: wormholeNetScaled(wormholeScaledFromPlanck(transfer.amount)), + outputAmount1: wormholeNetScaled(wormholeScaledFromToken(transfer.amount)), ), ]; final batches = [ @@ -397,7 +397,7 @@ class WormholeSendService { return ClaimResult( totalWithdrawn: submitted.fold( BigInt.zero, - (sum, b) => sum + b.fold(BigInt.zero, (s, spend) => s + wormholePlanckFromScaled(spend.outputAmount1)), + (sum, b) => sum + b.fold(BigInt.zero, (s, spend) => s + wormholeTokenFromScaled(spend.outputAmount1)), ), transfersProcessed: submitted.fold(0, (sum, b) => sum + b.length), batchesSubmitted: txHashes.length, @@ -424,7 +424,7 @@ class WormholeSendService { } /// 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. + /// the output buffers. Returns the token amount paid to exit slot 1. /// [onComplete] fires once the proof is written so callers can update /// progress per-leaf. Future _generateLeafProof({ @@ -501,9 +501,9 @@ class WormholeSendService { proofBuffer[outputIndex] = proof.proofBytes; nullifierBuffer[outputIndex] = '0x${hex.encode(proof.nullifier)}'; onComplete?.call(); - // On-chain dispatch transfers `outputAmount * scaleFactor` planck to + // On-chain dispatch transfers `outputAmount * scaleFactor` token units to // each exit account; slot 1 is the recipient's exact contribution. - return wormholePlanckFromScaled(spend.outputAmount1); + return wormholeTokenFromScaled(spend.outputAmount1); } /// Submits an unsigned extrinsic via `author_submitExtrinsic` and returns the diff --git a/quantus_sdk/lib/src/services/wormhole_utxo_service.dart b/quantus_sdk/lib/src/services/wormhole_utxo_service.dart index ea7e61adc..3a6ade1aa 100644 --- a/quantus_sdk/lib/src/services/wormhole_utxo_service.dart +++ b/quantus_sdk/lib/src/services/wormhole_utxo_service.dart @@ -62,19 +62,26 @@ 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. +/// One HD-derived wormhole address (index in the wormhole derivation sequence, +/// with [isChange] selecting the change branch of the derivation path) together +/// with the secret needed to compute nullifiers and spend proofs. /// /// [secretHex] is required on input to [WormholeUtxoService.getUnspentUtxos] /// (nullifier computation) but is always blanked on the [WormholeUtxo.owner] /// of returned UTXOs: UTXOs are kept in long-lived app state, and secrets are -/// never cached — spenders re-derive from [index] when needed (M11). +/// never cached — spenders re-derive from [index]/[isChange] when needed (M11). class WormholeAddressInfo { final int index; + final bool isChange; final String address; final String secretHex; - const WormholeAddressInfo({required this.index, required this.address, required this.secretHex}); + const WormholeAddressInfo({ + required this.index, + this.isChange = false, + required this.address, + required this.secretHex, + }); } /// An unspent wormhole transfer together with the address that owns it. @@ -90,10 +97,19 @@ class WormholeUtxo { class WormholeUtxoResult { final List utxos; - final BigInt totalReceivedPlanck; - final BigInt totalSpentPlanck; - - const WormholeUtxoResult({required this.utxos, required this.totalReceivedPlanck, required this.totalSpentPlanck}); + final BigInt totalReceivedToken; + + /// Slice of [totalReceivedToken] received on change-branch addresses, so + /// callers can report externally received funds separately from change. + final BigInt changeReceivedToken; + final BigInt totalSpentToken; + + const WormholeUtxoResult({ + required this.utxos, + required this.totalReceivedToken, + required this.changeReceivedToken, + required this.totalSpentToken, + }); } typedef WormholeProgressCallback = void Function(int phase, int completed, {int? total}); @@ -578,13 +594,20 @@ query SpentNullifiers($hashes: [String!]!) { final totalTransfers = fetched.byAddress.values.fold(0, (sum, l) => sum + l.length); if (totalTransfers == 0) { _log('getUnspentUtxos: no transfers found'); - return WormholeUtxoResult(utxos: const [], totalReceivedPlanck: BigInt.zero, totalSpentPlanck: BigInt.zero); + return WormholeUtxoResult( + utxos: const [], + totalReceivedToken: BigInt.zero, + changeReceivedToken: BigInt.zero, + totalSpentToken: BigInt.zero, + ); } - BigInt totalReceivedPlanck = BigInt.zero; - for (final transfers in fetched.byAddress.values) { - for (final t in transfers) { - totalReceivedPlanck += t.amount; + BigInt totalReceivedToken = BigInt.zero; + BigInt changeReceivedToken = BigInt.zero; + for (final owner in addresses) { + for (final t in fetched.byAddress[owner.address]!) { + totalReceivedToken += t.amount; + if (owner.isChange) changeReceivedToken += t.amount; } } @@ -610,7 +633,12 @@ query SpentNullifiers($hashes: [String!]!) { ); // The secret is used only for the nullifier above — the returned UTXO // carries a blanked owner so no secret is retained in app state (M11). - final redactedOwner = WormholeAddressInfo(index: owner.index, address: owner.address, secretHex: ''); + final redactedOwner = WormholeAddressInfo( + index: owner.index, + isChange: owner.isChange, + address: owner.address, + secretHex: '', + ); nullifierToUtxo[nullifierHex] = WormholeUtxo( transfer: transfer, owner: redactedOwner, @@ -658,14 +686,15 @@ query SpentNullifiers($hashes: [String!]!) { } final unspent = nullifierToUtxo.entries.where((e) => !allSpent.contains(e.key)).map((e) => e.value).toList(); - final totalSpentPlanck = nullifierToUtxo.entries + final totalSpentToken = nullifierToUtxo.entries .where((e) => allSpent.contains(e.key)) .fold(BigInt.zero, (sum, e) => sum + e.value.amount); _log('getUnspentUtxos: ${unspent.length} unspent out of $totalTransfers total'); return WormholeUtxoResult( utxos: unspent, - totalReceivedPlanck: totalReceivedPlanck, - totalSpentPlanck: totalSpentPlanck, + totalReceivedToken: totalReceivedToken, + changeReceivedToken: changeReceivedToken, + totalSpentToken: totalSpentToken, ); } @@ -695,7 +724,7 @@ query SpentNullifiers($hashes: [String!]!) { isCancelled: isCancelled, ); final balance = unspent.fold(BigInt.zero, (sum, t) => sum + t.amount); - _log('getUnspentBalance: $balance planck (${unspent.length} unspent transfers)'); + _log('getUnspentBalance: $balance token units (${unspent.length} unspent transfers)'); return balance; } } diff --git a/quantus_sdk/test/chain/call_decoder_test.dart b/quantus_sdk/test/chain/call_decoder_test.dart index e22711cfe..d6373c784 100644 --- a/quantus_sdk/test/chain/call_decoder_test.dart +++ b/quantus_sdk/test/chain/call_decoder_test.dart @@ -30,7 +30,7 @@ final codeHash = Uint8List.fromList(List.filled(32, 0xCC)); multi_address.MultiAddress dest(Uint8List id) => multi_address.MultiAddress.values.id(id); -final oneQuan = BigInt.from(1000000000000); +final oneToken = BigInt.from(1000000000000); /// Encodes then re-decodes through [CallDecoder.decodeBytes], so every assertion /// runs against the same path a signer takes: bytes in, display tree out. @@ -48,31 +48,31 @@ NestedCallField nestedField(DecodedCall call, String label) => void main() { group('transfers', () { test('balances.transfer_allow_death decodes destination, amount and summary', () { - final decoded = roundTrip(const balances_pallet.Txs().transferAllowDeath(dest: dest(bobId), value: oneQuan)); + final decoded = roundTrip(const balances_pallet.Txs().transferAllowDeath(dest: dest(bobId), value: oneToken)); expect(decoded.pallet, 'Balances'); expect(decoded.call, 'transfer_allow_death'); expect(valueField(decoded, 'Destination').kind, ValueKind.address); expect(valueField(decoded, 'Destination').value, startsWith('qz')); - expect(amountField(decoded, 'Amount').planck, oneQuan); - expect(decoded.summary?.amount, oneQuan); + expect(amountField(decoded, 'Amount').token, oneToken); + expect(decoded.summary?.amount, oneToken); expect(decoded.summary?.recipient, valueField(decoded, 'Destination').value); expect(decoded.summary?.assetId, isNull); }); test('balances.transfer_keep_alive carries the same summary as allow_death', () { - final decoded = roundTrip(const balances_pallet.Txs().transferKeepAlive(dest: dest(bobId), value: oneQuan)); + final decoded = roundTrip(const balances_pallet.Txs().transferKeepAlive(dest: dest(bobId), value: oneToken)); expect(decoded.call, 'transfer_keep_alive'); - expect(decoded.summary?.amount, oneQuan); + expect(decoded.summary?.amount, oneToken); expect(decoded.summary?.recipient, valueField(decoded, 'Destination').value); }); test('reversible schedule_transfer decodes destination, amount and summary', () { - final decoded = roundTrip(const reversible_pallet.Txs().scheduleTransfer(dest: dest(bobId), amount: oneQuan)); + final decoded = roundTrip(const reversible_pallet.Txs().scheduleTransfer(dest: dest(bobId), amount: oneToken)); expect(decoded.call, 'schedule_transfer'); - expect(decoded.summary?.amount, oneQuan); + expect(decoded.summary?.amount, oneToken); expect(decoded.summary?.recipient, valueField(decoded, 'Destination').value); }); @@ -80,11 +80,11 @@ void main() { final decoded = roundTrip( const balances_pallet.Txs().transferAllowDeath( dest: multi_address.MultiAddress.values.index(BigInt.one), - value: oneQuan, + value: oneToken, ), ); - expect(decoded.summary?.amount, oneQuan); + expect(decoded.summary?.amount, oneToken); expect(decoded.summary?.recipient, isNull); }); @@ -92,7 +92,7 @@ void main() { final decoded = roundTrip( const reversible_pallet.Txs().scheduleTransferWithDelay( dest: dest(bobId), - amount: oneQuan, + amount: oneToken, delay: qp.BlockNumberOrTimestamp.values.timestamp(BigInt.from(600000)), ), ); @@ -102,12 +102,12 @@ void main() { expect(delay.kind, ValueKind.blockOrTime); expect(delay.value, contains('10m')); expect(delay.value, contains('600000 ms')); - expect(decoded.summary?.amount, oneQuan); + expect(decoded.summary?.amount, oneToken); }); test('reversible schedule_asset_transfer carries the asset id into the summary', () { final decoded = roundTrip( - const reversible_pallet.Txs().scheduleAssetTransfer(assetId: 42, dest: dest(bobId), amount: oneQuan), + const reversible_pallet.Txs().scheduleAssetTransfer(assetId: 42, dest: dest(bobId), amount: oneToken), ); expect(decoded.call, 'schedule_asset_transfer'); @@ -119,7 +119,7 @@ void main() { group('multisig', () { test('approve exposes the inner call being approved and lifts its amount', () { - final inner = const balances_pallet.Txs().transferAllowDeath(dest: dest(bobId), value: oneQuan); + final inner = const balances_pallet.Txs().transferAllowDeath(dest: dest(bobId), value: oneToken); final decoded = roundTrip( const multisig_pallet.Txs().approve(multisigAddress: aliceId, proposalId: 7, call: inner.encode()), ); @@ -131,15 +131,15 @@ void main() { final approved = nestedField(decoded, 'Call being approved').call; expect(approved.call, 'transfer_allow_death'); - expect(amountField(approved, 'Amount').planck, oneQuan); + expect(amountField(approved, 'Amount').token, oneToken); // The hero amount for an approval comes from the call it authorises. - expect(decoded.summary?.amount, oneQuan); + expect(decoded.summary?.amount, oneToken); expect(decoded.summary?.recipient, valueField(approved, 'Destination').value); }); test('propose exposes the inner call and expiry', () { - final inner = const reversible_pallet.Txs().scheduleTransfer(dest: dest(bobId), amount: oneQuan); + final inner = const reversible_pallet.Txs().scheduleTransfer(dest: dest(bobId), amount: oneToken); final decoded = roundTrip( const multisig_pallet.Txs().propose(multisigAddress: aliceId, call: inner.encode(), expiry: 12345), ); @@ -147,7 +147,7 @@ void main() { expect(decoded.call, 'propose'); expect(valueField(decoded, 'Expires at block').value, '12345'); expect(nestedField(decoded, 'Proposed call').call.call, 'schedule_transfer'); - expect(decoded.summary?.amount, oneQuan); + expect(decoded.summary?.amount, oneToken); }); test('create_multisig lists every signer alongside the threshold', () { @@ -262,7 +262,7 @@ void main() { final decoded = roundTrip( const utility_pallet.Txs().batchAll( calls: [ - const balances_pallet.Txs().transferAllowDeath(dest: dest(bobId), value: oneQuan), + const balances_pallet.Txs().transferAllowDeath(dest: dest(bobId), value: oneToken), const collective_pallet.Txs().vote(poll: 3, aye: true), ], ), @@ -278,13 +278,13 @@ void main() { final decoded = roundTrip( const recovery_pallet.Txs().asRecovered( account: dest(aliceId), - call: const balances_pallet.Txs().transferAllowDeath(dest: dest(bobId), value: oneQuan), + call: const balances_pallet.Txs().transferAllowDeath(dest: dest(bobId), value: oneToken), ), ); expect(decoded.call, 'as_recovered'); expect(nestedField(decoded, 'Call').call.call, 'transfer_allow_death'); - expect(decoded.summary?.amount, oneQuan); + expect(decoded.summary?.amount, oneToken); }); }); diff --git a/quantus_sdk/test/generate_keys_test.dart b/quantus_sdk/test/generate_keys_test.dart index 72d695c1d..cc270eff9 100644 --- a/quantus_sdk/test/generate_keys_test.dart +++ b/quantus_sdk/test/generate_keys_test.dart @@ -92,10 +92,10 @@ void main() { test('wormhole derivation known values', () { const mnemonic = 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven'; - const expectedPreimage = 'e4be02a913727c01c1a155fd6e807b7c1a4a13abf37a352b7c9ed4412d127fc3'; + const expectedPreimage = '0xe4be02a913727c01c1a155fd6e807b7c1a4a13abf37a352b7c9ed4412d127fc3'; - final result = HdWalletService().deriveWormhole(mnemonic); - expect(hex.encode(result.firstHash), expectedPreimage); + final result = HdWalletService().deriveWormholeKeyPair(mnemonic: mnemonic); + expect(result.rewardsPreimageHex.toLowerCase(), expectedPreimage.toLowerCase()); final addressBytes = ss58ToAccountId(s: result.address); // Same account bytes as '5H8AGzwKPtKMfKKuKYCoAFApCoy4EVewCqc9k6GrSgqHoaXm' diff --git a/quantus_sdk/test/quantus_payload_parser_test.dart b/quantus_sdk/test/quantus_payload_parser_test.dart index 9d0a365bd..9f0a49f2c 100644 --- a/quantus_sdk/test/quantus_payload_parser_test.dart +++ b/quantus_sdk/test/quantus_payload_parser_test.dart @@ -96,7 +96,7 @@ void main() { expect(parsed.call.pallet, 'Balances'); expect(parsed.call.call, 'transfer_allow_death'); expect(valueField(parsed.call, 'Destination').value, 'qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86'); - expect(amountField(parsed.call, 'Amount').planck, BigInt.from(900000000000)); + expect(amountField(parsed.call, 'Amount').token, BigInt.from(900000000000)); expect(parsed.call.summary?.amount, BigInt.from(900000000000)); expect(parsed.extensions.era, const Era.mortal(64, 24)); expect(parsed.extensions.era.toString(), '64 blocks'); @@ -114,7 +114,7 @@ void main() { final parsed = QuantusPayloadParser.parsePayload(payload); expect(valueField(parsed.call, 'Destination').value, 'qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG'); - expect(amountField(parsed.call, 'Amount').planck, BigInt.from(100000000000)); + expect(amountField(parsed.call, 'Amount').token, BigInt.from(100000000000)); expect(parsed.extensions.era, const Era.immortal()); expect(parsed.extensions.era.toString(), 'Immortal'); expect(parsed.extensions.tip, tip); @@ -127,7 +127,7 @@ void main() { expect(parsed.call.pallet, 'ReversibleTransfers'); expect(parsed.call.call, 'schedule_transfer_with_delay'); expect(valueField(parsed.call, 'Destination').value, 'qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG'); - expect(amountField(parsed.call, 'Amount').planck, BigInt.from(1440000000000)); + expect(amountField(parsed.call, 'Amount').token, BigInt.from(1440000000000)); expect(valueField(parsed.call, 'Delay').value, contains('300000 ms')); // 5 minutes expect(parsed.extensions.nonce, 3); }); @@ -150,7 +150,7 @@ void main() { final approved = nestedField(parsed.call, 'Call being approved').call; expect(approved.call, 'transfer_allow_death'); - expect(amountField(approved, 'Amount').planck, BigInt.from(2500000000000)); + expect(amountField(approved, 'Amount').token, BigInt.from(2500000000000)); expect(parsed.call.summary?.amount, BigInt.from(2500000000000)); }); diff --git a/quantus_sdk/test/services/encrypted_account_service_test.dart b/quantus_sdk/test/services/encrypted_account_service_test.dart index 15f84f7c9..2cb9ddfe5 100644 --- a/quantus_sdk/test/services/encrypted_account_service_test.dart +++ b/quantus_sdk/test/services/encrypted_account_service_test.dart @@ -17,21 +17,36 @@ import 'package:ss58/ss58.dart'; /// `getAccountId32` can decode it inside `send`). String addressAt(int index) => Address(prefix: 189, pubkey: Uint8List(32)..[31] = index).encode(); +/// Change-branch counterpart of [addressAt], distinct at every index. +String changeAddressAt(int index) { + final pubkey = Uint8List(32) + ..[30] = 1 + ..[31] = index; + return Address(prefix: 189, pubkey: pubkey).encode(); +} + String secretAt(int index) => '0x${(index + 1).toRadixString(16).padLeft(2, '0') * 32}'; -WormholeUtxo _utxo(int scaled, {int index = 0, String? nullifierHex}) => WormholeUtxo( +String changeSecretAt(int index) => '0x${(index + 0x81).toRadixString(16).padLeft(2, '0') * 32}'; + +WormholeUtxo _utxo(int scaled, {int index = 0, bool isChange = false, String? nullifierHex}) => WormholeUtxo( transfer: WormholeTransfer( id: 't$scaled', blockHeight: 1, fromId: 'from', - toId: addressAt(index), - amount: wormholePlanckFromScaled(scaled), + toId: isChange ? changeAddressAt(index) : addressAt(index), + amount: wormholeTokenFromScaled(scaled), toHash: '0x00', leafIndex: BigInt.from(scaled), transferCount: BigInt.one, ), // secretHex blank like production getUnspentUtxos (M11): spenders re-derive. - owner: WormholeAddressInfo(index: index, address: addressAt(index), secretHex: ''), + owner: WormholeAddressInfo( + index: index, + isChange: isChange, + address: isChange ? changeAddressAt(index) : addressAt(index), + secretHex: '', + ), nullifierHex: nullifierHex ?? '0xn$scaled', ); @@ -50,23 +65,36 @@ class _FakeHdWallet extends HdWalletService { secretHex: secretAt(index), ); } + + @override + WormholeKeyPair deriveWormholeChangeAddressKeyPair({required String mnemonic, int index = 0}) { + derivations++; + return WormholeKeyPair( + address: changeAddressAt(index), + addressHex: '0x${'00' * 30}01${(index).toRadixString(16).padLeft(2, '0')}', + rewardsPreimageHex: '0x', + secretHex: changeSecretAt(index), + ); + } } class _FakeDiscovery extends AccountDiscoveryService { Set used; + Set usedChange = const {}; _FakeDiscovery(this.used) : super(_FakeHdWallet()); @override Future> discoverUsedIndices({required String Function(int index) addressAt, int gapLimit = 20}) async => - used; + addressAt(0) == changeAddressAt(0) ? usedChange : used; } class _FakeUtxoService extends WormholeUtxoService { WormholeUtxoResult result = WormholeUtxoResult( utxos: const [], - totalReceivedPlanck: BigInt.zero, - totalSpentPlanck: BigInt.zero, + totalReceivedToken: BigInt.zero, + changeReceivedToken: BigInt.zero, + totalSpentToken: BigInt.zero, ); /// When set, [getUnspentUtxos] blocks until the completer resolves — lets @@ -170,10 +198,18 @@ void main() { await tempDir.delete(recursive: true); }); - Future seedState({required int nextIndex, List pendingSpends = const []}) async { + Future seedState({ + required int nextIndex, + int? nextChangeIndex, + List pendingSpends = const [], + }) async { final file = File('${tempDir.path}/encrypted_account_w0.json'); await file.writeAsString( - jsonEncode({'nextIndex': nextIndex, 'pendingSpends': pendingSpends.map((e) => e.toJson()).toList()}), + jsonEncode({ + 'nextIndex': nextIndex, + 'nextChangeIndex': ?nextChangeIndex, + 'pendingSpends': pendingSpends.map((e) => e.toJson()).toList(), + }), ); } @@ -188,43 +224,64 @@ void main() { expect((await readStateFile())['nextIndex'], 3); }); + test('bumps nextChangeIndex to one past the highest discovered change index', () async { + discovery.used = {0}; + discovery.usedChange = {0, 1}; + final state = await service.load(); + expect(state.nextIndex, 1); + expect(state.nextChangeIndex, 2); + expect((await readStateFile())['nextChangeIndex'], 2); + }); + test('keeps a persisted nextIndex that is ahead of discovery', () async { - await seedState(nextIndex: 5); + await seedState(nextIndex: 5, nextChangeIndex: 3); discovery.used = {0, 1}; + discovery.usedChange = {0}; final state = await service.load(); expect(state.nextIndex, 5); + expect(state.nextChangeIndex, 3); + }); + + test('a legacy state file without nextChangeIndex loads with change index 0', () async { + await seedState(nextIndex: 4); + final state = await service.load(); + expect(state.nextIndex, 4); + expect(state.nextChangeIndex, 0); }); test('prunes a pending spend once nullifiers are spent and change arrived', () async { - discovery.used = {0, 1}; + discovery.used = {0}; + discovery.usedChange = {0}; utxoService.result = WormholeUtxoResult( utxos: [_utxo(500, nullifierHex: '0xc')], - totalReceivedPlanck: wormholePlanckFromScaled(500), - totalSpentPlanck: BigInt.zero, + totalReceivedToken: wormholeTokenFromScaled(500), + changeReceivedToken: BigInt.zero, + totalSpentToken: BigInt.zero, ); await seedState( - nextIndex: 2, + nextIndex: 1, + nextChangeIndex: 1, pendingSpends: [ PendingSpend( // '0xa' is absent from the unspent set (spent on-chain) and the - // change address (index 1) is discovered: fully confirmed. + // change address (change index 0) is discovered: fully confirmed. nullifiers: ['0xa'], - changeAddress: addressAt(1), - changeAmountPlanck: wormholePlanckFromScaled(100), + changeAddress: changeAddressAt(0), + changeAmountToken: wormholeTokenFromScaled(100), createdAtMs: DateTime.now().millisecondsSinceEpoch, ), ], ); final state = await service.load(); - expect(state.pendingChangePlanck, BigInt.zero); + expect(state.pendingChangeToken, BigInt.zero); expect(state.utxos.map((u) => u.nullifierHex), ['0xc']); expect((await readStateFile())['pendingSpends'], isEmpty); }); test('keeps an unconfirmed pending spend, hides its inputs and counts its change', () async { discovery.used = {0}; - final pendingChange = wormholePlanckFromScaled(70); + final pendingChange = wormholeTokenFromScaled(70); utxoService.result = WormholeUtxoResult( // '0xb' is still reported unspent by the indexer (the spend hasn't // been indexed yet) so the record must be kept and '0xb' hidden. @@ -232,16 +289,17 @@ void main() { _utxo(300, nullifierHex: '0xb'), _utxo(500, nullifierHex: '0xc'), ], - totalReceivedPlanck: wormholePlanckFromScaled(800), - totalSpentPlanck: BigInt.zero, + totalReceivedToken: wormholeTokenFromScaled(800), + changeReceivedToken: BigInt.zero, + totalSpentToken: BigInt.zero, ); await seedState( nextIndex: 1, pendingSpends: [ PendingSpend( nullifiers: ['0xb'], - changeAddress: addressAt(1), - changeAmountPlanck: pendingChange, + changeAddress: changeAddressAt(0), + changeAmountToken: pendingChange, createdAtMs: DateTime.now().millisecondsSinceEpoch, ), ], @@ -249,8 +307,8 @@ void main() { final state = await service.load(); expect(state.utxos.map((u) => u.nullifierHex), ['0xc']); - expect(state.pendingChangePlanck, pendingChange); - expect(state.balance, wormholePlanckFromScaled(500) + pendingChange); + expect(state.pendingChangeToken, pendingChange); + expect(state.balance, wormholeTokenFromScaled(500) + pendingChange); expect(((await readStateFile())['pendingSpends'] as List).length, 1); }); @@ -258,16 +316,17 @@ void main() { discovery.used = {0}; utxoService.result = WormholeUtxoResult( utxos: [_utxo(300, nullifierHex: '0xb')], - totalReceivedPlanck: wormholePlanckFromScaled(300), - totalSpentPlanck: BigInt.zero, + totalReceivedToken: wormholeTokenFromScaled(300), + changeReceivedToken: BigInt.zero, + totalSpentToken: BigInt.zero, ); await seedState( nextIndex: 1, pendingSpends: [ PendingSpend( nullifiers: ['0xb'], - changeAddress: addressAt(1), - changeAmountPlanck: wormholePlanckFromScaled(10), + changeAddress: changeAddressAt(0), + changeAmountToken: wormholeTokenFromScaled(10), createdAtMs: DateTime.now().subtract(const Duration(hours: 2)).millisecondsSinceEpoch, ), ], @@ -276,7 +335,7 @@ void main() { final state = await service.load(); // Record dropped: input spendable again, change no longer counted. expect(state.utxos.map((u) => u.nullifierHex), ['0xb']); - expect(state.pendingChangePlanck, BigInt.zero); + expect(state.pendingChangeToken, BigInt.zero); expect((await readStateFile())['pendingSpends'], isEmpty); }); }); @@ -284,43 +343,46 @@ void main() { group('send bookkeeping', () { final recipient = Address(prefix: 189, pubkey: Uint8List.fromList(List.filled(32, 0x22))).encode(); - test('records nullifiers and change, and bumps nextIndex, per submitted batch', () async { + test('records nullifiers and change, and bumps nextChangeIndex, per submitted batch', () async { await seedState(nextIndex: 1); - final plan = selectWormholeInputs(utxos: [_utxo(1000)], amountPlanck: wormholePlanckFromScaled(400)); - expect(plan.changePlanck, greaterThan(BigInt.zero)); + final plan = selectWormholeInputs(utxos: [_utxo(1000)], amountToken: wormholeTokenFromScaled(400)); + expect(plan.changeToken, greaterThan(BigInt.zero)); await service.send(plan: plan, recipientAddress: recipient, circuitBinsDir: '/unused', onProgress: (_) {}); final json = await readStateFile(); - expect(json['nextIndex'], 2); + // The receive sequence is untouched; change consumed change index 0. + expect(json['nextIndex'], 1); + expect(json['nextChangeIndex'], 1); final pending = (json['pendingSpends'] as List).cast>(); expect(pending.length, 1); expect(pending[0]['nullifiers'], ['0xsubmitted_0_400']); - expect(pending[0]['changeAddress'], addressAt(1)); - expect(BigInt.parse(pending[0]['changeAmountPlanck'] as String), plan.changePlanck); + expect(pending[0]['changeAddress'], changeAddressAt(0)); + expect(BigInt.parse(pending[0]['changeAmountToken'] as String), plan.changeToken); // Leaf spend wiring: full net split between recipient and change address. final spend = sendService.capturedBatches![0][0]; expect(spend.exitAccount1, Address.decode(recipient).pubkey); expect(spend.outputAmount1, 400); - expect(spend.exitAccount2, Address.decode(addressAt(1)).pubkey); - expect(spend.outputAmount2, wormholeScaledFromPlanck(plan.changePlanck)); + expect(spend.exitAccount2, Address.decode(changeAddressAt(0)).pubkey); + expect(spend.outputAmount2, wormholeScaledFromToken(plan.changeToken)); }); test('does not allocate a change address for an exact-max send', () async { await seedState(nextIndex: 1); final utxos = [_utxo(1000)]; - final plan = selectWormholeInputs(utxos: utxos, amountPlanck: wormholeMaxSendable(utxos)); - expect(plan.changePlanck, BigInt.zero); + final plan = selectWormholeInputs(utxos: utxos, amountToken: wormholeMaxSendable(utxos)); + expect(plan.changeToken, BigInt.zero); await service.send(plan: plan, recipientAddress: recipient, circuitBinsDir: '/unused', onProgress: (_) {}); final json = await readStateFile(); expect(json['nextIndex'], 1); + expect(json['nextChangeIndex'], 0); final pending = (json['pendingSpends'] as List).cast>(); expect(pending.length, 1); expect(pending[0]['changeAddress'], isNull); - expect(BigInt.parse(pending[0]['changeAmountPlanck'] as String), BigInt.zero); + expect(BigInt.parse(pending[0]['changeAmountToken'] as String), BigInt.zero); expect(sendService.capturedBatches![0][0].exitAccount2, isNull); }); }); @@ -339,7 +401,7 @@ void main() { test('send re-derives input secrets from the owner index and zeroizes them afterwards', () async { await seedState(nextIndex: 1); - final plan = selectWormholeInputs(utxos: [_utxo(1000)], amountPlanck: wormholePlanckFromScaled(400)); + final plan = selectWormholeInputs(utxos: [_utxo(1000)], amountToken: wormholeTokenFromScaled(400)); await service.send(plan: plan, recipientAddress: recipient, circuitBinsDir: '/unused', onProgress: (_) {}); @@ -350,9 +412,21 @@ void main() { expect(liveSecret.every((b) => b == 0), isTrue); }); + test('send re-derives change-branch secrets for change-owned inputs', () async { + await seedState(nextIndex: 1); + final plan = selectWormholeInputs( + utxos: [_utxo(1000, isChange: true)], + amountToken: wormholeTokenFromScaled(400), + ); + + await service.send(plan: plan, recipientAddress: recipient, circuitBinsDir: '/unused', onProgress: (_) {}); + + expect(sendService.capturedSecretCopies.single, hex.decode(changeSecretAt(0).replaceFirst('0x', ''))); + }); + test('send zeroizes secrets even when the send fails', () async { await seedState(nextIndex: 1); - final plan = selectWormholeInputs(utxos: [_utxo(1000)], amountPlanck: wormholePlanckFromScaled(400)); + final plan = selectWormholeInputs(utxos: [_utxo(1000)], amountToken: wormholeTokenFromScaled(400)); sendService.error = StateError('boom'); await expectLater( @@ -370,7 +444,7 @@ void main() { test('a batch submitted after logout cannot recreate cleared state', () async { await seedState(nextIndex: 1); - final plan = selectWormholeInputs(utxos: [_utxo(1000)], amountPlanck: wormholePlanckFromScaled(400)); + final plan = selectWormholeInputs(utxos: [_utxo(1000)], amountToken: wormholeTokenFromScaled(400)); sendService.gate = Completer(); final sendFuture = service.send( plan: plan, @@ -405,7 +479,7 @@ void main() { test('load and send refuse to start after dispose', () async { await seedState(nextIndex: 1); - final plan = selectWormholeInputs(utxos: [_utxo(1000)], amountPlanck: wormholePlanckFromScaled(400)); + final plan = selectWormholeInputs(utxos: [_utxo(1000)], amountToken: wormholeTokenFromScaled(400)); await service.dispose(); await expectLater(service.load(), throwsA(isA())); await expectLater( @@ -416,10 +490,10 @@ void main() { test('two overlapping sends allocate distinct change indices', () async { await seedState(nextIndex: 1); - final plan1 = selectWormholeInputs(utxos: [_utxo(1000)], amountPlanck: wormholePlanckFromScaled(400)); - final plan2 = selectWormholeInputs(utxos: [_utxo(900)], amountPlanck: wormholePlanckFromScaled(300)); - expect(plan1.changePlanck, greaterThan(BigInt.zero)); - expect(plan2.changePlanck, greaterThan(BigInt.zero)); + final plan1 = selectWormholeInputs(utxos: [_utxo(1000)], amountToken: wormholeTokenFromScaled(400)); + final plan2 = selectWormholeInputs(utxos: [_utxo(900)], amountToken: wormholeTokenFromScaled(300)); + expect(plan1.changeToken, greaterThan(BigInt.zero)); + expect(plan2.changeToken, greaterThan(BigInt.zero)); sendService.gate = Completer(); final f1 = service.send(plan: plan1, recipientAddress: recipient, circuitBinsDir: '/unused', onProgress: (_) {}); @@ -431,18 +505,21 @@ void main() { expect(sendService.capturedBatchesList.length, 2); final change1 = sendService.capturedBatchesList[0][0][0].exitAccount2; final change2 = sendService.capturedBatchesList[1][0][0].exitAccount2; - expect(change1, Address.decode(addressAt(1)).pubkey); - expect(change2, Address.decode(addressAt(2)).pubkey); + expect(change1, Address.decode(changeAddressAt(0)).pubkey); + expect(change2, Address.decode(changeAddressAt(1)).pubkey); }); }); group('ownsAddress', () { - test('recognizes every derived index up to and including nextIndex', () async { - await seedState(nextIndex: 2); + test('recognizes both branches up to and including their next indices', () async { + await seedState(nextIndex: 2, nextChangeIndex: 1); expect(await service.ownsAddress(addressAt(0)), isTrue); expect(await service.ownsAddress(addressAt(1)), isTrue); expect(await service.ownsAddress(addressAt(2)), isTrue); expect(await service.ownsAddress(addressAt(9)), isFalse); + expect(await service.ownsAddress(changeAddressAt(0)), isTrue); + expect(await service.ownsAddress(changeAddressAt(1)), isTrue); + expect(await service.ownsAddress(changeAddressAt(9)), isFalse); final other = Address(prefix: 189, pubkey: Uint8List.fromList(List.filled(32, 0x33))).encode(); expect(await service.ownsAddress(other), isFalse); }); @@ -453,7 +530,7 @@ void main() { final original = PendingSpend( nullifiers: ['0xabc', '0xdef'], changeAddress: 'addr_3', - changeAmountPlanck: BigInt.from(123456), + changeAmountToken: BigInt.from(123456), createdAtMs: 1700000000000, ); final json = original.toJson(); @@ -461,7 +538,7 @@ void main() { expect(restored.nullifiers, original.nullifiers); expect(restored.changeAddress, original.changeAddress); - expect(restored.changeAmountPlanck, original.changeAmountPlanck); + expect(restored.changeAmountToken, original.changeAmountToken); expect(restored.createdAtMs, original.createdAtMs); }); @@ -469,54 +546,68 @@ void main() { final original = PendingSpend( nullifiers: ['0x01'], changeAddress: null, - changeAmountPlanck: BigInt.zero, + changeAmountToken: BigInt.zero, createdAtMs: 1700000000000, ); final json = original.toJson(); final restored = PendingSpend.fromJson(json); expect(restored.changeAddress, isNull); - expect(restored.changeAmountPlanck, BigInt.zero); + expect(restored.changeAmountToken, BigInt.zero); + }); + + test('loads a legacy file written with the changeAmountPlanck key', () { + final restored = PendingSpend.fromJson({ + 'nullifiers': ['0x01'], + 'changeAddress': 'addr_1', + 'changeAmountPlanck': '123456', + 'createdAtMs': 1700000000000, + }); + + expect(restored.changeAmountToken, BigInt.from(123456)); }); }); group('EncryptedAccountState', () { + EncryptedAccountState stateWith({ + List utxos = const [], + BigInt? pendingChangeToken, + BigInt? totalReceivedToken, + BigInt? changeReceivedToken, + BigInt? totalSpentToken, + }) => EncryptedAccountState( + utxos: utxos, + pendingChangeToken: pendingChangeToken ?? BigInt.zero, + totalReceivedToken: totalReceivedToken ?? BigInt.zero, + changeReceivedToken: changeReceivedToken ?? BigInt.zero, + totalSpentToken: totalSpentToken ?? BigInt.zero, + nextIndex: 2, + nextChangeIndex: 1, + ); + test('balance includes pending change', () { - final state = EncryptedAccountState( - utxos: [_utxo(100), _utxo(200)], - pendingChangePlanck: wormholePlanckFromScaled(50), - totalReceivedPlanck: wormholePlanckFromScaled(500), - totalSpentPlanck: wormholePlanckFromScaled(150), - nextIndex: 2, - ); - final utxoSum = wormholePlanckFromScaled(100) + wormholePlanckFromScaled(200); - expect(state.balance, utxoSum + wormholePlanckFromScaled(50)); + final state = stateWith(utxos: [_utxo(100), _utxo(200)], pendingChangeToken: wormholeTokenFromScaled(50)); + final utxoSum = wormholeTokenFromScaled(100) + wormholeTokenFromScaled(200); + expect(state.balance, utxoSum + wormholeTokenFromScaled(50)); }); test('maxSendable excludes pending change', () { final utxos = [_utxo(100), _utxo(200)]; - final state = EncryptedAccountState( - utxos: utxos, - pendingChangePlanck: wormholePlanckFromScaled(50), - totalReceivedPlanck: wormholePlanckFromScaled(500), - totalSpentPlanck: wormholePlanckFromScaled(150), - nextIndex: 2, - ); + final state = stateWith(utxos: utxos, pendingChangeToken: wormholeTokenFromScaled(50)); expect(state.maxSendable, wormholeMaxSendable(utxos)); }); - test('totalReceivedPlanck and totalSpentPlanck are stored', () { - final received = wormholePlanckFromScaled(1000); - final spent = wormholePlanckFromScaled(300); - final state = EncryptedAccountState( - utxos: [], - pendingChangePlanck: BigInt.zero, - totalReceivedPlanck: received, - totalSpentPlanck: spent, - nextIndex: 5, + test('incomingToken excludes change received and balances against spent', () { + // Received 1000 externally + 300 as returning change, 400 nullified: + // the indexed balance identity is incoming + change - spent. + final state = stateWith( + utxos: [_utxo(900)], + totalReceivedToken: wormholeTokenFromScaled(1300), + changeReceivedToken: wormholeTokenFromScaled(300), + totalSpentToken: wormholeTokenFromScaled(400), ); - expect(state.totalReceivedPlanck, received); - expect(state.totalSpentPlanck, spent); + expect(state.incomingToken, wormholeTokenFromScaled(1000)); + expect(state.incomingToken + state.changeReceivedToken - state.totalSpentToken, state.balance); }); }); @@ -525,12 +616,14 @@ void main() { final utxos = [_utxo(100), _utxo(200)]; final result = WormholeUtxoResult( utxos: utxos, - totalReceivedPlanck: wormholePlanckFromScaled(500), - totalSpentPlanck: wormholePlanckFromScaled(200), + totalReceivedToken: wormholeTokenFromScaled(500), + changeReceivedToken: wormholeTokenFromScaled(120), + totalSpentToken: wormholeTokenFromScaled(200), ); expect(result.utxos.length, 2); - expect(result.totalReceivedPlanck, wormholePlanckFromScaled(500)); - expect(result.totalSpentPlanck, wormholePlanckFromScaled(200)); + expect(result.totalReceivedToken, wormholeTokenFromScaled(500)); + expect(result.changeReceivedToken, wormholeTokenFromScaled(120)); + expect(result.totalSpentToken, wormholeTokenFromScaled(200)); }); }); } diff --git a/quantus_sdk/test/services/wormhole_coin_selection_test.dart b/quantus_sdk/test/services/wormhole_coin_selection_test.dart index 32d209f5a..db2907f12 100644 --- a/quantus_sdk/test/services/wormhole_coin_selection_test.dart +++ b/quantus_sdk/test/services/wormhole_coin_selection_test.dart @@ -8,7 +8,7 @@ WormholeUtxo utxo(int scaled) => WormholeUtxo( blockHeight: 1, fromId: 'from', toId: 'to', - amount: wormholePlanckFromScaled(scaled), + amount: wormholeTokenFromScaled(scaled), toHash: '0x00', leafIndex: BigInt.from(scaled), transferCount: BigInt.one, @@ -17,30 +17,30 @@ WormholeUtxo utxo(int scaled) => WormholeUtxo( nullifierHex: '0xn$scaled', ); -BigInt quan(String v) => wormholePlanckFromScaled((double.parse(v) * 100).round()); +BigInt tokens(String v) => wormholeTokenFromScaled((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')); + test('plan worked example: 10 tokens from 1.1 + 5.8 + 4.0', () { + final plan = selectWormholeInputs(utxos: [utxo(110), utxo(580), utxo(400)], amountToken: tokens('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')); + expect(plan.amountToken, tokens('10')); + expect(plan.changeToken, tokens('0.87')); + expect(plan.feeToken, tokens('0.03')); final recipientTotal = plan.batches[0].fold(0, (s, a) => s + a.recipientScaled); - expect(wormholePlanckFromScaled(recipientTotal), quan('10')); + expect(wormholeTokenFromScaled(recipientTotal), tokens('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)); + final net = wormholeNetScaled(wormholeScaledFromToken(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')); + final plan = selectWormholeInputs(utxos: List.generate(9, (_) => utxo(200)), amountToken: tokens('16')); // 200 nets 199; 9 inputs net 17.91 total, 8 inputs net 15.92 < 16. expect(plan.inputCount, 9); @@ -51,49 +51,49 @@ void main() { final exit = batch.fold(0, (s, a) => s + a.exitScaled); expect(exit, greaterThanOrEqualTo(wormholeMinBatchExitScaled)); } - expect(plan.changePlanck, quan('1.91')); + expect(plan.changeToken, tokens('1.91')); }); test('insufficient funds reports exact max sendable', () { final e = throwsA( - isA().having((e) => e.maxSendablePlanck, 'maxSendable', quan('1.98')), + isA().having((e) => e.maxSendableToken, 'maxSendable', tokens('1.98')), ); - expect(() => selectWormholeInputs(utxos: [utxo(100), utxo(100)], amountPlanck: quan('2')), e); + expect(() => selectWormholeInputs(utxos: [utxo(100), utxo(100)], amountToken: tokens('2')), e); }); test('rejects non-quantized amounts', () { expect( - () => selectWormholeInputs(utxos: [utxo(1000)], amountPlanck: quan('1') + BigInt.one), + () => selectWormholeInputs(utxos: [utxo(1000)], amountToken: tokens('1') + BigInt.one), throwsArgumentError, ); }); test('rejects a batch below the chain minimum exit', () { expect( - () => selectWormholeInputs(utxos: [utxo(9)], amountPlanck: wormholePlanckFromScaled(8)), + () => selectWormholeInputs(utxos: [utxo(9)], amountToken: wormholeTokenFromScaled(8)), throwsA(isA()), ); }); test('wormholeMaxSendable sums per-input nets', () { - expect(wormholeMaxSendable([utxo(110), utxo(580), utxo(400)]), quan('10.87')); + expect(wormholeMaxSendable([utxo(110), utxo(580), utxo(400)]), tokens('10.87')); }); test('exactly 7 inputs fit in a single batch', () { - final plan = selectWormholeInputs(utxos: List.generate(7, (_) => utxo(200)), amountPlanck: quan('12')); + final plan = selectWormholeInputs(utxos: List.generate(7, (_) => utxo(200)), amountToken: tokens('12')); expect(plan.inputCount, 7); expect(plan.batches.length, 1); expect(plan.batches[0].length, 7); final recipientTotal = plan.batches[0].fold(0, (s, a) => s + a.recipientScaled); - expect(wormholePlanckFromScaled(recipientTotal), quan('12')); + expect(wormholeTokenFromScaled(recipientTotal), tokens('12')); }); test('send max: all input nets consumed with zero change', () { final inputs = [utxo(110), utxo(580), utxo(400)]; final maxSendable = wormholeMaxSendable(inputs); - final plan = selectWormholeInputs(utxos: inputs, amountPlanck: maxSendable); - expect(plan.amountPlanck, maxSendable); - expect(plan.changePlanck, BigInt.zero); + final plan = selectWormholeInputs(utxos: inputs, amountToken: maxSendable); + expect(plan.amountToken, maxSendable); + expect(plan.changeToken, BigInt.zero); final totalChange = plan.batches.expand((b) => b).fold(0, (s, a) => s + a.changeScaled); expect(totalChange, 0); }); diff --git a/quantus_sdk/test/services/wormhole_send_service_test.dart b/quantus_sdk/test/services/wormhole_send_service_test.dart index 713f360a3..8a418b190 100644 --- a/quantus_sdk/test/services/wormhole_send_service_test.dart +++ b/quantus_sdk/test/services/wormhole_send_service_test.dart @@ -135,7 +135,7 @@ void main() { blockHeight: 1, fromId: 'from', toId: 'wormhole_addr', - amount: wormholePlanckFromScaled(1000), + amount: wormholeTokenFromScaled(1000), toHash: '0x00', leafIndex: BigInt.one, transferCount: BigInt.one, diff --git a/quantus_sdk/test/services/wormhole_utxo_service_test.dart b/quantus_sdk/test/services/wormhole_utxo_service_test.dart index 3c6fa1844..55b0ddf39 100644 --- a/quantus_sdk/test/services/wormhole_utxo_service_test.dart +++ b/quantus_sdk/test/services/wormhole_utxo_service_test.dart @@ -70,25 +70,33 @@ void main() { test('getUnspentUtxos blanks owner secretHex on every returned UTXO (M11)', () async { final hd = HdWalletService(); final pairs = [for (var i = 0; i < 2; i++) hd.deriveWormholeKeyPair(mnemonic: _mnemonic, index: i)]; + final changePair = hd.deriveWormholeChangeAddressKeyPair(mnemonic: _mnemonic); final service = _OfflineUtxoService() ..transfersByAddress = { pairs[0].address: [_transfer(toId: pairs[0].address, count: 1)], pairs[1].address: [_transfer(toId: pairs[1].address, count: 2)], + changePair.address: [_transfer(toId: changePair.address, count: 3)], }; final result = await service.getUnspentUtxos( addresses: [ for (var i = 0; i < 2; i++) WormholeAddressInfo(index: i, address: pairs[i].address, secretHex: pairs[i].secretHex), + WormholeAddressInfo(index: 0, isChange: true, address: changePair.address, secretHex: changePair.secretHex), ], ); - expect(result.utxos, hasLength(2)); + expect(result.utxos, hasLength(3)); for (final utxo in result.utxos) { expect(utxo.owner.secretHex, isEmpty); } - // Index and address survive the redaction so spenders can re-derive. - expect(result.utxos.map((u) => u.owner.index).toSet(), {0, 1}); - expect(result.utxos.map((u) => u.owner.address).toSet(), {pairs[0].address, pairs[1].address}); + // Index, branch and address survive the redaction so spenders can re-derive. + expect(result.utxos.map((u) => u.owner.address).toSet(), {pairs[0].address, pairs[1].address, changePair.address}); + final changeUtxo = result.utxos.singleWhere((u) => u.owner.address == changePair.address); + expect(changeUtxo.owner.isChange, isTrue); + expect(changeUtxo.owner.index, 0); + // Change-branch receipts are totalled separately from external receipts. + expect(result.changeReceivedToken, changeUtxo.amount); + expect(result.totalReceivedToken, result.utxos.fold(BigInt.zero, (sum, u) => sum + u.amount)); }); }