import 'dart:async'; import 'dart:convert'; import 'package:decimal/decimal.dart'; import 'package:isar/isar.dart'; import 'package:stackwallet/db/isar/main_db.dart'; import 'package:stackwallet/models/balance.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/utxo.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/models/paymint/fee_object_model.dart'; import 'package:stackwallet/networking/http.dart'; import 'package:stackwallet/services/coins/coin_service.dart'; import 'package:stackwallet/services/event_bus/events/global/node_connection_status_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/mixins/wallet_cache.dart'; import 'package:stackwallet/services/mixins/wallet_db.dart'; import 'package:stackwallet/services/node_service.dart'; import 'package:stackwallet/services/tor_service.dart'; import 'package:stackwallet/services/transaction_notification_tracker.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/enums/fee_rate_type_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:tezart/tezart.dart'; import 'package:tuple/tuple.dart'; const int MINIMUM_CONFIRMATIONS = 1; const int _gasLimit = 10200; class TezosWallet extends CoinServiceAPI with WalletCache, WalletDB { TezosWallet({ required String walletId, required String walletName, required Coin coin, required SecureStorageInterface secureStore, required TransactionNotificationTracker tracker, MainDB? mockableOverride, }) { txTracker = tracker; _walletId = walletId; _walletName = walletName; _coin = coin; _secureStore = secureStore; initCache(walletId, coin); initWalletDB(mockableOverride: mockableOverride); } NodeModel? _xtzNode; NodeModel getCurrentNode() { return _xtzNode ?? NodeService(secureStorageInterface: _secureStore) .getPrimaryNodeFor(coin: Coin.tezos) ?? DefaultNodes.getNodeFor(Coin.tezos); } Future getKeystore() async { return Keystore.fromMnemonic((await mnemonicString).toString()); } @override String get walletId => _walletId; late String _walletId; @override String get walletName => _walletName; late String _walletName; @override set walletName(String name) => _walletName = name; @override set isFavorite(bool markFavorite) { _isFavorite = markFavorite; updateCachedIsFavorite(markFavorite); } @override bool get isFavorite => _isFavorite ??= getCachedIsFavorite(); bool? _isFavorite; @override Coin get coin => _coin; late Coin _coin; late SecureStorageInterface _secureStore; late final TransactionNotificationTracker txTracker; final _prefs = Prefs.instance; Timer? timer; bool _shouldAutoSync = false; Timer? _networkAliveTimer; @override bool get shouldAutoSync => _shouldAutoSync; HTTP client = HTTP(); @override set shouldAutoSync(bool shouldAutoSync) { if (_shouldAutoSync != shouldAutoSync) { _shouldAutoSync = shouldAutoSync; if (!shouldAutoSync) { timer?.cancel(); timer = null; stopNetworkAlivePinging(); } else { startNetworkAlivePinging(); refresh(); } } } void startNetworkAlivePinging() { // call once on start right away _periodicPingCheck(); // then periodically check _networkAliveTimer = Timer.periodic( Constants.networkAliveTimerDuration, (_) async { _periodicPingCheck(); }, ); } void stopNetworkAlivePinging() { _networkAliveTimer?.cancel(); _networkAliveTimer = null; } void _periodicPingCheck() async { bool hasNetwork = await testNetworkConnection(); if (_isConnected != hasNetwork) { NodeConnectionStatus status = hasNetwork ? NodeConnectionStatus.connected : NodeConnectionStatus.disconnected; GlobalEventBus.instance.fire( NodeConnectionStatusChangedEvent( status, walletId, coin, ), ); _isConnected = hasNetwork; if (hasNetwork) { unawaited(refresh()); } } } @override Balance get balance => _balance ??= getCachedBalance(); Balance? _balance; @override Future> prepareSend( {required String address, required Amount amount, Map? args}) async { try { if (amount.decimals != coin.decimals) { throw Exception("Amount decimals do not match coin decimals!"); } var fee = int.parse((await estimateFeeFor( amount, (args!["feeRate"] as FeeRateType).index)) .raw .toString()); Map txData = { "fee": fee, "address": address, "recipientAmt": amount, }; return Future.value(txData); } catch (e) { return Future.error(e); } } @override Future confirmSend({required Map txData}) async { try { final amount = txData["recipientAmt"] as Amount; final amountInMicroTez = amount.decimal * Decimal.fromInt(1000000); final microtezToInt = int.parse(amountInMicroTez.toString()); final int feeInMicroTez = int.parse(txData["fee"].toString()); final String destinationAddress = txData["address"] as String; final secretKey = Keystore.fromMnemonic((await mnemonicString)!).secretKey; Logging.instance.log(secretKey, level: LogLevel.Info); final sourceKeyStore = Keystore.fromSecretKey(secretKey); final client = TezartClient(getCurrentNode().host); int? sendAmount = microtezToInt; int gasLimit = _gasLimit; int thisFee = feeInMicroTez; if (balance.spendable == txData["recipientAmt"] as Amount) { //Fee guides for emptying a tz account // https://github.com/TezTech/eztz/blob/master/PROTO_004_FEES.md thisFee = thisFee + 32; sendAmount = microtezToInt - thisFee; gasLimit = _gasLimit + 320; } final operation = await client.transferOperation( source: sourceKeyStore, destination: destinationAddress, amount: sendAmount, customFee: feeInMicroTez, customGasLimit: gasLimit); await operation.executeAndMonitor(); return operation.result.id as String; } catch (e) { Logging.instance.log(e.toString(), level: LogLevel.Error); return Future.error(e); } } @override Future get currentReceivingAddress async { var mneString = await mnemonicString; if (mneString == null) { throw Exception("No mnemonic found!"); } return Future.value((Keystore.fromMnemonic(mneString)).address); } @override Future estimateFeeFor(Amount amount, int feeRate) async { var api = "https://api.tzstats.com/series/op?start_date=today&collapse=1d"; var response = jsonDecode((await client.get( url: Uri.parse(api), proxyInfo: Prefs.instance.useTor ? TorService.sharedInstance.proxyInfo : null, )) .body)[0]; double totalFees = response[4] as double; int totalTxs = response[8] as int; int feePerTx = (totalFees / totalTxs * 1000000).floor(); return Amount( rawValue: BigInt.from(feePerTx), fractionDigits: coin.decimals, ); } @override Future exit() { _hasCalledExit = true; return Future.value(); } @override Future get fees async { var api = "https://api.tzstats.com/series/op?start_date=today&collapse=10d"; var response = jsonDecode((await client.get( url: Uri.parse(api), proxyInfo: Prefs.instance.useTor ? TorService.sharedInstance.proxyInfo : null, )) .body); double totalFees = response[0][4] as double; int totalTxs = response[0][8] as int; int feePerTx = (totalFees / totalTxs * 1000000).floor(); Logging.instance.log("feePerTx:$feePerTx", level: LogLevel.Info); // TODO: fix numberOfBlocks - Since there is only one fee no need to set blocks return FeeObject( numberOfBlocksFast: 10, numberOfBlocksAverage: 10, numberOfBlocksSlow: 10, fast: feePerTx, medium: feePerTx, slow: feePerTx, ); } @override Future generateNewAddress() { // TODO: implement generateNewAddress throw UnimplementedError(); } @override bool get hasCalledExit => _hasCalledExit; bool _hasCalledExit = false; @override Future initializeExisting() async { await _prefs.init(); } @override Future initializeNew( ({String mnemonicPassphrase, int wordCount})? data, ) async { if ((await mnemonicString) != null || (await mnemonicPassphrase) != null) { throw Exception( "Attempted to overwrite mnemonic on generate new wallet!"); } await _prefs.init(); var newKeystore = Keystore.random(); await _secureStore.write( key: '${_walletId}_mnemonic', value: newKeystore.mnemonic, ); await _secureStore.write( key: '${_walletId}_mnemonicPassphrase', value: "", ); final address = Address( walletId: walletId, value: newKeystore.address, publicKey: [], derivationIndex: 0, derivationPath: null, type: AddressType.unknown, subType: AddressSubType.receiving, ); await db.putAddress(address); await Future.wait([ updateCachedId(walletId), updateCachedIsFavorite(false), ]); } @override bool get isConnected => _isConnected; bool _isConnected = false; @override bool get isRefreshing => refreshMutex; bool refreshMutex = false; @override // TODO: implement maxFee Future get maxFee => throw UnimplementedError(); @override Future> get mnemonic async { final mnemonic = await mnemonicString; final mnemonicPassphrase = await this.mnemonicPassphrase; if (mnemonic == null) { throw Exception("No mnemonic found!"); } if (mnemonicPassphrase == null) { throw Exception("No mnemonic passphrase found!"); } return mnemonic.split(" "); } @override Future get mnemonicPassphrase => _secureStore.read(key: '${_walletId}_mnemonicPassphrase'); @override Future get mnemonicString => _secureStore.read(key: '${_walletId}_mnemonic'); Future _recoverWalletFromSeedPhrase({ required String mnemonic, required String mnemonicPassphrase, bool isRescan = false, }) async { final keystore = Keystore.fromMnemonic( mnemonic, password: mnemonicPassphrase, ); final address = Address( walletId: walletId, value: keystore.address, publicKey: [], derivationIndex: 0, derivationPath: null, type: AddressType.unknown, subType: AddressSubType.receiving, ); if (isRescan) { await db.updateOrPutAddresses([address]); } else { await db.putAddress(address); } } bool longMutex = false; @override Future fullRescan( int maxUnusedAddressGap, int maxNumberOfIndexesToCheck, ) async { try { Logging.instance.log("Starting full rescan!", level: LogLevel.Info); longMutex = true; GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.syncing, walletId, coin, ), ); final _mnemonic = await mnemonicString; final _mnemonicPassphrase = await mnemonicPassphrase; await db.deleteWalletBlockchainData(walletId); await _recoverWalletFromSeedPhrase( mnemonic: _mnemonic!, mnemonicPassphrase: _mnemonicPassphrase!, isRescan: true, ); await refresh(); Logging.instance.log("Full rescan complete!", level: LogLevel.Info); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.synced, walletId, coin, ), ); } catch (e, s) { GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.unableToSync, walletId, coin, ), ); Logging.instance.log( "Exception rethrown from fullRescan(): $e\n$s", level: LogLevel.Error, ); rethrow; } finally { longMutex = false; } } @override Future recoverFromMnemonic({ required String mnemonic, String? mnemonicPassphrase, required int maxUnusedAddressGap, required int maxNumberOfIndexesToCheck, required int height, }) async { longMutex = true; try { if ((await mnemonicString) != null || (await this.mnemonicPassphrase) != null) { throw Exception("Attempted to overwrite mnemonic on restore!"); } await _secureStore.write( key: '${_walletId}_mnemonic', value: mnemonic.trim()); await _secureStore.write( key: '${_walletId}_mnemonicPassphrase', value: mnemonicPassphrase ?? "", ); await _recoverWalletFromSeedPhrase( mnemonic: mnemonic, mnemonicPassphrase: mnemonicPassphrase ?? "", isRescan: false, ); await Future.wait([ updateCachedId(walletId), updateCachedIsFavorite(false), ]); await refresh(); } catch (e, s) { Logging.instance.log( "Exception rethrown from recoverFromMnemonic(): $e\n$s", level: LogLevel.Error); rethrow; } finally { longMutex = false; } } Future updateBalance() async { try { String balanceCall = "https://api.mainnet.tzkt.io/v1/accounts/" "${await currentReceivingAddress}/balance"; var response = jsonDecode(await client .get( url: Uri.parse(balanceCall), proxyInfo: Prefs.instance.useTor ? TorService.sharedInstance.proxyInfo : null, ) .then((value) => value.body)); Amount balanceInAmount = Amount( rawValue: BigInt.parse(response.toString()), fractionDigits: coin.decimals); _balance = Balance( total: balanceInAmount, spendable: balanceInAmount, blockedTotal: Amount(rawValue: BigInt.parse("0"), fractionDigits: coin.decimals), pendingSpendable: Amount(rawValue: BigInt.parse("0"), fractionDigits: coin.decimals), ); await updateCachedBalance(_balance!); } catch (e, s) { Logging.instance .log("ERROR GETTING BALANCE ${e.toString()}", level: LogLevel.Error); } } Future updateTransactions() async { String transactionsCall = "https://api.mainnet.tzkt.io/v1/accounts/" "${await currentReceivingAddress}/operations"; var response = jsonDecode(await client .get( url: Uri.parse(transactionsCall), proxyInfo: Prefs.instance.useTor ? TorService.sharedInstance.proxyInfo : null, ) .then((value) => value.body)); List> txs = []; for (var tx in response as List) { if (tx["type"] == "transaction") { TransactionType txType; final String myAddress = await currentReceivingAddress; final String senderAddress = tx["sender"]["address"] as String; final String targetAddress = tx["target"]["address"] as String; if (senderAddress == myAddress && targetAddress == myAddress) { txType = TransactionType.sentToSelf; } else if (senderAddress == myAddress) { txType = TransactionType.outgoing; } else if (targetAddress == myAddress) { txType = TransactionType.incoming; } else { txType = TransactionType.unknown; } var theTx = Transaction( walletId: walletId, txid: tx["hash"].toString(), timestamp: DateTime.parse(tx["timestamp"].toString()) .toUtc() .millisecondsSinceEpoch ~/ 1000, type: txType, subType: TransactionSubType.none, amount: tx["amount"] as int, amountString: Amount( rawValue: BigInt.parse((tx["amount"] as int).toInt().toString()), fractionDigits: coin.decimals) .toJsonString(), fee: tx["bakerFee"] as int, height: int.parse(tx["level"].toString()), isCancelled: false, isLelantus: false, slateId: "", otherData: "", inputs: [], outputs: [], nonce: 0, numberOfMessages: null, ); final AddressSubType subType; switch (txType) { case TransactionType.incoming: case TransactionType.sentToSelf: subType = AddressSubType.receiving; break; case TransactionType.outgoing: case TransactionType.unknown: subType = AddressSubType.unknown; break; } final theAddress = Address( walletId: walletId, value: targetAddress, publicKey: [], derivationIndex: 0, derivationPath: null, type: AddressType.unknown, subType: subType, ); txs.add(Tuple2(theTx, theAddress)); } } Logging.instance.log("Transactions: $txs", level: LogLevel.Info); await db.addNewTransactionData(txs, walletId); } Future updateChainHeight() async { try { var api = "${getCurrentNode().host}/chains/main/blocks/head/header/shell"; var jsonParsedResponse = jsonDecode(await client .get( url: Uri.parse(api), proxyInfo: Prefs.instance.useTor ? TorService.sharedInstance.proxyInfo : null, ) .then((value) => value.body)); final int intHeight = int.parse(jsonParsedResponse["level"].toString()); Logging.instance.log("Chain height: $intHeight", level: LogLevel.Info); await updateCachedChainHeight(intHeight); } catch (e, s) { Logging.instance .log("GET CHAIN HEIGHT ERROR ${e.toString()}", level: LogLevel.Error); } } @override Future refresh() async { if (refreshMutex) { Logging.instance.log( "$walletId $walletName refreshMutex denied", level: LogLevel.Info, ); return; } else { refreshMutex = true; } try { GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.syncing, walletId, coin, ), ); await updateChainHeight(); await updateBalance(); await updateTransactions(); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.synced, walletId, coin, ), ); if (shouldAutoSync) { timer ??= Timer.periodic(const Duration(seconds: 30), (timer) async { Logging.instance.log( "Periodic refresh check for $walletId $walletName in object instance: $hashCode", level: LogLevel.Info); await refresh(); GlobalEventBus.instance.fire( UpdatedInBackgroundEvent( "New data found in $walletId $walletName in background!", walletId, ), ); }); } } catch (e, s) { Logging.instance.log( "Failed to refresh stellar wallet $walletId: '$walletName': $e\n$s", level: LogLevel.Warning, ); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.unableToSync, walletId, coin, ), ); } refreshMutex = false; } @override int get storedChainHeight => getCachedChainHeight(); @override Future testNetworkConnection() async { try { await client.get( url: Uri.parse( "${getCurrentNode().host}:${getCurrentNode().port}/chains/main/blocks/head/header/shell"), proxyInfo: Prefs.instance.useTor ? TorService.sharedInstance.proxyInfo : null, ); return true; } catch (e) { return false; } } @override Future> get transactions => db.getTransactions(walletId).findAll(); @override Future updateNode(bool shouldRefresh) async { _xtzNode = NodeService(secureStorageInterface: _secureStore) .getPrimaryNodeFor(coin: coin) ?? DefaultNodes.getNodeFor(coin); if (shouldRefresh) { await refresh(); } } @override Future updateSentCachedTxData(Map txData) async { final transaction = Transaction( walletId: walletId, txid: txData["txid"] as String, timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, type: TransactionType.outgoing, subType: TransactionSubType.none, // precision may be lost here hence the following amountString amount: (txData["recipientAmt"] as Amount).raw.toInt(), amountString: (txData["recipientAmt"] as Amount).toJsonString(), fee: txData["fee"] as int, height: null, isCancelled: false, isLelantus: false, otherData: null, slateId: null, nonce: null, inputs: [], outputs: [], numberOfMessages: null, ); final address = txData["address"] is String ? await db.getAddress(walletId, txData["address"] as String) : null; await db.addNewTransactionData( [ Tuple2(transaction, address), ], walletId, ); } @override // TODO: implement utxos Future> get utxos => throw UnimplementedError(); @override bool validateAddress(String address) { return RegExp(r"^tz[1-9A-HJ-NP-Za-km-z]{34}$").hasMatch(address); } }