Merge pull request #1450 from Ez3kiel-dev/fix/xelis-integration
What changed, and why it matters
This is a large feature/fix merge that restores and rewrites the Xelis (XEL) cryptocurrency integration in Stack Wallet. It swaps the old hand-rolled Xelis code for a new generated native interface (XWF), adds wallet restore/backup support, improves send-flow safety by cancelling prepared transactions when the user leaves the confirmation screen, and adds many integration tests. There is no direct evidence in the commit of an active security vulnerability being patched; the changes look like a substantial integration repair with several defensive-hardening improvements.
Treat this as a routine but large integration update. Review the new Xelis send/prepare/confirm flow for race conditions and ensure the cancelSend cleanup is invoked on every early-exit path. Run the new integration tests (adapter, restore, SWB, local transfer) before release. No emergency patch is indicated by the diff alone.
Security signals we found
Send-flow lifecycle hardening: prepared Xelis transactions are now discarded via cancelSend when the user cancels or the widget is disposed
Session-generation checks prevent stale wallet handles from being used after close/reopen
Mutex serialization added around send preparation, balance, history, and rescan operations
Native wallet directory is deleted when a Xelis wallet is removed from the app
Address mismatch check during init prevents loading a wallet whose native address differs from the cached one
No explicit security bug or CVE is mentioned in the commit message or diff
Evidence from the diff
The commit merges PR #1450 to fix the Xelis integration. Key changes: (1) replaces direct libXelis calls with a generated xelis_wallet_flutter (XWF) interface and typed wrappers; (2) rewrites XelisWallet initialization, balance/history persistence, send preparation, and event handling with session-generation checks and mutex serialization; (3) adds cancelSend cleanup in the send and confirm-transaction UI flows to discard prepared native transactions when the user cancels or navigates away; (4) updates satsPerCoin to derive from fractionDigits instead of a hard-coded 1e9; (5) deletes native wallet storage on wallet deletion; (6) adds integration/unit tests for adapter, local devnet transfers, restore, and SWB backup. The diff is mostly additive/refactoring; security-relevant signals are the lifecycle cleanup and concurrency guards, not a disclosed vulnerability fix.
Changed components
lib/wallets/wallet/impl/xelis_wallet.dartlib/wallets/wallet/intermediate/lib_xelis_wallet.dartlib/wl_gen/interfaces/lib_xelis_interface.dartlib/wl_gen/interfaces/xelis_types.dartlib/wallets/models/tx_data.dartlib/wallets/models/xelis_transaction.dartlib/pages/send_view/send_view.dartlib/pages/send_view/confirm_transaction_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dartlib/services/wallets.dartlib/wallets/crypto_currency/coins/xelis.dartlib/main.dartintegration_test/xelis/*test/wallets/xelis_*Inspect captured patch +4298 / −1626
### 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 or newer and Rustup. Native Assets builds the
+ native wallet library and installs its pinned Rust toolchain automatically.
+ 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,27 @@ dart run coinlib:build_windows
flutter run -d windows
```
+## Xelis tests
+
+Run on a configured desktop target (`windows`, `linux`, or `macos`) with
+the application's native plugin dependencies:
+
+```sh
+flutter test integration_test/xelis/xelis_adapter_test.dart -d windows
+flutter test integration_test/xelis/xelis_restore_test.dart -d windows
+flutter test integration_test/xelis/xelis_swb_test.dart -d windows
+```
+
+The transfer test starts a temporary devnet node using a host `xelis_daemon`
+1.25 executable:
+
+```sh
+flutter test integration_test/xelis/xelis_local_transfer_test.dart -d windows \
+ --dart-define=XELIS_LOCAL_DAEMON=/absolute/path/to/xelis_daemon
+```
+
+Missing `XELIS_LOCAL_DAEMON` or an invalid path fails the test.
+
# 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.
### integration_test/xelis/xelis_adapter_test.dart
@@ -0,0 +1,64 @@
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_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() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+ testWidgets(
+ 'Stack adapter opens native storage and exposes typed zero state',
+ (tester) 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 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)),
+ );
+}
### integration_test/xelis/xelis_local_transfer_test.dart
@@ -0,0 +1,367 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_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<void> refresh({int? topoheight}) async {}
+}
+
+class LocalRpcHttp extends HttpOverrides {}
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+ const executable = String.fromEnvironment('XELIS_LOCAL_DAEMON');
+ testWidgets(
+ 'real normal and maximum transfers confirm reviewed hashes and balances',
+ (tester) async {
+ if (!(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
+ fail('The devnet scenario requires a desktop host.');
+ }
+ if (executable.isEmpty || !File(executable).existsSync()) {
+ fail(
+ 'Set XELIS_LOCAL_DAEMON to an existing host xelis_daemon executable.',
+ );
+ }
+ 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 = <String>[];
+ final walletEvents = <String>[];
+ String phase = 'daemon startup';
+ final handles = <OpaqueXelisWallet>[];
+ final subscriptions = <XelisEventSubscription>[];
+ Future<dynamic> rpc(String method, [Map<String, dynamic>? 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<String, dynamic>;
+ if (result['error'] != null) {
+ throw StateError('Local RPC $method failed: ${result['error']}');
+ }
+ return result['result'];
+ }
+
+ Future<void> eventually(Future<bool> 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<void>.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<void> 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<void>();
+ 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);
+ }
+ }
+ }
+ },
+
+ timeout: const Timeout(Duration(minutes: 5)),
+ );
+}
### integration_test/xelis/xelis_restore_test.dart
@@ -0,0 +1,101 @@
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:stackwallet/db/isar/main_db.dart';
+import 'package:stackwallet/models/isar/models/blockchain_data/address.dart';
+import 'package:stackwallet/services/node_service.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 '../../test/wallets/support/xelis_test_fakes.dart';
+
+class OfflineNodes extends Fake implements NodeService {}
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+ setUp(() async {
+ if (!(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
+ throw UnsupportedError('Run this storage scenario on a desktop host.');
+ }
+ final root = await Directory.systemTemp.createTemp('stack_xelis_restore_');
+ addTearDown(() => root.delete(recursive: true));
+ StackFileSystem.setDesktopOverrideDir(root.path);
+ await MainDB.instance.initMainDB();
+ addTearDown(() => MainDB.instance.isar.close());
+ });
+ testWidgets(
+ 'native Stack factory restores exported recovery data and reloads',
+ (tester) async {
+ await libXelis.initRustLib();
+ final secrets = MemorySecrets();
+ final prefs = SessionPrefs();
+ final nodes = OfflineNodes();
+ final opened = <XelisWallet>[];
+ addTearDown(() async {
+ for (final item in opened.reversed) {
+ await item.exit();
+ }
+ });
+ Future<XelisWallet> 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(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);
+ },
+ );
+}
### integration_test/xelis/xelis_swb_test.dart
@@ -0,0 +1,189 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_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 '../../test/wallets/support/xelis_test_fakes.dart';
+
+class UnusedShopService extends Fake implements ShopInBitService {}
+
+class TestWakelock extends WakelockPlusPlatformInterface {
+ @override
+ Future<void> toggle({required bool enable}) async {}
+}
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets(
+ 'encrypted SWB restores native Xelis with duplicate ID and notes',
+ (tester) async {
+ if (!(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
+ fail('Run this storage scenario on a desktop host.');
+ }
+ final root = await Directory.systemTemp.createTemp('stack_xelis_swb_');
+ StackFileSystem.setDesktopOverrideDir(root.path);
+ final opened = <Wallet>[];
+ 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 MainDB.instance.initMainDB();
+ final hive = DB.instance.hive..init('${root.path}/hive');
+ hive.registerAdapter(NodeModelAdapter());
+ await hive.openBox<dynamic>(DB.boxNamePrefs);
+ await hive.openBox<NodeModel>(DB.boxNameNodeModels);
+ await hive.openBox<Trade>(DB.boxNameTradesV2);
+ await hive.openBox<String>(DB.boxNameTradeNotes);
+ await hive.openBox<TradeWalletLookup>(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<String, dynamic>;
+ 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)),
+ );
+}
### lib/main.dart
@@ -90,11 +90,11 @@ final openedFromSWBFileStringStateProvider = StateProvider<String?>(
// runs the MyApp widget and checks for new users, caching the value in the
// miscellaneous box for later use
void main(List<String> args) async {
+ WidgetsFlutterBinding.ensureInitialized();
// talker.info('initializing Rust lib ...');
if (AppConfig.coins.whereType<Xelis>().isNotEmpty) {
await libXelis.initRustLib();
}
- WidgetsFlutterBinding.ensureInitialized();
if (Util.isDesktop && args.length == 2 && args.first == "-d") {
StackFileSystem.setDesktopOverrideDir(args.last);
### 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<void> 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<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .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<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .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<StackColors>()!.background,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(
Constants.size.circularBorderRadius,
@@ -1091,9 +1100,9 @@ class _ConfirmTransactionViewState
),
Container(
height: 1,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
),
Padding(
padding: const EdgeInsets.all(12),
@@ -1128,9 +1137,9 @@ class _ConfirmTransactionViewState
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
),
],
@@ -1139,9 +1148,9 @@ class _ConfirmTransactionViewState
if (widget.isPaynymTransaction)
Container(
height: 1,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
),
if (widget.isPaynymTransaction)
Padding(
@@ -1163,9 +1172,9 @@ class _ConfirmTransactionViewState
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
),
],
@@ -1174,9 +1183,9 @@ class _ConfirmTransactionViewState
if (coin is Ethereum)
Container(
height: 1,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
),
if (coin is Ethereum)
Padding(
@@ -1198,9 +1207,9 @@ class _ConfirmTransactionViewState
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
),
],
@@ -1331,9 +1340,9 @@ class _ConfirmTransactionViewState
focusNode: _noteFocusNode,
style: STextStyles.desktopTextExtraSmall(context)
.copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
height: 1.8,
),
onChanged: (_) => setState(() {}),
@@ -1393,9 +1402,9 @@ class _ConfirmTransactionViewState
horizontal: 16,
vertical: 18,
),
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .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<StackColors>()!.textFieldDefaultBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .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<StackColors>()!.snackBarBackSuccess,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .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<StackColors>()!
- .textConfirmTotalAmount,
- )
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textConfirmTotalAmount,
+ )
: STextStyles.titleBold12(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
@@ -1471,13 +1479,12 @@ class _ConfirmTransactionViewState
.watch(pAmountFormatter(coin))
.format(amountWithoutChange + fee!),
style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textConfirmTotalAmount,
- )
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textConfirmTotalAmount,
+ )
: STextStyles.itemSubtitle12(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
@@ -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<StackColors>()!.warningBackground,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .warningBackground,
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.warningForeground,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .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<StackColors>()!.warningForeground,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .warningForeground,
),
),
),
### lib/pages/send_view/send_view.dart
@@ -431,7 +431,10 @@ class _SendViewState extends ConsumerState<SendView> {
ref.read(pSendAmount.notifier).state = amount;
}
+ bool _xelisSendAll = false;
+
void _cryptoAmountChanged() async {
+ _xelisSendAll = false;
if (!_cryptoAmountChangeLock) {
final cryptoAmount = ref
.read(pAmountFormatter(coin))
@@ -579,6 +582,10 @@ class _SendViewState extends ConsumerState<SendView> {
}
Future<Amount> 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 =
@@ -924,6 +931,7 @@ class _SendViewState extends ConsumerState<SendView> {
// cancel preview
return;
}
+ if (coin is Xelis) _xelisSendAll = true;
}
}
@@ -1087,6 +1095,7 @@ class _SendViewState extends ConsumerState<SendView> {
final memo = coin is Stellar ? memoController.text : null;
txDataFuture = wallet.prepareSend(
txData: TxData(
+ xelisSendAll: coin is Xelis && _xelisSendAll,
recipients: [
TxRecipient(
address: _address!,
@@ -1114,6 +1123,11 @@ class _SendViewState extends ConsumerState<SendView> {
TxData txData = results.first as TxData;
+ if (wasCancelled || !mounted) {
+ await wallet.cancelSend(txData: txData);
+ return;
+ }
+
if (!wasCancelled && mounted) {
if (isPaynymSend) {
txData = txData.copyWith(
@@ -1248,6 +1262,7 @@ class _SendViewState extends ConsumerState<SendView> {
.read(pAmountFormatter(coin))
.formatEditable(amount);
_cryptoAmountChanged();
+ _xelisSendAll = coin is Xelis;
}
bool get isPaynymSend => widget.accountLite != null;
@@ -2536,7 +2551,9 @@ class _SendViewState extends ConsumerState<SendView> {
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,
),
@@ -2545,7 +2562,15 @@ class _SendViewState extends ConsumerState<SendView> {
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)
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
@@ -545,6 +545,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
// cancel preview
return;
}
+ if (coin is Xelis) _xelisSendAll = true;
}
}
@@ -721,6 +722,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
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<DesktopSend> {
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<DesktopSend> {
ref.read(pOpReturnData.notifier).state = data;
}
+ bool _xelisSendAll = false;
+
void _cryptoAmountChanged() async {
+ _xelisSendAll = false;
if (!_cryptoAmountChangeLock) {
final cryptoAmount = ref
.read(pAmountFormatter(coin))
@@ -1239,6 +1249,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
.read(pAmountFormatter(coin))
.formatEditable(amount);
_syncFeeAmount(amount);
+ _xelisSendAll = coin is Xelis;
}
void _showDesktopCoinControl() async {
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart
@@ -164,7 +164,12 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
),
),
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)
### lib/services/wallets.dart
@@ -118,6 +118,16 @@ 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 = Directory(
+ "${root.path}${Platform.pathSeparator}$walletId",
+ );
+ if (await directory.exists()) await directory.delete(recursive: true);
+ }
+
await secureStorage.delete(key: Wallet.mnemonicKey(walletId: walletId));
await secureStorage.delete(
key: Wallet.mnemonicPassphraseKey(walletId: walletId),
### 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<bool> Function({
- required BuildContext context,
- required NodeFormData nodeFormData,
- required CryptoCurrency cryptoCurrency,
- void Function(NodeFormData)? onSuccess,
- });
+typedef TestNodeConnectionCallback = Future<bool> Function({
+ required BuildContext context,
+ required NodeFormData nodeFormData,
+ required CryptoCurrency cryptoCurrency,
+ void Function(NodeFormData)? onSuccess,
+});
final testNodeConnectionProvider = Provider<TestNodeConnectionCallback>((ref) {
return ({
@@ -329,6 +328,7 @@ Future<bool> testNodeConnection({
testPassed = await libXelis.testDaemonConnection(
"${formData.host!}:${formData.port!}",
formData.useSSL ?? false,
+ cryptoCurrency.network,
);
} catch (_) {
testPassed = false;
### 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;
### 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,
### 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 = <InputV2>[];
+ final outputs = <OutputV2>[];
+ 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,
+ }),
+ );
+}
### lib/wallets/wallet/impl/xelis_wallet.dart
@@ -1,16 +1,11 @@
import 'dart:async';
-import 'dart:convert';
-import 'dart:math';
import 'package:isar_community/isar.dart';
import 'package:mutex/mutex.dart';
import 'package:stack_wallet_backup/generate_password.dart';
import '../../../models/balance.dart';
import '../../../models/isar/models/blockchain_data/address.dart';
-import '../../../models/isar/models/blockchain_data/transaction.dart';
-import '../../../models/isar/models/blockchain_data/v2/input_v2.dart';
-import '../../../models/isar/models/blockchain_data/v2/output_v2.dart';
import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../../models/paymint/fee_object_model.dart';
import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart';
@@ -21,703 +16,382 @@ 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<void>? _initCompleter;
-
- XelisWallet(CryptoCurrencyNetwork network) : super(Xelis(network));
- // ==================== Overrides ============================================
+class XelisWallet extends LibXelisWallet<Xelis> {
+ 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<void>? _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<void> _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<void> 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<void> _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<void> _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<void> _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<void> _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<void> 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<void>();
-
+ 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<void> 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<bool> 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<void> 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<int> _fetchChainHeight() async {
- final infoString = await libXelis.getDaemonInfo(wallet!);
- final Map<String, dynamic> 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);
+ if (!isCurrentSession(handle, generation)) return;
+ final history = await xelis.allHistory(handle);
+ await _persistBalance(handle, generation, raw, history);
+ });
+
+ Future<void> _persistBalance(
+ OpaqueXelisWallet handle,
+ int generation,
+ BigInt raw,
+ List<TransactionEntryWrapper> history,
+ ) async {
+ if (!isCurrentSession(handle, generation)) return;
+ final address = xelis.getAddress(handle);
+ final reserved = history
+ .where((entry) => entry.topoheight == null)
+ .map((entry) => _project(entry, address))
+ .whereType<TransactionV2>()
+ .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<void> 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<void> 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<void> updateNode() async {
- try {
- await connect(disconnectFirst: true);
- } catch (e, s) {
- Logging.instance.e(
- "Error rethrown from $runtimeType updateNode()",
- error: e,
- stackTrace: s,
- );
- rethrow;
- }
- }
+ Future<void> updateNode() => connect();
@override
Future<List<String>> updateTransactions({
bool isRescan = false,
List<TransactionEntryWrapper>? 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;
+ }) => _historyMutex.protect(() async {
+ final handle = wallet;
+ final generation = sessionGeneration;
+ if (handle == null || exitInProgress) return [];
+ final entries = objTransactions ?? await xelis.allHistory(handle);
+ return _persistTransactions(
+ handle,
+ generation,
+ entries,
+ reconcile: objTransactions == null || isRescan,
+ );
+ });
- if (firstBlock > 10) {
- // add some buffer
- firstBlock -= 10;
+ Future<List<String>> _persistTransactions(
+ OpaqueXelisWallet handle,
+ int generation,
+ List<TransactionEntryWrapper> entries, {
+ required bool reconcile,
+ }) async {
+ if (!isCurrentSession(handle, generation)) return [];
+ final address = xelis.getAddress(handle);
+ final transactions = entries
+ .map((tx) => _project(tx, address))
+ .whereType<TransactionV2>()
+ .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<TransactionV2> 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<OutputV2> outputs = [];
- final List<InputV2> inputs = [];
- TransactionType? txType;
- const TransactionSubType txSubType = TransactionSubType.none;
- int? nonce;
- Amount fee = Amount(
- rawValue: BigInt.zero,
- fractionDigits: cryptoCurrency.fractionDigits,
- );
- final Map<String, dynamic> 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 (reconcile) {
+ // 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();
}
+ Future<void> _refreshHistoryAndBalance(
+ OpaqueXelisWallet handle,
+ int generation,
+ ) => _balanceMutex.protect(
+ () => _historyMutex.protect(() async {
+ if (!isCurrentSession(handle, generation)) return;
+ // Read balance before history, as in updateBalance. Serialize both writes
+ // to avoid overwriting an independent update with this snapshot.
+ final raw = await xelis.getXelisBalanceRaw(handle);
+ if (!isCurrentSession(handle, generation)) return;
+ final history = await xelis.allHistory(handle);
+ if (!isCurrentSession(handle, generation)) return;
+ await _persistTransactions(handle, generation, history, reconcile: true);
+ await _persistBalance(handle, generation, raw, history);
+ }),
+ );
+
+ TransactionV2? _project(TransactionEntryWrapper tx, String ownAddress) =>
+ projectXelisTransaction(
+ tx: tx,
+ ownAddress: ownAddress,
+ walletId: walletId,
+ xelisAsset: xelis.xelisAsset,
+ fractionDigits: cryptoCurrency.fractionDigits,
+ );
@override
- Future<bool> updateUTXOs() async {
- // not used in xel
- return false;
- }
-
+ Future<bool> updateUTXOs() async => false;
@override
- Future<void> checkSaveInitialReceivingAddress() async {
- // do nothing
- }
-
+ Future<void> 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<FeeObject> 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<TxData> 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>(
- // 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<FeeObject> get fees async => FeeObject(
+ numberOfBlocksFast: 1,
+ numberOfBlocksAverage: 1,
+ numberOfBlocksSlow: 1,
+ fast: BigInt.one,
+ medium: BigInt.one,
+ slow: BigInt.one,
+ );
@override
Future<Amount> estimateFeeFor(
@@ -727,312 +401,253 @@ class XelisWallet extends LibXelisWallet {
List<TxRecipient> 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<TxData> 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<TxData> 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<void> 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<void> 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<void> handleNewTopoHeight(int _) async =>
- eventBatcher.queueTopoheightChanged();
-
- @override
- Future<void> handleNewTransaction(TransactionEntryWrapper tx) async =>
- eventBatcher.queueTransaction(tx);
-
- @override
- Future<void> handleBalanceChanged(BalanceChanged event) async {
- if (event.asset == libXelis.xelisAsset) {
- eventBatcher.queueBalanceChanged();
- }
+ });
}
@override
- Future<void> applyXelisEventBatch(
- XelisEventBatch<TransactionEntryWrapper> batch,
- ) async {
- try {
- if (batch.topoheightChanged) {
- await updateChainHeight(rethrowErrors: true);
- }
-
- if (batch.transactions.isNotEmpty) {
- await updateTransactions(
- isRescan: false,
- objTransactions: batch.transactions,
- );
+ Future<TxData> 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<void> handleRescan(int startTopoheight) async {
- await runXelisRescan(() async {
- await mainDB.deleteWalletBlockchainData(walletId);
- await updateTransactions(isRescan: true, topoheight: startTopoheight);
- await updateBalance();
+ Future<void> 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<void> handleOnline() => runXelisSyncEvent();
-
- @override
- Future<void> handleOffline() async {
- GlobalEventBus.instance.fire(
- WalletSyncStatusChangedEvent(
- WalletSyncStatus.unableToSync,
- walletId,
- info.coin,
- ),
- );
+ Future<void> 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<void> handleHistorySynced(int _) => runXelisSyncEvent();
-
+ Future<void> handleNewTopoHeight(BigInt height) => updateChainHeight();
@override
- Future<void> 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<void> handleNewTransaction(TransactionEntryWrapper tx) async {
+ await updateTransactions(objTransactions: [tx]);
}
@override
- Future<void> 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<void> 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<void> refresh({int? topoheight}) =>
+ _operationCoordinator.refresh(() async {
+ final handle = wallet;
+ final generation = sessionGeneration;
+ if (handle == null || exitInProgress) return;
+ try {
+ await _refreshHistoryAndBalance(handle, generation);
+ 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;
- }
- }
+ });
}
### lib/wallets/wallet/intermediate/lib_xelis_wallet.dart
@@ -13,20 +13,23 @@ 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<T extends ElectrumCurrency>
extends ExternalWallet<T>
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<void>? _tableGenerationCompleter;
+ static Future<void>? _tableGenerationFuture;
- int pruningHeight = 0;
+ BigInt pruningHeight = BigInt.zero;
OpaqueXelisWallet? wallet;
@@ -36,22 +39,24 @@ abstract class LibXelisWallet<T extends ElectrumCurrency>
}
}
- final syncMutex = Mutex();
Timer? timer;
- StreamSubscription<void>? _eventSubscription;
- late final XelisOperationCoordinator _operationCoordinator =
- XelisOperationCoordinator(refreshMutex);
-
- static const _eventFlushInterval = Duration(milliseconds: 500);
-
- @protected
- late final XelisEventBatcher<TransactionEntryWrapper> eventBatcher =
- XelisEventBatcher(
- flushInterval: _eventFlushInterval,
- flush: (batch) =>
- runXelisEventUpdate(() => applyXelisEventBatch(batch)),
- );
+ final _connectionMutex = Mutex();
+ XelisEventSubscription? _runtimeEvents;
+ XelisEventSubscription? _businessEvents;
+ bool _businessEventsFailed = false;
+ Future<void> _eventWork = Future.value();
+ XelisEventBatcher<Event>? _businessBatcher;
+ XelisEventBatcher<Event>? _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<String> getPrecomputedTablesPath() async {
if (kIsWeb) {
@@ -63,10 +68,11 @@ abstract class LibXelisWallet<T extends ElectrumCurrency>
}
Future<XelisTableState> 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 +98,212 @@ abstract class LibXelisWallet<T extends ElectrumCurrency>
);
}
- Future<void> handleEvent(Event event) async {}
- Future<void> handleNewTopoHeight(int height);
+ Future<void> handleEvent(Event event, {required bool Function() isCurrent});
+ Future<void> handleNewTopoHeight(BigInt height);
Future<void> handleNewTransaction(TransactionEntryWrapper tx);
Future<void> handleBalanceChanged(BalanceChanged event);
- Future<void> handleRescan(int startTopoheight) async {}
+ Future<void> handleRescan(BigInt startTopoheight) async {}
Future<void> handleOnline() async {}
Future<void> handleOffline() async {}
- Future<void> handleHistorySynced(int topoheight) async {}
+ Future<void> handleHistorySynced(BigInt topoheight) async {}
Future<void> handleNewAsset(NewAsset asset) async {}
- @protected
- Future<void> performXelisRefresh();
-
- @protected
- Future<void> applyXelisEventBatch(
- XelisEventBatch<TransactionEntryWrapper> batch,
- );
-
- @protected
- Future<void> runXelisRescan(Future<void> Function() operation) {
- eventBatcher.reset();
- return _operationCoordinator.rescan(operation);
- }
-
- @protected
- Future<void> runXelisEventUpdate(Future<void> Function() operation) =>
- _operationCoordinator.processEvent(operation);
-
- @protected
- Future<void> runXelisSyncEvent() =>
- _operationCoordinator.processSyncEvent(performXelisRefresh);
-
- // Intentionally swallow logged errors because refresh is often unawaited.
@override
- Future<void> refresh() =>
- _operationCoordinator.refresh(performXelisRefresh).catchError((_) {});
-
- Future<void> connect({bool disconnectFirst = false}) =>
- _operationCoordinator.connect(() async {
- final node = getCurrentNode();
- try {
+ Future<void> refresh({int? topoheight});
+
+ Future<void> 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<void> 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<Event>(
+ 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);
+ }
+ },
+ );
+ 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;
}
- }, joinExisting: !disconnectFirst);
+ 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<FilterOperation> get standardReceivingAddressFilters => [
FilterCondition.equalTo(property: r"type", value: info.mainAddressType),
@@ -187,27 +326,79 @@ abstract class LibXelisWallet<T extends ElectrumCurrency>
final walletDir = Directory(
"${xelisDir.path}${Platform.pathSeparator}$walletId",
);
- // TODO: should we check for certain files within the dir?
return await walletDir.exists();
}
@override
- Future<void> open() => connect();
+ Future<void> open() async {
+ while (exitInProgress) {
+ await Future<void>.delayed(const Duration(milliseconds: 500));
+ }
- @override
- Future<void> exit() => _operationCoordinator.exit(() async {
- timer?.cancel();
- timer = null;
+ await init();
+ await connect();
+ unawaited(refresh());
+ }
- eventBatcher.reset();
- await _eventSubscription?.cancel();
- _eventSubscription = null;
+ bool exitInProgress = false;
+
+ /// Called after session invalidation, before releasing the native handle.
+ Future<void> drainSessionOperations() async {}
+
+ @override
+ Future<void> exit() async {
+ if (exitInProgress) {
+ while (exitInProgress) {
+ await Future<void>.delayed(const Duration(milliseconds: 20));
+ }
+ return;
+ }
+ exitInProgress = true;
+ ++sessionGeneration;
+ ++_connectionGeneration;
+ _reconnectTimer?.cancel();
+ _businessBatcher?.reset();
+ _businessBatcher = null;
+ _runtimeBatcher?.reset();
+ _runtimeBatcher = null;
+ Object? firstError;
+ StackTrace? firstStack;
+ Future<void> attempt(Future<void> Function() operation) async {
+ try {
+ await operation();
+ } catch (error, stack) {
+ firstError ??= error;
+ firstStack ??= stack;
+ }
+ }
- if (wallet != null && await libXelis.isOnline(wallet!)) {
- await libXelis.offlineMode(wallet!);
+ 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;
+ }
}
- await super.exit();
- });
+ if (firstError != null) Error.throwWithStackTrace(firstError!, firstStack!);
+ }
void invalidSeedLengthCheck(int length) {
if (!(length == 25)) {
@@ -219,75 +410,28 @@ abstract class LibXelisWallet<T extends ElectrumCurrency>
extension XelisTableManagement on LibXelisWallet {
Future<bool> isTableUpgradeAvailable() async {
if (kIsWeb) return false;
-
final state = await getTableState();
return state.currentSize != state.desiredSize;
}
- Future<void> updateTablesToDesiredSize() async {
- if (kIsWeb) return;
-
- await Future<void>.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<void> updateTablesToDesiredSize() {
+ if (kIsWeb) return Future<void>.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<void>();
- 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;
}
}
### lib/wallets/wallet/wallet.dart
@@ -167,6 +167,10 @@ abstract class Wallet<T extends CryptoCurrency> {
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<T extends CryptoCurrency> {
/// reflect updated balance, transactions, utxos, etc.
Future<TxData> confirmSend({required TxData txData});
+ /// Releases a preparation when its review is abandoned. Wallets whose
+ /// preparations own native resources override this lifecycle hook.
+ Future<void> 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
### 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<Event> eventsStream(OpaqueXelisWallet wallet);
+ Future<XelisEventSubscription> subscribeRuntimeEvents(
+ OpaqueXelisWallet wallet,
+ );
+ Future<XelisEventSubscription> subscribeBusinessEvents(
+ OpaqueXelisWallet wallet,
+ );
+
+ Future<void> closeWallet(OpaqueXelisWallet wallet);
Future<void> onlineMode(
OpaqueXelisWallet wallet, {
@@ -39,6 +48,11 @@ abstract class LibXelisInterface {
required bool stack_l1Low,
});
+ Future<bool> hasTables({
+ required String precomputedTablesPath,
+ required bool stack_l1Low,
+ });
+
Future<String> getSeed(OpaqueXelisWallet wallet);
Future<OpaqueXelisWallet> createXelisWallet(
@@ -65,123 +79,92 @@ abstract class LibXelisInterface {
String getAddress(OpaqueXelisWallet wallet);
- Future<String> getDaemonInfo(OpaqueXelisWallet wallet);
+ Future<XelisDaemonSnapshot> getDaemonInfo(OpaqueXelisWallet wallet);
Future<bool> isOnline(OpaqueXelisWallet wallet);
+ Future<bool> isSyncing(OpaqueXelisWallet wallet);
Future<void> rescan(OpaqueXelisWallet wallet, {required BigInt topoheight});
- Future<List<TransactionEntryWrapper>> allHistory(OpaqueXelisWallet wallet);
+ Future<List<TransactionEntryWrapper>> allHistory(
+ OpaqueXelisWallet wallet, {
+ BigInt? minTopoheight,
+ });
- Future<void> broadcastTransaction(
+ Future<XelisBroadcastOutcome> broadcastTransaction(
OpaqueXelisWallet wallet, {
- required String txHash,
+ required XelisPreparedTransaction transaction,
});
- Future<String> estimateFees(
+ Future<BigInt> estimateFees(
OpaqueXelisWallet wallet, {
- required List<WrappedTransfer> transfers,
+ required List<XelisTransfer> transfers,
});
- Future<String> createTransfersTransaction(
+ Future<XelisPreparedTransaction> prepareTransfers(
OpaqueXelisWallet wallet, {
- required List<WrappedTransfer> transfers,
+ required List<XelisTransfer> transfers,
});
- Future<String> formatCoin(
+ Future<XelisPreparedTransaction> prepareTransferAll(
OpaqueXelisWallet wallet, {
- required BigInt atomicAmount,
- String? assetHash,
+ required String destination,
});
- Future<int> getAssetDecimals(
+ Future<void> discardPreparedTransaction(
OpaqueXelisWallet wallet, {
- required String asset,
+ required XelisPreparedTransaction transaction,
});
Future<BigInt> getXelisBalanceRaw(OpaqueXelisWallet wallet);
- Future<bool> hasXelisBalance(OpaqueXelisWallet wallet);
-
- Future<bool> testDaemonConnection(String endPoint, bool useSSL);
+ Future<bool> testDaemonConnection(
+ String endPoint,
+ bool useSSL,
+ CryptoCurrencyNetwork network,
+ );
}
-// =============================================================================
-// ============== stupid =======================================================
+final class XelisEventSubscription {
+ const XelisEventSubscription({required this.events, required this.cancel});
+
+ final Stream<Event> events;
+ final Future<void> Function() cancel;
+}
final class OpaqueXelisWallet {
final Object _value;
const OpaqueXelisWallet(this._value);
T get<T>() => _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;
-
final EntryWrapper entryType;
final String hash;
final DateTime? timestamp;
- final int topoheight;
+ final BigInt? topoheight;
- TransactionEntryWrapper(
- this._value, {
+ TransactionEntryWrapper({
required this.entryType,
required this.hash,
required this.timestamp,
required this.topoheight,
});
-
- T getValue<T>() => _value is T
- ? _value as T
- : throw Exception(
- "Type mismatch: ${_value.runtimeType} is not ${T.runtimeType}",
- );
}
sealed class EntryWrapper {
const 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 +176,19 @@ class BurnEntryWrapper extends EntryWrapper {
class IncomingEntryWrapper extends EntryWrapper {
final String from;
- final List<({int amount, String asset, Map<String, dynamic>? extraData})>
+ final List<({BigInt amount, String asset, Map<String, dynamic>? 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<String, dynamic>? extraData,
})
@@ -221,16 +204,28 @@ 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;
+}
-// =============================================================================
-// ============== moved from lib_xelis_wallet.dart =============================
enum XelisTableSize {
low,
full;
- // TODO: add more granular table size management interface
- // for now, just patching the old system into the new FFI API
bool get isLow => this == XelisTableSize.low;
static XelisTableSize get platformDefault {
@@ -291,7 +286,7 @@ sealed class Event {
}
final class NewTopoheight extends Event {
- final int height;
+ final BigInt height;
const NewTopoheight(this.height);
}
@@ -317,13 +312,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 +332,22 @@ 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;
+}
### 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<XelisPreparedTransfer> transfers,
+ }) : _handle = handle,
+ transfers = List.unmodifiable(transfers);
+
+ final Object _handle;
+ final String hash;
+ final BigInt feeAtomic;
+ final List<XelisPreparedTransfer> transfers;
+
+ T handle<T>() => _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();
+}
### 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,20 +2659,21 @@ 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: dc953e2f2e960b7ffbf636fc72ed2142a71ef963
+ resolved-ref: dc953e2f2e960b7ffbf636fc72ed2142a71ef963
+ url: "https://github.com/xelis-project/xelis-wallet-flutter.git"
source: git
- version: "0.4.0"
+ version: "0.4.1"
xml:
dependency: transitive
description:
### 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;
### scripts/app_config/templates/pubspec.template.yaml
@@ -33,14 +33,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: dc953e2f2e960b7ffbf636fc72ed2142a71ef963 # v0.4.1
# %%END_ENABLE_XEL%%
# %%ENABLE_FIRO%%
### 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,
+ );
+ }
+ });
+ }
+}
### 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();
+ });
+}
### 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<void> 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<String, dynamic>;
+ final package = (config['packages'] as List<dynamic>)
+ .cast<Map<String, dynamic>>()
+ .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()},
+ );
+ }
+}
### 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 = <XelisPreparedTransaction>[];
+ final broadcast = <XelisPreparedTransaction>[];
+ XelisBroadcastOutcome outcome = const XelisBroadcastOutcome(
+ XelisBroadcastDisposition.submitted,
+ );
+ Completer<XelisPreparedTransaction>? pendingPreparation;
+ Completer<XelisBroadcastOutcome>? 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<XelisPreparedTransaction> prepareTransfers(
+ OpaqueXelisWallet wallet, {
+ required List<XelisTransfer> transfers,
+ }) async =>
+ pendingPreparation?.future ??
+ Future.value(makePrepared(transfers.single.amountAtomic));
+ @override
+ Future<XelisPreparedTransaction> prepareTransferAll(
+ OpaqueXelisWallet wallet, {
+ required String destination,
+ }) async {
+ usedMax = true;
+ return makePrepared(BigInt.from(93));
+ }
+
+ @override
+ Future<void> discardPreparedTransaction(
+ OpaqueXelisWallet wallet, {
+ required XelisPreparedTransaction transaction,
+ }) async => discarded.add(transaction);
+ @override
+ Future<XelisBroadcastOutcome> 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<void> 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 = <String, String>{'xelis_wants_full_tables': 'false'};
+ String? failingKey;
+ String? failingDeleteKey;
+ Future<void> 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<String?>.value(values[key]);
+ case #write:
+ if (key == failingKey) {
+ return Future<void>.error(StateError('fixture write failure'));
+ }
+ values[key] = invocation.namedArguments[#value] as String;
+ return Future<void>.value();
+ default:
+ return super.noSuchMethod(invocation);
+ }
+ }
+}
### test/wallets/xelis_persistence_test.dart
@@ -0,0 +1,633 @@
+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/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;
+ Object? historyFailure;
+ int balanceReads = 0;
+ Future<void> Function()? afterHistoryRead;
+ List<TransactionEntryWrapper> history = [];
+ int historyReads = 0;
+ Completer<void>? historyStarted;
+ Completer<void>? historyRelease;
+ int creates = 0;
+ int opens = 0;
+ int closes = 0;
+ Future<void> Function()? beforeClose;
+ int rescans = 0;
+ Completer<void>? rescanStarted;
+ Completer<void>? 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<bool>.value(false);
+ case #hasTables:
+ return Future<bool>.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<void>.value();
+ case #getSeed:
+ return Future<String>.value(fixtureSeed);
+ case #closeWallet:
+ closes++;
+ return beforeClose?.call() ?? Future<void>.value();
+ case #openXelisWallet:
+ opens++;
+ return Future<OpaqueXelisWallet>.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<BigInt> getXelisBalanceRaw(OpaqueXelisWallet wallet) async {
+ balanceReads++;
+ if (balanceFailure != null) throw balanceFailure!;
+ return balance;
+ }
+
+ @override
+ Future<List<TransactionEntryWrapper>> allHistory(
+ OpaqueXelisWallet wallet, {
+ BigInt? minTopoheight,
+ }) async {
+ historyReads++;
+ if (!(historyStarted?.isCompleted ?? true)) historyStarted!.complete();
+ await historyRelease?.future;
+ if (historyFailure != null) throw historyFailure!;
+ final snapshot = List<TransactionEntryWrapper>.of(history);
+ await afterHistoryRead?.call();
+ return snapshot;
+ }
+}
+
+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,
+ String destination = 'other',
+ }) => TransactionEntryWrapper(
+ hash: hash,
+ timestamp: DateTime.fromMillisecondsSinceEpoch(1000),
+ topoheight: height == null ? null : BigInt.from(height),
+ entryType: OutgoingEntryWrapper(
+ nonce: BigInt.one,
+ fee: BigInt.from(7),
+ transfers: [
+ (
+ destination: destination,
+ amount: BigInt.from(200),
+ asset: 'xel',
+ extraData: null,
+ ),
+ ],
+ ),
+ );
+
+ test('concurrent refreshes share one history and balance update', () async {
+ native.historyStarted = Completer<void>();
+ native.historyRelease = Completer<void>();
+ 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, 1);
+ });
+
+ 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);
+ });
+
+ for (final destination in ['other', 'own']) {
+ test(
+ 'full refresh reserves pending debit and fee to $destination',
+ () async {
+ native.balance = BigInt.from(1000);
+ native.history = [entry('pending', destination: destination)];
+ await wallet.refresh();
+ expect(native.historyReads, 1);
+ expect(native.balanceReads, 1);
+ expect(wallet.info.cachedBalance.blockedTotal.raw, BigInt.from(207));
+ expect(wallet.info.cachedBalance.spendable.raw, BigInt.from(793));
+ native.history = [
+ entry('pending', height: 10, destination: destination),
+ ];
+ native.balance = BigInt.from(destination == 'own' ? 993 : 793);
+ await wallet.updateBalance();
+ expect(native.historyReads, 2);
+ expect(wallet.info.cachedBalance.blockedTotal.raw, BigInt.zero);
+ expect(wallet.info.cachedBalance.spendable.raw, native.balance);
+ },
+ );
+ }
+
+ test(
+ 'confirmation during refresh is reconciled by the queued balance read',
+ () async {
+ native.balance = BigInt.from(1000);
+ native.history = [entry('pending')];
+ Future<void>? independentUpdate;
+ native.afterHistoryRead = () async {
+ native.afterHistoryRead = null;
+ native.balance = BigInt.from(793);
+ native.history = [entry('pending', height: 10)];
+ // This update must wait for the snapshot write, then read fresh state.
+ independentUpdate = wallet.updateBalance();
+ };
+ await wallet.refresh();
+ await independentUpdate;
+ expect(native.historyReads, 2);
+ expect(wallet.info.cachedBalance.total.raw, BigInt.from(793));
+ expect(wallet.info.cachedBalance.blockedTotal.raw, BigInt.zero);
+ await wallet.refresh();
+ expect((await isar.transactionV2s.where().findFirst())!.height, 10);
+ },
+ );
+
+ test('full refresh reconciles disappeared transactions', () async {
+ native.history = [entry('disappeared')];
+ await wallet.refresh();
+ expect(await isar.transactionV2s.count(), 1);
+ native.history = [];
+ await wallet.refresh();
+ expect(await isar.transactionV2s.count(), 0);
+ expect(wallet.info.cachedBalance.blockedTotal.raw, BigInt.zero);
+ });
+
+ for (final invalidateSession in [true, false]) {
+ test('refresh drops snapshot after '
+ '${invalidateSession ? 'session invalidation' : 'shutdown'}', () async {
+ native.balance = BigInt.from(1000);
+ native.history = [entry('stale')];
+ native.historyStarted = Completer<void>();
+ native.historyRelease = Completer<void>();
+ final refreshing = wallet.refresh();
+ await native.historyStarted!.future;
+ Future<void>? closing;
+ if (invalidateSession) {
+ wallet.sessionGeneration++;
+ } else {
+ wallet.prefs = SessionPrefs();
+ closing = wallet.exit();
+ }
+ native.historyRelease!.complete();
+ await refreshing;
+ await closing;
+ expect(await isar.transactionV2s.count(), 0);
+ expect(wallet.info.cachedBalance.total.raw, BigInt.zero);
+ });
+ }
+
+ test(
+ 'history read failure preserves the previously stored balance',
+ () async {
+ native.balance = BigInt.from(1000);
+ await wallet.updateBalance();
+ native.balance = BigInt.zero;
+ native.historyFailure = StateError('fixture history failure');
+ await expectLater(wallet.updateBalance(), throwsStateError);
+ expect(wallet.info.cachedBalance.total.raw, BigInt.from(1000));
+ },
+ );
+
+ for (final failHistory in [false, true]) {
+ test('failed full refresh preserves both persisted projections '
+ '(history failure: $failHistory)', () async {
+ native.balance = BigInt.from(1000);
+ native.history = [entry('existing', height: 10)];
+ await wallet.refresh();
+ native.balance = BigInt.zero;
+ native.history = [];
+ final failure = StateError('fixture read failure');
+ if (failHistory) {
+ native.historyFailure = failure;
+ } else {
+ native.balanceFailure = failure;
+ }
+ await wallet.refresh();
+ expect(wallet.info.cachedBalance.total.raw, BigInt.from(1000));
+ expect((await isar.transactionV2s.where().findFirst())!.txid, 'existing');
+ });
+ }
+
+ test('shutdown waits for an in-flight native rescan', () async {
+ wallet.prefs = SessionPrefs();
+ native.rescanStarted = Completer<void>();
+ native.rescanRelease = Completer<void>();
+ final rescanning = wallet.recover(isRescan: true);
+ await native.rescanStarted!.future;
+ final queuedRescan = wallet.recover(isRescan: true);
+ final closing = wallet.exit();
+ await Future<void>.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<String>(DB.boxNameWalletsToDeleteOnStart);
+ await hive.openBox<TradeWalletLookup>(DB.boxNameTradeLookup);
+ await hive.openBox<NotificationModel>(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<String>(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]);
+ },
+ );
+}
### 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<XelisBroadcastOutcome>();
+ final send = wallet.confirmSend(txData: reviewed);
+ await Future<void>.delayed(Duration.zero);
+ wallet.exitInProgress = true;
+ ++wallet.sessionGeneration;
+ var drained = false;
+ final closing = wallet.drainSessionOperations().then((_) => drained = true);
+ await Future<void>.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);
+ });
+}
### 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 = <StreamController<Event>>[];
+ final business = <StreamController<Event>>[];
+ final order = <String>[];
+ bool failRuntimeCancel = false;
+ bool failRuntimeSubscribe = false;
+ bool failBusinessSubscribe = false;
+ Completer<void>? onlineStarted;
+ Completer<void>? onlineRelease;
+ @override
+ Future<XelisEventSubscription> subscribeRuntimeEvents(
+ OpaqueXelisWallet wallet,
+ ) async {
+ if (failRuntimeSubscribe) throw StateError('runtime subscription failed');
+ final controller = StreamController<Event>();
+ 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<XelisEventSubscription> subscribeBusinessEvents(
+ OpaqueXelisWallet wallet,
+ ) async {
+ if (failBusinessSubscribe) throw StateError('business subscription failed');
+ final controller = StreamController<Event>();
+ business.add(controller);
+ return XelisEventSubscription(
+ events: controller.stream,
+ cancel: () async {
+ order.add('cancel-business');
+ await controller.close();
+ },
+ );
+ }
+
+ @override
+ Future<void> onlineMode(
+ OpaqueXelisWallet wallet, {
+ required String daemonAddress,
+ }) async {
+ order.add(daemonAddress);
+ if (!(onlineStarted?.isCompleted ?? true)) onlineStarted!.complete();
+ await onlineRelease?.future;
+ }
+
+ @override
+ Future<void> offlineMode(OpaqueXelisWallet wallet) async =>
+ order.add('offline');
+ @override
+ Future<void> 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 = <Event>[];
+ 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<void> refresh({int? topoheight}) async {}
+ @override
+ Future<void> handleEvent(
+ Event event, {
+ required bool Function() isCurrent,
+ }) async {
+ await Future<void>.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<NewTopoheight>(), hasLength(1));
+ expect(wallet.handled.whereType<BalanceChanged>(), hasLength(1));
+ expect(
+ wallet.handled.whereType<NewTopoheight>().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<String>.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<void>.delayed(Duration.zero);
+ await Future<void>.delayed(Duration.zero);
+ expect(wallet.handled, hasLength(1));
+ },
+ );
+ }
+
+ test(
+ 'rapid connection requests skip superseded queued connections',
+ () async {
+ final native = SessionNative()
+ ..onlineStarted = Completer<void>()
+ ..onlineRelease = Completer<void>();
+ 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);
+ });
+}
### 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 = <String, String>{};
+ @override
+ dynamic noSuchMethod(Invocation invocation) {
+ final key = invocation.namedArguments[#key] as String;
+ switch (invocation.memberName) {
+ case #read:
+ return Future<String?>.value(values[key]);
+ case #write:
+ values[key] = invocation.namedArguments[#value] as String;
+ return Future<void>.value();
+ default:
+ return super.noSuchMethod(invocation);
+ }
+ }
+}
+
+class TableNative extends Fake implements LibXelisInterface {
+ bool fullPresent = false;
+ int updates = 0;
+ Completer<void>? started;
+ Completer<void>? release;
+ @override
+ Future<bool> hasTables({
+ required String precomputedTablesPath,
+ // Matches the existing LibXelisInterface parameter name.
+ // ignore: non_constant_identifier_names
+ required bool stack_l1Low,
+ }) async => fullPresent;
+
+ @override
+ Future<void> 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<String> 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<void>()
+ ..release = Completer<void>();
+ 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<void>()
+ ..release = Completer<void>();
+ 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<void>()
+ ..release = Completer<void>();
+ 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');
+ },
+ );
+}
### test/wallets/xelis_transaction_test.dart
@@ -0,0 +1,124 @@
+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(
+ 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);
+ });
+}
### 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,
+ );
+ }
+ });
+}
### 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<xwf.XelisWallet>();
}
final class _LibXelisInterfaceImpl extends LibXelisInterface {
- const _LibXelisInterfaceImpl();
+ StreamSubscription<xwf.XelisLogEntry>? _logs;
+ StreamSubscription<xwf.ProgressReport>? _progress;
+ final _progressEvents = StreamController<XelisTableProgressState>.broadcast();
+ XelisTableProgressState _lastProgress = const XelisTableProgressState();
@override
- String get xelisAsset => xelis_sdk.xelisAsset;
+ String get xelisAsset => sdk.xelisAsset;
@override
- Future<void> initRustLib() => xelis_rust.RustLib.init();
+ Future<void> 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<void> setupRustLogger() => xelis_api.setUpRustLogger();
+ Future<void> 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<XelisTableProgressState> 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<XelisTableProgressState> 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<Event> 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<String, dynamic>,
- );
-
- yield NewAsset(
- data.name,
- data.decimals,
- DynamicObject(data.maxSupply),
- );
- case xelis_sdk.WalletEvent.newTransaction:
- final tx = xelis_sdk.TransactionEntry.fromJson(
- json['data'] as Map<String, dynamic>,
- );
- 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<String, dynamic>,
- );
- 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<XelisEventSubscription> 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<XelisEventSubscription> 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<void> onlineMode(
OpaqueXelisWallet wallet, {
required String daemonAddress,
- }) => wallet.actual.onlineMode(daemonAddress: daemonAddress);
+ }) => wallet.actual.setOnline(daemonAddress: daemonAddress);
@override
Future<void> offlineMode(OpaqueXelisWallet wallet) =>
- wallet.actual.offlineMode();
+ wallet.actual.setOffline();
+
+ @override
+ Future<void> closeWallet(OpaqueXelisWallet wallet) async {
+ try {
+ await wallet.actual.close();
+ } finally {
+ wallet.actual.dispose();
+ }
+ }
@override
Future<void> 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<bool> hasTables({
+ required String precomputedTablesPath,
+ required bool stack_l1Low,
+ }) => xwf.XelisWalletFlutter.hasPrecomputedTables(
+ path: precomputedTablesPath,
+ type: _tableType(stack_l1Low),
+ );
@override
Future<String> 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,311 @@ 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<String> getDaemonInfo(OpaqueXelisWallet wallet) =>
- wallet.actual.getDaemonInfo();
+ Future<XelisDaemonSnapshot> getDaemonInfo(OpaqueXelisWallet wallet) async {
+ final info = await wallet.actual.getDaemonInfo();
+ return XelisDaemonSnapshot(
+ topoheight: info.topoheight,
+ stableTopoheight: info.stableTopoheight,
+ prunedTopoheight: info.prunedTopoheight,
+ );
+ }
@override
Future<bool> isOnline(OpaqueXelisWallet wallet) => wallet.actual.isOnline();
-
+ @override
+ Future<bool> isSyncing(OpaqueXelisWallet wallet) => wallet.actual.isSyncing();
@override
Future<void> rescan(OpaqueXelisWallet wallet, {required BigInt topoheight}) =>
wallet.actual.rescan(topoheight: topoheight);
+ @override
+ Future<BigInt> getXelisBalanceRaw(OpaqueXelisWallet wallet) =>
+ wallet.actual.getXelisBalance();
@override
Future<List<TransactionEntryWrapper>> 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<void> broadcastTransaction(
+ Future<BigInt> estimateFees(
OpaqueXelisWallet wallet, {
- required String txHash,
- }) => wallet.actual.broadcastTransaction(txHash: txHash);
+ required List<XelisTransfer> transfers,
+ }) => wallet.actual.estimateTransferFees(
+ transfers: transfers.map(_transfer).toList(),
+ );
@override
- Future<String> createTransfersTransaction(
+ Future<XelisPreparedTransaction> prepareTransfers(
OpaqueXelisWallet wallet, {
- required List<WrappedTransfer> 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<XelisTransfer> transfers,
+ }) async => _prepared(
+ await wallet.actual.prepareTransfers(
+ transfers: transfers.map(_transfer).toList(),
+ ),
);
@override
- Future<String> estimateFees(
+ Future<XelisPreparedTransaction> prepareTransferAll(
OpaqueXelisWallet wallet, {
- required List<WrappedTransfer> 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<String> formatCoin(
+ Future<void> discardPreparedTransaction(
OpaqueXelisWallet wallet, {
- required BigInt atomicAmount,
- String? assetHash,
- }) => wallet.actual.formatCoin(
- atomicAmount: atomicAmount,
- assetHash: assetHash,
+ required XelisPreparedTransaction transaction,
+ }) => wallet.actual.discardPreparedTransaction(
+ transaction: transaction.handle<xwf.XelisWalletPreparedTransaction>(),
);
@override
- Future<int> getAssetDecimals(
+ Future<XelisBroadcastOutcome> broadcastTransaction(
OpaqueXelisWallet wallet, {
- required String asset,
- }) => wallet.actual.getAssetDecimals(asset: asset);
-
- @override
- Future<BigInt> getXelisBalanceRaw(OpaqueXelisWallet wallet) =>
- wallet.actual.getXelisBalanceRaw();
-
- @override
- Future<bool> hasXelisBalance(OpaqueXelisWallet wallet) =>
- wallet.actual.hasXelisBalance();
+ required XelisPreparedTransaction transaction,
+ }) async => switch (await wallet.actual.broadcastPreparedTransaction(
+ transaction: transaction.handle<xwf.XelisWalletPreparedTransaction>(),
+ )) {
+ 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<bool> testDaemonConnection(String endPoint, bool useSSL) async {
+ Future<bool> 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(
+ 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(
+ 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<xwf.XelisWalletAssetAmount> amounts) => amounts
+ .where((item) => item.asset == sdk.xelisAsset)
+ .fold(BigInt.zero, (sum, item) => sum + item.amount);
//END_ONWhy this scored 35/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.