import 'dart:async'; import 'package:isar/isar.dart'; import 'package:meta/meta.dart'; import 'package:mutex/mutex.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/blockchain_data/address.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/node_model.dart'; import '../../models/paymint/fee_object_model.dart'; import '../../services/event_bus/events/global/node_connection_status_changed_event.dart'; import '../../services/event_bus/events/global/refresh_percent_changed_event.dart'; import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import '../../services/event_bus/global_event_bus.dart'; import '../../services/node_service.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/constants.dart'; import '../../utilities/enums/sync_type_enum.dart'; import '../../utilities/flutter_secure_storage_interface.dart'; import '../../utilities/logger.dart'; import '../../utilities/paynym_is_api.dart'; import '../../utilities/prefs.dart'; import '../crypto_currency/crypto_currency.dart'; import '../isar/models/wallet_info.dart'; import '../models/tx_data.dart'; import 'impl/banano_wallet.dart'; import 'impl/bitcoin_frost_wallet.dart'; import 'impl/bitcoin_wallet.dart'; import 'impl/bitcoincash_wallet.dart'; import 'impl/cardano_wallet.dart'; import 'impl/dash_wallet.dart'; import 'impl/dogecoin_wallet.dart'; import 'impl/ecash_wallet.dart'; import 'impl/epiccash_wallet.dart'; import 'impl/ethereum_wallet.dart'; import 'impl/firo_wallet.dart'; import 'impl/litecoin_wallet.dart'; import 'impl/monero_wallet.dart'; import 'impl/namecoin_wallet.dart'; import 'impl/nano_wallet.dart'; import 'impl/particl_wallet.dart'; import 'impl/peercoin_wallet.dart'; import 'impl/solana_wallet.dart'; import 'impl/stellar_wallet.dart'; import 'impl/sub_wallets/eth_token_wallet.dart'; import 'impl/tezos_wallet.dart'; import 'impl/wownero_wallet.dart'; import 'intermediate/cryptonote_wallet.dart'; import 'wallet_mixin_interfaces/electrumx_interface.dart'; import 'wallet_mixin_interfaces/lelantus_interface.dart'; import 'wallet_mixin_interfaces/mnemonic_interface.dart'; import 'wallet_mixin_interfaces/multi_address_interface.dart'; import 'wallet_mixin_interfaces/paynym_interface.dart'; import 'wallet_mixin_interfaces/private_key_interface.dart'; import 'wallet_mixin_interfaces/spark_interface.dart'; import 'wallet_mixin_interfaces/view_only_option_interface.dart'; abstract class Wallet { // default to Transaction class. For TransactionV2 set to 2 int get isarTransactionVersion => 1; // whether the wallet currently supports multiple recipients per tx bool get supportsMultiRecipient => false; Wallet(this.cryptoCurrency); //============================================================================ // ========== Properties ===================================================== final T cryptoCurrency; late final MainDB mainDB; late final SecureStorageInterface secureStorageInterface; late final NodeService nodeService; late final Prefs prefs; final refreshMutex = Mutex(); late final String _walletId; WalletInfo get info => mainDB.isar.walletInfo.where().walletIdEqualTo(walletId).findFirstSync()!; bool get isConnected => _isConnected; bool get shouldAutoSync => _shouldAutoSync; set shouldAutoSync(bool shouldAutoSync) { if (_shouldAutoSync != shouldAutoSync) { _shouldAutoSync = shouldAutoSync; if (!shouldAutoSync) { _periodicRefreshTimer?.cancel(); _periodicRefreshTimer = null; _stopNetworkAlivePinging(); } else { _startNetworkAlivePinging(); refresh(); } } } // ===== private properties =========================================== Timer? _periodicRefreshTimer; Timer? _networkAliveTimer; bool _shouldAutoSync = false; bool _isConnected = false; void xmrAndWowSyncSpecificFunctionThatShouldBeGottenRidOfInTheFuture( bool flag, ) { _isConnected = flag; } //============================================================================ // ========== Wallet Info Convenience Getters ================================ String get walletId => _walletId; /// Attempt to fetch the most recent chain height. /// On failure return the last cached height. Future get chainHeight async { try { // attempt updating the walletInfo's cached height await updateChainHeight(); } catch (e, s) { // do nothing on failure (besides logging) Logging.instance.log("$e\n$s", level: LogLevel.Warning); } // return regardless of whether it was updated or not as we want a // number even if it isn't the most recent return info.cachedChainHeight; } //============================================================================ // ========== Static Main ==================================================== /// Create a new wallet and save [walletInfo] to db. static Future create({ required WalletInfo walletInfo, required MainDB mainDB, required SecureStorageInterface secureStorageInterface, required NodeService nodeService, required Prefs prefs, String? mnemonic, String? mnemonicPassphrase, String? privateKey, ViewOnlyWalletData? viewOnlyData, }) async { // TODO: rework soon? if (walletInfo.isViewOnly && viewOnlyData == null) { throw Exception("Missing view key while creating view only wallet!"); } final Wallet wallet = await _construct( walletInfo: walletInfo, mainDB: mainDB, secureStorageInterface: secureStorageInterface, nodeService: nodeService, prefs: prefs, ); if (wallet is ViewOnlyOptionInterface) { await secureStorageInterface.write( key: getViewOnlyWalletDataSecStoreKey(walletId: walletInfo.walletId), value: viewOnlyData!.toJsonEncodedString(), ); } else if (wallet is MnemonicInterface) { if (wallet is CryptonoteWallet) { // currently a special case due to the xmr/wow libraries handling their // own mnemonic generation on new wallet creation // if its a restore we must set them if (mnemonic != null) { if ((await secureStorageInterface.read( key: mnemonicKey(walletId: walletInfo.walletId), )) == null) { await secureStorageInterface.write( key: mnemonicKey(walletId: walletInfo.walletId), value: mnemonic, ); } if (mnemonicPassphrase != null) { if ((await secureStorageInterface.read( key: mnemonicPassphraseKey(walletId: walletInfo.walletId), )) == null) { await secureStorageInterface.write( key: mnemonicPassphraseKey(walletId: walletInfo.walletId), value: mnemonicPassphrase, ); } } } } else { await secureStorageInterface.write( key: mnemonicKey(walletId: walletInfo.walletId), value: mnemonic!, ); await secureStorageInterface.write( key: mnemonicPassphraseKey(walletId: walletInfo.walletId), value: mnemonicPassphrase!, ); } } // TODO [prio=low] handle eth differently? // This would need to be changed if we actually end up allowing eth wallets // to be created with a private key instead of mnemonic only if (wallet is PrivateKeyInterface && wallet is! EthereumWallet) { await secureStorageInterface.write( key: privateKeyKey(walletId: walletInfo.walletId), value: privateKey!, ); } // Store in db after wallet creation await wallet.mainDB.isar.writeTxn(() async { await wallet.mainDB.isar.walletInfo.put(walletInfo); }); return wallet; } /// Load an existing wallet via [WalletInfo] using [walletId]. static Future load({ required String walletId, required MainDB mainDB, required SecureStorageInterface secureStorageInterface, required NodeService nodeService, required Prefs prefs, }) async { final walletInfo = await mainDB.isar.walletInfo .where() .walletIdEqualTo(walletId) .findFirst(); if (walletInfo == null) { throw Exception( "WalletInfo not found for $walletId when trying to call Wallet.load()", ); } return await _construct( walletInfo: walletInfo, mainDB: mainDB, secureStorageInterface: secureStorageInterface, nodeService: nodeService, prefs: prefs, ); } // TODO: [prio=low] refactor to more generalized token rather than eth specific static Wallet loadTokenWallet({ required EthereumWallet ethWallet, required EthContract contract, }) { final Wallet wallet = EthTokenWallet( ethWallet, contract, ); wallet.prefs = ethWallet.prefs; wallet.nodeService = ethWallet.nodeService; wallet.secureStorageInterface = ethWallet.secureStorageInterface; wallet.mainDB = ethWallet.mainDB; return wallet.._walletId = ethWallet.info.walletId; } //============================================================================ // ========== Static Util ==================================================== // secure storage key static String mnemonicKey({ required String walletId, }) => "${walletId}_mnemonic"; // secure storage key static String mnemonicPassphraseKey({ required String walletId, }) => "${walletId}_mnemonicPassphrase"; // secure storage key static String privateKeyKey({ required String walletId, }) => "${walletId}_privateKey"; // secure storage key static String getViewOnlyWalletDataSecStoreKey({ required String walletId, }) => "${walletId}_viewOnlyWalletData"; //============================================================================ // ========== Private ======================================================== /// Construct wallet instance by [WalletType] from [walletInfo] static Future _construct({ required WalletInfo walletInfo, required MainDB mainDB, required SecureStorageInterface secureStorageInterface, required NodeService nodeService, required Prefs prefs, }) async { final Wallet wallet = _loadWallet( walletInfo: walletInfo, ); wallet.prefs = prefs; wallet.nodeService = nodeService; if (wallet is ElectrumXInterface || wallet is BitcoinFrostWallet) { // initialize electrumx instance await wallet.updateNode(); } return wallet ..secureStorageInterface = secureStorageInterface ..mainDB = mainDB .._walletId = walletInfo.walletId; } static Wallet _loadWallet({ required WalletInfo walletInfo, }) { final net = walletInfo.coin.network; switch (walletInfo.coin.runtimeType) { case const (Banano): return BananoWallet(net); case const (Bitcoin): return BitcoinWallet(net); case const (BitcoinFrost): return BitcoinFrostWallet(net); case const (Bitcoincash): return BitcoincashWallet(net); case const (Cardano): return CardanoWallet(net); case const (Dash): return DashWallet(net); case const (Dogecoin): return DogecoinWallet(net); case const (Ecash): return EcashWallet(net); case const (Epiccash): return EpiccashWallet(net); case const (Ethereum): return EthereumWallet(net); case const (Firo): return FiroWallet(net); case const (Litecoin): return LitecoinWallet(net); case const (Monero): return MoneroWallet(net); case const (Namecoin): return NamecoinWallet(net); case const (Nano): return NanoWallet(net); case const (Particl): return ParticlWallet(net); case const (Peercoin): return PeercoinWallet(net); case const (Solana): return SolanaWallet(net); case const (Stellar): return StellarWallet(net); case const (Tezos): return TezosWallet(net); case const (Wownero): return WowneroWallet(net); default: // should never hit in reality throw Exception("Unknown crypto currency: ${walletInfo.coin}"); } } void _startNetworkAlivePinging() { // call once on start right away _periodicPingCheck(); // then periodically check _networkAliveTimer = Timer.periodic( Constants.networkAliveTimerDuration, (_) async { _periodicPingCheck(); }, ); } void _periodicPingCheck() async { final bool hasNetwork = await pingCheck(); if (_isConnected != hasNetwork) { final NodeConnectionStatus status = hasNetwork ? NodeConnectionStatus.connected : NodeConnectionStatus.disconnected; GlobalEventBus.instance.fire( NodeConnectionStatusChangedEvent( status, walletId, cryptoCurrency, ), ); _isConnected = hasNetwork; if (status == NodeConnectionStatus.disconnected) { GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.unableToSync, walletId, cryptoCurrency, ), ); } if (hasNetwork) { unawaited(refresh()); } } } void _stopNetworkAlivePinging() { _networkAliveTimer?.cancel(); _networkAliveTimer = null; } //============================================================================ // ========== Must override ================================================== /// Create and sign a transaction in preparation to submit to network Future prepareSend({required TxData txData}); /// Broadcast transaction to network. On success update local wallet state to /// reflect updated balance, transactions, utxos, etc. Future confirmSend({required TxData txData}); /// 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 /// delete all locally stored blockchain data and refetch it. Future recover({required bool isRescan}); Future updateNode(); Future updateTransactions(); Future updateBalance(); /// returns true if new utxos were added to local db Future updateUTXOs(); /// updates the wallet info's cachedChainHeight Future updateChainHeight(); Future estimateFeeFor(Amount amount, int feeRate); Future get fees; Future pingCheck(); Future checkSaveInitialReceivingAddress(); //=========================================== /// add transaction to local db temporarily. Used for quickly updating ui /// before refresh can fetch data from server Future updateSentCachedTxData({required TxData txData}) async { if (txData.tempTx != null) { await mainDB.updateOrPutTransactionV2s([txData.tempTx!]); } return txData; } NodeModel getCurrentNode() { final node = nodeService.getPrimaryNodeFor(currency: cryptoCurrency) ?? cryptoCurrency.defaultNode; return node; } // Should fire events Future refresh() async { final refreshCompleter = Completer(); final future = refreshCompleter.future.then( (_) { GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.synced, walletId, cryptoCurrency, ), ); if (shouldAutoSync) { _periodicRefreshTimer ??= Timer.periodic(const Duration(seconds: 150), (timer) async { // chain height check currently broken // if ((await chainHeight) != (await storedChainHeight)) { // TODO: [prio=med] some kind of quick check if wallet needs to refresh to replace the old refreshIfThereIsNewData call // if (await refreshIfThereIsNewData()) { unawaited(refresh()); // } // } }); } }, onError: (Object error, StackTrace strace) { GlobalEventBus.instance.fire( NodeConnectionStatusChangedEvent( NodeConnectionStatus.disconnected, walletId, cryptoCurrency, ), ); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.unableToSync, walletId, cryptoCurrency, ), ); Logging.instance.log( "Caught exception in refreshWalletData(): $error\n$strace", level: LogLevel.Error, ); }, ); unawaited(_refresh(refreshCompleter)); return future; } // Should fire events Future _refresh(Completer completer) async { // Awaiting this lock could be dangerous. // Since refresh is periodic (generally) if (refreshMutex.isLocked) { return; } final start = DateTime.now(); bool tAlive = true; final t = Timer.periodic(const Duration(seconds: 1), (timer) async { if (tAlive) { final pingSuccess = await pingCheck(); if (!pingSuccess) { tAlive = false; } } else { timer.cancel(); } }); void _checkAlive() { if (!tAlive) throw Exception("refresh alive ping failure"); } try { // this acquire should be almost instant due to above check. // Slight possibility of race but should be irrelevant await refreshMutex.acquire(); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.syncing, walletId, cryptoCurrency, ), ); _checkAlive(); // add some small buffer before making calls. // this can probably be removed in the future but was added as a // debugging feature await Future.delayed(const Duration(milliseconds: 300)); _checkAlive(); // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. final Set codesToCheck = {}; _checkAlive(); if (this is PaynymInterface) { // isSegwit does not matter here at all final myCode = await (this as PaynymInterface).getPaymentCode(isSegwit: false); _checkAlive(); final nym = await PaynymIsApi().nym(myCode.toString()); _checkAlive(); if (nym.value != null) { for (final follower in nym.value!.followers) { codesToCheck.add(follower.code); } _checkAlive(); for (final following in nym.value!.following) { codesToCheck.add(following.code); } } _checkAlive(); } GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.0, walletId)); _checkAlive(); await updateChainHeight(); _checkAlive(); if (this is BitcoinFrostWallet) { await (this as BitcoinFrostWallet).lookAhead(); } _checkAlive(); GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.1, walletId)); _checkAlive(); // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. if (this is MultiAddressInterface) { if (info.otherData[WalletInfoKeys.reuseAddress] != true) { await (this as MultiAddressInterface) .checkReceivingAddressForTransactions(); } _checkAlive(); } GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.2, walletId)); _checkAlive(); // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. if (this is MultiAddressInterface) { await (this as MultiAddressInterface) .checkChangeAddressForTransactions(); } _checkAlive(); GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.3, walletId)); if (this is SparkInterface) { // this should be called before updateTransactions() await (this as SparkInterface).refreshSparkData(); } _checkAlive(); GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.50, walletId)); _checkAlive(); final fetchFuture = updateTransactions(); _checkAlive(); final utxosRefreshFuture = updateUTXOs(); // if (currentHeight != storedHeight) { GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.60, walletId)); _checkAlive(); await utxosRefreshFuture; GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.70, walletId)); _checkAlive(); await fetchFuture; // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. if (this is PaynymInterface && codesToCheck.isNotEmpty) { _checkAlive(); await (this as PaynymInterface) .checkForNotificationTransactionsTo(codesToCheck); // check utxos again for notification outputs _checkAlive(); await updateUTXOs(); } _checkAlive(); GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.80, walletId)); // await getAllTxsToWatch(); _checkAlive(); // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. if (this is LelantusInterface) { if (info.otherData[WalletInfoKeys.enableLelantusScanning] as bool? ?? false) { await (this as LelantusInterface).refreshLelantusData(); _checkAlive(); } } GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.90, walletId)); _checkAlive(); await updateBalance(); _checkAlive(); GlobalEventBus.instance.fire(RefreshPercentChangedEvent(1.0, walletId)); tAlive = false; // interrupt timer as its not needed anymore completer.complete(); } catch (error, strace) { completer.completeError(error, strace); } finally { t.cancel(); refreshMutex.release(); if (!completer.isCompleted) { completer.completeError( "finally block hit before completer completed", StackTrace.current, ); } Logging.instance.log( "Refresh for " "${info.name}: ${DateTime.now().difference(start)}", level: LogLevel.Info, ); } } Future exit() async { _periodicRefreshTimer?.cancel(); _networkAliveTimer?.cancel(); // If the syncing pref is currentWalletOnly or selectedWalletsAtStartup (and // this wallet isn't in walletIdsSyncOnStartup), then we close subscriptions. switch (prefs.syncType) { case SyncingType.currentWalletOnly: // Close the subscription for this coin's chain height. // NOTE: This does not work now that the subscription is shared // await (await ChainHeightServiceManager.getService(cryptoCurrency)) // ?.cancelListen(); case SyncingType.selectedWalletsAtStartup: // Close the subscription if this wallet is not in the list to be synced. if (!prefs.walletIdsSyncOnStartup.contains(walletId)) { // Check if there's another wallet of this coin on the sync list. final List walletIds = []; for (final id in prefs.walletIdsSyncOnStartup) { final wallet = mainDB.isar.walletInfo .where() .walletIdEqualTo(id) .findFirstSync()!; if (wallet.coin == cryptoCurrency) { walletIds.add(id); } } // TODO [prio=low]: use a query instead of iterating thru wallets. // If there are no other wallets of this coin, then close the sub. if (walletIds.isEmpty) { // NOTE: This does not work now that the subscription is shared // await (await ChainHeightServiceManager.getService( // cryptoCurrency)) // ?.cancelListen(); } } case SyncingType.allWalletsOnStartup: // Do nothing. break; } } @mustCallSuper Future init() async { await checkSaveInitialReceivingAddress(); final address = await getCurrentReceivingAddress(); if (address != null) { await info.updateReceivingAddress( newAddress: address.value, isar: mainDB.isar, ); } } // =========================================================================== FilterOperation? get transactionFilterOperation => null; FilterOperation? get receivingAddressFilterOperation; FilterOperation? get changeAddressFilterOperation; Future getCurrentReceivingAddress() async { return await _addressQuery(receivingAddressFilterOperation); } Future getCurrentChangeAddress() async { return await _addressQuery(changeAddressFilterOperation); } Future _addressQuery(FilterOperation? filterOperation) async { return await mainDB.isar.addresses .buildQuery
( whereClauses: [ IndexWhereClause.equalTo( indexName: r"walletId", value: [walletId], ), ], filter: filterOperation, sortBy: [ const SortProperty( property: r"derivationIndex", sort: Sort.desc, ), ], ) .findFirst(); } }