From 279ed8196b3bbdeb4e7f1e686ee7a5129eb18df7 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 14 Nov 2023 09:57:17 -0600 Subject: [PATCH] WIP ecash new --- lib/wallets/wallet/impl/ecash_wallet.dart | 446 ++++++++++++++++++++++ lib/wallets/wallet/wallet.dart | 7 + 2 files changed, 453 insertions(+) create mode 100644 lib/wallets/wallet/impl/ecash_wallet.dart diff --git a/lib/wallets/wallet/impl/ecash_wallet.dart b/lib/wallets/wallet/impl/ecash_wallet.dart new file mode 100644 index 000000000..31d236d19 --- /dev/null +++ b/lib/wallets/wallet/impl/ecash_wallet.dart @@ -0,0 +1,446 @@ +import 'dart:math'; + +import 'package:bitbox/bitbox.dart' as bitbox; +import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; +import 'package:isar/isar.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/v2/input_v2.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/output_v2.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import 'package:stackwallet/services/coins/bitcoincash/bch_utils.dart'; +import 'package:stackwallet/services/coins/bitcoincash/cashtokens.dart' + as cash_tokens; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/enums/derive_path_type_enum.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/ecash.dart'; +import 'package:stackwallet/wallets/wallet/intermediate/bip39_hd_wallet.dart'; +import 'package:stackwallet/wallets/wallet/mixins/electrumx_mixin.dart'; + +class EcashWallet extends Bip39HDWallet with ElectrumXMixin { + @override + int get isarTransactionVersion => 2; + + EcashWallet(Ecash cryptoCurrency) : super(cryptoCurrency); + + @override + FilterOperation? get changeAddressFilterOperation => FilterGroup.and( + [ + ...standardChangeAddressFilters, + const ObjectFilter( + property: "derivationPath", + filter: FilterCondition.startsWith( + property: "value", + value: "m/44'/899", + ), + ), + ], + ); + + @override + FilterOperation? get receivingAddressFilterOperation => FilterGroup.and( + [ + ...standardReceivingAddressFilters, + const ObjectFilter( + property: "derivationPath", + filter: FilterCondition.startsWith( + property: "value", + value: "m/44'/899", + ), + ), + ], + ); + + // =========================================================================== + + @override + Future> fetchAllOwnAddresses() async { + final allAddresses = await mainDB + .getAddresses(walletId) + .filter() + .not() + .typeEqualTo(AddressType.nonWallet) + .and() + .not() + .subTypeEqualTo(AddressSubType.nonWallet) + .findAll(); + return allAddresses; + } + + // =========================================================================== + + @override + Future updateTransactions() async { + List
allAddressesOld = await fetchAllOwnAddresses(); + + Set receivingAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.receiving) + .map((e) { + if (bitbox.Address.detectFormat(e.value) == bitbox.Address.formatLegacy && + (cryptoCurrency.addressType(address: e.value) == + DerivePathType.bip44 || + cryptoCurrency.addressType(address: e.value) == + DerivePathType.eCash44)) { + return bitbox.Address.toCashAddress(e.value); + } else { + return e.value; + } + }).toSet(); + + Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) { + if (bitbox.Address.detectFormat(e.value) == bitbox.Address.formatLegacy && + (cryptoCurrency.addressType(address: e.value) == + DerivePathType.bip44 || + cryptoCurrency.addressType(address: e.value) == + DerivePathType.eCash44)) { + return bitbox.Address.toCashAddress(e.value); + } else { + return e.value; + } + }).toSet(); + + final allAddressesSet = {...receivingAddresses, ...changeAddresses}; + + final List> allTxHashes = + await fetchHistory(allAddressesSet); + + List> allTransactions = []; + + for (final txHash in allTxHashes) { + final storedTx = await mainDB.isar.transactionV2s + .where() + .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) + .findFirst(); + + if (storedTx == null || + storedTx.height == null || + (storedTx.height != null && storedTx.height! <= 0)) { + final tx = await electrumXCached.getTransaction( + txHash: txHash["tx_hash"] as String, + verbose: true, + coin: cryptoCurrency.coin, + ); + + // check for duplicates before adding to list + if (allTransactions + .indexWhere((e) => e["txid"] == tx["txid"] as String) == + -1) { + tx["height"] = txHash["height"]; + allTransactions.add(tx); + } + } + } + + final List txns = []; + + for (final txData in allTransactions) { + // set to true if any inputs were detected as owned by this wallet + bool wasSentFromThisWallet = false; + + // set to true if any outputs were detected as owned by this wallet + bool wasReceivedInThisWallet = false; + BigInt amountReceivedInThisWallet = BigInt.zero; + BigInt changeAmountReceivedInThisWallet = BigInt.zero; + + // parse inputs + final List inputs = []; + for (final jsonInput in txData["vin"] as List) { + final map = Map.from(jsonInput as Map); + + final List addresses = []; + String valueStringSats = "0"; + OutpointV2? outpoint; + + final coinbase = map["coinbase"] as String?; + + if (coinbase == null) { + final txid = map["txid"] as String; + final vout = map["vout"] as int; + + final inputTx = await electrumXCached.getTransaction( + txHash: txid, + coin: cryptoCurrency.coin, + ); + + final prevOutJson = Map.from( + (inputTx["vout"] as List).firstWhere((e) => e["n"] == vout) + as Map); + + final prevOut = OutputV2.fromElectrumXJson( + prevOutJson, + decimalPlaces: cryptoCurrency.fractionDigits, + walletOwns: false, // doesn't matter here as this is not saved + ); + + outpoint = OutpointV2.isarCantDoRequiredInDefaultConstructor( + txid: txid, + vout: vout, + ); + valueStringSats = prevOut.valueStringSats; + addresses.addAll(prevOut.addresses); + } + + InputV2 input = InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: map["scriptSig"]?["hex"] as String?, + sequence: map["sequence"] as int?, + outpoint: outpoint, + valueStringSats: valueStringSats, + addresses: addresses, + witness: map["witness"] as String?, + coinbase: coinbase, + innerRedeemScriptAsm: map["innerRedeemscriptAsm"] as String?, + // don't know yet if wallet owns. Need addresses first + walletOwns: false, + ); + + if (allAddressesSet.intersection(input.addresses.toSet()).isNotEmpty) { + wasSentFromThisWallet = true; + input = input.copyWith(walletOwns: true); + } + + inputs.add(input); + } + + // parse outputs + final List outputs = []; + for (final outputJson in txData["vout"] as List) { + OutputV2 output = OutputV2.fromElectrumXJson( + Map.from(outputJson as Map), + decimalPlaces: cryptoCurrency.fractionDigits, + // don't know yet if wallet owns. Need addresses first + walletOwns: false, + ); + + // if output was to my wallet, add value to amount received + if (receivingAddresses + .intersection(output.addresses.toSet()) + .isNotEmpty) { + wasReceivedInThisWallet = true; + amountReceivedInThisWallet += output.value; + output = output.copyWith(walletOwns: true); + } else if (changeAddresses + .intersection(output.addresses.toSet()) + .isNotEmpty) { + wasReceivedInThisWallet = true; + changeAmountReceivedInThisWallet += output.value; + output = output.copyWith(walletOwns: true); + } + + outputs.add(output); + } + + final totalOut = outputs + .map((e) => e.value) + .fold(BigInt.zero, (value, element) => value + element); + + TransactionType type; + TransactionSubType subType = TransactionSubType.none; + + // at least one input was owned by this wallet + if (wasSentFromThisWallet) { + type = TransactionType.outgoing; + + if (wasReceivedInThisWallet) { + if (changeAmountReceivedInThisWallet + amountReceivedInThisWallet == + totalOut) { + // definitely sent all to self + type = TransactionType.sentToSelf; + } else if (amountReceivedInThisWallet == BigInt.zero) { + // most likely just a typical send + // do nothing here yet + } + + // check vout 0 for special scripts + if (outputs.isNotEmpty) { + final output = outputs.first; + + // check for fusion + if (BchUtils.isFUZE(output.scriptPubKeyHex.toUint8ListFromHex)) { + subType = TransactionSubType.cashFusion; + } else { + // check other cases here such as SLP or cash tokens etc + } + } + } + } else if (wasReceivedInThisWallet) { + // only found outputs owned by this wallet + type = TransactionType.incoming; + } else { + Logging.instance.log( + "Unexpected tx found (ignoring it): $txData", + level: LogLevel.Error, + ); + continue; + } + + final tx = TransactionV2( + walletId: walletId, + blockHash: txData["blockhash"] as String?, + hash: txData["hash"] as String, + txid: txData["txid"] as String, + height: txData["height"] as int?, + version: txData["version"] as int, + timestamp: txData["blocktime"] as int? ?? + DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + type: type, + subType: subType, + ); + + txns.add(tx); + } + + await mainDB.updateOrPutTransactionV2s(txns); + } + + @override + ({String? blockedReason, bool blocked}) checkBlockUTXO( + Map jsonUTXO, + String? scriptPubKeyHex, + Map jsonTX, + ) { + bool blocked = false; + String? blockedReason; + + if (scriptPubKeyHex != null) { + // check for cash tokens + try { + final ctOutput = + cash_tokens.unwrap_spk(scriptPubKeyHex.toUint8ListFromHex); + if (ctOutput.token_data != null) { + // found a token! + blocked = true; + blockedReason = "Cash token output detected"; + } + } catch (e, s) { + // Probably doesn't contain a cash token so just log failure + Logging.instance.log( + "Script pub key \"$scriptPubKeyHex\" cash token" + " parsing check failed: $e\n$s", + level: LogLevel.Warning, + ); + } + + // check for SLP tokens if not already blocked + if (!blocked && BchUtils.isSLP(scriptPubKeyHex.toUint8ListFromHex)) { + blocked = true; + blockedReason = "SLP token output detected"; + } + } + + return (blockedReason: blockedReason, blocked: blocked); + } + + // TODO: correct formula for bch? + @override + Amount roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB) { + return Amount( + rawValue: BigInt.from(((181 * inputCount) + (34 * outputCount) + 10) * + (feeRatePerKB / 1000).ceil()), + fractionDigits: info.coin.decimals, + ); + } + + @override + int estimateTxFee({required int vSize, required int feeRatePerKB}) { + return vSize * (feeRatePerKB / 1000).ceil(); + } + + // not all coins need to override this. BCH does due to cash addr string formatting + @override + Future<({List
addresses, int index})> checkGaps( + int txCountBatchSize, + coinlib.HDPrivateKey root, + DerivePathType type, + int chain, + ) async { + List
addressArray = []; + int gapCounter = 0; + int highestIndexWithHistory = 0; + + // Scan addresses until the minimum required addresses have been scanned or + // until the highest index with activity, plus the gap limit, whichever is + // higher, so that we if there is activity above the minimum index, we don't + // miss it. + for (int index = 0; + index < + max( + cryptoCurrency.maxNumberOfIndexesToCheck, + highestIndexWithHistory + + cryptoCurrency.maxUnusedAddressGap) && + gapCounter < cryptoCurrency.maxUnusedAddressGap; + index += txCountBatchSize) { + Logging.instance.log( + "index: $index, \t GapCounter $chain ${type.name}: $gapCounter", + level: LogLevel.Info); + + final _id = "k_$index"; + Map txCountCallArgs = {}; + + for (int j = 0; j < txCountBatchSize; j++) { + final derivePath = cryptoCurrency.constructDerivePath( + derivePathType: type, + chain: chain, + index: index + j, + ); + + final keys = root.derivePath(derivePath); + final addressData = cryptoCurrency.getAddressForPublicKey( + publicKey: keys.publicKey, + derivePathType: type, + ); + + // bch specific + final addressString = bitbox.Address.toCashAddress( + addressData.address.toString(), + ); + + final address = Address( + walletId: walletId, + value: addressString, + publicKey: keys.publicKey.data, + type: addressData.addressType, + derivationIndex: index + j, + derivationPath: DerivationPath()..value = derivePath, + subType: + chain == 0 ? AddressSubType.receiving : AddressSubType.change, + ); + + addressArray.add(address); + + txCountCallArgs.addAll({ + "${_id}_$j": addressString, + }); + } + + // get address tx counts + final counts = await fetchTxCountBatched(addresses: txCountCallArgs); + + // check and add appropriate addresses + for (int k = 0; k < txCountBatchSize; k++) { + int count = counts["${_id}_$k"]!; + if (count > 0) { + // update highest + highestIndexWithHistory = index + k; + + // reset counter + gapCounter = 0; + } + + // increase counter when no tx history found + if (count == 0) { + gapCounter++; + } + } + // // cache all the transactions while waiting for the current function to finish. + // unawaited(getTransactionCacheEarly(addressArray)); + } + return (index: highestIndexWithHistory, addresses: addressArray); + } +} diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index c3bc852d2..65fec0f73 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -21,6 +21,7 @@ import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/bitcoin.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/bitcoincash.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/ecash.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/epiccash.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/wownero.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; @@ -28,6 +29,7 @@ import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; import 'package:stackwallet/wallets/models/tx_data.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/ecash_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/epiccash_wallet.dart'; import 'package:stackwallet/wallets/wallet/impl/wownero_wallet.dart'; import 'package:stackwallet/wallets/wallet/intermediate/bip39_hd_wallet.dart'; @@ -268,6 +270,11 @@ abstract class Wallet { Bitcoincash(CryptoCurrencyNetwork.test), ); + case Coin.eCash: + return EcashWallet( + Ecash(CryptoCurrencyNetwork.main), + ); + case Coin.epicCash: return EpiccashWallet( Epiccash(CryptoCurrencyNetwork.main),