import 'dart:async'; import 'package:isar/isar.dart'; import 'package:meta/meta.dart'; import 'package:mutex/mutex.dart'; import 'package:stackwallet/db/isar/main_db.dart'; import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/models/paymint/fee_object_model.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/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/utilities/amount/amount.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/flutter_secure_storage_interface.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; import 'package:stackwallet/wallets/models/tx_data.dart'; import 'package:stackwallet/wallets/wallet/impl/banano_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/bitcoin_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/bitcoincash_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/dogecoin_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/ecash_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/nano_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/tezos_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/wownero_wallet.dart'; import 'package:stackwallet/wallets/wallet/private_key_based_wallet.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/lelantus_interface.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; abstract class Wallet { // default to Transaction class. For TransactionV2 set to 2 int get isarTransactionVersion => 1; 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(); WalletInfo get info => _walletInfo; 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 =========================================== late WalletInfo _walletInfo; late final Stream _walletInfoStream; Timer? _periodicRefreshTimer; Timer? _networkAliveTimer; bool _shouldAutoSync = false; bool _isConnected = false; void xmrAndWowSyncSpecificFunctionThatShouldBeGottenRidOfInTheFuture( bool flag) { _isConnected = flag; } //============================================================================ // ========== Wallet Info Convenience Getters ================================ String get walletId => info.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, }) async { final Wallet wallet = await _construct( walletInfo: walletInfo, mainDB: mainDB, secureStorageInterface: secureStorageInterface, nodeService: nodeService, prefs: prefs, ); if (wallet is MnemonicInterface) { await secureStorageInterface.write( key: mnemonicKey(walletId: walletInfo.walletId), value: mnemonic!, ); await secureStorageInterface.write( key: mnemonicPassphraseKey(walletId: walletInfo.walletId), value: mnemonicPassphrase!, ); } if (wallet is PrivateKeyBasedWallet) { 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(wallet.info); }); 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, ); } //============================================================================ // ========== 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"; //============================================================================ // ========== 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) { // initialize electrumx instance await wallet.updateNode(); } return wallet ..secureStorageInterface = secureStorageInterface ..mainDB = mainDB .._walletInfo = walletInfo .._watchWalletInfo(); } static Wallet _loadWallet({ required WalletInfo walletInfo, }) { switch (walletInfo.coin) { case Coin.banano: return BananoWallet(CryptoCurrencyNetwork.main); case Coin.bitcoin: return BitcoinWallet(CryptoCurrencyNetwork.main); case Coin.bitcoinTestNet: return BitcoinWallet(CryptoCurrencyNetwork.test); case Coin.bitcoincash: return BitcoincashWallet(CryptoCurrencyNetwork.main); case Coin.bitcoincashTestnet: return BitcoincashWallet(CryptoCurrencyNetwork.test); case Coin.dogecoin: return DogecoinWallet(CryptoCurrencyNetwork.main); case Coin.dogecoinTestNet: return DogecoinWallet(CryptoCurrencyNetwork.test); case Coin.eCash: return EcashWallet(CryptoCurrencyNetwork.main); case Coin.epicCash: return EpiccashWallet(CryptoCurrencyNetwork.main); case Coin.firo: return FiroWallet(CryptoCurrencyNetwork.main); case Coin.firoTestNet: return FiroWallet(CryptoCurrencyNetwork.test); case Coin.nano: return NanoWallet(CryptoCurrencyNetwork.main); case Coin.tezos: return TezosWallet(CryptoCurrencyNetwork.main); case Coin.wownero: return WowneroWallet(CryptoCurrencyNetwork.main); default: // should never hit in reality throw Exception("Unknown crypto currency: ${walletInfo.coin}"); } } // listen to changes in db and updated wallet info property as required void _watchWalletInfo() { _walletInfoStream = mainDB.isar.walletInfo.watchObject( _walletInfo.id, fireImmediately: true, ); _walletInfoStream.forEach((element) { if (element != null) { _walletInfo = element; } }); } 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 pingCheck(); if (_isConnected != hasNetwork) { NodeConnectionStatus status = hasNetwork ? NodeConnectionStatus.connected : NodeConnectionStatus.disconnected; GlobalEventBus.instance.fire( NodeConnectionStatusChangedEvent( status, walletId, cryptoCurrency.coin, ), ); _isConnected = hasNetwork; 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 updateUTXOs(); Future updateBalance(); /// updates the wallet info's cachedChainHeight Future updateChainHeight(); Future estimateFeeFor(Amount amount, int feeRate); Future get fees; Future pingCheck(); //=========================================== NodeModel getCurrentNode() { final node = nodeService.getPrimaryNodeFor(coin: cryptoCurrency.coin) ?? DefaultNodes.getNodeFor(cryptoCurrency.coin); return node; } // Should fire events Future refresh() async { // Awaiting this lock could be dangerous. // Since refresh is periodic (generally) if (refreshMutex.isLocked) { return; } 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.coin, ), ); GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.0, walletId)); await updateChainHeight(); GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.1, walletId)); // 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) .checkReceivingAddressForTransactions(); } GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.2, walletId)); // 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(); } GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.3, walletId)); GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.50, walletId)); final fetchFuture = updateTransactions(); final utxosRefreshFuture = updateUTXOs(); // if (currentHeight != storedHeight) { GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.60, walletId)); await utxosRefreshFuture; GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.70, walletId)); await fetchFuture; GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.80, walletId)); // await getAllTxsToWatch(); // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided. if (this is LelantusInterface) { await (this as LelantusInterface).refreshLelantusData(); } if (this is SparkInterface) { await (this as SparkInterface).refreshSparkData(); } GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.90, walletId)); await updateBalance(); GlobalEventBus.instance.fire(RefreshPercentChangedEvent(1.0, walletId)); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.synced, walletId, cryptoCurrency.coin, ), ); 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()); // } // } }); } } catch (error, strace) { GlobalEventBus.instance.fire( NodeConnectionStatusChangedEvent( NodeConnectionStatus.disconnected, walletId, cryptoCurrency.coin, ), ); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.unableToSync, walletId, cryptoCurrency.coin, ), ); Logging.instance.log( "Caught exception in refreshWalletData(): $error\n$strace", level: LogLevel.Error, ); } finally { refreshMutex.release(); } } Future exit() async { _periodicRefreshTimer?.cancel(); _networkAliveTimer?.cancel(); // TODO: } @mustCallSuper Future init() async { final address = await getCurrentReceivingAddress(); if (address != null) { await info.updateReceivingAddress( newAddress: address.value, isar: mainDB.isar, ); } // TODO: make sure subclasses override this if they require some set up // especially xmr/wow/epiccash } // =========================================================================== 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(); } }