diff --git a/crypto_plugins/flutter_libepiccash b/crypto_plugins/flutter_libepiccash index 7af247de8..9824a24c7 160000 --- a/crypto_plugins/flutter_libepiccash +++ b/crypto_plugins/flutter_libepiccash @@ -1 +1 @@ -Subproject commit 7af247de8f404206c79452e2286fc119bd1b7bee +Subproject commit 9824a24c727c1576ba7d1f53b9a689f16be90448 diff --git a/docs/building.md b/docs/building.md index 800a003f9..924386b7e 100644 --- a/docs/building.md +++ b/docs/building.md @@ -62,7 +62,7 @@ Install [Rust](https://www.rust-lang.org/tools/install) via [rustup.rs](https:// ``` curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source ~/.bashrc -rustup install 1.89.0 1.81.0 +rustup install 1.89.0 1.85.1 1.81.0 rustup default 1.89.0 cargo install cargo-ndk ``` @@ -209,11 +209,11 @@ brew install brotli cairo coreutils gdbm gettext glib gmp libevent libidn2 libng ``` -Download and install [Rust](https://www.rust-lang.org/tools/install). [Rustup](https://rustup.rs/) is recommended for Rust setup. Use `rustc` to confirm successful installation. Install toolchains 1.81.0 and 1.89.0 as well as `cbindgen` and `cargo-lipo` too. You will also have to add the platform target(s) `aarch64-apple-ios` and/or `aarch64-apple-darwin`. You can use the command(s): +Download and install [Rust](https://www.rust-lang.org/tools/install). [Rustup](https://rustup.rs/) is recommended for Rust setup. Use `rustc` to confirm successful installation. Install toolchains 1.81.0, 1.85.1, and 1.89.0 as well as `cbindgen` and `cargo-lipo` too. You will also have to add the platform target(s) `aarch64-apple-ios` and/or `aarch64-apple-darwin`. You can use the command(s): ``` curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source ~/.bashrc -rustup install 1.89.0 1.81.0 +rustup install 1.89.0 1.85.1 1.81.0 rustup default 1.89.0 cargo install cargo-ndk cargo install cbindgen cargo-lipo @@ -294,7 +294,7 @@ Install Flutter 3.38.5 on your Windows host (not in WSL2) by [following their gu ### Rust Install [Rust](https://www.rust-lang.org/tools/install) on the Windows host (not in WSL2). Download the installer from [rustup.rs](https://rustup.rs), make sure it works on the commandline (you may need to open a new terminal), and install the following versions: ``` -rustup install 1.89.0 1.81.0 +rustup install 1.89.0 1.85.1 1.81.0 rustup default 1.89.0 cargo install cargo-ndk ``` diff --git a/lib/models/epic_slatepack_models.dart b/lib/models/epic_slatepack_models.dart new file mode 100644 index 000000000..6df4cc70c --- /dev/null +++ b/lib/models/epic_slatepack_models.dart @@ -0,0 +1,116 @@ +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/pages/epic_finalize_view/epic_finalize_view.dart b/lib/pages/epic_finalize_view/epic_finalize_view.dart new file mode 100644 index 000000000..13abb38d6 --- /dev/null +++ b/lib/pages/epic_finalize_view/epic_finalize_view.dart @@ -0,0 +1,338 @@ +/* + * 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/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 7ef4f263b..61f8ae5fb 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -33,6 +33,7 @@ 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'; @@ -54,6 +55,8 @@ import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import 'addresses/wallet_addresses_view.dart'; import 'generate_receiving_uri_qr_code_view.dart'; +import 'sub_widgets/epic_slatepack_entry_dialog.dart'; +import 'sub_widgets/epic_slatepack_import_dialog.dart'; import 'sub_widgets/mwc_slatepack_import_dialog.dart'; import 'sub_widgets/slatepack_entry_dialog.dart'; @@ -150,6 +153,67 @@ class _ReceiveViewState extends ConsumerState { } } + Future _importEpicSlatepack() async { + final slatepackString = await showDialog( + context: context, + builder: (context) => const EpicSlatepackEntryDialog(), + ); + + 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, + ), + ); + } + } + } + } + Future generateNewAddress() async { final wallet = ref.read(pWallets).getWallet(walletId); @@ -764,6 +828,14 @@ class _ReceiveViewState extends ConsumerState { 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 new file mode 100644 index 000000000..c3583e7c4 --- /dev/null +++ b/lib/pages/receive_view/sub_widgets/epic_slatepack_entry_dialog.dart @@ -0,0 +1,219 @@ +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 new file mode 100644 index 000000000..769a58a94 --- /dev/null +++ b/lib/pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart @@ -0,0 +1,316 @@ +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/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 91570b63b..953871923 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -41,6 +41,7 @@ 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'; @@ -60,6 +61,7 @@ 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/mwc_slatepack_dialog.dart'; import 'sub_widgets/sending_transaction_dialog.dart'; @@ -188,6 +190,85 @@ class _ConfirmTransactionViewState } } + /// Handle Epic Cash slate creation for manual exchange. + Future _handleEpicSlatepackCreation( + BuildContext context, + EpiccashWallet wallet, + ) async { + try { + // Close the progress dialog first. + Navigator.of(context).pop(); + + // Get recipient information from txData. + final recipient = widget.txData.recipients?.first; + if (recipient == null) { + throw Exception('No recipient found in transaction data'); + } + + // Create slatepack. + final slatepackResult = await wallet.createSlatepack( + amount: recipient.amount, + recipientAddress: recipient.address.isNotEmpty + ? recipient.address + : null, + message: onChainNoteController.text.isNotEmpty + ? onChainNoteController.text + : null, + ); + + if (!slatepackResult.success || slatepackResult.slatepack == null) { + throw Exception(slatepackResult.error ?? 'Failed to create slate'); + } + + // Show slatepack dialog. + if (context.mounted) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => + EpicSlatepackDialog(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 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 showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Slate Creation Failed'), + content: Text('Failed to create slate: $e'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } + } + Future _attemptSend(BuildContext context) async { final wallet = ref.read(pWallets).getWallet(walletId); final coin = wallet.info.coin; @@ -276,11 +357,28 @@ class _ConfirmTransactionViewState ); } } else if (coin is Epiccash) { - txDataFuture = wallet.confirmSend( - txData: widget.txData.copyWith( - noteOnChain: onChainNoteController.text, - ), - ); + // 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, + ); + 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, + ), + ); + } } else { txDataFuture = wallet.confirmSend(txData: widget.txData); } diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 07595bee7..a58ec0b80 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -18,6 +18,7 @@ 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'; @@ -53,6 +54,7 @@ import '../../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; +import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; @@ -71,6 +73,7 @@ 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/epic_txs_method_toggle.dart'; import '../../widgets/mwc_txs_method_toggle.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; @@ -81,6 +84,7 @@ import '../coin_control/coin_control_view.dart'; import 'confirm_transaction_view.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/mwc_slatepack_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; @@ -695,6 +699,93 @@ class _SendViewState extends ConsumerState { } } + 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(), + ), + ); + } + } + } + Future _previewTransaction() async { // wait for keyboard to disappear FocusScope.of(context).unfocus(); @@ -1375,6 +1466,9 @@ 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; return Background( child: Scaffold( @@ -1553,7 +1647,16 @@ class _SendViewState extends ConsumerState { const SizedBox(height: 16), ], - if (!isMwcSlatepack) + // Epic Cash Transaction Method Selector. + if (coin is Epiccash) ...[ + const SizedBox( + height: 40, + child: EpicTxsMethodToggle(), + ), + const SizedBox(height: 16), + ], + + if (!isSlatepackMode) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -1582,7 +1685,7 @@ class _SendViewState extends ConsumerState { // ), ], ), - if (!isMwcSlatepack) const SizedBox(height: 8), + if (!isSlatepackMode) const SizedBox(height: 8), if (isPaynymSend) TextField( key: const Key("sendViewPaynymAddressFieldKey"), @@ -1591,7 +1694,7 @@ class _SendViewState extends ConsumerState { readOnly: true, style: STextStyles.fieldLabel(context), ), - if (!isPaynymSend && !isMwcSlatepack) + if (!isPaynymSend && !isSlatepackMode) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1648,7 +1751,7 @@ class _SendViewState extends ConsumerState { style: STextStyles.field(context), decoration: standardInputDecoration( - isMwcSlatepack + isSlatepackMode ? "Enter ${coin.ticker} address (optional)" : "Enter ${coin.ticker} address", _addressFocusNode, @@ -2559,9 +2662,11 @@ class _SendViewState extends ConsumerState { TextButton( onPressed: ref.watch(pPreviewTxButtonEnabled(coin)) - ? ref.watch(pIsSlatepack(widget.walletId)) + ? isMwcSlatepack ? _createSlatepack - : _previewTransaction + : isEpicSlatepack + ? _createEpicSlatepack + : _previewTransaction : null, style: ref.watch(pPreviewTxButtonEnabled(coin)) ? Theme.of(context) @@ -2571,8 +2676,8 @@ class _SendViewState extends ConsumerState { .extension()! .getPrimaryDisabledButtonStyle(context), child: Text( - ref.watch(pIsSlatepack(widget.walletId)) - ? "Create slatepack" + isSlatepackMode + ? "Create slate" : "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 new file mode 100644 index 000000000..7bcf97022 --- /dev/null +++ b/lib/pages/send_view/sub_widgets/epic_slatepack_dialog.dart @@ -0,0 +1,175 @@ +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/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index b3a333879..572292efd 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -50,6 +50,7 @@ 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'; @@ -90,6 +91,7 @@ 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 '../monkey/monkey_view.dart'; @@ -1028,6 +1030,21 @@ class _WalletViewState extends ConsumerState { } }, ), + if (wallet is EpiccashWallet) + WalletNavigationBarItemData( + label: "Finalize", + icon: const FinalizeNavIcon(), + onTap: () { + if (mounted) { + unawaited( + Navigator.of(context).pushNamed( + EpicFinalizeView.routeName, + arguments: walletId, + ), + ); + } + }, + ), if (ref.watch(pWalletCoin(walletId)) is FrostCurrency) WalletNavigationBarItemData( label: "Sign", 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 3cbb94493..eb354eb1e 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,6 +22,7 @@ import '../../../../models/isar/models/isar_models.dart'; import '../../../../models/keys/view_only_wallet_data.dart'; import '../../../../notifications/show_flush_bar.dart'; import '../../../../pages/receive_view/generate_receiving_uri_qr_code_view.dart'; +import '../../../../pages/receive_view/sub_widgets/epic_slatepack_import_dialog.dart'; import '../../../../pages/receive_view/sub_widgets/mwc_slatepack_import_dialog.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/ui/preview_tx_button_state_provider.dart'; @@ -39,6 +40,7 @@ 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'; @@ -56,6 +58,7 @@ import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/dialogs/s_dialog.dart'; import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/epic_txs_method_toggle.dart'; import '../../../../widgets/mwc_txs_method_toggle.dart'; import '../../../../widgets/qr.dart'; import '../../../../widgets/rounded_white_container.dart'; @@ -87,6 +90,7 @@ class _DesktopReceiveState extends ConsumerState { late bool supportsMweb; late final bool showMultiType; late final bool isMimblewimblecoin; + late final bool isEpiccash; late TextEditingController _receiveSlateController; String? _slate; bool _slateToggleFlag = false; @@ -169,6 +173,66 @@ class _DesktopReceiveState extends ConsumerState { } } + 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, + ), + ); + } + } + } + Future generateNewAddress() async { final wallet = ref.read(pWallets).getWallet(walletId); if (wallet is MultiAddressInterface) { @@ -351,6 +415,7 @@ class _DesktopReceiveState extends ConsumerState { wallet.info.isMwebEnabled; isMimblewimblecoin = wallet is MimblewimblecoinWallet; + isEpiccash = wallet is EpiccashWallet; if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) { showMultiType = false; @@ -513,9 +578,36 @@ class _DesktopReceiveState extends ConsumerState { ), ), ), - if (!(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + 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(), + ), + ), + ), + if (!((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) const SizedBox(height: 20), - if (!(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + if (!((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) ConditionalParent( condition: showMultiType, builder: (child) => Column( @@ -686,7 +778,7 @@ class _DesktopReceiveState extends ConsumerState { label: "Generate new address", ), const SizedBox(height: 20), - if (isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId))) + if ((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId))) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -817,14 +909,16 @@ class _DesktopReceiveState extends ConsumerState { // TODO: create transparent button class to account for hover // Conditional logic for 'Submit' button or QR code - if (isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId))) + 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 ? _onReceiveSlatePressed : null, + onPressed: _slateToggleFlag + ? (isEpiccash ? _onEpicReceiveSlatePressed : _onReceiveSlatePressed) + : null, ), ) else diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index c476879db..28c1fea51 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 @@ -17,6 +17,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../../../../models/epic_slatepack_models.dart'; import '../../../../models/isar/models/blockchain_data/address.dart'; import '../../../../models/isar/models/blockchain_data/utxo.dart'; import '../../../../models/isar/models/contact_entry.dart'; @@ -25,6 +26,7 @@ import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; import '../../../../pages/send_view/confirm_transaction_view.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/mwc_slatepack_dialog.dart'; import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../../providers/providers.dart'; @@ -51,6 +53,7 @@ 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/wallet_mixin_interfaces/coin_control_interface.dart'; @@ -69,6 +72,7 @@ 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/epic_txs_method_toggle.dart'; import '../../../../widgets/mwc_txs_method_toggle.dart'; import '../../../../widgets/rounded_container.dart'; import '../../../../widgets/stack_text_field.dart'; @@ -118,6 +122,7 @@ class _DesktopSendState extends ConsumerState { late final bool isStellar; late final bool isMimblewimblecoin; + late final bool isEpiccash; String? _note; String? _onChainNote; @@ -292,6 +297,131 @@ class _DesktopSendState extends ConsumerState { } } + /// 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(), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + } + } + Future previewSend() async { final wallet = ref.read(pWallets).getWallet(walletId); @@ -301,6 +431,12 @@ class _DesktopSendState extends ConsumerState { 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) { @@ -1078,6 +1214,7 @@ class _DesktopSendState extends ConsumerState { isStellar = coin is Stellar; isMimblewimblecoin = coin is Mimblewimblecoin; + isEpiccash = coin is Epiccash; sendToController = TextEditingController(); cryptoAmountController = TextEditingController(); @@ -1249,6 +1386,34 @@ class _DesktopSendState extends ConsumerState { ), ), + if (isEpiccash) + 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: EpicTxsMethodToggle(), + ), + ), + ), + if (coin is Firo) Text( "Send from", @@ -1540,7 +1705,7 @@ class _DesktopSendState extends ConsumerState { ), const SizedBox(height: 20), if (!isPaynymSend && - !(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) Text( "Send to", style: STextStyles.desktopTextExtraSmall(context).copyWith( @@ -1551,10 +1716,10 @@ class _DesktopSendState extends ConsumerState { textAlign: TextAlign.left, ), if (!isPaynymSend && - !(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) const SizedBox(height: 10), if (!isPaynymSend && - !(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -1733,7 +1898,7 @@ class _DesktopSendState extends ConsumerState { ), ), if (!isPaynymSend && - !(isMimblewimblecoin && ref.watch(pIsSlatepack(widget.walletId)))) + !((isMimblewimblecoin || isEpiccash) && ref.watch(pIsSlatepack(widget.walletId)))) Builder( builder: (_) { final String? error; @@ -1752,9 +1917,9 @@ class _DesktopSendState extends ConsumerState { } else { if (_data != null && _data.contactLabel == _address) { error = null; - } else if (coin is Mimblewimblecoin && + } else if ((coin is Mimblewimblecoin || coin is Epiccash) && ref.watch(pIsSlatepack(widget.walletId))) { - // For MWC slatepack transactions, address validation is not required. + // For MWC/Epic slatepack transactions, address validation is not required. // TODO: When implementing encrypted slatepacks, address validation will be required. error = null; } else if (!ref.watch(pValidSendToAddress)) { 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 1c490bea2..7be1146a1 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,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../frost_route_generator.dart'; +import '../../../../pages/epic_finalize_view/epic_finalize_view.dart'; import '../../../../pages/finalize_view/finalize_view.dart'; import '../../../../pages/send_view/frost_ms/frost_send_view.dart'; import '../../../../pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart'; @@ -49,6 +50,7 @@ class _MyWalletState extends ConsumerState { late final CryptoCurrency coin; late final bool isFrost; late final bool isMimblewimblecoin; + late final bool isEpiccash; late final bool isViewOnly; @override @@ -59,8 +61,9 @@ class _MyWalletState extends ConsumerState { isEth = coin is Ethereum; isSolana = wallet is SolanaWallet; isMimblewimblecoin = coin is Mimblewimblecoin; + isEpiccash = coin is Epiccash; - if (isMimblewimblecoin) { + if (isMimblewimblecoin || isEpiccash) { titles.add("Finalize"); } @@ -186,6 +189,11 @@ class _MyWalletState extends ConsumerState { padding: const EdgeInsets.all(20), child: FinalizeView(walletId: widget.walletId), ), + if (isEpiccash) + Padding( + padding: const EdgeInsets.all(20), + child: EpicFinalizeView(walletId: widget.walletId), + ), if (isEth && widget.contractAddress == null) Padding( diff --git a/lib/providers/ui/preview_tx_button_state_provider.dart b/lib/providers/ui/preview_tx_button_state_provider.dart index e800869f0..fcb77fe64 100644 --- a/lib/providers/ui/preview_tx_button_state_provider.dart +++ b/lib/providers/ui/preview_tx_button_state_provider.dart @@ -11,6 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../utilities/amount/amount.dart'; +import '../../utilities/enums/epic_transaction_method.dart'; import '../../utilities/enums/mwc_transaction_method.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; @@ -27,11 +28,21 @@ final pSelectedMwcTransactionMethod = StateProvider( (_) => MwcTransactionMethod.slatepack, ); +// Epic Cash Transaction Method Provider. +final pSelectedEpicTransactionMethod = StateProvider( + (_) => EpicTransactionMethod.epicbox, +); + final pIsSlatepack = Provider.family((ref, walletId) { - if (ref.watch(pWalletCoin(walletId)) is Mimblewimblecoin) { + 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; }); @@ -48,6 +59,14 @@ final pPreviewTxButtonEnabled = Provider.autoDispose } } + // 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) { final firoType = ref.watch(publicPrivateBalanceStateProvider); switch (firoType) { diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 35bf76bbb..8660430df 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -72,6 +72,7 @@ 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/epic_finalize_view/epic_finalize_view.dart'; import 'pages/finalize_view/finalize_view.dart'; import 'pages/generic/single_field_edit_view.dart'; import 'pages/home_view/home_view.dart'; @@ -1769,6 +1770,16 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case EpicFinalizeView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => EpicFinalizeView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case WalletAddressesView.routeName: if (args is String) { return getRoute( diff --git a/lib/utilities/enums/epic_transaction_method.dart b/lib/utilities/enums/epic_transaction_method.dart new file mode 100644 index 000000000..4ef30afc1 --- /dev/null +++ b/lib/utilities/enums/epic_transaction_method.dart @@ -0,0 +1,48 @@ +/// 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/wallets/crypto_currency/coins/epiccash.dart b/lib/wallets/crypto_currency/coins/epiccash.dart index 84fb1b465..42d67491d 100644 --- a/lib/wallets/crypto_currency/coins/epiccash.dart +++ b/lib/wallets/crypto_currency/coins/epiccash.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; + 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 '../../../wl_gen/interfaces/libepiccash_interface.dart'; import '../crypto_currency.dart'; import '../intermediate/bip39_currency.dart'; @@ -128,6 +131,40 @@ class Epiccash extends Bip39Currency { } } + /// 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; + } + } + + /// Check if address is Epicbox format. + bool isEpicboxAddress(String address) { + return address.contains('@'); + } + + /// Check if address is HTTP format. + bool isHttpAddress(String address) { + return address.startsWith('http://') || address.startsWith('https://'); + } + + /// Detect transaction type based on address/data format. + EpicTransactionMethod getTransactionMethod(String addressOrData) { + if (isSlateJson(addressOrData)) { + return EpicTransactionMethod.slatepack; + } else if (isEpicboxAddress(addressOrData) || isHttpAddress(addressOrData)) { + return EpicTransactionMethod.epicbox; + } else { + throw Exception("Unknown EpicTransactionMethod found!"); + } + } + @override AddressType? getAddressType(String address) { if (validateAddress(address)) { diff --git a/lib/wallets/wallet/impl/epiccash_wallet.dart b/lib/wallets/wallet/impl/epiccash_wallet.dart index 3fc6b56e9..86ca76a69 100644 --- a/lib/wallets/wallet/impl/epiccash_wallet.dart +++ b/lib/wallets/wallet/impl/epiccash_wallet.dart @@ -10,6 +10,7 @@ import 'package:web_socket_channel/web_socket_channel.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'; @@ -141,6 +142,273 @@ class EpiccashWallet extends Bip39Wallet { ); } + // ================= Slatepack Operations =================================== + + Future _ensureWalletOpen() async { + final existing = await secureStorageInterface.read( + key: '${walletId}_wallet', + ); + if (existing != null && existing.isNotEmpty) return existing; + + final config = await _getRealConfig(); + final password = await secureStorageInterface.read( + key: '${walletId}_password', + ); + if (password == null) { + throw Exception('Wallet password not found'); + } + final opened = await libEpic.openWallet(config: config, password: password); + await secureStorageInterface.write( + key: '${walletId}_wallet', + value: opened, + ); + return opened; + } + + /// Create a slatepack for sending Epic Cash. + Future createSlatepack({ + required Amount amount, + String? recipientAddress, + String? message, + int? minimumConfirmations, + }) async { + try { + _hackedCheckTorNodePrefs(); + final handle = await _ensureWalletOpen(); + final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); + + // Create transaction with returnSlate: true for slatepack mode. + final result = await libEpic.createTransaction( + wallet: handle, + amount: amount.raw.toInt(), + address: 'slate', // Not used in slate mode. + secretKeyIndex: 0, + epicboxConfig: epicboxConfig.toString(), + minimumConfirmations: + minimumConfirmations ?? cryptoCurrency.minConfirms, + note: message ?? '', + returnSlate: true, + ); + + return EpicSlatepackResult( + success: true, + slatepack: result.slateJson, + slateJson: result.slateJson, + wasEncrypted: false, + recipientAddress: recipientAddress, + ); + } catch (e, s) { + Logging.instance.e('Failed to create slatepack: $e\n$s'); + return EpicSlatepackResult(success: false, error: e.toString()); + } + } + + /// Decode a slatepack/slate JSON. + Future decodeSlatepack(String slateJson) async { + try { + // For Epic Cash, slates are already JSON, so we parse directly. + // Validate that the JSON is valid. + jsonDecode(slateJson); + + return EpicSlatepackDecodeResult( + success: true, + slateJson: slateJson, + 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()); + } + } + + /// 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 { + try { + _hackedCheckTorNodePrefs(); + final handle = await _ensureWalletOpen(); + + // Receive and get updated slate JSON. + final received = await libEpic.txReceive( + wallet: handle, + slateJson: slateJson, + ); + + return EpicReceiveResult( + success: true, + slateId: received.slateId, + commitId: received.commitId, + responseSlatepack: received.slateJson, + wasEncrypted: false, + recipientAddress: null, + ); + } catch (e, s) { + Logging.instance.e('Failed to receive slatepack: $e\n$s'); + return EpicReceiveResult(success: false, error: e.toString()); + } + } + + /// Finalize a slatepack (sender step 3). + Future finalizeSlatepack(String slateJson) async { + try { + _hackedCheckTorNodePrefs(); + final handle = await _ensureWalletOpen(); + + // Finalize transaction. + final finalized = await libEpic.txFinalize( + wallet: handle, + slateJson: slateJson, + ); + + return EpicFinalizeResult( + success: true, + slateId: finalized.slateId, + commitId: finalized.commitId, + ); + } catch (e, s) { + Logging.instance.e('Failed to finalize slatepack: $e\n$s'); + return EpicFinalizeResult(success: false, error: e.toString()); + } + } + + /// 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; + } + } + + /// Check if address is Epicbox format. + bool isEpicboxAddress(String address) { + return address.contains('@'); + } + + /// Check if address is HTTP format. + bool isHttpAddress(String address) { + return address.startsWith('http://') || address.startsWith('https://'); + } + // ================= Private ================================================= Future _getConfig() async { @@ -363,8 +631,6 @@ class EpiccashWallet extends Bip39Wallet { Future _startScans() async { try { - //First stop the current listener - libEpic.stopEpicboxListener(); final wallet = await secureStorageInterface.read( key: '${walletId}_wallet', ); @@ -380,6 +646,15 @@ class EpiccashWallet extends Bip39Wallet { 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 + libEpic.stopEpicboxListener(walletId: walletId); + } + // loop while scanning in chain in chunks (of blocks?) while (lastScannedBlock < chainHeight) { Logging.instance.d( @@ -407,8 +682,16 @@ class EpiccashWallet extends Bip39Wallet { } Logging.instance.d("_startScans successfully at the tip"); - //Once scanner completes restart listener - await _listenToEpicbox(); + + // 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 (!libEpic.isEpicboxListenerRunning(walletId: walletId)) { + 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; @@ -420,6 +703,7 @@ class EpiccashWallet extends Bip39Wallet { final wallet = await secureStorageInterface.read(key: '${walletId}_wallet'); final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig(); libEpic.startEpicboxListener( + walletId: walletId, wallet: wallet!, epicboxConfig: epicboxConfig.toString(), ); @@ -640,11 +924,11 @@ class EpiccashWallet extends Bip39Wallet { } } - ({String commitId, String slateId}) transaction; + ({String commitId, String slateId, String slateJson}) transaction; if (receiverAddress.startsWith("http://") || receiverAddress.startsWith("https://")) { - transaction = await libEpic.txHttpSend( + final httpResult = await libEpic.txHttpSend( wallet: wallet!, selectionStrategyIsAll: 0, minimumConfirmations: cryptoCurrency.minConfirms, @@ -652,6 +936,11 @@ class EpiccashWallet extends Bip39Wallet { amount: txData.recipients!.first.amount.raw.toInt(), address: txData.recipients!.first.address, ); + transaction = ( + commitId: httpResult.commitId, + slateId: httpResult.slateId, + slateJson: '', + ); } else { transaction = await libEpic.createTransaction( wallet: wallet!, @@ -667,7 +956,10 @@ class EpiccashWallet extends Bip39Wallet { final Map txAddressInfo = {}; txAddressInfo['from'] = (await getCurrentReceivingAddress())!.value; txAddressInfo['to'] = txData.recipients!.first.address; - await _putSendToAddresses(transaction, txAddressInfo); + await _putSendToAddresses( + (commitId: transaction.commitId, slateId: transaction.slateId), + txAddressInfo, + ); return txData.copyWith(txid: transaction.slateId); } catch (e, s) { @@ -1324,7 +1616,7 @@ class EpiccashWallet extends Bip39Wallet { @override Future exit() async { - libEpic.stopEpicboxListener(); + libEpic.stopEpicboxListener(walletId: walletId); timer?.cancel(); timer = null; await super.exit(); diff --git a/lib/widgets/epic_txs_method_toggle.dart b/lib/widgets/epic_txs_method_toggle.dart new file mode 100644 index 000000000..f08f086f1 --- /dev/null +++ b/lib/widgets/epic_txs_method_toggle.dart @@ -0,0 +1,67 @@ +/* + * 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/wl_gen/interfaces/libepiccash_interface.dart b/lib/wl_gen/interfaces/libepiccash_interface.dart index 287993f6d..a06352084 100644 --- a/lib/wl_gen/interfaces/libepiccash_interface.dart +++ b/lib/wl_gen/interfaces/libepiccash_interface.dart @@ -34,7 +34,7 @@ abstract class LibEpicCashInterface { required String address, }); - Future<({String commitId, String slateId})> createTransaction({ + Future<({String commitId, String slateId, String slateJson})> createTransaction({ required String wallet, required int amount, required String address, @@ -42,6 +42,17 @@ abstract class LibEpicCashInterface { required String epicboxConfig, required int minimumConfirmations, required String note, + bool returnSlate = false, + }); + + Future<({String slateId, String commitId, String slateJson})> txReceive({ + required String wallet, + required String slateJson, + }); + + Future<({String slateId, String commitId})> txFinalize({ + required String wallet, + required String slateJson, }); Future cancelTransaction({ @@ -55,11 +66,18 @@ abstract class LibEpicCashInterface { }); void startEpicboxListener({ + required String walletId, required String wallet, required String epicboxConfig, }); - void stopEpicboxListener(); + void stopEpicboxListener({required String walletId}); + + void stopAllEpicboxListeners(); + + bool isEpicboxListenerRunning({required String walletId}); + + List getActiveListenerWalletIds(); bool validateSendAddress({required String address}); diff --git a/scripts/android/build_all.sh b/scripts/android/build_all.sh index dc904e95d..c13540403 100755 --- a/scripts/android/build_all.sh +++ b/scripts/android/build_all.sh @@ -11,6 +11,7 @@ PLUGINS_DIR=../../crypto_plugins source ../rust_version.sh set_rust_version_for_libepiccash (cd "${PLUGINS_DIR}"/flutter_libepiccash/scripts/android && ./build_all.sh ) +set_rust_version_for_libmwc (cd "${PLUGINS_DIR}"/flutter_libmwc/scripts/android && ./build_all.sh ) # set rust (back) to a more recent stable release after building epiccash set_rust_to_everything_else diff --git a/scripts/ios/build_all.sh b/scripts/ios/build_all.sh index 83177db5c..ed5fb236f 100755 --- a/scripts/ios/build_all.sh +++ b/scripts/ios/build_all.sh @@ -14,6 +14,7 @@ rustup target add x86_64-apple-ios source ../rust_version.sh set_rust_version_for_libepiccash (cd ../../crypto_plugins/flutter_libepiccash/scripts/ios && ./build_all.sh ) +set_rust_version_for_libmwc (cd ../../crypto_plugins/flutter_libmwc/scripts/ios/ && ./build_all.sh ) # set rust (back) to a more recent stable release after building epiccash set_rust_to_everything_else diff --git a/scripts/linux/build_all.sh b/scripts/linux/build_all.sh index 50490b197..374b2d462 100755 --- a/scripts/linux/build_all.sh +++ b/scripts/linux/build_all.sh @@ -13,6 +13,7 @@ mkdir -p build source ../rust_version.sh set_rust_version_for_libepiccash (cd ../../crypto_plugins/flutter_libepiccash/scripts/linux && ./build_all.sh ) +set_rust_version_for_libmwc (cd ../../crypto_plugins/flutter_libmwc/scripts/linux && ./build_all.sh ) # set rust (back) to a more recent stable release after building epiccash set_rust_to_everything_else diff --git a/scripts/macos/build_all.sh b/scripts/macos/build_all.sh index de9b79efa..2012568b1 100755 --- a/scripts/macos/build_all.sh +++ b/scripts/macos/build_all.sh @@ -7,6 +7,7 @@ set -x -e source ../rust_version.sh set_rust_version_for_libepiccash (cd ../../crypto_plugins/flutter_libepiccash/scripts/macos && ./build_all.sh ) +set_rust_version_for_libmwc (cd ../../crypto_plugins/flutter_libmwc/scripts/macos && ./build_all.sh ) # set rust (back) to a more recent stable release after building epiccash set_rust_to_everything_else diff --git a/scripts/rust_version.sh b/scripts/rust_version.sh index eb302cfc7..68c52d6b9 100755 --- a/scripts/rust_version.sh +++ b/scripts/rust_version.sh @@ -11,10 +11,19 @@ set_rust_to_everything_else() { } set_rust_version_for_libepiccash() { - if rustup toolchain list | grep -q "1.81.0"; then - rustup default 1.81 + if rustup toolchain list | grep -q "1.89.0"; then + rustup default 1.89.0 + else + echo "Rust version 1.89.0 is not installed. Please install it using 'rustup install 1.89.0'." >&2 + exit 1 + fi +} + +set_rust_version_for_libmwc() { + if rustup toolchain list | grep -q "1.85.1"; then + rustup default 1.85.1 else - echo "Rust version 1.81.0 is not installed. Please install it using 'rustup install 1.81.0'." >&2 + echo "Rust version 1.85.1 is not installed. Please install it using 'rustup install 1.85.1'." >&2 exit 1 fi } \ No newline at end of file diff --git a/scripts/windows/build_all.sh b/scripts/windows/build_all.sh index 6d7395bbf..50513331b 100755 --- a/scripts/windows/build_all.sh +++ b/scripts/windows/build_all.sh @@ -8,6 +8,7 @@ mkdir -p build source ../rust_version.sh set_rust_version_for_libepiccash (cd ../../crypto_plugins/flutter_libepiccash/scripts/windows && ./build_all.sh ) +set_rust_version_for_libmwc (cd ../../crypto_plugins/flutter_libmwc/scripts/windows && ./build_all.sh ) # set rust (back) to a more recent stable release after building epiccash set_rust_to_everything_else diff --git a/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart b/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart index 579c32287..18cf45ab7 100644 --- a/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart +++ b/tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart @@ -30,7 +30,29 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { } @override - Future<({String commitId, String slateId})> createTransaction({ + Future<({String slateId, String commitId, String slateJson})> txReceive({ + required String wallet, + required String slateJson, + }) { + return LibEpiccash.txReceive( + wallet: wallet, + slateJson: slateJson, + ); + } + + @override + Future<({String slateId, String commitId})> txFinalize({ + required String wallet, + required String slateJson, + }) { + return LibEpiccash.txFinalize( + wallet: wallet, + slateJson: slateJson, + ); + } + + @override + Future<({String commitId, String slateId, String slateJson})> createTransaction({ required String wallet, required int amount, required String address, @@ -38,6 +60,7 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { required String epicboxConfig, required int minimumConfirmations, required String note, + bool returnSlate = false, }) { return LibEpiccash.createTransaction( wallet: wallet, @@ -47,6 +70,7 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { epicboxConfig: epicboxConfig, minimumConfirmations: minimumConfirmations, note: note, + returnSlate: returnSlate, ); } @@ -210,18 +234,35 @@ final class _LibEpicCashInterfaceImpl extends LibEpicCashInterface { @override void startEpicboxListener({ + required String walletId, required String wallet, required String epicboxConfig, }) { return LibEpiccash.startEpicboxListener( + walletId: walletId, wallet: wallet, epicboxConfig: epicboxConfig, ); } @override - void stopEpicboxListener() { - return LibEpiccash.stopEpicboxListener(); + void stopEpicboxListener({required String walletId}) { + return LibEpiccash.stopEpicboxListener(walletId: walletId); + } + + @override + void stopAllEpicboxListeners() { + return LibEpiccash.stopAllEpicboxListeners(); + } + + @override + bool isEpicboxListenerRunning({required String walletId}) { + return LibEpiccash.isEpicboxListenerRunning(walletId: walletId); + } + + @override + List getActiveListenerWalletIds() { + return LibEpiccash.getActiveListenerWalletIds(); } @override