From ff5825c71b35771f28c65d0fad1a3db9cbf1de97 Mon Sep 17 00:00:00 2001 From: Ez3kiel Date: Thu, 10 Sep 2026 11:38:48 +0200 Subject: [PATCH] fix(xelis): restore wallet integration with XWF Replace the legacy Flutter FFI with XWF 0.4 and Xelis Dart SDK 0.36. Install the Rust toolchain required by XWF Native Assets in build and test images and CI. Preserve exact atomic values and prepared transactions through review and broadcast. Handle reconnects, shutdown, confirmations, recovery and deletion through the Stack adapter and retain their behavioral regression tests. Run native wallet and SWB restoration with the default tests. Keep devnet transfers and channel rotation as a documented release check. --- .github/workflows/test.yaml | 8 +- Dockerfile | 9 +- docs/building.md | 26 +- lib/main.dart | 2 +- .../send_view/confirm_transaction_view.dart | 161 +- lib/pages/send_view/send_view.dart | 29 +- .../wallet_view/sub_widgets/desktop_send.dart | 11 + .../sub_widgets/desktop_send_fee_form.dart | 7 +- lib/services/wallets.dart | 9 + lib/utilities/test_node_connection.dart | 14 +- lib/utilities/xelis_storage.dart | 24 + lib/wallets/crypto_currency/coins/xelis.dart | 2 +- lib/wallets/models/tx_data.dart | 10 + lib/wallets/models/xelis_transaction.dart | 126 ++ lib/wallets/wallet/impl/xelis_wallet.dart | 1416 ++++++----------- .../wallet/intermediate/lib_xelis_wallet.dart | 475 ++++-- lib/wallets/wallet/wallet.dart | 8 + .../interfaces/lib_xelis_interface.dart | 147 +- lib/wl_gen/interfaces/xelis_types.dart | 109 ++ pubspec.lock | 27 +- .../ios/Runner.xcodeproj/project.pbxproj | 2 - .../templates/pubspec.template.yaml | 14 +- .../send_view/xelis_confirmation_test.dart | 146 ++ .../pages/send_view/xelis_send_view_test.dart | 113 ++ test/support/isar_test_utils.dart | 28 + test/wallets/support/xelis_test_fakes.dart | 136 ++ test/wallets/xelis_adapter_test.dart | 66 + test/wallets/xelis_local_transfer_test.dart | 358 +++++ test/wallets/xelis_persistence_test.dart | 588 +++++++ test/wallets/xelis_send_test.dart | 128 ++ test/wallets/xelis_session_test.dart | 290 ++++ test/wallets/xelis_storage_test.dart | 49 + test/wallets/xelis_swb_test.dart | 186 +++ test/wallets/xelis_tables_test.dart | 161 ++ test/wallets/xelis_transaction_test.dart | 125 ++ test/wallets/xelis_types_test.dart | 44 + ...XEL_lib_xelis_interface_impl.template.dart | 763 ++++----- 37 files changed, 4199 insertions(+), 1618 deletions(-) create mode 100644 lib/utilities/xelis_storage.dart create mode 100644 lib/wallets/models/xelis_transaction.dart create mode 100644 lib/wl_gen/interfaces/xelis_types.dart create mode 100644 test/pages/send_view/xelis_confirmation_test.dart create mode 100644 test/pages/send_view/xelis_send_view_test.dart create mode 100644 test/support/isar_test_utils.dart create mode 100644 test/wallets/support/xelis_test_fakes.dart create mode 100644 test/wallets/xelis_adapter_test.dart create mode 100644 test/wallets/xelis_local_transfer_test.dart create mode 100644 test/wallets/xelis_persistence_test.dart create mode 100644 test/wallets/xelis_send_test.dart create mode 100644 test/wallets/xelis_session_test.dart create mode 100644 test/wallets/xelis_storage_test.dart create mode 100644 test/wallets/xelis_swb_test.dart create mode 100644 test/wallets/xelis_tables_test.dart create mode 100644 test/wallets/xelis_transaction_test.dart create mode 100644 test/wallets/xelis_types_test.dart diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 53b0a10648..8bbf612339 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,8 +18,12 @@ jobs: fetch-depth: 0 submodules: recursive - # -d enables the pinned Epic/MWC native-assets prebuilts for flutter test. - - name: Configure app (prebuilt native assets) + - name: Install Xelis Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.94.1' + + - name: Configure app run: | cd scripts echo "yes" | ./build_app.sh -v "0.0.1" -b "1" -p "linux" -a "stack_wallet" -d -s diff --git a/Dockerfile b/Dockerfile index 2dd823fbb8..427ab34efd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ ENV RUSTUP_HOME=/usr/local/rustup \ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --default-toolchain 1.89.0 --profile minimal --no-modify-path \ - && rustup install 1.85.1 1.71.0 stable --profile minimal \ + && rustup install 1.85.1 1.71.0 1.94.1 stable --profile minimal \ && rustup target add x86_64-unknown-linux-gnu --toolchain 1.89.0 \ && cargo install cargo-ndk \ && chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" @@ -123,6 +123,10 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ && rustup target add \ aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android \ --toolchain 1.89.0 \ + && rustup toolchain install 1.94.1 --profile minimal \ + && rustup target add \ + aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android \ + --toolchain 1.94.1 \ && cargo install cargo-ndk \ && chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" @@ -185,7 +189,7 @@ RUN git config --system --add safe.directory '*' RUN flutter --version && rustc --version && cargo --version && go version -# Image for flutter test (no Android SDK or cross-compilers) +# Test image: Native Assets hooks also compile the Xelis Rust runtime. FROM ubuntu:24.04 AS test ENV DEBIAN_FRONTEND=noninteractive \ @@ -210,6 +214,7 @@ ENV RUSTUP_HOME=/usr/local/rustup \ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --default-toolchain 1.90.0 --profile minimal --no-modify-path \ + && rustup toolchain install 1.94.1 --profile minimal \ && chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" ENV PATH=/usr/local/go/bin:$PATH diff --git a/docs/building.md b/docs/building.md index 11145566af..6367f3ea11 100644 --- a/docs/building.md +++ b/docs/building.md @@ -4,6 +4,10 @@ Here you will find instructions on how to install the necessary tools for buildi ## Prerequisites +- Xelis requires Flutter 3.47.2 and Rustup. Its native wallet library is + built automatically through Native Assets, using Rust 1.94.1. + Android builds require NDK r28 or newer. + - The only OS supported for building Android and Linux desktop is Ubuntu 24.04. Windows builds require using Ubuntu 24.04 on WSL2. macOS builds for itself and iOS. Advanced users may also be able to build on other Debian-based distributions like Linux Mint. - Android setup ([Android Studio](https://developer.android.com/studio) and subsequent dependencies) - 100 GB of storage @@ -14,7 +18,7 @@ Here you will find instructions on how to install the necessary tools for buildi The following instructions are for building and running on a Linux host. Alternatively, see the [Mac](#mac-host) and/or [Windows](#windows-host) section. This entire section (except for the Android Studio section) needs to be completed in WSL if building on a Windows host. ### Flutter -Install Flutter 3.38.5 by [following their guide](https://docs.flutter.dev/get-started/install/linux/desktop?tab=download#install-the-flutter-sdk). Run `flutter doctor` in a terminal to confirm its installation. +Install Flutter 3.47.2 by [following their guide](https://docs.flutter.dev/get-started/install/linux/desktop?tab=download#install-the-flutter-sdk). Run `flutter doctor` in a terminal to confirm its installation. ### Android Studio Install Android Studio. Follow instructions here [https://developer.android.com/studio/install#linux](https://developer.android.com/studio/install#linux) or install via snap: @@ -69,7 +73,7 @@ pip3 install --upgrade meson==0.64.1 markdown==3.4.1 markupsafe==2.1.1 jinja2==3 ``` ### Flutter -Install Flutter 3.38.5 by [following their guide](https://docs.flutter.dev/install/manual). +Install Flutter 3.47.2 by [following their guide](https://docs.flutter.dev/install/manual). Run `flutter doctor` in a terminal to confirm its installation. @@ -220,7 +224,7 @@ rustup target add aarch64-apple-ios aarch64-apple-darwin Optionally download [Android Studio](https://developer.android.com/studio) as an IDE and activate its Dart and Flutter plugins. VS Code may work as an alternative, but this is not recommended. ### Flutter -Install 3.38.5 on your Mac host by [following their guide](https://docs.flutter.dev/install/manual). Run `flutter doctor` in a terminal to confirm its installation. +Install Flutter 3.47.2 on your Mac host by [following their guide](https://docs.flutter.dev/install/manual). Run `flutter doctor` in a terminal to confirm its installation. ### Build plugins and configure #### Building plugins for iOS @@ -300,7 +304,7 @@ If the DLL was built on the WSL filesystem instead of on Windows, copy `stack_wa Frostdart will be built by the Windows host later. ### Install Flutter on Windows host -Install Flutter 3.38.5 on your Windows host (not in WSL2) by [following their guide](https://docs.flutter.dev/install/manual). Run `flutter doctor` in PowerShell to confirm its installation. +Install Flutter 3.47.2 on your Windows host (not in WSL2) by [following their guide](https://docs.flutter.dev/install/manual). Run `flutter doctor` in PowerShell to confirm its installation. ### 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: @@ -363,6 +367,20 @@ dart run coinlib:build_windows flutter run -d windows ``` +## Xelis devnet test + +To validate Xelis transfers locally, provide a `xelis_daemon` 1.25 +executable built for your host OS. The test starts an isolated temporary +devnet node; no running node is required. + +```sh +flutter test test/wallets/xelis_local_transfer_test.dart \ + --dart-define=XELIS_LOCAL_DAEMON=/absolute/path/to/xelis_daemon +``` + +This test is skipped when `XELIS_LOCAL_DAEMON` is not set. +The daemon is not required to build the app. + # Troubleshooting Run with `-v` or `--verbose` to see a more detailed error. Certain exceptions (like missing a plugin library) may not report quality errors without `verbose`, especially on Windows. diff --git a/lib/main.dart b/lib/main.dart index 9ed09c185f..f4d18ee430 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -89,11 +89,11 @@ final openedFromSWBFileStringStateProvider = StateProvider( // runs the MyApp widget and checks for new users, caching the value in the // miscellaneous box for later use void main(List args) async { + WidgetsFlutterBinding.ensureInitialized(); // talker.info('initializing Rust lib ...'); if (AppConfig.coins.whereType().isNotEmpty) { await libXelis.initRustLib(); } - WidgetsFlutterBinding.ensureInitialized(); if (Util.isDesktop && args.length == 2 && args.first == "-d") { StackFileSystem.setDesktopOverrideDir(args.last); diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index ed4e967792..a4886bae84 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -106,6 +106,7 @@ class _ConfirmTransactionViewState late final String walletId; late final String routeOnSuccessName; late final bool isDesktop; + late final Future Function() _cancelPreparation; late final FocusNode _noteFocusNode; late final TextEditingController noteController; @@ -195,9 +196,8 @@ class _ConfirmTransactionViewState if (context.mounted) { widget.onSuccess.call(); if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -274,9 +274,8 @@ class _ConfirmTransactionViewState if (context.mounted) { widget.onSuccess.call(); if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -483,9 +482,8 @@ class _ConfirmTransactionViewState if (context.mounted) { if (widget.onSuccessInsteadOfRouteOnSuccess == null) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(routeOnSuccessName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(routeOnSuccessName)); } else { widget.onSuccessInsteadOfRouteOnSuccess!.call(); } @@ -563,9 +561,9 @@ class _ConfirmTransactionViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), onPressed: () { @@ -586,6 +584,8 @@ class _ConfirmTransactionViewState isDesktop = Util.isDesktop; walletId = widget.walletId; + final preparedWallet = ref.read(pWallets).getWallet(walletId); + _cancelPreparation = () => preparedWallet.cancelSend(txData: widget.txData); routeOnSuccessName = widget.routeOnSuccessName ?? (Util.isDesktop ? DesktopWalletView.routeName : WalletView.routeName); @@ -602,6 +602,15 @@ class _ConfirmTransactionViewState @override void dispose() { + unawaited( + _cancelPreparation().catchError((Object error, StackTrace stack) { + Logging.instance.e( + 'Failed to release transaction preparation', + error: error, + stackTrace: stack, + ); + }), + ); noteController.dispose(); onChainNoteController.dispose(); @@ -673,13 +682,13 @@ class _ConfirmTransactionViewState condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, leading: AppBarBackButton( onPressed: () async { // if (FocusScope.of(context).hasFocus) { @@ -913,18 +922,18 @@ class _ConfirmTransactionViewState ), child: RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of(context) + .extension()! + .background, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, borderRadius: BorderRadius.only( topLeft: Radius.circular( Constants.size.circularBorderRadius, @@ -1091,9 +1100,9 @@ class _ConfirmTransactionViewState ), Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), Padding( padding: const EdgeInsets.all(12), @@ -1128,9 +1137,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1139,9 +1148,9 @@ class _ConfirmTransactionViewState if (widget.isPaynymTransaction) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (widget.isPaynymTransaction) Padding( @@ -1163,9 +1172,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1174,9 +1183,9 @@ class _ConfirmTransactionViewState if (coin is Ethereum) Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ), if (coin is Ethereum) Padding( @@ -1198,9 +1207,9 @@ class _ConfirmTransactionViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1331,9 +1340,9 @@ class _ConfirmTransactionViewState focusNode: _noteFocusNode, style: STextStyles.desktopTextExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of(context) + .extension()! + .textFieldActiveText, height: 1.8, ), onChanged: (_) => setState(() {}), @@ -1393,9 +1402,9 @@ class _ConfirmTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( ref.watch(pAmountFormatter(coin)).format(fee!), style: STextStyles.itemSubtitle(context), @@ -1424,9 +1433,9 @@ class _ConfirmTransactionViewState horizontal: 16, vertical: 18, ), - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: SelectableText( "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle(context), @@ -1444,22 +1453,21 @@ class _ConfirmTransactionViewState padding: isDesktop ? const EdgeInsets.symmetric(horizontal: 16, vertical: 18) : const EdgeInsets.all(12), - color: Theme.of( - context, - ).extension()!.snackBarBackSuccess, + color: Theme.of(context) + .extension()! + .snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( isDesktop ? "Total amount to send" : "Total amount", style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.titleBold12(context).copyWith( color: Theme.of(context) .extension()! @@ -1471,13 +1479,12 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format(amountWithoutChange + fee!), style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) : STextStyles.itemSubtitle12(context).copyWith( color: Theme.of(context) .extension()! @@ -1495,16 +1502,16 @@ class _ConfirmTransactionViewState ? const EdgeInsets.symmetric(horizontal: 32, vertical: 8) : const EdgeInsets.symmetric(vertical: 8), child: RoundedContainer( - color: Theme.of( - context, - ).extension()!.warningBackground, + color: Theme.of(context) + .extension()! + .warningBackground, child: Row( children: [ Icon( Icons.warning_amber_rounded, - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, size: 20, ), const SizedBox(width: 8), @@ -1513,9 +1520,9 @@ class _ConfirmTransactionViewState "This transaction spends a UTXO containing " "an ordinal inscription.", style: STextStyles.smallMed12(context).copyWith( - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, ), ), ), diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 13615a092d..dcfe60fbbb 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -421,7 +421,10 @@ class _SendViewState extends ConsumerState { ref.read(pSendAmount.notifier).state = amount; } + bool _xelisSendAll = false; + void _cryptoAmountChanged() async { + _xelisSendAll = false; if (!_cryptoAmountChangeLock) { final cryptoAmount = ref .read(pAmountFormatter(coin)) @@ -569,6 +572,10 @@ class _SendViewState extends ConsumerState { } Future calculateFees(Amount amount) async { + // Xelis obtains the exact fee when preparing the reviewed transaction. + if (coin is Xelis) { + return Amount.zeroWith(fractionDigits: coin.fractionDigits); + } final feeRateType = ref.read(feeRateTypeMobileStateProvider); final cacheKey = (amount, feeRateType); final hasOpReturnData = @@ -914,6 +921,7 @@ class _SendViewState extends ConsumerState { // cancel preview return; } + if (coin is Xelis) _xelisSendAll = true; } } @@ -1077,6 +1085,7 @@ class _SendViewState extends ConsumerState { final memo = coin is Stellar ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( + xelisSendAll: coin is Xelis && _xelisSendAll, recipients: [ TxRecipient( address: _address!, @@ -1104,6 +1113,11 @@ class _SendViewState extends ConsumerState { TxData txData = results.first as TxData; + if (wasCancelled || !mounted) { + await wallet.cancelSend(txData: txData); + return; + } + if (!wasCancelled && mounted) { if (isPaynymSend) { txData = txData.copyWith( @@ -1238,6 +1252,7 @@ class _SendViewState extends ConsumerState { .read(pAmountFormatter(coin)) .formatEditable(amount); _cryptoAmountChanged(); + _xelisSendAll = coin is Xelis; } bool get isPaynymSend => widget.accountLite != null; @@ -2526,7 +2541,9 @@ class _SendViewState extends ConsumerState { coin is! NanoCurrency && coin is! Tezos) Text( - "Transaction fee (estimated)", + coin is Xelis + ? "Transaction fee" + : "Transaction fee (estimated)", style: STextStyles.smallMed12(context), textAlign: TextAlign.left, ), @@ -2535,7 +2552,15 @@ class _SendViewState extends ConsumerState { coin is! NanoCurrency && coin is! Tezos) const SizedBox(height: 8), - if (coin is! Epiccash && + if (coin is Xelis) + Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Calculated when reviewing', + style: STextStyles.itemSubtitle(context), + ), + ) + else if (coin is! Epiccash && coin is! Mimblewimblecoin && coin is! NanoCurrency && coin is! Tezos) 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 09d2e67979..b947609806 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 @@ -545,6 +545,7 @@ class _DesktopSendState extends ConsumerState { // cancel preview return; } + if (coin is Xelis) _xelisSendAll = true; } } @@ -721,6 +722,7 @@ class _DesktopSendState extends ConsumerState { final memo = hasOptionalMemo ? memoController.text : null; txDataFuture = wallet.prepareSend( txData: TxData( + xelisSendAll: coin is Xelis && _xelisSendAll, recipients: [ TxRecipient( address: _address!, @@ -749,6 +751,11 @@ class _DesktopSendState extends ConsumerState { txData = results.first as TxData; + if (wasCancelled || !mounted) { + await wallet.cancelSend(txData: txData); + return; + } + if (!wasCancelled && mounted) { if (isPaynymSend) { txData = txData.copyWith( @@ -879,7 +886,10 @@ class _DesktopSendState extends ConsumerState { ref.read(pOpReturnData.notifier).state = data; } + bool _xelisSendAll = false; + void _cryptoAmountChanged() async { + _xelisSendAll = false; if (!_cryptoAmountChangeLock) { final cryptoAmount = ref .read(pAmountFormatter(coin)) @@ -1229,6 +1239,7 @@ class _DesktopSendState extends ConsumerState { .read(pAmountFormatter(coin)) .formatEditable(amount); _syncFeeAmount(amount); + _xelisSendAll = coin is Xelis; } void _showDesktopCoinControl() async { diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart index cc57cc2495..c10b94e460 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart @@ -164,7 +164,12 @@ class _DesktopSendFeeFormState extends ConsumerState { ), ), const SizedBox(height: 10), - if (!isCustomFee) + if (cryptoCurrency is Xelis) + const Padding( + padding: EdgeInsets.all(10), + child: Text('Calculated when reviewing'), + ) + else if (!isCustomFee) Padding( padding: const EdgeInsets.all(10), child: (feeSelectionResult?.$2 == null) diff --git a/lib/services/wallets.dart b/lib/services/wallets.dart index 5a7cf0539e..bd3713d94f 100644 --- a/lib/services/wallets.dart +++ b/lib/services/wallets.dart @@ -22,6 +22,7 @@ import '../utilities/flutter_secure_storage_interface.dart'; import '../utilities/logger.dart'; import '../utilities/prefs.dart'; import '../utilities/stack_file_system.dart'; +import '../utilities/xelis_storage.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; import '../wallets/crypto_currency/intermediate/cryptonote_currency.dart'; import '../wallets/crypto_currency/intermediate/frost_currency.dart'; @@ -118,6 +119,14 @@ class Wallets { _wallets.remove(walletId); await wallet?.exit(); + if (info.coin is Xelis) { + // Remove unlocked native storage before forgetting its password. + // The precomputed tables belong to all Xelis wallets. + final root = await StackFileSystem.applicationXelisDirectory(); + final directory = await xelisWalletDirectory(root, walletId); + if (directory != null) await directory.delete(recursive: true); + } + await secureStorage.delete(key: Wallet.mnemonicKey(walletId: walletId)); await secureStorage.delete( key: Wallet.mnemonicPassphraseKey(walletId: walletId), diff --git a/lib/utilities/test_node_connection.dart b/lib/utilities/test_node_connection.dart index 1a94e23f39..0b3504ce21 100644 --- a/lib/utilities/test_node_connection.dart +++ b/lib/utilities/test_node_connection.dart @@ -29,13 +29,12 @@ import 'test_mwcmqs_connection.dart'; import 'test_stellar_node_connection.dart'; import 'tor_plain_net_option_enum.dart'; -typedef TestNodeConnectionCallback = - Future Function({ - required BuildContext context, - required NodeFormData nodeFormData, - required CryptoCurrency cryptoCurrency, - void Function(NodeFormData)? onSuccess, - }); +typedef TestNodeConnectionCallback = Future Function({ + required BuildContext context, + required NodeFormData nodeFormData, + required CryptoCurrency cryptoCurrency, + void Function(NodeFormData)? onSuccess, +}); final testNodeConnectionProvider = Provider((ref) { return ({ @@ -329,6 +328,7 @@ Future testNodeConnection({ testPassed = await libXelis.testDaemonConnection( "${formData.host!}:${formData.port!}", formData.useSSL ?? false, + cryptoCurrency.network, ); } catch (_) { testPassed = false; diff --git a/lib/utilities/xelis_storage.dart b/lib/utilities/xelis_storage.dart new file mode 100644 index 0000000000..30227246a5 --- /dev/null +++ b/lib/utilities/xelis_storage.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; + +/// Resolve one native wallet, rejecting aliases to other wallets or tables. +/// A missing directory is safe on a restored Stack backup with no native data. +Future xelisWalletDirectory(Directory root, String walletId) async { + if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(walletId) || + walletId.toLowerCase() == 'table') { + throw StateError('Invalid Xelis wallet directory'); + } + final directory = Directory(path.join(root.path, walletId)); + final type = await FileSystemEntity.type(directory.path, followLinks: false); + if (type == FileSystemEntityType.notFound) return null; + if (type != FileSystemEntityType.directory) { + throw StateError('Xelis wallet storage is not a regular directory'); + } + final expected = path.join(await root.resolveSymbolicLinks(), walletId); + final actual = await directory.resolveSymbolicLinks(); + if (!path.equals(expected, actual)) { + throw StateError('Xelis wallet storage resolves to a different directory'); + } + return directory; +} diff --git a/lib/wallets/crypto_currency/coins/xelis.dart b/lib/wallets/crypto_currency/coins/xelis.dart index 2d946bca62..0cc630979c 100644 --- a/lib/wallets/crypto_currency/coins/xelis.dart +++ b/lib/wallets/crypto_currency/coins/xelis.dart @@ -141,7 +141,7 @@ class Xelis extends ElectrumCurrency { AddressType get defaultAddressType => defaultDerivePathType.getAddressType(); @override - BigInt get satsPerCoin => BigInt.from(1000000000); + BigInt get satsPerCoin => BigInt.from(10).pow(fractionDigits); @override int get targetBlockTimeSeconds => 15; diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 1c8f81c931..a2a195a73a 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -14,6 +14,7 @@ import '../../utilities/extensions/impl/uint8_list.dart'; import '../../widgets/eth_fee_form.dart'; import '../../wl_gen/interfaces/cs_monero_interface.dart' show CsPendingTransaction; +import '../../wl_gen/interfaces/xelis_types.dart'; import '../isar/models/spark_coin.dart'; import 'name_op_state.dart'; import 'tx_recipient.dart'; @@ -101,6 +102,8 @@ class TxData { // xelis specific final String? otherData; + final XelisPreparedTransaction? xelisPreparedTransaction; + final bool xelisSendAll; final TransactionV2? tempTx; @@ -146,6 +149,8 @@ class TxData { this.tezosOperationsList, this.sparkRecipients, this.otherData, + this.xelisPreparedTransaction, + this.xelisSendAll = false, this.sparkMints, this.sparkSpends, this.usedSparkCoins, @@ -259,6 +264,8 @@ class TxData { : null; TxData copyWith({ + XelisPreparedTransaction? xelisPreparedTransaction, + bool? xelisSendAll, FeeRateType? feeRateType, BigInt? feeRateAmount, int? satsPerVByte, @@ -339,6 +346,9 @@ class TxData { chainId: chainId ?? this.chainId, solInstructions: solInstructions ?? this.solInstructions, pendingTransaction: pendingTransaction ?? this.pendingTransaction, + xelisPreparedTransaction: + xelisPreparedTransaction ?? this.xelisPreparedTransaction, + xelisSendAll: xelisSendAll ?? this.xelisSendAll, pendingSalviumTransaction: pendingSalviumTransaction ?? this.pendingSalviumTransaction, tezosOperationsList: tezosOperationsList ?? this.tezosOperationsList, diff --git a/lib/wallets/models/xelis_transaction.dart b/lib/wallets/models/xelis_transaction.dart new file mode 100644 index 0000000000..d46599bd54 --- /dev/null +++ b/lib/wallets/models/xelis_transaction.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; + +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 '../../utilities/amount/amount.dart'; +import '../../wl_gen/interfaces/lib_xelis_interface.dart'; + +TransactionV2? projectXelisTransaction({ + required TransactionEntryWrapper tx, + required String ownAddress, + required String walletId, + required String xelisAsset, + required int fractionDigits, +}) { + Amount amount(BigInt raw) => + Amount(rawValue: raw, fractionDigits: fractionDigits); + final inputs = []; + final outputs = []; + var fee = BigInt.zero; + BigInt? nonce; + var type = TransactionType.incoming; + String? action; + void input(BigInt value) => inputs.add( + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [ownAddress], + valueStringSats: value.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ), + ); + void output(BigInt value, String destination, {required bool owned}) => + outputs.add( + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: '', + valueStringSats: value.toString(), + addresses: [destination], + walletOwns: owned, + ), + ); + switch (tx.entryType) { + case CoinbaseEntryWrapper(:final reward): + output(reward, ownAddress, owned: true); + case BurnEntryWrapper(:final amount, :final fee, :final asset): + type = TransactionType.outgoing; + input(fee); + if (asset == xelisAsset) { + input(amount); + output(amount, 'burn', owned: false); + } + action = 'burn'; + // Pattern bindings are final, so assign the transaction fee below. + case IncomingEntryWrapper(:final from, :final transfers): + type = from == ownAddress + ? TransactionType.sentToSelf + : TransactionType.incoming; + for (final transfer in transfers.where((e) => e.asset == xelisAsset)) { + output(transfer.amount, ownAddress, owned: true); + } + if (outputs.isEmpty) return null; + case OutgoingEntryWrapper( + :final transfers, + fee: final outgoingFee, + nonce: final outgoingNonce, + ): + fee = outgoingFee; + nonce = outgoingNonce; + type = TransactionType.outgoing; + input(fee); + for (final transfer in transfers.where((e) => e.asset == xelisAsset)) { + input(transfer.amount); + output( + transfer.amount, + transfer.destination, + owned: transfer.destination == ownAddress, + ); + } + if (outputs.isNotEmpty && outputs.every((e) => e.walletOwns)) { + type = TransactionType.sentToSelf; + } + case XelisActionEntryWrapper( + kind: final kind, + :final spent, + :final received, + fee: final actionFee, + nonce: final actionNonce, + ): + fee = actionFee; + nonce = actionNonce; + action = kind; + input(spent + fee); + if (spent > BigInt.zero) output(spent, kind, owned: false); + if (received > BigInt.zero) output(received, ownAddress, owned: true); + type = spent + fee > received + ? TransactionType.outgoing + : TransactionType.incoming; + case UnknownEntryWrapper(): + return null; + } + if (tx.entryType case BurnEntryWrapper(fee: final burnFee)) fee = burnFee; + return TransactionV2( + walletId: walletId, + blockHash: '', + hash: tx.hash, + txid: tx.hash, + timestamp: (tx.timestamp?.millisecondsSinceEpoch ?? 0) ~/ 1000, + height: tx.topoheight == null ? null : xelisStorageInt(tx.topoheight!), + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + version: -1, + type: type, + subType: TransactionSubType.none, + otherData: jsonEncode({ + TxV2OdKeys.overrideFee: amount(fee).toJsonString(), + if (nonce != null) 'xelisNonce': nonce.toString(), + if (action != null) 'xelisAction': action, + }), + ); +} diff --git a/lib/wallets/wallet/impl/xelis_wallet.dart b/lib/wallets/wallet/impl/xelis_wallet.dart index 544519fda7..6eefb42f75 100644 --- a/lib/wallets/wallet/impl/xelis_wallet.dart +++ b/lib/wallets/wallet/impl/xelis_wallet.dart @@ -1,6 +1,4 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; import 'package:isar_community/isar.dart'; import 'package:mutex/mutex.dart'; @@ -8,9 +6,6 @@ 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/paymint/fee_object_model.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; @@ -21,703 +16,341 @@ import '../../../utilities/stack_file_system.dart'; import '../../../wl_gen/interfaces/lib_xelis_interface.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../models/tx_data.dart'; +import '../../models/xelis_transaction.dart'; import '../intermediate/lib_xelis_wallet.dart'; -import '../intermediate/xelis_event_batcher.dart'; +import '../intermediate/xelis_operation_coordinator.dart'; import '../wallet.dart'; -class XelisWallet extends LibXelisWallet { - Completer? _initCompleter; - - XelisWallet(CryptoCurrencyNetwork network) : super(Xelis(network)); - // ==================== Overrides ============================================ +class XelisWallet extends LibXelisWallet { + XelisWallet(CryptoCurrencyNetwork network, {super.native}) + : super(Xelis(network)); + + /// Set only by Wallet.create for an explicit new-wallet request. Loading an + /// existing database record must never silently generate another identity. + bool allowNewWallet = false; + Future? _initializing; + final _sendMutex = Mutex(); + final _balanceMutex = Mutex(); + final _historyMutex = Mutex(); + final _rescanMutex = Mutex(); + late final _operationCoordinator = XelisOperationCoordinator(refreshMutex); + XelisPreparedTransaction? _prepared; + OpaqueXelisWallet? _preparedHandle; + int? _preparedGeneration; + String? _reviewedDestination; + int _prepareRequest = 0; @override int get isarTransactionVersion => 2; - Future _restoreWallet() async { - final tablePath = await getPrecomputedTablesPath(); - final tableState = await getTableState(); - final xelisDir = await StackFileSystem.applicationXelisDirectory(); - final String name = walletId; - final String directory = xelisDir.path; - final password = await secureStorageInterface.read( - key: Wallet.mnemonicPassphraseKey(walletId: info.walletId), - ); - - final mnemonic = (await getMnemonic()).trim(); - final seedLength = mnemonic.split(" ").length; - - invalidSeedLengthCheck(seedLength); - - Logging.instance.i("Xelis: recovering wallet"); - final wallet = await libXelis.createXelisWallet( - walletId, - name: name, - directory: directory, - password: password!, - seed: mnemonic, - network: cryptoCurrency.network, - precomputedTablesPath: tablePath, - stack_l1Low: tableState.currentSize.isLow, - ); + Amount _amount(BigInt raw) => + Amount(rawValue: raw, fractionDigits: cryptoCurrency.fractionDigits); - await secureStorageInterface.write( - key: Wallet.mnemonicKey(walletId: walletId), - value: mnemonic, - ); - - this.wallet = wallet; - - await _finishInit(); + @override + Future init({bool? isRestore}) async { + if (exitInProgress) throw StateError('Xelis session is closing'); + final previous = _initializing; + if (previous != null) return previous; + if (wallet != null) return super.init(); + final attempt = _initialize(isRestore: isRestore == true); + _initializing = attempt; + try { + await attempt; + } finally { + if (identical(_initializing, attempt)) _initializing = null; + } } - Future _createNewWallet() async { - final tablePath = await getPrecomputedTablesPath(); - final tableState = await getTableState(); - final xelisDir = await StackFileSystem.applicationXelisDirectory(); - final String name = walletId; - final String directory = xelisDir.path; - final String password = generatePassword(); - - Logging.instance.d("Xelis: storing password"); - await secureStorageInterface.write( - key: Wallet.mnemonicPassphraseKey(walletId: info.walletId), - value: password, - ); - - final wallet = await libXelis.createXelisWallet( - walletId, - name: name, - directory: directory, - password: password, - network: cryptoCurrency.network, - precomputedTablesPath: tablePath, - stack_l1Low: tableState.currentSize.isLow, - ); - - final mnemonic = await libXelis.getSeed(wallet); - await secureStorageInterface.write( + Future _initialize({required bool isRestore}) async { + final generation = sessionGeneration; + final directory = await StackFileSystem.applicationXelisDirectory(); + final exists = await LibXelisWallet.checkWalletExists(walletId); + final seed = await secureStorageInterface.read( key: Wallet.mnemonicKey(walletId: walletId), - value: mnemonic.trim(), ); - - this.wallet = wallet; - - await _finishInit(); - } - - Future _existingWallet() async { - Logging.instance.i("Xelis: opening existing wallet"); - final tablePath = await getPrecomputedTablesPath(); - final tableState = await getTableState(); - final xelisDir = await StackFileSystem.applicationXelisDirectory(); - final String name = walletId; - final String directory = xelisDir.path; - final password = await secureStorageInterface.read( - key: Wallet.mnemonicPassphraseKey(walletId: info.walletId), - ); - - wallet = await libXelis.openXelisWallet( - walletId, - name: name, - directory: directory, - password: password!, - network: cryptoCurrency.network, - precomputedTablesPath: tablePath, - stack_l1Low: tableState.currentSize.isLow, - ); - - await _finishInit(); - } - - Future _finishInit() async { - if (await isTableUpgradeAvailable()) { - unawaited(updateTablesToDesiredSize()); - } - - final newReceivingAddress = - await getCurrentReceivingAddress() ?? - Address( - walletId: walletId, - derivationIndex: 0, - derivationPath: null, - value: libXelis.getAddress(wallet!), - publicKey: [], - type: AddressType.xelis, - subType: AddressSubType.receiving, - ); - - await mainDB.updateOrPutAddresses([newReceivingAddress]); - - if (info.cachedReceivingAddress != newReceivingAddress.value) { - await info.updateReceivingAddress( - newAddress: newReceivingAddress.value, - isar: mainDB.isar, - ); - } - } - - @override - Future init({bool? isRestore}) async { - Logging.instance.d("Xelis: init"); - - if (_initCompleter != null) { - await _initCompleter!.future; - return super.init(); + final passwordKey = Wallet.mnemonicPassphraseKey(walletId: walletId); + var password = await secureStorageInterface.read(key: passwordKey); + final tablesPath = await getPrecomputedTablesPath(); + final tables = await getTableState(); + if (exitInProgress || generation != sessionGeneration) { + throw StateError('Xelis session was closed'); } - - _initCompleter = Completer(); - + OpaqueXelisWallet? opened; try { - final bool walletExists = await LibXelisWallet.checkWalletExists( - walletId, - ); - - if (wallet == null) { - if (isRestore == true) { - await _restoreWallet(); - } else { - if (!walletExists) { - await _createNewWallet(); - } else { - await _existingWallet(); - } + if (exists) { + if (password == null) { + throw StateError('Xelis wallet password is missing'); } + opened = await xelis.openXelisWallet( + walletId, + name: walletId, + directory: directory.path, + password: password, + network: cryptoCurrency.network, + precomputedTablesPath: tablesPath, + stack_l1Low: tables.currentSize.isLow, + ); + } else if (seed != null && seed.trim().isNotEmpty) { + final normalizedSeed = seed.trim().split(RegExp(r'\s+')).join(' '); + invalidSeedLengthCheck(normalizedSeed.split(' ').length); + password ??= generatePassword(); + await secureStorageInterface.write(key: passwordKey, value: password); + opened = await xelis.createXelisWallet( + walletId, + name: walletId, + directory: directory.path, + password: password, + seed: normalizedSeed, + network: cryptoCurrency.network, + precomputedTablesPath: tablesPath, + stack_l1Low: tables.currentSize.isLow, + ); + } else { + if (isRestore || !allowNewWallet) { + throw StateError('Xelis wallet data and recovery seed are missing'); + } + password ??= generatePassword(); + await secureStorageInterface.write(key: passwordKey, value: password); + opened = await xelis.createXelisWallet( + walletId, + name: walletId, + directory: directory.path, + password: password, + network: cryptoCurrency.network, + precomputedTablesPath: tablesPath, + stack_l1Low: tables.currentSize.isLow, + ); } - _initCompleter!.complete(); - } catch (e, s) { - _initCompleter!.completeError(e); - Logging.instance.e( - "Xelis init() rethrowing error", - error: e, - stackTrace: s, - ); - rethrow; + if (exitInProgress || generation != sessionGeneration) { + throw StateError('Xelis session was closed'); + } + wallet = opened; + final address = xelis.getAddress(opened); + final previous = await getCurrentReceivingAddress(); + if (!isCurrentSession(opened, generation)) { + throw StateError('Xelis session was closed'); + } + final cachedAddress = info.cachedReceivingAddress; + if ((previous != null && previous.value != address) || + (cachedAddress.isNotEmpty && cachedAddress != address)) { + throw StateError( + 'Xelis wallet address does not match the saved wallet', + ); + } + if (seed == null || seed.trim().isEmpty) { + // Also repairs an interrupted creation: native storage may have been + // committed before Stack saved the recovery words. + final nativeSeed = await xelis.getSeed(opened); + if (!isCurrentSession(opened, generation)) { + throw StateError('Xelis session was closed'); + } + await secureStorageInterface.write( + key: Wallet.mnemonicKey(walletId: walletId), + value: nativeSeed.trim(), + ); + } + await mainDB.updateOrPutAddresses([ + previous ?? + Address( + walletId: walletId, + derivationIndex: 0, + derivationPath: null, + value: address, + publicKey: [], + type: AddressType.xelis, + subType: AddressSubType.receiving, + ), + ]); + if (!isCurrentSession(opened, generation)) { + throw StateError('Xelis session was closed'); + } + await info.updateReceivingAddress(newAddress: address, isar: mainDB.isar); + if (!isCurrentSession(opened, generation)) { + throw StateError('Xelis session was closed'); + } + allowNewWallet = false; + await super.init(); + if (tables.currentSize != tables.desiredSize) { + unawaited( + updateTablesToDesiredSize().catchError(( + Object error, + StackTrace stack, + ) { + Logging.instance.e( + 'Xelis table update failed', + error: error, + stackTrace: stack, + ); + }), + ); + } + } catch (error, stack) { + if (identical(wallet, opened)) wallet = null; + if (opened != null) { + try { + await xelis.closeWallet(opened); + } catch (cleanupError, cleanupStack) { + Logging.instance.e( + 'Xelis initialization cleanup failed', + error: cleanupError, + stackTrace: cleanupStack, + ); + } + } + Error.throwWithStackTrace(error, stack); } - - return super.init(); } @override Future recover({required bool isRescan}) async { - if (isRescan) { - await runXelisRescan(() async { - await mainDB.deleteWalletBlockchainData(walletId); - await updateTransactions(isRescan: true, topoheight: 0); - }); - return; - } - - // Borrowed from libmonero for now, need to refactor for Xelis view keys - // if (isViewOnly) { - // await recoverViewOnly(); - // return; - // } - - try { - await open(); - } catch (e, s) { - Logging.instance.e( - "Error rethrown from $runtimeType recover(isRescan: $isRescan)", - error: e, - stackTrace: s, - ); - rethrow; - } + if (!isRescan) return open(); + checkInitialized(); + final handle = wallet!; + final generation = sessionGeneration; + await _rescanMutex.protect(() async { + if (!isCurrentSession(handle, generation)) return; + final daemon = await xelis.getDaemonInfo(handle); + if (!isCurrentSession(handle, generation)) return; + pruningHeight = daemon.prunedTopoheight ?? BigInt.zero; + _status(WalletSyncStatus.syncing); + await xelis.rescan(handle, topoheight: pruningHeight); + }); } + void _status(WalletSyncStatus status) => GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent(status, walletId, info.coin), + ); + @override Future pingCheck() async { + final handle = wallet; + if (handle == null || exitInProgress) return false; try { - await libXelis.getDaemonInfo(wallet!); + await xelis.getDaemonInfo(handle); return true; } catch (_) { return false; } } - final _balanceUpdateMutex = Mutex(); - @override Future updateBalance({ - int? newBalance, - bool rethrowErrors = false, - }) async { - await _balanceUpdateMutex.protect(() async { - try { - if (await libXelis.hasXelisBalance(wallet!)) { - final BigInt xelBalance = newBalance != null - ? BigInt.from(newBalance) - : await libXelis.getXelisBalanceRaw( - wallet!, - ); // in the future, use getAssetBalances and handle each - final balance = Balance( - total: Amount( - rawValue: xelBalance, - fractionDigits: cryptoCurrency.fractionDigits, - ), - spendable: Amount( - rawValue: xelBalance, - fractionDigits: cryptoCurrency.fractionDigits, - ), - blockedTotal: Amount.zeroWith( - fractionDigits: cryptoCurrency.fractionDigits, - ), - pendingSpendable: Amount.zeroWith( - fractionDigits: cryptoCurrency.fractionDigits, - ), - ); - await info.updateBalance(newBalance: balance, isar: mainDB.isar); - } - } catch (e, s) { - Logging.instance.e( - "Error in $runtimeType updateBalance()", - error: e, - stackTrace: s, - ); - if (rethrowErrors) { - rethrow; - } - } - }); - } - - Future _fetchChainHeight() async { - final infoString = await libXelis.getDaemonInfo(wallet!); - final Map nodeInfo = (json.decode(infoString) as Map) - .cast(); - - pruningHeight = - int.tryParse(nodeInfo['pruned_topoheight']?.toString() ?? '0') ?? 0; - return int.parse(nodeInfo['topoheight'].toString()); - } + BigInt? newBalance, + }) => _balanceMutex.protect(() async { + final handle = wallet; + final generation = sessionGeneration; + if (handle == null || exitInProgress) return; + // A read includes a genuine zero and propagates storage errors. Never turn + // an error into zero or keep the old balance merely because it disappeared. + final raw = await xelis.getXelisBalanceRaw(handle); + final history = await xelis.allHistory(handle); + if (!isCurrentSession(handle, generation)) return; + final address = xelis.getAddress(handle); + final reserved = history + .where((entry) => entry.topoheight == null) + .map((entry) => _project(entry, address)) + .whereType() + .expand((entry) => entry.inputs) + .where((input) => input.walletOwns) + .fold(BigInt.zero, (sum, input) => sum + input.value); + // XWF exposes confirmed balances. Reserve the full pending debit, including + // fees and self transfers, until confirmation; native preparation remains + // authoritative for what can actually be spent. + final blocked = reserved > raw ? raw : reserved; + await info.updateBalance( + newBalance: Balance( + total: _amount(raw), + spendable: _amount(raw - blocked), + blockedTotal: _amount(blocked), + pendingSpendable: _amount(BigInt.zero), + ), + isar: mainDB.isar, + ); + }); @override - Future updateChainHeight({ - int? topoheight, - bool rethrowErrors = false, - }) async { - try { - final height = topoheight ?? await _fetchChainHeight(); - - await info.updateCachedChainHeight( - newHeight: height.toInt(), - isar: mainDB.isar, - ); - } catch (e, s) { - Logging.instance.e( - "Error in $runtimeType updateChainHeight()", - error: e, - stackTrace: s, - ); - if (rethrowErrors) { - rethrow; - } - } + Future updateChainHeight({int? topoheight}) async { + final handle = wallet; + final generation = sessionGeneration; + if (handle == null || exitInProgress) return; + final daemon = await xelis.getDaemonInfo(handle); + if (!isCurrentSession(handle, generation)) return; + pruningHeight = daemon.prunedTopoheight ?? BigInt.zero; + await info.updateCachedChainHeight( + newHeight: topoheight ?? xelisStorageInt(daemon.topoheight), + isar: mainDB.isar, + ); } @override - Future updateNode() async { - try { - await connect(disconnectFirst: true); - } catch (e, s) { - Logging.instance.e( - "Error rethrown from $runtimeType updateNode()", - error: e, - stackTrace: s, - ); - rethrow; - } - } + Future updateNode() => connect(); @override Future> updateTransactions({ bool isRescan = false, List? objTransactions, int? topoheight, - }) async { - checkInitialized(); - - final newReceivingAddress = - await getCurrentReceivingAddress() ?? - Address( - walletId: walletId, - derivationIndex: 0, - derivationPath: null, - value: libXelis.getAddress(wallet!), - publicKey: [], - type: AddressType.xelis, - subType: AddressSubType.receiving, - ); - - final thisAddress = newReceivingAddress.value; - - int firstBlock = 0; - if (!isRescan) { - firstBlock = - await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .heightProperty() - .max() ?? - 0; - - if (firstBlock > 10) { - // add some buffer - firstBlock -= 10; + }) => _historyMutex.protect(() async { + final handle = wallet; + final generation = sessionGeneration; + if (handle == null || exitInProgress) return []; + final entries = objTransactions ?? await xelis.allHistory(handle); + if (!isCurrentSession(handle, generation)) return []; + final address = xelis.getAddress(handle); + final transactions = entries + .map((tx) => _project(tx, address)) + .whereType() + .toList(); + await mainDB.isar.writeTxn(() async { + if (!isCurrentSession(handle, generation)) return; + final stored = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .findAll(); + final byHash = {for (final tx in stored) tx.txid: tx}; + for (final tx in transactions) { + final existing = byHash.remove(tx.txid); + if (existing != null) tx.id = existing.id; } - } else { - await libXelis.rescan(wallet!, topoheight: BigInt.from(pruningHeight)); - } - - final txList = objTransactions ?? (await libXelis.allHistory(wallet!)); - - final List txns = []; - - for (final transactionEntry in txList) { - try { - // Check for duplicates - final storedTx = await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(transactionEntry.hash, walletId) - .findFirst(); - - if (storedTx != null && - storedTx.height != null && - storedTx.height! > 0) { - continue; // Skip already processed transactions - } - - final List outputs = []; - final List inputs = []; - TransactionType? txType; - const TransactionSubType txSubType = TransactionSubType.none; - int? nonce; - Amount fee = Amount( - rawValue: BigInt.zero, - fractionDigits: cryptoCurrency.fractionDigits, - ); - final Map otherData = {}; - - final entryType = transactionEntry.entryType; - - if (entryType is CoinbaseEntryWrapper) { - final coinbase = entryType; - txType = TransactionType.incoming; - - final int decimals = await libXelis.getAssetDecimals( - wallet!, - asset: libXelis.xelisAsset, - ); - - fee = Amount( - rawValue: BigInt.zero, - fractionDigits: cryptoCurrency.fractionDigits, - ); - - outputs.add( - OutputV2.isarCantDoRequiredInDefaultConstructor( - scriptPubKeyHex: "", - valueStringSats: coinbase.reward.toString(), - addresses: [thisAddress], - walletOwns: true, - ), - ); - otherData['overrideFee'] = fee.toJsonString(); - } else if (entryType is BurnEntryWrapper) { - final burn = entryType; - txType = TransactionType.outgoing; - - final int decimals = await libXelis.getAssetDecimals( - wallet!, - asset: burn.asset, - ); - - fee = Amount( - rawValue: BigInt.from(burn.fee), - fractionDigits: cryptoCurrency.fractionDigits, - ); - - inputs.add( - InputV2.isarCantDoRequiredInDefaultConstructor( - scriptSigAsm: null, - scriptSigHex: null, - sequence: null, - outpoint: null, - valueStringSats: burn.amount.toString(), - addresses: [thisAddress], - witness: null, - innerRedeemScriptAsm: null, - coinbase: null, - walletOwns: true, - ), - ); - - outputs.add( - OutputV2.isarCantDoRequiredInDefaultConstructor( - scriptPubKeyHex: "", - valueStringSats: burn.amount.toString(), - addresses: ['burn'], - walletOwns: false, - ), - ); - - otherData['burnAsset'] = burn.asset; - } else if (entryType is IncomingEntryWrapper) { - final incoming = entryType; - txType = incoming.from == thisAddress - ? TransactionType.sentToSelf - : TransactionType.incoming; - - for (final transfer in incoming.transfers) { - final int decimals = await libXelis.getAssetDecimals( - wallet!, - asset: transfer.asset, - ); - - fee = Amount( - rawValue: BigInt.zero, - fractionDigits: cryptoCurrency.fractionDigits, - ); - - outputs.add( - OutputV2.isarCantDoRequiredInDefaultConstructor( - scriptPubKeyHex: "", - valueStringSats: transfer.amount.toString(), - addresses: [thisAddress], - walletOwns: true, - ), - ); - - otherData['asset_${transfer.asset}'] = transfer.amount.toString(); - if (transfer.extraData != null) { - otherData['extraData_${transfer.asset}'] = transfer.extraData!; - } - otherData['overrideFee'] = fee.toJsonString(); - } - } else if (entryType is OutgoingEntryWrapper) { - final outgoing = entryType; - txType = TransactionType.outgoing; - nonce = outgoing.nonce; - - fee = Amount( - rawValue: BigInt.from(outgoing.fee), - fractionDigits: cryptoCurrency.fractionDigits, - ); - - inputs.add( - InputV2.isarCantDoRequiredInDefaultConstructor( - scriptSigHex: null, - scriptSigAsm: null, - sequence: null, - outpoint: null, - addresses: [thisAddress], - valueStringSats: (outgoing.fee).toString(), - witness: null, - innerRedeemScriptAsm: null, - coinbase: null, - walletOwns: true, - ), - ); - - for (final transfer in outgoing.transfers) { - inputs.add( - InputV2.isarCantDoRequiredInDefaultConstructor( - scriptSigHex: null, - scriptSigAsm: null, - sequence: null, - outpoint: null, - addresses: [thisAddress], - valueStringSats: (transfer.amount).toString(), - witness: null, - innerRedeemScriptAsm: null, - coinbase: null, - walletOwns: true, - ), - ); - - outputs.add( - OutputV2.isarCantDoRequiredInDefaultConstructor( - scriptPubKeyHex: "", - valueStringSats: transfer.amount.toString(), - addresses: [transfer.destination], - walletOwns: false, - ), - ); - - otherData['asset_${transfer.asset}_amount'] = transfer.amount - .toString(); - if (transfer.extraData != null) { - otherData['extraData_${transfer.asset}'] = transfer.extraData!; - } - } - } else { - // Skip unknown entry types - continue; - } - - final txn = TransactionV2( - walletId: walletId, - blockHash: "", // Not provided in Xelis data - hash: transactionEntry.hash, - txid: transactionEntry.hash, - timestamp: - (transactionEntry.timestamp?.millisecondsSinceEpoch ?? 0) ~/ 1000, - height: transactionEntry.topoheight, - inputs: List.unmodifiable(inputs), - outputs: List.unmodifiable(outputs), - version: -1, // Version not provided - type: txType, - subType: txSubType, - otherData: jsonEncode({ - ...otherData, - if (nonce != null) 'nonce': nonce, - }), - ); - - // Logging.instance.log( - // "Entry done ${entryType.toString()}", - // level: LogLevel.Debug, - // ); - - txns.add(txn); - } catch (e, s) { - Logging.instance.w( - "Error in $runtimeType handling transaction: $transactionEntry", - error: e, - stackTrace: s, + await mainDB.isar.transactionV2s.putAll(transactions); + if (objTransactions == null || isRescan) { + // Reconcile disappeared pending entries and reorganized confirmations. + // Keep addresses and user notes; their lifetime is not chain-dependent. + await mainDB.isar.transactionV2s.deleteAll( + byHash.values.map((tx) => tx.id).toList(), ); } - } - await updateBalance(); - - await mainDB.updateOrPutTransactionV2s(txns); - return txns.map((e) => e.txid).toList(); - } - + }); + return transactions.map((tx) => tx.txid).toList(); + }); + + TransactionV2? _project(TransactionEntryWrapper tx, String ownAddress) => + projectXelisTransaction( + tx: tx, + ownAddress: ownAddress, + walletId: walletId, + xelisAsset: xelis.xelisAsset, + fractionDigits: cryptoCurrency.fractionDigits, + ); @override - Future updateUTXOs() async { - // not used in xel - return false; - } - + Future updateUTXOs() async => false; @override - Future checkSaveInitialReceivingAddress() async { - // do nothing - } - + Future checkSaveInitialReceivingAddress() async {} @override - FilterOperation? get changeAddressFilterOperation => - throw UnimplementedError("Not used for $runtimeType"); - + FilterOperation? get changeAddressFilterOperation => null; @override FilterOperation? get receivingAddressFilterOperation => FilterGroup.and(standardReceivingAddressFilters); @override - Future get fees async { - // TODO: implement _getFees... maybe - return FeeObject( - numberOfBlocksFast: 10, - numberOfBlocksAverage: 10, - numberOfBlocksSlow: 10, - fast: BigInt.one, - medium: BigInt.one, - slow: BigInt.one, - ); - } - - @override - Future prepareSend({required TxData txData, String? assetId}) async { - try { - checkInitialized(); - - final recipients = txData.recipients?.isNotEmpty == true - ? txData.recipients! - : throw ArgumentError( - 'Address cannot be empty.', - ); // in the future, support for multiple recipients will work. - - // but for now, no - // Validate recipients - if (recipients.length != 1) { - throw Exception("$runtimeType confirmSend requires 1 recipient"); - } - - final asset = assetId ?? libXelis.xelisAsset; - - // Calculate total send amount - final totalSendAmount = recipients.first.amount; - // final totalSendAmount = recipients.fold( - // Amount( - // rawValue: BigInt.zero, - // fractionDigits: cryptoCurrency.fractionDigits, - // ), - // (sum, recipient) => sum + recipient.amount, - // ); - - // Check balance using raw method - final xelBalance = await libXelis.getXelisBalanceRaw(wallet!); - final balance = Amount( - rawValue: xelBalance, - fractionDigits: cryptoCurrency.fractionDigits, - ); - - // Estimate fee using the shared method - final boostedFee = await estimateFeeFor( - totalSendAmount, - BigInt.one, - feeMultiplier: 1.0, - recipients: recipients, - assetId: asset, - ); - - final isSendAll = xelBalance == totalSendAmount.raw; - if (isSendAll) { - txData = txData.copyWith( - recipients: [ - TxRecipient( - address: recipients.first.address, - amount: recipients.first.amount - boostedFee, - isChange: recipients.first.isChange, - addressType: recipients.first.addressType, - ), - ], - ); - } else { - // Check if we have enough for both transfers and fee - if (totalSendAmount + boostedFee > balance) { - final requiredAmt = await libXelis.formatCoin( - wallet!, - atomicAmount: (totalSendAmount + boostedFee).raw, - assetHash: asset, - ); - - final availableAmt = await libXelis.formatCoin( - wallet!, - atomicAmount: xelBalance, - assetHash: asset, - ); - - throw Exception( - "Insufficient balance to cover transfers and fees. " - "Required: $requiredAmt, Available: $availableAmt", - ); - } - } - - return txData.copyWith( - fee: boostedFee, - otherData: jsonEncode({'asset': asset}), - ); - } catch (_) { - // Logging.instance.log( - // "Exception rethrown from prepareSend(): $e\n$s", - // level: LogLevel.Error, - // ); - rethrow; - } - } + Future get fees async => FeeObject( + numberOfBlocksFast: 1, + numberOfBlocksAverage: 1, + numberOfBlocksSlow: 1, + fast: BigInt.one, + medium: BigInt.one, + slow: BigInt.one, + ); @override Future estimateFeeFor( @@ -727,312 +360,255 @@ class XelisWallet extends LibXelisWallet { List recipients = const [], String? assetId, }) async { - try { - checkInitialized(); - final asset = assetId ?? libXelis.xelisAsset; - - // Default values for a new wallet or when estimation fails - final defaultDecimals = cryptoCurrency.fractionDigits; - final defaultFee = BigInt.from(0); - - // Use default address if recipients list is empty to ensure basic fee estimates are readily available - final effectiveRecipients = recipients.isNotEmpty - ? recipients - : [ - TxRecipient( - address: 'xel:xz9574c80c4xegnvurazpmxhw5dlg2n0g9qm60uwgt75uqyx3pcsqzzra9m', - amount: amount, - isChange: false, - addressType: AddressType.xelis, - ), - ]; - - try { - final transfers = await Future.wait( - effectiveRecipients.map((recipient) async { - try { - final amt = double.parse( - await libXelis.formatCoin( - wallet!, - atomicAmount: recipient.amount.raw, - assetHash: asset, - ), - ); - return WrappedTransfer( - floatAmount: amt, - strAddress: recipient.address, - assetHash: asset, - extraData: null, - ); - } catch (e, s) { - // Handle formatCoin error - use default conversion - Logging.instance.d( - "formatCoin failed, using fallback conversion", - error: e, - stackTrace: s, - ); - final rawAmount = recipient.amount.raw; - final floatAmount = - rawAmount / BigInt.from(10).pow(defaultDecimals); - return WrappedTransfer( - floatAmount: floatAmount.toDouble(), - strAddress: recipient.address, - assetHash: asset, - extraData: null, - ); - } - }), - ); - - final decimals = await libXelis.getAssetDecimals(wallet!, asset: asset); - final estimatedFee = double.parse( - await libXelis.estimateFees(wallet!, transfers: transfers), - ); - final rawFee = (estimatedFee * pow(10, decimals)).round(); - return Amount( - rawValue: BigInt.from(rawFee), - fractionDigits: cryptoCurrency.fractionDigits, - ); - } catch (e, s) { - Logging.instance.d( - "Fee estimation failed. Using fallback fee: $defaultFee", - error: e, - stackTrace: s, - ); - return Amount( - rawValue: defaultFee, - fractionDigits: cryptoCurrency.fractionDigits, - ); - } - } catch (_) { - // Logging.instance.log( - // "Exception rethrown from estimateFeeFor(): $e\n$s", - // level: LogLevel.Error, - // ); - rethrow; + checkInitialized(); + if (assetId != null && assetId != xelis.xelisAsset) { + throw UnsupportedError('Stack sends XEL only'); } + if (recipients.length != 1) { + throw StateError('Enter a Xelis recipient to estimate fees'); + } + final fee = await xelis.estimateFees( + wallet!, + transfers: [ + XelisTransfer( + destination: recipients.single.address, + amountAtomic: amount.raw, + asset: xelis.xelisAsset, + ), + ], + ); + return _amount(fee); } @override - Future confirmSend({required TxData txData}) async { - try { - checkInitialized(); - - // Validate recipients - if (txData.recipients == null || txData.recipients!.length != 1) { - throw Exception("$runtimeType confirmSend requires 1 recipient"); + Future prepareSend({required TxData txData, String? assetId}) async { + final request = ++_prepareRequest; + checkInitialized(); + final handle = wallet!; + final generation = sessionGeneration; + return _sendMutex.protect(() async { + bool current() => + request == _prepareRequest && isCurrentSession(handle, generation); + if (!current()) throw StateError('Xelis preparation was superseded'); + final previous = _prepared; + _prepared = null; + if (previous != null) { + await xelis.discardPreparedTransaction(handle, transaction: previous); } - - final recipient = txData.recipients!.first; - final Amount sendAmount = recipient.amount; - - final asset = - (txData.otherData != null - ? jsonDecode(txData.otherData!) - : null)?['asset'] - as String? ?? - libXelis.xelisAsset; - - final amt = double.parse( - await libXelis.formatCoin( - wallet!, - atomicAmount: sendAmount.raw, - assetHash: asset, - ), - ); - - // Create a transfer transaction - final txJson = await libXelis.createTransfersTransaction( - wallet!, - transfers: [ - WrappedTransfer( - floatAmount: amt, - strAddress: recipient.address, - assetHash: asset, - extraData: null, // Add extra data if needed + if (!current()) throw StateError('Xelis session was changed'); + if (assetId != null && assetId != xelis.xelisAsset) { + throw UnsupportedError('Stack sends XEL only'); + } + final recipients = txData.recipients; + if (recipients == null || recipients.length != 1) { + throw ArgumentError('Xelis requires one recipient'); + } + final recipient = recipients.single; + if (!xelis.isAddressValid( + address: recipient.address, + network: cryptoCurrency.network, + )) { + throw ArgumentError('Invalid Xelis destination'); + } + if (!txData.xelisSendAll && recipient.amount.raw <= BigInt.zero) { + throw ArgumentError('Xelis amount must be positive'); + } + final prepared = txData.xelisSendAll + ? await xelis.prepareTransferAll( + handle, + destination: recipient.address, + ) + : await xelis.prepareTransfers( + handle, + transfers: [ + XelisTransfer( + destination: recipient.address, + amountAtomic: recipient.amount.raw, + asset: xelis.xelisAsset, + ), + ], + ); + if (!current()) { + await xelis.discardPreparedTransaction(handle, transaction: prepared); + throw StateError('Xelis preparation was superseded'); + } + _prepared = prepared; + _preparedHandle = handle; + _preparedGeneration = generation; + _reviewedDestination = recipient.address; + return txData.copyWith( + xelisPreparedTransaction: prepared, + fee: _amount(prepared.feeAtomic), + recipients: [ + TxRecipient( + address: recipient.address, + amount: _amount(prepared.transfers.single.amountAtomic), + isChange: false, + addressType: AddressType.xelis, ), ], ); - - final txMap = jsonDecode(txJson); - final txHash = txMap['hash'] as String; - - // Broadcast the transaction - await libXelis.broadcastTransaction(wallet!, txHash: txHash); - - return await updateSentCachedTxData( - txData: txData.copyWith(txid: txHash), - ); - } catch (_) { - // Logging.instance.log( - // "Exception rethrown from confirmSend(): $e\n$s", - // level: LogLevel.Error, - // ); - rethrow; - } + }); } @override - Future handleEvent(Event event) async { - try { - switch (event) { - case NewTopoheight(:final height): - await handleNewTopoHeight(height); - case NewAsset(): - await handleNewAsset(event); - case NewTransaction(:final transaction): - await handleNewTransaction(transaction); - case BalanceChanged(): - await handleBalanceChanged(event); - case Rescan(:final startTopoheight): - await handleRescan(startTopoheight); - case Online(): - await handleOnline(); - case Offline(): - await handleOffline(); - case HistorySynced(:final topoheight): - await handleHistorySynced(topoheight); + Future cancelSend({required TxData txData}) async { + final prepared = txData.xelisPreparedTransaction; + if (prepared == null || !identical(prepared, _prepared)) return; + ++_prepareRequest; + await _sendMutex.protect(() async { + if (!identical(prepared, _prepared)) return; + _prepared = null; + final handle = wallet; + if (handle != null && !exitInProgress) { + await xelis.discardPreparedTransaction(handle, transaction: prepared); } - } catch (e, s) { - Logging.instance.e( - "Error in $runtimeType handleEvent($event)", - error: e, - stackTrace: s, - ); - } - } - - @override - Future handleNewTopoHeight(int _) async => - eventBatcher.queueTopoheightChanged(); - - @override - Future handleNewTransaction(TransactionEntryWrapper tx) async => - eventBatcher.queueTransaction(tx); - - @override - Future handleBalanceChanged(BalanceChanged event) async { - if (event.asset == libXelis.xelisAsset) { - eventBatcher.queueBalanceChanged(); - } + }); } @override - Future applyXelisEventBatch( - XelisEventBatch batch, - ) async { - try { - if (batch.topoheightChanged) { - await updateChainHeight(rethrowErrors: true); - } - - if (batch.transactions.isNotEmpty) { - await updateTransactions( - isRescan: false, - objTransactions: batch.transactions, - ); + Future confirmSend({required TxData txData}) async { + final handle = wallet; + final generation = sessionGeneration; + final prepared = txData.xelisPreparedTransaction; + return _sendMutex.protect(() async { + if (handle == null || + prepared == null || + !isCurrentSession(handle, generation) || + !identical(handle, _preparedHandle) || + generation != _preparedGeneration || + !identical(_prepared, prepared)) { + throw StateError('Review a new Xelis transaction before sending'); } - - if (batch.balanceChanged || batch.transactions.isNotEmpty) { - await updateBalance(rethrowErrors: true); + final recipients = txData.recipients; + if (recipients == null || + recipients.length != 1 || + recipients.single.address != _reviewedDestination || + recipients.single.amount.raw != + prepared.transfers.single.amountAtomic || + txData.fee?.raw != prepared.feeAtomic) { + throw StateError('Xelis transaction differs from its reviewed values'); } - } catch (e, s) { - Logging.instance.e( - "Error in $runtimeType applyXelisEventBatch()", - error: e, - stackTrace: s, + final outcome = await xelis.broadcastTransaction( + handle, + transaction: prepared, ); - unawaited(refresh()); - } + if (outcome.disposition != XelisBroadcastDisposition.retryable) { + _prepared = null; + } + if (!outcome.wasSubmitted) { + throw outcome.failure ?? StateError('Xelis submission failed'); + } + if (outcome.failure != null) { + Logging.instance.w( + 'Xelis submitted; reconciliation required', + error: outcome.failure, + ); + } + if (isCurrentSession(handle, generation)) unawaited(refresh()); + return txData.copyWith(txid: prepared.hash); + }); } @override - Future handleRescan(int startTopoheight) async { - await runXelisRescan(() async { - await mainDB.deleteWalletBlockchainData(walletId); - await updateTransactions(isRescan: true, topoheight: startTopoheight); - await updateBalance(); + Future drainSessionOperations() async { + ++_prepareRequest; + // init owns cleanup of a partially opened handle. Do not close it twice. + try { + await _initializing; + } catch (_) { + // The caller of init receives the original failure. + } + await _sendMutex.protect(() async { + _prepared = null; + _reviewedDestination = null; }); + await _balanceMutex.protect(() async {}); + await _historyMutex.protect(() async {}); + await _rescanMutex.protect(() async {}); } @override - Future handleOnline() => runXelisSyncEvent(); - - @override - Future handleOffline() async { - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.unableToSync, - walletId, - info.coin, - ), - ); + Future handleEvent( + Event event, { + required bool Function() isCurrent, + }) async { + if (!isCurrent()) return; + switch (event) { + case Online(): + _status(WalletSyncStatus.syncing); + case Offline(): + _status(WalletSyncStatus.unableToSync); + scheduleReconnect(); + case NewTopoheight(): + // Ordinary blocks do not emit HistorySynced. Read the daemon height + // after each completed block sync so confirmations keep progressing. + await updateChainHeight(); + case HistorySynced(): + await refresh(); + case Rescan(): + _status(WalletSyncStatus.syncing); + await refreshMutex.protect(() async { + if (!isCurrent()) return; + await updateTransactions(isRescan: true); + }); + case NewTransaction(): + await updateTransactions(); + if (isCurrent()) await updateBalance(); + case BalanceChanged(:final asset): + if (asset == xelis.xelisAsset) await updateBalance(); + case NewAsset() || XelisStateInvalidated(): + await refresh(); + case XelisSyncIssue(:final failure): + Logging.instance.w('Xelis sync issue', error: failure); + case XelisChannelClosed(:final failure, :final isRuntime): + Logging.instance.w('Xelis event channel closed', error: failure); + _status(WalletSyncStatus.unableToSync); + recoverEventChannel(isRuntime: isRuntime); + } } @override - Future handleHistorySynced(int _) => runXelisSyncEvent(); - + Future handleNewTopoHeight(BigInt height) => updateChainHeight(); @override - Future handleNewAsset(NewAsset asset) async { - // TODO: Store asset information if needed - // TODO: Update UI/state for new asset - Logging.instance.d("New xelis asset detected: $asset"); + Future handleNewTransaction(TransactionEntryWrapper tx) async { + await updateTransactions(objTransactions: [tx]); } @override - Future performXelisRefresh() async { - try { - final bool online = await libXelis.isOnline(wallet!); - if (online == true) { - if (!doNotFireRefreshEvents) { - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.syncing, - walletId, - info.coin, - ), - ); - } + Future handleBalanceChanged(BalanceChanged event) => updateBalance(); - await updateChainHeight(rethrowErrors: true); - await updateBalance(rethrowErrors: true); - await updateTransactions(); - - if (!doNotFireRefreshEvents) { - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.synced, - walletId, - info.coin, - ), - ); + @override + Future refresh({int? topoheight}) => + _operationCoordinator.refresh(() async { + final handle = wallet; + final generation = sessionGeneration; + if (handle == null || exitInProgress) return; + try { + await updateTransactions(); + if (!isCurrentSession(handle, generation)) return; + await updateBalance(); + if (!isCurrentSession(handle, generation)) return; + if (await xelis.isOnline(handle)) { + if (!isCurrentSession(handle, generation)) return; + await updateChainHeight(topoheight: topoheight); + if (!isCurrentSession(handle, generation)) return; + final syncing = await xelis.isSyncing(handle); + if (isCurrentSession(handle, generation)) { + _status( + syncing ? WalletSyncStatus.syncing : WalletSyncStatus.synced, + ); + } + } + } catch (error, stack) { + if (isCurrentSession(handle, generation)) { + Logging.instance.e( + 'Xelis refresh failed', + error: error, + stackTrace: stack, + ); + _status(WalletSyncStatus.unableToSync); + } } - ensurePeriodicRefreshTimer(); - } else if (!doNotFireRefreshEvents) { - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.unableToSync, - walletId, - info.coin, - ), - ); - } - } catch (e, s) { - if (!doNotFireRefreshEvents) { - GlobalEventBus.instance.fire( - WalletSyncStatusChangedEvent( - WalletSyncStatus.unableToSync, - walletId, - info.coin, - ), - ); - } - Logging.instance.e( - "Error in $runtimeType performXelisRefresh()", - error: e, - stackTrace: s, - ); - rethrow; - } - } + }); } diff --git a/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart b/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart index 4938bea2b3..90f04de0e1 100644 --- a/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart @@ -8,25 +8,29 @@ import 'package:mutex/mutex.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/stack_file_system.dart'; +import '../../../utilities/xelis_storage.dart'; import '../../../wl_gen/interfaces/lib_xelis_interface.dart'; import '../../crypto_currency/intermediate/electrum_currency.dart'; import '../wallet_mixin_interfaces/mnemonic_interface.dart'; import 'external_wallet.dart'; import 'xelis_event_batcher.dart'; -import 'xelis_operation_coordinator.dart'; abstract class LibXelisWallet extends ExternalWallet with MnemonicInterface { - LibXelisWallet(super.currency); + LibXelisWallet(super.currency, {LibXelisInterface? native}) + : _native = native; + + final LibXelisInterface? _native; + LibXelisInterface get xelis => _native ?? libXelis; static const String _kHasFullTablesKey = 'xelis_has_full_tables'; static const String _kGeneratingTablesKey = 'xelis_generating_tables'; static const String _kWantsFullTablesKey = 'xelis_wants_full_tables'; static final _tableGenerationMutex = Mutex(); - static Completer? _tableGenerationCompleter; + static Future? _tableGenerationFuture; - int pruningHeight = 0; + BigInt pruningHeight = BigInt.zero; OpaqueXelisWallet? wallet; @@ -36,22 +40,24 @@ abstract class LibXelisWallet } } - final syncMutex = Mutex(); Timer? timer; - StreamSubscription? _eventSubscription; - late final XelisOperationCoordinator _operationCoordinator = - XelisOperationCoordinator(refreshMutex); - - static const _eventFlushInterval = Duration(milliseconds: 500); - - @protected - late final XelisEventBatcher eventBatcher = - XelisEventBatcher( - flushInterval: _eventFlushInterval, - flush: (batch) => - runXelisEventUpdate(() => applyXelisEventBatch(batch)), - ); + final _connectionMutex = Mutex(); + XelisEventSubscription? _runtimeEvents; + XelisEventSubscription? _businessEvents; + bool _businessEventsFailed = false; + Future _eventWork = Future.value(); + XelisEventBatcher? _businessBatcher; + XelisEventBatcher? _runtimeBatcher; + Timer? _reconnectTimer; + int _connectionGeneration = 0; + int sessionGeneration = 0; + int _retryAttempt = 0; + + bool isCurrentSession(OpaqueXelisWallet handle, int generation) => + !exitInProgress && + identical(wallet, handle) && + sessionGeneration == generation; Future getPrecomputedTablesPath() async { if (kIsWeb) { @@ -63,10 +69,11 @@ abstract class LibXelisWallet } Future getTableState() async { - final hasFullTables = - await secureStorageInterface.read(key: _kHasFullTablesKey) == 'true'; - final isGenerating = - await secureStorageInterface.read(key: _kGeneratingTablesKey) == 'true'; + final hasFullTables = await xelis.hasTables( + precomputedTablesPath: await getPrecomputedTablesPath(), + stack_l1Low: false, + ); + final isGenerating = _tableGenerationFuture != null; final wantsFull = await secureStorageInterface.read(key: _kWantsFullTablesKey) != 'false'; @@ -92,79 +99,212 @@ abstract class LibXelisWallet ); } - Future handleEvent(Event event) async {} - Future handleNewTopoHeight(int height); + Future handleEvent(Event event, {required bool Function() isCurrent}); + Future handleNewTopoHeight(BigInt height); Future handleNewTransaction(TransactionEntryWrapper tx); Future handleBalanceChanged(BalanceChanged event); - Future handleRescan(int startTopoheight) async {} + Future handleRescan(BigInt startTopoheight) async {} Future handleOnline() async {} Future handleOffline() async {} - Future handleHistorySynced(int topoheight) async {} + Future handleHistorySynced(BigInt topoheight) async {} Future handleNewAsset(NewAsset asset) async {} - @protected - Future performXelisRefresh(); - - @protected - Future applyXelisEventBatch( - XelisEventBatch batch, - ); - - @protected - Future runXelisRescan(Future Function() operation) { - eventBatcher.reset(); - return _operationCoordinator.rescan(operation); - } - - @protected - Future runXelisEventUpdate(Future Function() operation) => - _operationCoordinator.processEvent(operation); - - @protected - Future runXelisSyncEvent() => - _operationCoordinator.processSyncEvent(performXelisRefresh); - - // Intentionally swallow logged errors because refresh is often unawaited. @override - Future refresh() => - _operationCoordinator.refresh(performXelisRefresh).catchError((_) {}); - - Future connect({bool disconnectFirst = false}) => - _operationCoordinator.connect(() async { - final node = getCurrentNode(); - try { + Future refresh({int? topoheight}); + + Future connect() async { + _reconnectTimer?.cancel(); + _businessBatcher?.reset(); + _runtimeBatcher?.reset(); + final requestedGeneration = ++_connectionGeneration; + await _connectionMutex + .protect(() async { + // Drain the old connection before starting its replacement. + // The generation invalidates the old connection's callbacks. + await _eventWork; checkInitialized(); - - final wasOnline = await libXelis.isOnline(wallet!); - await _eventSubscription?.cancel(); - _eventSubscription = null; - - if (wasOnline && disconnectFirst) { - await libXelis.offlineMode(wallet!); + final handle = wallet!; + final session = sessionGeneration; + bool current() => + isCurrentSession(handle, session) && + requestedGeneration == _connectionGeneration; + if (!current()) return; + await _runtimeEvents?.cancel(); + _runtimeEvents = null; + if (!current()) return; + await xelis.offlineMode(handle); + if (!current()) return; + if (_businessEventsFailed) { + final failed = _businessEvents; + _businessEvents = null; + await failed?.cancel(); + if (!current()) return; } - - _eventSubscription = libXelis.eventsStream(wallet!).listen((event) { - unawaited(handleEvent(event)); - }); - - if (!wasOnline || disconnectFirst) { - Logging.instance.i("Connecting to node: ${node.host}:${node.port}"); - await libXelis.onlineMode( - wallet!, - daemonAddress: "${node.host}:${node.port}", + if (_businessEvents == null) { + final events = await xelis.subscribeBusinessEvents(handle); + if (!current()) { + await events.cancel(); + return; + } + _businessEvents = events; + _businessEventsFailed = false; + _listen( + events, + () => + isCurrentSession(handle, session) && + identical(_businessEvents, events), + isRuntime: false, ); } - - await performXelisRefresh(); - } catch (e, s) { - Logging.instance.e( - "rethrowing error connecting to node: $node", - error: e, - stackTrace: s, + final events = await xelis.subscribeRuntimeEvents(handle); + if (!current()) { + await events.cancel(); + return; + } + _runtimeEvents = events; + _listen(events, current, isRuntime: true); + final node = getCurrentNode(); + await xelis.onlineMode( + handle, + daemonAddress: xelisDaemonOrigin( + host: node.host, + port: node.port, + useSSL: node.useSSL, + ), ); - rethrow; + if (current()) { + _retryAttempt = 0; + unawaited(refresh()); + } + }) + .catchError((Object error, StackTrace stack) { + // Subscriptions and offline transitions can fail before onlineMode. + // Only the latest connection may schedule recovery for this session. + if (requestedGeneration == _connectionGeneration && + !exitInProgress && + wallet != null) { + scheduleReconnect(); + } + Error.throwWithStackTrace(error, stack); + }); + } + + void _listen( + XelisEventSubscription events, + bool Function() current, { + required bool isRuntime, + }) { + Future enqueue(Event event) { + return _eventWork = _eventWork + .then((_) async { + if (current()) await handleEvent(event, isCurrent: current); + }) + .catchError((Object error, StackTrace stack) { + if (current()) { + Logging.instance.e( + 'Xelis event handling failed', + error: error, + stackTrace: stack, + ); + unawaited(refresh()); + } + }); + } + + // XWF reads authoritative snapshots. Coalesce bursts before adding work + // to the serialized queue, preserving staging's bounded refresh cadence. + NewTopoheight? latestHeight; + BalanceChanged? latestBalance; + final batcher = XelisEventBatcher( + flushInterval: const Duration(milliseconds: 500), + flush: (batch) async { + final height = latestHeight; + final balance = latestBalance; + latestHeight = null; + latestBalance = null; + if (!current()) return; + if (batch.topoheightChanged && height != null) { + await enqueue(height); + } + if (batch.transactions.isNotEmpty) { + await enqueue(batch.transactions.last); + } else if (batch.balanceChanged && balance != null) { + await enqueue(balance); } - }, joinExisting: !disconnectFirst); + }, + ); + if (isRuntime) { + _runtimeBatcher = batcher; + } else { + _businessBatcher = batcher; + } + events.events.listen( + (event) { + if (!current()) return; + switch (event) { + case NewTopoheight(): + latestHeight = event; + batcher.queueTopoheightChanged(); + return; + case NewTransaction(): + batcher.queueTransaction(event); + return; + case BalanceChanged(:final asset): + if (asset == xelis.xelisAsset) { + latestBalance = event; + batcher.queueBalanceChanged(); + } + return; + case Rescan() || HistorySynced() || XelisStateInvalidated(): + _runtimeBatcher?.reset(); + _businessBatcher?.reset(); + default: + break; + } + unawaited(enqueue(event)); + }, + onError: (Object error, StackTrace stack) { + if (!current()) return; + Logging.instance.e( + 'Xelis event stream failed', + error: error, + stackTrace: stack, + ); + recoverEventChannel(isRuntime: isRuntime); + }, + onDone: () { + if (!current()) return; + recoverEventChannel(isRuntime: isRuntime); + }, + ); + } + + void recoverEventChannel({required bool isRuntime}) { + if (!isRuntime) _businessEventsFailed = true; + scheduleReconnect(); + } + + void scheduleReconnect() { + if (exitInProgress || wallet == null || _reconnectTimer?.isActive == true) { + return; + } + final handle = wallet!; + final session = sessionGeneration; + final seconds = 1 << (_retryAttempt++).clamp(0, 5); + _reconnectTimer = Timer(Duration(seconds: seconds), () async { + if (!isCurrentSession(handle, session)) return; + try { + await connect(); + } catch (error, stack) { + Logging.instance.e( + 'Xelis reconnect failed', + error: error, + stackTrace: stack, + ); + if (isCurrentSession(handle, session)) scheduleReconnect(); + } + }); + } List get standardReceivingAddressFilters => [ FilterCondition.equalTo(property: r"type", value: info.mainAddressType), @@ -184,30 +324,88 @@ abstract class LibXelisWallet static Future checkWalletExists(String walletId) async { final xelisDir = await StackFileSystem.applicationXelisDirectory(); - final walletDir = Directory( - "${xelisDir.path}${Platform.pathSeparator}$walletId", - ); - // TODO: should we check for certain files within the dir? - return await walletDir.exists(); + // Opening must reject the same aliases and unexpected files as deletion. + return await xelisWalletDirectory(xelisDir, walletId) != null; } @override - Future open() => connect(); + Future open() async { + while (exitInProgress) { + await Future.delayed(const Duration(milliseconds: 500)); + } - @override - Future exit() => _operationCoordinator.exit(() async { - timer?.cancel(); - timer = null; + try { + await init(); + await connect(); + } catch (e) { + // Logging.instance.log( + // "Failed to start sync: $e", + // level: LogLevel.Error, + // ); + rethrow; + } + unawaited(refresh()); + } - eventBatcher.reset(); - await _eventSubscription?.cancel(); - _eventSubscription = null; + bool exitInProgress = false; - if (wallet != null && await libXelis.isOnline(wallet!)) { - await libXelis.offlineMode(wallet!); + /// Called after session invalidation, before releasing the native handle. + Future drainSessionOperations() async {} + + @override + Future exit() async { + if (exitInProgress) { + while (exitInProgress) { + await Future.delayed(const Duration(milliseconds: 20)); + } + return; } - await super.exit(); - }); + exitInProgress = true; + ++sessionGeneration; + ++_connectionGeneration; + _reconnectTimer?.cancel(); + _businessBatcher?.reset(); + _businessBatcher = null; + _runtimeBatcher?.reset(); + _runtimeBatcher = null; + Object? firstError; + StackTrace? firstStack; + Future attempt(Future Function() operation) async { + try { + await operation(); + } catch (error, stack) { + firstError ??= error; + firstStack ??= stack; + } + } + + try { + await drainSessionOperations(); + await _connectionMutex.protect(() async { + timer?.cancel(); + timer = null; + final runtime = _runtimeEvents; + final business = _businessEvents; + _runtimeEvents = null; + _businessEvents = null; + if (runtime != null) await attempt(runtime.cancel); + if (business != null) await attempt(business.cancel); + }); + await _eventWork; + await refreshMutex.protect(() async { + final handle = wallet; + wallet = null; + if (handle != null) await attempt(() => xelis.closeWallet(handle)); + }); + } finally { + try { + await attempt(() => super.exit()); + } finally { + exitInProgress = false; + } + } + if (firstError != null) Error.throwWithStackTrace(firstError!, firstStack!); + } void invalidSeedLengthCheck(int length) { if (!(length == 25)) { @@ -219,75 +417,28 @@ abstract class LibXelisWallet extension XelisTableManagement on LibXelisWallet { Future isTableUpgradeAvailable() async { if (kIsWeb) return false; - final state = await getTableState(); return state.currentSize != state.desiredSize; } - Future updateTablesToDesiredSize() async { - if (kIsWeb) return; - - await Future.delayed(const Duration(seconds: 1)); - if (LibXelisWallet._tableGenerationCompleter != null) { - try { - await LibXelisWallet._tableGenerationCompleter!.future; - return; - } catch (_) { - // Previous generation failed, we'll try again - } - } - - await LibXelisWallet._tableGenerationMutex.protect(() async { - // Check again after acquiring mutex - if (LibXelisWallet._tableGenerationCompleter != null) { - try { - await LibXelisWallet._tableGenerationCompleter!.future; - return; - } catch (_) { - // Previous generation failed, we'll try again - } - } - + Future updateTablesToDesiredSize() { + if (kIsWeb) return Future.value(); + final running = LibXelisWallet._tableGenerationFuture; + if (running != null) return running; + final operation = LibXelisWallet._tableGenerationMutex.protect(() async { final state = await getTableState(); if (state.currentSize == state.desiredSize) return; - - LibXelisWallet._tableGenerationCompleter = Completer(); - await setTableState(state.copyWith(isGenerating: true)); - - try { - Logging.instance.i("Xelis: Generating large tables in background"); - final tablePath = await getPrecomputedTablesPath(); - await libXelis.updateTables( - precomputedTablesPath: tablePath, - stack_l1Low: state.desiredSize.isLow, - ); - - await setTableState( - XelisTableState( - isGenerating: false, - currentSize: state.desiredSize, - desiredSize: state.desiredSize, - ), - ); - - Logging.instance.i("Xelis: Table upgrade done"); - LibXelisWallet._tableGenerationCompleter!.complete(); - } catch (e) { - // Logging.instance.log( - // "Failed to update tables: $e\n$s", - // level: LogLevel.Error, - // ); - await setTableState(state.copyWith(isGenerating: false)); - - LibXelisWallet._tableGenerationCompleter!.completeError(e); - } finally { - if (!LibXelisWallet._tableGenerationCompleter!.isCompleted) { - LibXelisWallet._tableGenerationCompleter!.completeError( - Exception('Table generation abandoned'), - ); - } - LibXelisWallet._tableGenerationCompleter = null; - } + // Actual table presence is authoritative. Do not overwrite a preference + // changed by another wallet while generation was in flight. + await xelis.updateTables( + precomputedTablesPath: await getPrecomputedTablesPath(), + stack_l1Low: state.desiredSize.isLow, + ); + }); + final shared = operation.whenComplete(() { + LibXelisWallet._tableGenerationFuture = null; }); + LibXelisWallet._tableGenerationFuture = shared; + return shared; } } diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index f89b682902..86a9129257 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -167,6 +167,10 @@ abstract class Wallet { prefs: prefs, ); + if (wallet is XelisWallet) { + wallet.allowNewWallet = mnemonic == null; + } + if (wallet is ViewOnlyOptionInterface && walletInfo.isViewOnly) { await secureStorageInterface.write( key: getViewOnlyWalletDataSecStoreKey(walletId: walletInfo.walletId), @@ -497,6 +501,10 @@ abstract class Wallet { /// reflect updated balance, transactions, utxos, etc. Future confirmSend({required TxData txData}); + /// Releases a preparation when its review is abandoned. Wallets whose + /// preparations own native resources override this lifecycle hook. + Future cancelSend({required TxData txData}) async {} + /// Recover a wallet by scanning the blockchain. If called on a new wallet a /// normal recovery should occur. When called on an existing wallet and /// [isRescan] is false then it should throw. Otherwise this function should diff --git a/lib/wl_gen/interfaces/lib_xelis_interface.dart b/lib/wl_gen/interfaces/lib_xelis_interface.dart index 4674347e3b..6a77ce442c 100644 --- a/lib/wl_gen/interfaces/lib_xelis_interface.dart +++ b/lib/wl_gen/interfaces/lib_xelis_interface.dart @@ -3,8 +3,10 @@ import 'package:flutter/foundation.dart'; import '../../providers/progress_report/xelis_table_progress_provider.dart'; import '../../utilities/dynamic_object.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import 'xelis_types.dart'; export '../generated/lib_xelis_interface_impl.dart'; +export 'xelis_types.dart'; abstract class LibXelisInterface { const LibXelisInterface(); @@ -26,7 +28,14 @@ abstract class LibXelisInterface { bool validateSeedWord(String word); - Stream eventsStream(OpaqueXelisWallet wallet); + Future subscribeRuntimeEvents( + OpaqueXelisWallet wallet, + ); + Future subscribeBusinessEvents( + OpaqueXelisWallet wallet, + ); + + Future closeWallet(OpaqueXelisWallet wallet); Future onlineMode( OpaqueXelisWallet wallet, { @@ -39,6 +48,11 @@ abstract class LibXelisInterface { required bool stack_l1Low, }); + Future hasTables({ + required String precomputedTablesPath, + required bool stack_l1Low, + }); + Future getSeed(OpaqueXelisWallet wallet); Future createXelisWallet( @@ -65,45 +79,57 @@ abstract class LibXelisInterface { String getAddress(OpaqueXelisWallet wallet); - Future getDaemonInfo(OpaqueXelisWallet wallet); + Future getDaemonInfo(OpaqueXelisWallet wallet); Future isOnline(OpaqueXelisWallet wallet); + Future isSyncing(OpaqueXelisWallet wallet); Future rescan(OpaqueXelisWallet wallet, {required BigInt topoheight}); - Future> allHistory(OpaqueXelisWallet wallet); + Future> allHistory( + OpaqueXelisWallet wallet, { + BigInt? minTopoheight, + }); - Future broadcastTransaction( + Future broadcastTransaction( OpaqueXelisWallet wallet, { - required String txHash, + required XelisPreparedTransaction transaction, }); - Future estimateFees( + Future estimateFees( OpaqueXelisWallet wallet, { - required List transfers, + required List transfers, }); - Future createTransfersTransaction( + Future prepareTransfers( OpaqueXelisWallet wallet, { - required List transfers, + required List transfers, }); - Future formatCoin( + Future prepareTransferAll( OpaqueXelisWallet wallet, { - required BigInt atomicAmount, - String? assetHash, + required String destination, }); - Future getAssetDecimals( + Future discardPreparedTransaction( OpaqueXelisWallet wallet, { - required String asset, + required XelisPreparedTransaction transaction, }); Future getXelisBalanceRaw(OpaqueXelisWallet wallet); - Future hasXelisBalance(OpaqueXelisWallet wallet); + Future testDaemonConnection( + String endPoint, + bool useSSL, + CryptoCurrencyNetwork network, + ); +} + +final class XelisEventSubscription { + const XelisEventSubscription({required this.events, required this.cancel}); - Future testDaemonConnection(String endPoint, bool useSSL); + final Stream events; + final Future Function() cancel; } // ============================================================================= @@ -115,37 +141,6 @@ final class OpaqueXelisWallet { T get() => _value as T; } -class WrappedTransfer { - final double floatAmount; - final String strAddress; - final String assetHash; - final String? extraData; - - const WrappedTransfer({ - required this.floatAmount, - required this.strAddress, - required this.assetHash, - this.extraData, - }); - - @override - int get hashCode => - floatAmount.hashCode ^ - strAddress.hashCode ^ - assetHash.hashCode ^ - extraData.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is WrappedTransfer && - runtimeType == other.runtimeType && - floatAmount == other.floatAmount && - strAddress == other.strAddress && - assetHash == other.assetHash && - extraData == other.extraData; -} - class TransactionEntryWrapper { final Object _value; @@ -153,7 +148,7 @@ class TransactionEntryWrapper { final String hash; final DateTime? timestamp; - final int topoheight; + final BigInt? topoheight; TransactionEntryWrapper( this._value, { @@ -175,13 +170,13 @@ sealed class EntryWrapper { } class CoinbaseEntryWrapper extends EntryWrapper { - final int reward; + final BigInt reward; const CoinbaseEntryWrapper({required this.reward}); } class BurnEntryWrapper extends EntryWrapper { - final int amount; - final int fee; + final BigInt amount; + final BigInt fee; final String asset; const BurnEntryWrapper({ @@ -193,19 +188,19 @@ class BurnEntryWrapper extends EntryWrapper { class IncomingEntryWrapper extends EntryWrapper { final String from; - final List<({int amount, String asset, Map? extraData})> + final List<({BigInt amount, String asset, Map? extraData})> transfers; const IncomingEntryWrapper({required this.from, required this.transfers}); } class OutgoingEntryWrapper extends EntryWrapper { - final int nonce; - final int fee; + final BigInt nonce; + final BigInt fee; final List< ({ String destination, - int amount, + BigInt amount, String asset, Map? extraData, }) @@ -221,6 +216,24 @@ class OutgoingEntryWrapper extends EntryWrapper { class UnknownEntryWrapper extends EntryWrapper {} +/// Passive history of non-transfer actions. Only exact XEL movements and fees +/// are projected; this does not enable these actions in Stack's send UI. +class XelisActionEntryWrapper extends EntryWrapper { + const XelisActionEntryWrapper({ + required this.kind, + required this.spent, + required this.received, + required this.fee, + this.nonce, + }); + + final String kind; + final BigInt spent; + final BigInt received; + final BigInt fee; + final BigInt? nonce; +} + // ============================================================================= // ============================================================================= @@ -291,7 +304,7 @@ sealed class Event { } final class NewTopoheight extends Event { - final int height; + final BigInt height; const NewTopoheight(this.height); } @@ -317,13 +330,13 @@ final class NewTransaction extends Event { final class BalanceChanged extends Event { // final xelis_sdk.BalanceChangedEvent event; final String asset; - final int balance; + final BigInt balance; const BalanceChanged(this.asset, this.balance); } final class Rescan extends Event { - final int startTopoheight; + final BigInt startTopoheight; const Rescan(this.startTopoheight); } @@ -337,8 +350,24 @@ final class Offline extends Event { } final class HistorySynced extends Event { - final int topoheight; + final BigInt topoheight; const HistorySynced(this.topoheight); } +final class XelisStateInvalidated extends Event { + const XelisStateInvalidated({this.failure}); + final Object? failure; +} + +final class XelisSyncIssue extends Event { + const XelisSyncIssue(this.failure); + final Object failure; +} + +final class XelisChannelClosed extends Event { + const XelisChannelClosed(this.failure, {required this.isRuntime}); + final Object failure; + final bool isRuntime; +} + // ============================================================================= diff --git a/lib/wl_gen/interfaces/xelis_types.dart b/lib/wl_gen/interfaces/xelis_types.dart new file mode 100644 index 0000000000..4dc1fe6fb4 --- /dev/null +++ b/lib/wl_gen/interfaces/xelis_types.dart @@ -0,0 +1,109 @@ +/// Stack-owned projections. These types keep non-XEL builds independent of the +/// optional native package and keep atomic values exact up to persistence/UI. +final class XelisTransfer { + const XelisTransfer({ + required this.destination, + required this.amountAtomic, + required this.asset, + }); + + final String destination; + final BigInt amountAtomic; + final String asset; +} + +final class XelisPreparedTransfer { + const XelisPreparedTransfer({ + required this.destination, + required this.amountAtomic, + required this.asset, + required this.hasExtraData, + }); + + final String destination; + final BigInt amountAtomic; + final String asset; + final bool hasExtraData; +} + +/// Retained only in memory. The value is the exact authored XWF object, never +/// reconstructed from a hash, serialized TxData or a displayed projection. +final class XelisPreparedTransaction { + XelisPreparedTransaction({ + required Object handle, + required this.hash, + required this.feeAtomic, + required List transfers, + }) : _handle = handle, + transfers = List.unmodifiable(transfers); + + final Object _handle; + final String hash; + final BigInt feeAtomic; + final List transfers; + + T handle() => _handle as T; +} + +enum XelisBroadcastDisposition { + submitted, + retryable, + rejected, + localFailure, + submittedNeedsResync, +} + +final class XelisBroadcastOutcome { + const XelisBroadcastOutcome(this.disposition, {this.failure}); + + final XelisBroadcastDisposition disposition; + + /// The original structured package failure, including its original XWF ID. + final Object? failure; + + bool get wasSubmitted => + disposition == XelisBroadcastDisposition.submitted || + disposition == XelisBroadcastDisposition.submittedNeedsResync; +} + +final class XelisDaemonSnapshot { + const XelisDaemonSnapshot({ + required this.topoheight, + required this.stableTopoheight, + required this.prunedTopoheight, + }); + + final BigInt topoheight; + final BigInt stableTopoheight; + final BigInt? prunedTopoheight; +} + +/// Converts only at Stack's signed-64-bit persistence boundary. BigInt.toInt() +/// can clamp out-of-range values, so it must never be used unchecked here. +int xelisStorageInt(BigInt value) { + if (value < BigInt.zero || value > BigInt.parse('9223372036854775807')) { + throw RangeError('Xelis value does not fit Stack storage'); + } + return value.toInt(); +} + +/// XWF accepts a credential-free origin; it appends its own RPC path. +String xelisDaemonOrigin({ + required String host, + required int port, + required bool useSSL, +}) { + final normalizedHost = host.trim(); + if (normalizedHost.isEmpty || + normalizedHost.contains(RegExp(r'[\s/@?#]')) || + normalizedHost.contains('://') || + port < 1 || + port > 65535) { + throw ArgumentError('Invalid Xelis daemon host or port'); + } + return Uri( + scheme: useSSL ? 'https' : 'http', + host: normalizedHost, + port: port, + ).toString(); +} diff --git a/pubspec.lock b/pubspec.lock index aa34a6911a..14c0a35c96 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2527,14 +2527,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.2" - very_good_analysis: - dependency: transitive - description: - name: very_good_analysis - sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d" - url: "https://pub.dev" - source: hosted - version: "10.3.0" vm_service: dependency: transitive description: @@ -2667,18 +2659,19 @@ packages: xelis_dart_sdk: dependency: "direct main" description: - name: xelis_dart_sdk - sha256: f185d7f81f194979e36c6ec5a2b33b342b4b76d3348446081c9597c0d40d89ec - url: "https://pub.dev" - source: hosted - version: "0.35.1" - xelis_flutter: + path: "." + ref: f1e912cf549d311f6ccbb065b1d1d5c70f42b578 + resolved-ref: f1e912cf549d311f6ccbb065b1d1d5c70f42b578 + url: "https://github.com/xelis-project/xelis-dart-sdk.git" + source: git + version: "0.36.0" + xelis_wallet_flutter: dependency: "direct main" description: path: "." - ref: "07a89303aacbeec6386e357287b6fd9fcca77d94" - resolved-ref: "07a89303aacbeec6386e357287b6fd9fcca77d94" - url: "https://github.com/cypherstack/xelis-flutter-ffi.git" + ref: "69e48e7da2efd69439f045592337b1f7c27a646e" + resolved-ref: "69e48e7da2efd69439f045592337b1f7c27a646e" + url: "https://github.com/xelis-project/xelis-wallet-flutter.git" source: git version: "0.4.0" xml: diff --git a/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj b/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj index d03d3a71c0..6293e27820 100644 --- a/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj +++ b/scripts/app_config/templates/ios/Runner.xcodeproj/project.pbxproj @@ -313,7 +313,6 @@ "${BUILT_PRODUCTS_DIR}/tor_ffi_plugin/tor_ffi_plugin.framework", "${BUILT_PRODUCTS_DIR}/url_launcher_ios/url_launcher_ios.framework", "${BUILT_PRODUCTS_DIR}/wakelock_plus/wakelock_plus.framework", - "${BUILT_PRODUCTS_DIR}/xelis_flutter/xelis_flutter.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( @@ -352,7 +351,6 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/tor_ffi_plugin.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/wakelock_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/xelis_flutter.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 0f50826743..d60fb0ed76 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -32,14 +32,14 @@ dependencies: # %%END_ENABLE_FROST%% # %%ENABLE_XEL%% -# xelis_dart_sdk: 0.35.1 -## git: -## url: https://github.com/xelis-project/xelis-dart-sdk.git -## ref: f1da98f8bad8b9ad3645661a23f9efb83e44b0c9 -# xelis_flutter: +# xelis_dart_sdk: # git: -# url: https://github.com/cypherstack/xelis-flutter-ffi.git -# ref: 07a89303aacbeec6386e357287b6fd9fcca77d94 +# url: https://github.com/xelis-project/xelis-dart-sdk.git +# ref: f1e912cf549d311f6ccbb065b1d1d5c70f42b578 # v0.36.0 +# xelis_wallet_flutter: +# git: +# url: https://github.com/xelis-project/xelis-wallet-flutter.git +# ref: 69e48e7da2efd69439f045592337b1f7c27a646e # v0.4.0 # %%END_ENABLE_XEL%% # %%ENABLE_FIRO%% diff --git a/test/pages/send_view/xelis_confirmation_test.dart b/test/pages/send_view/xelis_confirmation_test.dart new file mode 100644 index 0000000000..c30ae723c3 --- /dev/null +++ b/test/pages/send_view/xelis_confirmation_test.dart @@ -0,0 +1,146 @@ +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/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/send_view/confirm_transaction_view.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/stack_file_system.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; + +import '../../sample_data/theme_json.dart'; +import '../../wallets/support/xelis_test_fakes.dart'; + +void main() { + for (final (replacePreparation, sendAll, cancelAuthentication) in [ + (false, false, false), + (true, false, false), + (false, true, false), + (false, false, true), + (false, true, true), + ]) { + final reviewAction = replacePreparation + ? 'preserves a newer preparation' + : 'discards its preparation'; + testWidgets('closing Xelis ${sendAll ? 'maximum' : 'ordinary'} review ' + '$reviewAction (cancel auth: $cancelAuthentication)', (tester) async { + tester.view.resetPhysicalSize(); + tester.view.physicalSize = const Size(1400, 1600); + 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 native = FakeNative(); + final wallet = TestWallet(native); + final coin = wallet.cryptoCurrency; + final request = TxData( + xelisSendAll: sendAll, + recipients: [ + TxRecipient( + address: 'integrated-destination', + amount: Amount(rawValue: BigInt.from(100), fractionDigits: 8), + isChange: false, + addressType: AddressType.xelis, + ), + ], + ); + final reviewed = await wallet.prepareSend(txData: request); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pWallets.overrideWithValue(ConfirmationWallets(wallet)), + pWalletCoin('review-test').overrideWithValue(coin), + prefsChangeNotifierProvider.overrideWithValue(ConfirmationPrefs()), + themeProvider.overrideWithProvider(StateProvider((ref) => theme)), + pAmountFormatter(coin).overrideWithValue( + AmountFormatter( + unit: AmountUnit.normal, + locale: 'en_US', + coin: coin, + maxDecimals: 8, + ), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: Scaffold( + body: ConfirmTransactionView( + txData: reviewed, + walletId: 'review-test', + onSuccess: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Send'), findsOneWidget); + final expectedAmount = sendAll ? '0.00000093' : '0.00000100'; + expect( + find.byWidgetPredicate( + (widget) => + widget is SelectableText && + widget.data == '$expectedAmount ${coin.ticker}', + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => + widget is SelectableText && + widget.data == '0.00000007 ${coin.ticker}', + ), + findsOneWidget, + ); + expect(native.usedMax, sendAll); + expect(native.broadcast, isEmpty); + if (cancelAuthentication) { + await tester.ensureVisible(find.text('Send')); + await tester.tap(find.text('Send')); + await tester.pumpAndSettle(); + expect(find.text('Confirm transaction'), findsOneWidget); + expect(native.broadcast, isEmpty); + expect(native.discarded, isEmpty); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Confirm transaction'), findsNothing); + expect(find.byType(ConfirmTransactionView), findsOneWidget); + expect(native.broadcast, isEmpty); + expect(native.discarded, isEmpty); + } + final newer = replacePreparation + ? await wallet.prepareSend(txData: request) + : null; + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(native.discarded, [reviewed.xelisPreparedTransaction]); + expect(native.broadcast, isEmpty); + if (newer != null) { + await wallet.confirmSend(txData: newer); + expect(native.broadcast, [newer.xelisPreparedTransaction]); + } else { + await expectLater( + wallet.confirmSend(txData: reviewed), + throwsStateError, + ); + } + }); + } +} diff --git a/test/pages/send_view/xelis_send_view_test.dart b/test/pages/send_view/xelis_send_view_test.dart new file mode 100644 index 0000000000..d0eff3a70e --- /dev/null +++ b/test/pages/send_view/xelis_send_view_test.dart @@ -0,0 +1,113 @@ +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/pages/send_view/sub_widgets/transaction_fee_selection_sheet.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_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/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.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; +} + +void main() { + testWidgets('Xelis mobile form shows deferred fees without a fee selector', ( + tester, + ) async { + // This exercises SendView (the mobile form) on the host test runner. + 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(FakeNative()); + final coin = wallet.cryptoCurrency; + const walletId = 'xelis-send-form'; + final info = WalletInfo( + walletId: walletId, + name: 'Xelis test wallet', + mainAddressType: AddressType.xelis, + 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, + ), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: SendView(walletId: walletId, coin: coin), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final feeLabel = find.text('Calculated when reviewing'); + expect(feeLabel, findsOneWidget); + expect(find.text('Transaction fee'), findsOneWidget); + expect(find.text('Transaction fee (estimated)'), findsNothing); + + await tester.enterText( + find.byKey(const Key('amountInputFieldCryptoTextFieldKey')), + '0.12345678', + ); + await tester.pump(const Duration(milliseconds: 600)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(feeLabel, findsOneWidget); + + await tester.ensureVisible(feeLabel); + await tester.tap(feeLabel); + await tester.pumpAndSettle(); + expect(find.byType(TransactionFeeSelectionSheet), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); +} diff --git a/test/support/isar_test_utils.dart b/test/support/isar_test_utils.dart new file mode 100644 index 0000000000..f10102d3a8 --- /dev/null +++ b/test/support/isar_test_utils.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; +import 'dart:ffi' show Abi; +import 'dart:io'; + +import 'package:isar_community/isar.dart'; + +Future initializeTestIsar() async { + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + final configFile = File('.dart_tool/package_config.json').absolute; + final config = + jsonDecode(await configFile.readAsString()) as Map; + final package = (config['packages'] as List) + .cast>() + .singleWhere((entry) => entry['name'] == 'isar_community_flutter_libs'); + final rootUri = package['rootUri'] as String; + final packageRoot = configFile.uri.resolve( + rootUri.endsWith('/') ? rootUri : '$rootUri/', + ); + final binary = Platform.isWindows + ? 'windows/libisar.dll' + : Platform.isMacOS + ? 'macos/libisar.dylib' + : 'linux/libisar.so'; + await Isar.initializeIsarCore( + libraries: {Abi.current(): packageRoot.resolve(binary).toFilePath()}, + ); + } +} diff --git a/test/wallets/support/xelis_test_fakes.dart b/test/wallets/support/xelis_test_fakes.dart new file mode 100644 index 0000000000..06ff17da81 --- /dev/null +++ b/test/wallets/support/xelis_test_fakes.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/utilities/enums/sync_type_enum.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/xelis_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_xelis_interface.dart'; + +class FakeNative extends Fake implements LibXelisInterface { + @override + String get xelisAsset => 'xel'; + @override + bool isAddressValid({ + required String address, + required CryptoCurrencyNetwork network, + }) => true; + final discarded = []; + final broadcast = []; + XelisBroadcastOutcome outcome = const XelisBroadcastOutcome( + XelisBroadcastDisposition.submitted, + ); + Completer? pendingPreparation; + Completer? pendingBroadcast; + int preparedCount = 0; + bool usedMax = false; + + XelisPreparedTransaction makePrepared(BigInt amount) => + XelisPreparedTransaction( + handle: Object(), + hash: 'hash-${++preparedCount}', + feeAtomic: BigInt.from(7), + transfers: [ + XelisPreparedTransfer( + destination: 'base-destination', + amountAtomic: amount, + asset: xelisAsset, + hasExtraData: true, + ), + ], + ); + @override + Future prepareTransfers( + OpaqueXelisWallet wallet, { + required List transfers, + }) async => + pendingPreparation?.future ?? + Future.value(makePrepared(transfers.single.amountAtomic)); + @override + Future prepareTransferAll( + OpaqueXelisWallet wallet, { + required String destination, + }) async { + usedMax = true; + return makePrepared(BigInt.from(93)); + } + + @override + Future discardPreparedTransaction( + OpaqueXelisWallet wallet, { + required XelisPreparedTransaction transaction, + }) async => discarded.add(transaction); + @override + Future broadcastTransaction( + OpaqueXelisWallet wallet, { + required XelisPreparedTransaction transaction, + }) async { + broadcast.add(transaction); + return pendingBroadcast?.future ?? Future.value(outcome); + } +} + +class TestWallet extends XelisWallet { + TestWallet(FakeNative native) + : super(CryptoCurrencyNetwork.test, native: native) { + wallet = const OpaqueXelisWallet(Object()); + } + @override + Future refresh({int? topoheight}) async {} +} + +class SessionPrefs extends Fake implements Prefs { + @override + SyncingType get syncType => SyncingType.allWalletsOnStartup; +} + +class ConfirmationWallets extends Fake implements Wallets { + ConfirmationWallets(this.wallet); + final Wallet wallet; + @override + Wallet getWallet(String walletId) => wallet; +} + +class ConfirmationPrefs extends ChangeNotifier implements Prefs { + @override + bool get externalCalls => false; + @override + String get currency => 'USD'; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class MemorySecrets extends Fake implements SecureStorageInterface { + final values = {'xelis_wants_full_tables': 'false'}; + String? failingKey; + String? failingDeleteKey; + Future Function(String)? beforeDelete; + @override + dynamic noSuchMethod(Invocation invocation) { + final key = invocation.namedArguments[#key] as String; + switch (invocation.memberName) { + case #delete: + return () async { + await beforeDelete?.call(key); + if (key == failingDeleteKey) { + throw StateError('fixture delete failure'); + } + values.remove(key); + }(); + case #read: + return Future.value(values[key]); + case #write: + if (key == failingKey) { + return Future.error(StateError('fixture write failure')); + } + values[key] = invocation.namedArguments[#value] as String; + return Future.value(); + default: + return super.noSuchMethod(invocation); + } + } +} diff --git a/test/wallets/xelis_adapter_test.dart b/test/wallets/xelis_adapter_test.dart new file mode 100644 index 0000000000..4487c66d0a --- /dev/null +++ b/test/wallets/xelis_adapter_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as path; + +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_xelis_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'Stack adapter opens native storage and exposes typed zero state', + () async { + await libXelis.initRustLib(); + final root = await Directory.systemTemp.createTemp( + 'stack_xelis_adapter_', + ); + final tableDirectory = await Directory(path.join(root.path, 'table')) + .create(); + final tablesPath = '${tableDirectory.path}${Platform.pathSeparator}'; + OpaqueXelisWallet? wallet; + try { + wallet = await libXelis.createXelisWallet( + 'wallet', + name: 'wallet', + directory: root.path, + password: 'isolated-fixture-password', + network: CryptoCurrencyNetwork.test, + precomputedTablesPath: tablesPath, + stack_l1Low: true, + ); + expect( + await Directory(path.join(root.path, 'wallet')).exists(), + isTrue, + ); + expect(await libXelis.getXelisBalanceRaw(wallet), BigInt.zero); + expect(await libXelis.allHistory(wallet), isEmpty); + expect( + libXelis.isAddressValid( + address: libXelis.getAddress(wallet), + network: CryptoCurrencyNetwork.test, + ), + isTrue, + ); + expect( + libXelis.isAddressValid( + address: libXelis.getAddress(wallet), + network: CryptoCurrencyNetwork.main, + ), + isFalse, + ); + final seed = await libXelis.getSeed(wallet); + expect(seed.split(' '), hasLength(25)); + expect(seed.split(' ').every(libXelis.validateSeedWord), isTrue); + final runtime = await libXelis.subscribeRuntimeEvents(wallet); + final business = await libXelis.subscribeBusinessEvents(wallet); + await runtime.cancel(); + await business.cancel(); + } finally { + if (wallet != null) await libXelis.closeWallet(wallet); + await root.delete(recursive: true); + } + }, + timeout: const Timeout(Duration(minutes: 2)), + ); +} diff --git a/test/wallets/xelis_local_transfer_test.dart b/test/wallets/xelis_local_transfer_test.dart new file mode 100644 index 0000000000..bd715cadc5 --- /dev/null +++ b/test/wallets/xelis_local_transfer_test.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xelis_wallet_flutter/xelis_wallet_flutter.dart' as xwf; + +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/xelis_wallet.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_xelis_interface.dart'; + +// Devnet is a test fixture only. +class LocalTransferWallet extends XelisWallet { + LocalTransferWallet(OpaqueXelisWallet handle) + : super(CryptoCurrencyNetwork.test) { + wallet = handle; + } + @override + Future refresh({int? topoheight}) async {} +} + +class LocalRpcHttp extends HttpOverrides {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const executable = String.fromEnvironment('XELIS_LOCAL_DAEMON'); + test( + 'real normal and maximum transfers confirm reviewed hashes and balances', + () async { + final root = await Directory.systemTemp.createTemp( + 'stack_xelis_local_transfer_', + ); + final reservation = await ServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + ); + final port = reservation.port; + await reservation.close(); + final endpoint = Uri.parse('http://127.0.0.1:$port/json_rpc'); + final client = LocalRpcHttp().createHttpClient(null) + ..connectionTimeout = const Duration(seconds: 2); + Process? daemon; + final daemonTail = []; + final walletEvents = []; + String phase = 'daemon startup'; + final handles = []; + final subscriptions = []; + Future rpc(String method, [Map? params]) async { + final request = await client.postUrl(endpoint); + request.headers.contentType = ContentType.json; + request.write( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': method, + if (params != null) 'params': params, + }), + ); + final response = await request.close(); + final result = jsonDecode( + await utf8.decoder.bind(response).join(), + ) as Map; + if (result['error'] != null) { + throw StateError('Local RPC $method failed: ${result['error']}'); + } + return result['result']; + } + + Future eventually(Future Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 60)); + while (!await condition()) { + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException( + 'Local wallet state did not converge: $phase', + ); + } + await Future.delayed(const Duration(milliseconds: 200)); + } + } + + try { + daemon = await Process.start(File(executable).absolute.path, [ + '--network', + 'devnet', + '--skip-pow-verification', + '--disable-p2p-server', + '--rpc-bind-address', + '127.0.0.1:$port', + '--dir-path', + '${root.path}/chain/', + '--disable-file-logging', + '--disable-interactive-mode', + '--disable-ascii-art', + '--log-level', + 'debug', + ], workingDirectory: root.path); + void remember(String line) { + daemonTail.add(line); + if (daemonTail.length > 40) daemonTail.removeAt(0); + } + + daemon.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(remember); + daemon.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(remember); + await eventually(() async { + try { + return (await rpc('get_info'))['network'] == 'devnet'; + } on SocketException { + return false; + } + }); + await libXelis.initRustLib(); + final tablePath = '${root.path}/tables/'; + await Directory(tablePath).create(); + for (final name in ['sender', 'receiver', 'miner']) { + final native = await xwf.XelisWalletFlutter.createWallet( + walletPath: '${root.path}/$name', + password: 'local-fixture-password', + network: xwf.XelisNetwork.devnet, + precomputedTableType: const xwf.XelisPrecomputedTableType.l1Low(), + precomputedTablesPath: tablePath, + ); + handles.add(OpaqueXelisWallet(native)); + } + final sender = handles[0]; + final receiver = handles[1]; + final senderAddress = libXelis.getAddress(sender); + final receiverAddress = libXelis.getAddress(receiver); + final minerAddress = libXelis.getAddress(handles[2]); + Future mine(String address, int count) async { + for (var i = 0; i < count; i++) { + final template = await rpc('get_block_template', { + 'address': address, + }); + await rpc('submit_block', {'block_template': template['template']}); + } + } + + await mine(senderAddress, 1); + // Mature the funding reward without adding new rewards to either party. + await mine(minerAddress, 30); + for (final handle in handles.take(2)) { + final runtime = await libXelis.subscribeRuntimeEvents(handle); + final business = await libXelis.subscribeBusinessEvents(handle); + subscriptions.addAll([runtime, business]); + void event(Event e) { + walletEvents.add( + e is XelisSyncIssue + ? 'sync issue: ${e.failure}' + : '${e.runtimeType}', + ); + if (walletEvents.length > 40) walletEvents.removeAt(0); + } + + runtime.events.listen(event); + business.events.listen(event); + await libXelis.onlineMode( + handle, + daemonAddress: 'http://127.0.0.1:$port', + ); + } + phase = 'sender funding'; + await eventually( + () async => + (await libXelis.getXelisBalanceRaw(sender)) > + BigInt.from(10000000), + ); + final wallet = LocalTransferWallet(sender); + final fundedBalance = await libXelis.getXelisBalanceRaw(sender); + final reviewed = await wallet.prepareSend( + txData: TxData( + recipients: [ + TxRecipient( + address: receiverAddress, + amount: Amount( + rawValue: BigInt.from(10000000), + fractionDigits: 8, + ), + isChange: false, + addressType: AddressType.xelis, + ), + ], + ), + ); + final prepared = reviewed.xelisPreparedTransaction!; + expect(prepared.transfers.single.amountAtomic, BigInt.from(10000000)); + final sent = await wallet.confirmSend(txData: reviewed); + expect(sent.txid, prepared.hash); + await expectLater( + wallet.confirmSend(txData: reviewed), + throwsStateError, + ); + await mine(minerAddress, 30); + phase = 'receiver confirmation'; + await eventually( + () async => + await libXelis.getXelisBalanceRaw(receiver) == + BigInt.from(10000000), + ); + final received = (await libXelis.allHistory(receiver)) + .where((e) => e.hash == prepared.hash) + .single; + expect(received.topoheight, isNotNull); + final outgoing = (await libXelis.allHistory(sender)) + .where((e) => e.hash == prepared.hash) + .single; + expect(outgoing.topoheight, isNotNull); + expect( + (outgoing.entryType as OutgoingEntryWrapper).fee, + prepared.feeAtomic, + ); + final remaining = + fundedBalance - BigInt.from(10000000) - prepared.feeAtomic; + phase = 'sender debit'; + await eventually( + () async => await libXelis.getXelisBalanceRaw(sender) == remaining, + ); + + final maximum = await wallet.prepareSend( + txData: TxData( + recipients: [ + TxRecipient( + address: receiverAddress, + amount: Amount(rawValue: remaining, fractionDigits: 8), + isChange: false, + addressType: AddressType.xelis, + ), + ], + xelisSendAll: true, + ), + ); + final maxPrepared = maximum.xelisPreparedTransaction!; + final maxAmount = maxPrepared.transfers.single.amountAtomic; + expect(maxAmount + maxPrepared.feeAtomic, remaining); + expect(maximum.recipients!.single.amount.raw, maxAmount); + expect( + (await wallet.confirmSend(txData: maximum)).txid, + maxPrepared.hash, + ); + await mine(minerAddress, 30); + phase = 'maximum confirmation'; + await eventually( + () async => + await libXelis.getXelisBalanceRaw(sender) == BigInt.zero && + await libXelis.getXelisBalanceRaw(receiver) == + BigInt.from(10000000) + maxAmount, + ); + // A reconnection must replace the runtime channel while preserving + // business events and the synchronized wallet state. + phase = 'runtime channel rotation'; + final previousRuntime = subscriptions.first; + await previousRuntime.cancel(); + subscriptions.remove(previousRuntime); + await libXelis.offlineMode(sender); + final replacement = await libXelis.subscribeRuntimeEvents(sender); + subscriptions.add(replacement); + final resynced = Completer(); + replacement.events.listen((event) { + if (event is HistorySynced && !resynced.isCompleted) { + resynced.complete(); + } + }); + await libXelis.onlineMode( + sender, + daemonAddress: 'http://127.0.0.1:$port', + ); + await resynced.future.timeout(const Duration(seconds: 45)); + expect(await libXelis.isOnline(sender), isTrue); + expect(await libXelis.getXelisBalanceRaw(sender), BigInt.zero); + + // Stop networking before reopening so persisted state, rather than + // another synchronization, must satisfy the assertions. + phase = 'offline reopen'; + while (subscriptions.isNotEmpty) { + await subscriptions.last.cancel(); + subscriptions.removeLast(); + } + while (handles.isNotEmpty) { + await libXelis.closeWallet(handles.last); + handles.removeLast(); + } + daemon.kill(); + await daemon.exitCode; + daemon = null; + for (final name in ['sender', 'receiver']) { + final reopened = await xwf.XelisWalletFlutter.openWallet( + walletPath: '${root.path}/$name', + password: 'local-fixture-password', + network: xwf.XelisNetwork.devnet, + precomputedTableType: const xwf.XelisPrecomputedTableType.l1Low(), + precomputedTablesPath: tablePath, + ); + handles.add(OpaqueXelisWallet(reopened)); + } + expect(libXelis.getAddress(handles[0]), senderAddress); + expect(libXelis.getAddress(handles[1]), receiverAddress); + expect(await libXelis.getXelisBalanceRaw(handles[0]), BigInt.zero); + expect( + await libXelis.getXelisBalanceRaw(handles[1]), + BigInt.from(10000000) + maxAmount, + ); + for (final handle in handles) { + final history = await libXelis.allHistory(handle); + for (final transaction in [prepared, maxPrepared]) { + final entry = history.singleWhere( + (entry) => entry.hash == transaction.hash, + ); + expect(entry.topoheight, isNotNull); + if (identical(handle, handles[0])) { + expect( + (entry.entryType as OutgoingEntryWrapper).fee, + transaction.feeAtomic, + ); + } + } + } + } catch (_) { + // Only the isolated, synthetic chain is logged. + printOnFailure(daemonTail.join('\n')); + printOnFailure('Wallet events: $walletEvents'); + if (handles.length >= 2) { + printOnFailure( + 'Sender raw balance: ' + '${await libXelis.getXelisBalanceRaw(handles[0])}', + ); + printOnFailure( + 'Sender history entries: ' + '${(await libXelis.allHistory(handles[0])).length}', + ); + } + rethrow; + } finally { + try { + await Future.wait(subscriptions.map((events) => events.cancel())); + } finally { + try { + await Future.wait(handles.map(libXelis.closeWallet)); + } finally { + client.close(force: true); + daemon?.kill(); + await daemon?.exitCode; + await root.delete(recursive: true); + } + } + } + }, + skip: executable.isEmpty, + timeout: const Timeout(Duration(minutes: 5)), + ); +} diff --git a/test/wallets/xelis_persistence_test.dart b/test/wallets/xelis_persistence_test.dart new file mode 100644 index 0000000000..a82ce76a21 --- /dev/null +++ b/test/wallets/xelis_persistence_test.dart @@ -0,0 +1,588 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:isar_community/isar.dart'; + +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/models/notification_model.dart'; +import 'package:stackwallet/models/trade_wallet_lookup.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.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/isar/models/address_label.dart'; +import 'package:stackwallet/models/isar/models/transaction_note.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; +import 'package:stackwallet/wallets/isar/models/spark_coin.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/wallet/impl/xelis_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_xelis_interface.dart'; + +import 'support/xelis_test_fakes.dart'; +import '../support/isar_test_utils.dart'; + +class PersistenceNative extends Fake implements LibXelisInterface { + BigInt balance = BigInt.zero; + BigInt daemonTopoheight = BigInt.from(100); + Object? balanceFailure; + List history = []; + int historyReads = 0; + Completer? historyStarted; + Completer? historyRelease; + int creates = 0; + int opens = 0; + int closes = 0; + Future Function()? beforeClose; + int rescans = 0; + Completer? rescanStarted; + Completer? rescanRelease; + String address = 'own'; + String? createdPassword; + String? restoredSeed; + final fixtureSeed = List.filled(25, 'fixture-word').join(' '); + @override + dynamic noSuchMethod(Invocation invocation) { + switch (invocation.memberName) { + case #isOnline: + return Future.value(false); + case #hasTables: + return Future.value(false); + case #getDaemonInfo: + return Future.value( + XelisDaemonSnapshot( + topoheight: daemonTopoheight, + stableTopoheight: BigInt.from(76), + prunedTopoheight: BigInt.from(12), + ), + ); + case #rescan: + rescans++; + rescanStarted?.complete(); + return rescanRelease?.future ?? Future.value(); + case #getSeed: + return Future.value(fixtureSeed); + case #closeWallet: + closes++; + return beforeClose?.call() ?? Future.value(); + case #openXelisWallet: + opens++; + return Future.value( + const OpaqueXelisWallet(Object()), + ); + case #createXelisWallet: + creates++; + createdPassword = invocation.namedArguments[#password] as String; + restoredSeed = invocation.namedArguments[#seed] as String?; + final directory = invocation.namedArguments[#directory] as String; + final name = invocation.namedArguments[#name] as String; + return Directory('$directory${Platform.pathSeparator}$name') + .create(recursive: true) + .then((_) => const OpaqueXelisWallet(Object())); + default: + return super.noSuchMethod(invocation); + } + } + + @override + String get xelisAsset => 'xel'; + @override + String getAddress(OpaqueXelisWallet wallet) => address; + @override + Future getXelisBalanceRaw(OpaqueXelisWallet wallet) async { + if (balanceFailure != null) throw balanceFailure!; + return balance; + } + + @override + Future> allHistory( + OpaqueXelisWallet wallet, { + BigInt? minTopoheight, + }) async { + historyReads++; + if (!(historyStarted?.isCompleted ?? true)) historyStarted!.complete(); + await historyRelease?.future; + return history; + } +} + +class OfflineNodes extends Fake implements NodeService {} + +class PersistenceWallet extends XelisWallet { + PersistenceWallet(PersistenceNative native, this.testWalletId) + : super(CryptoCurrencyNetwork.test, native: native) { + wallet = const OpaqueXelisWallet(Object()); + mainDB = MainDB.instance; + } + final String testWalletId; + @override + String get walletId => testWalletId; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory root; + late Isar isar; + late PersistenceNative native; + late PersistenceWallet wallet; + late Directory nativeRoot; + var walletSequence = 0; + setUpAll(() async { + nativeRoot = await Directory.systemTemp.createTemp('stack_xelis_init_'); + StackFileSystem.setDesktopOverrideDir(nativeRoot.path); + await initializeTestIsar(); + }); + tearDownAll(() => nativeRoot.delete(recursive: true)); + setUp(() async { + root = await Directory.systemTemp.createTemp('stack_xelis_isar_'); + final testRoot = root; + addTearDown(() => testRoot.delete(recursive: true)); + isar = await Isar.open( + [ + WalletInfoSchema, + TransactionV2Schema, + AddressSchema, + TransactionSchema, + UTXOSchema, + SparkCoinSchema, + AddressLabelSchema, + TransactionNoteSchema, + ], + directory: root.path, + name: 'xelis-test', + inspector: false, + ); + final testIsar = isar; + addTearDown(() => testIsar.close()); + await MainDB.instance.initMainDB(mock: isar); + native = PersistenceNative(); + wallet = PersistenceWallet(native, 'persistence-test-${walletSequence++}'); + await MainDB.instance.putWalletInfo( + WalletInfo( + walletId: wallet.walletId, + name: 'fixture', + mainAddressType: AddressType.xelis, + coinName: 'xelisTestNet', + ), + ); + await MainDB.instance.updateOrPutAddresses([ + Address( + walletId: wallet.walletId, + derivationIndex: 0, + derivationPath: null, + value: 'own', + publicKey: [], + type: AddressType.xelis, + subType: AddressSubType.receiving, + ), + ]); + }); + TransactionEntryWrapper entry(String hash, {int? height}) => + TransactionEntryWrapper( + Object(), + hash: hash, + timestamp: DateTime.fromMillisecondsSinceEpoch(1000), + topoheight: height == null ? null : BigInt.from(height), + entryType: OutgoingEntryWrapper( + nonce: BigInt.one, + fee: BigInt.from(7), + transfers: [ + ( + destination: 'other', + amount: BigInt.from(200), + asset: 'xel', + extraData: null, + ), + ], + ), + ); + + test('concurrent refreshes share one history and balance update', () async { + native.historyStarted = Completer(); + native.historyRelease = Completer(); + final first = wallet.refresh(); + await native.historyStarted!.future; + final second = wallet.refresh(); + expect(native.historyReads, 1); + native.historyRelease!.complete(); + await Future.wait([first, second]); + expect(native.historyReads, 2); + }); + + test('ordinary blocks advance confirmations without HistorySynced', () async { + await wallet.info.updateCachedChainHeight(newHeight: 99, isar: isar); + native.balance = BigInt.from(1000); + native.history = [entry('confirmed', height: 100)]; + await wallet.handleEvent( + NewTransaction(native.history.single), + isCurrent: () => true, + ); + final tx = await isar.transactionV2s + .where() + .walletIdEqualTo(wallet.walletId) + .findFirst(); + expect(tx!.getConfirmations(wallet.info.cachedChainHeight), 0); + + await wallet.handleEvent( + NewTopoheight(BigInt.from(100)), + isCurrent: () => true, + ); + expect(tx.getConfirmations(wallet.info.cachedChainHeight), 1); + + native.daemonTopoheight = BigInt.from(101); + await wallet.handleEvent( + NewTopoheight(BigInt.from(101)), + isCurrent: () => true, + ); + expect(tx.getConfirmations(wallet.info.cachedChainHeight), 2); + + native.daemonTopoheight = BigInt.from(102); + await wallet.handleEvent( + NewTopoheight(BigInt.from(102)), + isCurrent: () => false, + ); + expect(tx.getConfirmations(wallet.info.cachedChainHeight), 2); + }); + + test( + 'native Stack factory restores exported recovery data and reloads', + () async { + await libXelis.initRustLib(); + final secrets = MemorySecrets(); + final prefs = SessionPrefs(); + final nodes = OfflineNodes(); + final opened = []; + addTearDown(() async { + for (final item in opened.reversed) { + await item.exit(); + } + }); + Future create( + String id, { + String? seed, + String? password, + }) async { + final result = await Wallet.create( + walletInfo: WalletInfo( + walletId: id, + name: 'native-restore-fixture', + mainAddressType: AddressType.xelis, + coinName: 'xelisTestNet', + ), + mainDB: MainDB.instance, + secureStorageInterface: secrets, + nodeService: nodes, + prefs: prefs, + mnemonic: seed, + mnemonicPassphrase: password, + ) as XelisWallet; + opened.add(result); + await result.init(isRestore: seed != null); + return result; + } + + final original = await create('native-original'); + final seed = await original.getMnemonic(); + final password = await original.getMnemonicPassphrase(); + final address = original.info.cachedReceivingAddress; + expect(seed.split(' ').length, 25); + expect(address, isNotEmpty); + await original.exit(); + opened.remove(original); + + final restored = await create( + 'native-restored', + seed: seed, + password: password, + ); + expect(restored.info.cachedReceivingAddress, address); + expect((await restored.getCurrentReceivingAddress())!.value, address); + expect((await restored.getMnemonic()) == seed, isTrue); + expect(await libXelis.getXelisBalanceRaw(restored.wallet!), BigInt.zero); + await restored.exit(); + opened.remove(restored); + + final reloaded = await Wallet.load( + walletId: 'native-restored', + mainDB: MainDB.instance, + secureStorageInterface: secrets, + nodeService: nodes, + prefs: prefs, + ) as XelisWallet; + opened.add(reloaded); + await reloaded.init(); + expect(reloaded.info.cachedReceivingAddress, address); + expect((await reloaded.getMnemonic()) == seed, isTrue); + expect(await libXelis.getXelisBalanceRaw(reloaded.wallet!), BigInt.zero); + }, + ); + + test('shutdown waits for an in-flight native rescan', () async { + wallet.prefs = SessionPrefs(); + native.rescanStarted = Completer(); + native.rescanRelease = Completer(); + final rescanning = wallet.recover(isRescan: true); + await native.rescanStarted!.future; + final queuedRescan = wallet.recover(isRescan: true); + final closing = wallet.exit(); + await Future.delayed(Duration.zero); + try { + expect(native.closes, 0); + } finally { + native.rescanRelease!.complete(); + await Future.wait([rescanning, queuedRescan, closing]); + } + expect(native.closes, 1); + expect(native.rescans, 1); + expect(wallet.wallet, isNull); + }); + + for (final failSecrets in [false, true]) { + final deletionAction = failSecrets + ? 'propagates a secret failure after removing the wallet' + : 'cleans the complete record'; + test('wallet service deletion $deletionAction', () async { + final service = Wallets.sharedInstance..mainDB = MainDB.instance; + final info = wallet.info; + wallet.prefs = SessionPrefs(); + service.addWallet(wallet); + final secrets = MemorySecrets(); + final passwordKey = Wallet.mnemonicPassphraseKey( + walletId: wallet.walletId, + ); + final seedKey = Wallet.mnemonicKey(walletId: wallet.walletId); + secrets.values[seedKey] = 'fixture-seed'; + secrets.values[passwordKey] = 'fixture-password'; + wallet.secureStorageInterface = secrets; + final xelisRoot = await StackFileSystem.applicationXelisDirectory(); + final nativeDirectory = await Directory( + '${xelisRoot.path}/${wallet.walletId}', + ).create(); + await File('${nativeDirectory.path}/storage-marker') + .writeAsString('fixture'); + final tableDirectory = await Directory('${xelisRoot.path}/table') + .create(); + final tableMarker = await File('${tableDirectory.path}/shared-table') + .writeAsString('table'); + final otherDirectory = await Directory( + '${xelisRoot.path}/other-${wallet.walletId}', + ).create(); + final otherMarker = await File('${otherDirectory.path}/storage-marker') + .writeAsString('other'); + native.beforeClose = () async { + expect(() => service.getWallet(info.walletId), throwsException); + expect(await nativeDirectory.exists(), isTrue); + }; + secrets.beforeDelete = (_) async { + expect(native.closes, 1); + expect(await nativeDirectory.exists(), isFalse); + }; + final hive = DB.instance.hive; + hive.init(root.path); + await hive.openBox(DB.boxNameWalletsToDeleteOnStart); + await hive.openBox(DB.boxNameTradeLookup); + await hive.openBox(DB.boxNameNotifications); + addTearDown(hive.close); + addTearDown(() async { + secrets.failingDeleteKey = null; + if (await isar.walletInfo.getByWalletId(info.walletId) != null) { + await service.deleteWallet(info, secrets); + } + }); + if (failSecrets) { + secrets.failingDeleteKey = passwordKey; + await expectLater( + service.deleteWallet(info, secrets), + throwsStateError, + ); + expect(() => service.getWallet(info.walletId), throwsException); + expect(await isar.walletInfo.getByWalletId(info.walletId), isNotNull); + expect(secrets.values[passwordKey], 'fixture-password'); + expect(await nativeDirectory.exists(), isFalse); + expect(await tableMarker.readAsString(), 'table'); + expect(await otherMarker.readAsString(), 'other'); + return; + } + await service.deleteWallet(info, secrets); + expect(() => service.getWallet(info.walletId), throwsException); + expect(await isar.walletInfo.getByWalletId(info.walletId), isNull); + expect(await isar.addresses.count(), 0); + expect(secrets.values.containsKey(seedKey), isFalse); + expect(secrets.values.containsKey(passwordKey), isFalse); + expect(await tableMarker.readAsString(), 'table'); + expect(await otherMarker.readAsString(), 'other'); + expect( + DB.instance.values(boxName: DB.boxNameWalletsToDeleteOnStart), + [info.walletId], + ); + }); + } + + test('confirmed/pending/reorganized snapshots replace history ' + 'but preserve addresses', () async { + native.history = [entry('one')]; + await wallet.updateTransactions(); + final first = await isar.transactionV2s.where().findFirst(); + expect(first!.height, isNull); + native.history = [entry('one', height: 10), entry('two', height: 10)]; + await wallet.updateTransactions(); + expect(await isar.transactionV2s.count(), 2); + expect((await isar.transactionV2s.get(first.id))!.height, 10); + native.history = [entry('one', height: 9)]; + await wallet.updateTransactions(isRescan: true); + expect(await isar.transactionV2s.count(), 1); + expect((await isar.transactionV2s.get(first.id))!.height, 9); + expect(await isar.addresses.count(), 1); + }); + + test('pending debit is reserved ' + 'and a later genuine zero replaces cached balance', () async { + native.balance = BigInt.from(1000); + native.history = [entry('pending')]; + await wallet.updateBalance(); + expect(wallet.info.cachedBalance.spendable.raw, BigInt.from(793)); + expect(wallet.info.cachedBalance.blockedTotal.raw, BigInt.from(207)); + native.history = []; + native.balance = BigInt.zero; + await wallet.updateBalance(); + expect(wallet.info.cachedBalance.total.raw, BigInt.zero); + expect(wallet.info.cachedBalance.spendable.raw, BigInt.zero); + }); + + test( + 'storage read failure stays an error and never persists a fabricated zero', + () async { + native.balance = BigInt.from(1000); + await wallet.updateBalance(); + final failure = StateError('fixture storage error'); + native.balanceFailure = failure; + await expectLater(wallet.updateBalance(), throwsA(same(failure))); + expect(wallet.info.cachedBalance.total.raw, BigInt.from(1000)); + }, + ); + + test('a loaded record without native storage or seed ' + 'never creates another identity', () async { + wallet.wallet = null; + wallet.secureStorageInterface = MemorySecrets(); + await expectLater(wallet.init(), throwsStateError); + expect(native.creates, 0); + expect(wallet.wallet, isNull); + }); + + test('interrupted seed persistence is repaired ' + 'by reopening the created database', () async { + wallet.wallet = null; + final secrets = MemorySecrets(); + wallet.secureStorageInterface = secrets; + wallet.allowNewWallet = true; + final seedKey = Wallet.mnemonicKey(walletId: wallet.walletId); + secrets.failingKey = seedKey; + await expectLater(wallet.init(), throwsStateError); + expect(wallet.wallet, isNull); + expect(native.closes, 1); + secrets.failingKey = null; + await wallet.init(); + expect(native.creates, 1); + expect(native.opens, 1); + expect(secrets.values[seedKey], native.fixtureSeed); + expect(wallet.info.cachedReceivingAddress, 'own'); + }); + + test( + 'existing native storage without its password fails before opening', + () async { + wallet.wallet = null; + final secrets = MemorySecrets(); + wallet.secureStorageInterface = secrets; + final directory = await StackFileSystem.applicationXelisDirectory(); + await Directory('${directory.path}/${wallet.walletId}').create(); + await expectLater(wallet.init(), throwsStateError); + expect(native.opens, 0); + expect(native.creates, 0); + expect(wallet.wallet, isNull); + expect( + secrets.values.containsKey( + Wallet.mnemonicKey(walletId: wallet.walletId), + ), + isFalse, + ); + }, + ); + + test( + 'mismatched native identity closes without saving its recovery seed', + () async { + wallet.wallet = null; + native.address = 'another-wallet'; + final secrets = MemorySecrets(); + wallet.secureStorageInterface = secrets; + final passwordKey = Wallet.mnemonicPassphraseKey( + walletId: wallet.walletId, + ); + secrets.values[passwordKey] = 'fixture-password'; + final directory = await StackFileSystem.applicationXelisDirectory(); + await Directory('${directory.path}/${wallet.walletId}').create(); + await expectLater(wallet.init(), throwsStateError); + expect(native.opens, 1); + expect(native.closes, 1); + expect(wallet.wallet, isNull); + expect(secrets.values[passwordKey], 'fixture-password'); + expect( + secrets.values.containsKey( + Wallet.mnemonicKey(walletId: wallet.walletId), + ), + isFalse, + ); + expect((await wallet.getCurrentReceivingAddress())!.value, 'own'); + }, + ); + + for (final hasPassword in [false, true]) { + test('seed restoration ${hasPassword ? 'preserves' : 'creates'} ' + 'the native password', () async { + wallet.wallet = null; + final secrets = MemorySecrets(); + wallet.secureStorageInterface = secrets; + final passwordKey = Wallet.mnemonicPassphraseKey( + walletId: wallet.walletId, + ); + secrets.values[Wallet.mnemonicKey(walletId: wallet.walletId)] = + ' ${native.fixtureSeed.replaceAll(' ', '\n ')} '; + if (hasPassword) secrets.values[passwordKey] = 'fixture-password'; + await wallet.init(isRestore: true); + expect(native.creates, 1); + expect(native.opens, 0); + expect(native.restoredSeed, native.fixtureSeed); + expect(native.createdPassword, isNotEmpty); + expect(native.createdPassword, secrets.values[passwordKey]); + if (hasPassword) expect(native.createdPassword, 'fixture-password'); + expect(wallet.info.cachedReceivingAddress, 'own'); + }); + } + + test( + 'password persistence failure prevents native creation and permits retry', + () async { + wallet.wallet = null; + wallet.allowNewWallet = true; + final passwordKey = Wallet.mnemonicPassphraseKey( + walletId: wallet.walletId, + ); + final secrets = MemorySecrets()..failingKey = passwordKey; + wallet.secureStorageInterface = secrets; + await expectLater(wallet.init(), throwsStateError); + expect(native.creates, 0); + expect(wallet.wallet, isNull); + secrets.failingKey = null; + await wallet.init(); + expect(native.creates, 1); + expect(native.createdPassword, secrets.values[passwordKey]); + }, + ); +} diff --git a/test/wallets/xelis_send_test.dart b/test/wallets/xelis_send_test.dart new file mode 100644 index 0000000000..f7666283d4 --- /dev/null +++ b/test/wallets/xelis_send_test.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; + +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_xelis_interface.dart'; + +import 'support/xelis_test_fakes.dart'; + +void main() { + Amount amount(int raw) => + Amount(rawValue: BigInt.from(raw), fractionDigits: 8); + TxData request({bool max = false}) => TxData( + recipients: [ + TxRecipient( + address: 'integrated-destination', + amount: amount(100), + isChange: false, + addressType: AddressType.xelis, + ), + ], + xelisSendAll: max, + ); + late FakeNative native; + late TestWallet wallet; + setUp(() { + native = FakeNative(); + wallet = TestWallet(native); + }); + + test('review keeps the integrated destination ' + 'and broadcasts its exact capability once', () async { + final reviewed = await wallet.prepareSend(txData: request()); + expect(reviewed.recipients!.single.address, 'integrated-destination'); + final result = await wallet.confirmSend(txData: reviewed); + expect(result.txid, reviewed.xelisPreparedTransaction!.hash); + expect(native.broadcast.single, same(reviewed.xelisPreparedTransaction)); + await expectLater(wallet.confirmSend(txData: reviewed), throwsStateError); + }); + + test('review edits and a closed session cannot broadcast', () async { + final reviewed = await wallet.prepareSend(txData: request()); + await expectLater( + wallet.confirmSend(txData: reviewed.copyWith(fee: amount(8))), + throwsStateError, + ); + await expectLater( + wallet.confirmSend( + txData: reviewed.copyWith( + recipients: [ + TxRecipient( + address: 'different-destination', + amount: amount(100), + isChange: false, + addressType: AddressType.xelis, + ), + ], + ), + ), + throwsStateError, + ); + ++wallet.sessionGeneration; + wallet.wallet = const OpaqueXelisWallet(Object()); + // A replaced handle must invalidate preparation even if TxData survives. + await expectLater(wallet.confirmSend(txData: reviewed), throwsStateError); + expect(native.broadcast, isEmpty); + }); + + test('max uses the native maximum and its reviewed amount', () async { + final reviewed = await wallet.prepareSend(txData: request(max: true)); + expect(native.usedMax, isTrue); + expect(reviewed.recipients!.single.amount.raw, BigInt.from(93)); + expect(reviewed.fee!.raw, BigInt.from(7)); + }); + + test( + 'retryable preserves capability; submitted-needs-resync consumes it', + () async { + final reviewed = await wallet.prepareSend(txData: request()); + final failure = StateError('structured failure stand-in'); + native.outcome = XelisBroadcastOutcome( + XelisBroadcastDisposition.retryable, + failure: failure, + ); + await expectLater( + wallet.confirmSend(txData: reviewed), + throwsA(same(failure)), + ); + native.outcome = const XelisBroadcastOutcome( + XelisBroadcastDisposition.submittedNeedsResync, + ); + await wallet.confirmSend(txData: reviewed); + expect( + native.broadcast, + everyElement(same(reviewed.xelisPreparedTransaction)), + ); + await expectLater(wallet.confirmSend(txData: reviewed), throwsStateError); + }, + ); + + test('cancelled review discards native preparation', () async { + final reviewed = await wallet.prepareSend(txData: request()); + await wallet.cancelSend(txData: reviewed); + expect(native.discarded.single, same(reviewed.xelisPreparedTransaction)); + await expectLater(wallet.confirmSend(txData: reviewed), throwsStateError); + }); + + test('closing waits for an in-flight broadcast to settle', () async { + final reviewed = await wallet.prepareSend(txData: request()); + native.pendingBroadcast = Completer(); + final send = wallet.confirmSend(txData: reviewed); + await Future.delayed(Duration.zero); + wallet.exitInProgress = true; + ++wallet.sessionGeneration; + var drained = false; + final closing = wallet.drainSessionOperations().then((_) => drained = true); + await Future.delayed(Duration.zero); + expect(drained, isFalse); + native.pendingBroadcast!.complete( + const XelisBroadcastOutcome(XelisBroadcastDisposition.submitted), + ); + expect((await send).txid, reviewed.xelisPreparedTransaction!.hash); + await closing; + expect(drained, isTrue); + }); +} diff --git a/test/wallets/xelis_session_test.dart b/test/wallets/xelis_session_test.dart new file mode 100644 index 0000000000..eb43d17d0d --- /dev/null +++ b/test/wallets/xelis_session_test.dart @@ -0,0 +1,290 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/xelis_wallet.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_xelis_interface.dart'; + +import 'support/xelis_test_fakes.dart'; + +class SessionNative extends Fake implements LibXelisInterface { + @override + String get xelisAsset => 'xel'; + final runtime = >[]; + final business = >[]; + final order = []; + bool failRuntimeCancel = false; + bool failRuntimeSubscribe = false; + bool failBusinessSubscribe = false; + Completer? onlineStarted; + Completer? onlineRelease; + @override + Future subscribeRuntimeEvents( + OpaqueXelisWallet wallet, + ) async { + if (failRuntimeSubscribe) throw StateError('runtime subscription failed'); + final controller = StreamController(); + runtime.add(controller); + return XelisEventSubscription( + events: controller.stream, + cancel: () async { + order.add('cancel-runtime'); + await controller.close(); + if (failRuntimeCancel) throw StateError('test cancellation failure'); + }, + ); + } + + @override + Future subscribeBusinessEvents( + OpaqueXelisWallet wallet, + ) async { + if (failBusinessSubscribe) throw StateError('business subscription failed'); + final controller = StreamController(); + business.add(controller); + return XelisEventSubscription( + events: controller.stream, + cancel: () async { + order.add('cancel-business'); + await controller.close(); + }, + ); + } + + @override + Future onlineMode( + OpaqueXelisWallet wallet, { + required String daemonAddress, + }) async { + order.add(daemonAddress); + if (!(onlineStarted?.isCompleted ?? true)) onlineStarted!.complete(); + await onlineRelease?.future; + } + + @override + Future offlineMode(OpaqueXelisWallet wallet) async => + order.add('offline'); + @override + Future closeWallet(OpaqueXelisWallet wallet) async => + order.add('close'); +} + +class SessionWallet extends XelisWallet { + SessionWallet(SessionNative native) + : super(CryptoCurrencyNetwork.test, native: native) { + wallet = const OpaqueXelisWallet(Object()); + prefs = SessionPrefs(); + } + final handled = []; + int reconnectRequests = 0; + @override + void scheduleReconnect() { + reconnectRequests++; + super.scheduleReconnect(); + } + + @override + String get walletId => 'isolated-session-test'; + @override + NodeModel getCurrentNode() => cryptoCurrency.defaultNode(isPrimary: true); + @override + Future refresh({int? topoheight}) async {} + @override + Future handleEvent( + Event event, { + required bool Function() isCurrent, + }) async { + await Future.delayed(Duration.zero); + if (isCurrent()) handled.add(event); + } +} + +void main() { + testWidgets( + 'business event bursts coalesce and shutdown drops pending work', + (tester) async { + final native = SessionNative(); + final wallet = SessionWallet(native); + await wallet.connect(); + for (var i = 0; i < 20; i++) { + native.runtime.single.add(NewTopoheight(BigInt.from(i))); + native.business.single.add(BalanceChanged('xel', BigInt.from(i))); + } + await tester.pump(); + expect(wallet.handled, isEmpty); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(); + await tester.pump(); + expect(wallet.handled.whereType(), hasLength(1)); + expect(wallet.handled.whereType(), hasLength(1)); + expect( + wallet.handled.whereType().single.height, + BigInt.from(19), + ); + native.business.single.add(NewTopoheight(BigInt.from(21))); + await tester.pump(); + await wallet.exit(); + await tester.pump(const Duration(seconds: 1)); + expect(wallet.handled, hasLength(2)); + }, + ); + + testWidgets('automatic retry backs off, caps at 32s and resets on success', ( + tester, + ) async { + final native = SessionNative()..failRuntimeSubscribe = true; + final wallet = SessionWallet(native); + await expectLater(wallet.connect(), throwsStateError); + int attempts() => native.order.where((entry) => entry == 'offline').length; + var expectedAttempts = 1; + for (final seconds in [1, 2, 4, 8, 16, 32, 32]) { + await tester.pump(Duration(milliseconds: seconds * 1000 - 1)); + expect(attempts(), expectedAttempts); + await tester.pump(const Duration(milliseconds: 1)); + expect(attempts(), ++expectedAttempts); + } + native.failRuntimeSubscribe = false; + await tester.pump(const Duration(seconds: 32)); + expect(attempts(), ++expectedAttempts); + expect(native.runtime, hasLength(1)); + native.runtime.single.addError(StateError('synthetic channel failure')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 999)); + expect(attempts(), expectedAttempts); + await tester.pump(const Duration(milliseconds: 1)); + expect(attempts(), ++expectedAttempts); + expect(native.runtime, hasLength(2)); + await wallet.exit(); + }); + + testWidgets('shutdown cancels a scheduled automatic retry', (tester) async { + final native = SessionNative()..failBusinessSubscribe = true; + final wallet = SessionWallet(native); + await expectLater(wallet.connect(), throwsStateError); + await wallet.exit(); + final orderAfterExit = List.of(native.order); + await tester.pump(const Duration(minutes: 2)); + expect(native.order, orderAfterExit); + expect(native.order.last, 'close'); + expect(wallet.wallet, isNull); + }); + + testWidgets('simultaneous channel failures share one automatic retry', ( + tester, + ) async { + final native = SessionNative(); + final wallet = SessionWallet(native); + await wallet.connect(); + native.runtime.single.addError(StateError('synthetic runtime failure')); + native.business.single.addError(StateError('synthetic business failure')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(native.runtime, hasLength(2)); + expect(native.business, hasLength(2)); + await tester.pump(const Duration(seconds: 40)); + expect(native.runtime, hasLength(2)); + expect(native.business, hasLength(2)); + await wallet.exit(); + }); + + for (final isRuntime in [false, true]) { + test( + 'failed ${isRuntime ? 'runtime' : 'business'} subscription can retry', + () async { + final native = SessionNative() + ..failRuntimeSubscribe = isRuntime + ..failBusinessSubscribe = !isRuntime; + final wallet = SessionWallet(native); + addTearDown(wallet.exit); + await expectLater(wallet.connect(), throwsStateError); + expect(wallet.reconnectRequests, 1); + native + ..failRuntimeSubscribe = false + ..failBusinessSubscribe = false; + await wallet.connect(); + expect(native.runtime, hasLength(1)); + expect(native.business, hasLength(1)); + }, + ); + + test( + 'closed ${isRuntime ? 'runtime' : 'business'} channel is replaced', + () async { + final native = SessionNative(); + final wallet = SessionWallet(native); + addTearDown(wallet.exit); + await wallet.connect(); + await (isRuntime ? native.runtime.single : native.business.single) + .close(); + expect(wallet.reconnectRequests, 1); + await wallet.connect(); + expect(native.runtime, hasLength(2)); + expect(native.business, hasLength(isRuntime ? 1 : 2)); + native.business.last.add(const Online()); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(wallet.handled, hasLength(1)); + }, + ); + } + + test( + 'rapid connection requests skip superseded queued connections', + () async { + final native = SessionNative() + ..onlineStarted = Completer() + ..onlineRelease = Completer(); + final wallet = SessionWallet(native); + addTearDown(wallet.exit); + final first = wallet.connect(); + await native.onlineStarted!.future; + final second = wallet.connect(); + final third = wallet.connect(); + native.onlineRelease!.complete(); + await Future.wait([first, second, third]); + expect(native.runtime, hasLength(2)); + expect(native.business, hasLength(1)); + expect( + native.order.where((entry) => entry.startsWith('https://')), + hasLength(2), + ); + }, + ); + + test( + 'node rotation replaces runtime but preserves the business channel', + () async { + final native = SessionNative(); + final wallet = SessionWallet(native); + await wallet.connect(); + native.runtime.single.add(const Online()); + await wallet.connect(); + expect(wallet.handled, isEmpty); + expect(native.runtime, hasLength(2)); + expect(native.business, hasLength(1)); + expect( + native.order.where((entry) => entry.startsWith('https://')), + hasLength(2), + ); + await wallet.exit(); + expect(native.order.last, 'close'); + expect(wallet.wallet, isNull); + }, + ); + + test('shutdown still cancels business and closes native storage ' + 'after a cancellation error', () async { + final native = SessionNative(); + final wallet = SessionWallet(native); + await wallet.connect(); + native.failRuntimeCancel = true; + await expectLater(wallet.exit(), throwsStateError); + expect( + native.order, + containsAllInOrder(['cancel-runtime', 'cancel-business', 'close']), + ); + expect(wallet.exitInProgress, isFalse); + expect(wallet.wallet, isNull); + }); +} diff --git a/test/wallets/xelis_storage_test.dart b/test/wallets/xelis_storage_test.dart new file mode 100644 index 0000000000..f6407b900c --- /dev/null +++ b/test/wallets/xelis_storage_test.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:stackwallet/utilities/xelis_storage.dart'; + +void main() { + late Directory root; + setUp(() async { + root = await Directory.systemTemp.createTemp('stack_xelis_path_test_'); + }); + tearDown(() async => root.delete(recursive: true)); + + test( + 'resolves only the requested wallet and accepts absent native data', + () async { + final wallet = await Directory(path.join(root.path, 'wallet-123')) + .create(); + expect( + (await xelisWalletDirectory(root, 'wallet-123'))?.path, + wallet.path, + ); + expect(await xelisWalletDirectory(root, 'absent'), isNull); + }, + ); + + test( + 'rejects table storage, traversal and a file in place of a wallet', + () async { + for (final id in [ + 'table', + 'TABLE', + '..', + '../other', + 'a/b', + r'a\b', + '', + ]) { + await expectLater(xelisWalletDirectory(root, id), throwsStateError); + } + await File(path.join(root.path, 'wallet-file')).writeAsString('fixture'); + await expectLater( + xelisWalletDirectory(root, 'wallet-file'), + throwsStateError, + ); + }, + ); +} diff --git a/test/wallets/xelis_swb_test.dart b/test/wallets/xelis_swb_test.dart new file mode 100644 index 0000000000..7d9b104497 --- /dev/null +++ b/test/wallets/xelis_swb_test.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:isar_community/isar.dart'; +// Test-only substitution of the existing wakelock plugin's platform layer. +// ignore: depend_on_referenced_packages +import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interface.dart'; + +import 'package:stackwallet/db/drift/shared_db/shared_database.dart'; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/models/transaction_note.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/models/stack_restoring_ui_state.dart'; +import 'package:stackwallet/models/trade_wallet_lookup.dart'; +import 'package:stackwallet/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/services/shopinbit/shopinbit_service.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/wallet/impl/xelis_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_xelis_interface.dart'; + +import '../support/isar_test_utils.dart'; +import 'support/xelis_test_fakes.dart'; + +class UnusedShopService extends Fake implements ShopInBitService {} + +class TestWakelock extends WakelockPlusPlatformInterface { + @override + Future toggle({required bool enable}) async {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'encrypted SWB restores native Xelis with duplicate ID and notes', + () async { + final root = await Directory.systemTemp.createTemp('stack_xelis_swb_'); + StackFileSystem.setDesktopOverrideDir(root.path); + final opened = []; + addTearDown(() async { + try { + for (final wallet in opened.reversed) { + await wallet.exit(); + } + await SharedDrift.get().close(); + } finally { + await MainDB.instance.isar.close(); + await DB.instance.hive.close(); + await root.delete(recursive: true); + } + }); + const paths = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(paths, (call) async { + if (call.method == 'getTemporaryDirectory') return root.path; + throw StateError('Unexpected path request: ${call.method}'); + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(paths, null), + ); + // The host wake-lock plugin has no role in this headless backup test. + final previousWakelock = WakelockPlusPlatformInterface.instance; + WakelockPlusPlatformInterface.instance = TestWakelock(); + addTearDown( + () => WakelockPlusPlatformInterface.instance = previousWakelock, + ); + await initializeTestIsar(); + await MainDB.instance.initMainDB(); + final hive = DB.instance.hive..init('${root.path}/hive'); + hive.registerAdapter(NodeModelAdapter()); + await hive.openBox(DB.boxNamePrefs); + await hive.openBox(DB.boxNameNodeModels); + await hive.openBox(DB.boxNameTradesV2); + await hive.openBox(DB.boxNameTradeNotes); + await hive.openBox(DB.boxNameTradeLookup); + final secrets = MemorySecrets(); + final prefs = Prefs.instance; + await prefs.init(); + final nodes = NodeService(secureStorageInterface: secrets); + final wallets = Wallets.sharedInstance + ..mainDB = MainDB.instance + ..nodeService = nodes; + await libXelis.initRustLib(); + final original = await Wallet.create( + walletInfo: WalletInfo( + walletId: 'swb-original', + name: 'Xelis SWB fixture', + mainAddressType: AddressType.xelis, + coinName: 'xelisTestNet', + ), + mainDB: MainDB.instance, + secureStorageInterface: secrets, + nodeService: nodes, + prefs: prefs, + ) as XelisWallet; + opened.add(original); + await original.init(); + wallets.addWallet(original); + final address = original.info.cachedReceivingAddress; + final seed = await original.getMnemonic(); + await MainDB.instance.isar.writeTxn(() async { + await MainDB.instance.isar.transactionNotes.put( + TransactionNote( + walletId: original.walletId, + txid: 'swb-fixture-transaction', + value: 'preserve this note', + ), + ); + }); + final json = await SWB.createStackWalletJSON(secureStorage: secrets); + final backup = (json['wallets'] as List).single as Map; + expect(backup['mnemonic'] == seed, isTrue); + expect(backup['coinName'], 'xelisTestNet'); + final plaintext = jsonEncode(json); + const passphrase = 'temporary fixture backup password'; + final encrypted = await SWB.encryptStackWalletWithPassphrase( + passphrase, + plaintext, + ); + final file = File('${root.path}/fixture.swb'); + await file.writeAsString(encrypted); + expect((await file.readAsString()).contains(seed), isFalse); + final decoded = await SWB.decryptStackWalletStringWithPassphrase(( + passphrase: passphrase, + encryptedText: await file.readAsString(), + )); + expect(decoded == plaintext, isTrue); + await original.exit(); + final state = StackRestoringUIState(); + addTearDown(state.dispose); + try { + final result = await SWB.restoreStackWalletJSON( + decoded!, + state, + secrets, + UnusedShopService(), + ); + expect(result, isTrue); + expect(state.succeeded, isTrue); + final restored = state.wallets.single as XelisWallet; + expect(restored.walletId, isNot(original.walletId)); + expect(restored.info.cachedReceivingAddress, address); + expect((await restored.getMnemonic()) == seed, isTrue); + expect( + await restored.info.isMnemonicVerified(MainDB.instance.isar), + isTrue, + ); + final notes = await MainDB.instance.isar.transactionNotes + .where() + .walletIdEqualTo(restored.walletId) + .findAll(); + expect(notes.single.value, 'preserve this note'); + await restored.exit(); + await MainDB.instance.isar.close(); + await MainDB.instance.initMainDB(); + final loaded = await Wallet.load( + walletId: restored.walletId, + mainDB: MainDB.instance, + secureStorageInterface: secrets, + nodeService: nodes, + prefs: prefs, + ) as XelisWallet; + opened.add(loaded); + await loaded.init(); + expect(loaded.info.cachedReceivingAddress, address); + expect((await loaded.getMnemonic()) == seed, isTrue); + } finally { + for (final wallet in state.wallets) { + await wallet.exit(); + } + } + }, + timeout: const Timeout(Duration(minutes: 3)), + ); +} diff --git a/test/wallets/xelis_tables_test.dart b/test/wallets/xelis_tables_test.dart new file mode 100644 index 0000000000..4988549985 --- /dev/null +++ b/test/wallets/xelis_tables_test.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/xelis_wallet.dart'; +import 'package:stackwallet/wallets/wallet/intermediate/lib_xelis_wallet.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_xelis_interface.dart'; + +class TableSecrets extends Fake implements SecureStorageInterface { + final values = {}; + @override + dynamic noSuchMethod(Invocation invocation) { + final key = invocation.namedArguments[#key] as String; + switch (invocation.memberName) { + case #read: + return Future.value(values[key]); + case #write: + values[key] = invocation.namedArguments[#value] as String; + return Future.value(); + default: + return super.noSuchMethod(invocation); + } + } +} + +class TableNative extends Fake implements LibXelisInterface { + bool fullPresent = false; + int updates = 0; + Completer? started; + Completer? release; + @override + Future hasTables({ + required String precomputedTablesPath, + // Matches the existing LibXelisInterface parameter name. + // ignore: non_constant_identifier_names + required bool stack_l1Low, + }) async => fullPresent; + + @override + Future updateTables({ + required String precomputedTablesPath, + // Matches the existing LibXelisInterface parameter name. + // ignore: non_constant_identifier_names + required bool stack_l1Low, + }) async { + updates++; + if (!(started?.isCompleted ?? true)) started!.complete(); + await release?.future; + if (!stack_l1Low) fullPresent = true; + } +} + +class TableWallet extends XelisWallet { + TableWallet(TableNative native, TableSecrets secrets) + : super(CryptoCurrencyNetwork.test, native: native) { + secureStorageInterface = secrets; + } + @override + Future getPrecomputedTablesPath() async => 'isolated-table-test'; +} + +void main() { + test( + 'two wallets share one table generation and report it in flight', + () async { + final native = TableNative() + ..started = Completer() + ..release = Completer(); + final secrets = TableSecrets(); + final first = TableWallet(native, secrets); + final second = TableWallet(native, secrets); + final operation = first.updateTablesToDesiredSize(); + final joined = second.updateTablesToDesiredSize(); + expect(identical(operation, joined), isTrue); + await native.started!.future; + expect((await second.getTableState()).isGenerating, isTrue); + native.release!.complete(); + await Future.wait([operation, joined]); + expect(native.updates, 1); + final state = await second.getTableState(); + expect(state.isGenerating, isFalse); + expect(state.currentSize, XelisTableSize.full); + await second.updateTablesToDesiredSize(); + expect(native.updates, 1); + }, + ); + + test( + 'generation failure reaches all callers and permits a fresh attempt', + () async { + final native = TableNative() + ..started = Completer() + ..release = Completer(); + final secrets = TableSecrets(); + final first = TableWallet(native, secrets); + final second = TableWallet(native, secrets); + final failure = StateError('table generation failed'); + final firstCheck = expectLater( + first.updateTablesToDesiredSize(), + throwsA(same(failure)), + ); + final secondCheck = expectLater( + second.updateTablesToDesiredSize(), + throwsA(same(failure)), + ); + await native.started!.future; + native.release!.completeError(failure); + await Future.wait([firstCheck, secondCheck]); + expect((await first.getTableState()).isGenerating, isFalse); + expect(native.updates, 1); + native.release = null; + await second.updateTablesToDesiredSize(); + expect(native.updates, 2); + expect((await first.getTableState()).currentSize, XelisTableSize.full); + }, + ); + + test( + 'stale persisted flags cannot hide missing tables or block generation', + () async { + final native = TableNative(); + final secrets = TableSecrets() + ..values['xelis_has_full_tables'] = 'true' + ..values['xelis_generating_tables'] = 'true'; + final wallet = TableWallet(native, secrets); + final state = await wallet.getTableState(); + expect(state.currentSize, XelisTableSize.low); + expect(state.isGenerating, isFalse); + await wallet.updateTablesToDesiredSize(); + expect(native.updates, 1); + expect((await wallet.getTableState()).currentSize, XelisTableSize.full); + }, + ); + + test( + 'finishing generation preserves a preference changed by another wallet', + () async { + final native = TableNative() + ..started = Completer() + ..release = Completer(); + final secrets = TableSecrets(); + final first = TableWallet(native, secrets); + final second = TableWallet(native, secrets); + final operation = first.updateTablesToDesiredSize(); + await native.started!.future; + await second.setTableState( + const XelisTableState( + isGenerating: true, + desiredSize: XelisTableSize.low, + ), + ); + native.release!.complete(); + await operation; + final state = await first.getTableState(); + expect(state.desiredSize, XelisTableSize.low); + expect(state.isGenerating, isFalse); + expect(secrets.values['xelis_wants_full_tables'], 'false'); + }, + ); +} diff --git a/test/wallets/xelis_transaction_test.dart b/test/wallets/xelis_transaction_test.dart new file mode 100644 index 0000000000..0fc8b5561a --- /dev/null +++ b/test/wallets/xelis_transaction_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart'; +import 'package:stackwallet/wallets/models/xelis_transaction.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_xelis_interface.dart'; + +void main() { + const own = 'own-address'; + const xel = 'native-asset'; + project(EntryWrapper entry, {BigInt? height}) => projectXelisTransaction( + tx: TransactionEntryWrapper( + Object(), + entryType: entry, + hash: 'hash', + timestamp: DateTime.fromMillisecondsSinceEpoch(123000), + topoheight: height, + ), + ownAddress: own, + walletId: 'wallet', + xelisAsset: xel, + fractionDigits: 8, + )!; + + test('burn keeps the XEL amount and its separate fee', () { + final tx = project( + BurnEntryWrapper( + amount: BigInt.from(400), + fee: BigInt.from(7), + asset: xel, + ), + height: BigInt.from(9), + ); + expect(tx.height, 9); + expect(tx.getFee(fractionDigits: 8).raw, BigInt.from(7)); + expect( + tx.getAmountSentFromThisWallet(fractionDigits: 8, subtractFee: true).raw, + BigInt.from(400), + ); + }); + + test( + 'non-XEL transfers record only their XEL fee without empty-list errors', + () { + final tx = project( + OutgoingEntryWrapper( + nonce: BigInt.one, + fee: BigInt.from(7), + transfers: [ + ( + destination: 'other', + amount: BigInt.from(999), + asset: 'token', + extraData: null, + ), + ], + ), + ); + expect(tx.height, isNull); + expect( + tx.nonce, + isNull, + ); // Xelis preserves its u64 separately as a string. + expect(tx.outputs, isEmpty); + expect(tx.getFee(fractionDigits: 8).raw, BigInt.from(7)); + expect( + tx + .getAmountSentFromThisWallet(fractionDigits: 8, subtractFee: true) + .raw, + BigInt.zero, + ); + }, + ); + + test('self transfer displays only the net fee as the wallet debit', () { + final tx = project( + OutgoingEntryWrapper( + nonce: BigInt.one, + fee: BigInt.from(7), + transfers: [ + ( + destination: own, + amount: BigInt.from(400), + asset: xel, + extraData: null, + ), + ], + ), + ); + expect(tx.type, TransactionType.sentToSelf); + expect( + tx.getAmountSentFromThisWallet(fractionDigits: 8, subtractFee: false).raw, + BigInt.from(7), + ); + expect( + tx.getAmountReceivedInThisWallet(fractionDigits: 8).raw, + BigInt.from(400), + ); + }); + + test('contract receipt offsets the outgoing XEL debit', () { + final tx = project( + XelisActionEntryWrapper( + kind: 'invoke_contract', + spent: BigInt.from(500), + received: BigInt.from(150), + fee: BigInt.from(7), + ), + ); + expect( + tx.getAmountSentFromThisWallet(fractionDigits: 8, subtractFee: true).raw, + BigInt.from(350), + ); + }); + + test('incoming native atomic amounts never pass through a double', () { + final exact = BigInt.parse('9007199254740993'); + final tx = project( + IncomingEntryWrapper( + from: 'other', + transfers: [(amount: exact, asset: xel, extraData: null)], + ), + ); + expect(tx.getAmountReceivedInThisWallet(fractionDigits: 8).raw, exact); + expect(tx.timestamp, 123); + }); +} diff --git a/test/wallets/xelis_types_test.dart b/test/wallets/xelis_types_test.dart new file mode 100644 index 0000000000..2eb3171566 --- /dev/null +++ b/test/wallets/xelis_types_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wl_gen/interfaces/xelis_types.dart'; + +void main() { + test( + 'Xelis persistence conversion preserves integers beyond double precision', + () { + final value = BigInt.parse('9007199254740993'); + expect(BigInt.from(xelisStorageInt(value)), value); + expect(xelisStorageInt(BigInt.zero), 0); + expect( + BigInt.from(xelisStorageInt(BigInt.parse('9223372036854775807'))), + BigInt.parse('9223372036854775807'), + ); + expect(() => xelisStorageInt(BigInt.from(-1)), throwsRangeError); + expect( + () => xelisStorageInt(BigInt.parse('9223372036854775808')), + throwsRangeError, + ); + }, + ); + + test('daemon origin preserves TLS and brackets IPv6', () { + expect( + xelisDaemonOrigin(host: 'node.example', port: 443, useSSL: true), + 'https://node.example', + ); + expect( + xelisDaemonOrigin(host: '::1', port: 8080, useSSL: false), + 'http://[::1]:8080', + ); + for (final host in [ + 'https://node.example', + 'user@host', + 'host/json_rpc', + '', + ]) { + expect( + () => xelisDaemonOrigin(host: host, port: 443, useSSL: true), + throwsArgumentError, + ); + } + }); +} diff --git a/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart b/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart index df1d41ac5d..32f61c5382 100644 --- a/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart +++ b/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart @@ -1,22 +1,12 @@ //ON -import 'dart:convert'; +import 'dart:async'; import 'package:logger/logger.dart'; -import 'package:xelis_dart_sdk/src/data_transfer_objects/get_asset/max_supply_mode.dart'; -import 'package:xelis_dart_sdk/xelis_dart_sdk.dart' as xelis_sdk; -import 'package:xelis_flutter/src/api/api.dart' as xelis_api; -import 'package:xelis_flutter/src/api/logger.dart' as xelis_logging; -import 'package:xelis_flutter/src/api/models/wallet_dtos.dart' as x_wallet_dtos; -import 'package:xelis_flutter/src/api/network.dart' as x_network; -import 'package:xelis_flutter/src/api/precomputed_tables.dart' as x_tables; -import 'package:xelis_flutter/src/api/progress_report.dart' as x_report; -import 'package:xelis_flutter/src/api/seed_search_engine.dart' as x_seed; -import 'package:xelis_flutter/src/api/utils.dart' as x_utils; -import 'package:xelis_flutter/src/api/wallet.dart' as x_wallet; -import 'package:xelis_flutter/src/frb_generated.dart' as xelis_rust; +import 'package:path/path.dart' as path; +import 'package:xelis_dart_sdk/xelis_dart_sdk.dart' as sdk; +import 'package:xelis_wallet_flutter/xelis_wallet_flutter.dart' as xwf; import '../../providers/progress_report/xelis_table_progress_provider.dart'; -import '../../utilities/dynamic_object.dart'; import '../../utilities/logger.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; //END_ON @@ -25,214 +15,185 @@ import '../interfaces/lib_xelis_interface.dart'; LibXelisInterface get libXelis => _getInterface(); //OFF -LibXelisInterface _getInterface() => throw Exception("XEL not enabled!"); - +LibXelisInterface _getInterface() => throw StateError('XEL not enabled'); //END_OFF //ON -LibXelisInterface _getInterface() => const _LibXelisInterfaceImpl(); +final _interface = _LibXelisInterfaceImpl(); +LibXelisInterface _getInterface() => _interface; -extension _OpaqueXelisWalletExt on OpaqueXelisWallet { - x_wallet.XelisWallet get actual => get(); +extension on OpaqueXelisWallet { + xwf.XelisWallet get actual => get(); } final class _LibXelisInterfaceImpl extends LibXelisInterface { - const _LibXelisInterfaceImpl(); + StreamSubscription? _logs; + StreamSubscription? _progress; + final _progressEvents = StreamController.broadcast(); + XelisTableProgressState _lastProgress = const XelisTableProgressState(); @override - String get xelisAsset => xelis_sdk.xelisAsset; + String get xelisAsset => sdk.xelisAsset; @override - Future initRustLib() => xelis_rust.RustLib.init(); + Future initRustLib() async { + await xwf.XelisWalletFlutter.initialize(); + await xwf.XelisWalletFlutter.initializeConfiguration(); + await xwf.XelisWalletFlutter.initializeCryptoProvider(); + _progress ??= xwf.XelisWalletFlutter.createProgressReportStream().listen(( + report, + ) { + _lastProgress = XelisTableProgressState( + tableProgress: report.progress, + currentStep: XelisTableGenerationStep.fromString(report.step), + ); + _progressEvents.add(_lastProgress); + }, onError: _progressEvents.addError); + } @override - Future setupRustLogger() => xelis_api.setUpRustLogger(); + Future setupRustLogger() => xwf.XelisWalletFlutter.initializeRustLogger( + scope: xwf.XelisNativeLogScope.standard, + ); @override - void startListeningToRustLogs() => xelis_api.createLogStream().listen( - (logEntry) { - final Level level; - switch (logEntry.level) { - case xelis_logging.Level.error: - level = Level.error; - case xelis_logging.Level.warn: - level = Level.warning; - case xelis_logging.Level.info: - level = Level.info; - case xelis_logging.Level.debug: - level = Level.debug; - case xelis_logging.Level.trace: - level = Level.trace; - } - - Logging.instance.log( - level, - "[Xelis Rust Log] ${logEntry.tag}: ${logEntry.msg}", - ); - }, - onError: (dynamic e) { - Logging.instance.e("Error receiving Xelis Rust logs: $e"); - }, - ); + void startListeningToRustLogs() { + _logs ??= xwf.XelisWalletFlutter.createRustLogStream().listen( + (entry) => Logging.instance.log(switch (entry.level) { + xwf.XelisLogLevel.error => Level.error, + xwf.XelisLogLevel.warn => Level.warning, + xwf.XelisLogLevel.info => Level.info, + xwf.XelisLogLevel.debug => Level.debug, + xwf.XelisLogLevel.trace => Level.trace, + }, '[Xelis] ${entry.message}'), + onError: (Object error, StackTrace stack) => Logging.instance.e( + 'Xelis log stream failed', + error: error, + stackTrace: stack, + ), + ); + } @override - Stream createProgressReportStream() { - double lastPrintedProgress = 0.0; - XelisTableGenerationStep? lastStep; - - return xelis_api.createProgressReportStream().map((report) { - return report.when( - tableGeneration: (progress, step, message) { - final currentStep = XelisTableGenerationStep.fromString(step); - - final hasProgressJump = - (progress - lastPrintedProgress).abs() >= 0.05; - final stepChanged = currentStep != lastStep; - final isFinished = progress >= 0.99; - - if (hasProgressJump || stepChanged || isFinished) { - final percent = (progress * 100).toStringAsFixed(1); - final extra = (message != null && message.isNotEmpty) - ? ' – $message' - : ''; - - Logging.instance.d( - 'Xelis Table Generation: $step - $percent%$extra', - ); - - lastPrintedProgress = progress; - lastStep = currentStep; - } - - return XelisTableProgressState( - tableProgress: progress, - currentStep: currentStep, - ); - }, - misc: (message) { - if (message != null && message.isNotEmpty) { - Logging.instance.d('Xelis Table Generation (misc): $message'); - } - return const XelisTableProgressState(); - }, - ); - }); + Stream createProgressReportStream() async* { + yield _lastProgress; + yield* _progressEvents.stream; } @override bool isAddressValid({ required String address, required CryptoCurrencyNetwork network, - }) => x_utils.isAddressValid( - strAddress: address, - network: network.xelisNetwork, + }) => xwf.XelisWalletFlutter.isAddressValid( + address: address, + network: _network(network), ); @override bool validateSeedWord(String word) { - return x_seed.SearchEngine.init( - languageIndex: BigInt.from(0), - ).search(query: word).isNotEmpty; + final engine = xwf.XelisWalletFlutter.createSeedSearchEngine( + language: xwf.SeedLanguage.english, + ); + try { + return word.isNotEmpty && engine.findInvalidWords(words: [word]).isEmpty; + } finally { + engine.dispose(); + } } @override - Stream eventsStream(OpaqueXelisWallet wallet) async* { - final rawEventStream = wallet.actual.eventsStream(); - - await for (final rawData in rawEventStream) { - final json = jsonDecode(rawData); - try { - final eventType = xelis_sdk.WalletEvent.fromStr( - json['event'] as String, - ); - switch (eventType) { - case xelis_sdk.WalletEvent.newTopoHeight: - yield NewTopoheight(json['data']['topoheight'] as int); - case xelis_sdk.WalletEvent.newAsset: - final data = xelis_sdk.AssetData.fromJson( - json['data'] as Map, - ); - - yield NewAsset( - data.name, - data.decimals, - DynamicObject(data.maxSupply), - ); - case xelis_sdk.WalletEvent.newTransaction: - final tx = xelis_sdk.TransactionEntry.fromJson( - json['data'] as Map, - ); - yield NewTransaction( - TransactionEntryWrapper( - tx, - entryType: _entryTypeConversion(tx.txEntryType), - hash: tx.hash, - timestamp: tx.timestamp, - topoheight: tx.topoheight, - ), - ); - case xelis_sdk.WalletEvent.newPendingTransaction: - continue; - case xelis_sdk.WalletEvent.balanceChanged: - final data = xelis_sdk.BalanceChangedEvent.fromJson( - json['data'] as Map, - ); - yield BalanceChanged(data.assetHash, data.balance); - case xelis_sdk.WalletEvent.trackAsset: - // TODO - continue; - case xelis_sdk.WalletEvent.untrackAsset: - // TODO - continue; - case xelis_sdk.WalletEvent.rescan: - yield Rescan(json['data']['start_topoheight'] as int); - case xelis_sdk.WalletEvent.online: - yield const Online(); - case xelis_sdk.WalletEvent.offline: - yield const Offline(); - case xelis_sdk.WalletEvent.historySynced: - yield HistorySynced(json['data']['topoheight'] as int); - case xelis_sdk.WalletEvent.syncError: - print("ERROR SYNCING: ${json['data']['message']}"); - yield const Offline(); // TODO: make a message describing the error with json['data']['message'] - } - } catch (e, s) { - Logging.instance.e( - "Error processing xelis wallet event: $rawData", - error: e, - stackTrace: s, - ); - continue; - } - } + Future subscribeRuntimeEvents( + OpaqueXelisWallet wallet, + ) async { + final subscription = await wallet.actual.subscribeRuntimeEvents(); + return XelisEventSubscription( + cancel: subscription.cancel, + events: subscription.events.map( + (frame) => switch (frame.event) { + xwf.XelisWalletOnline() => const Online(), + xwf.XelisWalletOffline() => const Offline(), + xwf.XelisWalletTopoheightChanged(:final topoheight) => NewTopoheight( + topoheight, + ), + xwf.XelisWalletHistorySynced(:final topoheight) => HistorySynced( + topoheight, + ), + xwf.XelisWalletRescanStarted(:final startTopoheight) => Rescan( + startTopoheight, + ), + xwf.XelisWalletSyncIssue(:final failure) => XelisSyncIssue(failure), + xwf.XelisWalletEventStreamDegraded(:final failure) => + XelisStateInvalidated(failure: failure), + xwf.XelisWalletEventStreamClosed(:final failure) => + XelisChannelClosed(failure, isRuntime: true), + }, + ), + ); + } + + @override + Future subscribeBusinessEvents( + OpaqueXelisWallet wallet, + ) async { + final subscription = await wallet.actual.subscribeBusinessEvents(); + return XelisEventSubscription( + cancel: subscription.cancel, + events: subscription.events.map( + (frame) => switch (frame.event) { + xwf.XelisWalletNewTransaction(:final transaction) => NewTransaction( + _confirmed(transaction), + ), + xwf.XelisWalletNewPendingTransaction(:final transaction) => + NewTransaction(_pending(transaction)), + xwf.XelisWalletBalanceChanged(:final asset, :final balance) => + BalanceChanged(asset, balance), + xwf.XelisWalletNewAsset() || + xwf.XelisWalletAssetTracked() || + xwf.XelisWalletAssetUntracked() => const XelisStateInvalidated(), + xwf.XelisWalletBusinessEventStreamDegraded(:final failure) => + XelisStateInvalidated(failure: failure), + xwf.XelisWalletBusinessEventStreamClosed(:final failure) => + XelisChannelClosed(failure, isRuntime: false), + }, + ), + ); } @override Future onlineMode( OpaqueXelisWallet wallet, { required String daemonAddress, - }) => wallet.actual.onlineMode(daemonAddress: daemonAddress); + }) => wallet.actual.setOnline(daemonAddress: daemonAddress); @override Future offlineMode(OpaqueXelisWallet wallet) => - wallet.actual.offlineMode(); + wallet.actual.setOffline(); + + @override + Future closeWallet(OpaqueXelisWallet wallet) async { + try { + await wallet.actual.close(); + } finally { + wallet.actual.dispose(); + } + } @override Future updateTables({ required String precomputedTablesPath, required bool stack_l1Low, - }) async { - // TODO: add more granular table size management interface - // for now, just patching the old system into the new FFI API - - x_tables.PrecomputedTableType tableType = stack_l1Low - ? x_tables.PrecomputedTableType.l1Low() - : x_tables.PrecomputedTableType.l1Full(); + }) => xwf.XelisWalletFlutter.updatePrecomputedTables( + path: precomputedTablesPath, + type: _tableType(stack_l1Low), + ); - return x_wallet.updateTables( - precomputedTablesPath: precomputedTablesPath, - precomputedTableType: tableType, - ); - } + @override + Future hasTables({ + required String precomputedTablesPath, + required bool stack_l1Low, + }) => xwf.XelisWalletFlutter.hasPrecomputedTables( + path: precomputedTablesPath, + type: _tableType(stack_l1Low), + ); @override Future getSeed(OpaqueXelisWallet wallet) => wallet.actual.getSeed(); @@ -249,24 +210,25 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { String? precomputedTablesPath, bool? stack_l1Low, }) async { - // TODO: add more granular table size management interface - // for now, just patching the old system into the new FFI API - - x_tables.PrecomputedTableType tableType = stack_l1Low ?? false - ? x_tables.PrecomputedTableType.l1Low() - : x_tables.PrecomputedTableType.l1Full(); - - final wallet = await x_wallet.createXelisWallet( - name: name, - directory: directory, - password: password, - privateKey: privateKey, - seed: seed, - network: network.xelisNetwork, - precomputedTablesPath: precomputedTablesPath, - precomputedTableType: tableType, - ); - + if (privateKey != null) + throw UnsupportedError('Stack Xelis uses mnemonic recovery'); + final walletPath = _walletPath(walletId, name, directory); + final wallet = seed == null + ? await xwf.XelisWalletFlutter.createWallet( + walletPath: walletPath, + password: password, + network: _network(network), + precomputedTablesPath: precomputedTablesPath, + precomputedTableType: _tableType(stack_l1Low ?? true), + ) + : await xwf.XelisWalletFlutter.recoverWalletFromSeed( + walletPath: walletPath, + password: password, + seed: seed, + network: _network(network), + precomputedTablesPath: precomputedTablesPath, + precomputedTableType: _tableType(stack_l1Low ?? true), + ); return OpaqueXelisWallet(wallet); } @@ -279,220 +241,313 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { required CryptoCurrencyNetwork network, String? precomputedTablesPath, bool? stack_l1Low, - }) async { - // TODO: add more granular table size management interface - // for now, just patching the old system into the new FFI API - - x_tables.PrecomputedTableType tableType = (stack_l1Low ?? false) - ? x_tables.PrecomputedTableType.l1Low() - : x_tables.PrecomputedTableType.l1Full(); - - final wallet = await x_wallet.openXelisWallet( - name: name, - directory: directory, + }) async => OpaqueXelisWallet( + await xwf.XelisWalletFlutter.openWallet( + walletPath: _walletPath(walletId, name, directory), password: password, - network: network.xelisNetwork, + network: _network(network), precomputedTablesPath: precomputedTablesPath, - precomputedTableType: tableType, - ); - - return OpaqueXelisWallet(wallet); - } + precomputedTableType: _tableType(stack_l1Low ?? true), + ), + ); @override - String getAddress(OpaqueXelisWallet wallet) => wallet.actual.getAddressStr(); + String getAddress(OpaqueXelisWallet wallet) => wallet.actual.address; @override - Future getDaemonInfo(OpaqueXelisWallet wallet) => - wallet.actual.getDaemonInfo(); + Future getDaemonInfo(OpaqueXelisWallet wallet) async { + final info = await wallet.actual.getDaemonInfo(); + return XelisDaemonSnapshot( + topoheight: info.topoheight, + stableTopoheight: info.stableTopoheight, + prunedTopoheight: info.prunedTopoheight, + ); + } @override Future isOnline(OpaqueXelisWallet wallet) => wallet.actual.isOnline(); - + @override + Future isSyncing(OpaqueXelisWallet wallet) => wallet.actual.isSyncing(); @override Future rescan(OpaqueXelisWallet wallet, {required BigInt topoheight}) => wallet.actual.rescan(topoheight: topoheight); + @override + Future getXelisBalanceRaw(OpaqueXelisWallet wallet) => + wallet.actual.getXelisBalance(); @override Future> allHistory( - OpaqueXelisWallet wallet, - ) async => (await wallet.actual.allHistory()).map((e) { - final tx = _checkDecodeJsonStringTxEntry(e); - return TransactionEntryWrapper( - tx, - entryType: _entryTypeConversion(tx.txEntryType), - hash: tx.hash, - timestamp: tx.timestamp, - topoheight: tx.topoheight, + OpaqueXelisWallet wallet, { + BigInt? minTopoheight, + }) async { + final pending = await wallet.actual.pendingTransactions(); + final confirmed = await wallet.actual.history( + filter: xwf.XelisWalletHistoryFilter( + page: BigInt.one, + minTopoheight: minTopoheight, + ), ); - }).toList(); + // Confirmed state wins if the native wallet transitions during these reads. + final byHash = {for (final tx in pending) tx.hash: _pending(tx)}; + for (final tx in confirmed) { + byHash[tx.hash] = _confirmed(tx); + } + return byHash.values.toList(); + } @override - Future broadcastTransaction( + Future estimateFees( OpaqueXelisWallet wallet, { - required String txHash, - }) => wallet.actual.broadcastTransaction(txHash: txHash); + required List transfers, + }) => wallet.actual.estimateTransferFees( + transfers: transfers.map(_transfer).toList(), + ); @override - Future createTransfersTransaction( + Future prepareTransfers( OpaqueXelisWallet wallet, { - required List transfers, - }) => wallet.actual.createTransfersTransaction( - transfers: transfers - .map( - (e) => x_wallet_dtos.Transfer( - floatAmount: e.floatAmount, - strAddress: e.strAddress, - assetHash: e.assetHash, - extraData: e.extraData, - ), - ) - .toList(), + required List transfers, + }) async => _prepared( + await wallet.actual.prepareTransfers( + transfers: transfers.map(_transfer).toList(), + ), ); @override - Future estimateFees( + Future prepareTransferAll( OpaqueXelisWallet wallet, { - required List transfers, - }) => wallet.actual.estimateFees( - transfers: transfers - .map( - (e) => x_wallet_dtos.Transfer( - floatAmount: e.floatAmount, - strAddress: e.strAddress, - assetHash: e.assetHash, - extraData: e.extraData, - ), - ) - .toList(), + required String destination, + }) async => _prepared( + await wallet.actual.prepareTransferAll( + destination: destination, + asset: xelisAsset, + ), ); @override - Future formatCoin( + Future discardPreparedTransaction( OpaqueXelisWallet wallet, { - required BigInt atomicAmount, - String? assetHash, - }) => wallet.actual.formatCoin( - atomicAmount: atomicAmount, - assetHash: assetHash, + required XelisPreparedTransaction transaction, + }) => wallet.actual.discardPreparedTransaction( + transaction: transaction.handle(), ); @override - Future getAssetDecimals( + Future broadcastTransaction( OpaqueXelisWallet wallet, { - required String asset, - }) => wallet.actual.getAssetDecimals(asset: asset); - - @override - Future getXelisBalanceRaw(OpaqueXelisWallet wallet) => - wallet.actual.getXelisBalanceRaw(); - - @override - Future hasXelisBalance(OpaqueXelisWallet wallet) => - wallet.actual.hasXelisBalance(); + required XelisPreparedTransaction transaction, + }) async => switch (await wallet.actual.broadcastPreparedTransaction( + transaction: transaction.handle(), + )) { + xwf.XelisWalletBroadcastSubmitted() => const XelisBroadcastOutcome( + XelisBroadcastDisposition.submitted, + ), + xwf.XelisWalletBroadcastRetryable(:final failure) => XelisBroadcastOutcome( + XelisBroadcastDisposition.retryable, + failure: failure, + ), + xwf.XelisWalletBroadcastRejected(:final failure) => XelisBroadcastOutcome( + XelisBroadcastDisposition.rejected, + failure: failure, + ), + xwf.XelisWalletBroadcastLocalFailure(:final failure) => + XelisBroadcastOutcome( + XelisBroadcastDisposition.localFailure, + failure: failure, + ), + xwf.XelisWalletBroadcastSubmittedNeedsResync(:final failure) => + XelisBroadcastOutcome( + XelisBroadcastDisposition.submittedNeedsResync, + failure: failure, + ), + }; @override - Future testDaemonConnection(String endPoint, bool useSSL) async { + Future testDaemonConnection( + String endPoint, + bool useSSL, + CryptoCurrencyNetwork network, + ) async { + final daemon = sdk.DaemonClient( + endPoint: endPoint, + secureWebSocket: useSSL, + timeout: 5000, + ); try { - final daemon = xelis_sdk.DaemonClient( - endPoint: endPoint, - secureWebSocket: useSSL, - timeout: 5000, - ); daemon.connect(); - final xelis_sdk.GetInfoResult networkInfo = await daemon.getInfo(); - daemon.disconnect(); - - Logging.instance.i( - "Xelis testNodeConnection result: \"${networkInfo.toString()}\"", - ); - return true; - } catch (e, s) { - Logging.instance.w( - "xelis daemon connection test failed, returning false.", - error: e, - stackTrace: s, - ); + final info = await daemon.getInfo(); + return info.network.name == _network(network).name; + } on sdk.RpcException { return false; + } finally { + daemon.disconnect(); } } } -extension _XelisNetworkConversion on CryptoCurrencyNetwork { - x_network.Network get xelisNetwork { - switch (this) { - case CryptoCurrencyNetwork.main: - return x_network.Network.mainnet; - case CryptoCurrencyNetwork.test: - return x_network.Network.testnet; - default: - throw ArgumentError('Unsupported network type for Xelis: $this'); - } +xwf.XelisNetwork _network(CryptoCurrencyNetwork network) => switch (network) { + CryptoCurrencyNetwork.main => xwf.XelisNetwork.mainnet, + CryptoCurrencyNetwork.test => xwf.XelisNetwork.testnet, + CryptoCurrencyNetwork.stage => xwf.XelisNetwork.stagenet, + _ => throw ArgumentError('Unsupported Xelis network'), +}; + +xwf.XelisPrecomputedTableType _tableType(bool low) => low + ? const xwf.XelisPrecomputedTableType.l1Low() + : const xwf.XelisPrecomputedTableType.l1Full(); + +String _walletPath(String walletId, String name, String directory) { + if (walletId != name || + !RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(name) || + name.toLowerCase() == 'table' || + path.isAbsolute(name)) { + throw ArgumentError('Invalid Xelis wallet identifier'); } + return path.join(directory, name); } -extension _CryptoCurrencyNetworkConversion on x_network.Network { - CryptoCurrencyNetwork get cryptoCurrencyNetwork { - switch (this) { - case x_network.Network.mainnet: - return CryptoCurrencyNetwork.main; - case x_network.Network.testnet: - return CryptoCurrencyNetwork.test; - default: - throw ArgumentError('Unsupported Xelis network type: $this'); - } - } +xwf.XelisWalletTransferRequest _transfer(XelisTransfer transfer) => + xwf.XelisWalletTransferRequest( + destination: transfer.destination, + asset: transfer.asset, + amountAtomic: transfer.amountAtomic, + ); + +XelisPreparedTransaction _prepared( + xwf.XelisWalletPreparedTransaction transaction, +) { + final details = transaction.details; + if (details is! xwf.XelisWalletPreparedTransfers) + throw StateError('Expected a Xelis transfer'); + return XelisPreparedTransaction( + handle: transaction, + hash: transaction.hash, + feeAtomic: transaction.feeAtomic, + transfers: details.transfers + .map( + (transfer) => XelisPreparedTransfer( + destination: transfer.destination, + amountAtomic: transfer.amountAtomic, + asset: transfer.asset, + hasExtraData: transfer.hasExtraData, + ), + ) + .toList(), + ); } -EntryWrapper _entryTypeConversion(xelis_sdk.TransactionEntryType entryType) { - if (entryType is xelis_sdk.CoinbaseEntry) { - return CoinbaseEntryWrapper(reward: entryType.reward); - } else if (entryType is xelis_sdk.BurnEntry) { - return BurnEntryWrapper( - amount: entryType.amount, - fee: entryType.fee, - asset: entryType.asset, +TransactionEntryWrapper _confirmed(xwf.XelisWalletTransactionEntry tx) => + TransactionEntryWrapper( + tx, + entryType: _entry(tx.entry), + hash: tx.hash, + timestamp: DateTime.fromMillisecondsSinceEpoch( + xelisStorageInt(tx.timestampMillis), + isUtc: true, + ), + topoheight: tx.topoheight, ); - } else if (entryType is xelis_sdk.IncomingEntry) { - return IncomingEntryWrapper( - from: entryType.from, - transfers: entryType.transfers - .map( - (e) => ( - amount: e.amount, - asset: e.asset, - extraData: e.extraData?.toJson(), - ), - ) - .toList(), + +TransactionEntryWrapper _pending(xwf.XelisWalletPendingTransaction tx) => + TransactionEntryWrapper( + tx, + entryType: _entry(tx.entry), + hash: tx.hash, + timestamp: DateTime.fromMillisecondsSinceEpoch( + xelisStorageInt(tx.timestampMillis), + isUtc: true, + ), + topoheight: null, ); - } else if (entryType is xelis_sdk.OutgoingEntry) { - return OutgoingEntryWrapper( - nonce: entryType.nonce, - fee: entryType.fee, - transfers: entryType.transfers + +EntryWrapper _entry( + xwf.XelisWalletTransactionEntryData entry, +) => switch (entry) { + xwf.XelisWalletCoinbaseEntry(:final reward) => CoinbaseEntryWrapper( + reward: reward, + ), + xwf.XelisWalletBurnEntry(:final amount, :final fee, :final asset) => + BurnEntryWrapper(amount: amount, fee: fee, asset: asset), + xwf.XelisWalletIncomingEntry(:final from, :final transfers) => + IncomingEntryWrapper( + from: from, + transfers: transfers + .map((e) => (amount: e.amount, asset: e.asset, extraData: null)) + .toList(), + ), + xwf.XelisWalletOutgoingEntry(:final nonce, :final fee, :final transfers) => + OutgoingEntryWrapper( + nonce: nonce, + fee: fee, + transfers: transfers .map( (e) => ( destination: e.destination, amount: e.amount, asset: e.asset, - extraData: e.extraData?.toJson(), + extraData: null, ), ) .toList(), - ); - } else { - return UnknownEntryWrapper(); - } -} - -xelis_sdk.TransactionEntry _checkDecodeJsonStringTxEntry(String jsonString) { - final json = jsonDecode(jsonString); - if (json is Map) { - return xelis_sdk.TransactionEntry.fromJson(json.cast()); - } - - throw Exception("Not a Map on jsonDecode($jsonString)"); -} - + ), + xwf.XelisWalletMultisigEntry(:final fee, :final nonce) => + XelisActionEntryWrapper( + kind: 'multisig', + spent: BigInt.zero, + received: BigInt.zero, + fee: fee, + nonce: nonce, + ), + xwf.XelisWalletOutgoingBlobEntry(:final fee, :final nonce) => + XelisActionEntryWrapper( + kind: 'blob', + spent: BigInt.zero, + received: BigInt.zero, + fee: fee, + nonce: nonce, + ), + xwf.XelisWalletIncomingContractEntry(:final transfers) => + XelisActionEntryWrapper( + kind: 'incoming_contract', + spent: BigInt.zero, + received: transfers + .expand((group) => group.transfers) + .where((e) => e.asset == sdk.xelisAsset) + .fold(BigInt.zero, (sum, e) => sum + e.amount), + fee: BigInt.zero, + ), + xwf.XelisWalletInvokeContractEntry( + :final deposits, + :final received, + :final fee, + :final maxGas, + :final nonce, + ) => + XelisActionEntryWrapper( + kind: 'invoke_contract', + spent: _xelAmounts(deposits) + maxGas, + received: _xelAmounts(received.expand((group) => group.transfers)), + fee: fee, + nonce: nonce, + ), + xwf.XelisWalletDeployContractEntry(:final fee, :final nonce, :final invoke) => + XelisActionEntryWrapper( + kind: 'deploy_contract', + // Pinned core db59b5c: BURN_PER_CONTRACT = COIN_VALUE (1 XEL). + spent: + BigInt.from(100000000) + + (invoke == null + ? BigInt.zero + : _xelAmounts(invoke.deposits) + invoke.maxGas), + received: BigInt.zero, + fee: fee, + nonce: nonce, + ), + // A received blob moves no XEL and Stack has no messaging UI. + xwf.XelisWalletIncomingBlobEntry() => UnknownEntryWrapper(), +}; + +BigInt _xelAmounts(Iterable amounts) => amounts + .where((item) => item.asset == sdk.xelisAsset) + .fold(BigInt.zero, (sum, item) => sum + item.amount); //END_ON