import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:convert/convert.dart' as convert; import 'dart:math'; import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_core/cake_hive.dart'; import 'package:cw_core/mweb_utxo.dart'; import 'package:cw_core/node.dart'; import 'package:cw_mweb/mwebd.pbgrpc.dart'; import 'package:fixnum/fixnum.dart'; import 'package:bip39/bip39.dart' as bip39; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:blockchain_utils/signer/ecdsa_signing_key.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; import 'package:cw_bitcoin/utils.dart'; import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_bitcoin/electrum_balance.dart'; import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; import 'package:cw_bitcoin/litecoin_wallet_addresses.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; import 'package:flutter/foundation.dart'; import 'package:grpc/grpc.dart'; import 'package:hive/hive.dart'; import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; import 'package:ledger_litecoin/ledger_litecoin.dart'; import 'package:mobx/mobx.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cw_mweb/cw_mweb.dart'; import 'package:bitcoin_base/src/crypto/keypair/sign_utils.dart'; import 'package:pointycastle/ecc/api.dart'; import 'package:pointycastle/ecc/curves/secp256k1.dart'; import 'package:shared_preferences/shared_preferences.dart'; part 'litecoin_wallet.g.dart'; class LitecoinWallet = LitecoinWalletBase with _$LitecoinWallet; abstract class LitecoinWalletBase extends ElectrumWallet with Store { LitecoinWalletBase({ required String password, required WalletInfo walletInfo, required Box<UnspentCoinsInfo> unspentCoinsInfo, required EncryptionFileUtils encryptionFileUtils, Uint8List? seedBytes, String? mnemonic, String? xpub, String? passphrase, String? addressPageType, List<BitcoinAddressRecord>? initialAddresses, List<BitcoinAddressRecord>? initialMwebAddresses, ElectrumBalance? initialBalance, Map<String, int>? initialRegularAddressIndex, Map<String, int>? initialChangeAddressIndex, int? initialMwebHeight, bool? alwaysScan, }) : super( mnemonic: mnemonic, password: password, xpub: xpub, walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfo, network: LitecoinNetwork.mainnet, initialAddresses: initialAddresses, initialBalance: initialBalance, seedBytes: seedBytes, encryptionFileUtils: encryptionFileUtils, currency: CryptoCurrency.ltc, alwaysScan: alwaysScan, ) { if (seedBytes != null) { mwebHd = Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/1000'") as Bip32Slip10Secp256k1; mwebEnabled = alwaysScan ?? false; } else { mwebHd = null; mwebEnabled = false; } walletAddresses = LitecoinWalletAddresses( walletInfo, initialAddresses: initialAddresses, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, initialMwebAddresses: initialMwebAddresses, mainHd: hd, sideHd: accountHD.childKey(Bip32KeyIndex(1)), network: network, mwebHd: mwebHd, mwebEnabled: mwebEnabled, isHardwareWallet: walletInfo.isHardwareWallet, ); autorun((_) { this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; }); reaction((_) => mwebSyncStatus, (status) async { if (mwebSyncStatus is FailedSyncStatus) { // we failed to connect to mweb, check if we are connected to the litecoin node: late int nodeHeight; try { nodeHeight = await electrumClient.getCurrentBlockChainTip() ?? 0; } catch (_) { nodeHeight = 0; } if (nodeHeight == 0) { // we aren't connected to the litecoin node, so the current electrum_wallet reactions will take care of this case for us } else { // we're connected to the litecoin node, but we failed to connect to mweb, try again after a few seconds: await CwMweb.stop(); await Future.delayed(const Duration(seconds: 5)); startSync(); } } else if (mwebSyncStatus is SyncingSyncStatus) { syncStatus = mwebSyncStatus; } else if (mwebSyncStatus is SyncronizingSyncStatus) { if (syncStatus is! SyncronizingSyncStatus) { syncStatus = mwebSyncStatus; } } else if (mwebSyncStatus is SyncedSyncStatus) { if (syncStatus is! SyncedSyncStatus) { syncStatus = mwebSyncStatus; } } }); } late final Bip32Slip10Secp256k1? mwebHd; late final Box<MwebUtxo> mwebUtxosBox; Timer? _syncTimer; Timer? _feeRatesTimer; Timer? _processingTimer; StreamSubscription<Utxo>? _utxoStream; late bool mwebEnabled; bool processingUtxos = false; @observable SyncStatus mwebSyncStatus = NotConnectedSyncStatus(); List<int> get scanSecret => mwebHd!.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw; List<int> get spendSecret => mwebHd!.childKey(Bip32KeyIndex(0x80000001)).privateKey.privKey.raw; static Future<LitecoinWallet> create( {required String mnemonic, required String password, required WalletInfo walletInfo, required Box<UnspentCoinsInfo> unspentCoinsInfo, required EncryptionFileUtils encryptionFileUtils, String? passphrase, String? addressPageType, List<BitcoinAddressRecord>? initialAddresses, List<BitcoinAddressRecord>? initialMwebAddresses, ElectrumBalance? initialBalance, Map<String, int>? initialRegularAddressIndex, Map<String, int>? initialChangeAddressIndex}) async { late Uint8List seedBytes; switch (walletInfo.derivationInfo?.derivationType) { case DerivationType.bip39: seedBytes = await bip39.mnemonicToSeed( mnemonic, passphrase: passphrase ?? "", ); break; case DerivationType.electrum: default: seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); break; } return LitecoinWallet( mnemonic: mnemonic, password: password, walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfo, initialAddresses: initialAddresses, initialMwebAddresses: initialMwebAddresses, initialBalance: initialBalance, encryptionFileUtils: encryptionFileUtils, passphrase: passphrase, seedBytes: seedBytes, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, addressPageType: addressPageType, ); } static Future<LitecoinWallet> open({ required String name, required WalletInfo walletInfo, required Box<UnspentCoinsInfo> unspentCoinsInfo, required String password, required bool alwaysScan, required EncryptionFileUtils encryptionFileUtils, }) async { final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type); ElectrumWalletSnapshot? snp = null; try { snp = await ElectrumWalletSnapshot.load( encryptionFileUtils, name, walletInfo.type, password, LitecoinNetwork.mainnet, ); } catch (e) { if (!hasKeysFile) rethrow; } final WalletKeysData keysData; // Migrate wallet from the old scheme to then new .keys file scheme if (!hasKeysFile) { keysData = WalletKeysData(mnemonic: snp!.mnemonic, xPub: snp.xpub, passphrase: snp.passphrase); } else { keysData = await WalletKeysFile.readKeysFile( name, walletInfo.type, password, encryptionFileUtils, ); } walletInfo.derivationInfo ??= DerivationInfo(); // set the default if not present: walletInfo.derivationInfo!.derivationPath ??= snp?.derivationPath ?? electrum_path; walletInfo.derivationInfo!.derivationType ??= snp?.derivationType ?? DerivationType.electrum; Uint8List? seedBytes = null; final mnemonic = keysData.mnemonic; final passphrase = keysData.passphrase; if (mnemonic != null) { switch (walletInfo.derivationInfo?.derivationType) { case DerivationType.bip39: seedBytes = await bip39.mnemonicToSeed( mnemonic, passphrase: passphrase ?? "", ); break; case DerivationType.electrum: default: seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); break; } } return LitecoinWallet( mnemonic: keysData.mnemonic, xpub: keysData.xPub, password: password, walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfo, initialAddresses: snp?.addresses, initialMwebAddresses: snp?.mwebAddresses, initialBalance: snp?.balance, seedBytes: seedBytes, passphrase: passphrase, encryptionFileUtils: encryptionFileUtils, initialRegularAddressIndex: snp?.regularAddressIndex, initialChangeAddressIndex: snp?.changeAddressIndex, addressPageType: snp?.addressPageType, alwaysScan: snp?.alwaysScan, ); } Future<void> waitForMwebAddresses() async { print("waitForMwebAddresses() called!"); // ensure that we have the full 1000 mweb addresses generated before continuing: // should no longer be needed, but leaving here just in case await (walletAddresses as LitecoinWalletAddresses).ensureMwebAddressUpToIndexExists(1020); } @action @override Future<void> connectToNode({required Node node}) async { await super.connectToNode(node: node); final prefs = await SharedPreferences.getInstance(); final mwebNodeUri = prefs.getString("mwebNodeUri") ?? "ltc-electrum.cakewallet.com:9333"; await CwMweb.setNodeUriOverride(mwebNodeUri); } @action @override Future<void> startSync() async { print("startSync() called!"); print("STARTING SYNC - MWEB ENABLED: $mwebEnabled"); if (!mwebEnabled) { try { // in case we're switching from a litecoin wallet that had mweb enabled CwMweb.stop(); } catch (_) {} super.startSync(); return; } if (mwebSyncStatus is SyncronizingSyncStatus) { return; } print("STARTING SYNC - MWEB ENABLED: $mwebEnabled"); _syncTimer?.cancel(); try { mwebSyncStatus = SyncronizingSyncStatus(); try { await subscribeForUpdates(); } catch (e) { print("failed to subcribe for updates: $e"); } updateFeeRates(); _feeRatesTimer?.cancel(); _feeRatesTimer = Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); print("START SYNC FUNCS"); await waitForMwebAddresses(); await processMwebUtxos(); await updateTransactions(); await updateUnspent(); await updateBalance(); print("DONE SYNC FUNCS"); } catch (e, s) { print("mweb sync failed: $e $s"); mwebSyncStatus = FailedSyncStatus(error: "mweb sync failed: $e"); return; } _syncTimer = Timer.periodic(const Duration(milliseconds: 3000), (timer) async { if (mwebSyncStatus is FailedSyncStatus) { _syncTimer?.cancel(); return; } final nodeHeight = await electrumClient.getCurrentBlockChainTip() ?? 0; // current block height of our node if (nodeHeight == 0) { // we aren't connected to the ltc node yet if (mwebSyncStatus is! NotConnectedSyncStatus) { mwebSyncStatus = FailedSyncStatus(error: "litecoin node isn't connected"); } return; } // update the current chain tip so that confirmation calculations are accurate: currentChainTip = nodeHeight; final resp = await CwMweb.status(StatusRequest()); try { if (resp.blockHeaderHeight < nodeHeight) { int h = resp.blockHeaderHeight; mwebSyncStatus = SyncingSyncStatus(nodeHeight - h, h / nodeHeight); } else if (resp.mwebHeaderHeight < nodeHeight) { int h = resp.mwebHeaderHeight; mwebSyncStatus = SyncingSyncStatus(nodeHeight - h, h / nodeHeight); } else if (resp.mwebUtxosHeight < nodeHeight) { mwebSyncStatus = SyncingSyncStatus(1, 0.999); } else { bool confirmationsUpdated = false; if (resp.mwebUtxosHeight > walletInfo.restoreHeight) { await walletInfo.updateRestoreHeight(resp.mwebUtxosHeight); await checkMwebUtxosSpent(); // update the confirmations for each transaction: for (final tx in transactionHistory.transactions.values) { if (tx.height == null || tx.height == 0) { // update with first confirmation on next block since it hasn't been confirmed yet: tx.height = resp.mwebUtxosHeight; continue; } final confirmations = (resp.mwebUtxosHeight - tx.height!) + 1; // if the confirmations haven't changed, skip updating: if (tx.confirmations == confirmations) continue; // if an outgoing tx is now confirmed, delete the utxo from the box (delete the unspent coin): if (confirmations >= 2 && tx.direction == TransactionDirection.outgoing && tx.unspents != null) { for (var coin in tx.unspents!) { final utxo = mwebUtxosBox.get(coin.address); if (utxo != null) { print("deleting utxo ${coin.address} @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"); await mwebUtxosBox.delete(coin.address); } } } tx.confirmations = confirmations; tx.isPending = false; transactionHistory.addOne(tx); confirmationsUpdated = true; } if (confirmationsUpdated) { await transactionHistory.save(); await updateTransactions(); } } // prevent unnecessary reaction triggers: if (mwebSyncStatus is! SyncedSyncStatus) { // mwebd is synced, but we could still be processing incoming utxos: if (!processingUtxos) { mwebSyncStatus = SyncedSyncStatus(); } } return; } } catch (e) { print("error syncing: $e"); mwebSyncStatus = FailedSyncStatus(error: e.toString()); } }); } @action @override Future<void> stopSync() async { print("stopSync() called!"); _syncTimer?.cancel(); _utxoStream?.cancel(); _feeRatesTimer?.cancel(); await CwMweb.stop(); print("stopped syncing!"); } Future<void> initMwebUtxosBox() async { final boxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; mwebUtxosBox = await CakeHive.openBox<MwebUtxo>(boxName); } @override Future<void> renameWalletFiles(String newWalletName) async { // rename the hive box: final oldBoxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; final newBoxName = "${newWalletName.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; final oldBox = await CakeHive.openBox<MwebUtxo>(oldBoxName); mwebUtxosBox = await CakeHive.openBox<MwebUtxo>(newBoxName); for (final key in oldBox.keys) { await mwebUtxosBox.put(key, oldBox.get(key)!); } oldBox.deleteFromDisk(); await super.renameWalletFiles(newWalletName); } @action @override Future<void> rescan({ required int height, int? chainTip, ScanData? scanData, bool? doSingleScan, bool? usingElectrs, }) async { _syncTimer?.cancel(); await walletInfo.updateRestoreHeight(height); // go through mwebUtxos and clear any that are above the new restore height: if (height == 0) { await mwebUtxosBox.clear(); transactionHistory.clear(); } else { for (final utxo in mwebUtxosBox.values) { if (utxo.height > height) { await mwebUtxosBox.delete(utxo.outputId); } } // TODO: remove transactions that are above the new restore height! } // reset coin balances and txCount to 0: unspentCoins.forEach((coin) { if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) coin.bitcoinAddressRecord.balance = 0; coin.bitcoinAddressRecord.txCount = 0; }); for (var addressRecord in walletAddresses.allAddresses) { addressRecord.balance = 0; addressRecord.txCount = 0; } await startSync(); } @override Future<void> init() async { await super.init(); await initMwebUtxosBox(); } Future<void> handleIncoming(MwebUtxo utxo) async { print("handleIncoming() called!"); final status = await CwMweb.status(StatusRequest()); var date = DateTime.now(); var confirmations = 0; if (utxo.height > 0) { date = DateTime.fromMillisecondsSinceEpoch(utxo.blockTime * 1000); confirmations = status.blockHeaderHeight - utxo.height + 1; } var tx = transactionHistory.transactions.values .firstWhereOrNull((tx) => tx.outputAddresses?.contains(utxo.outputId) ?? false); if (tx == null) { tx = ElectrumTransactionInfo( WalletType.litecoin, id: utxo.outputId, height: utxo.height, amount: utxo.value.toInt(), fee: 0, direction: TransactionDirection.incoming, isPending: utxo.height == 0, date: date, confirmations: confirmations, inputAddresses: [], outputAddresses: [utxo.outputId], isReplaced: false, ); } else { if (tx.confirmations != confirmations || tx.height != utxo.height) { tx.height = utxo.height; tx.confirmations = confirmations; tx.isPending = utxo.height == 0; } } bool isNew = transactionHistory.transactions[tx.id] == null; if (!(tx.outputAddresses?.contains(utxo.address) ?? false)) { tx.outputAddresses?.add(utxo.address); isNew = true; } if (isNew) { final addressRecord = walletAddresses.allAddresses .firstWhereOrNull((addressRecord) => addressRecord.address == utxo.address); if (addressRecord == null) { print("we don't have this address in the wallet! ${utxo.address}"); return; } // update the txCount: addressRecord.txCount++; addressRecord.balance += utxo.value.toInt(); addressRecord.setAsUsed(); } transactionHistory.addOne(tx); if (isNew) { // update the unconfirmed balance when a new tx is added: // we do this after adding the tx to the history so that sub address balances are updated correctly // (since that calculation is based on the tx history) await updateBalance(); } } Future<void> processMwebUtxos() async { print("processMwebUtxos() called!"); if (!mwebEnabled) { return; } int restoreHeight = walletInfo.restoreHeight; print("SCANNING FROM HEIGHT: $restoreHeight"); final req = UtxosRequest(scanSecret: scanSecret, fromHeight: restoreHeight); // process new utxos as they come in: await _utxoStream?.cancel(); ResponseStream<Utxo>? responseStream = await CwMweb.utxos(req); if (responseStream == null) { throw Exception("failed to get utxos stream!"); } _utxoStream = responseStream.listen( (Utxo sUtxo) async { // we're processing utxos, so our balance could still be innacurate: if (mwebSyncStatus is! SyncronizingSyncStatus && mwebSyncStatus is! SyncingSyncStatus) { mwebSyncStatus = SyncronizingSyncStatus(); processingUtxos = true; _processingTimer?.cancel(); _processingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { processingUtxos = false; timer.cancel(); }); } final utxo = MwebUtxo( address: sUtxo.address, blockTime: sUtxo.blockTime, height: sUtxo.height, outputId: sUtxo.outputId, value: sUtxo.value.toInt(), ); if (mwebUtxosBox.containsKey(utxo.outputId)) { // we've already stored this utxo, skip it: // but do update the utxo height if it's somehow different: final existingUtxo = mwebUtxosBox.get(utxo.outputId); if (existingUtxo!.height != utxo.height) { print( "updating utxo height for $utxo.outputId: ${existingUtxo.height} -> ${utxo.height}"); existingUtxo.height = utxo.height; await mwebUtxosBox.put(utxo.outputId, existingUtxo); } return; } await updateUnspent(); await updateBalance(); final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs; // don't process utxos with addresses that are not in the mwebAddrs list: if (utxo.address.isNotEmpty && !mwebAddrs.contains(utxo.address)) { return; } await mwebUtxosBox.put(utxo.outputId, utxo); await handleIncoming(utxo); }, onError: (error) { print("error in utxo stream: $error"); mwebSyncStatus = FailedSyncStatus(error: error.toString()); }, cancelOnError: true, ); } Future<void> deleteSpentUtxos() async { print("deleteSpentUtxos() called!"); final chainHeight = await electrumClient.getCurrentBlockChainTip(); final status = await CwMweb.status(StatusRequest()); if (chainHeight == null || status.blockHeaderHeight != chainHeight) return; if (status.mwebUtxosHeight != chainHeight) return; // we aren't synced // delete any spent utxos with >= 2 confirmations: final spentOutputIds = mwebUtxosBox.values .where((utxo) => utxo.spent && (chainHeight - utxo.height) >= 2) .map((utxo) => utxo.outputId) .toList(); if (spentOutputIds.isEmpty) return; final resp = await CwMweb.spent(SpentRequest(outputId: spentOutputIds)); final spent = resp.outputId; if (spent.isEmpty) return; for (final outputId in spent) { await mwebUtxosBox.delete(outputId); } } Future<void> checkMwebUtxosSpent() async { print("checkMwebUtxosSpent() called!"); if (!mwebEnabled) { return; } final pendingOutgoingTransactions = transactionHistory.transactions.values .where((tx) => tx.direction == TransactionDirection.outgoing && tx.isPending); // check if any of the pending outgoing transactions are now confirmed: bool updatedAny = false; for (final tx in pendingOutgoingTransactions) { updatedAny = await isConfirmed(tx) || updatedAny; } await deleteSpentUtxos(); // get output ids of all the mweb utxos that have > 0 height: final outputIds = mwebUtxosBox.values .where((utxo) => utxo.height > 0 && !utxo.spent) .map((utxo) => utxo.outputId) .toList(); final resp = await CwMweb.spent(SpentRequest(outputId: outputIds)); final spent = resp.outputId; if (spent.isEmpty) return; final status = await CwMweb.status(StatusRequest()); final height = await electrumClient.getCurrentBlockChainTip(); if (height == null || status.blockHeaderHeight != height) return; if (status.mwebUtxosHeight != height) return; // we aren't synced int amount = 0; Set<String> inputAddresses = {}; var output = convert.AccumulatorSink<Digest>(); var input = sha256.startChunkedConversion(output); for (final outputId in spent) { final utxo = mwebUtxosBox.get(outputId); await mwebUtxosBox.delete(outputId); if (utxo == null) continue; final addressRecord = walletAddresses.allAddresses .firstWhere((addressRecord) => addressRecord.address == utxo.address); if (!inputAddresses.contains(utxo.address)) { addressRecord.txCount++; } addressRecord.balance -= utxo.value.toInt(); amount += utxo.value.toInt(); inputAddresses.add(utxo.address); input.add(hex.decode(outputId)); } if (inputAddresses.isEmpty) return; input.close(); var digest = output.events.single; final tx = ElectrumTransactionInfo( WalletType.litecoin, id: digest.toString(), height: height, amount: amount, fee: 0, direction: TransactionDirection.outgoing, isPending: false, date: DateTime.fromMillisecondsSinceEpoch(status.blockTime * 1000), confirmations: 1, inputAddresses: inputAddresses.toList(), outputAddresses: [], isReplaced: false, ); transactionHistory.addOne(tx); await transactionHistory.save(); if (updatedAny) { await updateBalance(); } } // checks if a pending transaction is now confirmed, and updates the tx info accordingly: Future<bool> isConfirmed(ElectrumTransactionInfo tx) async { if (!mwebEnabled) return false; if (!tx.isPending) return false; final outputId = <String>[], target = <String>{}; final isHash = RegExp(r'^[a-f0-9]{64}$').hasMatch; final spendingOutputIds = tx.inputAddresses?.where(isHash) ?? []; final payingToOutputIds = tx.outputAddresses?.where(isHash) ?? []; outputId.addAll(spendingOutputIds); outputId.addAll(payingToOutputIds); target.addAll(spendingOutputIds); for (final outputId in payingToOutputIds) { final spendingTx = transactionHistory.transactions.values .firstWhereOrNull((tx) => tx.inputAddresses?.contains(outputId) ?? false); if (spendingTx != null && !spendingTx.isPending) { target.add(outputId); } } if (outputId.isEmpty) { return false; } final resp = await CwMweb.spent(SpentRequest(outputId: outputId)); if (!setEquals(resp.outputId.toSet(), target)) { return false; } final status = await CwMweb.status(StatusRequest()); tx.height = status.mwebUtxosHeight; tx.confirmations = 1; tx.isPending = false; await transactionHistory.save(); return true; } Future<void> updateUnspent() async { print("updateUnspent() called!"); await checkMwebUtxosSpent(); await updateAllUnspents(); } @override @action Future<void> updateAllUnspents() async { if (!mwebEnabled) { await super.updateAllUnspents(); return; } // add the mweb unspents to the list: List<BitcoinUnspent> mwebUnspentCoins = []; // update mweb unspents: final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs; mwebUtxosBox.keys.forEach((dynamic oId) { final String outputId = oId as String; final utxo = mwebUtxosBox.get(outputId); if (utxo == null || utxo.spent) { return; } if (utxo.address.isEmpty) { // not sure if a bug or a special case but we definitely ignore these return; } final addressRecord = walletAddresses.allAddresses .firstWhereOrNull((addressRecord) => addressRecord.address == utxo.address); if (addressRecord == null) { print("utxo contains an address that is not in the wallet: ${utxo.address}"); return; } final unspent = BitcoinUnspent( addressRecord, outputId, utxo.value.toInt(), mwebAddrs.indexOf(utxo.address), ); if (unspent.vout == 0) { unspent.isChange = true; } mwebUnspentCoins.add(unspent); }); // copy coin control attributes to mwebCoins: await updateCoins(mwebUnspentCoins); // get regular ltc unspents (this resets unspentCoins): await super.updateAllUnspents(); // add the mwebCoins: unspentCoins.addAll(mwebUnspentCoins); } @override Future<ElectrumBalance> fetchBalances() async { final balance = await super.fetchBalances(); if (!mwebEnabled) { return balance; } // update unspent balances: await updateUnspent(); int confirmed = balance.confirmed; int unconfirmed = balance.unconfirmed; int confirmedMweb = 0; int unconfirmedMweb = 0; try { mwebUtxosBox.values.forEach((utxo) { bool isConfirmed = utxo.height > 0; print( "utxo: ${isConfirmed ? "confirmed" : "unconfirmed"} ${utxo.spent ? "spent" : "unspent"} ${utxo.outputId} ${utxo.height} ${utxo.value}"); if (isConfirmed) { confirmedMweb += utxo.value.toInt(); } if (isConfirmed && utxo.spent) { unconfirmedMweb -= utxo.value.toInt(); } if (!isConfirmed && !utxo.spent) { unconfirmedMweb += utxo.value.toInt(); } }); } catch (_) {} for (var addressRecord in walletAddresses.allAddresses) { addressRecord.balance = 0; addressRecord.txCount = 0; } unspentCoins.forEach((coin) { final coinInfoList = unspentCoinsInfo.values.where( (element) => element.walletId.contains(id) && element.hash.contains(coin.hash) && element.vout == coin.vout, ); if (coinInfoList.isNotEmpty) { final coinInfo = coinInfoList.first; coin.isFrozen = coinInfo.isFrozen; coin.isSending = coinInfo.isSending; coin.note = coinInfo.note; if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) coin.bitcoinAddressRecord.balance += coinInfo.value; } else { super.addCoinInfo(coin); } }); // update the txCount for each address using the tx history, since we can't rely on mwebd // to have an accurate count, we should just keep it in sync with what we know from the tx history: for (final tx in transactionHistory.transactions.values) { if (tx.inputAddresses == null || tx.outputAddresses == null) { continue; } final txAddresses = tx.inputAddresses! + tx.outputAddresses!; for (final address in txAddresses) { final addressRecord = walletAddresses.allAddresses .firstWhereOrNull((addressRecord) => addressRecord.address == address); if (addressRecord == null) { continue; } addressRecord.txCount++; } } return ElectrumBalance( confirmed: confirmed, unconfirmed: unconfirmed, frozen: balance.frozen, secondConfirmed: confirmedMweb, secondUnconfirmed: unconfirmedMweb, ); } @override int feeRate(TransactionPriority priority) { if (priority is LitecoinTransactionPriority) { switch (priority) { case LitecoinTransactionPriority.slow: return 1; case LitecoinTransactionPriority.medium: return 2; case LitecoinTransactionPriority.fast: return 3; } } return 0; } @override Future<int> calcFee({ required List<UtxoWithAddress> utxos, required List<BitcoinBaseOutput> outputs, required BasedUtxoNetwork network, String? memo, required int feeRate, List<ECPrivateInfo>? inputPrivKeyInfos, List<Outpoint>? vinOutpoints, }) async { final spendsMweb = utxos.any((utxo) => utxo.utxo.scriptType == SegwitAddresType.mweb); final paysToMweb = outputs .any((output) => output.toOutput.scriptPubKey.getAddressType() == SegwitAddresType.mweb); if (!spendsMweb && !paysToMweb) { return await super.calcFee( utxos: utxos, outputs: outputs, network: network, memo: memo, feeRate: feeRate, inputPrivKeyInfos: inputPrivKeyInfos, vinOutpoints: vinOutpoints, ); } if (!mwebEnabled) { throw Exception("MWEB is not enabled! can't calculate fee without starting the mweb server!"); } if (outputs.length == 1 && outputs[0].toOutput.amount == BigInt.zero) { outputs = [ BitcoinScriptOutput( script: outputs[0].toOutput.scriptPubKey, value: utxos.sumOfUtxosValue()) ]; } // https://github.com/ltcmweb/mwebd?tab=readme-ov-file#fee-estimation final preOutputSum = outputs.fold<BigInt>(BigInt.zero, (acc, output) => acc + output.toOutput.amount); var fee = utxos.sumOfUtxosValue() - preOutputSum; // determines if the fee is correct: BigInt _sumOutputAmounts(List<TxOutput> outputs) { BigInt sum = BigInt.zero; for (final e in outputs) { sum += e.amount; } return sum; } final sum1 = _sumOutputAmounts(outputs.map((e) => e.toOutput).toList()) + fee; final sum2 = utxos.sumOfUtxosValue(); if (sum1 != sum2) { print("@@@@@ WE HAD TO ADJUST THE FEE! @@@@@@@@"); final diff = sum2 - sum1; // add the difference to the fee (abs value): fee += diff.abs(); } final txb = BitcoinTransactionBuilder(utxos: utxos, outputs: outputs, fee: fee, network: network); final resp = await CwMweb.create(CreateRequest( rawTx: txb.buildTransaction((a, b, c, d) => '').toBytes(), scanSecret: scanSecret, spendSecret: spendSecret, feeRatePerKb: Int64(feeRate * 1000), dryRun: true)); final tx = BtcTransaction.fromRaw(hex.encode(resp.rawTx)); final posUtxos = utxos .where((utxo) => tx.inputs .any((input) => input.txId == utxo.utxo.txHash && input.txIndex == utxo.utxo.vout)) .toList(); final posOutputSum = tx.outputs.fold<int>(0, (acc, output) => acc + output.amount.toInt()); final mwebInputSum = utxos.sumOfUtxosValue() - posUtxos.sumOfUtxosValue(); final expectedPegin = max(0, (preOutputSum - mwebInputSum).toInt()); var feeIncrease = posOutputSum - expectedPegin; if (expectedPegin > 0 && fee == BigInt.zero) { feeIncrease += await super.calcFee( utxos: posUtxos, outputs: tx.outputs .map((output) => BitcoinScriptOutput(script: output.scriptPubKey, value: output.amount)) .toList(), network: network, memo: memo, feeRate: feeRate) + feeRate * 41; } return fee.toInt() + feeIncrease; } @override Future<PendingTransaction> createTransaction(Object credentials) async { try { var tx = await super.createTransaction(credentials) as PendingBitcoinTransaction; tx.isMweb = mwebEnabled; if (!mwebEnabled) { tx.changeAddressOverride = (await (walletAddresses as LitecoinWalletAddresses).getChangeAddress(isPegIn: false)) .address; return tx; } await waitForMwebAddresses(); final resp = await CwMweb.create(CreateRequest( rawTx: hex.decode(tx.hex), scanSecret: scanSecret, spendSecret: spendSecret, feeRatePerKb: Int64.parseInt(tx.feeRate) * 1000, )); final tx2 = BtcTransaction.fromRaw(hex.encode(resp.rawTx)); // check if the transaction doesn't contain any mweb inputs or outputs: final transactionCredentials = credentials as BitcoinTransactionCredentials; bool hasMwebInput = false; bool hasMwebOutput = false; bool hasRegularOutput = false; for (final output in transactionCredentials.outputs) { final address = output.address.toLowerCase(); final extractedAddress = output.extractedAddress?.toLowerCase(); if (address.contains("mweb")) { hasMwebOutput = true; } if (!address.contains("mweb")) { hasRegularOutput = true; } if (extractedAddress != null && extractedAddress.isNotEmpty) { if (extractedAddress.contains("mweb")) { hasMwebOutput = true; } if (!extractedAddress.contains("mweb")) { hasRegularOutput = true; } } } // check if mweb inputs are used: for (final utxo in tx.utxos) { if (utxo.utxo.scriptType == SegwitAddresType.mweb) { hasMwebInput = true; } } bool isPegIn = !hasMwebInput && hasMwebOutput; bool isPegOut = hasMwebInput && hasRegularOutput; bool isRegular = !hasMwebInput && !hasMwebOutput; tx.changeAddressOverride = (await (walletAddresses as LitecoinWalletAddresses) .getChangeAddress(isPegIn: isPegIn || isRegular)) .address; if (!hasMwebInput && !hasMwebOutput) { tx.isMweb = false; return tx; } // check if any of the inputs of this transaction are hog-ex: // this list is only non-mweb inputs: tx2.inputs.forEach((txInput) { bool isHogEx = true; final utxo = unspentCoins .firstWhere((utxo) => utxo.hash == txInput.txId && utxo.vout == txInput.txIndex); // TODO: detect actual hog-ex inputs if (!isHogEx) { return; } int confirmations = utxo.confirmations ?? 0; if (confirmations < 6) { throw Exception( "A transaction input has less than 6 confirmations, please try again later."); } }); tx.hexOverride = tx2 .copyWith( witnesses: tx2.inputs.asMap().entries.map((e) { final utxo = unspentCoins .firstWhere((utxo) => utxo.hash == e.value.txId && utxo.vout == e.value.txIndex); final key = generateECPrivate( hd: utxo.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, index: utxo.bitcoinAddressRecord.index, network: network); final digest = tx2.getTransactionSegwitDigit( txInIndex: e.key, script: key.getPublic().toP2pkhAddress().toScriptPubKey(), amount: BigInt.from(utxo.value), ); return TxWitnessInput(stack: [key.signInput(digest), key.getPublic().toHex()]); }).toList()) .toHex(); tx.outputAddresses = resp.outputId; return tx ..addListener((transaction) async { final addresses = <String>{}; transaction.inputAddresses?.forEach((id) async { final utxo = mwebUtxosBox.get(id); // await mwebUtxosBox.delete(id); // gets deleted in checkMwebUtxosSpent if (utxo == null) return; // mark utxo as spent so we add it to the unconfirmed balance (as negative): utxo.spent = true; await mwebUtxosBox.put(id, utxo); final addressRecord = walletAddresses.allAddresses .firstWhere((addressRecord) => addressRecord.address == utxo.address); if (!addresses.contains(utxo.address)) { addresses.add(utxo.address); } addressRecord.balance -= utxo.value.toInt(); }); transaction.inputAddresses?.addAll(addresses); print("isPegIn: $isPegIn, isPegOut: $isPegOut"); transaction.additionalInfo["isPegIn"] = isPegIn; transaction.additionalInfo["isPegOut"] = isPegOut; transactionHistory.addOne(transaction); await updateUnspent(); await updateBalance(); }); } catch (e, s) { print(e); print(s); if (e.toString().contains("commit failed")) { print(e); throw Exception("Transaction commit failed (no peers responded), please try again."); } rethrow; } } @override Future<void> save() async { await super.save(); } @override Future<void> close({required bool shouldCleanup}) async { _utxoStream?.cancel(); _feeRatesTimer?.cancel(); _syncTimer?.cancel(); _processingTimer?.cancel(); if (shouldCleanup) { try { await stopSync(); } catch (_) {} } await super.close(shouldCleanup: shouldCleanup); } Future<void> setMwebEnabled(bool enabled) async { if (mwebEnabled == enabled) { return; } alwaysScan = enabled; mwebEnabled = enabled; (walletAddresses as LitecoinWalletAddresses).mwebEnabled = enabled; await save(); try { await stopSync(); } catch (_) {} await startSync(); } Future<StatusResponse> getStatusRequest() async { final resp = await CwMweb.status(StatusRequest()); return resp; } @override Future<String> signMessage(String message, {String? address = null}) async { final index = address != null ? walletAddresses.allAddresses.firstWhere((element) => element.address == address).index : null; final HD = index == null ? hd : hd.childKey(Bip32KeyIndex(index)); final priv = ECPrivate.fromHex(HD.privateKey.privKey.toHex()); final privateKey = ECDSAPrivateKey.fromBytes( priv.toBytes(), Curves.generatorSecp256k1, ); final signature = signLitecoinMessage(utf8.encode(message), privateKey: privateKey, bipPrive: priv.prive); return base64Encode(signature); } List<int> _magicPrefix(List<int> message, List<int> messagePrefix) { final encodeLength = IntUtils.encodeVarint(message.length); return [...messagePrefix, ...encodeLength, ...message]; } List<int> signLitecoinMessage(List<int> message, {required ECDSAPrivateKey privateKey, required Bip32PrivateKey bipPrive}) { String messagePrefix = '\x19Litecoin Signed Message:\n'; final messageHash = QuickCrypto.sha256Hash(magicMessage(message, messagePrefix)); final signingKey = EcdsaSigningKey(privateKey); ECDSASignature ecdsaSign = signingKey.signDigestDeterminstic(digest: messageHash, hashFunc: () => SHA256()); final n = Curves.generatorSecp256k1.order! >> 1; BigInt newS; if (ecdsaSign.s.compareTo(n) > 0) { newS = Curves.generatorSecp256k1.order! - ecdsaSign.s; } else { newS = ecdsaSign.s; } final rawSig = ECDSASignature(ecdsaSign.r, newS); final rawSigBytes = rawSig.toBytes(BitcoinSignerUtils.baselen); final pub = bipPrive.publicKey; final ECDomainParameters curve = ECCurve_secp256k1(); final point = curve.curve.decodePoint(pub.point.toBytes()); final rawSigEc = ECSignature(rawSig.r, rawSig.s); final recId = SignUtils.findRecoveryId( SignUtils.getHexString(messageHash, offset: 0, length: messageHash.length), rawSigEc, Uint8List.fromList(pub.uncompressed), ); final v = recId + 27 + (point!.isCompressed ? 4 : 0); final combined = Uint8List.fromList([v, ...rawSigBytes]); return combined; } List<int> magicMessage(List<int> message, String messagePrefix) { final prefixBytes = StringUtils.encode(messagePrefix); final magic = _magicPrefix(message, prefixBytes); return QuickCrypto.sha256Hash(magic); } @override Future<bool> verifyMessage(String message, String signature, {String? address = null}) async { if (address == null) { return false; } List<int> sigDecodedBytes = []; if (signature.endsWith('=')) { sigDecodedBytes = base64.decode(signature); } else { sigDecodedBytes = hex.decode(signature); } if (sigDecodedBytes.length != 64 && sigDecodedBytes.length != 65) { throw ArgumentException( "litecoin signature must be 64 bytes without recover-id or 65 bytes with recover-id"); } String messagePrefix = '\x19Litecoin Signed Message:\n'; final messageHash = QuickCrypto.sha256Hash(magicMessage(utf8.encode(message), messagePrefix)); List<int> correctSignature = sigDecodedBytes.length == 65 ? sigDecodedBytes.sublist(1) : List.from(sigDecodedBytes); List<int> rBytes = correctSignature.sublist(0, 32); List<int> sBytes = correctSignature.sublist(32); final sig = ECDSASignature(BigintUtils.fromBytes(rBytes), BigintUtils.fromBytes(sBytes)); List<int> possibleRecoverIds = [0, 1]; final baseAddress = RegexUtils.addressTypeFromStr(address, network); for (int recoveryId in possibleRecoverIds) { final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId); final recoveredPub = ECPublic.fromBytes(pubKey!.toBytes()); String? recoveredAddress; if (baseAddress is P2pkAddress) { recoveredAddress = recoveredPub.toP2pkAddress().toAddress(network); } else if (baseAddress is P2pkhAddress) { recoveredAddress = recoveredPub.toP2pkhAddress().toAddress(network); } else if (baseAddress is P2wshAddress) { recoveredAddress = recoveredPub.toP2wshAddress().toAddress(network); } else if (baseAddress is P2wpkhAddress) { recoveredAddress = recoveredPub.toP2wpkhAddress().toAddress(network); } if (recoveredAddress == address) { return true; } } return false; } LedgerConnection? _ledgerConnection; LitecoinLedgerApp? _litecoinLedgerApp; @override void setLedgerConnection(LedgerConnection connection) { _ledgerConnection = connection; _litecoinLedgerApp = LitecoinLedgerApp(_ledgerConnection!, derivationPath: walletInfo.derivationInfo!.derivationPath!); } @override Future<BtcTransaction> buildHardwareWalletTransaction({ required List<BitcoinBaseOutput> outputs, required BigInt fee, required BasedUtxoNetwork network, required List<UtxoWithAddress> utxos, required Map<String, PublicKeyWithDerivationPath> publicKeys, String? memo, bool enableRBF = false, BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, }) async { final readyInputs = <LedgerTransaction>[]; for (final utxo in utxos) { final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash); final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; readyInputs.add(LedgerTransaction( rawTx: rawTx, outputIndex: utxo.utxo.vout, ownerPublicKey: Uint8List.fromList(hex.decode(publicKeyAndDerivationPath.publicKey)), ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, // sequence: enableRBF ? 0x1 : 0xffffffff, sequence: 0xffffffff, )); } String? changePath; for (final output in outputs) { final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()]; if (maybeChangePath != null) changePath ??= maybeChangePath.derivationPath; } final rawHex = await _litecoinLedgerApp!.createTransaction( inputs: readyInputs, outputs: outputs .map((e) => TransactionOutput.fromBigInt((e as BitcoinOutput).value, Uint8List.fromList(e.address.toScriptPubKey().toBytes()))) .toList(), changePath: changePath, sigHashType: 0x01, additionals: ["bech32"], isSegWit: true, useTrustedInputForSegwit: true); return BtcTransaction.fromRaw(rawHex); } }