import 'dart:async'; import 'dart:io'; import 'package:cw_core/monero_transaction_priority.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_credentials.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cw_wownero/api/exceptions/creation_transaction_exception.dart'; import 'package:cw_wownero/api/wallet.dart'; import 'package:cw_wownero/pending_wownero_transaction.dart'; import 'package:cw_wownero/wownero_amount_format.dart'; import 'package:cw_wownero/wownero_wallet.dart'; import 'package:dart_numerics/dart_numerics.dart'; import 'package:decimal/decimal.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_libmonero/core/key_service.dart'; import 'package:flutter_libmonero/core/wallet_creation_service.dart'; import 'package:flutter_libmonero/view_model/send/output.dart' as wownero_output; import 'package:flutter_libmonero/wownero/wownero.dart'; import 'package:http/http.dart'; import 'package:mutex/mutex.dart'; import 'package:stackwallet/hive/db.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/models/paymint/fee_object_model.dart'; import 'package:stackwallet/models/paymint/transactions_model.dart'; import 'package:stackwallet/models/paymint/utxo_model.dart'; import 'package:stackwallet/services/coins/coin_service.dart'; import 'package:stackwallet/services/event_bus/events/global/blocks_remaining_event.dart'; import 'package:stackwallet/services/event_bus/events/global/node_connection_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/events/global/refresh_percent_changed_event.dart'; import 'package:stackwallet/services/event_bus/events/global/updated_in_background_event.dart'; import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/global_event_bus.dart'; import 'package:stackwallet/services/node_service.dart'; import 'package:stackwallet/services/price.dart'; import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/default_nodes.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; import 'package:stackwallet/utilities/format.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/stack_file_system.dart'; const int MINIMUM_CONFIRMATIONS = 10; //https://github.com/wownero-project/wownero/blob/8361d60aef6e17908658128284899e3a11d808d4/src/cryptonote_config.h#L162 const String GENESIS_HASH_MAINNET = "013c01ff0001ffffffffffff03029b2e4c0281c0b02e7c53291a94d1d0cbff8883f8024f5142ee494ffbbd08807121017767aafcde9be00dcfd098715ebcf7f410daebc582fda69d24a28e9d0bc890d1"; const String GENESIS_HASH_TESTNET = "013c01ff0001ffffffffffff03029b2e4c0281c0b02e7c53291a94d1d0cbff8883f8024f5142ee494ffbbd08807121017767aafcde9be00dcfd098715ebcf7f410daebc582fda69d24a28e9d0bc890d1"; class WowneroWallet extends CoinServiceAPI { static const integrationTestFlag = bool.fromEnvironment("IS_INTEGRATION_TEST"); final _prefs = Prefs.instance; Timer? timer; Timer? wowneroAutosaveTimer; late Coin _coin; late SecureStorageInterface _secureStore; late PriceAPI _priceAPI; Future getCurrentNode() async { return NodeService(secureStorageInterface: _secureStore) .getPrimaryNodeFor(coin: coin) ?? DefaultNodes.getNodeFor(coin); } WowneroWallet( {required String walletId, required String walletName, required Coin coin, PriceAPI? priceAPI, required SecureStorageInterface secureStore}) { _walletId = walletId; _walletName = walletName; _coin = coin; _priceAPI = priceAPI ?? PriceAPI(Client()); _secureStore = secureStore; } bool _shouldAutoSync = false; @override bool get shouldAutoSync => _shouldAutoSync; @override set shouldAutoSync(bool shouldAutoSync) { if (_shouldAutoSync != shouldAutoSync) { _shouldAutoSync = shouldAutoSync; if (!shouldAutoSync) { timer?.cancel(); wowneroAutosaveTimer?.cancel(); timer = null; wowneroAutosaveTimer = null; stopNetworkAlivePinging(); } else { startNetworkAlivePinging(); // Walletbase needs to be open for this to work refresh(); } } } @override Future updateNode(bool shouldRefresh) async { final node = await getCurrentNode(); final host = Uri.parse(node.host).host; await walletBase?.connectToNode( node: Node(uri: "$host:${node.port}", type: WalletType.wownero)); // TODO: is this sync call needed? Do we need to notify ui here? await walletBase?.startSync(); if (shouldRefresh) { await refresh(); } } Future> _getMnemonicList() async { final mnemonicString = await _secureStore.read(key: '${_walletId}_mnemonic'); if (mnemonicString == null) { return []; } final List data = mnemonicString.split(' '); return data; } @override Future> get mnemonic => _getMnemonicList(); Future get currentNodeHeight async { try { if (walletBase!.syncStatus! is SyncedSyncStatus && walletBase!.syncStatus!.progress() == 1.0) { return await walletBase!.getNodeHeight(); } } catch (e, s) {} int _height = -1; try { _height = (walletBase!.syncStatus as SyncingSyncStatus).height; } catch (e, s) { // Logging.instance.log("$e $s", level: LogLevel.Warning); } int blocksRemaining = -1; try { blocksRemaining = (walletBase!.syncStatus as SyncingSyncStatus).blocksLeft; } catch (e, s) { // Logging.instance.log("$e $s", level: LogLevel.Warning); } int currentHeight = _height + blocksRemaining; if (_height == -1 || blocksRemaining == -1) { currentHeight = int64MaxValue; } final cachedHeight = DB.instance .get(boxName: walletId, key: "storedNodeHeight") as int? ?? 0; if (currentHeight > cachedHeight && currentHeight != int64MaxValue) { await DB.instance.put( boxName: walletId, key: "storedNodeHeight", value: currentHeight); return currentHeight; } else { return cachedHeight; } } Future get currentSyncingHeight async { //TODO return the tip of the wownero blockchain try { if (walletBase!.syncStatus! is SyncedSyncStatus && walletBase!.syncStatus!.progress() == 1.0) { // Logging.instance // .log("currentSyncingHeight lol", level: LogLevel.Warning); return getSyncingHeight(); } } catch (e, s) {} int syncingHeight = -1; try { syncingHeight = (walletBase!.syncStatus as SyncingSyncStatus).height; } catch (e, s) { // Logging.instance.log("$e $s", level: LogLevel.Warning); } final cachedHeight = DB.instance.get(boxName: walletId, key: "storedSyncingHeight") as int? ?? 0; if (syncingHeight > cachedHeight) { await DB.instance.put( boxName: walletId, key: "storedSyncingHeight", value: syncingHeight); return syncingHeight; } else { return cachedHeight; } } Future updateStoredChainHeight({required int newHeight}) async { await DB.instance.put( boxName: walletId, key: "storedChainHeight", value: newHeight); } int get storedChainHeight { return DB.instance.get(boxName: walletId, key: "storedChainHeight") as int? ?? 0; } /// Increases the index for either the internal or external chain, depending on [chain]. /// [chain] - Use 0 for receiving (external), 1 for change (internal). Should not be any other value! Future _incrementAddressIndexForChain(int chain) async { // Here we assume chain == 1 if it isn't 0 String indexKey = chain == 0 ? "receivingIndex" : "changeIndex"; final newIndex = (DB.instance.get(boxName: walletId, key: indexKey)) + 1; await DB.instance .put(boxName: walletId, key: indexKey, value: newIndex); } Future _checkCurrentReceivingAddressesForTransactions() async { try { await _checkReceivingAddressForTransactions(); } catch (e, s) { Logging.instance.log( "Exception rethrown from _checkCurrentReceivingAddressesForTransactions(): $e\n$s", level: LogLevel.Error); rethrow; } } Future _checkReceivingAddressForTransactions() async { try { int highestIndex = -1; for (var element in walletBase!.transactionHistory!.transactions!.entries) { if (element.value.direction == TransactionDirection.incoming) { int curAddressIndex = element.value.additionalInfo!['addressIndex'] as int; if (curAddressIndex > highestIndex) { highestIndex = curAddressIndex; } } } // Check the new receiving index String indexKey = "receivingIndex"; final curIndex = DB.instance.get(boxName: walletId, key: indexKey) as int; if (highestIndex >= curIndex) { // First increment the receiving index await _incrementAddressIndexForChain(0); final newReceivingIndex = DB.instance.get(boxName: walletId, key: indexKey) as int; // Use new index to derive a new receiving address final newReceivingAddress = await _generateAddressForChain(0, newReceivingIndex); // Add that new receiving address to the array of receiving addresses await _addToAddressesArrayForChain(newReceivingAddress, 0); // Set the new receiving address that the service _currentReceivingAddress = Future(() => newReceivingAddress); } } on SocketException catch (se, s) { Logging.instance.log( "SocketException caught in _checkReceivingAddressForTransactions(): $se\n$s", level: LogLevel.Error); return; } catch (e, s) { Logging.instance.log( "Exception rethrown from _checkReceivingAddressForTransactions(): $e\n$s", level: LogLevel.Error); rethrow; } } @override bool get isRefreshing => refreshMutex; bool refreshMutex = false; Timer? syncPercentTimer; Mutex syncHeightMutex = Mutex(); Future stopSyncPercentTimer() async { syncPercentTimer?.cancel(); syncPercentTimer = null; } Future startSyncPercentTimer() async { if (syncPercentTimer != null) { return; } syncPercentTimer?.cancel(); GlobalEventBus.instance .fire(RefreshPercentChangedEvent(highestPercentCached, walletId)); syncPercentTimer = Timer.periodic(const Duration(seconds: 30), (_) async { if (syncHeightMutex.isLocked) { return; } await syncHeightMutex.protect(() async { // int restoreheight = walletBase!.walletInfo.restoreHeight ?? 0; int _height = await currentSyncingHeight; int _currentHeight = await currentNodeHeight; double progress = 0; try { progress = walletBase!.syncStatus!.progress(); } catch (e, s) { Logging.instance.log("$e $s", level: LogLevel.Warning); } final int blocksRemaining = _currentHeight - _height; GlobalEventBus.instance .fire(BlocksRemainingEvent(blocksRemaining, walletId)); if (progress == 1 && _currentHeight > 0 && _height > 0) { await stopSyncPercentTimer(); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.synced, walletId, coin, ), ); return; } // for some reason this can be 0 which screws up the percent calculation // int64MaxValue is NOT the best value to use here if (_currentHeight < 1) { _currentHeight = int64MaxValue; } if (_height < 1) { _height = 1; } double restorePercent = progress; double highestPercent = highestPercentCached; Logging.instance.log( "currentSyncingHeight: $_height, nodeHeight: $_currentHeight, restorePercent: $restorePercent, highestPercentCached: $highestPercentCached", level: LogLevel.Info); if (restorePercent > 0 && restorePercent <= 1) { // if (restorePercent > highestPercent) { highestPercent = restorePercent; highestPercentCached = restorePercent; // } } GlobalEventBus.instance .fire(RefreshPercentChangedEvent(highestPercent, walletId)); }); }); } double get highestPercentCached => DB.instance.get(boxName: walletId, key: "highestPercentCached") as double? ?? 0; set highestPercentCached(double value) => DB.instance.put( boxName: walletId, key: "highestPercentCached", value: value, ); /// Refreshes display data for the wallet @override Future refresh() async { if (refreshMutex) { Logging.instance.log("$walletId $walletName refreshMutex denied", level: LogLevel.Info); return; } else { refreshMutex = true; } if (walletBase == null) { throw Exception("Tried to call refresh() in wownero without walletBase!"); } try { await startSyncPercentTimer(); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.syncing, walletId, coin, ), ); final int _currentSyncingHeight = await currentSyncingHeight; final int storedHeight = storedChainHeight; int _currentNodeHeight = await currentNodeHeight; double progress = 0; try { progress = (walletBase!.syncStatus!).progress(); } catch (e, s) { // Logging.instance.log("$e $s", level: LogLevel.Warning); } await _fetchTransactionData(); bool stillSyncing = false; Logging.instance.log( "storedHeight: $storedHeight, _currentSyncingHeight: $_currentSyncingHeight, _currentNodeHeight: $_currentNodeHeight, progress: $progress, issynced: ${await walletBase!.isConnected()}", level: LogLevel.Info); if (progress < 1.0) { stillSyncing = true; } if (_currentSyncingHeight > storedHeight) { // 0 is returned from wownero as I assume an error????? if (_currentSyncingHeight > 0) { // 0 failed to fetch current height??? await updateStoredChainHeight(newHeight: _currentSyncingHeight); } } await _checkCurrentReceivingAddressesForTransactions(); String indexKey = "receivingIndex"; final curIndex = DB.instance.get(boxName: walletId, key: indexKey) as int; // Use new index to derive a new receiving address try { final newReceivingAddress = await _generateAddressForChain(0, curIndex); _currentReceivingAddress = Future(() => newReceivingAddress); } catch (e, s) { Logging.instance.log( "Failed to call _generateAddressForChain(0, $curIndex): $e\n$s", level: LogLevel.Error); } final newTxData = await _fetchTransactionData(); _transactionData = Future(() => newTxData); if (isActive || shouldAutoSync) { timer ??= Timer.periodic(const Duration(seconds: 60), (timer) async { debugPrint("run timer"); //TODO: check for new data and refresh if needed. if wownero even needs this // chain height check currently broken // if ((await chainHeight) != (await storedChainHeight)) { // if (await refreshIfThereIsNewData()) { await refresh(); GlobalEventBus.instance.fire(UpdatedInBackgroundEvent( "New data found in $walletId $walletName in background!", walletId)); // } // } }); wowneroAutosaveTimer ??= Timer.periodic(const Duration(seconds: 93), (timer) async { debugPrint("run wownero timer"); if (isActive) { await walletBase?.save(); GlobalEventBus.instance.fire(UpdatedInBackgroundEvent( "New data found in $walletId $walletName in background!", walletId)); } }); } if (stillSyncing) { debugPrint("still syncing"); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.syncing, walletId, coin, ), ); refreshMutex = false; return; } await stopSyncPercentTimer(); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.synced, walletId, coin, ), ); refreshMutex = false; } catch (error, strace) { refreshMutex = false; await stopSyncPercentTimer(); GlobalEventBus.instance.fire( NodeConnectionStatusChangedEvent( NodeConnectionStatus.disconnected, walletId, coin, ), ); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.unableToSync, walletId, coin, ), ); Logging.instance.log( "Caught exception in refreshWalletData(): $error\n$strace", level: LogLevel.Error); } } @override // TODO: implement allOwnAddresses Future> get allOwnAddresses { return Future(() => []); } @override Future get balanceMinusMaxFee async => (await availableBalance) - Format.satoshisToAmount(await maxFee, coin: Coin.wownero); @override Future get currentReceivingAddress => _currentReceivingAddress ??= _getCurrentAddressForChain(0); @override Future exit() async { await stopSyncPercentTimer(); _hasCalledExit = true; isActive = false; await walletBase?.save(prioritySave: true); walletBase?.close(); wowneroAutosaveTimer?.cancel(); wowneroAutosaveTimer = null; timer?.cancel(); timer = null; stopNetworkAlivePinging(); } bool _hasCalledExit = false; @override bool get hasCalledExit => _hasCalledExit; Future? _currentReceivingAddress; Future _getFees() async { // TODO: not use random hard coded values here return FeeObject( numberOfBlocksFast: 10, numberOfBlocksAverage: 15, numberOfBlocksSlow: 20, fast: MoneroTransactionPriority.fast.raw!, medium: MoneroTransactionPriority.regular.raw!, slow: MoneroTransactionPriority.slow.raw!, ); } @override Future get fees => _feeObject ??= _getFees(); Future? _feeObject; @override // TODO: implement fullRescan Future fullRescan( int maxUnusedAddressGap, int maxNumberOfIndexesToCheck, ) async { var restoreHeight = walletBase?.walletInfo.restoreHeight; await walletBase?.rescan(height: restoreHeight); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.syncing, walletId, coin, ), ); return; } Future _generateAddressForChain(int chain, int index) async { // String address = walletBase!.getTransactionAddress(chain, index); return address; } /// Adds [address] to the relevant chain's address array, which is determined by [chain]. /// [address] - Expects a standard native segwit address /// [chain] - Use 0 for receiving (external), 1 for change (internal). Should not be any other value! Future _addToAddressesArrayForChain(String address, int chain) async { String chainArray = ''; if (chain == 0) { chainArray = 'receivingAddresses'; } else { chainArray = 'changeAddresses'; } final addressArray = DB.instance.get(boxName: walletId, key: chainArray); if (addressArray == null) { Logging.instance.log( 'Attempting to add the following to $chainArray array for chain $chain:${[ address ]}', level: LogLevel.Info); await DB.instance .put(boxName: walletId, key: chainArray, value: [address]); } else { // Make a deep copy of the existing list final List newArray = []; addressArray .forEach((dynamic _address) => newArray.add(_address as String)); newArray.add(address); // Add the address passed into the method await DB.instance .put(boxName: walletId, key: chainArray, value: newArray); } } /// Returns the latest receiving/change (external/internal) address for the wallet depending on [chain] /// and /// [chain] - Use 0 for receiving (external), 1 for change (internal). Should not be any other value! Future _getCurrentAddressForChain(int chain) async { // Here, we assume that chain == 1 if it isn't 0 String arrayKey = chain == 0 ? "receivingAddresses" : "changeAddresses"; final internalChainArray = (DB.instance .get(boxName: walletId, key: arrayKey)) as List; return internalChainArray.last as String; } //TODO: take in the default language when creating wallet. Future _generateNewWallet({int seedWordsLength = 14}) async { Logging.instance .log("IS_INTEGRATION_TEST: $integrationTestFlag", level: LogLevel.Info); // TODO: ping wownero server and make sure the genesis hash matches // if (!integrationTestFlag) { // final features = await electrumXClient.getServerFeatures(); // Logging.instance.log("features: $features"); // if (_networkType == BasicNetworkType.main) { // if (features['genesis_hash'] != GENESIS_HASH_MAINNET) { // throw Exception("genesis hash does not match main net!"); // } // } else if (_networkType == BasicNetworkType.test) { // if (features['genesis_hash'] != GENESIS_HASH_TESTNET) { // throw Exception("genesis hash does not match test net!"); // } // } // } // this should never fail if ((await _secureStore.read(key: '${_walletId}_mnemonic')) != null) { throw Exception( "Attempted to overwrite mnemonic on generate new wallet!"); } // TODO: Wallet Service may need to be switched to Wownero walletService = wownero.createWowneroWalletService(DB.instance.moneroWalletInfoBox); keysStorage = KeyService(_secureStore); WalletInfo walletInfo; WalletCredentials credentials; try { String name = _walletId; final dirPath = await pathForWalletDir(name: name, type: WalletType.wownero); final path = await pathForWallet(name: name, type: WalletType.wownero); credentials = wownero.createWowneroNewWalletCredentials( name: name, language: "English", seedWordsLength: seedWordsLength); walletInfo = WalletInfo.external( id: WalletBase.idFor(name, WalletType.wownero), name: name, type: WalletType.wownero, isRecovery: false, restoreHeight: credentials.height ?? 0, date: DateTime.now(), path: path, dirPath: dirPath, // TODO: find out what to put for address address: ''); credentials.walletInfo = walletInfo; _walletCreationService = WalletCreationService( secureStorage: _secureStore, walletService: walletService, keyService: keysStorage, ); _walletCreationService?.changeWalletType(); // To restore from a seed final wallet = await _walletCreationService?.create(credentials); final bufferedCreateHeight = (seedWordsLength == 14) ? getSeedHeightSync(wallet?.seed.trim() as String) : wownero.getHeightByDate( date: DateTime.now().subtract(const Duration( days: 2))); // subtract a couple days to ensure we have a buffer for SWB await DB.instance.put( boxName: walletId, key: "restoreHeight", value: bufferedCreateHeight); walletInfo.restoreHeight = bufferedCreateHeight; await _secureStore.write( key: '${_walletId}_mnemonic', value: wallet?.seed.trim()); walletInfo.address = wallet?.walletAddresses.address; await DB.instance .add(boxName: WalletInfo.boxName, value: walletInfo); walletBase?.close(); walletBase = wallet as WowneroWalletBase; } catch (e, s) { debugPrint(e.toString()); debugPrint(s.toString()); } final node = await getCurrentNode(); final host = Uri.parse(node.host).host; await walletBase?.connectToNode( node: Node(uri: "$host:${node.port}", type: WalletType.wownero)); await walletBase?.startSync(); await DB.instance .put(boxName: walletId, key: "id", value: _walletId); // Set relevant indexes await DB.instance .put(boxName: walletId, key: "receivingIndex", value: 0); await DB.instance .put(boxName: walletId, key: "changeIndex", value: 0); await DB.instance.put( boxName: walletId, key: 'blocked_tx_hashes', value: ["0xdefault"], ); // A list of transaction hashes to represent frozen utxos in wallet // initialize address book entries await DB.instance.put( boxName: walletId, key: 'addressBookEntries', value: {}); await DB.instance .put(boxName: walletId, key: "isFavorite", value: false); // Generate and add addresses to relevant arrays final initialReceivingAddress = await _generateAddressForChain(0, 0); // final initialChangeAddress = await _generateAddressForChain(1, 0); await _addToAddressesArrayForChain(initialReceivingAddress, 0); // await _addToAddressesArrayForChain(initialChangeAddress, 1); await DB.instance.put( boxName: walletId, key: 'receivingAddresses', value: [initialReceivingAddress]); await DB.instance .put(boxName: walletId, key: "receivingIndex", value: 0); _currentReceivingAddress = Future(() => initialReceivingAddress); Logging.instance.log("_generateNewWalletFinished", level: LogLevel.Info); } @override // TODO: implement initializeWallet Future initializeNew({int seedWordsLength = 14}) async { await _prefs.init(); // TODO: ping actual wownero network // try { // final hasNetwork = await _electrumXClient.ping(); // if (!hasNetwork) { // return false; // } // } catch (e, s) { // Logging.instance.log("Caught in initializeWallet(): $e\n$s"); // return false; // } walletService = wownero.createWowneroWalletService(DB.instance.moneroWalletInfoBox); keysStorage = KeyService(_secureStore); await _generateNewWallet(seedWordsLength: seedWordsLength); // var password; // try { // password = // await keysStorage?.getWalletPassword(walletName: this._walletId); // } catch (e, s) { // Logging.instance.log("$e $s"); // Logging.instance.log("Generating new ${coin.ticker} wallet."); // // Triggers for new users automatically. Generates new wallet // await _generateNewWallet(wallet); // await wallet.put("id", this._walletId); // return true; // } // walletBase = (await walletService?.openWallet(this._walletId, password)) // as WowneroWalletBase; // Logging.instance.log("Opening existing ${coin.ticker} wallet."); // // Wallet already exists, triggers for a returning user // final currentAddress = awaicurrentHeightt _getCurrentAddressForChain(0); // this._currentReceivingAddress = Future(() => currentAddress); // // await walletBase?.connectToNode( // node: Node( // uri: "xmr-node.cakewallet.com:18081", type: WalletType.wownero)); // walletBase?.startSync(); return true; } @override Future initializeExisting() async { Logging.instance.log( "Opening existing ${coin.prettyName} wallet $walletName...", level: LogLevel.Info); if ((DB.instance.get(boxName: walletId, key: "id")) == null) { debugPrint("Exception was thrown"); throw Exception( "Attempted to initialize an existing wallet using an unknown wallet ID!"); } walletService = wownero.createWowneroWalletService(DB.instance.moneroWalletInfoBox); keysStorage = KeyService(_secureStore); await _prefs.init(); final data = DB.instance.get(boxName: walletId, key: "latest_tx_model") as TransactionData?; if (data != null) { _transactionData = Future(() => data); } String? password; try { password = await keysStorage?.getWalletPassword(walletName: _walletId); } catch (e, s) { debugPrint("Exception was thrown $e $s"); throw Exception("Password not found $e, $s"); } walletBase = (await walletService?.openWallet(_walletId, password!)) as WowneroWalletBase; debugPrint("walletBase $walletBase"); Logging.instance.log( "Opened existing ${coin.prettyName} wallet $walletName", level: LogLevel.Info); // Wallet already exists, triggers for a returning user String indexKey = "receivingIndex"; final curIndex = await DB.instance.get(boxName: walletId, key: indexKey) as int; // Use new index to derive a new receiving address final newReceivingAddress = await _generateAddressForChain(0, curIndex); Logging.instance.log( "wownero address in init existing: $newReceivingAddress", level: LogLevel.Info); _currentReceivingAddress = Future(() => newReceivingAddress); } @override Future get maxFee async { var bal = await availableBalance; var fee = walletBase!.calculateEstimatedFee( wownero.getDefaultTransactionPriority(), Format.decimalAmountToSatoshis(bal, coin), ); return fee; } @override // TODO: implement pendingBalance Future get pendingBalance => throw UnimplementedError(); bool longMutex = false; // TODO: are these needed? WalletService? walletService; KeyService? keysStorage; WowneroWalletBase? walletBase; WalletCreationService? _walletCreationService; String toStringForinfo(WalletInfo info) { return "id: ${info.id} name: ${info.name} type: ${info.type} recovery: ${info.isRecovery}" " restoreheight: ${info.restoreHeight} timestamp: ${info.timestamp} dirPath: ${info.dirPath} " "path: ${info.path} address: ${info.address} addresses: ${info.addresses}"; } Future pathForWalletDir({ required String name, required WalletType type, }) async { Directory root = await StackFileSystem.applicationRootDirectory(); final prefix = walletTypeToString(type).toLowerCase(); final walletsDir = Directory('${root.path}/wallets'); final walletDire = Directory('${walletsDir.path}/$prefix/$name'); if (!walletDire.existsSync()) { walletDire.createSync(recursive: true); } return walletDire.path; } Future pathForWallet({ required String name, required WalletType type, }) async => await pathForWalletDir(name: name, type: type) .then((path) => '$path/$name'); // TODO: take in a dynamic height @override Future recoverFromMnemonic({ required String mnemonic, required int maxUnusedAddressGap, required int maxNumberOfIndexesToCheck, required int height, }) async { final int seedLength = mnemonic.trim().split(" ").length; if (!(seedLength == 14 || seedLength == 25)) { throw Exception("Invalid wownero mnemonic length found: $seedLength"); } await _prefs.init(); longMutex = true; final start = DateTime.now(); try { // Logging.instance.log("IS_INTEGRATION_TEST: $integrationTestFlag"); // if (!integrationTestFlag) { // final features = await electrumXClient.getServerFeatures(); // Logging.instance.log("features: $features"); // if (_networkType == BasicNetworkType.main) { // if (features['genesis_hash'] != GENESIS_HASH_MAINNET) { // throw Exception("genesis hash does not match main net!"); // } // } else if (_networkType == BasicNetworkType.test) { // if (features['genesis_hash'] != GENESIS_HASH_TESTNET) { // throw Exception("genesis hash does not match test net!"); // } // } // } // check to make sure we aren't overwriting a mnemonic // this should never fail if ((await _secureStore.read(key: '${_walletId}_mnemonic')) != null) { longMutex = false; throw Exception("Attempted to overwrite mnemonic on restore!"); } await _secureStore.write( key: '${_walletId}_mnemonic', value: mnemonic.trim()); // extract seed height from 14 word seed if (seedLength == 14) { height = getSeedHeightSync(mnemonic.trim()); } else { // 25 word seed. TODO validate if (height == 0) { height = wownero.getHeightByDate( date: DateTime.now().subtract(const Duration( days: 2))); // subtract a couple days to ensure we have a buffer for SWB\ } } await DB.instance .put(boxName: walletId, key: "restoreHeight", value: height); walletService = wownero.createWowneroWalletService(DB.instance.moneroWalletInfoBox); keysStorage = KeyService(_secureStore); WalletInfo walletInfo; WalletCredentials credentials; String name = _walletId; final dirPath = await pathForWalletDir(name: name, type: WalletType.wownero); final path = await pathForWallet(name: name, type: WalletType.wownero); credentials = wownero.createWowneroRestoreWalletFromSeedCredentials( name: name, height: height, mnemonic: mnemonic.trim(), ); try { walletInfo = WalletInfo.external( id: WalletBase.idFor(name, WalletType.wownero), name: name, type: WalletType.wownero, isRecovery: false, restoreHeight: credentials.height ?? 0, date: DateTime.now(), path: path, dirPath: dirPath, // TODO: find out what to put for address address: ''); credentials.walletInfo = walletInfo; _walletCreationService = WalletCreationService( secureStorage: _secureStore, walletService: walletService, keyService: keysStorage, ); _walletCreationService!.changeWalletType(); // To restore from a seed final wallet = await _walletCreationService!.restoreFromSeed(credentials); walletInfo.address = wallet.walletAddresses.address; await DB.instance .add(boxName: WalletInfo.boxName, value: walletInfo); walletBase?.close(); walletBase = wallet as WowneroWalletBase; await DB.instance.put( boxName: walletId, key: 'receivingAddresses', value: [walletInfo.address!]); await DB.instance .put(boxName: walletId, key: "receivingIndex", value: 0); await DB.instance .put(boxName: walletId, key: "id", value: _walletId); await DB.instance .put(boxName: walletId, key: "changeIndex", value: 0); await DB.instance.put( boxName: walletId, key: 'blocked_tx_hashes', value: ["0xdefault"], ); // A list of transaction hashes to represent frozen utxos in wallet // initialize address book entries await DB.instance.put( boxName: walletId, key: 'addressBookEntries', value: {}); await DB.instance .put(boxName: walletId, key: "isFavorite", value: false); } catch (e, s) { debugPrint(e.toString()); debugPrint(s.toString()); } final node = await getCurrentNode(); final host = Uri.parse(node.host).host; await walletBase?.connectToNode( node: Node(uri: "$host:${node.port}", type: WalletType.wownero)); await walletBase?.rescan(height: credentials.height); } catch (e, s) { Logging.instance.log( "Exception rethrown from recoverFromMnemonic(): $e\n$s", level: LogLevel.Error); longMutex = false; rethrow; } longMutex = false; final end = DateTime.now(); Logging.instance.log( "$walletName Recovery time: ${end.difference(start).inMilliseconds} millis", level: LogLevel.Info); } @override Future send({ required String toAddress, required int amount, Map args = const {}, }) async { try { final txData = await prepareSend( address: toAddress, satoshiAmount: amount, args: args); final txHash = await confirmSend(txData: txData); return txHash; } catch (e, s) { Logging.instance .log("Exception rethrown from send(): $e\n$s", level: LogLevel.Error); rethrow; } } @override Future testNetworkConnection() async { return await walletBase?.isConnected() ?? false; } Timer? _networkAliveTimer; void startNetworkAlivePinging() { // call once on start right away _periodicPingCheck(); // then periodically check _networkAliveTimer = Timer.periodic( Constants.networkAliveTimerDuration, (_) async { _periodicPingCheck(); }, ); } void _periodicPingCheck() async { bool hasNetwork = await testNetworkConnection(); _isConnected = hasNetwork; if (_isConnected != hasNetwork) { NodeConnectionStatus status = hasNetwork ? NodeConnectionStatus.connected : NodeConnectionStatus.disconnected; GlobalEventBus.instance .fire(NodeConnectionStatusChangedEvent(status, walletId, coin)); } } void stopNetworkAlivePinging() { _networkAliveTimer?.cancel(); _networkAliveTimer = null; } bool _isConnected = false; @override bool get isConnected => _isConnected; @override Future get totalBalance async { var transactions = walletBase?.transactionHistory!.transactions; int transactionBalance = 0; for (var tx in transactions!.entries) { if (tx.value.direction == TransactionDirection.incoming) { transactionBalance += tx.value.amount!; } else { transactionBalance += -tx.value.amount! - tx.value.fee!; } } // TODO: grab total balance var bal = 0; for (var element in walletBase!.balance!.entries) { bal = bal + element.value.fullBalance; } debugPrint("balances: $transactionBalance $bal"); if (isActive) { String am = wowneroAmountToString(amount: bal); return Decimal.parse(am); } else { String am = wowneroAmountToString(amount: transactionBalance); return Decimal.parse(am); } } @override // TODO: implement onIsActiveWalletChanged void Function(bool)? get onIsActiveWalletChanged => (isActive) async { await walletBase?.save(); walletBase?.close(); wowneroAutosaveTimer?.cancel(); wowneroAutosaveTimer = null; timer?.cancel(); timer = null; await stopSyncPercentTimer(); if (isActive) { String? password; try { password = await keysStorage?.getWalletPassword(walletName: _walletId); } catch (e, s) { debugPrint("Exception was thrown $e $s"); throw Exception("Password not found $e, $s"); } walletBase = (await walletService?.openWallet(_walletId, password!)) as WowneroWalletBase?; if (!(await walletBase!.isConnected())) { final node = await getCurrentNode(); final host = Uri.parse(node.host).host; await walletBase?.connectToNode( node: Node(uri: "$host:${node.port}", type: WalletType.wownero)); await walletBase?.startSync(); } await refresh(); } this.isActive = isActive; }; bool isActive = false; @override Future get transactionData => _transactionData ??= _fetchTransactionData(); Future? _transactionData; // not used in wownero TransactionData? cachedTxData; @override Future updateSentCachedTxData(Map txData) async { // not used in wownero } Future _fetchTransactionData() async { final transactions = walletBase?.transactionHistory!.transactions; final cachedTransactions = DB.instance.get(boxName: walletId, key: 'latest_tx_model') as TransactionData?; int latestTxnBlockHeight = DB.instance.get(boxName: walletId, key: "storedTxnDataHeight") as int? ?? 0; final txidsList = DB.instance .get(boxName: walletId, key: "cachedTxids") as List? ?? []; final Set cachedTxids = Set.from(txidsList); // TODO: filter to skip cached + confirmed txn processing in next step // final unconfirmedCachedTransactions = // cachedTransactions?.getAllTransactions() ?? {}; // unconfirmedCachedTransactions // .removeWhere((key, value) => value.confirmedStatus); // // if (cachedTransactions != null) { // for (final tx in allTxHashes.toList(growable: false)) { // final txHeight = tx["height"] as int; // if (txHeight > 0 && // txHeight < latestTxnBlockHeight - MINIMUM_CONFIRMATIONS) { // if (unconfirmedCachedTransactions[tx["tx_hash"] as String] == null) { // allTxHashes.remove(tx); // } // } // } // } // sort thing stuff // change to get Wownero price final priceData = await _priceAPI.getPricesAnd24hChange(baseCurrency: _prefs.currency); Decimal currentPrice = priceData[coin]?.item1 ?? Decimal.zero; final List> midSortedArray = []; if (transactions != null) { for (var tx in transactions.entries) { cachedTxids.add(tx.value.id); Logging.instance.log( "${tx.value.accountIndex} ${tx.value.addressIndex} ${tx.value.amount} ${tx.value.date} " "${tx.value.direction} ${tx.value.fee} ${tx.value.height} ${tx.value.id} ${tx.value.isPending} ${tx.value.key} " "${tx.value.recipientAddress}, ${tx.value.additionalInfo} con:${tx.value.confirmations}" " ${tx.value.keyIndex}", level: LogLevel.Info); String am = wowneroAmountToString(amount: tx.value.amount!); final worthNow = (currentPrice * Decimal.parse(am)).toStringAsFixed(2); Map midSortedTx = {}; // // create final tx map midSortedTx["txid"] = tx.value.id; midSortedTx["confirmed_status"] = !tx.value.isPending && tx.value.confirmations != null && tx.value.confirmations! >= MINIMUM_CONFIRMATIONS; midSortedTx["confirmations"] = tx.value.confirmations ?? 0; midSortedTx["timestamp"] = (tx.value.date.millisecondsSinceEpoch ~/ 1000); midSortedTx["txType"] = tx.value.direction == TransactionDirection.incoming ? "Received" : "Sent"; midSortedTx["amount"] = tx.value.amount; midSortedTx["worthNow"] = worthNow; midSortedTx["worthAtBlockTimestamp"] = worthNow; midSortedTx["fees"] = tx.value.fee; // TODO: shouldn't wownero have an address I can grab if (tx.value.direction == TransactionDirection.incoming) { final addressInfo = tx.value.additionalInfo; midSortedTx["address"] = walletBase?.getTransactionAddress( addressInfo!['accountIndex'] as int, addressInfo['addressIndex'] as int, ); } else { midSortedTx["address"] = ""; } final int txHeight = tx.value.height ?? 0; midSortedTx["height"] = txHeight; if (txHeight >= latestTxnBlockHeight) { latestTxnBlockHeight = txHeight; } midSortedTx["aliens"] = []; midSortedTx["inputSize"] = 0; midSortedTx["outputSize"] = 0; midSortedTx["inputs"] = []; midSortedTx["outputs"] = []; midSortedArray.add(midSortedTx); } } // sort by date ---- midSortedArray .sort((a, b) => (b["timestamp"] as int) - (a["timestamp"] as int)); Logging.instance.log(midSortedArray, level: LogLevel.Info); // buildDateTimeChunks final Map result = {"dateTimeChunks": []}; final dateArray = []; for (int i = 0; i < midSortedArray.length; i++) { final txObject = midSortedArray[i]; final date = extractDateFromTimestamp(txObject["timestamp"] as int); final txTimeArray = [txObject["timestamp"], date]; if (dateArray.contains(txTimeArray[1])) { result["dateTimeChunks"].forEach((dynamic chunk) { if (extractDateFromTimestamp(chunk["timestamp"] as int) == txTimeArray[1]) { if (chunk["transactions"] == null) { chunk["transactions"] = >[]; } chunk["transactions"].add(txObject); } }); } else { dateArray.add(txTimeArray[1]); final chunk = { "timestamp": txTimeArray[0], "transactions": [txObject], }; result["dateTimeChunks"].add(chunk); } } final transactionsMap = cachedTransactions?.getAllTransactions() ?? {}; transactionsMap .addAll(TransactionData.fromJson(result).getAllTransactions()); final txModel = TransactionData.fromMap(transactionsMap); await DB.instance.put( boxName: walletId, key: 'storedTxnDataHeight', value: latestTxnBlockHeight); await DB.instance.put( boxName: walletId, key: 'latest_tx_model', value: txModel); await DB.instance.put( boxName: walletId, key: 'cachedTxids', value: cachedTxids.toList(growable: false)); return txModel; } @override // TODO: implement unspentOutputs Future> get unspentOutputs => throw UnimplementedError(); @override bool validateAddress(String address) { bool valid = walletBase!.validateAddress(address); return valid; } @override String get walletId => _walletId; late String _walletId; @override String get walletName => _walletName; late String _walletName; // setter for updating on rename @override set walletName(String newName) => _walletName = newName; @override set isFavorite(bool markFavorite) { DB.instance.put( boxName: walletId, key: "isFavorite", value: markFavorite); } @override bool get isFavorite { try { return DB.instance.get(boxName: walletId, key: "isFavorite") as bool; } catch (e, s) { Logging.instance.log( "isFavorite fetch failed (returning false by default): $e\n$s", level: LogLevel.Error); return false; } } @override // TODO: implement availableBalance Future get availableBalance async { var bal = 0; for (var element in walletBase!.balance!.entries) { bal = bal + element.value.unlockedBalance; } String am = wowneroAmountToString(amount: bal); return Decimal.parse(am); } @override Coin get coin => _coin; @override Future confirmSend({required Map txData}) async { try { Logging.instance.log("confirmSend txData: $txData", level: LogLevel.Info); final pendingWowneroTransaction = txData['pendingWowneroTransaction'] as PendingWowneroTransaction; try { await pendingWowneroTransaction.commit(); Logging.instance.log( "transaction ${pendingWowneroTransaction.id} has been sent", level: LogLevel.Info); return pendingWowneroTransaction.id; } catch (e, s) { Logging.instance.log("$walletName wownero confirmSend: $e\n$s", level: LogLevel.Error); rethrow; } } catch (e, s) { Logging.instance.log("Exception rethrown from confirmSend(): $e\n$s", level: LogLevel.Info); rethrow; } } // TODO: fix the double free memory crash error. @override Future> prepareSend( {required String address, required int satoshiAmount, Map? args}) async { int amount = satoshiAmount; String toAddress = address; try { final feeRate = args?["feeRate"]; if (feeRate is FeeRateType) { MoneroTransactionPriority feePriority; switch (feeRate) { case FeeRateType.fast: feePriority = MoneroTransactionPriority.fast; break; case FeeRateType.average: feePriority = MoneroTransactionPriority.regular; break; case FeeRateType.slow: feePriority = MoneroTransactionPriority.slow; break; } Future? awaitPendingTransaction; try { // check for send all bool isSendAll = false; final balance = await availableBalance; final satInDecimal = ((Decimal.fromInt(satoshiAmount) / Decimal.fromInt(Constants.satsPerCoin(coin))) .toDecimal()); if (satInDecimal == balance) { isSendAll = true; } Logging.instance .log("$toAddress $amount $args", level: LogLevel.Info); String amountToSend = wowneroAmountToString(amount: amount); Logging.instance.log("$amount $amountToSend", level: LogLevel.Info); wownero_output.Output output = wownero_output.Output(walletBase!); output.address = toAddress; output.sendAll = isSendAll; output.setCryptoAmount(amountToSend); List outputs = [output]; Object tmp = wownero.createWowneroTransactionCreationCredentials( outputs: outputs, priority: feePriority); await prepareSendMutex.protect(() async { awaitPendingTransaction = walletBase!.createTransaction(tmp); }); } catch (e, s) { Logging.instance.log("Exception rethrown from prepareSend(): $e\n$s", level: LogLevel.Warning); } PendingWowneroTransaction pendingWowneroTransaction = await (awaitPendingTransaction!) as PendingWowneroTransaction; int realfee = Format.decimalAmountToSatoshis( Decimal.parse(pendingWowneroTransaction.feeFormatted), coin); debugPrint("fee? $realfee"); Map txData = { "pendingWowneroTransaction": pendingWowneroTransaction, "fee": realfee, "addresss": toAddress, "recipientAmt": satoshiAmount, }; Logging.instance.log("prepare send: $txData", level: LogLevel.Info); return txData; } else { throw ArgumentError("Invalid fee rate argument provided!"); } } catch (e, s) { Logging.instance.log("Exception rethrown from prepare send(): $e\n$s", level: LogLevel.Info); if (e.toString().contains("Incorrect unlocked balance")) { throw Exception("Insufficient balance!"); } else if (e is CreationTransactionException) { throw Exception("Insufficient funds to pay for transaction fee!"); } else { throw Exception("Transaction failed with error code $e"); } } } Mutex prepareSendMutex = Mutex(); Mutex estimateFeeMutex = Mutex(); @override Future estimateFeeFor(int satoshiAmount, int feeRate) async { MoneroTransactionPriority priority; FeeRateType feeRateType = FeeRateType.slow; switch (feeRate) { case 1: priority = MoneroTransactionPriority.regular; feeRateType = FeeRateType.average; break; case 2: priority = MoneroTransactionPriority.medium; feeRateType = FeeRateType.average; break; case 3: priority = MoneroTransactionPriority.fast; feeRateType = FeeRateType.fast; break; case 4: priority = MoneroTransactionPriority.fastest; feeRateType = FeeRateType.fast; break; case 0: default: priority = MoneroTransactionPriority.slow; feeRateType = FeeRateType.slow; break; } var aprox; await estimateFeeMutex.protect(() async { { try { aprox = (await prepareSend( // This address is only used for getting an approximate fee, never for sending address: "WW3iVcnoAY6K9zNdU4qmdvZELefx6xZz4PMpTwUifRkvMQckyadhSPYMVPJhBdYE8P9c27fg9RPmVaWNFx1cDaj61HnetqBiy", satoshiAmount: satoshiAmount, args: {"feeRate": feeRateType}))['fee']; await Future.delayed(const Duration(milliseconds: 500)); } catch (e, s) { aprox = walletBase!.calculateEstimatedFee(priority, satoshiAmount); } } }); print("this is the aprox fee $aprox for $satoshiAmount"); final fee = (aprox as int); return fee; } @override Future generateNewAddress() async { try { const String indexKey = "receivingIndex"; // First increment the receiving index await _incrementAddressIndexForChain(0); final newReceivingIndex = DB.instance.get(boxName: walletId, key: indexKey) as int; // Use new index to derive a new receiving address final newReceivingAddress = await _generateAddressForChain(0, newReceivingIndex); // Add that new receiving address to the array of receiving addresses await _addToAddressesArrayForChain(newReceivingAddress, 0); // Set the new receiving address that the service _currentReceivingAddress = Future(() => newReceivingAddress); return true; } catch (e, s) { Logging.instance.log( "Exception rethrown from generateNewAddress(): $e\n$s", level: LogLevel.Error); return false; } } }