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/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/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"], + ); + }); +}