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/isar/main_db.dart b/lib/db/isar/main_db.dart index 8958114736..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, @@ -463,52 +464,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 +481,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(); }); } 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/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index 94b650b103..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; } @@ -519,7 +520,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 +540,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/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/models/exchange/incomplete_exchange.dart b/lib/models/exchange/incomplete_exchange.dart index 86441bc90e..28e0b06c3f 100644 --- a/lib/models/exchange/incomplete_exchange.dart +++ b/lib/models/exchange/incomplete_exchange.dart @@ -28,6 +28,10 @@ class IncompleteExchangeModel extends ChangeNotifier { final Decimal sendAmount; final Decimal receiveAmount; + String get payInAmount => trade?.payInAmount ?? sendAmount.toString(); + + Decimal? get payInDecimal => Decimal.tryParse(payInAmount); + final ExchangeRateType rateType; final bool reversed; @@ -55,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/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/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/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/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart index c328e5e4f7..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; @@ -178,6 +197,7 @@ class _ExchangeFormState extends ConsumerState { coin: Bitcoin( CryptoCurrencyNetwork.main, ), // dummy value (not used due to override) + strict: true, overrideWithDecimalPlacesFromString: true, ) ?.decimal; @@ -392,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 @@ -421,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") { @@ -645,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); @@ -660,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); @@ -704,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; } @@ -809,6 +856,8 @@ class _ExchangeFormState extends ConsumerState { @override void dispose() { + _sendFieldOnChangedTimer?.cancel(); + _receiveFieldOnChangedTimer?.cancel(); _receiveController.dispose(); _sendController.dispose(); _receiveFocusNode.dispose(); 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..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,12 +173,13 @@ class _Step2ViewState extends ConsumerState { } else { _toController.text = qrResult.rawContent!; model.recipientAddress = _toController.text; + _setRecipientMemo(null); setState(() { enableNext = _toController.text.isNotEmpty && (_refundController.text.isNotEmpty || - !!ref.read(efExchangeProvider).supportsRefundAddress); + !ref.read(efExchangeProvider).supportsRefundAddress); }); } } on PlatformException catch (e, s) { @@ -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/exchange_view/exchange_step_views/step_4_view.dart b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart index a48b85b23c..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 @@ -250,7 +250,23 @@ class _Step4ViewState extends ConsumerState { final wallet = ref.read(pWallets).getWallet(tuple.item1); - final Amount amount = model.sendAmount.toAmount( + 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; @@ -456,10 +472,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 +570,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 +580,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( @@ -609,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, @@ -621,7 +651,7 @@ class _SendFromButton extends ConsumerWidget { return SendFromView( coin: coin, - amount: model.sendAmount.toAmount( + amount: payInDecimal.toAmount( fractionDigits: coin.fractionDigits, ), address: model.trade!.payInAddress, 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/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), ), ), ), 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 d485cfa148..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 @@ -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/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'; 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; @@ -90,7 +93,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,19 +126,25 @@ 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(); 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 @@ -146,16 +156,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( @@ -237,10 +239,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 +297,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 +361,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 +380,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 +450,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 +523,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 +546,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 +564,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 +588,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, + ), ), ), ], diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 18b8d5be2e..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(feeRateTypeDesktopStateProvider), - 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 6187d4c53a..36b027f73e 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, ), ], @@ -1268,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/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/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; 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/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/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..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 @@ -10,7 +10,6 @@ import 'dart:async'; -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -113,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, ); @@ -210,14 +210,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, @@ -395,7 +408,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_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/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/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_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..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 @@ -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) @@ -1036,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/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/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/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/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/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/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/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, 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/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/utilities/address_utils.dart b/lib/utilities/address_utils.dart index cb1f5f8ad6..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. }; @@ -76,6 +79,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,40 +100,37 @@ 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': - 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; } - /// 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. @@ -136,17 +138,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 +153,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'], @@ -385,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/amount/amount.dart b/lib/utilities/amount/amount.dart index b31d87d7a0..9c259f8cc8 100644 --- a/lib/utilities/amount/amount.dart +++ b/lib/utilities/amount/amount.dart @@ -32,33 +32,80 @@ class Amount { fractionDigits: fractionDigits, ); - static Amount? tryParseFiatString(String value, {required String locale}) { - final parts = value.split(" "); + 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, "."); + } - if (parts.first.isEmpty) { + static Decimal? tryParseLocalizedNumber( + String value, { + required String locale, + }) { + if (value.isEmpty || value.contains(RegExp(r'[+\-\x09-\x0D ]'))) { return null; } - String str = parts.first; - if (str.startsWith(RegExp(r'[+-]'))) { - str = str.substring(1); - } + 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+)\$', + ); - if (str.isEmpty) { + // 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; } - // get number symbols for decimal place and group separator - final numberSymbols = Util.getSymbolsFor(locale: locale); + if (localizedPattern.hasMatch(value)) { + return Decimal.tryParse(normalizeLocalizedNumber(value, locale: locale)); + } - final groupSeparator = numberSymbols?.GROUP_SEP ?? ","; - final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? "."; + if (groupSeparator == "." && + decimalSeparator != "." && + !value.contains(decimalSeparator) && + RegExp(r'^(?:\d+(?:\.\d+)?|\.\d+)$').hasMatch(value)) { + return Decimal.tryParse(value); + } - str = str.replaceAll(groupSeparator, ""); + return null; + } - final decimalString = str.replaceFirst(decimalSeparator, "."); + static Amount? tryParseFiatString(String value, {required String locale}) { + if (value.isEmpty || value.contains(RegExp(r'[+\-\x09-\x0D ]'))) { + return null; + } - return Decimal.tryParse(decimalString)?.toAmount(fractionDigits: 2); + // get number symbols for decimal place and group separator + return Decimal.tryParse( + 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_input_formatter.dart b/lib/utilities/amount/amount_input_formatter.dart index 2ecd9fe540..8237b5049c 100644 --- a/lib/utilities/amount/amount_input_formatter.dart +++ b/lib/utilities/amount/amount_input_formatter.dart @@ -26,11 +26,42 @@ 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 = newValue.selection.isValid + ? min(newValue.selection.end, text.length) + : 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 +71,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 +83,7 @@ class AmountInputFormatter extends TextInputFormatter { return TextEditingValue( text: newText, selection: TextSelection.collapsed( - offset: newText.length - selectionIndexFromTheRight, + offset: min(selectionOffset, newText.length), ), ); } @@ -88,11 +120,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..0f1cd5ca22 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -200,9 +200,15 @@ extension AmountUnitExt on AmountUnit { String value, { required String locale, required CryptoCurrency coin, - Contract? tokenContract, + 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,25 +217,12 @@ 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 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/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/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/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/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/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/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/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/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/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, 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/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/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 00537b12b8..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'; @@ -125,6 +126,7 @@ class BitcoinFrostWallet extends Wallet .getUTXOs(walletId) .filter() .isBlockedEqualTo(false) + .group((q) => q.usedEqualTo(false).or().usedIsNull()) .findAll(); if (utxos.isEmpty) { @@ -1111,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"); diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index 3746a4836e..3e5af8a423 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'; @@ -40,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 @@ -57,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), ); @@ -931,18 +936,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 +960,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/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index d31be377f1..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), ); @@ -834,7 +838,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; 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/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..eaf21d927e 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -34,12 +34,41 @@ 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'; 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 { @@ -123,14 +152,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 +232,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 +258,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; - } - } - } 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!", - ); + 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); } - - 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 +610,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; + }, ); } @@ -1583,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; @@ -1620,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; @@ -2193,13 +2009,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 +2030,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/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart index b08b6bb426..1b12d1ed3b 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart @@ -37,6 +37,25 @@ Map _buildHeaders(String url) { return result; } +({String frontier, String representative, BigInt balanceAfterSend}) +parseNanoSendState(Map accountInfo, BigInt sendAmount) { + 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"); + } + 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 +431,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 +448,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 +460,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 +479,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/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index 6c815ec12d..a38df01671 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,29 @@ 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!); +} + +@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( @@ -570,18 +593,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; @@ -615,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/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..00c39c7b19 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: @@ -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: "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: "7.2.0" + 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: @@ -1430,7 +1446,7 @@ packages: source: hosted version: "4.12.0" json_rpc_2: - dependency: "direct overridden" + dependency: transitive description: name: json_rpc_2 sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" @@ -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: @@ -2013,18 +2029,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: @@ -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: @@ -2338,6 +2354,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 +2490,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 +2506,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 +2579,7 @@ packages: source: hosted version: "1.2.1" win32: - dependency: "direct overridden" + dependency: transitive description: name: win32 sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e @@ -2583,10 +2590,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/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/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 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 61556b0174..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 @@ -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 diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index c3ce3cbad0..059eb7afd5 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -40,6 +40,78 @@ 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); + 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); @@ -68,6 +140,66 @@ 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("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/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/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', 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); } diff --git a/test/models/exchange/incomplete_exchange_test.dart b/test/models/exchange/incomplete_exchange_test.dart new file mode 100644 index 0000000000..dd36e2b5ce --- /dev/null +++ b/test/models/exchange/incomplete_exchange_test.dart @@ -0,0 +1,71 @@ +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"); + expect(model.payInDecimal, Decimal.parse("1.23456789")); + + model.trade = _Trade(""); + expect(model.payInDecimal, isNull); + 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/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); + }); +} 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'), + ); + }); +} 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, + ); + }); +} 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, + ]); + }); +} 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); + }); +} 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"); + }); +} 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, + ); + }); +} diff --git a/test/paynym_p2tr_test.dart b/test/paynym_p2tr_test.dart index e4fd1abb0d..73ba39dece 100644 --- a/test/paynym_p2tr_test.dart +++ b/test/paynym_p2tr_test.dart @@ -2,12 +2,33 @@ 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/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:test/test.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( + 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 +64,48 @@ 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', + ]); + }); + + 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); 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( 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); 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), ) 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/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'), + ); + }); +} diff --git a/test/utilities/amount/amount_unit_test.dart b/test/utilities/amount/amount_unit_test.dart index 96a708702a..807f4e6b14 100644 --- a/test/utilities/amount/amount_unit_test.dart +++ b/test/utilities/amount/amount_unit_test.dart @@ -1,7 +1,11 @@ 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/utilities/util.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; void main() { @@ -225,4 +229,303 @@ void main() { amount, ); }); + + 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"); + 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); + }); + + 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( + TextEditingValue.empty, + const TextEditingValue(text: "1234"), + ); + expect(result.text, "1,234"); + }); } 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); +} 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, + ); + } + }); +} 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); + } + }); + }); +} 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); + }); } 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); + }); +} 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/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); + }); +} 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, + ); + }); +} diff --git a/test/wallets/nano_interface_test.dart b/test/wallets/nano_interface_test.dart new file mode 100644 index 0000000000..ca57ba5cdf --- /dev/null +++ b/test/wallets/nano_interface_test.dart @@ -0,0 +1,33 @@ +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, + ); + }); + + 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')), + ), + ); + }); +} 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)); + }); +} 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); + }); +} 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, + ); + }); +} 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"], + ); + }); +}