dev
dart 1,596 lines 53.8 KB
Raw
1 import 'dart:async';
2 import 'dart:convert';
3 import 'dart:math';
4 import 'package:collection/collection.dart';
5 import 'package:crypto/crypto.dart';
6 import 'package:cw_bitcoin/address_from_output.dart';
7 import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
8 import 'package:cw_core/amount/money.dart';
9 import 'package:cw_core/cake_hive.dart';
10 import 'package:cw_core/mweb_utxo.dart';
11 import 'package:cw_core/unspent_coin_type.dart';
12 import 'package:cw_core/utils/print_verbose.dart';
13 import 'package:cw_core/node.dart';
14 import 'package:cw_mweb/mwebd.pbgrpc.dart';
15 import 'package:fixnum/fixnum.dart';
16 import 'package:bip39/bip39.dart' as bip39;
17 import 'package:bitcoin_base/bitcoin_base.dart';
18 import 'package:bitcoin_base/src/crypto/keypair/sign_utils.dart';
19 import 'package:blockchain_utils/blockchain_utils.dart';
20 import 'package:blockchain_utils/signer/ecdsa_signing_key.dart';
21 import 'package:convert/convert.dart' as convert;
22 import 'package:cw_bitcoin/bitcoin_address_record.dart';
23 import 'package:cw_bitcoin/bitcoin_mnemonic.dart';
24 import 'package:cw_bitcoin/bitcoin_transaction_priority.dart';
25 import 'package:cw_bitcoin/bitcoin_unspent.dart';
26 import 'package:cw_bitcoin/electrum_balance.dart';
27 import 'package:cw_bitcoin/electrum_derivations.dart';
28 import 'package:cw_bitcoin/electrum_transaction_info.dart';
29 import 'package:cw_bitcoin/electrum_wallet.dart';
30 import 'package:cw_bitcoin/electrum_wallet_snapshot.dart';
31 import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart';
32 import 'package:cw_bitcoin/litecoin_wallet_addresses.dart';
33 import 'package:cw_bitcoin/output_ordering.dart';
34 import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
35 import 'package:cw_bitcoin/psbt/transaction_builder.dart';
36 import 'package:cw_bitcoin/utils.dart';
37 import 'package:cw_core/crypto_currency.dart';
38 import 'package:cw_core/encryption_file_utils.dart';
39 import 'package:cw_core/output_info.dart';
40 import 'package:cw_core/pending_transaction.dart';
41 import 'package:cw_core/sync_status.dart';
42 import 'package:cw_core/transaction_direction.dart';
43 import 'package:cw_core/transaction_priority.dart';
44 import 'package:cw_core/unspent_coins_info.dart';
45 import 'package:cw_core/wallet_info.dart';
46 import 'package:cw_core/wallet_keys_file.dart';
47 import 'package:cw_core/wallet_type.dart';
48 import 'package:cw_mweb/cw_mweb.dart';
49 import 'package:flutter/foundation.dart';
50 import 'package:grpc/grpc.dart';
51 import 'package:hive/hive.dart';
52 import 'package:mobx/mobx.dart';
53 import 'package:pointycastle/ecc/api.dart';
54 import 'package:pointycastle/ecc/curves/secp256k1.dart';
55 import 'package:shared_preferences/shared_preferences.dart';
56 import 'package:ur/cbor_lite.dart';
57 import 'package:ur/ur.dart';
58 import 'package:ur/ur_decoder.dart';
59
60 part 'litecoin_wallet.g.dart';
61
62 class LitecoinWallet = LitecoinWalletBase with _$LitecoinWallet;
63
64 abstract class LitecoinWalletBase extends ElectrumWallet with Store {
65 LitecoinWalletBase({
66 required String password,
67 required WalletInfo walletInfo,
68 required DerivationInfo derivationInfo,
69 required Box<UnspentCoinsInfo> unspentCoinsInfo,
70 required EncryptionFileUtils encryptionFileUtils,
71 Uint8List? seedBytes,
72 String? mnemonic,
73 String? xpub,
74 this.scanSecretOverride,
75 this.spendPubkeyOverride,
76 String? passphrase,
77 String? addressPageType,
78 List<BitcoinAddressRecord>? initialAddresses,
79 List<BitcoinAddressRecord>? initialMwebAddresses,
80 ElectrumBalance? initialBalance,
81 Map<String, int>? initialRegularAddressIndex,
82 Map<String, int>? initialChangeAddressIndex,
83 int? initialMwebHeight,
84 bool? alwaysScan,
85 }) : super(
86 mnemonic: mnemonic,
87 password: password,
88 passphrase: passphrase,
89 xpub: xpub,
90 walletInfo: walletInfo,
91 derivationInfo: derivationInfo,
92 unspentCoinsInfo: unspentCoinsInfo,
93 network: LitecoinNetwork.mainnet,
94 initialAddresses: initialAddresses,
95 initialBalance: initialBalance,
96 seedBytes: seedBytes,
97 encryptionFileUtils: encryptionFileUtils,
98 currency: CryptoCurrency.ltc,
99 alwaysScan: alwaysScan,
100 ) {
101 if (seedBytes != null) {
102 mwebHd =
103 Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/1000'") as Bip32Slip10Secp256k1;
104 mwebEnabled = alwaysScan ?? false;
105 } else if (scanSecretOverride != null && spendPubkeyOverride != null) {
106 mwebHd = null;
107 mwebEnabled = alwaysScan ?? false;
108 } else {
109 mwebHd = null;
110 mwebEnabled = false;
111 }
112 walletAddresses = LitecoinWalletAddresses(
113 walletInfo,
114 initialAddresses: initialAddresses,
115 initialRegularAddressIndex: initialRegularAddressIndex,
116 initialChangeAddressIndex: initialChangeAddressIndex,
117 initialMwebAddresses: initialMwebAddresses,
118 mainHdByType: mainHdByType,
119 sideHdByType: sideHdByType,
120 legacyMainHd: mainHd,
121 legacySideHd: sideHd,
122 network: network,
123 mwebHd: mwebHd,
124 mwebEnabled: mwebEnabled,
125 scanSecretOverride: scanSecretOverride,
126 spendPubkeyOverride: spendPubkeyOverride,
127 isHardwareWallet: walletInfo.isHardwareWallet,
128 );
129 autorun((_) {
130 this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress;
131 });
132 reaction((_) => mwebSyncStatus, (status) async {
133 if (mwebSyncStatus is FailedSyncStatus) {
134 // we failed to connect to mweb, check if we are connected to the litecoin node:
135 late int nodeHeight;
136 try {
137 nodeHeight = await electrumClient.getCurrentBlockChainTip() ?? 0;
138 } catch (_) {
139 nodeHeight = 0;
140 }
141
142 if (nodeHeight == 0) {
143 // we aren't connected to the litecoin node, so the current electrum_wallet reactions will take care of this case for us
144 } else {
145 // we're connected to the litecoin node, but we failed to connect to mweb, try again after a few seconds:
146 await CwMweb.stop();
147 await Future.delayed(const Duration(seconds: 5));
148 startSync();
149 }
150 } else if (mwebSyncStatus is SyncingSyncStatus) {
151 syncStatus = mwebSyncStatus;
152 } else if (mwebSyncStatus is SyncronizingSyncStatus) {
153 if (syncStatus is! SyncronizingSyncStatus) {
154 syncStatus = mwebSyncStatus;
155 }
156 } else if (mwebSyncStatus is SyncedSyncStatus) {
157 if (syncStatus is! SyncedSyncStatus) {
158 syncStatus = mwebSyncStatus;
159 }
160 }
161 });
162 }
163
164 late final Bip32Slip10Secp256k1? mwebHd;
165 late final Box<MwebUtxo> mwebUtxosBox;
166 Timer? _syncTimer;
167 Timer? _feeRatesTimer;
168 Timer? _processingTimer;
169 StreamSubscription<Utxo>? _utxoStream;
170 late bool mwebEnabled;
171 bool processingUtxos = false;
172
173 @observable
174 SyncStatus mwebSyncStatus = NotConnectedSyncStatus();
175
176 @override
177 bool get hasRescan => true;
178
179 final String? scanSecretOverride;
180 final String? spendPubkeyOverride;
181 List<int> get scanSecret => (scanSecretOverride != null && scanSecretOverride?.isNotEmpty == true)
182 ? hex.decode(scanSecretOverride!)
183 : mwebHd?.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw ?? List.filled(32, 0);
184
185 List<int> get spendSecret =>
186 mwebHd?.childKey(Bip32KeyIndex(0x80000001)).privateKey.privKey.raw ?? List.filled(32, 0);
187
188 static Future<LitecoinWallet> create(
189 {required String mnemonic,
190 required String password,
191 required WalletInfo walletInfo,
192 required DerivationInfo derivationInfo,
193 required Box<UnspentCoinsInfo> unspentCoinsInfo,
194 required EncryptionFileUtils encryptionFileUtils,
195 String? passphrase,
196 String? addressPageType,
197 List<BitcoinAddressRecord>? initialAddresses,
198 List<BitcoinAddressRecord>? initialMwebAddresses,
199 ElectrumBalance? initialBalance,
200 Map<String, int>? initialRegularAddressIndex,
201 Map<String, int>? initialChangeAddressIndex}) async {
202 late Uint8List seedBytes;
203
204 switch (derivationInfo.derivationType) {
205 case DerivationType.bip39:
206 seedBytes = await bip39.mnemonicToSeed(
207 mnemonic,
208 passphrase: passphrase ?? "",
209 );
210 break;
211 case DerivationType.electrum:
212 default:
213 seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? "");
214 break;
215 }
216 return LitecoinWallet(
217 mnemonic: mnemonic,
218 password: password,
219 walletInfo: walletInfo,
220 derivationInfo: derivationInfo,
221 unspentCoinsInfo: unspentCoinsInfo,
222 initialAddresses: initialAddresses,
223 initialMwebAddresses: initialMwebAddresses,
224 initialBalance: initialBalance,
225 encryptionFileUtils: encryptionFileUtils,
226 passphrase: passphrase,
227 seedBytes: seedBytes,
228 initialRegularAddressIndex: initialRegularAddressIndex,
229 initialChangeAddressIndex: initialChangeAddressIndex,
230 addressPageType: addressPageType,
231 );
232 }
233
234 @override
235 WalletKeysData get walletKeysData => WalletKeysData(
236 mnemonic: seed,
237 xPub: xpub,
238 passphrase: passphrase,
239 scanSecret: scanSecretOverride,
240 spendPubkey: spendPubkeyOverride);
241
242 static Future<LitecoinWallet> open({
243 required String name,
244 required WalletInfo walletInfo,
245 required Box<UnspentCoinsInfo> unspentCoinsInfo,
246 required String password,
247 required EncryptionFileUtils encryptionFileUtils,
248 }) async {
249 final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type);
250
251 ElectrumWalletSnapshot? snp = null;
252
253 try {
254 snp = await ElectrumWalletSnapshot.load(
255 encryptionFileUtils,
256 name,
257 walletInfo.type,
258 password,
259 LitecoinNetwork.mainnet,
260 );
261 } catch (e) {
262 if (!hasKeysFile) rethrow;
263 }
264
265 final WalletKeysData keysData;
266 // Migrate wallet from the old scheme to then new .keys file scheme
267 if (!hasKeysFile) {
268 keysData =
269 WalletKeysData(mnemonic: snp!.mnemonic, xPub: snp.xpub, passphrase: snp.passphrase);
270 } else {
271 keysData = await WalletKeysFile.readKeysFile(
272 name,
273 walletInfo.type,
274 password,
275 encryptionFileUtils,
276 );
277 }
278
279 final derivationInfo = await walletInfo.getDerivationInfo();
280 // set the default if not present:
281 derivationInfo.derivationPath ??= snp?.derivationPath ?? electrum_path;
282 derivationInfo.derivationType ??= snp?.derivationType ?? DerivationType.electrum;
283 if (derivationInfo.derivationType == DerivationType.unknown) {
284 if (snp?.derivationPath == electrum_path || snp?.derivationType == DerivationType.electrum) {
285 derivationInfo.derivationPath = electrum_path;
286 derivationInfo.derivationType = DerivationType.electrum;
287 } else {
288 derivationInfo.derivationPath = segwit_path;
289 derivationInfo.derivationType = DerivationType.bip39;
290 }
291 }
292
293 Uint8List? seedBytes = null;
294 final mnemonic = keysData.mnemonic;
295 final passphrase = keysData.passphrase;
296
297 if (mnemonic != null) {
298 switch (derivationInfo.derivationType) {
299 case DerivationType.bip39:
300 seedBytes = await bip39.mnemonicToSeed(
301 mnemonic,
302 passphrase: passphrase ?? "",
303 );
304 break;
305 case DerivationType.electrum:
306 default:
307 seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? "");
308 break;
309 }
310 }
311 await derivationInfo.save();
312
313 return LitecoinWallet(
314 mnemonic: keysData.mnemonic,
315 xpub: keysData.xPub,
316 scanSecretOverride: keysData.scanSecret,
317 spendPubkeyOverride: keysData.spendPubkey,
318 password: password,
319 walletInfo: walletInfo,
320 derivationInfo: derivationInfo,
321 unspentCoinsInfo: unspentCoinsInfo,
322 initialAddresses: snp?.addresses,
323 initialMwebAddresses: snp?.mwebAddresses,
324 initialBalance: snp?.balance,
325 seedBytes: seedBytes,
326 passphrase: passphrase,
327 encryptionFileUtils: encryptionFileUtils,
328 initialRegularAddressIndex: snp?.regularAddressIndex,
329 initialChangeAddressIndex: snp?.changeAddressIndex,
330 addressPageType: snp?.addressPageType,
331 alwaysScan: snp?.alwaysScan,
332 );
333 }
334
335 Future<void> waitForMwebAddresses() async {
336 printV("waitForMwebAddresses() called!");
337 // ensure that we have the full 1000 mweb addresses generated before continuing:
338 // should no longer be needed, but leaving here just in case
339 await (walletAddresses as LitecoinWalletAddresses).ensureMwebAddressUpToIndexExists(1020);
340 }
341
342 @action
343 @override
344 Future<void> connectToNode({required Node node}) async {
345 await super.connectToNode(node: node);
346
347 final prefs = await SharedPreferences.getInstance();
348 final mwebNodeUri = prefs.getString("mwebNodeUri") ?? "ltc-electrum.cakewallet.com:9333";
349 await CwMweb.setNodeUriOverride(mwebNodeUri);
350 }
351
352 @action
353 @override
354 Future<void> startSync() async {
355 printV("startSync() called!");
356 printV("STARTING SYNC - MWEB ENABLED: $mwebEnabled");
357 if (!mwebEnabled) {
358 try {
359 // in case we're switching from a litecoin wallet that had mweb enabled
360 CwMweb.stop();
361 } catch (_) {}
362 super.startSync();
363 return;
364 }
365
366 if (mwebSyncStatus is SyncronizingSyncStatus) {
367 return;
368 }
369
370 printV("STARTING SYNC - MWEB ENABLED: $mwebEnabled");
371 _syncTimer?.cancel();
372 try {
373 mwebSyncStatus = SyncronizingSyncStatus();
374 try {
375 await subscribeForUpdates();
376 } catch (e) {
377 printV("failed to subscribe for updates: $e");
378 }
379 await checkIfBatchSupported();
380 updateFeeRates();
381 _feeRatesTimer?.cancel();
382 _feeRatesTimer =
383 Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates());
384
385 printV("START SYNC FUNCS");
386 await waitForMwebAddresses();
387 await processMwebUtxos();
388 await updateTransactions();
389 await updateUnspent();
390 await updateBalance();
391 } catch (e) {
392 printV("failed to start mweb sync: $e");
393 syncStatus = FailedSyncStatus();
394 return;
395 }
396
397 _syncTimer?.cancel();
398 _syncTimer = Timer.periodic(const Duration(milliseconds: 3000), (timer) async {
399 if (mwebSyncStatus is FailedSyncStatus) {
400 _syncTimer?.cancel();
401 return;
402 }
403
404 final nodeHeight =
405 await electrumClient.getCurrentBlockChainTip() ?? 0; // current block height of our node
406
407 if (nodeHeight == 0) {
408 // we aren't connected to the ltc node yet
409 if (mwebSyncStatus is! NotConnectedSyncStatus) {
410 mwebSyncStatus = FailedSyncStatus(error: "litecoin node isn't connected");
411 }
412 return;
413 }
414
415 // update the current chain tip so that confirmation calculations are accurate:
416 currentChainTip = nodeHeight;
417
418 final resp = await CwMweb.status(StatusRequest());
419
420 try {
421 if (resp.blockHeaderHeight < nodeHeight) {
422 int h = resp.blockHeaderHeight;
423 mwebSyncStatus = SyncingSyncStatus(nodeHeight - h, h / nodeHeight);
424 } else if (resp.mwebHeaderHeight < nodeHeight) {
425 int h = resp.mwebHeaderHeight;
426 mwebSyncStatus = SyncingSyncStatus(nodeHeight - h, h / nodeHeight);
427 } else if (resp.mwebUtxosHeight < nodeHeight) {
428 mwebSyncStatus = SyncingSyncStatus(1, 0.999);
429 } else {
430 bool confirmationsUpdated = false;
431 if (resp.mwebUtxosHeight > walletInfo.restoreHeight) {
432 await walletInfo.updateRestoreHeight(resp.mwebUtxosHeight);
433 await checkMwebUtxosSpent();
434 // update the confirmations for each transaction:
435 for (final tx in transactionHistory.transactions.values) {
436 if (tx.height == null || tx.height == 0) {
437 // update with first confirmation on next block since it hasn't been confirmed yet:
438 tx.height = resp.mwebUtxosHeight;
439 continue;
440 }
441
442 final confirmations = (resp.mwebUtxosHeight - tx.height!) + 1;
443
444 // if the confirmations haven't changed, skip updating:
445 if (tx.confirmations == confirmations) continue;
446
447 // if an outgoing tx is now confirmed, delete the utxo from the box (delete the unspent coin):
448 if (confirmations >= 2 &&
449 tx.direction == TransactionDirection.outgoing &&
450 tx.unspents != null) {
451 for (var coin in tx.unspents!) {
452 final utxo = mwebUtxosBox.get(coin.address);
453 if (utxo != null) {
454 printV("deleting utxo ${coin.address} @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@");
455 await mwebUtxosBox.delete(coin.address);
456 }
457 }
458 }
459
460 tx.confirmations = confirmations;
461 tx.isPending = false;
462 transactionHistory.addOne(tx);
463 confirmationsUpdated = true;
464 }
465 if (confirmationsUpdated) {
466 await transactionHistory.save();
467 await updateTransactions();
468 }
469 }
470
471 // prevent unnecessary reaction triggers:
472 if (mwebSyncStatus is! SyncedSyncStatus) {
473 // mwebd is synced, but we could still be processing incoming utxos:
474 if (!processingUtxos) {
475 mwebSyncStatus = SyncedSyncStatus();
476 }
477 }
478 return;
479 }
480 } catch (e) {
481 printV("error syncing: $e");
482 mwebSyncStatus = FailedSyncStatus(error: e.toString());
483 }
484 });
485 }
486
487 @action
488 @override
489 Future<void> stopSync() async {
490 printV("stopSync() called!");
491 _syncTimer?.cancel();
492 _utxoStream?.cancel();
493 _feeRatesTimer?.cancel();
494 await CwMweb.stop();
495 printV("stopped syncing!");
496 }
497
498 Future<void> initMwebUtxosBox() async {
499 final boxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
500
501 mwebUtxosBox = await CakeHive.openBox<MwebUtxo>(boxName);
502 }
503
504 static Future<void> copyMwebBox({
505 required String fromName,
506 required String toName,
507 }) async {
508 final oldBoxName = "${fromName.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
509 final newBoxName = "${toName.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
510 if (oldBoxName == newBoxName) return;
511
512 final oldBox = await CakeHive.openBox<MwebUtxo>(oldBoxName);
513 final newBox = await CakeHive.openBox<MwebUtxo>(newBoxName);
514 for (final key in oldBox.keys) {
515 await newBox.put(key, oldBox.get(key)!);
516 }
517 }
518
519 static Future<void> deleteMwebBox(String name) async {
520 final boxName = "${name.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
521 final box = await CakeHive.openBox<MwebUtxo>(boxName);
522 await box.deleteFromDisk();
523 }
524
525 @action
526 @override
527 Future<void> rescan({
528 required int height,
529 int? chainTip,
530 ScanData? scanData,
531 bool? doSingleScan,
532 bool? usingElectrs,
533 }) async {
534 _syncTimer?.cancel();
535 await walletInfo.updateRestoreHeight(height);
536
537 // go through mwebUtxos and clear any that are above the new restore height:
538 if (height == 0) {
539 await mwebUtxosBox.clear();
540 transactionHistory.clear();
541 } else {
542 for (final utxo in mwebUtxosBox.values) {
543 if (utxo.height > height) {
544 await mwebUtxosBox.delete(utxo.outputId);
545 }
546 }
547 // TODO: remove transactions that are above the new restore height!
548 }
549
550 // reset coin balances and txCount to 0:
551 unspentCoins.forEach((coin) {
552 if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
553 coin.bitcoinAddressRecord.balance = 0;
554 coin.bitcoinAddressRecord.txCount = 0;
555 });
556
557 for (var addressRecord in walletAddresses.allAddresses) {
558 addressRecord.balance = 0;
559 addressRecord.txCount = 0;
560 }
561
562 await startSync();
563 }
564
565 @override
566 Future<void> init() async {
567 await super.init();
568 await initMwebUtxosBox();
569 }
570
571 Future<void> handleIncoming(MwebUtxo utxo) async {
572 printV("handleIncoming() called!");
573 final status = await CwMweb.status(StatusRequest());
574 var date = DateTime.now();
575 var confirmations = 0;
576 if (utxo.height > 0) {
577 date = DateTime.fromMillisecondsSinceEpoch(utxo.blockTime * 1000);
578 confirmations = status.blockHeaderHeight - utxo.height + 1;
579 }
580 var tx = transactionHistory.transactions.values
581 .firstWhereOrNull((tx) => tx.outputAddresses?.contains(utxo.outputId) ?? false);
582
583 if (tx == null) {
584 tx = ElectrumTransactionInfo(
585 WalletType.litecoin,
586 id: utxo.outputId,
587 height: utxo.height,
588 amount: Money.fromInt(utxo.value, currency),
589 fee: Money.zero(currency),
590 direction: TransactionDirection.incoming,
591 isPending: utxo.height == 0,
592 date: date,
593 confirmations: confirmations,
594 inputAddresses: [],
595 outputAddresses: [utxo.outputId],
596 isReplaced: false,
597 );
598 } else {
599 if (tx.confirmations != confirmations || tx.height != utxo.height) {
600 tx.height = utxo.height;
601 tx.confirmations = confirmations;
602 tx.isPending = utxo.height == 0;
603 }
604 }
605
606 bool isNew = transactionHistory.transactions[tx.id] == null;
607
608 if (!(tx.outputAddresses?.contains(utxo.address) ?? false)) {
609 tx.outputAddresses?.add(utxo.address);
610 isNew = true;
611 }
612
613 if (isNew) {
614 final addressRecord = walletAddresses.allAddresses
615 .firstWhereOrNull((addressRecord) => addressRecord.address == utxo.address);
616 if (addressRecord == null) {
617 printV("we don't have this address in the wallet! ${utxo.address}");
618 return;
619 }
620
621 // update the txCount:
622 addressRecord.txCount++;
623 addressRecord.balance += utxo.value.toInt();
624 addressRecord.setAsUsed();
625 }
626
627 transactionHistory.addOne(tx);
628
629 if (isNew) {
630 // update the unconfirmed balance when a new tx is added:
631 // we do this after adding the tx to the history so that sub address balances are updated correctly
632 // (since that calculation is based on the tx history)
633 await updateBalance();
634 }
635 }
636
637 Future<void> processMwebUtxos() async {
638 printV("processMwebUtxos() called!");
639 if (!mwebEnabled) {
640 return;
641 }
642
643 int restoreHeight = walletInfo.restoreHeight;
644 printV("SCANNING FROM HEIGHT: $restoreHeight");
645 final req = UtxosRequest(scanSecret: scanSecret, fromHeight: restoreHeight);
646
647 // process new utxos as they come in:
648 await _utxoStream?.cancel();
649 ResponseStream<Utxo>? responseStream = await CwMweb.utxos(req);
650 if (responseStream == null) {
651 throw Exception("failed to get utxos stream!");
652 }
653 _utxoStream = responseStream.listen(
654 (Utxo sUtxo) async {
655 // we're processing utxos, so our balance could still be inaccurate:
656 if (mwebSyncStatus is! SyncronizingSyncStatus && mwebSyncStatus is! SyncingSyncStatus) {
657 mwebSyncStatus = SyncronizingSyncStatus();
658 processingUtxos = true;
659 _processingTimer?.cancel();
660 _processingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async {
661 processingUtxos = false;
662 timer.cancel();
663 });
664 }
665
666 final utxo = MwebUtxo(
667 address: sUtxo.address,
668 blockTime: sUtxo.blockTime,
669 height: sUtxo.height,
670 outputId: sUtxo.outputId,
671 value: sUtxo.value.toInt(),
672 );
673
674 if (mwebUtxosBox.containsKey(utxo.outputId)) {
675 // we've already stored this utxo, skip it:
676 // but do update the utxo height if it's somehow different:
677 final existingUtxo = mwebUtxosBox.get(utxo.outputId);
678 if (existingUtxo!.height != utxo.height) {
679 printV(
680 "updating utxo height for $utxo.outputId: ${existingUtxo.height} -> ${utxo.height}");
681 existingUtxo.height = utxo.height;
682 await mwebUtxosBox.put(utxo.outputId, existingUtxo);
683 }
684 return;
685 }
686
687 await updateUnspent();
688 await updateBalance();
689
690 final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs;
691
692 // don't process utxos with addresses that are not in the mwebAddrs list:
693 if (utxo.address.isNotEmpty && !mwebAddrs.contains(utxo.address)) {
694 return;
695 }
696
697 await mwebUtxosBox.put(utxo.outputId, utxo);
698
699 await handleIncoming(utxo);
700 },
701 onError: (error) {
702 printV("error in utxo stream: $error");
703 mwebSyncStatus = FailedSyncStatus(error: error.toString());
704 },
705 cancelOnError: true,
706 );
707 }
708
709 Future<void> deleteSpentUtxos() async {
710 printV("deleteSpentUtxos() called!");
711 final chainHeight = await electrumClient.getCurrentBlockChainTip();
712 final status = await CwMweb.status(StatusRequest());
713 if (chainHeight == null || status.blockHeaderHeight != chainHeight) return;
714 if (status.mwebUtxosHeight != chainHeight) return; // we aren't synced
715
716 // delete any spent utxos with >= 2 confirmations:
717 final spentOutputIds = mwebUtxosBox.values
718 .where((utxo) => utxo.spent && (chainHeight - utxo.height) >= 2)
719 .map((utxo) => utxo.outputId)
720 .toList();
721
722 if (spentOutputIds.isEmpty) return;
723
724 final resp = await CwMweb.spent(SpentRequest(outputId: spentOutputIds));
725 final spent = resp.outputId;
726 if (spent.isEmpty) return;
727
728 for (final outputId in spent) {
729 await mwebUtxosBox.delete(outputId);
730 }
731 }
732
733 Future<void> checkMwebUtxosSpent() async {
734 printV("checkMwebUtxosSpent() called!");
735 if (!mwebEnabled) return;
736
737 final pendingOutgoingTransactions = transactionHistory.transactions.values
738 .where((tx) => tx.direction == TransactionDirection.outgoing && tx.isPending);
739
740 // check if any of the pending outgoing transactions are now confirmed:
741 bool updatedAny = false;
742 for (final tx in pendingOutgoingTransactions) {
743 updatedAny = await isConfirmed(tx) || updatedAny;
744 }
745
746 await deleteSpentUtxos();
747
748 // get output ids of all the mweb utxos that have > 0 height:
749 final outputIds = mwebUtxosBox.values
750 .where((utxo) => utxo.height > 0 && !utxo.spent)
751 .map((utxo) => utxo.outputId)
752 .toList();
753
754 final resp = await CwMweb.spent(SpentRequest(outputId: outputIds));
755 final spent = resp.outputId;
756 if (spent.isEmpty) return;
757
758 final status = await CwMweb.status(StatusRequest());
759 final height = await electrumClient.getCurrentBlockChainTip();
760 if (height == null || status.blockHeaderHeight != height) return;
761 if (status.mwebUtxosHeight != height) return; // we aren't synced
762 var amount = 0;
763 var inputAddresses = <String>{};
764 var output = convert.AccumulatorSink<Digest>();
765 var input = sha256.startChunkedConversion(output);
766
767 for (final outputId in spent) {
768 final utxo = mwebUtxosBox.get(outputId);
769 await mwebUtxosBox.delete(outputId);
770 if (utxo == null) continue;
771 final addressRecord = walletAddresses.allAddresses
772 .firstWhere((addressRecord) => addressRecord.address == utxo.address);
773 if (!inputAddresses.contains(utxo.address)) {
774 addressRecord.txCount++;
775 }
776 addressRecord.balance -= utxo.value;
777 amount += utxo.value;
778 inputAddresses.add(utxo.address);
779 input.add(hex.decode(outputId));
780 }
781
782 if (inputAddresses.isEmpty) return;
783 input.close();
784 var digest = output.events.single;
785 final tx = ElectrumTransactionInfo(
786 WalletType.litecoin,
787 id: digest.toString(),
788 height: height,
789 amount: Money.fromInt(amount, currency),
790 fee: Money.zero(currency),
791 direction: TransactionDirection.outgoing,
792 isPending: false,
793 date: DateTime.fromMillisecondsSinceEpoch(status.blockTime * 1000),
794 confirmations: 1,
795 inputAddresses: inputAddresses.toList(),
796 outputAddresses: [],
797 isReplaced: false,
798 );
799
800 transactionHistory.addOne(tx);
801 await transactionHistory.save();
802
803 if (updatedAny) {
804 await updateBalance();
805 }
806 }
807
808 // checks if a pending transaction is now confirmed, and updates the tx info accordingly:
809 Future<bool> isConfirmed(ElectrumTransactionInfo tx) async {
810 if (!mwebEnabled) return false;
811 if (!tx.isPending) return false;
812
813 final isMwebTx = (tx.inputAddresses?.any((addr) => addr.contains("mweb")) ?? false) ||
814 (tx.outputAddresses?.any((addr) => addr.contains("mweb")) ?? false);
815
816 if (!isMwebTx) {
817 return false;
818 }
819
820 final outputId = <String>[], target = <String>{};
821 final isHash = RegExp(r'^[a-f0-9]{64}$').hasMatch;
822 final spendingOutputIds = tx.inputAddresses?.where(isHash) ?? [];
823 final payingToOutputIds = tx.outputAddresses?.where(isHash) ?? [];
824 outputId.addAll(spendingOutputIds);
825 outputId.addAll(payingToOutputIds);
826 target.addAll(spendingOutputIds);
827
828 for (final outputId in payingToOutputIds) {
829 final spendingTx = transactionHistory.transactions.values
830 .firstWhereOrNull((tx) => tx.inputAddresses?.contains(outputId) ?? false);
831 if (spendingTx != null && !spendingTx.isPending) {
832 target.add(outputId);
833 }
834 }
835
836 if (outputId.isEmpty) {
837 return false;
838 }
839
840 final resp = await CwMweb.spent(SpentRequest(outputId: outputId));
841 if (!setEquals(resp.outputId.toSet(), target)) {
842 return false;
843 }
844
845 final status = await CwMweb.status(StatusRequest());
846 tx.height = status.mwebUtxosHeight;
847 tx.confirmations = 1;
848 tx.isPending = false;
849 await transactionHistory.save();
850 return true;
851 }
852
853 Future<void> updateUnspent() async {
854 printV("updateUnspent() called!");
855 await checkMwebUtxosSpent();
856 await updateAllUnspents();
857 }
858
859 @override
860 @action
861 Future<void> updateAllUnspents() async {
862 if (!mwebEnabled) {
863 await super.updateAllUnspents();
864 return;
865 }
866
867 // add the mweb unspents to the list:
868 List<BitcoinUnspent> mwebUnspentCoins = [];
869 // update mweb unspents:
870 final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs;
871 mwebUtxosBox.keys.forEach((dynamic oId) {
872 final String outputId = oId as String;
873 final utxo = mwebUtxosBox.get(outputId);
874 if (utxo == null || utxo.spent) {
875 return;
876 }
877 if (utxo.address.isEmpty) {
878 // not sure if a bug or a special case but we definitely ignore these
879 return;
880 }
881 final addressRecord = walletAddresses.allAddresses
882 .firstWhereOrNull((addressRecord) => addressRecord.address == utxo.address);
883
884 if (addressRecord == null) {
885 printV("utxo contains an address that is not in the wallet: ${utxo.address}");
886 return;
887 }
888 final unspent = BitcoinUnspent(
889 addressRecord,
890 outputId,
891 utxo.value.toInt(),
892 mwebAddrs.indexOf(utxo.address),
893 );
894 if (unspent.vout == 0) {
895 unspent.isChange = true;
896 }
897
898 // printV("unspent: $unspent ${unspent.vout} ${utxo.value}");
899 mwebUnspentCoins.add(unspent);
900 });
901
902 // copy coin control attributes to mwebCoins:
903 await updateCoins(mwebUnspentCoins);
904 // get regular ltc unspents (this resets unspentCoins):
905 await super.updateAllUnspents();
906 // add the mwebCoins:
907 unspentCoins.addAll(mwebUnspentCoins);
908 }
909
910 @override
911 Future<ElectrumBalance> fetchBalances() async {
912 final balance = await super.fetchBalances();
913 if (!mwebEnabled) {
914 return balance;
915 }
916
917 // update unspent balances:
918 await updateUnspent();
919
920 var confirmedMweb = 0;
921 var unconfirmedMweb = 0;
922 try {
923 mwebUtxosBox.values.forEach((utxo) {
924 bool isConfirmed = utxo.height > 0;
925
926 printV(
927 "utxo: ${isConfirmed ? "confirmed" : "unconfirmed"} ${utxo.spent ? "spent" : "unspent"} ${utxo.outputId} ${utxo.height} ${utxo.value}");
928
929 if (isConfirmed) {
930 confirmedMweb += utxo.value;
931 }
932
933 if (isConfirmed && utxo.spent) {
934 unconfirmedMweb -= utxo.value;
935 }
936
937 if (!isConfirmed && !utxo.spent) {
938 unconfirmedMweb += utxo.value;
939 }
940 });
941 } catch (_) {}
942
943 for (final addressRecord in walletAddresses.allAddresses) {
944 addressRecord.balance = 0;
945 addressRecord.txCount = 0;
946 }
947
948 unspentCoins.forEach((coin) {
949 final coinInfoList = unspentCoinsInfo.values.where(
950 (element) =>
951 element.walletId.contains(id) &&
952 element.hash.contains(coin.hash) &&
953 element.vout == coin.vout,
954 );
955
956 if (coinInfoList.isNotEmpty) {
957 final coinInfo = coinInfoList.first;
958
959 coin.isFrozen = coinInfo.isFrozen;
960 coin.isSending = coinInfo.isSending;
961 coin.note = coinInfo.note;
962 if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
963 coin.bitcoinAddressRecord.balance += coinInfo.value;
964 } else {
965 super.addCoinInfo(coin);
966 }
967 });
968
969 // update the txCount for each address using the tx history, since we can't rely on mwebd
970 // to have an accurate count, we should just keep it in sync with what we know from the tx history:
971 for (final tx in transactionHistory.transactions.values) {
972 if (tx.inputAddresses == null || tx.outputAddresses == null) {
973 continue;
974 }
975 final txAddresses = tx.inputAddresses! + tx.outputAddresses!;
976 for (final address in txAddresses) {
977 final addressRecord = walletAddresses.allAddresses
978 .firstWhereOrNull((addressRecord) => addressRecord.address == address);
979 if (addressRecord == null) {
980 continue;
981 }
982 addressRecord.txCount++;
983 }
984 }
985
986 return ElectrumBalance(
987 confirmed: balance.confirmed,
988 unconfirmed: balance.unconfirmed,
989 frozen: balance.frozen,
990 secondConfirmed: Money.fromInt(confirmedMweb, currency),
991 secondUnconfirmed: Money.fromInt(unconfirmedMweb, currency),
992 );
993 }
994
995 @override
996 int feeRate(TransactionPriority priority) {
997 if (priority is LitecoinTransactionPriority) {
998 switch (priority) {
999 case LitecoinTransactionPriority.slow:
1000 return 1;
1001 case LitecoinTransactionPriority.medium:
1002 return 2;
1003 case LitecoinTransactionPriority.fast:
1004 return 3;
1005 }
1006 }
1007
1008 return 0;
1009 }
1010
1011 @override
1012 Future<int> calcFee({
1013 required List<UtxoWithAddress> utxos,
1014 required List<BitcoinBaseOutput> outputs,
1015 required BasedUtxoNetwork network,
1016 String? memo,
1017 required int feeRate,
1018 List<ECPrivateInfo>? inputPrivKeyInfos,
1019 List<Outpoint>? vinOutpoints,
1020 }) async {
1021 bool spendsMweb = utxos.any((utxo) => utxo.utxo.scriptType == SegwitAddresType.mweb);
1022 bool paysToMweb = outputs
1023 .any((output) => output.toOutput.scriptPubKey.getAddressType() == SegwitAddresType.mweb);
1024
1025 bool isRegular = !spendsMweb && !paysToMweb;
1026 bool isMweb = spendsMweb || paysToMweb;
1027
1028 if (isMweb && !mwebEnabled) {
1029 throw Exception("MWEB is not enabled! can't calculate fee without starting the mweb server!");
1030 // TODO: likely the change address is mweb and just not updated
1031 }
1032
1033 if (isRegular) {
1034 return await super.calcFee(
1035 utxos: utxos,
1036 outputs: outputs,
1037 network: network,
1038 memo: memo,
1039 feeRate: feeRate,
1040 inputPrivKeyInfos: inputPrivKeyInfos,
1041 vinOutpoints: vinOutpoints,
1042 );
1043 }
1044
1045 if (outputs.length == 1 && outputs[0].toOutput.amount == BigInt.zero) {
1046 outputs = [
1047 BitcoinScriptOutput(
1048 script: outputs[0].toOutput.scriptPubKey, value: utxos.sumOfUtxosValue())
1049 ];
1050 }
1051
1052 // https://github.com/ltcmweb/mwebd?tab=readme-ov-file#fee-estimation
1053 final preOutputSum =
1054 outputs.fold<BigInt>(BigInt.zero, (acc, output) => acc + output.toOutput.amount);
1055 var fee = utxos.sumOfUtxosValue() - preOutputSum;
1056
1057 // determines if the fee is correct:
1058 BigInt _sumOutputAmounts(List<TxOutput> outputs) {
1059 BigInt sum = BigInt.zero;
1060 for (final e in outputs) {
1061 sum += e.amount;
1062 }
1063 return sum;
1064 }
1065
1066 final sum1 = _sumOutputAmounts(outputs.map((e) => e.toOutput).toList()) + fee;
1067 final sum2 = utxos.sumOfUtxosValue();
1068 if (sum1 != sum2) {
1069 printV("@@@@@ WE HAD TO ADJUST THE FEE! @@@@@@@@");
1070 final diff = sum2 - sum1;
1071 // add the difference to the fee (abs value):
1072 fee += diff.abs();
1073 }
1074
1075 final txb =
1076 BitcoinTransactionBuilder(utxos: utxos, outputs: outputs, fee: fee, network: network);
1077 final resp = await CwMweb.create(CreateRequest(
1078 rawTx: txb.buildTransaction((a, b, c, d) => '').toBytes(),
1079 scanSecret: scanSecret,
1080 spendSecret: spendSecret,
1081 feeRatePerKb: Int64(feeRate * 1000),
1082 dryRun: true));
1083 final tx = BtcTransaction.fromRaw(hex.encode(resp.rawTx));
1084 final posUtxos = utxos
1085 .where((utxo) => tx.inputs
1086 .any((input) => input.txId == utxo.utxo.txHash && input.txIndex == utxo.utxo.vout))
1087 .toList();
1088 final posOutputSum = tx.outputs.fold<int>(0, (acc, output) => acc + output.amount.toInt());
1089 final mwebInputSum = utxos.sumOfUtxosValue() - posUtxos.sumOfUtxosValue();
1090 final expectedPegin = max(0, (preOutputSum - mwebInputSum).toInt());
1091 var feeIncrease = posOutputSum - expectedPegin;
1092 if (expectedPegin > 0 && fee == BigInt.zero) {
1093 feeIncrease += await super.calcFee(
1094 utxos: posUtxos,
1095 outputs: tx.outputs
1096 .map((output) =>
1097 BitcoinScriptOutput(script: output.scriptPubKey, value: output.amount))
1098 .toList(),
1099 network: network,
1100 memo: memo,
1101 feeRate: feeRate) +
1102 feeRate * 41;
1103 }
1104 return fee.toInt() + feeIncrease;
1105 }
1106
1107 Future<Uint8List> buildPsbt(PendingBitcoinTransaction transaction, bool isMweb) async {
1108 final List<TxInput> inputs = [];
1109 final List<TxOut> txouts = [];
1110 for (final utxo in transaction.utxos) {
1111 if (utxo.utxo.scriptType != SegwitAddresType.mweb) {
1112 inputs.add(utxo.utxo.toInput());
1113 txouts.add(TxOut(
1114 value: Int64(utxo.utxo.value.toInt()),
1115 pkScript: utxo.ownerDetails.address.toScriptPubKey().toBytes()));
1116 }
1117 }
1118 var resp = await CwMweb.psbtCreate(PsbtCreateRequest(
1119 rawTx: inputs.isEmpty
1120 ? null
1121 : BtcTransaction(
1122 inputs: inputs,
1123 outputs: isMweb ? [] : transaction.outputs,
1124 ).toBytes(),
1125 witnessUtxo: txouts,
1126 ));
1127 for (final utxo in transaction.utxos) {
1128 if (utxo.utxo.scriptType == SegwitAddresType.mweb) {
1129 resp = await CwMweb.psbtAddInput(PsbtAddInputRequest(
1130 psbtB64: resp.psbtB64,
1131 scanSecret: scanSecret,
1132 outputId: utxo.utxo.txHash,
1133 addressIndex: utxo.utxo.vout,
1134 ));
1135 }
1136 }
1137 if (isMweb)
1138 for (final output in transaction.outputs) {
1139 var address = addressFromOutputScript(output.scriptPubKey, LitecoinNetwork.mainnet);
1140 if (output.scriptPubKey.getAddressType() == SegwitAddresType.mweb) {
1141 address = SegwitBech32Encoder.encode("ltcmweb", 0, output.scriptPubKey.toBytes());
1142 }
1143 resp = await CwMweb.psbtAddRecipient(PsbtAddRecipientRequest(
1144 psbtB64: resp.psbtB64,
1145 recipient: PsbtRecipient(address: address, value: Int64(output.amount.toInt())),
1146 feeRatePerKb: Int64.parseInt(transaction.feeRate) * 1000,
1147 ));
1148 }
1149 return base64.decode(resp.psbtB64);
1150 }
1151
1152 @override
1153 Future<PendingTransaction> createTransaction(Object credentials) async {
1154 try {
1155 var creds;
1156 if (!mwebEnabled) {
1157 BitcoinTransactionCredentials btcCreds = (credentials as BitcoinTransactionCredentials);
1158 // sets unspent coin type to nonMweb:
1159 creds = BitcoinTransactionCredentials(
1160 btcCreds.outputs,
1161 priority: btcCreds.priority,
1162 feeRate: btcCreds.feeRate,
1163 coinTypeToSpendFrom: UnspentCoinType.nonMweb,
1164 );
1165 } else {
1166 creds = credentials;
1167 }
1168 var tx = await super.createTransaction(creds as Object) as PendingBitcoinTransaction;
1169 tx.isMweb = mwebEnabled;
1170
1171 if (!mwebEnabled) {
1172 tx.changeAddressOverride = (await (walletAddresses as LitecoinWalletAddresses)
1173 .getChangeAddress(coinTypeToSpendFrom: UnspentCoinType.nonMweb))
1174 .address;
1175
1176 if (tx.shouldCommitUR()) {
1177 tx.unsignedPsbt = await buildPsbt(tx, false);
1178 }
1179 return tx;
1180 }
1181 await waitForMwebAddresses();
1182
1183 // check if the transaction doesn't contain any mweb inputs or outputs:
1184 final transactionCredentials = credentials as BitcoinTransactionCredentials;
1185
1186 bool hasMwebInput = false;
1187 bool hasMwebOutput = false;
1188 bool hasRegularOutput = false;
1189
1190 for (final output in transactionCredentials.outputs) {
1191 final address = output.address.toLowerCase();
1192 final extractedAddress = output.extractedAddress?.toLowerCase();
1193
1194 if (address.startsWith("ltcmweb")) {
1195 hasMwebOutput = true;
1196 }
1197 if (!address.startsWith("ltcmweb")) {
1198 hasRegularOutput = true;
1199 }
1200 if (extractedAddress != null && extractedAddress.isNotEmpty) {
1201 if (extractedAddress.startsWith("ltcmweb")) {
1202 hasMwebOutput = true;
1203 }
1204 if (!extractedAddress.startsWith("ltcmweb")) {
1205 hasRegularOutput = true;
1206 }
1207 }
1208 }
1209
1210 // check if mweb inputs are used:
1211 for (final utxo in tx.utxos) {
1212 if (utxo.utxo.scriptType == SegwitAddresType.mweb) {
1213 hasMwebInput = true;
1214 } else {
1215 // check if any of the inputs of this transaction are hog-ex:
1216 // this list is only non-mweb inputs:
1217 final coin = unspentCoins
1218 .firstWhere((coin) => coin.hash == utxo.utxo.txHash && coin.vout == utxo.utxo.vout);
1219 if (coin.isPegOut != true) continue;
1220
1221 int confirmations = coin.confirmations ?? 0;
1222 if (confirmations < 6) {
1223 throw Exception(
1224 "A transaction input is an MWEB peg-out and has less than 6 confirmations, please try again later.");
1225 }
1226 }
1227 }
1228
1229 // could probably be simplified but left for clarity:
1230 bool isPegIn = !hasMwebInput && hasMwebOutput;
1231 bool isPegOut = hasMwebInput && hasRegularOutput;
1232 bool isRegular = !hasMwebInput && !hasMwebOutput;
1233 bool shouldNotUseMwebChange = isPegIn || isRegular || !hasMwebInput;
1234 tx.changeAddressOverride = (await (walletAddresses as LitecoinWalletAddresses)
1235 .getChangeAddress(
1236 coinTypeToSpendFrom:
1237 shouldNotUseMwebChange ? UnspentCoinType.nonMweb : UnspentCoinType.any))
1238 .address;
1239 if (isRegular) {
1240 tx.isMweb = false;
1241 if (tx.shouldCommitUR()) {
1242 tx.unsignedPsbt = await buildPsbt(tx, false);
1243 }
1244 return tx;
1245 }
1246
1247 if (tx.shouldCommitUR()) {
1248 tx.unsignedPsbt = await buildPsbt(tx, true);
1249 return tx;
1250 }
1251
1252 final resp = await CwMweb.create(CreateRequest(
1253 rawTx: hex.decode(tx.hex),
1254 scanSecret: scanSecret,
1255 spendSecret: spendSecret,
1256 feeRatePerKb: Int64.parseInt(tx.feeRate) * 1000,
1257 ));
1258 final tx2 = BtcTransaction.fromRaw(hex.encode(resp.rawTx));
1259
1260 tx.hexOverride = tx2
1261 .copyWith(
1262 witnesses: tx2.inputs.asMap().entries.map((e) {
1263 final utxo = unspentCoins
1264 .firstWhere((utxo) => utxo.hash == e.value.txId && utxo.vout == e.value.txIndex);
1265 final key = generateECPrivate(
1266 hd: utxo.bitcoinAddressRecord.isHidden ? sideHd : mainHd,
1267 index: utxo.bitcoinAddressRecord.index,
1268 network: network);
1269 final digest = tx2.getTransactionSegwitDigit(
1270 txInIndex: e.key,
1271 script: key.getPublic().toP2pkhAddress().toScriptPubKey(),
1272 amount: BigInt.from(utxo.value),
1273 );
1274 return TxWitnessInput(stack: [key.signInput(digest), key.getPublic().toHex()]);
1275 }).toList())
1276 .toHex();
1277 tx.outputAddresses = resp.outputId;
1278
1279 addTransactionListener(tx, [], isPegIn, isPegOut);
1280 return tx;
1281 } catch (e, s) {
1282 printV(e);
1283 printV(s);
1284 if (e.toString().contains("commit failed")) {
1285 printV(e);
1286 throw Exception("Transaction commit failed (no peers responded), please try again.");
1287 }
1288 rethrow;
1289 }
1290 }
1291
1292 void addTransactionListener(
1293 PendingBitcoinTransaction tx, List<String> inputAddresses, bool isPegIn, bool isPegOut) {
1294 tx.addListener((transaction) async {
1295 final addresses = <String>{};
1296 transaction.inputAddresses?.addAll(inputAddresses);
1297 transaction.inputAddresses?.forEach((id) async {
1298 final utxo = mwebUtxosBox.get(id);
1299 // await mwebUtxosBox.delete(id); // gets deleted in checkMwebUtxosSpent
1300 if (utxo == null) return;
1301 // mark utxo as spent so we add it to the unconfirmed balance (as negative):
1302 utxo.spent = true;
1303 await mwebUtxosBox.put(id, utxo);
1304 final addressRecord = walletAddresses.allAddresses
1305 .firstWhere((addressRecord) => addressRecord.address == utxo.address);
1306 if (!addresses.contains(utxo.address)) {
1307 addresses.add(utxo.address);
1308 }
1309 addressRecord.balance -= utxo.value.toInt();
1310 });
1311 transaction.inputAddresses?.addAll(addresses);
1312 printV("isPegIn: $isPegIn, isPegOut: $isPegOut");
1313 transaction.additionalInfo["isPegIn"] = isPegIn;
1314 transaction.additionalInfo["isPegOut"] = isPegOut;
1315 transactionHistory.addOne(transaction);
1316 await updateUnspent();
1317 await updateBalance();
1318 });
1319 }
1320
1321 Future<void> commitPsbtUR(List<String> urCodes) async {
1322 if (urCodes.isEmpty) throw Exception("No QR code got scanned");
1323 bool isUr = urCodes.any((str) {
1324 return str.startsWith("ur:psbt/");
1325 });
1326 if (!isUr) return;
1327
1328 final ur = URDecoder();
1329 for (final inp in urCodes) {
1330 ur.receivePart(inp);
1331 }
1332 final result = ur.result as UR;
1333 final psbtB64 = base64Encode(CBORDecoder(result.cbor).decodeBytes().$1);
1334
1335 final resp = await CwMweb.psbtGetRecipients(PsbtGetRecipientsRequest(psbtB64: psbtB64));
1336
1337 bool hasMwebInput = false;
1338 bool hasMwebOutput = false;
1339 bool hasRegularOutput = false;
1340
1341 for (final recipient in resp.recipient) {
1342 if (recipient.address.contains("mweb")) {
1343 hasMwebOutput = true;
1344 } else {
1345 hasRegularOutput = true;
1346 }
1347 }
1348
1349 for (final address in resp.inputAddress) {
1350 try {
1351 LitecoinAddress(address);
1352 } catch (_) {
1353 hasMwebInput = true;
1354 }
1355 }
1356
1357 bool isPegIn = !hasMwebInput && hasMwebOutput;
1358 bool isPegOut = hasMwebInput && hasRegularOutput;
1359 bool isRegular = !hasMwebInput && !hasMwebOutput;
1360
1361 final resp2 = await CwMweb.psbtExtract(PsbtExtractRequest(psbtB64: psbtB64));
1362
1363 final btcTx = BtcTransaction.fromRaw(hex.encode(resp2.rawTx));
1364
1365 final tx = PendingBitcoinTransaction(
1366 btcTx,
1367 type,
1368 electrumClient: electrumClient,
1369 amount: Money.zero(currency),
1370 fee: Money.fromInt(resp.fee.toInt(), currency),
1371 feeRate: "",
1372 network: network,
1373 hasChange: resp.recipient.length > 1,
1374 isMweb: !isRegular,
1375 isViewOnly: false,
1376 );
1377 tx.outputAddresses = resp2.outputId;
1378 addTransactionListener(tx, resp.inputAddress, isPegIn, isPegOut);
1379
1380 try {
1381 await tx.commit();
1382 } catch (e, s) {
1383 printV(e);
1384 printV(s);
1385 if (e.toString().contains("commit failed")) {
1386 printV(e);
1387 throw Exception("Transaction commit failed (no peers responded), please try again.");
1388 }
1389 rethrow;
1390 }
1391 }
1392
1393 @override
1394 Future<void> save() async {
1395 await super.save();
1396 }
1397
1398 @override
1399 Future<void> close({bool shouldCleanup = false}) async {
1400 _utxoStream?.cancel();
1401 _feeRatesTimer?.cancel();
1402 _syncTimer?.cancel();
1403 _processingTimer?.cancel();
1404 if (shouldCleanup) {
1405 try {
1406 await stopSync();
1407 } catch (_) {}
1408 }
1409 await super.close(shouldCleanup: shouldCleanup);
1410 }
1411
1412 Future<void> setMwebEnabled(bool enabled) async {
1413 if (mwebEnabled == enabled &&
1414 alwaysScan == enabled &&
1415 (walletAddresses as LitecoinWalletAddresses).mwebEnabled == enabled) {
1416 return;
1417 }
1418
1419 alwaysScan = enabled;
1420 mwebEnabled = enabled;
1421 (walletAddresses as LitecoinWalletAddresses).mwebEnabled = enabled;
1422 await save();
1423 try {
1424 await stopSync();
1425 } catch (_) {}
1426 await startSync();
1427 }
1428
1429 Future<StatusResponse> getStatusRequest() async {
1430 final resp = await CwMweb.status(StatusRequest());
1431 return resp;
1432 }
1433
1434 @override
1435 Future<String> signMessage(String message, {String? address = null}) async {
1436 final index = address != null
1437 ? walletAddresses.allAddresses.firstWhere((element) => element.address == address).index
1438 : null;
1439 final HD = index == null ? mainHd : mainHd.childKey(Bip32KeyIndex(index));
1440 final priv = ECPrivate.fromHex(HD.privateKey.privKey.toHex());
1441
1442 final privateKey = ECDSAPrivateKey.fromBytes(
1443 priv.toBytes(),
1444 Curves.generatorSecp256k1,
1445 );
1446
1447 final signature =
1448 signLitecoinMessage(utf8.encode(message), privateKey: privateKey, bipPrive: priv.prive);
1449
1450 return base64Encode(signature);
1451 }
1452
1453 List<int> _magicPrefix(List<int> message, List<int> messagePrefix) {
1454 final encodeLength = IntUtils.encodeVarint(message.length);
1455
1456 return [...messagePrefix, ...encodeLength, ...message];
1457 }
1458
1459 List<int> signLitecoinMessage(List<int> message,
1460 {required ECDSAPrivateKey privateKey, required Bip32PrivateKey bipPrive}) {
1461 String messagePrefix = '\x19Litecoin Signed Message:\n';
1462 final messageHash = QuickCrypto.sha256Hash(magicMessage(message, messagePrefix));
1463 final signingKey = EcdsaSigningKey(privateKey);
1464 ECDSASignature ecdsaSign =
1465 signingKey.signDigestDeterminstic(digest: messageHash, hashFunc: () => SHA256());
1466 final n = Curves.generatorSecp256k1.order! >> 1;
1467 BigInt newS;
1468 if (ecdsaSign.s.compareTo(n) > 0) {
1469 newS = Curves.generatorSecp256k1.order! - ecdsaSign.s;
1470 } else {
1471 newS = ecdsaSign.s;
1472 }
1473 final rawSig = ECDSASignature(ecdsaSign.r, newS);
1474 final rawSigBytes = rawSig.toBytes(BitcoinSignerUtils.baselen);
1475
1476 final pub = bipPrive.publicKey;
1477 final ECDomainParameters curve = ECCurve_secp256k1();
1478 final point = curve.curve.decodePoint(pub.point.toBytes());
1479
1480 final rawSigEc = ECSignature(rawSig.r, rawSig.s);
1481
1482 final recId = SignUtils.findRecoveryId(
1483 SignUtils.getHexString(messageHash, offset: 0, length: messageHash.length),
1484 rawSigEc,
1485 Uint8List.fromList(pub.uncompressed),
1486 );
1487
1488 final v = recId + 27 + (point!.isCompressed ? 4 : 0);
1489
1490 final combined = Uint8List.fromList([v, ...rawSigBytes]);
1491
1492 return combined;
1493 }
1494
1495 List<int> magicMessage(List<int> message, String messagePrefix) {
1496 final prefixBytes = StringUtils.encode(messagePrefix);
1497 final magic = _magicPrefix(message, prefixBytes);
1498 return QuickCrypto.sha256Hash(magic);
1499 }
1500
1501 @override
1502 Future<bool> verifyMessage(String message, String signature, {String? address = null}) async {
1503 if (address == null) {
1504 return false;
1505 }
1506
1507 List<int> sigDecodedBytes = [];
1508
1509 if (signature.endsWith('=')) {
1510 sigDecodedBytes = base64.decode(signature);
1511 } else {
1512 sigDecodedBytes = hex.decode(signature);
1513 }
1514
1515 if (sigDecodedBytes.length != 64 && sigDecodedBytes.length != 65) {
1516 throw ArgumentException(
1517 "litecoin signature must be 64 bytes without recover-id or 65 bytes with recover-id");
1518 }
1519
1520 String messagePrefix = '\x19Litecoin Signed Message:\n';
1521 final messageHash = QuickCrypto.sha256Hash(magicMessage(utf8.encode(message), messagePrefix));
1522
1523 List<int> correctSignature =
1524 sigDecodedBytes.length == 65 ? sigDecodedBytes.sublist(1) : List.from(sigDecodedBytes);
1525 List<int> rBytes = correctSignature.sublist(0, 32);
1526 List<int> sBytes = correctSignature.sublist(32);
1527 final sig = ECDSASignature(BigintUtils.fromBytes(rBytes), BigintUtils.fromBytes(sBytes));
1528
1529 List<int> possibleRecoverIds = [0, 1];
1530
1531 final baseAddress = RegexUtils.addressTypeFromStr(address, network);
1532
1533 for (int recoveryId in possibleRecoverIds) {
1534 final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId);
1535 final recoveredPub = ECPublic.fromBytes(pubKey!.toBytes());
1536
1537 String? recoveredAddress;
1538
1539 if (baseAddress is P2pkAddress) {
1540 recoveredAddress = recoveredPub.toP2pkAddress().toAddress(network);
1541 } else if (baseAddress is P2pkhAddress) {
1542 recoveredAddress = recoveredPub.toP2pkhAddress().toAddress(network);
1543 } else if (baseAddress is P2wshAddress) {
1544 recoveredAddress = recoveredPub.toP2wshAddress().toAddress(network);
1545 } else if (baseAddress is P2wpkhAddress) {
1546 recoveredAddress = recoveredPub.toP2wpkhAddress().toAddress(network);
1547 }
1548
1549 if (recoveredAddress == address) {
1550 return true;
1551 }
1552 }
1553
1554 return false;
1555 }
1556
1557 @override
1558 Future<BtcTransaction> buildHardwareWalletTransaction({
1559 required List<BitcoinBaseOutput> outputs,
1560 required BigInt fee,
1561 required BasedUtxoNetwork network,
1562 required List<UtxoWithAddress> utxos,
1563 required List<OutputInfo> cwOutputs,
1564 required Map<String, PublicKeyWithDerivationPath> publicKeys,
1565 String? memo,
1566 bool enableRBF = false,
1567 BitcoinOrdering inputOrdering = BitcoinOrdering.bip69,
1568 BitcoinOrdering outputOrdering = BitcoinOrdering.bip69,
1569 }) async {
1570 final masterFingerprint =
1571 await (hardwareWalletService as BitcoinHardwareWalletService).getMasterFingerprint();
1572
1573 final readyInputs = <PSBTReadyUtxoWithAddress>[];
1574 for (final utxo in utxos) {
1575 final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash);
1576 final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!;
1577
1578 readyInputs.add(PSBTReadyUtxoWithAddress(
1579 utxo: utxo.utxo,
1580 rawTx: rawTx,
1581 ownerDetails: utxo.ownerDetails,
1582 ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
1583 ownerMasterFingerprint: masterFingerprint,
1584 ownerPublicKey: publicKeyAndDerivationPath.publicKey,
1585 ));
1586 }
1587
1588 final orderedOutputs = orderOutputs(outputs, outputOrdering);
1589
1590 final rawHex = await (hardwareWalletService as LitecoinHardwareWalletService)
1591 .signLitecoinTransaction(
1592 outputs: orderedOutputs, inputs: readyInputs, publicKeys: publicKeys);
1593
1594 return BtcTransaction.fromRaw(rawHex);
1595 }
1596 }