From 00a8191236249751adbf8168bec89941a6b4860c Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 11:13:43 -0600 Subject: [PATCH 01/52] fix possible nano send amount bug --- .../nano_interface.dart | 37 +++++++++++-------- test/wallets/nano_interface_test.dart | 20 ++++++++++ 2 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 test/wallets/nano_interface_test.dart diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart index b08b6bb426..1fbe4fbeec 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart @@ -37,6 +37,19 @@ Map _buildHeaders(String url) { return result; } +({String frontier, String representative, BigInt balanceAfterSend}) +parseNanoSendState(Map accountInfo, BigInt sendAmount) { + final liveBalance = BigInt.parse(accountInfo["balance"].toString()); + if (sendAmount > liveBalance) { + throw Exception("Insufficient balance"); + } + return ( + frontier: accountInfo["frontier"].toString(), + representative: accountInfo["representative"].toString(), + balanceAfterSend: liveBalance - sendAmount, + ); +} + mixin NanoInterface on Bip39Wallet { // since nano based coins only have a single address/account we can cache // the address instead of fetching from db every time we need it in certain @@ -412,12 +425,6 @@ mixin NanoInterface on Bip39Wallet { final String publicAddress = (_cachedAddress ?? await getCurrentReceivingAddress())!.value; - // first update to get latest account balance: - - final currentBalance = info.cachedBalance.spendable; - final txAmount = txData.amount!; - final BigInt balanceAfterTx = (currentBalance - txAmount).raw; - // get the account info (we need the frontier and representative): final infoBody = jsonEncode({ "action": "account_info", @@ -435,12 +442,10 @@ mixin NanoInterface on Bip39Wallet { : null, ); - final String frontier = jsonDecode( - infoResponse.body, - )["frontier"].toString(); - final String representative = jsonDecode( - infoResponse.body, - )["representative"].toString(); + final accountInfo = Map.from( + jsonDecode(infoResponse.body) as Map, + ); + final sendState = parseNanoSendState(accountInfo, txData.amount!.raw); // link = destination address: final String linkAsAccount = txData.recipients!.first.address; final String link = NanoAccounts.extractPublicKey(linkAsAccount); @@ -449,9 +454,9 @@ mixin NanoInterface on Bip39Wallet { final Map sendBlock = { "type": "state", "account": publicAddress, - "previous": frontier, - "representative": representative, - "balance": balanceAfterTx.toString(), + "previous": sendState.frontier, + "representative": sendState.representative, + "balance": sendState.balanceAfterSend.toString(), "link": link, }; @@ -468,7 +473,7 @@ mixin NanoInterface on Bip39Wallet { final String signature = NanoSignatures.signBlock(hash, privateKey); // get PoW for the send block: - final String? work = await _requestWork(frontier); + final String? work = await _requestWork(sendState.frontier); if (work == null) { throw Exception("Failed to get PoW for send block"); } diff --git a/test/wallets/nano_interface_test.dart b/test/wallets/nano_interface_test.dart new file mode 100644 index 0000000000..d80968fcc7 --- /dev/null +++ b/test/wallets/nano_interface_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart'; + +void main() { + test('Nano send state uses the live account balance', () { + final state = parseNanoSendState({ + 'frontier': 'frontier', + 'representative': 'representative', + 'balance': '15', + }, BigInt.from(3)); + + expect(state.frontier, 'frontier'); + expect(state.representative, 'representative'); + expect(state.balanceAfterSend, BigInt.from(12)); + expect( + () => parseNanoSendState({'balance': '2'}, BigInt.from(3)), + throwsException, + ); + }); +} From 0c74bbd916fb6db2d3d5a0b2d87278015a71f76e Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 11:16:05 -0600 Subject: [PATCH 02/52] firo fix segwit address validation --- lib/wallets/crypto_currency/coins/firo.dart | 24 ++++---- .../wallets/firo_address_validation_test.dart | 57 +++++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 test/wallets/firo_address_validation_test.dart diff --git a/lib/wallets/crypto_currency/coins/firo.dart b/lib/wallets/crypto_currency/coins/firo.dart index 583dc4b8dc..fb470c6898 100644 --- a/lib/wallets/crypto_currency/coins/firo.dart +++ b/lib/wallets/crypto_currency/coins/firo.dart @@ -13,6 +13,11 @@ import '../crypto_currency.dart'; import '../interfaces/electrumx_currency_interface.dart'; import '../intermediate/bip39_hd_currency.dart'; +bool _isBitcoinBech32Address(String address) { + final value = address.toLowerCase(); + return value.startsWith("bc1") || value.startsWith("tb1"); +} + class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { Firo(super.network) { _idMain = "firo"; @@ -106,7 +111,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { p2shPrefix: 0x07, privHDPrefix: 0x0488ade4, pubHDPrefix: 0x0488b21e, - bech32Hrp: "bc", + bech32Hrp: "", messagePrefix: '\x16Zcoin Signed Message:\n', minFee: BigInt.from(1), // Not used in stack wallet currently minOutput: dustLimit.raw, // Not used in stack wallet currently @@ -119,7 +124,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { p2shPrefix: 0xb2, privHDPrefix: 0x04358394, pubHDPrefix: 0x043587cf, - bech32Hrp: "tb", + bech32Hrp: "", messagePrefix: "\x16Zcoin Signed Message:\n", minFee: BigInt.from(1), // Not used in stack wallet currently minOutput: dustLimit.raw, // Not used in stack wallet currently @@ -188,11 +193,8 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { coinlib.Address.fromString(address, networkParams); return true; } catch (_) { - if (validateSparkAddress(address)) { - return true; - } else { - return isExchangeAddress(address); - } + if (_isBitcoinBech32Address(address)) return false; + return isExchangeAddress(address) || validateSparkAddress(address); } } @@ -301,9 +303,9 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { @override AddressType? getAddressType(String address) { - if (validateSparkAddress(address)) { - return .spark; - } - return super.getAddressType(address); + if (_isBitcoinBech32Address(address)) return null; + final type = super.getAddressType(address); + if (type != null) return type; + return validateSparkAddress(address) ? .spark : null; } } diff --git a/test/wallets/firo_address_validation_test.dart b/test/wallets/firo_address_validation_test.dart new file mode 100644 index 0000000000..74e668c258 --- /dev/null +++ b/test/wallets/firo_address_validation_test.dart @@ -0,0 +1,57 @@ +import "package:coinlib_flutter/coinlib_flutter.dart" as coinlib; +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/models/isar/models/blockchain_data/address.dart"; +import "package:stackwallet/wallets/crypto_currency/crypto_currency.dart"; + +void main() { + final mainnet = Firo(CryptoCurrencyNetwork.main); + final testnet = Firo(CryptoCurrencyNetwork.test); + + test("accepts Firo transparent addresses", () { + expect( + mainnet.validateAddress("a8VV7vMzJdTQj1eLEJNskhLEBUxfNWhpAg"), + isTrue, + ); + expect( + mainnet.getAddressType("a8VV7vMzJdTQj1eLEJNskhLEBUxfNWhpAg"), + AddressType.p2pkh, + ); + expect( + testnet.validateAddress("THqfkegzJjpF4PQFAWPhJWMWagwHecfqva"), + isTrue, + ); + expect( + testnet.getAddressType("THqfkegzJjpF4PQFAWPhJWMWagwHecfqva"), + AddressType.p2pkh, + ); + }); + + test("rejects Bitcoin Bech32 addresses", () { + const mainnetBitcoin = "bc1qc5ymmsay89r6gr4fy2kklvrkuvzyln4shdvjhf"; + const testnetBitcoin = "tb1qzzlm6mnc8k54mx6akehl8p9ray8r439va5ndyq"; + + expect(mainnet.validateAddress(mainnetBitcoin), isFalse); + expect(mainnet.getAddressType(mainnetBitcoin), isNull); + expect( + () => coinlib.Address.fromString(mainnetBitcoin, mainnet.networkParams), + throwsA(anything), + ); + expect(testnet.validateAddress(testnetBitcoin), isFalse); + expect(testnet.getAddressType(testnetBitcoin), isNull); + expect( + () => coinlib.Address.fromString(testnetBitcoin, testnet.networkParams), + throwsA(anything), + ); + }); + + test("keeps Firo exchange addresses", () { + expect( + mainnet.validateAddress("EXXMGtieRLNGfgewJ4jJCN4kZFTUcjYMDdHs"), + isTrue, + ); + expect( + testnet.validateAddress("EXTKtrsZSTGU2vUbuCV6sBDVqPAS3JQkaYJ3"), + isTrue, + ); + }); +} From 8163840948c891a2bfecc05ca280e73a35400d01 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 11:19:03 -0600 Subject: [PATCH 03/52] use payInAmount if available, not as a fallback --- lib/models/exchange/incomplete_exchange.dart | 2 + .../exchange_step_views/step_4_view.dart | 21 ++++----- .../exchange_steps/step_scaffold.dart | 2 +- .../subwidgets/desktop_step_4.dart | 33 +++++++------- .../exchange/incomplete_exchange_test.dart | 45 +++++++++++++++++++ 5 files changed, 74 insertions(+), 29 deletions(-) create mode 100644 test/models/exchange/incomplete_exchange_test.dart diff --git a/lib/models/exchange/incomplete_exchange.dart b/lib/models/exchange/incomplete_exchange.dart index 86441bc90e..664ae0b100 100644 --- a/lib/models/exchange/incomplete_exchange.dart +++ b/lib/models/exchange/incomplete_exchange.dart @@ -28,6 +28,8 @@ class IncompleteExchangeModel extends ChangeNotifier { final Decimal sendAmount; final Decimal receiveAmount; + String get payInAmount => trade?.payInAmount ?? sendAmount.toString(); + final ExchangeRateType rateType; final bool reversed; diff --git a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart index a48b85b23c..2625d61c42 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart @@ -10,6 +10,7 @@ import 'dart:async'; +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; @@ -250,9 +251,9 @@ class _Step4ViewState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(tuple.item1); - final Amount amount = model.sendAmount.toAmount( - fractionDigits: wallet.info.coin.fractionDigits, - ); + final Amount amount = Decimal.parse( + model.payInAmount, + ).toAmount(fractionDigits: wallet.info.coin.fractionDigits); final address = model.trade!.payInAddress; bool wasCancelled = false; @@ -456,10 +457,10 @@ class _Step4ViewState extends ConsumerState { DetailItem( title: "Amount", detail: - "${model.sendAmount.toString()} " + "${model.payInAmount} " "${model.sendTicker.toUpperCase()}", button: SimpleCopyButton( - data: model.sendAmount.toString(), + data: model.payInAmount, ), ), const SizedBox(height: 8), @@ -554,7 +555,7 @@ class _WarningInfo extends StatelessWidget { text: TextSpan( text: "You must send at least " - "${model.sendAmount.toString()} ${model.sendTicker}. ", + "${model.payInAmount} ${model.sendTicker}. ", style: STextStyles.label700(context).copyWith( color: Theme.of( context, @@ -564,7 +565,7 @@ class _WarningInfo extends StatelessWidget { TextSpan( text: "If you send less than " - "${model.sendAmount.toString()} ${model.sendTicker}," + "${model.payInAmount} ${model.sendTicker}," " your transaction may not be converted and it may not be" " refunded.", style: STextStyles.label(context).copyWith( @@ -621,9 +622,9 @@ class _SendFromButton extends ConsumerWidget { return SendFromView( coin: coin, - amount: model.sendAmount.toAmount( - fractionDigits: coin.fractionDigits, - ), + amount: Decimal.parse( + model.payInAmount, + ).toAmount(fractionDigits: coin.fractionDigits), address: model.trade!.payInAddress, trade: model.trade!, ); diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart index 27d0b68a64..cb07dcf4f2 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart @@ -395,7 +395,7 @@ class _StepScaffoldState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( - "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toStringAsFixed(8)))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))} to this address", + "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))} to this address", style: STextStyles.desktopH3(context), ), const SizedBox(height: 48), diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart index 6e18c5086b..12cad5e3a5 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart @@ -109,23 +109,21 @@ class _DesktopStep4State extends ConsumerState { child: RichText( text: TextSpan( text: - "You must send at least ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toString()))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}. ", + "You must send at least ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}. ", style: STextStyles.label700(context).copyWith( - color: - Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of( + context, + ).extension()!.warningForeground, fontSize: 14, ), children: [ TextSpan( text: - "If you send less than ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toString()))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}, your transaction may not be converted and it may not be refunded.", + "If you send less than ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}, your transaction may not be converted and it may not be refunded.", style: STextStyles.label(context).copyWith( - color: - Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of( + context, + ).extension()!.warningForeground, fontSize: 14, ), ), @@ -186,7 +184,7 @@ class _DesktopStep4State extends ConsumerState { DesktopStepItem( label: "Amount", value: - "${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toStringAsFixed(8)))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))}", + "${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))}", ), Container( height: 1, @@ -217,13 +215,12 @@ class _DesktopStep4State extends ConsumerState { ), Text( _statusString, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .colorForStatus(_statusString), - ), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .colorForStatus(_statusString), + ), ), ], ), diff --git a/test/models/exchange/incomplete_exchange_test.dart b/test/models/exchange/incomplete_exchange_test.dart new file mode 100644 index 0000000000..5f2c07becc --- /dev/null +++ b/test/models/exchange/incomplete_exchange_test.dart @@ -0,0 +1,45 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/exchange/incomplete_exchange.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/models/isar/exchange_cache/currency.dart'; +import 'package:stackwallet/utilities/enums/exchange_rate_type_enum.dart'; + +class _Currency implements Currency { + @override + String get exchangeName => "exchange"; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Trade implements Trade { + _Trade(this.payInAmount); + + @override + final String payInAmount; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test("pay-in amount follows the created trade", () { + final currency = _Currency(); + final model = IncompleteExchangeModel( + sendCurrency: currency, + receiveCurrency: currency, + rateInfo: "", + sendAmount: Decimal.parse("1.2"), + receiveAmount: Decimal.one, + rateType: ExchangeRateType.estimated, + reversed: false, + walletInitiated: false, + ); + final trade = _Trade("1.23456789"); + + expect(model.payInAmount, "1.2"); + model.trade = trade; + expect(model.payInAmount, "1.23456789"); + }); +} From 5e2eeba37035d984b3267b028cacc881990ef00d Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 11:45:35 -0600 Subject: [PATCH 04/52] better handling of localized group and decimal separators in amounts --- lib/utilities/amount/amount.dart | 34 +++++++---- .../amount/amount_input_formatter.dart | 60 +++++++++++++++---- lib/utilities/amount/amount_unit.dart | 14 ++--- test/utilities/amount/amount_unit_test.dart | 52 ++++++++++++++++ 4 files changed, 129 insertions(+), 31 deletions(-) diff --git a/lib/utilities/amount/amount.dart b/lib/utilities/amount/amount.dart index b31d87d7a0..2e9d5c6bda 100644 --- a/lib/utilities/amount/amount.dart +++ b/lib/utilities/amount/amount.dart @@ -32,6 +32,27 @@ class Amount { fractionDigits: fractionDigits, ); + static String normalizeLocalizedNumber( + String value, { + required String locale, + }) { + final symbols = Util.getSymbolsFor(locale: locale); + final groupSeparator = symbols?.GROUP_SEP ?? ","; + final decimalSeparator = symbols?.DECIMAL_SEP ?? "."; + + if (groupSeparator == "." && + decimalSeparator != "." && + !value.contains(decimalSeparator)) { + return RegExp(r'^[1-9]\d{0,2}(\.\d{3})+$').hasMatch(value) + ? value.replaceAll(groupSeparator, "") + : value; + } + + return value + .replaceAll(groupSeparator, "") + .replaceFirst(decimalSeparator, "."); + } + static Amount? tryParseFiatString(String value, {required String locale}) { final parts = value.split(" "); @@ -49,16 +70,9 @@ class Amount { } // get number symbols for decimal place and group separator - final numberSymbols = Util.getSymbolsFor(locale: locale); - - final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; - final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; - - str = str.replaceAll(groupSeparator, ""); - - final decimalString = str.replaceFirst(decimalSeparator, "."); - - return Decimal.tryParse(decimalString)?.toAmount(fractionDigits: 2); + return Decimal.tryParse( + normalizeLocalizedNumber(str, locale: locale), + )?.toAmount(fractionDigits: 2); } // =========================================================================== diff --git a/lib/utilities/amount/amount_input_formatter.dart b/lib/utilities/amount/amount_input_formatter.dart index 2ecd9fe540..c3121d2a81 100644 --- a/lib/utilities/amount/amount_input_formatter.dart +++ b/lib/utilities/amount/amount_input_formatter.dart @@ -26,11 +26,40 @@ class AmountInputFormatter extends TextInputFormatter { final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; - - String newText = newValue.text.replaceAll(groupSeparator, ""); - - final selectionIndexFromTheRight = - newValue.text.length - newValue.selection.end; + final oldSelection = oldValue.selection.isValid + ? oldValue.selection + : TextSelection.collapsed(offset: oldValue.text.length); + + String text = newValue.text; + if (groupSeparator == "." && decimalSeparator != ".") { + final insertedLength = + newValue.text.length - + oldValue.text.length + + oldSelection.end - + oldSelection.start; + final insertedStart = oldSelection.start; + final insertedEnd = insertedStart + insertedLength; + if (insertedLength > 0 && + insertedStart >= 0 && + insertedEnd <= text.length) { + final inserted = text.substring(insertedStart, insertedEnd); + final isGrouped = RegExp( + r'^[1-9]\d{0,2}(\.\d{3})+$', + ).hasMatch(inserted); + text = + text.substring(0, insertedStart) + + (inserted.contains(decimalSeparator) || isGrouped + ? inserted + : inserted.replaceAll(groupSeparator, decimalSeparator)) + + text.substring(insertedEnd); + } + } + final selectionEnd = min(newValue.selection.end, text.length); + final textBeforeSelection = text + .substring(0, selectionEnd) + .replaceAll(groupSeparator, ""); + String newText = text.replaceAll(groupSeparator, ""); + final selectionOffset = textBeforeSelection.length; String? fraction; if (newText.contains(decimalSeparator)) { @@ -40,8 +69,9 @@ class AmountInputFormatter extends TextInputFormatter { return oldValue; } - final fractionDigits = - unit == null ? decimals : max(decimals - unit!.shift, 0); + final fractionDigits = unit == null + ? decimals + : max(decimals - unit!.shift, 0); if (newText.startsWith(decimalSeparator)) { if (newText.length - 1 > fractionDigits) { @@ -51,7 +81,7 @@ class AmountInputFormatter extends TextInputFormatter { return TextEditingValue( text: newText, selection: TextSelection.collapsed( - offset: newText.length - selectionIndexFromTheRight, + offset: min(selectionOffset, newText.length), ), ); } @@ -88,11 +118,19 @@ class AmountInputFormatter extends TextInputFormatter { } } + int formattedSelectionOffset = 0; + int normalizedOffset = 0; + while (formattedSelectionOffset < newString.length && + normalizedOffset < selectionOffset) { + if (newString[formattedSelectionOffset] != groupSeparator) { + normalizedOffset++; + } + formattedSelectionOffset++; + } + return TextEditingValue( text: newString, - selection: TextSelection.collapsed( - offset: newString.length - selectionIndexFromTheRight, - ), + selection: TextSelection.collapsed(offset: formattedSelectionOffset), ); } } diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index 0d96fbdeff..6ea366f0ed 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -200,7 +200,7 @@ extension AmountUnitExt on AmountUnit { String value, { required String locale, required CryptoCurrency coin, - Contract? tokenContract, + Contract? tokenContract, bool overrideWithDecimalPlacesFromString = false, }) { final precisionLost = value.startsWith("~"); @@ -221,15 +221,9 @@ extension AmountUnitExt on AmountUnit { } // get number symbols for decimal place and group separator - final numberSymbols = Util.getSymbolsFor(locale: locale); - - final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; - final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; - - str = str.replaceAll(groupSeparator, ""); - - final decimalString = str.replaceFirst(decimalSeparator, "."); - final Decimal? decimal = Decimal.tryParse(decimalString); + final Decimal? decimal = Decimal.tryParse( + Amount.normalizeLocalizedNumber(str, locale: locale), + ); if (decimal == null) { return null; diff --git a/test/utilities/amount/amount_unit_test.dart b/test/utilities/amount/amount_unit_test.dart index 96a708702a..f59a67ad81 100644 --- a/test/utilities/amount/amount_unit_test.dart +++ b/test/utilities/amount/amount_unit_test.dart @@ -1,6 +1,8 @@ import 'package:decimal/decimal.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_unit.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; @@ -225,4 +227,54 @@ void main() { amount, ); }); + + test("parse ASCII decimals in dot-group locales", () { + final coin = Bitcoin(CryptoCurrencyNetwork.main); + final formatter = AmountInputFormatter(decimals: 8, locale: "de_DE"); + expect( + AmountUnit.normal.tryParse("1.5", locale: "de_DE", coin: coin)?.decimal, + Decimal.parse("1.5"), + ); + expect( + AmountUnit.normal.tryParse("1.234", locale: "de_DE", coin: coin)?.decimal, + Decimal.fromInt(1234), + ); + expect( + Amount.tryParseFiatString("1.50", locale: "de_DE")?.decimal, + Decimal.parse("1.5"), + ); + final formatted = formatter.formatEditUpdate( + TextEditingValue.empty, + const TextEditingValue( + text: "1.5", + selection: TextSelection.collapsed(offset: 3), + ), + ); + expect(formatted.text, "1,5"); + + final appended = formatter.formatEditUpdate( + const TextEditingValue( + text: "1.234", + selection: TextSelection.collapsed(offset: 5), + ), + const TextEditingValue( + text: "1.2345", + selection: TextSelection.collapsed(offset: 6), + ), + ); + expect(appended.text, "12.345"); + + final insertedDecimal = formatter.formatEditUpdate( + const TextEditingValue( + text: "1.234", + selection: TextSelection.collapsed(offset: 1), + ), + const TextEditingValue( + text: "1..234", + selection: TextSelection.collapsed(offset: 2), + ), + ); + expect(insertedDecimal.text, "1,234"); + expect(insertedDecimal.selection.baseOffset, 2); + }); } From 2bba546a15c41b89626f3b0c3ae636d5edba1f1a Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 11:57:24 -0600 Subject: [PATCH 05/52] fix desktop token amount parsing --- .../sub_widgets/desktop_sol_token_send.dart | 93 ++++++++++++------- .../sub_widgets/desktop_token_send.dart | 18 ++-- .../desktop_token_amount_parsing_test.dart | 44 +++++++++ 3 files changed, 115 insertions(+), 40 deletions(-) create mode 100644 test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart index cd4e227a51..76cc7368df 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart @@ -16,6 +16,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/isar/models/contact_entry.dart'; +import '../../../../models/isar/models/solana/sol_contract.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; @@ -25,8 +26,8 @@ import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; import '../../../../utilities/amount/amount.dart'; -import '../../../../utilities/amount/amount_formatter.dart'; import '../../../../utilities/amount/amount_input_formatter.dart'; +import '../../../../utilities/amount/amount_unit.dart'; import '../../../../utilities/clipboard_interface.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/logger.dart'; @@ -50,6 +51,26 @@ import '../../../../widgets/textfield_icon_button.dart'; import '../../../desktop_home_view.dart'; import 'address_book_address_chooser/address_book_address_chooser.dart'; +Amount? parseDesktopSolTokenAmount( + String value, { + required String locale, + required CryptoCurrency coin, + required SolContract tokenContract, +}) { + if (value.contains(RegExp(r'[+\- ]'))) return null; + return AmountUnit.normal.tryParse( + value, + locale: locale, + coin: coin, + tokenContract: tokenContract, + ); +} + +Amount? parseDesktopSolTokenFiatAmount(String value, {required String locale}) { + if (value.contains(RegExp(r'[+\- ]'))) return null; + return Amount.tryParseFiatString(value, locale: locale); +} + class DesktopSolTokenSend extends ConsumerStatefulWidget { const DesktopSolTokenSend({ super.key, @@ -370,27 +391,40 @@ class _DesktopSolTokenSendState extends ConsumerState { } } + String _formatTokenAmount(Amount amount) { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + return AmountUnit.normal.displayAmount( + amount: amount, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + coin: coin, + maxDecimalPlaces: tokenWallet.tokenDecimals, + withUnitName: false, + tokenContract: tokenWallet.solContract, + ); + } + void _cryptoAmountChanged() async { if (!_cryptoAmountChangeLock) { // Get the token's decimal places for proper amount parsing - final tokenDecimals = ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals; + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; if (cryptoAmountController.text.isNotEmpty && cryptoAmountController.text != "." && cryptoAmountController.text != ",") { try { // Parse the amount using the token's decimal places, not the coin's - final inputDecimal = Decimal.parse( - cryptoAmountController.text.replaceFirst(",", "."), + final parsedAmount = parseDesktopSolTokenAmount( + cryptoAmountController.text, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + coin: coin, + tokenContract: tokenWallet.solContract, ); - final cryptoAmount = Amount.fromDecimal( - inputDecimal, - fractionDigits: tokenDecimals, - ); - + if (parsedAmount == null) { + throw const FormatException(); + } // Only proceed if the parsed amount is valid - if (cryptoAmount.raw > BigInt.zero) { - _amountToSend = cryptoAmount; + if (parsedAmount.raw > BigInt.zero) { + _amountToSend = parsedAmount; if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { return; @@ -499,9 +533,7 @@ class _DesktopSolTokenSendState extends ConsumerState { final Amount amount = Decimal.parse(paymentData.amount!).toAmount( fractionDigits: ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals, ); - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .format(amount, withUnitName: false); + cryptoAmountController.text = _formatTokenAmount(amount); _amountToSend = amount; } @@ -555,15 +587,12 @@ class _DesktopSolTokenSendState extends ConsumerState { .read(pCurrentSolanaTokenWallet)! .tokenDecimals; - if (baseAmountString.isNotEmpty && - baseAmountString != "." && - baseAmountString != ",") { - final baseAmount = baseAmountString.contains(",") - ? Decimal.parse( - baseAmountString.replaceFirst(",", "."), - ).toAmount(fractionDigits: 2) - : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2); + final baseAmount = parseDesktopSolTokenFiatAmount( + baseAmountString, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + if (baseAmount != null) { final Decimal? _price = ref .read(priceAnd24hChangeNotifierProvider) .getTokenPrice(ref.read(pCurrentSolanaTokenWallet)!.tokenMint) @@ -583,12 +612,8 @@ class _DesktopSolTokenSendState extends ConsumerState { } _cachedAmountToSend = _amountToSend; - final amountString = ref - .read(pAmountFormatter(coin)) - .format(_amountToSend!, withUnitName: false); - _cryptoAmountChangeLock = true; - cryptoAmountController.text = amountString; + cryptoAmountController.text = _formatTokenAmount(_amountToSend!); _cryptoAmountChangeLock = false; } else { _amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals); @@ -609,9 +634,7 @@ class _DesktopSolTokenSendState extends ConsumerState { )), ); - cryptoAmountController.text = balance.spendable.decimal.toStringAsFixed( - tokenWallet.tokenDecimals, - ); + cryptoAmountController.text = _formatTokenAmount(balance.spendable); } @override @@ -639,7 +662,13 @@ class _DesktopSolTokenSendState extends ConsumerState { if (_data != null) { if (_data!.amount != null) { - cryptoAmountController.text = _data!.amount!.toString(); + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + cryptoAmountController.text = _formatTokenAmount( + Amount.fromDecimal( + _data!.amount!, + fractionDigits: tokenWallet.tokenDecimals, + ), + ); } sendToController.text = _data!.contactLabel; _address = _data!.address; @@ -734,7 +763,7 @@ class _DesktopSolTokenSendState extends ConsumerState { inputFormatters: [ AmountInputFormatter( decimals: tokenWallet.tokenDecimals, - unit: ref.watch(pAmountUnit(coin)), + unit: AmountUnit.normal, locale: ref.watch( localeServiceChangeNotifierProvider.select( (value) => value.locale, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart index f01cdd2464..014f8d80fd 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart @@ -55,6 +55,11 @@ import '../../../desktop_home_view.dart'; import 'address_book_address_chooser/address_book_address_chooser.dart'; import 'desktop_send_fee_form.dart'; +Amount? parseDesktopTokenFiatAmount(String value, {required String locale}) { + if (value.contains(RegExp(r'[+\- ]'))) return null; + return Amount.tryParseFiatString(value, locale: locale); +} + class DesktopTokenSend extends ConsumerStatefulWidget { const DesktopTokenSend({ super.key, @@ -523,15 +528,12 @@ class _DesktopTokenSendState extends ConsumerState { .tokenContract .decimals; - if (baseAmountString.isNotEmpty && - baseAmountString != "." && - baseAmountString != ",") { - final baseAmount = baseAmountString.contains(",") - ? Decimal.parse( - baseAmountString.replaceFirst(",", "."), - ).toAmount(fractionDigits: 2) - : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2); + final baseAmount = parseDesktopTokenFiatAmount( + baseAmountString, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + if (baseAmount != null) { final Decimal? _price = ref .read(priceAnd24hChangeNotifierProvider) .getTokenPrice(ref.read(pCurrentTokenWallet)!.tokenContract.address) diff --git a/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart b/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart new file mode 100644 index 0000000000..00a658094a --- /dev/null +++ b/test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/solana/sol_contract.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + test('desktop token inputs parse locale grouping', () { + final token = SolContract( + address: 'mint', + name: 'Token', + symbol: 'TKN', + decimals: 6, + ); + final solana = Solana(CryptoCurrencyNetwork.main); + + expect( + parseDesktopSolTokenAmount( + '1,000', + locale: 'en_US', + coin: solana, + tokenContract: token, + )?.raw, + BigInt.from(1000000000), + ); + expect( + parseDesktopSolTokenFiatAmount('1,000', locale: 'en_US')?.raw, + BigInt.from(100000), + ); + expect( + parseDesktopTokenFiatAmount('1.000', locale: 'de_DE')?.raw, + BigInt.from(100000), + ); + expect( + parseDesktopSolTokenAmount( + '-1', + locale: 'en_US', + coin: solana, + tokenContract: token, + ), + isNull, + ); + }); +} From 5c54df95160dda590e888cb023df924dabc412ff Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 12:02:39 -0600 Subject: [PATCH 06/52] fix mobile SPL token amount parsing --- lib/pages/send_view/sol_token_send_view.dart | 83 ++++++++++++------- .../sol_token_amount_parsing_test.dart | 40 +++++++++ 2 files changed, 91 insertions(+), 32 deletions(-) create mode 100644 test/pages/send_view/sol_token_amount_parsing_test.dart diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index 6187d4c53a..f81015b8d9 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -27,6 +27,7 @@ import '../../utilities/address_utils.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/amount/amount_input_formatter.dart'; +import '../../utilities/amount/amount_unit.dart'; import '../../utilities/assets.dart'; import '../../utilities/barcode_scanner_interface.dart'; import '../../utilities/clipboard_interface.dart'; @@ -56,6 +57,26 @@ import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; +Amount? parseMobileSolTokenAmount( + String value, { + required String locale, + required CryptoCurrency coin, + required SolContract tokenContract, +}) { + if (value.contains(RegExp(r'[+\- ]'))) return null; + return AmountUnit.normal.tryParse( + value, + locale: locale, + coin: coin, + tokenContract: tokenContract, + ); +} + +Amount? parseMobileSolTokenFiatAmount(String value, {required String locale}) { + if (value.contains(RegExp(r'[+\- ]'))) return null; + return Amount.tryParseFiatString(value, locale: locale); +} + class SolTokenSendView extends ConsumerStatefulWidget { const SolTokenSendView({ super.key, @@ -167,13 +188,7 @@ class _SolTokenSendViewState extends ConsumerState { final Amount amount = Decimal.parse( paymentData.amount!, ).toAmount(fractionDigits: tokenWallet.tokenDecimals); - cryptoAmountController.text = ref - .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) - .format( - amount, - withUnitName: false, - indicatePrecisionLoss: false, - ); + cryptoAmountController.text = _formatTokenAmount(amount); _amountToSend = amount; } } @@ -217,8 +232,20 @@ class _SolTokenSendViewState extends ConsumerState { } } + String _formatTokenAmount(Amount amount) { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + return AmountUnit.normal.displayAmount( + amount: amount, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + coin: tokenWallet.cryptoCurrency, + maxDecimalPlaces: tokenWallet.tokenDecimals, + withUnitName: false, + tokenContract: tokenWallet.solContract, + ); + } + void _onFiatAmountFieldChanged(String baseAmountString) { - final baseAmount = Amount.tryParseFiatString( + final baseAmount = parseMobileSolTokenFiatAmount( baseAmountString, locale: ref.read(localeServiceChangeNotifierProvider).locale, ); @@ -249,9 +276,7 @@ class _SolTokenSendViewState extends ConsumerState { _cachedAmountToSend = _amountToSend; _cryptoAmountChangeLock = true; - cryptoAmountController.text = ref - .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) - .format(_amountToSend!, withUnitName: false); + cryptoAmountController.text = _formatTokenAmount(_amountToSend!); _cryptoAmountChangeLock = false; } else { _amountToSend = Amount.zero; @@ -267,9 +292,12 @@ class _SolTokenSendViewState extends ConsumerState { final tokenWallet = ref.read(pCurrentSolanaTokenWallet); if (tokenWallet == null) return; - final cryptoAmount = Decimal.tryParse( + final cryptoAmount = parseMobileSolTokenAmount( cryptoAmountController.text, - )?.toAmount(fractionDigits: tokenWallet.tokenDecimals); + locale: ref.read(localeServiceChangeNotifierProvider).locale, + coin: tokenWallet.cryptoCurrency, + tokenContract: tokenWallet.solContract, + ); if (cryptoAmount != null) { _amountToSend = cryptoAmount; if (_cachedAmountToSend != null && @@ -581,7 +609,13 @@ class _SolTokenSendViewState extends ConsumerState { if (_data != null) { if (_data.amount != null) { - cryptoAmountController.text = _data.amount!.toString(); + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + cryptoAmountController.text = _formatTokenAmount( + Amount.fromDecimal( + _data.amount!, + fractionDigits: tokenWallet.tokenDecimals, + ), + ); } sendToController.text = _data.contactLabel; _address = _data.address.trim(); @@ -731,15 +765,8 @@ class _SolTokenSendViewState extends ConsumerState { const Spacer(), GestureDetector( onTap: () { - cryptoAmountController.text = ref - .watch( - pAmountFormatter( - Solana( - CryptoCurrencyNetwork.main, - ), - ), - ) - .format( + cryptoAmountController.text = + _formatTokenAmount( ref .read( pSolanaTokenBalance(( @@ -748,8 +775,6 @@ class _SolTokenSendViewState extends ConsumerState { )), ) .spendable, - withUnitName: false, - indicatePrecisionLoss: true, ); }, child: Container( @@ -1064,13 +1089,7 @@ class _SolTokenSendViewState extends ConsumerState { inputFormatters: [ AmountInputFormatter( decimals: tokenWallet.tokenDecimals, - // TODO: Implement token-specific unit lookup - // similar to Ethereum's pAmountUnit(coin).unitForContract(tokenContract) - unit: ref.watch( - pAmountUnit( - Solana(CryptoCurrencyNetwork.main), - ), - ), + unit: AmountUnit.normal, locale: locale, ), ], diff --git a/test/pages/send_view/sol_token_amount_parsing_test.dart b/test/pages/send_view/sol_token_amount_parsing_test.dart new file mode 100644 index 0000000000..542dabcf4b --- /dev/null +++ b/test/pages/send_view/sol_token_amount_parsing_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/solana/sol_contract.dart'; +import 'package:stackwallet/pages/send_view/sol_token_send_view.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + test("mobile SPL inputs parse locale grouping", () { + final token = SolContract( + address: "mint", + name: "Token", + symbol: "TKN", + decimals: 6, + ); + final solana = Solana(CryptoCurrencyNetwork.main); + + expect( + parseMobileSolTokenAmount( + "1,000", + locale: "en_US", + coin: solana, + tokenContract: token, + )?.raw, + BigInt.from(1000000000), + ); + expect( + parseMobileSolTokenAmount( + "1.000", + locale: "de_DE", + coin: solana, + tokenContract: token, + )?.raw, + BigInt.from(1000000000), + ); + expect( + parseMobileSolTokenFiatAmount("1,000", locale: "en_US")?.raw, + BigInt.from(100000), + ); + expect(parseMobileSolTokenFiatAmount("+1", locale: "en_US"), isNull); + }); +} From 467f610308e4f5a54f0d259594be0c78257ddd13 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 12:14:05 -0600 Subject: [PATCH 07/52] clean up isar wallet data delete --- lib/db/isar/main_db.dart | 64 ++++------------------------------------ 1 file changed, 6 insertions(+), 58 deletions(-) diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 8958114736..b39757f208 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -463,52 +463,14 @@ class MainDB { // Future deleteWalletBlockchainData(String walletId) async { await isar.writeTxn(() async { - final transactionCount = await getTransactions(walletId).count(); - final transactionCountV2 = await isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .count(); - final addressCount = await getAddresses(walletId).count(); - final utxoCount = await getUTXOs(walletId).count(); - - const paginateLimit = 100; - // transactions - for (int i = 0; i < transactionCount; i += paginateLimit) { - final txnIds = await getTransactions( - walletId, - ).offset(i).limit(paginateLimit).idProperty().findAll(); - await isar.transactions.deleteAll(txnIds); - } - + await getTransactions(walletId).deleteAll(); // transactions V2 - for (int i = 0; i < transactionCountV2; i += paginateLimit) { - final txnIds = await isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .offset(i) - .limit(paginateLimit) - .idProperty() - .findAll(); - await isar.transactionV2s.deleteAll(txnIds); - } - + await isar.transactionV2s.where().walletIdEqualTo(walletId).deleteAll(); // addresses - for (int i = 0; i < addressCount; i += paginateLimit) { - final addressIds = await getAddresses( - walletId, - ).offset(i).limit(paginateLimit).idProperty().findAll(); - await isar.addresses.deleteAll(addressIds); - } - + await getAddresses(walletId).deleteAll(); // utxos - for (int i = 0; i < utxoCount; i += paginateLimit) { - final utxoIds = await getUTXOs( - walletId, - ).offset(i).limit(paginateLimit).idProperty().findAll(); - await isar.utxos.deleteAll(utxoIds); - } - + await getUTXOs(walletId).deleteAll(); // spark coins await isar.sparkCoins .where() @@ -518,28 +480,14 @@ class MainDB { } Future deleteAddressLabels(String walletId) async { - final addressLabelCount = await getAddressLabels(walletId).count(); await isar.writeTxn(() async { - const paginateLimit = 50; - for (int i = 0; i < addressLabelCount; i += paginateLimit) { - final labelIds = await getAddressLabels( - walletId, - ).offset(i).limit(paginateLimit).idProperty().findAll(); - await isar.addressLabels.deleteAll(labelIds); - } + await getAddressLabels(walletId).deleteAll(); }); } Future deleteTransactionNotes(String walletId) async { - final noteCount = await getTransactionNotes(walletId).count(); await isar.writeTxn(() async { - const paginateLimit = 50; - for (int i = 0; i < noteCount; i += paginateLimit) { - final labelIds = await getTransactionNotes( - walletId, - ).offset(i).limit(paginateLimit).idProperty().findAll(); - await isar.transactionNotes.deleteAll(labelIds); - } + await getTransactionNotes(walletId).deleteAll(); }); } From 0e56467c649733db625ed242d071c3869782d6f0 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 12:16:47 -0600 Subject: [PATCH 08/52] fix mwc first block --- lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index d31be377f1..02797e4601 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -834,7 +834,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { int _calculateRestoreHeightFrom({required DateTime date}) { final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; - const int mimblewimblecoinFirstBlock = 1565370278; + const int mimblewimblecoinFirstBlock = 1573462800; const double overestimateSecondsPerBlock = 61; final int chosenSeconds = secondsSinceEpoch - mimblewimblecoinFirstBlock; final int approximateHeight = chosenSeconds ~/ overestimateSecondsPerBlock; From 9c37a8f05c08fc76b80028f3a2ebd69f84fd68f8 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 12:22:15 -0600 Subject: [PATCH 09/52] fix frost prev keys issue --- .../wallet_keys_desktop_popup.dart | 254 +++++++++--------- .../wallet_keys_desktop_popup_test.dart | 58 ++++ 2 files changed, 187 insertions(+), 125 deletions(-) create mode 100644 test/widget_tests/desktop/wallet_keys_desktop_popup_test.dart diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart index 2c31098a2d..95b2855ecc 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart @@ -88,90 +88,16 @@ class WalletKeysDesktopPopup extends ConsumerWidget { const SizedBox(height: 6), frostData != null ? Column( - children: [ - Text("Keys", style: STextStyles.desktopTextMedium(context)), - const SizedBox(height: 8), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 9, - ), - child: Row( - children: [ - Flexible( - child: SelectableText( - frostData!.keys, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(width: 10), - IconCopyButton(data: frostData!.keys), - // TODO [prio=low: Add QR code button and dialog. - ], - ), - ), - ), - ), - const SizedBox(height: 24), - Text("Config", style: STextStyles.desktopTextMedium(context)), - const SizedBox(height: 8), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 9, - ), - child: Row( - children: [ - Flexible( - child: SelectableText( - frostData!.config, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(width: 10), - IconCopyButton(data: frostData!.config), - // TODO [prio=low: Add QR code button and dialog. - ], - ), - ), - ), - ), - if (frostData?.prevGen != null) const SizedBox(height: 24), - if (frostData?.prevGen != null) - Text( - "Previous generation Keys", - style: STextStyles.desktopTextMedium(context), - ), - if (frostData?.prevGen != null) const SizedBox(height: 8), - if (frostData?.prevGen != null) + children: [ + Text("Keys", style: STextStyles.desktopTextMedium(context)), + const SizedBox(height: 8), Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 9, @@ -195,22 +121,19 @@ class WalletKeysDesktopPopup extends ConsumerWidget { ), ), ), - if (frostData?.prevGen != null) const SizedBox(height: 24), - if (frostData?.prevGen != null) + const SizedBox(height: 24), Text( - "Previous generation Config", + "Config", style: STextStyles.desktopTextMedium(context), ), - if (frostData?.prevGen != null) const SizedBox(height: 8), - if (frostData?.prevGen != null) + const SizedBox(height: 8), Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 9, @@ -219,7 +142,7 @@ class WalletKeysDesktopPopup extends ConsumerWidget { children: [ Flexible( child: SelectableText( - frostData!.prevGen!.config, + frostData!.config, style: STextStyles.desktopTextExtraExtraSmall( context, ), @@ -227,48 +150,128 @@ class WalletKeysDesktopPopup extends ConsumerWidget { ), ), const SizedBox(width: 10), - IconCopyButton(data: frostData!.prevGen!.config), + IconCopyButton(data: frostData!.config), // TODO [prio=low: Add QR code button and dialog. ], ), ), ), ), - const SizedBox(height: 24), - ], - ) - : keyData != null - ? keyData is ViewOnlyWalletData - ? Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ViewOnlyWalletDataWidget( - data: keyData as ViewOnlyWalletData, - ), - ) - : CustomTabView( - titles: [ - if (words.isNotEmpty) "Mnemonic", - if (keyData is XPrivData) "XPriv(s)", - if (keyData is CWKeyData) "Keys", - ], - children: [ - if (words.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 16), - child: _Mnemonic(words: words), + if (frostData?.prevGen != null) const SizedBox(height: 24), + if (frostData?.prevGen != null) + Text( + "Previous generation Keys", + style: STextStyles.desktopTextMedium(context), + ), + if (frostData?.prevGen != null) const SizedBox(height: 8), + if (frostData?.prevGen != null) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 9, + ), + child: Row( + children: [ + Flexible( + child: SelectableText( + frostData!.prevGen!.keys, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 10), + IconCopyButton(data: frostData!.prevGen!.keys), + // TODO [prio=low: Add QR code button and dialog. + ], + ), + ), ), - if (keyData is XPrivData) - WalletXPrivs( - xprivData: keyData as XPrivData, - walletId: walletId, + ), + if (frostData?.prevGen != null) const SizedBox(height: 24), + if (frostData?.prevGen != null) + Text( + "Previous generation Config", + style: STextStyles.desktopTextMedium(context), + ), + if (frostData?.prevGen != null) const SizedBox(height: 8), + if (frostData?.prevGen != null) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 9, + ), + child: Row( + children: [ + Flexible( + child: SelectableText( + frostData!.prevGen!.config, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 10), + IconCopyButton( + data: frostData!.prevGen!.config, + ), + // TODO [prio=low: Add QR code button and dialog. + ], + ), + ), ), - if (keyData is CWKeyData) - CNWalletKeys( - cwKeyData: keyData as CWKeyData, - walletId: walletId, + ), + const SizedBox(height: 24), + ], + ) + : keyData != null + ? keyData is ViewOnlyWalletData + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ViewOnlyWalletDataWidget( + data: keyData as ViewOnlyWalletData, ), - ], - ) + ) + : CustomTabView( + titles: [ + if (words.isNotEmpty) "Mnemonic", + if (keyData is XPrivData) "XPriv(s)", + if (keyData is CWKeyData) "Keys", + ], + children: [ + if (words.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 16), + child: _Mnemonic(words: words), + ), + if (keyData is XPrivData) + WalletXPrivs( + xprivData: keyData as XPrivData, + walletId: walletId, + ), + if (keyData is CWKeyData) + CNWalletKeys( + cwKeyData: keyData as CWKeyData, + walletId: walletId, + ), + ], + ) : _Mnemonic(words: words), const SizedBox(height: 32), ], @@ -311,8 +314,9 @@ class _Mnemonic extends StatelessWidget { child: MnemonicTable( words: words, isDesktop: true, - itemBorderColor: - Theme.of(context).extension()!.buttonBackSecondary, + itemBorderColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, ), ), const SizedBox(height: 24), diff --git a/test/widget_tests/desktop/wallet_keys_desktop_popup_test.dart b/test/widget_tests/desktop/wallet_keys_desktop_popup_test.dart new file mode 100644 index 0000000000..6cfa28fa8e --- /dev/null +++ b/test/widget_tests/desktop/wallet_keys_desktop_popup_test.dart @@ -0,0 +1,58 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:stackwallet/models/isar/stack_theme.dart"; +import "package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart" + show IconCopyButton; +import "package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart"; +import "package:stackwallet/themes/stack_colors.dart"; + +import "../../sample_data/theme_json.dart"; + +void main() { + testWidgets("shows and copies the previous FROST keys", (tester) async { + tester.view.physicalSize = const Size(1200, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: const Scaffold( + body: WalletKeysDesktopPopup( + words: [], + walletId: "wallet", + frostData: ( + myName: "name", + keys: "current-keys", + config: "current-config", + prevGen: (keys: "previous-keys", config: "previous-config"), + ), + ), + ), + ), + ), + ); + + expect( + tester + .widgetList(find.byType(SelectableText)) + .map((widget) => widget.data), + ["current-keys", "current-config", "previous-keys", "previous-config"], + ); + expect( + tester + .widgetList(find.byType(IconCopyButton)) + .map((widget) => widget.data), + ["current-keys", "current-config", "previous-keys", "previous-config"], + ); + }); +} From 11c5bdfd70bd82bb2a0fe55f7ac7621f69bea6a1 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 12:29:58 -0600 Subject: [PATCH 10/52] fix test with platform specific check --- .../screen_tests/onboarding/create_pin_view_screen_test.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/screen_tests/onboarding/create_pin_view_screen_test.dart b/test/screen_tests/onboarding/create_pin_view_screen_test.dart index 455d347c45..9029e84792 100644 --- a/test/screen_tests/onboarding/create_pin_view_screen_test.dart +++ b/test/screen_tests/onboarding/create_pin_view_screen_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,7 +12,6 @@ import 'package:stackwallet/providers/global/prefs_provider.dart'; import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/themes/theme_service.dart'; import 'package:stackwallet/utilities/biometrics.dart'; -import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/widgets/custom_pin_put/pin_keyboard.dart'; import '../../sample_data/theme_json.dart'; @@ -132,7 +133,7 @@ void main() { expect(await platformOverrides.secureStorage.read(key: kPinKey), '1234'); expect(platformOverrides.secureStorage.writes, 1); - expect(biometrics.calls, 0); + expect(biometrics.calls, Platform.isLinux ? 0 : 1); verify(prefs.useBiometrics = false).called(1); verify(prefs.hasPin = true).called(1); From f372d52282ef47d3d1b71bc8d7d1bcda9e5f7743 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 12:33:00 -0600 Subject: [PATCH 11/52] ensure marked used UTXOs do not get included in frost txn --- lib/wallets/wallet/impl/bitcoin_frost_wallet.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 00537b12b8..28f53e33db 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -125,6 +125,7 @@ class BitcoinFrostWallet extends Wallet .getUTXOs(walletId) .filter() .isBlockedEqualTo(false) + .group((q) => q.usedEqualTo(false).or().usedIsNull()) .findAll(); if (utxos.isEmpty) { From c6f639c8334b4c266a539aa8562b029de7fd7ac8 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 12:38:33 -0600 Subject: [PATCH 12/52] fix checkTrades early return --- lib/services/notifications_service.dart | 48 ++++++++++++------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/lib/services/notifications_service.dart b/lib/services/notifications_service.dart index 4eca542e06..fa7575d314 100644 --- a/lib/services/notifications_service.dart +++ b/lib/services/notifications_service.dart @@ -109,7 +109,7 @@ class NotificationsService extends ChangeNotifier { _timer = Timer.periodic(notificationRefreshInterval, (_) { Logging.instance.d("Periodic notifications update check"); if (prefs.externalCalls) { - _checkTrades(); + unawaited(_checkTrades()); } _checkTransactions(); }); @@ -159,21 +159,20 @@ class NotificationsService extends ChangeNotifier { torEnabled: node.torEnabled, clearnetEnabled: node.clearnetEnabled, ); - final failovers = - nodeService - .failoverNodesFor(currency: coin) - .map( - (e) => ElectrumXNode( - address: e.host, - port: e.port, - name: e.name, - id: e.id, - useSSL: e.useSSL, - torEnabled: node.torEnabled, - clearnetEnabled: node.clearnetEnabled, - ), - ) - .toList(); + final failovers = nodeService + .failoverNodesFor(currency: coin) + .map( + (e) => ElectrumXNode( + address: e.host, + port: e.port, + name: e.name, + id: e.id, + useSSL: e.useSSL, + torEnabled: node.torEnabled, + clearnetEnabled: node.clearnetEnabled, + ), + ) + .toList(); final client = ElectrumXClient.from( node: eNode, @@ -233,7 +232,7 @@ class NotificationsService extends ChangeNotifier { } } - void _checkTrades() async { + Future _checkTrades() async { for (final notification in _watchedChangeNowTradeNotifications) { final id = notification.changeNowId!; @@ -243,7 +242,7 @@ class NotificationsService extends ChangeNotifier { ); if (trades.isEmpty) { - return; + continue; } final oldTrade = trades.first; late final ExchangeResponse response; @@ -252,11 +251,11 @@ class NotificationsService extends ChangeNotifier { final exchange = Exchange.fromName(oldTrade.exchangeName); response = await exchange.updateTrade(oldTrade); } catch (_) { - return; + continue; } if (response.value == null) { - return; + continue; } final trade = response.value!; @@ -371,11 +370,10 @@ class NotificationsService extends ChangeNotifier { } Future markAsRead(int id, bool shouldNotifyListeners) async { - final model = - DB.instance.get( - boxName: DB.boxNameNotifications, - key: id, - )!; + final model = DB.instance.get( + boxName: DB.boxNameNotifications, + key: id, + )!; await DB.instance.put( boxName: DB.boxNameNotifications, key: model.id, From 3b57511d2d0387d5ca7a279083bc18c64d94e674 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 12:49:14 -0600 Subject: [PATCH 13/52] fix exchange refund gating --- lib/pages/exchange_view/exchange_step_views/step_2_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart index 1b2fa42c44..e9735a5e26 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart @@ -150,7 +150,7 @@ class _Step2ViewState extends ConsumerState { enableNext = _toController.text.isNotEmpty && (_refundController.text.isNotEmpty || - !!ref.read(efExchangeProvider).supportsRefundAddress); + !ref.read(efExchangeProvider).supportsRefundAddress); }); } } on PlatformException catch (e, s) { From 9cd17dbd874718d1fbe86e120229d00a4bb18d2b Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 13:06:05 -0600 Subject: [PATCH 14/52] fix mweb fee provider --- lib/pages/send_view/send_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 18b8d5be2e..e763eb9104 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -1080,7 +1080,7 @@ class _SendViewState extends ConsumerState { addressType: wallet.cryptoCurrency.getAddressType(_address!)!, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), + feeRateType: ref.read(feeRateTypeMobileStateProvider), satsPerVByte: isCustomFee.value ? customFeeRate : null, // these will need to be mweb utxos From 598898ae899969d3151d2dc1e3c6fc67d01c4c69 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 13:16:06 -0600 Subject: [PATCH 15/52] clean up epicbox/http/https check --- lib/wallets/wallet/impl/epiccash_wallet.dart | 14 +++++++++----- test/wallets/epiccash_routing_test.dart | 13 +++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 test/wallets/epiccash_routing_test.dart diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index 3746a4836e..805ea30a4e 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:decimal/decimal.dart'; +import 'package:flutter/foundation.dart'; import 'package:isar_community/isar.dart'; import 'package:mutex/mutex.dart'; import 'package:stack_wallet_backup/generate_password.dart'; @@ -931,18 +932,22 @@ class EpiccashWallet extends Bip39Wallet { return await super.init(); } + @visibleForTesting + bool shouldCheckEpicbox(String receiverAddress) => + !isHttpAddress(receiverAddress); + @override Future confirmSend({required TxData txData}) async { try { _hackedCheckTorNodePrefs(); - final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); // TODO determine whether it is worth sending change to a change address. final String receiverAddress = txData.recipients!.first.address; + final useEpicbox = shouldCheckEpicbox(receiverAddress); - if (!receiverAddress.startsWith("http://") || - !receiverAddress.startsWith("https://")) { + if (useEpicbox) { + final epicboxConfig = await getEpicBoxConfig(); final bool isEpicboxConnected = await _testEpicboxServer(epicboxConfig); if (!isEpicboxConnected) { throw Exception("Failed to send TX : Unable to reach epicbox server"); @@ -951,8 +956,7 @@ class EpiccashWallet extends Bip39Wallet { ({String commitId, String slateId, String slateJson}) transaction; - if (receiverAddress.startsWith("http://") || - receiverAddress.startsWith("https://")) { + if (!useEpicbox) { final httpResult = await libEpic.txHttpSend( wallet: _wallet!, selectionStrategyIsAll: 0, diff --git a/test/wallets/epiccash_routing_test.dart b/test/wallets/epiccash_routing_test.dart new file mode 100644 index 0000000000..2b56d64641 --- /dev/null +++ b/test/wallets/epiccash_routing_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; + +void main() { + test('HTTP receivers bypass Epicbox', () { + final wallet = EpiccashWallet(CryptoCurrencyNetwork.main); + + expect(wallet.shouldCheckEpicbox('http://receiver'), isFalse); + expect(wallet.shouldCheckEpicbox('https://receiver'), isFalse); + expect(wallet.shouldCheckEpicbox('user@epicbox.example'), isTrue); + }); +} From 53481873cc38d5bb232ce774e7ff162f8b95b697 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 13:17:21 -0600 Subject: [PATCH 16/52] fix missing xelis stagenet case --- lib/wallets/crypto_currency/coins/xelis.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/wallets/crypto_currency/coins/xelis.dart b/lib/wallets/crypto_currency/coins/xelis.dart index d022082021..2d946bca62 100644 --- a/lib/wallets/crypto_currency/coins/xelis.dart +++ b/lib/wallets/crypto_currency/coins/xelis.dart @@ -83,7 +83,7 @@ class Xelis extends ElectrumCurrency { isPrimary: isPrimary, ); - case CryptoCurrencyNetwork.test: + case CryptoCurrencyNetwork.stage: return NodeModel( host: "stagenet-node.xelis.io", port: 443, From 97ddbfbae6fdb348ce960708dda446c0c89219e4 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 13:42:52 -0600 Subject: [PATCH 17/52] return false on ping timeout --- lib/electrumx_rpc/electrumx_client.dart | 11 ++++++++--- test/electrumx_test.dart | 26 +++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index 94b650b103..d90a662ad5 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -519,7 +519,11 @@ class ElectrumXClient { /// Ping the server to ensure it is responding /// /// Returns true if ping succeeded - Future ping({String? requestID, int retryCount = 1}) async { + Future ping({ + String? requestID, + int retryCount = 1, + Duration timeout = const Duration(seconds: 30), + }) async { try { // This doesn't work because electrum_adapter only returns the result: // (which is always `null`). @@ -535,14 +539,15 @@ class ElectrumXClient { return await request( requestID: requestID, command: 'server.ping', - requestTimeout: const Duration(seconds: 30), + requestTimeout: timeout, retries: retryCount, ).timeout( - const Duration(seconds: 30), + timeout, onTimeout: () { Logging.instance.d( "ElectrumxClient.ping timed out with retryCount=$retryCount, host=$_host", ); + return false; }, ) as bool; diff --git a/test/electrumx_test.dart b/test/electrumx_test.dart index b8c82c8c18..769dbc772f 100644 --- a/test/electrumx_test.dart +++ b/test/electrumx_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:decimal/decimal.dart'; @@ -11,8 +12,6 @@ import 'package:stackwallet/services/tor_service.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/tor_plain_net_option_enum.dart'; -import 'package:stackwallet/wallets/crypto_currency/coins/bitcoin.dart'; -import 'package:stackwallet/wallets/crypto_currency/coins/firo.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'sample_data/get_anonymity_set_sample_data.dart'; @@ -217,6 +216,29 @@ void main() { expect(server.requestCount('server.ping'), 1); }); + test('ping timeout returns false', () async { + final response = Completer(); + addTearDown(() { + if (!response.isCompleted) { + response.complete(); + } + }); + final server = registerServer( + handlers: {'server.ping': (_) => response.future}, + ); + final client = buildClient(clearServer: server, coin: bitcoin()); + await client.checkElectrumAdapter(); + + final result = await client.ping( + requestID: 'ping-timeout', + timeout: const Duration(milliseconds: 100), + ); + response.complete(); + + expect(result, isFalse); + expect(server.requestCount('server.ping'), 1); + }); + test('server.features success returns a parsed map', () async { final expected = { 'genesis_hash': 'genesis', From e1354a89ffe5d94c87fcc3762f3c37bf0badb4f3 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 14:05:07 -0600 Subject: [PATCH 18/52] fix sorting to match comment --- .../paynym_interface.dart | 29 +++++++------- test/paynym_p2tr_test.dart | 38 ++++++++++++++++++- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index 6c815ec12d..37895551f7 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -9,6 +9,7 @@ import 'package:bitcoindart/src/utils/constants/op.dart' as op; import 'package:bitcoindart/src/utils/script.dart' as bscript; import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; import 'package:isar_community/isar.dart'; +import 'package:meta/meta.dart'; import 'package:pointycastle/digests/sha256.dart'; import 'package:tuple/tuple.dart'; @@ -24,7 +25,6 @@ import '../../../utilities/bip32_utils.dart'; import '../../../utilities/bip47_utils.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; import '../../../utilities/extensions/extensions.dart'; -import '../../../utilities/format.dart'; import '../../../utilities/logger.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../crypto_currency/interfaces/paynym_currency_interface.dart'; @@ -46,6 +46,20 @@ String _receivingPaynymAddressDerivationPath( String _sendPaynymAddressDerivationPath(int index, {required bool testnet}) => "${_basePaynymDerivePath(testnet: testnet)}/0/$index"; +@visibleForTesting +int comparePaynymNotificationUtxos(UTXO a, UTXO b) { + final aIsTaproot = + a.address?.startsWith('bc1p') == true || + a.address?.startsWith('tb1p') == true; + final bIsTaproot = + b.address?.startsWith('bc1p') == true || + b.address?.startsWith('tb1p') == true; + if (aIsTaproot != bIsTaproot) { + return aIsTaproot ? 1 : -1; + } + return a.blockTime!.compareTo(b.blockTime!); +} + mixin PaynymInterface on Bip39HDWallet, ElectrumXInterface { btc_dart.NetworkType get networkType => btc_dart.NetworkType( @@ -570,18 +584,7 @@ mixin PaynymInterface // Sort spendable by age (oldest first), but push taproot UTXOs to the // end since taproot inputs don't expose the raw public key needed by the // receiver to compute ECDH for BIP47 notification parsing. - spendableOutputs.sort((a, b) { - final aIsTaproot = - a.address?.startsWith('bc1p') == true || - a.address?.startsWith('tb1p') == true; - final bIsTaproot = - b.address?.startsWith('bc1p') == true || - b.address?.startsWith('tb1p') == true; - if (aIsTaproot != bIsTaproot) { - return aIsTaproot ? 1 : -1; - } - return b.blockTime!.compareTo(a.blockTime!); - }); + spendableOutputs.sort(comparePaynymNotificationUtxos); BigInt satoshisBeingUsed = BigInt.zero; int outputsBeingUsed = 0; diff --git a/test/paynym_p2tr_test.dart b/test/paynym_p2tr_test.dart index e4fd1abb0d..0d031b8a00 100644 --- a/test/paynym_p2tr_test.dart +++ b/test/paynym_p2tr_test.dart @@ -2,12 +2,30 @@ import 'package:bip32/bip32.dart' as bip32; import 'package:bip39/bip39.dart' as bip39; import 'package:bip47/bip47.dart'; import 'package:bitcoindart/bitcoindart.dart' as bitcoindart; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; import 'package:stackwallet/models/paynym/paynym_account_lite.dart'; -import 'package:test/test.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; + +UTXO _utxo(String txid, int blockTime, String address) => UTXO( + walletId: 'wallet', + txid: txid, + vout: 0, + value: 1, + name: '', + isBlocked: false, + blockedReason: null, + isCoinbase: false, + blockHash: 'hash', + blockHeight: 1, + blockTime: blockTime, + address: address, +); void main() { const mnemonic = - 'response seminar brave million suit skate inhale proud weapon daring champion'; + 'response seminar brave million suit skate inhale proud weapon ' + 'daring champion'; final networkType = bip32.NetworkType( wif: bitcoindart.bitcoin.wif, @@ -43,6 +61,22 @@ void main() { taprootPaymentCodeString = taprootCode.toString(); }); + test('notification UTXOs prefer non-Taproot then oldest', () { + final utxos = [ + _utxo('taproot-newer', 50, 'bc1ptaproot'), + _utxo('legacy-newer', 200, 'bc1qlegacy'), + _utxo('taproot-older', 25, 'tb1ptaproot'), + _utxo('legacy-older', 100, '1legacy'), + ]..sort(comparePaynymNotificationUtxos); + + expect(utxos.map((utxo) => utxo.txid).toList(), [ + 'legacy-older', + 'legacy-newer', + 'taproot-older', + 'taproot-newer', + ]); + }); + group('PaynymAccountLite taproot inference', () { test('inferTaproot returns true for taproot-enabled payment code', () { final result = PaynymAccountLite.inferTaproot(taprootPaymentCodeString); From 75d0e18d160af93c03f0eb14ba19621028af4b03 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 14:19:11 -0600 Subject: [PATCH 19/52] fix duplicate trade details dialog appearing on desktop --- .../desktop_all_trades_view.dart | 326 +++++++++--------- .../desktop_trade_details_presenter_test.dart | 25 ++ 2 files changed, 179 insertions(+), 172 deletions(-) create mode 100644 test/pages_desktop_specific/desktop_exchange/desktop_trade_details_presenter_test.dart diff --git a/lib/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart b/lib/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart index 61d763bf26..19985a9d3b 100644 --- a/lib/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart +++ b/lib/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart @@ -44,6 +44,11 @@ import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; +Future loadAndPresentDesktopTradeDetails({ + required Future Function() load, + required void Function(T) present, +}) async => present(await load()); + class DesktopAllTradesView extends ConsumerStatefulWidget { const DesktopAllTradesView({super.key}); @@ -107,19 +112,17 @@ class _DesktopAllTradesViewState extends ConsumerState { const SizedBox(width: 32), AppBarIconButton( size: 32, - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: Navigator.of(context).pop, ), @@ -150,54 +153,52 @@ class _DesktopAllTradesViewState extends ConsumerState { _searchString = value; }); }, - style: STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: - Theme.of( + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of( context, ).extension()!.textFieldActiveText, - height: 1.8, - ), - decoration: standardInputDecoration( - "Search...", - searchFieldFocusNode, - context, - desktopMed: true, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 18, + height: 1.8, ), - child: SvgPicture.asset( - Assets.svg.search, - width: 20, - height: 20, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search...", + searchFieldFocusNode, + context, + desktopMed: true, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 18, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 20, + height: 20, + ), + ), + suffixIcon: _searchController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - _searchString = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = ""; + _searchString = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -240,25 +241,22 @@ class _DesktopAllTradesViewState extends ConsumerState { child: ListView.separated( shrinkWrap: true, primary: false, - separatorBuilder: - (context, _) => Container( - height: 1, - color: - Theme.of(context) - .extension()! - .background, - ), + separatorBuilder: (context, _) => Container( + height: 1, + color: Theme.of( + context, + ).extension()!.background, + ), itemCount: month.item2.length, - itemBuilder: - (context, index) => Padding( - padding: const EdgeInsets.all(4), - child: DesktopTradeRowCard( - key: Key( - "transactionCard_key_${month.item2[index].tradeId}", - ), - tradeId: month.item2[index].tradeId, - ), + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.all(4), + child: DesktopTradeRowCard( + key: Key( + "transactionCard_key_${month.item2[index].tradeId}", ), + tradeId: month.item2[index].tradeId, + ), + ), ), ), ], @@ -341,8 +339,9 @@ class _DesktopTradeRowCardState extends ConsumerState { .read(tradeSentFromStackLookupProvider) .getWalletIdsForTradeId(tradeId); - final trade = - ref.watch(tradesServiceProvider.select((value) => value.get(tradeId)))!; + final trade = ref.watch( + tradesServiceProvider.select((value) => value.get(tradeId)), + )!; return Material( color: Theme.of(context).extension()!.popupBG, @@ -363,36 +362,18 @@ class _DesktopTradeRowCardState extends ConsumerState { //todo: check if print needed // debugPrint("name: ${manager.walletName}"); - final tx = - await MainDB.instance - .getTransactions(walletIds.first) - .filter() - .txidEqualTo(txid) - .findFirst(); - - if (mounted) { - await showDialog( - context: context, - builder: - (context) => DesktopDialog( - maxHeight: MediaQuery.of(context).size.height - 64, - maxWidth: 580, - child: TradeDetailsView( - tradeId: tradeId, - transactionIfSentFromStack: tx, - walletName: ref.read(pWalletName(walletIds.first)), - walletId: walletIds.first, - ), - ), - ); - } - - if (mounted) { - unawaited( - showDialog( - context: context, - builder: - (context) => Navigator( + await loadAndPresentDesktopTradeDetails( + load: () => MainDB.instance + .getTransactions(walletIds.first) + .filter() + .txidEqualTo(txid) + .findFirst(), + present: (tx) { + if (mounted) { + unawaited( + showDialog( + context: context, + builder: (context) => Navigator( initialRoute: TradeDetailsView.routeName, onGenerateRoute: RouteGenerator.generateRoute, onGenerateInitialRoutes: (_, __) { @@ -420,11 +401,10 @@ class _DesktopTradeRowCardState extends ConsumerState { ), ), DesktopDialogCloseButton( - onPressedOverride: - Navigator.of( - context, - rootNavigator: true, - ).pop, + onPressedOverride: Navigator.of( + context, + rootNavigator: true, + ).pop, ), ], ), @@ -452,70 +432,68 @@ class _DesktopTradeRowCardState extends ConsumerState { ]; }, ), - ), - ); - } + ), + ); + } + }, + ); } else { unawaited( showDialog( context: context, - builder: - (context) => Navigator( - initialRoute: TradeDetailsView.routeName, - onGenerateRoute: RouteGenerator.generateRoute, - onGenerateInitialRoutes: (_, __) { - return [ - FadePageRoute( - DesktopDialog( - maxHeight: null, - maxWidth: 580, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only( - left: 32, - bottom: 16, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - "Trade details", - style: STextStyles.desktopH3(context), - ), - DesktopDialogCloseButton( - onPressedOverride: - Navigator.of( - context, - rootNavigator: true, - ).pop, - ), - ], + builder: (context) => Navigator( + initialRoute: TradeDetailsView.routeName, + onGenerateRoute: RouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, __) { + return [ + FadePageRoute( + DesktopDialog( + maxHeight: null, + maxWidth: 580, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 32, + bottom: 16, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + "Trade details", + style: STextStyles.desktopH3(context), ), - ), - Flexible( - child: SingleChildScrollView( - primary: false, - child: TradeDetailsView( - tradeId: tradeId, - transactionIfSentFromStack: null, - walletName: null, - walletId: walletIds?.first, - ), + DesktopDialogCloseButton( + onPressedOverride: Navigator.of( + context, + rootNavigator: true, + ).pop, ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + primary: false, + child: TradeDetailsView( + tradeId: tradeId, + transactionIfSentFromStack: null, + walletName: null, + walletId: walletIds?.first, ), - ], + ), ), - ), - const RouteSettings( - name: TradeDetailsView.routeName, - ), + ], ), - ]; - }, - ), + ), + const RouteSettings(name: TradeDetailsView.routeName), + ), + ]; + }, + ), ), ); } @@ -547,12 +525,14 @@ class _DesktopTradeRowCardState extends ConsumerState { Expanded( flex: 3, child: Text( - "${trade.payInCurrency.toUpperCase()} → ${trade.payOutCurrency.toUpperCase()}", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context).extension()!.textDark, - ), + "${trade.payInCurrency.toUpperCase()} " + "→ ${trade.payOutCurrency.toUpperCase()}", + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), Expanded( @@ -568,11 +548,12 @@ class _DesktopTradeRowCardState extends ConsumerState { flex: 6, child: Text( "-${Decimal.tryParse(trade.payInAmount)?.toStringAsFixed(8) ?? "..."} ${trade.payInCurrency.toUpperCase()}", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context).extension()!.textDark, - ), + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ), Expanded( @@ -586,8 +567,9 @@ class _DesktopTradeRowCardState extends ConsumerState { Assets.svg.circleInfo, width: 20, height: 20, - color: - Theme.of(context).extension()!.textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, ), ], ), diff --git a/test/pages_desktop_specific/desktop_exchange/desktop_trade_details_presenter_test.dart b/test/pages_desktop_specific/desktop_exchange/desktop_trade_details_presenter_test.dart new file mode 100644 index 0000000000..b81b04e7ec --- /dev/null +++ b/test/pages_desktop_specific/desktop_exchange/desktop_trade_details_presenter_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart'; + +void main() { + test("one transaction load opens one trade-details dialog", () async { + var loads = 0; + var presentations = 0; + String? presented; + + await loadAndPresentDesktopTradeDetails( + load: () async { + loads++; + return "transaction"; + }, + present: (value) { + presentations++; + presented = value; + }, + ); + + expect(loads, 1); + expect(presentations, 1); + expect(presented, "transaction"); + }); +} From ea722de8a30843cfbea965d01dcfd94026c6a0f2 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 14:22:54 -0600 Subject: [PATCH 20/52] update mocks --- test/cached_electrumx_test.mocks.dart | 7 ++++++- test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart | 7 ++++++- .../coins/bitcoincash/bitcoincash_wallet_test.mocks.dart | 7 ++++++- .../coins/dogecoin/dogecoin_wallet_test.mocks.dart | 7 ++++++- .../coins/namecoin/namecoin_wallet_test.mocks.dart | 7 ++++++- test/services/coins/particl/particl_wallet_test.mocks.dart | 7 ++++++- 6 files changed, 36 insertions(+), 6 deletions(-) diff --git a/test/cached_electrumx_test.mocks.dart b/test/cached_electrumx_test.mocks.dart index 41d6b0203c..1fd47df1d1 100644 --- a/test/cached_electrumx_test.mocks.dart +++ b/test/cached_electrumx_test.mocks.dart @@ -190,11 +190,16 @@ class MockElectrumXClient extends _i1.Mock implements _i6.ElectrumXClient { as _i9.Future>); @override - _i9.Future ping({String? requestID, int? retryCount = 1}) => + _i9.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i9.Future.value(false), ) diff --git a/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart b/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart index b9d265e2f8..7c9e989ed8 100644 --- a/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart +++ b/test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart @@ -186,11 +186,16 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { as _i8.Future>); @override - _i8.Future ping({String? requestID, int? retryCount = 1}) => + _i8.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i8.Future.value(false), ) diff --git a/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart b/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart index a91186a54f..99b488ffb8 100644 --- a/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart +++ b/test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart @@ -186,11 +186,16 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { as _i8.Future>); @override - _i8.Future ping({String? requestID, int? retryCount = 1}) => + _i8.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i8.Future.value(false), ) diff --git a/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart b/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart index 8fde902450..144bc982ef 100644 --- a/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart +++ b/test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart @@ -186,11 +186,16 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { as _i8.Future>); @override - _i8.Future ping({String? requestID, int? retryCount = 1}) => + _i8.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i8.Future.value(false), ) diff --git a/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart b/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart index 9ecb591912..5cf930564d 100644 --- a/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart +++ b/test/services/coins/namecoin/namecoin_wallet_test.mocks.dart @@ -186,11 +186,16 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { as _i8.Future>); @override - _i8.Future ping({String? requestID, int? retryCount = 1}) => + _i8.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i8.Future.value(false), ) diff --git a/test/services/coins/particl/particl_wallet_test.mocks.dart b/test/services/coins/particl/particl_wallet_test.mocks.dart index 6929d60a42..ea579e379d 100644 --- a/test/services/coins/particl/particl_wallet_test.mocks.dart +++ b/test/services/coins/particl/particl_wallet_test.mocks.dart @@ -186,11 +186,16 @@ class MockElectrumXClient extends _i1.Mock implements _i5.ElectrumXClient { as _i8.Future>); @override - _i8.Future ping({String? requestID, int? retryCount = 1}) => + _i8.Future ping({ + String? requestID, + int? retryCount = 1, + Duration? timeout = const Duration(seconds: 30), + }) => (super.noSuchMethod( Invocation.method(#ping, [], { #requestID: requestID, #retryCount: retryCount, + #timeout: timeout, }), returnValue: _i8.Future.value(false), ) From c12e533d4a89d741c1bb53ecd0d4c1ac56b1d6c5 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 15:12:28 -0600 Subject: [PATCH 21/52] add paynym notif tx input check --- .../paynym_interface.dart | 11 +++++++ test/paynym_p2tr_test.dart | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index 37895551f7..a38df01671 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -60,6 +60,15 @@ int comparePaynymNotificationUtxos(UTXO a, UTXO b) { return a.blockTime!.compareTo(b.blockTime!); } +@visibleForTesting +void validatePaynymNotificationInputs(List inputs) { + if (inputs.first.derivePathType == DerivePathType.bip86) { + throw PaynymSendException( + "A non-Taproot UTXO is required for a PayNym notification transaction.", + ); + } +} + mixin PaynymInterface on Bip39HDWallet, ElectrumXInterface { btc_dart.NetworkType get networkType => btc_dart.NetworkType( @@ -618,6 +627,8 @@ mixin PaynymInterface utxoObjectsToUse.map((e) => StandardInput(e)).toList(), )).whereType().toList(); + validatePaynymNotificationInputs(inputsWithKeys); + final vSizeForNoChange = BigInt.from( (await _createNotificationTx( targetPaymentCodeString: targetPaymentCodeString, diff --git a/test/paynym_p2tr_test.dart b/test/paynym_p2tr_test.dart index 0d031b8a00..73ba39dece 100644 --- a/test/paynym_p2tr_test.dart +++ b/test/paynym_p2tr_test.dart @@ -3,8 +3,11 @@ import 'package:bip39/bip39.dart' as bip39; import 'package:bip47/bip47.dart'; import 'package:bitcoindart/bitcoindart.dart' as bitcoindart; import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/exceptions/wallet/paynym_send_exception.dart'; +import 'package:stackwallet/models/input.dart'; import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; import 'package:stackwallet/models/paynym/paynym_account_lite.dart'; +import 'package:stackwallet/utilities/enums/derive_path_type_enum.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; UTXO _utxo(String txid, int blockTime, String address) => UTXO( @@ -77,6 +80,32 @@ void main() { ]); }); + test('notification requires a non-Taproot designated input', () { + final segwit = StandardInput( + _utxo('segwit', 1, 'bc1qsegwit'), + derivePathType: DerivePathType.bip84, + ); + final taproot = StandardInput( + _utxo('taproot', 1, 'bc1ptaproot'), + derivePathType: DerivePathType.bip86, + ); + + expect( + () => validatePaynymNotificationInputs([taproot]), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('non-Taproot UTXO'), + ), + ), + ); + expect( + () => validatePaynymNotificationInputs([segwit, taproot]), + returnsNormally, + ); + }); + group('PaynymAccountLite taproot inference', () { test('inferTaproot returns true for taproot-enabled payment code', () { final result = PaynymAccountLite.inferTaproot(taprootPaymentCodeString); From a4ea73bfa25bafeeb7fa16a6458e67841f36b81a Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 15:21:32 -0600 Subject: [PATCH 22/52] fix dash dust limit --- lib/wallets/crypto_currency/coins/dash.dart | 2 +- test/wallets/dash_policy_test.dart | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 test/wallets/dash_policy_test.dart diff --git a/lib/wallets/crypto_currency/coins/dash.dart b/lib/wallets/crypto_currency/coins/dash.dart index e2ad041eaf..4c3f3d81c1 100644 --- a/lib/wallets/crypto_currency/coins/dash.dart +++ b/lib/wallets/crypto_currency/coins/dash.dart @@ -90,7 +90,7 @@ class Dash extends Bip39HDCurrency with ElectrumXCurrencyInterface { @override Amount get dustLimit => - Amount(rawValue: BigInt.from(1000000), fractionDigits: fractionDigits); + Amount(rawValue: BigInt.from(546), fractionDigits: fractionDigits); @override String get genesisHash { diff --git a/test/wallets/dash_policy_test.dart b/test/wallets/dash_policy_test.dart new file mode 100644 index 0000000000..0e6a238a3e --- /dev/null +++ b/test/wallets/dash_policy_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + test('Dash dust limit uses network policy units', () { + final dash = Dash(CryptoCurrencyNetwork.main); + + expect(dash.dustLimit.raw, BigInt.from(546)); + expect(dash.dustLimit.fractionDigits, 8); + }); +} From 696a54e5d830594459e541e3c421d8ba07c7cc9a Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 15:35:27 -0600 Subject: [PATCH 23/52] ppc default fee rate update --- lib/wallets/crypto_currency/coins/peercoin.dart | 11 +++++------ test/wallets/peercoin_policy_test.dart | 10 ++++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 test/wallets/peercoin_policy_test.dart diff --git a/lib/wallets/crypto_currency/coins/peercoin.dart b/lib/wallets/crypto_currency/coins/peercoin.dart index 0515beb4c3..a3241e2c94 100644 --- a/lib/wallets/crypto_currency/coins/peercoin.dart +++ b/lib/wallets/crypto_currency/coins/peercoin.dart @@ -165,11 +165,10 @@ class Peercoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { return (address: addr, addressType: AddressType.p2pkh); case DerivePathType.bip49: - final p2wpkhScript = - coinlib.P2WPKHAddress.fromPublicKey( - publicKey, - hrp: networkParams.bech32Hrp, - ).program.script; + final p2wpkhScript = coinlib.P2WPKHAddress.fromPublicKey( + publicKey, + hrp: networkParams.bech32Hrp, + ).program.script; final addr = coinlib.P2SHAddress.fromRedeemScript( p2wpkhScript, @@ -266,5 +265,5 @@ class Peercoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { int get transactionVersion => 3; @override - BigInt get defaultFeeRate => BigInt.from(5000); + BigInt get defaultFeeRate => BigInt.from(10000); } diff --git a/test/wallets/peercoin_policy_test.dart b/test/wallets/peercoin_policy_test.dart new file mode 100644 index 0000000000..45877a0de8 --- /dev/null +++ b/test/wallets/peercoin_policy_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + test('Peercoin fallback fee uses the fixed network rate', () { + final peercoin = Peercoin(CryptoCurrencyNetwork.main); + + expect(peercoin.defaultFeeRate, BigInt.from(10000)); + }); +} From 68b206bb2bcdfff9fe9675787214ab58672abc1a Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 15:45:49 -0600 Subject: [PATCH 24/52] fix double decoding which can cause weird issues --- lib/utilities/address_utils.dart | 10 +++++----- test/address_utils_test.dart | 7 +++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index cb1f5f8ad6..3059c0c1b2 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -99,21 +99,21 @@ class AddressUtils { break; case 'label': case 'recipient_name': - result['label'] = Uri.decodeComponent(value); + result['label'] = value; break; case 'message': case 'tx_description': - result['message'] = Uri.decodeComponent(value); + result['message'] = value; break; case 'tx_payment_id': - result['tx_payment_id'] = Uri.decodeComponent(value); + result['tx_payment_id'] = value; break; default: - result[lowerKey] = Uri.decodeComponent(value); + result[lowerKey] = value; } } else { // Include unrecognized parameters as-is. - result[key] = Uri.decodeComponent(value); + result[key] = value; } }); return result; diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index c3ce3cbad0..caa47e0db6 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -40,6 +40,13 @@ void main() { expect(result.message, "eggs are good!"); }); + test("parse query parameters exactly once", () { + const uri = "bitcoin:$firoAddress?label=Save%25&amount=1.5"; + final result = AddressUtils.parsePaymentUri(uri); + expect(result!.label, "Save%"); + expect(result.amount, "1.5"); + }); + test("parse an invalid uri string", () { const uri = "firo$firoAddress?amount=50&label=eggs"; final result = AddressUtils.parsePaymentUri(uri); From a6531a9835c164663e42817bdfe11f4fa221c9bc Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 15:50:21 -0600 Subject: [PATCH 25/52] fix address copy button value --- lib/pages/cakepay/cakepay_order_view.dart | 2 +- .../cakepay/cakepay_order_view_test.dart | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 test/pages/cakepay/cakepay_order_view_test.dart diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart index 4232a4edac..c805e1c0a9 100644 --- a/lib/pages/cakepay/cakepay_order_view.dart +++ b/lib/pages/cakepay/cakepay_order_view.dart @@ -816,7 +816,7 @@ class _CakePayOrderViewState extends ConsumerState { : STextStyles.itemSubtitle12(context), ), const Spacer(), - IconCopyButton(data: order.orderId), + IconCopyButton(data: selected.address), const SizedBox(width: 4), Text("Copy", style: STextStyles.link2(context)), ], diff --git a/test/pages/cakepay/cakepay_order_view_test.dart b/test/pages/cakepay/cakepay_order_view_test.dart new file mode 100644 index 0000000000..842bda17ce --- /dev/null +++ b/test/pages/cakepay/cakepay_order_view_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/cakepay/cakepay_order_view.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; +import 'package:stackwallet/providers/global/cakepay_orders_provider.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/cakepay/cakepay_orders_service.dart'; +import 'package:stackwallet/services/cakepay/src/models/order.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; + +import '../../sample_data/theme_json.dart'; + +class _OrdersService extends CakePayOrdersService { + @override + void startPolling( + String orderId, { + Duration interval = CakePayOrdersService.defaultPollInterval, + }) {} +} + +void main() { + testWidgets("address copy button uses the visible address", (tester) async { + const address = "bc1qpaymentaddress"; + final order = CakePayOrder( + orderId: "order-id", + status: CakePayOrderStatus.new_, + paymentOptions: { + "BTC": CakePayPaymentOption( + ticker: "BTC", + amountFrom: 1, + address: address, + ), + }, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pCakePayOrdersService.overrideWithValue(_OrdersService()), + pWallets.overrideWithValue(Wallets.sharedInstance), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: CakePayOrderView(order: order), + ), + ), + ); + + expect( + find.byWidgetPredicate( + (widget) => widget is IconCopyButton && widget.data == address, + ), + findsOneWidget, + ); + }); +} From e0dbdb8f2ac64e83bf86a9e470b10d27109c54c7 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 14 Aug 2026 15:53:15 -0600 Subject: [PATCH 26/52] handle zero restore chain height --- lib/wallets/wallet/impl/epiccash_wallet.dart | 6 +++++- lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart | 6 +++++- lib/wallets/wallet/supporting/restore_progress.dart | 4 ++++ test/wallets/restore_progress_test.dart | 9 +++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 lib/wallets/wallet/supporting/restore_progress.dart create mode 100644 test/wallets/restore_progress_test.dart diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index 805ea30a4e..3e5af8a423 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -41,6 +41,7 @@ import '../../crypto_currency/crypto_currency.dart'; import '../../models/tx_data.dart'; import '../intermediate/bip39_wallet.dart'; import '../supporting/epiccash_wallet_info_extension.dart'; +import '../supporting/restore_progress.dart'; // // refactor of https://github.com/cypherstack/stack_wallet/blob/1d9fb4cd069f22492ece690ac788e05b8f8b1209/lib/services/coins/epiccash/epiccash_wallet.dart @@ -58,7 +59,10 @@ class EpiccashWallet extends Bip39Wallet { Future get getSyncPercent async { final int lastScannedBlock = info.epicData?.lastScannedBlock ?? 0; final _chainHeight = await chainHeight; - final double restorePercent = lastScannedBlock / _chainHeight; + final restorePercent = calculateRestoreProgress( + scannedHeight: lastScannedBlock, + chainHeight: _chainHeight, + ); GlobalEventBus.instance.fire( RefreshPercentChangedEvent(highestPercent, walletId), ); diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index 02797e4601..6a65be7153 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -35,6 +35,7 @@ import '../../crypto_currency/crypto_currency.dart'; import '../../models/tx_data.dart'; import '../intermediate/bip39_wallet.dart'; import '../supporting/mimblewimblecoin_wallet_info_extension.dart'; +import '../supporting/restore_progress.dart'; class MimblewimblecoinWallet extends Bip39Wallet { MimblewimblecoinWallet(CryptoCurrencyNetwork network) @@ -55,7 +56,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { final int lastScannedBlock = info.mimblewimblecoinData?.lastScannedBlock ?? 0; final _chainHeight = await chainHeight; - final double restorePercent = lastScannedBlock / _chainHeight; + final restorePercent = calculateRestoreProgress( + scannedHeight: lastScannedBlock, + chainHeight: _chainHeight, + ); GlobalEventBus.instance.fire( RefreshPercentChangedEvent(highestPercent, walletId), ); diff --git a/lib/wallets/wallet/supporting/restore_progress.dart b/lib/wallets/wallet/supporting/restore_progress.dart new file mode 100644 index 0000000000..c2516b6b43 --- /dev/null +++ b/lib/wallets/wallet/supporting/restore_progress.dart @@ -0,0 +1,4 @@ +double calculateRestoreProgress({ + required int scannedHeight, + required int chainHeight, +}) => chainHeight <= 0 ? 0.0 : scannedHeight / chainHeight; diff --git a/test/wallets/restore_progress_test.dart b/test/wallets/restore_progress_test.dart new file mode 100644 index 0000000000..b1c9729ae3 --- /dev/null +++ b/test/wallets/restore_progress_test.dart @@ -0,0 +1,9 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/supporting/restore_progress.dart'; + +void main() { + test('restore progress waits for a chain height', () { + expect(calculateRestoreProgress(scannedHeight: 25, chainHeight: 0), 0); + expect(calculateRestoreProgress(scannedHeight: 25, chainHeight: 100), 0.25); + }); +} From f32a921e71870ea740c0e4b06c9189636dbe2f0b Mon Sep 17 00:00:00 2001 From: Julian Date: Sat, 15 Aug 2026 08:19:11 -0600 Subject: [PATCH 27/52] tighten up amount parsing --- lib/pages/exchange_view/exchange_form.dart | 1 + lib/utilities/amount/amount.dart | 15 ++------- lib/utilities/amount/amount_formatter.dart | 1 + lib/utilities/amount/amount_unit.dart | 15 +++++---- test/utilities/amount/amount_unit_test.dart | 34 +++++++++++++++++++++ 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart index c328e5e4f7..b861b59944 100644 --- a/lib/pages/exchange_view/exchange_form.dart +++ b/lib/pages/exchange_view/exchange_form.dart @@ -178,6 +178,7 @@ class _ExchangeFormState extends ConsumerState { coin: Bitcoin( CryptoCurrencyNetwork.main, ), // dummy value (not used due to override) + strict: true, overrideWithDecimalPlacesFromString: true, ) ?.decimal; diff --git a/lib/utilities/amount/amount.dart b/lib/utilities/amount/amount.dart index 2e9d5c6bda..db2336e6be 100644 --- a/lib/utilities/amount/amount.dart +++ b/lib/utilities/amount/amount.dart @@ -54,24 +54,13 @@ class Amount { } static Amount? tryParseFiatString(String value, {required String locale}) { - final parts = value.split(" "); - - if (parts.first.isEmpty) { - return null; - } - - String str = parts.first; - if (str.startsWith(RegExp(r'[+-]'))) { - str = str.substring(1); - } - - if (str.isEmpty) { + if (value.isEmpty || value.contains(RegExp(r'[+\-\x09-\x0D ]'))) { return null; } // get number symbols for decimal place and group separator return Decimal.tryParse( - normalizeLocalizedNumber(str, locale: locale), + normalizeLocalizedNumber(value, locale: locale), )?.toAmount(fractionDigits: 2); } diff --git a/lib/utilities/amount/amount_formatter.dart b/lib/utilities/amount/amount_formatter.dart index 6a6f01f7b9..c03c4877d8 100644 --- a/lib/utilities/amount/amount_formatter.dart +++ b/lib/utilities/amount/amount_formatter.dart @@ -72,6 +72,7 @@ class AmountFormatter { locale: locale, coin: coin, tokenContract: tokenContract, + strict: true, ); } } diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index 6ea366f0ed..0f1cd5ca22 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -201,8 +201,14 @@ extension AmountUnitExt on AmountUnit { required String locale, required CryptoCurrency coin, Contract? tokenContract, + bool strict = false, bool overrideWithDecimalPlacesFromString = false, }) { + if (value.contains(RegExp(r'[+\-\x09-\x0D]')) || + (strict && value.contains(" "))) { + return null; + } + final precisionLost = value.startsWith("~"); final parts = (precisionLost ? value.substring(1) : value).split(" "); @@ -211,14 +217,7 @@ extension AmountUnitExt on AmountUnit { return null; } - String str = parts.first; - if (str.startsWith(RegExp(r'[+-]'))) { - str = str.substring(1); - } - - if (str.isEmpty) { - return null; - } + final str = parts.first; // get number symbols for decimal place and group separator final Decimal? decimal = Decimal.tryParse( diff --git a/test/utilities/amount/amount_unit_test.dart b/test/utilities/amount/amount_unit_test.dart index f59a67ad81..9056a46901 100644 --- a/test/utilities/amount/amount_unit_test.dart +++ b/test/utilities/amount/amount_unit_test.dart @@ -2,6 +2,7 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_unit.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; @@ -228,6 +229,39 @@ void main() { ); }); + test("amount field parsing rejects signs and ASCII whitespace", () { + final coin = Bitcoin(CryptoCurrencyNetwork.main); + final formatter = AmountFormatter( + unit: AmountUnit.normal, + locale: "en_US", + coin: coin, + maxDecimals: 8, + ); + + expect(formatter.tryParse("5")?.decimal, Decimal.fromInt(5)); + + for (final value in [ + "+5", + "-5", + for (final codePoint in [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20]) + "1${String.fromCharCode(codePoint)}234", + ]) { + expect(formatter.tryParse(value), isNull, reason: value); + expect( + Amount.tryParseFiatString(value, locale: "en_US"), + isNull, + reason: value, + ); + } + + expect( + AmountUnit.normal + .tryParse("5 legacy", locale: "en_US", coin: coin) + ?.decimal, + Decimal.fromInt(5), + ); + }); + test("parse ASCII decimals in dot-group locales", () { final coin = Bitcoin(CryptoCurrencyNetwork.main); final formatter = AmountInputFormatter(decimals: 8, locale: "de_DE"); From f5bfe88b2a5b048f643370cb6306fb8b876fd7ce Mon Sep 17 00:00:00 2001 From: Julian Date: Sat, 15 Aug 2026 08:39:21 -0600 Subject: [PATCH 28/52] tighten up payment uri parsing --- lib/utilities/address_utils.dart | 23 +++++++++++------------ test/address_utils_test.dart | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 3059c0c1b2..8db346ce3e 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -136,17 +136,13 @@ class AddressUtils { /// Returns null on failure to parse static PaymentUriData? parsePaymentUri(String uri, {Logging? logging}) { // hacky check its not just a bcash, ecash, or xel address - final parts = uri.split(":"); - if (parts.length == 2) { - if ([ - "xel", - "bitcoincash", - "bchtest", - "ecash", - "ectest", - ].contains(parts.first.toLowerCase())) { - return null; - } + const cashAddrSchemes = {"bitcoincash", "bchtest", "ecash", "ectest"}; + final parsedUri = Uri.tryParse(uri); + final scheme = parsedUri?.scheme.toLowerCase(); + if (parsedUri != null && + (scheme == "xel" || + (!parsedUri.hasQuery && cashAddrSchemes.contains(scheme)))) { + return null; } try { @@ -155,13 +151,16 @@ class AddressUtils { // Normalize the URI scheme. final String scheme = parsedData['scheme'] ?? ''; parsedData.remove('scheme'); + final address = parsedData['address']!.trim(); // Filter out unrecognized parameters. final filteredParams = _filterParams(parsedData); return PaymentUriData( scheme: scheme, - address: parsedData['address']!.trim(), + address: cashAddrSchemes.contains(scheme) + ? "$scheme:$address".toLowerCase() + : address, amount: filteredParams['amount'] ?? filteredParams['tx_amount'], label: filteredParams['label'] ?? filteredParams['recipient_name'], message: filteredParams['message'] ?? filteredParams['tx_description'], diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index caa47e0db6..ee4ede0038 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -75,6 +75,33 @@ void main() { expect(result.message, "eggs are good!"); }); + test("distinguish CashAddr payment URIs from prefixed addresses", () { + const address = "qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a"; + + for (final scheme in ["bitcoincash", "bchtest", "ecash", "ectest"]) { + expect(AddressUtils.parsePaymentUri("$scheme:$address"), isNull); + + final result = AddressUtils.parsePaymentUri( + "$scheme:$address?amount=1.25", + ); + expect(result?.scheme, scheme); + expect(result?.address, "$scheme:$address"); + expect(result?.amount, "1.25"); + } + + final uppercase = AddressUtils.parsePaymentUri( + "BITCOINCASH:${address.toUpperCase()}?amount=1.25", + ); + expect(uppercase?.address, "bitcoincash:$address"); + + expect(AddressUtils.parsePaymentUri("xel:$address?amount=1.25"), isNull); + + final xelis = AddressUtils.parsePaymentUri( + "xelis:xel:$address?amount=1.25", + ); + expect((xelis?.address, xelis?.amount), ("xel:$address", "1.25")); + }); + test("encode a list of (mnemonic) words/strings as a json object", () { final List list = [ "hello", From f91825241a5e2c33f8e2c5dd9b2b5971d477af24 Mon Sep 17 00:00:00 2001 From: Julian Date: Sat, 15 Aug 2026 09:08:07 -0600 Subject: [PATCH 29/52] fix swap rate sorting --- .../sorted_exchange_providers.dart | 17 ++++----- .../exchange_rate_sort_test.dart | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 test/pages/exchange_view/exchange_rate_sort_test.dart diff --git a/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart b/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart index c478f9ead0..b0a99c7abc 100644 --- a/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart +++ b/lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart @@ -58,17 +58,16 @@ class _SortedExchangeProvidersState } flattened.sort((a, b) { - if (a.$2 == null && b.$2 == null) return 1; - if (a.$2 != null && b.$2 == null) return 0; - if (a.$2 == null && b.$2 != null) return 0; + if (a.$2 != null && b.$2 != null) { + assert(a.$2!.reversed == b.$2!.reversed); + } - // or we get problems!!! - assert(a.$2!.reversed == b.$2!.reversed); + final aRate = a.$2 == null ? null : _getRate(a.$2!, amount, rcvTicker); + final bRate = b.$2 == null ? null : _getRate(b.$2!, amount, rcvTicker); - return _getRate(a.$2!, amount, rcvTicker) > - _getRate(b.$2!, amount, rcvTicker) - ? 0 - : 1; + if (aRate == null) return bRate == null ? 0 : 1; + if (bRate == null) return -1; + return bRate.decimal.compareTo(aRate.decimal); }); return flattened; diff --git a/test/pages/exchange_view/exchange_rate_sort_test.dart b/test/pages/exchange_view/exchange_rate_sort_test.dart new file mode 100644 index 0000000000..4fb94512a4 --- /dev/null +++ b/test/pages/exchange_view/exchange_rate_sort_test.dart @@ -0,0 +1,37 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/exchange/response_objects/estimate.dart'; +import 'package:stackwallet/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart'; +import 'package:stackwallet/services/exchange/exchange.dart'; + +void main() { + test('exchange rates sort highest first with failed providers last', () { + final exchange = Exchange.defaultExchange; + final dynamic state = SortedExchangeProviders( + exchangees: [exchange], + fixedRate: false, + reversed: false, + ).createState(); + + Estimate estimate(int rate) => Estimate( + estimatedAmount: Decimal.fromInt(rate), + fixedRate: false, + reversed: false, + exchangeProvider: exchange.name, + ); + + state.estimates.addAll(<(Exchange, List?)>[ + (exchange, [estimate(1)]), + (exchange, null), + (exchange, [estimate(3)]), + ]); + + final result = state.transform(Decimal.one, 'BTC') as List; + + expect(result.map((entry) => entry.$2?.estimatedAmount).toList(), [ + Decimal.fromInt(3), + Decimal.fromInt(1), + null, + ]); + }); +} From 9685de56de81293fdc3b9085473fdaaa6e447616 Mon Sep 17 00:00:00 2001 From: Julian Date: Sat, 15 Aug 2026 09:37:42 -0600 Subject: [PATCH 30/52] fix btc frost price --- lib/services/price.dart | 22 ++++++++++++---------- test/price_test.dart | 4 ++++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/lib/services/price.dart b/lib/services/price.dart index 7af1ec2ba8..23f1cf2903 100644 --- a/lib/services/price.dart +++ b/lib/services/price.dart @@ -159,16 +159,14 @@ class PriceAPI { for (final map in coinGeckoData) { final String coinName = map["name"] as String; - late CryptoCurrency coin; - try { - coin = AppConfig.getCryptoCurrencyByPrettyName( - coinName == "Factor" ? "Fact0rn" : coinName, - ); - } catch (e, s) { + final coins = AppConfig.coins.where( + (coin) => + coin.network == CryptoCurrencyNetwork.main && + _coinToIdMap[coin.runtimeType] == map["id"], + ); + if (coins.isEmpty) { Logging.instance.e( "Failed to find matching app coin for $coinName. Moving on", - error: e, - stackTrace: s, ); continue; } @@ -179,9 +177,13 @@ class PriceAPI { ? double.parse(map["price_change_percentage_24h"].toString()) : 0.0; - result[coin] = (value: price, change24h: change24h); + for (final coin in coins) { + result[coin] = (value: price, change24h: change24h); + } } catch (_) { - result.remove(coin); + for (final coin in coins) { + result.remove(coin); + } } } diff --git a/test/price_test.dart b/test/price_test.dart index 468295b79b..d28988fecd 100644 --- a/test/price_test.dart +++ b/test/price_test.dart @@ -31,6 +31,10 @@ void main() { prices, contains("Instance of 'Bitcoin': (change24h: 0.0, value: 1)"), ); + expect( + prices, + contains("Instance of 'BitcoinFrost': (change24h: 0.0, value: 1)"), + ); expect( prices, contains( From f8c2bb125e9a4f9aa1cbbe1e783d1af230552423 Mon Sep 17 00:00:00 2001 From: Julian Date: Sat, 15 Aug 2026 09:48:40 -0600 Subject: [PATCH 31/52] fix node backup restores --- lib/models/node_model.dart | 48 +++++++++++++++ .../helpers/restore_create_backup.dart | 37 ++---------- test/models/node_model_backup_test.dart | 59 +++++++++++++++++++ 3 files changed, 112 insertions(+), 32 deletions(-) create mode 100644 test/models/node_model_backup_test.dart diff --git a/lib/models/node_model.dart b/lib/models/node_model.dart index 5386cae0f9..85c02c48a3 100644 --- a/lib/models/node_model.dart +++ b/lib/models/node_model.dart @@ -69,6 +69,54 @@ class NodeModel { this.nodeApiSecret, }); + factory NodeModel.fromStackBackup( + Map map, { + Set? legacyPrimaryNodeIds, + }) { + final id = map['id'] as String; + return NodeModel( + host: map['host'] as String, + port: map['port'] as int, + name: map['name'] as String, + id: id, + useSSL: _backupBool(map['useSSL'], fallback: true), + loginName: map['loginName'] as String?, + enabled: _backupBool(map['enabled'], fallback: true), + coinName: map['coinName'] as String, + isFailover: _backupBool(map['isFailover'], fallback: false), + isDown: _backupBool(map['isDown'], fallback: false), + trusted: _nullableBackupBool(map['trusted']), + torEnabled: _backupBool(map['torEnabled'], fallback: true), + clearnetEnabled: _backupBool( + map['clearEnabled'] ?? map['plainEnabled'], + fallback: true, + ), + forceNoTor: _backupBool(map['forceNoTor'], fallback: false), + isPrimary: _backupBool( + map['isPrimary'], + fallback: legacyPrimaryNodeIds?.contains(id) ?? false, + ), + nodeApiSecret: map['nodeApiSecret'] as String?, + ); + } + + static bool _backupBool(Object? value, {required bool fallback}) => + _nullableBackupBool(value) ?? fallback; + + static bool? _nullableBackupBool(Object? value) { + if (value is bool) { + return value; + } + if (value is String) { + return switch (value.trim().toLowerCase()) { + 'true' => true, + 'false' => false, + _ => null, + }; + } + return null; + } + NodeModel copyWith({ String? host, int? port, diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart index cb4ec42a6a..b131db8312 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart @@ -1028,7 +1028,7 @@ abstract class SWB { } } } else { - final Map preNodeMap = {}; + final Map> preNodeMap = {}; for (final nodeData in nodes) { preNodeMap[nodeData['id'] as String] = nodeData as Map; } @@ -1039,19 +1039,7 @@ abstract class SWB { // node existed before restore attempt // revert to pre restore node await nodeService.save( - node.copyWith( - host: nodeData['host'] as String, - port: nodeData['port'] as int, - name: nodeData['name'] as String, - useSSL: nodeData['useSSL'] == "false" ? false : true, - enabled: nodeData['enabled'] == "false" ? false : true, - coinName: nodeData['coinName'] as String, - loginName: nodeData['loginName'] as String?, - isFailover: nodeData['isFailover'] as bool, - isDown: nodeData['isDown'] as bool, - trusted: nodeData['trusted'] as bool?, - isPrimary: nodeData["isPrimary"] as bool? ?? false, - ), + NodeModel.fromStackBackup({...nodeData, 'id': node.id}), nodeData['password'] as String?, true, ); @@ -1258,25 +1246,10 @@ abstract class SWB { .toSet(); for (final node in nodes) { - final id = node['id'] as String; + final nodeData = Map.from(node as Map); await nodeService.save( - NodeModel( - host: node['host'] as String, - port: node['port'] as int, - name: node['name'] as String, - id: id, - useSSL: node['useSSL'] == "false" ? false : true, - enabled: node['enabled'] == "false" ? false : true, - coinName: node['coinName'] as String, - loginName: node['loginName'] as String?, - isFailover: node['isFailover'] as bool, - isDown: node['isDown'] as bool, - torEnabled: node['torEnabled'] as bool? ?? true, - clearnetEnabled: node['plainEnabled'] as bool? ?? true, - isPrimary: - node["isPrimary"] as bool? ?? primaryIds?.contains(id) ?? false, - ), - node["password"] as String?, + NodeModel.fromStackBackup(nodeData, legacyPrimaryNodeIds: primaryIds), + nodeData["password"] as String?, true, ); } diff --git a/test/models/node_model_backup_test.dart b/test/models/node_model_backup_test.dart new file mode 100644 index 0000000000..d0259c1541 --- /dev/null +++ b/test/models/node_model_backup_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/node_model.dart'; + +void main() { + test('restores current and legacy node backup fields', () { + final source = NodeModel( + host: 'node.example.com', + port: 50002, + name: 'Node', + id: 'current', + useSSL: false, + loginName: 'user', + enabled: false, + coinName: 'bitcoin', + isFailover: true, + isDown: false, + trusted: false, + torEnabled: false, + clearnetEnabled: false, + forceNoTor: true, + isPrimary: false, + nodeApiSecret: 'current-secret', + ); + expect(NodeModel.fromStackBackup(source.toMap()).toMap(), source.toMap()); + + final legacyMap = { + ...source.toMap(), + 'id': 'legacy', + 'useSSL': 'false', + 'enabled': 'false', + 'isFailover': 'true', + 'trusted': 'true', + 'torEnabled': 'false', + 'plainEnabled': 'false', + 'forceNoTor': 'true', + 'nodeApiSecret': 'legacy-secret', + }; + legacyMap.remove('clearEnabled'); + legacyMap.remove('isPrimary'); + final legacy = NodeModel.fromStackBackup( + legacyMap, + legacyPrimaryNodeIds: {'legacy'}, + ); + expect( + ( + legacy.useSSL, + legacy.enabled, + legacy.isFailover, + legacy.trusted, + legacy.torEnabled, + legacy.clearnetEnabled, + legacy.forceNoTor, + legacy.isPrimary, + legacy.nodeApiSecret, + ), + (false, false, true, true, false, false, true, true, 'legacy-secret'), + ); + }); +} From 1c85b97acb921f19596464badacc88f78a56a99c Mon Sep 17 00:00:00 2001 From: Julian Date: Sun, 16 Aug 2026 11:27:34 -0600 Subject: [PATCH 32/52] ensure frost wallet data is fully deleted on wallet delete --- lib/services/wallets.dart | 36 +++++++++++++++++-- .../wallet/impl/bitcoin_frost_wallet.dart | 17 +++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/lib/services/wallets.dart b/lib/services/wallets.dart index e1d38149b8..73ca76ad5d 100644 --- a/lib/services/wallets.dart +++ b/lib/services/wallets.dart @@ -24,7 +24,10 @@ import '../utilities/prefs.dart'; import '../utilities/stack_file_system.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; import '../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; +import '../wallets/crypto_currency/intermediate/frost_currency.dart'; +import '../wallets/isar/models/frost_wallet_info.dart'; import '../wallets/isar/models/wallet_info.dart'; +import '../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../wallets/wallet/impl/epiccash_wallet.dart'; import '../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../wallets/wallet/intermediate/cryptonote_wallet.dart'; @@ -108,6 +111,7 @@ class Wallets { SecureStorageInterface secureStorage, ) async { final walletId = info.walletId; + final isFrostWallet = info.coin is FrostCurrency; Logging.instance.d("deleteWallet called with walletId=$walletId"); final wallet = _wallets[walletId]; @@ -123,6 +127,13 @@ class Wallets { key: Wallet.getViewOnlyWalletDataSecStoreKey(walletId: walletId), ); + if (isFrostWallet) { + await BitcoinFrostWallet.deleteSecureStorage( + walletId: walletId, + secureStorage: secureStorage, + ); + } + if (info.coin is CryptonoteCurrency) { await _deleteCryptonoteWalletFilesHelper(info); } else if (info.coin is Epiccash) { @@ -184,6 +195,9 @@ class Wallets { } await mainDB.isar.writeTxn(() async { + if (isFrostWallet) { + await mainDB.isar.frostWalletInfo.deleteByWalletId(walletId); + } await mainDB.isar.walletInfo.deleteByWalletId(walletId); }); @@ -669,8 +683,24 @@ class Wallets { Future _deleteWallet(String walletId) async { // TODO proper clean up of other wallet data in addition to the following - await mainDB.isar.writeTxn( - () async => await mainDB.isar.walletInfo.deleteByWalletId(walletId), - ); + final info = await mainDB.isar.walletInfo + .where() + .walletIdEqualTo(walletId) + .findFirst(); + final isFrostWallet = + info != null && + AppConfig.getCryptoCurrencyFor(info.coinName) is FrostCurrency; + if (isFrostWallet) { + await BitcoinFrostWallet.deleteSecureStorage( + walletId: walletId, + secureStorage: nodeService.secureStorageInterface, + ); + } + await mainDB.isar.writeTxn(() async { + if (isFrostWallet) { + await mainDB.isar.frostWalletInfo.deleteByWalletId(walletId); + } + await mainDB.isar.walletInfo.deleteByWalletId(walletId); + }); } } diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 28f53e33db..aa34ffa3b9 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -22,6 +22,7 @@ import '../../../services/event_bus/events/global/wallet_sync_status_changed_eve import '../../../services/event_bus/global_event_bus.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/extensions/extensions.dart'; +import '../../../utilities/flutter_secure_storage_interface.dart'; import '../../../utilities/logger.dart'; import '../../../wl_gen/interfaces/frost_interface.dart'; import '../../crypto_currency/crypto_currency.dart'; @@ -1112,6 +1113,22 @@ class BitcoinFrostWallet extends Wallet // =================== Secure storage ======================================== + static Future deleteSecureStorage({ + required String walletId, + required SecureStorageInterface secureStorage, + }) async { + for (final suffix in const [ + 'serializedFROSTKeys', + 'serializedFROSTKeysPrevGen', + 'multisigConfig', + 'multisigConfigPrevGen', + 'multisigIdFROST', + 'recoveryStringFROST', + ]) { + await secureStorage.delete(key: '{$walletId}_$suffix'); + } + } + Future getSerializedKeys() async => await secureStorageInterface.read(key: "{$walletId}_serializedFROSTKeys"); From d84aba48ce26725f3528a85a0c73170595dc15d9 Mon Sep 17 00:00:00 2001 From: Julian Date: Sun, 16 Aug 2026 11:31:28 -0600 Subject: [PATCH 33/52] clean up node uri handling --- .../add_edit_node_view.dart | 7 +--- lib/utilities/node_uri_util.dart | 4 +++ test/utilities/node_uri_util_test.dart | 33 ++++++++++++------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart index bd009f1710..7cbbcf20ab 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart @@ -828,16 +828,11 @@ class _NodeFormState extends ConsumerState { } bool get canSave { - // 65535 is max tcp port return _nameController.text.isNotEmpty && canTestConnection; } bool get canTestConnection { - // 65535 is max tcp port - return _hostController.text.isNotEmpty && - port != null && - port! >= 0 && - port! <= 65535; + return _hostController.text.isNotEmpty && isValidNodePort(port); } bool enableField(TextEditingController controller) { diff --git a/lib/utilities/node_uri_util.dart b/lib/utilities/node_uri_util.dart index 73876949e8..b64c45367e 100644 --- a/lib/utilities/node_uri_util.dart +++ b/lib/utilities/node_uri_util.dart @@ -1,3 +1,5 @@ +bool isValidNodePort(int? port) => port != null && port > 0 && port <= 65535; + abstract interface class NodeQrData { final String host; final int port; @@ -109,6 +111,7 @@ abstract final class NodeQrUtil { switch (uri.scheme) { case "xmrrpc": + if (!uri.hasPort) throw Exception("Uri has no port."); return MoneroNodeQrData( host: uri.host, port: uri.port, @@ -117,6 +120,7 @@ abstract final class NodeQrUtil { label: query["label"], ); case "wowrpc": + if (!uri.hasPort) throw Exception("Uri has no port."); return WowneroNodeQrData( host: uri.host, port: uri.port, diff --git a/test/utilities/node_uri_util_test.dart b/test/utilities/node_uri_util_test.dart index 42d8474a0c..2f112dcea7 100644 --- a/test/utilities/node_uri_util_test.dart +++ b/test/utilities/node_uri_util_test.dart @@ -13,19 +13,22 @@ void main() { test("Valid wowrpc scheme node uri", () { expect( - NodeQrUtil.decodeUri( - "wowrpc://nodo:password@10.0.0.10:18083", - ), + NodeQrUtil.decodeUri("wowrpc://nodo:password@10.0.0.10:18083"), isA(), ); }); + test("Node uri requires an explicit port", () { + expect(() => NodeQrUtil.decodeUri("xmrrpc://bob.onion:0"), throwsException); + expect(() => NodeQrUtil.decodeUri("xmrrpc://bob.onion"), throwsException); + expect(() => NodeQrUtil.decodeUri("wowrpc://bob.onion"), throwsException); + expect(NodeQrUtil.decodeUri("xmrrpc://bob.onion:18083").port, 18083); + }); + test("Invalid authority node uri", () { String? message; try { - NodeQrUtil.decodeUri( - "nodo:password@bob.onion:18083?label=Nodo Tor Node", - ); + NodeQrUtil.decodeUri("nodo:password@bob.onion:18083?label=Nodo Tor Node"); } catch (e) { message = e.toString(); } @@ -77,18 +80,14 @@ void main() { test("encoding to string", () { const validString = "xmrrpc://nodo:password@bob.onion:18083?label=Nodo+Tor+Node"; - final data = NodeQrUtil.decodeUri( - validString, - ); + final data = NodeQrUtil.decodeUri(validString); expect(data.encode(), validString); }); test("normal to string", () { const validString = "xmrrpc://nodo:password@bob.onion:18083?label=Nodo+Tor+Node"; - final data = NodeQrUtil.decodeUri( - validString, - ); + final data = NodeQrUtil.decodeUri(validString); expect( data.toString(), "MoneroNodeQrData {" @@ -101,4 +100,14 @@ void main() { "}", ); }); + + test("node port validation", () { + expect(isValidNodePort(null), false); + expect(isValidNodePort(0), false); + expect(isValidNodePort(-1), false); + expect(isValidNodePort(65536), false); + expect(isValidNodePort(1), true); + expect(isValidNodePort(18081), true); + expect(isValidNodePort(65535), true); + }); } From 24d58009d4bb0b4c212351dcd573a497a0bf8a2a Mon Sep 17 00:00:00 2001 From: Julian Date: Sun, 16 Aug 2026 11:35:45 -0600 Subject: [PATCH 34/52] fix nanswap networks --- .../nanswap/api_response_models/n_trade.dart | 3 ++ .../exchange/nanswap/nanswap_exchange.dart | 30 ++++++++++----- .../exchange/nanswap_exchange_test.dart | 37 +++++++++++++++++++ 3 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 test/services/exchange/nanswap_exchange_test.dart diff --git a/lib/services/exchange/nanswap/api_response_models/n_trade.dart b/lib/services/exchange/nanswap/api_response_models/n_trade.dart index f26e19f3f8..b9355cda3c 100644 --- a/lib/services/exchange/nanswap/api_response_models/n_trade.dart +++ b/lib/services/exchange/nanswap/api_response_models/n_trade.dart @@ -17,6 +17,9 @@ class NTrade { final String? fromNetwork; final String? toNetwork; + String get payInNetwork => fromNetwork ?? from; + String get payOutNetwork => toNetwork ?? to; + NTrade({ required this.id, required this.from, diff --git a/lib/services/exchange/nanswap/nanswap_exchange.dart b/lib/services/exchange/nanswap/nanswap_exchange.dart index a26a35cfb9..6ef4873b21 100644 --- a/lib/services/exchange/nanswap/nanswap_exchange.dart +++ b/lib/services/exchange/nanswap/nanswap_exchange.dart @@ -1,4 +1,5 @@ import 'package:decimal/decimal.dart'; +import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; import '../../../app_config.dart'; @@ -11,14 +12,25 @@ import '../../../models/isar/exchange_cache/pair.dart'; import '../exchange.dart'; import '../exchange_response.dart'; import 'api_response_models/n_estimate.dart'; +import 'api_response_models/n_trade.dart'; import 'nanswap_api.dart'; +typedef NanswapOrderLookup = + Future> Function({required String id}); + class NanswapExchange extends Exchange { - NanswapExchange._(); + NanswapExchange._({NanswapOrderLookup? getOrder}) + : _getOrder = getOrder ?? NanswapAPI.instance.getOrder; + + @visibleForTesting + NanswapExchange.forTesting({required NanswapOrderLookup getOrder}) + : this._(getOrder: getOrder); static NanswapExchange? _instance; static NanswapExchange get instance => _instance ??= NanswapExchange._(); + final NanswapOrderLookup _getOrder; + static const exchangeName = "Nanswap"; static const filter = ["BTC", "BAN", "XNO"]; @@ -92,13 +104,13 @@ class NanswapExchange extends Exchange { payInCurrency: from, payInAmount: t.expectedAmountFrom.toString(), payInAddress: t.payinAddress, - payInNetwork: t.toNetwork ?? t.to, + payInNetwork: t.payInNetwork, payInExtraId: t.payinExtraId ?? "", payInTxid: t.payinHash ?? "", payOutCurrency: to, payOutAmount: t.expectedAmountTo.toString(), payOutAddress: t.payoutAddress, - payOutNetwork: t.fromNetwork ?? t.from, + payOutNetwork: t.payOutNetwork, payOutExtraId: "", payOutTxid: t.payoutHash ?? "", refundAddress: "", @@ -319,7 +331,7 @@ class NanswapExchange extends Exchange { @override Future> getTrade(String tradeId) async { try { - final response = await NanswapAPI.instance.getOrder(id: tradeId); + final response = await _getOrder(id: tradeId); if (response.exception != null) { return ExchangeResponse(exception: response.exception); @@ -338,13 +350,13 @@ class NanswapExchange extends Exchange { payInCurrency: t.from, payInAmount: t.expectedAmountFrom.toString(), payInAddress: t.payinAddress, - payInNetwork: t.toNetwork ?? t.to, + payInNetwork: t.payInNetwork, payInExtraId: t.payinExtraId ?? "", payInTxid: t.payinHash ?? "", payOutCurrency: t.to, payOutAmount: t.expectedAmountTo.toString(), payOutAddress: t.payoutAddress, - payOutNetwork: t.fromNetwork ?? t.from, + payOutNetwork: t.payOutNetwork, payOutExtraId: "", payOutTxid: t.payoutHash ?? "", refundAddress: "", @@ -377,7 +389,7 @@ class NanswapExchange extends Exchange { @override Future> updateTrade(Trade trade) async { try { - final response = await NanswapAPI.instance.getOrder(id: trade.tradeId); + final response = await _getOrder(id: trade.tradeId); if (response.exception != null) { return ExchangeResponse(exception: response.exception); @@ -396,13 +408,13 @@ class NanswapExchange extends Exchange { payInCurrency: t.from, payInAmount: t.expectedAmountFrom.toString(), payInAddress: t.payinAddress, - payInNetwork: t.toNetwork ?? trade.payInNetwork, + payInNetwork: t.payInNetwork, payInExtraId: t.payinExtraId ?? trade.payInExtraId, payInTxid: t.payinHash ?? trade.payInTxid, payOutCurrency: t.to, payOutAmount: t.expectedAmountTo.toString(), payOutAddress: t.payoutAddress, - payOutNetwork: t.fromNetwork ?? trade.payOutNetwork, + payOutNetwork: t.payOutNetwork, payOutExtraId: trade.payOutExtraId, payOutTxid: t.payoutHash ?? trade.payOutTxid, refundAddress: trade.refundAddress, diff --git a/test/services/exchange/nanswap_exchange_test.dart b/test/services/exchange/nanswap_exchange_test.dart new file mode 100644 index 0000000000..ee0bcd5b96 --- /dev/null +++ b/test/services/exchange/nanswap_exchange_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/exchange/exchange_response.dart'; +import 'package:stackwallet/services/exchange/nanswap/api_response_models/n_trade.dart'; +import 'package:stackwallet/services/exchange/nanswap/nanswap_exchange.dart'; + +void main() { + test('maps Nanswap source and destination networks', () async { + final nTrade = NTrade( + id: 'trade-id', + from: 'BTC', + to: 'XNO', + expectedAmountFrom: 1, + expectedAmountTo: 2, + payinAddress: 'pay-in', + payoutAddress: 'pay-out', + ); + final exchange = NanswapExchange.forTesting( + getOrder: ({required String id}) async { + expect(id, nTrade.id); + return ExchangeResponse(value: nTrade); + }, + ); + + final trade = (await exchange.getTrade(nTrade.id)).value!; + final staleTrade = trade.copyWith( + payInNetwork: 'XNO', + payOutNetwork: 'BTC', + ); + final updatedTrade = (await exchange.updateTrade(staleTrade)).value!; + + expect((trade.payInNetwork, trade.payOutNetwork), ('BTC', 'XNO')); + expect( + (updatedTrade.payInNetwork, updatedTrade.payOutNetwork), + ('BTC', 'XNO'), + ); + }); +} From 6876df3f4e4bc65476c398c6823bdca04dbac9ef Mon Sep 17 00:00:00 2001 From: Julian Date: Sun, 16 Aug 2026 11:38:55 -0600 Subject: [PATCH 35/52] add extra check to swap pay in amount parsing --- lib/models/exchange/incomplete_exchange.dart | 2 + .../exchange_step_views/step_4_view.dart | 43 ++++++++++++++++--- .../exchange_steps/step_scaffold.dart | 22 +++++++--- .../exchange/incomplete_exchange_test.dart | 6 +++ 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/lib/models/exchange/incomplete_exchange.dart b/lib/models/exchange/incomplete_exchange.dart index 664ae0b100..c2464ddea1 100644 --- a/lib/models/exchange/incomplete_exchange.dart +++ b/lib/models/exchange/incomplete_exchange.dart @@ -30,6 +30,8 @@ class IncompleteExchangeModel extends ChangeNotifier { String get payInAmount => trade?.payInAmount ?? sendAmount.toString(); + Decimal? get payInDecimal => Decimal.tryParse(payInAmount); + final ExchangeRateType rateType; final bool reversed; diff --git a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart index 2625d61c42..aee16aa136 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart @@ -10,7 +10,6 @@ import 'dart:async'; -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; @@ -251,9 +250,25 @@ class _Step4ViewState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(tuple.item1); - final Amount amount = Decimal.parse( - model.payInAmount, - ).toAmount(fractionDigits: wallet.info.coin.fractionDigits); + final payInDecimal = model.payInDecimal; + if (payInDecimal == null) { + if (mounted) { + await showDialog( + context: context, + barrierDismissible: true, + builder: (context) => StackOkDialog( + title: "Invalid trade amount", + message: + "The exchange returned an invalid pay-in amount:" + " \"${model.payInAmount}\"", + ), + ); + } + return; + } + final Amount amount = payInDecimal.toAmount( + fractionDigits: wallet.info.coin.fractionDigits, + ); final address = model.trade!.payInAddress; bool wasCancelled = false; @@ -610,6 +625,20 @@ class _SendFromButton extends ConsumerWidget { tuple.item2.ticker.toLowerCase()) { await confirmSend(tuple); } else { + final payInDecimal = model.payInDecimal; + if (payInDecimal == null) { + await showDialog( + context: context, + barrierDismissible: true, + builder: (context) => StackOkDialog( + title: "Invalid trade amount", + message: + "The exchange returned an invalid pay-in amount:" + " \"${model.payInAmount}\"", + ), + ); + return; + } await Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, @@ -622,9 +651,9 @@ class _SendFromButton extends ConsumerWidget { return SendFromView( coin: coin, - amount: Decimal.parse( - model.payInAmount, - ).toAmount(fractionDigits: coin.fractionDigits), + amount: payInDecimal.toAmount( + fractionDigits: coin.fractionDigits, + ), address: model.trade!.payInAddress, trade: model.trade!, ); diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart index cb07dcf4f2..c512ffa0cb 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart @@ -10,7 +10,6 @@ import 'dart:async'; -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -210,14 +209,27 @@ class _StepScaffoldState extends ConsumerState { } void sendFromStack() { - final trade = ref.read(desktopExchangeModelProvider)!.trade!; + final model = ref.read(desktopExchangeModelProvider)!; + final trade = model.trade!; final address = trade.payInAddress; final coin = AppConfig.getCryptoCurrencyForTicker(trade.payInCurrency) ?? AppConfig.getCryptoCurrencyByPrettyName(trade.payInCurrency); - final amount = Decimal.parse( - trade.payInAmount, - ).toAmount(fractionDigits: coin.fractionDigits); + final payInDecimal = model.payInDecimal; + if (payInDecimal == null) { + showDialog( + context: context, + barrierDismissible: true, + builder: (_) => SimpleDesktopDialog( + title: "Invalid trade amount", + message: + "The exchange returned an invalid pay-in amount:" + " \"${trade.payInAmount}\"", + ), + ); + return; + } + final amount = payInDecimal.toAmount(fractionDigits: coin.fractionDigits); showDialog( context: context, diff --git a/test/models/exchange/incomplete_exchange_test.dart b/test/models/exchange/incomplete_exchange_test.dart index 5f2c07becc..798377a422 100644 --- a/test/models/exchange/incomplete_exchange_test.dart +++ b/test/models/exchange/incomplete_exchange_test.dart @@ -41,5 +41,11 @@ void main() { expect(model.payInAmount, "1.2"); model.trade = trade; expect(model.payInAmount, "1.23456789"); + expect(model.payInDecimal, Decimal.parse("1.23456789")); + + model.trade = _Trade(""); + expect(model.payInDecimal, isNull); + model.trade = _Trade("not a number"); + expect(model.payInDecimal, isNull); }); } From 0fde946a0e5997e3a638b6c041c47ee332ec5253 Mon Sep 17 00:00:00 2001 From: Julian Date: Sun, 16 Aug 2026 11:40:11 -0600 Subject: [PATCH 36/52] add extra check to nano interface account info parsing --- .../wallet_mixin_interfaces/nano_interface.dart | 8 +++++++- test/wallets/nano_interface_test.dart | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart index 1fbe4fbeec..1b12d1ed3b 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart @@ -39,7 +39,13 @@ Map _buildHeaders(String url) { ({String frontier, String representative, BigInt balanceAfterSend}) parseNanoSendState(Map accountInfo, BigInt sendAmount) { - final liveBalance = BigInt.parse(accountInfo["balance"].toString()); + if (accountInfo["error"] != null) { + throw Exception("account_info error: ${accountInfo["error"]}"); + } + final liveBalance = BigInt.tryParse(accountInfo["balance"].toString()); + if (liveBalance == null) { + throw Exception("Invalid account_info balance"); + } if (sendAmount > liveBalance) { throw Exception("Insufficient balance"); } diff --git a/test/wallets/nano_interface_test.dart b/test/wallets/nano_interface_test.dart index d80968fcc7..ca57ba5cdf 100644 --- a/test/wallets/nano_interface_test.dart +++ b/test/wallets/nano_interface_test.dart @@ -17,4 +17,17 @@ void main() { throwsException, ); }); + + test('Nano send state surfaces error and malformed responses clearly', () { + expect( + () => parseNanoSendState({'error': 'Account not found'}, BigInt.one), + throwsA(predicate((e) => e.toString().contains('Account not found'))), + ); + expect( + () => parseNanoSendState({}, BigInt.one), + throwsA( + predicate((e) => e.toString().contains('Invalid account_info balance')), + ), + ); + }); } From 0144f910d3f953c725dad6828ef7369596ed7877 Mon Sep 17 00:00:00 2001 From: Julian Date: Sun, 16 Aug 2026 11:45:30 -0600 Subject: [PATCH 37/52] add extra check to handle amount formatter invalid selection --- lib/utilities/amount/amount_input_formatter.dart | 4 +++- test/utilities/amount/amount_unit_test.dart | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/utilities/amount/amount_input_formatter.dart b/lib/utilities/amount/amount_input_formatter.dart index c3121d2a81..8237b5049c 100644 --- a/lib/utilities/amount/amount_input_formatter.dart +++ b/lib/utilities/amount/amount_input_formatter.dart @@ -54,7 +54,9 @@ class AmountInputFormatter extends TextInputFormatter { text.substring(insertedEnd); } } - final selectionEnd = min(newValue.selection.end, text.length); + final selectionEnd = newValue.selection.isValid + ? min(newValue.selection.end, text.length) + : text.length; final textBeforeSelection = text .substring(0, selectionEnd) .replaceAll(groupSeparator, ""); diff --git a/test/utilities/amount/amount_unit_test.dart b/test/utilities/amount/amount_unit_test.dart index 9056a46901..c1c786101e 100644 --- a/test/utilities/amount/amount_unit_test.dart +++ b/test/utilities/amount/amount_unit_test.dart @@ -311,4 +311,13 @@ void main() { expect(insertedDecimal.text, "1,234"); expect(insertedDecimal.selection.baseOffset, 2); }); + + test("formatter tolerates an invalid selection", () { + final formatter = AmountInputFormatter(decimals: 8, locale: "en_US"); + final result = formatter.formatEditUpdate( + TextEditingValue.empty, + const TextEditingValue(text: "1234"), + ); + expect(result.text, "1,234"); + }); } From 6541cdcc4915437dbc1db3634272ca39ccde0491 Mon Sep 17 00:00:00 2001 From: Julian Date: Sun, 16 Aug 2026 15:14:48 -0600 Subject: [PATCH 38/52] fix autobackup location --- .../stack_backup_views/create_auto_backup_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart index 9e32ad392c..5c1be8d153 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart @@ -141,7 +141,7 @@ class _EnableAutoBackupViewState extends ConsumerState { Navigator.of(context).pop(); if (savedPath != null) { - ref.read(prefsChangeNotifierProvider).autoBackupLocation = savedPath; + ref.read(prefsChangeNotifierProvider).autoBackupLocation = pathToSave; ref.read(prefsChangeNotifierProvider).lastAutoBackup = now; ref.read(prefsChangeNotifierProvider).isAutoBackupEnabled = true; From 898c56d2e06c1c9692a17882cd1e5309d00e2f11 Mon Sep 17 00:00:00 2001 From: Julian Date: Sun, 16 Aug 2026 17:24:55 -0600 Subject: [PATCH 39/52] tighten up DPS persistence --- lib/utilities/desktop_password_service.dart | 192 +++++++++++++-- .../desktop_password_service_test.dart | 229 ++++++++++++++++++ 2 files changed, 400 insertions(+), 21 deletions(-) create mode 100644 test/utilities/desktop_password_service_test.dart diff --git a/lib/utilities/desktop_password_service.dart b/lib/utilities/desktop_password_service.dart index 1649a5d3bf..aab071e49f 100644 --- a/lib/utilities/desktop_password_service.dart +++ b/lib/utilities/desktop_password_service.dart @@ -60,13 +60,23 @@ class DPS { } try { - _handler = await StorageCryptoHandler.fromNewPassphrase( + if (await _get(key: _kKeyBlobKey) != null) { + throw Exception( + "DPS: attempted to overwrite an existing keyBlob with a new one", + ); + } + + final handler = await StorageCryptoHandler.fromNewPassphrase( passphrase, kLatestBlobVersion, ); + final keyBlob = await handler.getKeyBlob(); - await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob()); + // The blob is the password-exists commit marker. Store its version first + // so a failed blob write leaves a safe, retryable version-only state. await _updateStoredKeyBlobVersion(kLatestBlobVersion); + await _putAndVerify(key: _kKeyBlobKey, value: keyBlob); + _handler = handler; } catch (e, s) { Logging.instance.e( "${_getMessageFromException(e)}\n$s", @@ -89,21 +99,36 @@ class DPS { if (keyBlob == null) { throw Exception( - "DPS: failed to find keyBlob while attempting to initialize with existing passphrase", + "DPS: failed to find keyBlob while attempting to initialize with" + " existing passphrase", ); } - final blobVersion = await _getStoredKeyBlobVersion(); - _handler = await StorageCryptoHandler.fromExisting( + final versionHint = await _getStoredKeyBlobVersion(); + final authenticated = await _authenticateKeyBlob( passphrase, keyBlob, - blobVersion, + versionHint, ); - if (blobVersion < kLatestBlobVersion) { - // update blob - await _handler!.resetPassphrase(passphrase, kLatestBlobVersion); - await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob()); - await _updateStoredKeyBlobVersion(kLatestBlobVersion); + _handler = authenticated.handler; + + if (authenticated.version < kLatestBlobVersion) { + await _tryUpgradeKeyBlob( + passphrase: passphrase, + keyBlob: keyBlob, + version: authenticated.version, + ); + } else if (versionHint != authenticated.version) { + try { + await _updateStoredKeyBlobVersion(authenticated.version); + } catch (e, s) { + Logging.instance.w( + "DPS: failed to repair key blob version metadata", + error: e, + stackTrace: s, + ); + } } + await _tryCompactPasswordStorage(); } catch (e, s) { Logging.instance.e( "${_getMessageFromException(e)}\n$s", @@ -122,8 +147,8 @@ class DPS { // no passphrase key blob found so any passphrase is technically bad return false; } - final blobVersion = await _getStoredKeyBlobVersion(); - await StorageCryptoHandler.fromExisting(passphrase, keyBlob, blobVersion); + final versionHint = await _getStoredKeyBlobVersion(); + await _authenticateKeyBlob(passphrase, keyBlob, versionHint); // existing passphrase matches key blob return true; } catch (e, s) { @@ -142,6 +167,10 @@ class DPS { String passphraseNew, ) async { try { + if (_handler == null) { + return false; + } + final keyBlob = await _get(key: _kKeyBlobKey); if (keyBlob == null) { @@ -149,14 +178,22 @@ class DPS { return false; } - if (!(await verifyPassphrase(passphraseOld))) { - return false; - } + final versionHint = await _getStoredKeyBlobVersion(); + final authenticated = await _authenticateKeyBlob( + passphraseOld, + keyBlob, + versionHint, + ); + final newHandler = authenticated.handler; + await newHandler.resetPassphrase(passphraseNew, kLatestBlobVersion); + final newBlob = await newHandler.getKeyBlob(); - final blobVersion = await _getStoredKeyBlobVersion(); - await _handler!.resetPassphrase(passphraseNew, blobVersion); - await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob()); - await _updateStoredKeyBlobVersion(blobVersion); + // The version may be temporarily ahead if the blob write fails. Readers + // probe supported versions, so the old blob remains usable and retryable. + await _updateStoredKeyBlobVersion(kLatestBlobVersion); + await _putAndVerify(key: _kKeyBlobKey, value: newBlob); + _handler = newHandler; + await _tryCompactPasswordStorage(); // successfully updated passphrase return true; @@ -181,7 +218,118 @@ class DPS { } Future _updateStoredKeyBlobVersion(int version) async { - await _put(key: _kKeyBlobVersionKey, value: version.toString()); + await _putAndVerify(key: _kKeyBlobVersionKey, value: version.toString()); + } + + Future<({StorageCryptoHandler handler, int version})> _authenticateKeyBlob( + String passphrase, + String keyBlob, + int versionHint, + ) async { + Object? lastError; + StackTrace? lastStackTrace; + final versions = {versionHint}; + for (int version = kLatestBlobVersion; version >= 1; version--) { + versions.add(version); + } + + for (final version in versions) { + try { + return ( + handler: await StorageCryptoHandler.fromExisting( + passphrase, + keyBlob, + version, + ), + version: version, + ); + } on IncorrectPassphraseOrVersion catch (e, s) { + lastError = e; + lastStackTrace = s; + } on VersionError catch (e, s) { + lastError = e; + lastStackTrace = s; + } + } + + Error.throwWithStackTrace(lastError!, lastStackTrace!); + } + + Future _tryUpgradeKeyBlob({ + required String passphrase, + required String keyBlob, + required int version, + }) async { + try { + final upgradedHandler = await StorageCryptoHandler.fromExisting( + passphrase, + keyBlob, + version, + ); + await upgradedHandler.resetPassphrase(passphrase, kLatestBlobVersion); + final upgradedBlob = await upgradedHandler.getKeyBlob(); + + await _updateStoredKeyBlobVersion(kLatestBlobVersion); + await _putAndVerify(key: _kKeyBlobKey, value: upgradedBlob); + _handler = upgradedHandler; + } catch (e, s) { + Logging.instance.w( + "DPS: key blob upgrade failed; continuing with authenticated version", + error: e, + stackTrace: s, + ); + } + } + + Future _tryCompactPasswordStorage() async { + Box? box; + try { + box = await DB.instance.hive.openBox(kBoxNameDesktopData); + await box.compact(); + } catch (e, s) { + Logging.instance.w( + "DPS: failed to compact desktop password storage", + error: e, + stackTrace: s, + ); + } finally { + try { + await box?.close(); + } catch (e, s) { + Logging.instance.w( + "DPS: failed to close desktop password storage after compaction", + error: e, + stackTrace: s, + ); + } + } + } + + Future _putAndVerify({ + required String key, + required String value, + }) async { + try { + await _put(key: key, value: value); + } catch (e, s) { + try { + if (await _get(key: key) == value) { + Logging.instance.w( + "DPS: put($key) reported an error but persisted data was verified", + error: e, + stackTrace: s, + ); + return; + } + } catch (_) { + // Preserve the original write error below. + } + Error.throwWithStackTrace(e, s); + } + + if (await _get(key: key) != value) { + throw Exception("DPS: persisted value verification failed for $key"); + } } Future _put({required String key, required String value}) async { @@ -191,6 +339,7 @@ class DPS { await box.put(key, value); } catch (e, s) { Logging.instance.f("DPS failed put($key): ", error: e, stackTrace: s); + rethrow; } finally { await box?.close(); } @@ -204,6 +353,7 @@ class DPS { value = box.get(key); } catch (e, s) { Logging.instance.f("DPS failed get($key): ", error: e, stackTrace: s); + rethrow; } finally { await box?.close(); } diff --git a/test/utilities/desktop_password_service_test.dart b/test/utilities/desktop_password_service_test.dart new file mode 100644 index 0000000000..0947f2c353 --- /dev/null +++ b/test/utilities/desktop_password_service_test.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart' show Box; +import 'package:stack_wallet_backup/secure_storage.dart'; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/utilities/desktop_password_service.dart'; + +const _blobKey = "swbKeyBlobKeyStringID"; +const _versionKey = "swbKeyBlobVersionKeyStringID"; + +void main() { + late Directory tempDirectory; + + setUp(() async { + await DB.instance.hive.close(); + tempDirectory = await Directory.systemTemp.createTemp("dps_test_"); + DB.instance.hive.init(tempDirectory.path); + }); + + tearDown(() async { + await DB.instance.hive.close(); + await tempDirectory.delete(recursive: true); + }); + + test("new password persists in the legacy-compatible format", () async { + const passphrase = "correct horse battery staple"; + final service = DPS(); + await service.initFromNew(passphrase); + + final stored = await _readStoredCredentials(); + expect(stored.keys, {_blobKey, _versionKey}); + expect(stored.version, kLatestBlobVersion.toString()); + await StorageCryptoHandler.fromExisting( + passphrase, + stored.blob!, + int.parse(stored.version!), + ); + + final restarted = DPS(); + await restarted.initFromExisting(passphrase); + expect(await restarted.verifyPassphrase(passphrase), isTrue); + }); + + test("failed setup does not install an in-memory handler", () async { + final service = DPS(); + final initialization = service.initFromNew("new password"); + final blockingBox = await _openIncompatibleBox(); + try { + await expectLater(initialization, throwsA(anything)); + expect(() => service.handler, throwsException); + } finally { + await blockingBox.close(); + } + + await service.initFromNew("new password"); + expect(await service.verifyPassphrase("new password"), isTrue); + }); + + test("password change is atomic from the service's perspective", () async { + const field = "wallet secret"; + const plaintext = "seed material"; + final service = DPS(); + await service.initFromNew("old password"); + final ciphertext = await service.handler.encryptValue(field, plaintext); + final originalBlob = (await _readStoredCredentials()).blob!; + expect(await _desktopDataFileContains(tempDirectory, originalBlob), isTrue); + + final failedChange = service.changePassphrase( + "old password", + "failed password", + ); + final blockingBox = await _openIncompatibleBox(); + try { + expect(await failedChange, isFalse); + } finally { + await blockingBox.close(); + } + + expect((await _readStoredCredentials()).blob, originalBlob); + expect(await service.verifyPassphrase("old password"), isTrue); + + final compactionBlocker = Directory( + _desktopDataPath(tempDirectory, "hivec"), + ); + await compactionBlocker.create(); + try { + expect( + await service.changePassphrase("old password", "new password"), + isTrue, + ); + } finally { + await compactionBlocker.delete(); + } + final stored = await _readStoredCredentials(); + expect(stored.blob, isNot(originalBlob)); + expect(stored.version, kLatestBlobVersion.toString()); + expect(await _desktopDataFileContains(tempDirectory, originalBlob), isTrue); + + final restarted = DPS(); + expect(await restarted.verifyPassphrase("old password"), isFalse); + await restarted.initFromExisting("new password"); + expect(await restarted.handler.decryptValue(field, ciphertext), plaintext); + expect( + await _desktopDataFileContains(tempDirectory, originalBlob), + isFalse, + ); + }); + + test("failed automatic upgrade stays usable and retries", () async { + const passphrase = "legacy password"; + const field = "wallet secret"; + const plaintext = "seed material"; + final oldHandler = await StorageCryptoHandler.fromNewPassphrase( + passphrase, + 1, + ); + final oldBlob = await oldHandler.getKeyBlob(); + final ciphertext = await oldHandler.encryptValue(field, plaintext); + await _writeStoredCredentials(blob: oldBlob, version: 1); + + final firstLogin = DPS(); + final initialization = firstLogin.initFromExisting(passphrase); + final blockingBox = await _openIncompatibleBox(); + try { + await initialization; + expect( + await firstLogin.handler.decryptValue(field, ciphertext), + plaintext, + ); + } finally { + await blockingBox.close(); + } + + var stored = await _readStoredCredentials(); + expect(stored.blob, oldBlob); + expect(stored.version, "1"); + + final retriedLogin = DPS(); + await retriedLogin.initFromExisting(passphrase); + stored = await _readStoredCredentials(); + expect(stored.blob, isNot(oldBlob)); + expect(stored.version, kLatestBlobVersion.toString()); + expect( + await retriedLogin.handler.decryptValue(field, ciphertext), + plaintext, + ); + expect(await _desktopDataFileContains(tempDirectory, oldBlob), isFalse); + + final restarted = DPS(); + await restarted.initFromExisting(passphrase); + expect(await restarted.handler.decryptValue(field, ciphertext), plaintext); + }); + + test("interrupted upgrade states recover and finish at latest", () async { + const passphrase = "legacy password"; + + final latestHandler = await StorageCryptoHandler.fromNewPassphrase( + passphrase, + kLatestBlobVersion, + ); + final latestBlob = await latestHandler.getKeyBlob(); + await _writeStoredCredentials(blob: latestBlob); + await DPS().initFromExisting(passphrase); + var stored = await _readStoredCredentials(); + expect(stored.blob, latestBlob); + expect(stored.version, kLatestBlobVersion.toString()); + + await DB.instance.hive.deleteBoxFromDisk(kBoxNameDesktopData); + final oldHandler = await StorageCryptoHandler.fromNewPassphrase( + passphrase, + 1, + ); + final oldBlob = await oldHandler.getKeyBlob(); + await _writeStoredCredentials(blob: oldBlob, version: kLatestBlobVersion); + await DPS().initFromExisting(passphrase); + stored = await _readStoredCredentials(); + expect(stored.blob, isNot(oldBlob)); + expect(stored.version, kLatestBlobVersion.toString()); + }); +} + +Future<({String? blob, String? version, Set keys})> +_readStoredCredentials() async { + final box = await DB.instance.hive.openBox(kBoxNameDesktopData); + final result = ( + blob: box.get(_blobKey), + version: box.get(_versionKey), + keys: box.keys.toSet(), + ); + await box.close(); + return result; +} + +Future _writeStoredCredentials({ + required String blob, + int? version, +}) async { + final box = await DB.instance.hive.openBox(kBoxNameDesktopData); + await box.put(_blobKey, blob); + if (version != null) { + await box.put(_versionKey, version.toString()); + } + await box.close(); +} + +Future> _openIncompatibleBox() async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (true) { + try { + return await DB.instance.hive.openBox(kBoxNameDesktopData); + } catch (_) { + if (DateTime.now().isAfter(deadline)) { + rethrow; + } + await Future.delayed(const Duration(milliseconds: 10)); + } + } +} + +String _desktopDataPath(Directory directory, String extension) => + "${directory.path}${Platform.pathSeparator}" + "${kBoxNameDesktopData.toLowerCase()}.$extension"; + +Future _desktopDataFileContains(Directory directory, String value) async { + final bytes = await File(_desktopDataPath(directory, "hive")).readAsBytes(); + return latin1.decode(bytes).contains(value); +} From 78eced11f109467a8130c44819e953d66d07797b Mon Sep 17 00:00:00 2001 From: Julian Date: Mon, 17 Aug 2026 07:34:55 -0600 Subject: [PATCH 40/52] handle edgecase where estimate could be stale in the 1.5s window --- lib/pages/exchange_view/exchange_form.dart | 98 ++++++++++++++++------ 1 file changed, 73 insertions(+), 25 deletions(-) diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart index b861b59944..97800da9e4 100644 --- a/lib/pages/exchange_view/exchange_form.dart +++ b/lib/pages/exchange_view/exchange_form.dart @@ -166,6 +166,25 @@ class _ExchangeFormState extends ConsumerState { }); } + bool _flushPendingAmountChange() { + bool flushed = false; + if (_sendFieldOnChangedTimer?.isActive ?? false) { + _sendFieldOnChangedTimer!.cancel(); + ref.read(efSendAmountProvider.notifier).state = _localizedStringToNum( + _sendController.text, + ); + flushed = true; + } + if (_receiveFieldOnChangedTimer?.isActive ?? false) { + _receiveFieldOnChangedTimer!.cancel(); + ref.read(efReceiveAmountProvider.notifier).state = _localizedStringToNum( + _receiveController.text, + ); + flushed = true; + } + return flushed; + } + Decimal? _localizedStringToNum(String? value) { if (value == null) { return null; @@ -393,6 +412,11 @@ class _ExchangeFormState extends ConsumerState { } void onExchangePressed() async { + if (_flushPendingAmountChange()) { + await showUpdatingExchangeRate(whileFuture: update()); + if (!mounted) return; + } + final exchangeName = ref.read(efExchangeProvider).name; final fromCurrency = ref @@ -422,8 +446,24 @@ class _ExchangeFormState extends ConsumerState { } final rateType = ref.read(efRateTypeProvider); - final estimate = ref.read(efEstimateProvider)!; - final sendAmount = ref.read(efSendAmountProvider)!; + final estimate = ref.read(efEstimateProvider); + final sendAmount = ref.read(efSendAmountProvider); + + if (estimate == null || sendAmount == null) { + if (mounted) { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + title: "Exchange rate not ready", + message: + "Please wait for the exchange rate to update and try again", + maxWidth: Util.isDesktop ? 300 : null, + ), + ); + } + + return; + } if (rateType == ExchangeRateType.fixed && toCurrency.ticker.toUpperCase() == "WOW") { @@ -646,9 +686,30 @@ class _ExchangeFormState extends ConsumerState { Future update() async { final uuid = const Uuid().v1(); _latestUuid = uuid; - _addUpdate(uuid); - for (final exchange in usableExchanges) { - ref.read(efEstimatesListProvider(exchange.name).notifier).state = null; + + final exchanges = usableExchanges; + final estimatesNotifiers = { + for (final exchange in exchanges) + exchange.name: ref.read( + efEstimatesListProvider(exchange.name).notifier, + ), + }; + final refreshingNotifier = ref.read(efRefreshingProvider.notifier); + + _uuids.add(uuid); + refreshingNotifier.state = true; + + void removeUpdate() { + _uuids.remove(uuid); + if (_uuids.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + refreshingNotifier.state = false; + }); + } + } + + for (final exchange in exchanges) { + estimatesNotifiers[exchange.name]!.state = null; } final reversed = ref.read(efReversedProvider); @@ -661,14 +722,14 @@ class _ExchangeFormState extends ConsumerState { amount <= Decimal.zero || pair.send == null || pair.receive == null) { - _removeUpdate(uuid); + removeUpdate(); return; } final rateType = ref.read(efRateTypeProvider); final Map>, Range?>> results = {}; - for (final exchange in usableExchanges) { + for (final exchange in exchanges) { final sendCurrency = pair.send?.forExchange(exchange.name); final receiveCurrency = pair.receive?.forExchange(exchange.name); @@ -705,33 +766,18 @@ class _ExchangeFormState extends ConsumerState { } } - for (final exchange in usableExchanges) { + for (final exchange in exchanges) { if (uuid == _latestUuid) { - ref.read(efEstimatesListProvider(exchange.name).notifier).state = - results[exchange.name]; + estimatesNotifiers[exchange.name]!.state = results[exchange.name]; } } - _removeUpdate(uuid); + removeUpdate(); } String? _latestUuid; final Set _uuids = {}; - void _addUpdate(String uuid) { - _uuids.add(uuid); - ref.read(efRefreshingProvider.notifier).state = true; - } - - void _removeUpdate(String uuid) { - _uuids.remove(uuid); - if (_uuids.isEmpty) { - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(efRefreshingProvider.notifier).state = false; - }); - } - } - void updateSend(Estimate? estimate) { ref.read(efSendAmountProvider.notifier).state = estimate?.estimatedAmount; } @@ -810,6 +856,8 @@ class _ExchangeFormState extends ConsumerState { @override void dispose() { + _sendFieldOnChangedTimer?.cancel(); + _receiveFieldOnChangedTimer?.cancel(); _receiveController.dispose(); _sendController.dispose(); _receiveFocusNode.dispose(); From f23251473c79d21cb18cb41acdbf1d7b4bf3c1d1 Mon Sep 17 00:00:00 2001 From: Julian Date: Mon, 17 Aug 2026 08:37:23 -0600 Subject: [PATCH 41/52] formatting --- .../generate_receiving_uri_qr_code_view.dart | 390 +++++++++--------- 1 file changed, 184 insertions(+), 206 deletions(-) diff --git a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart index d485cfa148..8b81c59a98 100644 --- a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart +++ b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart @@ -90,7 +90,8 @@ class _GenerateUriQrCodeViewState extends State { final dir = Directory("${Platform.environment['HOME']}"); if (!dir.existsSync()) { throw Exception( - "Home dir not found while trying to open filepicker on QR image save", + "Home dir not found while trying to open filepicker on QR image" + " save", ); } final path = await FilePicker.platform.saveFile( @@ -122,10 +123,13 @@ class _GenerateUriQrCodeViewState extends State { } } } else { - // await DocumentFileSavePlus.saveFile( - // pngBytes, - // "receive_qr_code_${DateTime.now().toLocal().toIso8601String()}.png", - // "image/png"); + // await DocumentFileSavePlus.saveFile( + // pngBytes, + // "receive_qr_code_" + // "${DateTime.now().toLocal().toIso8601String()}" + // ".png", + // "image/png", + // ); } } else { final tempDir = await getTemporaryDirectory(); @@ -237,10 +241,12 @@ class _GenerateUriQrCodeViewState extends State { Assets.svg.share, width: 14, height: 14, - color: - Theme.of( - context, - ).extension()!.buttonTextSecondary, + colorFilter: .mode( + Theme.of( + context, + ).extension()!.buttonTextSecondary, + .srcIn, + ), ), onPressed: () async { await _capturePng(false); @@ -293,68 +299,56 @@ class _GenerateUriQrCodeViewState extends State { return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 70), - ); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "Generate QR code", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (buildContext, constraints) { - return Padding( - padding: const EdgeInsets.only( - left: 12, - top: 12, - right: 12, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 70)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Generate QR code", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (buildContext, constraints) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, ), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 24, - ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(4), - child: child, - ), - ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: child, ), ), - ); - }, - ), - ), + ), + ), + ); + }, ), ), + ), + ), child: Padding( - padding: - isDesktop - ? const EdgeInsets.only( - top: 12, - left: 32, - right: 32, - bottom: 32, - ) - : const EdgeInsets.all(0), + padding: isDesktop + ? const EdgeInsets.only(top: 12, left: 32, right: 32, bottom: 32) + : const EdgeInsets.all(0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max, @@ -369,17 +363,13 @@ class _GenerateUriQrCodeViewState extends State { if (!isDesktop) const SizedBox(height: 12), Text( "Amount (Optional)", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ) + : STextStyles.smallMed12(context), textAlign: TextAlign.left, ), SizedBox(height: isDesktop ? 10 : 8), @@ -392,74 +382,64 @@ class _GenerateUriQrCodeViewState extends State { enableSuggestions: Util.isDesktop ? false : true, controller: amountController, focusNode: _amountFocusNode, - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultText, - height: 1.8, - ) - : STextStyles.field(context), - keyboardType: - Util.isDesktop - ? null - : const TextInputType.numberWithOptions(decimal: true), + ).extension()!.textFieldDefaultText, + height: 1.8, + ) + : STextStyles.field(context), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions(decimal: true), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Amount", - _amountFocusNode, - context, - ).copyWith( - contentPadding: - isDesktop + decoration: + standardInputDecoration( + "Amount", + _amountFocusNode, + context, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ) + left: 16, + top: 11, + bottom: 12, + right: 5, + ) : null, - suffixIcon: - amountController.text.isNotEmpty + suffixIcon: amountController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - amountController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + amountController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), SizedBox(height: isDesktop ? 20 : 12), Text( "Note (Optional)", - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ) + : STextStyles.smallMed12(context), textAlign: TextAlign.left, ), SizedBox(height: isDesktop ? 10 : 8), @@ -472,73 +452,67 @@ class _GenerateUriQrCodeViewState extends State { enableSuggestions: Util.isDesktop ? false : true, controller: noteController, focusNode: _noteFocusNode, - style: - isDesktop - ? STextStyles.desktopTextExtraExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultText, - height: 1.8, - ) - : STextStyles.field(context), + ).extension()!.textFieldDefaultText, + height: 1.8, + ) + : STextStyles.field(context), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Note", - _noteFocusNode, - context, - ).copyWith( - contentPadding: - isDesktop + decoration: + standardInputDecoration( + "Note", + _noteFocusNode, + context, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.only( - left: 16, - top: 11, - bottom: 12, - right: 5, - ) + left: 16, + top: 11, + bottom: 12, + right: 5, + ) : null, - suffixIcon: - noteController.text.isNotEmpty + suffixIcon: noteController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - noteController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + noteController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), SizedBox(height: isDesktop ? 20 : 8), PrimaryButton( label: "Generate QR code", - onPressed: - isDesktop - ? () { - final uriString = _generateURI(); - if (uriString == null) { - return; - } - - setState(() { - didGenerate = true; - _uriString = uriString; - }); + onPressed: isDesktop + ? () { + final uriString = _generateURI(); + if (uriString == null) { + return; } - : onGeneratePressed, + + setState(() { + didGenerate = true; + _uriString = uriString; + }); + } + : onGeneratePressed, buttonHeight: isDesktop ? ButtonHeight.l : null, ), if (isDesktop && didGenerate) @@ -551,10 +525,9 @@ class _GenerateUriQrCodeViewState extends State { children: [ const SizedBox(height: 20), RoundedWhiteContainer( - borderColor: - Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of( + context, + ).extension()!.background, width: isDesktop ? 370 : null, child: Column( children: [ @@ -575,16 +548,16 @@ class _GenerateUriQrCodeViewState extends State { ), const SizedBox(height: 12), Row( - mainAxisAlignment: - isDesktop - ? MainAxisAlignment.center - : MainAxisAlignment.start, + mainAxisAlignment: isDesktop + ? MainAxisAlignment.center + : MainAxisAlignment.start, children: [ if (!isDesktop) SecondaryButton( width: 170, - buttonHeight: - isDesktop ? ButtonHeight.l : null, + buttonHeight: isDesktop + ? ButtonHeight.l + : null, onPressed: () async { await _capturePng(false); }, @@ -593,17 +566,20 @@ class _GenerateUriQrCodeViewState extends State { Assets.svg.share, width: 20, height: 20, - color: - Theme.of(context) - .extension()! - .buttonTextSecondary, + colorFilter: .mode( + Theme.of(context) + .extension()! + .buttonTextSecondary, + .srcIn, + ), ), ), if (!isDesktop) const SizedBox(width: 16), PrimaryButton( width: 170, - buttonHeight: - isDesktop ? ButtonHeight.l : null, + buttonHeight: isDesktop + ? ButtonHeight.l + : null, onPressed: () async { // TODO: add save functionality instead of share // save works on linux at the moment @@ -614,10 +590,12 @@ class _GenerateUriQrCodeViewState extends State { Assets.svg.arrowDown, width: 20, height: 20, - color: - Theme.of(context) - .extension()! - .buttonTextPrimary, + colorFilter: .mode( + Theme.of(context) + .extension()! + .buttonTextPrimary, + .srcIn, + ), ), ), ], From 9bb5acac36f86cc82b09857a24f9e068c81f81ca Mon Sep 17 00:00:00 2001 From: Julian Date: Mon, 17 Aug 2026 11:49:22 -0600 Subject: [PATCH 42/52] tighten localized number string parsing --- .../generate_receiving_uri_qr_code_view.dart | 23 +- lib/utilities/amount/amount.dart | 44 ++++ test/utilities/amount/amount_unit_test.dart | 208 ++++++++++++++++++ 3 files changed, 261 insertions(+), 14 deletions(-) diff --git a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart index 8b81c59a98..203e0a0423 100644 --- a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart +++ b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart @@ -14,17 +14,19 @@ import 'dart:typed_data'; import 'dart:ui' as ui; // import 'package:document_file_save_plus/document_file_save_plus.dart'; -import 'package:decimal/decimal.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_svg/svg.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/locale_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; +import '../../utilities/amount/amount.dart'; import '../../utilities/assets.dart'; import '../../utilities/clipboard_interface.dart'; import '../../utilities/constants.dart'; @@ -44,7 +46,7 @@ import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; -class GenerateUriQrCodeView extends StatefulWidget { +class GenerateUriQrCodeView extends ConsumerStatefulWidget { const GenerateUriQrCodeView({ super.key, required this.coin, @@ -59,10 +61,11 @@ class GenerateUriQrCodeView extends StatefulWidget { final ClipboardInterface clipboard; @override - State createState() => _GenerateUriQrCodeViewState(); + ConsumerState createState() => + _GenerateUriQrCodeViewState(); } -class _GenerateUriQrCodeViewState extends State { +class _GenerateUriQrCodeViewState extends ConsumerState { final _qrKey = GlobalKey(); late TextEditingController amountController; @@ -150,16 +153,8 @@ class _GenerateUriQrCodeViewState extends State { final amountString = amountController.text; final noteString = noteController.text; - // try "." - Decimal? amount = Decimal.tryParse(amountString); - if (amount == null) { - // try single instance of "," - final first = amountString.indexOf(","); - final last = amountString.lastIndexOf(","); - if (first == last) { - amount = Decimal.tryParse(amountString.replaceFirst(",", ".")); - } - } + final locale = ref.read(localeServiceChangeNotifierProvider).locale; + final amount = Amount.tryParseLocalizedNumber(amountString, locale: locale); if (amountString.isNotEmpty && amount == null) { showFloatingFlushBar( diff --git a/lib/utilities/amount/amount.dart b/lib/utilities/amount/amount.dart index db2336e6be..9c259f8cc8 100644 --- a/lib/utilities/amount/amount.dart +++ b/lib/utilities/amount/amount.dart @@ -53,6 +53,50 @@ class Amount { .replaceFirst(decimalSeparator, "."); } + static Decimal? tryParseLocalizedNumber( + String value, { + required String locale, + }) { + if (value.isEmpty || value.contains(RegExp(r'[+\-\x09-\x0D ]'))) { + return null; + } + + final symbols = Util.getSymbolsFor(locale: locale); + final groupSeparator = symbols?.GROUP_SEP ?? ","; + final decimalSeparator = symbols?.DECIMAL_SEP ?? "."; + final escapedGroup = RegExp.escape(groupSeparator); + final escapedDecimal = RegExp.escape(decimalSeparator); + final integerPattern = + r'(?:\d+|[1-9]\d{0,2}(?:' + escapedGroup + r'\d{3})+)'; + final localizedPattern = RegExp( + '^(?:$integerPattern(?:$escapedDecimal\\d+)?|$escapedDecimal\\d+)\$', + ); + + // In locales that group digits with ".", a single-dot value with exactly + // three trailing digits ("1.123") is both a validly grouped integer and a + // plausible plain dot-decimal amount. Money input must not guess between + // readings that differ 1000x, so such values are rejected outright. + if (groupSeparator == "." && + decimalSeparator != "." && + !value.contains(decimalSeparator) && + RegExp(r'^[1-9]\d{0,2}\.\d{3}$').hasMatch(value)) { + return null; + } + + if (localizedPattern.hasMatch(value)) { + return Decimal.tryParse(normalizeLocalizedNumber(value, locale: locale)); + } + + if (groupSeparator == "." && + decimalSeparator != "." && + !value.contains(decimalSeparator) && + RegExp(r'^(?:\d+(?:\.\d+)?|\.\d+)$').hasMatch(value)) { + return Decimal.tryParse(value); + } + + return null; + } + static Amount? tryParseFiatString(String value, {required String locale}) { if (value.isEmpty || value.contains(RegExp(r'[+\-\x09-\x0D ]'))) { return null; diff --git a/test/utilities/amount/amount_unit_test.dart b/test/utilities/amount/amount_unit_test.dart index c1c786101e..807f4e6b14 100644 --- a/test/utilities/amount/amount_unit_test.dart +++ b/test/utilities/amount/amount_unit_test.dart @@ -5,6 +5,7 @@ import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/amount/amount_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_input_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/util.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; void main() { @@ -312,6 +313,213 @@ void main() { expect(insertedDecimal.selection.baseOffset, 2); }); + test("strict localized parsing validates grouping", () { + expect( + Amount.tryParseLocalizedNumber("1,000", locale: "en_US"), + Decimal.fromInt(1000), + ); + expect( + Amount.tryParseLocalizedNumber("1.5", locale: "en_US"), + Decimal.parse("1.5"), + ); + expect( + Amount.tryParseLocalizedNumber("1,5", locale: "de_DE"), + Decimal.parse("1.5"), + ); + expect( + Amount.tryParseLocalizedNumber("1.5", locale: "de_DE"), + Decimal.parse("1.5"), + ); + + for (final malformed in ["1,5", "12,34", "0,001", "1,,000"]) { + expect( + Amount.tryParseLocalizedNumber(malformed, locale: "en_US"), + isNull, + reason: malformed, + ); + } + }); + + test("ambiguous dot-grouped values are rejected", () { + // A single "." group with exactly three trailing digits reads as both a + // grouped integer (1123) and a dot-decimal amount (1.123). Reject. + for (final ambiguous in ["1.123", "1.000", "12.345", "999.999"]) { + expect( + Amount.tryParseLocalizedNumber(ambiguous, locale: "de_DE"), + isNull, + reason: ambiguous, + ); + } + + // Values with only one possible reading still parse. + expect( + Amount.tryParseLocalizedNumber("1.12", locale: "de_DE"), + Decimal.parse("1.12"), + ); + expect( + Amount.tryParseLocalizedNumber("1.1234", locale: "de_DE"), + Decimal.parse("1.1234"), + ); + expect( + Amount.tryParseLocalizedNumber("0.123", locale: "de_DE"), + Decimal.parse("0.123"), + ); + expect( + Amount.tryParseLocalizedNumber("1234.123", locale: "de_DE"), + Decimal.parse("1234.123"), + ); + expect( + Amount.tryParseLocalizedNumber("1.000.000", locale: "de_DE"), + Decimal.fromInt(1000000), + ); + expect( + Amount.tryParseLocalizedNumber("1.000,5", locale: "de_DE"), + Decimal.parse("1000.5"), + ); + + // Locales with "." as the decimal separator are unaffected. + expect( + Amount.tryParseLocalizedNumber("1.123", locale: "en_US"), + Decimal.parse("1.123"), + ); + expect( + Amount.tryParseLocalizedNumber("1,123", locale: "en_US"), + Decimal.fromInt(1123), + ); + }); + + test("tryParseLocalizedNumber input class matrix", () { + // (input, expected for en_US, expected for de_DE); null means rejected. + final cases = <(String, String?, String?)>[ + // Plain integers. + ("0", "0", "0"), + ("5", "5", "5"), + ("007", "7", "7"), + ( + "1234567890123456789012345678901234567890", + "1234567890123456789012345678901234567890", + "1234567890123456789012345678901234567890", + ), + // Decimal-separator forms. + ("1.5", "1.5", "1.5"), + ("0.5", "0.5", "0.5"), + (".5", "0.5", "0.5"), + ("00.5", "0.5", "0.5"), + ("1.12345678", "1.12345678", "1.12345678"), + ("1,5", null, "1.5"), + (",5", null, "0.5"), + (",000", null, "0"), + ("0,5", null, "0.5"), + ("1,12345678", null, "1.12345678"), + ("0.000000000000000001", "0.000000000000000001", "0.000000000000000001"), + // Grouped values; note a 3-digit comma "decimal" is valid in de_DE. + ("1,000", "1000", "1"), + ("10,000", "10000", "10"), + ("100,000", "100000", "100"), + ("999,999", "999999", "999.999"), + ("1,234,567", "1234567", null), + ("1,000.5", "1000.5", null), + ("1,000,000.12345678", "1000000.12345678", null), + ("1.234.567", null, "1234567"), + ("1.000,5", null, "1000.5"), + ("1.000.000,12345678", null, "1000000.12345678"), + // Malformed grouping (en_US); most re-read as decimals in de_DE. + ("1,23", null, "1.23"), + ("12,3456", null, "12.3456"), + ("1234,567", null, "1234.567"), + ("0,001", null, "0.001"), + ("1,0000", null, "1"), + ("1,,000", null, null), + ("1,000,00", null, null), + // Ambiguous single dot group in de_DE; plain decimals in en_US. + ("1.123", "1.123", null), + ("1.000", "1", null), + ("12.345", "12.345", null), + ("999.999", "999.999", null), + // Unambiguous dot forms in de_DE. + ("1.12", "1.12", "1.12"), + ("1.1234", "1.1234", "1.1234"), + ("0.123", "0.123", "0.123"), + ("1000.123", "1000.123", "1000.123"), + ("1234.123", "1234.123", "1234.123"), + // Separator garbage. + ("1.2.3", null, null), + ("1..5", null, null), + (".", null, null), + ("..", null, null), + (",", null, null), + ("1.", null, null), + ("5.", null, null), + ("5,", null, null), + ("1,000.", null, null), + ("1.000.", null, null), + (".5.5", null, null), + // Signs and whitespace. + ("", null, null), + ("+5", null, null), + ("-5", null, null), + ("5-", null, null), + ("1-2", null, null), + (" 5", null, null), + ("5 ", null, null), + ("1 000", null, null), + ("\t5", null, null), + ("5\n", null, null), + ("5\r", null, null), + // Non-numeric and exotic digits. + ("abc", null, null), + ("1a", null, null), + ("a1", null, null), + ("1e5", null, null), + ("1E5", null, null), + ("0x10", null, null), + ("NaN", null, null), + ("Infinity", null, null), + ("١٢٣", null, null), + ("123", null, null), + ]; + + for (final (input, enExpected, deExpected) in cases) { + expect( + Amount.tryParseLocalizedNumber(input, locale: "en_US"), + enExpected == null ? isNull : Decimal.parse(enExpected), + reason: "en_US: '$input'", + ); + expect( + Amount.tryParseLocalizedNumber(input, locale: "de_DE"), + deExpected == null ? isNull : Decimal.parse(deExpected), + reason: "de_DE: '$input'", + ); + } + }); + + test("tryParseLocalizedNumber locale symbols and fallback defaults", () { + // fr_FR groups with a non-breaking space variant; build input from the + // actual symbol so the test survives intl data updates. + final frGroup = Util.getSymbolsFor(locale: "fr_FR")!.GROUP_SEP; + expect( + Amount.tryParseLocalizedNumber("1${frGroup}234,5", locale: "fr_FR"), + Decimal.parse("1234.5"), + ); + expect( + Amount.tryParseLocalizedNumber("1234,5", locale: "fr_FR"), + Decimal.parse("1234.5"), + ); + // A typed ASCII space is never a valid separator. + expect(Amount.tryParseLocalizedNumber("1 000", locale: "fr_FR"), isNull); + + // Unknown locale falls back to "," grouping and "." decimals. + expect( + Amount.tryParseLocalizedNumber("1,000.5", locale: "zz_ZZ"), + Decimal.parse("1000.5"), + ); + expect( + Amount.tryParseLocalizedNumber("1.5", locale: "zz_ZZ"), + Decimal.parse("1.5"), + ); + expect(Amount.tryParseLocalizedNumber("1,5", locale: "zz_ZZ"), isNull); + }); + test("formatter tolerates an invalid selection", () { final formatter = AmountInputFormatter(decimals: 8, locale: "en_US"); final result = formatter.formatEditUpdate( From c31d8288860a125404ad1d172c00c976ea4f693d Mon Sep 17 00:00:00 2001 From: Julian Date: Mon, 17 Aug 2026 12:01:02 -0600 Subject: [PATCH 43/52] fix payment uri amount parsing --- lib/utilities/address_utils.dart | 21 +++++------ test/address_utils_test.dart | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 8db346ce3e..8e0fa247f1 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -76,6 +76,8 @@ class AddressUtils { result["tx_description"] = Uri.decodeComponent(u.fragment); } } + } on FormatException { + rethrow; } catch (e, s) { Logging.instance.d( "Exception caught in parseUri($uri): $e", @@ -95,7 +97,11 @@ class AddressUtils { switch (lowerKey) { case 'amount': case 'tx_amount': - result['amount'] = _normalizeAmount(value); + final normalized = _normalizeAmount(value); + if (normalized == null) { + throw FormatException("Invalid payment URI amount: $value"); + } + result['amount'] = normalized; break; case 'label': case 'recipient_name': @@ -119,16 +125,9 @@ class AddressUtils { return result; } - /// Normalizes amount value to a standard format. - static String _normalizeAmount(String amount) { - // Remove any non-numeric characters except for '.' - final sanitized = amount.replaceAll(RegExp(r'[^\d.]'), ''); - // Ensure only one decimal point - final parts = sanitized.split('.'); - if (parts.length > 2) { - return '${parts[0]}.${parts.sublist(1).join()}'; - } - return sanitized; + static String? _normalizeAmount(String amount) { + final trimmed = amount.trim(); + return RegExp(r'^(\d+(\.\d+)?|\.\d+)$').hasMatch(trimmed) ? trimmed : null; } /// Centralized method to handle various cryptocurrency URIs and return a common object. diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index ee4ede0038..0f1d082c41 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -40,6 +40,71 @@ void main() { expect(result.message, "eggs are good!"); }); + test("parse uri with malformed amount rejects the whole uri", () { + // Payment URI amounts are machine-format plain decimals (BIP21 style): + // no signs, no exponents, no grouping or locale separators, no units. + const malformed = [ + "-5", + "%2B5", // literal "+5"; a raw "+" is query-encoding for a space + "1e3", + "1E3", + "1e-3", + "1.2.3", + "5%20BTC", + "1,220.0", // grouped + "1,5", // comma decimal + "1.220,00", // European format + "1%20220.0", // space grouped + "5.", // trailing separator + "5,", + ".", + "", // explicitly present but empty + "0x10", + "NaN", + "Infinity", + "abc", + ]; + for (final amount in malformed) { + expect( + AddressUtils.parsePaymentUri("bitcoin:$firoAddress?amount=$amount"), + isNull, + reason: "amount=$amount", + ); + } + }); + + test("parse uri with valid amount preserves it verbatim", () { + const valid = [ + "5", + "007", + "1220.0", + "1.220", + "0.5", + ".5", + "0.00000001", + "123456789.123456789", + ]; + for (final amount in valid) { + final result = AddressUtils.parsePaymentUri( + "bitcoin:$firoAddress?amount=$amount", + ); + expect(result?.amount, amount, reason: "amount=$amount"); + } + + // Surrounding whitespace is trimmed, not rejected. + final padded = AddressUtils.parsePaymentUri( + "bitcoin:$firoAddress?amount=%201.5%20", + ); + expect(padded?.amount, "1.5"); + + // A raw "+" in a query decodes to a space, so "+5" arrives as " 5" and + // trims to a valid "5". A literal plus sign (%2B5) is rejected above. + final plusAsSpace = AddressUtils.parsePaymentUri( + "bitcoin:$firoAddress?amount=+5", + ); + expect(plusAsSpace?.amount, "5"); + }); + test("parse query parameters exactly once", () { const uri = "bitcoin:$firoAddress?label=Save%25&amount=1.5"; final result = AddressUtils.parsePaymentUri(uri); From 09ac90fe1b013176c2db5001dc362a658b15b365 Mon Sep 17 00:00:00 2001 From: Julian Date: Tue, 18 Aug 2026 07:40:08 -0600 Subject: [PATCH 44/52] memo/extraid swap support --- lib/models/exchange/incomplete_exchange.dart | 22 +++ .../exchange_step_views/step_2_view.dart | 167 +++++++++++++++++- .../exchange_step_views/step_3_view.dart | 114 ++++++++---- .../exchange_steps/step_scaffold.dart | 5 +- .../subwidgets/desktop_step_2.dart | 153 +++++++++++++++- .../subwidgets/desktop_step_3.dart | 85 +++++++-- .../change_now/change_now_exchange.dart | 1 + .../cyphergoat/cyphergoat_exchange.dart | 9 + lib/services/exchange/exchange.dart | 3 + .../exchange/trocador/trocador_exchange.dart | 8 +- lib/utilities/address_utils.dart | 17 ++ lib/utilities/extra_id_currency_support.dart | 25 +++ test/address_utils_test.dart | 33 ++++ .../exchange/incomplete_exchange_test.dart | 20 +++ .../cyphergoat/cyphergoat_exchange_test.dart | 42 +++++ .../extra_id_currency_support_test.dart | 26 +++ 16 files changed, 661 insertions(+), 69 deletions(-) create mode 100644 lib/utilities/extra_id_currency_support.dart create mode 100644 test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart create mode 100644 test/utilities/extra_id_currency_support_test.dart diff --git a/lib/models/exchange/incomplete_exchange.dart b/lib/models/exchange/incomplete_exchange.dart index c2464ddea1..28e0b06c3f 100644 --- a/lib/models/exchange/incomplete_exchange.dart +++ b/lib/models/exchange/incomplete_exchange.dart @@ -59,6 +59,28 @@ class IncompleteExchangeModel extends ChangeNotifier { } } + String? _extraId; + + String? get extraId => _extraId; + + set extraId(String? extraId) { + if (_extraId != extraId) { + _extraId = extraId; + notifyListeners(); + } + } + + String? _refundExtraId; + + String? get refundExtraId => _refundExtraId; + + set refundExtraId(String? refundExtraId) { + if (_refundExtraId != refundExtraId) { + _refundExtraId = refundExtraId; + notifyListeners(); + } + } + Estimate? _estimate; Estimate? get estimate => _estimate; diff --git a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart index e9735a5e26..8a3c05f7b6 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart @@ -20,6 +20,7 @@ import '../../../utilities/address_utils.dart'; import '../../../utilities/barcode_scanner_interface.dart'; import '../../../utilities/clipboard_interface.dart'; import '../../../utilities/constants.dart'; +import '../../../utilities/extra_id_currency_support.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; import '../../../widgets/background.dart'; @@ -61,12 +62,36 @@ class _Step2ViewState extends ConsumerState { late final TextEditingController _toController; late final TextEditingController _refundController; + late final TextEditingController _toMemoController; + late final TextEditingController _refundMemoController; late final FocusNode _toFocusNode; late final FocusNode _refundFocusNode; + late final FocusNode _toMemoFocusNode; + late final FocusNode _refundMemoFocusNode; bool enableNext = false; + bool get _showRecipientMemo => + ref.read(efExchangeProvider).supportsExtraId && + ExtraIdCurrencySupport.mayRequire(model.receiveTicker); + + bool get _showRefundMemo => + ref.read(efExchangeProvider).supportsExtraId && + ExtraIdCurrencySupport.mayRequire(model.sendTicker); + + void _setRecipientMemo(String? memo) { + final value = _showRecipientMemo ? (memo ?? "") : ""; + _toMemoController.text = value; + model.extraId = value.isEmpty ? null : value; + } + + void _setRefundMemo(String? memo) { + final value = _showRefundMemo ? (memo ?? "") : ""; + _refundMemoController.text = value; + model.refundExtraId = value.isEmpty ? null : value; + } + void _onRefundQrTapped() async { try { final qrResult = await ref.read(pBarcodeScanner).scan(context: context); @@ -81,6 +106,7 @@ class _Step2ViewState extends ConsumerState { // auto fill address _refundController.text = paymentData.address; model.refundAddress = _refundController.text; + _setRefundMemo(paymentData.memo); setState(() { enableNext = @@ -90,6 +116,7 @@ class _Step2ViewState extends ConsumerState { } else { _refundController.text = qrResult.rawContent!; model.refundAddress = _refundController.text; + _setRefundMemo(null); setState(() { enableNext = @@ -135,6 +162,7 @@ class _Step2ViewState extends ConsumerState { // auto fill address _toController.text = paymentData.address; model.recipientAddress = _toController.text; + _setRecipientMemo(paymentData.memo); setState(() { enableNext = @@ -145,6 +173,7 @@ class _Step2ViewState extends ConsumerState { } else { _toController.text = qrResult.rawContent!; model.recipientAddress = _toController.text; + _setRecipientMemo(null); setState(() { enableNext = @@ -184,9 +213,15 @@ class _Step2ViewState extends ConsumerState { _toController = TextEditingController(); _refundController = TextEditingController(); + _toMemoController = TextEditingController(text: model.extraId ?? ""); + _refundMemoController = TextEditingController( + text: model.refundExtraId ?? "", + ); _toFocusNode = FocusNode(); _refundFocusNode = FocusNode(); + _toMemoFocusNode = FocusNode(); + _refundMemoFocusNode = FocusNode(); final tuple = ref.read(exchangeSendFromWalletIdStateProvider.state).state; if (tuple != null) { @@ -199,6 +234,7 @@ class _Step2ViewState extends ConsumerState { .then((value) { _toController.text = value!.value; model.recipientAddress = _toController.text; + _setRecipientMemo(null); }); } else { if (model.sendTicker.toUpperCase() == @@ -210,6 +246,7 @@ class _Step2ViewState extends ConsumerState { .then((value) { _refundController.text = value!.value; model.refundAddress = _refundController.text; + _setRefundMemo(null); }); } } @@ -222,9 +259,13 @@ class _Step2ViewState extends ConsumerState { void dispose() { _toController.dispose(); _refundController.dispose(); + _toMemoController.dispose(); + _refundMemoController.dispose(); _toFocusNode.dispose(); _refundFocusNode.dispose(); + _toMemoFocusNode.dispose(); + _refundMemoFocusNode.dispose(); super.dispose(); } @@ -314,6 +355,7 @@ class _Step2ViewState extends ConsumerState { value.walletName; model.recipientAddress = value.address; + _setRecipientMemo(null); setState(() { enableNext = @@ -402,6 +444,7 @@ class _Step2ViewState extends ConsumerState { _toController.text = ""; model.recipientAddress = _toController.text; + _setRecipientMemo(null); setState(() { enableNext = @@ -436,8 +479,27 @@ class _Step2ViewState extends ConsumerState { .text! .trim(); - _toController.text = - content; + final paymentData = + AddressUtils.parsePaymentUri( + content, + logging: Logging + .instance, + ); + if (paymentData != + null) { + _toController.text = + paymentData + .address; + _setRecipientMemo( + paymentData.memo, + ); + } else { + _toController.text = + content; + _setRecipientMemo( + null, + ); + } model.recipientAddress = _toController .text; @@ -498,6 +560,7 @@ class _Step2ViewState extends ConsumerState { address; model.recipientAddress = _toController.text; + _setRecipientMemo(null); ref .read( exchangeFromAddressBookAddressStateProvider @@ -543,6 +606,39 @@ class _Step2ViewState extends ConsumerState { style: STextStyles.label(context), ), ), + if (_showRecipientMemo) const SizedBox(height: 16), + if (_showRecipientMemo) + Text( + "Memo or destination tag", + style: STextStyles.smallMed12(context), + ), + if (_showRecipientMemo) const SizedBox(height: 4), + if (_showRecipientMemo) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key( + "recipientExchangeStep2ViewMemoFieldKey", + ), + controller: _toMemoController, + focusNode: _toMemoFocusNode, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (value) { + model.extraId = value.isEmpty + ? null + : value; + }, + decoration: standardInputDecoration( + "Enter memo or tag if required", + _toMemoFocusNode, + context, + ), + ), + ), const SizedBox(height: 24), if (supportsRefund) Row( @@ -583,6 +679,7 @@ class _Step2ViewState extends ConsumerState { value.walletName; model.refundAddress = value.address; + _setRefundMemo(null); } setState(() { enableNext = @@ -675,6 +772,7 @@ class _Step2ViewState extends ConsumerState { model.refundAddress = _refundController .text; + _setRefundMemo(null); setState(() { enableNext = @@ -708,9 +806,30 @@ class _Step2ViewState extends ConsumerState { .text! .trim(); - _refundController - .text = - content; + final paymentData = + AddressUtils.parsePaymentUri( + content, + logging: Logging + .instance, + ); + if (paymentData != + null) { + _refundController + .text = + paymentData + .address; + _setRefundMemo( + paymentData + .memo, + ); + } else { + _refundController + .text = + content; + _setRefundMemo( + null, + ); + } model.refundAddress = _refundController .text; @@ -775,6 +894,9 @@ class _Step2ViewState extends ConsumerState { model.refundAddress = _refundController .text; + _setRefundMemo( + null, + ); } setState(() { enableNext = @@ -815,6 +937,41 @@ class _Step2ViewState extends ConsumerState { style: STextStyles.label(context), ), ), + if (supportsRefund && _showRefundMemo) + const SizedBox(height: 16), + if (supportsRefund && _showRefundMemo) + Text( + "Refund memo or destination tag", + style: STextStyles.smallMed12(context), + ), + if (supportsRefund && _showRefundMemo) + const SizedBox(height: 4), + if (supportsRefund && _showRefundMemo) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key( + "refundExchangeStep2ViewMemoFieldKey", + ), + controller: _refundMemoController, + focusNode: _refundMemoFocusNode, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (value) { + model.refundExtraId = value.isEmpty + ? null + : value; + }, + decoration: standardInputDecoration( + "Enter memo or tag if required", + _refundMemoFocusNode, + context, + ), + ), + ), const SizedBox(height: 16), const Spacer(), Row( diff --git a/lib/pages/exchange_view/exchange_step_views/step_3_view.dart b/lib/pages/exchange_view/exchange_step_views/step_3_view.dart index 4f0b352c3c..b649d56121 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_3_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_3_view.dart @@ -169,6 +169,27 @@ class _Step3ViewState extends ConsumerState { ], ), ), + if (model.extraId?.isNotEmpty == true) + const SizedBox(height: 8), + if (model.extraId?.isNotEmpty == true) + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Recipient memo or tag", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 4), + Text( + model.extraId!, + style: STextStyles.itemSubtitle12( + context, + ), + ), + ], + ), + ), if (supportsRefund) const SizedBox(height: 8), if (supportsRefund) RoundedWhiteContainer( @@ -189,6 +210,29 @@ class _Step3ViewState extends ConsumerState { ], ), ), + if (supportsRefund && + model.refundExtraId?.isNotEmpty == true) + const SizedBox(height: 8), + if (supportsRefund && + model.refundExtraId?.isNotEmpty == true) + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Refund memo or tag", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 4), + Text( + model.refundExtraId!, + style: STextStyles.itemSubtitle12( + context, + ), + ), + ], + ), + ), const SizedBox(height: 8), const Spacer(), Row( @@ -205,14 +249,12 @@ class _Step3ViewState extends ConsumerState { ), child: Text( "Back", - style: STextStyles.button( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) .extension()! .buttonTextSecondary, - ), + ), ), ), ), @@ -224,22 +266,19 @@ class _Step3ViewState extends ConsumerState { showDialog( context: context, barrierDismissible: false, - builder: - (_) => WillPopScope( - onWillPop: () async => false, - child: Container( - color: Theme.of(context) - .extension()! - .overlay - .withOpacity(0.6), - child: - const CustomLoadingOverlay( - message: - "Creating a trade", - eventBus: null, - ), - ), + builder: (_) => WillPopScope( + onWillPop: () async => false, + child: Container( + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.6), + child: const CustomLoadingOverlay( + message: "Creating a trade", + eventBus: null, ), + ), + ), ), ); @@ -256,17 +295,16 @@ class _Step3ViewState extends ConsumerState { fixedRate: model.rateType != ExchangeRateType.estimated, - amount: - model.reversed - ? model.receiveAmount - : model.sendAmount, + amount: model.reversed + ? model.receiveAmount + : model.sendAmount, addressTo: model.recipientAddress!, - extraId: null, - addressRefund: - supportsRefund - ? model.refundAddress! - : "", - refundExtraId: "", + extraId: model.extraId, + addressRefund: supportsRefund + ? model.refundAddress! + : "", + refundExtraId: + model.refundExtraId ?? "", estimate: model.estimate, reversed: model.reversed, ); @@ -278,8 +316,8 @@ class _Step3ViewState extends ConsumerState { // TODO: better errors String? message; if (response.exception != null) { - message = - response.exception!.toString(); + message = response.exception! + .toString(); if (message.startsWith( "FormatException:", ) && @@ -293,12 +331,10 @@ class _Step3ViewState extends ConsumerState { showDialog( context: context, barrierDismissible: true, - builder: - (_) => StackDialog( - title: - "Failed to create trade", - message: message ?? "", - ), + builder: (_) => StackDialog( + title: "Failed to create trade", + message: message ?? "", + ), ), ); } diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart index c512ffa0cb..a0c7594875 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart @@ -112,9 +112,10 @@ class _StepScaffoldState extends ConsumerState { ? ref.read(desktopExchangeModelProvider)!.receiveAmount : ref.read(desktopExchangeModelProvider)!.sendAmount, addressTo: ref.read(desktopExchangeModelProvider)!.recipientAddress!, - extraId: null, + extraId: ref.read(desktopExchangeModelProvider)!.extraId, addressRefund: ref.read(desktopExchangeModelProvider)!.refundAddress!, - refundExtraId: "", + refundExtraId: + ref.read(desktopExchangeModelProvider)!.refundExtraId ?? "", estimate: ref.read(desktopExchangeModelProvider)!.estimate, reversed: ref.read(desktopExchangeModelProvider)!.reversed, ); diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart index 46038a58d6..f46d7040d5 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart @@ -17,8 +17,10 @@ import '../../../../app_config.dart'; import '../../../../models/contact_address_entry.dart'; import '../../../../providers/providers.dart'; import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/address_utils.dart'; import '../../../../utilities/clipboard_interface.dart'; import '../../../../utilities/constants.dart'; +import '../../../../utilities/extra_id_currency_support.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../widgets/custom_buttons/blue_text_button.dart'; @@ -53,9 +55,41 @@ class _DesktopStep2State extends ConsumerState { late final TextEditingController _toController; late final TextEditingController _refundController; + late final TextEditingController _toMemoController; + late final TextEditingController _refundMemoController; late final FocusNode _toFocusNode; late final FocusNode _refundFocusNode; + late final FocusNode _toMemoFocusNode; + late final FocusNode _refundMemoFocusNode; + + bool get _showRecipientMemo => + ref.read(efExchangeProvider).supportsExtraId && + ExtraIdCurrencySupport.mayRequire( + ref.read(desktopExchangeModelProvider)!.receiveTicker, + ); + + bool get _showRefundMemo => + ref.read(efExchangeProvider).supportsExtraId && + ExtraIdCurrencySupport.mayRequire( + ref.read(desktopExchangeModelProvider)!.sendTicker, + ); + + void _setRecipientMemo(String? memo) { + final value = _showRecipientMemo ? (memo ?? "") : ""; + _toMemoController.text = value; + ref.read(desktopExchangeModelProvider)!.extraId = value.isEmpty + ? null + : value; + } + + void _setRefundMemo(String? memo) { + final value = _showRefundMemo ? (memo ?? "") : ""; + _refundMemoController.text = value; + ref.read(desktopExchangeModelProvider)!.refundExtraId = value.isEmpty + ? null + : value; + } void selectRecipientAddressFromStack() async { try { @@ -79,6 +113,7 @@ class _DesktopStep2State extends ConsumerState { if (info is Tuple2) { _toController.text = info.item1; ref.read(desktopExchangeModelProvider)!.recipientAddress = info.item2; + _setRecipientMemo(null); } } catch (e, s) { Logging.instance.i("$e\n$s", error: e, stackTrace: s); @@ -108,6 +143,7 @@ class _DesktopStep2State extends ConsumerState { if (info is Tuple2) { _refundController.text = info.item1; ref.read(desktopExchangeModelProvider)!.refundAddress = info.item2; + _setRefundMemo(null); } } catch (e, s) { Logging.instance.i("$e\n$s", error: e, stackTrace: s); @@ -151,6 +187,7 @@ class _DesktopStep2State extends ConsumerState { if (entry != null) { _toController.text = entry.address; ref.read(desktopExchangeModelProvider)!.recipientAddress = entry.address; + _setRecipientMemo(null); widget.enableNextChanged.call(_next()); } } @@ -191,6 +228,7 @@ class _DesktopStep2State extends ConsumerState { if (entry != null) { _refundController.text = entry.address; ref.read(desktopExchangeModelProvider)!.refundAddress = entry.address; + _setRefundMemo(null); widget.enableNextChanged.call(_next()); } } @@ -211,9 +249,17 @@ class _DesktopStep2State extends ConsumerState { _toController = TextEditingController(); _refundController = TextEditingController(); + _toMemoController = TextEditingController( + text: ref.read(desktopExchangeModelProvider)!.extraId ?? "", + ); + _refundMemoController = TextEditingController( + text: ref.read(desktopExchangeModelProvider)!.refundExtraId ?? "", + ); _toFocusNode = FocusNode(); _refundFocusNode = FocusNode(); + _toMemoFocusNode = FocusNode(); + _refundMemoFocusNode = FocusNode(); doesRefundAddress = ref.read(efExchangeProvider).supportsRefundAddress; @@ -237,6 +283,7 @@ class _DesktopStep2State extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(desktopExchangeModelProvider)!.recipientAddress = _toController.text; + _setRecipientMemo(null); }); } else { if (doesRefundAddress && @@ -250,6 +297,7 @@ class _DesktopStep2State extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(desktopExchangeModelProvider)!.refundAddress = _refundController.text; + _setRefundMemo(null); }); } } @@ -262,9 +310,13 @@ class _DesktopStep2State extends ConsumerState { void dispose() { _toController.dispose(); _refundController.dispose(); + _toMemoController.dispose(); + _refundMemoController.dispose(); _toFocusNode.dispose(); _refundFocusNode.dispose(); + _toMemoFocusNode.dispose(); + _refundMemoFocusNode.dispose(); super.dispose(); } @@ -370,6 +422,7 @@ class _DesktopStep2State extends ConsumerState { .read(desktopExchangeModelProvider)! .recipientAddress = _toController.text; + _setRecipientMemo(null); widget.enableNextChanged.call(_next()); }, child: const XIcon(), @@ -384,7 +437,19 @@ class _DesktopStep2State extends ConsumerState { if (data?.text != null && data!.text!.isNotEmpty) { final content = data.text!.trim(); - _toController.text = content; + final paymentData = + AddressUtils.parsePaymentUri( + content, + logging: Logging.instance, + ); + if (paymentData != null) { + _toController.text = + paymentData.address; + _setRecipientMemo(paymentData.memo); + } else { + _toController.text = content; + _setRecipientMemo(null); + } ref .read(desktopExchangeModelProvider)! .recipientAddress = _toController @@ -416,6 +481,42 @@ class _DesktopStep2State extends ConsumerState { ), ), ), + if (_showRecipientMemo) const SizedBox(height: 10), + if (_showRecipientMemo) + Text( + "Memo or destination tag", + style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + ), + if (_showRecipientMemo) const SizedBox(height: 10), + if (_showRecipientMemo) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("recipientExchangeStep2ViewMemoFieldKey"), + controller: _toMemoController, + focusNode: _toMemoFocusNode, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (value) { + ref.read(desktopExchangeModelProvider)!.extraId = value.isEmpty + ? null + : value; + }, + decoration: standardInputDecoration( + "Enter the memo or tag required by the payout address, if any", + _toMemoFocusNode, + context, + desktopMed: true, + ), + ), + ), const SizedBox(height: 10), RoundedWhiteContainer( borderColor: Theme.of(context).extension()!.background, @@ -510,6 +611,7 @@ class _DesktopStep2State extends ConsumerState { .read(desktopExchangeModelProvider)! .refundAddress = _refundController .text; + _setRefundMemo(null); widget.enableNextChanged.call(_next()); }, @@ -528,7 +630,19 @@ class _DesktopStep2State extends ConsumerState { data!.text!.isNotEmpty) { final content = data.text!.trim(); - _refundController.text = content; + final paymentData = + AddressUtils.parsePaymentUri( + content, + logging: Logging.instance, + ); + if (paymentData != null) { + _refundController.text = + paymentData.address; + _setRefundMemo(paymentData.memo); + } else { + _refundController.text = content; + _setRefundMemo(null); + } ref .read(desktopExchangeModelProvider)! .refundAddress = _refundController @@ -561,6 +675,41 @@ class _DesktopStep2State extends ConsumerState { ), ), ), + if (doesRefundAddress && _showRefundMemo) const SizedBox(height: 10), + if (doesRefundAddress && _showRefundMemo) + Text( + "Refund memo or destination tag", + style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + ), + if (doesRefundAddress && _showRefundMemo) const SizedBox(height: 10), + if (doesRefundAddress && _showRefundMemo) + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("refundExchangeStep2ViewMemoFieldKey"), + controller: _refundMemoController, + focusNode: _refundMemoFocusNode, + autocorrect: false, + enableSuggestions: false, + style: STextStyles.field(context), + onChanged: (value) { + ref.read(desktopExchangeModelProvider)!.refundExtraId = + value.isEmpty ? null : value; + }, + decoration: standardInputDecoration( + "Enter the memo or tag required by the refund address, if any", + _refundMemoFocusNode, + context, + desktopMed: true, + ), + ), + ), if (doesRefundAddress) const SizedBox(height: 10), if (doesRefundAddress) RoundedWhiteContainer( diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart index 98c4daaf53..1d2b82467f 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart @@ -20,9 +20,7 @@ import '../step_scaffold.dart'; import 'desktop_step_item.dart'; class DesktopStep3 extends ConsumerStatefulWidget { - const DesktopStep3({ - super.key, - }); + const DesktopStep3({super.key}); @override ConsumerState createState() => _DesktopStep3State(); @@ -37,9 +35,7 @@ class _DesktopStep3State extends ConsumerState { "Confirm exchange details", style: STextStyles.desktopTextMedium(context), ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), RoundedWhiteContainer( borderColor: Theme.of(context).extension()!.background, padding: const EdgeInsets.all(0), @@ -72,16 +68,19 @@ class _DesktopStep3State extends ConsumerState { color: Theme.of(context).extension()!.background, ), DesktopStepItem( - label: ref.watch( - desktopExchangeModelProvider - .select((value) => value!.rateType), + label: + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.rateType, + ), ) == ExchangeRateType.estimated ? "Estimated rate" : "Fixed rate", value: ref.watch( - desktopExchangeModelProvider - .select((value) => value!.rateInfo), + desktopExchangeModelProvider.select( + (value) => value!.rateInfo, + ), ), ), Container( @@ -92,12 +91,37 @@ class _DesktopStep3State extends ConsumerState { vertical: true, label: "Recipient ${ref.watch(desktopExchangeModelProvider.select((value) => value!.receiveTicker.toUpperCase()))} address", - value: ref.watch( - desktopExchangeModelProvider - .select((value) => value!.recipientAddress), + value: + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.recipientAddress, + ), ) ?? "Error", ), + if (ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.extraId?.isNotEmpty == true, + ), + )) + Container( + height: 1, + color: Theme.of(context).extension()!.background, + ), + if (ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.extraId?.isNotEmpty == true, + ), + )) + DesktopStepItem( + vertical: true, + label: "Recipient memo or tag", + value: ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.extraId!, + ), + ), + ), if (ref.watch(efExchangeProvider).supportsRefundAddress) Container( height: 1, @@ -108,12 +132,39 @@ class _DesktopStep3State extends ConsumerState { vertical: true, label: "Refund ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))} address", - value: ref.watch( - desktopExchangeModelProvider - .select((value) => value!.refundAddress), + value: + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.refundAddress, + ), ) ?? "Error", ), + if (ref.watch(efExchangeProvider).supportsRefundAddress && + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.refundExtraId?.isNotEmpty == true, + ), + )) + Container( + height: 1, + color: Theme.of(context).extension()!.background, + ), + if (ref.watch(efExchangeProvider).supportsRefundAddress && + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.refundExtraId?.isNotEmpty == true, + ), + )) + DesktopStepItem( + vertical: true, + label: "Refund memo or tag", + value: ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.refundExtraId!, + ), + ), + ), ], ), ), diff --git a/lib/services/exchange/change_now/change_now_exchange.dart b/lib/services/exchange/change_now/change_now_exchange.dart index 48389afeb6..1c2106829e 100644 --- a/lib/services/exchange/change_now/change_now_exchange.dart +++ b/lib/services/exchange/change_now/change_now_exchange.dart @@ -52,6 +52,7 @@ class ChangeNowExchange extends Exchange { toCurrency: to, toNetwork: toNetwork ?? "", address: addressTo, + extraId: extraId, rateId: estimate?.rateId, refundAddress: addressRefund, refundExtraId: refundExtraId, diff --git a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart index 0189908834..dde981f1f0 100644 --- a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart +++ b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart @@ -159,6 +159,9 @@ class CypherGoatExchange extends Exchange { @override bool get supportsRefundAddress => false; + @override + bool get supportsExtraId => false; + @override Future>> getAllCurrencies( bool fixedRate, @@ -380,6 +383,12 @@ class CypherGoatExchange extends Exchange { ExchangeExceptionType.generic, ); } + if (extraId?.isNotEmpty == true || refundExtraId.isNotEmpty) { + throw ExchangeException( + "CypherGoat does not support destination or refund memos", + ExchangeExceptionType.generic, + ); + } final response = await CypherGoatAPI.createSwap( coin1: from, diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart index 7868b9e286..b3d6faebd9 100644 --- a/lib/services/exchange/exchange.dart +++ b/lib/services/exchange/exchange.dart @@ -61,6 +61,9 @@ abstract class Exchange { bool get supportsRefundAddress => true; + /// Whether createTrade forwards a payout memo/destination tag to the API. + bool get supportsExtraId => true; + Future>> getAllCurrencies(bool fixedRate); // Future>> getPairedCurrencies( diff --git a/lib/services/exchange/trocador/trocador_exchange.dart b/lib/services/exchange/trocador/trocador_exchange.dart index 800f921816..9f9e5f9deb 100644 --- a/lib/services/exchange/trocador/trocador_exchange.dart +++ b/lib/services/exchange/trocador/trocador_exchange.dart @@ -77,9 +77,9 @@ class TrocadorExchange extends Exchange { toNetwork: onlySupportedNetwork, toAmount: amount.toString(), receivingAddress: addressTo, - receivingMemo: null, + receivingMemo: extraId?.isNotEmpty == true ? extraId : null, refundAddress: addressRefund, - refundMemo: null, + refundMemo: refundExtraId.isNotEmpty ? refundExtraId : null, exchangeProvider: estimate!.exchangeProvider!, isFixedRate: fixedRate, ) @@ -92,9 +92,9 @@ class TrocadorExchange extends Exchange { toNetwork: onlySupportedNetwork, fromAmount: amount.toString(), receivingAddress: addressTo, - receivingMemo: null, + receivingMemo: extraId?.isNotEmpty == true ? extraId : null, refundAddress: addressRefund, - refundMemo: null, + refundMemo: refundExtraId.isNotEmpty ? refundExtraId : null, exchangeProvider: estimate!.exchangeProvider!, isFixedRate: fixedRate, ); diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 8e0fa247f1..acd6c1e13c 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -24,6 +24,9 @@ class AddressUtils { 'recipient_name', 'tx_description', 'op_return', // For Rosen Bridge and other OP_RETURN protocols. + 'memo', // Stellar SEP-0007. + 'dt', // XRP destination tag. + 'destination_tag', // TODO [prio=med]: Add more recognized params for other coins. }; @@ -383,6 +386,20 @@ class PaymentUriData { scheme ?? "", // empty will just return null ); + String? get memo { + for (final value in [ + paymentId, + additionalParams["memo"], + additionalParams["dt"], + additionalParams["destination_tag"], + ]) { + if (value?.isNotEmpty == true) { + return value; + } + } + return null; + } + PaymentUriData({ required this.address, this.scheme, diff --git a/lib/utilities/extra_id_currency_support.dart b/lib/utilities/extra_id_currency_support.dart new file mode 100644 index 0000000000..6e5a78f8aa --- /dev/null +++ b/lib/utilities/extra_id_currency_support.dart @@ -0,0 +1,25 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2026 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +/// Currencies whose custodial deposits commonly require a destination +/// tag/memo ("extra ID") attached to the payout transaction. A payout sent +/// to such a platform without its tag lands unattributed. +abstract final class ExtraIdCurrencySupport { + static const Set _tickers = { + "atom", + "eos", + "hbar", + "ton", + "xlm", + "xrp", + }; + + static bool mayRequire(String ticker) => + _tickers.contains(ticker.trim().toLowerCase()); +} diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index 0f1d082c41..059eb7afd5 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -167,6 +167,39 @@ void main() { expect((xelis?.address, xelis?.amount), ("xel:$address", "1.25")); }); + test("parse payment URI memo and destination-tag aliases", () { + const aliases = { + "tx_payment_id": "payment-id", + "memo": "memo-value", + "dt": "12345", + "destination_tag": "destination-tag", + }; + + for (final entry in aliases.entries) { + final result = AddressUtils.parsePaymentUri( + "ripple:$firoAddress?${entry.key}=${entry.value}", + ); + expect(result?.memo, entry.value, reason: entry.key); + } + + final fallback = AddressUtils.parsePaymentUri( + "ripple:$firoAddress?memo=&dt=54321", + ); + expect(fallback?.memo, "54321"); + + // Memo and amount combine. + final combined = AddressUtils.parsePaymentUri( + "ripple:$firoAddress?amount=1.5&dt=12345", + ); + expect((combined?.amount, combined?.memo), ("1.5", "12345")); + + // A malformed amount rejects the whole URI; the memo does not survive. + expect( + AddressUtils.parsePaymentUri("ripple:$firoAddress?amount=1,5&dt=12345"), + isNull, + ); + }); + test("encode a list of (mnemonic) words/strings as a json object", () { final List list = [ "hello", diff --git a/test/models/exchange/incomplete_exchange_test.dart b/test/models/exchange/incomplete_exchange_test.dart index 798377a422..dd36e2b5ce 100644 --- a/test/models/exchange/incomplete_exchange_test.dart +++ b/test/models/exchange/incomplete_exchange_test.dart @@ -48,4 +48,24 @@ void main() { model.trade = _Trade("not a number"); expect(model.payInDecimal, isNull); }); + + test("stores destination and refund memo values", () { + final currency = _Currency(); + final model = IncompleteExchangeModel( + sendCurrency: currency, + receiveCurrency: currency, + rateInfo: "", + sendAmount: Decimal.one, + receiveAmount: Decimal.one, + rateType: ExchangeRateType.estimated, + reversed: false, + walletInitiated: false, + ); + + model.extraId = "destination memo"; + model.refundExtraId = "refund memo"; + + expect(model.extraId, "destination memo"); + expect(model.refundExtraId, "refund memo"); + }); } diff --git a/test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart b/test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart new file mode 100644 index 0000000000..2ea7c1d714 --- /dev/null +++ b/test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart @@ -0,0 +1,42 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/exchange/response_objects/estimate.dart'; +import 'package:stackwallet/services/exchange/cyphergoat/cyphergoat_exchange.dart'; + +void main() { + test("does not advertise extra ID support", () { + expect(CypherGoatExchange.instance.supportsExtraId, isFalse); + }); + + for (final values in [ + (destination: "12345", refund: ""), + (destination: null, refund: "refund memo"), + ]) { + test("rejects an unsupported " + "${values.destination == null ? "refund" : "destination"} memo " + "before a network call", () async { + final response = await CypherGoatExchange.instance.createTrade( + from: "btc", + to: "xrp", + fromNetwork: "btc", + toNetwork: "xrp", + fixedRate: false, + amount: Decimal.one, + addressTo: "destination", + extraId: values.destination, + addressRefund: "", + refundExtraId: values.refund, + estimate: Estimate( + estimatedAmount: Decimal.one, + fixedRate: false, + reversed: false, + exchangeProvider: "provider", + ), + reversed: false, + ); + + expect(response.value, isNull); + expect(response.exception.toString(), contains("does not support")); + }); + } +} diff --git a/test/utilities/extra_id_currency_support_test.dart b/test/utilities/extra_id_currency_support_test.dart new file mode 100644 index 0000000000..4fd296f07f --- /dev/null +++ b/test/utilities/extra_id_currency_support_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/extra_id_currency_support.dart'; + +void main() { + test("known tag currencies match case-insensitively", () { + for (final ticker in [ + "xrp", + "XRP", + " xlm ", + "Atom", + "eos", + "hbar", + "ton", + ]) { + expect(ExtraIdCurrencySupport.mayRequire(ticker), isTrue, reason: ticker); + } + + for (final ticker in ["btc", "eth", "xmr", "ltc", "doge", "bnb", ""]) { + expect( + ExtraIdCurrencySupport.mayRequire(ticker), + isFalse, + reason: ticker, + ); + } + }); +} From a8ca183a4b600c2fced7d3f8ba026bc0d5ccd351 Mon Sep 17 00:00:00 2001 From: Julian Date: Tue, 18 Aug 2026 10:37:00 -0600 Subject: [PATCH 45/52] fix custom fee ui state drift on mobile --- lib/pages/send_view/send_view.dart | 60 ++++---- lib/pages/send_view/sol_token_send_view.dart | 13 +- .../transaction_fee_selection_sheet.dart | 131 ++++-------------- lib/pages/send_view/token_send_view.dart | 33 ++--- .../wallet_view/sub_widgets/desktop_send.dart | 29 ++-- .../sub_widgets/desktop_send_fee_form.dart | 31 ++--- .../sub_widgets/desktop_token_send.dart | 2 +- lib/utilities/enums/fee_rate_type_enum.dart | 4 + test/utilities/fee_rate_type_enum_test.dart | 20 +++ 9 files changed, 121 insertions(+), 202 deletions(-) create mode 100644 test/utilities/fee_rate_type_enum_test.dart diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index e763eb9104..ffc8fbb4e0 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -967,9 +967,10 @@ class _SendViewState extends ConsumerState { final time = Future.delayed(const Duration(milliseconds: 2500)); Future txDataFuture; + final feeRateType = ref.read(feeRateTypeMobileStateProvider); + final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); if (isPaynymSend) { - final feeRate = ref.read(feeRateTypeMobileStateProvider); txDataFuture = (wallet as PaynymInterface).preparePaymentCodeSend( txData: TxData( paynymAccountLite: widget.accountLite!, @@ -981,8 +982,8 @@ class _SendViewState extends ConsumerState { addressType: AddressType.unknown, ), ], - satsPerVByte: isCustomFee.value ? customFeeRate : null, - feeRateType: feeRate, + satsPerVByte: satsPerVByte, + feeRateType: feeRateType, utxos: (wallet is CoinControlInterface && wallet is! SalviumWallet && @@ -1007,8 +1008,8 @@ class _SendViewState extends ConsumerState { isChange: false, ), ], - feeRateType: ref.read(feeRateTypeMobileStateProvider), - satsPerVByte: isCustomFee.value ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, @@ -1027,8 +1028,8 @@ class _SendViewState extends ConsumerState { )!, ), ], - feeRateType: ref.read(feeRateTypeMobileStateProvider), - satsPerVByte: isCustomFee.value ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, @@ -1080,8 +1081,8 @@ class _SendViewState extends ConsumerState { addressType: wallet.cryptoCurrency.getAddressType(_address!)!, ), ], - feeRateType: ref.read(feeRateTypeMobileStateProvider), - satsPerVByte: isCustomFee.value ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, // these will need to be mweb utxos // utxos: @@ -1105,8 +1106,8 @@ class _SendViewState extends ConsumerState { ), ], memo: memo, - feeRateType: ref.read(feeRateTypeMobileStateProvider), - satsPerVByte: isCustomFee.value ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, ethEIP1559Fee: ethFee, utxos: (wallet is CoinControlInterface && @@ -1261,7 +1262,6 @@ class _SendViewState extends ConsumerState { bool get isPaynymSend => widget.accountLite != null; - final isCustomFee = ValueNotifier(false); int customFeeRate = 1; EthEIP1559Fee? ethFee; @@ -1281,22 +1281,20 @@ class _SendViewState extends ConsumerState { ref.watch(pSendAmount)?.decimal ?? Decimal.zero) .toAmount(fractionDigits: coin.fractionDigits), - updateChosen: (String fee) { - if (fee == "custom") { - if (!isCustomFee.value) { - setState(() { - isCustomFee.value = true; - }); - } + updateChosen: (feeRateType, fee) { + if (feeRateType.isCustom) { return; } - _setCurrentFee(fee, true); + if (fee != null) { + _setCurrentFee(fee, true); + } setState(() { - _calculateFeesFuture = Future(() => fee); - if (isCustomFee.value) { - isCustomFee.value = false; + if (fee != null) { + _calculateFeesFuture = Future(() => fee); } + customFeeRate = 1; + ethFee = null; }); }, ), @@ -1318,12 +1316,6 @@ class _SendViewState extends ConsumerState { ref.refresh(feeSheetSessionCacheProvider); ref.refresh(pIsExchangeAddress); }); - isCustomFee.addListener(() { - if (!isCustomFee.value) { - customFeeRate = 1; - ethFee = null; - } - }); hasFees = coin is! Epiccash && coin is! NanoCurrency && coin is! Tezos; _currentFee = 0.toAmountAsRaw(fractionDigits: coin.fractionDigits); @@ -1433,13 +1425,13 @@ class _SendViewState extends ConsumerState { _cryptoFocus.dispose(); _baseFocus.dispose(); _memoFocus.dispose(); - isCustomFee.dispose(); super.dispose(); } @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + final isCustomFee = ref.watch(feeRateTypeMobileStateProvider).isCustom; final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); @@ -2683,7 +2675,7 @@ class _SendViewState extends ConsumerState { false, ); return Text( - isCustomFee.value + isCustomFee ? "" : "~${snapshot.data!}", style: @@ -2722,7 +2714,7 @@ class _SendViewState extends ConsumerState { ), ], ), - if (isCustomFee.value && !isEth) + if (isCustomFee && !isEth) Padding( padding: const EdgeInsets.only( bottom: 12, @@ -2735,9 +2727,9 @@ class _SendViewState extends ConsumerState { }, ), ), - if (isCustomFee.value && isEth) + if (isCustomFee && isEth) const SizedBox(height: 12), - if (isCustomFee.value && isEth) + if (isCustomFee && isEth) EthFeeForm( minGasLimit: kEthereumMinGasLimit, stateChanged: (fee) => ethFee = fee, diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart index f81015b8d9..36b027f73e 100644 --- a/lib/pages/send_view/sol_token_send_view.dart +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -1287,12 +1287,13 @@ class _SolTokenSendViewState extends ConsumerState { tokenWallet .tokenDecimals, ), - updateChosen: (String fee) { - setState(() { - _calculateFeesFuture = Future( - () => fee, - ); - }); + updateChosen: (_, fee) { + if (fee != null) { + setState(() { + _calculateFeesFuture = + Future(() => fee); + }); + } }, ), ); diff --git a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart index 387138d8cf..333606fb23 100644 --- a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart +++ b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart @@ -58,7 +58,7 @@ class TransactionFeeSelectionSheet extends ConsumerStatefulWidget { final String walletId; final Amount amount; - final Function updateChosen; + final void Function(FeeRateType feeRateType, String? fee) updateChosen; final bool isToken; @override @@ -80,6 +80,16 @@ class _TransactionFeeSelectionSheetState "Calculating...", ]; + void _selectFeeRate(FeeRateType feeRateType, CryptoCurrency coin) { + ref.read(feeRateTypeMobileStateProvider.state).state = feeRateType; + widget.updateChosen( + feeRateType, + feeRateType.isCustom ? null : getAmount(feeRateType, coin), + ); + + Navigator.of(context).pop(); + } + Amount _addFiroOpReturnFee({ required Amount fee, required BigInt feeRate, @@ -349,23 +359,7 @@ class _TransactionFeeSelectionSheetState ), const SizedBox(height: 16), GestureDetector( - onTap: () { - final state = ref - .read(feeRateTypeMobileStateProvider.state) - .state; - if (state != FeeRateType.fast) { - ref.read(feeRateTypeMobileStateProvider.state).state = - FeeRateType.fast; - } - final String? fee = getAmount( - FeeRateType.fast, - wallet.info.coin, - ); - if (fee != null) { - widget.updateChosen(fee); - } - Navigator.of(context).pop(); - }, + onTap: () => _selectFeeRate(FeeRateType.fast, coin), child: Container( color: Colors.transparent, child: Row( @@ -387,17 +381,8 @@ class _TransactionFeeSelectionSheetState feeRateTypeMobileStateProvider.state, ) .state, - onChanged: (x) { - ref - .read( - feeRateTypeMobileStateProvider - .state, - ) - .state = - FeeRateType.fast; - - Navigator.of(context).pop(); - }, + onChanged: (_) => + _selectFeeRate(FeeRateType.fast, coin), ), ), ], @@ -486,23 +471,7 @@ class _TransactionFeeSelectionSheetState ), const SizedBox(height: 16), GestureDetector( - onTap: () { - final state = ref - .read(feeRateTypeMobileStateProvider.state) - .state; - if (state != FeeRateType.average) { - ref.read(feeRateTypeMobileStateProvider.state).state = - FeeRateType.average; - } - final String? fee = getAmount( - FeeRateType.average, - coin, - ); - if (fee != null) { - widget.updateChosen(fee); - } - Navigator.of(context).pop(); - }, + onTap: () => _selectFeeRate(FeeRateType.average, coin), child: Container( color: Colors.transparent, child: Row( @@ -523,16 +492,10 @@ class _TransactionFeeSelectionSheetState feeRateTypeMobileStateProvider.state, ) .state, - onChanged: (x) { - ref - .read( - feeRateTypeMobileStateProvider - .state, - ) - .state = - FeeRateType.average; - Navigator.of(context).pop(); - }, + onChanged: (_) => _selectFeeRate( + FeeRateType.average, + coin, + ), ), ), ], @@ -621,20 +584,7 @@ class _TransactionFeeSelectionSheetState ), const SizedBox(height: 16), GestureDetector( - onTap: () { - final state = ref - .read(feeRateTypeMobileStateProvider.state) - .state; - if (state != FeeRateType.slow) { - ref.read(feeRateTypeMobileStateProvider.state).state = - FeeRateType.slow; - } - final String? fee = getAmount(FeeRateType.slow, coin); - if (fee != null) { - widget.updateChosen(fee); - } - Navigator.of(context).pop(); - }, + onTap: () => _selectFeeRate(FeeRateType.slow, coin), child: Container( color: Colors.transparent, child: Row( @@ -655,16 +605,8 @@ class _TransactionFeeSelectionSheetState feeRateTypeMobileStateProvider.state, ) .state, - onChanged: (x) { - ref - .read( - feeRateTypeMobileStateProvider - .state, - ) - .state = - FeeRateType.slow; - Navigator.of(context).pop(); - }, + onChanged: (_) => + _selectFeeRate(FeeRateType.slow, coin), ), ), ], @@ -754,20 +696,7 @@ class _TransactionFeeSelectionSheetState const SizedBox(height: 24), if (wallet is ElectrumXInterface || coin is Ethereum) GestureDetector( - onTap: () { - final state = ref - .read(feeRateTypeMobileStateProvider.state) - .state; - if (state != FeeRateType.custom) { - ref - .read(feeRateTypeMobileStateProvider.state) - .state = - FeeRateType.custom; - } - widget.updateChosen("custom"); - - Navigator.of(context).pop(); - }, + onTap: () => _selectFeeRate(FeeRateType.custom, coin), child: Container( color: Colors.transparent, child: Row( @@ -789,16 +718,10 @@ class _TransactionFeeSelectionSheetState .state, ) .state, - onChanged: (x) { - ref - .read( - feeRateTypeMobileStateProvider - .state, - ) - .state = - FeeRateType.custom; - Navigator.of(context).pop(); - }, + onChanged: (_) => _selectFeeRate( + FeeRateType.custom, + coin, + ), ), ), ], diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index 3d30fc5f6a..9277117591 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -118,8 +118,6 @@ class _TokenSendViewState extends ConsumerState { late Future _calculateFeesFuture; String cachedFees = ""; - final isCustomFee = ValueNotifier(false); - EthEIP1559Fee? ethFee; void _onTokenSendViewPasteAddressFieldButtonPressed() async { @@ -586,9 +584,6 @@ class _TokenSendViewState extends ConsumerState { @override void initState() { ref.refresh(feeSheetSessionCacheProvider); - isCustomFee.addListener(() { - if (!isCustomFee.value) ethFee = null; - }); _calculateFeesFuture = calculateFees(); _data = widget.autoFillData; @@ -637,13 +632,13 @@ class _TokenSendViewState extends ConsumerState { _addressFocusNode.dispose(); _cryptoFocus.dispose(); _baseFocus.dispose(); - isCustomFee.dispose(); super.dispose(); } @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + final isCustomFee = ref.watch(feeRateTypeMobileStateProvider).isCustom; final String locale = ref.watch( localeServiceChangeNotifierProvider.select((value) => value.locale), ); @@ -1157,7 +1152,7 @@ class _TokenSendViewState extends ConsumerState { ), const SizedBox(height: 12), Text( - "Transaction fee ${isCustomFee.value ? "" : "(max)"}", + "Transaction fee ${isCustomFee ? "" : "(max)"}", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), @@ -1210,23 +1205,17 @@ class _TokenSendViewState extends ConsumerState { tokenContract .decimals, ), - updateChosen: (String fee) { - if (fee == "custom") { - if (!isCustomFee.value) { - setState(() { - isCustomFee.value = true; - }); - } + updateChosen: (feeRateType, fee) { + if (feeRateType.isCustom) { return; } setState(() { - _calculateFeesFuture = Future( - () => fee, - ); - if (isCustomFee.value) { - isCustomFee.value = false; + if (fee != null) { + _calculateFeesFuture = + Future(() => fee); } + ethFee = null; }); }, ), @@ -1258,7 +1247,7 @@ class _TokenSendViewState extends ConsumerState { ConnectionState.done && snapshot.hasData) { return Text( - isCustomFee.value + isCustomFee ? "" : "~${snapshot.data!}", style: @@ -1302,8 +1291,8 @@ class _TokenSendViewState extends ConsumerState { ), ], ), - if (isCustomFee.value) const SizedBox(height: 12), - if (isCustomFee.value) + if (isCustomFee) const SizedBox(height: 12), + if (isCustomFee) EthFeeForm( minGasLimit: kEthereumTokenMinGasLimit, stateChanged: (value) => ethFee = value, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 5060d2bdb0..58d6826f30 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -44,6 +44,7 @@ import '../../../../utilities/amount/amount_unit.dart'; import '../../../../utilities/assets.dart'; import '../../../../utilities/clipboard_interface.dart'; import '../../../../utilities/constants.dart'; +import '../../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/prefs.dart'; import '../../../../utilities/show_loading.dart'; @@ -140,7 +141,6 @@ class _DesktopSendState extends ConsumerState { bool get isPaynymSend => widget.accountLite != null; - bool isCustomFee = false; int customFeeRate = 1; EthEIP1559Fee? ethFee; @@ -580,11 +580,11 @@ class _DesktopSendState extends ConsumerState { TxData txData; Future txDataFuture; + final feeRateType = ref.read(feeRateTypeDesktopStateProvider); + final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate); if (isPaynymSend) { final paynymWallet = wallet as PaynymInterface; - - final feeRate = ref.read(feeRateTypeDesktopStateProvider); txDataFuture = paynymWallet.preparePaymentCodeSend( txData: TxData( paynymAccountLite: widget.accountLite!, @@ -596,8 +596,8 @@ class _DesktopSendState extends ConsumerState { addressType: AddressType.unknown, ), ], - satsPerVByte: isCustomFee ? customFeeRate : null, - feeRateType: feeRate, + satsPerVByte: satsPerVByte, + feeRateType: feeRateType, utxos: (wallet is CoinControlInterface && wallet is! SalviumWallet && @@ -621,8 +621,8 @@ class _DesktopSendState extends ConsumerState { isChange: false, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), - satsPerVByte: isCustomFee ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, utxos: (coinControlEnabled && ref.read(pDesktopUseUTXOs).isNotEmpty) @@ -643,8 +643,8 @@ class _DesktopSendState extends ConsumerState { )!, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), - satsPerVByte: isCustomFee ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, utxos: (coinControlEnabled && ref.read(pDesktopUseUTXOs).isNotEmpty) @@ -698,8 +698,8 @@ class _DesktopSendState extends ConsumerState { addressType: wallet.cryptoCurrency.getAddressType(_address!)!, ), ], - feeRateType: ref.read(feeRateTypeDesktopStateProvider), - satsPerVByte: isCustomFee ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, // these will need to be mweb utxos // utxos: // (wallet is CoinControlInterface && @@ -722,8 +722,8 @@ class _DesktopSendState extends ConsumerState { ), ], memo: memo, - feeRateType: ref.read(feeRateTypeDesktopStateProvider), - satsPerVByte: isCustomFee ? customFeeRate : null, + feeRateType: feeRateType, + satsPerVByte: satsPerVByte, nonce: wallet.cryptoCurrency is Ethereum ? int.tryParse(nonceController.text) : null, @@ -2127,8 +2127,7 @@ class _DesktopSendState extends ConsumerState { walletId: walletId, isToken: false, onCustomFeeSliderChanged: (value) => customFeeRate = value, - onCustomFeeOptionChanged: (value) { - isCustomFee = value; + onCustomFeeOptionChanged: () { customFeeRate = 1; ethFee = null; }, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart index b1e2b468e1..eb40e662c8 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../../providers/providers.dart'; +import '../../../../providers/ui/fee_rate_type_state_provider.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../providers/wallet/desktop_fee_providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; @@ -39,7 +40,7 @@ class DesktopSendFeeForm extends ConsumerStatefulWidget { final String walletId; final bool isToken; final void Function(int) onCustomFeeSliderChanged; - final void Function(bool) onCustomFeeOptionChanged; + final VoidCallback onCustomFeeOptionChanged; final void Function(EthEIP1559Fee)? onCustomEip1559FeeOptionChanged; @override @@ -58,15 +59,6 @@ class _DesktopSendFeeFormState extends ConsumerState { bool get isEth => cryptoCurrency is Ethereum; - bool _isCustomFeeValue = false; - bool get _isCustomFee => _isCustomFeeValue; - set _isCustomFee(bool newValue) { - if (_isCustomFeeValue != newValue) { - _isCustomFeeValue = newValue; - widget.onCustomFeeOptionChanged.call(_isCustomFeeValue); - } - } - (FeeRateType, String?, String?)? feeSelectionResult; Amount _addFiroOpReturnFee({ @@ -104,6 +96,7 @@ class _DesktopSendFeeFormState extends ConsumerState { @override Widget build(BuildContext context) { + final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; final canEditFees = isEth || cryptoCurrency is Solana || @@ -124,6 +117,7 @@ class _DesktopSendFeeFormState extends ConsumerState { CustomTextButton( text: "Edit", onTap: () async { + final wasCustomFee = isCustomFee; feeSelectionResult = await showDialog<(FeeRateType, String?, String?)?>( context: context, @@ -134,12 +128,9 @@ class _DesktopSendFeeFormState extends ConsumerState { ); if (feeSelectionResult != null) { - if (_isCustomFee && - feeSelectionResult!.$1 != FeeRateType.custom) { - _isCustomFee = false; - } else if (!_isCustomFee && - feeSelectionResult!.$1 == FeeRateType.custom) { - _isCustomFee = true; + final selectedIsCustomFee = feeSelectionResult!.$1.isCustom; + if (wasCustomFee != selectedIsCustomFee) { + widget.onCustomFeeOptionChanged.call(); } } @@ -150,7 +141,7 @@ class _DesktopSendFeeFormState extends ConsumerState { ), child: Text( "Transaction fee" - "${_isCustomFee ? "" : " (${isEth ? "max" : "estimated"})"}", + "${isCustomFee ? "" : " (${isEth ? "max" : "estimated"})"}", style: STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of( context, @@ -160,7 +151,7 @@ class _DesktopSendFeeFormState extends ConsumerState { ), ), const SizedBox(height: 10), - if (!_isCustomFee) + if (!isCustomFee) Padding( padding: const EdgeInsets.all(10), child: (feeSelectionResult?.$2 == null) @@ -340,7 +331,7 @@ class _DesktopSendFeeFormState extends ConsumerState { ], ), ), - if (_isCustomFee && isEth) + if (isCustomFee && isEth) EthFeeForm( minGasLimit: widget.isToken ? kEthereumTokenMinGasLimit @@ -348,7 +339,7 @@ class _DesktopSendFeeFormState extends ConsumerState { stateChanged: (value) => widget.onCustomEip1559FeeOptionChanged?.call(value), ), - if (_isCustomFee && !isEth) + if (isCustomFee && !isEth) Padding( padding: const EdgeInsets.only(bottom: 12, top: 16), child: FeeSlider( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart index 014f8d80fd..bd77a9be1b 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart @@ -1038,7 +1038,7 @@ class _DesktopTokenSendState extends ConsumerState { walletId: walletId, isToken: true, onCustomFeeSliderChanged: (value) => {}, - onCustomFeeOptionChanged: (value) { + onCustomFeeOptionChanged: () { ethFee = null; }, onCustomEip1559FeeOptionChanged: (value) => ethFee = value, diff --git a/lib/utilities/enums/fee_rate_type_enum.dart b/lib/utilities/enums/fee_rate_type_enum.dart index 0ad32f1f69..a4348351dd 100644 --- a/lib/utilities/enums/fee_rate_type_enum.dart +++ b/lib/utilities/enums/fee_rate_type_enum.dart @@ -11,6 +11,10 @@ enum FeeRateType { fast, average, slow, custom } extension FeeRateTypeExt on FeeRateType { + bool get isCustom => this == FeeRateType.custom; + + int? customSatsPerVByte(int satsPerVByte) => isCustom ? satsPerVByte : null; + String get prettyName { switch (this) { case FeeRateType.fast: diff --git a/test/utilities/fee_rate_type_enum_test.dart b/test/utilities/fee_rate_type_enum_test.dart new file mode 100644 index 0000000000..424736a353 --- /dev/null +++ b/test/utilities/fee_rate_type_enum_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; + +void main() { + group("FeeRateTypeExt.customSatsPerVByte", () { + test("returns the selected rate for a custom fee", () { + expect(FeeRateType.custom.customSatsPerVByte(7), 7); + }); + + test("returns null for preset fees", () { + for (final feeRateType in [ + FeeRateType.fast, + FeeRateType.average, + FeeRateType.slow, + ]) { + expect(feeRateType.customSatsPerVByte(7), isNull); + } + }); + }); +} From d00afa713930aa3901cc108dfafacac6e71888f7 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 19 Aug 2026 09:22:51 -0600 Subject: [PATCH 46/52] refactor electrumx based coin transaction building and fee calculation --- lib/wallets/models/tx_data.dart | 6 + .../electrum_fee_planner.dart | 100 ++++ .../electrumx_interface.dart | 526 +++++------------- test/wallets/electrum_fee_planner_test.dart | 187 +++++++ test/wallets/tx_data_test.dart | 18 + 5 files changed, 465 insertions(+), 372 deletions(-) create mode 100644 lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart create mode 100644 test/wallets/electrum_fee_planner_test.dart create mode 100644 test/wallets/tx_data_test.dart diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 744d848107..1c8f81c931 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -105,6 +105,7 @@ class TxData { final TransactionV2? tempTx; final bool ignoreCachedBalanceChecks; + final bool subtractFeeFromAmount; // Namecoin Name related final NameOpState? opNameState; @@ -150,6 +151,7 @@ class TxData { this.usedSparkCoins, this.tempTx, this.ignoreCachedBalanceChecks = false, + this.subtractFeeFromAmount = false, this.opNameState, this.sparkNameInfo, this.vExtraData, @@ -298,6 +300,7 @@ class TxData { List? usedSparkCoins, TransactionV2? tempTx, bool? ignoreCachedBalanceChecks, + bool? subtractFeeFromAmount, NameOpState? opNameState, ({ String additionalInfo, @@ -346,6 +349,8 @@ class TxData { tempTx: tempTx ?? this.tempTx, ignoreCachedBalanceChecks: ignoreCachedBalanceChecks ?? this.ignoreCachedBalanceChecks, + subtractFeeFromAmount: + subtractFeeFromAmount ?? this.subtractFeeFromAmount, opNameState: opNameState ?? this.opNameState, sparkNameInfo: sparkNameInfo ?? this.sparkNameInfo, vExtraData: vExtraData ?? this.vExtraData, @@ -390,6 +395,7 @@ class TxData { 'otherData: $otherData, ' 'tempTx: $tempTx, ' 'ignoreCachedBalanceChecks: $ignoreCachedBalanceChecks, ' + 'subtractFeeFromAmount: $subtractFeeFromAmount, ' 'opNameState: $opNameState, ' 'sparkNameInfo: $sparkNameInfo, ' 'vExtraData: ${vExtraData?.toHex}, ' diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart new file mode 100644 index 0000000000..55f38dac26 --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart @@ -0,0 +1,100 @@ +enum ElectrumFeeMode { fixedAmount, subtractFeeFromAmount, sweep } + +final class ElectrumFeeInsufficientFunds implements Exception { + /// The fee the rejected transaction needed to pay. Callers retrying with + /// more funds should select inputs covering at least the recipient amount + /// plus this fee. + final BigInt requiredFee; + + const ElectrumFeeInsufficientFunds({required this.requiredFee}); +} + +typedef ElectrumFeeTransactionBuilder = + Future<({T transaction, int vSize})> Function({ + required BigInt recipientAmount, + BigInt? changeAmount, + }); + +final class ElectrumFeeResult { + final T transaction; + final BigInt fee; + + const ElectrumFeeResult({required this.transaction, required this.fee}); +} + +/// The fee paid is always at least [minimumFeeAmount] (when non-null), the +/// rate-based fee for the measured vSize, and one sat per vByte, whichever is +/// greatest. [minimumFeeAmount] is a floor, not an exact override. +Future> planElectrumFee({ + required ElectrumFeeMode mode, + required BigInt inputTotal, + required BigInt recipientAmount, + required BigInt dustLimit, + required int? satsPerVByte, + required BigInt feeRatePerKB, + required BigInt? minimumFeeAmount, + required ElectrumFeeTransactionBuilder build, +}) async { + if (mode != ElectrumFeeMode.sweep && recipientAmount < dustLimit) { + throw Exception( + "Recipient amount ($recipientAmount) is below dust limit ($dustLimit)", + ); + } + + BigInt requiredFeeFor(int vSize) { + final BigInt rateFee; + if (satsPerVByte != null) { + rateFee = BigInt.from(satsPerVByte * vSize); + } else { + final kb = BigInt.from(1000); + rateFee = (feeRatePerKB * BigInt.from(vSize) + kb - BigInt.one) ~/ kb; + } + + final vSizeFloor = BigInt.from(vSize); + final minimumFloor = minimumFeeAmount ?? BigInt.zero; + final feeFloor = rateFee > minimumFloor ? rateFee : minimumFloor; + return feeFloor > vSizeFloor ? feeFloor : vSizeFloor; + } + + final selectedSurplus = inputTotal - recipientAmount; + final subDustSurplus = + mode == .subtractFeeFromAmount && + selectedSurplus > BigInt.zero && + selectedSurplus < dustLimit + ? selectedSurplus + : BigInt.zero; + BigInt fee = -subDustSurplus; + while (true) { + final amountToSend = switch (mode) { + .fixedAmount => recipientAmount, + .subtractFeeFromAmount => recipientAmount - fee, + .sweep => inputTotal - fee, + }; + if (amountToSend < dustLimit) { + throw Exception("Estimated fee ($fee sats) leaves no spendable amount!"); + } + + final possibleChange = switch (mode) { + .fixedAmount => inputTotal - recipientAmount - fee, + .subtractFeeFromAmount => inputTotal - recipientAmount, + .sweep => null, + }; + final changeAmount = possibleChange != null && possibleChange >= dustLimit + ? possibleChange + : null; + + final built = await build( + recipientAmount: amountToSend, + changeAmount: changeAmount, + ); + final feePaid = inputTotal - amountToSend - (changeAmount ?? BigInt.zero); + final requiredFee = requiredFeeFor(built.vSize); + if (feePaid >= requiredFee) { + return ElectrumFeeResult(transaction: built.transaction, fee: feePaid); + } + if (mode == ElectrumFeeMode.fixedAmount && possibleChange! < dustLimit) { + throw ElectrumFeeInsufficientFunds(requiredFee: requiredFee); + } + fee += requiredFee - feePaid; + } +} diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 100aa9f9fe..9a2f2f35cb 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -34,6 +34,7 @@ import '../impl/firo_wallet.dart'; import '../impl/peercoin_wallet.dart'; import '../intermediate/bip39_hd_wallet.dart'; import 'cpfp_interface.dart'; +import 'electrum_fee_planner.dart'; import 'mweb_interface.dart'; import 'paynym_interface.dart'; import 'rbf_interface.dart'; @@ -123,14 +124,18 @@ mixin ElectrumXInterface required bool coinControl, required bool isSendAll, required bool isSendAllCoinControlUtxos, - int additionalOutputs = 0, List? utxos, - BigInt? overrideFeeAmount, + BigInt? minimumFeeAmount, }) async { Logging.instance.d("Starting coinSelection ----------"); // TODO: multiple recipients one day assert(txData.recipients!.length == 1); + if (txData.recipients!.length != 1) { + throw Exception( + "Transactions with more than one recipient are not supported", + ); + } if (coinControl && utxos == null) { throw Exception("Coin control used where utxos is null!"); @@ -199,7 +204,8 @@ mixin ElectrumXInterface throw Exception("Insufficient balance"); } else if (spendableSatoshiValue == satoshiAmountToSend && !isSendAll && - !isSendAllCoinControlUtxos) { + !isSendAllCoinControlUtxos && + !txData.subtractFeeFromAmount) { throw Exception("Insufficient balance to pay transaction fee"); } @@ -224,411 +230,193 @@ mixin ElectrumXInterface Logging.instance.d("satoshiAmountToSend: $satoshiAmountToSend"); // Use coinlib CoinSelection algorithms except for - // "coinControl", "SendAll", "MWEB", "overrideFeeAmount", + // "coinControl", "SendAll", "MWEB", "minimumFeeAmount", + // and "subtractFeeFromAmount" // because they do not need a selection or // do not meet the requirements for the algorithms final bool useOptimalSelection = !coinControl && !isSendAll && !isSendAllCoinControlUtxos && - overrideFeeAmount == null && + !txData.subtractFeeFromAmount && + minimumFeeAmount == null && txData.type != TxType.mweb && txData.type != TxType.mwebPegOut && txData.type != TxType.mwebPegIn; if (useOptimalSelection) { - return await _optimalCoinSelection( - txData: txData, - spendableOutputs: spendableOutputs.whereType().toList(), - recipientAddress: recipientAddress, - satoshiAmountToSend: satoshiAmountToSend, - satsPerVByte: satsPerVByte, - feeRatePerKB: selectedTxFeeRate, - changeAddress: await changeAddress(), - ); + try { + return await _optimalCoinSelection( + txData: txData, + spendableOutputs: spendableOutputs + .whereType() + .toList(), + recipientAddress: recipientAddress, + satoshiAmountToSend: satoshiAmountToSend, + satsPerVByte: satsPerVByte, + feeRatePerKB: selectedTxFeeRate, + changeAddress: await changeAddress(), + ); + } on ElectrumFeeInsufficientFunds catch (e) { + Logging.instance.w( + "Optimal coin selection could not cover the measured transaction " + "fee (${e.requiredFee} sats). Falling back to previous/old input " + "selection.", + ); + } } BigInt satoshisBeingUsed = BigInt.zero; int inputsBeingConsumed = 0; final List utxoObjectsToUse = []; + final List inputsWithKeys = []; - if (!coinControl) { - for ( - var i = 0; - satoshisBeingUsed < satoshiAmountToSend && i < spendableOutputs.length; - i++ - ) { - utxoObjectsToUse.add(spendableOutputs[i]); - satoshisBeingUsed += spendableOutputs[i].value; - inputsBeingConsumed += 1; - } - for ( - int i = 0; - i < additionalOutputs && inputsBeingConsumed < spendableOutputs.length; - i++ - ) { - utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); - satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; - inputsBeingConsumed += 1; + /// Consume spendable outputs until [target] is covered (all of them when + /// using coin control), gathering signing data for newly added inputs. + Future consumeInputsFor(BigInt target) async { + final start = inputsBeingConsumed; + if (coinControl) { + satoshisBeingUsed = spendableSatoshiValue; + utxoObjectsToUse.addAll(spendableOutputs); + inputsBeingConsumed = spendableOutputs.length; + } else { + while (satoshisBeingUsed < target && + inputsBeingConsumed < spendableOutputs.length) { + utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); + satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; + inputsBeingConsumed += 1; + } } - } else { - satoshisBeingUsed = spendableSatoshiValue; - utxoObjectsToUse.addAll(spendableOutputs); - inputsBeingConsumed = spendableOutputs.length; - } - - Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed"); - Logging.instance.d("inputsBeingConsumed: $inputsBeingConsumed"); - Logging.instance.d('utxoObjectsToUse: $utxoObjectsToUse'); + inputsWithKeys.addAll( + await addSigningKeys(utxoObjectsToUse.sublist(start)), + ); - // numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray - final List recipientsArray = [recipientAddress]; - final List recipientsAmtArray = [satoshiAmountToSend]; + Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed"); + Logging.instance.d("inputsBeingConsumed: $inputsBeingConsumed"); + Logging.instance.d('utxoObjectsToUse: $utxoObjectsToUse'); + } - // gather required signing data - final inputsWithKeys = await addSigningKeys(utxoObjectsToUse); + await consumeInputsFor(satoshiAmountToSend); if (isSendAll || isSendAllCoinControlUtxos) { - if ((overrideFeeAmount ?? BigInt.zero) + satoshiAmountToSend != + if ((minimumFeeAmount ?? BigInt.zero) + satoshiAmountToSend != satoshisBeingUsed) { Logging.instance.d("txData.type: ${txData.type}"); Logging.instance.d("isSendAll: $isSendAll"); Logging.instance.d( "isSendAllCoinControlUtxos: $isSendAllCoinControlUtxos", ); - Logging.instance.d("overrideFeeAmount: $overrideFeeAmount"); + Logging.instance.d("minimumFeeAmount: $minimumFeeAmount"); Logging.instance.d("satoshiAmountToSend: $satoshiAmountToSend"); Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed"); // hack check if (!(txData.type == TxType.mwebPegIn || - (txData.type.isMweb() && overrideFeeAmount != null))) { + (txData.type.isMweb() && minimumFeeAmount != null))) { throw Exception( "Something happened that should never actually happen. " "Please report this error to the developers.", ); } } - return await _sendAllBuilder( + return await _buildTransactionPayingFee( txData: txData, - recipientAddress: recipientAddress, - satoshisBeingUsed: satoshisBeingUsed, inputsWithKeys: inputsWithKeys, + recipientAddress: recipientAddress, + recipientAmount: satoshiAmountToSend, + inputTotal: satoshisBeingUsed, satsPerVByte: satsPerVByte, feeRatePerKB: selectedTxFeeRate, - overrideFeeAmount: overrideFeeAmount, + minimumFeeAmount: minimumFeeAmount, + isSweep: true, + nextChangeAddress: () async => (await changeAddress()).value, ); } - final int vSizeForOneOutput; - try { - vSizeForOneOutput = (await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshisBeingUsed - BigInt.one], - ), - ), - )).vSize!; - } catch (e, s) { - Logging.instance.e("vSizeForOneOutput: $e", error: e, stackTrace: s); - rethrow; - } - - final int vSizeForTwoOutPuts; - - BigInt maxBI(BigInt a, BigInt b) => a > b ? a : b; - - try { - vSizeForTwoOutPuts = (await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress, (await changeAddress()).value], - [ - satoshiAmountToSend, - maxBI( - BigInt.zero, - satoshisBeingUsed - (satoshiAmountToSend + BigInt.one), - ), - ], - ), - ), - )).vSize!; - } catch (e, s) { - Logging.instance.e("vSizeForTwoOutPuts: $e", error: e, stackTrace: s); - rethrow; - } - - // Assume 1 output, only for recipient and no change - final feeForOneOutput = - overrideFeeAmount ?? - BigInt.from( - satsPerVByte != null - ? (satsPerVByte * vSizeForOneOutput) - : estimateTxFee( - vSize: vSizeForOneOutput, - feeRatePerKB: selectedTxFeeRate, - ), - ); - // Assume 2 outputs, one for recipient and one for change - final feeForTwoOutputs = - overrideFeeAmount ?? - BigInt.from( - satsPerVByte != null - ? (satsPerVByte * vSizeForTwoOutPuts) - : estimateTxFee( - vSize: vSizeForTwoOutPuts, - feeRatePerKB: selectedTxFeeRate, - ), - ); - - Logging.instance.d("feeForTwoOutputs: $feeForTwoOutputs"); - Logging.instance.d("feeForOneOutput: $feeForOneOutput"); - - final difference = satoshisBeingUsed - satoshiAmountToSend; - - Future singleOutputTxn() async { - Logging.instance.d('Input size: $satoshisBeingUsed'); - Logging.instance.d('Recipient output size: $satoshiAmountToSend'); - Logging.instance.d('Fee being paid: $difference sats'); - Logging.instance.d('Estimated fee: $feeForOneOutput'); - final txnData = await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - ), - ); - return txnData.copyWith( - fee: Amount( - rawValue: feeForOneOutput, - fractionDigits: cryptoCurrency.fractionDigits, - ), - usedUTXOs: inputsWithKeys, - ); - } - - // no change output required - if (difference == feeForOneOutput) { - Logging.instance.d('1 output in tx'); - return await singleOutputTxn(); - } else if (difference < feeForOneOutput) { - Logging.instance.w( - 'Cannot pay tx fee - checking for more outputs and trying again', - ); - // try adding more outputs - if (spendableOutputs.length > inputsBeingConsumed) { - return coinSelection( + while (true) { + try { + return await _buildTransactionPayingFee( txData: txData, - isSendAll: isSendAll, - additionalOutputs: additionalOutputs + 1, - utxos: utxos, - coinControl: coinControl, - isSendAllCoinControlUtxos: isSendAllCoinControlUtxos, - overrideFeeAmount: overrideFeeAmount, + inputsWithKeys: inputsWithKeys, + recipientAddress: recipientAddress, + recipientAmount: satoshiAmountToSend, + inputTotal: satoshisBeingUsed, + satsPerVByte: satsPerVByte, + feeRatePerKB: selectedTxFeeRate, + minimumFeeAmount: minimumFeeAmount, + isSweep: false, + nextChangeAddress: () async { + if (!(txData.type == TxType.mweb || + txData.type == TxType.mwebPegOut)) { + await checkChangeAddressForTransactions(); + } + return (await changeAddress()).value; + }, ); - } - throw Exception("Insufficient balance to pay transaction fee"); - } else { - if (difference > (feeForOneOutput + cryptoCurrency.dustLimit.raw)) { - final changeOutputSize = difference - feeForTwoOutputs; - // check if possible to add the change output - if (changeOutputSize > cryptoCurrency.dustLimit.raw && - difference - changeOutputSize == feeForTwoOutputs) { - if (!(txData.type == TxType.mweb || - txData.type == TxType.mwebPegOut)) { - // generate new change address if current change address has been used - await checkChangeAddressForTransactions(); - } - final newChangeAddress = await changeAddress(); - - BigInt feeBeingPaid = difference - changeOutputSize; - - // add change output - recipientsArray.add(newChangeAddress.value); - recipientsAmtArray.add(changeOutputSize); - - Logging.instance.d('2 outputs in tx'); - Logging.instance.d('Input size: $satoshisBeingUsed'); - Logging.instance.d('Recipient output size: $satoshiAmountToSend'); - Logging.instance.d('Change Output Size: $changeOutputSize'); - Logging.instance.d('Difference (fee being paid): $feeBeingPaid sats'); - Logging.instance.d('Estimated fee: $feeForTwoOutputs'); - - TxData txnData = await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - usedUTXOs: inputsWithKeys, - ), - ); - - // make sure minimum fee is accurate if that is being used - if (BigInt.from(txnData.vSize!) - feeBeingPaid == BigInt.one) { - final changeOutputSize = difference - BigInt.from(txnData.vSize!); - feeBeingPaid = difference - changeOutputSize; - recipientsAmtArray.removeLast(); - recipientsAmtArray.add(changeOutputSize); - - Logging.instance.d('Adjusted Input size: $satoshisBeingUsed'); - Logging.instance.d( - 'Adjusted Recipient output size: $satoshiAmountToSend', - ); - Logging.instance.d( - 'Adjusted Change Output Size: $changeOutputSize', - ); - Logging.instance.d( - 'Adjusted Difference (fee being paid): $feeBeingPaid sats', - ); - Logging.instance.d('Adjusted Estimated fee: $feeForTwoOutputs'); - - txnData = await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - usedUTXOs: inputsWithKeys, - ), - ); - } - - return txnData.copyWith( - fee: Amount( - rawValue: feeBeingPaid, - fractionDigits: cryptoCurrency.fractionDigits, - ), - usedUTXOs: inputsWithKeys, - ); - } else { - // Something went wrong here. It either overshot or undershot the estimated fee amount or the changeOutputSize - // is smaller than or equal to cryptoCurrency.dustLimit. Revert to single output transaction. - Logging.instance.d('Reverting to 1 output in tx'); - - return await singleOutputTxn(); + } on ElectrumFeeInsufficientFunds catch (e) { + if (coinControl || inputsBeingConsumed >= spendableOutputs.length) { + throw Exception("Insufficient balance to pay transaction fee"); } + Logging.instance.w( + "Cannot pay tx fee (${e.requiredFee} sats) -" + " selecting more inputs and trying again", + ); + // Select enough to also cover the fee the last attempt needed. The + // added inputs grow the transaction, so the next attempt may still + // fall short and raise the target again until it converges. + await consumeInputsFor(satoshiAmountToSend + e.requiredFee); } } - - return txData; } - Future _sendAllBuilder({ + Future _buildTransactionPayingFee({ required TxData txData, - required String recipientAddress, - required BigInt satoshisBeingUsed, required List inputsWithKeys, + required String recipientAddress, + required BigInt recipientAmount, + required BigInt inputTotal, required int? satsPerVByte, required BigInt feeRatePerKB, - BigInt? overrideFeeAmount, + required BigInt? minimumFeeAmount, + required bool isSweep, + required Future Function() nextChangeAddress, }) async { - Logging.instance.d("Attempting to send all $cryptoCurrency"); - if (txData.recipients!.length != 1) { - throw Exception("Send all to more than one recipient not yet supported"); - } - - BigInt feeForOneOutput; - if (overrideFeeAmount == null) { - final int vSizeForOneOutput = (await buildTransaction( - inputsWithKeys: inputsWithKeys, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshisBeingUsed - BigInt.one], - ), - ), - )).vSize!; - feeForOneOutput = BigInt.from( - satsPerVByte != null - ? (satsPerVByte * vSizeForOneOutput) - : estimateTxFee( - vSize: vSizeForOneOutput, - feeRatePerKB: feeRatePerKB, - ), - ); - - if (satsPerVByte == null) { - final roughEstimate = roughFeeEstimate( - inputsWithKeys.length, - 1, - feeRatePerKB, - ).raw; - if (feeForOneOutput < roughEstimate) { - feeForOneOutput = roughEstimate; + final BigInt dustLimit = cryptoCurrency.dustLimit.raw; + String? changeAddress; + final result = await planElectrumFee( + mode: isSweep + ? ElectrumFeeMode.sweep + : txData.subtractFeeFromAmount + ? ElectrumFeeMode.subtractFeeFromAmount + : ElectrumFeeMode.fixedAmount, + inputTotal: inputTotal, + recipientAmount: recipientAmount, + dustLimit: dustLimit, + satsPerVByte: satsPerVByte, + feeRatePerKB: feeRatePerKB, + minimumFeeAmount: minimumFeeAmount, + build: ({required recipientAmount, changeAmount}) async { + final addresses = [recipientAddress]; + final amounts = [recipientAmount]; + if (changeAmount != null) { + final address = changeAddress ??= await nextChangeAddress(); + addresses.add(address); + amounts.add(changeAmount); } - } - } else { - feeForOneOutput = overrideFeeAmount; - } - - late TxData data; - if (txData.type == TxType.mwebPegIn) { - while (true) { - final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput; - if (satoshiAmountToSend.isNegative) { - throw Exception( - "Estimated fee ($feeForOneOutput sats) is greater than balance!", - ); - } - - data = await buildTransaction( + final transaction = await buildTransaction( + inputsWithKeys: inputsWithKeys, txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshiAmountToSend], - ), + recipients: await helperRecipientsConvert(addresses, amounts), + usedUTXOs: inputsWithKeys, ), - inputsWithKeys: inputsWithKeys, - ); - - if (overrideFeeAmount != null) { - break; - } - - // Signing can change vSize, so calculate the fee from the final tx. - final vSize = BigInt.from(data.vSize!); - final feeForFinalVSize = BigInt.from( - satsPerVByte != null - ? satsPerVByte * data.vSize! - : estimateTxFee(vSize: data.vSize!, feeRatePerKB: feeRatePerKB), ); - final requiredFee = feeForFinalVSize > vSize ? feeForFinalVSize : vSize; - if (feeForOneOutput >= requiredFee) { - break; - } - feeForOneOutput = requiredFee; - } - } else { - final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput; - - if (satoshiAmountToSend.isNegative) { - throw Exception( - "Estimated fee ($feeForOneOutput sats) is greater than balance!", - ); - } - - data = await buildTransaction( - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - [recipientAddress], - [satoshiAmountToSend], - ), - ), - inputsWithKeys: inputsWithKeys, - ); - } + return (transaction: transaction, vSize: transaction.vSize!); + }, + ); - return data.copyWith( + return result.transaction.copyWith( fee: Amount( - rawValue: feeForOneOutput, + rawValue: result.fee, fractionDigits: cryptoCurrency.fractionDigits, ), usedUTXOs: inputsWithKeys, @@ -794,33 +582,25 @@ mixin ElectrumXInterface " signedSize=${selection.signedSize}", ); - /// Add the change if there is one - final List recipientsArray = [recipientAddress]; - final List recipientsAmtArray = [satoshiAmountToSend]; - if (!selection.changeless) { - await checkChangeAddressForTransactions(); - final freshChange = (await getCurrentChangeAddress())!; - recipientsArray.add(freshChange.value); - recipientsAmtArray.add(selection.changeValue); - } - - final TxData txBuilt = await buildTransaction( - inputsWithKeys: selectedBaseInputs, - txData: txData.copyWith( - recipients: await helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - usedUTXOs: selectedBaseInputs, - ), + final BigInt inputTotal = selectedBaseInputs.fold( + BigInt.zero, + (sum, input) => sum + input.value, ); - return txBuilt.copyWith( - fee: Amount( - rawValue: selection.fee, - fractionDigits: cryptoCurrency.fractionDigits, - ), - usedUTXOs: selectedBaseInputs, + return _buildTransactionPayingFee( + txData: txData, + inputsWithKeys: selectedBaseInputs, + recipientAddress: recipientAddress, + recipientAmount: satoshiAmountToSend, + inputTotal: inputTotal, + satsPerVByte: satsPerVByte, + feeRatePerKB: feeRatePerKB, + minimumFeeAmount: null, + isSweep: false, + nextChangeAddress: () async { + await checkChangeAddressForTransactions(); + return (await getCurrentChangeAddress())!.value; + }, ); } @@ -2193,13 +1973,15 @@ mixin ElectrumXInterface TxData mwebData = await coinSelection( txData: result.copyWith( - recipients: result.recipients!.where((e) => !(e.isChange)).toList(), + recipients: txData.subtractFeeFromAmount + ? txData.recipients + : result.recipients!.where((e) => !(e.isChange)).toList(), ), utxos: utxos?.toList(), coinControl: coinControl, isSendAll: isSendAll, isSendAllCoinControlUtxos: isSendAllCoinControlUtxos, - overrideFeeAmount: fee.raw, + minimumFeeAmount: fee.raw, ); if (mwebData.type == TxType.mwebPegIn) { @@ -2212,7 +1994,7 @@ mixin ElectrumXInterface mwebData, ); Logging.instance.d("prepare MWEB send: $data"); - return data.copyWith(fee: fee); + return data; } Logging.instance.d("prepare send: $result"); diff --git a/test/wallets/electrum_fee_planner_test.dart b/test/wallets/electrum_fee_planner_test.dart new file mode 100644 index 0000000000..5a541f5426 --- /dev/null +++ b/test/wallets/electrum_fee_planner_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart'; + +typedef _Payment = ({BigInt recipientAmount, BigInt? changeAmount}); + +Future<({ElectrumFeeResult<_Payment> result, List<_Payment> builds})> _plan({ + required ElectrumFeeMode mode, + required int inputTotal, + required int recipientAmount, + required int dustLimit, + required List vSizes, + int? satsPerVByte = 1, + int feeRatePerKB = 1000, + int? minimumFeeAmount, +}) async { + final builds = <_Payment>[]; + var buildIndex = 0; + final result = await planElectrumFee<_Payment>( + mode: mode, + inputTotal: BigInt.from(inputTotal), + recipientAmount: BigInt.from(recipientAmount), + dustLimit: BigInt.from(dustLimit), + satsPerVByte: satsPerVByte, + feeRatePerKB: BigInt.from(feeRatePerKB), + minimumFeeAmount: minimumFeeAmount == null + ? null + : BigInt.from(minimumFeeAmount), + build: ({required recipientAmount, changeAmount}) async { + final payment = ( + recipientAmount: recipientAmount, + changeAmount: changeAmount, + ); + builds.add(payment); + final vSize = + vSizes[buildIndex < vSizes.length ? buildIndex++ : vSizes.length - 1]; + return (transaction: payment, vSize: vSize); + }, + ); + return (result: result, builds: builds); +} + +void main() { + test('keeps the larger fee when measured vsize shrinks', () async { + final plan = await _plan( + mode: ElectrumFeeMode.sweep, + inputTotal: 10000, + recipientAmount: 10000, + dustLimit: 546, + vSizes: [192, 191], + ); + + expect(plan.result.fee, BigInt.from(192)); + expect(plan.result.transaction.recipientAmount, BigInt.from(9808)); + expect(plan.builds.length, 2); + }); + + test('rounds per-kilobyte fees up', () async { + final plan = await _plan( + mode: ElectrumFeeMode.sweep, + inputTotal: 10000, + recipientAmount: 10000, + dustLimit: 546, + vSizes: [191], + satsPerVByte: null, + feeRatePerKB: 1001, + ); + + expect(plan.result.fee, BigInt.from(192)); + }); + + test('does not let a minimum fee underpay the measured vsize', () async { + final plan = await _plan( + mode: ElectrumFeeMode.sweep, + inputTotal: 10000, + recipientAmount: 10000, + dustLimit: 546, + vSizes: [225], + satsPerVByte: null, + feeRatePerKB: 0, + minimumFeeAmount: 100, + ); + + expect(plan.result.fee, BigInt.from(225)); + }); + + test('keeps exact-dust fixed change after vsize shrinks', () async { + final plan = await _plan( + mode: ElectrumFeeMode.fixedAmount, + inputTotal: 1319, + recipientAmount: 547, + dustLimit: 546, + vSizes: [226, 225], + ); + + expect(plan.result.fee, BigInt.from(226)); + expect(plan.result.transaction.recipientAmount, BigInt.from(547)); + expect(plan.result.transaction.changeAmount, BigInt.from(546)); + }); + + test('accepts an exact-dust fixed recipient', () async { + final plan = await _plan( + mode: ElectrumFeeMode.fixedAmount, + inputTotal: 772, + recipientAmount: 546, + dustLimit: 546, + vSizes: [226], + ); + + expect(plan.result.fee, BigInt.from(226)); + expect(plan.result.transaction.recipientAmount, BigInt.from(546)); + }); + + test('subtracts the fee and preserves change', () async { + final plan = await _plan( + mode: ElectrumFeeMode.subtractFeeFromAmount, + inputTotal: 10000, + recipientAmount: 6000, + dustLimit: 546, + vSizes: [225], + ); + + expect(plan.result.fee, BigInt.from(225)); + expect(plan.result.transaction.recipientAmount, BigInt.from(5775)); + expect(plan.result.transaction.changeAmount, BigInt.from(4000)); + }); + + test('uses a sub-dust surplus toward the subtracted fee', () async { + final plan = await _plan( + mode: ElectrumFeeMode.subtractFeeFromAmount, + inputTotal: 10000, + recipientAmount: 9900, + dustLimit: 546, + vSizes: [225], + ); + + expect(plan.result.fee, BigInt.from(225)); + expect(plan.result.transaction.recipientAmount, BigInt.from(9775)); + expect(plan.result.transaction.changeAmount, isNull); + }); + + test('uses an equal sub-dust surplus as the fee', () async { + final plan = await _plan( + mode: ElectrumFeeMode.subtractFeeFromAmount, + inputTotal: 10000, + recipientAmount: 9775, + dustLimit: 546, + vSizes: [225], + ); + + expect(plan.result.fee, BigInt.from(225)); + expect(plan.result.transaction.recipientAmount, BigInt.from(9775)); + expect(plan.result.transaction.changeAmount, isNull); + }); + + test('returns excess sub-dust surplus to the recipient', () async { + final plan = await _plan( + mode: ElectrumFeeMode.subtractFeeFromAmount, + inputTotal: 10000, + recipientAmount: 9700, + dustLimit: 546, + vSizes: [225], + ); + + expect(plan.result.fee, BigInt.from(225)); + expect(plan.result.transaction.recipientAmount, BigInt.from(9775)); + expect(plan.result.transaction.changeAmount, isNull); + }); + + test('fixed mode requests another input when the fee is short', () { + expect( + _plan( + mode: ElectrumFeeMode.fixedAmount, + inputTotal: 10000, + recipientAmount: 9900, + dustLimit: 546, + vSizes: [225], + ), + throwsA( + isA().having( + (e) => e.requiredFee, + 'requiredFee', + BigInt.from(225), + ), + ), + ); + }); +} diff --git a/test/wallets/tx_data_test.dart b/test/wallets/tx_data_test.dart new file mode 100644 index 0000000000..b07748a049 --- /dev/null +++ b/test/wallets/tx_data_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; + +void main() { + test('subtractFeeFromAmount defaults and copies', () { + final txData = TxData(); + + expect(txData.subtractFeeFromAmount, false); + + final enabled = txData.copyWith(subtractFeeFromAmount: true); + expect(enabled.subtractFeeFromAmount, true); + expect(enabled.copyWith().subtractFeeFromAmount, true); + expect( + enabled.copyWith(subtractFeeFromAmount: false).subtractFeeFromAmount, + false, + ); + }); +} From 143ae539852e0a73cca376b2f7595c29c2b51581 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 19 Aug 2026 09:31:02 -0600 Subject: [PATCH 47/52] handle mweb 6 required min confirms --- lib/db/isar/main_db.dart | 1 + .../isar/models/blockchain_data/utxo.dart | 27 ++++-- .../crypto_currency/coins/litecoin.dart | 12 +-- .../crypto_currency/crypto_currency.dart | 3 +- .../electrumx_interface.dart | 36 ++++++++ test/models/isar/mweb_pegout_test.dart | 88 +++++++++++++++++++ 6 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 test/models/isar/mweb_pegout_test.dart diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index b39757f208..e8efa7dc72 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -349,6 +349,7 @@ class MainDB { blockTime: utxo.blockTime, blockHeight: utxo.blockHeight, blockHash: utxo.blockHash, + otherData: utxo.otherData, // passing null keeps the stored value isBlocked: applyAutoBlock ? true : null, blockedReason: applyAutoBlock ? utxo.blockedReason : null, diff --git a/lib/models/isar/models/blockchain_data/utxo.dart b/lib/models/isar/models/blockchain_data/utxo.dart index 988a713aeb..871b553645 100644 --- a/lib/models/isar/models/blockchain_data/utxo.dart +++ b/lib/models/isar/models/blockchain_data/utxo.dart @@ -86,12 +86,10 @@ class UTXO { int? overrideMinConfirms, // added to handle namecoin name op outputs }) { final confirmations = getConfirmations(currentChainHeight); - - if (overrideMinConfirms != null) { - return confirmations >= overrideMinConfirms; - } - return confirmations >= + final requiredConfirmations = + overrideMinConfirms ?? (isCoinbase ? minimumCoinbaseConfirms : minimumConfirms); + return confirmations >= max(requiredConfirmations, mwebPegoutMaturity ?? 0); } /// A lingering [blockedReason] on an unblocked utxo means the wallet @@ -107,6 +105,24 @@ class UTXO { return keyImage != null; } + @ignore + int? get mwebPegoutMaturity { + if (otherData == null) { + return null; + } + + try { + final value = + (jsonDecode(otherData!) as Map)[UTXOOtherDataKeys.mwebPegoutMaturity]; + return value is int && value > 0 ? value : null; + } catch (_) { + return null; + } + } + + @ignore + bool get isMwebPegout => mwebPegoutMaturity != null; + @ignore String? get keyImage { if (otherData == null) { @@ -189,6 +205,7 @@ class UTXO { abstract final class UTXOOtherDataKeys { static const keyImage = "keyImage"; + static const mwebPegoutMaturity = "mwebPegoutMaturity"; static const spent = "spent"; static const nameOpData = "nameOpData"; } diff --git a/lib/wallets/crypto_currency/coins/litecoin.dart b/lib/wallets/crypto_currency/coins/litecoin.dart index 1b830c4f96..0d3ce6f89e 100644 --- a/lib/wallets/crypto_currency/coins/litecoin.dart +++ b/lib/wallets/crypto_currency/coins/litecoin.dart @@ -51,6 +51,9 @@ class Litecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { // change this to change the number of confirms a tx needs in order to show as confirmed int get minConfirms => 1; + @override + int get mwebPegoutMaturity => 6; + @override bool get torSupport => true; @@ -169,11 +172,10 @@ class Litecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface { return (address: addr, addressType: AddressType.p2pkh); case DerivePathType.bip49: - final p2wpkhScript = - coinlib.P2WPKHAddress.fromPublicKey( - publicKey, - hrp: networkParams.bech32Hrp, - ).program.script; + final p2wpkhScript = coinlib.P2WPKHAddress.fromPublicKey( + publicKey, + hrp: networkParams.bech32Hrp, + ).program.script; final addr = coinlib.P2SHAddress.fromRedeemScript( p2wpkhScript, diff --git a/lib/wallets/crypto_currency/crypto_currency.dart b/lib/wallets/crypto_currency/crypto_currency.dart index 8d02b130c0..16bba3ff7b 100644 --- a/lib/wallets/crypto_currency/crypto_currency.dart +++ b/lib/wallets/crypto_currency/crypto_currency.dart @@ -11,11 +11,11 @@ export 'coins/dash.dart'; export 'coins/dogecoin.dart'; export 'coins/ecash.dart'; export 'coins/epiccash.dart'; -export 'coins/mimblewimblecoin.dart'; export 'coins/ethereum.dart'; export 'coins/fact0rn.dart'; export 'coins/firo.dart'; export 'coins/litecoin.dart'; +export 'coins/mimblewimblecoin.dart'; export 'coins/monero.dart'; export 'coins/namecoin.dart'; export 'coins/nano.dart'; @@ -65,6 +65,7 @@ abstract class CryptoCurrency { int get minConfirms; int get minCoinbaseConfirms => minConfirms; + int? get mwebPegoutMaturity => null; // TODO: [prio=low] could be handled differently as (at least) epiccash/mimblewimblecoin does not use this String get genesisHash; diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 9a2f2f35cb..eaf21d927e 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -41,6 +41,34 @@ import 'rbf_interface.dart'; import 'sign_verify_interface.dart'; import 'view_only_option_interface.dart'; +@visibleForTesting +bool isMwebPegoutOutput(List outputs, int vout) { + if (vout <= 0) { + return false; + } + + for (final output in outputs) { + if (output is! Map || output["n"] != 0) { + continue; + } + + final scriptPubKey = output["scriptPubKey"]; + if (scriptPubKey is! Map) { + return false; + } + + if (scriptPubKey["type"] == "witness_mweb_hogaddr") { + return true; + } + + final scriptHex = scriptPubKey["hex"]; + return scriptHex is String && + RegExp(r'^5820[0-9a-fA-F]{64}$').hasMatch(scriptHex); + } + + return false; +} + mixin ElectrumXInterface on Bip39HDWallet implements ViewOnlyOptionInterface, SignVerifyInterface { @@ -1363,6 +1391,9 @@ mixin ElectrumXInterface final vout = jsonUTXO["tx_pos"] as int; final outputs = txn["vout"] as List; + final mwebPegoutMaturity = cryptoCurrency.mwebPegoutMaturity; + final isMwebPegout = + mwebPegoutMaturity != null && isMwebPegoutOutput(outputs, vout); String? scriptPubKey; String? utxoOwnerAddress; @@ -1400,6 +1431,11 @@ mixin ElectrumXInterface blockHeight: jsonUTXO["height"] as int?, blockTime: txn["blocktime"] as int?, address: utxoOwnerAddress, + otherData: isMwebPegout + ? jsonEncode({ + UTXOOtherDataKeys.mwebPegoutMaturity: mwebPegoutMaturity, + }) + : null, ); return utxo; diff --git a/test/models/isar/mweb_pegout_test.dart b/test/models/isar/mweb_pegout_test.dart new file mode 100644 index 0000000000..0639019b1e --- /dev/null +++ b/test/models/isar/mweb_pegout_test.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; + +void main() { + UTXO utxo({String? otherData}) => UTXO( + walletId: "walletId", + txid: "txid", + vout: 1, + value: 1000, + name: "", + isBlocked: false, + blockedReason: null, + isCoinbase: false, + blockHash: "blockHash", + blockHeight: 100, + blockTime: 1, + otherData: otherData, + ); + + group("MWEB pegout detection", () { + test("recognizes outputs after the HogAddr output", () { + final outputs = [ + { + "n": 0, + "scriptPubKey": {"type": "witness_mweb_hogaddr"}, + }, + { + "n": 1, + "scriptPubKey": {"type": "witness_v0_keyhash"}, + }, + ]; + + expect(isMwebPegoutOutput(outputs, 0), isFalse); + expect(isMwebPegoutOutput(outputs, 1), isTrue); + }); + + test("recognizes the HogAddr script when type is unavailable", () { + final outputs = [ + { + "n": 0, + "scriptPubKey": { + "hex": + "5820000000000000000000000000000000" + "0000000000000000000000000000000000", + }, + }, + ]; + + expect(isMwebPegoutOutput(outputs, 1), isTrue); + }); + + test("does not classify native MWEB or ordinary outputs as pegouts", () { + final outputs = [ + { + "n": 0, + "ismweb": true, + "scriptPubKey": {"type": "witness_v0_keyhash"}, + }, + ]; + + expect(isMwebPegoutOutput(outputs, 1), isFalse); + }); + }); + + test("Litecoin pegouts require six confirmations", () { + final maturity = Litecoin(CryptoCurrencyNetwork.main).mwebPegoutMaturity; + final pegout = utxo( + otherData: jsonEncode({UTXOOtherDataKeys.mwebPegoutMaturity: maturity}), + ); + + expect(pegout.isMwebPegout, isTrue); + expect(pegout.getConfirmations(104), 5); + expect(pegout.isConfirmed(104, 1, 1), isFalse); + expect(pegout.isConfirmed(104, 1, 1, overrideMinConfirms: 1), isFalse); + expect(pegout.isConfirmed(105, 1, 1), isTrue); + }); + + test("ordinary outputs retain the currency confirmation policy", () { + final ordinary = utxo(); + + expect(ordinary.isMwebPegout, isFalse); + expect(ordinary.isConfirmed(100, 1, 1), isTrue); + }); +} From 2466857236d46245b901d8e3ae12300c9f91a3c6 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 19 Aug 2026 15:11:19 -0600 Subject: [PATCH 48/52] some dependency clean up --- .../receive_view/addresses/address_card.dart | 9 ++- .../addresses/address_qr_popup.dart | 54 ++++++--------- .../generate_receiving_uri_qr_code_view.dart | 11 +-- .../dialogs/frost/frost_step_qr_dialog.dart | 41 +++++------- pubspec.lock | 67 ++++++++----------- .../templates/pubspec.template.yaml | 30 ++------- 6 files changed, 88 insertions(+), 124 deletions(-) diff --git a/lib/pages/receive_view/addresses/address_card.dart b/lib/pages/receive_view/addresses/address_card.dart index f8b6ec8067..0cd361fb2a 100644 --- a/lib/pages/receive_view/addresses/address_card.dart +++ b/lib/pages/receive_view/addresses/address_card.dart @@ -132,9 +132,12 @@ class _AddressCardState extends ConsumerState { final file = await File("${tempDir.path}/qrcode.png").create(); await file.writeAsBytes(pngBytes); - await Share.shareFiles([ - "${tempDir.path}/qrcode.png", - ], text: "Receive URI QR Code"); + await SharePlus.instance.share( + ShareParams( + files: [XFile("${tempDir.path}/qrcode.png")], + text: "Receive URI QR Code", + ), + ); } } catch (e) { //todo: comeback to this diff --git a/lib/pages/receive_view/addresses/address_qr_popup.dart b/lib/pages/receive_view/addresses/address_qr_popup.dart index b4446f5431..6af226f274 100644 --- a/lib/pages/receive_view/addresses/address_qr_popup.dart +++ b/lib/pages/receive_view/addresses/address_qr_popup.dart @@ -58,8 +58,9 @@ class _AddressQrPopupState extends State { final RenderRepaintBoundary boundary = _qrKey.currentContext?.findRenderObject() as RenderRepaintBoundary; final ui.Image image = await boundary.toImage(); - final ByteData? byteData = - await image.toByteData(format: ui.ImageByteFormat.png); + final ByteData? byteData = await image.toByteData( + format: ui.ImageByteFormat.png, + ); final Uint8List pngBytes = byteData!.buffer.asUint8List(); if (shouldSaveInsteadOfShare) { @@ -67,7 +68,8 @@ class _AddressQrPopupState extends State { final dir = Directory("${Platform.environment['HOME']}"); if (!dir.existsSync()) { throw Exception( - "Home dir not found while trying to open filepicker on QR image save", + "Home dir not found while trying to open filepicker on QR image" + " save", ); } final path = await FilePicker.platform.saveFile( @@ -107,9 +109,11 @@ class _AddressQrPopupState extends State { final file = await File("${tempDir.path}/qrcode.png").create(); await file.writeAsBytes(pngBytes); - await Share.shareFiles( - ["${tempDir.path}/qrcode.png"], - text: "Receive URI QR Code", + await SharePlus.instance.share( + ShareParams( + files: [XFile("${tempDir.path}/qrcode.png")], + text: "Receive URI QR Code", + ), ); } } catch (e) { @@ -123,20 +127,10 @@ class _AddressQrPopupState extends State { return StackDialogBase( child: Column( children: [ - Text( - "todo: custom label", - style: STextStyles.pageTitleH2(context), - ), - const SizedBox( - height: 8, - ), - Text( - widget.addressString, - style: STextStyles.itemSubtitle(context), - ), - const SizedBox( - height: 16, - ), + Text("Address", style: STextStyles.pageTitleH2(context)), + const SizedBox(height: 8), + Text(widget.addressString, style: STextStyles.itemSubtitle(context)), + const SizedBox(height: 16), Center( child: RepaintBoundary( key: _qrKey, @@ -150,9 +144,7 @@ class _AddressQrPopupState extends State { ), ), ), - const SizedBox( - height: 16, - ), + const SizedBox(height: 16), Row( children: [ Expanded( @@ -167,15 +159,13 @@ class _AddressQrPopupState extends State { Assets.svg.share, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Expanded( child: PrimaryButton( width: 170, @@ -187,9 +177,9 @@ class _AddressQrPopupState extends State { Assets.svg.arrowDown, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .buttonTextPrimary, + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, ), ), ), diff --git a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart index 203e0a0423..adeb67c556 100644 --- a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart +++ b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart @@ -16,8 +16,8 @@ import 'dart:ui' as ui; // import 'package:document_file_save_plus/document_file_save_plus.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -139,9 +139,12 @@ class _GenerateUriQrCodeViewState extends ConsumerState { final file = await File("${tempDir.path}/qrcode.png").create(); await file.writeAsBytes(pngBytes); - await Share.shareFiles([ - "${tempDir.path}/qrcode.png", - ], text: "Receive URI QR Code"); + await SharePlus.instance.share( + ShareParams( + files: [XFile("${tempDir.path}/qrcode.png")], + text: "Receive URI QR Code", + ), + ); } } catch (e) { //todo: comeback to this diff --git a/lib/widgets/dialogs/frost/frost_step_qr_dialog.dart b/lib/widgets/dialogs/frost/frost_step_qr_dialog.dart index 949e2fed75..81f3400200 100644 --- a/lib/widgets/dialogs/frost/frost_step_qr_dialog.dart +++ b/lib/widgets/dialogs/frost/frost_step_qr_dialog.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:path_provider/path_provider.dart'; - import 'package:share_plus/share_plus.dart'; import '../../../notifications/show_flush_bar.dart'; @@ -94,9 +93,11 @@ class _FrostStepQrDialogState extends State { final file = await File("${tempDir.path}/qrcode.png").create(); await file.writeAsBytes(pngBytes); - await Share.shareFiles( - ["${tempDir.path}/qrcode.png"], - text: "Receive URI QR Code", + await SharePlus.instance.share( + ShareParams( + files: [XFile("${tempDir.path}/qrcode.png")], + text: "Receive URI QR Code", + ), ); } } catch (e) { @@ -124,21 +125,18 @@ class _FrostStepQrDialogState extends State { Text( widget.myName, style: STextStyles.w600_16(context).copyWith( - color: Theme.of(context) - .extension()! - .customTextButtonEnabledText, + color: Theme.of( + context, + ).extension()!.customTextButtonEnabledText, ), ), const SizedBox(height: 8), - Text( - widget.title, - style: STextStyles.w600_12(context), - ), + Text(widget.title, style: STextStyles.w600_12(context)), const SizedBox(height: 8), RoundedContainer( - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, radiusMultiplier: 1, child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -146,9 +144,7 @@ class _FrostStepQrDialogState extends State { ConditionalParent( condition: Util.isDesktop, builder: (child) => ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 360, - ), + constraints: const BoxConstraints(maxWidth: 360), child: child, ), child: Padding( @@ -174,10 +170,7 @@ class _FrostStepQrDialogState extends State { ), ), ), - if (!Util.isDesktop) - const SizedBox( - height: 16, - ), + if (!Util.isDesktop) const SizedBox(height: 16), if (!Util.isDesktop) Row( children: [ @@ -190,9 +183,9 @@ class _FrostStepQrDialogState extends State { Assets.svg.share, width: 14, height: 14, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: () async { await _capturePng(false); diff --git a/pubspec.lock b/pubspec.lock index e85d3e8109..441fae0c2e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -252,10 +252,10 @@ packages: dependency: "direct main" description: name: camera_macos - sha256: a0e15729caf4e7c2831b9cd964e8c2e2ea985cd816e56316be03355de44aa743 + sha256: "64e199368efb0dc12c5298819df98aada4fe2cc50a5d84997f7bf7d94edaa3f8" url: "https://pub.dev" source: hosted - version: "0.0.9" + version: "0.1.1" camera_platform_interface: dependency: "direct main" description: @@ -376,18 +376,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "77a180d6938f78ca7d2382d2240eb626c0f6a735d0bfdce227d8ffb80f95c48b" + sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "7.3.1" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" url: "https://pub.dev" source: hosted - version: "1.2.4" + version: "2.1.0" convert: dependency: "direct main" description: @@ -777,18 +777,18 @@ packages: dependency: "direct main" description: name: desktop_drop - sha256: d55a010fe46c8e8fcff4ea4b451a9ff84a162217bdb3b2a0aa1479776205e15d + sha256: aa1e797255bfbc76f9eb5aa4f61e5b68dbf69962ab1be6495816d2f251bc0d1f url: "https://pub.dev" source: hosted - version: "0.4.4" + version: "0.7.1" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "10.1.2" + version: "12.4.0" device_info_plus_platform_interface: dependency: transitive description: @@ -801,11 +801,11 @@ packages: dependency: "direct main" description: path: "." - ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce - resolved-ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce + ref: "73c4ed946816c5ec032d13a44f8f510e2ced9886" + resolved-ref: "73c4ed946816c5ec032d13a44f8f510e2ced9886" url: "https://github.com/cypherstack/flutter-devicelocale" source: git - version: "0.8.1" + version: "0.9.0" digest_auth: dependency: "direct main" description: @@ -1430,7 +1430,7 @@ packages: source: hosted version: "4.12.0" json_rpc_2: - dependency: "direct overridden" + dependency: transitive description: name: json_rpc_2 sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" @@ -2013,18 +2013,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "3ef39599b00059db0990ca2e30fca0a29d8b37aae924d60063f8e0184cf20900" + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "12.0.2" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496" + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "6.1.0" shelf: dependency: transitive description: @@ -2338,6 +2338,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" unorm_dart: dependency: "direct main" description: @@ -2466,14 +2474,6 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" - wakelock_platform_interface: - dependency: transitive - description: - name: wakelock_platform_interface - sha256: "1f4aeb81fb592b863da83d2d0f7b8196067451e4df91046c26b54a403f9de621" - url: "https://pub.dev" - source: hosted - version: "0.3.0" wakelock_plus: dependency: "direct main" description: @@ -2490,15 +2490,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - wakelock_windows: - dependency: "direct overridden" - description: - path: wakelock_windows - ref: "2a9bca63a540771f241d688562351482b2cf234c" - resolved-ref: "2a9bca63a540771f241d688562351482b2cf234c" - url: "https://github.com/diegotori/wakelock" - source: git - version: "0.2.2" wallet: dependency: "direct main" description: @@ -2572,7 +2563,7 @@ packages: source: hosted version: "1.2.1" win32: - dependency: "direct overridden" + dependency: transitive description: name: win32 sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e @@ -2583,10 +2574,10 @@ packages: dependency: transitive description: name: win32_registry - sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" url: "https://pub.dev" source: hosted - version: "1.1.5" + version: "2.1.0" window_size: dependency: "direct main" description: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 61556b0174..e6b4fe90a1 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -173,20 +173,20 @@ dependencies: devicelocale: git: url: https://github.com/cypherstack/flutter-devicelocale - ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce - device_info_plus: ^10.1.2 + ref: 73c4ed946816c5ec032d13a44f8f510e2ced9886 + device_info_plus: ^12.4.0 keyboard_dismisser: ^3.0.0 another_flushbar: ^1.10.28 tuple: ^2.0.0 flutter_riverpod: ^1.0.3 qr_flutter: ^4.0.0 - share_plus: ^7.0.2 + share_plus: ^12.0.2 emojis: ^0.9.9 pointycastle: ^4.0.0 package_info_plus: ^8.0.2 lottie: ^3.3.2 file_picker: ^10.3.3 - connectivity_plus: ^4.0.1 + connectivity_plus: ^7.3.1 isar_community: 3.3.0-dev.2 isar_community_flutter_libs: 3.3.0-dev.2 dropdown_button2: ^2.1.3 @@ -200,7 +200,7 @@ dependencies: ref: bed60e43e4e509ea45bb097e6caee9f8293ddf98 hex: ^0.2.0 archive: ^4.0.2 - desktop_drop: ^0.4.4 + desktop_drop: ^0.7.1 nanodart: git: url: https://github.com/cypherstack/nanodart @@ -245,7 +245,7 @@ dependencies: url: https://github.com/cypherstack/packages.git path: packages/camera/camera_windows camera_platform_interface: ^2.8.0 - camera_macos: ^0.0.8 + camera_macos: ^0.1.1 blockchain_utils: ^3.3.0 on_chain: ^4.0.1 cbor: ^6.3.3 @@ -308,12 +308,9 @@ dependency_overrides: url: https://github.com/cypherstack/logger ref: 3c0cba27868ebb5c7d65ebc30a8e6e5342186692 - # required to make devicelocale work + # required to make web socket channel work (solana) web: ^0.5.0 - # needed for dart 3.5+ (at least for now) - win32: ^5.5.4 - # coinlib_flutter requires this coinlib: git: @@ -321,24 +318,12 @@ dependency_overrides: path: coinlib ref: f0e12dacb6d39e1cb340f0deae178d0fad1d6fd6 - bip47: - git: - url: https://github.com/cypherstack/bip47.git - ref: bdc0c0788d1d6dfb04863a793955f848ba1624a8 - # bip47 pins a different bitcoindart commit; override to ours bitcoindart: git: url: https://github.com/cypherstack/bitcoindart.git ref: ea33b1f5d6a701791359a2e180f73866dc667732 - # required for dart 3, at least until a fix is merged upstream - wakelock_windows: - git: - url: https://github.com/diegotori/wakelock - ref: 2a9bca63a540771f241d688562351482b2cf234c - path: wakelock_windows - # required override for solana, etc bip39: git: @@ -352,7 +337,6 @@ dependency_overrides: analyzer: ">=8.2.0 <8.4.0" # xelis override - json_rpc_2: ^4.0.0 freezed: ^3.1.0 freezed_annotation: ^3.1.0 From f7a7a60d3df4620f3dc24e7fb677b6f16ab934ce Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 19 Aug 2026 15:25:16 -0600 Subject: [PATCH 49/52] fix wifi check (due to connectivity_plus package updated) --- lib/electrumx_rpc/electrumx_client.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index d90a662ad5..7f7ade19bd 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -210,8 +210,9 @@ class ElectrumXClient { Future _allow() async { if (_prefs.wifiOnly) { - return (await Connectivity().checkConnectivity()) == - ConnectivityResult.wifi; + return (await Connectivity().checkConnectivity()).contains( + ConnectivityResult.wifi, + ); } return true; } From 000c3fe6c39586bb74928ae54cde96166a801269 Mon Sep 17 00:00:00 2001 From: Julian Date: Wed, 19 Aug 2026 17:39:21 -0600 Subject: [PATCH 50/52] another set of dep upgrades for SPM as well as android/gradle stuff --- Dockerfile | 6 + android/gradle.properties | 5 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle | 4 +- lib/db/special_migrations.dart | 4 +- lib/main.dart | 7 +- .../global/secure_store_provider.dart | 4 +- lib/services/notifications_api.dart | 32 ++++- pubspec.lock | 88 +++++++----- .../templates/android/app/build.gradle | 6 +- .../macos/Runner.xcodeproj/project.pbxproj | 6 +- .../templates/pubspec.template.yaml | 6 +- ...r_secure_storage_interface_test.mocks.dart | 129 ++++++++++++------ 13 files changed, 201 insertions(+), 98 deletions(-) diff --git a/Dockerfile b/Dockerfile index 72e8cd3e2c..3fa3a25e16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,11 +63,14 @@ RUN mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" \ && sdkmanager \ "platform-tools" \ "build-tools;35.0.0" \ + "build-tools;36.0.0" \ + "build-tools;37.0.0" \ "platforms;android-32" \ "platforms;android-33" \ "platforms;android-34" \ "platforms;android-35" \ "platforms;android-36" \ + "platforms;android-37" \ "ndk;28.0.13004108" \ "ndk;28.2.13676358" \ "cmake;3.22.1" \ @@ -148,11 +151,14 @@ RUN mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" \ && sdkmanager \ "platform-tools" \ "build-tools;35.0.0" \ + "build-tools;36.0.0" \ + "build-tools;37.0.0" \ "platforms;android-32" \ "platforms;android-33" \ "platforms;android-34" \ "platforms;android-35" \ "platforms;android-36" \ + "platforms;android-37" \ "ndk;28.0.13004108" \ "ndk;28.2.13676358" \ "cmake;3.22.1" \ diff --git a/android/gradle.properties b/android/gradle.properties index 24863d2185..1dadf40b85 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true \ No newline at end of file +# Jetifier removed in AGP 9; all deps are AndroidX already. +# Preserve Flutter's legacy Kotlin and Android DSL compatibility on AGP 9. +android.newDsl=false +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e4ef43fb98..a20f2c46d2 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index ebf08564f2..67b6e47cd1 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version '8.11.1' apply false - id "org.jetbrains.kotlin.android" version "2.2.20" apply false + id "com.android.application" version '9.1.1' apply false + id "org.jetbrains.kotlin.android" version "2.3.20" apply false } include ":app" diff --git a/lib/db/special_migrations.dart b/lib/db/special_migrations.dart index 84b17518cd..c1d6f26604 100644 --- a/lib/db/special_migrations.dart +++ b/lib/db/special_migrations.dart @@ -43,7 +43,9 @@ abstract class CampfireMigration { final myHive = HiveImpl(); myHive.init(appDirectory.path); _wallets = await myHive.openBox('wallets'); - _secureStore = const FlutterSecureStorage(); + _secureStore = const FlutterSecureStorage( + aOptions: AndroidOptions(resetOnError: false, migrateWithBackup: true), + ); } else { await setDidRun(); } diff --git a/lib/main.dart b/lib/main.dart index 89d7decb85..b7eff08ee9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -303,7 +303,12 @@ void main(List args) async { await DbVersionMigrator().migrate( dbVersion, secureStore: const SecureStorageWrapper( - store: FlutterSecureStorage(), + store: FlutterSecureStorage( + aOptions: AndroidOptions( + resetOnError: false, + migrateWithBackup: true, + ), + ), isDesktop: false, ), ); diff --git a/lib/providers/global/secure_store_provider.dart b/lib/providers/global/secure_store_provider.dart index d49d6c552f..76b8c28016 100644 --- a/lib/providers/global/secure_store_provider.dart +++ b/lib/providers/global/secure_store_provider.dart @@ -24,7 +24,9 @@ final secureStoreProvider = Provider((ref) { ); } else { return const SecureStorageWrapper( - store: FlutterSecureStorage(), + store: FlutterSecureStorage( + aOptions: AndroidOptions(resetOnError: false, migrateWithBackup: true), + ), isDesktop: false, ); } diff --git a/lib/services/notifications_api.dart b/lib/services/notifications_api.dart index 4263c951f1..1e2c6e1fd1 100644 --- a/lib/services/notifications_api.dart +++ b/lib/services/notifications_api.dart @@ -12,6 +12,7 @@ import 'dart:async'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import '../app_config.dart'; import '../models/notification_model.dart'; import '../utilities/logger.dart'; import '../utilities/prefs.dart'; @@ -54,14 +55,31 @@ abstract final class NotificationApi { defaultActionName: "temporary_stack_wallet", ); const macOS = DarwinInitializationSettings(); - const settings = InitializationSettings( + final (windowsAppUserModelId, windowsGuid) = switch (AppConfig.appName) { + "Campfire" => ( + "CypherStack.Campfire", + "fe1bb964-a80c-45cd-a5d6-c95b2d8b1142", + ), + "Stack Duo" => ( + "CypherStack.StackDuo", + "7b01c69c-c282-493e-98a1-6a5cd3153049", + ), + _ => ("CypherStack.StackWallet", "986f4b88-0a22-42c2-a005-e5e150c8653f"), + }; + final windows = WindowsInitializationSettings( + appName: AppConfig.appName, + appUserModelId: windowsAppUserModelId, + guid: windowsGuid, + ); + final settings = InitializationSettings( android: android, iOS: iOS, linux: linux, macOS: macOS, + windows: windows, ); await _notifications.initialize( - settings, + settings: settings, // onDidReceiveNotificationResponse: (payload) async { // onNotifications.add(payload.payload); // }, @@ -79,7 +97,7 @@ abstract final class NotificationApi { static Future clearNotification(int id) async { await init(); - await _notifications.cancel(id); + await _notifications.cancel(id: id); } //=================================== @@ -94,10 +112,10 @@ abstract final class NotificationApi { await init(); final id = await prefs.incrementCurrentNotificationIndex(); await _notifications.show( - id, - title, - body, - await _notificationDetails(), + id: id, + title: title, + body: body, + notificationDetails: await _notificationDetails(), payload: payload, ); return id; diff --git a/pubspec.lock b/pubspec.lock index 441fae0c2e..00c39c7b19 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1037,26 +1037,42 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" url: "https://pub.dev" source: hosted - version: "17.2.4" + version: "22.3.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "8.0.1" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" url: "https://pub.dev" source: hosted - version: "7.2.0" + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" flutter_mwebd: dependency: "direct main" description: @@ -1101,50 +1117,50 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: "22dbf16f23a4bcf9d35e51be1c84ad5bb6f627750565edd70dab70f3ff5fff8f" + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" url: "https://pub.dev" source: hosted - version: "8.1.0" - flutter_secure_storage_linux: + version: "10.3.1" + flutter_secure_storage_darwin: dependency: transitive description: - name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" url: "https://pub.dev" source: hosted - version: "1.2.3" - flutter_secure_storage_macos: + version: "0.3.2" + flutter_secure_storage_linux: dependency: transitive description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.0.2" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.3" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.1" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "38f9501c7cb6f38961ef0e1eacacee2b2d4715c63cc83fe56449c4d3d0b47255" + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "4.1.0" flutter_svg: dependency: "direct main" description: @@ -1781,50 +1797,50 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + sha256: e7317eb2eb611d1bf0386b6b974be9c50449f975f19a90a7b8ea013550302b30 url: "https://pub.dev" source: hosted - version: "12.0.1" + version: "13.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + sha256: d7676c6fcf2f0b92537ec41476a6ead45a00b0d8bbb852395a6f9f33f49d6242 url: "https://pub.dev" source: hosted - version: "13.0.1" + version: "14.0.0" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 url: "https://pub.dev" source: hosted - version: "9.4.7" + version: "9.6.1" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" url: "https://pub.dev" source: hosted - version: "0.1.3+5" + version: "0.1.4+1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.4.0" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.2.2" petitparser: dependency: transitive description: @@ -2277,10 +2293,10 @@ packages: dependency: transitive description: name: timezone - sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.11.1" timing: dependency: transitive description: diff --git a/scripts/app_config/templates/android/app/build.gradle b/scripts/app_config/templates/android/app/build.gradle index 8fee783991..6d79983c7c 100644 --- a/scripts/app_config/templates/android/app/build.gradle +++ b/scripts/app_config/templates/android/app/build.gradle @@ -13,7 +13,7 @@ if (keystorePropertiesFile.exists()) { android { namespace "com.place.holder" - compileSdk flutter.compileSdkVersion + compileSdk 37 // ndkVersion flutter.ndkVersion ndkVersion = "28.2.13676358" @@ -42,7 +42,7 @@ android { } dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } // No ndk.abiFilters here: AGP rejects it alongside the abi splits set @@ -88,7 +88,7 @@ android { tasks.whenTaskAdded { task -> if (task.name == 'assembleDebug') { task.doFirst { - println "The compileSdkVersion is $flutter.compileSdkVersion" + println "The compileSdkVersion is $android.compileSdk" println "The targetSdkVersion is $flutter.targetSdkVersion" println "The ndkVersion is $ndkVersion" } diff --git a/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj b/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj index d6cb54f868..d5b9e5fc04 100644 --- a/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj +++ b/scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj @@ -543,7 +543,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -646,7 +646,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -694,7 +694,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index e6b4fe90a1..bd30568377 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -115,8 +115,8 @@ dependencies: # Utility plugins http: ^1.6.0 local_auth: ^2.3.0 - permission_handler: ^12.0.0+1 - flutter_local_notifications: ^17.2.2 + permission_handler: ^13.0.1 + flutter_local_notifications: ^22.3.0 zxcvbn: ^1.0.0 dart_numerics: ^0.0.6 @@ -152,7 +152,7 @@ dependencies: ref: 6a5d3d69e54c175ae44b44040fb2743c9b6405a6 # Storage plugins - flutter_secure_storage: ^8.0.0 + flutter_secure_storage: ^10.3.1 hive_ce: ^2.13.2 hive_ce_flutter: ^2.3.2 path_provider: ^2.1.5 diff --git a/test/flutter_secure_storage_interface_test.mocks.dart b/test/flutter_secure_storage_interface_test.mocks.dart index 5d16aaa3e7..209dcc62f8 100644 --- a/test/flutter_secure_storage_interface_test.mocks.dart +++ b/test/flutter_secure_storage_interface_test.mocks.dart @@ -3,8 +3,9 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i3; +import 'dart:async' as _i4; +import 'package:flutter/foundation.dart' as _i3; import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; @@ -50,8 +51,8 @@ class _FakeWebOptions_4 extends _i1.SmartFake implements _i2.WebOptions { : super(parent, parentInvocation); } -class _FakeMacOsOptions_5 extends _i1.SmartFake implements _i2.MacOsOptions { - _FakeMacOsOptions_5(Object parent, Invocation parentInvocation) +class _FakeAppleOptions_5 extends _i1.SmartFake implements _i2.AppleOptions { + _FakeAppleOptions_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } @@ -117,25 +118,67 @@ class MockFlutterSecureStorage extends _i1.Mock as _i2.WebOptions); @override - _i2.MacOsOptions get mOptions => + _i2.AppleOptions get mOptions => (super.noSuchMethod( Invocation.getter(#mOptions), - returnValue: _FakeMacOsOptions_5( + returnValue: _FakeAppleOptions_5( this, Invocation.getter(#mOptions), ), ) - as _i2.MacOsOptions); + as _i2.AppleOptions); @override - _i3.Future write({ + Map>> get getListeners => + (super.noSuchMethod( + Invocation.getter(#getListeners), + returnValue: >>{}, + ) + as Map>>); + + @override + void registerListener({ + required String? key, + required _i3.ValueChanged? listener, + }) => super.noSuchMethod( + Invocation.method(#registerListener, [], {#key: key, #listener: listener}), + returnValueForMissingStub: null, + ); + + @override + void unregisterListener({ + required String? key, + required _i3.ValueChanged? listener, + }) => super.noSuchMethod( + Invocation.method(#unregisterListener, [], { + #key: key, + #listener: listener, + }), + returnValueForMissingStub: null, + ); + + @override + void unregisterAllListenersForKey({required String? key}) => + super.noSuchMethod( + Invocation.method(#unregisterAllListenersForKey, [], {#key: key}), + returnValueForMissingStub: null, + ); + + @override + void unregisterAllListeners() => super.noSuchMethod( + Invocation.method(#unregisterAllListeners, []), + returnValueForMissingStub: null, + ); + + @override + _i4.Future write({ required String? key, required String? value, - _i2.IOSOptions? iOptions, + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -149,19 +192,19 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i3.Future); + as _i4.Future); @override - _i3.Future read({ + _i4.Future read({ required String? key, - _i2.IOSOptions? iOptions, + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -174,18 +217,18 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future.value(), + returnValue: _i4.Future.value(), ) - as _i3.Future); + as _i4.Future); @override - _i3.Future containsKey({ + _i4.Future containsKey({ required String? key, - _i2.IOSOptions? iOptions, + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -198,18 +241,18 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future.value(false), + returnValue: _i4.Future.value(false), ) - as _i3.Future); + as _i4.Future); @override - _i3.Future delete({ + _i4.Future delete({ required String? key, - _i2.IOSOptions? iOptions, + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -222,18 +265,18 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i3.Future); + as _i4.Future); @override - _i3.Future> readAll({ - _i2.IOSOptions? iOptions, + _i4.Future> readAll({ + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -245,19 +288,19 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future>.value( + returnValue: _i4.Future>.value( {}, ), ) - as _i3.Future>); + as _i4.Future>); @override - _i3.Future deleteAll({ - _i2.IOSOptions? iOptions, + _i4.Future deleteAll({ + _i2.AppleOptions? iOptions, _i2.AndroidOptions? aOptions, _i2.LinuxOptions? lOptions, _i2.WebOptions? webOptions, - _i2.MacOsOptions? mOptions, + _i2.AppleOptions? mOptions, _i2.WindowsOptions? wOptions, }) => (super.noSuchMethod( @@ -269,8 +312,16 @@ class MockFlutterSecureStorage extends _i1.Mock #mOptions: mOptions, #wOptions: wOptions, }), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future isCupertinoProtectedDataAvailable() => + (super.noSuchMethod( + Invocation.method(#isCupertinoProtectedDataAvailable, []), + returnValue: _i4.Future.value(), ) - as _i3.Future); + as _i4.Future); } From dafffe88983b0707881fdd93bb35d2a9665180bf Mon Sep 17 00:00:00 2001 From: Julian Date: Sat, 22 Aug 2026 07:38:00 -0600 Subject: [PATCH 51/52] replace deprecated share call --- lib/pages/paynym/paynym_home_view.dart | 233 +++++++++---------------- 1 file changed, 87 insertions(+), 146 deletions(-) diff --git a/lib/pages/paynym/paynym_home_view.dart b/lib/pages/paynym/paynym_home_view.dart index d27a8ae429..d70cff6cf0 100644 --- a/lib/pages/paynym/paynym_home_view.dart +++ b/lib/pages/paynym/paynym_home_view.dart @@ -45,10 +45,7 @@ import 'subwidgets/paynym_followers_list.dart'; import 'subwidgets/paynym_following_list.dart'; class PaynymHomeView extends ConsumerStatefulWidget { - const PaynymHomeView({ - super.key, - required this.walletId, - }); + const PaynymHomeView({super.key, required this.walletId}); final String walletId; @@ -86,23 +83,20 @@ class _PaynymHomeViewState extends ConsumerState { leading: Row( children: [ Padding( - padding: const EdgeInsets.only( - left: 24, - right: 20, - ), + padding: const EdgeInsets.only(left: 24, right: 20), child: AppBarIconButton( size: 32, - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, - color: Theme.of(context) - .extension()! - .topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: Navigator.of(context).pop, ), @@ -113,13 +107,8 @@ class _PaynymHomeViewState extends ConsumerState { height: 32, color: Theme.of(context).extension()!.textDark, ), - const SizedBox( - width: 10, - ), - Text( - "PayNym", - style: STextStyles.desktopH3(context), - ), + const SizedBox(width: 10), + Text("PayNym", style: STextStyles.desktopH3(context)), ], ), trailing: kDisableFollowing @@ -146,12 +135,13 @@ class _PaynymHomeViewState extends ConsumerState { ); }, child: RoundedContainer( - padding: - const EdgeInsets.symmetric(horizontal: 24.0), + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + ), color: _followButtonHoverState - ? Theme.of(context) - .extension()! - .highlight + ? Theme.of( + context, + ).extension()!.highlight : Colors.transparent, radiusMultiplier: 100, child: Row( @@ -160,28 +150,22 @@ class _PaynymHomeViewState extends ConsumerState { Assets.svg.plus, width: 16, height: 16, - color: Theme.of(context) - .extension()! - .textDark, - ), - const SizedBox( - width: 8, + color: Theme.of( + context, + ).extension()!.textDark, ), + const SizedBox(width: 8), Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( "Follow", - style: STextStyles - .desktopButtonSecondaryEnabled( - context, - ).copyWith( - fontSize: 16, - ), - ), - const SizedBox( - height: 2, + style: + STextStyles.desktopButtonSecondaryEnabled( + context, + ).copyWith(fontSize: 16), ), + const SizedBox(height: 2), ], ), ], @@ -215,9 +199,9 @@ class _PaynymHomeViewState extends ConsumerState { Assets.svg.circlePlusFilled, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), onPressed: () { Navigator.of(context).pushNamed( @@ -237,9 +221,9 @@ class _PaynymHomeViewState extends ConsumerState { Assets.svg.circleQuestion, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), onPressed: () { // todo info ? @@ -247,22 +231,18 @@ class _PaynymHomeViewState extends ConsumerState { ), ), ), - const SizedBox( - width: 4, - ), + const SizedBox(width: 4), ], ), body: ConditionalParent( condition: !isDesktop, builder: (child) => SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: child, - ), + child: Padding(padding: const EdgeInsets.all(16), child: child), ), child: Column( - crossAxisAlignment: - isDesktop ? CrossAxisAlignment.start : CrossAxisAlignment.center, + crossAxisAlignment: isDesktop + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, children: [ if (!isDesktop) Column( @@ -281,13 +261,10 @@ class _PaynymHomeViewState extends ConsumerState { secretCount = 0; } - timer ??= Timer( - const Duration(milliseconds: 1500), - () { - secretCount = 0; - timer = null; - }, - ); + timer ??= Timer(const Duration(milliseconds: 1500), () { + secretCount = 0; + timer = null; + }); }, child: PayNymBot( paymentCodeString: ref @@ -297,9 +274,7 @@ class _PaynymHomeViewState extends ConsumerState { .code, ), ), - const SizedBox( - height: 10, - ), + const SizedBox(height: 10), Text( ref .watch(myPaynymAccountStateProvider.state) @@ -307,9 +282,7 @@ class _PaynymHomeViewState extends ConsumerState { .nymName, style: STextStyles.desktopMenuItemSelected(context), ), - const SizedBox( - height: 4, - ), + const SizedBox(height: 4), Text( Format.shorten( ref @@ -320,13 +293,9 @@ class _PaynymHomeViewState extends ConsumerState { 12, 5, ), - style: STextStyles.label(context).copyWith( - fontSize: 14, - ), - ), - const SizedBox( - height: 11, + style: STextStyles.label(context).copyWith(fontSize: 14), ), + const SizedBox(height: 11), Row( children: [ Expanded( @@ -337,9 +306,9 @@ class _PaynymHomeViewState extends ConsumerState { icon: CopyIcon( width: 12, height: 12, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: () async { await Clipboard.setData( @@ -362,9 +331,7 @@ class _PaynymHomeViewState extends ConsumerState { }, ), ), - const SizedBox( - width: 13, - ), + const SizedBox(width: 13), Expanded( child: SecondaryButton( label: "Share", @@ -373,9 +340,9 @@ class _PaynymHomeViewState extends ConsumerState { icon: ShareIcon( width: 12, height: 12, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: () async { Rect? sharePositionOrigin; @@ -388,20 +355,19 @@ class _PaynymHomeViewState extends ConsumerState { } } - await Share.share( - ref - .read(myPaynymAccountStateProvider.state) - .state! - .nonSegwitPaymentCode - .code, - sharePositionOrigin: sharePositionOrigin, + await SharePlus.instance.share( + ShareParams( + text: ref + .read(myPaynymAccountStateProvider)! + .nonSegwitPaymentCode + .code, + sharePositionOrigin: sharePositionOrigin, + ), ); }, ), ), - const SizedBox( - width: 13, - ), + const SizedBox(width: 13), Expanded( child: SecondaryButton( label: "Address", @@ -410,9 +376,9 @@ class _PaynymHomeViewState extends ConsumerState { icon: QrCodeIcon( width: 12, height: 12, - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), onPressed: () { showDialog( @@ -437,9 +403,7 @@ class _PaynymHomeViewState extends ConsumerState { padding: const EdgeInsets.all(16), child: Row( children: [ - const SizedBox( - width: 4, - ), + const SizedBox(width: 4), GestureDetector( onTap: () { secretCount++; @@ -469,9 +433,7 @@ class _PaynymHomeViewState extends ConsumerState { .code, ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -482,9 +444,7 @@ class _PaynymHomeViewState extends ConsumerState { .nymName, style: STextStyles.desktopH3(context), ), - const SizedBox( - height: 4, - ), + const SizedBox(height: 4), Text( Format.shorten( ref @@ -495,8 +455,9 @@ class _PaynymHomeViewState extends ConsumerState { 12, 5, ), - style: - STextStyles.desktopTextExtraExtraSmall(context), + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), ), ], ), @@ -508,9 +469,9 @@ class _PaynymHomeViewState extends ConsumerState { icon: CopyIcon( width: 18, height: 18, - color: Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), onPressed: () async { await Clipboard.setData( @@ -532,9 +493,7 @@ class _PaynymHomeViewState extends ConsumerState { ); }, ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), SecondaryButton( label: "Address", width: 160, @@ -542,9 +501,9 @@ class _PaynymHomeViewState extends ConsumerState { icon: QrCodeIcon( width: 18, height: 18, - color: Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), onPressed: () { showDialog( @@ -561,10 +520,7 @@ class _PaynymHomeViewState extends ConsumerState { ), ), ), - if (!isDesktop) - const SizedBox( - height: 24, - ), + if (!isDesktop) const SizedBox(height: 24), ConditionalParent( condition: isDesktop, builder: (child) => Padding( @@ -579,9 +535,9 @@ class _PaynymHomeViewState extends ConsumerState { onColor: Theme.of(context).extension()!.popupBG, onText: "Following (${ref.watch(myPaynymAccountStateProvider.state).state?.following.length ?? 0})", - offColor: Theme.of(context) - .extension()! - .textFieldDefaultBG, + offColor: Theme.of( + context, + ).extension()!.textFieldDefaultBG, offText: "Followers (${ref.watch(myPaynymAccountStateProvider.state).state?.followers.length ?? 0})", isOn: showFollowers, @@ -599,9 +555,7 @@ class _PaynymHomeViewState extends ConsumerState { ), ), ), - SizedBox( - height: isDesktop ? 20 : 16, - ), + SizedBox(height: isDesktop ? 20 : 16), Expanded( child: ConditionalParent( condition: isDesktop, @@ -610,13 +564,8 @@ class _PaynymHomeViewState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 490, - child: child, - ), - const SizedBox( - width: 24, - ), + SizedBox(width: 490, child: child), + const SizedBox(width: 24), if (ref .watch(selectedPaynymDetailsItemProvider.state) .state != @@ -645,24 +594,16 @@ class _PaynymHomeViewState extends ConsumerState { .watch(selectedPaynymDetailsItemProvider.state) .state != null) - const SizedBox( - width: 24, - ), + const SizedBox(width: 24), ], ), ), child: ConditionalParent( condition: !isDesktop, - builder: (child) => Container( - child: child, - ), + builder: (child) => Container(child: child), child: !showFollowers - ? PaynymFollowingList( - walletId: widget.walletId, - ) - : PaynymFollowersList( - walletId: widget.walletId, - ), + ? PaynymFollowingList(walletId: widget.walletId) + : PaynymFollowersList(walletId: widget.walletId), ), ), ), From 0f6c54fa9e0ccc46cfb0db44af29ad35c4cd02cb Mon Sep 17 00:00:00 2001 From: Julian Date: Mon, 24 Aug 2026 10:11:23 -0600 Subject: [PATCH 52/52] update NSPhotoLibraryUsageDescription string --- scripts/app_config/templates/ios/Runner/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/app_config/templates/ios/Runner/Info.plist b/scripts/app_config/templates/ios/Runner/Info.plist index ea8af9f9fb..9201a4c02c 100644 --- a/scripts/app_config/templates/ios/Runner/Info.plist +++ b/scripts/app_config/templates/ios/Runner/Info.plist @@ -35,7 +35,7 @@ NSFaceIDUsageDescription This app requires Face ID permissions so that the user can securely lock their wallet if their device uses Face ID. It will be useful feature for all users, and especially those prone to forget their login pin on iPhones where Touch ID is not available. NSPhotoLibraryUsageDescription - Photo Library Access Warning + This app only reads images you select, such as a picture containing a QR code to scan. It will never access your photo library on its own. UIFileSharingEnabled UILaunchStoryboardName