From a9e3c5944d598a9a128d56f0c0d996057c6ff9ad Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 7 Sep 2026 15:45:05 -0500 Subject: [PATCH 01/30] Share slatepack and relay server models between Epic and MWC The slatepack result types were identical apart from an Epic prefix, so there is now one set. The epicbox/mwcmqs server and config models keep their own classes, Hive type ids and JSON keys but inherit the common fields from RelayServerModel and RelayConfigModel. --- lib/models/epic_slatepack_models.dart | 116 ------------------ lib/models/epicbox_config_model.dart | 32 ++--- lib/models/epicbox_server_model.dart | 68 ++-------- lib/models/mwcmqs_config_model.dart | 29 ++--- lib/models/mwcmqs_server_model.dart | 68 ++-------- lib/models/relay_config_model.dart | 23 ++++ lib/models/relay_server_model.dart | 59 +++++++++ ...pack_models.dart => slatepack_models.dart} | 0 .../epic_slatepack_import_dialog.dart | 4 +- .../mwc_slatepack_import_dialog.dart | 2 +- lib/pages/send_view/send_view.dart | 5 +- .../sub_widgets/epic_slatepack_dialog.dart | 4 +- .../sub_widgets/mwc_slatepack_dialog.dart | 2 +- .../wallet_view/sub_widgets/desktop_send.dart | 5 +- lib/wallets/wallet/impl/epiccash_wallet.dart | 28 ++--- .../wallet/impl/mimblewimblecoin_wallet.dart | 2 +- 16 files changed, 147 insertions(+), 300 deletions(-) delete mode 100644 lib/models/epic_slatepack_models.dart create mode 100644 lib/models/relay_config_model.dart create mode 100644 lib/models/relay_server_model.dart rename lib/models/{mwc_slatepack_models.dart => slatepack_models.dart} (100%) diff --git a/lib/models/epic_slatepack_models.dart b/lib/models/epic_slatepack_models.dart deleted file mode 100644 index 6df4cc70cf..0000000000 --- a/lib/models/epic_slatepack_models.dart +++ /dev/null @@ -1,116 +0,0 @@ -class EpicSlatepackResult { - final bool success; - final String? error; - final String? slatepack; - final String? slateJson; - final bool? wasEncrypted; - final String? recipientAddress; - - EpicSlatepackResult({ - required this.success, - this.error, - this.slatepack, - this.slateJson, - this.wasEncrypted, - this.recipientAddress, - }); - - @override - String toString() { - return "EpicSlatepackResult(" - "success: $success, " - "error: $error, " - "slatepack: $slatepack, " - "slateJson: $slateJson, " - "wasEncrypted: $wasEncrypted, " - "recipientAddress: $recipientAddress" - ")"; - } -} - -class EpicSlatepackDecodeResult { - final bool success; - final String? error; - final String? slateJson; - final bool? wasEncrypted; - final String? senderAddress; - final String? recipientAddress; - - EpicSlatepackDecodeResult({ - required this.success, - this.error, - this.slateJson, - this.wasEncrypted, - this.senderAddress, - this.recipientAddress, - }); - - @override - String toString() { - return "EpicSlatepackDecodeResult(" - "success: $success, " - "error: $error, " - "slateJson: $slateJson, " - "wasEncrypted: $wasEncrypted, " - "senderAddress: $senderAddress, " - "recipientAddress: $recipientAddress" - ")"; - } -} - -class EpicReceiveResult { - final bool success; - final String? error; - final String? slateId; - final String? commitId; - final String? responseSlatepack; - final bool? wasEncrypted; - final String? recipientAddress; - - EpicReceiveResult({ - required this.success, - this.error, - this.slateId, - this.commitId, - this.responseSlatepack, - this.wasEncrypted, - this.recipientAddress, - }); - - @override - String toString() { - return "EpicReceiveResult(" - "success: $success, " - "error: $error, " - "slateId: $slateId, " - "commitId: $commitId, " - "responseSlatepack: $responseSlatepack, " - "wasEncrypted: $wasEncrypted, " - "recipientAddress: $recipientAddress" - ")"; - } -} - -class EpicFinalizeResult { - final bool success; - final String? error; - final String? slateId; - final String? commitId; - - EpicFinalizeResult({ - required this.success, - this.error, - this.slateId, - this.commitId, - }); - - @override - String toString() { - return "EpicFinalizeResult(" - "success: $success, " - "error: $error, " - "slateId: $slateId, " - "commitId: $commitId" - ")"; - } -} diff --git a/lib/models/epicbox_config_model.dart b/lib/models/epicbox_config_model.dart index 71fe82281b..8e6f33a35a 100644 --- a/lib/models/epicbox_config_model.dart +++ b/lib/models/epicbox_config_model.dart @@ -13,15 +13,12 @@ import 'dart:convert'; import 'package:hive_ce/hive.dart'; import 'epicbox_server_model.dart'; +import 'relay_config_model.dart'; part 'type_adaptors/epicbox_config_model.g.dart'; // @HiveType(typeId: 72) -class EpicBoxConfigModel { - // @HiveField(1) - final String host; - // @HiveField(2) - final int? port; +class EpicBoxConfigModel extends RelayConfigModel { // @HiveField(3) final bool? protocolInsecure; // @HiveField(4) @@ -31,9 +28,9 @@ class EpicBoxConfigModel { // // @HiveField(6) // final String? name; - EpicBoxConfigModel({ - required this.host, - this.port, + const EpicBoxConfigModel({ + required super.host, + super.port, this.protocolInsecure, this.addressIndex, // this.id, @@ -57,19 +54,9 @@ class EpicBoxConfigModel { ); } - Map toMap() { - final Map map = {}; - map['epicbox_domain'] = host; - map['epicbox_port'] = port; - map['epicbox_protocol_insecure'] = protocolInsecure; - map['epicbox_address_index'] = addressIndex; - // map['id'] = id; - // map['name'] = name; - return map; - } - + @override Map toJson() { - return { + return { 'epicbox_domain': host, 'epicbox_port': port, 'epicbox_protocol_insecure': protocolInsecure, @@ -79,11 +66,6 @@ class EpicBoxConfigModel { }; } - @override - String toString() { - return json.encode(toJson()); - } - static EpicBoxConfigModel fromString(String epicBoxConfigString) { final dynamic _epicBox = json.decode(epicBoxConfigString); diff --git a/lib/models/epicbox_server_model.dart b/lib/models/epicbox_server_model.dart index c0d5816733..69a383cac2 100644 --- a/lib/models/epicbox_server_model.dart +++ b/lib/models/epicbox_server_model.dart @@ -10,38 +10,24 @@ import 'package:hive_ce/hive.dart'; +import 'relay_server_model.dart'; + part 'type_adaptors/epicbox_server_model.g.dart'; // @HiveType(typeId: 71) -class EpicBoxServerModel { - // @HiveField(0) - final String id; - // @HiveField(1) - final String host; - // @HiveField(2) - final int? port; - // @HiveField(3) - final String name; - // @HiveField(4) - final bool? useSSL; - // @HiveField(5) - final bool? enabled; - // @HiveField(6) - final bool? isFailover; - // @HiveField(7) - final bool? isDown; - - EpicBoxServerModel({ - required this.id, - required this.host, - this.port, - required this.name, - this.useSSL, - this.enabled, - this.isFailover, - this.isDown, +class EpicBoxServerModel extends RelayServerModel { + const EpicBoxServerModel({ + required super.id, + required super.host, + super.port, + required super.name, + super.useSSL, + super.enabled, + super.isFailover, + super.isDown, }); + @override EpicBoxServerModel copyWith({ String? host, int? port, @@ -62,32 +48,4 @@ class EpicBoxServerModel { isDown: isDown ?? this.isDown, ); } - - Map toMap() { - final Map map = {}; - map['id'] = id; - map['host'] = host; - map['port'] = port; - map['name'] = name; - map['useSSL'] = useSSL; - map['enabled'] = enabled; - map['isFailover'] = isFailover; - map['isDown'] = isDown; - return map; - } - - bool get isDefault => id.startsWith("default_"); - - Map toJson() { - return { - 'id': id, - 'host': host, - 'port': port, - 'name': name, - 'useSSL': useSSL, - 'enabled': enabled, - 'isFailover': isFailover, - 'isDown': isDown, - }; - } } diff --git a/lib/models/mwcmqs_config_model.dart b/lib/models/mwcmqs_config_model.dart index eb37702f5d..d97e487fe2 100644 --- a/lib/models/mwcmqs_config_model.dart +++ b/lib/models/mwcmqs_config_model.dart @@ -13,40 +13,25 @@ import 'dart:convert'; import 'package:hive_ce/hive.dart'; import 'mwcmqs_server_model.dart'; +import 'relay_config_model.dart'; part 'type_adaptors/mwcmqs_config_model.g.dart'; @HiveType(typeId: 82) -class MwcMqsConfigModel { - @HiveField(1) - final String host; - @HiveField(2) - final int? port; - - MwcMqsConfigModel({required this.host, this.port}); +class MwcMqsConfigModel extends RelayConfigModel { + const MwcMqsConfigModel({required super.host, super.port}); MwcMqsConfigModel copyWith({int? port, bool? protocolInsecure}) { return MwcMqsConfigModel(host: host, port: this.port ?? 443); } - Map toMap() { - final Map map = {}; - map['mwcmqs_domain'] = host; - map['mwcmqs_port'] = port; - return map; - } - - Map toJson() { - return {'mwcmqs_domain': host, 'mwcmqs_port': port}; - } - @override - String toString() { - return json.encode(toJson()); + Map toJson() { + return {'mwcmqs_domain': host, 'mwcmqs_port': port}; } - static MwcMqsConfigModel fromString(String MwcMqsConfigString) { - final dynamic _mwcmqs = json.decode(MwcMqsConfigString); + static MwcMqsConfigModel fromString(String mwcMqsConfigString) { + final dynamic _mwcmqs = json.decode(mwcMqsConfigString); final oldDomain = _mwcmqs["domain"] ?? "empty"; if (oldDomain != "empty") { diff --git a/lib/models/mwcmqs_server_model.dart b/lib/models/mwcmqs_server_model.dart index 9b5bd5d27c..d4f2139ad8 100644 --- a/lib/models/mwcmqs_server_model.dart +++ b/lib/models/mwcmqs_server_model.dart @@ -10,38 +10,24 @@ import 'package:hive_ce/hive.dart'; +import 'relay_server_model.dart'; + part 'type_adaptors/mwcmqs_server_model.g.dart'; @HiveType(typeId: 81) -class MwcMqsServerModel { - @HiveField(0) - final String id; - @HiveField(1) - final String host; - @HiveField(2) - final int? port; - @HiveField(3) - final String name; - @HiveField(4) - final bool? useSSL; - @HiveField(5) - final bool? enabled; - @HiveField(6) - final bool? isFailover; - @HiveField(7) - final bool? isDown; - - MwcMqsServerModel({ - required this.id, - required this.host, - this.port, - required this.name, - this.useSSL, - this.enabled, - this.isFailover, - this.isDown, +class MwcMqsServerModel extends RelayServerModel { + const MwcMqsServerModel({ + required super.id, + required super.host, + super.port, + required super.name, + super.useSSL, + super.enabled, + super.isFailover, + super.isDown, }); + @override MwcMqsServerModel copyWith({ String? host, int? port, @@ -62,32 +48,4 @@ class MwcMqsServerModel { isDown: isDown ?? this.isDown, ); } - - Map toMap() { - final Map map = {}; - map['id'] = id; - map['host'] = host; - map['port'] = port; - map['name'] = name; - map['useSSL'] = useSSL; - map['enabled'] = enabled; - map['isFailover'] = isFailover; - map['isDown'] = isDown; - return map; - } - - bool get isDefault => id.startsWith("default_"); - - Map toJson() { - return { - 'id': id, - 'host': host, - 'port': port, - 'name': name, - 'useSSL': useSSL, - 'enabled': enabled, - 'isFailover': isFailover, - 'isDown': isDown, - }; - } } diff --git a/lib/models/relay_config_model.dart b/lib/models/relay_config_model.dart new file mode 100644 index 0000000000..2269f07bf9 --- /dev/null +++ b/lib/models/relay_config_model.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; + +import 'package:hive_ce/hive.dart'; + +/// Relay server settings handed to the native wallet as a JSON string. +/// +/// The JSON keys are dictated by the native library, so each coin encodes +/// its own. +abstract class RelayConfigModel { + @HiveField(1) + final String host; + @HiveField(2) + final int? port; + + const RelayConfigModel({required this.host, this.port}); + + Map toJson(); + + Map toMap() => toJson(); + + @override + String toString() => json.encode(toJson()); +} diff --git a/lib/models/relay_server_model.dart b/lib/models/relay_server_model.dart new file mode 100644 index 0000000000..8cd46a22c7 --- /dev/null +++ b/lib/models/relay_server_model.dart @@ -0,0 +1,59 @@ +import 'package:hive_ce/hive.dart'; + +/// A slate relay server: an epicbox for Epic Cash, an mwcmqs for MWC. +abstract class RelayServerModel { + @HiveField(0) + final String id; + @HiveField(1) + final String host; + @HiveField(2) + final int? port; + @HiveField(3) + final String name; + @HiveField(4) + final bool? useSSL; + @HiveField(5) + final bool? enabled; + @HiveField(6) + final bool? isFailover; + @HiveField(7) + final bool? isDown; + + const RelayServerModel({ + required this.id, + required this.host, + this.port, + required this.name, + this.useSSL, + this.enabled, + this.isFailover, + this.isDown, + }); + + RelayServerModel copyWith({ + String? host, + int? port, + String? name, + bool? useSSL, + bool? enabled, + bool? isFailover, + bool? isDown, + }); + + bool get isDefault => id.startsWith("default_"); + + Map toMap() => toJson(); + + Map toJson() { + return { + 'id': id, + 'host': host, + 'port': port, + 'name': name, + 'useSSL': useSSL, + 'enabled': enabled, + 'isFailover': isFailover, + 'isDown': isDown, + }; + } +} diff --git a/lib/models/mwc_slatepack_models.dart b/lib/models/slatepack_models.dart similarity index 100% rename from lib/models/mwc_slatepack_models.dart rename to lib/models/slatepack_models.dart diff --git a/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart b/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart index 769a58a94f..c67ce45641 100644 --- a/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart +++ b/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../models/epic_slatepack_models.dart'; +import '../../../models/slatepack_models.dart'; import '../../../providers/global/wallets_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; @@ -36,7 +36,7 @@ class EpicSlatepackImportDialog extends ConsumerStatefulWidget { final String walletId; final String rawSlatepack; - final EpicSlatepackDecodeResult decoded; + final SlatepackDecodeResult decoded; final String slatepackType; final ClipboardInterface clipboard; diff --git a/lib/pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart b/lib/pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart index 6b1bc060d8..d30490c693 100644 --- a/lib/pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart +++ b/lib/pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../models/mwc_slatepack_models.dart'; +import '../../../models/slatepack_models.dart'; import '../../../providers/global/wallets_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 91a9e4520e..cde088df61 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -19,10 +19,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:tuple/tuple.dart'; -import '../../models/epic_slatepack_models.dart'; +import '../../models/slatepack_models.dart'; import '../../models/input.dart'; import '../../models/isar/models/isar_models.dart'; -import '../../models/mwc_slatepack_models.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../models/send_view_auto_fill_data.dart'; import '../../notifications/show_flush_bar.dart'; @@ -979,7 +978,7 @@ class _SendViewState extends ConsumerState { final amount = ref.read(pSendAmount)!; - Future wrappedFutureWithDelay() async { + Future wrappedFutureWithDelay() async { await Future.delayed(const Duration(seconds: 1)); return wallet.createSlatepack( amount: amount, diff --git a/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart b/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart index 7bcf97022f..26ec1d76fd 100644 --- a/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart +++ b/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import '../../../models/epic_slatepack_models.dart'; +import '../../../models/slatepack_models.dart'; import '../../../notifications/show_flush_bar.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; @@ -24,7 +24,7 @@ class EpicSlatepackDialog extends ConsumerStatefulWidget { this.clipboard = const ClipboardWrapper(), }); - final EpicSlatepackResult slatepackResult; + final SlatepackResult slatepackResult; final ClipboardInterface clipboard; @override diff --git a/lib/pages/send_view/sub_widgets/mwc_slatepack_dialog.dart b/lib/pages/send_view/sub_widgets/mwc_slatepack_dialog.dart index ec57cf0a53..a9d15be3a0 100644 --- a/lib/pages/send_view/sub_widgets/mwc_slatepack_dialog.dart +++ b/lib/pages/send_view/sub_widgets/mwc_slatepack_dialog.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import '../../../models/mwc_slatepack_models.dart'; +import '../../../models/slatepack_models.dart'; import '../../../notifications/show_flush_bar.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; 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 77a78bfff4..95d7740a3c 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 @@ -18,11 +18,10 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import '../../../../models/epic_slatepack_models.dart'; +import '../../../../models/slatepack_models.dart'; import '../../../../models/isar/models/blockchain_data/address.dart'; import '../../../../models/isar/models/blockchain_data/utxo.dart'; import '../../../../models/isar/models/contact_entry.dart'; -import '../../../../models/mwc_slatepack_models.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; import '../../../../notifications/show_flush_bar.dart'; @@ -334,7 +333,7 @@ class _DesktopSendState extends ConsumerState { try { final amount = ref.read(pSendAmount)!; - Future wrappedFutureWithDelay() async { + Future wrappedFutureWithDelay() async { await Future.delayed(const Duration(seconds: 1)); return wallet.createSlatepack( amount: amount, diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index 4212299207..b0f4b0d6fc 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -12,7 +12,7 @@ import 'package:web_socket_channel/web_socket_channel.dart'; import '../../../exceptions/main_db/main_db_exception.dart'; import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; import '../../../models/balance.dart'; -import '../../../models/epic_slatepack_models.dart'; +import '../../../models/slatepack_models.dart'; import '../../../models/epicbox_config_model.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/isar/models/blockchain_data/transaction.dart'; @@ -191,7 +191,7 @@ class EpiccashWallet extends Bip39Wallet { // ================= Slatepack Operations =================================== /// Create a slatepack for sending Epic Cash. - Future createSlatepack({ + Future createSlatepack({ required Amount amount, String? recipientAddress, String? message, @@ -214,7 +214,7 @@ class EpiccashWallet extends Bip39Wallet { returnSlate: true, ); - return EpicSlatepackResult( + return SlatepackResult( success: true, slatepack: result.slateJson, slateJson: result.slateJson, @@ -223,18 +223,18 @@ class EpiccashWallet extends Bip39Wallet { ); } catch (e, s) { Logging.instance.e('Failed to create slatepack: $e\n$s'); - return EpicSlatepackResult(success: false, error: e.toString()); + return SlatepackResult(success: false, error: e.toString()); } } /// Decode a slatepack/slate JSON. - Future decodeSlatepack(String slateJson) async { + Future decodeSlatepack(String slateJson) async { try { // For Epic Cash, slates are already JSON, so we parse directly. // Validate that the JSON is valid. jsonDecode(slateJson); - return EpicSlatepackDecodeResult( + return SlatepackDecodeResult( success: true, slateJson: slateJson, wasEncrypted: false, @@ -243,12 +243,12 @@ class EpiccashWallet extends Bip39Wallet { ); } catch (e, s) { Logging.instance.e('Failed to decode slatepack: $e\n$s'); - return EpicSlatepackDecodeResult(success: false, error: e.toString()); + return SlatepackDecodeResult(success: false, error: e.toString()); } } /// Full decode of a slatepack including type analysis. - Future<({EpicSlatepackDecodeResult result, String type, String raw})?> + Future<({SlatepackDecodeResult result, String type, String raw})?> fullDecodeSlatepack(String slateJson) async { // Add delay for showloading exception catching hack fix. await Future.delayed(const Duration(seconds: 1)); @@ -277,7 +277,7 @@ class EpiccashWallet extends Bip39Wallet { } /// Receive a slatepack and return response slate JSON. - Future receiveSlatepack(String slateJson) async { + Future receiveSlatepack(String slateJson) async { try { _hackedCheckTorNodePrefs(); if (_wallet == null) { @@ -290,7 +290,7 @@ class EpiccashWallet extends Bip39Wallet { slateJson: slateJson, ); - return EpicReceiveResult( + return ReceiveResult( success: true, slateId: received.slateId, commitId: received.commitId, @@ -300,12 +300,12 @@ class EpiccashWallet extends Bip39Wallet { ); } catch (e, s) { Logging.instance.e('Failed to receive slatepack: $e\n$s'); - return EpicReceiveResult(success: false, error: e.toString()); + return ReceiveResult(success: false, error: e.toString()); } } /// Finalize a slatepack (sender step 3). - Future finalizeSlatepack(String slateJson) async { + Future finalizeSlatepack(String slateJson) async { try { _hackedCheckTorNodePrefs(); if (_wallet == null) { @@ -318,14 +318,14 @@ class EpiccashWallet extends Bip39Wallet { slateJson: slateJson, ); - return EpicFinalizeResult( + return FinalizeResult( success: true, slateId: finalized.slateId, commitId: finalized.commitId, ); } catch (e, s) { Logging.instance.e('Failed to finalize slatepack: $e\n$s'); - return EpicFinalizeResult(success: false, error: e.toString()); + return FinalizeResult(success: false, error: e.toString()); } } diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index 721773da6d..7afa331577 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -14,7 +14,7 @@ import '../../../models/isar/models/blockchain_data/transaction.dart'; import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; -import '../../../models/mwc_slatepack_models.dart'; +import '../../../models/slatepack_models.dart'; import '../../../models/mwcmqs_config_model.dart'; import '../../../models/node_model.dart'; import '../../../models/paymint/fee_object_model.dart'; From ea154c9a521653fd99a079df9c0f4b6ce432ca52 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 7 Sep 2026 15:46:16 -0500 Subject: [PATCH 02/30] Share the native transaction model between libepiccash and libmwc EpicTransaction/MwcTransaction and their message types had the same fields. Both native interfaces now return MimblewimbleTransaction; the templates in tool/wl_templates construct it, so regenerate lib/wl_gen after pulling this. --- .../interfaces/libepiccash_interface.dart | 89 +------------------ lib/wl_gen/interfaces/libmwc_interface.dart | 61 +------------ .../interfaces/mimblewimble_transaction.dart | 87 ++++++++++++++++++ ...C_libepiccash_interface_impl.template.dart | 6 +- .../MWC_libmwc_interface_impl.template.dart | 6 +- 5 files changed, 100 insertions(+), 149 deletions(-) create mode 100644 lib/wl_gen/interfaces/mimblewimble_transaction.dart diff --git a/lib/wl_gen/interfaces/libepiccash_interface.dart b/lib/wl_gen/interfaces/libepiccash_interface.dart index d6ab18565f..25db0b6f75 100644 --- a/lib/wl_gen/interfaces/libepiccash_interface.dart +++ b/lib/wl_gen/interfaces/libepiccash_interface.dart @@ -1,8 +1,8 @@ -import 'dart:math'; - import '../../utilities/dynamic_object.dart'; +import 'mimblewimble_transaction.dart'; export '../generated/libepiccash_interface_impl.dart'; +export 'mimblewimble_transaction.dart'; abstract class LibEpicCashInterface { const LibEpicCashInterface(); @@ -68,7 +68,7 @@ abstract class LibEpicCashInterface { required String transactionId, }); - Future> getTransactions({ + Future> getTransactions({ required DynamicObject wallet, required int refreshFromNode, }); @@ -129,87 +129,4 @@ abstract class LibEpicCashInterface { void updateConfig({required DynamicObject wallet, required String config}); } -class EpicTransaction { - final String parentKeyId; - final int id; - final String? txSlateId; - final Enum txType; - final String creationTs; - final String confirmationTs; - final bool confirmed; - final int numInputs; - final int numOutputs; - final String amountCredited; - final String amountDebited; - final String? fee; - final String? ttlCutoffHeight; - final List? messages; - final String? storedTx; - final String? kernelExcess; - final int? kernelLookupMinHeight; - final String? paymentProof; - - EpicTransaction({ - required this.parentKeyId, - required this.id, - this.txSlateId, - required this.txType, - required this.creationTs, - required this.confirmationTs, - required this.confirmed, - required this.numInputs, - required this.numOutputs, - required this.amountCredited, - required this.amountDebited, - this.fee, - this.ttlCutoffHeight, - this.messages, - this.storedTx, - this.kernelExcess, - this.kernelLookupMinHeight, - this.paymentProof, - }); - - @override - String toString() { - return 'EpicTransaction(' - 'id: $id, ' - 'txSlateId: $txSlateId, ' - 'type: $txType, ' - 'confirmed: $confirmed, ' - 'inputs: $numInputs, ' - 'outputs: $numOutputs, ' - 'credited: $amountCredited, ' - 'debited: $amountDebited, ' - 'fee: $fee, ' - 'created: $creationTs, ' - 'confirmed: $confirmationTs, ' - 'messages: ${messages?.length ?? 0}' - ')'; - } -} - -class EpicMessage { - final String id; - final String publicKey; - final String? message; - final String? messageSig; - - EpicMessage({ - required this.id, - required this.publicKey, - this.message, - this.messageSig, - }); - - @override - String toString() { - return 'EpicMessage(' - 'id: $id, ' - 'publicKey: ${publicKey.substring(0, 8)}..., ' - 'message: ${message != null ? '"${message!.substring(0, min(20, message!.length))}..."' : 'null'}' - ')'; - } -} - class BadHttpAddressException implements Exception {} diff --git a/lib/wl_gen/interfaces/libmwc_interface.dart b/lib/wl_gen/interfaces/libmwc_interface.dart index 52608da9b9..3c17c41736 100644 --- a/lib/wl_gen/interfaces/libmwc_interface.dart +++ b/lib/wl_gen/interfaces/libmwc_interface.dart @@ -1,4 +1,7 @@ +import 'mimblewimble_transaction.dart'; + export '../generated/libmwc_interface_impl.dart'; +export 'mimblewimble_transaction.dart'; abstract class LibMwcInterface { const LibMwcInterface(); @@ -55,7 +58,7 @@ abstract class LibMwcInterface { String message = '', }); - Future> getTransactions({ + Future> getTransactions({ required String wallet, required int refreshFromNode, }); @@ -142,59 +145,3 @@ abstract class LibMwcInterface { Future initLogs({required String config}); } - -class MwcTransaction { - final String parentKeyId; - final int id; - final String? txSlateId; - final Enum txType; - final String creationTs; - final String confirmationTs; - final bool confirmed; - final int numInputs; - final int numOutputs; - final String amountCredited; - final String amountDebited; - final String? fee; - final String? ttlCutoffHeight; - final List? messages; - final String? storedTx; - final String? kernelExcess; - final int? kernelLookupMinHeight; - final String? paymentProof; - - MwcTransaction({ - required this.parentKeyId, - required this.id, - this.txSlateId, - required this.txType, - required this.creationTs, - required this.confirmationTs, - required this.confirmed, - required this.numInputs, - required this.numOutputs, - required this.amountCredited, - required this.amountDebited, - this.fee, - this.ttlCutoffHeight, - this.messages, - this.storedTx, - this.kernelExcess, - this.kernelLookupMinHeight, - this.paymentProof, - }); -} - -class MwcMessage { - final String id; - final String publicKey; - final String? message; - final String? messageSig; - - MwcMessage({ - required this.id, - required this.publicKey, - this.message, - this.messageSig, - }); -} diff --git a/lib/wl_gen/interfaces/mimblewimble_transaction.dart b/lib/wl_gen/interfaces/mimblewimble_transaction.dart new file mode 100644 index 0000000000..8eb1778b83 --- /dev/null +++ b/lib/wl_gen/interfaces/mimblewimble_transaction.dart @@ -0,0 +1,87 @@ +import 'dart:math'; + +class MimblewimbleTransaction { + final String parentKeyId; + final int id; + final String? txSlateId; + final Enum txType; + final String creationTs; + final String confirmationTs; + final bool confirmed; + final int numInputs; + final int numOutputs; + final String amountCredited; + final String amountDebited; + final String? fee; + final String? ttlCutoffHeight; + final List? messages; + final String? storedTx; + final String? kernelExcess; + final int? kernelLookupMinHeight; + final String? paymentProof; + + MimblewimbleTransaction({ + required this.parentKeyId, + required this.id, + this.txSlateId, + required this.txType, + required this.creationTs, + required this.confirmationTs, + required this.confirmed, + required this.numInputs, + required this.numOutputs, + required this.amountCredited, + required this.amountDebited, + this.fee, + this.ttlCutoffHeight, + this.messages, + this.storedTx, + this.kernelExcess, + this.kernelLookupMinHeight, + this.paymentProof, + }); + + @override + String toString() { + return 'MimblewimbleTransaction(' + 'id: $id, ' + 'txSlateId: $txSlateId, ' + 'type: $txType, ' + 'confirmed: $confirmed, ' + 'inputs: $numInputs, ' + 'outputs: $numOutputs, ' + 'credited: $amountCredited, ' + 'debited: $amountDebited, ' + 'fee: $fee, ' + 'created: $creationTs, ' + 'confirmed: $confirmationTs, ' + 'messages: ${messages?.length ?? 0}' + ')'; + } +} + +class MimblewimbleMessage { + final String id; + final String publicKey; + final String? message; + final String? messageSig; + + MimblewimbleMessage({ + required this.id, + required this.publicKey, + this.message, + this.messageSig, + }); + + @override + String toString() { + final preview = message == null + ? 'null' + : '"${message!.substring(0, min(20, message!.length))}..."'; + return 'MimblewimbleMessage(' + 'id: $id, ' + 'publicKey: ${publicKey.substring(0, 8)}..., ' + 'message: $preview' + ')'; + } +} diff --git a/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart b/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart index 415c447976..0e795e5bdb 100644 --- a/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart +++ b/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart @@ -113,7 +113,7 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { } @override - Future> getTransactions({ + Future> getTransactions({ required DynamicObject wallet, required int refreshFromNode, }) async { @@ -123,7 +123,7 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { return transactions .map( - (e) => EpicTransaction( + (e) => MimblewimbleTransaction( parentKeyId: e.parentKeyId, id: e.id, txType: e.txType, @@ -139,7 +139,7 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { ttlCutoffHeight: e.ttlCutoffHeight, messages: e.messages?.messages .map( - (f) => EpicMessage( + (f) => MimblewimbleMessage( id: f.id, publicKey: f.publicKey, message: f.message, diff --git a/tool/wl_templates/MWC_libmwc_interface_impl.template.dart b/tool/wl_templates/MWC_libmwc_interface_impl.template.dart index 01dc5c123a..1caf699a75 100644 --- a/tool/wl_templates/MWC_libmwc_interface_impl.template.dart +++ b/tool/wl_templates/MWC_libmwc_interface_impl.template.dart @@ -137,7 +137,7 @@ final class _LibMwcInterfaceImpl extends LibMwcInterface { } @override - Future> getTransactions({ + Future> getTransactions({ required String wallet, required int refreshFromNode, }) async { @@ -148,7 +148,7 @@ final class _LibMwcInterfaceImpl extends LibMwcInterface { return transactions .map( - (e) => MwcTransaction( + (e) => MimblewimbleTransaction( parentKeyId: e.parentKeyId, id: e.id, txType: e.txType, @@ -164,7 +164,7 @@ final class _LibMwcInterfaceImpl extends LibMwcInterface { ttlCutoffHeight: e.ttlCutoffHeight, messages: e.messages?.messages .map( - (f) => MwcMessage( + (f) => MimblewimbleMessage( id: f.id, publicKey: f.publicKey, message: f.message, From 4757d21555a16f8c3f2339fe7d89a8fd3611a4ea Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 7 Sep 2026 15:54:19 -0500 Subject: [PATCH 03/30] Add MimblewimbleWallet base for Epic Cash and MWC Both wallets carried the same sync percent, scan loop, refresh sequence, balance parsing, slate analysis and wallet info bookkeeping, differing only in how they talk to their native library. The shared parts now live in an abstract MimblewimbleWallet with hooks for the native calls, and the slatepack/relay API (createSlatepack, receiveSlatepack, finalizeSlatepack, getRelayConfig, ...) is declared there so UI can be written against one type. ExtraEpiccashWalletInfo and ExtraMimblewimblecoinWalletInfo had the same JSON shape and are now one class; each wallet still stores it under its own WalletInfoKeys entry. --- lib/db/migrate_wallets_to_isar.dart | 36 +- .../restore_wallet_view.dart | 7 +- .../epicbox_settings/manage_epicbox_view.dart | 2 +- .../wallet_network_settings_view.dart | 14 +- .../wallet_settings_view.dart | 15 +- .../edit_refresh_height_view.dart | 7 +- .../desktop_manage_epicbox_dialog.dart | 2 +- lib/wallets/wallet/impl/epiccash_wallet.dart | 912 ++++-------------- .../wallet/impl/mimblewimblecoin_wallet.dart | 836 ++++------------ .../intermediate/mimblewimble_wallet.dart | 614 ++++++++++++ ...> mimblewimble_wallet_info_extension.dart} | 31 +- ...imblewimblecoin_wallet_info_extension.dart | 111 --- 12 files changed, 1041 insertions(+), 1546 deletions(-) create mode 100644 lib/wallets/wallet/intermediate/mimblewimble_wallet.dart rename lib/wallets/wallet/supporting/{epiccash_wallet_info_extension.dart => mimblewimble_wallet_info_extension.dart} (76%) delete mode 100644 lib/wallets/wallet/supporting/mimblewimblecoin_wallet_info_extension.dart diff --git a/lib/db/migrate_wallets_to_isar.dart b/lib/db/migrate_wallets_to_isar.dart index de9cff6ddf..5ee6cad6e4 100644 --- a/lib/db/migrate_wallets_to_isar.dart +++ b/lib/db/migrate_wallets_to_isar.dart @@ -10,7 +10,7 @@ import '../wallets/crypto_currency/crypto_currency.dart'; import '../wallets/isar/models/token_wallet_info.dart'; import '../wallets/isar/models/wallet_info.dart'; import '../wallets/isar/models/wallet_info_meta.dart'; -import '../wallets/wallet/supporting/epiccash_wallet_info_extension.dart'; +import '../wallets/wallet/supporting/mimblewimble_wallet_info_extension.dart'; import 'hive/db.dart'; import 'isar/main_db.dart'; @@ -53,10 +53,9 @@ Future migrateWalletsToIsar({ // // Get current ordered list of favourite wallet Ids // - final List favourites = - (await DB.instance.hive.openBox( - DB.boxNameFavoriteWallets, - )).values.toList(); + final List favourites = (await DB.instance.hive.openBox( + DB.boxNameFavoriteWallets, + )).values.toList(); final List<(WalletInfo, WalletInfoMeta)> newInfo = []; final List tokenInfo = []; @@ -71,11 +70,10 @@ Future migrateWalletsToIsar({ // // First handle transaction notes // - final newNoteCount = - await MainDB.instance.isar.transactionNotes - .where() - .walletIdEqualTo(old.walletId) - .count(); + final newNoteCount = await MainDB.instance.isar.transactionNotes + .where() + .walletIdEqualTo(old.walletId) + .count(); if (newNoteCount == 0) { final map = walletBox.get('notes') as Map?; @@ -115,11 +113,10 @@ Future migrateWalletsToIsar({ otherData[WalletInfoKeys.tokenContractAddresses] = tokenContractAddresses; for (final address in tokenContractAddresses!) { - final contract = - await MainDB.instance.isar.ethContracts - .where() - .addressEqualTo(address) - .findFirst(); + final contract = await MainDB.instance.isar.ethContracts + .where() + .addressEqualTo(address) + .findFirst(); if (contract != null) { tokenInfo.add( TokenWalletInfo( @@ -134,7 +131,7 @@ Future migrateWalletsToIsar({ // epiccash specifics if (old.coinIdentifier == Epiccash(CryptoCurrencyNetwork.main).identifier) { - final epicWalletInfo = ExtraEpiccashWalletInfo.fromMap({ + final epicWalletInfo = ExtraMimblewimbleWalletInfo.fromMap({ "receivingIndex": walletBox.get("receivingIndex") as int? ?? 0, "changeIndex": walletBox.get("changeIndex") as int? ?? 0, "slatesToAddresses": walletBox.get("slate_to_address") as Map? ?? {}, @@ -165,10 +162,9 @@ Future migrateWalletsToIsar({ coinName: old.coinIdentifier, walletId: old.walletId, name: old.name, - mainAddressType: - AppConfig.getCryptoCurrencyFor( - old.coinIdentifier, - )!.defaultAddressType, + mainAddressType: AppConfig.getCryptoCurrencyFor( + old.coinIdentifier, + )!.defaultAddressType, favouriteOrderIndex: favourites.indexOf(old.walletId), cachedChainHeight: walletBox.get(DBKeys.storedChainHeight) as int? ?? 0, cachedBalanceString: walletBox.get(DBKeys.cachedBalance) as String?, diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart index a1ea19c405..fead2171be 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart @@ -48,8 +48,7 @@ import '../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../../wallets/wallet/impl/xelis_wallet.dart'; import '../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../wallets/wallet/intermediate/external_wallet.dart'; -import '../../../wallets/wallet/supporting/epiccash_wallet_info_extension.dart'; -import '../../../wallets/wallet/supporting/mimblewimblecoin_wallet_info_extension.dart'; +import '../../../wallets/wallet/supporting/mimblewimble_wallet_info_extension.dart'; import '../../../wallets/wallet/wallet.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/desktop/desktop_app_bar.dart'; @@ -226,7 +225,7 @@ class _RestoreWalletViewState extends ConsumerState { if (widget.coin is Epiccash) { otherDataJsonString = jsonEncode({ WalletInfoKeys.epiccashData: jsonEncode( - ExtraEpiccashWalletInfo( + ExtraMimblewimbleWalletInfo( receivingIndex: 0, changeIndex: 0, slatesToAddresses: {}, @@ -254,7 +253,7 @@ class _RestoreWalletViewState extends ConsumerState { } otherDataJsonString = jsonEncode({ WalletInfoKeys.mimblewimblecoinData: jsonEncode( - ExtraMimblewimblecoinWalletInfo( + ExtraMimblewimbleWalletInfo( receivingIndex: 0, changeIndex: 0, slatesToAddresses: {}, diff --git a/lib/pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart b/lib/pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart index 797ce8e019..6b642207a6 100644 --- a/lib/pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/epicbox_settings/manage_epicbox_view.dart @@ -61,7 +61,7 @@ class _ManageEpicboxViewState extends ConsumerState { // update wallet's epicbox config final wallet = ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; - await wallet.updateEpicboxConfig(epicBox.host, epicBox.port ?? 443); + await wallet.updateRelayConfig(epicBox.host, epicBox.port ?? 443); if (mounted) { unawaited( diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart index 3eccb79285..faf54888c5 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart @@ -42,7 +42,7 @@ import '../../../../wallets/crypto_currency/coins/wownero.dart'; import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; -import '../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; @@ -354,17 +354,9 @@ class _WalletNetworkSettingsViewState if (_percent < highestPercent) { _percent = highestPercent.clamp(0.0, 1.0); } - } else if (coin is Epiccash) { + } else if (coin is Epiccash || coin is Mimblewimblecoin) { final double highestPercent = - (ref.watch(pWallets).getWallet(widget.walletId) as EpiccashWallet) - .highestPercent; - if (_percent < highestPercent) { - _percent = highestPercent.clamp(0.0, 1.0); - } - } else if (coin is Mimblewimblecoin) { - final double highestPercent = - (ref.watch(pWallets).getWallet(widget.walletId) - as MimblewimblecoinWallet) + (ref.watch(pWallets).getWallet(widget.walletId) as MimblewimbleWallet) .highestPercent; if (_percent < highestPercent) { _percent = highestPercent.clamp(0.0, 1.0); diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart index 4fd2e8f95d..3fcdd648a0 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_view.dart @@ -417,10 +417,9 @@ class _WalletSettingsViewState extends ConsumerState { iconSize: 16, title: "Epicbox Servers", onPressed: () { - Navigator.of(context).pushNamed( - ManageEpicboxView.routeName, - arguments: walletId, - ); + Navigator.of( + context, + ).pushNamed(ManageEpicboxView.routeName, arguments: walletId); }, ), if (canBackup) const SizedBox(height: 8), @@ -651,7 +650,7 @@ class _EpiBoxInfoFormState extends ConsumerState { void initState() { wallet = ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; - wallet.getEpicBoxConfig().then((EpicBoxConfigModel epicBoxConfig) { + wallet.getRelayConfig().then((EpicBoxConfigModel epicBoxConfig) { hostController.text = epicBoxConfig.host; portController.text = "${epicBoxConfig.port ?? 443}"; }); @@ -691,7 +690,7 @@ class _EpiBoxInfoFormState extends ConsumerState { TextButton( onPressed: () async { try { - await wallet.updateEpicboxConfig( + await wallet.updateRelayConfig( hostController.text, int.parse(portController.text), ); @@ -748,7 +747,7 @@ class _MwcmqsInfoFormState extends ConsumerState { wallet = ref.read(pWallets).getWallet(widget.walletId) as MimblewimblecoinWallet; - wallet.getMwcMqsConfig().then((MwcMqsConfigModel mwcmqsConfig) { + wallet.getRelayConfig().then((MwcMqsConfigModel mwcmqsConfig) { hostController.text = mwcmqsConfig.host; portController.text = "${mwcmqsConfig.port ?? 443}"; }); @@ -788,7 +787,7 @@ class _MwcmqsInfoFormState extends ConsumerState { TextButton( onPressed: () async { try { - await wallet.updateMwcmqsConfig( + await wallet.updateRelayConfig( hostController.text, int.parse(portController.text), ); diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart index 05d51ac80e..6b5c7b3eab 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart @@ -13,7 +13,6 @@ import '../../../../utilities/util.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; -import '../../../../wallets/wallet/supporting/epiccash_wallet_info_extension.dart'; import '../../../../widgets/background.dart'; import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -104,11 +103,7 @@ class _EditRefreshHeightViewState extends ConsumerState { _controller = TextEditingController(); final wallet = ref.read(pWallets).getWallet(widget.walletId); if (wallet is EpiccashWallet) { - _controller.text = ref - .read(pWalletInfo(widget.walletId)) - .epicData! - .restoreHeight - .toString(); + _controller.text = wallet.mimblewimbleData!.restoreHeight.toString(); } else if (wallet is CryptonoteWallet && wallet.wallet != null) { wallet.getRefreshFromBlockHeight().then((height) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart index 5dab7e2d55..a2a78a2182 100644 --- a/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart +++ b/lib/pages_desktop_specific/settings/settings_menu/epicbox_settings/desktop_manage_epicbox_dialog.dart @@ -62,7 +62,7 @@ class _DesktopManageEpicBoxDialogState // update wallet's epicbox config final wallet = ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; - await wallet.updateEpicboxConfig(epicBox.host, epicBox.port ?? 443); + await wallet.updateRelayConfig(epicBox.host, epicBox.port ?? 443); if (mounted) { unawaited( diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index b0f4b0d6fc..a3ac6b96ff 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -1,18 +1,14 @@ import 'dart:async'; 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'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../../../exceptions/main_db/main_db_exception.dart'; import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; -import '../../../models/balance.dart'; -import '../../../models/slatepack_models.dart'; import '../../../models/epicbox_config_model.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/isar/models/blockchain_data/transaction.dart'; @@ -21,105 +17,44 @@ import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/transaction_note.dart'; import '../../../models/node_model.dart'; -import '../../../models/paymint/fee_object_model.dart'; +import '../../../models/slatepack_models.dart'; import '../../../pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; -import '../../../services/event_bus/events/global/blocks_remaining_event.dart'; -import '../../../services/event_bus/events/global/node_connection_status_changed_event.dart'; -import '../../../services/event_bus/events/global/refresh_percent_changed_event.dart'; -import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; -import '../../../services/event_bus/global_event_bus.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/default_epicboxes.dart'; import '../../../utilities/dynamic_object.dart'; import '../../../utilities/flutter_secure_storage_interface.dart'; import '../../../utilities/logger.dart'; -import '../../../utilities/stack_file_system.dart'; import '../../../utilities/test_epic_box_connection.dart'; import '../../../utilities/tor_plain_net_option_enum.dart'; import '../../../wl_gen/interfaces/libepiccash_interface.dart'; import '../../crypto_currency/crypto_currency.dart'; +import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; -import '../intermediate/bip39_wallet.dart'; -import '../supporting/epiccash_wallet_info_extension.dart'; -import '../supporting/restore_progress.dart'; +import '../intermediate/mimblewimble_wallet.dart'; +import '../supporting/mimblewimble_wallet_info_extension.dart'; // // refactor of https://github.com/cypherstack/stack_wallet/blob/1d9fb4cd069f22492ece690ac788e05b8f8b1209/lib/services/coins/epiccash/epiccash_wallet.dart // -class EpiccashWallet extends Bip39Wallet { +class EpiccashWallet extends MimblewimbleWallet { EpiccashWallet(CryptoCurrencyNetwork network) : super(Epiccash(network)); - final syncMutex = Mutex(); NodeModel? _epicNode; - Timer? timer; DynamicObject? _wallet; - double highestPercent = 0; - Future get getSyncPercent async { - final int lastScannedBlock = info.epicData?.lastScannedBlock ?? 0; - final _chainHeight = await chainHeight; - final restorePercent = calculateRestoreProgress( - scannedHeight: lastScannedBlock, - chainHeight: _chainHeight, - ); - GlobalEventBus.instance.fire( - RefreshPercentChangedEvent(highestPercent, walletId), - ); - if (restorePercent > highestPercent) { - highestPercent = restorePercent; - } + @override + String get walletInfoDataKey => WalletInfoKeys.epiccashData; - final int blocksRemaining = _chainHeight - lastScannedBlock; - GlobalEventBus.instance.fire( - BlocksRemainingEvent(blocksRemaining, walletId), - ); + @override + String get walletDirName => "epiccash"; - return restorePercent < 0 ? 0.0 : restorePercent; - } + @override + int get firstBlockTimestamp => 1565370278; - /// Opens and initializes the Epic wallet instance. - /// Should only be called once during wallet initialization. - // Future open() async { - // if (_wallet != null) { - // Logging.instance.d("Wallet already open, ensuring listener"); - // if (!await libEpic.isEpicboxListenerRunning(wallet: _wallet!)) { - // await _listenToEpicbox(); - // } - // return; - // } - // - // try { - // final config = await _getRealConfig(); - // final password = await secureStorageInterface.read( - // key: '${walletId}_password', - // ); - // if (password == null) { - // throw Exception('Wallet password not found'); - // } - // - // final epicboxConfig = await getEpicBoxConfig(); - // - // _wallet = await libEpic.openWallet( - // config: config, - // password: password, - // epicboxConfig: epicboxConfig.toString(), - // ); - // - // await _listenToEpicbox(); - // - // Logging.instance.d( - // "Epic wallet opened successfully with persistent isolate", - // ); - // } catch (e, s) { - // Logging.instance.e("Failed to open Epic wallet", error: e, stackTrace: s); - // _wallet = null; - // rethrow; - // } - // } - - Future updateEpicboxConfig(String host, int port) async { - final String stringConfig = jsonEncode({ + @override + Future updateRelayConfig(String host, int port) async { + final String stringConfig = jsonEncode({ "epicbox_domain": host, "epicbox_port": port, "epicbox_protocol_unsecure": false, @@ -135,7 +70,7 @@ class EpiccashWallet extends Bip39Wallet { // TODO: refresh anything that needs to be refreshed/updated due to epicbox info changed } - /// returns an empty String on success, error message on failure + @override Future cancelPendingTransactionAndPost(String txSlateId) async { try { _hackedCheckTorNodePrefs(); @@ -143,7 +78,7 @@ class EpiccashWallet extends Bip39Wallet { throw Exception('Wallet not initialized'); } - final result = await libEpic.cancelTransaction( + final String result = await libEpic.cancelTransaction( wallet: _wallet!, transactionId: txSlateId, ); @@ -155,9 +90,10 @@ class EpiccashWallet extends Bip39Wallet { } } - Future getEpicBoxConfig() async { + @override + Future getRelayConfig() async { // check for user-configured epicbox first - final storedConfig = await secureStorageInterface.read( + final String? storedConfig = await secureStorageInterface.read( key: '${walletId}_epicboxConfigNewNewNew', ); if (storedConfig != null && storedConfig.isNotEmpty) { @@ -179,22 +115,15 @@ class EpiccashWallet extends Bip39Wallet { return EpicBoxConfigModel.fromServer(DefaultEpicBoxes.defaultEpicBoxServer); } - Future updateRestoreHeight(int height) async { - final epicData = info.epicData!.copyWith(restoreHeight: height); - - await info.updateExtraEpiccashWalletInfo( - epicData: epicData, - isar: mainDB.isar, - ); - } - // ================= Slatepack Operations =================================== - /// Create a slatepack for sending Epic Cash. + /// Epic slates are exchanged as plain JSON; [encrypt] is ignored. + @override Future createSlatepack({ required Amount amount, String? recipientAddress, String? message, + bool encrypt = false, int? minimumConfirmations, }) async { try { @@ -202,7 +131,6 @@ class EpiccashWallet extends Bip39Wallet { if (_wallet == null) { throw Exception('Wallet not opened. Call open() first.'); } - // Create transaction with returnSlate: true for slatepack mode. final result = await libEpic.createTransaction( wallet: _wallet!, amount: amount.raw.toInt(), @@ -227,16 +155,15 @@ class EpiccashWallet extends Bip39Wallet { } } - /// Decode a slatepack/slate JSON. - Future decodeSlatepack(String slateJson) async { + /// Slates are already JSON so this only validates the input. + @override + Future decodeSlatepack(String slatepack) async { try { - // For Epic Cash, slates are already JSON, so we parse directly. - // Validate that the JSON is valid. - jsonDecode(slateJson); + jsonDecode(slatepack); return SlatepackDecodeResult( success: true, - slateJson: slateJson, + slateJson: slatepack, wasEncrypted: false, senderAddress: null, recipientAddress: null, @@ -247,47 +174,17 @@ class EpiccashWallet extends Bip39Wallet { } } - /// Full decode of a slatepack including type analysis. - Future<({SlatepackDecodeResult result, String type, String raw})?> - fullDecodeSlatepack(String slateJson) async { - // Add delay for showloading exception catching hack fix. - await Future.delayed(const Duration(seconds: 1)); - - if (slateJson.isEmpty) { - return null; - } - - // Attempt to decode. - final decoded = await decodeSlatepack(slateJson); - - if (decoded.success) { - final analysis = await analyzeSlatepack(slateJson); - - final String slatepackType = switch (analysis.status) { - 'S1' => "S1 (Initial Send)", - 'S2' => "S2 (Response)", - 'S3' => "S3 (Finalized)", - _ => "Unknown", - }; - - return (result: decoded, type: slatepackType, raw: slateJson); - } else { - throw Exception(decoded.error ?? "Failed to decode slatepack"); - } - } - - /// Receive a slatepack and return response slate JSON. - Future receiveSlatepack(String slateJson) async { + @override + Future receiveSlatepack(String slatepack) async { try { _hackedCheckTorNodePrefs(); if (_wallet == null) { throw Exception('Wallet not opened. Call open() first.'); } - // Receive and get updated slate JSON. final received = await libEpic.txReceive( wallet: _wallet!, - slateJson: slateJson, + slateJson: slatepack, ); return ReceiveResult( @@ -304,18 +201,17 @@ class EpiccashWallet extends Bip39Wallet { } } - /// Finalize a slatepack (sender step 3). - Future finalizeSlatepack(String slateJson) async { + @override + Future finalizeSlatepack(String slatepack) async { try { _hackedCheckTorNodePrefs(); if (_wallet == null) { throw Exception('Wallet not opened. Call open() first.'); } - // Finalize transaction. final finalized = await libEpic.txFinalize( wallet: _wallet!, - slateJson: slateJson, + slateJson: slatepack, ); return FinalizeResult( @@ -329,96 +225,11 @@ class EpiccashWallet extends Bip39Wallet { } } - /// Analyze a slatepack and determine transaction type and metadata. - Future< - ({ - String type, - String status, - String? amount, - bool wasEncrypted, - String? senderAddress, - String? recipientAddress, - String slateId, - }) - > - analyzeSlatepack(String slateJson) async { - try { - // Parse the slate JSON to extract metadata. - final slateData = jsonDecode(slateJson); - final String slateId = "${slateData['id'] ?? ''}"; - final String? amountStr = slateData['amount']?.toString(); - - Logging.instance.d('Analyzed slatepack with ID: $slateId'); - - // Determine slate status from the slate structure. - String status = 'Unknown'; - String type = 'Unknown'; - - // Check participant data to determine slate status. - final List? participants = - slateData['participant_data'] as List?; - if (participants != null && participants.isNotEmpty) { - // Count how many participants have signatures. - int signedParticipants = 0; - for (final participant in participants) { - if (participant['part_sig'] != null) { - signedParticipants++; - } - } - - // Determine status based on signatures and participant count. - if (signedParticipants == 0) { - status = 'S1'; - type = 'Outgoing'; // Initial send slate - this is outgoing. - } else if (signedParticipants == 1) { - status = 'S2'; - type = 'Incoming'; // Response slate - this means we're receiving. - } else if (signedParticipants >= participants.length) { - status = 'S3'; - type = - 'Outgoing'; // Finalized slate - completed outgoing transaction. - } - } - - // Fallback: check for explicit 'sta' field (some slates may have this). - if (status == 'Unknown' && slateData['sta'] != null) { - status = "${slateData['sta']}"; - if (status == 'S1') { - type = 'Outgoing'; - } else if (status == 'S2') { - type = 'Incoming'; - } else if (status == 'S3') { - type = 'Outgoing'; - } - } - - return ( - type: type, - status: status, - amount: amountStr, - wasEncrypted: false, - senderAddress: null, - recipientAddress: null, - slateId: slateId, - ); - } catch (e) { - // If we can't decode it, return unknown. - return ( - type: 'Unknown', - status: 'Unknown', - amount: null, - wasEncrypted: false, - senderAddress: null, - recipientAddress: null, - slateId: '', - ); - } - } - /// Check if data is a slate JSON. - bool isSlateJson(String data) { + @override + bool isSlatepack(String data) { try { - final parsed = jsonDecode(data); + final dynamic parsed = jsonDecode(data); // Check for common slate fields. return parsed is Map && (parsed.containsKey('id') || parsed.containsKey('slate_id')) && @@ -434,11 +245,6 @@ class EpiccashWallet extends Bip39Wallet { return address.contains('@'); } - /// Check if address is HTTP format. - bool isHttpAddress(String address) { - return address.startsWith('http://') || address.startsWith('https://'); - } - // ================= Private ================================================= Future _hasConfig() async => @@ -451,13 +257,13 @@ class EpiccashWallet extends Bip39Wallet { final String nodeAddress = node.host; final int port = node.port; - final uri = Uri.parse(nodeAddress).replace(port: port); + final Uri uri = Uri.parse(nodeAddress).replace(port: port); final String nodeApiAddress = uri.toString(); - final walletDir = await _currentWalletDirPath(); + final String walletDir = await currentWalletDirPath(); - final Map config = {}; + final Map config = {}; config["wallet_dir"] = walletDir; config["check_node_api_http_addr"] = nodeApiAddress; config["chain"] = "mainnet"; @@ -472,18 +278,8 @@ class EpiccashWallet extends Bip39Wallet { return stringConfig; } - Future _currentWalletDirPath() async { - final Directory appDir = await StackFileSystem.applicationRootDirectory(); - - final path = "${appDir.path}/epiccash"; - final String name = walletId.trim(); - return '$path/$name'; - } - - Future _nativeFee( - int satoshiAmount, { - bool ifErrorEstimateFee = false, - }) async { + @override + Future nativeFee(int satoshiAmount) async { if (_wallet == null) { throw Exception('Wallet not opened. Call open() first.'); } @@ -512,27 +308,7 @@ class EpiccashWallet extends Bip39Wallet { } } - Future _startSync() async { - _hackedCheckTorNodePrefs(); - Logging.instance.d("request start sync"); - if (_wallet == null) { - throw Exception('Wallet not opened. Call open() first.'); - } - const int refreshFromNode = 1; - if (!syncMutex.isLocked) { - await syncMutex.protect(() async { - // How does getWalletBalances start syncing?????????!!!!! - await libEpic.getWalletBalances( - wallet: _wallet!, - refreshFromNode: refreshFromNode, - minimumConfirmations: 10, - ); - }); - } else { - Logging.instance.d("request start sync denied"); - } - } - + @override Future< ({ double awaitingFinalization, @@ -541,32 +317,41 @@ class EpiccashWallet extends Bip39Wallet { double total, }) > - _allWalletBalances() async { + fetchWalletBalances({ + required int refreshFromNode, + required int minimumConfirmations, + }) { _hackedCheckTorNodePrefs(); if (_wallet == null) { throw Exception('Wallet not opened. Call open() first.'); } - const refreshFromNode = 0; - return (await libEpic.getWalletBalances( + return libEpic.getWalletBalances( wallet: _wallet!, refreshFromNode: refreshFromNode, - minimumConfirmations: cryptoCurrency.minConfirms, - )); + minimumConfirmations: minimumConfirmations, + ); + } + + @override + Future fetchChainHeight() async { + _hackedCheckTorNodePrefs(); + final String config = await _buildConfig(); + return libEpic.getChainHeight(config: config); } Future _testEpicboxServer(EpicBoxConfigModel epicboxConfig) async { _hackedCheckTorNodePrefs(); - final host = epicboxConfig.host; - final port = epicboxConfig.port ?? 443; + final String host = epicboxConfig.host; + final int port = epicboxConfig.port ?? 443; WebSocketChannel? channel; try { - final uri = Uri.parse('wss://$host:$port'); + final Uri uri = Uri.parse('wss://$host:$port'); channel = WebSocketChannel.connect(uri); await channel.ready; - final response = await channel.stream.first.timeout( + final dynamic response = await channel.stream.first.timeout( const Duration(seconds: 2), ); @@ -583,46 +368,9 @@ class EpiccashWallet extends Bip39Wallet { } } - Future _putSendToAddresses( - ({String slateId, String commitId}) slateData, - Map txAddressInfo, - ) async { - try { - final slatesToCommits = info.epicData?.slatesToCommits ?? {}; - final from = txAddressInfo['from']; - final to = txAddressInfo['to']; - slatesToCommits[slateData.slateId] = { - "commitId": slateData.commitId, - "from": from, - "to": to, - }; - await info.updateExtraEpiccashWalletInfo( - epicData: info.epicData!.copyWith(slatesToCommits: slatesToCommits), - isar: mainDB.isar, - ); - return true; - } catch (e, s) { - Logging.instance.e("ERROR STORING ADDRESS", error: e, stackTrace: s); - return false; - } - } - - Future _getCurrentIndex() async { - try { - final int receivingIndex = info.epicData!.receivingIndex; - // TODO: go through pendingarray and processed array and choose the index - // of the last one that has not been processed, or the index after the - // one most recently processed; - return receivingIndex; - } catch (e, s) { - Logging.instance.e("$e $s", error: e, stackTrace: s); - return 0; - } - } - Future _updateAddressInDB(Address address) async { try { - final storedAddress = await getCurrentReceivingAddress(); + final Address? storedAddress = await getCurrentReceivingAddress(); await mainDB.isar.writeTxn(() async { if (storedAddress == null) { await mainDB.isar.addresses.put(address); @@ -647,7 +395,7 @@ class EpiccashWallet extends Bip39Wallet { throw Exception('Wallet not opened. Call open() first.'); } - final epicBoxConfig = await getEpicBoxConfig(); + final epicBoxConfig = await getRelayConfig(); return libEpic.getAddressInfo( wallet: _wallet!, index: 0, @@ -667,7 +415,7 @@ class EpiccashWallet extends Bip39Wallet { Logging.instance.d("WALLET_ADDRESS_IS $walletAddress"); - final address = Address( + final Address address = Address( walletId: walletId, value: walletAddress, derivationIndex: index, @@ -687,74 +435,44 @@ class EpiccashWallet extends Bip39Wallet { return address; } - Future _startScans() async { - try { - // max number of blocks to scan per loop iteration - const scanChunkSize = 10000; - - // force firing of scan progress event - await getSyncPercent; - - // fetch current chain height and last scanned block (should be the - // restore height if full rescan or a wallet restore) - int chainHeight = await this.chainHeight; - int lastScannedBlock = info.epicData!.lastScannedBlock; - - // Only stop the listener if we actually have blocks to scan. - // This avoids unnecessary reconnections during periodic refresh - // when the wallet is already synced to the tip. - final needsScanning = lastScannedBlock < chainHeight; - if (needsScanning) { - // Stop listener during active scanning to avoid potential conflicts - await libEpic.stopEpicboxListener(wallet: _wallet!); - } - - // loop while scanning in chain in chunks (of blocks?) - while (lastScannedBlock < chainHeight) { - Logging.instance.d( - "chainHeight: $chainHeight, lastScannedBlock: $lastScannedBlock", - ); - - final int nextScannedBlock = await libEpic.scanOutputs( - wallet: _wallet!, - startHeight: lastScannedBlock, - numberOfBlocks: scanChunkSize, - ); - - // update local cache - await info.updateExtraEpiccashWalletInfo( - epicData: info.epicData!.copyWith(lastScannedBlock: nextScannedBlock), - isar: mainDB.isar, - ); - - // force firing of scan progress event - await getSyncPercent; - - // update while loop condition variables - chainHeight = await this.chainHeight; - lastScannedBlock = nextScannedBlock; - } + @override + Future scanOutputs({ + required int startHeight, + required int numberOfBlocks, + }) { + return libEpic.scanOutputs( + wallet: _wallet!, + startHeight: startHeight, + numberOfBlocks: numberOfBlocks, + ); + } - Logging.instance.d("_startScans successfully at the tip"); + @override + Future pauseListenerForScan({required bool needsScanning}) async { + // Only stop the listener if we actually have blocks to scan. + // This avoids unnecessary reconnections during periodic refresh + // when the wallet is already synced to the tip. + if (needsScanning) { + // Stop listener during active scanning to avoid potential conflicts + await libEpic.stopEpicboxListener(wallet: _wallet!); + } + } - // Ensure listener is running after refresh. - // Use health check to verify the Rust listener task is actually alive, - // not just that we have a pointer (which could be stale). - if (!await libEpic.isEpicboxListenerRunning(wallet: _wallet!)) { - Logging.instance.d("Listener not running, starting it..."); - await _listenToEpicbox(); - } else { - Logging.instance.d("Listener already running, no restart needed"); - } - } catch (e, s) { - Logging.instance.e("_startScans failed: ", error: e, stackTrace: s); - rethrow; + @override + Future resumeListenerAfterScan() async { + // Use health check to verify the Rust listener task is actually alive, + // not just that we have a pointer (which could be stale). + if (!await libEpic.isEpicboxListenerRunning(wallet: _wallet!)) { + Logging.instance.d("Listener not running, starting it..."); + await _listenToEpicbox(); + } else { + Logging.instance.d("Listener already running, no restart needed"); } } Future _listenToEpicbox() async { Logging.instance.d("STARTING WALLET LISTENER ...."); - final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); + final EpicBoxConfigModel epicboxConfig = await getRelayConfig(); if (_wallet == null) { throw Exception('Wallet not opened. Call open() first.'); } @@ -765,25 +483,11 @@ class EpiccashWallet extends Bip39Wallet { await libEpic.startEpicboxListener(wallet: _wallet!); } - // TODO: make more robust estimate of date maybe using https://explorer.epic.tech/api-index - int _calculateRestoreHeightFrom({required DateTime date}) { - final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; - const int epicCashFirstBlock = 1565370278; - const double overestimateSecondsPerBlock = 61; - final int chosenSeconds = secondsSinceEpoch - epicCashFirstBlock; - final int approximateHeight = chosenSeconds ~/ overestimateSecondsPerBlock; - int height = approximateHeight; - if (height < 0) { - height = 0; - } - return height; - } - static const _mid = "_:'", _end = "':"; /// eeehhhhhhhhhhhhhhh bool _fuzzyEquals(TransactionV2 a, TransactionV2 b) { - final isAmountReceivedMatches = + final bool isAmountReceivedMatches = a.getAmountReceivedInThisWallet( fractionDigits: cryptoCurrency.fractionDigits, ) == @@ -791,11 +495,11 @@ class EpiccashWallet extends Bip39Wallet { fractionDigits: cryptoCurrency.fractionDigits, ); - final isFeeMatches = + final bool isFeeMatches = a.getFee(fractionDigits: cryptoCurrency.fractionDigits) == b.getFee(fractionDigits: cryptoCurrency.fractionDigits); - final isAmountSentMatches = + final bool isAmountSentMatches = a.getAmountSentFromThisWallet( fractionDigits: cryptoCurrency.fractionDigits, subtractFee: false, @@ -805,9 +509,9 @@ class EpiccashWallet extends Bip39Wallet { subtractFee: false, ); - final isHeightMatches = a.height == b.height; - final isTxTypeMatches = a.type == b.type && a.subType == b.subType; - final isSlateIdMatches = a.slateId == b.slateId; + final bool isHeightMatches = a.height == b.height; + final bool isTxTypeMatches = a.type == b.type && a.subType == b.subType; + final bool isSlateIdMatches = a.slateId == b.slateId; if (isHeightMatches && isTxTypeMatches && @@ -824,33 +528,26 @@ class EpiccashWallet extends Bip39Wallet { // ============== Overrides ================================================== @override - int get isarTransactionVersion => 2; - - @override - FilterOperation? get changeAddressFilterOperation => - FilterGroup.and(standardChangeAddressFilters); - - @override - FilterOperation? get receivingAddressFilterOperation => - FilterGroup.and(standardReceivingAddressFilters); + Future prepareRefresh() async { + _hackedCheckTorNodePrefs(); - @override - Future checkSaveInitialReceivingAddress() async { - // epiccash seems ok with nothing here? + if (_wallet == null) { + throw Exception('Wallet not opened. Call open() first.'); + } } @override Future init({bool? isRestore}) async { if (isRestore != true) { - final existingWalletConfig = await _hasConfig(); + final bool existingWalletConfig = await _hasConfig(); // check if should create a new wallet if (!existingWalletConfig) { await updateNode(); - final mnemonicString = await getMnemonic(); + final String mnemonicString = await getMnemonic(); final String password = generatePassword(); - final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); + final EpicBoxConfigModel epicboxConfig = await getRelayConfig(); final String stringConfig = await _buildConfig(); @@ -884,24 +581,22 @@ class EpiccashWallet extends Bip39Wallet { await _generateAndStoreReceivingAddressForIndex(0); // subtract a couple days to ensure we have a buffer for SWB - final bufferedCreateHeight = _calculateRestoreHeightFrom( + final int bufferedCreateHeight = calculateRestoreHeightFrom( date: DateTime.now().subtract(const Duration(days: 2)), ); - final epicData = ExtraEpiccashWalletInfo( - receivingIndex: 0, - changeIndex: 0, - slatesToAddresses: {}, - slatesToCommits: {}, - lastScannedBlock: bufferedCreateHeight, - restoreHeight: bufferedCreateHeight, - creationHeight: bufferedCreateHeight, - ); + final ExtraMimblewimbleWalletInfo epicData = + ExtraMimblewimbleWalletInfo( + receivingIndex: 0, + changeIndex: 0, + slatesToAddresses: {}, + slatesToCommits: {}, + lastScannedBlock: bufferedCreateHeight, + restoreHeight: bufferedCreateHeight, + creationHeight: bufferedCreateHeight, + ); - await info.updateExtraEpiccashWalletInfo( - epicData: epicData, - isar: mainDB.isar, - ); + await updateMimblewimbleData(epicData); await _listenToEpicbox(); } else { @@ -910,10 +605,10 @@ class EpiccashWallet extends Bip39Wallet { "initializeExisting() ${cryptoCurrency.prettyName} wallet", ); - final password = await secureStorageInterface.read( + final String? password = await secureStorageInterface.read( key: '${walletId}_password', ); - final epicboxConfig = await getEpicBoxConfig(); + final EpicBoxConfigModel epicboxConfig = await getRelayConfig(); _wallet = await libEpic.openWallet( config: await _buildConfig(), @@ -956,7 +651,7 @@ class EpiccashWallet extends Bip39Wallet { final useEpicbox = shouldCheckEpicbox(receiverAddress); if (useEpicbox) { - final epicboxConfig = await getEpicBoxConfig(); + final epicboxConfig = await getRelayConfig(); final bool isEpicboxConnected = await _testEpicboxServer(epicboxConfig); if (!isEpicboxConnected) { throw Exception("Failed to send TX : Unable to reach epicbox server"); @@ -990,10 +685,10 @@ class EpiccashWallet extends Bip39Wallet { )); } - final Map txAddressInfo = {}; + final Map txAddressInfo = {}; txAddressInfo['from'] = (await getCurrentReceivingAddress())!.value; txAddressInfo['to'] = txData.recipients!.first.address; - await _putSendToAddresses(( + await putSendToAddresses(( commitId: transaction.commitId, slateId: transaction.slateId, ), txAddressInfo); @@ -1015,8 +710,8 @@ class EpiccashWallet extends Bip39Wallet { TxRecipient recipient = txData.recipients!.first; - final int realFee = await _nativeFee(recipient.amount.raw.toInt()); - final feeAmount = Amount( + final int realFee = await nativeFee(recipient.amount.raw.toInt()); + final Amount feeAmount = Amount( rawValue: BigInt.from(realFee), fractionDigits: cryptoCurrency.fractionDigits, ); @@ -1048,15 +743,19 @@ class EpiccashWallet extends Bip39Wallet { // with the current db, there is no other way besides editing the // unique key (txid+walletId). Since we cannot change the wallet id we // must therefore hack some stupid stuff into the txid... - final currentTxns1 = await mainDB.isar.transactionV2s + final List currentTxns1 = await mainDB + .isar + .transactionV2s .where() .walletIdEqualTo(walletId) .findAll(); final List currentTxns = []; - for (final current in currentTxns1) { - if (currentTxns.where((e) => _fuzzyEquals(e, current)).isNotEmpty) { + for (final TransactionV2 current in currentTxns1) { + if (currentTxns + .where((TransactionV2 e) => _fuzzyEquals(e, current)) + .isNotEmpty) { Logging.instance.f("DELETING: $current"); await mainDB.isar.writeTxn(() async { await mainDB.isar.transactionV2s.delete(current.id); @@ -1066,15 +765,15 @@ class EpiccashWallet extends Bip39Wallet { } } - for (final current in currentTxns) { + for (final TransactionV2 current in currentTxns) { // check notes first - final note = await mainDB.isar.transactionNotes + final TransactionNote? note = await mainDB.isar.transactionNotes .where() .txidWalletIdEqualTo(current.slateId ?? current.txid, walletId) .findFirst(); // now handle transaction - final firstTime = + final bool firstTime = !(current.txid.contains(_mid) && current.txid.endsWith(_end)); final String txid; @@ -1082,17 +781,17 @@ class EpiccashWallet extends Bip39Wallet { txid = "${current.txid}${_mid}0$_end"; } else { // this should always be 2 parts if we've gotten this far - final parts = current.txid.split(_mid); - final rescanCount = + final List parts = current.txid.split(_mid); + final int rescanCount = int.parse(parts.last.replaceFirst(_end, "")) + 1; txid = "${parts.first}$_mid$rescanCount$_end"; } // finally update in db await mainDB.isar.writeTxn(() async { - final updated = current.copyWith(txid: txid); + final TransactionV2 updated = current.copyWith(txid: txid); if (note != null) { - final updatedNote = TransactionNote( + final TransactionNote updatedNote = TransactionNote( walletId: walletId, txid: current.slateId ?? txid, value: note.value, @@ -1106,20 +805,19 @@ class EpiccashWallet extends Bip39Wallet { }); } - await info.updateExtraEpiccashWalletInfo( - epicData: info.epicData!.copyWith( - lastScannedBlock: info.epicData!.restoreHeight, + await updateMimblewimbleData( + mimblewimbleData!.copyWith( + lastScannedBlock: mimblewimbleData!.restoreHeight, ), - isar: mainDB.isar, ); - final password = await secureStorageInterface.read( + final String? password = await secureStorageInterface.read( key: '${walletId}_password', ); - final epicboxConfig = await getEpicBoxConfig(); + final EpicBoxConfigModel epicboxConfig = await getRelayConfig(); // maybe there is some way to tel epic-wallet rust to fully rescan... - final result = await deleteEpicWallet( + final String result = await deleteEpicWallet( wallet: this, secureStore: secureStorageInterface, ); @@ -1140,7 +838,7 @@ class EpiccashWallet extends Bip39Wallet { ); await _generateAndStoreReceivingAddressForIndex( - info.epicData?.receivingIndex ?? 0, + mimblewimbleData?.receivingIndex ?? 0, ); await _listenToEpicbox(); @@ -1150,7 +848,7 @@ class EpiccashWallet extends Bip39Wallet { await updateNode(); final String password = generatePassword(); - final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); + final EpicBoxConfigModel epicboxConfig = await getRelayConfig(); // no need to save the config, just a string flag to know we have a // wallet created @@ -1184,20 +882,19 @@ class EpiccashWallet extends Bip39Wallet { await _listenToEpicbox(); - final epicData = ExtraEpiccashWalletInfo( - receivingIndex: 0, - changeIndex: 0, - slatesToAddresses: {}, - slatesToCommits: {}, - lastScannedBlock: info.restoreHeight, - restoreHeight: info.restoreHeight, - creationHeight: info.epicData?.creationHeight ?? info.restoreHeight, - ); + final ExtraMimblewimbleWalletInfo epicData = + ExtraMimblewimbleWalletInfo( + receivingIndex: 0, + changeIndex: 0, + slatesToAddresses: {}, + slatesToCommits: {}, + lastScannedBlock: info.restoreHeight, + restoreHeight: info.restoreHeight, + creationHeight: + mimblewimbleData?.creationHeight ?? info.restoreHeight, + ); - await info.updateExtraEpiccashWalletInfo( - epicData: epicData, - isar: mainDB.isar, - ); + await updateMimblewimbleData(epicData); await _generateAndStoreReceivingAddressForIndex( epicData.receivingIndex, @@ -1217,167 +914,13 @@ class EpiccashWallet extends Bip39Wallet { } } - @override - Future refresh({bool doScan = true}) async { - // Awaiting this lock could be dangerous. - // Since refresh is periodic (generally) - if (refreshMutex.isLocked) { - return; - } - - try { - // this acquire should be almost instant due to above check. - // Slight possibility of race but should be irrelevant - await refreshMutex.acquire(); - - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.syncing, - walletId, - cryptoCurrency, - ), - ); - _hackedCheckTorNodePrefs(); - - // if (info.epicData?.creationHeight == null) { - // await info.updateExtraEpiccashWalletInfo(epicData: inf, isar: isar) - // await epicUpdateCreationHeight(await chainHeight); - // } - - if (_wallet == null) { - throw Exception('Wallet not opened. Call open() first.'); - } - - if (doScan) { - await _startScans(); - - unawaited(_startSync()); - } - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.0, walletId)); - await updateChainHeight(); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.1, walletId)); - - // if (this is MultiAddressInterface) { - // await (this as MultiAddressInterface) - // .checkReceivingAddressForTransactions(); - // } - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.2, walletId)); - - // // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. - // if (this is MultiAddressInterface) { - // await (this as MultiAddressInterface) - // .checkChangeAddressForTransactions(); - // } - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.3, walletId)); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.50, walletId)); - final fetchFuture = updateTransactions(); - // if (currentHeight != storedHeight) { - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.60, walletId)); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.70, walletId)); - - await fetchFuture; - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.80, walletId)); - - // await getAllTxsToWatch(); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.90, walletId)); - - await updateBalance(); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(1.0, walletId)); - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.synced, - walletId, - cryptoCurrency, - ), - ); - - if (shouldAutoSync) { - timer ??= Timer.periodic(const Duration(seconds: 150), (timer) async { - // chain height check currently broken - // if ((await chainHeight) != (await storedChainHeight)) { - - // TODO: [prio=med] some kind of quick check if wallet needs to - // refresh to replace the old refreshIfThereIsNewData call - // if (await refreshIfThereIsNewData()) { - unawaited(refresh()); - - // } - // } - }); - } - } catch (e, s) { - GlobalEventBus.instance.fire( - NodeConnectionStatusChangedEvent( - NodeConnectionStatus.disconnected, - walletId, - cryptoCurrency, - ), - ); - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.unableToSync, - walletId, - cryptoCurrency, - ), - ); - Logging.instance.e( - "Caught exception in refreshWalletData()", - error: e, - stackTrace: s, - ); - } finally { - refreshMutex.release(); - } - } - - @override - Future updateBalance() async { - try { - _hackedCheckTorNodePrefs(); - final balances = await _allWalletBalances(); - final balance = Balance( - total: Amount.fromDecimal( - Decimal.parse(balances.total.toString()) + - Decimal.parse(balances.awaitingFinalization.toString()), - fractionDigits: cryptoCurrency.fractionDigits, - ), - spendable: Amount.fromDecimal( - Decimal.parse(balances.spendable.toString()), - fractionDigits: cryptoCurrency.fractionDigits, - ), - blockedTotal: Amount.zeroWith( - fractionDigits: cryptoCurrency.fractionDigits, - ), - pendingSpendable: Amount.fromDecimal( - Decimal.parse(balances.pending.toString()), - fractionDigits: cryptoCurrency.fractionDigits, - ), - ); - - await info.updateBalance(newBalance: balance, isar: mainDB.isar); - } catch (e, s) { - Logging.instance.w( - "Epic cash wallet failed to update balance: ", - error: e, - stackTrace: s, - ); - } - } - @override Future updateTransactions() async { try { _hackedCheckTorNodePrefs(); - const refreshFromNode = 1; + const int refreshFromNode = 1; - final myAddresses = await mainDB + final List myAddresses = await mainDB .getAddresses(walletId) .filter() .typeEqualTo(AddressType.mimbleWimble) @@ -1387,37 +930,38 @@ class EpiccashWallet extends Bip39Wallet { .valueIsNotEmpty() .valueProperty() .findAll(); - final myAddressesSet = myAddresses.toSet(); + final Set myAddressesSet = myAddresses.toSet(); - final transactions = await libEpic.getTransactions( - wallet: _wallet!, - refreshFromNode: refreshFromNode, - ); + final List transactions = await libEpic + .getTransactions(wallet: _wallet!, refreshFromNode: refreshFromNode); final List txns = []; - final slatesToCommits = info.epicData?.slatesToCommits ?? {}; + final Map slatesToCommits = + mimblewimbleData?.slatesToCommits ?? {}; - for (final tx in transactions) { - final isIncoming = + for (final MimblewimbleTransaction tx in transactions) { + final bool isIncoming = libEpic.txTypeIsReceived(tx.txType) || libEpic.txTypeIsReceiveCancelled(tx.txType); - final slateId = tx.txSlateId; - final commitId = slatesToCommits[slateId]?['commitId'] as String?; - final numberOfMessages = tx.messages?.length; - final onChainNote = tx.messages?.first.message; - final addressFrom = slatesToCommits[slateId]?["from"] as String?; - final addressTo = slatesToCommits[slateId]?["to"] as String?; - - final credit = int.parse(tx.amountCredited); - final debit = int.parse(tx.amountDebited); - final fee = int.tryParse(tx.fee ?? "0") ?? 0; + final String? slateId = tx.txSlateId; + final String? commitId = + slatesToCommits[slateId]?['commitId'] as String?; + final int? numberOfMessages = tx.messages?.length; + final String? onChainNote = tx.messages?.first.message; + final String? addressFrom = + slatesToCommits[slateId]?["from"] as String?; + final String? addressTo = slatesToCommits[slateId]?["to"] as String?; + + final int credit = int.parse(tx.amountCredited); + final int debit = int.parse(tx.amountDebited); + final int fee = int.tryParse(tx.fee ?? "0") ?? 0; // hack epic tx data into inputs and outputs final List outputs = []; final List inputs = []; - final addressFromIsMine = myAddressesSet.contains(addressFrom); - final addressToIsMine = myAddressesSet.contains(addressTo); + final bool addressFromIsMine = myAddressesSet.contains(addressFrom); + final bool addressToIsMine = myAddressesSet.contains(addressTo); OutputV2 output = OutputV2.isarCantDoRequiredInDefaultConstructor( scriptPubKeyHex: "00", @@ -1460,7 +1004,7 @@ class EpiccashWallet extends Bip39Wallet { outputs.add(output); inputs.add(input); - final otherData = { + final Map otherData = { "isEpiccashTransaction": true, "numberOfMessages": numberOfMessages, "slateId": slateId, @@ -1474,7 +1018,7 @@ class EpiccashWallet extends Bip39Wallet { ).toJsonString(), }; - final txn = TransactionV2( + final TransactionV2 txn = TransactionV2( walletId: walletId, blockHash: null, hash: commitId ?? tx.id.toString(), @@ -1490,20 +1034,20 @@ class EpiccashWallet extends Bip39Wallet { otherData: jsonEncode(otherData), ); - if (txns.where((e) => _fuzzyEquals(e, txn)).isEmpty) { + if (txns.where((TransactionV2 e) => _fuzzyEquals(e, txn)).isEmpty) { txns.add(txn); } } - final existingTxns = await mainDB.isar.transactionV2s + final List existingTxns = await mainDB.isar.transactionV2s .where() .walletIdEqualTo(walletId) .findAll(); await mainDB.isar.writeTxn(() async { - for (final tx in txns) { - final existingMatches = existingTxns.where( - (e) => _fuzzyEquals(e, tx), + for (final TransactionV2 tx in txns) { + final Iterable existingMatches = existingTxns.where( + (TransactionV2 e) => _fuzzyEquals(e, tx), ); TransactionNote? note; if (existingMatches.isNotEmpty) { @@ -1514,7 +1058,7 @@ class EpiccashWallet extends Bip39Wallet { "existingMatches length: ${existingMatches.length}", ); } - for (final match in existingMatches) { + for (final TransactionV2 match in existingMatches) { if (await mainDB.isar.transactionV2s .where() .txidWalletIdEqualTo(match.txid, walletId) @@ -1531,7 +1075,7 @@ class EpiccashWallet extends Bip39Wallet { } } - final id = await mainDB.isar.transactionV2s + final int? id = await mainDB.isar.transactionV2s .where() .txidWalletIdEqualTo(tx.txid, walletId) .idProperty() @@ -1565,12 +1109,6 @@ class EpiccashWallet extends Bip39Wallet { } } - @override - Future updateUTXOs() async { - // not used for epiccash - return false; - } - @override Future updateNode() async { _epicNode = getCurrentNode(); @@ -1585,7 +1123,9 @@ class EpiccashWallet extends Bip39Wallet { @override Future pingCheck() async { try { - final node = nodeService.getPrimaryNodeFor(currency: cryptoCurrency); + final NodeModel? node = nodeService.getPrimaryNodeFor( + currency: cryptoCurrency, + ); // force unwrap optional as we want connection test to fail if wallet // wasn't initialized or epicbox node was set to null @@ -1606,65 +1146,17 @@ class EpiccashWallet extends Bip39Wallet { } } - @override - Future updateChainHeight() async { - _hackedCheckTorNodePrefs(); - final config = await _buildConfig(); - final latestHeight = await libEpic.getChainHeight(config: config); - await info.updateCachedChainHeight( - newHeight: latestHeight, - isar: mainDB.isar, - ); - } - - @override - Future estimateFeeFor(Amount amount, BigInt feeRate) async { - _hackedCheckTorNodePrefs(); - // setting ifErrorEstimateFee doesn't do anything as its not used in the - // nativeFee function????? - final int currentFee = await _nativeFee( - amount.raw.toInt(), - ifErrorEstimateFee: true, - ); - return Amount( - rawValue: BigInt.from(currentFee), - fractionDigits: cryptoCurrency.fractionDigits, - ); - } - - @override - Future get fees async { - // this wasn't done before the refactor either so... - // TODO: implement _getFees - return FeeObject( - numberOfBlocksFast: 10, - numberOfBlocksAverage: 10, - numberOfBlocksSlow: 10, - fast: BigInt.one, - medium: BigInt.one, - slow: BigInt.one, - ); - } - - @override - Future updateSentCachedTxData({required TxData txData}) async { - // TODO: [prio=low] Was not used before refactor so maybe not required(?) - return txData; - } - @override Future exit() async { if (_wallet != null) await libEpic.stopEpicboxListener(wallet: _wallet!); - timer?.cancel(); - timer = null; - await super.exit(); - Logging.instance.d("EpicCash_wallet exit finished"); } void _hackedCheckTorNodePrefs() { - final node = nodeService.getPrimaryNodeFor(currency: cryptoCurrency)!; - final netOption = TorPlainNetworkOption.fromNodeData( + final NodeModel node = nodeService.getPrimaryNodeFor( + currency: cryptoCurrency, + )!; + final TorPlainNetworkOption netOption = TorPlainNetworkOption.fromNodeData( node.torEnabled, node.clearnetEnabled, ); @@ -1689,7 +1181,9 @@ Future deleteEpicWallet({ required EpiccashWallet wallet, required SecureStorageInterface secureStore, }) async { - final config = await wallet._hasConfig() ? await wallet._buildConfig() : null; + final String? config = await wallet._hasConfig() + ? await wallet._buildConfig() + : null; if (config == null) { return "Tried to delete non existent epic wallet file with" diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index 7afa331577..66ece7833a 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -8,22 +8,15 @@ import 'package:isar_community/isar.dart'; import 'package:mutex/mutex.dart'; import 'package:stack_wallet_backup/generate_password.dart'; -import '../../../models/balance.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/isar/models/blockchain_data/transaction.dart'; import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; -import '../../../models/slatepack_models.dart'; import '../../../models/mwcmqs_config_model.dart'; import '../../../models/node_model.dart'; -import '../../../models/paymint/fee_object_model.dart'; +import '../../../models/slatepack_models.dart'; import '../../../pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; -import '../../../services/event_bus/events/global/blocks_remaining_event.dart'; -import '../../../services/event_bus/events/global/node_connection_status_changed_event.dart'; -import '../../../services/event_bus/events/global/refresh_percent_changed_event.dart'; -import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; -import '../../../services/event_bus/global_event_bus.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/default_mwcmqs.dart'; import '../../../utilities/flutter_secure_storage_interface.dart'; @@ -32,51 +25,35 @@ import '../../../utilities/stack_file_system.dart'; import '../../../utilities/test_mwcmqs_connection.dart'; import '../../../wl_gen/interfaces/libmwc_interface.dart'; import '../../crypto_currency/crypto_currency.dart'; +import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; -import '../intermediate/bip39_wallet.dart'; -import '../supporting/mimblewimblecoin_wallet_info_extension.dart'; -import '../supporting/restore_progress.dart'; +import '../intermediate/mimblewimble_wallet.dart'; +import '../supporting/mimblewimble_wallet_info_extension.dart'; -class MimblewimblecoinWallet extends Bip39Wallet { +class MimblewimblecoinWallet extends MimblewimbleWallet { MimblewimblecoinWallet(CryptoCurrencyNetwork network) : super(Mimblewimblecoin(network)); - final syncMutex = Mutex(); - final _walletOpenMutex = Mutex(); + final Mutex _walletOpenMutex = Mutex(); NodeModel? _mimblewimblecoinNode; - Timer? timer; static bool _mwcLogsInitialized = false; // Process-scoped Rust pointer; do not persist. String? _walletHandle; - double highestPercent = 0; - Future get getSyncPercent async { - final int lastScannedBlock = - info.mimblewimblecoinData?.lastScannedBlock ?? 0; - final _chainHeight = await chainHeight; - final restorePercent = calculateRestoreProgress( - scannedHeight: lastScannedBlock, - chainHeight: _chainHeight, - ); - GlobalEventBus.instance.fire( - RefreshPercentChangedEvent(highestPercent, walletId), - ); - if (restorePercent > highestPercent) { - highestPercent = restorePercent; - } + @override + String get walletInfoDataKey => WalletInfoKeys.mimblewimblecoinData; - final int blocksRemaining = _chainHeight - lastScannedBlock; - GlobalEventBus.instance.fire( - BlocksRemainingEvent(blocksRemaining, walletId), - ); + @override + String get walletDirName => "mimblewimblecoin"; - return restorePercent < 0 ? 0.0 : restorePercent; - } + @override + int get firstBlockTimestamp => 1573462800; - Future updateMwcmqsConfig(String host, int port) async { - final String stringConfig = jsonEncode({ + @override + Future updateRelayConfig(String host, int port) async { + final String stringConfig = jsonEncode({ "mwcmqs_domain": host, "mwcmqs_port": port, }); @@ -102,10 +79,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { Future _ensureWalletOpen() async { return await _walletOpenMutex.protect(() async { - final cached = _walletHandle; + final String? cached = _walletHandle; if (cached != null && cached.isNotEmpty) return cached; - final config = await _getRealConfig(); + final String config = await _getRealConfig(); if (!_mwcLogsInitialized) { try { await libMwc.initLogs(config: config); @@ -114,13 +91,13 @@ class MimblewimblecoinWallet extends Bip39Wallet { Logging.instance.w("libMwc.initLogs failed: $e\n$s"); } } - final password = await secureStorageInterface.read( + final String? password = await secureStorageInterface.read( key: '${walletId}_password', ); if (password == null) { throw Exception('Wallet password not found'); } - final opened = await libMwc + final String opened = await libMwc .openWallet(config: config, password: password) .timeout( const Duration(seconds: 60), @@ -131,12 +108,12 @@ class MimblewimblecoinWallet extends Bip39Wallet { }); } - /// Returns an empty String on success, error message on failure. + @override Future cancelPendingTransactionAndPost(String txSlateId) async { try { final String wallet = await _ensureWalletOpen(); - final result = await libMwc.cancelTransaction( + final String result = await libMwc.cancelTransaction( wallet: wallet, transactionId: txSlateId, ); @@ -148,18 +125,19 @@ class MimblewimblecoinWallet extends Bip39Wallet { } } - Future getMwcMqsConfig() async { + @override + Future getRelayConfig() async { // Check if there's a custom MWCMQS config stored. - final customConfigJson = await secureStorageInterface.read( + final String? customConfigJson = await secureStorageInterface.read( key: '${walletId}_mwcmqsConfig', ); if (customConfigJson != null) { try { - final customConfig = + final Map customConfig = jsonDecode(customConfigJson) as Map; - final host = customConfig['mwcmqs_domain'] as String?; - final port = customConfig['mwcmqs_port'] as int?; + final String? host = customConfig['mwcmqs_domain'] as String?; + final int? port = customConfig['mwcmqs_port'] as int?; if (host != null && port != null) { return MwcMqsConfigModel(host: host, port: port); @@ -175,7 +153,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { // ================= Slatepack Operations =================================== - /// Create a slatepack for sending MWC. + @override Future createSlatepack({ required Amount amount, String? recipientAddress, @@ -184,10 +162,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { int? minimumConfirmations, }) async { try { - final handle = await _ensureWalletOpen(); + final String handle = await _ensureWalletOpen(); // Generate S1 slate JSON. - final s1Json = await libMwc.txInit( + final String s1Json = await libMwc.txInit( wallet: handle, amount: amount.raw.toInt(), minimumConfirmations: @@ -217,57 +195,17 @@ class MimblewimblecoinWallet extends Bip39Wallet { } } - // this isn't nearly the best way to handle this but better than copy pasting - // this function in two places (mobile and desktop widgets)... - Future<({SlatepackDecodeResult result, String type, String raw})?> - fullDecodeSlatepack(String slatepack) async { - // add delay for showloading exception catching hack fix - await Future.delayed(const Duration(seconds: 1)); - - if (slatepack.isEmpty) { - return null; - } - - // Basic format validation. - final coin = cryptoCurrency as Mimblewimblecoin; - if (!coin.isSlatepack(slatepack)) { + @override + void checkSlatepackFormat(String slatepack) { + if (!isSlatepack(slatepack)) { throw Exception("Invalid slatepack format"); } - - // Attempt to decode. - final decoded = await decodeSlatepack(slatepack); - - if (decoded.success) { - final analysis = await analyzeSlatepack(slatepack); - - String _determineSlatepackType(SlatepackDecodeResult decoded) { - // Fallback analysis based on sender/recipient addresses. - if (decoded.senderAddress != null && decoded.recipientAddress != null) { - return "S2 (Response)"; - } else if (decoded.senderAddress != null) { - return "S1 (Initial)"; - } else { - return "Unknown"; - } - } - - final String slatepackType = switch (analysis.status) { - 'S1' => "S1 (Initial Send)", - 'S2' => "S2 (Response)", - 'S3' => "S3 (Finalized)", - _ => _determineSlatepackType(decoded), // Fallback. - }; - - return (result: decoded, type: slatepackType, raw: slatepack); - } else { - throw Exception(decoded.error ?? "Failed to decode slatepack"); - } } - /// Decode a slatepack. + @override Future decodeSlatepack(String slatepack) async { try { - final handle = _walletHandle; + final String? handle = _walletHandle; final result = handle != null ? await libMwc.decodeSlatepackWithWallet( wallet: handle, @@ -288,10 +226,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { } } - /// Receive a slatepack and return response slatepack. + @override Future receiveSlatepack(String slatepack) async { try { - final handle = await _ensureWalletOpen(); + final String handle = await _ensureWalletOpen(); // Decode to get slate JSON and sender address. final decoded = await libMwc.decodeSlatepackWithWallet( @@ -327,10 +265,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { } } - /// Finalize a slatepack (sender step 3). + @override Future finalizeSlatepack(String slatepack) async { try { - final handle = await _ensureWalletOpen(); + final String handle = await _ensureWalletOpen(); // Decode to get slate JSON. final decoded = await libMwc.decodeSlatepackWithWallet( @@ -358,8 +296,8 @@ class MimblewimblecoinWallet extends Bip39Wallet { /// Start MWCMQS listener for automatic transaction processing. Future startSlatepackListener() async { try { - final wallet = await _ensureWalletOpen(); - final mwcmqsConfig = await getMwcMqsConfig(); + final String wallet = await _ensureWalletOpen(); + final MwcMqsConfigModel mwcmqsConfig = await getRelayConfig(); libMwc.startMwcMqsListener( wallet: wallet, mwcmqsConfig: mwcmqsConfig.toString(), @@ -384,7 +322,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { return libMwc.validateSendAddress(address: address); } - /// Detect if an address is a slatepack. + @override bool isSlatepack(String data) { return data.trim().startsWith('BEGINSLATE') && (data.trim().endsWith('ENDSLATEPACK') || @@ -398,107 +336,6 @@ class MimblewimblecoinWallet extends Bip39Wallet { return address.startsWith('mwcmqs://'); } - /// Detect if an address is HTTP format. - bool isHttpAddress(String address) { - return address.startsWith('http://') || address.startsWith('https://'); - } - - /// Analyze a slatepack and determine transaction type and metadata. - /// Returns a record with transaction type and slate information. - Future< - ({ - String type, - String status, - String? amount, - bool wasEncrypted, - String? senderAddress, - String? recipientAddress, - String slateId, - }) - > - analyzeSlatepack(String slatepack) async { - try { - final wallet = _walletHandle; - - // Decode the slatepack - final decoded = wallet != null - ? await libMwc.decodeSlatepackWithWallet( - wallet: wallet, - slatepack: slatepack, - ) - : await libMwc.decodeSlatepack(slatepack: slatepack); - - // Parse the slate JSON to extract metadata - final slateData = jsonDecode(decoded.slateJson); - final String slateId = "${slateData['id'] ?? ''}"; - final String? amountStr = slateData['amount']?.toString(); - - Logging.instance.d('Analyzed slatepack with ID: $slateId'); - - // Determine slate status from the slate structure - String status = 'Unknown'; - String type = 'Unknown'; - - // Check participant data to determine slate status - final List? participants = - slateData['participant_data'] as List?; - if (participants != null && participants.isNotEmpty) { - // Count how many participants have signatures - int signedParticipants = 0; - for (final participant in participants) { - if (participant['part_sig'] != null) { - signedParticipants++; - } - } - - // Determine status based on signatures and participant count - if (signedParticipants == 0) { - status = 'S1'; - type = 'Outgoing'; // Initial send slate - this is outgoing - } else if (signedParticipants == 1) { - status = 'S2'; - type = 'Incoming'; // Response slate - this means we're receiving - } else if (signedParticipants >= participants.length) { - status = 'S3'; - type = 'Outgoing'; // Finalized slate - completed outgoing transaction - } - } - - // Fallback: check for explicit 'sta' field (some slates may have this) - if (status == 'Unknown' && slateData['sta'] != null) { - status = "${slateData['sta']}"; - if (status == 'S1') { - type = 'Outgoing'; - } else if (status == 'S2') { - type = 'Incoming'; - } else if (status == 'S3') { - type = 'Outgoing'; - } - } - - return ( - type: type, - status: status, - amount: amountStr, - wasEncrypted: decoded.wasEncrypted, - senderAddress: decoded.senderAddress, - recipientAddress: decoded.recipientAddress, - slateId: slateId, - ); - } catch (e) { - // If we can't decode it, return unknown - return ( - type: 'Unknown', - status: 'Unknown', - amount: null, - wasEncrypted: false, - senderAddress: null, - recipientAddress: null, - slateId: '', - ); - } - } - /// Improved transaction type detection for slatepacks. /// This replaces "Unknown" types with better determined types based on slate analysis. Future getSlatepackTransactionType(String address) async { @@ -542,10 +379,12 @@ class MimblewimblecoinWallet extends Bip39Wallet { // Check if this is a MWC transaction with slatepack data if (transaction.isMimblewimblecoinTransaction) { // Try to analyze any slatepack addresses in the transaction - for (final output in transaction.outputs) { - for (final address in output.addresses) { + for (final OutputV2 output in transaction.outputs) { + for (final String address in output.addresses) { if (isSlatepack(address)) { - final slatepackType = await getSlatepackTransactionType(address); + final String slatepackType = await getSlatepackTransactionType( + address, + ); switch (slatepackType) { case 'Outgoing': return TransactionType.outgoing; @@ -570,8 +409,8 @@ class MimblewimblecoinWallet extends Bip39Wallet { // ================= Private ================================================= Future _ensureApiSecret(String walletDir) async { - final file = File('$walletDir/.api_secret'); - final secret = _mimblewimblecoinNode?.nodeApiSecret; + final File file = File('$walletDir/.api_secret'); + final String? secret = _mimblewimblecoinNode?.nodeApiSecret; if (secret != null) { await Directory(walletDir).create(recursive: true); await file.writeAsString(secret); @@ -588,12 +427,12 @@ class MimblewimblecoinWallet extends Bip39Wallet { final String nodeAddress = node.host; final int port = node.port; - final uri = Uri.parse(nodeAddress).replace(port: port); + final Uri uri = Uri.parse(nodeAddress).replace(port: port); final String nodeApiAddress = uri.toString(); - final walletDir = await _currentWalletDirPath(); + final String walletDir = await currentWalletDirPath(); - final Map config = {}; + final Map config = {}; config["wallet_dir"] = walletDir; config["check_node_api_http_addr"] = nodeApiAddress; config["chain"] = "mainnet"; @@ -602,21 +441,11 @@ class MimblewimblecoinWallet extends Bip39Wallet { return stringConfig; } - Future _currentWalletDirPath() async { - final Directory appDir = await StackFileSystem.applicationRootDirectory(); - - final path = "${appDir.path}/mimblewimblecoin"; - final String name = walletId.trim(); - return '$path/$name'; - } - - Future _nativeFee( - int satoshiAmount, { - bool ifErrorEstimateFee = false, - }) async { - final wallet = await _ensureWalletOpen(); + @override + Future nativeFee(int satoshiAmount) async { + final String wallet = await _ensureWalletOpen(); try { - final available = info.cachedBalance.spendable.raw.toInt(); + final int available = info.cachedBalance.spendable.raw.toInt(); final transactionFees = await libMwc.getTransactionFees( wallet: wallet, amount: satoshiAmount, @@ -640,24 +469,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { } } - Future _startSync() async { - Logging.instance.i("request start sync"); - final wallet = await _ensureWalletOpen(); - const int refreshFromNode = 1; - if (!syncMutex.isLocked) { - await syncMutex.protect(() async { - // How does getWalletBalances start syncing???? - await libMwc.getWalletBalances( - wallet: wallet, - refreshFromNode: refreshFromNode, - minimumConfirmations: 10, - ); - }); - } else { - Logging.instance.i("request start sync denied"); - } - } - + @override Future< ({ double awaitingFinalization, @@ -666,59 +478,29 @@ class MimblewimblecoinWallet extends Bip39Wallet { double total, }) > - _allWalletBalances() async { - final wallet = await _ensureWalletOpen(); - const refreshFromNode = 0; + fetchWalletBalances({ + required int refreshFromNode, + required int minimumConfirmations, + }) async { + final String wallet = await _ensureWalletOpen(); return await libMwc.getWalletBalances( wallet: wallet, refreshFromNode: refreshFromNode, - minimumConfirmations: cryptoCurrency.minConfirms, + minimumConfirmations: minimumConfirmations, ); } - Future _putSendToAddresses( - ({String slateId, String commitId}) slateData, - Map txAddressInfo, - ) async { - try { - final slatesToCommits = info.mimblewimblecoinData?.slatesToCommits ?? {}; - final from = txAddressInfo['from']; - final to = txAddressInfo['to']; - slatesToCommits[slateData.slateId] = { - "commitId": slateData.commitId, - "from": from, - "to": to, - }; - await info.updateExtraMimblewimblecoinWalletInfo( - mimblewimblecoinData: info.mimblewimblecoinData!.copyWith( - slatesToCommits: slatesToCommits, - ), - isar: mainDB.isar, - ); - return true; - } catch (e, s) { - Logging.instance.e("ERROR STORING ADDRESS $e $s"); - return false; - } - } - - Future _getCurrentIndex() async { - try { - final int receivingIndex = info.mimblewimblecoinData!.receivingIndex; - // TODO: go through pendingarray and processed array and choose the index - // of the last one that has not been processed, or the index after the one most recently processed; - return receivingIndex; - } catch (e, s) { - Logging.instance.e("$e $s"); - return 0; - } + @override + Future fetchChainHeight() async { + final String config = await _getRealConfig(); + return libMwc.getChainHeight(config: config); } Future
_generateAndStoreReceivingAddressForIndex(int index) async { Address? address = await getCurrentReceivingAddress(); if (address == null) { - final mwcmqsConfig = await getMwcMqsConfig(); + final MwcMqsConfigModel mwcmqsConfig = await getRelayConfig(); address = await thisWalletAddress(index, mwcmqsConfig); } @@ -735,16 +517,16 @@ class MimblewimblecoinWallet extends Bip39Wallet { int index, MwcMqsConfigModel mwcmqsConfig, ) async { - final wallet = await _ensureWalletOpen(); + final String wallet = await _ensureWalletOpen(); - final walletAddress = await libMwc.getAddressInfo( + final String walletAddress = await libMwc.getAddressInfo( wallet: wallet, index: index, ); Logging.instance.i("WALLET_ADDRESS_IS $walletAddress"); - final address = Address( + final Address address = Address( walletId: walletId, value: walletAddress, derivationIndex: index, @@ -757,64 +539,36 @@ class MimblewimblecoinWallet extends Bip39Wallet { return address; } - Future _startScans() async { - try { - //First stop the current listener - libMwc.stopMwcMqsListener(); - final wallet = await _ensureWalletOpen(); - - // max number of blocks to scan per loop iteration - const scanChunkSize = 10000; - - // force firing of scan progress event - await getSyncPercent; - - // fetch current chain height and last scanned block (should be the - // restore height if full rescan or a wallet restore) - int chainHeight = await this.chainHeight; - int lastScannedBlock = info.mimblewimblecoinData!.lastScannedBlock; - - // loop while scanning in chain in chunks (of blocks?) - while (lastScannedBlock < chainHeight) { - Logging.instance.i( - "chainHeight: $chainHeight, lastScannedBlock: $lastScannedBlock", - ); - - final int nextScannedBlock = await libMwc.scanOutputs( - wallet: wallet, - startHeight: lastScannedBlock, - numberOfBlocks: scanChunkSize, - ); - - // update local cache - await info.updateExtraMimblewimblecoinWalletInfo( - mimblewimblecoinData: info.mimblewimblecoinData!.copyWith( - lastScannedBlock: nextScannedBlock, - ), - isar: mainDB.isar, - ); - - // force firing of scan progress event - await getSyncPercent; + @override + Future scanOutputs({ + required int startHeight, + required int numberOfBlocks, + }) async { + final String wallet = await _ensureWalletOpen(); + return libMwc.scanOutputs( + wallet: wallet, + startHeight: startHeight, + numberOfBlocks: numberOfBlocks, + ); + } - // update while loop condition variables - chainHeight = await this.chainHeight; - lastScannedBlock = nextScannedBlock; - } + @override + Future pauseListenerForScan({required bool needsScanning}) async { + //First stop the current listener + libMwc.stopMwcMqsListener(); + await _ensureWalletOpen(); + } - Logging.instance.i("_startScans successfully at the tip"); - //Once scanner completes restart listener - await _listenToMwcmqs(); - } catch (e, s) { - Logging.instance.e("_startScans failed: $e\n$s"); - rethrow; - } + @override + Future resumeListenerAfterScan() async { + //Once scanner completes restart listener + await _listenToMwcmqs(); } Future _listenToMwcmqs() async { Logging.instance.i("STARTING WALLET LISTENER ...."); - final wallet = await _ensureWalletOpen(); - final MwcMqsConfigModel mwcmqsConfig = await getMwcMqsConfig(); + final String wallet = await _ensureWalletOpen(); + final MwcMqsConfigModel mwcmqsConfig = await getRelayConfig(); libMwc.startMwcMqsListener( wallet: wallet, mwcmqsConfig: mwcmqsConfig.toString(), @@ -827,8 +581,8 @@ class MimblewimblecoinWallet extends Bip39Wallet { key: '${walletId}_config', ); if (Platform.isIOS) { - final walletDir = await _currentWalletDirPath(); - final editConfig = jsonDecode(config as String); + final String walletDir = await currentWalletDirPath(); + final dynamic editConfig = jsonDecode(config as String); editConfig["wallet_dir"] = walletDir; config = jsonEncode(editConfig); @@ -836,35 +590,13 @@ class MimblewimblecoinWallet extends Bip39Wallet { return config!; } - int _calculateRestoreHeightFrom({required DateTime date}) { - final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; - const int mimblewimblecoinFirstBlock = 1573462800; - const double overestimateSecondsPerBlock = 61; - final int chosenSeconds = secondsSinceEpoch - mimblewimblecoinFirstBlock; - final int approximateHeight = chosenSeconds ~/ overestimateSecondsPerBlock; - int height = approximateHeight; - if (height < 0) { - height = 0; - } - return height; - } - // ============== Overrides ================================================== @override - int get isarTransactionVersion => 2; - - @override - FilterOperation? get changeAddressFilterOperation => - FilterGroup.and(standardChangeAddressFilters); - - @override - FilterOperation? get receivingAddressFilterOperation => - FilterGroup.and(standardReceivingAddressFilters); - - @override - Future checkSaveInitialReceivingAddress() async { - // epiccash seems ok with nothing here? + Future prepareRefresh() async { + // this will always be zero???? + final int curAdd = await getCurrentIndex(); + await _generateAndStoreReceivingAddressForIndex(curAdd); } @override @@ -872,17 +604,17 @@ class MimblewimblecoinWallet extends Bip39Wallet { if (isRestore != true) { // Password presence is the durable "wallet provisioned" marker; the // old wallet-handle marker was process-scoped. - final existingPassword = await secureStorageInterface.read( + final String? existingPassword = await secureStorageInterface.read( key: '${walletId}_password', ); if (existingPassword == null) { await updateNode(); - final mnemonicString = await getMnemonic(); + final String mnemonicString = await getMnemonic(); final String password = generatePassword(); final String stringConfig = await _getConfig(); - final MwcMqsConfigModel mwcmqsConfig = await getMwcMqsConfig(); + final MwcMqsConfigModel mwcmqsConfig = await getRelayConfig(); await secureStorageInterface.write( key: '${walletId}_config', value: stringConfig, @@ -911,24 +643,22 @@ class MimblewimblecoinWallet extends Bip39Wallet { await _generateAndStoreReceivingAddressForIndex(0); // subtract a couple days to ensure we have a buffer for SWB - final bufferedCreateHeight = _calculateRestoreHeightFrom( + final int bufferedCreateHeight = calculateRestoreHeightFrom( date: DateTime.now().subtract(const Duration(days: 2)), ); - final mimblewimblecoinData = ExtraMimblewimblecoinWalletInfo( - receivingIndex: 0, - changeIndex: 0, - slatesToAddresses: {}, - slatesToCommits: {}, - lastScannedBlock: bufferedCreateHeight, - restoreHeight: bufferedCreateHeight, - creationHeight: bufferedCreateHeight, - ); - - await info.updateExtraMimblewimblecoinWalletInfo( - mimblewimblecoinData: mimblewimblecoinData, - isar: mainDB.isar, - ); + final ExtraMimblewimbleWalletInfo mimblewimblecoinData = + ExtraMimblewimbleWalletInfo( + receivingIndex: 0, + changeIndex: 0, + slatesToAddresses: {}, + slatesToCommits: {}, + lastScannedBlock: bufferedCreateHeight, + restoreHeight: bufferedCreateHeight, + creationHeight: bufferedCreateHeight, + ); + + await updateMimblewimbleData(mimblewimblecoinData); } else { try { await _ensureWalletOpen(); @@ -947,24 +677,13 @@ class MimblewimblecoinWallet extends Bip39Wallet { @override Future confirmSend({required TxData txData}) async { try { - final wallet = await _ensureWalletOpen(); - final MwcMqsConfigModel mwcmqsConfig = await getMwcMqsConfig(); + final String wallet = await _ensureWalletOpen(); + final MwcMqsConfigModel mwcmqsConfig = await getRelayConfig(); // TODO determine whether it is worth sending change to a change address. final String receiverAddress = txData.recipients!.first.address; - //if (!receiverAddress.startsWith("http://") || - // !receiverAddress.startsWith("https://")) { - // final bool isMwcmqsConnected = await _testMwcmqsServer( - // mwcmqsConfig, - // ); - // if (!isMwcmqsConnected) { - // throw Exception( - // "Failed to send TX : Unable to reach mimblewimblecoin server"); - // } - //} - ({String commitId, String slateId}) transaction; if (receiverAddress.startsWith("http://") || @@ -993,10 +712,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { ); } - final Map txAddressInfo = {}; + final Map txAddressInfo = {}; txAddressInfo['from'] = (await getCurrentReceivingAddress())!.value; txAddressInfo['to'] = txData.recipients!.first.address; - await _putSendToAddresses(transaction, txAddressInfo); + await putSendToAddresses(transaction, txAddressInfo); return txData.copyWith(txid: transaction.slateId); } catch (e, s) { @@ -1023,13 +742,13 @@ class MimblewimblecoinWallet extends Bip39Wallet { // This would be used for receiving/finalizing slatepacks. return txData.copyWith( fee: Amount.zeroWith(fractionDigits: cryptoCurrency.fractionDigits), - otherData: jsonEncode({'isSlatepackInput': true}), + otherData: jsonEncode({'isSlatepackInput': true}), ); } // For regular address-based sends, calculate fee - final int realFee = await _nativeFee(recipient.amount.raw.toInt()); - final feeAmount = Amount( + final int realFee = await nativeFee(recipient.amount.raw.toInt()); + final Amount feeAmount = Amount( rawValue: BigInt.from(realFee), fractionDigits: cryptoCurrency.fractionDigits, ); @@ -1062,7 +781,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { return txData.copyWith( recipients: [recipient], fee: feeAmount, - otherData: jsonEncode({'transactionMethod': txMethod}), + otherData: jsonEncode({'transactionMethod': txMethod}), ); } catch (e, s) { Logging.instance.e("Mimblewimblecoin prepareSend: $e\n$s"); @@ -1078,20 +797,19 @@ class MimblewimblecoinWallet extends Bip39Wallet { // clear blockchain info await mainDB.deleteWalletBlockchainData(walletId); - await info.updateExtraMimblewimblecoinWalletInfo( - mimblewimblecoinData: info.mimblewimblecoinData!.copyWith( - lastScannedBlock: info.mimblewimblecoinData!.restoreHeight, + await updateMimblewimbleData( + mimblewimbleData!.copyWith( + lastScannedBlock: mimblewimbleData!.restoreHeight, ), - isar: mainDB.isar, ); - unawaited(_startScans()); + unawaited(startScans()); } else { await updateNode(); final String password = generatePassword(); final String stringConfig = await _getConfig(); - final MwcMqsConfigModel mwcmqsConfig = await getMwcMqsConfig(); + final MwcMqsConfigModel mwcmqsConfig = await getRelayConfig(); await secureStorageInterface.write( key: '${walletId}_config', @@ -1101,7 +819,6 @@ class MimblewimblecoinWallet extends Bip39Wallet { key: '${walletId}_password', value: password, ); - await secureStorageInterface.write( key: '${walletId}_mwcmqsConfig', value: mwcmqsConfig.toString(), @@ -1114,21 +831,19 @@ class MimblewimblecoinWallet extends Bip39Wallet { name: info.walletId, ); - final mimblewimblecoinData = ExtraMimblewimblecoinWalletInfo( - receivingIndex: 0, - changeIndex: 0, - slatesToAddresses: {}, - slatesToCommits: {}, - lastScannedBlock: info.restoreHeight, - restoreHeight: info.restoreHeight, - creationHeight: - info.mimblewimblecoinData?.creationHeight ?? info.restoreHeight, - ); + final ExtraMimblewimbleWalletInfo mimblewimblecoinData = + ExtraMimblewimbleWalletInfo( + receivingIndex: 0, + changeIndex: 0, + slatesToAddresses: {}, + slatesToCommits: {}, + lastScannedBlock: info.restoreHeight, + restoreHeight: info.restoreHeight, + creationHeight: + mimblewimbleData?.creationHeight ?? info.restoreHeight, + ); - await info.updateExtraMimblewimblecoinWalletInfo( - mimblewimblecoinData: mimblewimblecoinData, - isar: mainDB.isar, - ); + await updateMimblewimbleData(mimblewimblecoinData); //Open Wallet await _ensureWalletOpen(); @@ -1149,158 +864,13 @@ class MimblewimblecoinWallet extends Bip39Wallet { } } - @override - Future refresh() async { - // Awaiting this lock could be dangerous. - // Since refresh is periodic (generally) - if (refreshMutex.isLocked) { - return; - } - - try { - // this acquire should be almost instant due to above check. - // Slight possibility of race but should be irrelevant - await refreshMutex.acquire(); - - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.syncing, - walletId, - cryptoCurrency, - ), - ); - - // if (info.epicData?.creationHeight == null) { - // await info.updateExtraEpiccashWalletInfo(epicData: inf, isar: isar) - // await epicUpdateCreationHeight(await chainHeight); - // } - - // this will always be zero???? - final int curAdd = await _getCurrentIndex(); - await _generateAndStoreReceivingAddressForIndex(curAdd); - - await _startScans(); - - unawaited(_startSync()); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.0, walletId)); - await updateChainHeight(); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.1, walletId)); - - // if (this is MultiAddressInterface) { - // await (this as MultiAddressInterface) - // .checkReceivingAddressForTransactions(); - // } - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.2, walletId)); - - // // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. - // if (this is MultiAddressInterface) { - // await (this as MultiAddressInterface) - // .checkChangeAddressForTransactions(); - // } - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.3, walletId)); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.50, walletId)); - final fetchFuture = updateTransactions(); - // if (currentHeight != storedHeight) { - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.60, walletId)); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.70, walletId)); - - await fetchFuture; - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.80, walletId)); - - // await getAllTxsToWatch(); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.90, walletId)); - - await updateBalance(); - - GlobalEventBus.instance.fire(RefreshPercentChangedEvent(1.0, walletId)); - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.synced, - walletId, - cryptoCurrency, - ), - ); - - if (shouldAutoSync) { - timer ??= Timer.periodic(const Duration(seconds: 150), (timer) async { - // chain height check currently broken - // if ((await chainHeight) != (await storedChainHeight)) { - - // TODO: [prio=med] some kind of quick check if wallet needs to refresh to replace the old refreshIfThereIsNewData call - // if (await refreshIfThereIsNewData()) { - unawaited(refresh()); - - // } - // } - }); - } - } catch (error, strace) { - GlobalEventBus.instance.fire( - NodeConnectionStatusChangedEvent( - NodeConnectionStatus.disconnected, - walletId, - cryptoCurrency, - ), - ); - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.unableToSync, - walletId, - cryptoCurrency, - ), - ); - Logging.instance.e( - "Caught exception in refreshWalletData(): $error\n$strace", - ); - } finally { - refreshMutex.release(); - } - } - - @override - Future updateBalance() async { - try { - final balances = await _allWalletBalances(); - final balance = Balance( - total: Amount.fromDecimal( - Decimal.parse(balances.total.toString()) + - Decimal.parse(balances.awaitingFinalization.toString()), - fractionDigits: cryptoCurrency.fractionDigits, - ), - spendable: Amount.fromDecimal( - Decimal.parse(balances.spendable.toString()), - fractionDigits: cryptoCurrency.fractionDigits, - ), - blockedTotal: Amount.zeroWith( - fractionDigits: cryptoCurrency.fractionDigits, - ), - pendingSpendable: Amount.fromDecimal( - Decimal.parse(balances.pending.toString()), - fractionDigits: cryptoCurrency.fractionDigits, - ), - ); - - await info.updateBalance(newBalance: balance, isar: mainDB.isar); - } catch (e, s) { - Logging.instance.e( - "Mimblewimblecoin wallet failed to update balance: $e\n$s", - ); - } - } - @override Future updateTransactions() async { try { - final wallet = await _ensureWalletOpen(); - const refreshFromNode = 1; + final String wallet = await _ensureWalletOpen(); + const int refreshFromNode = 1; - final myAddresses = await mainDB + final List myAddresses = await mainDB .getAddresses(walletId) .filter() .typeEqualTo(AddressType.mimbleWimble) @@ -1310,39 +880,40 @@ class MimblewimblecoinWallet extends Bip39Wallet { .valueIsNotEmpty() .valueProperty() .findAll(); - final myAddressesSet = myAddresses.toSet(); + final Set myAddressesSet = myAddresses.toSet(); - final transactions = await libMwc.getTransactions( - wallet: wallet, - refreshFromNode: refreshFromNode, - ); + final List transactions = await libMwc + .getTransactions(wallet: wallet, refreshFromNode: refreshFromNode); final List txns = []; - final slatesToCommits = info.mimblewimblecoinData?.slatesToCommits ?? {}; + final Map slatesToCommits = + mimblewimbleData?.slatesToCommits ?? {}; - for (final tx in transactions) { + for (final MimblewimbleTransaction tx in transactions) { Logging.instance.i("tx: $tx"); - final isIncoming = + final bool isIncoming = libMwc.txTypeIsReceived(tx.txType) || libMwc.txTypeIsReceiveCancelled(tx.txType); - final slateId = tx.txSlateId; - final commitId = slatesToCommits[slateId]?['commitId'] as String?; - final numberOfMessages = tx.messages?.length; - final onChainNote = tx.messages?.first.message; - final addressFrom = slatesToCommits[slateId]?["from"] as String?; - final addressTo = slatesToCommits[slateId]?["to"] as String?; - - final credit = int.parse(tx.amountCredited); - final debit = int.parse(tx.amountDebited); - final fee = int.tryParse(tx.fee ?? "0") ?? 0; + final String? slateId = tx.txSlateId; + final String? commitId = + slatesToCommits[slateId]?['commitId'] as String?; + final int? numberOfMessages = tx.messages?.length; + final String? onChainNote = tx.messages?.first.message; + final String? addressFrom = + slatesToCommits[slateId]?["from"] as String?; + final String? addressTo = slatesToCommits[slateId]?["to"] as String?; + + final int credit = int.parse(tx.amountCredited); + final int debit = int.parse(tx.amountDebited); + final int fee = int.tryParse(tx.fee ?? "0") ?? 0; // hack Mimblewimblecoin tx data into inputs and outputs final List outputs = []; final List inputs = []; - final addressFromIsMine = myAddressesSet.contains(addressFrom); - final addressToIsMine = myAddressesSet.contains(addressTo); + final bool addressFromIsMine = myAddressesSet.contains(addressFrom); + final bool addressToIsMine = myAddressesSet.contains(addressTo); OutputV2 output = OutputV2.isarCantDoRequiredInDefaultConstructor( scriptPubKeyHex: "00", @@ -1383,7 +954,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { // Try to get better type determination for slatepack transactions if (addressTo != null) { try { - final slatepackType = await getSlatepackTransactionType( + final String slatepackType = await getSlatepackTransactionType( addressTo, ); if (slatepackType == 'Incoming') { @@ -1406,7 +977,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { outputs.add(output); inputs.add(input); - final otherData = { + final Map otherData = { "isMimblewimblecoinTransaction": true, "numberOfMessages": numberOfMessages, "slateId": slateId, @@ -1420,7 +991,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { ).toJsonString(), }; - final txn = TransactionV2( + final TransactionV2 txn = TransactionV2( walletId: walletId, blockHash: null, hash: commitId ?? tx.id.toString(), @@ -1454,17 +1025,11 @@ class MimblewimblecoinWallet extends Bip39Wallet { } } - @override - Future updateUTXOs() async { - // not used for mimblewimblecoin - return false; - } - @override Future updateNode() async { _mimblewimblecoinNode = getCurrentNode(); - final walletDir = await _currentWalletDirPath(); + final String walletDir = await currentWalletDirPath(); await _ensureApiSecret(walletDir); // TODO: [prio=low] move this out of secure storage if secure storage not needed @@ -1480,7 +1045,9 @@ class MimblewimblecoinWallet extends Bip39Wallet { @override Future pingCheck() async { try { - final node = nodeService.getPrimaryNodeFor(currency: cryptoCurrency); + final NodeModel? node = nodeService.getPrimaryNodeFor( + currency: cryptoCurrency, + ); return await testMwcNodeConnection( NodeFormData() ..host = node!.host @@ -1494,57 +1061,6 @@ class MimblewimblecoinWallet extends Bip39Wallet { return false; } } - - @override - Future updateChainHeight() async { - final config = await _getRealConfig(); - final latestHeight = await libMwc.getChainHeight(config: config); - await info.updateCachedChainHeight( - newHeight: latestHeight, - isar: mainDB.isar, - ); - } - - @override - Future estimateFeeFor(Amount amount, BigInt feeRate) async { - // setting ifErrorEstimateFee doesn't do anything as its not used in the nativeFee function????? - final int currentFee = await _nativeFee( - amount.raw.toInt(), - ifErrorEstimateFee: true, - ); - return Amount( - rawValue: BigInt.from(currentFee), - fractionDigits: cryptoCurrency.fractionDigits, - ); - } - - @override - Future get fees async { - // this wasn't done before the refactor either so... - // TODO: implement _getFees - return FeeObject( - numberOfBlocksFast: 10, - numberOfBlocksAverage: 10, - numberOfBlocksSlow: 10, - fast: BigInt.one, - medium: BigInt.one, - slow: BigInt.one, - ); - } - - @override - Future updateSentCachedTxData({required TxData txData}) async { - // TODO: [prio=low] Was not used before refactor so maybe not required(?) - return txData; - } - - @override - Future exit() async { - timer?.cancel(); - timer = null; - await super.exit(); - Logging.instance.i("Mimblewimblecoin_wallet exit finished"); - } } Future deleteMimblewimblecoinWallet({ @@ -1560,11 +1076,11 @@ Future deleteMimblewimblecoinWallet({ if (Platform.isIOS) { final Directory appDir = await StackFileSystem.applicationRootDirectory(); - final path = "${appDir.path}/mimblewimblecoin"; + final String path = "${appDir.path}/mimblewimblecoin"; final String name = walletId.trim(); - final walletDir = '$path/$name'; + final String walletDir = '$path/$name'; - final editConfig = jsonDecode(config); + final dynamic editConfig = jsonDecode(config); editConfig["wallet_dir"] = walletDir; config = jsonEncode(editConfig); diff --git a/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart b/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart new file mode 100644 index 0000000000..1d909f6b62 --- /dev/null +++ b/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart @@ -0,0 +1,614 @@ +import 'dart:async'; +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 '../../../models/balance.dart'; +import '../../../models/paymint/fee_object_model.dart'; +import '../../../models/relay_config_model.dart'; +import '../../../models/slatepack_models.dart'; +import '../../../services/event_bus/events/global/blocks_remaining_event.dart'; +import '../../../services/event_bus/events/global/node_connection_status_changed_event.dart'; +import '../../../services/event_bus/events/global/refresh_percent_changed_event.dart'; +import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../../services/event_bus/global_event_bus.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/stack_file_system.dart'; +import '../../models/tx_data.dart'; +import '../supporting/mimblewimble_wallet_info_extension.dart'; +import '../supporting/restore_progress.dart'; +import 'bip39_wallet.dart'; + +/// What Epic Cash and MWC have in common: a native wallet that is scanned +/// in block chunks, a relay server for automatic slate exchange, and manual +/// slatepack exchange. +abstract class MimblewimbleWallet extends Bip39Wallet { + MimblewimbleWallet(super.currency); + + final Mutex syncMutex = Mutex(); + Timer? timer; + + double highestPercent = 0; + + /// The [WalletInfoKeys] entry [ExtraMimblewimbleWalletInfo] is stored under. + String get walletInfoDataKey; + + /// Subdirectory of the app root holding the native wallet files. + String get walletDirName; + + /// Unix time of the chain's first block, used to estimate a height from a + /// date. + int get firstBlockTimestamp; + + ExtraMimblewimbleWalletInfo? get mimblewimbleData => + info.mimblewimbleData(walletInfoDataKey); + + Future updateMimblewimbleData(ExtraMimblewimbleWalletInfo data) { + return info.updateMimblewimbleData( + key: walletInfoDataKey, + data: data, + isar: mainDB.isar, + ); + } + + Future get getSyncPercent async { + final int lastScannedBlock = mimblewimbleData?.lastScannedBlock ?? 0; + final int _chainHeight = await chainHeight; + final restorePercent = calculateRestoreProgress( + scannedHeight: lastScannedBlock, + chainHeight: _chainHeight, + ); + GlobalEventBus.instance.fire( + RefreshPercentChangedEvent(highestPercent, walletId), + ); + if (restorePercent > highestPercent) { + highestPercent = restorePercent; + } + + final int blocksRemaining = _chainHeight - lastScannedBlock; + GlobalEventBus.instance.fire( + BlocksRemainingEvent(blocksRemaining, walletId), + ); + + return restorePercent < 0 ? 0.0 : restorePercent; + } + + Future updateRestoreHeight(int height) async { + await updateMimblewimbleData( + mimblewimbleData!.copyWith(restoreHeight: height), + ); + } + + /// Returns an empty String on success, error message on failure. + Future cancelPendingTransactionAndPost(String txSlateId); + + Future getRelayConfig(); + + Future updateRelayConfig(String host, int port); + + // ================= Slatepack Operations =================================== + + Future createSlatepack({ + required Amount amount, + String? recipientAddress, + String? message, + bool encrypt = false, + int? minimumConfirmations, + }); + + Future decodeSlatepack(String slatepack); + + /// Receive a slatepack and return the response slatepack. + Future receiveSlatepack(String slatepack); + + /// Finalize a slatepack (sender step 3). + Future finalizeSlatepack(String slatepack); + + bool isSlatepack(String data); + + bool isHttpAddress(String address) { + return address.startsWith('http://') || address.startsWith('https://'); + } + + /// Cheap syntactic check run by [fullDecodeSlatepack] before decoding. + /// Throws to reject input that is obviously not a slatepack. + @protected + void checkSlatepackFormat(String slatepack) {} + + // this isn't nearly the best way to handle this but better than copy pasting + // this function in two places (mobile and desktop widgets)... + Future<({SlatepackDecodeResult result, String type, String raw})?> + fullDecodeSlatepack(String slatepack) async { + // add delay for showloading exception catching hack fix + await Future.delayed(const Duration(seconds: 1)); + + if (slatepack.isEmpty) { + return null; + } + + checkSlatepackFormat(slatepack); + + final SlatepackDecodeResult decoded = await decodeSlatepack(slatepack); + if (!decoded.success) { + throw Exception(decoded.error ?? "Failed to decode slatepack"); + } + + final analysis = await analyzeSlatepack(slatepack); + + final String slatepackType = switch (analysis.status) { + 'S1' => "S1 (Initial Send)", + 'S2' => "S2 (Response)", + 'S3' => "S3 (Finalized)", + _ => _slatepackTypeFromAddresses(decoded), + }; + + return (result: decoded, type: slatepackType, raw: slatepack); + } + + String _slatepackTypeFromAddresses(SlatepackDecodeResult decoded) { + if (decoded.senderAddress != null && decoded.recipientAddress != null) { + return "S2 (Response)"; + } else if (decoded.senderAddress != null) { + return "S1 (Initial)"; + } else { + return "Unknown"; + } + } + + /// Analyze a slatepack and determine transaction type and metadata. + Future< + ({ + String type, + String status, + String? amount, + bool wasEncrypted, + String? senderAddress, + String? recipientAddress, + String slateId, + }) + > + analyzeSlatepack(String slatepack) async { + try { + final SlatepackDecodeResult decoded = await decodeSlatepack(slatepack); + if (!decoded.success) { + throw Exception(decoded.error); + } + + final dynamic slateData = jsonDecode(decoded.slateJson!); + final String slateId = "${slateData['id'] ?? ''}"; + final String? amountStr = slateData['amount']?.toString(); + + Logging.instance.d('Analyzed slatepack with ID: $slateId'); + + String status = 'Unknown'; + String type = 'Unknown'; + + // Count signatures in the participant data to work out which step + // the slate is at. + final List? participants = + slateData['participant_data'] as List?; + if (participants != null && participants.isNotEmpty) { + int signedParticipants = 0; + for (final dynamic participant in participants) { + if (participant['part_sig'] != null) { + signedParticipants++; + } + } + + if (signedParticipants == 0) { + status = 'S1'; + type = 'Outgoing'; + } else if (signedParticipants == 1) { + status = 'S2'; + type = 'Incoming'; + } else if (signedParticipants >= participants.length) { + status = 'S3'; + type = 'Outgoing'; + } + } + + // Fallback: some slates carry an explicit 'sta' field. + if (status == 'Unknown' && slateData['sta'] != null) { + status = "${slateData['sta']}"; + if (status == 'S1') { + type = 'Outgoing'; + } else if (status == 'S2') { + type = 'Incoming'; + } else if (status == 'S3') { + type = 'Outgoing'; + } + } + + return ( + type: type, + status: status, + amount: amountStr, + wasEncrypted: decoded.wasEncrypted ?? false, + senderAddress: decoded.senderAddress, + recipientAddress: decoded.recipientAddress, + slateId: slateId, + ); + } catch (e) { + return ( + type: 'Unknown', + status: 'Unknown', + amount: null, + wasEncrypted: false, + senderAddress: null, + recipientAddress: null, + slateId: '', + ); + } + } + + // ================= Native wallet hooks ===================================== + + @protected + Future< + ({ + double awaitingFinalization, + double pending, + double spendable, + double total, + }) + > + fetchWalletBalances({ + required int refreshFromNode, + required int minimumConfirmations, + }); + + @protected + Future fetchChainHeight(); + + @protected + Future nativeFee(int satoshiAmount); + + @protected + Future scanOutputs({ + required int startHeight, + required int numberOfBlocks, + }); + + /// Called before scanning starts. [needsScanning] is false when the wallet + /// is already at the tip. + @protected + Future pauseListenerForScan({required bool needsScanning}); + + /// Called once scanning has reached the tip. + @protected + Future resumeListenerAfterScan(); + + /// Runs at the start of every [refresh], before scanning. + @protected + Future prepareRefresh(); + + // ================= Shared implementation =================================== + + Future currentWalletDirPath() async { + final Directory appDir = await StackFileSystem.applicationRootDirectory(); + + final String path = "${appDir.path}/$walletDirName"; + final String name = walletId.trim(); + return '$path/$name'; + } + + // TODO: make more robust estimate of date maybe using https://explorer.epic.tech/api-index + int calculateRestoreHeightFrom({required DateTime date}) { + final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000; + const double overestimateSecondsPerBlock = 61; + final int chosenSeconds = secondsSinceEpoch - firstBlockTimestamp; + final int approximateHeight = chosenSeconds ~/ overestimateSecondsPerBlock; + int height = approximateHeight; + if (height < 0) { + height = 0; + } + return height; + } + + @protected + Future startSync() async { + Logging.instance.d("request start sync"); + const int refreshFromNode = 1; + if (!syncMutex.isLocked) { + await syncMutex.protect(() async { + // How does getWalletBalances start syncing?????????!!!!! + await fetchWalletBalances( + refreshFromNode: refreshFromNode, + minimumConfirmations: 10, + ); + }); + } else { + Logging.instance.d("request start sync denied"); + } + } + + @protected + Future startScans() async { + try { + // max number of blocks to scan per loop iteration + const int scanChunkSize = 10000; + + // force firing of scan progress event + await getSyncPercent; + + // fetch current chain height and last scanned block (should be the + // restore height if full rescan or a wallet restore) + int chainHeight = await this.chainHeight; + int lastScannedBlock = mimblewimbleData!.lastScannedBlock; + + await pauseListenerForScan(needsScanning: lastScannedBlock < chainHeight); + + // loop while scanning in chain in chunks (of blocks?) + while (lastScannedBlock < chainHeight) { + Logging.instance.d( + "chainHeight: $chainHeight, lastScannedBlock: $lastScannedBlock", + ); + + final int nextScannedBlock = await scanOutputs( + startHeight: lastScannedBlock, + numberOfBlocks: scanChunkSize, + ); + + // update local cache + await updateMimblewimbleData( + mimblewimbleData!.copyWith(lastScannedBlock: nextScannedBlock), + ); + + // force firing of scan progress event + await getSyncPercent; + + // update while loop condition variables + chainHeight = await this.chainHeight; + lastScannedBlock = nextScannedBlock; + } + + Logging.instance.d("startScans successfully at the tip"); + + await resumeListenerAfterScan(); + } catch (e, s) { + Logging.instance.e("startScans failed: ", error: e, stackTrace: s); + rethrow; + } + } + + @protected + Future putSendToAddresses( + ({String slateId, String commitId}) slateData, + Map txAddressInfo, + ) async { + try { + final Map slatesToCommits = + mimblewimbleData?.slatesToCommits ?? {}; + final String? from = txAddressInfo['from']; + final String? to = txAddressInfo['to']; + slatesToCommits[slateData.slateId] = { + "commitId": slateData.commitId, + "from": from, + "to": to, + }; + await updateMimblewimbleData( + mimblewimbleData!.copyWith(slatesToCommits: slatesToCommits), + ); + return true; + } catch (e, s) { + Logging.instance.e("ERROR STORING ADDRESS", error: e, stackTrace: s); + return false; + } + } + + @protected + Future getCurrentIndex() async { + try { + final int receivingIndex = mimblewimbleData!.receivingIndex; + // TODO: go through pendingarray and processed array and choose the index + // of the last one that has not been processed, or the index after the + // one most recently processed; + return receivingIndex; + } catch (e, s) { + Logging.instance.e("$e $s", error: e, stackTrace: s); + return 0; + } + } + + // ============== Overrides ================================================== + + @override + int get isarTransactionVersion => 2; + + @override + FilterOperation? get changeAddressFilterOperation => + FilterGroup.and(standardChangeAddressFilters); + + @override + FilterOperation? get receivingAddressFilterOperation => + FilterGroup.and(standardReceivingAddressFilters); + + @override + Future checkSaveInitialReceivingAddress() async { + // epiccash seems ok with nothing here? + } + + @override + Future refresh({bool doScan = true}) async { + // Awaiting this lock could be dangerous. + // Since refresh is periodic (generally) + if (refreshMutex.isLocked) { + return; + } + + try { + // this acquire should be almost instant due to above check. + // Slight possibility of race but should be irrelevant + await refreshMutex.acquire(); + + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.syncing, + walletId, + cryptoCurrency, + ), + ); + + await prepareRefresh(); + + if (doScan) { + await startScans(); + + unawaited(startSync()); + } + + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.0, walletId)); + await updateChainHeight(); + + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.1, walletId)); + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.2, walletId)); + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.3, walletId)); + + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.50, walletId)); + final Future fetchFuture = updateTransactions(); + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.60, walletId)); + + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.70, walletId)); + + await fetchFuture; + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.80, walletId)); + + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.90, walletId)); + + await updateBalance(); + + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(1.0, walletId)); + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.synced, + walletId, + cryptoCurrency, + ), + ); + + if (shouldAutoSync) { + timer ??= Timer.periodic(const Duration(seconds: 150), (Timer timer) { + // TODO: [prio=med] some kind of quick check if wallet needs to + // refresh to replace the old refreshIfThereIsNewData call + unawaited(refresh()); + }); + } + } catch (e, s) { + GlobalEventBus.instance.fire( + NodeConnectionStatusChangedEvent( + NodeConnectionStatus.disconnected, + walletId, + cryptoCurrency, + ), + ); + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.unableToSync, + walletId, + cryptoCurrency, + ), + ); + Logging.instance.e( + "Caught exception in refreshWalletData()", + error: e, + stackTrace: s, + ); + } finally { + refreshMutex.release(); + } + } + + @override + Future updateBalance() async { + try { + final balances = await fetchWalletBalances( + refreshFromNode: 0, + minimumConfirmations: cryptoCurrency.minConfirms, + ); + final Balance balance = Balance( + total: Amount.fromDecimal( + Decimal.parse(balances.total.toString()) + + Decimal.parse(balances.awaitingFinalization.toString()), + fractionDigits: cryptoCurrency.fractionDigits, + ), + spendable: Amount.fromDecimal( + Decimal.parse(balances.spendable.toString()), + fractionDigits: cryptoCurrency.fractionDigits, + ), + blockedTotal: Amount.zeroWith( + fractionDigits: cryptoCurrency.fractionDigits, + ), + pendingSpendable: Amount.fromDecimal( + Decimal.parse(balances.pending.toString()), + fractionDigits: cryptoCurrency.fractionDigits, + ), + ); + + await info.updateBalance(newBalance: balance, isar: mainDB.isar); + } catch (e, s) { + Logging.instance.w( + "${cryptoCurrency.prettyName} wallet failed to update balance: ", + error: e, + stackTrace: s, + ); + } + } + + @override + Future updateUTXOs() async { + // not used for mimblewimble coins + return false; + } + + @override + Future updateChainHeight() async { + final int latestHeight = await fetchChainHeight(); + await info.updateCachedChainHeight( + newHeight: latestHeight, + isar: mainDB.isar, + ); + } + + @override + Future estimateFeeFor(Amount amount, BigInt feeRate) async { + final int currentFee = await nativeFee(amount.raw.toInt()); + return Amount( + rawValue: BigInt.from(currentFee), + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + + @override + Future get fees async { + // this wasn't done before the refactor either so... + // TODO: implement _getFees + return FeeObject( + numberOfBlocksFast: 10, + numberOfBlocksAverage: 10, + numberOfBlocksSlow: 10, + fast: BigInt.one, + medium: BigInt.one, + slow: BigInt.one, + ); + } + + @override + Future updateSentCachedTxData({required TxData txData}) async { + // TODO: [prio=low] Was not used before refactor so maybe not required(?) + return txData; + } + + @override + Future exit() async { + timer?.cancel(); + timer = null; + + await super.exit(); + Logging.instance.d("$runtimeType exit finished"); + } +} diff --git a/lib/wallets/wallet/supporting/epiccash_wallet_info_extension.dart b/lib/wallets/wallet/supporting/mimblewimble_wallet_info_extension.dart similarity index 76% rename from lib/wallets/wallet/supporting/epiccash_wallet_info_extension.dart rename to lib/wallets/wallet/supporting/mimblewimble_wallet_info_extension.dart index b12e472d0b..d2582dd20e 100644 --- a/lib/wallets/wallet/supporting/epiccash_wallet_info_extension.dart +++ b/lib/wallets/wallet/supporting/mimblewimble_wallet_info_extension.dart @@ -5,19 +5,20 @@ import 'package:isar_community/isar.dart'; import '../../../utilities/logger.dart'; import '../../isar/models/wallet_info.dart'; -extension EpiccashWalletInfoExtension on WalletInfo { - ExtraEpiccashWalletInfo? get epicData { - final String? data = otherData[WalletInfoKeys.epiccashData] as String?; +extension MimblewimbleWalletInfoExtension on WalletInfo { + /// [key] is the [WalletInfoKeys] entry the coin keeps its data under. + ExtraMimblewimbleWalletInfo? mimblewimbleData(String key) { + final String? data = otherData[key] as String?; if (data == null) { return null; } try { - return ExtraEpiccashWalletInfo.fromMap( + return ExtraMimblewimbleWalletInfo.fromMap( Map.from(jsonDecode(data) as Map), ); } catch (e, s) { Logging.instance.e( - "ExtraEpiccashWalletInfo.fromMap failed: ", + "ExtraMimblewimbleWalletInfo.fromMap failed: ", error: e, stackTrace: s, ); @@ -25,19 +26,20 @@ extension EpiccashWalletInfoExtension on WalletInfo { } } - Future updateExtraEpiccashWalletInfo({ - required ExtraEpiccashWalletInfo epicData, + Future updateMimblewimbleData({ + required String key, + required ExtraMimblewimbleWalletInfo data, required Isar isar, }) async { await updateOtherData( - newEntries: {WalletInfoKeys.epiccashData: jsonEncode(epicData.toMap())}, + newEntries: {key: jsonEncode(data.toMap())}, isar: isar, ); } } /// Holds data previously stored in hive -class ExtraEpiccashWalletInfo { +class ExtraMimblewimbleWalletInfo { final int receivingIndex; final int changeIndex; @@ -49,7 +51,7 @@ class ExtraEpiccashWalletInfo { final int restoreHeight; final int creationHeight; - ExtraEpiccashWalletInfo({ + ExtraMimblewimbleWalletInfo({ required this.receivingIndex, required this.changeIndex, required this.slatesToAddresses, @@ -59,9 +61,8 @@ class ExtraEpiccashWalletInfo { required this.creationHeight, }); - // Convert the object to JSON Map toMap() { - return { + return { 'receivingIndex': receivingIndex, 'changeIndex': changeIndex, 'slatesToAddresses': slatesToAddresses, @@ -72,7 +73,7 @@ class ExtraEpiccashWalletInfo { }; } - ExtraEpiccashWalletInfo.fromMap(Map json) + ExtraMimblewimbleWalletInfo.fromMap(Map json) : receivingIndex = json['receivingIndex'] as int, changeIndex = json['changeIndex'] as int, slatesToAddresses = json['slatesToAddresses'] as Map, @@ -81,7 +82,7 @@ class ExtraEpiccashWalletInfo { restoreHeight = json['restoreHeight'] as int, creationHeight = json['creationHeight'] as int; - ExtraEpiccashWalletInfo copyWith({ + ExtraMimblewimbleWalletInfo copyWith({ int? receivingIndex, int? changeIndex, Map? slatesToAddresses, @@ -90,7 +91,7 @@ class ExtraEpiccashWalletInfo { int? restoreHeight, int? creationHeight, }) { - return ExtraEpiccashWalletInfo( + return ExtraMimblewimbleWalletInfo( receivingIndex: receivingIndex ?? this.receivingIndex, changeIndex: changeIndex ?? this.changeIndex, slatesToAddresses: slatesToAddresses ?? this.slatesToAddresses, diff --git a/lib/wallets/wallet/supporting/mimblewimblecoin_wallet_info_extension.dart b/lib/wallets/wallet/supporting/mimblewimblecoin_wallet_info_extension.dart deleted file mode 100644 index ec820d0fbe..0000000000 --- a/lib/wallets/wallet/supporting/mimblewimblecoin_wallet_info_extension.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'dart:convert'; - -import 'package:isar_community/isar.dart'; - -import '../../../utilities/logger.dart'; -import '../../isar/models/wallet_info.dart'; - -extension MimblewimblecoinWalletInfoExtension on WalletInfo { - ExtraMimblewimblecoinWalletInfo? get mimblewimblecoinData { - final String? data = - otherData[WalletInfoKeys.mimblewimblecoinData] as String?; - if (data == null) { - return null; - } - try { - return ExtraMimblewimblecoinWalletInfo.fromMap( - Map.from(jsonDecode(data) as Map), - ); - } catch (e, s) { - Logging.instance.e( - "ExtraMimblewimblecoinWalletInfo.fromMap failed: $e\n$s", - ); - return null; - } - } - - Future updateExtraMimblewimblecoinWalletInfo({ - required ExtraMimblewimblecoinWalletInfo mimblewimblecoinData, - required Isar isar, - }) async { - await updateOtherData( - newEntries: { - WalletInfoKeys.mimblewimblecoinData: jsonEncode( - mimblewimblecoinData.toMap(), - ), - }, - isar: isar, - ); - } -} - -/// Holds data previously stored in hive -class ExtraMimblewimblecoinWalletInfo { - final int receivingIndex; - final int changeIndex; - - // TODO [prio=low] strongly type these maps at some point - final Map slatesToAddresses; - final Map slatesToCommits; - - final int lastScannedBlock; - final int restoreHeight; - final int creationHeight; - - ExtraMimblewimblecoinWalletInfo({ - required this.receivingIndex, - required this.changeIndex, - required this.slatesToAddresses, - required this.slatesToCommits, - required this.lastScannedBlock, - required this.restoreHeight, - required this.creationHeight, - }); - - // Convert the object to JSON - Map toMap() { - return { - 'receivingIndex': receivingIndex, - 'changeIndex': changeIndex, - 'slatesToAddresses': slatesToAddresses, - 'slatesToCommits': slatesToCommits, - 'lastScannedBlock': lastScannedBlock, - 'restoreHeight': restoreHeight, - 'creationHeight': creationHeight, - }; - } - - ExtraMimblewimblecoinWalletInfo.fromMap(Map json) - : receivingIndex = json['receivingIndex'] as int, - changeIndex = json['changeIndex'] as int, - slatesToAddresses = json['slatesToAddresses'] as Map, - slatesToCommits = json['slatesToCommits'] as Map, - lastScannedBlock = json['lastScannedBlock'] as int, - restoreHeight = json['restoreHeight'] as int, - creationHeight = json['creationHeight'] as int; - - ExtraMimblewimblecoinWalletInfo copyWith({ - int? receivingIndex, - int? changeIndex, - Map? slatesToAddresses, - Map? slatesToCommits, - int? lastScannedBlock, - int? restoreHeight, - int? creationHeight, - }) { - return ExtraMimblewimblecoinWalletInfo( - receivingIndex: receivingIndex ?? this.receivingIndex, - changeIndex: changeIndex ?? this.changeIndex, - slatesToAddresses: slatesToAddresses ?? this.slatesToAddresses, - slatesToCommits: slatesToCommits ?? this.slatesToCommits, - lastScannedBlock: lastScannedBlock ?? this.lastScannedBlock, - restoreHeight: restoreHeight ?? this.restoreHeight, - creationHeight: creationHeight ?? this.creationHeight, - ); - } - - @override - String toString() { - return toMap().toString(); - } -} From 3eca358a6fe12a0cc08885fc93f7606594882325 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 7 Sep 2026 20:21:19 -0500 Subject: [PATCH 04/30] Use one slatepack UI for Epic Cash and MWC The finalize view, entry/import/response dialogs, send-slatepack dialog and method toggle existed twice, differing only in the wallet type they cast to and in whether the copy said "slate" or "slatepack". They now take a MimblewimbleWallet and pick their wording from SlatepackTerms. The forked _createSlatepack/_importSlatepack/receive handlers in the send, receive and confirm views collapse the same way. EpicFinalizeView stays as a thin wrapper so its route name keeps working. The transaction method enums and their providers become one enum keyed by coin, keeping Epic's default of relay (epicbox) and MWC's default of slatepack. --- .../epic_finalize_view.dart | 331 +----------------- lib/pages/finalize_view/finalize_view.dart | 22 +- lib/pages/receive_view/receive_view.dart | 95 +---- .../epic_slatepack_entry_dialog.dart | 219 ------------ .../epic_slatepack_import_dialog.dart | 316 ----------------- .../sub_widgets/slatepack_entry_dialog.dart | 7 +- ...alog.dart => slatepack_import_dialog.dart} | 119 ++++--- .../send_view/confirm_transaction_view.dart | 138 +------- lib/pages/send_view/send_view.dart | 140 ++------ .../sub_widgets/epic_slatepack_dialog.dart | 175 --------- ...pack_dialog.dart => slatepack_dialog.dart} | 68 ++-- .../transaction_details_view.dart | 62 +--- .../tx_v2/transaction_v2_details_view.dart | 62 +--- lib/pages/wallet_view/wallet_view.dart | 23 +- .../sub_widgets/desktop_receive.dart | 132 ++----- .../wallet_view/sub_widgets/desktop_send.dart | 217 ++---------- .../wallet_view/sub_widgets/my_wallet.dart | 16 +- .../ui/preview_tx_button_state_provider.dart | 95 +++-- .../enums/epic_transaction_method.dart | 48 --- .../mimblewimble_transaction_method.dart | 8 + .../enums/mwc_transaction_method.dart | 70 ---- lib/utilities/slatepack_terms.dart | 25 ++ .../crypto_currency/coins/epiccash.dart | 10 +- .../coins/mimblewimblecoin.dart | 12 +- lib/widgets/epic_txs_method_toggle.dart | 67 ---- lib/widgets/mwc_txs_method_toggle.dart | 67 ---- lib/widgets/slatepack_method_toggle.dart | 63 ++++ 27 files changed, 385 insertions(+), 2222 deletions(-) delete mode 100644 lib/pages/receive_view/sub_widgets/epic_slatepack_entry_dialog.dart delete mode 100644 lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart rename lib/pages/receive_view/sub_widgets/{mwc_slatepack_import_dialog.dart => slatepack_import_dialog.dart} (80%) delete mode 100644 lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart rename lib/pages/send_view/sub_widgets/{mwc_slatepack_dialog.dart => slatepack_dialog.dart} (79%) delete mode 100644 lib/utilities/enums/epic_transaction_method.dart create mode 100644 lib/utilities/enums/mimblewimble_transaction_method.dart delete mode 100644 lib/utilities/enums/mwc_transaction_method.dart create mode 100644 lib/utilities/slatepack_terms.dart delete mode 100644 lib/widgets/epic_txs_method_toggle.dart delete mode 100644 lib/widgets/mwc_txs_method_toggle.dart create mode 100644 lib/widgets/slatepack_method_toggle.dart diff --git a/lib/pages/epic_finalize_view/epic_finalize_view.dart b/lib/pages/epic_finalize_view/epic_finalize_view.dart index 13abb38d6e..1c56cfc30e 100644 --- a/lib/pages/epic_finalize_view/epic_finalize_view.dart +++ b/lib/pages/epic_finalize_view/epic_finalize_view.dart @@ -1,338 +1,17 @@ -/* - * This file is part of Stack Wallet. - * - * Copyright (c) 2023 Cypher Stack - * All Rights Reserved. - * The code is distributed under GPLv3 license, see LICENSE file for details. - * Generated by Cypher Stack on 2026-01-12 - * - */ - -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../notifications/show_flush_bar.dart'; -import '../../providers/global/barcode_scanner_provider.dart'; -import '../../providers/global/wallets_provider.dart'; -import '../../themes/stack_colors.dart'; -import '../../utilities/barcode_scanner_interface.dart'; -import '../../utilities/clipboard_interface.dart'; -import '../../utilities/constants.dart'; -import '../../utilities/logger.dart'; -import '../../utilities/show_loading.dart'; -import '../../utilities/text_styles.dart'; -import '../../utilities/util.dart'; -import '../../wallets/wallet/impl/epiccash_wallet.dart'; -import '../../widgets/background.dart'; -import '../../widgets/conditional_parent.dart'; -import '../../widgets/custom_buttons/app_bar_icon_button.dart'; -import '../../widgets/desktop/primary_button.dart'; -import '../../widgets/icon_widgets/clipboard_icon.dart'; -import '../../widgets/icon_widgets/qrcode_icon.dart'; -import '../../widgets/icon_widgets/x_icon.dart'; -import '../../widgets/stack_dialog.dart'; -import '../../widgets/stack_text_field.dart'; -import '../../widgets/textfield_icon_button.dart'; +import '../finalize_view/finalize_view.dart'; -class EpicFinalizeView extends ConsumerStatefulWidget { - const EpicFinalizeView({ - super.key, - required this.walletId, - this.clipboard = const ClipboardWrapper(), - }); +/// Keeps the old Epic Cash route resolving; the screen is [FinalizeView]. +class EpicFinalizeView extends StatelessWidget { + const EpicFinalizeView({super.key, required this.walletId}); static const String routeName = "/epicFinalizeView"; final String walletId; - final ClipboardInterface clipboard; - - @override - ConsumerState createState() => _EpicFinalizeViewState(); -} - -class _EpicFinalizeViewState extends ConsumerState { - late final TextEditingController _slateController; - late final FocusNode _slateFocusNode; - - bool _slateToggleFlag = false; - - Future _pasteSlatepack() async { - final ClipboardData? data = await widget.clipboard.getData( - Clipboard.kTextPlain, - ); - if (data?.text != null && data!.text!.isNotEmpty) { - _slateController.text = data.text!; - setState(() { - _slateToggleFlag = _slateController.text.isNotEmpty; - }); - } - } - - Future _scanQr() async { - try { - if (!Util.isDesktop && _slateFocusNode.hasFocus) { - _slateFocusNode.unfocus(); - await Future.delayed(const Duration(milliseconds: 75)); - } - - if (mounted) { - final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - if (qrResult.rawContent != null && qrResult.rawContent!.isNotEmpty) { - _slateController.text = qrResult.rawContent!; - setState(() { - _slateToggleFlag = _slateController.text.isNotEmpty; - }); - } - } - } on PlatformException catch (e, s) { - if (mounted) { - try { - await checkCamPermDeniedMobileAndOpenAppSettings( - context, - logging: Logging.instance, - ); - } catch (e, s) { - Logging.instance.e( - "Failed to check cam permissions", - error: e, - stackTrace: s, - ); - } - } else { - Logging.instance.e( - "Failed to get camera permissions while trying to scan qr code in SendView: ", - error: e, - stackTrace: s, - ); - } - } - } - - Future _finalize() async { - // add delay for showloading exception catching hack fix - await Future.delayed(const Duration(seconds: 1)); - - final wallet = - ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; - - final decoded = await wallet.decodeSlatepack(_slateController.text); - if (!decoded.success) { - throw Exception(decoded.error ?? "Failed to decode slate"); - } - - final analysis = await wallet.analyzeSlatepack(_slateController.text); - if (analysis.status != "S2") { - throw Exception("Invalid slate type: ${analysis.status}"); - } - - final result = await wallet.finalizeSlatepack(_slateController.text); - - if (!result.success) { - throw Exception( - result.error ?? "Finalize failed without providing an error???", - ); - } - } - - Future _finalizePressed() async { - if (!Util.isDesktop && _slateFocusNode.hasFocus) { - _slateFocusNode.unfocus(); - await Future.delayed(const Duration(milliseconds: 75)); - } - if (mounted) { - Exception? ex; - await showLoading( - whileFuture: _finalize(), - context: context, - message: "Finalizing slate...", - rootNavigator: Util.isDesktop, - onException: (e) => ex = e, - ); - - if (mounted) { - if (ex != null) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - desktopPopRootNavigator: Util.isDesktop, - title: "Slate finalize error", - message: ex?.toString() ?? "Unexpected result without exception", - maxWidth: Util.isDesktop ? 400 : null, - ), - ); - } else { - setState(() { - _slateController.text = ""; - }); - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Transaction finalized and broadcast successfully!", - context: context, - ), - ); - } - } - } - } - - @override - void initState() { - super.initState(); - _slateController = TextEditingController(); - _slateFocusNode = FocusNode(); - } - - @override - void dispose() { - _slateController.dispose(); - _slateFocusNode.dispose(); - super.dispose(); - } @override Widget build(BuildContext context) { - debugPrint("BUILD: $runtimeType"); - - return ConditionalParent( - condition: !Util.isDesktop, - builder: (child) => Background( - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () { - Navigator.of(context).pop(); - }, - ), - title: Text( - "Finalize slate", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: Constants.size.standardPadding, - ), - child: child, - ), - ), - ), - ); - }, - ), - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - minLines: 1, - maxLines: 5, - key: const Key("epicFinalizeSlateFieldKey"), - controller: _slateController, - readOnly: false, - autocorrect: false, - enableSuggestions: false, - toolbarOptions: const ToolbarOptions( - copy: false, - cut: false, - paste: true, - selectAll: false, - ), - onChanged: (newValue) { - setState(() { - _slateToggleFlag = newValue.isNotEmpty; - }); - }, - focusNode: _slateFocusNode, - style: STextStyles.field(context), - decoration: - standardInputDecoration( - "Enter Response Slate JSON", - _slateFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: - 12, // Adjust vertical padding for better alignment - ), - suffixIcon: Padding( - padding: _slateController.text.isEmpty - ? const EdgeInsets.only(right: 8) - : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _slateToggleFlag - ? TextFieldIconButton( - key: const Key( - "epicSlateFinalizeClearFieldButtonKey", - ), - onTap: () { - _slateController.text = ""; - setState(() { - _slateToggleFlag = false; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "epicSlateFinalizePasteFieldButtonKey", - ), - onTap: _pasteSlatepack, - child: _slateController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_slateController.text.isEmpty) - TextFieldIconButton( - semanticsLabel: - "Scan QR Button. Opens Camera For Scanning QR Code.", - key: const Key("sendViewScanQrButtonKey"), - onTap: _scanQr, - child: const QrCodeIcon(), - ), - ], - ), - ), - ), - ), - ), - ), - Util.isDesktop ? const SizedBox(height: 24) : const Spacer(), - PrimaryButton( - label: "Finalize Slate", - enabled: _slateToggleFlag, - onPressed: _slateToggleFlag ? _finalizePressed : null, - ), - - if (!Util.isDesktop) SizedBox(height: Constants.size.standardPadding), - ], - ), - ); + return FinalizeView(walletId: walletId); } } diff --git a/lib/pages/finalize_view/finalize_view.dart b/lib/pages/finalize_view/finalize_view.dart index 65fb50a1be..751bf7f53b 100644 --- a/lib/pages/finalize_view/finalize_view.dart +++ b/lib/pages/finalize_view/finalize_view.dart @@ -25,7 +25,9 @@ import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../utilities/slatepack_terms.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -56,6 +58,7 @@ class FinalizeView extends ConsumerStatefulWidget { class _FinalizeViewState extends ConsumerState { late final TextEditingController _slateController; late final FocusNode _slateFocusNode; + late final SlatepackTerms _terms; bool _slateToggleFlag = false; @@ -116,16 +119,16 @@ class _FinalizeViewState extends ConsumerState { await Future.delayed(const Duration(seconds: 1)); final wallet = - ref.read(pWallets).getWallet(widget.walletId) as MimblewimblecoinWallet; + ref.read(pWallets).getWallet(widget.walletId) as MimblewimbleWallet; final decoded = await wallet.decodeSlatepack(_slateController.text); if (!decoded.success) { - throw Exception(decoded.error ?? "Failed to decode slatepack"); + throw Exception(decoded.error ?? "Failed to decode ${_terms.noun}"); } final analysis = await wallet.analyzeSlatepack(_slateController.text); if (analysis.status != "S2") { - throw Exception("Invalid slatepack type: ${analysis.status}"); + throw Exception("Invalid ${_terms.noun} type: ${analysis.status}"); } final result = await wallet.finalizeSlatepack(_slateController.text); @@ -147,7 +150,7 @@ class _FinalizeViewState extends ConsumerState { await showLoading( whileFuture: _finalize(), context: context, - message: "Finalizing slatepack...", + message: "Finalizing ${_terms.noun}...", rootNavigator: Util.isDesktop, onException: (e) => ex = e, ); @@ -158,7 +161,7 @@ class _FinalizeViewState extends ConsumerState { context: context, builder: (context) => StackOkDialog( desktopPopRootNavigator: Util.isDesktop, - title: "Slatepack finalize error", + title: "${_terms.title} finalize error", message: ex?.toString() ?? "Unexpected result without exception", maxWidth: Util.isDesktop ? 400 : null, ), @@ -184,6 +187,7 @@ class _FinalizeViewState extends ConsumerState { super.initState(); _slateController = TextEditingController(); _slateFocusNode = FocusNode(); + _terms = SlatepackTerms.forCoin(ref.read(pWalletCoin(widget.walletId))); } @override @@ -211,7 +215,7 @@ class _FinalizeViewState extends ConsumerState { }, ), title: Text( - "Finalize slatepack", + "Finalize ${_terms.noun}", style: STextStyles.navBarTitle(context), ), ), @@ -268,7 +272,7 @@ class _FinalizeViewState extends ConsumerState { style: STextStyles.field(context), decoration: standardInputDecoration( - "Enter Final Slatepack Message", + _terms.finalizeHint, _slateFocusNode, context, desktopMed: true, @@ -325,7 +329,7 @@ class _FinalizeViewState extends ConsumerState { ), Util.isDesktop ? const SizedBox(height: 24) : const Spacer(), PrimaryButton( - label: "Finalize Slatepack", + label: "Finalize ${_terms.title}", enabled: _slateToggleFlag, onPressed: _slateToggleFlag ? _finalizePressed : null, ), diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 61f8ae5fbe..10eddf532f 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -29,12 +29,12 @@ import '../../utilities/clipboard_interface.dart'; import '../../utilities/constants.dart'; import '../../utilities/enums/derive_path_type_enum.dart'; import '../../utilities/show_loading.dart'; +import '../../utilities/slatepack_terms.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/bitcoin_wallet.dart'; -import '../../wallets/wallet/impl/epiccash_wallet.dart'; -import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../wallets/wallet/intermediate/bip39_hd_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/bcash_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; @@ -55,9 +55,7 @@ import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import 'addresses/wallet_addresses_view.dart'; import 'generate_receiving_uri_qr_code_view.dart'; -import 'sub_widgets/epic_slatepack_entry_dialog.dart'; -import 'sub_widgets/epic_slatepack_import_dialog.dart'; -import 'sub_widgets/mwc_slatepack_import_dialog.dart'; +import 'sub_widgets/slatepack_import_dialog.dart'; import 'sub_widgets/slatepack_entry_dialog.dart'; class ReceiveView extends ConsumerStatefulWidget { @@ -93,82 +91,23 @@ class _ReceiveViewState extends ConsumerState { final Map> _addressSubMap = {}; Future _importSlatepack() async { - final slatepackString = await showDialog( - context: context, - builder: (context) => const SlatepackEntryDialog(), - ); - - if (slatepackString == null) return; - if (mounted) { - final wallet = - ref.read(pWallets).getWallet(walletId) as MimblewimblecoinWallet; - - Exception? ex; - final result = await showLoading( - whileFuture: wallet.fullDecodeSlatepack(slatepackString), - context: context, - message: "Decoding slatepack...", - onException: (e) => ex = e, - ); - - if (result == null || ex != null) { - if (mounted) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - title: "Slatepack receive error", - message: ex?.toString() ?? "Unexpected result without exception", - ), - ); - } - return; - } + final SlatepackTerms terms = SlatepackTerms.forCoin(coin); - if (mounted) { - final response = - await showDialog<({String responseSlatepack, bool wasEncrypted})>( - context: context, - builder: (context) => SDialog( - child: MwcSlatepackImportDialog( - walletId: widget.walletId, - clipboard: widget.clipboard, - rawSlatepack: result.raw, - decoded: result.result, - slatepackType: result.type, - ), - ), - ); - - if (mounted && response != null) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => SlatepackResponseDialog( - responseSlatepack: response.responseSlatepack, - wasEncrypted: response.wasEncrypted, - ), - ); - } - } - } - } - - Future _importEpicSlatepack() async { final slatepackString = await showDialog( context: context, - builder: (context) => const EpicSlatepackEntryDialog(), + builder: (context) => SlatepackEntryDialog(terms: terms), ); if (slatepackString == null) return; if (mounted) { final wallet = - ref.read(pWallets).getWallet(walletId) as EpiccashWallet; + ref.read(pWallets).getWallet(walletId) as MimblewimbleWallet; Exception? ex; final result = await showLoading( whileFuture: wallet.fullDecodeSlatepack(slatepackString), context: context, - message: "Decoding slate...", + message: "Decoding ${terms.noun}...", onException: (e) => ex = e, ); @@ -177,7 +116,7 @@ class _ReceiveViewState extends ConsumerState { await showDialog( context: context, builder: (context) => StackOkDialog( - title: "Slate receive error", + title: "${terms.title} receive error", message: ex?.toString() ?? "Unexpected result without exception", ), ); @@ -190,7 +129,7 @@ class _ReceiveViewState extends ConsumerState { await showDialog<({String responseSlatepack, bool wasEncrypted})>( context: context, builder: (context) => SDialog( - child: EpicSlatepackImportDialog( + child: SlatepackImportDialog( walletId: widget.walletId, clipboard: widget.clipboard, rawSlatepack: result.raw, @@ -204,7 +143,8 @@ class _ReceiveViewState extends ConsumerState { await showDialog( context: context, barrierDismissible: false, - builder: (context) => EpicSlatepackResponseDialog( + builder: (context) => SlatepackResponseDialog( + terms: terms, responseSlatepack: response.responseSlatepack, wasEncrypted: response.wasEncrypted, ), @@ -820,22 +760,13 @@ class _ReceiveViewState extends ConsumerState { ? generateNewSparkAddress : generateNewAddress, ), - // MWC Slatepack import button. - if (coin is Mimblewimblecoin) ...[ + if (coin is Mimblewimblecoin || coin is Epiccash) ...[ const SizedBox(height: 12), SecondaryButton( - label: "Import Slatepack", + label: "Import ${SlatepackTerms.forCoin(coin).title}", onPressed: _importSlatepack, ), ], - // Epic Cash Slate import button. - if (coin is Epiccash) ...[ - const SizedBox(height: 12), - SecondaryButton( - label: "Import Slate", - onPressed: _importEpicSlatepack, - ), - ], const SizedBox(height: 30), RoundedWhiteContainer( child: Padding( diff --git a/lib/pages/receive_view/sub_widgets/epic_slatepack_entry_dialog.dart b/lib/pages/receive_view/sub_widgets/epic_slatepack_entry_dialog.dart deleted file mode 100644 index c3583e7c49..0000000000 --- a/lib/pages/receive_view/sub_widgets/epic_slatepack_entry_dialog.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../providers/global/barcode_scanner_provider.dart'; -import '../../../themes/stack_colors.dart'; -import '../../../utilities/barcode_scanner_interface.dart'; -import '../../../utilities/clipboard_interface.dart'; -import '../../../utilities/constants.dart'; -import '../../../utilities/logger.dart'; -import '../../../utilities/text_styles.dart'; -import '../../../widgets/desktop/primary_button.dart'; -import '../../../widgets/desktop/secondary_button.dart'; -import '../../../widgets/icon_widgets/clipboard_icon.dart'; -import '../../../widgets/icon_widgets/qrcode_icon.dart'; -import '../../../widgets/icon_widgets/x_icon.dart'; -import '../../../widgets/stack_dialog.dart'; -import '../../../widgets/stack_text_field.dart'; -import '../../../widgets/textfield_icon_button.dart'; - -class EpicSlatepackEntryDialog extends ConsumerStatefulWidget { - const EpicSlatepackEntryDialog({ - super.key, - this.clipboard = const ClipboardWrapper(), - }); - - final ClipboardInterface clipboard; - - @override - ConsumerState createState() => - _EpicSlatepackEntryDialogState(); -} - -class _EpicSlatepackEntryDialogState extends ConsumerState { - final _receiveSlateController = TextEditingController(); - final _slateFocusNode = FocusNode(); - - bool _slateToggleFlag = false; - - Future _pasteSlatepack() async { - final ClipboardData? data = await widget.clipboard.getData( - Clipboard.kTextPlain, - ); - if (data?.text != null && data!.text!.isNotEmpty) { - _receiveSlateController.text = data.text!; - setState(() { - _slateToggleFlag = _receiveSlateController.text.isNotEmpty; - }); - } - } - - Future _scanQr() async { - try { - if (_slateFocusNode.hasFocus) { - _slateFocusNode.unfocus(); - await Future.delayed(const Duration(milliseconds: 75)); - } - - if (mounted) { - final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - if (qrResult.rawContent != null && qrResult.rawContent!.isNotEmpty) { - _receiveSlateController.text = qrResult.rawContent!; - setState(() { - _slateToggleFlag = _receiveSlateController.text.isNotEmpty; - }); - } - } - } on PlatformException catch (e, s) { - if (mounted) { - try { - await checkCamPermDeniedMobileAndOpenAppSettings( - context, - logging: Logging.instance, - ); - } catch (e, s) { - Logging.instance.e( - "Failed to check cam permissions", - error: e, - stackTrace: s, - ); - } - } else { - Logging.instance.e( - "Failed to get camera permissions while trying to scan qr code in SendView: ", - error: e, - stackTrace: s, - ); - } - } - } - - @override - void dispose() { - _receiveSlateController.dispose(); - _slateFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return StackDialogBase( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "Receive Slate", - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - textAlign: TextAlign.left, - ), - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - minLines: 1, - maxLines: 5, - key: const Key("receiveViewEpicSlateFieldKey"), - controller: _receiveSlateController, - readOnly: false, - autocorrect: false, - enableSuggestions: false, - toolbarOptions: const ToolbarOptions( - copy: false, - cut: false, - paste: true, - selectAll: false, - ), - onChanged: (newValue) { - setState(() { - _slateToggleFlag = newValue.isNotEmpty; - }); - }, - focusNode: _slateFocusNode, - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ), - decoration: - standardInputDecoration( - "Enter Slate JSON", - _slateFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: - 12, // Adjust vertical padding for better alignment - ), - suffixIcon: Padding( - padding: _receiveSlateController.text.isEmpty - ? const EdgeInsets.only(right: 8) - : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _slateToggleFlag - ? TextFieldIconButton( - key: const Key( - "receiveViewClearEpicSlateFieldButtonKey", - ), - onTap: () { - _receiveSlateController.text = ""; - setState(() { - _slateToggleFlag = false; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "receiveViewPasteEpicSlateFieldButtonKey", - ), - onTap: _pasteSlatepack, - child: _receiveSlateController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_receiveSlateController.text.isEmpty) - TextFieldIconButton( - semanticsLabel: - "Scan QR Button. Opens Camera For Scanning QR Code.", - key: const Key("sendViewScanQrButtonKey"), - onTap: _scanQr, - child: const QrCodeIcon(), - ), - ], - ), - ), - ), - ), - ), - ), - const SizedBox(height: 16), - PrimaryButton( - label: "Import", - enabled: _slateToggleFlag, - onPressed: !_slateToggleFlag - ? null - : () => Navigator.of(context).pop(_receiveSlateController.text), - ), - const SizedBox(height: 16), - SecondaryButton( - label: "Cancel", - onPressed: Navigator.of(context).pop, - ), - ], - ), - ); - } -} diff --git a/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart b/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart deleted file mode 100644 index c67ce45641..0000000000 --- a/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart +++ /dev/null @@ -1,316 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../models/slatepack_models.dart'; -import '../../../providers/global/wallets_provider.dart'; -import '../../../themes/stack_colors.dart'; -import '../../../utilities/amount/amount.dart'; -import '../../../utilities/amount/amount_formatter.dart'; -import '../../../utilities/clipboard_interface.dart'; -import '../../../utilities/show_loading.dart'; -import '../../../utilities/text_styles.dart'; -import '../../../utilities/util.dart'; -import '../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../wallets/wallet/impl/epiccash_wallet.dart'; -import '../../../widgets/conditional_parent.dart'; -import '../../../widgets/custom_buttons/simple_copy_button.dart'; -import '../../../widgets/desktop/desktop_dialog_close_button.dart'; -import '../../../widgets/desktop/primary_button.dart'; -import '../../../widgets/desktop/secondary_button.dart'; -import '../../../widgets/detail_item.dart'; -import '../../../widgets/dialogs/s_dialog.dart'; -import '../../../widgets/rounded_white_container.dart'; -import '../../../widgets/stack_dialog.dart'; - -class EpicSlatepackImportDialog extends ConsumerStatefulWidget { - const EpicSlatepackImportDialog({ - super.key, - required this.walletId, - required this.rawSlatepack, - required this.decoded, - required this.slatepackType, - this.clipboard = const ClipboardWrapper(), - }); - - final String walletId; - final String rawSlatepack; - final SlatepackDecodeResult decoded; - final String slatepackType; - final ClipboardInterface clipboard; - - @override - ConsumerState createState() => - _EpicSlatepackImportDialogState(); -} - -class _EpicSlatepackImportDialogState - extends ConsumerState { - Future<({String responseSlatepack, bool wasEncrypted})> - _processSlatepack() async { - // add delay for showloading exception catching hack fix - await Future.delayed(const Duration(seconds: 1)); - - final wallet = - ref.read(pWallets).getWallet(widget.walletId) as EpiccashWallet; - - // Determine action based on slatepack type. - if (widget.slatepackType.contains("S1")) { - // This is an initial slatepack - receive it and create response. - final result = await wallet.receiveSlatepack(widget.rawSlatepack); - - if (result.success && result.responseSlatepack != null) { - return ( - responseSlatepack: result.responseSlatepack!, - wasEncrypted: result.wasEncrypted ?? false, - ); - } else { - throw Exception(result.error ?? 'Failed to process slatepack'); - } - } else { - throw Exception('Unsupported slatepack type: ${widget.slatepackType}'); - } - } - - Future _processPressed() async { - Exception? ex; - final result = await showLoading( - whileFuture: _processSlatepack(), - context: context, - message: "Processing slate...", - onException: (e) => ex = e, - ); - - if (result == null || ex != null) { - if (mounted) { - await showDialog( - context: context, - useRootNavigator: true, - builder: - (context) => StackOkDialog( - desktopPopRootNavigator: true, - maxWidth: Util.isDesktop ? 400 : null, - title: "Slate receive error", - message: - ex?.toString() ?? "Unexpected result without exception", - ), - ); - } - return; - } - - if (mounted) { - Navigator.of(context).pop(result); - } - } - - late final Amount? _amount; - - @override - void initState() { - final map = jsonDecode(widget.decoded.slateJson!) as Map; - - final rawAmount = BigInt.tryParse(map["amount"].toString()); - _amount = - rawAmount == null - ? null - : Amount( - rawValue: rawAmount, - fractionDigits: - ref.read(pWalletCoin(widget.walletId)).fractionDigits, - ); - - super.initState(); - } - - @override - Widget build(BuildContext context) { - final isDesktop = Util.isDesktop; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (isDesktop) - // Header with title and close button. - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Import Slate", - style: STextStyles.pageTitleH2(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: isDesktop ? 32 : 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConditionalParent( - condition: isDesktop, - builder: - (child) => RoundedWhiteContainer( - borderColor: - isDesktop - ? Theme.of( - context, - ).extension()!.backgroundAppBar - : null, - padding: const EdgeInsets.all(0), - child: child, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isDesktop) - Padding( - padding: const EdgeInsets.only(top: 24, bottom: 24), - child: Text( - "Import slate", - style: STextStyles.pageTitleH2(context), - ), - ), - - if (_amount != null) - DetailItem( - title: "Amount", - detail: ref - .watch( - pAmountFormatter( - ref.watch(pWalletCoin(widget.walletId)), - ), - ) - .format(_amount), - ), - ], - ), - ), - const SizedBox(height: 24), - ConditionalParent( - condition: isDesktop, - builder: - (child) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [child], - ), - child: PrimaryButton( - width: isDesktop ? 220 : null, - - buttonHeight: isDesktop ? ButtonHeight.l : null, - label: "Process", - onPressed: _processPressed, - ), - ), - if (!isDesktop) const SizedBox(height: 12), - if (!isDesktop) - SecondaryButton( - label: "Cancel", - onPressed: Navigator.of(context).pop, - ), - ], - ), - ), - isDesktop ? const SizedBox(height: 32) : const SizedBox(height: 24), - ], - ); - } -} - -class EpicSlatepackResponseDialog extends StatelessWidget { - const EpicSlatepackResponseDialog({ - super.key, - required this.responseSlatepack, - required this.wasEncrypted, - }); - - final String responseSlatepack; - final bool wasEncrypted; - - @override - Widget build(BuildContext context) { - return SDialog( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header with title and close button. - if (Util.isDesktop) - Padding( - padding: const EdgeInsets.only(left: 32), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Response Slate", - style: STextStyles.pageTitleH2(context), - ), - const DesktopDialogCloseButton(), - ], - ), - ), - Padding( - padding: - Util.isDesktop - ? const EdgeInsets.only(left: 32, right: 32, bottom: 32) - : const EdgeInsets.only(left: 24, right: 24, bottom: 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!Util.isDesktop) const SizedBox(height: 24), - Text( - "Return this slate to the sender to complete the transaction.", - style: STextStyles.pageTitleH2(context), - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Response slate", - style: STextStyles.itemSubtitle(context), - ), - SimpleCopyButton(data: responseSlatepack), - ], - ), - const SizedBox(height: 8), - ConditionalParent( - condition: !Util.isDesktop, - builder: - (child) => SizedBox( - height: 220, - child: SingleChildScrollView(child: child), - ), - child: SelectableText( - responseSlatepack, - style: STextStyles.w500_14(context), - ), - ), - const SizedBox(height: 24), - ConditionalParent( - condition: Util.isDesktop, - builder: - (child) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [child], - ), - child: PrimaryButton( - label: "Done", - width: Util.isDesktop ? 220 : null, - buttonHeight: Util.isDesktop ? ButtonHeight.l : null, - onPressed: Navigator.of(context).pop, - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart b/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart index 941e7755f0..62a78fec08 100644 --- a/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart +++ b/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart @@ -8,6 +8,7 @@ import '../../../utilities/barcode_scanner_interface.dart'; import '../../../utilities/clipboard_interface.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/logger.dart'; +import '../../../utilities/slatepack_terms.dart'; import '../../../utilities/text_styles.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/desktop/secondary_button.dart'; @@ -21,9 +22,11 @@ import '../../../widgets/textfield_icon_button.dart'; class SlatepackEntryDialog extends ConsumerStatefulWidget { const SlatepackEntryDialog({ super.key, + required this.terms, this.clipboard = const ClipboardWrapper(), }); + final SlatepackTerms terms; final ClipboardInterface clipboard; @override @@ -103,7 +106,7 @@ class _SlatepackEntryDialogState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - "Receive Slatepack", + "Receive ${widget.terms.title}", style: STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of( context, @@ -144,7 +147,7 @@ class _SlatepackEntryDialogState extends ConsumerState { ), decoration: standardInputDecoration( - "Enter Slatepack Message", + "Enter ${widget.terms.payload}", _slateFocusNode, context, desktopMed: true, diff --git a/lib/pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart b/lib/pages/receive_view/sub_widgets/slatepack_import_dialog.dart similarity index 80% rename from lib/pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart rename to lib/pages/receive_view/sub_widgets/slatepack_import_dialog.dart index d30490c693..0b93cc7bbb 100644 --- a/lib/pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart +++ b/lib/pages/receive_view/sub_widgets/slatepack_import_dialog.dart @@ -10,10 +10,11 @@ import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/clipboard_interface.dart'; import '../../../utilities/show_loading.dart'; +import '../../../utilities/slatepack_terms.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/custom_buttons/simple_copy_button.dart'; import '../../../widgets/desktop/desktop_dialog_close_button.dart'; @@ -24,8 +25,8 @@ import '../../../widgets/dialogs/s_dialog.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/stack_dialog.dart'; -class MwcSlatepackImportDialog extends ConsumerStatefulWidget { - const MwcSlatepackImportDialog({ +class SlatepackImportDialog extends ConsumerStatefulWidget { + const SlatepackImportDialog({ super.key, required this.walletId, required this.rawSlatepack, @@ -41,19 +42,20 @@ class MwcSlatepackImportDialog extends ConsumerStatefulWidget { final ClipboardInterface clipboard; @override - ConsumerState createState() => - _MwcSlatepackImportDialogState(); + ConsumerState createState() => + _SlatepackImportDialogState(); } -class _MwcSlatepackImportDialogState - extends ConsumerState { +class _SlatepackImportDialogState extends ConsumerState { + late final SlatepackTerms _terms; + Future<({String responseSlatepack, bool wasEncrypted})> _processSlatepack() async { // add delay for showloading exception catching hack fix await Future.delayed(const Duration(seconds: 1)); final wallet = - ref.read(pWallets).getWallet(widget.walletId) as MimblewimblecoinWallet; + ref.read(pWallets).getWallet(widget.walletId) as MimblewimbleWallet; // Determine action based on slatepack type. if (widget.slatepackType.contains("S1")) { @@ -78,7 +80,7 @@ class _MwcSlatepackImportDialogState final result = await showLoading( whileFuture: _processSlatepack(), context: context, - message: "Processing slatepack...", + message: "Processing ${_terms.noun}...", onException: (e) => ex = e, ); @@ -87,14 +89,12 @@ class _MwcSlatepackImportDialogState await showDialog( context: context, useRootNavigator: true, - builder: - (context) => StackOkDialog( - desktopPopRootNavigator: true, - maxWidth: Util.isDesktop ? 400 : null, - title: "Slatepack receive error", - message: - ex?.toString() ?? "Unexpected result without exception", - ), + builder: (context) => StackOkDialog( + desktopPopRootNavigator: true, + maxWidth: Util.isDesktop ? 400 : null, + title: "${_terms.title} receive error", + message: ex?.toString() ?? "Unexpected result without exception", + ), ); } return; @@ -111,17 +111,19 @@ class _MwcSlatepackImportDialogState @override void initState() { + _terms = SlatepackTerms.forCoin(ref.read(pWalletCoin(widget.walletId))); + final map = jsonDecode(widget.decoded.slateJson!) as Map; final rawAmount = BigInt.tryParse(map["amount"].toString()); - _amount = - rawAmount == null - ? null - : Amount( - rawValue: rawAmount, - fractionDigits: - ref.read(pWalletCoin(widget.walletId)).fractionDigits, - ); + _amount = rawAmount == null + ? null + : Amount( + rawValue: rawAmount, + fractionDigits: ref + .read(pWalletCoin(widget.walletId)) + .fractionDigits, + ); // final rawFee = BigInt.tryParse(map["fee"].toString()); // _fee = @@ -151,7 +153,7 @@ class _MwcSlatepackImportDialogState Padding( padding: const EdgeInsets.only(left: 32), child: Text( - "Import Slatepack", + "Import ${_terms.title}", style: STextStyles.pageTitleH2(context), ), ), @@ -166,17 +168,15 @@ class _MwcSlatepackImportDialogState children: [ ConditionalParent( condition: isDesktop, - builder: - (child) => RoundedWhiteContainer( - borderColor: - isDesktop - ? Theme.of( - context, - ).extension()!.backgroundAppBar - : null, - padding: const EdgeInsets.all(0), - child: child, - ), + builder: (child) => RoundedWhiteContainer( + borderColor: isDesktop + ? Theme.of( + context, + ).extension()!.backgroundAppBar + : null, + padding: const EdgeInsets.all(0), + child: child, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -185,7 +185,7 @@ class _MwcSlatepackImportDialogState Padding( padding: const EdgeInsets.only(top: 24, bottom: 24), child: Text( - "Import slatepack", + "Import ${_terms.noun}", style: STextStyles.pageTitleH2(context), ), ), @@ -240,11 +240,10 @@ class _MwcSlatepackImportDialogState const SizedBox(height: 24), ConditionalParent( condition: isDesktop, - builder: - (child) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [child], - ), + builder: (child) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [child], + ), child: PrimaryButton( width: isDesktop ? 220 : null, @@ -271,10 +270,12 @@ class _MwcSlatepackImportDialogState class SlatepackResponseDialog extends StatelessWidget { const SlatepackResponseDialog({ super.key, + required this.terms, required this.responseSlatepack, required this.wasEncrypted, }); + final SlatepackTerms terms; final String responseSlatepack; final bool wasEncrypted; @@ -292,7 +293,7 @@ class SlatepackResponseDialog extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Response Slatepack", + "Response ${terms.title}", style: STextStyles.pageTitleH2(context), ), const DesktopDialogCloseButton(), @@ -300,16 +301,16 @@ class SlatepackResponseDialog extends StatelessWidget { ), ), Padding( - padding: - Util.isDesktop - ? const EdgeInsets.only(left: 32, right: 32, bottom: 32) - : const EdgeInsets.only(left: 24, right: 24, bottom: 24), + padding: Util.isDesktop + ? const EdgeInsets.only(left: 32, right: 32, bottom: 32) + : const EdgeInsets.only(left: 24, right: 24, bottom: 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (!Util.isDesktop) const SizedBox(height: 24), Text( - "Return this slatepack to the sender to complete the transaction.", + "Return this ${terms.noun} to the sender to complete the " + "transaction.", style: STextStyles.pageTitleH2(context), ), const SizedBox(height: 24), @@ -317,7 +318,7 @@ class SlatepackResponseDialog extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Response slatepack", + "Response ${terms.noun}", style: STextStyles.itemSubtitle(context), ), SimpleCopyButton(data: responseSlatepack), @@ -326,11 +327,10 @@ class SlatepackResponseDialog extends StatelessWidget { const SizedBox(height: 8), ConditionalParent( condition: !Util.isDesktop, - builder: - (child) => SizedBox( - height: 220, - child: SingleChildScrollView(child: child), - ), + builder: (child) => SizedBox( + height: 220, + child: SingleChildScrollView(child: child), + ), child: SelectableText( responseSlatepack, style: STextStyles.w500_14(context), @@ -339,11 +339,10 @@ class SlatepackResponseDialog extends StatelessWidget { const SizedBox(height: 24), ConditionalParent( condition: Util.isDesktop, - builder: - (child) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [child], - ), + builder: (child) => Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [child], + ), child: PrimaryButton( label: "Done", width: Util.isDesktop ? 220 : null, diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index ba495cdd74..ab115a1d7e 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -36,6 +36,7 @@ import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/amount/fee_rate_formatter.dart'; import '../../utilities/constants.dart'; import '../../utilities/logger.dart'; +import '../../utilities/slatepack_terms.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -44,9 +45,8 @@ import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; -import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; -import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../wallets/wallet/impl/solana_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; @@ -65,9 +65,8 @@ import '../../widgets/textfield_icon_button.dart'; import '../../wl_gen/interfaces/libepiccash_interface.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../wallet_view/wallet_view.dart'; -import 'sub_widgets/epic_slatepack_dialog.dart'; import 'sub_widgets/multi_recipient.dart'; -import 'sub_widgets/mwc_slatepack_dialog.dart'; +import 'sub_widgets/slatepack_dialog.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; import 'sub_widgets/split_transaction_warning.dart'; @@ -156,11 +155,12 @@ class _ConfirmTransactionViewState } } - /// Handle MWC slatepack creation for manual exchange. - Future _handleMwcSlatepackCreation( + /// Create a slatepack for manual exchange instead of sending directly. + Future _handleSlatepackCreation( BuildContext context, - MimblewimblecoinWallet wallet, + MimblewimbleWallet wallet, ) async { + final SlatepackTerms terms = SlatepackTerms.forCoin(wallet.cryptoCurrency); try { // Close the progress dialog first. Navigator.of(context).pop(); @@ -186,85 +186,9 @@ class _ConfirmTransactionViewState ); if (!slatepackResult.success || slatepackResult.slatepack == null) { - throw Exception(slatepackResult.error ?? 'Failed to create slatepack'); - } - - // Show slatepack dialog. - if (context.mounted) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => - MwcSlatepackDialog(slatepackResult: slatepackResult), + throw Exception( + slatepackResult.error ?? 'Failed to create ${terms.noun}', ); - - // After slatepack dialog is closed, navigate back to wallet. - if (context.mounted) { - widget.onSuccess.call(); - if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of(context) - .popUntil(ModalRoute.withName(routeOnSuccessName)); - } else { - widget.onSuccessInsteadOfRouteOnSuccess!.call(); - } - } - } - } catch (e, s) { - Logging.instance.e('Failed to create MWC slatepack: $e\n$s'); - - if (context.mounted) { - // Show user-friendly error message. - final errorMessage = e.toString().contains('insufficient funds') - ? 'Insufficient funds for this transaction' - : e.toString().contains('wallet not open') - ? 'Wallet not accessible. Please restart the app.' - : 'Failed to create slatepack: ${e.toString()}'; - - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Slatepack Creation Failed'), - content: Text(errorMessage), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ], - ), - ); - } - } - } - - /// Handle Epic Cash slate creation for manual exchange. - Future _handleEpicSlatepackCreation( - BuildContext context, - EpiccashWallet wallet, - ) async { - try { - // Close the progress dialog first. - Navigator.of(context).pop(); - - // Get recipient information from txData. - final recipient = widget.txData.recipients?.first; - if (recipient == null) { - throw Exception('No recipient found in transaction data'); - } - - // Create slatepack. - final slatepackResult = await wallet.createSlatepack( - amount: recipient.amount, - recipientAddress: recipient.address.isNotEmpty - ? recipient.address - : null, - message: onChainNoteController.text.isNotEmpty - ? onChainNoteController.text - : null, - ); - - if (!slatepackResult.success || slatepackResult.slatepack == null) { - throw Exception(slatepackResult.error ?? 'Failed to create slate'); } // Show slatepack dialog. @@ -273,7 +197,7 @@ class _ConfirmTransactionViewState context: context, barrierDismissible: false, builder: (context) => - EpicSlatepackDialog(slatepackResult: slatepackResult), + SlatepackDialog(terms: terms, slatepackResult: slatepackResult), ); // After slatepack dialog is closed, navigate back to wallet. @@ -288,7 +212,10 @@ class _ConfirmTransactionViewState } } } catch (e, s) { - Logging.instance.e('Failed to create Epic Cash slate: $e\n$s'); + Logging.instance.e( + 'Failed to create ${wallet.cryptoCurrency.prettyName} ${terms.noun}: ' + '$e\n$s', + ); if (context.mounted) { // Show user-friendly error message. @@ -296,12 +223,12 @@ class _ConfirmTransactionViewState ? 'Insufficient funds for this transaction' : e.toString().contains('wallet not open') ? 'Wallet not accessible. Please restart the app.' - : 'Failed to create slate: ${e.toString()}'; + : 'Failed to create ${terms.noun}: ${e.toString()}'; await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Slate Creation Failed'), + title: Text('${terms.title} Creation Failed'), content: Text(errorMessage), actions: [ TextButton( @@ -392,7 +319,7 @@ class _ConfirmTransactionViewState break; } } else { - if (coin is Mimblewimblecoin) { + if (wallet is MimblewimbleWallet) { // Check if this is a slatepack transaction (manual exchange). final otherDataMap = widget.txData.otherData != null ? jsonDecode(widget.txData.otherData!) @@ -402,38 +329,11 @@ class _ConfirmTransactionViewState if (transactionMethod == 'slatepack') { // Handle slatepack creation instead of direct send. - await _handleMwcSlatepackCreation( - context, - wallet as MimblewimblecoinWallet, - ); - closeSendingDialog(); - return; // Exit early, don't continue with normal transaction flow. - } else { - // Handle MWCMQS or HTTP transactions normally. - txDataFuture = wallet.confirmSend( - txData: widget.txData.copyWith( - noteOnChain: onChainNoteController.text, - ), - ); - } - } else if (coin is Epiccash) { - // Check if this is a slatepack transaction (manual exchange). - final epicOtherDataMap = widget.txData.otherData != null - ? jsonDecode(widget.txData.otherData!) - : null; - final epicTransactionMethod = - epicOtherDataMap?['transactionMethod'] as String?; - - if (epicTransactionMethod == 'slatepack') { - // Handle slatepack creation instead of direct send. - await _handleEpicSlatepackCreation( - context, - wallet as EpiccashWallet, - ); + await _handleSlatepackCreation(context, wallet); closeSendingDialog(); return; // Exit early, don't continue with normal transaction flow. } else { - // Handle Epicbox transactions normally. + // Relay or HTTP transactions go through the normal path. txDataFuture = wallet.confirmSend( txData: widget.txData.copyWith( noteOnChain: onChainNoteController.text, diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index cde088df61..650de61e56 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -51,6 +51,7 @@ import '../../utilities/extensions/extensions.dart'; import '../../utilities/logger.dart'; import '../../utilities/prefs.dart'; import '../../utilities/show_loading.dart'; +import '../../utilities/slatepack_terms.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -58,9 +59,8 @@ import '../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; -import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; -import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../wallets/wallet/impl/salvium_wallet.dart'; import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; @@ -72,14 +72,13 @@ import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/dialogs/firo_exchange_address_dialog.dart'; -import '../../widgets/epic_txs_method_toggle.dart'; import '../../widgets/eth_fee_form.dart'; import '../../widgets/fee_slider.dart'; import '../../widgets/icon_widgets/addressbook_icon.dart'; import '../../widgets/icon_widgets/clipboard_icon.dart'; import '../../widgets/icon_widgets/qrcode_icon.dart'; import '../../widgets/icon_widgets/x_icon.dart'; -import '../../widgets/mwc_txs_method_toggle.dart'; +import '../../widgets/slatepack_method_toggle.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; @@ -90,9 +89,8 @@ import 'confirm_transaction_view.dart'; import 'frost_ms/recipient.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/dual_balance_selection_sheet.dart'; -import 'sub_widgets/epic_slatepack_dialog.dart'; import 'sub_widgets/multi_recipient.dart'; -import 'sub_widgets/mwc_slatepack_dialog.dart'; +import 'sub_widgets/slatepack_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; class SendView extends ConsumerStatefulWidget { @@ -884,10 +882,12 @@ class _SendViewState extends ConsumerState { FocusScope.of(context).unfocus(); await Future.delayed(const Duration(milliseconds: 100)); + final SlatepackTerms terms = SlatepackTerms.forCoin(coin); + try { if (mounted) { final wallet = - ref.read(pWallets).getWallet(walletId) as MimblewimblecoinWallet; + ref.read(pWallets).getWallet(walletId) as MimblewimbleWallet; final amount = ref.read(pSendAmount)!; @@ -909,93 +909,7 @@ class _SendViewState extends ConsumerState { final slatepackResult = await showLoading( whileFuture: wrappedFutureWithDelay(), context: context, - message: "Building slatepack...", - delay: const Duration(seconds: 2), - onException: (e) => ex = e, - ); - - if (slatepackResult == null || - !slatepackResult.success || - slatepackResult.slatepack == null || - ex != null) { - String error = - ex?.toString() ?? - slatepackResult?.error ?? - 'Failed to create slatepack'; - if (error.startsWith("Exception:")) { - error = error.replaceFirst("Exception:", "").trim(); - } - throw Exception(error); - } - - // refresh asap to show the pending slate tx in history - unawaited(() async { - await Future.delayed(Duration.zero); - await wallet.refresh(); - }()); - - // Show slatepack dialog. - if (mounted) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => StackDialogBase( - child: MwcSlatepackDialog(slatepackResult: slatepackResult), - ), - ); - - // Clear form after slatepack dialog is closed. - clearSendForm(); - } - } - } catch (e, s) { - Logging.instance.e( - 'Failed to create MWC slatepack on mobile', - error: e, - stackTrace: s, - ); - - if (mounted) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - title: "Slatepack Creation Failed", - message: e.toString(), - ), - ); - } - } - } - - Future _createEpicSlatepack() async { - // wait for keyboard to disappear - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 100)); - - try { - if (mounted) { - final wallet = ref.read(pWallets).getWallet(walletId) as EpiccashWallet; - - final amount = ref.read(pSendAmount)!; - - Future wrappedFutureWithDelay() async { - await Future.delayed(const Duration(seconds: 1)); - return wallet.createSlatepack( - amount: amount, - recipientAddress: null, - // No specific recipient for manual slatepack. - message: onChainNoteController.text.isNotEmpty == true - ? onChainNoteController.text - : null, - ); - } - - // Create slatepack. - Exception? ex; - final slatepackResult = await showLoading( - whileFuture: wrappedFutureWithDelay(), - context: context, - message: "Building slate...", + message: "Building ${terms.noun}...", delay: const Duration(seconds: 2), onException: (e) => ex = e, ); @@ -1007,7 +921,7 @@ class _SendViewState extends ConsumerState { String error = ex?.toString() ?? slatepackResult?.error ?? - 'Failed to create slate'; + 'Failed to create ${terms.noun}'; if (error.startsWith("Exception:")) { error = error.replaceFirst("Exception:", "").trim(); } @@ -1026,7 +940,10 @@ class _SendViewState extends ConsumerState { context: context, barrierDismissible: false, builder: (context) => StackDialogBase( - child: EpicSlatepackDialog(slatepackResult: slatepackResult), + child: SlatepackDialog( + terms: terms, + slatepackResult: slatepackResult, + ), ), ); @@ -1036,7 +953,7 @@ class _SendViewState extends ConsumerState { } } catch (e, s) { Logging.instance.e( - 'Failed to create Epic Cash slate on mobile', + 'Failed to create ${coin.prettyName} ${terms.noun} on mobile', error: e, stackTrace: s, ); @@ -1045,7 +962,7 @@ class _SendViewState extends ConsumerState { await showDialog( context: context, builder: (context) => StackOkDialog( - title: "Slate Creation Failed", + title: "${terms.title} Creation Failed", message: e.toString(), ), ); @@ -1822,11 +1739,7 @@ class _SendViewState extends ConsumerState { ); } - final isMwcSlatepack = - coin is Mimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)); - final isEpicSlatepack = - coin is Epiccash && ref.watch(pIsSlatepack(widget.walletId)); - final isSlatepackMode = isMwcSlatepack || isEpicSlatepack; + final isSlatepackMode = ref.watch(pIsSlatepack(widget.walletId)); return Background( child: Scaffold( @@ -1983,20 +1896,11 @@ class _SendViewState extends ConsumerState { ), const SizedBox(height: 16), - // MWC Transaction Method Selector. - if (coin is Mimblewimblecoin) ...[ - const SizedBox( - height: 40, - child: MwcTxsMethodToggle(), - ), - const SizedBox(height: 16), - ], - - // Epic Cash Transaction Method Selector. - if (coin is Epiccash) ...[ - const SizedBox( + if (coin is Mimblewimblecoin || + coin is Epiccash) ...[ + SizedBox( height: 40, - child: EpicTxsMethodToggle(), + child: SlatepackMethodToggle(coin: coin), ), const SizedBox(height: 16), ], @@ -3120,10 +3024,8 @@ class _SendViewState extends ConsumerState { (!needsEthFee || ethFee != null); return TextButton( onPressed: enabled - ? isMwcSlatepack + ? isSlatepackMode ? _createSlatepack - : isEpicSlatepack - ? _createEpicSlatepack : _previewTransaction : null, style: enabled diff --git a/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart b/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart deleted file mode 100644 index 26ec1d76fd..0000000000 --- a/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -import '../../../models/slatepack_models.dart'; -import '../../../notifications/show_flush_bar.dart'; -import '../../../themes/stack_colors.dart'; -import '../../../utilities/assets.dart'; -import '../../../utilities/clipboard_interface.dart'; -import '../../../utilities/text_styles.dart'; -import '../../../utilities/util.dart'; -import '../../../widgets/conditional_parent.dart'; -import '../../../widgets/desktop/desktop_dialog_close_button.dart'; -import '../../../widgets/desktop/primary_button.dart'; -import '../../../widgets/qr.dart'; -import '../../../widgets/rounded_container.dart'; -import '../../../widgets/rounded_white_container.dart'; - -class EpicSlatepackDialog extends ConsumerStatefulWidget { - const EpicSlatepackDialog({ - super.key, - required this.slatepackResult, - this.clipboard = const ClipboardWrapper(), - }); - - final SlatepackResult slatepackResult; - final ClipboardInterface clipboard; - - @override - ConsumerState createState() => _EpicSlatepackDialogState(); -} - -class _EpicSlatepackDialogState extends ConsumerState { - void _copySlatepack() { - widget.clipboard.setData( - ClipboardData(text: widget.slatepackResult.slatepack!), - ); - showFloatingFlushBar( - type: FlushBarType.info, - message: "Slate copied to clipboard", - iconAsset: Assets.svg.copy, - context: context, - ); - } - - void _shareSlatepack() { - // TODO: Implement file sharing for desktop platforms. - showFloatingFlushBar( - type: FlushBarType.info, - message: "Share functionality coming soon", - context: context, - ); - } - - @override - Widget build(BuildContext context) { - return ConditionalParent( - condition: Util.isDesktop, - builder: - (child) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header with title and close button. - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Send Slate", - style: STextStyles.pageTitleH2(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Padding(padding: const EdgeInsets.all(32), child: child), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Instructions. - RoundedContainer( - color: - Theme.of(context).extension()!.textFieldDefaultBG, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Next Steps:", - style: STextStyles.label( - context, - ).copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Text( - "1. Share this slate with the recipient\n" - "2. Wait for them to return the response slate\n" - "3. Import their response to finalize the transaction", - style: STextStyles.w400_14(context), - ), - ], - ), - ), - - const SizedBox(height: 12), - - // QR Code view. - Center( - child: QR( - data: widget.slatepackResult.slatepack!, - size: 220, - ), - ), - - const SizedBox(height: 12), - - // Slatepack text view. - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Text("Slate", style: STextStyles.itemSubtitle(context)), - const Spacer(), - GestureDetector( - onTap: _copySlatepack, - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.copy, - width: 10, - height: 10, - color: - Theme.of( - context, - ).extension()!.infoItemIcons, - ), - const SizedBox(width: 4), - Text("Copy", style: STextStyles.link2(context)), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - Container( - constraints: const BoxConstraints( - maxHeight: 200, - minHeight: 100, - ), - child: SingleChildScrollView( - child: SelectableText( - widget.slatepackResult.slatepack!, - style: STextStyles.w400_14( - context, - ).copyWith(fontFamily: 'monospace'), - ), - ), - ), - ], - ), - ), - - if (!Util.isDesktop) - PrimaryButton(label: "Done", onPressed: Navigator.of(context).pop), - ], - ), - ); - } -} diff --git a/lib/pages/send_view/sub_widgets/mwc_slatepack_dialog.dart b/lib/pages/send_view/sub_widgets/slatepack_dialog.dart similarity index 79% rename from lib/pages/send_view/sub_widgets/mwc_slatepack_dialog.dart rename to lib/pages/send_view/sub_widgets/slatepack_dialog.dart index a9d15be3a0..ea3acec64f 100644 --- a/lib/pages/send_view/sub_widgets/mwc_slatepack_dialog.dart +++ b/lib/pages/send_view/sub_widgets/slatepack_dialog.dart @@ -8,6 +8,7 @@ import '../../../notifications/show_flush_bar.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/clipboard_interface.dart'; +import '../../../utilities/slatepack_terms.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../widgets/conditional_parent.dart'; @@ -17,28 +18,30 @@ import '../../../widgets/qr.dart'; import '../../../widgets/rounded_container.dart'; import '../../../widgets/rounded_white_container.dart'; -class MwcSlatepackDialog extends ConsumerStatefulWidget { - const MwcSlatepackDialog({ +class SlatepackDialog extends ConsumerStatefulWidget { + const SlatepackDialog({ super.key, + required this.terms, required this.slatepackResult, this.clipboard = const ClipboardWrapper(), }); + final SlatepackTerms terms; final SlatepackResult slatepackResult; final ClipboardInterface clipboard; @override - ConsumerState createState() => _MwcSlatepackDialogState(); + ConsumerState createState() => _SlatepackDialogState(); } -class _MwcSlatepackDialogState extends ConsumerState { +class _SlatepackDialogState extends ConsumerState { void _copySlatepack() { widget.clipboard.setData( ClipboardData(text: widget.slatepackResult.slatepack!), ); showFloatingFlushBar( type: FlushBarType.info, - message: "Slatepack copied to clipboard", + message: "${widget.terms.title} copied to clipboard", iconAsset: Assets.svg.copy, context: context, ); @@ -57,35 +60,35 @@ class _MwcSlatepackDialogState extends ConsumerState { Widget build(BuildContext context) { return ConditionalParent( condition: Util.isDesktop, - builder: - (child) => Column( - mainAxisSize: MainAxisSize.min, + builder: (child) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with title and close button. + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Header with title and close button. - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Send Slatepack", - style: STextStyles.pageTitleH2(context), - ), - ), - const DesktopDialogCloseButton(), - ], + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Send ${widget.terms.title}", + style: STextStyles.pageTitleH2(context), + ), ), - Padding(padding: const EdgeInsets.all(32), child: child), + const DesktopDialogCloseButton(), ], ), + Padding(padding: const EdgeInsets.all(32), child: child), + ], + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Instructions. RoundedContainer( - color: - Theme.of(context).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -97,8 +100,9 @@ class _MwcSlatepackDialogState extends ConsumerState { ), const SizedBox(height: 8), Text( - "1. Share this slatepack with the recipient\n" - "2. Wait for them to return the response slatepack\n" + "1. Share this ${widget.terms.noun} with the recipient\n" + "2. Wait for them to return the response " + "${widget.terms.noun}\n" "3. Import their response to finalize the transaction", style: STextStyles.w400_14(context), ), @@ -164,7 +168,10 @@ class _MwcSlatepackDialogState extends ConsumerState { children: [ Row( children: [ - Text("Slatepack", style: STextStyles.itemSubtitle(context)), + Text( + widget.terms.title, + style: STextStyles.itemSubtitle(context), + ), const Spacer(), GestureDetector( onTap: _copySlatepack, @@ -174,10 +181,9 @@ class _MwcSlatepackDialogState extends ConsumerState { Assets.svg.copy, width: 10, height: 10, - color: - Theme.of( - context, - ).extension()!.infoItemIcons, + color: Theme.of( + context, + ).extension()!.infoItemIcons, ), const SizedBox(width: 4), Text("Copy", style: STextStyles.link2(context)), diff --git a/lib/pages/wallet_view/transaction_views/transaction_details_view.dart b/lib/pages/wallet_view/transaction_views/transaction_details_view.dart index b38935dadf..c7b4b176f9 100644 --- a/lib/pages/wallet_view/transaction_views/transaction_details_view.dart +++ b/lib/pages/wallet_view/transaction_views/transaction_details_view.dart @@ -37,8 +37,7 @@ import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../wallets/wallet/impl/epiccash_wallet.dart'; -import '../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -1699,66 +1698,15 @@ class _TransactionDetailsViewState onPressed: () async { final wallet = ref.read(pWallets).getWallet(walletId); - if (wallet is EpiccashWallet) { + if (wallet is MimblewimbleWallet) { final String? id = _transaction.slateId; if (id == null) { unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: "Could not find Epic transaction ID", - context: context, - ), - ); - return; - } - - unawaited( - showDialog( - barrierDismissible: false, - context: context, - builder: (_) => - const CancellingTransactionProgressDialog(), - ), - ); - - final result = await wallet - .cancelPendingTransactionAndPost(id); - - if (context.mounted) { - // pop progress dialog - Navigator.of(context).pop(); - - if (result.isEmpty) { - await showDialog( - context: context, - builder: (_) => StackOkDialog( - title: "Transaction cancelled", - maxWidth: Util.isDesktop ? 400 : null, - onOkPressed: (_) { - Navigator.of(context).popUntil( - ModalRoute.withName(WalletView.routeName), - ); - }, - ), - ); - } else { - await showDialog( - context: context, - builder: (_) => StackOkDialog( - title: "Failed to cancel transaction", - message: result, - maxWidth: Util.isDesktop ? 400 : null, - ), - ); - } - } - } else if (wallet is MimblewimblecoinWallet) { - final String? id = _transaction.slateId; - if (id == null) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Could not find MWC transaction ID", + message: + "Could not find " + "${wallet.cryptoCurrency.ticker} transaction ID", context: context, ), ); diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart index c6eb3706ff..ddc932b8a1 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart @@ -41,8 +41,7 @@ import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../../wallets/isar/models/spark_coin.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; -import '../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; @@ -1497,13 +1496,15 @@ class _TransactionV2DetailsViewState onPressed: () async { final wallet = ref.read(pWallets).getWallet(walletId); - if (wallet is EpiccashWallet) { + if (wallet is MimblewimbleWallet) { final String? id = _transaction.slateId; if (id == null) { unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: "Could not find Epic transaction ID", + message: + "Could not find " + "${wallet.cryptoCurrency.ticker} transaction ID", context: context, ), ); @@ -1525,59 +1526,6 @@ class _TransactionV2DetailsViewState // Pop progress dialog. Navigator.of(context).pop(); - if (result.isEmpty) { - await showDialog( - context: context, - builder: (_) => StackOkDialog( - title: "Transaction cancelled", - onOkPressed: (_) { - Navigator.of(context).popUntil( - ModalRoute.withName(WalletView.routeName), - ); - }, - maxWidth: Util.isDesktop ? 400 : null, - ), - ); - unawaited(wallet.refresh()); - } else { - await showDialog( - context: context, - builder: (_) => StackOkDialog( - title: "Failed to cancel transaction", - message: result, - maxWidth: Util.isDesktop ? 400 : null, - ), - ); - } - } - } else if (wallet is MimblewimblecoinWallet) { - final String? id = _transaction.slateId; - if (id == null) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Could not find MWC transaction ID", - context: context, - ), - ); - return; - } - - unawaited( - showDialog( - barrierDismissible: false, - context: context, - builder: (_) => - const CancellingTransactionProgressDialog(), - ), - ); - - final result = await wallet - .cancelPendingTransactionAndPost(id); - if (context.mounted) { - // pop progress dialog - Navigator.of(context).pop(); - if (result.isEmpty) { await showDialog( context: context, diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 9ced7e8546..e6f556efde 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -52,11 +52,11 @@ import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; -import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../wallets/wallet/impl/namecoin_wallet.dart'; import '../../wallets/wallet/impl/salvium_wallet.dart'; import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; +import '../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; @@ -1011,7 +1011,7 @@ class _WalletViewState extends ConsumerState { } }, ), - if (wallet is MimblewimblecoinWallet) + if (wallet is MimblewimbleWallet) WalletNavigationBarItemData( label: "Finalize", icon: const FinalizeNavIcon(), @@ -1019,22 +1019,9 @@ class _WalletViewState extends ConsumerState { if (mounted) { unawaited( Navigator.of(context).pushNamed( - FinalizeView.routeName, - arguments: walletId, - ), - ); - } - }, - ), - if (wallet is EpiccashWallet) - WalletNavigationBarItemData( - label: "Finalize", - icon: const FinalizeNavIcon(), - onTap: () { - if (mounted) { - unawaited( - Navigator.of(context).pushNamed( - EpicFinalizeView.routeName, + wallet is EpiccashWallet + ? EpicFinalizeView.routeName + : FinalizeView.routeName, arguments: walletId, ), ); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart index eb354eb1e0..8b7a7dbe13 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart @@ -22,8 +22,7 @@ import '../../../../models/isar/models/isar_models.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/receive_view/generate_receiving_uri_qr_code_view.dart'; -import '../../../../pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart'; -import '../../../../pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart'; +import '../../../../pages/receive_view/sub_widgets/slatepack_import_dialog.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../route_generator.dart'; @@ -34,14 +33,14 @@ import '../../../../utilities/clipboard_interface.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/enums/derive_path_type_enum.dart'; import '../../../../utilities/show_loading.dart'; +import '../../../../utilities/slatepack_terms.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/impl/bitcoin_wallet.dart'; -import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; -import '../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../../../wallets/wallet/intermediate/bip39_hd_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/bcash_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; @@ -58,8 +57,7 @@ import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/dialogs/s_dialog.dart'; import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; -import '../../../../widgets/epic_txs_method_toggle.dart'; -import '../../../../widgets/mwc_txs_method_toggle.dart'; +import '../../../../widgets/slatepack_method_toggle.dart'; import '../../../../widgets/qr.dart'; import '../../../../widgets/rounded_white_container.dart'; import '../../../../widgets/stack_dialog.dart'; @@ -89,8 +87,7 @@ class _DesktopReceiveState extends ConsumerState { late final bool supportsSpark; late bool supportsMweb; late final bool showMultiType; - late final bool isMimblewimblecoin; - late final bool isEpiccash; + late final bool usesSlatepacks; late TextEditingController _receiveSlateController; String? _slate; bool _slateToggleFlag = false; @@ -114,14 +111,14 @@ class _DesktopReceiveState extends ConsumerState { } Future _onReceiveSlatePressed() async { - final wallet = - ref.read(pWallets).getWallet(walletId) as MimblewimblecoinWallet; + final SlatepackTerms terms = SlatepackTerms.forCoin(coin); + final wallet = ref.read(pWallets).getWallet(walletId) as MimblewimbleWallet; Exception? ex; final result = await showLoading( whileFuture: wallet.fullDecodeSlatepack(_receiveSlateController.text), context: context, - message: "Decoding slatepack...", + message: "Decoding ${terms.noun}...", rootNavigator: Util.isDesktop, onException: (e) => ex = e, ); @@ -133,7 +130,7 @@ class _DesktopReceiveState extends ConsumerState { useRootNavigator: true, builder: (context) => StackOkDialog( desktopPopRootNavigator: true, - title: "Slatepack receive error", + title: "${terms.title} receive error", message: ex?.toString() ?? "Unexpected result without exception", maxWidth: 400, ), @@ -149,7 +146,7 @@ class _DesktopReceiveState extends ConsumerState { builder: (context) => SDialog( child: SizedBox( width: 700, - child: MwcSlatepackImportDialog( + child: SlatepackImportDialog( walletId: widget.walletId, clipboard: widget.clipboard, rawSlatepack: result.raw, @@ -165,66 +162,7 @@ class _DesktopReceiveState extends ConsumerState { context: context, barrierDismissible: false, builder: (context) => SlatepackResponseDialog( - responseSlatepack: response.responseSlatepack, - wasEncrypted: response.wasEncrypted, - ), - ); - } - } - } - - Future _onEpicReceiveSlatePressed() async { - final wallet = - ref.read(pWallets).getWallet(walletId) as EpiccashWallet; - - Exception? ex; - final result = await showLoading( - whileFuture: wallet.fullDecodeSlatepack(_receiveSlateController.text), - context: context, - message: "Decoding slatepack...", - rootNavigator: Util.isDesktop, - onException: (e) => ex = e, - ); - - if (result == null || ex != null) { - if (mounted) { - await showDialog( - context: context, - useRootNavigator: true, - builder: (context) => StackOkDialog( - desktopPopRootNavigator: true, - title: "Slatepack receive error", - message: ex?.toString() ?? "Unexpected result without exception", - maxWidth: 400, - ), - ); - } - return; - } - - if (mounted) { - final response = - await showDialog<({String responseSlatepack, bool wasEncrypted})>( - context: context, - builder: (context) => SDialog( - child: SizedBox( - width: 700, - child: EpicSlatepackImportDialog( - walletId: widget.walletId, - clipboard: widget.clipboard, - rawSlatepack: result.raw, - decoded: result.result, - slatepackType: result.type, - ), - ), - ), - ); - - if (mounted && response != null) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => EpicSlatepackResponseDialog( + terms: terms, responseSlatepack: response.responseSlatepack, wasEncrypted: response.wasEncrypted, ), @@ -414,8 +352,7 @@ class _DesktopReceiveState extends ConsumerState { !wallet.info.isViewOnly && wallet.info.isMwebEnabled; - isMimblewimblecoin = wallet is MimblewimblecoinWallet; - isEpiccash = wallet is EpiccashWallet; + usesSlatepacks = wallet is MimblewimbleWallet; if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { showMultiType = false; @@ -551,7 +488,7 @@ class _DesktopReceiveState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 4), - if (isMimblewimblecoin) + if (usesSlatepacks) Padding( padding: const EdgeInsets.all(0), child: Container( @@ -571,43 +508,16 @@ class _DesktopReceiveState extends ConsumerState { width: 1, ), ), - child: const SizedBox( - height: - 60, // Provide an explicit height to avoid infinite constraints - child: MwcTxsMethodToggle(), - ), - ), - ), - if (isEpiccash) - Padding( - padding: const EdgeInsets.all(0), - child: Container( - decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()?.textFieldDefaultBG ?? - Colors.white, // Fallback color - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: - Theme.of( - context, - ).extension()?.backgroundAppBar ?? - Colors.grey, // Fallback color - width: 1, - ), - ), - child: const SizedBox( + child: SizedBox( height: 60, // Provide an explicit height to avoid infinite constraints - child: EpicTxsMethodToggle(), + child: SlatepackMethodToggle(coin: coin), ), ), ), - if (!((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) + if (!(usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId)))) const SizedBox(height: 20), - if (!((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) + if (!(usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId)))) ConditionalParent( condition: showMultiType, builder: (child) => Column( @@ -778,7 +688,7 @@ class _DesktopReceiveState extends ConsumerState { label: "Generate new address", ), const SizedBox(height: 20), - if ((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))) + if (usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId))) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -909,16 +819,14 @@ class _DesktopReceiveState extends ConsumerState { // TODO: create transparent button class to account for hover // Conditional logic for 'Submit' button or QR code - if ((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))) + if (usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId))) Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: PrimaryButton( buttonHeight: ButtonHeight.l, label: "Receive Slatepack", enabled: _slateToggleFlag, - onPressed: _slateToggleFlag - ? (isEpiccash ? _onEpicReceiveSlatePressed : _onReceiveSlatePressed) - : null, + onPressed: _slateToggleFlag ? _onReceiveSlatePressed : null, ), ) else 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 95d7740a3c..275c18833a 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 @@ -28,9 +28,8 @@ import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/frost_ms/recipient.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; -import '../../../../pages/send_view/sub_widgets/epic_slatepack_dialog.dart'; import '../../../../pages/send_view/sub_widgets/multi_recipient.dart'; -import '../../../../pages/send_view/sub_widgets/mwc_slatepack_dialog.dart'; +import '../../../../pages/send_view/sub_widgets/slatepack_dialog.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'; @@ -55,15 +54,15 @@ import '../../../../utilities/integer_input.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/prefs.dart'; import '../../../../utilities/show_loading.dart'; +import '../../../../utilities/slatepack_terms.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/models/tx_data.dart'; -import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../../wallets/wallet/impl/firo_wallet.dart'; -import '../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../../../wallets/wallet/impl/salvium_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; @@ -77,13 +76,12 @@ import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/desktop/simple_desktop_dialog.dart'; import '../../../../widgets/dialogs/firo_exchange_address_dialog.dart'; -import '../../../../widgets/epic_txs_method_toggle.dart'; import '../../../../widgets/eth_fee_form.dart'; import '../../../../widgets/icon_widgets/addressbook_icon.dart'; import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/qrcode_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; -import '../../../../widgets/mwc_txs_method_toggle.dart'; +import '../../../../widgets/slatepack_method_toggle.dart'; import '../../../../widgets/rounded_container.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; @@ -131,8 +129,7 @@ class _DesktopSendState extends ConsumerState { final _nonceFocusNode = FocusNode(); late final bool hasOptionalMemo; - late final bool isMimblewimblecoin; - late final bool isEpiccash; + late final bool usesSlatepacks; String? _note; String? _onChainNote; @@ -201,10 +198,10 @@ class _DesktopSendState extends ConsumerState { } } - /// Handle MWC slatepack creation for desktop. Future _handleDesktopSlatepackCreation( - MimblewimblecoinWallet wallet, + MimblewimbleWallet wallet, ) async { + final SlatepackTerms terms = SlatepackTerms.forCoin(coin); try { final amount = ref.read(pSendAmount)!; @@ -224,7 +221,7 @@ class _DesktopSendState extends ConsumerState { whileFuture: wrappedFutureWithDelay(), context: context, rootNavigator: true, - message: "Building slatepack...", + message: "Building ${terms.noun}...", delay: const Duration(seconds: 2), onException: (e) => ex = e, ); @@ -236,7 +233,7 @@ class _DesktopSendState extends ConsumerState { String error = ex?.toString() ?? slatepackResult?.error ?? - 'Failed to create slatepack'; + 'Failed to create ${terms.noun}'; if (error.startsWith("Exception:")) { error = error.replaceFirst("Exception:", "").trim(); } @@ -257,140 +254,19 @@ class _DesktopSendState extends ConsumerState { builder: (context) => DesktopDialog( maxHeight: double.infinity, maxWidth: 700, - child: MwcSlatepackDialog(slatepackResult: slatepackResult), - ), - ); - - // Clear form after slatepack dialog is closed. - clearSendForm(); - } - } catch (e, s) { - Logging.instance.e( - 'Failed to create MWC slatepack on desktop', - error: e, - stackTrace: s, - ); - - if (mounted) { - await showDialog( - context: context, - builder: (context) => DesktopDialog( - maxWidth: 450, - maxHeight: double.infinity, - child: Padding( - padding: const EdgeInsets.only(left: 32, bottom: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Slatepack Creation Failed', - style: STextStyles.desktopH3(context), - ), - const DesktopDialogCloseButton(), - ], - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.only(right: 32), - child: Text( - 'Failed to create slatepack: $e', - textAlign: TextAlign.left, - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith(fontSize: 18), - ), - ), - const SizedBox(height: 40), - Padding( - padding: const EdgeInsets.only(right: 32), - child: Row( - children: [ - Expanded( - child: PrimaryButton( - buttonHeight: ButtonHeight.l, - label: 'OK', - onPressed: () => Navigator.of(context).pop(), - ), - ), - ], - ), - ), - ], - ), + child: SlatepackDialog( + terms: terms, + slatepackResult: slatepackResult, ), ), ); - } - } - } - - /// Handle Epic Cash slate creation for desktop. - Future _handleDesktopEpicSlatepackCreation( - EpiccashWallet wallet, - ) async { - try { - final amount = ref.read(pSendAmount)!; - - Future wrappedFutureWithDelay() async { - await Future.delayed(const Duration(seconds: 1)); - return wallet.createSlatepack( - amount: amount, - recipientAddress: null, // No specific recipient for manual slatepack. - message: _onChainNote?.isNotEmpty == true ? _onChainNote : null, - ); - } - - // Create slatepack. - Exception? ex; - final slatepackResult = await showLoading( - whileFuture: wrappedFutureWithDelay(), - context: context, - rootNavigator: true, - message: "Building slate...", - delay: const Duration(seconds: 2), - onException: (e) => ex = e, - ); - - if (slatepackResult == null || - !slatepackResult.success || - slatepackResult.slatepack == null || - ex != null) { - String error = - ex?.toString() ?? - slatepackResult?.error ?? - 'Failed to create slate'; - if (error.startsWith("Exception:")) { - error = error.replaceFirst("Exception:", "").trim(); - } - throw Exception(error); - } - - // refresh asap to show the pending slate tx in history - unawaited(() async { - await Future.delayed(Duration.zero); - await wallet.refresh(); - }()); - - // Show slatepack dialog. - if (mounted) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => DesktopDialog( - maxHeight: double.infinity, - maxWidth: 700, - child: EpicSlatepackDialog(slatepackResult: slatepackResult), - ), - ); // Clear form after slatepack dialog is closed. clearSendForm(); } } catch (e, s) { Logging.instance.e( - 'Failed to create Epic Cash slate on desktop', + 'Failed to create ${coin.prettyName} ${terms.noun} on desktop', error: e, stackTrace: s, ); @@ -410,7 +286,7 @@ class _DesktopSendState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Slate Creation Failed', + '${terms.title} Creation Failed', style: STextStyles.desktopH3(context), ), const DesktopDialogCloseButton(), @@ -420,7 +296,7 @@ class _DesktopSendState extends ConsumerState { Padding( padding: const EdgeInsets.only(right: 32), child: Text( - 'Failed to create slate: $e', + 'Failed to create ${terms.noun}: $e', textAlign: TextAlign.left, style: STextStyles.desktopTextExtraExtraSmall(context) .copyWith(fontSize: 18), @@ -529,15 +405,9 @@ class _DesktopSendState extends ConsumerState { } late final recipientAddress = resolvedAddress ?? _address!; - // Handle MWC slatepack transactions directly. - if (isMimblewimblecoin && ref.read(pIsSlatepack(widget.walletId))) { - await _handleDesktopSlatepackCreation(wallet as MimblewimblecoinWallet); - return; - } - - // Handle Epic Cash slatepack transactions directly. - if (isEpiccash && ref.read(pIsSlatepack(widget.walletId))) { - await _handleDesktopEpicSlatepackCreation(wallet as EpiccashWallet); + if (wallet is MimblewimbleWallet && + ref.read(pIsSlatepack(widget.walletId))) { + await _handleDesktopSlatepackCreation(wallet); return; } @@ -1567,8 +1437,7 @@ class _DesktopSendState extends ConsumerState { clipboard = widget.clipboard; hasOptionalMemo = coin is Stellar || coin is Solana; - isMimblewimblecoin = coin is Mimblewimblecoin; - isEpiccash = coin is Epiccash; + usesSlatepacks = coin is Mimblewimblecoin || coin is Epiccash; sendToController = TextEditingController(); cryptoAmountController = TextEditingController(); @@ -1751,34 +1620,7 @@ class _DesktopSendState extends ConsumerState { children: [ const SizedBox(height: 4), if (showPrivateBalance) const SizedBox(height: 4), - if (isMimblewimblecoin) - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Container( - decoration: BoxDecoration( - color: - Theme.of(context) - .extension() - ?.textFieldDefaultBG ?? - Colors.white, // Fallback color - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: - Theme.of(context) - .extension() - ?.backgroundAppBar ?? - Colors.grey, // Fallback color - width: 1, - ), - ), - child: const SizedBox( - height: 60, // Provide an explicit height to avoid infinite constraints - child: MwcTxsMethodToggle(), - ), - ), - ), - - if (isEpiccash) + if (usesSlatepacks) Padding( padding: const EdgeInsets.only(bottom: 16), child: Container( @@ -1798,9 +1640,10 @@ class _DesktopSendState extends ConsumerState { width: 1, ), ), - child: const SizedBox( - height: 60, // Provide an explicit height to avoid infinite constraints - child: EpicTxsMethodToggle(), + child: SizedBox( + height: + 60, // Provide an explicit height to avoid infinite constraints + child: SlatepackMethodToggle(coin: coin), ), ), ), @@ -2104,8 +1947,7 @@ class _DesktopSendState extends ConsumerState { ), const SizedBox(height: 20), if (!isPaynymSend && - !((isMimblewimblecoin || isEpiccash) && - ref.watch(pIsSlatepack(widget.walletId)))) + !(usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId)))) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -2126,8 +1968,7 @@ class _DesktopSendState extends ConsumerState { ], ), if (!isPaynymSend && - !((isMimblewimblecoin || isEpiccash) && - ref.watch(pIsSlatepack(widget.walletId)))) + !(usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId)))) const SizedBox(height: 10), if (_recipientIndexes != null) RecipientForms( @@ -2141,8 +1982,7 @@ class _DesktopSendState extends ConsumerState { onMessage: (message) => _note = message, ), if (!isPaynymSend && - !((isMimblewimblecoin || isEpiccash) && - ref.watch(pIsSlatepack(widget.walletId))) && + !(usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId))) && _recipientIndexes == null) ClipRRect( borderRadius: BorderRadius.circular( @@ -2327,8 +2167,7 @@ class _DesktopSendState extends ConsumerState { ), ), if (!isPaynymSend && - !((isMimblewimblecoin || isEpiccash) && - ref.watch(pIsSlatepack(widget.walletId)))) + !(usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId)))) Builder( builder: (_) { final String? error; diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart index dad37c3356..8a1f9b856c 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart @@ -12,7 +12,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../frost_route_generator.dart'; -import '../../../../pages/epic_finalize_view/epic_finalize_view.dart'; import '../../../../pages/finalize_view/finalize_view.dart'; import '../../../../pages/send_view/frost_ms/frost_send_view.dart'; import '../../../../pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart'; @@ -49,8 +48,7 @@ class _MyWalletState extends ConsumerState { late final bool isSolana; late final CryptoCurrency coin; late final bool isFrost; - late final bool isMimblewimblecoin; - late final bool isEpiccash; + late final bool usesSlatepacks; late final bool isViewOnly; @override @@ -60,10 +58,9 @@ class _MyWalletState extends ConsumerState { isFrost = wallet is BitcoinFrostWallet; isEth = coin is Ethereum; isSolana = wallet is SolanaWallet; - isMimblewimblecoin = coin is Mimblewimblecoin; - isEpiccash = coin is Epiccash; + usesSlatepacks = coin is Mimblewimblecoin || coin is Epiccash; - if (isMimblewimblecoin || isEpiccash) { + if (usesSlatepacks) { titles.add("Finalize"); } @@ -184,16 +181,11 @@ class _MyWalletState extends ConsumerState { contractAddress: widget.contractAddress, ), ), - if (isMimblewimblecoin) + if (usesSlatepacks) Padding( padding: const EdgeInsets.all(20), child: FinalizeView(walletId: widget.walletId), ), - if (isEpiccash) - Padding( - padding: const EdgeInsets.all(20), - child: EpicFinalizeView(walletId: widget.walletId), - ), if ((isEth || isSolana) && widget.contractAddress == null) Padding( diff --git a/lib/providers/ui/preview_tx_button_state_provider.dart b/lib/providers/ui/preview_tx_button_state_provider.dart index 285d4b8781..00dfb2b705 100644 --- a/lib/providers/ui/preview_tx_button_state_provider.dart +++ b/lib/providers/ui/preview_tx_button_state_provider.dart @@ -11,8 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../utilities/amount/amount.dart'; -import '../../utilities/enums/epic_transaction_method.dart'; -import '../../utilities/enums/mwc_transaction_method.dart'; +import '../../utilities/enums/mimblewimble_transaction_method.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../wallet/public_private_balance_state_provider.dart'; @@ -25,73 +24,59 @@ final pIsExchangeAddress = StateProvider((_) => false); final pOpReturnData = StateProvider.autoDispose((_) => null); -// MWC Transaction Method Provider. -final pSelectedMwcTransactionMethod = StateProvider( - (_) => MwcTransactionMethod.slatepack, -); - -// Epic Cash Transaction Method Provider. -final pSelectedEpicTransactionMethod = StateProvider( - (_) => EpicTransactionMethod.epicbox, -); +// Selected exchange method per slatepack coin. Epic Cash defaults to its +// relay, MWC to manual slatepacks. +final pSelectedSlatepackMethod = + StateProvider.family( + (_, CryptoCurrency coin) => coin is Epiccash + ? MimblewimbleTransactionMethod.relay + : MimblewimbleTransactionMethod.slatepack, + ); final pIsSlatepack = Provider.family((ref, walletId) { final coin = ref.watch(pWalletCoin(walletId)); - if (coin is Mimblewimblecoin) { - return ref.watch(pSelectedMwcTransactionMethod) == - MwcTransactionMethod.slatepack; - } - if (coin is Epiccash) { - return ref.watch(pSelectedEpicTransactionMethod) == - EpicTransactionMethod.slatepack; + if (coin is Mimblewimblecoin || coin is Epiccash) { + return ref.watch(pSelectedSlatepackMethod(coin)) == + MimblewimbleTransactionMethod.slatepack; } return false; }); -final pPreviewTxButtonEnabled = Provider.autoDispose.family( - (ref, coin) { - final amount = ref.watch(pSendAmount) ?? Amount.zero; - final opReturnData = ref.watch(pOpReturnData); +final pPreviewTxButtonEnabled = Provider.autoDispose + .family((ref, coin) { + final amount = ref.watch(pSendAmount) ?? Amount.zero; + final opReturnData = ref.watch(pOpReturnData); - if (coin is! Firo && opReturnData != null) { - return false; - } - - // For MWC slatepack transactions, address validation is not required. - if (coin is Mimblewimblecoin) { - final selectedMethod = ref.watch(pSelectedMwcTransactionMethod); - if (selectedMethod == MwcTransactionMethod.slatepack) { - return amount > Amount.zero; + if (coin is! Firo && opReturnData != null) { + return false; } - } - // For Epic Cash slatepack transactions, address validation is not required. - if (coin is Epiccash) { - final selectedMethod = ref.watch(pSelectedEpicTransactionMethod); - if (selectedMethod == EpicTransactionMethod.slatepack) { - return amount > Amount.zero; + // Slatepack transactions have no address to validate. + if (coin is Mimblewimblecoin || coin is Epiccash) { + if (ref.watch(pSelectedSlatepackMethod(coin)) == + MimblewimbleTransactionMethod.slatepack) { + return amount > Amount.zero; + } } - } - - if (coin is Firo) { - final firoType = ref.watch(publicPrivateBalanceStateProvider); - switch (firoType) { - case BalanceType.private: - return (ref.watch(pValidSendToAddress) || - ref.watch(pValidSparkSendToAddress)) && - !ref.watch(pIsExchangeAddress) && - opReturnData == null && - amount > Amount.zero; - case BalanceType.public: - return ref.watch(pValidSendToAddress) && amount > Amount.zero; + if (coin is Firo) { + final firoType = ref.watch(publicPrivateBalanceStateProvider); + switch (firoType) { + case BalanceType.private: + return (ref.watch(pValidSendToAddress) || + ref.watch(pValidSparkSendToAddress)) && + !ref.watch(pIsExchangeAddress) && + opReturnData == null && + amount > Amount.zero; + + case BalanceType.public: + return ref.watch(pValidSendToAddress) && amount > Amount.zero; + } + } else { + return ref.watch(pValidSendToAddress) && amount > Amount.zero; } - } else { - return ref.watch(pValidSendToAddress) && amount > Amount.zero; - } - }, -); + }); final previewTokenTxButtonStateProvider = StateProvider.autoDispose((_) { return false; diff --git a/lib/utilities/enums/epic_transaction_method.dart b/lib/utilities/enums/epic_transaction_method.dart deleted file mode 100644 index 4ef30afc1f..0000000000 --- a/lib/utilities/enums/epic_transaction_method.dart +++ /dev/null @@ -1,48 +0,0 @@ -/// Enum to represent different Epic Cash transaction methods. -enum EpicTransactionMethod { - /// Manual slate exchange (copy/paste, QR codes, files). - slatepack, - - /// Automatic transaction via Epicbox. - epicbox; - - /// Human readable name for the transaction method. - String get displayName { - switch (this) { - case EpicTransactionMethod.slatepack: - return 'Slatepack'; - case EpicTransactionMethod.epicbox: - return 'Epicbox'; - } - } - - /// Description of how the transaction method works. - String get description { - switch (this) { - case EpicTransactionMethod.slatepack: - return 'Manual exchange via text, QR codes, or files'; - case EpicTransactionMethod.epicbox: - return 'Automatic exchange via Epicbox messaging'; - } - } - - /// Whether this method requires manual intervention. - bool get isManual { - switch (this) { - case EpicTransactionMethod.slatepack: - return true; - case EpicTransactionMethod.epicbox: - return false; - } - } - - /// Whether this method works offline. - bool get worksOffline { - switch (this) { - case EpicTransactionMethod.slatepack: - return true; - case EpicTransactionMethod.epicbox: - return false; - } - } -} diff --git a/lib/utilities/enums/mimblewimble_transaction_method.dart b/lib/utilities/enums/mimblewimble_transaction_method.dart new file mode 100644 index 0000000000..857be81b34 --- /dev/null +++ b/lib/utilities/enums/mimblewimble_transaction_method.dart @@ -0,0 +1,8 @@ +/// How an Epic Cash or MWC transaction is exchanged with the other party. +enum MimblewimbleTransactionMethod { + /// Manual slate exchange (copy/paste, QR codes, files). + slatepack, + + /// Automatic exchange through the coin's relay server (epicbox, mwcmqs). + relay, +} diff --git a/lib/utilities/enums/mwc_transaction_method.dart b/lib/utilities/enums/mwc_transaction_method.dart deleted file mode 100644 index 4b24ad37f7..0000000000 --- a/lib/utilities/enums/mwc_transaction_method.dart +++ /dev/null @@ -1,70 +0,0 @@ -/// Enum to represent different MWC transaction methods. -enum MwcTransactionMethod { - /// Manual slatepack exchange (copy/paste, QR codes, files). - slatepack, - - /// Automatic transaction via MWCMQS. - mwcmqs; - - // /// Direct HTTP/HTTPS to recipient's wallet. - // http, - // - // /// Unknown or unsupported method. - // unknown; - - /// Human readable name for the transaction method. - String get displayName { - switch (this) { - case MwcTransactionMethod.slatepack: - return 'Slatepack'; - case MwcTransactionMethod.mwcmqs: - return 'MWCMQS'; - // case MwcTransactionMethod.http: - // return 'HTTP'; - // case MwcTransactionMethod.unknown: - // return 'Unknown'; - } - } - - /// Description of how the transaction method works. - String get description { - switch (this) { - case MwcTransactionMethod.slatepack: - return 'Manual exchange via text, QR codes, or files'; - case MwcTransactionMethod.mwcmqs: - return 'Automatic exchange via MWCMQS messaging'; - // case MwcTransactionMethod.http: - // return 'Direct connection to recipient wallet'; - // case MwcTransactionMethod.unknown: - // return 'Unsupported transaction method'; - } - } - - /// Whether this method requires manual intervention. - bool get isManual { - switch (this) { - case MwcTransactionMethod.slatepack: - return true; - case MwcTransactionMethod.mwcmqs: - return false; - // case MwcTransactionMethod.http: - // return false; - // case MwcTransactionMethod.unknown: - // return true; - } - } - - /// Whether this method works offline. - bool get worksOffline { - switch (this) { - case MwcTransactionMethod.slatepack: - return true; - case MwcTransactionMethod.mwcmqs: - return false; - // case MwcTransactionMethod.http: - // return false; - // case MwcTransactionMethod.unknown: - // return false; - } - } -} diff --git a/lib/utilities/slatepack_terms.dart b/lib/utilities/slatepack_terms.dart new file mode 100644 index 0000000000..f9ae72e08b --- /dev/null +++ b/lib/utilities/slatepack_terms.dart @@ -0,0 +1,25 @@ +import '../wallets/crypto_currency/crypto_currency.dart'; + +/// Wording the slatepack screens use for the payload being exchanged. Epic +/// Cash passes raw slate JSON around while MWC uses armored slatepacks. +enum SlatepackTerms { + slate("slate", "Slate JSON", "Enter Response Slate JSON"), + slatepack("slatepack", "Slatepack Message", "Enter Final Slatepack Message"); + + const SlatepackTerms(this.noun, this.payload, this.finalizeHint); + + /// "slate" or "slatepack". + final String noun; + + /// What the user is asked to paste, e.g. "Slatepack Message". + final String payload; + + /// Hint for the response field on the finalize screen. + final String finalizeHint; + + /// [noun] with a capital, for titles. + String get title => "${noun[0].toUpperCase()}${noun.substring(1)}"; + + static SlatepackTerms forCoin(CryptoCurrency coin) => + coin is Epiccash ? slate : slatepack; +} diff --git a/lib/wallets/crypto_currency/coins/epiccash.dart b/lib/wallets/crypto_currency/coins/epiccash.dart index 97c1b18d3a..b5894fad8f 100644 --- a/lib/wallets/crypto_currency/coins/epiccash.dart +++ b/lib/wallets/crypto_currency/coins/epiccash.dart @@ -4,7 +4,7 @@ import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/node_model.dart'; import '../../../utilities/default_nodes.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; -import '../../../utilities/enums/epic_transaction_method.dart'; +import '../../../utilities/enums/mimblewimble_transaction_method.dart'; import '../../../wl_gen/interfaces/libepiccash_interface.dart'; import '../crypto_currency.dart'; import '../intermediate/bip39_currency.dart'; @@ -156,14 +156,14 @@ class Epiccash extends Bip39Currency { } /// Detect transaction type based on address/data format. - EpicTransactionMethod getTransactionMethod(String addressOrData) { + MimblewimbleTransactionMethod getTransactionMethod(String addressOrData) { if (isSlateJson(addressOrData)) { - return EpicTransactionMethod.slatepack; + return MimblewimbleTransactionMethod.slatepack; } else if (isEpicboxAddress(addressOrData) || isHttpAddress(addressOrData)) { - return EpicTransactionMethod.epicbox; + return MimblewimbleTransactionMethod.relay; } else { - throw Exception("Unknown EpicTransactionMethod found!"); + throw Exception("Unknown MimblewimbleTransactionMethod found!"); } } diff --git a/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart b/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart index 2ed274b35c..2389fc7527 100644 --- a/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart +++ b/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart @@ -2,7 +2,7 @@ import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/node_model.dart'; import '../../../utilities/default_nodes.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; -import '../../../utilities/enums/mwc_transaction_method.dart'; +import '../../../utilities/enums/mimblewimble_transaction_method.dart'; import '../../../wl_gen/interfaces/libmwc_interface.dart'; import '../crypto_currency.dart'; import '../intermediate/bip39_currency.dart'; @@ -71,16 +71,14 @@ class Mimblewimblecoin extends Bip39Currency { } /// Detect transaction type based on address/data format. - MwcTransactionMethod getTransactionMethod(String addressOrData) { + MimblewimbleTransactionMethod getTransactionMethod(String addressOrData) { if (isSlatepack(addressOrData)) { - return MwcTransactionMethod.slatepack; + return MimblewimbleTransactionMethod.slatepack; } else if (isMwcmqsAddress(addressOrData)) { - return MwcTransactionMethod.mwcmqs; + return MimblewimbleTransactionMethod.relay; // } else if (isHttpAddress(addressOrData)) { - // return MwcTransactionMethod.http; } else { - throw Exception("Unknown MwcTransactionMethod found!"); - // return MwcTransactionMethod.unknown; + throw Exception("Unknown MimblewimbleTransactionMethod found!"); } } diff --git a/lib/widgets/epic_txs_method_toggle.dart b/lib/widgets/epic_txs_method_toggle.dart deleted file mode 100644 index f08f086f16..0000000000 --- a/lib/widgets/epic_txs_method_toggle.dart +++ /dev/null @@ -1,67 +0,0 @@ -/* - * This file is part of Stack Wallet. - * - * Copyright (c) 2023 Cypher Stack - * All Rights Reserved. - * The code is distributed under GPLv3 license, see LICENSE file for details. - * Generated by Cypher Stack on 2026-01-12 - * - */ - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../providers/ui/preview_tx_button_state_provider.dart'; -import '../themes/stack_colors.dart'; -import '../utilities/assets.dart'; -import '../utilities/constants.dart'; -import '../utilities/enums/epic_transaction_method.dart'; -import '../utilities/util.dart'; -import 'toggle.dart'; - -class EpicTxsMethodToggle extends ConsumerWidget { - const EpicTxsMethodToggle({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - debugPrint("BUILD: $runtimeType"); - final isDesktop = Util.isDesktop; - - return Toggle( - onValueChanged: (value) { - ref.read(pSelectedEpicTransactionMethod.notifier).state = - value - ? EpicTransactionMethod.epicbox - : EpicTransactionMethod.slatepack; - }, - isOn: - ref.watch(pSelectedEpicTransactionMethod) == - EpicTransactionMethod.epicbox, - onColor: - isDesktop - ? Theme.of( - context, - ).extension()!.rateTypeToggleDesktopColorOn - : Theme.of( - context, - ).extension()!.rateTypeToggleColorOn, - offColor: - isDesktop - ? Theme.of( - context, - ).extension()!.rateTypeToggleDesktopColorOff - : Theme.of( - context, - ).extension()!.rateTypeToggleColorOff, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - onIcon: Assets.svg.gear, - onText: "Slatepack", - offIcon: Assets.svg.radioSyncing, - offText: "Automatic", - ); - } -} diff --git a/lib/widgets/mwc_txs_method_toggle.dart b/lib/widgets/mwc_txs_method_toggle.dart deleted file mode 100644 index 4381d6e236..0000000000 --- a/lib/widgets/mwc_txs_method_toggle.dart +++ /dev/null @@ -1,67 +0,0 @@ -/* - * This file is part of Stack Wallet. - * - * Copyright (c) 2023 Cypher Stack - * All Rights Reserved. - * The code is distributed under GPLv3 license, see LICENSE file for details. - * Generated by Cypher Stack on 2023-05-26 - * - */ - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../providers/ui/preview_tx_button_state_provider.dart'; -import '../themes/stack_colors.dart'; -import '../utilities/assets.dart'; -import '../utilities/constants.dart'; -import '../utilities/enums/mwc_transaction_method.dart'; -import '../utilities/util.dart'; -import 'toggle.dart'; - -class MwcTxsMethodToggle extends ConsumerWidget { - const MwcTxsMethodToggle({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - debugPrint("BUILD: $runtimeType"); - final isDesktop = Util.isDesktop; - - return Toggle( - onValueChanged: (value) { - ref.read(pSelectedMwcTransactionMethod.notifier).state = - value - ? MwcTransactionMethod.mwcmqs - : MwcTransactionMethod.slatepack; - }, - isOn: - ref.watch(pSelectedMwcTransactionMethod) == - MwcTransactionMethod.mwcmqs, - onColor: - isDesktop - ? Theme.of( - context, - ).extension()!.rateTypeToggleDesktopColorOn - : Theme.of( - context, - ).extension()!.rateTypeToggleColorOn, - offColor: - isDesktop - ? Theme.of( - context, - ).extension()!.rateTypeToggleDesktopColorOff - : Theme.of( - context, - ).extension()!.rateTypeToggleColorOff, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - onIcon: Assets.svg.gear, - onText: "Slatepack", - offIcon: Assets.svg.radioSyncing, - offText: "Automatic", - ); - } -} diff --git a/lib/widgets/slatepack_method_toggle.dart b/lib/widgets/slatepack_method_toggle.dart new file mode 100644 index 0000000000..ab86e93adf --- /dev/null +++ b/lib/widgets/slatepack_method_toggle.dart @@ -0,0 +1,63 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/ui/preview_tx_button_state_provider.dart'; +import '../themes/stack_colors.dart'; +import '../utilities/assets.dart'; +import '../utilities/constants.dart'; +import '../utilities/enums/mimblewimble_transaction_method.dart'; +import '../utilities/util.dart'; +import '../wallets/crypto_currency/crypto_currency.dart'; +import 'toggle.dart'; + +class SlatepackMethodToggle extends ConsumerWidget { + const SlatepackMethodToggle({super.key, required this.coin}); + + final CryptoCurrency coin; + + @override + Widget build(BuildContext context, WidgetRef ref) { + debugPrint("BUILD: $runtimeType"); + final bool isDesktop = Util.isDesktop; + + return Toggle( + onValueChanged: (bool value) { + ref.read(pSelectedSlatepackMethod(coin).notifier).state = value + ? MimblewimbleTransactionMethod.relay + : MimblewimbleTransactionMethod.slatepack; + }, + isOn: + ref.watch(pSelectedSlatepackMethod(coin)) == + MimblewimbleTransactionMethod.relay, + onColor: isDesktop + ? Theme.of( + context, + ).extension()!.rateTypeToggleDesktopColorOn + : Theme.of(context).extension()!.rateTypeToggleColorOn, + offColor: isDesktop + ? Theme.of( + context, + ).extension()!.rateTypeToggleDesktopColorOff + : Theme.of(context).extension()!.rateTypeToggleColorOff, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onIcon: Assets.svg.gear, + onText: "Slatepack", + offIcon: Assets.svg.radioSyncing, + offText: "Automatic", + ); + } +} From 981f4e80ece4016ccb832dc284a0f198b27dc1b3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 7 Sep 2026 20:21:23 -0500 Subject: [PATCH 05/30] Use MWC's own genesis time when estimating a creation height The MWC wallet copied Epic Cash's first-block timestamp (1565370278) under the name mimblewimblecoinFirstBlock. MWC mainnet started on 2019-11-11 (1573462801, the value restore_wallet_view already uses), so the estimate for a freshly created wallet landed about 130k blocks above the actual tip: the initial scan and any later rescan from that height never ran. Wallets restored from a seed were unaffected. Staging already fixed this in 0e56467c6 with 1573462800. Rebased onto it, this only aligns the value with restore_wallet_view. --- lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index 66ece7833a..994b8e3b7a 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -49,7 +49,7 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { String get walletDirName => "mimblewimblecoin"; @override - int get firstBlockTimestamp => 1573462800; + int get firstBlockTimestamp => 1573462801; @override Future updateRelayConfig(String host, int port) async { From cbe4368d7044f6433c2d5db2895d19ea8265dac8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 7 Nov 2026 11:46:05 -0600 Subject: [PATCH 06/30] Point the wallet creation integration test at the shared slatepack toggle and relay config --- .../wallet_creation/drivers/desktop_driver.dart | 13 +++++++------ .../wallet_creation/seed_validators.dart | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/integration_test/wallet_creation/drivers/desktop_driver.dart b/integration_test/wallet_creation/drivers/desktop_driver.dart index f70052bbde..2362c60ab5 100644 --- a/integration_test/wallet_creation/drivers/desktop_driver.dart +++ b/integration_test/wallet_creation/drivers/desktop_driver.dart @@ -8,7 +8,7 @@ import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub import 'package:stackwallet/pages_desktop_specific/password/create_password_view.dart'; import 'package:stackwallet/pages_desktop_specific/password/desktop_login_view.dart'; import 'package:stackwallet/wallets/wallet/wallet.dart'; -import 'package:stackwallet/widgets/mwc_txs_method_toggle.dart'; +import 'package:stackwallet/widgets/slatepack_method_toggle.dart'; import 'package:stackwallet/widgets/toggle.dart'; import 'package:stackwallet/widgets/wallet_card.dart'; @@ -92,16 +92,17 @@ class DesktopDriver extends PlatformDriver { ); final receive = find.byType(DesktopReceive); await ui.waitFor(receive); - final mwcMode = find.descendant( + final slatepackMode = find.descendant( of: receive, - matching: find.byType(MwcTxsMethodToggle), + matching: find.byType(SlatepackMethodToggle), ); - if (mwcMode.evaluate().isNotEmpty) { + if (slatepackMode.evaluate().isNotEmpty) { final toggle = ui.tester.widget( - find.descendant(of: mwcMode, matching: find.byType(Toggle)), + find.descendant(of: slatepackMode, matching: find.byType(Toggle)), ); // MWC defaults to Slatepack, which does not display the MQS address. - if (!toggle.isOn) await ui.tap(mwcMode); + // Epic Cash defaults to epicbox, so this is a no-op there. + if (!toggle.isOn) await ui.tap(slatepackMode); } await selectPrimaryAddress(wallet, receive); final displayed = find.descendant( diff --git a/integration_test/wallet_creation/seed_validators.dart b/integration_test/wallet_creation/seed_validators.dart index cb8983b45f..e975522e99 100644 --- a/integration_test/wallet_creation/seed_validators.dart +++ b/integration_test/wallet_creation/seed_validators.dart @@ -29,7 +29,7 @@ Future verifyOpenNativeWallet(Wallet wallet, String expected) async { } else if (wallet is MimblewimblecoinWallet) { actual = (await wallet.thisWalletAddress( 0, - await wallet.getMwcMqsConfig(), + await wallet.getRelayConfig(), )).value; } else if (wallet is LibXelisWallet) { wallet.checkInitialized(); From 64201e54f04f2a2e479ca6b3a8248b4e60caaef9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 7 Nov 2026 12:25:23 -0600 Subject: [PATCH 07/30] Show a message instead of a broken QR code when the data does not fit qr 3 only notices oversized data while painting, after qr_flutter's own validation has passed, so a slatepack too long for a level L code threw during build. QR now checks the data first and shows an errorStateBuilder, defaulting to a short message. --- .../epic_finalize_view.dart | 17 ------ .../slatepack_dialog.dart | 0 .../slatepack_summary_dialog.dart} | 0 .../slatepack_view.dart} | 0 ...tion_method.dart => slatepack_method.dart} | 0 lib/widgets/qr.dart | 54 ++++++++++++++++++- 6 files changed, 53 insertions(+), 18 deletions(-) delete mode 100644 lib/pages/epic_finalize_view/epic_finalize_view.dart rename lib/pages/{send_view/sub_widgets => slatepack}/slatepack_dialog.dart (100%) rename lib/pages/{receive_view/sub_widgets/slatepack_import_dialog.dart => slatepack/slatepack_summary_dialog.dart} (100%) rename lib/pages/{finalize_view/finalize_view.dart => slatepack/slatepack_view.dart} (100%) rename lib/utilities/enums/{mimblewimble_transaction_method.dart => slatepack_method.dart} (100%) diff --git a/lib/pages/epic_finalize_view/epic_finalize_view.dart b/lib/pages/epic_finalize_view/epic_finalize_view.dart deleted file mode 100644 index 1c56cfc30e..0000000000 --- a/lib/pages/epic_finalize_view/epic_finalize_view.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../finalize_view/finalize_view.dart'; - -/// Keeps the old Epic Cash route resolving; the screen is [FinalizeView]. -class EpicFinalizeView extends StatelessWidget { - const EpicFinalizeView({super.key, required this.walletId}); - - static const String routeName = "/epicFinalizeView"; - - final String walletId; - - @override - Widget build(BuildContext context) { - return FinalizeView(walletId: walletId); - } -} diff --git a/lib/pages/send_view/sub_widgets/slatepack_dialog.dart b/lib/pages/slatepack/slatepack_dialog.dart similarity index 100% rename from lib/pages/send_view/sub_widgets/slatepack_dialog.dart rename to lib/pages/slatepack/slatepack_dialog.dart diff --git a/lib/pages/receive_view/sub_widgets/slatepack_import_dialog.dart b/lib/pages/slatepack/slatepack_summary_dialog.dart similarity index 100% rename from lib/pages/receive_view/sub_widgets/slatepack_import_dialog.dart rename to lib/pages/slatepack/slatepack_summary_dialog.dart diff --git a/lib/pages/finalize_view/finalize_view.dart b/lib/pages/slatepack/slatepack_view.dart similarity index 100% rename from lib/pages/finalize_view/finalize_view.dart rename to lib/pages/slatepack/slatepack_view.dart diff --git a/lib/utilities/enums/mimblewimble_transaction_method.dart b/lib/utilities/enums/slatepack_method.dart similarity index 100% rename from lib/utilities/enums/mimblewimble_transaction_method.dart rename to lib/utilities/enums/slatepack_method.dart diff --git a/lib/widgets/qr.dart b/lib/widgets/qr.dart index 3b63538b15..ec27c43ff6 100644 --- a/lib/widgets/qr.dart +++ b/lib/widgets/qr.dart @@ -3,14 +3,49 @@ import 'package:qr_flutter/qr_flutter.dart'; /// Centralised Qr code image widget class QR extends StatelessWidget { - const QR({super.key, required this.data, this.size, this.padding}); + const QR({ + super.key, + required this.data, + this.size, + this.padding, + this.errorStateBuilder, + }); final String data; final double? size; final EdgeInsets? padding; + /// Shown instead of the code when [data] does not fit in one. Defaults to + /// a short message. + final QrErrorBuilder? errorStateBuilder; + + /// Whether [data] fits in a level L code. qr 3 only finds out while + /// painting, after qr_flutter's own validation passed, so oversized data + /// would throw during build instead of reaching errorStateBuilder. + static Object? _encodingError(String data) { + try { + QrImage.withMaskPattern( + QrCode.fromData(data: data, errorCorrectLevel: QrErrorCorrectLevel.L), + 0, + ); + return null; + } on InputTooLongException catch (e) { + return e; + } + } + @override Widget build(BuildContext context) { + final errorBuilder = errorStateBuilder ?? _defaultError; + final error = _encodingError(data); + if (error != null) { + return SizedBox( + width: size, + height: size, + child: errorBuilder(context, error), + ); + } + return SizedBox( width: size, height: size, @@ -20,6 +55,7 @@ class QR extends StatelessWidget { padding: padding ?? const EdgeInsets.all(10), backgroundColor: Colors.white, foregroundColor: Colors.black, + errorStateBuilder: errorBuilder, // backgroundColor: // Theme.of(context).extension()!.background, // foregroundColor: Theme.of(context) @@ -28,4 +64,20 @@ class QR extends StatelessWidget { ), ); } + + static Widget _defaultError(BuildContext context, Object? error) { + return const ColoredBox( + color: Colors.white, + child: Center( + child: Padding( + padding: EdgeInsets.all(8), + child: Text( + "Too much data to show as a QR code", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.black), + ), + ), + ), + ); + } } From 64b9629374256f4020ce2b0ed0b3d5824b400f90 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 7 Nov 2026 12:27:25 -0600 Subject: [PATCH 08/30] Add SlatepackInterface and route the slatepack screens through it SlatepackInterface is the coin-neutral contract the slatepack screens use: supported methods, on-chain note and invoice capabilities, terms, restore progress, inspectSlatepack, localTxForSlate, create/receive/ finalize, cancel, and unsupported defaults for pending slatepacks, repost, invoices and payment proofs. MimblewimbleWallet mixes it in; inspectSlatepack replaces fullDecodeSlatepack and analyzeSlatepack. SlatepackResult gains state, slateId, amount and fee, FinalizeResult gains posted and kernelExcess, and receive returns a SlatepackResult. UI changes: - Slatepack sends go through prepareSend and the confirm screen on mobile and desktop, so the fee is shown. Epic and MWC prepareSend tag otherData with the method; the confirm screen creates the slatepack. - SlatepackImportFlow inspects any pasted slatepack and dispatches on its state: receive S1, finalize S2, placeholders for invoices, messages for finalized and unknown slates. Finalize summaries use the local tx. - One SlatepackDialog for S1/S2/I1/I2 with copy, share, save to ..slatepack, the encrypted flag and a QR code only up to 1000 characters, tap to enlarge. - SlatepackInputField: paste, scan (QrCodeScannerDialog on desktop) and open file with a 256 KiB cap, used wherever a slatepack is entered. - The Finalize nav item and desktop tab become "Slatepack" and open the import flow. Desktop receive always shows the address and QR. - The method toggle is per wallet instead of per coin. - Casts to MimblewimbleWallet and coin gates in slatepack UI use the interface. Relay settings stay coin specific. Dead code removed: Epic's isSlatepack, MWC's slatepack type detection helpers and its slatepack-as-address branch in prepareSend. --- lib/models/slatepack_models.dart | 123 ++- lib/pages/receive_view/receive_view.dart | 119 +-- .../sub_widgets/slatepack_entry_dialog.dart | 187 +---- .../send_view/confirm_transaction_view.dart | 181 ++--- lib/pages/send_view/send_view.dart | 151 +--- .../wallet_network_settings_view.dart | 15 +- lib/pages/slatepack/slatepack_dialog.dart | 397 +++++----- .../slatepack/slatepack_import_flow.dart | 300 ++++++++ .../slatepack/slatepack_input_field.dart | 264 +++++++ lib/pages/slatepack/slatepack_popup.dart | 78 ++ lib/pages/slatepack/slatepack_qr.dart | 77 ++ .../slatepack/slatepack_summary_dialog.dart | 416 ++-------- lib/pages/slatepack/slatepack_view.dart | 291 ++----- .../transaction_details_view.dart | 6 +- .../tx_v2/transaction_v2_details_view.dart | 28 +- lib/pages/wallet_view/wallet_view.dart | 14 +- .../sub_widgets/desktop_receive.dart | 712 +++++++----------- .../wallet_view/sub_widgets/desktop_send.dart | 183 +---- .../wallet_view/sub_widgets/my_wallet.dart | 9 +- .../ui/preview_tx_button_state_provider.dart | 87 ++- lib/route_generator.dart | 17 +- lib/utilities/enums/slatepack_method.dart | 25 +- lib/utilities/slatepack_terms.dart | 17 +- .../crypto_currency/coins/epiccash.dart | 15 +- .../coins/mimblewimblecoin.dart | 10 +- lib/wallets/wallet/impl/epiccash_wallet.dart | 74 +- .../wallet/impl/mimblewimblecoin_wallet.dart | 164 +--- .../intermediate/mimblewimble_wallet.dart | 263 +++---- .../slatepack_interface.dart | 106 +++ lib/widgets/slatepack_method_toggle.dart | 42 +- 30 files changed, 2105 insertions(+), 2266 deletions(-) create mode 100644 lib/pages/slatepack/slatepack_import_flow.dart create mode 100644 lib/pages/slatepack/slatepack_input_field.dart create mode 100644 lib/pages/slatepack/slatepack_popup.dart create mode 100644 lib/pages/slatepack/slatepack_qr.dart create mode 100644 lib/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart diff --git a/lib/models/slatepack_models.dart b/lib/models/slatepack_models.dart index 8b5c05b832..d731a982f7 100644 --- a/lib/models/slatepack_models.dart +++ b/lib/models/slatepack_models.dart @@ -1,3 +1,68 @@ +import '../utilities/amount/amount.dart'; + +/// Where a slate is in its round trip. S is a standard send, I an invoice. +enum SlateState { + s1("S1"), + s2("S2"), + s3("S3"), + i1("I1"), + i2("I2"), + i3("I3"), + unknown("Unknown"); + + const SlateState(this.code); + + /// "S1", "I2" and so on, as libwallet names them. + final String code; + + bool get isInvoice => this == i1 || this == i2 || this == i3; + + static SlateState fromCode(String? code) { + final upper = code?.trim().toUpperCase(); + return values.firstWhere((e) => e.code == upper, orElse: () => unknown); + } +} + +/// What a wallet can tell about a slatepack without acting on it. +class SlateInspection { + const SlateInspection({ + required this.state, + required this.slateId, + this.amount, + this.fee, + this.senderAddress, + this.recipientAddress, + this.isEncrypted = false, + this.ttlCutoffHeight, + }); + + final SlateState state; + final String slateId; + + /// Null when the slate does not carry it. Grin's S2 and I2 decode with 0; + /// look the amount up with localTxForSlate instead. + final Amount? amount; + final Amount? fee; + final String? senderAddress; + final String? recipientAddress; + final bool isEncrypted; + final int? ttlCutoffHeight; + + @override + String toString() { + return "SlateInspection(" + "state: ${state.code}, " + "slateId: $slateId, " + "amount: $amount, " + "fee: $fee, " + "senderAddress: $senderAddress, " + "recipientAddress: $recipientAddress, " + "isEncrypted: $isEncrypted, " + "ttlCutoffHeight: $ttlCutoffHeight" + ")"; + } +} + class SlatepackResult { final bool success; final String? error; @@ -5,6 +70,10 @@ class SlatepackResult { final String? slateJson; final bool? wasEncrypted; final String? recipientAddress; + final SlateState state; + final String? slateId; + final Amount? amount; + final Amount? fee; SlatepackResult({ required this.success, @@ -13,6 +82,10 @@ class SlatepackResult { this.slateJson, this.wasEncrypted, this.recipientAddress, + this.state = SlateState.unknown, + this.slateId, + this.amount, + this.fee, }); @override @@ -23,7 +96,11 @@ class SlatepackResult { "slatepack: $slatepack, " "slateJson: $slateJson, " "wasEncrypted: $wasEncrypted, " - "recipientAddress: $recipientAddress" + "recipientAddress: $recipientAddress, " + "state: ${state.code}, " + "slateId: $slateId, " + "amount: $amount, " + "fee: $fee" ")"; } } @@ -58,50 +135,26 @@ class SlatepackDecodeResult { } } -class ReceiveResult { +class FinalizeResult { final bool success; final String? error; final String? slateId; final String? commitId; - final String? responseSlatepack; - final bool? wasEncrypted; - final String? recipientAddress; - - ReceiveResult({ - required this.success, - this.error, - this.slateId, - this.commitId, - this.responseSlatepack, - this.wasEncrypted, - this.recipientAddress, - }); - @override - String toString() { - return "ReceiveResult(" - "success: $success, " - "error: $error, " - "slateId: $slateId, " - "commitId: $commitId, " - "responseSlatepack: $responseSlatepack, " - "wasEncrypted: $wasEncrypted, " - "recipientAddress: $recipientAddress" - ")"; - } -} + /// Whether the finalized transaction reached the node. Epic Cash and MWC + /// always post on finalize. + final bool posted; -class FinalizeResult { - final bool success; - final String? error; - final String? slateId; - final String? commitId; + /// Null for Epic Cash and MWC. + final String? kernelExcess; FinalizeResult({ required this.success, this.error, this.slateId, this.commitId, + this.posted = false, + this.kernelExcess, }); @override @@ -110,7 +163,9 @@ class FinalizeResult { "success: $success, " "error: $error, " "slateId: $slateId, " - "commitId: $commitId" + "commitId: $commitId, " + "posted: $posted, " + "kernelExcess: $kernelExcess" ")"; } } diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 10eddf532f..175186dae9 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -34,12 +34,12 @@ import '../../utilities/text_styles.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/bitcoin_wallet.dart'; -import '../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../wallets/wallet/intermediate/bip39_hd_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/bcash_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../widgets/background.dart'; @@ -49,13 +49,11 @@ import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/custom_loading_overlay.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; -import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/qr.dart'; import '../../widgets/rounded_white_container.dart'; -import '../../widgets/stack_dialog.dart'; import 'addresses/wallet_addresses_view.dart'; import 'generate_receiving_uri_qr_code_view.dart'; -import 'sub_widgets/slatepack_import_dialog.dart'; +import '../slatepack/slatepack_import_flow.dart'; import 'sub_widgets/slatepack_entry_dialog.dart'; class ReceiveView extends ConsumerStatefulWidget { @@ -83,6 +81,7 @@ class _ReceiveViewState extends ConsumerState { late final bool _supportsSpark; late final bool _showMultiType; late bool supportsMweb; + SlatepackTerms? _slatepackTerms; int _currentIndex = 0; @@ -91,67 +90,18 @@ class _ReceiveViewState extends ConsumerState { final Map> _addressSubMap = {}; Future _importSlatepack() async { - final SlatepackTerms terms = SlatepackTerms.forCoin(coin); + final wallet = ref.read(pWallets).getWallet(walletId) as SlatepackInterface; - final slatepackString = await showDialog( + final text = await showDialog( context: context, - builder: (context) => SlatepackEntryDialog(terms: terms), + builder: (context) => SlatepackEntryDialog( + terms: wallet.slatepackTerms, + clipboard: widget.clipboard, + ), ); + if (text == null || text.trim().isEmpty || !mounted) return; - if (slatepackString == null) return; - if (mounted) { - final wallet = - ref.read(pWallets).getWallet(walletId) as MimblewimbleWallet; - - Exception? ex; - final result = await showLoading( - whileFuture: wallet.fullDecodeSlatepack(slatepackString), - context: context, - message: "Decoding ${terms.noun}...", - onException: (e) => ex = e, - ); - - if (result == null || ex != null) { - if (mounted) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - title: "${terms.title} receive error", - message: ex?.toString() ?? "Unexpected result without exception", - ), - ); - } - return; - } - - if (mounted) { - final response = - await showDialog<({String responseSlatepack, bool wasEncrypted})>( - context: context, - builder: (context) => SDialog( - child: SlatepackImportDialog( - walletId: widget.walletId, - clipboard: widget.clipboard, - rawSlatepack: result.raw, - decoded: result.result, - slatepackType: result.type, - ), - ), - ); - - if (mounted && response != null) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => SlatepackResponseDialog( - terms: terms, - responseSlatepack: response.responseSlatepack, - wasEncrypted: response.wasEncrypted, - ), - ); - } - } - } + await SlatepackImportFlow.run(context: context, wallet: wallet, text: text); } Future generateNewAddress() async { @@ -166,9 +116,10 @@ class _ReceiveViewState extends ConsumerState { return WillPopScope( onWillPop: () async => shouldPop, child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.5), + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.5), child: const CustomLoadingOverlay( message: "Generating address", eventBus: null, @@ -225,9 +176,8 @@ class _ReceiveViewState extends ConsumerState { shouldPop = true; if (mounted) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(ReceiveView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(ReceiveView.routeName)); setState(() { _addressMap[_walletAddressTypes[_currentIndex]] = @@ -248,9 +198,10 @@ class _ReceiveViewState extends ConsumerState { return WillPopScope( onWillPop: () async => shouldPop, child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.5), + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.5), child: const CustomLoadingOverlay( message: "Generating address", eventBus: null, @@ -332,6 +283,9 @@ class _ReceiveViewState extends ConsumerState { clipboard = widget.clipboard; final wallet = ref.read(pWallets).getWallet(walletId); _supportsSpark = wallet is SparkInterface; + if (wallet is SlatepackInterface) { + _slatepackTerms = wallet.slatepackTerms; + } supportsMweb = wallet is MwebInterface && !wallet.info.isViewOnly && @@ -493,17 +447,16 @@ class _ReceiveViewState extends ConsumerState { child: AspectRatio( aspectRatio: 1, child: AppBarIconButton( - semanticsLabel: - "Address List Pop-up Button. Opens A Pop-up For Address List Button.", + semanticsLabel: "Address List Pop-up Button. Opens A Pop-up For Address List Button.", key: const Key("walletNetworkSettingsAddNewNodeViewButton"), size: 36, shadows: const [], color: Theme.of(context).extension()!.background, icon: SvgPicture.asset( Assets.svg.verticalEllipsis, - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, width: 20, height: 20, ), @@ -520,9 +473,9 @@ class _ReceiveViewState extends ConsumerState { right: 10, child: Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.popupBG, + color: Theme.of(context) + .extension()! + .popupBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), @@ -591,9 +544,9 @@ class _ReceiveViewState extends ConsumerState { Text( "Address type", style: STextStyles.w500_14(context).copyWith( - color: Theme.of( - context, - ).extension()!.infoItemLabel, + color: Theme.of(context) + .extension()! + .infoItemLabel, ), ), const SizedBox(height: 10), @@ -760,10 +713,10 @@ class _ReceiveViewState extends ConsumerState { ? generateNewSparkAddress : generateNewAddress, ), - if (coin is Mimblewimblecoin || coin is Epiccash) ...[ + if (_slatepackTerms != null) ...[ const SizedBox(height: 12), SecondaryButton( - label: "Import ${SlatepackTerms.forCoin(coin).title}", + label: "Import ${_slatepackTerms!.title}", onPressed: _importSlatepack, ), ], diff --git a/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart b/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart index 62a78fec08..d68197a162 100644 --- a/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart +++ b/lib/pages/receive_view/sub_widgets/slatepack_entry_dialog.dart @@ -1,25 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../providers/global/barcode_scanner_provider.dart'; import '../../../themes/stack_colors.dart'; -import '../../../utilities/barcode_scanner_interface.dart'; import '../../../utilities/clipboard_interface.dart'; -import '../../../utilities/constants.dart'; -import '../../../utilities/logger.dart'; import '../../../utilities/slatepack_terms.dart'; import '../../../utilities/text_styles.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/desktop/secondary_button.dart'; -import '../../../widgets/icon_widgets/clipboard_icon.dart'; -import '../../../widgets/icon_widgets/qrcode_icon.dart'; -import '../../../widgets/icon_widgets/x_icon.dart'; import '../../../widgets/stack_dialog.dart'; -import '../../../widgets/stack_text_field.dart'; -import '../../../widgets/textfield_icon_button.dart'; +import '../../slatepack/slatepack_input_field.dart'; -class SlatepackEntryDialog extends ConsumerStatefulWidget { +/// Asks for a slatepack on mobile and pops with the text. +class SlatepackEntryDialog extends StatefulWidget { const SlatepackEntryDialog({ super.key, required this.terms, @@ -30,72 +21,17 @@ class SlatepackEntryDialog extends ConsumerStatefulWidget { final ClipboardInterface clipboard; @override - ConsumerState createState() => - _SlatepackEntryDialogState(); + State createState() => _SlatepackEntryDialogState(); } -class _SlatepackEntryDialogState extends ConsumerState { - final _receiveSlateController = TextEditingController(); - final _slateFocusNode = FocusNode(); +class _SlatepackEntryDialogState extends State { + final _controller = TextEditingController(); - bool _slateToggleFlag = false; - - Future _pasteSlatepack() async { - final ClipboardData? data = await widget.clipboard.getData( - Clipboard.kTextPlain, - ); - if (data?.text != null && data!.text!.isNotEmpty) { - _receiveSlateController.text = data.text!; - setState(() { - _slateToggleFlag = _receiveSlateController.text.isNotEmpty; - }); - } - } - - Future _scanQr() async { - try { - if (_slateFocusNode.hasFocus) { - _slateFocusNode.unfocus(); - await Future.delayed(const Duration(milliseconds: 75)); - } - - if (mounted) { - final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - if (qrResult.rawContent != null && qrResult.rawContent!.isNotEmpty) { - _receiveSlateController.text = qrResult.rawContent!; - setState(() { - _slateToggleFlag = _receiveSlateController.text.isNotEmpty; - }); - } - } - } on PlatformException catch (e, s) { - if (mounted) { - try { - await checkCamPermDeniedMobileAndOpenAppSettings( - context, - logging: Logging.instance, - ); - } catch (e, s) { - Logging.instance.e( - "Failed to check cam permissions", - error: e, - stackTrace: s, - ); - } - } else { - Logging.instance.e( - "Failed to get camera permissions while trying to scan qr code in SendView: ", - error: e, - stackTrace: s, - ); - } - } - } + bool get _hasText => _controller.text.trim().isNotEmpty; @override void dispose() { - _receiveSlateController.dispose(); - _slateFocusNode.dispose(); + _controller.dispose(); super.dispose(); } @@ -106,109 +42,28 @@ class _SlatepackEntryDialogState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - "Receive ${widget.terms.title}", + "Import ${widget.terms.title}", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, ), textAlign: TextAlign.left, ), const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - minLines: 1, - maxLines: 5, - key: const Key("receiveViewSlatepackFieldKey"), - controller: _receiveSlateController, - readOnly: false, - autocorrect: false, - enableSuggestions: false, - toolbarOptions: const ToolbarOptions( - copy: false, - cut: false, - paste: true, - selectAll: false, - ), - onChanged: (newValue) { - setState(() { - _slateToggleFlag = newValue.isNotEmpty; - }); - }, - focusNode: _slateFocusNode, - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ), - decoration: - standardInputDecoration( - "Enter ${widget.terms.payload}", - _slateFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: - 12, // Adjust vertical padding for better alignment - ), - suffixIcon: Padding( - padding: _receiveSlateController.text.isEmpty - ? const EdgeInsets.only(right: 8) - : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _slateToggleFlag - ? TextFieldIconButton( - key: const Key( - "receiveViewClearSlatepackFieldButtonKey", - ), - onTap: () { - _receiveSlateController.text = ""; - setState(() { - _slateToggleFlag = false; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "receiveViewPasteSlatepackFieldButtonKey", - ), - onTap: _pasteSlatepack, - child: _receiveSlateController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_receiveSlateController.text.isEmpty) - TextFieldIconButton( - semanticsLabel: - "Scan QR Button. Opens Camera For Scanning QR Code.", - key: const Key("sendViewScanQrButtonKey"), - onTap: _scanQr, - child: const QrCodeIcon(), - ), - ], - ), - ), - ), - ), - ), + SlatepackInputField( + controller: _controller, + hint: "Enter ${widget.terms.payload}", + clipboard: widget.clipboard, + onChanged: (_) => setState(() {}), ), const SizedBox(height: 16), PrimaryButton( label: "Import", - enabled: _slateToggleFlag, - onPressed: !_slateToggleFlag - ? null - : () => Navigator.of(context).pop(_receiveSlateController.text), + enabled: _hasText, + onPressed: _hasText + ? () => Navigator.of(context).pop(_controller.text.trim()) + : null, ), const SizedBox(height: 16), SecondaryButton( diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index ab115a1d7e..7e1326c909 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -9,7 +9,6 @@ */ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:decimal/decimal.dart'; @@ -36,7 +35,6 @@ import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/amount/fee_rate_formatter.dart'; import '../../utilities/constants.dart'; import '../../utilities/logger.dart'; -import '../../utilities/slatepack_terms.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -46,10 +44,10 @@ import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.da import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; -import '../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../wallets/wallet/impl/solana_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -66,7 +64,7 @@ import '../../wl_gen/interfaces/libepiccash_interface.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../wallet_view/wallet_view.dart'; import 'sub_widgets/multi_recipient.dart'; -import 'sub_widgets/slatepack_dialog.dart'; +import '../slatepack/slatepack_dialog.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; import 'sub_widgets/split_transaction_warning.dart'; @@ -155,89 +153,39 @@ class _ConfirmTransactionViewState } } - /// Create a slatepack for manual exchange instead of sending directly. - Future _handleSlatepackCreation( + /// Slatepack sends end here instead of broadcasting: create the S1 and + /// show it. + Future _createSlatepack( BuildContext context, - MimblewimbleWallet wallet, + SlatepackInterface wallet, + void Function() closeSendingDialog, ) async { - final SlatepackTerms terms = SlatepackTerms.forCoin(wallet.cryptoCurrency); - try { - // Close the progress dialog first. - Navigator.of(context).pop(); - - // Get recipient information from txData. - final recipient = widget.txData.recipients?.first; - if (recipient == null) { - throw Exception('No recipient found in transaction data'); - } + final terms = wallet.slatepackTerms; + final recipient = widget.txData.recipients!.first; + final note = onChainNoteController.text; - // Create slatepack. - final slatepackResult = await wallet.createSlatepack( - amount: recipient.amount, - recipientAddress: recipient.address.isNotEmpty - ? recipient.address - : null, - message: onChainNoteController.text.isNotEmpty - ? onChainNoteController.text - : null, - encrypt: recipient - .address - .isNotEmpty, // Encrypt if we have a recipient address. - ); - - if (!slatepackResult.success || slatepackResult.slatepack == null) { - throw Exception( - slatepackResult.error ?? 'Failed to create ${terms.noun}', - ); - } - - // Show slatepack dialog. - if (context.mounted) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => - SlatepackDialog(terms: terms, slatepackResult: slatepackResult), - ); + final result = await wallet.createSlatepack( + amount: recipient.amount, + recipientAddress: recipient.address.isEmpty ? null : recipient.address, + message: wallet.supportsOnChainNote && note.isNotEmpty ? note : null, + ); + if (!result.success || result.slatepack == null) { + throw Exception(result.error ?? "Failed to create ${terms.noun}"); + } - // After slatepack dialog is closed, navigate back to wallet. - if (context.mounted) { - widget.onSuccess.call(); - if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of(context) - .popUntil(ModalRoute.withName(routeOnSuccessName)); - } else { - widget.onSuccessInsteadOfRouteOnSuccess!.call(); - } - } - } - } catch (e, s) { - Logging.instance.e( - 'Failed to create ${wallet.cryptoCurrency.prettyName} ${terms.noun}: ' - '$e\n$s', - ); + unawaited(wallet.refresh()); + closeSendingDialog(); - if (context.mounted) { - // Show user-friendly error message. - final errorMessage = e.toString().contains('insufficient funds') - ? 'Insufficient funds for this transaction' - : e.toString().contains('wallet not open') - ? 'Wallet not accessible. Please restart the app.' - : 'Failed to create ${terms.noun}: ${e.toString()}'; + if (context.mounted) { + await showSlatepackDialog(context: context, terms: terms, result: result); + } - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('${terms.title} Creation Failed'), - content: Text(errorMessage), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ], - ), - ); + widget.onSuccess.call(); + if (context.mounted) { + if (widget.onSuccessInsteadOfRouteOnSuccess == null) { + Navigator.of(context).popUntil(ModalRoute.withName(routeOnSuccessName)); + } else { + widget.onSuccessInsteadOfRouteOnSuccess!.call(); } } } @@ -319,27 +267,18 @@ class _ConfirmTransactionViewState break; } } else { - if (wallet is MimblewimbleWallet) { - // Check if this is a slatepack transaction (manual exchange). - final otherDataMap = widget.txData.otherData != null - ? jsonDecode(widget.txData.otherData!) - : null; - final transactionMethod = - otherDataMap?['transactionMethod'] as String?; - - if (transactionMethod == 'slatepack') { - // Handle slatepack creation instead of direct send. - await _handleSlatepackCreation(context, wallet); - closeSendingDialog(); - return; // Exit early, don't continue with normal transaction flow. - } else { - // Relay or HTTP transactions go through the normal path. - txDataFuture = wallet.confirmSend( - txData: widget.txData.copyWith( - noteOnChain: onChainNoteController.text, - ), - ); + if (wallet is SlatepackInterface) { + if (widget.txData.isSlatepackSend) { + await _createSlatepack(context, wallet, closeSendingDialog); + return; } + txDataFuture = wallet.confirmSend( + txData: widget.txData.copyWith( + noteOnChain: wallet.supportsOnChainNote + ? onChainNoteController.text + : null, + ), + ); } else { txDataFuture = wallet.confirmSend(txData: widget.txData); } @@ -591,6 +530,16 @@ class _ConfirmTransactionViewState final feeRate = formatApproximateFeeRate(fee?.raw, widget.txData.vSize); + final slatepackWallet = wallet is SlatepackInterface ? wallet : null; + final showOnChainNote = slatepackWallet?.supportsOnChainNote ?? false; + // A slatepack send without an address goes to whoever gets the slatepack. + final String? slatepackRecipient = + slatepackWallet != null && + widget.txData.isSlatepackSend && + (widget.txData.recipients?.firstOrNull?.address.isEmpty ?? false) + ? "${slatepackWallet.slatepackTerms.title}, exchanged manually" + : null; + return ConditionalParent( condition: !isDesktop, builder: (child) => Background( @@ -716,7 +665,8 @@ class _ConfirmTransactionViewState Text( widget.isPaynymTransaction ? widget.txData.paynymAccountLite!.nymName - : widget + : slatepackRecipient ?? + widget .txData .recipients ?.firstOrNull @@ -810,11 +760,11 @@ class _ConfirmTransactionViewState ], ), ), - if ((coin is Epiccash || coin is Mimblewimblecoin) && - widget.txData.noteOnChain!.isNotEmpty) + if (showOnChainNote && + (widget.txData.noteOnChain ?? "").isNotEmpty) const SizedBox(height: 12), - if ((coin is Epiccash || coin is Mimblewimblecoin) && - widget.txData.noteOnChain!.isNotEmpty) + if (showOnChainNote && + (widget.txData.noteOnChain ?? "").isNotEmpty) RoundedWhiteContainer( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -839,9 +789,7 @@ class _ConfirmTransactionViewState crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - (coin is Epiccash || coin is Mimblewimblecoin) - ? "Local Note" - : "Note", + slatepackWallet != null ? "Local Note" : "Note", style: STextStyles.smallMed12(context), ), const SizedBox(height: 4), @@ -1119,7 +1067,8 @@ class _ConfirmTransactionViewState // TODO: [prio=med] spark transaction specifics - better handling widget.isPaynymTransaction ? widget.txData.paynymAccountLite!.nymName - : widget + : slatepackRecipient ?? + widget .txData .recipients ?.firstOrNull @@ -1255,15 +1204,14 @@ class _ConfirmTransactionViewState mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (coin is Epiccash || coin is Mimblewimblecoin) + if (showOnChainNote) Text( "On chain Note", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), - if (coin is Epiccash || coin is Mimblewimblecoin) - const SizedBox(height: 8), - if (coin is Epiccash || coin is Mimblewimblecoin) + if (showOnChainNote) const SizedBox(height: 8), + if (showOnChainNote) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1308,10 +1256,9 @@ class _ConfirmTransactionViewState ), ), ), - if (coin is Epiccash || coin is Mimblewimblecoin) - const SizedBox(height: 12), + if (showOnChainNote) const SizedBox(height: 12), SelectableText( - (coin is Epiccash || coin is Mimblewimblecoin) + slatepackWallet != null ? "Local Note (optional)" : "Note (optional)", style: STextStyles.desktopTextExtraSmall(context) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 650de61e56..b9ec72bfa3 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -19,7 +19,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:tuple/tuple.dart'; -import '../../models/slatepack_models.dart'; import '../../models/input.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/paynym/paynym_account_lite.dart'; @@ -46,12 +45,11 @@ import '../../utilities/barcode_scanner_interface.dart'; import '../../utilities/clipboard_interface.dart'; import '../../utilities/constants.dart'; import '../../utilities/enums/fee_rate_type_enum.dart'; +import '../../utilities/enums/slatepack_method.dart'; import '../../utilities/eth_commons.dart'; import '../../utilities/extensions/extensions.dart'; import '../../utilities/logger.dart'; import '../../utilities/prefs.dart'; -import '../../utilities/show_loading.dart'; -import '../../utilities/slatepack_terms.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -60,12 +58,12 @@ import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; -import '../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../wallets/wallet/impl/salvium_wallet.dart'; import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../widgets/animated_text.dart'; import '../../widgets/background.dart'; @@ -90,7 +88,6 @@ import 'frost_ms/recipient.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/dual_balance_selection_sheet.dart'; import 'sub_widgets/multi_recipient.dart'; -import 'sub_widgets/slatepack_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; class SendView extends ConsumerStatefulWidget { @@ -147,6 +144,7 @@ class _SendViewState extends ConsumerState { late final bool hasOptionalMemo; late final bool isFiro; late final bool isEth; + SlatepackInterface? _slatepackWallet; Amount? _cachedAmountToSend; String? _addressValue; @@ -877,99 +875,6 @@ class _SendViewState extends ConsumerState { } } - Future _createSlatepack() async { - // wait for keyboard to disappear - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 100)); - - final SlatepackTerms terms = SlatepackTerms.forCoin(coin); - - try { - if (mounted) { - final wallet = - ref.read(pWallets).getWallet(walletId) as MimblewimbleWallet; - - final amount = ref.read(pSendAmount)!; - - Future wrappedFutureWithDelay() async { - await Future.delayed(const Duration(seconds: 1)); - return wallet.createSlatepack( - amount: amount, - recipientAddress: null, - // No specific recipient for manual slatepack. - message: onChainNoteController.text.isNotEmpty == true - ? onChainNoteController.text - : null, - encrypt: false, // No encryption without recipient address. - ); - } - - // Create slatepack. - Exception? ex; - final slatepackResult = await showLoading( - whileFuture: wrappedFutureWithDelay(), - context: context, - message: "Building ${terms.noun}...", - delay: const Duration(seconds: 2), - onException: (e) => ex = e, - ); - - if (slatepackResult == null || - !slatepackResult.success || - slatepackResult.slatepack == null || - ex != null) { - String error = - ex?.toString() ?? - slatepackResult?.error ?? - 'Failed to create ${terms.noun}'; - if (error.startsWith("Exception:")) { - error = error.replaceFirst("Exception:", "").trim(); - } - throw Exception(error); - } - - // refresh asap to show the pending slate tx in history - unawaited(() async { - await Future.delayed(Duration.zero); - await wallet.refresh(); - }()); - - // Show slatepack dialog. - if (mounted) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => StackDialogBase( - child: SlatepackDialog( - terms: terms, - slatepackResult: slatepackResult, - ), - ), - ); - - // Clear form after slatepack dialog is closed. - clearSendForm(); - } - } - } catch (e, s) { - Logging.instance.e( - 'Failed to create ${coin.prettyName} ${terms.noun} on mobile', - error: e, - stackTrace: s, - ); - - if (mounted) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - title: "${terms.title} Creation Failed", - message: e.toString(), - ), - ); - } - } - } - /// Looks up the OpenAlias in the address field. Returns null if the lookup /// failed or was cancelled. Future _resolveOpenAlias(String application) async { @@ -1021,10 +926,13 @@ class _SendViewState extends ConsumerState { FocusScope.of(context).unfocus(); await Future.delayed(const Duration(milliseconds: 100)); final wallet = ref.read(pWallets).getWallet(walletId); + final isSlatepackSend = + wallet is SlatepackInterface && ref.read(pIsSlatepack(walletId)); String? openAlias; String? resolvedAddress; final application = coin.openAliasApplication; if (application != null && + !isSlatepackSend && !coin.validateAddress(_address ?? "") && isOpenAlias(_address ?? "")) { var resolved = _openAlias; @@ -1174,6 +1082,21 @@ class _SendViewState extends ConsumerState { opReturnData: ref.read(pOpReturnData), ), ); + } else if (isSlatepackSend) { + // No address: the confirm screen hands out a slatepack instead. + txDataFuture = wallet.prepareSend( + txData: TxData( + recipients: [ + TxRecipient( + address: "", + amount: amount, + isChange: false, + addressType: AddressType.mimbleWimble, + ), + ], + otherData: slatepackMethodOtherData(SlatepackMethod.slatepack), + ), + ); } else if (wallet is FiroWallet) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case BalanceType.public: @@ -1514,6 +1437,10 @@ class _SendViewState extends ConsumerState { _data = widget.autoFillData; walletId = widget.walletId; clipboard = widget.clipboard; + final wallet = ref.read(pWallets).getWallet(walletId); + if (wallet is SlatepackInterface) { + _slatepackWallet = wallet; + } WidgetsBinding.instance.addPostFrameCallback((_) { ref.refresh(feeSheetSessionCacheProvider); @@ -1662,7 +1589,7 @@ class _SendViewState extends ConsumerState { // ethFee is checked in the ValueListenableBuilder around the preview // button so fee keystrokes don't rebuild this whole view. final previewEnabled = - ref.watch(pPreviewTxButtonEnabled(coin)) && + ref.watch(pPreviewTxButtonEnabled(walletId)) && (ref.watch(pOpReturnData) == null || balType != BalanceType.private); final needsEthFee = isEth && isCustomFee; @@ -1740,6 +1667,7 @@ class _SendViewState extends ConsumerState { } final isSlatepackMode = ref.watch(pIsSlatepack(widget.walletId)); + final showOnChainNote = _slatepackWallet?.supportsOnChainNote ?? false; return Background( child: Scaffold( @@ -1896,11 +1824,12 @@ class _SendViewState extends ConsumerState { ), const SizedBox(height: 16), - if (coin is Mimblewimblecoin || - coin is Epiccash) ...[ + if (_slatepackWallet != null) ...[ SizedBox( height: 40, - child: SlatepackMethodToggle(coin: coin), + child: SlatepackMethodToggle( + walletId: walletId, + ), ), const SizedBox(height: 16), ], @@ -2660,14 +2589,14 @@ class _SendViewState extends ConsumerState { ), ), const SizedBox(height: 12), - if (coin is Epiccash) + if (showOnChainNote) Text( "On chain Note", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), - if (coin is Epiccash) const SizedBox(height: 8), - if (coin is Epiccash) + if (showOnChainNote) const SizedBox(height: 8), + if (showOnChainNote) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -2717,10 +2646,10 @@ class _SendViewState extends ConsumerState { ), ), ), - if (coin is Epiccash || coin is Mimblewimblecoin) + if (_slatepackWallet != null) const SizedBox(height: 12), Text( - (coin is Epiccash || coin is Mimblewimblecoin) + _slatepackWallet != null ? "Local Note (optional)" : "Note (optional)", style: STextStyles.smallMed12(context), @@ -3024,9 +2953,7 @@ class _SendViewState extends ConsumerState { (!needsEthFee || ethFee != null); return TextButton( onPressed: enabled - ? isSlatepackMode - ? _createSlatepack - : _previewTransaction + ? _previewTransaction : null, style: enabled ? Theme.of(context) @@ -3040,9 +2967,7 @@ class _SendViewState extends ConsumerState { context, ), child: Text( - isSlatepackMode - ? "Create slate" - : "Preview", + "Preview", style: STextStyles.button(context), ), ); diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart index faf54888c5..981378f839 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart @@ -42,10 +42,10 @@ import '../../../../wallets/crypto_currency/coins/wownero.dart'; import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; -import '../../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../../../widgets/animated_text.dart'; import '../../../../widgets/background.dart'; import '../../../../widgets/conditional_parent.dart'; @@ -354,12 +354,13 @@ class _WalletNetworkSettingsViewState if (_percent < highestPercent) { _percent = highestPercent.clamp(0.0, 1.0); } - } else if (coin is Epiccash || coin is Mimblewimblecoin) { - final double highestPercent = - (ref.watch(pWallets).getWallet(widget.walletId) as MimblewimbleWallet) - .highestPercent; - if (_percent < highestPercent) { - _percent = highestPercent.clamp(0.0, 1.0); + } else { + final wallet = ref.watch(pWallets).getWallet(widget.walletId); + if (wallet is SlatepackInterface) { + final double highestPercent = wallet.restoreProgress; + if (_percent < highestPercent) { + _percent = highestPercent.clamp(0.0, 1.0); + } } } diff --git a/lib/pages/slatepack/slatepack_dialog.dart b/lib/pages/slatepack/slatepack_dialog.dart index ea3acec64f..5a8dc27583 100644 --- a/lib/pages/slatepack/slatepack_dialog.dart +++ b/lib/pages/slatepack/slatepack_dialog.dart @@ -1,218 +1,279 @@ +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../models/slatepack_models.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/clipboard_interface.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/slatepack_terms.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'slatepack_popup.dart'; +import 'slatepack_qr.dart'; -import '../../../models/slatepack_models.dart'; -import '../../../notifications/show_flush_bar.dart'; -import '../../../themes/stack_colors.dart'; -import '../../../utilities/assets.dart'; -import '../../../utilities/clipboard_interface.dart'; -import '../../../utilities/slatepack_terms.dart'; -import '../../../utilities/text_styles.dart'; -import '../../../utilities/util.dart'; -import '../../../widgets/conditional_parent.dart'; -import '../../../widgets/desktop/desktop_dialog_close_button.dart'; -import '../../../widgets/desktop/primary_button.dart'; -import '../../../widgets/qr.dart'; -import '../../../widgets/rounded_container.dart'; -import '../../../widgets/rounded_white_container.dart'; +/// Shows a slatepack the user has to pass to the other party. +Future showSlatepackDialog({ + required BuildContext context, + required SlatepackTerms terms, + required SlatepackResult result, +}) { + return showSlatepackPopup( + context: context, + barrierDismissible: false, + child: SlatepackDialog.fromResult(terms: terms, result: result), + ); +} -class SlatepackDialog extends ConsumerStatefulWidget { +/// One dialog for every slatepack the wallet hands out (S1, S2, I1, I2): +/// copy, share, save to file, and a QR code when the text is short enough. +class SlatepackDialog extends StatelessWidget { const SlatepackDialog({ super.key, required this.terms, - required this.slatepackResult, + required this.slatepack, + required this.state, + this.slateId, + this.isEncrypted = false, this.clipboard = const ClipboardWrapper(), }); + factory SlatepackDialog.fromResult({ + Key? key, + required SlatepackTerms terms, + required SlatepackResult result, + ClipboardInterface clipboard = const ClipboardWrapper(), + }) => SlatepackDialog( + key: key, + terms: terms, + slatepack: result.slatepack!, + state: result.state, + slateId: result.slateId, + isEncrypted: result.wasEncrypted ?? false, + clipboard: clipboard, + ); + final SlatepackTerms terms; - final SlatepackResult slatepackResult; + final String slatepack; + final SlateState state; + final String? slateId; + final bool isEncrypted; final ClipboardInterface clipboard; - @override - ConsumerState createState() => _SlatepackDialogState(); -} + String get title => switch (state) { + SlateState.s1 => "Send ${terms.title}", + SlateState.s2 => "Response ${terms.title}", + SlateState.i1 => "Invoice ${terms.title}", + SlateState.i2 => "Payment ${terms.title}", + _ => terms.title, + }; -class _SlatepackDialogState extends ConsumerState { - void _copySlatepack() { - widget.clipboard.setData( - ClipboardData(text: widget.slatepackResult.slatepack!), - ); - showFloatingFlushBar( - type: FlushBarType.info, - message: "${widget.terms.title} copied to clipboard", - iconAsset: Assets.svg.copy, - context: context, - ); + String get instructions => switch (state) { + SlateState.s1 => + "1. Share this ${terms.noun} with the recipient\n" + "2. Wait for them to return the response ${terms.noun}\n" + "3. Import their response on the Slatepack screen to finalize the " + "transaction\n\n" + "The funds stay locked until the transaction is finalized or " + "cancelled.", + SlateState.s2 => + "Return this ${terms.noun} to the sender. They finalize and broadcast " + "the transaction.", + SlateState.i1 => + "1. Share this invoice with the payer\n" + "2. Wait for them to return the payment ${terms.noun}\n" + "3. Import it on the Slatepack screen to finalize the payment", + SlateState.i2 => + "Return this ${terms.noun} to the payee. They finalize and broadcast " + "the payment. The funds stay locked until then.", + _ => "Pass this ${terms.noun} to the other party.", + }; + + /// `..slatepack`, e.g. `0436...a1.S1.slatepack`. + String get fileName { + final id = (slateId == null || slateId!.isEmpty) ? "slatepack" : slateId!; + final suffix = state == SlateState.unknown ? "" : ".${state.code}"; + return "$id$suffix.slatepack"; } - void _shareSlatepack() { - // TODO: Implement file sharing for desktop platforms. - showFloatingFlushBar( - type: FlushBarType.info, - message: "Share functionality coming soon", - context: context, - ); + Future _copy(BuildContext context) async { + await clipboard.setData(ClipboardData(text: slatepack)); + if (context.mounted) { + await showFloatingFlushBar( + type: FlushBarType.info, + message: "${terms.title} copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ); + } + } + + Future _share() async { + try { + await SharePlus.instance.share( + ShareParams(text: slatepack, subject: title), + ); + } catch (e, s) { + Logging.instance.e( + "Sharing ${terms.noun} failed", + error: e, + stackTrace: s, + ); + } } + Future _save(BuildContext context) async { + try { + final path = await FilePicker.platform.saveFile( + dialogTitle: "Save ${terms.noun}", + fileName: fileName, + bytes: Uint8List.fromList(utf8.encode(slatepack)), + lockParentWindow: true, + ); + if (path != null && context.mounted) { + await showFloatingFlushBar( + type: FlushBarType.success, + message: "Saved to $path", + context: context, + ); + } + } catch (e, s) { + Logging.instance.e( + "Saving ${terms.noun} failed", + error: e, + stackTrace: s, + ); + if (context.mounted) { + await showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not save the ${terms.noun}: $e", + context: context, + ); + } + } + } + + Widget _icon(BuildContext context, String asset) => SvgPicture.asset( + asset, + width: 14, + height: 14, + colorFilter: ColorFilter.mode( + Theme.of(context).extension()!.buttonTextSecondary, + BlendMode.srcIn, + ), + ); + @override Widget build(BuildContext context) { - return ConditionalParent( - condition: Util.isDesktop, - builder: (child) => Column( + final colors = Theme.of(context).extension()!; + final isDesktop = Util.isDesktop; + + return SlatepackPopupLayout( + title: title, + child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Header with title and close button. + RoundedContainer( + color: colors.textFieldDefaultBG, + child: Text(instructions, style: STextStyles.w400_14(context)), + ), + const SizedBox(height: 12), Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Padding( - padding: const EdgeInsets.only(left: 32), + SvgPicture.asset( + isEncrypted ? Assets.svg.lock : Assets.svg.lockOpen, + width: 14, + height: 14, + colorFilter: ColorFilter.mode( + colors.infoItemIcons, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 8), + Flexible( child: Text( - "Send ${widget.terms.title}", - style: STextStyles.pageTitleH2(context), + isEncrypted + ? "Encrypted to the other party's address" + : "Not encrypted", + style: STextStyles.label(context), ), ), - const DesktopDialogCloseButton(), ], ), - Padding(padding: const EdgeInsets.all(32), child: child), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Instructions. - RoundedContainer( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Next Steps:", - style: STextStyles.label( - context, - ).copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Text( - "1. Share this ${widget.terms.noun} with the recipient\n" - "2. Wait for them to return the response " - "${widget.terms.noun}\n" - "3. Import their response to finalize the transaction", - style: STextStyles.w400_14(context), - ), - ], - ), - ), - - // Encryption status. - // we don't encrypt so ignore for now - // if (widget.slatepackResult.wasEncrypted == true) - // Container( - // padding: const EdgeInsets.symmetric( - // horizontal: 12, - // vertical: 8, - // ), - // decoration: BoxDecoration( - // color: Theme.of( - // context, - // ).extension()!.infoItemIcons.withOpacity(0.1), - // borderRadius: BorderRadius.circular(8), - // ), - // child: Row( - // mainAxisSize: MainAxisSize.min, - // children: [ - // Icon( - // Icons.lock, - // size: 16, - // color: - // Theme.of( - // context, - // ).extension()!.infoItemIcons, - // ), - // const SizedBox(width: 8), - // Text( - // "Encrypted for recipient", - // style: STextStyles.label(context).copyWith( - // color: - // Theme.of( - // context, - // ).extension()!.infoItemIcons, - // ), - // ), - // ], - // ), - // ), const SizedBox(height: 12), - - // QR Code view. Center( - child: QR( - data: widget.slatepackResult.slatepack!, - size: 220, - // errorCorrectionLevel: QrErrorCorrectLevel.M, - ), + child: SlatepackQr(data: slatepack, noun: terms.noun), ), - const SizedBox(height: 12), - - // Slatepack text view. RoundedWhiteContainer( + borderColor: isDesktop ? colors.backgroundAppBar : null, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Text( - widget.terms.title, - style: STextStyles.itemSubtitle(context), - ), - const Spacer(), - GestureDetector( - onTap: _copySlatepack, - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.copy, - width: 10, - height: 10, - color: Theme.of( - context, - ).extension()!.infoItemIcons, - ), - const SizedBox(width: 4), - Text("Copy", style: STextStyles.link2(context)), - ], - ), - ), - ], - ), + Text(terms.title, style: STextStyles.itemSubtitle(context)), const SizedBox(height: 8), - Container( - constraints: const BoxConstraints( - maxHeight: 200, - minHeight: 100, - ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), child: SingleChildScrollView( child: SelectableText( - widget.slatepackResult.slatepack!, - style: STextStyles.w400_14( - context, - ).copyWith(fontFamily: 'monospace'), + slatepack, + style: STextStyles.w400_14(context) + .copyWith(fontFamily: 'monospace'), ), ), ), ], ), ), - - if (!Util.isDesktop) - PrimaryButton(label: "Done", onPressed: Navigator.of(context).pop), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Copy", + buttonHeight: isDesktop ? ButtonHeight.l : null, + icon: _icon(context, Assets.svg.copy), + onPressed: () => _copy(context), + ), + ), + if (!isDesktop) ...[ + const SizedBox(width: 12), + Expanded( + child: SecondaryButton( + label: "Share", + icon: _icon(context, Assets.svg.share), + onPressed: _share, + ), + ), + ], + const SizedBox(width: 12), + Expanded( + child: SecondaryButton( + label: "Save", + buttonHeight: isDesktop ? ButtonHeight.l : null, + icon: _icon(context, Assets.svg.downloadFolder), + onPressed: () => _save(context), + ), + ), + ], + ), + const SizedBox(height: 16), + PrimaryButton( + label: "Done", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: Navigator.of(context).pop, + ), ], ), ); diff --git a/lib/pages/slatepack/slatepack_import_flow.dart b/lib/pages/slatepack/slatepack_import_flow.dart new file mode 100644 index 0000000000..b6162bf558 --- /dev/null +++ b/lib/pages/slatepack/slatepack_import_flow.dart @@ -0,0 +1,300 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../models/slatepack_models.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../utilities/show_loading.dart'; +import '../../utilities/util.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; +import '../../widgets/stack_dialog.dart'; +import 'slatepack_dialog.dart'; +import 'slatepack_popup.dart'; +import 'slatepack_summary_dialog.dart'; + +/// Where an imported slatepack goes. +enum SlatepackRoute { + /// S1: answer it with an S2. + receive, + + /// S2: finalize our send. + finalizeSend, + + /// I1: pay someone's invoice. + payInvoice, + + /// I2: finalize our invoice. + finalizeInvoice, + + /// S3 or I3: nothing left to do. + alreadyFinalized, + + /// I1 or I2 for a wallet without invoices. + invoicesUnsupported, + + /// State could not be worked out. + unsupported, +} + +/// Takes any slatepack the user pastes, scans or opens, works out what it +/// is and sends it to the matching screen. +abstract final class SlatepackImportFlow { + static SlatepackRoute routeFor( + SlateState state, { + required bool supportsInvoices, + }) => switch (state) { + SlateState.s1 => SlatepackRoute.receive, + SlateState.s2 => SlatepackRoute.finalizeSend, + SlateState.i1 => + supportsInvoices + ? SlatepackRoute.payInvoice + : SlatepackRoute.invoicesUnsupported, + SlateState.i2 => + supportsInvoices + ? SlatepackRoute.finalizeInvoice + : SlatepackRoute.invoicesUnsupported, + SlateState.s3 || SlateState.i3 => SlatepackRoute.alreadyFinalized, + SlateState.unknown => SlatepackRoute.unsupported, + }; + + /// Returns true once the slatepack has been acted on, so the caller can + /// clear its input. + static Future run({ + required BuildContext context, + required SlatepackInterface wallet, + required String text, + }) async { + final terms = wallet.slatepackTerms; + + final inspection = await _withLoading( + context, + "Reading ${terms.noun}...", + () => wallet.inspectSlatepack(text), + errorTitle: "Could not read ${terms.noun}", + ); + if (inspection == null || !context.mounted) return false; + + final route = routeFor( + inspection.state, + supportsInvoices: wallet.supportsInvoices, + ); + + switch (route) { + case SlatepackRoute.receive: + return _receive(context, wallet, text, inspection); + + case SlatepackRoute.finalizeSend: + return _finalize(context, wallet, text, inspection); + + case SlatepackRoute.payInvoice: + // TODO(S5): PayInvoiceConfirmView. + await _message( + context, + "Invoice", + "Paying invoices is not available in this version yet.", + ); + return false; + + case SlatepackRoute.finalizeInvoice: + // TODO(S5): finalize with the summary from the issued invoice. + await _message( + context, + "Invoice payment", + "Finalizing invoices is not available in this version yet.", + ); + return false; + + case SlatepackRoute.invoicesUnsupported: + await _message( + context, + "Invoices not supported", + "This ${terms.noun} is part of an invoice. " + "${wallet.cryptoCurrency.prettyName} wallets do not support " + "invoices.", + ); + return false; + + case SlatepackRoute.alreadyFinalized: + await _message( + context, + "Already finalized", + "This ${terms.noun} is already finalized. There is nothing left " + "to do with it.", + ); + return false; + + case SlatepackRoute.unsupported: + await _message( + context, + "Unsupported ${terms.noun}", + "Could not tell which step of a transaction this ${terms.noun} " + "belongs to.", + ); + return false; + } + } + + static Future _receive( + BuildContext context, + SlatepackInterface wallet, + String text, + SlateInspection inspection, + ) async { + final terms = wallet.slatepackTerms; + + final confirmed = await showSlatepackPopup( + context: context, + child: SlatepackSummaryDialog( + title: "Receive ${terms.noun}", + coin: wallet.cryptoCurrency, + actionLabel: "Receive", + amount: inspection.amount, + details: [ + if (inspection.senderAddress != null) + ("From", inspection.senderAddress!), + ("Encrypted", inspection.isEncrypted ? "Yes" : "No"), + if (inspection.ttlCutoffHeight != null) + ("Expires at block", inspection.ttlCutoffHeight.toString()), + if (inspection.slateId.isNotEmpty) ("Slate ID", inspection.slateId), + ], + ), + ); + if (confirmed != true || !context.mounted) return false; + + final result = await _withLoading( + context, + "Processing ${terms.noun}...", + () async { + final r = await wallet.receiveSlatepack(text); + if (!r.success || r.slatepack == null) { + throw Exception(r.error ?? "Failed to receive ${terms.noun}"); + } + return r; + }, + errorTitle: "${terms.title} receive error", + ); + if (result == null || !context.mounted) return false; + + unawaited(wallet.refresh()); + await showSlatepackDialog(context: context, terms: terms, result: result); + return true; + } + + static Future _finalize( + BuildContext context, + SlatepackInterface wallet, + String text, + SlateInspection inspection, + ) async { + final terms = wallet.slatepackTerms; + + // An S2 may carry amount 0, so the summary comes from our own record of + // the send when there is one. + final local = await wallet.localTxForSlate(inspection.slateId); + if (!context.mounted) return false; + + final confirmed = await showSlatepackPopup( + context: context, + child: SlatepackSummaryDialog( + title: "Finalize ${terms.noun}", + coin: wallet.cryptoCurrency, + actionLabel: "Finalize", + amount: local?.amount ?? inspection.amount, + fee: local?.fee ?? inspection.fee, + details: [ + if (inspection.slateId.isNotEmpty) ("Slate ID", inspection.slateId), + ("Encrypted", inspection.isEncrypted ? "Yes" : "No"), + ], + note: local == null + ? "No matching send was found in this wallet's history. " + "Finalizing only works for a send this wallet started." + : null, + ), + ); + if (confirmed != true || !context.mounted) return false; + + final result = await _withLoading( + context, + "Finalizing ${terms.noun}...", + () async { + final r = await wallet.finalizeSlatepack(text); + if (!r.success) { + throw Exception(r.error ?? "Finalize failed"); + } + return r; + }, + errorTitle: "${terms.title} finalize error", + ); + if (result == null || !context.mounted) return false; + + unawaited(wallet.refresh()); + + if (result.posted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Transaction finalized and broadcast", + context: context, + ), + ); + } else { + await _message( + context, + "Finalized, not broadcast", + "The transaction is finalized but the node did not accept it yet. " + "Try again from the transaction details.", + ); + } + return true; + } + + /// Runs [action] under a loading overlay. Shows the error and returns null + /// when it throws. + static Future _withLoading( + BuildContext context, + String message, + Future Function() action, { + required String errorTitle, + }) async { + Exception? ex; + final result = await showLoading( + // Let the overlay open before the work starts, so an early error + // does not pop the wrong route. + whileFuture: Future.delayed(const Duration(milliseconds: 300)) + .then((_) => action()), + context: context, + message: message, + rootNavigator: true, + onException: (e) => ex = e, + ); + + if (ex != null || result == null) { + if (context.mounted) { + await _message( + context, + errorTitle, + ex?.toString().replaceFirst("Exception: ", "") ?? + "Unexpected result without an error", + ); + } + return null; + } + return result; + } + + static Future _message( + BuildContext context, + String title, + String message, + ) { + return showDialog( + context: context, + builder: (_) => StackOkDialog( + title: title, + message: message, + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 400 : null, + ), + ); + } +} diff --git a/lib/pages/slatepack/slatepack_input_field.dart b/lib/pages/slatepack/slatepack_input_field.dart new file mode 100644 index 0000000000..4afbe7c80e --- /dev/null +++ b/lib/pages/slatepack/slatepack_input_field.dart @@ -0,0 +1,264 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/barcode_scanner_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/barcode_scanner_interface.dart'; +import '../../utilities/clipboard_interface.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/desktop/qr_code_scanner_dialog.dart'; +import '../../widgets/icon_widgets/clipboard_icon.dart'; +import '../../widgets/icon_widgets/qrcode_icon.dart'; +import '../../widgets/icon_widgets/x_icon.dart'; +import '../../widgets/stack_text_field.dart'; +import '../../widgets/textfield_icon_button.dart'; + +/// Largest file accepted as a slatepack. Real ones are a few KB. +const kSlatepackFileMaxBytes = 256 * 1024; + +/// Text field for a slatepack with paste, scan and open-file buttons. +class SlatepackInputField extends ConsumerStatefulWidget { + const SlatepackInputField({ + super.key, + required this.controller, + required this.hint, + this.onChanged, + this.focusNode, + this.clipboard = const ClipboardWrapper(), + }); + + final TextEditingController controller; + final String hint; + final void Function(String)? onChanged; + final FocusNode? focusNode; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => + _SlatepackInputFieldState(); +} + +class _SlatepackInputFieldState extends ConsumerState { + late final FocusNode _focusNode; + + bool get _isEmpty => widget.controller.text.isEmpty; + + void _set(String text) { + widget.controller.text = text; + widget.onChanged?.call(text); + setState(() {}); + } + + Future _paste() async { + final ClipboardData? data = await widget.clipboard.getData( + Clipboard.kTextPlain, + ); + final text = data?.text?.trim(); + if (text != null && text.isNotEmpty) { + _set(text); + } + } + + Future _scan() async { + try { + if (Platform.isAndroid || Platform.isIOS) { + if (_focusNode.hasFocus) { + _focusNode.unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + if (!mounted) return; + final result = await ref.read(pBarcodeScanner).scan(context: context); + final text = result.rawContent?.trim(); + if (text != null && text.isNotEmpty) { + _set(text); + } + } else { + final text = await showDialog( + context: context, + builder: (context) => const QrCodeScannerDialog(), + ); + if (text != null && text.trim().isNotEmpty) { + _set(text.trim()); + } + } + } on PlatformException catch (e, s) { + if (mounted) { + try { + await checkCamPermDeniedMobileAndOpenAppSettings( + context, + logging: Logging.instance, + ); + } catch (e, s) { + Logging.instance.e( + "Failed to check cam permissions", + error: e, + stackTrace: s, + ); + } + } else { + Logging.instance.w( + "Camera permission error while scanning a slatepack", + error: e, + stackTrace: s, + ); + } + } + } + + Future _openFile() async { + try { + final result = await FilePicker.platform.pickFiles( + dialogTitle: "Open slatepack file", + lockParentWindow: true, + ); + final picked = result?.files.singleOrNull; + if (picked == null) return; + + if (picked.size > kSlatepackFileMaxBytes) { + throw Exception( + "File is too large (${picked.size} bytes). " + "A slatepack file is at most $kSlatepackFileMaxBytes bytes.", + ); + } + + final List bytes = + picked.bytes ?? await File(picked.path!).readAsBytes(); + if (bytes.length > kSlatepackFileMaxBytes) { + throw Exception("File is too large to be a slatepack."); + } + + final text = utf8.decode(bytes).trim(); + if (text.isEmpty) { + throw Exception("The file is empty."); + } + _set(text); + } on FormatException catch (_) { + _showError("That file is not a text file."); + } catch (e, s) { + Logging.instance.w( + "Opening slatepack file failed", + error: e, + stackTrace: s, + ); + _showError(e.toString().replaceFirst("Exception: ", "")); + } + } + + void _showError(String message) { + if (!mounted) return; + showFloatingFlushBar( + type: FlushBarType.warning, + message: message, + context: context, + ); + } + + @override + void initState() { + super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + } + + @override + void dispose() { + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + + return ClipRRect( + borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), + child: TextField( + key: const Key("slatepackInputField"), + minLines: 1, + maxLines: 5, + controller: widget.controller, + focusNode: _focusNode, + autocorrect: false, + enableSuggestions: false, + onChanged: (value) { + widget.onChanged?.call(value); + setState(() {}); + }, + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context) + .copyWith(color: colors.textFieldActiveText, height: 1.8) + : STextStyles.field(context), + decoration: + standardInputDecoration( + widget.hint, + _focusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + suffixIcon: Padding( + padding: _isEmpty + ? const EdgeInsets.only(right: 8) + : EdgeInsets.zero, + child: UnconstrainedBox( + child: Row( + children: _isEmpty + ? [ + TextFieldIconButton( + key: const Key("slatepackPasteButton"), + semanticsLabel: "Paste", + onTap: _paste, + child: const ClipboardIcon(), + ), + TextFieldIconButton( + key: const Key("slatepackScanButton"), + semanticsLabel: "Scan QR code", + onTap: _scan, + child: const QrCodeIcon(), + ), + TextFieldIconButton( + key: const Key("slatepackFileButton"), + semanticsLabel: "Open file", + onTap: _openFile, + child: SvgPicture.asset( + Assets.svg.file, + width: 17, + height: 17, + colorFilter: ColorFilter.mode( + colors.textDark3, + BlendMode.srcIn, + ), + ), + ), + ] + : [ + TextFieldIconButton( + key: const Key("slatepackClearButton"), + semanticsLabel: "Clear", + onTap: () => _set(""), + child: const XIcon(), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/slatepack/slatepack_popup.dart b/lib/pages/slatepack/slatepack_popup.dart new file mode 100644 index 0000000000..6c0413b66a --- /dev/null +++ b/lib/pages/slatepack/slatepack_popup.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/stack_dialog.dart'; + +/// Shows a slatepack screen as a dialog: a [DesktopDialog] on desktop, a +/// bottom sheet style [StackDialogBase] on mobile. +Future showSlatepackPopup({ + required BuildContext context, + required Widget child, + bool barrierDismissible = true, +}) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (_) => Util.isDesktop + ? DesktopDialog(maxWidth: 700, maxHeight: null, child: child) + : StackDialogBase(child: child), + ); +} + +/// Title and body for a slatepack popup. On desktop the title row carries a +/// close button and the body scrolls. +class SlatepackPopupLayout extends StatelessWidget { + const SlatepackPopupLayout({ + super.key, + required this.title, + required this.child, + }); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + if (Util.isDesktop) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 32), + child: Text(title, style: STextStyles.desktopH3(context)), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: child, + ), + ), + ), + ], + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(title, style: STextStyles.pageTitleH2(context)), + const SizedBox(height: 16), + child, + ], + ); + } +} diff --git a/lib/pages/slatepack/slatepack_qr.dart b/lib/pages/slatepack/slatepack_qr.dart new file mode 100644 index 0000000000..bb438c4bab --- /dev/null +++ b/lib/pages/slatepack/slatepack_qr.dart @@ -0,0 +1,77 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../widgets/qr.dart'; +import '../../widgets/rounded_container.dart'; + +/// Longest slatepack shown as a QR code. Measured armored Grin slatepacks +/// (characters): S1 360 plain, 806-955 encrypted; I1 348 plain, 719-855 +/// encrypted; S2 1837-2185; I2 1881-2068; S3 2683-2871. S1 and I1 fit one +/// QR at any error correction level, S2 and I2 only at L or M, so the cap +/// sits above S1/I1 and below S2/I2, which fall back to copy, share or save. +const kSlatepackQrMaxLength = 1000; + +bool slatepackFitsQr(String text) => text.length <= kSlatepackQrMaxLength; + +/// QR code for a slatepack, or a note when it is too long for one. Tapping +/// the code shows it full size. +class SlatepackQr extends StatelessWidget { + const SlatepackQr({ + super.key, + required this.data, + required this.noun, + this.size = 220, + }); + + final String data; + + /// "slate" or "slatepack", for the too-long note. + final String noun; + final double size; + + Future _enlarge(BuildContext context) { + final screen = MediaQuery.of(context).size; + final side = math.min(math.min(screen.width, screen.height) - 64, 640.0); + return showDialog( + context: context, + builder: (dialogContext) => GestureDetector( + onTap: () => Navigator.of(dialogContext).pop(), + child: Center( + child: QR(data: data, size: side), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!slatepackFitsQr(data)) { + return RoundedContainer( + key: const Key("slatepackQrTooLong"), + color: Theme.of(context).extension()!.textFieldDefaultBG, + child: Text( + "This $noun is too long for a QR code. " + "Copy, share or save it instead.", + style: STextStyles.label(context), + textAlign: TextAlign.center, + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + key: const Key("slatepackQrCode"), + onTap: () => _enlarge(context), + child: QR(data: data, size: size), + ), + const SizedBox(height: 4), + Text("Tap to enlarge", style: STextStyles.label(context)), + ], + ); + } +} diff --git a/lib/pages/slatepack/slatepack_summary_dialog.dart b/lib/pages/slatepack/slatepack_summary_dialog.dart index 0b93cc7bbb..a13456bd5a 100644 --- a/lib/pages/slatepack/slatepack_summary_dialog.dart +++ b/lib/pages/slatepack/slatepack_summary_dialog.dart @@ -1,358 +1,108 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../models/slatepack_models.dart'; -import '../../../providers/global/wallets_provider.dart'; -import '../../../themes/stack_colors.dart'; -import '../../../utilities/amount/amount.dart'; -import '../../../utilities/amount/amount_formatter.dart'; -import '../../../utilities/clipboard_interface.dart'; -import '../../../utilities/show_loading.dart'; -import '../../../utilities/slatepack_terms.dart'; -import '../../../utilities/text_styles.dart'; -import '../../../utilities/util.dart'; -import '../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; -import '../../../widgets/conditional_parent.dart'; -import '../../../widgets/custom_buttons/simple_copy_button.dart'; -import '../../../widgets/desktop/desktop_dialog_close_button.dart'; -import '../../../widgets/desktop/primary_button.dart'; -import '../../../widgets/desktop/secondary_button.dart'; -import '../../../widgets/detail_item.dart'; -import '../../../widgets/dialogs/s_dialog.dart'; -import '../../../widgets/rounded_white_container.dart'; -import '../../../widgets/stack_dialog.dart'; - -class SlatepackImportDialog extends ConsumerStatefulWidget { - const SlatepackImportDialog({ +import '../../themes/stack_colors.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/detail_item.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'slatepack_popup.dart'; + +/// What an imported slatepack will do, with a button to go ahead. Pops true +/// when the user confirms. +class SlatepackSummaryDialog extends ConsumerWidget { + const SlatepackSummaryDialog({ super.key, - required this.walletId, - required this.rawSlatepack, - required this.decoded, - required this.slatepackType, - this.clipboard = const ClipboardWrapper(), + required this.title, + required this.coin, + required this.actionLabel, + this.amount, + this.fee, + this.details = const [], + this.note, }); - final String walletId; - final String rawSlatepack; - final SlatepackDecodeResult decoded; - final String slatepackType; - final ClipboardInterface clipboard; - - @override - ConsumerState createState() => - _SlatepackImportDialogState(); -} - -class _SlatepackImportDialogState extends ConsumerState { - late final SlatepackTerms _terms; - - Future<({String responseSlatepack, bool wasEncrypted})> - _processSlatepack() async { - // add delay for showloading exception catching hack fix - await Future.delayed(const Duration(seconds: 1)); - - final wallet = - ref.read(pWallets).getWallet(widget.walletId) as MimblewimbleWallet; - - // Determine action based on slatepack type. - if (widget.slatepackType.contains("S1")) { - // This is an initial slatepack - receive it and create response. - final result = await wallet.receiveSlatepack(widget.rawSlatepack); - - if (result.success && result.responseSlatepack != null) { - return ( - responseSlatepack: result.responseSlatepack!, - wasEncrypted: result.wasEncrypted ?? false, - ); - } else { - throw Exception(result.error ?? 'Failed to process slatepack'); - } - } else { - throw Exception('Unsupported slatepack type: ${widget.slatepackType}'); - } - } - - Future _processPressed() async { - Exception? ex; - final result = await showLoading( - whileFuture: _processSlatepack(), - context: context, - message: "Processing ${_terms.noun}...", - onException: (e) => ex = e, - ); - - if (result == null || ex != null) { - if (mounted) { - await showDialog( - context: context, - useRootNavigator: true, - builder: (context) => StackOkDialog( - desktopPopRootNavigator: true, - maxWidth: Util.isDesktop ? 400 : null, - title: "${_terms.title} receive error", - message: ex?.toString() ?? "Unexpected result without exception", - ), - ); - } - return; - } - - if (mounted) { - Navigator.of(context).pop(result); - } - } - - late final Amount? _amount; - - // late final Amount? _fee; + final String title; + final CryptoCurrency coin; + final String actionLabel; + final Amount? amount; + final Amount? fee; - @override - void initState() { - _terms = SlatepackTerms.forCoin(ref.read(pWalletCoin(widget.walletId))); - - final map = jsonDecode(widget.decoded.slateJson!) as Map; - - final rawAmount = BigInt.tryParse(map["amount"].toString()); - _amount = rawAmount == null - ? null - : Amount( - rawValue: rawAmount, - fractionDigits: ref - .read(pWalletCoin(widget.walletId)) - .fractionDigits, - ); + /// Extra label and value rows shown under the amount and fee. + final List<(String, String)> details; - // final rawFee = BigInt.tryParse(map["fee"].toString()); - // _fee = - // rawFee == null - // ? null - // : Amount( - // rawValue: rawFee, - // fractionDigits: - // ref.read(pWalletCoin(widget.walletId)).fractionDigits, - // ); - - super.initState(); - } + /// Shown above the buttons, e.g. a warning. + final String? note; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final isDesktop = Util.isDesktop; + final colors = Theme.of(context).extension()!; + final formatter = ref.watch(pAmountFormatter(coin)); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (isDesktop) - // Header with title and close button. - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Import ${_terms.title}", - style: STextStyles.pageTitleH2(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: isDesktop ? 32 : 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConditionalParent( - condition: isDesktop, - builder: (child) => RoundedWhiteContainer( - borderColor: isDesktop - ? Theme.of( - context, - ).extension()!.backgroundAppBar - : null, - padding: const EdgeInsets.all(0), - child: child, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isDesktop) - Padding( - padding: const EdgeInsets.only(top: 24, bottom: 24), - child: Text( - "Import ${_terms.noun}", - style: STextStyles.pageTitleH2(context), - ), - ), + final rows = <(String, String)>[ + if (amount != null) ("Amount", formatter.format(amount!)), + if (fee != null) ("Fee", formatter.format(fee!)), + ...details, + ]; - // DetailItem(title: "Type", detail: widget.slatepackType), - // const DetailDivider(), - // DetailItem( - // title: "Encrypted", - // detail: (widget.decoded.wasEncrypted ?? false).toString(), - // ), - // if (widget.decoded.senderAddress != null) - // const DetailDivider(), - // if (widget.decoded.senderAddress != null) - // DetailItem( - // title: "From", - // detail: widget.decoded.senderAddress!, - // ), - // if (widget.decoded.recipientAddress != null) - // const DetailDivider(), - // if (widget.decoded.recipientAddress != null) - // DetailItem( - // title: "To", - // detail: widget.decoded.recipientAddress!, - // ), - // if (_amount != null) const DetailDivider(), - if (_amount != null) - DetailItem( - title: "Amount", - detail: ref - .watch( - pAmountFormatter( - ref.watch(pWalletCoin(widget.walletId)), - ), - ) - .format(_amount), - ), - // if (_fee != null) const DetailDivider(), - // if (_fee != null) - // DetailItem( - // title: "Fee", - // detail: ref - // .watch( - // pAmountFormatter( - // ref.watch(pWalletCoin(widget.walletId)), - // ), - // ) - // .format(_fee), - // ), - ], - ), - ), - const SizedBox(height: 24), - ConditionalParent( - condition: isDesktop, - builder: (child) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [child], - ), - child: PrimaryButton( - width: isDesktop ? 220 : null, - - buttonHeight: isDesktop ? ButtonHeight.l : null, - label: "Process", - onPressed: _processPressed, - ), - ), - if (!isDesktop) const SizedBox(height: 12), - if (!isDesktop) - SecondaryButton( - label: "Cancel", - onPressed: Navigator.of(context).pop, - ), - ], - ), - ), - isDesktop ? const SizedBox(height: 32) : const SizedBox(height: 24), - ], - ); - } -} - -class SlatepackResponseDialog extends StatelessWidget { - const SlatepackResponseDialog({ - super.key, - required this.terms, - required this.responseSlatepack, - required this.wasEncrypted, - }); - - final SlatepackTerms terms; - final String responseSlatepack; - final bool wasEncrypted; - - @override - Widget build(BuildContext context) { - return SDialog( + return SlatepackPopupLayout( + title: title, child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Header with title and close button. - if (Util.isDesktop) - Padding( - padding: const EdgeInsets.only(left: 32), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Response ${terms.title}", - style: STextStyles.pageTitleH2(context), - ), - const DesktopDialogCloseButton(), - ], - ), - ), - Padding( - padding: Util.isDesktop - ? const EdgeInsets.only(left: 32, right: 32, bottom: 32) - : const EdgeInsets.only(left: 24, right: 24, bottom: 24), + RoundedWhiteContainer( + padding: EdgeInsets.zero, + borderColor: isDesktop ? colors.backgroundAppBar : null, child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!Util.isDesktop) const SizedBox(height: 24), - Text( - "Return this ${terms.noun} to the sender to complete the " - "transaction.", - style: STextStyles.pageTitleH2(context), - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Response ${terms.noun}", - style: STextStyles.itemSubtitle(context), - ), - SimpleCopyButton(data: responseSlatepack), - ], - ), - const SizedBox(height: 8), - ConditionalParent( - condition: !Util.isDesktop, - builder: (child) => SizedBox( - height: 220, - child: SingleChildScrollView(child: child), - ), - child: SelectableText( - responseSlatepack, - style: STextStyles.w500_14(context), - ), - ), - const SizedBox(height: 24), - ConditionalParent( - condition: Util.isDesktop, - builder: (child) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [child], - ), - child: PrimaryButton( - label: "Done", - width: Util.isDesktop ? 220 : null, - buttonHeight: Util.isDesktop ? ButtonHeight.l : null, - onPressed: Navigator.of(context).pop, - ), - ), + for (int i = 0; i < rows.length; i++) ...[ + if (i > 0) const DetailDivider(), + DetailItem(title: rows[i].$1, detail: rows[i].$2), + ], ], ), ), + if (note != null) ...[ + const SizedBox(height: 16), + RoundedContainer( + color: colors.warningBackground, + child: Text( + note!, + style: STextStyles.label(context) + .copyWith(color: colors.warningForeground), + ), + ), + ], + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(false), + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: actionLabel, + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ], + ), ], ), ); diff --git a/lib/pages/slatepack/slatepack_view.dart b/lib/pages/slatepack/slatepack_view.dart index 751bf7f53b..7fcac3cb59 100644 --- a/lib/pages/slatepack/slatepack_view.dart +++ b/lib/pages/slatepack/slatepack_view.dart @@ -8,216 +8,100 @@ * */ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../notifications/show_flush_bar.dart'; -import '../../providers/global/barcode_scanner_provider.dart'; import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; -import '../../utilities/barcode_scanner_interface.dart'; import '../../utilities/clipboard_interface.dart'; import '../../utilities/constants.dart'; -import '../../utilities/logger.dart'; -import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../utilities/slatepack_terms.dart'; -import '../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/primary_button.dart'; -import '../../widgets/icon_widgets/clipboard_icon.dart'; -import '../../widgets/icon_widgets/qrcode_icon.dart'; -import '../../widgets/icon_widgets/x_icon.dart'; -import '../../widgets/stack_dialog.dart'; -import '../../widgets/stack_text_field.dart'; -import '../../widgets/textfield_icon_button.dart'; +import 'slatepack_import_flow.dart'; +import 'slatepack_input_field.dart'; -class FinalizeView extends ConsumerStatefulWidget { - const FinalizeView({ +/// The "Slatepack" screen: takes any slatepack and hands it to +/// [SlatepackImportFlow], which works out whether to receive or finalize. +class SlatepackView extends ConsumerStatefulWidget { + const SlatepackView({ super.key, required this.walletId, this.clipboard = const ClipboardWrapper(), }); - static const String routeName = "/finalizeView"; + static const String routeName = "/slatepackView"; final String walletId; final ClipboardInterface clipboard; @override - ConsumerState createState() => _FinalizeViewState(); + ConsumerState createState() => _SlatepackViewState(); } -class _FinalizeViewState extends ConsumerState { - late final TextEditingController _slateController; - late final FocusNode _slateFocusNode; - late final SlatepackTerms _terms; - - bool _slateToggleFlag = false; +class _SlatepackViewState extends ConsumerState { + late final TextEditingController _controller; + late final FocusNode _focusNode; + late final SlatepackInterface _wallet; - Future _pasteSlatepack() async { - final ClipboardData? data = await widget.clipboard.getData( - Clipboard.kTextPlain, - ); - if (data?.text != null && data!.text!.isNotEmpty) { - _slateController.text = data.text!; - setState(() { - _slateToggleFlag = _slateController.text.isNotEmpty; - }); - } - } - - Future _scanQr() async { - try { - if (!Util.isDesktop && _slateFocusNode.hasFocus) { - _slateFocusNode.unfocus(); - await Future.delayed(const Duration(milliseconds: 75)); - } - - if (mounted) { - final qrResult = await ref.read(pBarcodeScanner).scan(context: context); - if (qrResult.rawContent != null && qrResult.rawContent!.isNotEmpty) { - _slateController.text = qrResult.rawContent!; - setState(() { - _slateToggleFlag = _slateController.text.isNotEmpty; - }); - } - } - } on PlatformException catch (e, s) { - if (mounted) { - try { - await checkCamPermDeniedMobileAndOpenAppSettings( - context, - logging: Logging.instance, - ); - } catch (e, s) { - Logging.instance.e( - "Failed to check cam permissions", - error: e, - stackTrace: s, - ); - } - } else { - Logging.instance.e( - "Failed to get camera permissions while trying to scan qr code in SendView: ", - error: e, - stackTrace: s, - ); - } - } - } + bool get _hasText => _controller.text.trim().isNotEmpty; - Future _finalize() async { - // add delay for showloading exception catching hack fix - await Future.delayed(const Duration(seconds: 1)); - - final wallet = - ref.read(pWallets).getWallet(widget.walletId) as MimblewimbleWallet; - - final decoded = await wallet.decodeSlatepack(_slateController.text); - if (!decoded.success) { - throw Exception(decoded.error ?? "Failed to decode ${_terms.noun}"); - } - - final analysis = await wallet.analyzeSlatepack(_slateController.text); - if (analysis.status != "S2") { - throw Exception("Invalid ${_terms.noun} type: ${analysis.status}"); - } - - final result = await wallet.finalizeSlatepack(_slateController.text); - - if (!result.success) { - throw Exception( - result.error ?? "Finalize failed without providing an error???", - ); - } - } - - Future _finalizePressed() async { - if (!Util.isDesktop && _slateFocusNode.hasFocus) { - _slateFocusNode.unfocus(); + Future _continuePressed() async { + if (!Util.isDesktop && _focusNode.hasFocus) { + _focusNode.unfocus(); await Future.delayed(const Duration(milliseconds: 75)); } - if (mounted) { - Exception? ex; - await showLoading( - whileFuture: _finalize(), - context: context, - message: "Finalizing ${_terms.noun}...", - rootNavigator: Util.isDesktop, - onException: (e) => ex = e, - ); + if (!mounted) return; - if (mounted) { - if (ex != null) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - desktopPopRootNavigator: Util.isDesktop, - title: "${_terms.title} finalize error", - message: ex?.toString() ?? "Unexpected result without exception", - maxWidth: Util.isDesktop ? 400 : null, - ), - ); - } else { - setState(() { - _slateController.text = ""; - }); - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Transaction finalized and broadcast successfully!", - context: context, - ), - ); - } - } + final done = await SlatepackImportFlow.run( + context: context, + wallet: _wallet, + text: _controller.text, + ); + if (done && mounted) { + setState(() => _controller.text = ""); } } @override void initState() { super.initState(); - _slateController = TextEditingController(); - _slateFocusNode = FocusNode(); - _terms = SlatepackTerms.forCoin(ref.read(pWalletCoin(widget.walletId))); + _controller = TextEditingController(); + _focusNode = FocusNode(); + _wallet = + ref.read(pWallets).getWallet(widget.walletId) as SlatepackInterface; } @override void dispose() { - _slateController.dispose(); - _slateFocusNode.dispose(); + _controller.dispose(); + _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + final terms = _wallet.slatepackTerms; return ConditionalParent( condition: !Util.isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { Navigator.of(context).pop(); }, ), - title: Text( - "Finalize ${_terms.noun}", - style: STextStyles.navBarTitle(context), - ), + title: Text("Slatepack", style: STextStyles.navBarTitle(context)), ), body: SafeArea( child: LayoutBuilder( @@ -245,95 +129,28 @@ class _FinalizeViewState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - minLines: 1, - maxLines: 5, - key: const Key("finalizeSlatepackFieldKey"), - controller: _slateController, - readOnly: false, - autocorrect: false, - enableSuggestions: false, - toolbarOptions: const ToolbarOptions( - copy: false, - cut: false, - paste: true, - selectAll: false, - ), - onChanged: (newValue) { - setState(() { - _slateToggleFlag = newValue.isNotEmpty; - }); - }, - focusNode: _slateFocusNode, - style: STextStyles.field(context), - decoration: - standardInputDecoration( - _terms.finalizeHint, - _slateFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: - 12, // Adjust vertical padding for better alignment - ), - suffixIcon: Padding( - padding: _slateController.text.isEmpty - ? const EdgeInsets.only(right: 8) - : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _slateToggleFlag - ? TextFieldIconButton( - key: const Key( - "slateFinalizeClearFieldButtonKey", - ), - onTap: () { - _slateController.text = ""; - setState(() { - _slateToggleFlag = false; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "slateFinalizePasteFieldButtonKey", - ), - onTap: _pasteSlatepack, - child: _slateController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - if (_slateController.text.isEmpty) - TextFieldIconButton( - semanticsLabel: - "Scan QR Button. Opens Camera For Scanning QR Code.", - key: const Key("sendViewScanQrButtonKey"), - onTap: _scanQr, - child: const QrCodeIcon(), - ), - ], - ), - ), - ), - ), - ), + Text( + "Paste, scan or open a ${terms.noun} you were sent. It is " + "received or finalized depending on what it is.", + style: Util.isDesktop + ? STextStyles.desktopTextExtraSmall(context) + : STextStyles.label(context), + ), + const SizedBox(height: 12), + SlatepackInputField( + controller: _controller, + focusNode: _focusNode, + hint: "Enter ${terms.payload}", + clipboard: widget.clipboard, + onChanged: (_) => setState(() {}), ), Util.isDesktop ? const SizedBox(height: 24) : const Spacer(), PrimaryButton( - label: "Finalize ${_terms.title}", - enabled: _slateToggleFlag, - onPressed: _slateToggleFlag ? _finalizePressed : null, + label: "Continue", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + enabled: _hasText, + onPressed: _hasText ? _continuePressed : null, ), - if (!Util.isDesktop) SizedBox(height: Constants.size.standardPadding), ], ), diff --git a/lib/pages/wallet_view/transaction_views/transaction_details_view.dart b/lib/pages/wallet_view/transaction_views/transaction_details_view.dart index c7b4b176f9..5f5264492e 100644 --- a/lib/pages/wallet_view/transaction_views/transaction_details_view.dart +++ b/lib/pages/wallet_view/transaction_views/transaction_details_view.dart @@ -37,7 +37,7 @@ import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -1698,7 +1698,7 @@ class _TransactionDetailsViewState onPressed: () async { final wallet = ref.read(pWallets).getWallet(walletId); - if (wallet is MimblewimbleWallet) { + if (wallet is SlatepackInterface) { final String? id = _transaction.slateId; if (id == null) { unawaited( @@ -1758,7 +1758,7 @@ class _TransactionDetailsViewState showFloatingFlushBar( type: FlushBarType.warning, message: - "ERROR: Wallet type is not Epic Cash or MimbleWimbleCoin", + "ERROR: This wallet cannot cancel slatepack transactions", context: context, ), ); diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart index ddc932b8a1..c629675f93 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart @@ -41,9 +41,9 @@ import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../../wallets/isar/models/spark_coin.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; -import '../../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../../widgets/background.dart'; import '../../../../widgets/conditional_parent.dart'; @@ -99,6 +99,8 @@ class _TransactionV2DetailsViewState late final Contract? tokenContract; late final bool supportsRbf; late final bool hasTxKeyProbably; + late final bool isSlatepackWallet; + late final bool showOnChainNote; bool get isTokenTx => tokenContract != null; @@ -203,6 +205,9 @@ class _TransactionV2DetailsViewState walletId = widget.walletId; final wallet = ref.read(pWallets).getWallet(walletId); + isSlatepackWallet = wallet is SlatepackInterface; + showOnChainNote = + wallet is SlatepackInterface && wallet.supportsOnChainNote; hasTxKeyProbably = (wallet is CryptonoteWallet) && @@ -879,11 +884,11 @@ class _TransactionV2DetailsViewState ], ), ), - if (coin is Epiccash || coin is Mimblewimblecoin) + if (showOnChainNote) isDesktop ? const _Divider() : const SizedBox(height: 12), - if (coin is Epiccash || coin is Mimblewimblecoin) + if (showOnChainNote) RoundedWhiteContainer( padding: isDesktop ? const EdgeInsets.all(16) @@ -933,8 +938,7 @@ class _TransactionV2DetailsViewState MainAxisAlignment.spaceBetween, children: [ Text( - (coin is Epiccash || - coin is Mimblewimblecoin) + isSlatepackWallet ? "Local Note" : "Note ", style: labelStyle, @@ -1404,11 +1408,11 @@ class _TransactionV2DetailsViewState // ], // ), // ), - if (coin is Epiccash || coin is Mimblewimblecoin) + if (isSlatepackWallet) isDesktop ? const _Divider() : const SizedBox(height: 12), - if (coin is Epiccash || coin is Mimblewimblecoin) + if (isSlatepackWallet) RoundedWhiteContainer( padding: isDesktop ? const EdgeInsets.all(16) @@ -1463,7 +1467,7 @@ class _TransactionV2DetailsViewState ), ), ), - if ((coin is Epiccash || coin is Mimblewimblecoin) && + if (isSlatepackWallet && _transaction.getConfirmations(currentHeight) < 1 && _transaction.isCancelled == false) const SizedBox(height: 40), @@ -1473,7 +1477,7 @@ class _TransactionV2DetailsViewState ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: - ((coin is Epiccash || coin is Mimblewimblecoin) && + (isSlatepackWallet && _transaction.getConfirmations(currentHeight) < 1 && _transaction.isCancelled == false) ? ConditionalParent( @@ -1496,7 +1500,7 @@ class _TransactionV2DetailsViewState onPressed: () async { final wallet = ref.read(pWallets).getWallet(walletId); - if (wallet is MimblewimbleWallet) { + if (wallet is SlatepackInterface) { final String? id = _transaction.slateId; if (id == null) { unawaited( @@ -1556,8 +1560,8 @@ class _TransactionV2DetailsViewState showFloatingFlushBar( type: FlushBarType.warning, message: - "ERROR: Wallet type is not " - "Epic Cash or MimbleWimbleCoin", + "ERROR: This wallet cannot cancel " + "slatepack transactions", context: context, ), ); diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index e6f556efde..e50745d9e7 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -50,13 +50,12 @@ import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; -import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/namecoin_wallet.dart'; import '../../wallets/wallet/impl/salvium_wallet.dart'; import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; -import '../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; @@ -92,9 +91,8 @@ import '../buy_view/buy_in_wallet_view.dart'; import '../cashfusion/cashfusion_view.dart'; import '../churning/churning_view.dart'; import '../coin_control/coin_control_view.dart'; -import '../epic_finalize_view/epic_finalize_view.dart'; import '../exchange_view/wallet_initiated_exchange_view.dart'; -import '../finalize_view/finalize_view.dart'; +import '../slatepack/slatepack_view.dart'; import '../masternodes/masternodes_home_view.dart'; import '../monkey/monkey_view.dart'; import '../namecoin_names/namecoin_names_home_view.dart'; @@ -1011,17 +1009,15 @@ class _WalletViewState extends ConsumerState { } }, ), - if (wallet is MimblewimbleWallet) + if (wallet is SlatepackInterface) WalletNavigationBarItemData( - label: "Finalize", + label: "Slatepack", icon: const FinalizeNavIcon(), onTap: () { if (mounted) { unawaited( Navigator.of(context).pushNamed( - wallet is EpiccashWallet - ? EpicFinalizeView.routeName - : FinalizeView.routeName, + SlatepackView.routeName, arguments: walletId, ), ); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart index 8b7a7dbe13..4ae8216bcc 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart @@ -22,7 +22,8 @@ import '../../../../models/isar/models/isar_models.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/receive_view/generate_receiving_uri_qr_code_view.dart'; -import '../../../../pages/receive_view/sub_widgets/slatepack_import_dialog.dart'; +import '../../../../pages/slatepack/slatepack_import_flow.dart'; +import '../../../../pages/slatepack/slatepack_input_field.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; import '../../../../route_generator.dart'; @@ -40,12 +41,12 @@ import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/wallet/impl/bitcoin_wallet.dart'; -import '../../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../../../wallets/wallet/intermediate/bip39_hd_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/bcash_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/conditional_parent.dart'; @@ -54,15 +55,9 @@ import '../../../../widgets/custom_loading_overlay.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/primary_button.dart'; import '../../../../widgets/desktop/secondary_button.dart'; -import '../../../../widgets/dialogs/s_dialog.dart'; -import '../../../../widgets/icon_widgets/clipboard_icon.dart'; -import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/slatepack_method_toggle.dart'; import '../../../../widgets/qr.dart'; import '../../../../widgets/rounded_white_container.dart'; -import '../../../../widgets/stack_dialog.dart'; -import '../../../../widgets/stack_text_field.dart'; -import '../../../../widgets/textfield_icon_button.dart'; class DesktopReceive extends ConsumerStatefulWidget { const DesktopReceive({ @@ -88,10 +83,8 @@ class _DesktopReceiveState extends ConsumerState { late bool supportsMweb; late final bool showMultiType; late final bool usesSlatepacks; - late TextEditingController _receiveSlateController; - String? _slate; - bool _slateToggleFlag = false; - final _slateFocusNode = FocusNode(); + late final TextEditingController _receiveSlateController; + late final SlatepackTerms _slatepackTerms; int _currentIndex = 0; @@ -99,75 +92,15 @@ class _DesktopReceiveState extends ConsumerState { final Map _addressMap = {}; final Map> _addressSubMap = {}; - Future _pasteSlatepack() async { - final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); - if (data?.text != null && data!.text!.isNotEmpty) { - _slate = data.text; - _receiveSlateController.text = _slate!; - setState(() { - _slateToggleFlag = _receiveSlateController.text.isNotEmpty; - }); - } - } - - Future _onReceiveSlatePressed() async { - final SlatepackTerms terms = SlatepackTerms.forCoin(coin); - final wallet = ref.read(pWallets).getWallet(walletId) as MimblewimbleWallet; - - Exception? ex; - final result = await showLoading( - whileFuture: wallet.fullDecodeSlatepack(_receiveSlateController.text), + Future _onImportSlatepackPressed() async { + final wallet = ref.read(pWallets).getWallet(walletId) as SlatepackInterface; + final done = await SlatepackImportFlow.run( context: context, - message: "Decoding ${terms.noun}...", - rootNavigator: Util.isDesktop, - onException: (e) => ex = e, + wallet: wallet, + text: _receiveSlateController.text, ); - - if (result == null || ex != null) { - if (mounted) { - await showDialog( - context: context, - useRootNavigator: true, - builder: (context) => StackOkDialog( - desktopPopRootNavigator: true, - title: "${terms.title} receive error", - message: ex?.toString() ?? "Unexpected result without exception", - maxWidth: 400, - ), - ); - } - return; - } - - if (mounted) { - final response = - await showDialog<({String responseSlatepack, bool wasEncrypted})>( - context: context, - builder: (context) => SDialog( - child: SizedBox( - width: 700, - child: SlatepackImportDialog( - walletId: widget.walletId, - clipboard: widget.clipboard, - rawSlatepack: result.raw, - decoded: result.result, - slatepackType: result.type, - ), - ), - ), - ); - - if (mounted && response != null) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => SlatepackResponseDialog( - terms: terms, - responseSlatepack: response.responseSlatepack, - wasEncrypted: response.wasEncrypted, - ), - ); - } + if (done && mounted) { + setState(() => _receiveSlateController.text = ""); } } @@ -182,9 +115,10 @@ class _DesktopReceiveState extends ConsumerState { return WillPopScope( onWillPop: () async => shouldPop, child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.5), + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.5), child: const CustomLoadingOverlay( message: "Generating address", eventBus: null, @@ -261,9 +195,10 @@ class _DesktopReceiveState extends ConsumerState { return WillPopScope( onWillPop: () async => shouldPop, child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.5), + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.5), child: const CustomLoadingOverlay( message: "Generating address", eventBus: null, @@ -352,7 +287,10 @@ class _DesktopReceiveState extends ConsumerState { !wallet.info.isViewOnly && wallet.info.isMwebEnabled; - usesSlatepacks = wallet is MimblewimbleWallet; + usesSlatepacks = wallet is SlatepackInterface; + _slatepackTerms = wallet is SlatepackInterface + ? wallet.slatepackTerms + : SlatepackTerms.slatepack; if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { showMultiType = false; @@ -494,183 +432,177 @@ class _DesktopReceiveState extends ConsumerState { child: Container( decoration: BoxDecoration( color: - Theme.of( - context, - ).extension()?.textFieldDefaultBG ?? + Theme.of(context) + .extension() + ?.textFieldDefaultBG ?? Colors.white, // Fallback color borderRadius: BorderRadius.circular(8), border: Border.all( color: - Theme.of( - context, - ).extension()?.backgroundAppBar ?? + Theme.of(context) + .extension() + ?.backgroundAppBar ?? Colors.grey, // Fallback color width: 1, ), ), child: SizedBox( - height: - 60, // Provide an explicit height to avoid infinite constraints - child: SlatepackMethodToggle(coin: coin), + height: 60, // Provide an explicit height to avoid infinite constraints + child: SlatepackMethodToggle(walletId: walletId), ), ), ), - if (!(usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId)))) - const SizedBox(height: 20), - if (!(usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId)))) - ConditionalParent( - condition: showMultiType, - builder: (child) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DropdownButtonHideUnderline( - child: DropdownButton2( - value: _currentIndex, - items: [ - for (int i = 0; i < _walletAddressTypes.length; i++) - DropdownMenuItem( - value: i, - child: Text( - supportsSpark && - _walletAddressTypes[i] == AddressType.p2pkh - ? "Transparent address" - : "${_walletAddressTypes[i].readableName} address", - style: STextStyles.w500_14(context), - ), - ), - ], - onChanged: (value) { - if (value != null && value != _currentIndex) { - setState(() { - _currentIndex = value; - }); - } - }, - isExpanded: true, - iconStyleData: IconStyleData( - icon: Padding( - padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, + const SizedBox(height: 20), + ConditionalParent( + condition: showMultiType, + builder: (child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonHideUnderline( + child: DropdownButton2( + value: _currentIndex, + items: [ + for (int i = 0; i < _walletAddressTypes.length; i++) + DropdownMenuItem( + value: i, + child: Text( + supportsSpark && + _walletAddressTypes[i] == AddressType.p2pkh + ? "Transparent address" + : "${_walletAddressTypes[i].readableName} address", + style: STextStyles.w500_14(context), ), ), - ), - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), + ], + onChanged: (value) { + if (value != null && value != _currentIndex) { + setState(() { + _currentIndex = value; + }); + } + }, + isExpanded: true, + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, ), ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, -10), - elevation: 0, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), + ), + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, ), ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, ), ), ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), ), - const SizedBox(height: 12), - child, - ], - ), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - clipboard.setData(ClipboardData(text: address)); - showFloatingFlushBar( - type: FlushBarType.info, - message: "Copied to clipboard", - iconAsset: Assets.svg.copy, - context: context, - ); - }, - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of( - context, - ).extension()!.backgroundAppBar, - width: 1, - ), - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), + ), + const SizedBox(height: 12), + child, + ], + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + clipboard.setData(ClipboardData(text: address)); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context) + .extension()! + .backgroundAppBar, + width: 1, ), - child: RoundedWhiteContainer( - child: Column( - children: [ - Row( - children: [ - Text( - // "Your ${widget.contractAddress == null ? coin.ticker : ref.watch(pCurrentTokenWallet.select((value) => value!.tokenContract.symbol))} address", - // TODO [prio=high]: Make the above work for Sol tokens instead of the placeholder below. - "Your ${widget.contractAddress == null ? coin.ticker : "token"} address", - style: STextStyles.itemSubtitle(context), - ), - const Spacer(), - Row( - children: [ - SvgPicture.asset( - Assets.svg.copy, - width: 15, - height: 15, - color: Theme.of( - context, - ).extension()!.infoItemIcons, - ), - const SizedBox(width: 4), - Text("Copy", style: STextStyles.link2(context)), - ], - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Text( - address, - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, - ), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: RoundedWhiteContainer( + child: Column( + children: [ + Row( + children: [ + Text( + // "Your ${widget.contractAddress == null ? coin.ticker : ref.watch(pCurrentTokenWallet.select((value) => value!.tokenContract.symbol))} address", + // TODO [prio=high]: Make the above work for Sol tokens instead of the placeholder below. + "Your ${widget.contractAddress == null ? coin.ticker : "token"} address", + style: STextStyles.itemSubtitle(context), + ), + const Spacer(), + Row( + children: [ + SvgPicture.asset( + Assets.svg.copy, + width: 15, + height: 15, + color: Theme.of(context) + .extension()! + .infoItemIcons, ), + const SizedBox(width: 4), + Text("Copy", style: STextStyles.link2(context)), + ], + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + address, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), ), - ], - ), - ], - ), + ), + ], + ), + ], ), ), ), ), ), + ), if (canGen) const SizedBox(height: 20), @@ -688,229 +620,123 @@ class _DesktopReceiveState extends ConsumerState { label: "Generate new address", ), const SizedBox(height: 20), - if (usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId))) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Label Text - Text( - "Receive Slatepack", - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - textAlign: TextAlign.left, - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - minLines: 1, - maxLines: 5, - key: const Key("receiveViewSlatepackFieldKey"), - controller: _receiveSlateController, - readOnly: false, - autocorrect: false, - enableSuggestions: false, - toolbarOptions: const ToolbarOptions( - copy: false, - cut: false, - paste: true, - selectAll: false, - ), - onChanged: (newValue) { - _slate = newValue; - setState(() { - _slateToggleFlag = newValue.isNotEmpty; - }); - }, - focusNode: _slateFocusNode, - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ), - decoration: - standardInputDecoration( - "Enter Slatepack Message", - _slateFocusNode, - context, - desktopMed: true, - ).copyWith( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: - 12, // Adjust vertical padding for better alignment - ), - suffixIcon: Padding( - padding: _receiveSlateController.text.isEmpty - ? const EdgeInsets.only(right: 8) - : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _slateToggleFlag - ? TextFieldIconButton( - key: const Key( - "receiveViewClearSlatepackFieldButtonKey", - ), - onTap: () { - _receiveSlateController.text = ""; - _slate = ""; - setState(() { - _slateToggleFlag = false; - }); - }, - child: const XIcon(), - ) - : TextFieldIconButton( - key: const Key( - "receiveViewPasteSlatepackFieldButtonKey", - ), - onTap: _pasteSlatepack, - child: - _receiveSlateController.text.isEmpty - ? const ClipboardIcon() - : const XIcon(), - ), - ], - ), - ), - ), - ), - ), - ), - ], - ) - //Padding( - // padding: const EdgeInsets.symmetric(vertical: 8.0), - // child: TextField( - // maxLines: 8, // Set to a higher number to make the height larger - // minLines: 5, // Allow it to shrink if input is small - // decoration: InputDecoration( - // labelText: 'Enter Slatepack Message', - // alignLabelWithHint: true, - // border: OutlineInputBorder( - // borderRadius: BorderRadius.circular(8), - // ), - // filled: true, - // fillColor: Theme.of(context).extension()?.textFieldDefaultBG ?? Colors.white, - // contentPadding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 12.0), - // ), - // onChanged: (value) { - // // Handle text input changes (e.g., store in a state variable) - // debugPrint('Slatepack Message: $value'); - // }, - // ), - //) - else - Center( - child: QR( - data: AddressUtils.buildUriString(coin.uriScheme, address, {}), - size: 200, - ), + Center( + child: QR( + data: AddressUtils.buildUriString(coin.uriScheme, address, {}), + size: 200, ), + ), const SizedBox(height: 32), // TODO: create transparent button class to account for hover - // Conditional logic for 'Submit' button or QR code - if (usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId))) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: PrimaryButton( - buttonHeight: ButtonHeight.l, - label: "Receive Slatepack", - enabled: _slateToggleFlag, - onPressed: _slateToggleFlag ? _onReceiveSlatePressed : null, - ), - ) - else - GestureDetector( - onTap: () async { - if (Util.isDesktop) { - await showDialog( - context: context, - builder: (context) => DesktopDialog( - maxHeight: double.infinity, - maxWidth: 580, - child: Column( - children: [ - Row( - children: [ - const AppBarBackButton(size: 40, iconSize: 24), - Text( - "Generate QR code", - style: STextStyles.desktopH3(context), + GestureDetector( + onTap: () async { + if (Util.isDesktop) { + await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxHeight: double.infinity, + maxWidth: 580, + child: Column( + children: [ + Row( + children: [ + const AppBarBackButton(size: 40, iconSize: 24), + Text( + "Generate QR code", + style: STextStyles.desktopH3(context), + ), + ], + ), + IntrinsicHeight( + child: Navigator( + onGenerateRoute: RouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, __) => [ + RouteGenerator.generateRoute( + RouteSettings( + name: GenerateUriQrCodeView.routeName, + arguments: Tuple2(coin, address), + ), ), ], ), - IntrinsicHeight( - child: Navigator( - onGenerateRoute: RouteGenerator.generateRoute, - onGenerateInitialRoutes: (_, __) => [ - RouteGenerator.generateRoute( - RouteSettings( - name: GenerateUriQrCodeView.routeName, - arguments: Tuple2(coin, address), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } else { - unawaited( - Navigator.of(context).push( - RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator.useMaterialPageRoute, - builder: (_) => GenerateUriQrCodeView( - coin: coin, - receivingAddress: address, ), - settings: const RouteSettings( - name: GenerateUriQrCodeView.routeName, - ), - ), - ), - ); - } - }, - child: Container( - color: Colors.transparent, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SvgPicture.asset( - Assets.svg.qrcode, - width: 14, - height: 16, - color: Theme.of( - context, - ).extension()!.accentColorBlue, + ], ), - const SizedBox(width: 8), - Text( - "Create new QR code", - style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorBlue, + ), + ); + } else { + unawaited( + Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => GenerateUriQrCodeView( + coin: coin, + receivingAddress: address, + ), + settings: const RouteSettings( + name: GenerateUriQrCodeView.routeName, ), ), - ], - ), + ), + ); + } + }, + child: Container( + color: Colors.transparent, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.qrcode, + width: 14, + height: 16, + color: Theme.of(context) + .extension()! + .accentColorBlue, + ), + const SizedBox(width: 8), + Text( + "Create new QR code", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorBlue, + ), + ), + ], + ), + ), + ), + if (usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId))) ...[ + const SizedBox(height: 32), + Text( + "Import ${_slatepackTerms.title}", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), + textAlign: TextAlign.left, + ), + const SizedBox(height: 8), + SlatepackInputField( + controller: _receiveSlateController, + hint: "Enter ${_slatepackTerms.payload}", + clipboard: clipboard, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Import ${_slatepackTerms.title}", + enabled: _receiveSlateController.text.trim().isNotEmpty, + onPressed: _receiveSlateController.text.trim().isNotEmpty + ? _onImportSlatepackPressed + : null, ), + ], ], ); } 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 275c18833a..d655399f2d 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 @@ -18,7 +18,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import '../../../../models/slatepack_models.dart'; import '../../../../models/isar/models/blockchain_data/address.dart'; import '../../../../models/isar/models/blockchain_data/utxo.dart'; import '../../../../models/isar/models/contact_entry.dart'; @@ -29,7 +28,6 @@ import '../../../../pages/send_view/confirm_transaction_view.dart'; import '../../../../pages/send_view/frost_ms/recipient.dart'; import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; import '../../../../pages/send_view/sub_widgets/multi_recipient.dart'; -import '../../../../pages/send_view/sub_widgets/slatepack_dialog.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'; @@ -50,11 +48,10 @@ import '../../../../utilities/assets.dart'; import '../../../../utilities/clipboard_interface.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/enums/fee_rate_type_enum.dart'; +import '../../../../utilities/enums/slatepack_method.dart'; import '../../../../utilities/integer_input.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/prefs.dart'; -import '../../../../utilities/show_loading.dart'; -import '../../../../utilities/slatepack_terms.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; @@ -62,11 +59,11 @@ import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../../wallets/models/tx_data.dart'; import '../../../../wallets/wallet/impl/firo_wallet.dart'; -import '../../../../wallets/wallet/intermediate/mimblewimble_wallet.dart'; import '../../../../wallets/wallet/impl/salvium_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import '../../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; @@ -198,134 +195,6 @@ class _DesktopSendState extends ConsumerState { } } - Future _handleDesktopSlatepackCreation( - MimblewimbleWallet wallet, - ) async { - final SlatepackTerms terms = SlatepackTerms.forCoin(coin); - try { - final amount = ref.read(pSendAmount)!; - - Future wrappedFutureWithDelay() async { - await Future.delayed(const Duration(seconds: 1)); - return wallet.createSlatepack( - amount: amount, - recipientAddress: null, // No specific recipient for manual slatepack. - message: _onChainNote?.isNotEmpty == true ? _onChainNote : null, - encrypt: false, // No encryption without recipient address. - ); - } - - // Create slatepack. - Exception? ex; - final slatepackResult = await showLoading( - whileFuture: wrappedFutureWithDelay(), - context: context, - rootNavigator: true, - message: "Building ${terms.noun}...", - delay: const Duration(seconds: 2), - onException: (e) => ex = e, - ); - - if (slatepackResult == null || - !slatepackResult.success || - slatepackResult.slatepack == null || - ex != null) { - String error = - ex?.toString() ?? - slatepackResult?.error ?? - 'Failed to create ${terms.noun}'; - if (error.startsWith("Exception:")) { - error = error.replaceFirst("Exception:", "").trim(); - } - throw Exception(error); - } - - // refresh asap to show the pending slate tx in history - unawaited(() async { - await Future.delayed(Duration.zero); - await wallet.refresh(); - }()); - - // Show slatepack dialog. - if (mounted) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => DesktopDialog( - maxHeight: double.infinity, - maxWidth: 700, - child: SlatepackDialog( - terms: terms, - slatepackResult: slatepackResult, - ), - ), - ); - - // Clear form after slatepack dialog is closed. - clearSendForm(); - } - } catch (e, s) { - Logging.instance.e( - 'Failed to create ${coin.prettyName} ${terms.noun} on desktop', - error: e, - stackTrace: s, - ); - - if (mounted) { - await showDialog( - context: context, - builder: (context) => DesktopDialog( - maxWidth: 450, - maxHeight: double.infinity, - child: Padding( - padding: const EdgeInsets.only(left: 32, bottom: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${terms.title} Creation Failed', - style: STextStyles.desktopH3(context), - ), - const DesktopDialogCloseButton(), - ], - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.only(right: 32), - child: Text( - 'Failed to create ${terms.noun}: $e', - textAlign: TextAlign.left, - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith(fontSize: 18), - ), - ), - const SizedBox(height: 40), - Padding( - padding: const EdgeInsets.only(right: 32), - child: Row( - children: [ - Expanded( - child: PrimaryButton( - buttonHeight: ButtonHeight.l, - label: 'OK', - onPressed: () => Navigator.of(context).pop(), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - } - } - /// Looks up the OpenAlias in the address field. Returns null if the lookup /// failed or was cancelled. Future _resolveOpenAlias(String application) async { @@ -386,10 +255,13 @@ class _DesktopSendState extends ConsumerState { final nonce = nonceInput.value; final wallet = ref.read(pWallets).getWallet(walletId); + final isSlatepackSend = + wallet is SlatepackInterface && ref.read(pIsSlatepack(walletId)); String? openAlias; String? resolvedAddress; final application = coin.openAliasApplication; if (application != null && + !isSlatepackSend && !coin.validateAddress(_address ?? "") && isOpenAlias(_address ?? "")) { var resolved = _openAlias; @@ -405,12 +277,6 @@ class _DesktopSendState extends ConsumerState { } late final recipientAddress = resolvedAddress ?? _address!; - if (wallet is MimblewimbleWallet && - ref.read(pIsSlatepack(widget.walletId))) { - await _handleDesktopSlatepackCreation(wallet); - return; - } - final Amount amount = ref.read(pSendAmount)!; final Amount availableBalance; if (coin is Firo || ref.read(pWalletInfo(walletId)).isMwebEnabled) { @@ -579,6 +445,21 @@ class _DesktopSendState extends ConsumerState { : null, ), ); + } else if (isSlatepackSend) { + // No address: the confirm screen hands out a slatepack instead. + txDataFuture = wallet.prepareSend( + txData: TxData( + recipients: [ + TxRecipient( + address: "", + amount: amount, + isChange: false, + addressType: AddressType.mimbleWimble, + ), + ], + otherData: slatepackMethodOtherData(SlatepackMethod.slatepack), + ), + ); } else if (wallet is FiroWallet) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case BalanceType.public: @@ -738,10 +619,7 @@ class _DesktopSendState extends ConsumerState { ); } else { txData = txData.copyWith(note: _note ?? ""); - if (coin is Epiccash) { - txData = txData.copyWith(noteOnChain: _onChainNote ?? ""); - } - if (coin is Mimblewimblecoin) { + if (wallet is SlatepackInterface && wallet.supportsOnChainNote) { txData = txData.copyWith(noteOnChain: _onChainNote ?? ""); } } @@ -1437,7 +1315,8 @@ class _DesktopSendState extends ConsumerState { clipboard = widget.clipboard; hasOptionalMemo = coin is Stellar || coin is Solana; - usesSlatepacks = coin is Mimblewimblecoin || coin is Epiccash; + usesSlatepacks = + ref.read(pWallets).getWallet(walletId) is SlatepackInterface; sendToController = TextEditingController(); cryptoAmountController = TextEditingController(); @@ -1549,7 +1428,7 @@ class _DesktopSendState extends ConsumerState { final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom; // ethFee is checked in the ValueListenableBuilder around the preview // button so fee keystrokes don't rebuild this whole view. - final previewEnabled = ref.watch(pPreviewTxButtonEnabled(coin)); + final previewEnabled = ref.watch(pPreviewTxButtonEnabled(walletId)); final needsEthFee = coin is Ethereum && isCustomFee; // add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address) @@ -1641,9 +1520,8 @@ class _DesktopSendState extends ConsumerState { ), ), child: SizedBox( - height: - 60, // Provide an explicit height to avoid infinite constraints - child: SlatepackMethodToggle(coin: coin), + height: 60, // Provide an explicit height to avoid infinite constraints + child: SlatepackMethodToggle(walletId: walletId), ), ), ), @@ -2186,10 +2064,9 @@ class _DesktopSendState extends ConsumerState { } else { if (_data != null && _data.contactLabel == _address) { error = null; - } else if ((coin is Mimblewimblecoin || coin is Epiccash) && + } else if (usesSlatepacks && ref.watch(pIsSlatepack(widget.walletId))) { - // For MWC/Epic slatepack transactions, address validation is not required. - // TODO: When implementing encrypted slatepacks, address validation will be required. + // Slatepack sends have no address to validate. error = null; } else if (!ref.watch(pValidSendToAddress)) { error = "Invalid address"; @@ -2426,9 +2303,7 @@ class _DesktopSendState extends ConsumerState { (!needsEthFee || ethFee != null); return PrimaryButton( buttonHeight: ButtonHeight.l, - label: ref.watch(pIsSlatepack(widget.walletId)) - ? "Create slatepack" - : "Preview send", + label: "Preview send", enabled: enabled, onPressed: enabled ? previewSend : null, ); diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart index 8a1f9b856c..3a4620c77b 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart @@ -12,14 +12,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../frost_route_generator.dart'; -import '../../../../pages/finalize_view/finalize_view.dart'; import '../../../../pages/send_view/frost_ms/frost_send_view.dart'; +import '../../../../pages/slatepack/slatepack_view.dart'; import '../../../../pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../utilities/clipboard_interface.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../../../wallets/wallet/impl/solana_wallet.dart' show SolanaWallet; +import '../../../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/custom_tab_view.dart'; import '../../../../widgets/desktop/secondary_button.dart'; @@ -58,10 +59,10 @@ class _MyWalletState extends ConsumerState { isFrost = wallet is BitcoinFrostWallet; isEth = coin is Ethereum; isSolana = wallet is SolanaWallet; - usesSlatepacks = coin is Mimblewimblecoin || coin is Epiccash; + usesSlatepacks = wallet is SlatepackInterface; if (usesSlatepacks) { - titles.add("Finalize"); + titles.add("Slatepack"); } if ((isEth || isSolana) && widget.contractAddress == null) { @@ -184,7 +185,7 @@ class _MyWalletState extends ConsumerState { if (usesSlatepacks) Padding( padding: const EdgeInsets.all(20), - child: FinalizeView(walletId: widget.walletId), + child: SlatepackView(walletId: widget.walletId), ), if ((isEth || isSolana) && widget.contractAddress == null) diff --git a/lib/providers/ui/preview_tx_button_state_provider.dart b/lib/providers/ui/preview_tx_button_state_provider.dart index 00dfb2b705..1b5d4540f6 100644 --- a/lib/providers/ui/preview_tx_button_state_provider.dart +++ b/lib/providers/ui/preview_tx_button_state_provider.dart @@ -11,9 +11,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../utilities/amount/amount.dart'; -import '../../utilities/enums/mimblewimble_transaction_method.dart'; +import '../../utilities/enums/slatepack_method.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; +import '../global/wallets_provider.dart'; import '../wallet/public_private_balance_state_provider.dart'; final pSendAmount = StateProvider.autoDispose((_) => null); @@ -24,59 +26,54 @@ final pIsExchangeAddress = StateProvider((_) => false); final pOpReturnData = StateProvider.autoDispose((_) => null); -// Selected exchange method per slatepack coin. Epic Cash defaults to its -// relay, MWC to manual slatepacks. -final pSelectedSlatepackMethod = - StateProvider.family( - (_, CryptoCurrency coin) => coin is Epiccash - ? MimblewimbleTransactionMethod.relay - : MimblewimbleTransactionMethod.slatepack, - ); +// Selected exchange method per slatepack wallet, null for other wallets. +// Starts at the wallet's default (Epic Cash: epicbox, MWC: slatepack). +final pSelectedSlatepackMethod = StateProvider.family( + (ref, walletId) { + final wallet = ref.read(pWallets).getWallet(walletId); + return wallet is SlatepackInterface ? wallet.supportedMethods.first : null; + }, +); final pIsSlatepack = Provider.family((ref, walletId) { - final coin = ref.watch(pWalletCoin(walletId)); - if (coin is Mimblewimblecoin || coin is Epiccash) { - return ref.watch(pSelectedSlatepackMethod(coin)) == - MimblewimbleTransactionMethod.slatepack; - } - - return false; + return ref.watch(pSelectedSlatepackMethod(walletId)) == + SlatepackMethod.slatepack; }); -final pPreviewTxButtonEnabled = Provider.autoDispose - .family((ref, coin) { - final amount = ref.watch(pSendAmount) ?? Amount.zero; - final opReturnData = ref.watch(pOpReturnData); +final pPreviewTxButtonEnabled = Provider.autoDispose.family(( + ref, + walletId, +) { + final coin = ref.watch(pWalletCoin(walletId)); + final amount = ref.watch(pSendAmount) ?? Amount.zero; + final opReturnData = ref.watch(pOpReturnData); - if (coin is! Firo && opReturnData != null) { - return false; - } + if (coin is! Firo && opReturnData != null) { + return false; + } - // Slatepack transactions have no address to validate. - if (coin is Mimblewimblecoin || coin is Epiccash) { - if (ref.watch(pSelectedSlatepackMethod(coin)) == - MimblewimbleTransactionMethod.slatepack) { - return amount > Amount.zero; - } - } + // Slatepack transactions have no address to validate. + if (ref.watch(pIsSlatepack(walletId))) { + return amount > Amount.zero; + } - if (coin is Firo) { - final firoType = ref.watch(publicPrivateBalanceStateProvider); - switch (firoType) { - case BalanceType.private: - return (ref.watch(pValidSendToAddress) || - ref.watch(pValidSparkSendToAddress)) && - !ref.watch(pIsExchangeAddress) && - opReturnData == null && - amount > Amount.zero; + if (coin is Firo) { + final firoType = ref.watch(publicPrivateBalanceStateProvider); + switch (firoType) { + case BalanceType.private: + return (ref.watch(pValidSendToAddress) || + ref.watch(pValidSparkSendToAddress)) && + !ref.watch(pIsExchangeAddress) && + opReturnData == null && + amount > Amount.zero; - case BalanceType.public: - return ref.watch(pValidSendToAddress) && amount > Amount.zero; - } - } else { + case BalanceType.public: return ref.watch(pValidSendToAddress) && amount > Amount.zero; - } - }); + } + } else { + return ref.watch(pValidSendToAddress) && amount > Amount.zero; + } +}); final previewTokenTxButtonStateProvider = StateProvider.autoDispose((_) { return false; diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 99cfe112b7..ba6ae2ce0f 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -72,7 +72,6 @@ import 'pages/churning/churning_progress_view.dart'; import 'pages/churning/churning_view.dart'; import 'pages/coin_control/coin_control_view.dart'; import 'pages/coin_control/utxo_details_view.dart'; -import 'pages/epic_finalize_view/epic_finalize_view.dart'; import 'pages/exchange_view/choose_address_from_stack_view.dart'; import 'pages/exchange_view/edit_trade_note_view.dart'; import 'pages/exchange_view/exchange_step_views/step_1_view.dart'; @@ -82,7 +81,6 @@ import 'pages/exchange_view/exchange_step_views/step_4_view.dart'; import 'pages/exchange_view/send_from_view.dart'; import 'pages/exchange_view/trade_details_view.dart'; import 'pages/exchange_view/wallet_initiated_exchange_view.dart'; -import 'pages/finalize_view/finalize_view.dart'; import 'pages/generic/single_field_edit_view.dart'; import 'pages/home_view/home_view.dart'; import 'pages/intro_view.dart'; @@ -114,6 +112,7 @@ import 'pages/salvium_stake/salvium_create_stake_view.dart'; import 'pages/send_view/confirm_transaction_view.dart'; import 'pages/send_view/frost_ms/frost_send_view.dart'; import 'pages/send_view/send_view.dart'; +import 'pages/slatepack/slatepack_view.dart'; import 'pages/send_view/sol_token_send_view.dart'; import 'pages/send_view/token_send_view.dart'; import 'pages/settings_views/global_settings_view/about_view.dart'; @@ -2071,21 +2070,11 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); - case FinalizeView.routeName: + case SlatepackView.routeName: if (args is String) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => FinalizeView(walletId: args), - settings: RouteSettings(name: settings.name), - ); - } - return _routeError("${settings.name} invalid args: ${args.toString()}"); - - case EpicFinalizeView.routeName: - if (args is String) { - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => EpicFinalizeView(walletId: args), + builder: (_) => SlatepackView(walletId: args), settings: RouteSettings(name: settings.name), ); } diff --git a/lib/utilities/enums/slatepack_method.dart b/lib/utilities/enums/slatepack_method.dart index 857be81b34..a9b3006523 100644 --- a/lib/utilities/enums/slatepack_method.dart +++ b/lib/utilities/enums/slatepack_method.dart @@ -1,8 +1,23 @@ -/// How an Epic Cash or MWC transaction is exchanged with the other party. -enum MimblewimbleTransactionMethod { - /// Manual slate exchange (copy/paste, QR codes, files). - slatepack, +/// How a Mimblewimble transaction is exchanged with the other party. +enum SlatepackMethod { + /// Manual slatepack exchange (copy/paste, QR codes, files). + slatepack("Slatepack"), /// Automatic exchange through the coin's relay server (epicbox, mwcmqs). - relay, + relay("Automatic"), + + /// Direct send to the recipient's listener (http(s), Tor). + http("Address"); + + const SlatepackMethod(this.label); + + /// Label on the method toggle. + final String label; + + static SlatepackMethod? fromName(String? name) { + for (final m in values) { + if (m.name == name) return m; + } + return null; + } } diff --git a/lib/utilities/slatepack_terms.dart b/lib/utilities/slatepack_terms.dart index f9ae72e08b..54a87e5c35 100644 --- a/lib/utilities/slatepack_terms.dart +++ b/lib/utilities/slatepack_terms.dart @@ -1,12 +1,11 @@ -import '../wallets/crypto_currency/crypto_currency.dart'; - /// Wording the slatepack screens use for the payload being exchanged. Epic -/// Cash passes raw slate JSON around while MWC uses armored slatepacks. +/// Cash passes raw slate JSON around while MWC uses armored slatepacks. A +/// wallet picks its terms through SlatepackInterface.slatepackTerms. enum SlatepackTerms { - slate("slate", "Slate JSON", "Enter Response Slate JSON"), - slatepack("slatepack", "Slatepack Message", "Enter Final Slatepack Message"); + slate("slate", "Slate JSON"), + slatepack("slatepack", "Slatepack Message"); - const SlatepackTerms(this.noun, this.payload, this.finalizeHint); + const SlatepackTerms(this.noun, this.payload); /// "slate" or "slatepack". final String noun; @@ -14,12 +13,6 @@ enum SlatepackTerms { /// What the user is asked to paste, e.g. "Slatepack Message". final String payload; - /// Hint for the response field on the finalize screen. - final String finalizeHint; - /// [noun] with a capital, for titles. String get title => "${noun[0].toUpperCase()}${noun.substring(1)}"; - - static SlatepackTerms forCoin(CryptoCurrency coin) => - coin is Epiccash ? slate : slatepack; } diff --git a/lib/wallets/crypto_currency/coins/epiccash.dart b/lib/wallets/crypto_currency/coins/epiccash.dart index b5894fad8f..9dc848c362 100644 --- a/lib/wallets/crypto_currency/coins/epiccash.dart +++ b/lib/wallets/crypto_currency/coins/epiccash.dart @@ -4,7 +4,7 @@ import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/node_model.dart'; import '../../../utilities/default_nodes.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; -import '../../../utilities/enums/mimblewimble_transaction_method.dart'; +import '../../../utilities/enums/slatepack_method.dart'; import '../../../wl_gen/interfaces/libepiccash_interface.dart'; import '../crypto_currency.dart'; import '../intermediate/bip39_currency.dart'; @@ -156,14 +156,15 @@ class Epiccash extends Bip39Currency { } /// Detect transaction type based on address/data format. - MimblewimbleTransactionMethod getTransactionMethod(String addressOrData) { + SlatepackMethod getTransactionMethod(String addressOrData) { if (isSlateJson(addressOrData)) { - return MimblewimbleTransactionMethod.slatepack; - } else if (isEpicboxAddress(addressOrData) || - isHttpAddress(addressOrData)) { - return MimblewimbleTransactionMethod.relay; + return SlatepackMethod.slatepack; + } else if (isHttpAddress(addressOrData)) { + return SlatepackMethod.http; + } else if (isEpicboxAddress(addressOrData)) { + return SlatepackMethod.relay; } else { - throw Exception("Unknown MimblewimbleTransactionMethod found!"); + throw Exception("Unknown SlatepackMethod found!"); } } diff --git a/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart b/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart index 2389fc7527..49a8982c7b 100644 --- a/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart +++ b/lib/wallets/crypto_currency/coins/mimblewimblecoin.dart @@ -2,7 +2,7 @@ import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/node_model.dart'; import '../../../utilities/default_nodes.dart'; import '../../../utilities/enums/derive_path_type_enum.dart'; -import '../../../utilities/enums/mimblewimble_transaction_method.dart'; +import '../../../utilities/enums/slatepack_method.dart'; import '../../../wl_gen/interfaces/libmwc_interface.dart'; import '../crypto_currency.dart'; import '../intermediate/bip39_currency.dart'; @@ -71,14 +71,14 @@ class Mimblewimblecoin extends Bip39Currency { } /// Detect transaction type based on address/data format. - MimblewimbleTransactionMethod getTransactionMethod(String addressOrData) { + SlatepackMethod getTransactionMethod(String addressOrData) { if (isSlatepack(addressOrData)) { - return MimblewimbleTransactionMethod.slatepack; + return SlatepackMethod.slatepack; } else if (isMwcmqsAddress(addressOrData)) { - return MimblewimbleTransactionMethod.relay; + return SlatepackMethod.relay; // } else if (isHttpAddress(addressOrData)) { } else { - throw Exception("Unknown MimblewimbleTransactionMethod found!"); + throw Exception("Unknown SlatepackMethod found!"); } } diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index a3ac6b96ff..7f129a5dac 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -22,8 +22,10 @@ import '../../../pages/settings_views/global_settings_view/manage_nodes_views/ad import '../../../utilities/amount/amount.dart'; import '../../../utilities/default_epicboxes.dart'; import '../../../utilities/dynamic_object.dart'; +import '../../../utilities/enums/slatepack_method.dart'; import '../../../utilities/flutter_secure_storage_interface.dart'; import '../../../utilities/logger.dart'; +import '../../../utilities/slatepack_terms.dart'; import '../../../utilities/test_epic_box_connection.dart'; import '../../../utilities/tor_plain_net_option_enum.dart'; import '../../../wl_gen/interfaces/libepiccash_interface.dart'; @@ -32,6 +34,7 @@ import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; import '../intermediate/mimblewimble_wallet.dart'; import '../supporting/mimblewimble_wallet_info_extension.dart'; +import '../wallet_mixin_interfaces/slatepack_interface.dart'; // // refactor of https://github.com/cypherstack/stack_wallet/blob/1d9fb4cd069f22492ece690ac788e05b8f8b1209/lib/services/coins/epiccash/epiccash_wallet.dart @@ -52,6 +55,16 @@ class EpiccashWallet extends MimblewimbleWallet { @override int get firstBlockTimestamp => 1565370278; + @override + SlatepackTerms get slatepackTerms => SlatepackTerms.slate; + + // Epic Cash users have always defaulted to epicbox. + @override + List get supportedMethods => const [ + SlatepackMethod.relay, + SlatepackMethod.slatepack, + ]; + @override Future updateRelayConfig(String host, int port) async { final String stringConfig = jsonEncode({ @@ -117,14 +130,14 @@ class EpiccashWallet extends MimblewimbleWallet { // ================= Slatepack Operations =================================== - /// Epic slates are exchanged as plain JSON; [encrypt] is ignored. + /// Epic slates are exchanged as plain JSON and are never encrypted. + /// [sendAll] is ignored; prepareSend already took the fee off the amount. @override Future createSlatepack({ required Amount amount, String? recipientAddress, String? message, - bool encrypt = false, - int? minimumConfirmations, + bool sendAll = false, }) async { try { _hackedCheckTorNodePrefs(); @@ -136,18 +149,22 @@ class EpiccashWallet extends MimblewimbleWallet { amount: amount.raw.toInt(), address: 'slate', // Not used in slate mode. secretKeyIndex: 0, - minimumConfirmations: - minimumConfirmations ?? cryptoCurrency.minConfirms, + minimumConfirmations: cryptoCurrency.minConfirms, note: message ?? '', returnSlate: true, ); + final info = inspectSlateJsonString(result.slateJson); return SlatepackResult( success: true, slatepack: result.slateJson, slateJson: result.slateJson, wasEncrypted: false, recipientAddress: recipientAddress, + state: SlateState.s1, + slateId: result.slateId, + amount: info?.amount ?? amount, + fee: info?.fee, ); } catch (e, s) { Logging.instance.e('Failed to create slatepack: $e\n$s'); @@ -175,7 +192,7 @@ class EpiccashWallet extends MimblewimbleWallet { } @override - Future receiveSlatepack(String slatepack) async { + Future receiveSlatepack(String slatepack) async { try { _hackedCheckTorNodePrefs(); if (_wallet == null) { @@ -187,17 +204,21 @@ class EpiccashWallet extends MimblewimbleWallet { slateJson: slatepack, ); - return ReceiveResult( + final info = inspectSlateJsonString(received.slateJson); + return SlatepackResult( success: true, - slateId: received.slateId, - commitId: received.commitId, - responseSlatepack: received.slateJson, + slatepack: received.slateJson, + slateJson: received.slateJson, wasEncrypted: false, recipientAddress: null, + state: SlateState.s2, + slateId: received.slateId, + amount: info?.amount, + fee: info?.fee, ); } catch (e, s) { Logging.instance.e('Failed to receive slatepack: $e\n$s'); - return ReceiveResult(success: false, error: e.toString()); + return SlatepackResult(success: false, error: e.toString()); } } @@ -218,6 +239,7 @@ class EpiccashWallet extends MimblewimbleWallet { success: true, slateId: finalized.slateId, commitId: finalized.commitId, + posted: true, ); } catch (e, s) { Logging.instance.e('Failed to finalize slatepack: $e\n$s'); @@ -225,21 +247,6 @@ class EpiccashWallet extends MimblewimbleWallet { } } - /// Check if data is a slate JSON. - @override - bool isSlatepack(String data) { - try { - final dynamic parsed = jsonDecode(data); - // Check for common slate fields. - return parsed is Map && - (parsed.containsKey('id') || parsed.containsKey('slate_id')) && - (parsed.containsKey('amount') || - parsed.containsKey('participant_data')); - } catch (e) { - return false; - } - } - /// Check if address is Epicbox format. bool isEpicboxAddress(String address) { return address.contains('@'); @@ -726,7 +733,20 @@ class EpiccashWallet extends MimblewimbleWallet { recipient = recipient.copyWith(amount: recipient.amount - feeAmount); } - return txData.copyWith(recipients: [recipient], fee: feeAmount); + final SlatepackMethod method; + if (txData.isSlatepackSend || recipient.address.isEmpty) { + method = SlatepackMethod.slatepack; + } else if (isHttpAddress(recipient.address)) { + method = SlatepackMethod.http; + } else { + method = SlatepackMethod.relay; + } + + return txData.copyWith( + recipients: [recipient], + fee: feeAmount, + otherData: slatepackMethodOtherData(method), + ); } catch (e, s) { Logging.instance.e("Epic cash prepareSend", error: e, stackTrace: s); rethrow; diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index 994b8e3b7a..ec2af0633b 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -19,6 +19,7 @@ import '../../../models/slatepack_models.dart'; import '../../../pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/default_mwcmqs.dart'; +import '../../../utilities/enums/slatepack_method.dart'; import '../../../utilities/flutter_secure_storage_interface.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/stack_file_system.dart'; @@ -29,6 +30,7 @@ import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; import '../intermediate/mimblewimble_wallet.dart'; import '../supporting/mimblewimble_wallet_info_extension.dart'; +import '../wallet_mixin_interfaces/slatepack_interface.dart'; class MimblewimblecoinWallet extends MimblewimbleWallet { MimblewimblecoinWallet(CryptoCurrencyNetwork network) @@ -153,23 +155,27 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { // ================= Slatepack Operations =================================== + /// Encrypts to [recipientAddress] when one is given. [sendAll] is + /// ignored; prepareSend already took the fee off the amount. @override Future createSlatepack({ required Amount amount, String? recipientAddress, String? message, - bool encrypt = false, - int? minimumConfirmations, + bool sendAll = false, }) async { try { final String handle = await _ensureWalletOpen(); + final String? recipient = + recipientAddress == null || recipientAddress.isEmpty + ? null + : recipientAddress; // Generate S1 slate JSON. final String s1Json = await libMwc.txInit( wallet: handle, amount: amount.raw.toInt(), - minimumConfirmations: - minimumConfirmations ?? cryptoCurrency.minConfirms, + minimumConfirmations: cryptoCurrency.minConfirms, selectionStrategyIsAll: false, message: message ?? '', ); @@ -177,17 +183,22 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { // Encode to slatepack. final encoded = await libMwc.encodeSlatepack( slateJson: s1Json, - recipientAddress: recipientAddress, - encrypt: encrypt, + recipientAddress: recipient, + encrypt: recipient != null, wallet: handle, ); + final info = inspectSlateJsonString(s1Json); return SlatepackResult( success: true, slatepack: encoded.slatepack, slateJson: s1Json, wasEncrypted: encoded.wasEncrypted, recipientAddress: encoded.recipientAddress, + state: SlateState.s1, + slateId: info?.slateId, + amount: info?.amount ?? amount, + fee: info?.fee, ); } catch (e, s) { Logging.instance.e('Failed to create slatepack: $e\n$s'); @@ -227,7 +238,7 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { } @override - Future receiveSlatepack(String slatepack) async { + Future receiveSlatepack(String slatepack) async { try { final String handle = await _ensureWalletOpen(); @@ -251,17 +262,21 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { wallet: handle, ); - return ReceiveResult( + final info = inspectSlateJsonString(received.slateJson); + return SlatepackResult( success: true, - slateId: received.slateId, - commitId: received.commitId, - responseSlatepack: encoded.slatepack, + slatepack: encoded.slatepack, + slateJson: received.slateJson, wasEncrypted: encoded.wasEncrypted, recipientAddress: decoded.senderAddress, + state: SlateState.s2, + slateId: received.slateId, + amount: info?.amount, + fee: info?.fee, ); } catch (e, s) { Logging.instance.e('Failed to receive slatepack: $e\n$s'); - return ReceiveResult(success: false, error: e.toString()); + return SlatepackResult(success: false, error: e.toString()); } } @@ -286,6 +301,7 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { success: true, slateId: finalized.slateId, commitId: finalized.commitId, + posted: true, ); } catch (e, s) { Logging.instance.e('Failed to finalize slatepack: $e\n$s'); @@ -322,7 +338,6 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { return libMwc.validateSendAddress(address: address); } - @override bool isSlatepack(String data) { return data.trim().startsWith('BEGINSLATE') && (data.trim().endsWith('ENDSLATEPACK') || @@ -336,76 +351,6 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { return address.startsWith('mwcmqs://'); } - /// Improved transaction type detection for slatepacks. - /// This replaces "Unknown" types with better determined types based on slate analysis. - Future getSlatepackTransactionType(String address) async { - try { - // Check if the address is actually a slatepack - if (!isSlatepack(address)) { - return 'Unknown'; - } - - // Analyze the slatepack to determine the actual transaction type - final analysis = await analyzeSlatepack(address); - - // Map slate status to meaningful transaction types - switch (analysis.status) { - case 'S1': - return 'Outgoing'; // Initial send slate - this is outgoing - case 'S2': - return 'Incoming'; // Response slate - this means we're receiving - case 'S3': - return 'Outgoing'; // Finalized slate - completed outgoing transaction - default: - return analysis.type; // Fall back to our basic analysis - } - } catch (e) { - // If analysis fails, return Unknown - return 'Unknown'; - } - } - - /// Enhanced transaction type detection that can analyze slatepack transactions. - /// Use this method to improve "Unknown" transaction types after they're loaded. - Future getEnhancedTransactionType( - TransactionV2 transaction, - ) async { - try { - // If transaction is already properly typed, return as-is - if (transaction.type != TransactionType.unknown) { - return transaction.type; - } - - // Check if this is a MWC transaction with slatepack data - if (transaction.isMimblewimblecoinTransaction) { - // Try to analyze any slatepack addresses in the transaction - for (final OutputV2 output in transaction.outputs) { - for (final String address in output.addresses) { - if (isSlatepack(address)) { - final String slatepackType = await getSlatepackTransactionType( - address, - ); - switch (slatepackType) { - case 'Outgoing': - return TransactionType.outgoing; - case 'Incoming': - return TransactionType.incoming; - default: - continue; - } - } - } - } - } - - // If we can't determine a better type, return unknown - return TransactionType.unknown; - } catch (e) { - Logging.instance.w("Failed to enhance transaction type: $e"); - return transaction.type; - } - } - // ================= Private ================================================= Future _ensureApiSecret(String walletDir) async { @@ -736,16 +681,6 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { TxRecipient recipient = txData.recipients!.first; final String receiverAddress = recipient.address; - // Check if this is a slatepack being provided instead of an address. - if (isSlatepack(receiverAddress)) { - // For slatepack input, we need different handling - // This would be used for receiving/finalizing slatepacks. - return txData.copyWith( - fee: Amount.zeroWith(fractionDigits: cryptoCurrency.fractionDigits), - otherData: jsonEncode({'isSlatepackInput': true}), - ); - } - // For regular address-based sends, calculate fee final int realFee = await nativeFee(recipient.amount.raw.toInt()); final Amount feeAmount = Amount( @@ -768,20 +703,24 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { ); } - // Determine transaction method based on address format. - String txMethod = 'unknown'; - if (isMwcmqsAddress(receiverAddress)) { - txMethod = 'mwcmqs'; + // A plain MWC address means a slatepack encrypted to it. + final SlatepackMethod? method; + if (txData.isSlatepackSend || receiverAddress.isEmpty) { + method = SlatepackMethod.slatepack; + } else if (isMwcmqsAddress(receiverAddress)) { + method = SlatepackMethod.relay; } else if (isHttpAddress(receiverAddress)) { - txMethod = 'http'; + method = SlatepackMethod.http; } else if (validateMwcAddress(receiverAddress)) { - txMethod = 'slatepack'; // Manual slatepack exchange. + method = SlatepackMethod.slatepack; + } else { + method = null; } return txData.copyWith( recipients: [recipient], fee: feeAmount, - otherData: jsonEncode({'transactionMethod': txMethod}), + otherData: method == null ? null : slatepackMethodOtherData(method), ); } catch (e, s) { Logging.instance.e("Mimblewimblecoin prepareSend: $e\n$s"); @@ -948,30 +887,7 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { walletOwns: true, ); } else { - // For outgoing transactions, check if we have a slatepack address to analyze - TransactionType determinedType = TransactionType.outgoing; - - // Try to get better type determination for slatepack transactions - if (addressTo != null) { - try { - final String slatepackType = await getSlatepackTransactionType( - addressTo, - ); - if (slatepackType == 'Incoming') { - determinedType = TransactionType.incoming; - } else if (slatepackType == 'Outgoing') { - determinedType = TransactionType.outgoing; - } - // If slatepackType is 'Unknown', we keep the original outgoing type - } catch (e) { - // If analysis fails, keep original type determination - Logging.instance.w( - "Failed to analyze slatepack for better type detection: $e", - ); - } - } - - txType = determinedType; + txType = TransactionType.outgoing; } outputs.add(output); diff --git a/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart b/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart index 1d909f6b62..de2abe8ce3 100644 --- a/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart +++ b/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart @@ -8,6 +8,8 @@ import 'package:isar_community/isar.dart'; import 'package:mutex/mutex.dart'; import '../../../models/balance.dart'; +import '../../../models/isar/models/blockchain_data/transaction.dart'; +import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/paymint/fee_object_model.dart'; import '../../../models/relay_config_model.dart'; import '../../../models/slatepack_models.dart'; @@ -17,17 +19,19 @@ import '../../../services/event_bus/events/global/refresh_percent_changed_event. import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../../services/event_bus/global_event_bus.dart'; import '../../../utilities/amount/amount.dart'; +import '../../../utilities/enums/slatepack_method.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/stack_file_system.dart'; import '../../models/tx_data.dart'; import '../supporting/mimblewimble_wallet_info_extension.dart'; import '../supporting/restore_progress.dart'; +import '../wallet_mixin_interfaces/slatepack_interface.dart'; import 'bip39_wallet.dart'; /// What Epic Cash and MWC have in common: a native wallet that is scanned /// in block chunks, a relay server for automatic slate exchange, and manual /// slatepack exchange. -abstract class MimblewimbleWallet extends Bip39Wallet { +abstract class MimblewimbleWallet extends Bip39Wallet with SlatepackInterface { MimblewimbleWallet(super.currency); final Mutex syncMutex = Mutex(); @@ -84,166 +88,173 @@ abstract class MimblewimbleWallet extends Bip39Wallet { ); } - /// Returns an empty String on success, error message on failure. - Future cancelPendingTransactionAndPost(String txSlateId); - Future getRelayConfig(); Future updateRelayConfig(String host, int port); // ================= Slatepack Operations =================================== - Future createSlatepack({ - required Amount amount, - String? recipientAddress, - String? message, - bool encrypt = false, - int? minimumConfirmations, - }); + @override + List get supportedMethods => const [ + SlatepackMethod.slatepack, + SlatepackMethod.relay, + ]; - Future decodeSlatepack(String slatepack); + @override + bool get supportsOnChainNote => true; - /// Receive a slatepack and return the response slatepack. - Future receiveSlatepack(String slatepack); + @override + double get restoreProgress => highestPercent; - /// Finalize a slatepack (sender step 3). - Future finalizeSlatepack(String slatepack); + @override + Future slatepackAddress() async => + (await getCurrentReceivingAddress())?.value ?? ""; - bool isSlatepack(String data); + Future decodeSlatepack(String slatepack); bool isHttpAddress(String address) { return address.startsWith('http://') || address.startsWith('https://'); } - /// Cheap syntactic check run by [fullDecodeSlatepack] before decoding. + /// Cheap syntactic check run by [inspectSlatepack] before decoding. /// Throws to reject input that is obviously not a slatepack. @protected void checkSlatepackFormat(String slatepack) {} - // this isn't nearly the best way to handle this but better than copy pasting - // this function in two places (mobile and desktop widgets)... - Future<({SlatepackDecodeResult result, String type, String raw})?> - fullDecodeSlatepack(String slatepack) async { - // add delay for showloading exception catching hack fix - await Future.delayed(const Duration(seconds: 1)); - - if (slatepack.isEmpty) { - return null; + @override + Future inspectSlatepack(String armored) async { + final String text = armored.trim(); + if (text.isEmpty) { + throw Exception("No ${slatepackTerms.noun} entered"); } - checkSlatepackFormat(slatepack); + checkSlatepackFormat(text); - final SlatepackDecodeResult decoded = await decodeSlatepack(slatepack); - if (!decoded.success) { - throw Exception(decoded.error ?? "Failed to decode slatepack"); + final SlatepackDecodeResult decoded = await decodeSlatepack(text); + if (!decoded.success || decoded.slateJson == null) { + throw Exception( + decoded.error ?? "Failed to decode ${slatepackTerms.noun}", + ); } - final analysis = await analyzeSlatepack(slatepack); - - final String slatepackType = switch (analysis.status) { - 'S1' => "S1 (Initial Send)", - 'S2' => "S2 (Response)", - 'S3' => "S3 (Finalized)", - _ => _slatepackTypeFromAddresses(decoded), - }; - - return (result: decoded, type: slatepackType, raw: slatepack); - } - - String _slatepackTypeFromAddresses(SlatepackDecodeResult decoded) { - if (decoded.senderAddress != null && decoded.recipientAddress != null) { - return "S2 (Response)"; - } else if (decoded.senderAddress != null) { - return "S1 (Initial)"; - } else { - return "Unknown"; + final dynamic json = jsonDecode(decoded.slateJson!); + if (json is! Map) { + throw Exception("Not a ${slatepackTerms.noun}"); } + + final inspection = inspectSlateJson( + json, + fractionDigits: cryptoCurrency.fractionDigits, + isEncrypted: decoded.wasEncrypted ?? false, + senderAddress: decoded.senderAddress, + recipientAddress: decoded.recipientAddress, + ); + Logging.instance.d("Inspected ${slatepackTerms.noun}: $inspection"); + return inspection; } - /// Analyze a slatepack and determine transaction type and metadata. - Future< - ({ - String type, - String status, - String? amount, - bool wasEncrypted, - String? senderAddress, - String? recipientAddress, - String slateId, - }) - > - analyzeSlatepack(String slatepack) async { - try { - final SlatepackDecodeResult decoded = await decodeSlatepack(slatepack); - if (!decoded.success) { - throw Exception(decoded.error); + /// Works out the state of an Epic Cash or MWC slate from its JSON. Neither + /// marks the state reliably, so it counts partial signatures and falls + /// back to the `sta` field and then to the addresses. + @visibleForTesting + static SlateInspection inspectSlateJson( + Map json, { + required int fractionDigits, + bool isEncrypted = false, + String? senderAddress, + String? recipientAddress, + }) { + SlateState state = SlateState.unknown; + + final dynamic participants = json['participant_data']; + if (participants is List && participants.isNotEmpty) { + int signed = 0; + for (final dynamic p in participants) { + if (p is Map && p['part_sig'] != null) { + signed++; + } + } + if (signed == 0) { + state = SlateState.s1; + } else if (signed == 1) { + state = SlateState.s2; + } else if (signed >= participants.length) { + state = SlateState.s3; } + } - final dynamic slateData = jsonDecode(decoded.slateJson!); - final String slateId = "${slateData['id'] ?? ''}"; - final String? amountStr = slateData['amount']?.toString(); - - Logging.instance.d('Analyzed slatepack with ID: $slateId'); - - String status = 'Unknown'; - String type = 'Unknown'; - - // Count signatures in the participant data to work out which step - // the slate is at. - final List? participants = - slateData['participant_data'] as List?; - if (participants != null && participants.isNotEmpty) { - int signedParticipants = 0; - for (final dynamic participant in participants) { - if (participant['part_sig'] != null) { - signedParticipants++; - } - } + if (state == SlateState.unknown && json['sta'] != null) { + state = SlateState.fromCode(json['sta'].toString()); + } - if (signedParticipants == 0) { - status = 'S1'; - type = 'Outgoing'; - } else if (signedParticipants == 1) { - status = 'S2'; - type = 'Incoming'; - } else if (signedParticipants >= participants.length) { - status = 'S3'; - type = 'Outgoing'; - } + if (state == SlateState.unknown) { + if (senderAddress != null && recipientAddress != null) { + state = SlateState.s2; + } else if (senderAddress != null) { + state = SlateState.s1; } + } - // Fallback: some slates carry an explicit 'sta' field. - if (status == 'Unknown' && slateData['sta'] != null) { - status = "${slateData['sta']}"; - if (status == 'S1') { - type = 'Outgoing'; - } else if (status == 'S2') { - type = 'Incoming'; - } else if (status == 'S3') { - type = 'Outgoing'; - } - } + Amount? parseAmount(dynamic raw) { + final value = BigInt.tryParse(raw?.toString() ?? ""); + return value == null + ? null + : Amount(rawValue: value, fractionDigits: fractionDigits); + } - return ( - type: type, - status: status, - amount: amountStr, - wasEncrypted: decoded.wasEncrypted ?? false, - senderAddress: decoded.senderAddress, - recipientAddress: decoded.recipientAddress, - slateId: slateId, - ); - } catch (e) { - return ( - type: 'Unknown', - status: 'Unknown', - amount: null, - wasEncrypted: false, - senderAddress: null, - recipientAddress: null, - slateId: '', + return SlateInspection( + state: state, + slateId: (json['id'] ?? '').toString(), + amount: parseAmount(json['amount'] ?? json['amt']), + fee: parseAmount(json['fee']), + senderAddress: senderAddress, + recipientAddress: recipientAddress, + isEncrypted: isEncrypted, + ttlCutoffHeight: int.tryParse( + (json['ttl_cutoff_height'] ?? json['ttl'] ?? '').toString(), + ), + ); + } + + /// State, id, amount and fee for a [SlatepackResult], from the slate JSON. + @protected + SlateInspection? inspectSlateJsonString(String? slateJson) { + if (slateJson == null) return null; + try { + final dynamic json = jsonDecode(slateJson); + if (json is! Map) return null; + return inspectSlateJson( + json, + fractionDigits: cryptoCurrency.fractionDigits, ); + } catch (_) { + return null; + } + } + + @override + Future<({Amount amount, Amount fee})?> localTxForSlate(String slateId) async { + if (slateId.isEmpty) return null; + + final List candidates = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .otherDataContains(slateId) + .findAll(); + + for (final TransactionV2 tx in candidates) { + if (tx.slateId != slateId) continue; + final int digits = cryptoCurrency.fractionDigits; + final Amount amount = tx.type == TransactionType.incoming + ? tx.getAmountReceivedInThisWallet(fractionDigits: digits) + : tx.getAmountSentFromThisWallet( + fractionDigits: digits, + subtractFee: true, + ); + return (amount: amount, fee: tx.getFee(fractionDigits: digits)); } + return null; } // ================= Native wallet hooks ===================================== diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart new file mode 100644 index 0000000000..7b19c01461 --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import '../../../models/slatepack_models.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/enums/slatepack_method.dart'; +import '../../../utilities/slatepack_terms.dart'; +import '../../crypto_currency/crypto_currency.dart'; +import '../../models/tx_data.dart'; +import '../wallet.dart'; + +/// Slatepack exchange shared by the Mimblewimble coins. The slatepack +/// screens talk to this instead of a concrete wallet type. +/// +/// The optional members (invoices, proofs, repost) default to unsupported so +/// Epic Cash and MWC only implement the standard send/receive round trip. +mixin SlatepackInterface on Wallet { + /// Methods the send and receive screens offer. The first is the default. + List get supportedMethods; + + /// Whether a send can carry a message that ends up on chain. + bool get supportsOnChainNote; + + bool get supportsInvoices => false; + + bool get supportsPaymentProofs => false; + + /// "slate" or "slatepack" wording for this coin. + SlatepackTerms get slatepackTerms => SlatepackTerms.slatepack; + + /// Restore or scan progress from 0 to 1. + double get restoreProgress; + + /// The address another wallet encrypts a slatepack to. + Future slatepackAddress(); + + /// Decode [armored] and work out its state without changing the wallet. + /// Throws when the text cannot be decoded at all. + Future inspectSlatepack(String armored); + + /// Amount and fee of this wallet's transaction for [slateId], or null when + /// the wallet has none. + Future<({Amount amount, Amount fee})?> localTxForSlate(String slateId); + + /// Start a send (S1). [message] is dropped when [supportsOnChainNote] is + /// false. [recipientAddress] encrypts the slatepack to that address where + /// the coin supports it. + Future createSlatepack({ + required Amount amount, + String? recipientAddress, + String? message, + bool sendAll = false, + }); + + /// Answer an S1 with an S2. + Future receiveSlatepack(String s1); + + /// Finalize an S2 from the recipient and post it. + Future finalizeSlatepack(String s2); + + /// Returns an empty String on success, error message on failure. + Future cancelPendingTransactionAndPost(String slateId); + + /// The stored S1 or I1 for an unfinished transaction, to show again. + Future pendingSlatepackFor(String txid) async => null; + + bool get supportsRepost => false; + + Future repost(String txid) => throw UnsupportedError("repost"); + + Future issueInvoice({ + required Amount amount, + String? payerAddress, + }) => throw UnsupportedError("invoices"); + + Future processInvoice( + String i1, { + required Amount confirmedAmount, + }) => throw UnsupportedError("invoices"); + + Future finalizeInvoice(String i2) => + throw UnsupportedError("invoices"); + + Future exportPaymentProof(String txid) => + throw UnsupportedError("proofs"); +} + +/// Key in [TxData.otherData] that tells the confirm screen how to send. +const kSlatepackMethodKey = "transactionMethod"; + +String slatepackMethodOtherData(SlatepackMethod method) => + jsonEncode({kSlatepackMethodKey: method.name}); + +extension SlatepackTxData on TxData { + SlatepackMethod? get slatepackMethod { + if (otherData == null) return null; + try { + final map = jsonDecode(otherData!); + if (map is Map) { + return SlatepackMethod.fromName(map[kSlatepackMethodKey] as String?); + } + } catch (_) {} + return null; + } + + bool get isSlatepackSend => slatepackMethod == SlatepackMethod.slatepack; +} diff --git a/lib/widgets/slatepack_method_toggle.dart b/lib/widgets/slatepack_method_toggle.dart index ab86e93adf..4819291bb1 100644 --- a/lib/widgets/slatepack_method_toggle.dart +++ b/lib/widgets/slatepack_method_toggle.dart @@ -11,43 +11,49 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/global/wallets_provider.dart'; import '../providers/ui/preview_tx_button_state_provider.dart'; import '../themes/stack_colors.dart'; import '../utilities/assets.dart'; import '../utilities/constants.dart'; -import '../utilities/enums/mimblewimble_transaction_method.dart'; +import '../utilities/enums/slatepack_method.dart'; import '../utilities/util.dart'; -import '../wallets/crypto_currency/crypto_currency.dart'; +import '../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; import 'toggle.dart'; +/// Switches a slatepack wallet between manual slatepacks (left) and its +/// other method (right): the relay for Epic Cash and MWC. class SlatepackMethodToggle extends ConsumerWidget { - const SlatepackMethodToggle({super.key, required this.coin}); + const SlatepackMethodToggle({super.key, required this.walletId}); - final CryptoCurrency coin; + final String walletId; @override Widget build(BuildContext context, WidgetRef ref) { debugPrint("BUILD: $runtimeType"); final bool isDesktop = Util.isDesktop; + final wallet = + ref.watch(pWallets).getWallet(walletId) as SlatepackInterface; + final SlatepackMethod other = wallet.supportedMethods.firstWhere( + (m) => m != SlatepackMethod.slatepack, + ); return Toggle( onValueChanged: (bool value) { - ref.read(pSelectedSlatepackMethod(coin).notifier).state = value - ? MimblewimbleTransactionMethod.relay - : MimblewimbleTransactionMethod.slatepack; + ref.read(pSelectedSlatepackMethod(walletId).notifier).state = value + ? other + : SlatepackMethod.slatepack; }, - isOn: - ref.watch(pSelectedSlatepackMethod(coin)) == - MimblewimbleTransactionMethod.relay, + isOn: ref.watch(pSelectedSlatepackMethod(walletId)) == other, onColor: isDesktop - ? Theme.of( - context, - ).extension()!.rateTypeToggleDesktopColorOn + ? Theme.of(context) + .extension()! + .rateTypeToggleDesktopColorOn : Theme.of(context).extension()!.rateTypeToggleColorOn, offColor: isDesktop - ? Theme.of( - context, - ).extension()!.rateTypeToggleDesktopColorOff + ? Theme.of(context) + .extension()! + .rateTypeToggleDesktopColorOff : Theme.of(context).extension()!.rateTypeToggleColorOff, decoration: BoxDecoration( borderRadius: BorderRadius.circular( @@ -55,9 +61,9 @@ class SlatepackMethodToggle extends ConsumerWidget { ), ), onIcon: Assets.svg.gear, - onText: "Slatepack", + onText: SlatepackMethod.slatepack.label, offIcon: Assets.svg.radioSyncing, - offText: "Automatic", + offText: other.label, ); } } From 05863bc76fc073c320e18036d0bd11fa8be99291 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Sat, 7 Nov 2026 12:27:30 -0600 Subject: [PATCH 09/30] Test slatepack routing, slate inspection and the QR length guard --- .../slatepack/slatepack_import_flow_test.dart | 188 ++++++++++++++++++ test/pages/slatepack/slatepack_qr_test.dart | 67 +++++++ .../mimblewimble_slate_inspection_test.dart | 92 +++++++++ 3 files changed, 347 insertions(+) create mode 100644 test/pages/slatepack/slatepack_import_flow_test.dart create mode 100644 test/pages/slatepack/slatepack_qr_test.dart create mode 100644 test/wallets/mimblewimble_slate_inspection_test.dart diff --git a/test/pages/slatepack/slatepack_import_flow_test.dart b/test/pages/slatepack/slatepack_import_flow_test.dart new file mode 100644 index 0000000000..376642d50b --- /dev/null +++ b/test/pages/slatepack/slatepack_import_flow_test.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/models/slatepack_models.dart'; +import 'package:stackwallet/pages/slatepack/slatepack_import_flow.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; + +import '../../sample_data/theme_json.dart'; + +/// Epic Cash wallet whose inspection is canned, so run() can be driven +/// without a native wallet. +class _InspectOnlyWallet extends EpiccashWallet { + _InspectOnlyWallet(this.state, {this.invoices = false}) + : super(CryptoCurrencyNetwork.main); + + final SlateState state; + final bool invoices; + int receiveCalls = 0; + int finalizeCalls = 0; + + @override + bool get supportsInvoices => invoices; + + @override + Future inspectSlatepack(String armored) async => + SlateInspection(state: state, slateId: "test-slate"); + + @override + Future receiveSlatepack(String slatepack) async { + receiveCalls++; + return SlatepackResult(success: false, error: "not in tests"); + } + + @override + Future finalizeSlatepack(String slatepack) async { + finalizeCalls++; + return FinalizeResult(success: false, error: "not in tests"); + } +} + +void main() { + group("routeFor", () { + test("standard send states", () { + for (final invoices in [true, false]) { + expect( + SlatepackImportFlow.routeFor( + SlateState.s1, + supportsInvoices: invoices, + ), + SlatepackRoute.receive, + ); + expect( + SlatepackImportFlow.routeFor( + SlateState.s2, + supportsInvoices: invoices, + ), + SlatepackRoute.finalizeSend, + ); + expect( + SlatepackImportFlow.routeFor( + SlateState.s3, + supportsInvoices: invoices, + ), + SlatepackRoute.alreadyFinalized, + ); + expect( + SlatepackImportFlow.routeFor( + SlateState.i3, + supportsInvoices: invoices, + ), + SlatepackRoute.alreadyFinalized, + ); + expect( + SlatepackImportFlow.routeFor( + SlateState.unknown, + supportsInvoices: invoices, + ), + SlatepackRoute.unsupported, + ); + } + }); + + test("invoice states depend on supportsInvoices", () { + expect( + SlatepackImportFlow.routeFor(SlateState.i1, supportsInvoices: true), + SlatepackRoute.payInvoice, + ); + expect( + SlatepackImportFlow.routeFor(SlateState.i2, supportsInvoices: true), + SlatepackRoute.finalizeInvoice, + ); + expect( + SlatepackImportFlow.routeFor(SlateState.i1, supportsInvoices: false), + SlatepackRoute.invoicesUnsupported, + ); + expect( + SlatepackImportFlow.routeFor(SlateState.i2, supportsInvoices: false), + SlatepackRoute.invoicesUnsupported, + ); + }); + }); + + test("SlateState.fromCode", () { + expect(SlateState.fromCode("S1"), SlateState.s1); + expect(SlateState.fromCode("i2"), SlateState.i2); + expect(SlateState.fromCode(" S3 "), SlateState.s3); + expect(SlateState.fromCode("X9"), SlateState.unknown); + expect(SlateState.fromCode(null), SlateState.unknown); + }); + + group("run", () { + Future runFlow( + WidgetTester tester, + _InspectOnlyWallet wallet, + ) async { + bool? outcome; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + outcome = await SlatepackImportFlow.run( + context: context, + wallet: wallet, + text: "{}", + ); + }, + child: const Text("go"), + ), + ), + ), + ), + ); + await tester.tap(find.text("go")); + // Loading overlay delay, then the inspection. + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + return outcome; + } + + Future closeDialog(WidgetTester tester) async { + await tester.tap(find.text("OK")); + await tester.pump(const Duration(milliseconds: 400)); + } + + testWidgets("finalized slate shows a message", (tester) async { + final wallet = _InspectOnlyWallet(SlateState.s3); + await runFlow(tester, wallet); + expect(find.text("Already finalized"), findsOneWidget); + await closeDialog(tester); + expect(wallet.receiveCalls + wallet.finalizeCalls, 0); + }); + + testWidgets("unknown slate is unsupported", (tester) async { + final wallet = _InspectOnlyWallet(SlateState.unknown); + await runFlow(tester, wallet); + expect(find.text("Unsupported slate"), findsOneWidget); + await closeDialog(tester); + }); + + testWidgets("invoice without invoice support", (tester) async { + final wallet = _InspectOnlyWallet(SlateState.i1); + await runFlow(tester, wallet); + expect(find.text("Invoices not supported"), findsOneWidget); + await closeDialog(tester); + }); + + testWidgets("invoice with invoice support is a placeholder", ( + tester, + ) async { + final wallet = _InspectOnlyWallet(SlateState.i2, invoices: true); + await runFlow(tester, wallet); + expect(find.text("Invoice payment"), findsOneWidget); + await closeDialog(tester); + }); + }); +} diff --git a/test/pages/slatepack/slatepack_qr_test.dart b/test/pages/slatepack/slatepack_qr_test.dart new file mode 100644 index 0000000000..c763ea648b --- /dev/null +++ b/test/pages/slatepack/slatepack_qr_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/slatepack/slatepack_qr.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/widgets/qr.dart'; + +import '../../sample_data/theme_json.dart'; + +Widget _app(Widget child) => MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold(body: SingleChildScrollView(child: child)), +); + +void main() { + test("length guard", () { + expect(slatepackFitsQr("a" * kSlatepackQrMaxLength), isTrue); + expect(slatepackFitsQr("a" * (kSlatepackQrMaxLength + 1)), isFalse); + // Measured Grin lengths: an encrypted S1 fits, an S2 does not. + expect(slatepackFitsQr("a" * 955), isTrue); + expect(slatepackFitsQr("a" * 1837), isFalse); + }); + + testWidgets("short slatepack shows a QR code", (tester) async { + await tester.pumpWidget( + _app( + const SlatepackQr( + data: "BEGINSLATEPACK. abc. ENDSLATEPACK.", + noun: "slatepack", + ), + ), + ); + expect(find.byKey(const Key("slatepackQrCode")), findsOneWidget); + expect(find.byType(QR), findsOneWidget); + expect(find.byKey(const Key("slatepackQrTooLong")), findsNothing); + }); + + testWidgets("tap enlarges the QR code", (tester) async { + await tester.pumpWidget( + _app(const SlatepackQr(data: "short", noun: "slatepack")), + ); + await tester.tap(find.byKey(const Key("slatepackQrCode"))); + await tester.pumpAndSettle(); + expect(find.byType(QR), findsNWidgets(2)); + }); + + testWidgets("long slatepack falls back to a note", (tester) async { + await tester.pumpWidget( + _app(SlatepackQr(data: "a" * (kSlatepackQrMaxLength + 1), noun: "slate")), + ); + expect(find.byType(QR), findsNothing); + expect(find.byKey(const Key("slatepackQrTooLong")), findsOneWidget); + expect(find.textContaining("too long for a QR code"), findsOneWidget); + }); + + testWidgets("QR shows a message when data does not fit", (tester) async { + await tester.pumpWidget(_app(QR(data: "x" * 5000, size: 200))); + await tester.pump(); + expect(find.text("Too much data to show as a QR code"), findsOneWidget); + }); +} diff --git a/test/wallets/mimblewimble_slate_inspection_test.dart b/test/wallets/mimblewimble_slate_inspection_test.dart new file mode 100644 index 0000000000..77f581e69f --- /dev/null +++ b/test/wallets/mimblewimble_slate_inspection_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/slatepack_models.dart'; +import 'package:stackwallet/utilities/enums/slatepack_method.dart'; +import 'package:stackwallet/utilities/slatepack_terms.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import 'package:stackwallet/wallets/wallet/intermediate/mimblewimble_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; + +Map _slate({required int signatures, int participants = 2}) { + return { + "id": "0436430c-2b02-624c-2032-570501212b00", + "amount": "150000000", + "fee": "8000000", + "ttl_cutoff_height": "123456", + "participant_data": [ + for (int i = 0; i < participants; i++) + {"id": "$i", "part_sig": i < signatures ? "sig$i" : null}, + ], + }; +} + +void main() { + SlateInspection inspect( + Map json, { + String? sender, + String? recipient, + }) => MimblewimbleWallet.inspectSlateJson( + json, + fractionDigits: 8, + senderAddress: sender, + recipientAddress: recipient, + ); + + test("state from partial signatures", () { + expect(inspect(_slate(signatures: 0)).state, SlateState.s1); + expect(inspect(_slate(signatures: 1)).state, SlateState.s2); + expect(inspect(_slate(signatures: 2)).state, SlateState.s3); + }); + + test("falls back to sta, then to addresses", () { + expect(inspect({"id": "a", "sta": "I1"}).state, SlateState.i1); + expect( + inspect({"id": "a"}, sender: "s", recipient: "r").state, + SlateState.s2, + ); + expect(inspect({"id": "a"}, sender: "s").state, SlateState.s1); + expect(inspect({"id": "a"}).state, SlateState.unknown); + }); + + test("id, amount, fee and ttl", () { + final i = inspect(_slate(signatures: 0)); + expect(i.slateId, "0436430c-2b02-624c-2032-570501212b00"); + expect(i.amount!.raw, BigInt.from(150000000)); + expect(i.fee!.raw, BigInt.from(8000000)); + expect(i.ttlCutoffHeight, 123456); + expect(inspect({"id": "a", "amt": 5}).amount!.raw, BigInt.from(5)); + expect(inspect({"id": "a"}).amount, isNull); + }); + + test("Epic and MWC capabilities", () { + final epic = EpiccashWallet(CryptoCurrencyNetwork.main); + final mwc = MimblewimblecoinWallet(CryptoCurrencyNetwork.main); + + expect(epic.supportedMethods.first, SlatepackMethod.relay); + expect(mwc.supportedMethods.first, SlatepackMethod.slatepack); + for (final SlatepackInterface w in [epic, mwc]) { + expect(w.supportedMethods, contains(SlatepackMethod.slatepack)); + expect(w.supportsOnChainNote, isTrue); + expect(w.supportsInvoices, isFalse); + expect(w.supportsRepost, isFalse); + } + expect(epic.slatepackTerms, SlatepackTerms.slate); + expect(mwc.slatepackTerms, SlatepackTerms.slatepack); + }); + + test("slatepack method rides in TxData.otherData", () { + final tx = TxData( + otherData: slatepackMethodOtherData(SlatepackMethod.slatepack), + ); + expect(tx.isSlatepackSend, isTrue); + expect( + TxData(otherData: slatepackMethodOtherData(SlatepackMethod.relay)) + .isSlatepackSend, + isFalse, + ); + expect(TxData().slatepackMethod, isNull); + expect(TxData(otherData: "not json").slatepackMethod, isNull); + }); +} From 1fa7aca7e1df1cd6415fea67efc95ba7a5ccbcb5 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 14:29:18 -0600 Subject: [PATCH 10/30] Keep local notes when creating a slatepack --- .../send_view/confirm_transaction_view.dart | 28 +++ .../slatepack_confirmation_test.dart | 164 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 test/pages/send_view/slatepack_confirmation_test.dart diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 7e1326c909..f274d94224 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -163,6 +163,8 @@ class _ConfirmTransactionViewState final terms = wallet.slatepackTerms; final recipient = widget.txData.recipients!.first; final note = onChainNoteController.text; + final localNote = noteController.text; + final db = ref.read(mainDBProvider); final result = await wallet.createSlatepack( amount: recipient.amount, @@ -173,10 +175,36 @@ class _ConfirmTransactionViewState throw Exception(result.error ?? "Failed to create ${terms.noun}"); } + var noteSaved = localNote.isEmpty; + if (!noteSaved && result.slateId != null) { + try { + await db.putTransactionNote( + TransactionNote( + walletId: walletId, + txid: result.slateId!, + value: localNote, + ), + ); + noteSaved = true; + } catch (_) { + // The slate already exists and still needs to reach the recipient. + } + } + unawaited(wallet.refresh()); closeSendingDialog(); if (context.mounted) { + if (!noteSaved) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Transaction created, but the local note could not be saved.", + context: context, + ), + ); + } await showSlatepackDialog(context: context, terms: terms, result: result); } diff --git a/test/pages/send_view/slatepack_confirmation_test.dart b/test/pages/send_view/slatepack_confirmation_test.dart new file mode 100644 index 0000000000..7c6bda018a --- /dev/null +++ b/test/pages/send_view/slatepack_confirmation_test.dart @@ -0,0 +1,164 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/models/transaction_note.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/models/slatepack_models.dart'; +import 'package:stackwallet/pages/send_view/confirm_transaction_view.dart'; +import 'package:stackwallet/pages/slatepack/slatepack_dialog.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/enums/slatepack_method.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; + +import '../../sample_data/theme_json.dart'; +import '../../wallets/support/xelis_test_fakes.dart'; + +class _Info extends Fake implements WalletInfo { + _Info(this.coin); + @override + final CryptoCurrency coin; +} + +class _Wallet extends EpiccashWallet { + _Wallet() : super(CryptoCurrencyNetwork.main); + int created = 0; + @override + WalletInfo get info => _Info(cryptoCurrency); + @override + Future refresh({bool doScan = true}) async {} + @override + Future createSlatepack({ + required Amount amount, + String? recipientAddress, + String? message, + bool sendAll = false, + }) async { + created++; + return SlatepackResult( + success: true, + slatepack: '{}', + slateId: 'slate-1', + state: SlateState.s1, + ); + } +} + +class _Notes extends Fake implements MainDB { + _Notes({required this.fail}); + final bool fail; + final notes = []; + @override + Future putTransactionNote(TransactionNote note) async { + if (fail) throw StateError('note unavailable'); + notes.add(note); + } +} + +void main() { + for (final failNote in [false, true]) { + testWidgets('slate send keeps its result with note failure=$failNote', ( + tester, + ) async { + tester.view.physicalSize = const Size(1400, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final previousThemes = StackFileSystem.themesDir; + StackFileSystem.themesDir = Directory('test/sample_data').absolute; + addTearDown(() => StackFileSystem.themesDir = previousThemes); + final wallet = _Wallet(); + final coin = wallet.cryptoCurrency; + final notes = _Notes(fail: failNote); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + var succeeded = false; + await tester.pumpWidget( + ProviderScope( + overrides: [ + mainDBProvider.overrideWithValue(notes), + pWallets.overrideWithValue(ConfirmationWallets(wallet)), + pWalletCoin('wallet-1').overrideWithValue(coin), + prefsChangeNotifierProvider.overrideWithValue(ConfirmationPrefs()), + themeProvider.overrideWithProvider(StateProvider((ref) => theme)), + pAmountFormatter(coin).overrideWithValue( + AmountFormatter( + unit: AmountUnit.normal, + locale: 'en_US', + coin: coin, + maxDecimals: coin.fractionDigits, + ), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: Scaffold( + body: ConfirmTransactionView( + walletId: 'wallet-1', + onSuccess: () => succeeded = true, + onSuccessInsteadOfRouteOnSuccess: () {}, + txData: TxData( + note: 'local note', + fee: Amount(rawValue: BigInt.one, fractionDigits: 8), + recipients: [ + TxRecipient( + address: '', + amount: Amount( + rawValue: BigInt.from(100), + fractionDigits: 8, + ), + isChange: false, + addressType: AddressType.mimbleWimble, + ), + ], + otherData: slatepackMethodOtherData( + SlatepackMethod.slatepack, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Send')); + await tester.tap(find.text('Send')); + await tester.pumpAndSettle(); + Navigator.of(tester.element(find.byType(DesktopAuthSend))).pop(true); + await tester.pumpAndSettle(); + expect(wallet.created, 1); + expect(find.byType(SlatepackDialog), findsOneWidget); + if (!failNote) { + expect(notes.notes, hasLength(1)); + expect(notes.notes.single.walletId, 'wallet-1'); + expect(notes.notes.single.txid, 'slate-1'); + expect(notes.notes.single.value, 'local note'); + } else { + expect(notes.notes, isEmpty); + } + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + expect(succeeded, isTrue); + await tester.pump(const Duration(seconds: 3)); + expect(tester.takeException(), isNull); + }); + } +} From 4d0adec71434d419ad45adafdd4b556c2f1c7e88 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 14:29:18 -0600 Subject: [PATCH 11/30] Ignore slatepack input results after the screen closes --- .../slatepack/slatepack_input_field.dart | 1 + .../slatepack/slatepack_input_field_test.dart | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 test/pages/slatepack/slatepack_input_field_test.dart diff --git a/lib/pages/slatepack/slatepack_input_field.dart b/lib/pages/slatepack/slatepack_input_field.dart index 4afbe7c80e..154061f86a 100644 --- a/lib/pages/slatepack/slatepack_input_field.dart +++ b/lib/pages/slatepack/slatepack_input_field.dart @@ -55,6 +55,7 @@ class _SlatepackInputFieldState extends ConsumerState { bool get _isEmpty => widget.controller.text.isEmpty; void _set(String text) { + if (!mounted) return; widget.controller.text = text; widget.onChanged?.call(text); setState(() {}); diff --git a/test/pages/slatepack/slatepack_input_field_test.dart b/test/pages/slatepack/slatepack_input_field_test.dart new file mode 100644 index 0000000000..5104771e8c --- /dev/null +++ b/test/pages/slatepack/slatepack_input_field_test.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/slatepack/slatepack_input_field.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/clipboard_interface.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; + +import '../../sample_data/theme_json.dart'; + +class _Clipboard extends Fake implements ClipboardInterface { + final response = Completer(); + + @override + Future getData(String format) => response.future; +} + +void main() { + testWidgets('paste completes after the input closes', (tester) async { + final previousThemes = StackFileSystem.themesDir; + StackFileSystem.themesDir = Directory('test/sample_data').absolute; + addTearDown(() => StackFileSystem.themesDir = previousThemes); + final controller = TextEditingController(); + final clipboard = _Clipboard(); + final changes = []; + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + await tester.pumpWidget( + ProviderScope( + overrides: [ + themeProvider.overrideWithProvider(StateProvider((ref) => theme)), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: Scaffold( + body: SlatepackInputField( + controller: controller, + hint: 'Slatepack', + clipboard: clipboard, + onChanged: changes.add, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('slatepackPasteButton'))); + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + clipboard.response.complete(const ClipboardData(text: 'slatepack')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(changes, isEmpty); + }); +} From dde3a997d56984bf5ae5850d884b48f47bd8c434 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 17:17:04 -0600 Subject: [PATCH 12/30] Show an error when the Epicbox relay cannot be reached The pre-send relay check awaited sink.close() in a finally block. After a failed connection that future never completes, so a send to an unreachable or misconfigured relay left the sending dialog up forever instead of reporting that the relay could not be reached. Move the check into probeEpicboxServer, close the channel without waiting, and bound the connect with a timeout. --- lib/wallets/wallet/impl/epiccash_wallet.dart | 17 +----- .../wallet/supporting/epicbox_probe.dart | 23 ++++++++ test/wallets/epicbox_probe_test.dart | 59 +++++++++++++++++++ 3 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 lib/wallets/wallet/supporting/epicbox_probe.dart create mode 100644 test/wallets/epicbox_probe_test.dart diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index a3ac6b96ff..c27953a2c2 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -5,7 +5,6 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/foundation.dart'; import 'package:isar_community/isar.dart'; import 'package:stack_wallet_backup/generate_password.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; import '../../../exceptions/main_db/main_db_exception.dart'; import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; @@ -31,6 +30,7 @@ import '../../crypto_currency/crypto_currency.dart'; import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; import '../intermediate/mimblewimble_wallet.dart'; +import '../supporting/epicbox_probe.dart'; import '../supporting/mimblewimble_wallet_info_extension.dart'; // @@ -343,19 +343,8 @@ class EpiccashWallet extends MimblewimbleWallet { _hackedCheckTorNodePrefs(); final String host = epicboxConfig.host; final int port = epicboxConfig.port ?? 443; - WebSocketChannel? channel; try { - final Uri uri = Uri.parse('wss://$host:$port'); - - channel = WebSocketChannel.connect(uri); - - await channel.ready; - - final dynamic response = await channel.stream.first.timeout( - const Duration(seconds: 2), - ); - - return response is String && response.contains("Challenge"); + return await probeEpicboxServer(Uri.parse('wss://$host:$port')); } catch (e, s) { Logging.instance.w( "_testEpicboxServer failed on \"$host:$port\"", @@ -363,8 +352,6 @@ class EpiccashWallet extends MimblewimbleWallet { stackTrace: s, ); return false; - } finally { - await channel?.sink.close(); } } diff --git a/lib/wallets/wallet/supporting/epicbox_probe.dart b/lib/wallets/wallet/supporting/epicbox_probe.dart new file mode 100644 index 0000000000..b7370ae079 --- /dev/null +++ b/lib/wallets/wallet/supporting/epicbox_probe.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Connects to an Epicbox relay at [uri] and returns whether it greets with +/// a challenge. Throws if the connection fails or times out. +Future probeEpicboxServer( + Uri uri, { + Duration connectTimeout = const Duration(seconds: 10), + Duration replyTimeout = const Duration(seconds: 2), +}) async { + WebSocketChannel? channel; + try { + channel = WebSocketChannel.connect(uri); + await channel.ready.timeout(connectTimeout); + final dynamic response = await channel.stream.first.timeout(replyTimeout); + return response is String && response.contains("Challenge"); + } finally { + // After a failed connection sink.close() never completes, so awaiting it + // here would leave the caller (a send in progress) waiting forever. + channel?.sink.close().ignore(); + } +} diff --git a/test/wallets/epicbox_probe_test.dart b/test/wallets/epicbox_probe_test.dart new file mode 100644 index 0000000000..ed1683c3fc --- /dev/null +++ b/test/wallets/epicbox_probe_test.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/supporting/epicbox_probe.dart'; + +void main() { + test('accepts a relay that greets with a challenge', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + final socket = await WebSocketTransformer.upgrade(request); + socket.add('{"type":"Challenge","str":"abc"}'); + }); + + expect( + await probeEpicboxServer(Uri.parse('ws://127.0.0.1:${server.port}')), + isTrue, + ); + }); + + test('rejects a relay that greets with something else', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + final socket = await WebSocketTransformer.upgrade(request); + socket.add('hello'); + }); + + expect( + await probeEpicboxServer(Uri.parse('ws://127.0.0.1:${server.port}')), + isFalse, + ); + }); + + test('fails promptly when nothing listens on the port', () async { + final reserved = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = reserved.port; + await reserved.close(); + + await expectLater( + probeEpicboxServer(Uri.parse('ws://127.0.0.1:$port')) + .timeout(const Duration(seconds: 15)), + throwsA(isNot(isA())), + ); + }); + + test('fails promptly on a TLS handshake with a plain server', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) => request.response.close()); + + await expectLater( + probeEpicboxServer(Uri.parse('wss://127.0.0.1:${server.port}')) + .timeout(const Duration(seconds: 15)), + throwsA(isNot(isA())), + ); + }); +} From 29f45a9520e7174f0e883860de0306c37da06e18 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 17:17:04 -0600 Subject: [PATCH 13/30] Show the on-chain note from the participant who wrote one Both plugins turn a missing slate message into the string "null", and on a send the first participant entry belongs to the recipient. Taking messages.first therefore showed "null" as the on-chain note of every sent transaction. Use the first participant message that has text. --- lib/wallets/wallet/impl/epiccash_wallet.dart | 2 +- .../wallet/impl/mimblewimblecoin_wallet.dart | 2 +- .../interfaces/mimblewimble_transaction.dart | 13 +++++++ .../mimblewimble_on_chain_note_test.dart | 39 +++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 test/wallets/mimblewimble_on_chain_note_test.dart diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index c27953a2c2..4c62127271 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -935,7 +935,7 @@ class EpiccashWallet extends MimblewimbleWallet { final String? commitId = slatesToCommits[slateId]?['commitId'] as String?; final int? numberOfMessages = tx.messages?.length; - final String? onChainNote = tx.messages?.first.message; + final String? onChainNote = tx.onChainNote; final String? addressFrom = slatesToCommits[slateId]?["from"] as String?; final String? addressTo = slatesToCommits[slateId]?["to"] as String?; diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index 994b8e3b7a..cbd33ea16c 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -900,7 +900,7 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { final String? commitId = slatesToCommits[slateId]?['commitId'] as String?; final int? numberOfMessages = tx.messages?.length; - final String? onChainNote = tx.messages?.first.message; + final String? onChainNote = tx.onChainNote; final String? addressFrom = slatesToCommits[slateId]?["from"] as String?; final String? addressTo = slatesToCommits[slateId]?["to"] as String?; diff --git a/lib/wl_gen/interfaces/mimblewimble_transaction.dart b/lib/wl_gen/interfaces/mimblewimble_transaction.dart index 8eb1778b83..0d168b84f4 100644 --- a/lib/wl_gen/interfaces/mimblewimble_transaction.dart +++ b/lib/wl_gen/interfaces/mimblewimble_transaction.dart @@ -41,6 +41,19 @@ class MimblewimbleTransaction { this.paymentProof, }); + /// The first participant message that has text. The plugins turn a + /// missing message into the string "null", and on a send the first entry + /// can belong to the other party. + String? get onChainNote { + for (final m in messages ?? const []) { + final String? text = m.message; + if (text != null && text.isNotEmpty && text != "null") { + return text; + } + } + return null; + } + @override String toString() { return 'MimblewimbleTransaction(' diff --git a/test/wallets/mimblewimble_on_chain_note_test.dart b/test/wallets/mimblewimble_on_chain_note_test.dart new file mode 100644 index 0000000000..36d46447a0 --- /dev/null +++ b/test/wallets/mimblewimble_on_chain_note_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wl_gen/interfaces/mimblewimble_transaction.dart'; + +enum _Type { sent } + +MimblewimbleTransaction _tx(List? messages) => MimblewimbleTransaction( + parentKeyId: '0200000000000000000000000000000000', + id: 1, + txType: _Type.sent, + creationTs: '2026-11-10T00:00:00Z', + confirmationTs: '', + confirmed: false, + numInputs: 1, + numOutputs: 1, + amountCredited: '0', + amountDebited: '1', + messages: messages + ?.map((m) => MimblewimbleMessage(id: '0', publicKey: 'pk', message: m)) + .toList(), +); + +void main() { + test('skips the other party\'s empty entry on a send', () { + // The plugins turn a missing message into the string "null". + expect(_tx(['null', 'thanks for lunch']).onChainNote, 'thanks for lunch'); + expect(_tx([null, 'thanks for lunch']).onChainNote, 'thanks for lunch'); + }); + + test('is null when no participant left a message', () { + expect(_tx(['null', 'null']).onChainNote, isNull); + expect(_tx(['', null]).onChainNote, isNull); + expect(_tx([]).onChainNote, isNull); + expect(_tx(null).onChainNote, isNull); + }); + + test('keeps a real first message', () { + expect(_tx(['first', 'second']).onChainNote, 'first'); + }); +} From 1c7ed4d3d04410860a9cd1c6ad2330d4574760fd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 17:17:04 -0600 Subject: [PATCH 14/30] Store the Epicbox insecure flag under the plugin's key EpicboxConfig in the plugin reads epicbox_protocol_unsecure, but the model wrote epicbox_protocol_insecure, so the flag never reached it. Write the plugin's key and keep reading the two older spellings. Also make copyWith on the Epicbox and MWCMQS config models apply their arguments. --- lib/models/epicbox_config_model.dart | 21 +++--- lib/models/mwcmqs_config_model.dart | 2 +- test/models/epicbox_config_model_test.dart | 86 ++++++++++++++++++++++ 3 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 test/models/epicbox_config_model_test.dart diff --git a/lib/models/epicbox_config_model.dart b/lib/models/epicbox_config_model.dart index 8e6f33a35a..c0887f0a7e 100644 --- a/lib/models/epicbox_config_model.dart +++ b/lib/models/epicbox_config_model.dart @@ -46,9 +46,9 @@ class EpicBoxConfigModel extends RelayConfigModel { }) { return EpicBoxConfigModel( host: host, - port: this.port ?? 443, - protocolInsecure: this.protocolInsecure ?? false, - addressIndex: this.addressIndex ?? 0, + port: port ?? this.port ?? 443, + protocolInsecure: protocolInsecure ?? this.protocolInsecure ?? false, + addressIndex: addressIndex ?? this.addressIndex ?? 0, // id: id ?? this.id, // name: name ?? this.name, ); @@ -59,7 +59,8 @@ class EpicBoxConfigModel extends RelayConfigModel { return { 'epicbox_domain': host, 'epicbox_port': port, - 'epicbox_protocol_insecure': protocolInsecure, + // The plugin's EpicboxConfig field is spelled "unsecure". + 'epicbox_protocol_unsecure': protocolInsecure, 'epicbox_address_index': addressIndex, // 'id': id, // 'name': name, @@ -78,22 +79,22 @@ class EpicBoxConfigModel extends RelayConfigModel { if (oldPort != "empty") { _epicBox['epicbox_port'] = _epicBox['port']; } - final oldProtocolInsecure = _epicBox["protocol_insecure"] ?? "empty"; - if (oldProtocolInsecure != "empty") { - _epicBox['epicbox_protocol_insecure'] = _epicBox['protocol_insecure']; - } + // Configs saved before the key matched the plugin's use + // "epicbox_protocol_insecure", and older ones "protocol_insecure". + _epicBox['epicbox_protocol_unsecure'] ??= + _epicBox['epicbox_protocol_insecure'] ?? _epicBox['protocol_insecure']; final oldAddressIndex = _epicBox["address_index"] ?? "empty"; if (oldAddressIndex != "empty") { _epicBox['epicbox_address_index'] = _epicBox['address_index']; } - _epicBox['epicbox_protocol_insecure'] ??= false; + _epicBox['epicbox_protocol_unsecure'] ??= false; _epicBox['epicbox_address_index'] ??= 0; return EpicBoxConfigModel( host: _epicBox['epicbox_domain'] as String, port: _epicBox['epicbox_port'] as int, - protocolInsecure: _epicBox['epicbox_protocol_insecure'] as bool, + protocolInsecure: _epicBox['epicbox_protocol_unsecure'] as bool, addressIndex: _epicBox['epicbox_address_index'] as int, // name: fields[5] as String, // id: fields[6] as String, diff --git a/lib/models/mwcmqs_config_model.dart b/lib/models/mwcmqs_config_model.dart index d97e487fe2..b1bbdbab38 100644 --- a/lib/models/mwcmqs_config_model.dart +++ b/lib/models/mwcmqs_config_model.dart @@ -22,7 +22,7 @@ class MwcMqsConfigModel extends RelayConfigModel { const MwcMqsConfigModel({required super.host, super.port}); MwcMqsConfigModel copyWith({int? port, bool? protocolInsecure}) { - return MwcMqsConfigModel(host: host, port: this.port ?? 443); + return MwcMqsConfigModel(host: host, port: port ?? this.port ?? 443); } @override diff --git a/test/models/epicbox_config_model_test.dart b/test/models/epicbox_config_model_test.dart new file mode 100644 index 0000000000..b76919ad4a --- /dev/null +++ b/test/models/epicbox_config_model_test.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/epicbox_config_model.dart'; + +void main() { + test('serializes the insecure flag under the key the plugin reads', () { + const config = EpicBoxConfigModel( + host: 'relay.example', + port: 8443, + protocolInsecure: true, + addressIndex: 2, + ); + + final json = jsonDecode(config.toString()) as Map; + + expect(json['epicbox_protocol_unsecure'], true); + expect(json.containsKey('epicbox_protocol_insecure'), isFalse); + expect(json['epicbox_domain'], 'relay.example'); + expect(json['epicbox_port'], 8443); + expect(json['epicbox_address_index'], 2); + }); + + test('round-trips through fromString', () { + const config = EpicBoxConfigModel( + host: 'relay.example', + port: 8443, + protocolInsecure: true, + addressIndex: 1, + ); + + final restored = EpicBoxConfigModel.fromString(config.toString()); + + expect(restored.host, 'relay.example'); + expect(restored.port, 8443); + expect(restored.protocolInsecure, true); + expect(restored.addressIndex, 1); + }); + + test('reads configs saved with the earlier key names', () { + final saved = EpicBoxConfigModel.fromString( + jsonEncode({ + 'epicbox_domain': 'relay.example', + 'epicbox_port': 443, + 'epicbox_protocol_insecure': true, + 'epicbox_address_index': 0, + }), + ); + expect(saved.protocolInsecure, true); + + final legacy = EpicBoxConfigModel.fromString( + jsonEncode({ + 'domain': 'relay.example', + 'port': 443, + 'protocol_insecure': true, + 'address_index': 0, + }), + ); + expect(legacy.host, 'relay.example'); + expect(legacy.protocolInsecure, true); + }); + + test('defaults to a secure connection when no flag was saved', () { + final config = EpicBoxConfigModel.fromString( + jsonEncode({'epicbox_domain': 'relay.example', 'epicbox_port': 443}), + ); + expect(config.protocolInsecure, false); + expect(config.addressIndex, 0); + }); + + test('copyWith applies its arguments', () { + const config = EpicBoxConfigModel(host: 'relay.example', port: 443); + + final changed = config.copyWith( + port: 8443, + protocolInsecure: true, + addressIndex: 3, + ); + + expect(changed.host, 'relay.example'); + expect(changed.port, 8443); + expect(changed.protocolInsecure, true); + expect(changed.addressIndex, 3); + expect(config.copyWith().port, 443); + }); +} From 0607f96c6d9e2417b48e70f479d3322d986f5d22 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 17:18:44 -0600 Subject: [PATCH 15/30] Use a readable color for the Slatepack screen's help text desktopTextExtraSmall uses the disabled button text color, which is nearly white on the desktop card, so the explanation above the input was almost invisible. Use the subtitle color like the other slatepack hints. --- lib/pages/slatepack/slatepack_view.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pages/slatepack/slatepack_view.dart b/lib/pages/slatepack/slatepack_view.dart index 7fcac3cb59..1d3d7d72d4 100644 --- a/lib/pages/slatepack/slatepack_view.dart +++ b/lib/pages/slatepack/slatepack_view.dart @@ -133,7 +133,11 @@ class _SlatepackViewState extends ConsumerState { "Paste, scan or open a ${terms.noun} you were sent. It is " "received or finalized depending on what it is.", style: Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context) + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ) : STextStyles.label(context), ), const SizedBox(height: 12), From ba9427724430f250607a0dfb5ea549d16dce6f22 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 21:07:57 -0600 Subject: [PATCH 16/30] Let a typed http(s) send address stay in the field The send field parses its text as a payment URI as soon as it contains a colon. "http:" parsed as a URI with an empty address, so typing an Epic Cash HTTP listener address cleared the field at the colon and left "//host:port", which failed validation. Only pasting the whole URL worked. Treat http and https URLs as addresses, not payment URIs. --- lib/utilities/address_utils.dart | 8 ++++++++ test/address_utils_test.dart | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 9005166e64..da3c91b98c 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -261,6 +261,14 @@ class AddressUtils { return null; } + // A web URL is a send address for coins that send over HTTP (Epic Cash's + // and Grin's foreign API), never a payment URI. Without this, typing the + // "http:" of such an address parsed as a URI with an empty address and + // cleared the field. + if (scheme == "http" || scheme == "https") { + return null; + } + try { final parsedUri = _parseUri(uri); final Map parsedData = parsedUri.params; diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart index af615c5b56..d211b5fa6a 100644 --- a/test/address_utils_test.dart +++ b/test/address_utils_test.dart @@ -454,4 +454,23 @@ void main() { ); }); }); + + test("web URLs are send addresses, not payment URIs", () { + for (final input in [ + "http:", + "http://", + "http://127.0.0.1:3415", + "https://wallet.example.com/v2/foreign", + "HTTPS://wallet.example.com", + ]) { + expect(AddressUtils.parsePaymentUri(input), isNull, reason: input); + } + // Ordinary payment URIs still parse. + expect( + AddressUtils.parsePaymentUri( + "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1", + )?.address, + "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + ); + }); } From c4ac96469a1d06f97b4e827e4a3eee17dd5afdd0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 22:31:28 -0600 Subject: [PATCH 17/30] Keep typed Epic Cash and MWC send addresses intact The send views added a listener to the address controller inside build() for Epic Cash (and, on desktop, MWC) that reassigned the controller text on every change. Listeners piled up with each rebuild, and resetting the text dropped the selection, so on Android only the first character of a typed address survived; pasting still worked. Strip the relay address prefixes in the field's onChanged instead, and only rewrite the field when that changes the text. --- lib/pages/send_view/send_view.dart | 38 +++++++------ .../wallet_view/sub_widgets/desktop_send.dart | 57 +++++++++---------- 2 files changed, 46 insertions(+), 49 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 650de61e56..5d4a5f56c7 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -407,6 +407,25 @@ class _SendViewState extends ConsumerState { } } + /// Strips the prefixes an Epicbox address can be pasted with. Only + /// rewrites the field when that changes the text, so typing stays intact. + String _formatRelayAddress(String text) { + if (coin is! Epiccash) return text; + String address = text.trim(); + if (address.contains("\n")) { + address = address.substring(0, address.indexOf("\n")); + } + final formatted = AddressUtils().formatEpicCashAddress(address); + if (formatted != address) { + sendToController.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + return formatted; + } + return text; + } + Future _checkSparkNameAndOrSetAddress( String content, { bool setController = true, @@ -1712,24 +1731,6 @@ class _SendViewState extends ConsumerState { } }); - // add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address) - if (coin is Epiccash) { - sendToController.addListener(() { - _address = sendToController.text.trim(); - - if (_address != null && _address!.isNotEmpty) { - _address = _address!.trim(); - if (_address!.contains("\n")) { - _address = _address!.substring(0, _address!.indexOf("\n")); - } - - sendToController.text = AddressUtils().formatEpicCashAddress( - _address!, - ); - } - }); - } - Decimal? price; if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { price = ref.watch( @@ -1987,6 +1988,7 @@ class _SendViewState extends ConsumerState { selectAll: false, ), onChanged: (newValue) async { + newValue = _formatRelayAddress(newValue); final trimmed = newValue.trim(); if (AddressUtils.mayBePaymentUri( 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 275c18833a..562c21f7a2 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 @@ -1217,6 +1217,31 @@ class _DesktopSendState extends ConsumerState { } } + /// Strips the prefixes an Epicbox or MWCMQS address can be pasted with. + /// Only rewrites the field when that changes the text, so typing stays + /// intact. + String _formatRelayAddress(String text) { + final String Function(String)? format = coin is Epiccash + ? formatAddress + : coin is Mimblewimblecoin + ? formatAddressMwc + : null; + if (format == null) return text; + String address = text.trim(); + if (address.contains("\n")) { + address = address.substring(0, address.indexOf("\n")); + } + final formatted = format(address); + if (formatted != address) { + sendToController.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + return formatted; + } + return text; + } + Future _checkSparkNameAndOrSetAddress( String content, { bool setController = true, @@ -1552,39 +1577,8 @@ class _DesktopSendState extends ConsumerState { final previewEnabled = ref.watch(pPreviewTxButtonEnabled(coin)); final needsEthFee = coin is Ethereum && isCustomFee; - // add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address) - if (coin is Epiccash) { - sendToController.addListener(() { - _address = sendToController.text; - - if (_address != null && _address!.isNotEmpty) { - _address = _address!.trim(); - if (_address!.contains("\n")) { - _address = _address!.substring(0, _address!.indexOf("\n")); - } - - sendToController.text = formatAddress(_address!); - } - }); - } - final balType = ref.watch(publicPrivateBalanceStateProvider); - if (coin is Mimblewimblecoin) { - sendToController.addListener(() { - _address = sendToController.text; - - if (_address != null && _address!.isNotEmpty) { - _address = _address!.trim(); - if (_address!.contains("\n")) { - _address = _address!.substring(0, _address!.indexOf("\n")); - } - - sendToController.text = formatAddressMwc(_address!); - } - }); - } - final isMwebEnabled = ref.watch( pWalletInfo(walletId).select((s) => s.isMwebEnabled), ); @@ -2007,6 +2001,7 @@ class _DesktopSendState extends ConsumerState { selectAll: false, ), onChanged: (newValue) async { + newValue = _formatRelayAddress(newValue); final trimmed = newValue; if (AddressUtils.mayBePaymentUri(trimmed, _address ?? "")) { From 518eb4f1de6bfc69ea2f1034e10c205fb3ce580d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 15:35:19 -0600 Subject: [PATCH 18/30] Skip MWC fee estimation when there is nothing to spend Opening Send on an empty MWC wallet asks for a fee estimate. libmwc treats amount == available as send all, retries with the amount minus the required fee and aborts the app on the resulting negative amount (seen on the iOS Simulator as a SIGABRT in mwc_rust_get_tx_fees). There is no fee to estimate without a spendable balance or an amount, so return 0 without calling the native library. The real send still reports insufficient funds. --- lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index cbd33ea16c..e6898c44be 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -443,9 +443,13 @@ class MimblewimblecoinWallet extends MimblewimbleWallet { @override Future nativeFee(int satoshiAmount) async { + final int available = info.cachedBalance.spendable.raw.toInt(); + // With nothing to spend, libmwc treats amount == available as send all, + // retries with amount minus the required fee and aborts the app on the + // negative amount. There is no fee to estimate, so skip the call. + if (satoshiAmount <= 0 || available <= 0) return 0; final String wallet = await _ensureWalletOpen(); try { - final int available = info.cachedBalance.spendable.raw.toInt(); final transactionFees = await libMwc.getTransactionFees( wallet: wallet, amount: satoshiAmount, From b3bd3b9179118f97780d24278c4fb6885d914f08 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 16:58:52 -0600 Subject: [PATCH 19/30] Strip relay prefixes from scanned Epic Cash and MWC addresses The address field's onChanged strips http(s)://, mailto: and the trailing slash from an Epicbox or MWCMQS address, but onChanged only runs for typed input. Since the build-time controller listener was removed, an address set from a scanned QR code or from a payment URI kept its prefixes. Run the scanned text and the URI address through the same stripping before the address and the field are set, in the mobile send view for Epic Cash and in the desktop send view for Epic Cash and MWC. --- lib/pages/send_view/send_view.dart | 14 +- .../wallet_view/sub_widgets/desktop_send.dart | 25 ++- .../send_view/epic_scan_address_test.dart | 162 ++++++++++++++++++ .../desktop_send_relay_address_test.dart | 53 ++++++ 4 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 test/pages/send_view/epic_scan_address_test.dart create mode 100644 test/pages_desktop_specific/wallet/desktop_send_relay_address_test.dart diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 5d4a5f56c7..0f9a9d1f98 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -338,7 +338,7 @@ class _SendViewState extends ConsumerState { _clearRecipientForms(); // auto fill address - _address = paymentData.address.trim(); + _address = _stripRelayPrefixes(paymentData.address.trim()); // autofill notes field if (paymentData.message != null) { @@ -407,6 +407,12 @@ class _SendViewState extends ConsumerState { } } + /// [address] without the prefixes an Epicbox address can be scanned or + /// pasted with. Other coins' addresses come back unchanged. + String _stripRelayPrefixes(String address) => coin is Epiccash + ? AddressUtils().formatEpicCashAddress(address) + : address; + /// Strips the prefixes an Epicbox address can be pasted with. Only /// rewrites the field when that changes the text, so typing stays intact. String _formatRelayAddress(String text) { @@ -415,7 +421,7 @@ class _SendViewState extends ConsumerState { if (address.contains("\n")) { address = address.substring(0, address.indexOf("\n")); } - final formatted = AddressUtils().formatEpicCashAddress(address); + final formatted = _stripRelayPrefixes(address); if (formatted != address) { sendToController.value = TextEditingValue( text: formatted, @@ -545,7 +551,9 @@ class _SendViewState extends ConsumerState { _applyUri(paymentData); } else { _setOpReturnData(null); - _address = qrResult.rawContent!.split("\n").first.trim(); + _address = _stripRelayPrefixes( + qrResult.rawContent!.split("\n").first.trim(), + ); sendToController.text = _address ?? ""; _setValidAddressProviders(_address); 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 562c21f7a2..f1a1a50721 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 @@ -950,7 +950,10 @@ class _DesktopSendState extends ConsumerState { _applyUri(paymentData); } else { _setOpReturnData(null); - _address = qrCodeData.split("\n").first.trim(); + _address = stripRelayPrefixes( + coin, + qrCodeData.split("\n").first.trim(), + ); sendToController.text = _address ?? ""; _setValidAddressProviders(_address); @@ -1166,7 +1169,7 @@ class _DesktopSendState extends ConsumerState { _clearRecipientForms(); // auto fill address - _address = paymentData.address; + _address = stripRelayPrefixes(coin, paymentData.address.trim()); sendToController.text = _address!; // autofill notes field. @@ -1221,17 +1224,12 @@ class _DesktopSendState extends ConsumerState { /// Only rewrites the field when that changes the text, so typing stays /// intact. String _formatRelayAddress(String text) { - final String Function(String)? format = coin is Epiccash - ? formatAddress - : coin is Mimblewimblecoin - ? formatAddressMwc - : null; - if (format == null) return text; + if (coin is! Epiccash && coin is! Mimblewimblecoin) return text; String address = text.trim(); if (address.contains("\n")) { address = address.substring(0, address.indexOf("\n")); } - final formatted = format(address); + final formatted = stripRelayPrefixes(coin, address); if (formatted != address) { sendToController.value = TextEditingValue( text: formatted, @@ -2434,6 +2432,15 @@ class _DesktopSendState extends ConsumerState { } } +/// [address] without the prefixes an Epicbox or MWCMQS address can be scanned +/// or pasted with. Other coins' addresses come back unchanged. +String stripRelayPrefixes(CryptoCurrency coin, String address) => + coin is Epiccash + ? formatAddress(address) + : coin is Mimblewimblecoin + ? formatAddressMwc(address) + : address; + String formatAddress(String epicAddress) { // strip http:// or https:// prefixes if the address contains an @ symbol (and is thus an epicbox address) if ((epicAddress.startsWith("http://") || diff --git a/test/pages/send_view/epic_scan_address_test.dart b/test/pages/send_view/epic_scan_address_test.dart new file mode 100644 index 0000000000..52c24d20c9 --- /dev/null +++ b/test/pages/send_view/epic_scan_address_test.dart @@ -0,0 +1,162 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/send_view/send_view.dart'; +import 'package:stackwallet/providers/global/barcode_scanner_provider.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/themes/coin_icon_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/barcode_scanner_interface.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; + +import '../../sample_data/theme_json.dart'; +import '../../wallets/support/xelis_test_fakes.dart'; + +class _SendPrefs extends ConfirmationPrefs { + @override + bool get enableCoinControl => false; + @override + AmountUnit amountUnit(CryptoCurrency coin) => AmountUnit.normal; + @override + int maxDecimals(CryptoCurrency coin) => coin.fractionDigits; +} + +class _TestWallet extends EpiccashWallet { + _TestWallet() : super(CryptoCurrencyNetwork.main); + + @override + Future estimateFeeFor(Amount amount, BigInt feeRate) async => + Amount.zeroWith(fractionDigits: cryptoCurrency.fractionDigits); +} + +class _FakeScanner implements BarcodeScannerInterface { + _FakeScanner(this.rawContent); + + final String rawContent; + + @override + Future scan({required BuildContext context}) async => + ScanResult(rawContent: rawContent); +} + +void main() { + Future pumpSendView(WidgetTester tester, String scanned) async { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final previousThemesDir = StackFileSystem.themesDir; + addTearDown(() => StackFileSystem.themesDir = previousThemesDir); + StackFileSystem.themesDir = Directory('test/sample_data').absolute; + + final wallet = _TestWallet(); + final coin = wallet.cryptoCurrency; + const walletId = 'epic-send-form'; + final info = WalletInfo( + walletId: walletId, + name: 'Epic Cash test wallet', + mainAddressType: AddressType.mimbleWimble, + coinName: coin.identifier, + ); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pWallets.overrideWithValue(ConfirmationWallets(wallet)), + pWalletInfo(walletId).overrideWithValue(info), + pWalletCoin(walletId).overrideWithValue(coin), + pWalletName(walletId).overrideWithValue(info.name), + pWalletBalance(walletId) + .overrideWithValue(Balance.zeroFor(currency: coin)), + prefsChangeNotifierProvider.overrideWithValue(_SendPrefs()), + themeProvider.overrideWithProvider(StateProvider((ref) => theme)), + coinIconProvider(coin).overrideWithValue( + File('test/sample_data/light/assets/dummy.svg').absolute.path, + ), + pAmountFormatter(coin).overrideWithValue( + AmountFormatter( + unit: AmountUnit.normal, + locale: 'en_US', + coin: coin, + maxDecimals: 8, + ), + ), + pBarcodeScanner.overrideWithValue(_FakeScanner(scanned)), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: SendView(walletId: walletId, coin: coin), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + String addressField(WidgetTester tester) => tester + .widget(find.byKey(const Key('sendViewAddressFieldKey'))) + .controller! + .text; + + Future scan(WidgetTester tester) async { + final button = find.byKey(const Key('sendViewScanQrButtonKey')); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + Future dispose(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } + + testWidgets('a scanned Epicbox address loses its https prefix and slash', ( + tester, + ) async { + await pumpSendView(tester, 'https://user@epicbox.example/\nsecond line'); + + await scan(tester); + + expect(addressField(tester), 'user@epicbox.example'); + await dispose(tester); + }); + + testWidgets('a scanned mailto Epicbox address loses its prefix', ( + tester, + ) async { + await pumpSendView(tester, 'mailto:user@epicbox.example'); + + await scan(tester); + + expect(addressField(tester), 'user@epicbox.example'); + await dispose(tester); + }); + + testWidgets('a scanned http receiver address is kept as it is', ( + tester, + ) async { + await pumpSendView(tester, 'https://receiver.example:3415'); + + await scan(tester); + + expect(addressField(tester), 'https://receiver.example:3415'); + await dispose(tester); + }); +} diff --git a/test/pages_desktop_specific/wallet/desktop_send_relay_address_test.dart b/test/pages_desktop_specific/wallet/desktop_send_relay_address_test.dart new file mode 100644 index 0000000000..7f427f1277 --- /dev/null +++ b/test/pages_desktop_specific/wallet/desktop_send_relay_address_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; + +void main() { + final epic = Epiccash(CryptoCurrencyNetwork.main); + final mwc = Mimblewimblecoin(CryptoCurrencyNetwork.main); + final bitcoin = Bitcoin(CryptoCurrencyNetwork.main); + + test('an Epicbox address loses the prefixes and the trailing slash', () { + expect( + stripRelayPrefixes(epic, 'https://user@epicbox.example/'), + 'user@epicbox.example', + ); + expect( + stripRelayPrefixes(epic, 'http://user@epicbox.example'), + 'user@epicbox.example', + ); + expect( + stripRelayPrefixes(epic, 'mailto:user@epicbox.example'), + 'user@epicbox.example', + ); + }); + + test('an MWCMQS address loses the prefixes and the trailing slash', () { + expect( + stripRelayPrefixes(mwc, 'https://user@mqs.example/'), + 'user@mqs.example', + ); + expect( + stripRelayPrefixes(mwc, 'mailto:user@mqs.example'), + 'user@mqs.example', + ); + }); + + test('an http receiver address is kept as it is', () { + expect( + stripRelayPrefixes(epic, 'https://receiver.example:3415'), + 'https://receiver.example:3415', + ); + expect( + stripRelayPrefixes(mwc, 'http://receiver.example:3415/'), + 'http://receiver.example:3415/', + ); + }); + + test('other coins keep the text as it is', () { + expect( + stripRelayPrefixes(bitcoin, 'https://user@host.example/'), + 'https://user@host.example/', + ); + }); +} From e36aa679d660ca8d8f4526a5563084b0ad4c7850 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:01:13 -0600 Subject: [PATCH 20/30] Leave coins without a block explorer out of the explorer settings Epic Cash and MWC have no default block explorer, and asking for one throws. The block explorer settings listed every coin, so opening the entry for either of them failed while the edit screen was being built, on mobile and in the desktop dialog. Only list coins that have a default block explorer. --- ...desktop_manage_block_explorers_dialog.dart | 1 + lib/utilities/block_explorers.dart | 12 ++ lib/widgets/choose_coin_view.dart | 5 + .../block_explorer_coins_test.dart | 104 ++++++++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 test/pages/settings_views/block_explorer_coins_test.dart diff --git a/lib/pages_desktop_specific/settings/settings_menu/advanced_settings/desktop_manage_block_explorers_dialog.dart b/lib/pages_desktop_specific/settings/settings_menu/advanced_settings/desktop_manage_block_explorers_dialog.dart index 41e65e9d76..3007fc951c 100644 --- a/lib/pages_desktop_specific/settings/settings_menu/advanced_settings/desktop_manage_block_explorers_dialog.dart +++ b/lib/pages_desktop_specific/settings/settings_menu/advanced_settings/desktop_manage_block_explorers_dialog.dart @@ -44,6 +44,7 @@ class DesktopManageBlockExplorersDialog extends ConsumerWidget { .where( (e) => showTestNet || e.network == CryptoCurrencyNetwork.main, ) + .where(hasDefaultBlockExplorer) .toList(); return DesktopDialog( diff --git a/lib/utilities/block_explorers.dart b/lib/utilities/block_explorers.dart index b7f55b4b4e..80718de7cf 100644 --- a/lib/utilities/block_explorers.dart +++ b/lib/utilities/block_explorers.dart @@ -39,3 +39,15 @@ Uri getBlockExplorerTransactionUrlFor({ return Uri.parse(url); } } + +/// Whether [coin] has a default block explorer. Coins without one throw from +/// [CryptoCurrency.defaultBlockExplorer], so they cannot be offered where an +/// explorer URL is shown or edited. +bool hasDefaultBlockExplorer(CryptoCurrency coin) { + try { + coin.defaultBlockExplorer("[TXID]"); + return true; + } catch (_) { + return false; + } +} diff --git a/lib/widgets/choose_coin_view.dart b/lib/widgets/choose_coin_view.dart index b44f7152a1..e65999ade6 100644 --- a/lib/widgets/choose_coin_view.dart +++ b/lib/widgets/choose_coin_view.dart @@ -15,8 +15,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../providers/providers.dart'; import '../app_config.dart'; +import '../pages/settings_views/global_settings_view/advanced_views/manage_explorer_view.dart'; import '../themes/coin_icon_provider.dart'; import '../themes/stack_colors.dart'; +import '../utilities/block_explorers.dart'; import '../utilities/constants.dart'; import '../utilities/text_styles.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; @@ -59,6 +61,9 @@ class _ChooseCoinViewState extends ConsumerState { _coins.removeWhere( (e) => e.identifier == Firo(CryptoCurrencyNetwork.test).identifier, ); + if (widget.nextRouteName == ManageExplorerView.routeName) { + _coins.removeWhere((e) => !hasDefaultBlockExplorer(e)); + } super.initState(); } diff --git a/test/pages/settings_views/block_explorer_coins_test.dart b/test/pages/settings_views/block_explorer_coins_test.dart new file mode 100644 index 0000000000..2b3f37578e --- /dev/null +++ b/test/pages/settings_views/block_explorer_coins_test.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/app_config.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/settings_views/global_settings_view/advanced_views/manage_explorer_view.dart'; +import 'package:stackwallet/pages_desktop_specific/settings/settings_menu/advanced_settings/desktop_manage_block_explorers_dialog.dart'; +import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/themes/coin_icon_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/block_explorers.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/choose_coin_view.dart'; + +import '../../sample_data/theme_json.dart'; + +class _TestPrefs extends ChangeNotifier implements Prefs { + @override + bool get showTestNetCoins => false; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + final epic = Epiccash(CryptoCurrencyNetwork.main); + final mwc = Mimblewimblecoin(CryptoCurrencyNetwork.main); + final bitcoin = Bitcoin(CryptoCurrencyNetwork.main); + + Future pump(WidgetTester tester, Widget home) async { + tester.view.physicalSize = const Size(1200, 6000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final previousThemesDir = StackFileSystem.themesDir; + addTearDown(() => StackFileSystem.themesDir = previousThemesDir); + StackFileSystem.themesDir = Directory('test/sample_data').absolute; + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + final icon = File('test/sample_data/light/assets/dummy.svg').absolute.path; + await tester.pumpWidget( + ProviderScope( + overrides: [ + prefsChangeNotifierProvider.overrideWithValue(_TestPrefs()), + themeProvider.overrideWithProvider(StateProvider((ref) => theme)), + coinIconProvider.overrideWithProvider((_) => Provider((_) => icon)), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: home, + ), + ), + ); + await tester.pump(); + expect(tester.takeException(), isNull); + } + + test('Epic Cash and MWC have no default block explorer', () { + expect(hasDefaultBlockExplorer(epic), isFalse); + expect(hasDefaultBlockExplorer(mwc), isFalse); + expect(hasDefaultBlockExplorer(bitcoin), isTrue); + }); + + test('the app offers Epic Cash, MWC and Bitcoin', () { + expect(AppConfig.coins, containsAll([epic, mwc, bitcoin])); + }); + + testWidgets('the mobile explorer list leaves out coins without an explorer', ( + tester, + ) async { + await pump( + tester, + const ChooseCoinView( + title: 'Manage block explorers', + coinAdditional: 'block explorer', + nextRouteName: ManageExplorerView.routeName, + ), + ); + + expect(find.text('${bitcoin.prettyName} block explorer'), findsOneWidget); + expect(find.text('${epic.prettyName} block explorer'), findsNothing); + expect(find.text('${mwc.prettyName} block explorer'), findsNothing); + }); + + testWidgets( + 'the desktop explorer list leaves out coins without an explorer', + (tester) async { + await pump( + tester, + const Scaffold(body: DesktopManageBlockExplorersDialog()), + ); + + expect(find.text('${bitcoin.prettyName} block explorer'), findsOneWidget); + expect(find.text('${epic.prettyName} block explorer'), findsNothing); + expect(find.text('${mwc.prettyName} block explorer'), findsNothing); + }, + ); +} From 8c77c0f13502e810a301bd22b8e529dacda82d7f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:09:22 -0600 Subject: [PATCH 21/30] Check the created slatepack against the confirmed send Creating a slatepack did not compare the result with what the user had just confirmed, reported failures as "Broadcast transaction failed" although nothing is broadcast, and dropped the result without a trace when the confirmation view had closed in the meantime. A slatepack created for another amount is now cancelled and refused with an error naming both amounts. A different fee is logged and shown as a warning only, because the confirmed fee is an estimate and the wallet may select other inputs when it builds the slate. Failures are titled "Could not create the slate" or "slatepack". The sending dialog is closed through a navigator looked up before the send, so it no longer stays up when the view is gone, and that case is logged. --- .../send_view/confirm_transaction_view.dart | 61 +++- .../slatepack_confirmation_test.dart | 301 ++++++++++++++---- 2 files changed, 292 insertions(+), 70 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index f274d94224..db6483184b 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -165,6 +165,7 @@ class _ConfirmTransactionViewState final note = onChainNoteController.text; final localNote = noteController.text; final db = ref.read(mainDBProvider); + final format = ref.read(pAmountFormatter(wallet.cryptoCurrency)).format; final result = await wallet.createSlatepack( amount: recipient.amount, @@ -175,6 +176,37 @@ class _ConfirmTransactionViewState throw Exception(result.error ?? "Failed to create ${terms.noun}"); } + // The user confirmed one amount, so a slate for any other amount must not + // be handed over. + final createdAmount = result.amount; + if (createdAmount != null && createdAmount != recipient.amount) { + final slateId = result.slateId; + final cancelError = slateId == null + ? "it has no slate id" + : await wallet.cancelPendingTransactionAndPost(slateId); + final outcome = cancelError.isEmpty + ? "The transaction was cancelled." + : "The transaction could not be cancelled ($cancelError). Cancel it" + " from its details before trying again."; + throw Exception( + "The ${terms.noun} was created for ${format(createdAmount)} instead of" + " the confirmed ${format(recipient.amount)}. $outcome", + ); + } + + // The confirmed fee is an estimate and the wallet may pick other inputs + // when it builds the slate, so a different fee is reported, not refused. + final createdFee = result.fee; + final confirmedFee = widget.txData.fee; + final feeChange = + createdFee != null && confirmedFee != null && createdFee != confirmedFee + ? "The ${terms.noun} pays a fee of ${format(createdFee)} instead of" + " the estimated ${format(confirmedFee)}." + : null; + if (feeChange != null) { + Logging.instance.w("$feeChange Slate id: ${result.slateId}"); + } + var noteSaved = localNote.isEmpty; if (!noteSaved && result.slateId != null) { try { @@ -195,17 +227,26 @@ class _ConfirmTransactionViewState closeSendingDialog(); if (context.mounted) { - if (!noteSaved) { + if (!noteSaved || feeChange != null) { unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: + message: [ + ?feeChange, + if (!noteSaved) "Transaction created, but the local note could not be saved.", + ].join(" "), context: context, ), ); } await showSlatepackDialog(context: context, terms: terms, result: result); + } else { + Logging.instance.w( + "Created ${terms.noun} ${result.slateId} but the confirmation view" + " closed before it could be shown. The pending transaction is in the" + " wallet's history and can be cancelled there.", + ); } widget.onSuccess.call(); @@ -225,11 +266,14 @@ class _ConfirmTransactionViewState final sendProgressController = ProgressAndSuccessController(); var isSendingDialogOpen = true; + // Looked up now so the dialog can still be closed if this view is gone by + // the time the send returns. + final navigator = Navigator.of(context, rootNavigator: true); + void closeSendingDialog() { - if (!context.mounted || !isSendingDialogOpen) { + if (!navigator.mounted || !isSendingDialogOpen) { return; } - final navigator = Navigator.of(context, rootNavigator: true); if (navigator.canPop()) { navigator.pop(); } @@ -376,12 +420,15 @@ class _ConfirmTransactionViewState return; } } catch (e, s) { - const message = "Broadcast transaction failed"; + // Nothing is broadcast when a slatepack is created. + final message = + wallet is SlatepackInterface && widget.txData.isSlatepackSend + ? "Could not create the ${wallet.slatepackTerms.noun}" + : "Broadcast transaction failed"; Logging.instance.e(message, error: e, stackTrace: s); // pop sending dialog + closeSendingDialog(); if (context.mounted) { - closeSendingDialog(); - await showDialog( context: context, useSafeArea: false, diff --git a/test/pages/send_view/slatepack_confirmation_test.dart b/test/pages/send_view/slatepack_confirmation_test.dart index 7c6bda018a..892cfdf7ee 100644 --- a/test/pages/send_view/slatepack_confirmation_test.dart +++ b/test/pages/send_view/slatepack_confirmation_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -9,6 +10,7 @@ import 'package:stackwallet/models/isar/models/transaction_note.dart'; import 'package:stackwallet/models/isar/stack_theme.dart'; import 'package:stackwallet/models/slatepack_models.dart'; import 'package:stackwallet/pages/send_view/confirm_transaction_view.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/sending_transaction_dialog.dart'; import 'package:stackwallet/pages/slatepack/slatepack_dialog.dart'; import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; import 'package:stackwallet/providers/db/main_db_provider.dart'; @@ -38,8 +40,27 @@ class _Info extends Fake implements WalletInfo { } class _Wallet extends EpiccashWallet { - _Wallet() : super(CryptoCurrencyNetwork.main); + _Wallet({ + this.createdAmount, + this.createdFee, + this.createError, + this.cancelResult = '', + this.gate, + }) : super(CryptoCurrencyNetwork.main); + + /// What the created slate reports, in raw units. + final int? createdAmount; + final int? createdFee; + + final String? createError; + final String cancelResult; + + /// When set, creation waits for it. + final Completer? gate; + int created = 0; + final cancelled = []; + @override WalletInfo get info => _Info(cryptoCurrency); @override @@ -52,13 +73,104 @@ class _Wallet extends EpiccashWallet { bool sendAll = false, }) async { created++; + await gate?.future; + if (createError != null) { + return SlatepackResult(success: false, error: createError); + } return SlatepackResult( success: true, slatepack: '{}', slateId: 'slate-1', state: SlateState.s1, + amount: createdAmount == null ? null : _amount(createdAmount!), + fee: createdFee == null ? null : _amount(createdFee!), ); } + + @override + Future cancelPendingTransactionAndPost(String txSlateId) async { + cancelled.add(txSlateId); + return cancelResult; + } +} + +/// Outlasts the minimum time the sending dialog is meant to stay up. +const _sendTimer = Duration(seconds: 3); + +Amount _amount(int raw) => + Amount(rawValue: BigInt.from(raw), fractionDigits: 8); + +AmountFormatter _formatter(CryptoCurrency coin) => AmountFormatter( + unit: AmountUnit.normal, + locale: 'en_US', + coin: coin, + maxDecimals: coin.fractionDigits, +); + +/// Pumps the confirmation of a slate send of 100 with a fee of 1 and presses +/// Send. [shown] takes the view off screen when set to false. +Future _confirmSend( + WidgetTester tester, + _Wallet wallet, { + MainDB? db, + void Function()? onSuccess, + ValueNotifier? shown, +}) async { + tester.view.physicalSize = const Size(1400, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final previousThemes = StackFileSystem.themesDir; + StackFileSystem.themesDir = Directory('test/sample_data').absolute; + addTearDown(() => StackFileSystem.themesDir = previousThemes); + final coin = wallet.cryptoCurrency; + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + final view = ConfirmTransactionView( + walletId: 'wallet-1', + onSuccess: onSuccess ?? () {}, + onSuccessInsteadOfRouteOnSuccess: () {}, + txData: TxData( + note: 'local note', + fee: _amount(1), + recipients: [ + TxRecipient( + address: '', + amount: _amount(100), + isChange: false, + addressType: AddressType.mimbleWimble, + ), + ], + otherData: slatepackMethodOtherData(SlatepackMethod.slatepack), + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + mainDBProvider.overrideWithValue(db ?? _Notes(fail: false)), + pWallets.overrideWithValue(ConfirmationWallets(wallet)), + pWalletCoin('wallet-1').overrideWithValue(coin), + prefsChangeNotifierProvider.overrideWithValue(ConfirmationPrefs()), + themeProvider.overrideWithProvider(StateProvider((ref) => theme)), + pAmountFormatter(coin).overrideWithValue(_formatter(coin)), + ], + child: MaterialApp( + theme: ThemeData(extensions: [StackColors.fromStackColorTheme(theme)]), + home: Scaffold( + body: shown == null + ? view + : ValueListenableBuilder( + valueListenable: shown, + builder: (_, isShown, _) => isShown ? view : const SizedBox(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Send')); + await tester.tap(find.text('Send')); + await tester.pumpAndSettle(); + Navigator.of(tester.element(find.byType(DesktopAuthSend))).pop(true); } class _Notes extends Fake implements MainDB { @@ -77,73 +189,16 @@ void main() { testWidgets('slate send keeps its result with note failure=$failNote', ( tester, ) async { - tester.view.physicalSize = const Size(1400, 2400); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final previousThemes = StackFileSystem.themesDir; - StackFileSystem.themesDir = Directory('test/sample_data').absolute; - addTearDown(() => StackFileSystem.themesDir = previousThemes); final wallet = _Wallet(); - final coin = wallet.cryptoCurrency; final notes = _Notes(fail: failNote); - final theme = StackTheme.fromJson(json: lightThemeJsonMap); var succeeded = false; - await tester.pumpWidget( - ProviderScope( - overrides: [ - mainDBProvider.overrideWithValue(notes), - pWallets.overrideWithValue(ConfirmationWallets(wallet)), - pWalletCoin('wallet-1').overrideWithValue(coin), - prefsChangeNotifierProvider.overrideWithValue(ConfirmationPrefs()), - themeProvider.overrideWithProvider(StateProvider((ref) => theme)), - pAmountFormatter(coin).overrideWithValue( - AmountFormatter( - unit: AmountUnit.normal, - locale: 'en_US', - coin: coin, - maxDecimals: coin.fractionDigits, - ), - ), - ], - child: MaterialApp( - theme: ThemeData( - extensions: [StackColors.fromStackColorTheme(theme)], - ), - home: Scaffold( - body: ConfirmTransactionView( - walletId: 'wallet-1', - onSuccess: () => succeeded = true, - onSuccessInsteadOfRouteOnSuccess: () {}, - txData: TxData( - note: 'local note', - fee: Amount(rawValue: BigInt.one, fractionDigits: 8), - recipients: [ - TxRecipient( - address: '', - amount: Amount( - rawValue: BigInt.from(100), - fractionDigits: 8, - ), - isChange: false, - addressType: AddressType.mimbleWimble, - ), - ], - otherData: slatepackMethodOtherData( - SlatepackMethod.slatepack, - ), - ), - ), - ), - ), - ), + await _confirmSend( + tester, + wallet, + db: notes, + onSuccess: () => succeeded = true, ); await tester.pumpAndSettle(); - await tester.ensureVisible(find.text('Send')); - await tester.tap(find.text('Send')); - await tester.pumpAndSettle(); - Navigator.of(tester.element(find.byType(DesktopAuthSend))).pop(true); - await tester.pumpAndSettle(); expect(wallet.created, 1); expect(find.byType(SlatepackDialog), findsOneWidget); if (!failNote) { @@ -161,4 +216,124 @@ void main() { expect(tester.takeException(), isNull); }); } + + for (final cancelResult in ['', 'node unreachable']) { + testWidgets('a slate for another amount is cancelled and refused, cancel ' + 'result "$cancelResult"', (tester) async { + final wallet = _Wallet(createdAmount: 90, cancelResult: cancelResult); + final format = _formatter(wallet.cryptoCurrency).format; + final notes = _Notes(fail: false); + var succeeded = false; + await _confirmSend( + tester, + wallet, + db: notes, + onSuccess: () => succeeded = true, + ); + await tester.pumpAndSettle(); + + expect(wallet.cancelled, ['slate-1']); + expect(find.byType(SlatepackDialog), findsNothing); + expect(find.byType(SendingTransactionDialog), findsNothing); + expect(find.text('Could not create the slate'), findsOneWidget); + expect( + find.textContaining( + 'created for ${format(_amount(90))} instead of the confirmed ' + '${format(_amount(100))}', + ), + findsOneWidget, + ); + expect( + find.textContaining( + cancelResult.isEmpty + ? 'The transaction was cancelled.' + : 'could not be cancelled (node unreachable)', + ), + findsOneWidget, + ); + expect(notes.notes, isEmpty); + expect(succeeded, isFalse); + await tester.pump(_sendTimer); + }); + } + + testWidgets('a slate with another fee is shown with a warning', ( + tester, + ) async { + final wallet = _Wallet(createdAmount: 100, createdFee: 3); + final format = _formatter(wallet.cryptoCurrency).format; + await _confirmSend(tester, wallet); + await tester.pumpAndSettle(); + + expect(wallet.cancelled, isEmpty); + expect(find.byType(SlatepackDialog), findsOneWidget); + expect( + find.textContaining( + 'fee of ${format(_amount(3))} instead of the estimated ' + '${format(_amount(1))}', + ), + findsOneWidget, + ); + await tester.pump(const Duration(seconds: 10)); + await tester.pumpAndSettle(); + }); + + testWidgets('a slate matching the confirmation is shown without a warning', ( + tester, + ) async { + final wallet = _Wallet(createdAmount: 100, createdFee: 1); + await _confirmSend(tester, wallet); + await tester.pumpAndSettle(); + + expect(wallet.cancelled, isEmpty); + expect(find.byType(SlatepackDialog), findsOneWidget); + expect(find.textContaining('instead of'), findsNothing); + await tester.pump(_sendTimer); + }); + + testWidgets('a failed creation is not called a broadcast failure', ( + tester, + ) async { + final wallet = _Wallet(createError: 'not enough funds'); + await _confirmSend(tester, wallet); + await tester.pumpAndSettle(); + + expect(find.text('Could not create the slate'), findsOneWidget); + expect(find.text('Broadcast transaction failed'), findsNothing); + expect(find.textContaining('not enough funds'), findsOneWidget); + expect(find.byType(SendingTransactionDialog), findsNothing); + expect(wallet.cancelled, isEmpty); + await tester.pump(_sendTimer); + }); + + testWidgets('the sending dialog closes when the view is gone before the ' + 'slate is created', (tester) async { + final gate = Completer(); + final wallet = _Wallet(gate: gate); + final shown = ValueNotifier(true); + addTearDown(shown.dispose); + var succeeded = false; + await _confirmSend( + tester, + wallet, + shown: shown, + onSuccess: () => succeeded = true, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(SendingTransactionDialog), findsOneWidget); + + shown.value = false; + await tester.pump(); + expect(find.byType(ConfirmTransactionView), findsNothing); + + gate.complete(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.byType(SendingTransactionDialog), findsNothing); + expect(find.byType(SlatepackDialog), findsNothing); + expect(succeeded, isTrue); + await tester.pump(_sendTimer); + expect(tester.takeException(), isNull); + }); } From bc2677d0f64acec5f63f4c2ba050652283c67fcd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:12:13 -0600 Subject: [PATCH 22/30] Offer Finalize only for a send this wallet started An imported response slatepack was offered for finalizing even when the wallet had no transaction for it, with only a note in the summary, and a transaction the wallet had received counted as a match. The lookup now reports the direction of the local transaction, prefers the send when a wallet holds both sides of a slate, and refreshes the stored history once before giving up, since a send made moments ago may not be in it yet. Without a send of its own the import flow explains that only the wallet that started the transaction can finalize it and no longer shows the Finalize button. --- .../slatepack/slatepack_import_flow.dart | 74 +++++++---- .../intermediate/mimblewimble_wallet.dart | 64 +++++++--- .../slatepack_interface.dart | 9 +- .../slatepack/slatepack_import_flow_test.dart | 115 +++++++++++++++--- .../mimblewimble_slate_inspection_test.dart | 33 +++++ 5 files changed, 236 insertions(+), 59 deletions(-) diff --git a/lib/pages/slatepack/slatepack_import_flow.dart b/lib/pages/slatepack/slatepack_import_flow.dart index b6162bf558..610bd86582 100644 --- a/lib/pages/slatepack/slatepack_import_flow.dart +++ b/lib/pages/slatepack/slatepack_import_flow.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../models/slatepack_models.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../utilities/amount/amount.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/util.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; @@ -66,25 +67,61 @@ abstract final class SlatepackImportFlow { }) async { final terms = wallet.slatepackTerms; - final inspection = await _withLoading( + final read = await _withLoading( context, "Reading ${terms.noun}...", - () => wallet.inspectSlatepack(text), + () async { + final inspection = await wallet.inspectSlatepack(text); + final route = routeFor( + inspection.state, + supportsInvoices: wallet.supportsInvoices, + ); + return ( + inspection: inspection, + route: route, + // Only the wallet that started a send can finalize it. + local: route == SlatepackRoute.finalizeSend + ? await wallet.localTxForSlate(inspection.slateId) + : null, + ); + }, errorTitle: "Could not read ${terms.noun}", ); - if (inspection == null || !context.mounted) return false; + if (read == null || !context.mounted) return false; - final route = routeFor( - inspection.state, - supportsInvoices: wallet.supportsInvoices, - ); + final inspection = read.inspection; + final route = read.route; switch (route) { case SlatepackRoute.receive: return _receive(context, wallet, text, inspection); case SlatepackRoute.finalizeSend: - return _finalize(context, wallet, text, inspection); + final local = read.local; + if (local == null || !local.isSend) { + await _message( + context, + "Cannot finalize this ${terms.noun}", + local == null + ? "This wallet did not start the transaction this " + "${terms.noun} belongs to. Only the wallet that " + "started a send can finalize it. If the send was made " + "here moments ago, let the wallet finish syncing and " + "try again." + : "This ${terms.noun} belongs to a transaction this wallet " + "received, not one it started. Only the wallet that " + "started a send can finalize it.", + ); + return false; + } + return _finalize( + context, + wallet, + text, + inspection, + amount: local.amount, + fee: local.fee, + ); case SlatepackRoute.payInvoice: // TODO(S5): PayInvoiceConfirmView. @@ -184,31 +221,26 @@ abstract final class SlatepackImportFlow { BuildContext context, SlatepackInterface wallet, String text, - SlateInspection inspection, - ) async { + SlateInspection inspection, { + required Amount amount, + required Amount fee, + }) async { final terms = wallet.slatepackTerms; - // An S2 may carry amount 0, so the summary comes from our own record of - // the send when there is one. - final local = await wallet.localTxForSlate(inspection.slateId); - if (!context.mounted) return false; - + // An S2 may carry amount 0, so the summary shows [amount] and [fee] from + // our own record of the send. final confirmed = await showSlatepackPopup( context: context, child: SlatepackSummaryDialog( title: "Finalize ${terms.noun}", coin: wallet.cryptoCurrency, actionLabel: "Finalize", - amount: local?.amount ?? inspection.amount, - fee: local?.fee ?? inspection.fee, + amount: amount, + fee: fee, details: [ if (inspection.slateId.isNotEmpty) ("Slate ID", inspection.slateId), ("Encrypted", inspection.isEncrypted ? "Yes" : "No"), ], - note: local == null - ? "No matching send was found in this wallet's history. " - "Finalizing only works for a send this wallet started." - : null, ), ); if (confirmed != true || !context.mounted) return false; diff --git a/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart b/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart index de2abe8ce3..e57b9e3bb3 100644 --- a/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart +++ b/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart @@ -233,28 +233,60 @@ abstract class MimblewimbleWallet extends Bip39Wallet with SlatepackInterface { } @override - Future<({Amount amount, Amount fee})?> localTxForSlate(String slateId) async { + Future<({Amount amount, Amount fee, bool isSend})?> localTxForSlate( + String slateId, + ) async { if (slateId.isEmpty) return null; - final List candidates = await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .otherDataContains(slateId) - .findAll(); + Future find() async => localTxMatching( + await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .otherDataContains(slateId) + .findAll(), + slateId, + ); - for (final TransactionV2 tx in candidates) { - if (tx.slateId != slateId) continue; - final int digits = cryptoCurrency.fractionDigits; - final Amount amount = tx.type == TransactionType.incoming - ? tx.getAmountReceivedInThisWallet(fractionDigits: digits) - : tx.getAmountSentFromThisWallet( + TransactionV2? tx = await find(); + if (tx == null) { + // The stored history only changes on a refresh, and a refresh is + // skipped while another one runs, so a send made moments ago may not be + // in it yet. + await updateTransactions(); + tx = await find(); + } + if (tx == null) return null; + + final int digits = cryptoCurrency.fractionDigits; + final bool isSend = tx.type == TransactionType.outgoing; + return ( + amount: isSend + ? tx.getAmountSentFromThisWallet( fractionDigits: digits, subtractFee: true, - ); - return (amount: amount, fee: tx.getFee(fractionDigits: digits)); + ) + : tx.getAmountReceivedInThisWallet(fractionDigits: digits), + fee: tx.getFee(fractionDigits: digits), + isSend: isSend, + ); + } + + /// The transaction for [slateId] among [candidates]. A wallet that sent to + /// itself holds both sides of the slate, and then the send is the one + /// returned. + @visibleForTesting + static TransactionV2? localTxMatching( + Iterable candidates, + String slateId, + ) { + TransactionV2? match; + for (final TransactionV2 tx in candidates) { + if (tx.slateId != slateId) continue; + if (tx.type == TransactionType.outgoing) return tx; + match ??= tx; } - return null; + return match; } // ================= Native wallet hooks ===================================== diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart index 7b19c01461..5b2b2cbcfe 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart @@ -37,9 +37,12 @@ mixin SlatepackInterface on Wallet { /// Throws when the text cannot be decoded at all. Future inspectSlatepack(String armored); - /// Amount and fee of this wallet's transaction for [slateId], or null when - /// the wallet has none. - Future<({Amount amount, Amount fee})?> localTxForSlate(String slateId); + /// Amount, fee and direction of this wallet's transaction for [slateId], or + /// null when the wallet has none. `isSend` is true for a send this wallet + /// started, which is the only kind it can finalize. + Future<({Amount amount, Amount fee, bool isSend})?> localTxForSlate( + String slateId, + ); /// Start a send (S1). [message] is dropped when [supportsOnChainNote] is /// false. [recipientAddress] encrypts the slatepack to that address where diff --git a/test/pages/slatepack/slatepack_import_flow_test.dart b/test/pages/slatepack/slatepack_import_flow_test.dart index 376642d50b..8ee3fb359d 100644 --- a/test/pages/slatepack/slatepack_import_flow_test.dart +++ b/test/pages/slatepack/slatepack_import_flow_test.dart @@ -1,9 +1,13 @@ 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/models/slatepack_models.dart'; import 'package:stackwallet/pages/slatepack/slatepack_import_flow.dart'; import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; @@ -12,11 +16,15 @@ import '../../sample_data/theme_json.dart'; /// Epic Cash wallet whose inspection is canned, so run() can be driven /// without a native wallet. class _InspectOnlyWallet extends EpiccashWallet { - _InspectOnlyWallet(this.state, {this.invoices = false}) + _InspectOnlyWallet(this.state, {this.invoices = false, this.localIsSend}) : super(CryptoCurrencyNetwork.main); final SlateState state; final bool invoices; + + /// Direction of the wallet's own transaction for the slate. Null when the + /// wallet has none. + final bool? localIsSend; int receiveCalls = 0; int finalizeCalls = 0; @@ -27,6 +35,17 @@ class _InspectOnlyWallet extends EpiccashWallet { Future inspectSlatepack(String armored) async => SlateInspection(state: state, slateId: "test-slate"); + @override + Future<({Amount amount, Amount fee, bool isSend})?> localTxForSlate( + String slateId, + ) async => localIsSend == null + ? null + : ( + amount: Amount(rawValue: BigInt.from(100), fractionDigits: 8), + fee: Amount(rawValue: BigInt.one, fractionDigits: 8), + isSend: localIsSend!, + ); + @override Future receiveSlatepack(String slatepack) async { receiveCalls++; @@ -116,26 +135,39 @@ void main() { _InspectOnlyWallet wallet, ) async { bool? outcome; + final coin = wallet.cryptoCurrency; await tester.pumpWidget( - MaterialApp( - theme: ThemeData( - extensions: [ - StackColors.fromStackColorTheme( - StackTheme.fromJson(json: lightThemeJsonMap), + ProviderScope( + overrides: [ + pAmountFormatter(coin).overrideWithValue( + AmountFormatter( + unit: AmountUnit.normal, + locale: "en_US", + coin: coin, + maxDecimals: coin.fractionDigits, ), - ], - ), - home: Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () async { - outcome = await SlatepackImportFlow.run( - context: context, - wallet: wallet, - text: "{}", - ); - }, - child: const Text("go"), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + outcome = await SlatepackImportFlow.run( + context: context, + wallet: wallet, + text: "{}", + ); + }, + child: const Text("go"), + ), ), ), ), @@ -176,6 +208,51 @@ void main() { await closeDialog(tester); }); + testWidgets("response to a send this wallet did not start", (tester) async { + final wallet = _InspectOnlyWallet(SlateState.s2); + await runFlow(tester, wallet); + expect(find.text("Cannot finalize this slate"), findsOneWidget); + expect( + find.textContaining("This wallet did not start the transaction"), + findsOneWidget, + ); + expect(find.text("Finalize"), findsNothing); + await closeDialog(tester); + expect(wallet.finalizeCalls, 0); + }); + + testWidgets("response to a transaction this wallet received", ( + tester, + ) async { + final wallet = _InspectOnlyWallet(SlateState.s2, localIsSend: false); + await runFlow(tester, wallet); + expect(find.text("Cannot finalize this slate"), findsOneWidget); + expect( + find.textContaining("a transaction this wallet received"), + findsOneWidget, + ); + expect(find.text("Finalize"), findsNothing); + await closeDialog(tester); + expect(wallet.finalizeCalls, 0); + }); + + testWidgets("response to this wallet's own send can be finalized", ( + tester, + ) async { + final wallet = _InspectOnlyWallet(SlateState.s2, localIsSend: true); + await runFlow(tester, wallet); + expect(find.text("Cannot finalize this slate"), findsNothing); + expect(find.text("Finalize slate"), findsOneWidget); + expect(find.text("0.00000100 EPIC"), findsOneWidget); + await tester.tap(find.text("Finalize")); + // Loading overlay delay, then the canned failure and its dialog. + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + expect(wallet.finalizeCalls, 1); + await closeDialog(tester); + }); + testWidgets("invoice with invoice support is a placeholder", ( tester, ) async { diff --git a/test/wallets/mimblewimble_slate_inspection_test.dart b/test/wallets/mimblewimble_slate_inspection_test.dart index 77f581e69f..0761fbe497 100644 --- a/test/wallets/mimblewimble_slate_inspection_test.dart +++ b/test/wallets/mimblewimble_slate_inspection_test.dart @@ -1,4 +1,8 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart'; import 'package:stackwallet/models/slatepack_models.dart'; import 'package:stackwallet/utilities/enums/slatepack_method.dart'; import 'package:stackwallet/utilities/slatepack_terms.dart'; @@ -22,7 +26,36 @@ Map _slate({required int signatures, int participants = 2}) { }; } +TransactionV2 _tx(int n, TransactionType type, String slateId) => TransactionV2( + walletId: "wallet", + blockHash: null, + hash: "hash$n", + txid: "txid$n", + timestamp: 0, + height: null, + inputs: const [], + outputs: const [], + version: 0, + type: type, + subType: TransactionSubType.none, + otherData: jsonEncode({"slateId": slateId}), +); + void main() { + test("local transaction for a slate", () { + const id = "0436430c-2b02-624c-2032-570501212b00"; + final received = _tx(1, TransactionType.incoming, id); + final sent = _tx(2, TransactionType.outgoing, id); + // Contains the id without being the transaction for it. + final other = _tx(3, TransactionType.outgoing, "$id-other"); + + expect(MimblewimbleWallet.localTxMatching([other], id), isNull); + expect(MimblewimbleWallet.localTxMatching([other, received], id), received); + // A send to self holds both sides, and the send is what gets finalized. + expect(MimblewimbleWallet.localTxMatching([received, sent], id), sent); + expect(MimblewimbleWallet.localTxMatching([sent, received], id), sent); + }); + SlateInspection inspect( Map json, { String? sender, From e16ccffe56c333fe3041fa83b706a30c1e4c1c94 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:13:03 -0600 Subject: [PATCH 23/30] Draw a slatepack QR code whenever the data fits in one The slatepack dialog refused to draw a QR code for anything over 1000 characters. Epic Cash slates and MWC slatepacks can be longer than that and were drawn at any length before the shared dialog replaced the per-coin ones, so the longer ones lost their QR code. The limit is now what a QR code can hold at the error correction level the widget draws at, 2953 bytes, counted in bytes instead of characters. Longer data still gets the note to copy, share or save it. --- lib/pages/slatepack/slatepack_qr.dart | 14 ++++----- test/pages/slatepack/slatepack_qr_test.dart | 33 +++++++++++++++++---- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/lib/pages/slatepack/slatepack_qr.dart b/lib/pages/slatepack/slatepack_qr.dart index bb438c4bab..30442cf8a1 100644 --- a/lib/pages/slatepack/slatepack_qr.dart +++ b/lib/pages/slatepack/slatepack_qr.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -7,14 +8,13 @@ import '../../utilities/text_styles.dart'; import '../../widgets/qr.dart'; import '../../widgets/rounded_container.dart'; -/// Longest slatepack shown as a QR code. Measured armored Grin slatepacks -/// (characters): S1 360 plain, 806-955 encrypted; I1 348 plain, 719-855 -/// encrypted; S2 1837-2185; I2 1881-2068; S3 2683-2871. S1 and I1 fit one -/// QR at any error correction level, S2 and I2 only at L or M, so the cap -/// sits above S1/I1 and below S2/I2, which fall back to copy, share or save. -const kSlatepackQrMaxLength = 1000; +/// Most bytes one QR code holds: version 40 in byte mode at error correction +/// level L, which is the level [QR] draws at. Anything up to that is drawn. A +/// code near the limit is dense, and tapping it shows it full size. +const kSlatepackQrMaxBytes = 2953; -bool slatepackFitsQr(String text) => text.length <= kSlatepackQrMaxLength; +bool slatepackFitsQr(String text) => + utf8.encode(text).length <= kSlatepackQrMaxBytes; /// QR code for a slatepack, or a note when it is too long for one. Tapping /// the code shows it full size. diff --git a/test/pages/slatepack/slatepack_qr_test.dart b/test/pages/slatepack/slatepack_qr_test.dart index c763ea648b..fde826e7bb 100644 --- a/test/pages/slatepack/slatepack_qr_test.dart +++ b/test/pages/slatepack/slatepack_qr_test.dart @@ -20,11 +20,32 @@ Widget _app(Widget child) => MaterialApp( void main() { test("length guard", () { - expect(slatepackFitsQr("a" * kSlatepackQrMaxLength), isTrue); - expect(slatepackFitsQr("a" * (kSlatepackQrMaxLength + 1)), isFalse); - // Measured Grin lengths: an encrypted S1 fits, an S2 does not. - expect(slatepackFitsQr("a" * 955), isTrue); - expect(slatepackFitsQr("a" * 1837), isFalse); + expect(slatepackFitsQr("a" * kSlatepackQrMaxBytes), isTrue); + expect(slatepackFitsQr("a" * (kSlatepackQrMaxBytes + 1)), isFalse); + // Bytes count, not characters. + expect(slatepackFitsQr("é" * (kSlatepackQrMaxBytes ~/ 2)), isTrue); + expect(slatepackFitsQr("é" * (kSlatepackQrMaxBytes ~/ 2 + 1)), isFalse); + }); + + testWidgets("the longest slatepack that fits is drawn", (tester) async { + await tester.pumpWidget( + _app(SlatepackQr(data: "a" * kSlatepackQrMaxBytes, noun: "slate")), + ); + await tester.pump(); + expect(find.byKey(const Key("slatepackQrCode")), findsOneWidget); + expect(find.byKey(const Key("slatepackQrTooLong")), findsNothing); + expect(find.text("Too much data to show as a QR code"), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets("a slate of a few thousand characters is drawn", (tester) async { + await tester.pumpWidget( + _app(SlatepackQr(data: '{"slate": "${"a" * 2400}"}', noun: "slate")), + ); + await tester.pump(); + expect(find.byKey(const Key("slatepackQrCode")), findsOneWidget); + expect(find.text("Too much data to show as a QR code"), findsNothing); + expect(tester.takeException(), isNull); }); testWidgets("short slatepack shows a QR code", (tester) async { @@ -52,7 +73,7 @@ void main() { testWidgets("long slatepack falls back to a note", (tester) async { await tester.pumpWidget( - _app(SlatepackQr(data: "a" * (kSlatepackQrMaxLength + 1), noun: "slate")), + _app(SlatepackQr(data: "a" * (kSlatepackQrMaxBytes + 1), noun: "slate")), ); expect(find.byType(QR), findsNothing); expect(find.byKey(const Key("slatepackQrTooLong")), findsOneWidget); From b6cb75a4a1c60e549885cf466b74ba9a0090cc2b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:14:18 -0600 Subject: [PATCH 24/30] Normalise slatepack text once before importing it The import flow passed whatever was pasted, scanned or opened straight to the wallet, so a slatepack with a greeting, a quote marker or other text around it was rejected. The flow now trims the text and, when it holds an armored slatepack, keeps only the part from the begin marker to the end marker. Both the BEGINSLATEPACK and BEGINSLATE_BIN frames are recognised, with or without the final period. JSON slates and anything without a frame are only trimmed. Inspecting, receiving and finalizing all use that text. --- .../slatepack/slatepack_import_flow.dart | 22 ++++- .../slatepack/slatepack_import_flow_test.dart | 89 +++++++++++++++++-- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/lib/pages/slatepack/slatepack_import_flow.dart b/lib/pages/slatepack/slatepack_import_flow.dart index 610bd86582..33994b5493 100644 --- a/lib/pages/slatepack/slatepack_import_flow.dart +++ b/lib/pages/slatepack/slatepack_import_flow.dart @@ -58,6 +58,21 @@ abstract final class SlatepackImportFlow { SlateState.unknown => SlatepackRoute.unsupported, }; + static final _armored = RegExp( + r"BEGINSLATE(PACK|_BIN)\.[\s\S]*?ENDSLATE\1\.?", + ); + + /// The slatepack in [text] without what came along with it. A pasted or + /// scanned message often has a greeting, a quote or blank lines around the + /// armored slatepack, so only the part from its begin marker to its end + /// marker is kept. Text without such a frame, like a JSON slate, is only + /// trimmed. + static String normalize(String text) { + final trimmed = text.trim(); + if (trimmed.startsWith("{")) return trimmed; + return _armored.firstMatch(trimmed)?.group(0) ?? trimmed; + } + /// Returns true once the slatepack has been acted on, so the caller can /// clear its input. static Future run({ @@ -66,12 +81,13 @@ abstract final class SlatepackImportFlow { required String text, }) async { final terms = wallet.slatepackTerms; + final slatepack = normalize(text); final read = await _withLoading( context, "Reading ${terms.noun}...", () async { - final inspection = await wallet.inspectSlatepack(text); + final inspection = await wallet.inspectSlatepack(slatepack); final route = routeFor( inspection.state, supportsInvoices: wallet.supportsInvoices, @@ -94,7 +110,7 @@ abstract final class SlatepackImportFlow { switch (route) { case SlatepackRoute.receive: - return _receive(context, wallet, text, inspection); + return _receive(context, wallet, slatepack, inspection); case SlatepackRoute.finalizeSend: final local = read.local; @@ -117,7 +133,7 @@ abstract final class SlatepackImportFlow { return _finalize( context, wallet, - text, + slatepack, inspection, amount: local.amount, fee: local.fee, diff --git a/test/pages/slatepack/slatepack_import_flow_test.dart b/test/pages/slatepack/slatepack_import_flow_test.dart index 8ee3fb359d..7ce063e040 100644 --- a/test/pages/slatepack/slatepack_import_flow_test.dart +++ b/test/pages/slatepack/slatepack_import_flow_test.dart @@ -31,9 +31,14 @@ class _InspectOnlyWallet extends EpiccashWallet { @override bool get supportsInvoices => invoices; + /// What the flow handed to the wallet. + final seen = []; + @override - Future inspectSlatepack(String armored) async => - SlateInspection(state: state, slateId: "test-slate"); + Future inspectSlatepack(String armored) async { + seen.add(armored); + return SlateInspection(state: state, slateId: "test-slate"); + } @override Future<({Amount amount, Amount fee, bool isSend})?> localTxForSlate( @@ -54,6 +59,7 @@ class _InspectOnlyWallet extends EpiccashWallet { @override Future finalizeSlatepack(String slatepack) async { + seen.add(slatepack); finalizeCalls++; return FinalizeResult(success: false, error: "not in tests"); } @@ -129,11 +135,71 @@ void main() { expect(SlateState.fromCode(null), SlateState.unknown); }); + group("normalize", () { + const armored = + "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv. " + "ENDSLATEPACK."; + + test("trims whitespace", () { + expect(SlatepackImportFlow.normalize(" \n$armored\r\n "), armored); + expect(SlatepackImportFlow.normalize(armored), armored); + }); + + test("drops text around the armored frame", () { + expect( + SlatepackImportFlow.normalize( + "Here is the slatepack:\n\n$armored\n\nSend it back soon.", + ), + armored, + ); + expect(SlatepackImportFlow.normalize("> $armored <"), armored); + }); + + test("keeps line breaks inside the frame", () { + const wrapped = + "BEGINSLATEPACK. 4H1qx1wHe668tFW\nyC2gfL8PPd8kSgv. ENDSLATEPACK."; + expect(SlatepackImportFlow.normalize("Hi\n$wrapped\nBye"), wrapped); + }); + + test("accepts the binary markers and a missing final period", () { + const binary = "BEGINSLATE_BIN. 4H1qx1wHe668tFW. ENDSLATE_BIN."; + expect(SlatepackImportFlow.normalize("x $binary y"), binary); + expect( + SlatepackImportFlow.normalize( + "BEGINSLATEPACK. 4H1qx1wHe668tFW. ENDSLATEPACK", + ), + "BEGINSLATEPACK. 4H1qx1wHe668tFW. ENDSLATEPACK", + ); + }); + + test("takes the first of two slatepacks", () { + expect(SlatepackImportFlow.normalize("$armored\n$armored"), armored); + }); + + test("leaves JSON slates alone apart from trimming", () { + const json = '{"id": "abc", "note": "BEGINSLATEPACK. x. ENDSLATEPACK."}'; + expect(SlatepackImportFlow.normalize(" $json\n"), json); + }); + + test("leaves other text alone apart from trimming", () { + expect( + SlatepackImportFlow.normalize(" not a slatepack "), + "not a slatepack", + ); + expect( + SlatepackImportFlow.normalize("BEGINSLATEPACK. cut off"), + "BEGINSLATEPACK. cut off", + ); + expect(SlatepackImportFlow.normalize(" "), ""); + }); + }); + group("run", () { Future runFlow( WidgetTester tester, - _InspectOnlyWallet wallet, - ) async { + _InspectOnlyWallet wallet, { + String text = "{}", + }) async { bool? outcome; final coin = wallet.cryptoCurrency; await tester.pumpWidget( @@ -163,7 +229,7 @@ void main() { outcome = await SlatepackImportFlow.run( context: context, wallet: wallet, - text: "{}", + text: text, ); }, child: const Text("go"), @@ -253,6 +319,19 @@ void main() { await closeDialog(tester); }); + testWidgets("the wallet only sees the slatepack itself", (tester) async { + const armored = "BEGINSLATEPACK. 4H1qx1wHe668tFW. ENDSLATEPACK."; + final wallet = _InspectOnlyWallet(SlateState.s2, localIsSend: true); + await runFlow(tester, wallet, text: "Here you go:\n$armored\nThanks\n"); + await tester.tap(find.text("Finalize")); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + // Once for the inspection and once for the finalize. + expect(wallet.seen, [armored, armored]); + await closeDialog(tester); + }); + testWidgets("invoice with invoice support is a placeholder", ( tester, ) async { From 2564c075454fd15008a640ff3765176398ee8ab0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:16:12 -0600 Subject: [PATCH 25/30] Keep Continue from starting a slatepack import twice On the slatepack screen a second press of Continue could start the import again while the first was still under way. On a phone the screen closes the keyboard and waits briefly before it starts, which leaves room for that, and the slatepack would then be processed twice. The screen now tracks a running import, ignores further presses and disables the button until the import has returned. --- lib/pages/slatepack/slatepack_view.dart | 39 ++++--- test/pages/slatepack/slatepack_view_test.dart | 107 ++++++++++++++++++ 2 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 test/pages/slatepack/slatepack_view_test.dart diff --git a/lib/pages/slatepack/slatepack_view.dart b/lib/pages/slatepack/slatepack_view.dart index 1d3d7d72d4..dc2ff95864 100644 --- a/lib/pages/slatepack/slatepack_view.dart +++ b/lib/pages/slatepack/slatepack_view.dart @@ -48,22 +48,33 @@ class _SlatepackViewState extends ConsumerState { late final FocusNode _focusNode; late final SlatepackInterface _wallet; + /// True while a slatepack is being imported, so a second press of Continue + /// cannot start the import again. + bool _importing = false; + bool get _hasText => _controller.text.trim().isNotEmpty; Future _continuePressed() async { - if (!Util.isDesktop && _focusNode.hasFocus) { - _focusNode.unfocus(); - await Future.delayed(const Duration(milliseconds: 75)); - } - if (!mounted) return; + if (_importing) return; + setState(() => _importing = true); + try { + if (!Util.isDesktop && _focusNode.hasFocus) { + _focusNode.unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + if (!mounted) return; - final done = await SlatepackImportFlow.run( - context: context, - wallet: _wallet, - text: _controller.text, - ); - if (done && mounted) { - setState(() => _controller.text = ""); + final done = await SlatepackImportFlow.run( + context: context, + wallet: _wallet, + text: _controller.text, + ); + if (done && mounted) { + setState(() => _controller.text = ""); + } + } finally { + _importing = false; + if (mounted) setState(() {}); } } @@ -152,8 +163,8 @@ class _SlatepackViewState extends ConsumerState { PrimaryButton( label: "Continue", buttonHeight: Util.isDesktop ? ButtonHeight.l : null, - enabled: _hasText, - onPressed: _hasText ? _continuePressed : null, + enabled: _hasText && !_importing, + onPressed: _hasText && !_importing ? _continuePressed : null, ), if (!Util.isDesktop) SizedBox(height: Constants.size.standardPadding), ], diff --git a/test/pages/slatepack/slatepack_view_test.dart b/test/pages/slatepack/slatepack_view_test.dart new file mode 100644 index 0000000000..a24652c7c6 --- /dev/null +++ b/test/pages/slatepack/slatepack_view_test.dart @@ -0,0 +1,107 @@ +import 'dart:async'; +import 'dart:io'; + +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/models/slatepack_models.dart'; +import 'package:stackwallet/pages/slatepack/slatepack_view.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; + +import '../../sample_data/theme_json.dart'; +import '../../wallets/support/xelis_test_fakes.dart'; + +/// Epic Cash wallet whose inspection waits for [gate] and then reports a +/// finalized slate, which the import flow answers with a plain message. +class _SlowWallet extends EpiccashWallet { + _SlowWallet() : super(CryptoCurrencyNetwork.main); + + final gate = Completer(); + int inspections = 0; + + @override + Future inspectSlatepack(String armored) async { + inspections++; + await gate.future; + return const SlateInspection(state: SlateState.s3, slateId: "test-slate"); + } +} + +void main() { + testWidgets("Continue starts one import however often it is pressed", ( + tester, + ) async { + tester.view.physicalSize = const Size(1400, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + // The phone layout closes the keyboard and waits a moment before it + // starts the import, which is when a second press can get in. + final previousWidth = Util.screenWidth; + Util.screenWidth = 400; + addTearDown(() => Util.screenWidth = previousWidth); + final previousThemes = StackFileSystem.themesDir; + StackFileSystem.themesDir = Directory("test/sample_data").absolute; + addTearDown(() => StackFileSystem.themesDir = previousThemes); + final wallet = _SlowWallet(); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pWallets.overrideWithValue(ConfirmationWallets(wallet)), + themeProvider.overrideWithProvider(StateProvider((ref) => theme)), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: const Scaffold(body: SlatepackView(walletId: "wallet-1")), + ), + ), + ); + await tester.pumpAndSettle(); + + PrimaryButton button() => + tester.widget(find.byType(PrimaryButton)); + + expect(button().enabled, isFalse); + await tester.enterText(find.byType(TextField), "{}"); + await tester.pump(); + expect(button().enabled, isTrue); + + // The second press lands before a frame has disabled the button, the + // third while the keyboard is still closing. + await tester.tap(find.text("Continue")); + await tester.tap(find.text("Continue"), warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 20)); + expect(button().enabled, isFalse); + await tester.tap(find.text("Continue"), warnIfMissed: false); + + // Keyboard delay, loading overlay delay, then the inspection. + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 400)); + expect(wallet.inspections, 1); + + wallet.gate.complete(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text("Already finalized"), findsOneWidget); + await tester.tap(find.text("OK")); + await tester.pump(const Duration(milliseconds: 400)); + + expect(wallet.inspections, 1); + // Nothing was acted on, so the text stays and Continue works again. + expect(find.text("{}"), findsOneWidget); + expect(button().enabled, isTrue); + expect(tester.takeException(), isNull); + }); +} From febcf363079ecd7ed648e0b5cd0f78bdaf8d997a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:21:04 -0600 Subject: [PATCH 26/30] Show a received slatepack's response after its screen closes Receiving a slatepack takes a while. If the screen that started the import was gone by the time the wallet had signed it, the response was dropped without a word, even though the wallet had already recorded the transaction and the sender still needs that response. The import flow now keeps hold of the root navigator before it starts and shows the response from there when its own screen has closed. It logs the slate id in that case, and also when a finalize completes after the screen closed. The response text itself is not logged. --- .../slatepack/slatepack_import_flow.dart | 41 ++++++- .../slatepack/slatepack_import_flow_test.dart | 111 +++++++++++++++--- 2 files changed, 130 insertions(+), 22 deletions(-) diff --git a/lib/pages/slatepack/slatepack_import_flow.dart b/lib/pages/slatepack/slatepack_import_flow.dart index 33994b5493..6fb617934d 100644 --- a/lib/pages/slatepack/slatepack_import_flow.dart +++ b/lib/pages/slatepack/slatepack_import_flow.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../../models/slatepack_models.dart'; import '../../notifications/show_flush_bar.dart'; import '../../utilities/amount/amount.dart'; +import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/util.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; @@ -214,6 +215,10 @@ abstract final class SlatepackImportFlow { ); if (confirmed != true || !context.mounted) return false; + // Looked up before the wallet is asked, so the response can still be + // shown if the screen that started the import is gone by then. + final root = Navigator.of(context, rootNavigator: true); + final result = await _withLoading( context, "Processing ${terms.noun}...", @@ -226,10 +231,32 @@ abstract final class SlatepackImportFlow { }, errorTitle: "${terms.title} receive error", ); - if (result == null || !context.mounted) return false; + if (result == null) return false; unawaited(wallet.refresh()); - await showSlatepackDialog(context: context, terms: terms, result: result); + + // The transaction is received now and the sender cannot finalize it + // without the response, so the response is shown wherever it still can + // be. + if (context.mounted) { + await showSlatepackDialog(context: context, terms: terms, result: result); + } else if (root.mounted) { + Logging.instance.w( + "Received ${terms.noun} ${result.slateId} after the screen that " + "imported it had closed. Showing the response from the root " + "navigator.", + ); + await showSlatepackDialog( + context: root.context, + terms: terms, + result: result, + ); + } else { + Logging.instance.e( + "Received ${terms.noun} ${result.slateId} but there was nowhere left " + "to show the response.", + ); + } return true; } @@ -273,10 +300,18 @@ abstract final class SlatepackImportFlow { }, errorTitle: "${terms.title} finalize error", ); - if (result == null || !context.mounted) return false; + if (result == null) return false; unawaited(wallet.refresh()); + if (!context.mounted) { + Logging.instance.w( + "Finalized ${terms.noun} ${inspection.slateId} after the screen that " + "imported it had closed. Broadcast: ${result.posted}.", + ); + return true; + } + if (result.posted) { unawaited( showFloatingFlushBar( diff --git a/test/pages/slatepack/slatepack_import_flow_test.dart b/test/pages/slatepack/slatepack_import_flow_test.dart index 7ce063e040..888d0ed67a 100644 --- a/test/pages/slatepack/slatepack_import_flow_test.dart +++ b/test/pages/slatepack/slatepack_import_flow_test.dart @@ -1,13 +1,19 @@ +import 'dart:async'; +import 'dart:io'; + 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/models/slatepack_models.dart'; +import 'package:stackwallet/pages/slatepack/slatepack_dialog.dart'; import 'package:stackwallet/pages/slatepack/slatepack_import_flow.dart'; import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/amount/amount_formatter.dart'; import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; @@ -16,8 +22,15 @@ import '../../sample_data/theme_json.dart'; /// Epic Cash wallet whose inspection is canned, so run() can be driven /// without a native wallet. class _InspectOnlyWallet extends EpiccashWallet { - _InspectOnlyWallet(this.state, {this.invoices = false, this.localIsSend}) - : super(CryptoCurrencyNetwork.main); + _InspectOnlyWallet( + this.state, { + this.invoices = false, + this.localIsSend, + this.receiveGate, + }) : super(CryptoCurrencyNetwork.main); + + /// When set, receiving waits for it and then succeeds. + final Completer? receiveGate; final SlateState state; final bool invoices; @@ -54,9 +67,21 @@ class _InspectOnlyWallet extends EpiccashWallet { @override Future receiveSlatepack(String slatepack) async { receiveCalls++; - return SlatepackResult(success: false, error: "not in tests"); + if (receiveGate == null) { + return SlatepackResult(success: false, error: "not in tests"); + } + await receiveGate!.future; + return SlatepackResult( + success: true, + slatepack: '{"response": true}', + slateId: "test-slate", + state: SlateState.s2, + ); } + @override + Future refresh({bool doScan = true}) async {} + @override Future finalizeSlatepack(String slatepack) async { seen.add(slatepack); @@ -199,12 +224,21 @@ void main() { WidgetTester tester, _InspectOnlyWallet wallet, { String text = "{}", + ValueNotifier? shown, + void Function(bool)? onDone, }) async { bool? outcome; + shown ??= ValueNotifier(true); final coin = wallet.cryptoCurrency; + // The loading overlay reads its indicator from the theme. + final previousThemes = StackFileSystem.themesDir; + StackFileSystem.themesDir = Directory("test/sample_data").absolute; + addTearDown(() => StackFileSystem.themesDir = previousThemes); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); await tester.pumpWidget( ProviderScope( overrides: [ + themeProvider.overrideWithProvider(StateProvider((ref) => theme)), pAmountFormatter(coin).overrideWithValue( AmountFormatter( unit: AmountUnit.normal, @@ -216,24 +250,26 @@ void main() { ], child: MaterialApp( theme: ThemeData( - extensions: [ - StackColors.fromStackColorTheme( - StackTheme.fromJson(json: lightThemeJsonMap), - ), - ], + extensions: [StackColors.fromStackColorTheme(theme)], ), home: Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () async { - outcome = await SlatepackImportFlow.run( - context: context, - wallet: wallet, - text: text, - ); - }, - child: const Text("go"), - ), + body: ValueListenableBuilder( + valueListenable: shown, + builder: (context, isShown, _) => !isShown + ? const SizedBox() + : Builder( + builder: (context) => TextButton( + onPressed: () async { + outcome = await SlatepackImportFlow.run( + context: context, + wallet: wallet, + text: text, + ); + onDone?.call(outcome!); + }, + child: const Text("go"), + ), + ), ), ), ), @@ -332,6 +368,43 @@ void main() { await closeDialog(tester); }); + testWidgets("the response is shown even if the importing screen closed " + "while the slate was received", (tester) async { + tester.view.physicalSize = const Size(1400, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final gate = Completer(); + final wallet = _InspectOnlyWallet(SlateState.s1, receiveGate: gate); + final shown = ValueNotifier(true); + addTearDown(shown.dispose); + bool? done; + await runFlow(tester, wallet, shown: shown, onDone: (v) => done = v); + expect(find.text("Receive slate"), findsOneWidget); + await tester.tap(find.text("Receive")); + // Summary closes, loading overlay delay, then the wallet is asked. + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + expect(wallet.receiveCalls, 1); + expect(find.byType(SlatepackDialog), findsNothing); + + shown.value = false; + await tester.pump(); + expect(find.text("go"), findsNothing); + + gate.complete(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(SlatepackDialog), findsOneWidget); + expect(find.text('{"response": true}'), findsOneWidget); + + await tester.tap(find.text("Done")); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + expect(done, isTrue); + expect(tester.takeException(), isNull); + }); + testWidgets("invoice with invoice support is a placeholder", ( tester, ) async { From cfd161e525a52611e081685509e71ce28f65164e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:22:11 -0600 Subject: [PATCH 27/30] Anchor the slatepack share sheet to the Share button The slatepack dialog shared its text without a position for the share sheet. An iPad needs one to place the popover and refuses to open the sheet without it, so Share did nothing there apart from logging an error. The dialog now passes the Share button's rect on screen. Other devices ignore it. --- lib/pages/slatepack/slatepack_dialog.dart | 24 ++++-- .../slatepack/slatepack_dialog_test.dart | 75 +++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 test/pages/slatepack/slatepack_dialog_test.dart diff --git a/lib/pages/slatepack/slatepack_dialog.dart b/lib/pages/slatepack/slatepack_dialog.dart index 5a8dc27583..47260064f6 100644 --- a/lib/pages/slatepack/slatepack_dialog.dart +++ b/lib/pages/slatepack/slatepack_dialog.dart @@ -118,10 +118,20 @@ class SlatepackDialog extends StatelessWidget { } } - Future _share() async { + /// [context] is the Share button's. An iPad anchors the share sheet to the + /// button's rect and will not open it without one. + Future _share(BuildContext context) async { + final box = context.findRenderObject(); + final origin = box is RenderBox && box.hasSize + ? box.localToGlobal(Offset.zero) & box.size + : null; try { await SharePlus.instance.share( - ShareParams(text: slatepack, subject: title), + ShareParams( + text: slatepack, + subject: title, + sharePositionOrigin: origin, + ), ); } catch (e, s) { Logging.instance.e( @@ -250,10 +260,12 @@ class SlatepackDialog extends StatelessWidget { if (!isDesktop) ...[ const SizedBox(width: 12), Expanded( - child: SecondaryButton( - label: "Share", - icon: _icon(context, Assets.svg.share), - onPressed: _share, + child: Builder( + builder: (context) => SecondaryButton( + label: "Share", + icon: _icon(context, Assets.svg.share), + onPressed: () => _share(context), + ), ), ), ], diff --git a/test/pages/slatepack/slatepack_dialog_test.dart b/test/pages/slatepack/slatepack_dialog_test.dart new file mode 100644 index 0000000000..74fd356693 --- /dev/null +++ b/test/pages/slatepack/slatepack_dialog_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/models/slatepack_models.dart'; +import 'package:stackwallet/pages/slatepack/slatepack_dialog.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/slatepack_terms.dart'; +import 'package:stackwallet/utilities/util.dart'; + +import '../../sample_data/theme_json.dart'; + +void main() { + testWidgets("Share anchors the share sheet to the Share button", ( + tester, + ) async { + tester.view.physicalSize = const Size(1400, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + // Share is only offered on the phone layout. + final previousWidth = Util.screenWidth; + Util.screenWidth = 400; + addTearDown(() => Util.screenWidth = previousWidth); + + const channel = MethodChannel("dev.fluttercommunity.plus/share"); + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return ""; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: const Scaffold( + body: SlatepackDialog( + terms: SlatepackTerms.slate, + slatepack: '{"slate": true}', + state: SlateState.s1, + slateId: "test-slate", + ), + ), + ), + ), + ); + + await tester.tap(find.text("Share")); + await tester.pump(); + + expect(calls, hasLength(1)); + final args = calls.single.arguments as Map; + expect(args["text"], '{"slate": true}'); + final origin = Rect.fromLTWH( + args["originX"] as double, + args["originY"] as double, + args["originWidth"] as double, + args["originHeight"] as double, + ); + expect(origin.isEmpty, isFalse); + expect(origin.contains(tester.getCenter(find.text("Share"))), isTrue); + expect( + (Offset.zero & tester.view.physicalSize).contains(origin.topLeft), + isTrue, + ); + }); +} From 20a50577092bdf3150452ad21dc044759a87199d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:45:08 -0600 Subject: [PATCH 28/30] Close the sending dialog by its own route The sending dialog was closed by popping the root navigator. If another route had opened above the dialog by the time the send returned, that route was popped and the dialog stayed up. The dialog's route is now kept and removed directly, so nothing else is closed. A failed HTTP address also closes the dialog when the view is already gone. --- .../send_view/confirm_transaction_view.dart | 42 ++++++++---------- .../slatepack_confirmation_test.dart | 43 +++++++++++++++++++ 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index db6483184b..49f52dd7cc 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -264,36 +264,33 @@ class _ConfirmTransactionViewState final coin = wallet.info.coin; final sendProgressController = ProgressAndSuccessController(); - var isSendingDialogOpen = true; // Looked up now so the dialog can still be closed if this view is gone by // the time the send returns. final navigator = Navigator.of(context, rootNavigator: true); + // The dialog's own route is kept so that closing it removes that route + // and nothing else, whatever is on top of it by then. + final sendingRoute = DialogRoute( + context: context, + useSafeArea: false, + barrierDismissible: false, + themes: InheritedTheme.capture(from: context, to: navigator.context), + builder: (context) { + return SendingTransactionDialog( + coin: coin, + controller: sendProgressController, + ); + }, + ); + void closeSendingDialog() { - if (!navigator.mounted || !isSendingDialogOpen) { - return; + if (navigator.mounted && sendingRoute.isActive) { + navigator.removeRoute(sendingRoute); } - if (navigator.canPop()) { - navigator.pop(); - } - isSendingDialogOpen = false; } - unawaited( - showDialog( - context: context, - useRootNavigator: true, - useSafeArea: false, - barrierDismissible: false, - builder: (context) { - return SendingTransactionDialog( - coin: coin, - controller: sendProgressController, - ); - }, - ).whenComplete(() => isSendingDialogOpen = false), - ); + unawaited(navigator.push(sendingRoute)); final time = Future.delayed(const Duration(milliseconds: 2500)); @@ -406,9 +403,8 @@ class _ConfirmTransactionViewState } } } on BadHttpAddressException catch (_) { + closeSendingDialog(); if (context.mounted) { - // pop building dialog - closeSendingDialog(); unawaited( showFloatingFlushBar( type: FlushBarType.warning, diff --git a/test/pages/send_view/slatepack_confirmation_test.dart b/test/pages/send_view/slatepack_confirmation_test.dart index 892cfdf7ee..c40dc7db61 100644 --- a/test/pages/send_view/slatepack_confirmation_test.dart +++ b/test/pages/send_view/slatepack_confirmation_test.dart @@ -336,4 +336,47 @@ void main() { await tester.pump(_sendTimer); expect(tester.takeException(), isNull); }); + + testWidgets('closing the sending dialog leaves a route opened above it ' + 'alone', (tester) async { + final gate = Completer(); + final wallet = _Wallet(gate: gate); + final shown = ValueNotifier(true); + addTearDown(shown.dispose); + await _confirmSend(tester, wallet, shown: shown); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(SendingTransactionDialog), findsOneWidget); + + final root = Navigator.of( + tester.element(find.byType(SendingTransactionDialog)), + rootNavigator: true, + ); + unawaited( + root.push( + MaterialPageRoute(builder: (_) => const Text('opened above')), + ), + ); + shown.value = false; + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('opened above'), findsOneWidget); + + gate.complete(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('opened above'), findsOneWidget); + expect( + find.byType(SendingTransactionDialog, skipOffstage: false), + findsNothing, + ); + + root.pop(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('opened above'), findsNothing); + expect(find.byType(SendingTransactionDialog), findsNothing); + await tester.pump(_sendTimer); + expect(tester.takeException(), isNull); + }); } From 8bace85a294ecb9f7fefc4616b16d9863d9d932c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:47:53 -0600 Subject: [PATCH 29/30] Take the slatepack loading overlay down after the screen closes The import flow showed its loading overlay through showLoading, which only removes the overlay while the calling screen is still mounted. If that screen closed while the wallet was working, the overlay stayed over the whole app, under the response that is now shown in that case. The flow keeps its own overlay route on the root navigator and removes it when the wallet answers. An error is shown from the root navigator too when the screen is gone. --- .../slatepack/slatepack_import_flow.dart | 61 +++++++++++++------ .../slatepack/slatepack_import_flow_test.dart | 48 ++++++++++++++- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/lib/pages/slatepack/slatepack_import_flow.dart b/lib/pages/slatepack/slatepack_import_flow.dart index 6fb617934d..a8d5927eda 100644 --- a/lib/pages/slatepack/slatepack_import_flow.dart +++ b/lib/pages/slatepack/slatepack_import_flow.dart @@ -5,10 +5,11 @@ import 'package:flutter/material.dart'; import '../../models/slatepack_models.dart'; import '../../notifications/show_flush_bar.dart'; import '../../utilities/amount/amount.dart'; +import '../../themes/stack_colors.dart'; import '../../utilities/logger.dart'; -import '../../utilities/show_loading.dart'; import '../../utilities/util.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart'; +import '../../widgets/custom_loading_overlay.dart'; import '../../widgets/stack_dialog.dart'; import 'slatepack_dialog.dart'; import 'slatepack_popup.dart'; @@ -339,26 +340,52 @@ abstract final class SlatepackImportFlow { Future Function() action, { required String errorTitle, }) async { - Exception? ex; - final result = await showLoading( - // Let the overlay open before the work starts, so an early error - // does not pop the wrong route. - whileFuture: Future.delayed(const Duration(milliseconds: 300)) - .then((_) => action()), + // The overlay is this flow's own route on the root navigator, so it can + // be taken down again even if the screen that started the import is gone + // by the time the wallet answers. + final root = Navigator.of(context, rootNavigator: true); + final overlayColor = Theme.of(context) + .extension()! + .overlay + .withValues(alpha: 0.6); + final overlay = DialogRoute( context: context, - message: message, - rootNavigator: true, - onException: (e) => ex = e, + barrierDismissible: false, + themes: InheritedTheme.capture(from: context, to: root.context), + builder: (_) => PopScope( + canPop: false, + child: Container( + color: overlayColor, + child: CustomLoadingOverlay(message: message, eventBus: null), + ), + ), ); + unawaited(root.push(overlay)); + + T? result; + Object? error; + try { + // Let the overlay paint before the wallet is asked. + await Future.delayed(const Duration(milliseconds: 300)); + result = await action(); + } catch (e, s) { + Logging.instance.w(errorTitle, error: e, stackTrace: s); + error = e; + } - if (ex != null || result == null) { + if (root.mounted && overlay.isActive) { + root.removeRoute(overlay); + } + + if (error != null || result == null) { + // An error is worth showing even after the importing screen closed. + final text = + error?.toString().replaceFirst("Exception: ", "") ?? + "Unexpected result without an error"; if (context.mounted) { - await _message( - context, - errorTitle, - ex?.toString().replaceFirst("Exception: ", "") ?? - "Unexpected result without an error", - ); + await _message(context, errorTitle, text); + } else if (root.mounted) { + await _message(root.context, errorTitle, text); } return null; } diff --git a/test/pages/slatepack/slatepack_import_flow_test.dart b/test/pages/slatepack/slatepack_import_flow_test.dart index 888d0ed67a..8a251cadce 100644 --- a/test/pages/slatepack/slatepack_import_flow_test.dart +++ b/test/pages/slatepack/slatepack_import_flow_test.dart @@ -16,6 +16,7 @@ import 'package:stackwallet/utilities/amount/amount_unit.dart'; import 'package:stackwallet/utilities/stack_file_system.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; +import 'package:stackwallet/widgets/custom_loading_overlay.dart'; import '../../sample_data/theme_json.dart'; @@ -27,10 +28,13 @@ class _InspectOnlyWallet extends EpiccashWallet { this.invoices = false, this.localIsSend, this.receiveGate, + this.receiveError, }) : super(CryptoCurrencyNetwork.main); - /// When set, receiving waits for it and then succeeds. + /// When set, receiving waits for it and then succeeds, or throws + /// [receiveError] if that is set. final Completer? receiveGate; + final Exception? receiveError; final SlateState state; final bool invoices; @@ -71,6 +75,7 @@ class _InspectOnlyWallet extends EpiccashWallet { return SlatepackResult(success: false, error: "not in tests"); } await receiveGate!.future; + if (receiveError != null) throw receiveError!; return SlatepackResult( success: true, slatepack: '{"response": true}', @@ -387,6 +392,7 @@ void main() { await tester.pump(const Duration(milliseconds: 400)); expect(wallet.receiveCalls, 1); expect(find.byType(SlatepackDialog), findsNothing); + expect(find.byType(CustomLoadingOverlay), findsOneWidget); shown.value = false; await tester.pump(); @@ -402,6 +408,46 @@ void main() { await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400)); expect(done, isTrue); + // Nothing is left over the app once the response is closed. + expect( + find.byType(CustomLoadingOverlay, skipOffstage: false), + findsNothing, + ); + expect(find.byType(ModalBarrier), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets("an error is shown even if the importing screen closed " + "while the slate was received", (tester) async { + final gate = Completer(); + final wallet = _InspectOnlyWallet( + SlateState.s1, + receiveGate: gate, + receiveError: Exception("the slate was refused"), + ); + final shown = ValueNotifier(true); + addTearDown(shown.dispose); + bool? done; + await runFlow(tester, wallet, shown: shown, onDone: (v) => done = v); + await tester.tap(find.text("Receive")); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + expect(wallet.receiveCalls, 1); + + shown.value = false; + await tester.pump(); + gate.complete(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text("the slate was refused"), findsOneWidget); + expect( + find.byType(CustomLoadingOverlay, skipOffstage: false), + findsNothing, + ); + + await closeDialog(tester); + await tester.pump(const Duration(milliseconds: 400)); + expect(done, isFalse); expect(tester.takeException(), isNull); }); From 1a9d15bef0ab2cabfba351937c029df80558f950 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 11 Nov 2026 17:47:53 -0600 Subject: [PATCH 30/30] Remove the unused note from the slatepack summary dialog --- .../slatepack/slatepack_summary_dialog.dart | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/lib/pages/slatepack/slatepack_summary_dialog.dart b/lib/pages/slatepack/slatepack_summary_dialog.dart index a13456bd5a..9f0435e82b 100644 --- a/lib/pages/slatepack/slatepack_summary_dialog.dart +++ b/lib/pages/slatepack/slatepack_summary_dialog.dart @@ -4,13 +4,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; -import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/detail_item.dart'; -import '../../widgets/rounded_container.dart'; import '../../widgets/rounded_white_container.dart'; import 'slatepack_popup.dart'; @@ -25,7 +23,6 @@ class SlatepackSummaryDialog extends ConsumerWidget { this.amount, this.fee, this.details = const [], - this.note, }); final String title; @@ -37,9 +34,6 @@ class SlatepackSummaryDialog extends ConsumerWidget { /// Extra label and value rows shown under the amount and fee. final List<(String, String)> details; - /// Shown above the buttons, e.g. a warning. - final String? note; - @override Widget build(BuildContext context, WidgetRef ref) { final isDesktop = Util.isDesktop; @@ -72,17 +66,6 @@ class SlatepackSummaryDialog extends ConsumerWidget { ], ), ), - if (note != null) ...[ - const SizedBox(height: 16), - RoundedContainer( - color: colors.warningBackground, - child: Text( - note!, - style: STextStyles.label(context) - .copyWith(color: colors.warningForeground), - ), - ), - ], const SizedBox(height: 24), Row( children: [