import 'dart:io'; import 'package:isar/isar.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/node_model.dart'; import 'package:stackwallet/models/paymint/fee_object_model.dart'; import 'package:stackwallet/services/node_service.dart'; import 'package:stackwallet/services/tor_service.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/extensions/impl/string.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/wallets/api/tezos/tezos_account.dart'; import 'package:stackwallet/wallets/api/tezos/tezos_api.dart'; import 'package:stackwallet/wallets/api/tezos/tezos_rpc_api.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/tezos.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/intermediate/bip39_wallet.dart'; import 'package:tezart/tezart.dart' as tezart; import 'package:tuple/tuple.dart'; // const kDefaultTransactionStorageLimit = 496; // const kDefaultTransactionGasLimit = 10600; // // const kDefaultKeyRevealFee = 1270; // const kDefaultKeyRevealStorageLimit = 0; // const kDefaultKeyRevealGasLimit = 1100; class TezosWallet extends Bip39Wallet { TezosWallet(CryptoCurrencyNetwork network) : super(Tezos(network)); NodeModel? _xtzNode; String get derivationPath => info.otherData[WalletInfoKeys.tezosDerivationPath] as String? ?? ""; Future _scanPossiblePaths({ required String mnemonic, String passphrase = "", }) async { try { for (final path in Tezos.possibleDerivationPaths) { final ks = await _getKeyStore(path: path.value); // TODO: some kind of better check to see if the address has been used final hasHistory = (await TezosAPI.getTransactions(ks.address)).isNotEmpty; if (hasHistory) { return path; } } return Tezos.standardDerivationPath; } catch (e, s) { Logging.instance.log( "Error in _scanPossiblePaths() in tezos_wallet.dart: $e\n$s", level: LogLevel.Error, ); rethrow; } } Future _getKeyStore({String? path}) async { final mnemonic = await getMnemonic(); final passphrase = await getMnemonicPassphrase(); return Tezos.mnemonicToKeyStore( mnemonic: mnemonic, mnemonicPassphrase: passphrase, derivationPath: path ?? derivationPath, ); } Future
_getAddressFromMnemonic() async { final keyStore = await _getKeyStore(); return Address( walletId: walletId, value: keyStore.address, publicKey: keyStore.publicKey.toUint8ListFromBase58CheckEncoded, derivationIndex: 0, derivationPath: DerivationPath()..value = derivationPath, type: info.coin.primaryAddressType, subType: AddressSubType.receiving, ); } Future _buildSendTransaction({ required Amount amount, required String address, required int counter, // required bool reveal, // int? customGasLimit, // Amount? customFee, // Amount? customRevealFee, }) async { try { final sourceKeyStore = await _getKeyStore(); final server = (_xtzNode ?? getCurrentNode()).host; // if (kDebugMode) { // print("SERVER: $server"); // print("COUNTER: $counter"); // print("customFee: $customFee"); // } final ({InternetAddress host, int port})? proxyInfo = prefs.useTor ? TorService.sharedInstance.getProxyInfo() : null; final tezartClient = tezart.TezartClient( server, proxy: proxyInfo != null ? "socks5://${proxyInfo.host}:${proxyInfo.port};" : null, ); final opList = await tezartClient.transferOperation( source: sourceKeyStore, destination: address, amount: amount.raw.toInt(), // customFee: customFee?.raw.toInt(), // customGasLimit: customGasLimit, // reveal: false, ); // if (reveal) { // opList.prependOperation( // tezart.RevealOperation( // customGasLimit: customGasLimit, // customFee: customRevealFee?.raw.toInt(), // ), // ); // } for (final op in opList.operations) { op.counter = counter; counter++; } return opList; } catch (e, s) { Logging.instance.log( "Error in _buildSendTransaction() in tezos_wallet.dart: $e\n$s", level: LogLevel.Error, ); rethrow; } } // =========================================================================== @override Future checkSaveInitialReceivingAddress() async { try { final _address = await getCurrentReceivingAddress(); if (_address == null) { final address = await _getAddressFromMnemonic(); await mainDB.updateOrPutAddresses([address]); } } catch (e, s) { // do nothing, still allow user into wallet Logging.instance.log( "$runtimeType checkSaveInitialReceivingAddress() failed: $e\n$s", level: LogLevel.Error, ); } } @override FilterOperation? get changeAddressFilterOperation => throw UnimplementedError("Not used for $runtimeType"); @override FilterOperation? get receivingAddressFilterOperation => FilterGroup.and(standardReceivingAddressFilters); @override Future prepareSend({required TxData txData}) async { try { if (txData.recipients == null || txData.recipients!.length != 1) { throw Exception("$runtimeType prepareSend requires 1 recipient"); } final Amount sendAmount = txData.amount!; if (sendAmount > info.cachedBalance.spendable) { throw Exception("Insufficient available balance"); } final myAddress = (await getCurrentReceivingAddress())!; final account = await TezosAPI.getAccount( myAddress.value, ); // final bool isSendAll = sendAmount == info.cachedBalance.spendable; // // int? customGasLimit; // Amount? fee; // Amount? revealFee; // // if (isSendAll) { // final fees = await _estimate( // account, // txData.recipients!.first.address, // ); // //Fee guides for emptying a tz account // // https://github.com/TezTech/eztz/blob/master/PROTO_004_FEES.md // // customGasLimit = kDefaultTransactionGasLimit + 320; // fee = Amount( // rawValue: BigInt.from(fees.transfer + 32), // fractionDigits: cryptoCurrency.fractionDigits, // ); // // BigInt rawAmount = sendAmount.raw - fee.raw; // // if (!account.revealed) { // revealFee = Amount( // rawValue: BigInt.from(fees.reveal + 32), // fractionDigits: cryptoCurrency.fractionDigits, // ); // // rawAmount = rawAmount - revealFee.raw; // } // // sendAmount = Amount( // rawValue: rawAmount, // fractionDigits: cryptoCurrency.fractionDigits, // ); // } final opList = await _buildSendTransaction( amount: sendAmount, address: txData.recipients!.first.address, counter: account.counter + 1, // reveal: !account.revealed, // customFee: isSendAll ? fee : null, // customRevealFee: isSendAll ? revealFee : null, // customGasLimit: customGasLimit, ); await opList.computeLimits(); await opList.computeFees(); await opList.simulate(); return txData.copyWith( recipients: [ ( amount: sendAmount, address: txData.recipients!.first.address, isChange: txData.recipients!.first.isChange, ), ], // fee: fee, fee: Amount( rawValue: opList.operations .map( (e) => BigInt.from(e.fee), ) .fold( BigInt.zero, (p, e) => p + e, ), fractionDigits: cryptoCurrency.fractionDigits, ), tezosOperationsList: opList, ); } catch (e, s) { Logging.instance.log( "Error in prepareSend() in tezos_wallet.dart: $e\n$s", level: LogLevel.Error, ); if (e .toString() .contains("(_operationResult['errors']): Must not be null")) { throw Exception("Probably insufficient balance"); } else if (e.toString().contains( "The simulation of the operation: \"transaction\" failed with error(s) :" " contract.balance_too_low, tez.subtraction_underflow.", )) { throw Exception("Insufficient balance to pay fees"); } rethrow; } } @override Future confirmSend({required TxData txData}) async { await txData.tezosOperationsList!.inject(); await txData.tezosOperationsList!.monitor(); return txData.copyWith( txid: txData.tezosOperationsList!.result.id, ); } int _estCount = 0; Future<({int reveal, int transfer})> _estimate( TezosAccount account, String recipientAddress, ) async { try { final opList = await _buildSendTransaction( amount: Amount( rawValue: BigInt.one, fractionDigits: cryptoCurrency.fractionDigits, ), address: recipientAddress, counter: account.counter + 1, // reveal: !account.revealed, ); await opList.computeLimits(); await opList.computeFees(); await opList.simulate(); int reveal = 0; int transfer = 0; for (final op in opList.operations) { if (op is tezart.TransactionOperation) { transfer += op.fee; } else if (op is tezart.RevealOperation) { reveal += op.fee; } } return (reveal: reveal, transfer: transfer); } catch (e, s) { if (_estCount > 3) { _estCount = 0; Logging.instance.log( " Error in _estimate in tezos_wallet.dart: $e\n$s", level: LogLevel.Error, ); rethrow; } else { _estCount++; Logging.instance.log( "_estimate() retry _estCount=$_estCount", level: LogLevel.Warning, ); return await _estimate( account, recipientAddress, ); } } } @override Future estimateFeeFor( Amount amount, int feeRate, { String recipientAddress = "tz1MXvDCyXSqBqXPNDcsdmVZKfoxL9FTHmp2", }) async { if (info.cachedBalance.spendable.raw == BigInt.zero) { return Amount( rawValue: BigInt.zero, fractionDigits: cryptoCurrency.fractionDigits, ); } final myAddress = (await getCurrentReceivingAddress())!; final account = await TezosAPI.getAccount( myAddress.value, ); try { final fees = await _estimate(account, recipientAddress); final fee = Amount( rawValue: BigInt.from(fees.reveal + fees.transfer), fractionDigits: cryptoCurrency.fractionDigits, ); return fee; } catch (e, s) { Logging.instance.log( " Error in estimateFeeFor() in tezos_wallet.dart: $e\n$s", level: LogLevel.Error, ); rethrow; } } /// Not really used (yet) @override Future get fees async { const feePerTx = 1; return FeeObject( numberOfBlocksFast: 10, numberOfBlocksAverage: 10, numberOfBlocksSlow: 10, fast: feePerTx, medium: feePerTx, slow: feePerTx, ); } @override Future pingCheck() async { final currentNode = getCurrentNode(); return await TezosRpcAPI.testNetworkConnection( nodeInfo: ( host: currentNode.host, port: currentNode.port, ), ); } @override Future recover({required bool isRescan}) async { await refreshMutex.protect(() async { if (isRescan) { await mainDB.deleteWalletBlockchainData(walletId); } else { final derivationPath = await _scanPossiblePaths( mnemonic: await getMnemonic(), passphrase: await getMnemonicPassphrase(), ); await info.updateOtherData( newEntries: { WalletInfoKeys.tezosDerivationPath: derivationPath.value, }, isar: mainDB.isar, ); } final address = await _getAddressFromMnemonic(); await mainDB.updateOrPutAddresses([address]); // ensure we only have a single address mainDB.isar.writeTxnSync(() { mainDB.isar.addresses .where() .walletIdEqualTo(walletId) .filter() .not() .derivationPath((q) => q.valueEqualTo(derivationPath)) .deleteAllSync(); }); if (info.cachedReceivingAddress != address.value) { await info.updateReceivingAddress( newAddress: address.value, isar: mainDB.isar, ); } await Future.wait([ updateBalance(), updateTransactions(), updateChainHeight(), ]); }); } @override Future updateBalance() async { try { final currentNode = _xtzNode ?? getCurrentNode(); final balance = await TezosRpcAPI.getBalance( nodeInfo: (host: currentNode.host, port: currentNode.port), address: (await getCurrentReceivingAddress())!.value, ); final balanceInAmount = Amount( rawValue: balance!, fractionDigits: cryptoCurrency.fractionDigits, ); final newBalance = Balance( total: balanceInAmount, spendable: balanceInAmount, blockedTotal: Amount( rawValue: BigInt.zero, fractionDigits: cryptoCurrency.fractionDigits, ), pendingSpendable: Amount( rawValue: BigInt.zero, fractionDigits: cryptoCurrency.fractionDigits, ), ); await info.updateBalance(newBalance: newBalance, isar: mainDB.isar); } catch (e, s) { Logging.instance.log( "Error getting balance in tezos_wallet.dart: $e\n$s", level: LogLevel.Error, ); } } @override Future updateChainHeight() async { try { final currentNode = _xtzNode ?? getCurrentNode(); final height = await TezosRpcAPI.getChainHeight( nodeInfo: ( host: currentNode.host, port: currentNode.port, ), ); await info.updateCachedChainHeight( newHeight: height!, isar: mainDB.isar, ); } catch (e, s) { Logging.instance.log( "Error occurred in tezos_wallet.dart while getting" " chain height for tezos: $e\n$s", level: LogLevel.Error, ); } } @override Future updateNode() async { _xtzNode = NodeService(secureStorageInterface: secureStorageInterface) .getPrimaryNodeFor(currency: info.coin) ?? info.coin.defaultNode; await refresh(); } @override NodeModel getCurrentNode() { return _xtzNode ?? NodeService(secureStorageInterface: secureStorageInterface) .getPrimaryNodeFor(currency: info.coin) ?? info.coin.defaultNode; } @override Future updateTransactions() async { // TODO: optimize updateTransactions and use V2 final myAddress = (await getCurrentReceivingAddress())!; final txs = await TezosAPI.getTransactions(myAddress.value); if (txs.isEmpty) { return; } final List> transactions = []; for (final theTx in txs) { final TransactionType txType; if (myAddress.value == theTx.senderAddress) { txType = TransactionType.outgoing; } else if (myAddress.value == theTx.receiverAddress) { if (myAddress.value == theTx.senderAddress) { txType = TransactionType.sentToSelf; } else { txType = TransactionType.incoming; } } else { txType = TransactionType.unknown; } final transaction = Transaction( walletId: walletId, txid: theTx.hash, timestamp: theTx.timestamp, type: txType, subType: TransactionSubType.none, amount: theTx.amountInMicroTez, amountString: Amount( rawValue: BigInt.from(theTx.amountInMicroTez), fractionDigits: cryptoCurrency.fractionDigits, ).toJsonString(), fee: theTx.feeInMicroTez, height: theTx.height, isCancelled: false, isLelantus: false, slateId: "", otherData: "", inputs: [], outputs: [], nonce: 0, numberOfMessages: null, ); final Address theAddress; switch (txType) { case TransactionType.incoming: case TransactionType.sentToSelf: theAddress = myAddress; break; case TransactionType.outgoing: case TransactionType.unknown: theAddress = Address( walletId: walletId, value: theTx.receiverAddress, publicKey: [], derivationIndex: 0, derivationPath: null, type: AddressType.tezos, subType: AddressSubType.unknown, ); break; } transactions.add(Tuple2(transaction, theAddress)); } await mainDB.addNewTransactionData(transactions, walletId); } @override Future updateUTXOs() async { // do nothing. Not used in tezos return false; } }