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(); 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/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..c0887f0a7e 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, @@ -49,41 +46,27 @@ class EpicBoxConfigModel { }) { 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, ); } - 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, + // The plugin's EpicboxConfig field is spelled "unsecure". + 'epicbox_protocol_unsecure': protocolInsecure, 'epicbox_address_index': addressIndex, // 'id': id, // 'name': name, }; } - @override - String toString() { - return json.encode(toJson()); - } - static EpicBoxConfigModel fromString(String epicBoxConfigString) { final dynamic _epicBox = json.decode(epicBoxConfigString); @@ -96,22 +79,22 @@ class EpicBoxConfigModel { 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/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/mwc_slatepack_models.dart b/lib/models/mwc_slatepack_models.dart deleted file mode 100644 index 8b5c05b832..0000000000 --- a/lib/models/mwc_slatepack_models.dart +++ /dev/null @@ -1,116 +0,0 @@ -class SlatepackResult { - final bool success; - final String? error; - final String? slatepack; - final String? slateJson; - final bool? wasEncrypted; - final String? recipientAddress; - - SlatepackResult({ - required this.success, - this.error, - this.slatepack, - this.slateJson, - this.wasEncrypted, - this.recipientAddress, - }); - - @override - String toString() { - return "SlatepackResult(" - "success: $success, " - "error: $error, " - "slatepack: $slatepack, " - "slateJson: $slateJson, " - "wasEncrypted: $wasEncrypted, " - "recipientAddress: $recipientAddress" - ")"; - } -} - -class SlatepackDecodeResult { - final bool success; - final String? error; - final String? slateJson; - final bool? wasEncrypted; - final String? senderAddress; - final String? recipientAddress; - - SlatepackDecodeResult({ - required this.success, - this.error, - this.slateJson, - this.wasEncrypted, - this.senderAddress, - this.recipientAddress, - }); - - @override - String toString() { - return "SlatepackDecodeResult(" - "success: $success, " - "error: $error, " - "slateJson: $slateJson, " - "wasEncrypted: $wasEncrypted, " - "senderAddress: $senderAddress, " - "recipientAddress: $recipientAddress" - ")"; - } -} - -class ReceiveResult { - 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" - ")"; - } -} - -class FinalizeResult { - final bool success; - final String? error; - final String? slateId; - final String? commitId; - - FinalizeResult({ - required this.success, - this.error, - this.slateId, - this.commitId, - }); - - @override - String toString() { - return "FinalizeResult(" - "success: $success, " - "error: $error, " - "slateId: $slateId, " - "commitId: $commitId" - ")"; - } -} diff --git a/lib/models/mwcmqs_config_model.dart b/lib/models/mwcmqs_config_model.dart index eb37702f5d..b1bbdbab38 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}; + return MwcMqsConfigModel(host: host, port: port ?? this.port ?? 443); } @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/slatepack_models.dart b/lib/models/slatepack_models.dart new file mode 100644 index 0000000000..d731a982f7 --- /dev/null +++ b/lib/models/slatepack_models.dart @@ -0,0 +1,171 @@ +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; + final String? slatepack; + 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, + this.error, + this.slatepack, + this.slateJson, + this.wasEncrypted, + this.recipientAddress, + this.state = SlateState.unknown, + this.slateId, + this.amount, + this.fee, + }); + + @override + String toString() { + return "SlatepackResult(" + "success: $success, " + "error: $error, " + "slatepack: $slatepack, " + "slateJson: $slateJson, " + "wasEncrypted: $wasEncrypted, " + "recipientAddress: $recipientAddress, " + "state: ${state.code}, " + "slateId: $slateId, " + "amount: $amount, " + "fee: $fee" + ")"; + } +} + +class SlatepackDecodeResult { + final bool success; + final String? error; + final String? slateJson; + final bool? wasEncrypted; + final String? senderAddress; + final String? recipientAddress; + + SlatepackDecodeResult({ + required this.success, + this.error, + this.slateJson, + this.wasEncrypted, + this.senderAddress, + this.recipientAddress, + }); + + @override + String toString() { + return "SlatepackDecodeResult(" + "success: $success, " + "error: $error, " + "slateJson: $slateJson, " + "wasEncrypted: $wasEncrypted, " + "senderAddress: $senderAddress, " + "recipientAddress: $recipientAddress" + ")"; + } +} + +class FinalizeResult { + final bool success; + final String? error; + final String? slateId; + final String? commitId; + + /// Whether the finalized transaction reached the node. Epic Cash and MWC + /// always post on finalize. + final bool posted; + + /// 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 + String toString() { + return "FinalizeResult(" + "success: $success, " + "error: $error, " + "slateId: $slateId, " + "commitId: $commitId, " + "posted: $posted, " + "kernelExcess: $kernelExcess" + ")"; + } +} 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 8239e8906f..918e0ea607 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'; @@ -227,7 +226,7 @@ class _RestoreWalletViewState extends ConsumerState { if (widget.coin is Epiccash) { otherDataJsonString = jsonEncode({ WalletInfoKeys.epiccashData: jsonEncode( - ExtraEpiccashWalletInfo( + ExtraMimblewimbleWalletInfo( receivingIndex: 0, changeIndex: 0, slatesToAddresses: {}, @@ -255,7 +254,7 @@ class _RestoreWalletViewState extends ConsumerState { } otherDataJsonString = jsonEncode({ WalletInfoKeys.mimblewimblecoinData: jsonEncode( - ExtraMimblewimblecoinWalletInfo( + ExtraMimblewimbleWalletInfo( receivingIndex: 0, changeIndex: 0, slatesToAddresses: {}, 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 13abb38d6e..0000000000 --- a/lib/pages/epic_finalize_view/epic_finalize_view.dart +++ /dev/null @@ -1,338 +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 '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'; - -class EpicFinalizeView extends ConsumerStatefulWidget { - const EpicFinalizeView({ - super.key, - required this.walletId, - this.clipboard = const ClipboardWrapper(), - }); - - 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), - ], - ), - ); - } -} diff --git a/lib/pages/finalize_view/finalize_view.dart b/lib/pages/finalize_view/finalize_view.dart deleted file mode 100644 index 65fb50a1be..0000000000 --- a/lib/pages/finalize_view/finalize_view.dart +++ /dev/null @@ -1,338 +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 '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/mimblewimblecoin_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'; - -class FinalizeView extends ConsumerStatefulWidget { - const FinalizeView({ - super.key, - required this.walletId, - this.clipboard = const ClipboardWrapper(), - }); - - static const String routeName = "/finalizeView"; - - final String walletId; - final ClipboardInterface clipboard; - - @override - ConsumerState createState() => _FinalizeViewState(); -} - -class _FinalizeViewState 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 MimblewimblecoinWallet; - - final decoded = await wallet.decodeSlatepack(_slateController.text); - if (!decoded.success) { - throw Exception(decoded.error ?? "Failed to decode slatepack"); - } - - final analysis = await wallet.analyzeSlatepack(_slateController.text); - if (analysis.status != "S2") { - throw Exception("Invalid slatepack 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 slatepack...", - rootNavigator: Util.isDesktop, - onException: (e) => ex = e, - ); - - if (mounted) { - if (ex != null) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - desktopPopRootNavigator: Util.isDesktop, - title: "Slatepack 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 slatepack", - 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("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( - "Enter Final Slatepack Message", - _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(), - ), - ], - ), - ), - ), - ), - ), - ), - Util.isDesktop ? const SizedBox(height: 24) : const Spacer(), - PrimaryButton( - label: "Finalize Slatepack", - enabled: _slateToggleFlag, - onPressed: _slateToggleFlag ? _finalizePressed : null, - ), - - if (!Util.isDesktop) SizedBox(height: Constants.size.standardPadding), - ], - ), - ); - } -} diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 61f8ae5fbe..175186dae9 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -29,17 +29,17 @@ 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/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,15 +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/epic_slatepack_entry_dialog.dart'; -import 'sub_widgets/epic_slatepack_import_dialog.dart'; -import 'sub_widgets/mwc_slatepack_import_dialog.dart'; +import '../slatepack/slatepack_import_flow.dart'; import 'sub_widgets/slatepack_entry_dialog.dart'; class ReceiveView extends ConsumerStatefulWidget { @@ -85,6 +81,7 @@ class _ReceiveViewState extends ConsumerState { late final bool _supportsSpark; late final bool _showMultiType; late bool supportsMweb; + SlatepackTerms? _slatepackTerms; int _currentIndex = 0; @@ -93,125 +90,18 @@ 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 wallet = ref.read(pWallets).getWallet(walletId) as SlatepackInterface; - 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( + final text = await showDialog( context: context, - builder: (context) => const EpicSlatepackEntryDialog(), + 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 EpiccashWallet; - - Exception? ex; - final result = await showLoading( - whileFuture: wallet.fullDecodeSlatepack(slatepackString), - context: context, - message: "Decoding slate...", - onException: (e) => ex = e, - ); - - if (result == null || ex != null) { - if (mounted) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - title: "Slate 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: 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( - responseSlatepack: response.responseSlatepack, - wasEncrypted: response.wasEncrypted, - ), - ); - } - } - } + await SlatepackImportFlow.run(context: context, wallet: wallet, text: text); } Future generateNewAddress() async { @@ -226,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, @@ -285,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]] = @@ -308,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, @@ -392,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 && @@ -553,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, ), @@ -580,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, ), @@ -651,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), @@ -820,22 +713,13 @@ class _ReceiveViewState extends ConsumerState { ? generateNewSparkAddress : generateNewAddress, ), - // MWC Slatepack import button. - if (coin is Mimblewimblecoin) ...[ + if (_slatepackTerms != null) ...[ const SizedBox(height: 12), SecondaryButton( - label: "Import Slatepack", + label: "Import ${_slatepackTerms!.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 769a58a94f..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/epic_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 EpicSlatepackDecodeResult 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/mwc_slatepack_import_dialog.dart b/lib/pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart deleted file mode 100644 index 6b1bc060d8..0000000000 --- a/lib/pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart +++ /dev/null @@ -1,361 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../models/mwc_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/mimblewimblecoin_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 MwcSlatepackImportDialog extends ConsumerStatefulWidget { - const MwcSlatepackImportDialog({ - 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() => - _MwcSlatepackImportDialogState(); -} - -class _MwcSlatepackImportDialogState - 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 MimblewimblecoinWallet; - - // 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 slatepack...", - 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: "Slatepack 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; - - @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, - ); - - // final rawFee = BigInt.tryParse(map["fee"].toString()); - // _fee = - // rawFee == null - // ? null - // : Amount( - // rawValue: rawFee, - // 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 Slatepack", - 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 slatepack", - style: STextStyles.pageTitleH2(context), - ), - ), - - // 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.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 Slatepack", - 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 slatepack to the sender to complete the transaction.", - style: STextStyles.pageTitleH2(context), - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Response slatepack", - 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..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,98 +1,37 @@ 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, this.clipboard = const ClipboardWrapper(), }); + final SlatepackTerms terms; 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(); } @@ -103,109 +42,28 @@ class _SlatepackEntryDialogState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - "Receive Slatepack", + "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 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 = ""; - 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 ba495cdd74..49f52dd7cc 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'; @@ -44,12 +43,11 @@ 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/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'; @@ -65,9 +63,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 '../slatepack/slatepack_dialog.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; import 'sub_widgets/split_transaction_warning.dart'; @@ -156,161 +153,108 @@ class _ConfirmTransactionViewState } } - /// Handle MWC slatepack creation for manual exchange. - Future _handleMwcSlatepackCreation( + /// Slatepack sends end here instead of broadcasting: create the S1 and + /// show it. + Future _createSlatepack( BuildContext context, - MimblewimblecoinWallet wallet, + SlatepackInterface wallet, + void Function() closeSendingDialog, ) async { - try { - // Close the progress dialog first. - Navigator.of(context).pop(); + final terms = wallet.slatepackTerms; + final recipient = widget.txData.recipients!.first; + final note = onChainNoteController.text; + final localNote = noteController.text; + final db = ref.read(mainDBProvider); + final format = ref.read(pAmountFormatter(wallet.cryptoCurrency)).format; - // Get recipient information from txData. - final recipient = widget.txData.recipients?.first; - if (recipient == null) { - throw Exception('No recipient found in transaction data'); - } + 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}"); + } - // 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. + // 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", ); + } - 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), - ); - - // 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()}'; + // 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}"); + } - 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'), - ), - ], + 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. } } - } - /// 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, - ); + unawaited(wallet.refresh()); + closeSendingDialog(); - if (!slatepackResult.success || slatepackResult.slatepack == null) { - throw Exception(slatepackResult.error ?? 'Failed to create slate'); - } - - // Show slatepack dialog. - if (context.mounted) { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => - EpicSlatepackDialog(slatepackResult: slatepackResult), + if (context.mounted) { + if (!noteSaved || feeChange != null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: [ + ?feeChange, + if (!noteSaved) + "Transaction created, but the local note could not be saved.", + ].join(" "), + context: context, + ), ); - - // 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 Epic Cash slate: $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 slate: ${e.toString()}'; + 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.", + ); + } - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Slate 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(); } } } @@ -320,33 +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 (!context.mounted || !isSendingDialogOpen) { - return; - } - final navigator = Navigator.of(context, rootNavigator: true); - if (navigator.canPop()) { - navigator.pop(); + if (navigator.mounted && sendingRoute.isActive) { + navigator.removeRoute(sendingRoute); } - 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)); @@ -392,54 +336,18 @@ class _ConfirmTransactionViewState break; } } else { - if (coin is Mimblewimblecoin) { - // 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 _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, - ); - closeSendingDialog(); - return; // Exit early, don't continue with normal transaction flow. - } else { - // Handle Epicbox transactions normally. - 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); } @@ -495,9 +403,8 @@ class _ConfirmTransactionViewState } } } on BadHttpAddressException catch (_) { + closeSendingDialog(); if (context.mounted) { - // pop building dialog - closeSendingDialog(); unawaited( showFloatingFlushBar( type: FlushBarType.warning, @@ -509,12 +416,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, @@ -691,6 +601,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( @@ -816,7 +736,8 @@ class _ConfirmTransactionViewState Text( widget.isPaynymTransaction ? widget.txData.paynymAccountLite!.nymName - : widget + : slatepackRecipient ?? + widget .txData .recipients ?.firstOrNull @@ -910,11 +831,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, @@ -939,9 +860,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), @@ -1219,7 +1138,8 @@ class _ConfirmTransactionViewState // TODO: [prio=med] spark transaction specifics - better handling widget.isPaynymTransaction ? widget.txData.paynymAccountLite!.nymName - : widget + : slatepackRecipient ?? + widget .txData .recipients ?.firstOrNull @@ -1355,15 +1275,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, @@ -1408,10 +1327,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 91a9e4520e..342e87a923 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -19,10 +19,8 @@ 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/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'; @@ -47,11 +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/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -59,28 +57,26 @@ 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/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'; 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'; @@ -91,9 +87,7 @@ 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/transaction_fee_selection_sheet.dart'; class SendView extends ConsumerStatefulWidget { @@ -150,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; @@ -341,7 +336,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) { @@ -410,6 +405,31 @@ 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) { + if (coin is! Epiccash) return text; + String address = text.trim(); + if (address.contains("\n")) { + address = address.substring(0, address.indexOf("\n")); + } + final formatted = _stripRelayPrefixes(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, @@ -529,7 +549,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); @@ -880,180 +902,6 @@ class _SendViewState extends ConsumerState { } } - Future _createSlatepack() 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 MimblewimblecoinWallet; - - 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 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...", - 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) => StackDialogBase( - 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 mobile', - error: e, - stackTrace: s, - ); - - if (mounted) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - title: "Slate 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 { @@ -1105,10 +953,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; @@ -1258,6 +1109,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: @@ -1598,6 +1464,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); @@ -1746,7 +1616,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; @@ -1796,24 +1666,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( @@ -1823,11 +1675,8 @@ 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)); + final showOnChainNote = _slatepackWallet?.supportsOnChainNote ?? false; return Background( child: Scaffold( @@ -1984,20 +1833,12 @@ class _SendViewState extends ConsumerState { ), const SizedBox(height: 16), - // MWC Transaction Method Selector. - if (coin is Mimblewimblecoin) ...[ - const SizedBox( + if (_slatepackWallet != null) ...[ + SizedBox( height: 40, - child: MwcTxsMethodToggle(), - ), - const SizedBox(height: 16), - ], - - // Epic Cash Transaction Method Selector. - if (coin is Epiccash) ...[ - const SizedBox( - height: 40, - child: EpicTxsMethodToggle(), + child: SlatepackMethodToggle( + walletId: walletId, + ), ), const SizedBox(height: 16), ], @@ -2084,6 +1925,7 @@ class _SendViewState extends ConsumerState { selectAll: false, ), onChanged: (newValue) async { + newValue = _formatRelayAddress(newValue); final trimmed = newValue.trim(); if (AddressUtils.mayBePaymentUri( @@ -2757,14 +2599,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, @@ -2814,10 +2656,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), @@ -3121,11 +2963,7 @@ class _SendViewState extends ConsumerState { (!needsEthFee || ethFee != null); return TextButton( onPressed: enabled - ? isMwcSlatepack - ? _createSlatepack - : isEpicSlatepack - ? _createEpicSlatepack - : _previewTransaction + ? _previewTransaction : null, style: enabled ? Theme.of(context) @@ -3139,9 +2977,7 @@ class _SendViewState extends ConsumerState { context, ), child: Text( - isSlatepackMode - ? "Create slate" - : "Preview", + "Preview", style: STextStyles.button(context), ), ); 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 7bcf97022f..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/epic_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 EpicSlatepackResult 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/mwc_slatepack_dialog.dart deleted file mode 100644 index ec57cf0a53..0000000000 --- a/lib/pages/send_view/sub_widgets/mwc_slatepack_dialog.dart +++ /dev/null @@ -1,214 +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/mwc_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 MwcSlatepackDialog extends ConsumerStatefulWidget { - const MwcSlatepackDialog({ - super.key, - required this.slatepackResult, - this.clipboard = const ClipboardWrapper(), - }); - - final SlatepackResult slatepackResult; - final ClipboardInterface clipboard; - - @override - ConsumerState createState() => _MwcSlatepackDialogState(); -} - -class _MwcSlatepackDialogState extends ConsumerState { - void _copySlatepack() { - widget.clipboard.setData( - ClipboardData(text: widget.slatepackResult.slatepack!), - ); - showFloatingFlushBar( - type: FlushBarType.info, - message: "Slatepack 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 Slatepack", - 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 slatepack with the recipient\n" - "2. Wait for them to return the response slatepack\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, - ), - ), - - const SizedBox(height: 12), - - // Slatepack text view. - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Text("Slatepack", 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/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..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/impl/mimblewimblecoin_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,20 +354,13 @@ class _WalletNetworkSettingsViewState if (_percent < highestPercent) { _percent = highestPercent.clamp(0.0, 1.0); } - } else if (coin is Epiccash) { - 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) - .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/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/slatepack/slatepack_dialog.dart b/lib/pages/slatepack/slatepack_dialog.dart new file mode 100644 index 0000000000..47260064f6 --- /dev/null +++ b/lib/pages/slatepack/slatepack_dialog.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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'; + +/// 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), + ); +} + +/// 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.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 String slatepack; + final SlateState state; + final String? slateId; + final bool isEncrypted; + final ClipboardInterface clipboard; + + 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, + }; + + 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"; + } + + 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, + ); + } + } + + /// [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, + sharePositionOrigin: origin, + ), + ); + } 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) { + final colors = Theme.of(context).extension()!; + final isDesktop = Util.isDesktop; + + return SlatepackPopupLayout( + title: title, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedContainer( + color: colors.textFieldDefaultBG, + child: Text(instructions, style: STextStyles.w400_14(context)), + ), + const SizedBox(height: 12), + Row( + children: [ + 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( + isEncrypted + ? "Encrypted to the other party's address" + : "Not encrypted", + style: STextStyles.label(context), + ), + ), + ], + ), + const SizedBox(height: 12), + Center( + child: SlatepackQr(data: slatepack, noun: terms.noun), + ), + const SizedBox(height: 12), + RoundedWhiteContainer( + borderColor: isDesktop ? colors.backgroundAppBar : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(terms.title, style: STextStyles.itemSubtitle(context)), + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: SelectableText( + slatepack, + style: STextStyles.w400_14(context) + .copyWith(fontFamily: 'monospace'), + ), + ), + ), + ], + ), + ), + 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: Builder( + builder: (context) => SecondaryButton( + label: "Share", + icon: _icon(context, Assets.svg.share), + onPressed: () => _share(context), + ), + ), + ), + ], + 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..a8d5927eda --- /dev/null +++ b/lib/pages/slatepack/slatepack_import_flow.dart @@ -0,0 +1,410 @@ +import 'dart:async'; + +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/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'; +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, + }; + + 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({ + required BuildContext context, + required SlatepackInterface wallet, + 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(slatepack); + 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 (read == null || !context.mounted) return false; + + final inspection = read.inspection; + final route = read.route; + + switch (route) { + case SlatepackRoute.receive: + return _receive(context, wallet, slatepack, inspection); + + case SlatepackRoute.finalizeSend: + 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, + slatepack, + inspection, + amount: local.amount, + fee: local.fee, + ); + + 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; + + // 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}...", + () 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) return false; + + unawaited(wallet.refresh()); + + // 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; + } + + static Future _finalize( + BuildContext context, + SlatepackInterface wallet, + String text, + SlateInspection inspection, { + required Amount amount, + required Amount fee, + }) async { + final terms = wallet.slatepackTerms; + + // 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: amount, + fee: fee, + details: [ + if (inspection.slateId.isNotEmpty) ("Slate ID", inspection.slateId), + ("Encrypted", inspection.isEncrypted ? "Yes" : "No"), + ], + ), + ); + 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) 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( + 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 { + // 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, + 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 (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, text); + } else if (root.mounted) { + await _message(root.context, errorTitle, text); + } + 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..154061f86a --- /dev/null +++ b/lib/pages/slatepack/slatepack_input_field.dart @@ -0,0 +1,265 @@ +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) { + if (!mounted) return; + 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..30442cf8a1 --- /dev/null +++ b/lib/pages/slatepack/slatepack_qr.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; +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'; + +/// 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) => + 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. +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 new file mode 100644 index 0000000000..9f0435e82b --- /dev/null +++ b/lib/pages/slatepack/slatepack_summary_dialog.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +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/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_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.title, + required this.coin, + required this.actionLabel, + this.amount, + this.fee, + this.details = const [], + }); + + final String title; + final CryptoCurrency coin; + final String actionLabel; + final Amount? amount; + final Amount? fee; + + /// Extra label and value rows shown under the amount and fee. + final List<(String, String)> details; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDesktop = Util.isDesktop; + final colors = Theme.of(context).extension()!; + final formatter = ref.watch(pAmountFormatter(coin)); + + final rows = <(String, String)>[ + if (amount != null) ("Amount", formatter.format(amount!)), + if (fee != null) ("Fee", formatter.format(fee!)), + ...details, + ]; + + return SlatepackPopupLayout( + title: title, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedWhiteContainer( + padding: EdgeInsets.zero, + borderColor: isDesktop ? colors.backgroundAppBar : null, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (int i = 0; i < rows.length; i++) ...[ + if (i > 0) const DetailDivider(), + DetailItem(title: rows[i].$1, detail: rows[i].$2), + ], + ], + ), + ), + 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 new file mode 100644 index 0000000000..dc2ff95864 --- /dev/null +++ b/lib/pages/slatepack/slatepack_view.dart @@ -0,0 +1,174 @@ +/* + * 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/global/wallets_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/clipboard_interface.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.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 'slatepack_import_flow.dart'; +import 'slatepack_input_field.dart'; + +/// 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 = "/slatepackView"; + + final String walletId; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => _SlatepackViewState(); +} + +class _SlatepackViewState extends ConsumerState { + late final TextEditingController _controller; + 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 (_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 = ""); + } + } finally { + _importing = false; + if (mounted) setState(() {}); + } + } + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + _focusNode = FocusNode(); + _wallet = + ref.read(pWallets).getWallet(widget.walletId) as SlatepackInterface; + } + + @override + void 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, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text("Slatepack", 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: [ + 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).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ) + : 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: "Continue", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + enabled: _hasText && !_importing, + onPressed: _hasText && !_importing ? _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 b38935dadf..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,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/wallet_mixin_interfaces/slatepack_interface.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 SlatepackInterface) { 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, ), ); @@ -1810,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 c6eb3706ff..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,10 +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/impl/epiccash_wallet.dart'; -import '../../../../wallets/wallet/impl/mimblewimblecoin_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'; @@ -100,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; @@ -204,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) && @@ -880,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) @@ -934,8 +938,7 @@ class _TransactionV2DetailsViewState MainAxisAlignment.spaceBetween, children: [ Text( - (coin is Epiccash || - coin is Mimblewimblecoin) + isSlatepackWallet ? "Local Note" : "Note ", style: labelStyle, @@ -1405,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) @@ -1464,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), @@ -1474,7 +1477,7 @@ class _TransactionV2DetailsViewState ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: - ((coin is Epiccash || coin is Mimblewimblecoin) && + (isSlatepackWallet && _transaction.getConfirmations(currentHeight) < 1 && _transaction.isCancelled == false) ? ConditionalParent( @@ -1497,13 +1500,15 @@ class _TransactionV2DetailsViewState onPressed: () async { final wallet = ref.read(pWallets).getWallet(walletId); - if (wallet is EpiccashWallet) { + if (wallet is SlatepackInterface) { 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 +1530,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, @@ -1608,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 9ced7e8546..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/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/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,30 +1009,15 @@ class _WalletViewState extends ConsumerState { } }, ), - if (wallet is MimblewimblecoinWallet) + if (wallet is SlatepackInterface) WalletNavigationBarItemData( - label: "Finalize", + label: "Slatepack", icon: const FinalizeNavIcon(), onTap: () { 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, + 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 eb354eb1e0..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,8 +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/epic_slatepack_import_dialog.dart'; -import '../../../../pages/receive_view/sub_widgets/mwc_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'; @@ -34,19 +34,19 @@ 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/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'; @@ -55,16 +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/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'; -import '../../../../widgets/stack_text_field.dart'; -import '../../../../widgets/textfield_icon_button.dart'; class DesktopReceive extends ConsumerStatefulWidget { const DesktopReceive({ @@ -89,12 +82,9 @@ class _DesktopReceiveState extends ConsumerState { late final bool supportsSpark; late bool supportsMweb; late final bool showMultiType; - late final bool isMimblewimblecoin; - late final bool isEpiccash; - late TextEditingController _receiveSlateController; - String? _slate; - bool _slateToggleFlag = false; - final _slateFocusNode = FocusNode(); + late final bool usesSlatepacks; + late final TextEditingController _receiveSlateController; + late final SlatepackTerms _slatepackTerms; int _currentIndex = 0; @@ -102,134 +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 wallet = - ref.read(pWallets).getWallet(walletId) as MimblewimblecoinWallet; - - 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 slatepack...", - 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: "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: 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 _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( - responseSlatepack: response.responseSlatepack, - wasEncrypted: response.wasEncrypted, - ), - ); - } + if (done && mounted) { + setState(() => _receiveSlateController.text = ""); } } @@ -244,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, @@ -323,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, @@ -414,8 +287,10 @@ class _DesktopReceiveState extends ConsumerState { !wallet.info.isViewOnly && wallet.info.isMwebEnabled; - isMimblewimblecoin = wallet is MimblewimblecoinWallet; - isEpiccash = wallet is EpiccashWallet; + usesSlatepacks = wallet is SlatepackInterface; + _slatepackTerms = wallet is SlatepackInterface + ? wallet.slatepackTerms + : SlatepackTerms.slatepack; if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { showMultiType = false; @@ -551,216 +426,183 @@ class _DesktopReceiveState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 4), - if (isMimblewimblecoin) + if (usesSlatepacks) Padding( padding: const EdgeInsets.all(0), 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: 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( - 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(walletId: walletId), ), ), ), - if (!((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) - const SizedBox(height: 20), - if (!((isMimblewimblecoin || isEpiccash) && 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), @@ -778,231 +620,123 @@ class _DesktopReceiveState extends ConsumerState { label: "Generate new address", ), const SizedBox(height: 20), - if ((isMimblewimblecoin || isEpiccash) && 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 ((isMimblewimblecoin || isEpiccash) && 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, - ), - ) - 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 77a78bfff4..4088a6936f 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,20 +18,16 @@ 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/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'; 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/transaction_fee_selection_sheet.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/ui/fee_rate_type_state_provider.dart'; @@ -52,23 +48,22 @@ 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/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/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'; @@ -78,13 +73,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'; @@ -132,8 +126,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; @@ -202,255 +195,6 @@ class _DesktopSendState extends ConsumerState { } } - /// Handle MWC slatepack creation for desktop. - Future _handleDesktopSlatepackCreation( - MimblewimblecoinWallet 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, - encrypt: false, // No encryption without recipient address. - ); - } - - // Create slatepack. - Exception? ex; - final slatepackResult = await showLoading( - whileFuture: wrappedFutureWithDelay(), - context: context, - rootNavigator: true, - 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) => 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(), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - } - } - - /// 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', - 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( - 'Slate Creation Failed', - style: STextStyles.desktopH3(context), - ), - const DesktopDialogCloseButton(), - ], - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.only(right: 32), - child: Text( - 'Failed to create slate: $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 { @@ -511,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; @@ -530,18 +277,6 @@ 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); - return; - } - final Amount amount = ref.read(pSendAmount)!; final Amount availableBalance; if (coin is Firo || ref.read(pWalletInfo(walletId)).isMwebEnabled) { @@ -710,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: @@ -869,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 ?? ""); } } @@ -1081,7 +828,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); @@ -1297,7 +1047,7 @@ class _DesktopSendState extends ConsumerState { _clearRecipientForms(); // auto fill address - _address = paymentData.address; + _address = stripRelayPrefixes(coin, paymentData.address.trim()); sendToController.text = _address!; // autofill notes field. @@ -1348,6 +1098,26 @@ 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) { + 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 = stripRelayPrefixes(coin, 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, @@ -1568,8 +1338,8 @@ class _DesktopSendState extends ConsumerState { clipboard = widget.clipboard; hasOptionalMemo = coin is Stellar || coin is Solana; - isMimblewimblecoin = coin is Mimblewimblecoin; - isEpiccash = coin is Epiccash; + usesSlatepacks = + ref.read(pWallets).getWallet(walletId) is SlatepackInterface; sendToController = TextEditingController(); cryptoAmountController = TextEditingController(); @@ -1681,42 +1451,11 @@ 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) - 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), ); @@ -1752,34 +1491,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( @@ -1799,9 +1511,9 @@ class _DesktopSendState extends ConsumerState { width: 1, ), ), - child: const SizedBox( + child: SizedBox( height: 60, // Provide an explicit height to avoid infinite constraints - child: EpicTxsMethodToggle(), + child: SlatepackMethodToggle(walletId: walletId), ), ), ), @@ -2105,8 +1817,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: [ @@ -2127,8 +1838,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( @@ -2142,8 +1852,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( @@ -2168,6 +1877,7 @@ class _DesktopSendState extends ConsumerState { selectAll: false, ), onChanged: (newValue) async { + newValue = _formatRelayAddress(newValue); final trimmed = newValue; if (AddressUtils.mayBePaymentUri(trimmed, _address ?? "")) { @@ -2328,8 +2038,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; @@ -2348,10 +2057,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"; @@ -2588,9 +2296,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, ); @@ -2601,6 +2307,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/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..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,15 +12,15 @@ 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/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'; @@ -49,8 +49,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,11 +59,10 @@ 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 = wallet is SlatepackInterface; - if (isMimblewimblecoin || isEpiccash) { - titles.add("Finalize"); + if (usesSlatepacks) { + titles.add("Slatepack"); } if ((isEth || isSolana) && widget.contractAddress == null) { @@ -184,15 +182,10 @@ 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), + child: SlatepackView(walletId: widget.walletId), ), if ((isEth || isSolana) && widget.contractAddress == null) 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/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/providers/ui/preview_tx_button_state_provider.dart b/lib/providers/ui/preview_tx_button_state_provider.dart index 285d4b8781..1b5d4540f6 100644 --- a/lib/providers/ui/preview_tx_button_state_provider.dart +++ b/lib/providers/ui/preview_tx_button_state_provider.dart @@ -11,10 +11,11 @@ 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/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); @@ -25,73 +26,54 @@ 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 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) { - return ref.watch(pSelectedMwcTransactionMethod) == - MwcTransactionMethod.slatepack; - } - if (coin is Epiccash) { - return ref.watch(pSelectedEpicTransactionMethod) == - EpicTransactionMethod.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); - - 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; - } - } +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); - // 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; - } - } + if (coin is! Firo && opReturnData != null) { + return false; + } - 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; + // Slatepack transactions have no address to validate. + if (ref.watch(pIsSlatepack(walletId))) { + return amount > Amount.zero; + } - case BalanceType.public: - return ref.watch(pValidSendToAddress) && amount > Amount.zero; - } - } else { - 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; + } +}); 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/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/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/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/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/enums/slatepack_method.dart b/lib/utilities/enums/slatepack_method.dart new file mode 100644 index 0000000000..a9b3006523 --- /dev/null +++ b/lib/utilities/enums/slatepack_method.dart @@ -0,0 +1,23 @@ +/// 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("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 new file mode 100644 index 0000000000..54a87e5c35 --- /dev/null +++ b/lib/utilities/slatepack_terms.dart @@ -0,0 +1,18 @@ +/// Wording the slatepack screens use for the payload being exchanged. Epic +/// 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"), + slatepack("slatepack", "Slatepack Message"); + + const SlatepackTerms(this.noun, this.payload); + + /// "slate" or "slatepack". + final String noun; + + /// What the user is asked to paste, e.g. "Slatepack Message". + final String payload; + + /// [noun] with a capital, for titles. + String get title => "${noun[0].toUpperCase()}${noun.substring(1)}"; +} diff --git a/lib/wallets/crypto_currency/coins/epiccash.dart b/lib/wallets/crypto_currency/coins/epiccash.dart index 97c1b18d3a..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/epic_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. - EpicTransactionMethod getTransactionMethod(String addressOrData) { + SlatepackMethod getTransactionMethod(String addressOrData) { if (isSlateJson(addressOrData)) { - return EpicTransactionMethod.slatepack; - } else if (isEpicboxAddress(addressOrData) || - isHttpAddress(addressOrData)) { - return EpicTransactionMethod.epicbox; + return SlatepackMethod.slatepack; + } else if (isHttpAddress(addressOrData)) { + return SlatepackMethod.http; + } else if (isEpicboxAddress(addressOrData)) { + return SlatepackMethod.relay; } else { - throw Exception("Unknown EpicTransactionMethod 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 2ed274b35c..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/mwc_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,16 +71,14 @@ class Mimblewimblecoin extends Bip39Currency { } /// Detect transaction type based on address/data format. - MwcTransactionMethod getTransactionMethod(String addressOrData) { + SlatepackMethod getTransactionMethod(String addressOrData) { if (isSlatepack(addressOrData)) { - return MwcTransactionMethod.slatepack; + return SlatepackMethod.slatepack; } else if (isMwcmqsAddress(addressOrData)) { - return MwcTransactionMethod.mwcmqs; + return SlatepackMethod.relay; // } else if (isHttpAddress(addressOrData)) { - // return MwcTransactionMethod.http; } else { - throw Exception("Unknown MwcTransactionMethod found!"); - // return MwcTransactionMethod.unknown; + throw Exception("Unknown SlatepackMethod found!"); } } diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index 4212299207..0a0da8e7e7 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -1,18 +1,13 @@ 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/epic_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 +16,58 @@ 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/enums/slatepack_method.dart'; import '../../../utilities/flutter_secure_storage_interface.dart'; import '../../../utilities/logger.dart'; -import '../../../utilities/stack_file_system.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'; 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/epicbox_probe.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 // -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 + 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({ "epicbox_domain": host, "epicbox_port": port, "epicbox_protocol_unsecure": false, @@ -135,7 +83,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 +91,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 +103,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,253 +128,122 @@ 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. - Future createSlatepack({ + /// 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, - int? minimumConfirmations, + bool sendAll = false, }) async { try { _hackedCheckTorNodePrefs(); 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(), address: 'slate', // Not used in slate mode. secretKeyIndex: 0, - minimumConfirmations: - minimumConfirmations ?? cryptoCurrency.minConfirms, + minimumConfirmations: cryptoCurrency.minConfirms, note: message ?? '', returnSlate: true, ); - return EpicSlatepackResult( + 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'); - return EpicSlatepackResult(success: false, error: e.toString()); + return SlatepackResult(success: false, error: e.toString()); } } - /// 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 EpicSlatepackDecodeResult( + return SlatepackDecodeResult( success: true, - slateJson: slateJson, + slateJson: slatepack, wasEncrypted: false, senderAddress: null, recipientAddress: null, ); } 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})?> - 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 EpicReceiveResult( + 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 EpicReceiveResult(success: false, error: e.toString()); + return SlatepackResult(success: false, error: e.toString()); } } - /// 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 EpicFinalizeResult( + return FinalizeResult( success: true, slateId: finalized.slateId, commitId: finalized.commitId, + posted: true, ); } catch (e, s) { Logging.instance.e('Failed to finalize slatepack: $e\n$s'); - return EpicFinalizeResult(success: false, error: e.toString()); - } - } - - /// 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) { - try { - final 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; + return FinalizeResult(success: false, error: e.toString()); } } @@ -434,11 +252,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 +264,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 +285,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 +315,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,36 +324,34 @@ 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; - WebSocketChannel? channel; + final String host = epicboxConfig.host; + final int port = epicboxConfig.port ?? 443; try { - final uri = Uri.parse('wss://$host:$port'); - - channel = WebSocketChannel.connect(uri); - - await channel.ready; - - final 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\"", @@ -578,51 +359,12 @@ class EpiccashWallet extends Bip39Wallet { stackTrace: s, ); return false; - } finally { - await channel?.sink.close(); - } - } - - 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 +389,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 +409,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 +429,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 +477,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 +489,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 +503,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 +522,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 +575,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 +599,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 +645,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 +679,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 +704,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, ); @@ -1031,7 +720,20 @@ class EpiccashWallet extends Bip39Wallet { 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; @@ -1048,15 +750,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 +772,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 +788,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 +812,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 +845,7 @@ class EpiccashWallet extends Bip39Wallet { ); await _generateAndStoreReceivingAddressForIndex( - info.epicData?.receivingIndex ?? 0, + mimblewimbleData?.receivingIndex ?? 0, ); await _listenToEpicbox(); @@ -1150,7 +855,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 +889,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 +921,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 +937,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.onChainNote; + 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 +1011,7 @@ class EpiccashWallet extends Bip39Wallet { outputs.add(output); inputs.add(input); - final otherData = { + final Map otherData = { "isEpiccashTransaction": true, "numberOfMessages": numberOfMessages, "slateId": slateId, @@ -1474,7 +1025,7 @@ class EpiccashWallet extends Bip39Wallet { ).toJsonString(), }; - final txn = TransactionV2( + final TransactionV2 txn = TransactionV2( walletId: walletId, blockHash: null, hash: commitId ?? tx.id.toString(), @@ -1490,20 +1041,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 +1065,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 +1082,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 +1116,6 @@ class EpiccashWallet extends Bip39Wallet { } } - @override - Future updateUTXOs() async { - // not used for epiccash - return false; - } - @override Future updateNode() async { _epicNode = getCurrentNode(); @@ -1585,7 +1130,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 +1153,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 +1188,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 721773da6d..191c8fffcb 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -8,75 +8,54 @@ 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/mwc_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/enums/slatepack_method.dart'; import '../../../utilities/flutter_secure_storage_interface.dart'; import '../../../utilities/logger.dart'; 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'; +import '../wallet_mixin_interfaces/slatepack_interface.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 => 1573462801; - 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 +81,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 +93,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 +110,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 +127,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,23 +155,27 @@ class MimblewimblecoinWallet extends Bip39Wallet { // ================= Slatepack Operations =================================== - /// Create a slatepack for sending MWC. + /// 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 handle = await _ensureWalletOpen(); + final String handle = await _ensureWalletOpen(); + final String? recipient = + recipientAddress == null || recipientAddress.isEmpty + ? null + : recipientAddress; // Generate S1 slate JSON. - final s1Json = await libMwc.txInit( + final String s1Json = await libMwc.txInit( wallet: handle, amount: amount.raw.toInt(), - minimumConfirmations: - minimumConfirmations ?? cryptoCurrency.minConfirms, + minimumConfirmations: cryptoCurrency.minConfirms, selectionStrategyIsAll: false, message: message ?? '', ); @@ -199,17 +183,22 @@ class MimblewimblecoinWallet extends Bip39Wallet { // 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'); @@ -217,57 +206,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 +237,10 @@ class MimblewimblecoinWallet extends Bip39Wallet { } } - /// Receive a slatepack and return response slatepack. - Future receiveSlatepack(String slatepack) async { + @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( @@ -313,24 +262,28 @@ class MimblewimblecoinWallet extends Bip39Wallet { 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()); } } - /// 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( @@ -348,6 +301,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { success: true, slateId: finalized.slateId, commitId: finalized.commitId, + posted: true, ); } catch (e, s) { Logging.instance.e('Failed to finalize slatepack: $e\n$s'); @@ -358,8 +312,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 +338,6 @@ class MimblewimblecoinWallet extends Bip39Wallet { return libMwc.validateSendAddress(address: address); } - /// Detect if an address is a slatepack. bool isSlatepack(String data) { return data.trim().startsWith('BEGINSLATE') && (data.trim().endsWith('ENDSLATEPACK') || @@ -398,180 +351,11 @@ 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 { - 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 output in transaction.outputs) { - for (final address in output.addresses) { - if (isSlatepack(address)) { - final 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 { - 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 +372,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 +386,15 @@ 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 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 available = info.cachedBalance.spendable.raw.toInt(); final transactionFees = await libMwc.getTransactionFees( wallet: wallet, amount: satoshiAmount, @@ -640,24 +418,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 +427,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 +466,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 +488,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 +530,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 +539,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 +553,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 +592,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 +626,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 +661,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) { @@ -1017,19 +685,9 @@ class MimblewimblecoinWallet extends Bip39Wallet { 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 feeAmount = Amount( + final int realFee = await nativeFee(recipient.amount.raw.toInt()); + final Amount feeAmount = Amount( rawValue: BigInt.from(realFee), fractionDigits: cryptoCurrency.fractionDigits, ); @@ -1049,20 +707,24 @@ class MimblewimblecoinWallet extends Bip39Wallet { ); } - // 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"); @@ -1078,20 +740,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 +762,6 @@ class MimblewimblecoinWallet extends Bip39Wallet { key: '${walletId}_password', value: password, ); - await secureStorageInterface.write( key: '${walletId}_mwcmqsConfig', value: mwcmqsConfig.toString(), @@ -1114,21 +774,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 +807,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 +823,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.onChainNote; + 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", @@ -1377,36 +891,13 @@ class MimblewimblecoinWallet extends Bip39Wallet { 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 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); inputs.add(input); - final otherData = { + final Map otherData = { "isMimblewimblecoinTransaction": true, "numberOfMessages": numberOfMessages, "slateId": slateId, @@ -1420,7 +911,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { ).toJsonString(), }; - final txn = TransactionV2( + final TransactionV2 txn = TransactionV2( walletId: walletId, blockHash: null, hash: commitId ?? tx.id.toString(), @@ -1454,17 +945,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 +965,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 +981,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 +996,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..e57b9e3bb3 --- /dev/null +++ b/lib/wallets/wallet/intermediate/mimblewimble_wallet.dart @@ -0,0 +1,657 @@ +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/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'; +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/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 with SlatepackInterface { + 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), + ); + } + + Future getRelayConfig(); + + Future updateRelayConfig(String host, int port); + + // ================= Slatepack Operations =================================== + + @override + List get supportedMethods => const [ + SlatepackMethod.slatepack, + SlatepackMethod.relay, + ]; + + @override + bool get supportsOnChainNote => true; + + @override + double get restoreProgress => highestPercent; + + @override + Future slatepackAddress() async => + (await getCurrentReceivingAddress())?.value ?? ""; + + Future decodeSlatepack(String slatepack); + + bool isHttpAddress(String address) { + return address.startsWith('http://') || address.startsWith('https://'); + } + + /// Cheap syntactic check run by [inspectSlatepack] before decoding. + /// Throws to reject input that is obviously not a slatepack. + @protected + void checkSlatepackFormat(String slatepack) {} + + @override + Future inspectSlatepack(String armored) async { + final String text = armored.trim(); + if (text.isEmpty) { + throw Exception("No ${slatepackTerms.noun} entered"); + } + + checkSlatepackFormat(text); + + final SlatepackDecodeResult decoded = await decodeSlatepack(text); + if (!decoded.success || decoded.slateJson == null) { + throw Exception( + decoded.error ?? "Failed to decode ${slatepackTerms.noun}", + ); + } + + 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; + } + + /// 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; + } + } + + if (state == SlateState.unknown && json['sta'] != null) { + state = SlateState.fromCode(json['sta'].toString()); + } + + if (state == SlateState.unknown) { + if (senderAddress != null && recipientAddress != null) { + state = SlateState.s2; + } else if (senderAddress != null) { + state = SlateState.s1; + } + } + + Amount? parseAmount(dynamic raw) { + final value = BigInt.tryParse(raw?.toString() ?? ""); + return value == null + ? null + : Amount(rawValue: value, fractionDigits: fractionDigits); + } + + 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, bool isSend})?> localTxForSlate( + String slateId, + ) async { + if (slateId.isEmpty) return null; + + Future find() async => localTxMatching( + await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .otherDataContains(slateId) + .findAll(), + slateId, + ); + + 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, + ) + : 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 match; + } + + // ================= 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/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/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(); - } -} 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..5b2b2cbcfe --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/slatepack_interface.dart @@ -0,0 +1,109 @@ +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, 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 + /// 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/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/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/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), + ), + ), + ), + ); + } } diff --git a/lib/widgets/slatepack_method_toggle.dart b/lib/widgets/slatepack_method_toggle.dart new file mode 100644 index 0000000000..4819291bb1 --- /dev/null +++ b/lib/widgets/slatepack_method_toggle.dart @@ -0,0 +1,69 @@ +/* + * 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/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/slatepack_method.dart'; +import '../utilities/util.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.walletId}); + + 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(walletId).notifier).state = value + ? other + : SlatepackMethod.slatepack; + }, + isOn: ref.watch(pSelectedSlatepackMethod(walletId)) == other, + 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: SlatepackMethod.slatepack.label, + offIcon: Assets.svg.radioSyncing, + offText: other.label, + ); + } +} 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..0d168b84f4 --- /dev/null +++ b/lib/wl_gen/interfaces/mimblewimble_transaction.dart @@ -0,0 +1,100 @@ +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, + }); + + /// 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(' + '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/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", + ); + }); } 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); + }); +} 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/send_view/slatepack_confirmation_test.dart b/test/pages/send_view/slatepack_confirmation_test.dart new file mode 100644 index 0000000000..c40dc7db61 --- /dev/null +++ b/test/pages/send_view/slatepack_confirmation_test.dart @@ -0,0 +1,382 @@ +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/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/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'; +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({ + 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 + Future refresh({bool doScan = true}) async {} + @override + Future createSlatepack({ + required Amount amount, + String? recipientAddress, + String? message, + 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 { + _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 { + final wallet = _Wallet(); + final notes = _Notes(fail: failNote); + var succeeded = false; + await _confirmSend( + tester, + wallet, + db: notes, + onSuccess: () => succeeded = 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); + }); + } + + 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); + }); + + 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); + }); +} 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); + }, + ); +} 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, + ); + }); +} 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..8a251cadce --- /dev/null +++ b/test/pages/slatepack/slatepack_import_flow_test.dart @@ -0,0 +1,463 @@ +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'; +import 'package:stackwallet/widgets/custom_loading_overlay.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, + this.localIsSend, + this.receiveGate, + this.receiveError, + }) : super(CryptoCurrencyNetwork.main); + + /// 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; + + /// 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; + + @override + bool get supportsInvoices => invoices; + + /// What the flow handed to the wallet. + final seen = []; + + @override + Future inspectSlatepack(String armored) async { + seen.add(armored); + return 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++; + if (receiveGate == null) { + return SlatepackResult(success: false, error: "not in tests"); + } + await receiveGate!.future; + if (receiveError != null) throw receiveError!; + 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); + 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("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, { + 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, + locale: "en_US", + coin: coin, + maxDecimals: coin.fractionDigits, + ), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: Scaffold( + 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"), + ), + ), + ), + ), + ), + ), + ); + 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("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("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("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); + expect(find.byType(CustomLoadingOverlay), findsOneWidget); + + 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); + // 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); + }); + + 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_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); + }); +} diff --git a/test/pages/slatepack/slatepack_qr_test.dart b/test/pages/slatepack/slatepack_qr_test.dart new file mode 100644 index 0000000000..fde826e7bb --- /dev/null +++ b/test/pages/slatepack/slatepack_qr_test.dart @@ -0,0 +1,88 @@ +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" * 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 { + 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" * (kSlatepackQrMaxBytes + 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/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); + }); +} 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/', + ); + }); +} 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())), + ); + }); +} 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'); + }); +} diff --git a/test/wallets/mimblewimble_slate_inspection_test.dart b/test/wallets/mimblewimble_slate_inspection_test.dart new file mode 100644 index 0000000000..0761fbe497 --- /dev/null +++ b/test/wallets/mimblewimble_slate_inspection_test.dart @@ -0,0 +1,125 @@ +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'; +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}, + ], + }; +} + +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, + 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); + }); +} 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,