1
+import 'dart:async';
2
import 'dart:convert';
2
-
3
+import 'package:convert/convert.dart' as convert;
4
+import 'dart:math';
5
+import 'package:collection/collection.dart';
6
+import 'package:crypto/crypto.dart';
7
+import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
8
+import 'package:cw_core/cake_hive.dart';
9
+import 'package:cw_core/mweb_utxo.dart';
10
+import 'package:cw_mweb/mwebd.pbgrpc.dart';
11
+import 'package:fixnum/fixnum.dart';
12
+import 'package:bip39/bip39.dart' as bip39;
13
import 'package:bitcoin_base/bitcoin_base.dart';
14
import 'package:blockchain_utils/blockchain_utils.dart';
15
import 'package:blockchain_utils/signer/ecdsa_signing_key.dart';
6
-import 'package:bip39/bip39.dart' as bip39;
16
import 'package:cw_bitcoin/bitcoin_address_record.dart';
17
import 'package:cw_bitcoin/bitcoin_mnemonic.dart';
18
import 'package:cw_bitcoin/bitcoin_transaction_priority.dart';
19
+import 'package:cw_bitcoin/bitcoin_unspent.dart';
20
+import 'package:cw_bitcoin/electrum_transaction_info.dart';
21
+import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
22
+import 'package:cw_bitcoin/utils.dart';
23
import 'package:cw_bitcoin/electrum_derivations.dart';
24
import 'package:cw_core/encryption_file_utils.dart';
25
import 'package:cw_core/crypto_currency.dart';
26
+import 'package:cw_core/pending_transaction.dart';
27
+import 'package:cw_core/sync_status.dart';
28
+import 'package:cw_core/transaction_direction.dart';
29
import 'package:cw_core/unspent_coins_info.dart';
30
import 'package:cw_bitcoin/electrum_balance.dart';
31
import 'package:cw_bitcoin/electrum_wallet.dart';
35
import 'package:cw_core/wallet_info.dart';
36
import 'package:cw_core/wallet_keys_file.dart';
37
import 'package:flutter/foundation.dart';
38
+import 'package:grpc/grpc.dart';
39
import 'package:hive/hive.dart';
40
import 'package:mobx/mobx.dart';
41
+import 'package:cw_core/wallet_type.dart';
42
+import 'package:cw_mweb/cw_mweb.dart';
43
import 'package:bitcoin_base/src/crypto/keypair/sign_utils.dart';
44
import 'package:pointycastle/ecc/api.dart';
45
import 'package:pointycastle/ecc/curves/secp256k1.dart';
59
String? passphrase,
60
String? addressPageType,
61
List<BitcoinAddressRecord>? initialAddresses,
62
+ List<BitcoinAddressRecord>? initialMwebAddresses,
63
ElectrumBalance? initialBalance,
64
Map<String, int>? initialRegularAddressIndex,
65
Map<String, int>? initialChangeAddressIndex,
66
+ int? initialMwebHeight,
67
+ bool? alwaysScan,
68
}) : super(
47
- mnemonic: mnemonic,
48
- password: password,
49
- walletInfo: walletInfo,
50
- unspentCoinsInfo: unspentCoinsInfo,
51
- network: LitecoinNetwork.mainnet,
52
- initialAddresses: initialAddresses,
53
- initialBalance: initialBalance,
54
- seedBytes: seedBytes,
55
- encryptionFileUtils: encryptionFileUtils,
56
- passphrase: passphrase,
57
- currency: CryptoCurrency.ltc) {
69
+ mnemonic: mnemonic,
70
+ password: password,
71
+ walletInfo: walletInfo,
72
+ unspentCoinsInfo: unspentCoinsInfo,
73
+ network: LitecoinNetwork.mainnet,
74
+ initialAddresses: initialAddresses,
75
+ initialBalance: initialBalance,
76
+ seedBytes: seedBytes,
77
+ encryptionFileUtils: encryptionFileUtils,
78
+ currency: CryptoCurrency.ltc,
79
+ ) {
80
+ mwebHd = Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/1000'") as Bip32Slip10Secp256k1;
81
+ mwebEnabled = alwaysScan ?? false;
82
walletAddresses = LitecoinWalletAddresses(
83
walletInfo,
84
initialAddresses: initialAddresses,
85
initialRegularAddressIndex: initialRegularAddressIndex,
86
initialChangeAddressIndex: initialChangeAddressIndex,
87
+ initialMwebAddresses: initialMwebAddresses,
88
mainHd: hd,
89
sideHd: accountHD.childKey(Bip32KeyIndex(1)),
90
network: network,
91
+ mwebHd: mwebHd,
92
+ mwebEnabled: mwebEnabled,
93
);
94
autorun((_) {
95
this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress;
96
});
97
}
98
+ late final Bip32Slip10Secp256k1 mwebHd;
99
+ late final Box<MwebUtxo> mwebUtxosBox;
100
+ Timer? _syncTimer;
101
+ Timer? _feeRatesTimer;
102
+ Timer? _processingTimer;
103
+ StreamSubscription<Utxo>? _utxoStream;
104
+ late RpcClient _stub;
105
+ late bool mwebEnabled;
106
+ bool processingUtxos = false;
107
+
108
+ List<int> get scanSecret => mwebHd.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw;
109
+ List<int> get spendSecret => mwebHd.childKey(Bip32KeyIndex(0x80000001)).privateKey.privKey.raw;
110
111
static Future<LitecoinWallet> create(
112
{required String mnemonic,
117
String? passphrase,
118
String? addressPageType,
119
List<BitcoinAddressRecord>? initialAddresses,
120
+ List<BitcoinAddressRecord>? initialMwebAddresses,
121
ElectrumBalance? initialBalance,
122
Map<String, int>? initialRegularAddressIndex,
123
Map<String, int>? initialChangeAddressIndex}) async {
141
walletInfo: walletInfo,
142
unspentCoinsInfo: unspentCoinsInfo,
143
initialAddresses: initialAddresses,
144
+ initialMwebAddresses: initialMwebAddresses,
145
initialBalance: initialBalance,
146
encryptionFileUtils: encryptionFileUtils,
147
passphrase: passphrase,
152
);
153
}
154
114
- static Future<LitecoinWallet> open(
115
- {required String name,
116
- required WalletInfo walletInfo,
117
- required Box<UnspentCoinsInfo> unspentCoinsInfo,
118
- required String password,
119
- required EncryptionFileUtils encryptionFileUtils}) async {
155
+ static Future<LitecoinWallet> open({
156
+ required String name,
157
+ required WalletInfo walletInfo,
158
+ required Box<UnspentCoinsInfo> unspentCoinsInfo,
159
+ required String password,
160
+ required bool alwaysScan,
161
+ required EncryptionFileUtils encryptionFileUtils,
162
+ }) async {
163
final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type);
164
165
ElectrumWalletSnapshot? snp = null;
221
walletInfo: walletInfo,
222
unspentCoinsInfo: unspentCoinsInfo,
223
initialAddresses: snp?.addresses,
224
+ initialMwebAddresses: snp?.mwebAddresses,
225
initialBalance: snp?.balance,
226
seedBytes: seedBytes!,
227
passphrase: passphrase,
229
initialRegularAddressIndex: snp?.regularAddressIndex,
230
initialChangeAddressIndex: snp?.changeAddressIndex,
231
addressPageType: snp?.addressPageType,
232
+ alwaysScan: alwaysScan,
233
+ );
234
+ }
235
+
236
+ Future<void> waitForMwebAddresses() async {
237
+ // ensure that we have the full 1000 mweb addresses generated before continuing:
238
+ // should no longer be needed, but leaving here just in case
239
+ final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs;
240
+ while (mwebAddrs.length < 1000) {
241
+ print("waiting for mweb addresses to finish generating...");
242
+ await Future.delayed(const Duration(milliseconds: 1000));
243
+ }
244
+ }
245
+
246
+ @action
247
+ @override
248
+ Future<void> startSync() async {
249
+ if (syncStatus is SyncronizingSyncStatus) {
250
+ return;
251
+ }
252
+ print("STARTING SYNC - MWEB ENABLED: $mwebEnabled");
253
+ try {
254
+ syncStatus = SyncronizingSyncStatus();
255
+ await subscribeForUpdates();
256
+ updateFeeRates();
257
+
258
+ _feeRatesTimer?.cancel();
259
+ _feeRatesTimer =
260
+ Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates());
261
+
262
+ if (!mwebEnabled) {
263
+ try {
264
+ await updateAllUnspents();
265
+ await updateTransactions();
266
+ await updateBalance();
267
+ syncStatus = SyncedSyncStatus();
268
+ } catch (e, s) {
269
+ print(e);
270
+ print(s);
271
+ syncStatus = FailedSyncStatus();
272
+ }
273
+ return;
274
+ }
275
+
276
+ await waitForMwebAddresses();
277
+ await getStub();
278
+ await processMwebUtxos();
279
+ await updateTransactions();
280
+ await updateUnspent();
281
+ await updateBalance();
282
+ } catch (e) {
283
+ print("failed to start mweb sync: $e");
284
+ syncStatus = FailedSyncStatus();
285
+ return;
286
+ }
287
+
288
+ _syncTimer?.cancel();
289
+ _syncTimer = Timer.periodic(const Duration(milliseconds: 1500), (timer) async {
290
+ if (syncStatus is FailedSyncStatus) return;
291
+
292
+ final nodeHeight =
293
+ await electrumClient.getCurrentBlockChainTip() ?? 0; // current block height of our node
294
+ final resp = await CwMweb.status(StatusRequest());
295
+ print("resp.mwebUtxosHeight: ${resp.mwebUtxosHeight}");
296
+ print("resp.mwebHeaderHeight: ${resp.mwebHeaderHeight}");
297
+ print("resp.blockHeaderHeight: ${resp.blockHeaderHeight}");
298
+
299
+ if (resp.blockHeaderHeight < nodeHeight) {
300
+ int h = resp.blockHeaderHeight;
301
+ syncStatus = SyncingSyncStatus(nodeHeight - h, h / nodeHeight);
302
+ } else if (resp.mwebHeaderHeight < nodeHeight) {
303
+ int h = resp.mwebHeaderHeight;
304
+ syncStatus = SyncingSyncStatus(nodeHeight - h, h / nodeHeight);
305
+ } else if (resp.mwebUtxosHeight < nodeHeight) {
306
+ syncStatus = SyncingSyncStatus(1, 0.999);
307
+ } else {
308
+ if (resp.mwebUtxosHeight > walletInfo.restoreHeight) {
309
+ await walletInfo.updateRestoreHeight(resp.mwebUtxosHeight);
310
+ await checkMwebUtxosSpent();
311
+ // update the confirmations for each transaction:
312
+ for (final transaction in transactionHistory.transactions.values) {
313
+ if (transaction.isPending) continue;
314
+ int txHeight = transaction.height ?? resp.mwebUtxosHeight;
315
+ final confirmations = (resp.mwebUtxosHeight - txHeight) + 1;
316
+ if (transaction.confirmations == confirmations) continue;
317
+ transaction.confirmations = confirmations;
318
+ transactionHistory.addOne(transaction);
319
+ }
320
+ await transactionHistory.save();
321
+ }
322
+
323
+ // prevent unnecessary reaction triggers:
324
+ if (syncStatus is! SyncedSyncStatus) {
325
+ // mwebd is synced, but we could still be processing incoming utxos:
326
+ if (!processingUtxos) {
327
+ syncStatus = SyncedSyncStatus();
328
+ }
329
+ }
330
+ return;
331
+ }
332
+ });
333
+ }
334
+
335
+ @action
336
+ @override
337
+ Future<void> stopSync() async {
338
+ _syncTimer?.cancel();
339
+ _utxoStream?.cancel();
340
+ _feeRatesTimer?.cancel();
341
+ await CwMweb.stop();
342
+ }
343
+
344
+ Future<void> initMwebUtxosBox() async {
345
+ final boxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
346
+
347
+ mwebUtxosBox = await CakeHive.openBox<MwebUtxo>(boxName);
348
+ }
349
+
350
+ @override
351
+ Future<void> renameWalletFiles(String newWalletName) async {
352
+ // rename the hive box:
353
+ final oldBoxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
354
+ final newBoxName = "${newWalletName.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
355
+
356
+ final oldBox = await CakeHive.openBox<MwebUtxo>(oldBoxName);
357
+ mwebUtxosBox = await CakeHive.openBox<MwebUtxo>(newBoxName);
358
+ for (final key in oldBox.keys) {
359
+ await mwebUtxosBox.put(key, oldBox.get(key)!);
360
+ }
361
+ oldBox.deleteFromDisk();
362
+
363
+ await super.renameWalletFiles(newWalletName);
364
+ }
365
+
366
+ @action
367
+ @override
368
+ Future<void> rescan({
369
+ required int height,
370
+ int? chainTip,
371
+ ScanData? scanData,
372
+ bool? doSingleScan,
373
+ bool? usingElectrs,
374
+ }) async {
375
+ _syncTimer?.cancel();
376
+ int oldHeight = walletInfo.restoreHeight;
377
+ await walletInfo.updateRestoreHeight(height);
378
+
379
+ // go through mwebUtxos and clear any that are above the new restore height:
380
+ if (height == 0) {
381
+ await mwebUtxosBox.clear();
382
+ transactionHistory.clear();
383
+ } else {
384
+ for (final utxo in mwebUtxosBox.values) {
385
+ if (utxo.height > height) {
386
+ await mwebUtxosBox.delete(utxo.outputId);
387
+ }
388
+ }
389
+ // TODO: remove transactions that are above the new restore height!
390
+ }
391
+
392
+ // reset coin balances and txCount to 0:
393
+ unspentCoins.forEach((coin) {
394
+ if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
395
+ coin.bitcoinAddressRecord.balance = 0;
396
+ coin.bitcoinAddressRecord.txCount = 0;
397
+ });
398
+
399
+ for (var addressRecord in walletAddresses.allAddresses) {
400
+ addressRecord.balance = 0;
401
+ addressRecord.txCount = 0;
402
+ }
403
+
404
+ await startSync();
405
+ }
406
+
407
+ @override
408
+ Future<void> init() async {
409
+ await super.init();
410
+ await initMwebUtxosBox();
411
+ }
412
+
413
+ Future<void> handleIncoming(MwebUtxo utxo, RpcClient stub) async {
414
+ final status = await stub.status(StatusRequest());
415
+ var date = DateTime.now();
416
+ var confirmations = 0;
417
+ if (utxo.height > 0) {
418
+ date = DateTime.fromMillisecondsSinceEpoch(utxo.blockTime * 1000);
419
+ confirmations = status.blockHeaderHeight - utxo.height + 1;
420
+ }
421
+ var tx = transactionHistory.transactions.values
422
+ .firstWhereOrNull((tx) => tx.outputAddresses?.contains(utxo.outputId) ?? false);
423
+
424
+ if (tx == null) {
425
+ tx = ElectrumTransactionInfo(
426
+ WalletType.litecoin,
427
+ id: utxo.outputId,
428
+ height: utxo.height,
429
+ amount: utxo.value.toInt(),
430
+ fee: 0,
431
+ direction: TransactionDirection.incoming,
432
+ isPending: utxo.height == 0,
433
+ date: date,
434
+ confirmations: confirmations,
435
+ inputAddresses: [],
436
+ outputAddresses: [utxo.outputId],
437
+ isReplaced: false,
438
+ );
439
+ }
440
+
441
+ // don't update the confirmations if the tx is updated by electrum:
442
+ if (tx.confirmations == 0 || utxo.height != 0) {
443
+ tx.height = utxo.height;
444
+ tx.isPending = utxo.height == 0;
445
+ tx.confirmations = confirmations;
446
+ }
447
+
448
+ bool isNew = transactionHistory.transactions[tx.id] == null;
449
+
450
+ if (!(tx.outputAddresses?.contains(utxo.address) ?? false)) {
451
+ tx.outputAddresses?.add(utxo.address);
452
+ isNew = true;
453
+ }
454
+
455
+ if (isNew) {
456
+ final addressRecord = walletAddresses.allAddresses
457
+ .firstWhereOrNull((addressRecord) => addressRecord.address == utxo.address);
458
+ if (addressRecord == null) {
459
+ print("we don't have this address in the wallet! ${utxo.address}");
460
+ return;
461
+ }
462
+
463
+ // update the txCount:
464
+ addressRecord.txCount++;
465
+ addressRecord.balance += utxo.value.toInt();
466
+ addressRecord.setAsUsed();
467
+ }
468
+
469
+ transactionHistory.addOne(tx);
470
+
471
+ if (isNew) {
472
+ // update the unconfirmed balance when a new tx is added:
473
+ // we do this after adding the tx to the history so that sub address balances are updated correctly
474
+ // (since that calculation is based on the tx history)
475
+ await updateBalance();
476
+ }
477
+ }
478
+
479
+ Future<void> processMwebUtxos() async {
480
+ if (!mwebEnabled) {
481
+ return;
482
+ }
483
+
484
+ int restoreHeight = walletInfo.restoreHeight;
485
+ print("SCANNING FROM HEIGHT: $restoreHeight");
486
+ final req = UtxosRequest(scanSecret: scanSecret, fromHeight: restoreHeight);
487
+
488
+ // process new utxos as they come in:
489
+ _utxoStream?.cancel();
490
+ ResponseStream<Utxo>? responseStream = await CwMweb.utxos(req);
491
+ if (responseStream == null) {
492
+ throw Exception("failed to get utxos stream!");
493
+ }
494
+ _utxoStream = responseStream.listen((Utxo sUtxo) async {
495
+ // we're processing utxos, so our balance could still be innacurate:
496
+ if (syncStatus is! SyncronizingSyncStatus && syncStatus is! SyncingSyncStatus) {
497
+ syncStatus = SyncronizingSyncStatus();
498
+ processingUtxos = true;
499
+ _processingTimer?.cancel();
500
+ _processingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async {
501
+ processingUtxos = false;
502
+ timer.cancel();
503
+ });
504
+ }
505
+
506
+ final utxo = MwebUtxo(
507
+ address: sUtxo.address,
508
+ blockTime: sUtxo.blockTime,
509
+ height: sUtxo.height,
510
+ outputId: sUtxo.outputId,
511
+ value: sUtxo.value.toInt(),
512
+ );
513
+
514
+ // if (mwebUtxosBox.containsKey(utxo.outputId)) {
515
+ // // we've already stored this utxo, skip it:
516
+ // return;
517
+ // }
518
+
519
+ await updateUnspent();
520
+ await updateBalance();
521
+
522
+ final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs;
523
+
524
+ // don't process utxos with addresses that are not in the mwebAddrs list:
525
+ if (utxo.address.isNotEmpty && !mwebAddrs.contains(utxo.address)) {
526
+ return;
527
+ }
528
+
529
+ await mwebUtxosBox.put(utxo.outputId, utxo);
530
+
531
+ await handleIncoming(utxo, _stub);
532
+ });
533
+ }
534
+
535
+ Future<void> checkMwebUtxosSpent() async {
536
+ if (!mwebEnabled) {
537
+ return;
538
+ }
539
+
540
+ final pendingOutgoingTransactions = transactionHistory.transactions.values
541
+ .where((tx) => tx.direction == TransactionDirection.outgoing && tx.isPending);
542
+
543
+ // check if any of the pending outgoing transactions are now confirmed:
544
+ bool updatedAny = false;
545
+ for (final tx in pendingOutgoingTransactions) {
546
+ updatedAny = await isConfirmed(tx) || updatedAny;
547
+ }
548
+
549
+ // get output ids of all the mweb utxos that have > 0 height:
550
+ final outputIds =
551
+ mwebUtxosBox.values.where((utxo) => utxo.height > 0).map((utxo) => utxo.outputId).toList();
552
+
553
+ final resp = await CwMweb.spent(SpentRequest(outputId: outputIds));
554
+ final spent = resp.outputId;
555
+ if (spent.isEmpty) {
556
+ return;
557
+ }
558
+
559
+ final status = await CwMweb.status(StatusRequest());
560
+ final height = await electrumClient.getCurrentBlockChainTip();
561
+ if (height == null || status.blockHeaderHeight != height) return;
562
+ if (status.mwebUtxosHeight != height) return; // we aren't synced
563
+
564
+ int amount = 0;
565
+ Set<String> inputAddresses = {};
566
+ var output = convert.AccumulatorSink<Digest>();
567
+ var input = sha256.startChunkedConversion(output);
568
+
569
+ for (final outputId in spent) {
570
+ final utxo = mwebUtxosBox.get(outputId);
571
+ await mwebUtxosBox.delete(outputId);
572
+ if (utxo == null) continue;
573
+ final addressRecord = walletAddresses.allAddresses
574
+ .firstWhere((addressRecord) => addressRecord.address == utxo.address);
575
+ if (!inputAddresses.contains(utxo.address)) {
576
+ addressRecord.txCount++;
577
+ }
578
+ addressRecord.balance -= utxo.value.toInt();
579
+ amount += utxo.value.toInt();
580
+ inputAddresses.add(utxo.address);
581
+ input.add(hex.decode(outputId));
582
+ }
583
+
584
+ if (inputAddresses.isEmpty) return;
585
+ input.close();
586
+ var digest = output.events.single;
587
+ final tx = ElectrumTransactionInfo(
588
+ WalletType.litecoin,
589
+ id: digest.toString(),
590
+ height: height,
591
+ amount: amount,
592
+ fee: 0,
593
+ direction: TransactionDirection.outgoing,
594
+ isPending: false,
595
+ date: DateTime.fromMillisecondsSinceEpoch(status.blockTime * 1000),
596
+ confirmations: 1,
597
+ inputAddresses: inputAddresses.toList(),
598
+ outputAddresses: [],
599
+ isReplaced: false,
600
+ );
601
+
602
+ transactionHistory.addOne(tx);
603
+ await transactionHistory.save();
604
+
605
+ if (updatedAny) {
606
+ await updateBalance();
607
+ }
608
+ }
609
+
610
+ // checks if a pending transaction is now confirmed, and updates the tx info accordingly:
611
+ Future<bool> isConfirmed(ElectrumTransactionInfo tx) async {
612
+ if (!mwebEnabled) return false;
613
+ if (!tx.isPending) return false;
614
+
615
+ final outputId = <String>[], target = <String>{};
616
+ final isHash = RegExp(r'^[a-f0-9]{64}$').hasMatch;
617
+ final spendingOutputIds = tx.inputAddresses?.where(isHash) ?? [];
618
+ final payingToOutputIds = tx.outputAddresses?.where(isHash) ?? [];
619
+ outputId.addAll(spendingOutputIds);
620
+ outputId.addAll(payingToOutputIds);
621
+ target.addAll(spendingOutputIds);
622
+
623
+ for (final outputId in payingToOutputIds) {
624
+ final spendingTx = transactionHistory.transactions.values
625
+ .firstWhereOrNull((tx) => tx.inputAddresses?.contains(outputId) ?? false);
626
+ if (spendingTx != null && !spendingTx.isPending) {
627
+ target.add(outputId);
628
+ }
629
+ }
630
+
631
+ if (outputId.isEmpty) {
632
+ return false;
633
+ }
634
+
635
+ final resp = await CwMweb.spent(SpentRequest(outputId: outputId));
636
+ if (!setEquals(resp.outputId.toSet(), target)) {
637
+ return false;
638
+ }
639
+
640
+ final status = await CwMweb.status(StatusRequest());
641
+ tx.height = status.mwebUtxosHeight;
642
+ tx.confirmations = 1;
643
+ tx.isPending = false;
644
+ await transactionHistory.save();
645
+ return true;
646
+ }
647
+
648
+ Future<void> updateUnspent() async {
649
+ await checkMwebUtxosSpent();
650
+ await updateAllUnspents();
651
+ }
652
+
653
+ @override
654
+ @action
655
+ Future<void> updateAllUnspents() async {
656
+ // get ltc unspents:
657
+ await super.updateAllUnspents();
658
+
659
+ if (!mwebEnabled) {
660
+ return;
661
+ }
662
+ await getStub();
663
+
664
+ // add the mweb unspents to the list:
665
+ List<BitcoinUnspent> mwebUnspentCoins = [];
666
+ // update mweb unspents:
667
+ final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs;
668
+ mwebUtxosBox.keys.forEach((dynamic oId) {
669
+ final String outputId = oId as String;
670
+ final utxo = mwebUtxosBox.get(outputId);
671
+ if (utxo == null) {
672
+ return;
673
+ }
674
+ if (utxo.address.isEmpty) {
675
+ // not sure if a bug or a special case but we definitely ignore these
676
+ return;
677
+ }
678
+ final addressRecord = walletAddresses.allAddresses
679
+ .firstWhereOrNull((addressRecord) => addressRecord.address == utxo.address);
680
+
681
+ if (addressRecord == null) {
682
+ print("utxo contains an address that is not in the wallet: ${utxo.address}");
683
+ return;
684
+ }
685
+ final unspent = BitcoinUnspent(
686
+ addressRecord,
687
+ outputId,
688
+ utxo.value.toInt(),
689
+ mwebAddrs.indexOf(utxo.address),
690
+ );
691
+ if (unspent.vout == 0) {
692
+ unspent.isChange = true;
693
+ }
694
+ mwebUnspentCoins.add(unspent);
695
+ });
696
+ unspentCoins.addAll(mwebUnspentCoins);
697
+ }
698
+
699
+ @override
700
+ Future<ElectrumBalance> fetchBalances() async {
701
+ final balance = await super.fetchBalances();
702
+ if (!mwebEnabled) {
703
+ return balance;
704
+ }
705
+ await getStub();
706
+
707
+ // update unspent balances:
708
+ await updateUnspent();
709
+
710
+ int confirmed = balance.confirmed;
711
+ int unconfirmed = balance.unconfirmed;
712
+ int confirmedMweb = 0;
713
+ int unconfirmedMweb = 0;
714
+ try {
715
+ mwebUtxosBox.values.forEach((utxo) {
716
+ if (utxo.height > 0) {
717
+ confirmedMweb += utxo.value.toInt();
718
+ } else {
719
+ unconfirmedMweb += utxo.value.toInt();
720
+ }
721
+ });
722
+ if (unconfirmedMweb > 0) {
723
+ unconfirmedMweb = -1 * (confirmedMweb - unconfirmedMweb);
724
+ }
725
+ } catch (_) {}
726
+
727
+ for (var addressRecord in walletAddresses.allAddresses) {
728
+ addressRecord.balance = 0;
729
+ addressRecord.txCount = 0;
730
+ }
731
+
732
+ unspentCoins.forEach((coin) {
733
+ final coinInfoList = unspentCoinsInfo.values.where(
734
+ (element) =>
735
+ element.walletId.contains(id) &&
736
+ element.hash.contains(coin.hash) &&
737
+ element.vout == coin.vout,
738
+ );
739
+
740
+ if (coinInfoList.isNotEmpty) {
741
+ final coinInfo = coinInfoList.first;
742
+
743
+ coin.isFrozen = coinInfo.isFrozen;
744
+ coin.isSending = coinInfo.isSending;
745
+ coin.note = coinInfo.note;
746
+ if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
747
+ coin.bitcoinAddressRecord.balance += coinInfo.value;
748
+ } else {
749
+ super.addCoinInfo(coin);
750
+ }
751
+ });
752
+
753
+ // update the txCount for each address using the tx history, since we can't rely on mwebd
754
+ // to have an accurate count, we should just keep it in sync with what we know from the tx history:
755
+ for (final tx in transactionHistory.transactions.values) {
756
+ // if (tx.isPending) continue;
757
+ if (tx.inputAddresses == null || tx.outputAddresses == null) {
758
+ continue;
759
+ }
760
+ final txAddresses = tx.inputAddresses! + tx.outputAddresses!;
761
+ for (final address in txAddresses) {
762
+ final addressRecord = walletAddresses.allAddresses
763
+ .firstWhereOrNull((addressRecord) => addressRecord.address == address);
764
+ if (addressRecord == null) {
765
+ continue;
766
+ }
767
+ addressRecord.txCount++;
768
+ }
769
+ }
770
+
771
+ return ElectrumBalance(
772
+ confirmed: confirmed,
773
+ unconfirmed: unconfirmed,
774
+ frozen: balance.frozen,
775
+ secondConfirmed: confirmedMweb,
776
+ secondUnconfirmed: unconfirmedMweb,
777
);
778
}
779
793
return 0;
794
}
795
796
+ @override
797
+ Future<int> calcFee({
798
+ required List<UtxoWithAddress> utxos,
799
+ required List<BitcoinBaseOutput> outputs,
800
+ required BasedUtxoNetwork network,
801
+ String? memo,
802
+ required int feeRate,
803
+ List<ECPrivateInfo>? inputPrivKeyInfos,
804
+ List<Outpoint>? vinOutpoints,
805
+ }) async {
806
+ final spendsMweb = utxos.any((utxo) => utxo.utxo.scriptType == SegwitAddresType.mweb);
807
+ final paysToMweb = outputs
808
+ .any((output) => output.toOutput.scriptPubKey.getAddressType() == SegwitAddresType.mweb);
809
+ if (!spendsMweb && !paysToMweb) {
810
+ return await super.calcFee(
811
+ utxos: utxos,
812
+ outputs: outputs,
813
+ network: network,
814
+ memo: memo,
815
+ feeRate: feeRate,
816
+ inputPrivKeyInfos: inputPrivKeyInfos,
817
+ vinOutpoints: vinOutpoints,
818
+ );
819
+ }
820
+
821
+ if (!mwebEnabled) {
822
+ throw Exception("MWEB is not enabled! can't calculate fee without starting the mweb server!");
823
+ }
824
+
825
+ if (outputs.length == 1 && outputs[0].toOutput.amount == BigInt.zero) {
826
+ outputs = [
827
+ BitcoinScriptOutput(
828
+ script: outputs[0].toOutput.scriptPubKey, value: utxos.sumOfUtxosValue())
829
+ ];
830
+ }
831
+
832
+ // https://github.com/ltcmweb/mwebd?tab=readme-ov-file#fee-estimation
833
+ final preOutputSum =
834
+ outputs.fold<BigInt>(BigInt.zero, (acc, output) => acc + output.toOutput.amount);
835
+ final fee = utxos.sumOfUtxosValue() - preOutputSum;
836
+ final txb =
837
+ BitcoinTransactionBuilder(utxos: utxos, outputs: outputs, fee: fee, network: network);
838
+ final resp = await CwMweb.create(CreateRequest(
839
+ rawTx: txb.buildTransaction((a, b, c, d) => '').toBytes(),
840
+ scanSecret: scanSecret,
841
+ spendSecret: spendSecret,
842
+ feeRatePerKb: Int64(feeRate * 1000),
843
+ dryRun: true));
844
+ final tx = BtcTransaction.fromRaw(hex.encode(resp.rawTx));
845
+ final posUtxos = utxos
846
+ .where((utxo) => tx.inputs
847
+ .any((input) => input.txId == utxo.utxo.txHash && input.txIndex == utxo.utxo.vout))
848
+ .toList();
849
+ final posOutputSum = tx.outputs.fold<int>(0, (acc, output) => acc + output.amount.toInt());
850
+ final mwebInputSum = utxos.sumOfUtxosValue() - posUtxos.sumOfUtxosValue();
851
+ final expectedPegin = max(0, (preOutputSum - mwebInputSum).toInt());
852
+ var feeIncrease = posOutputSum - expectedPegin;
853
+ if (expectedPegin > 0 && fee == BigInt.zero) {
854
+ feeIncrease += await super.calcFee(
855
+ utxos: posUtxos,
856
+ outputs: tx.outputs
857
+ .map((output) =>
858
+ BitcoinScriptOutput(script: output.scriptPubKey, value: output.amount))
859
+ .toList(),
860
+ network: network,
861
+ memo: memo,
862
+ feeRate: feeRate) +
863
+ feeRate * 41;
864
+ }
865
+ return fee.toInt() + feeIncrease;
866
+ }
867
+
868
+ @override
869
+ Future<PendingTransaction> createTransaction(Object credentials) async {
870
+ try {
871
+ var tx = await super.createTransaction(credentials) as PendingBitcoinTransaction;
872
+ tx.isMweb = mwebEnabled;
873
+
874
+ if (!mwebEnabled) {
875
+ return tx;
876
+ }
877
+ await waitForMwebAddresses();
878
+ await getStub();
879
+
880
+ final resp = await CwMweb.create(CreateRequest(
881
+ rawTx: hex.decode(tx.hex),
882
+ scanSecret: scanSecret,
883
+ spendSecret: spendSecret,
884
+ feeRatePerKb: Int64.parseInt(tx.feeRate) * 1000,
885
+ ));
886
+ final tx2 = BtcTransaction.fromRaw(hex.encode(resp.rawTx));
887
+
888
+ // check if the transaction doesn't contain any mweb inputs or outputs:
889
+ final transactionCredentials = credentials as BitcoinTransactionCredentials;
890
+
891
+ bool hasMwebInput = false;
892
+ bool hasMwebOutput = false;
893
+
894
+ for (final output in transactionCredentials.outputs) {
895
+ if (output.extractedAddress?.toLowerCase().contains("mweb") ?? false) {
896
+ hasMwebOutput = true;
897
+ break;
898
+ }
899
+ }
900
+
901
+ if (tx2.mwebBytes != null && tx2.mwebBytes!.isNotEmpty) {
902
+ hasMwebInput = true;
903
+ }
904
+
905
+ if (!hasMwebInput && !hasMwebOutput) {
906
+ return tx;
907
+ }
908
+
909
+ // check if any of the inputs of this transaction are hog-ex:
910
+ // this list is only non-mweb inputs:
911
+ tx2.inputs.forEach((txInput) {
912
+ bool isHogEx = true;
913
+
914
+ final utxo = unspentCoins
915
+ .firstWhere((utxo) => utxo.hash == txInput.txId && utxo.vout == txInput.txIndex);
916
+
917
+ // TODO: detect actual hog-ex inputs
918
+
919
+ if (!isHogEx) {
920
+ return;
921
+ }
922
+
923
+ int confirmations = utxo.confirmations ?? 0;
924
+ if (confirmations < 6) {
925
+ throw Exception(
926
+ "A transaction input has less than 6 confirmations, please try again later.");
927
+ }
928
+ });
929
+
930
+ tx.hexOverride = tx2
931
+ .copyWith(
932
+ witnesses: tx2.inputs.asMap().entries.map((e) {
933
+ final utxo = unspentCoins
934
+ .firstWhere((utxo) => utxo.hash == e.value.txId && utxo.vout == e.value.txIndex);
935
+ final key = generateECPrivate(
936
+ hd: utxo.bitcoinAddressRecord.isHidden
937
+ ? walletAddresses.sideHd
938
+ : walletAddresses.mainHd,
939
+ index: utxo.bitcoinAddressRecord.index,
940
+ network: network);
941
+ final digest = tx2.getTransactionSegwitDigit(
942
+ txInIndex: e.key,
943
+ script: key.getPublic().toP2pkhAddress().toScriptPubKey(),
944
+ amount: BigInt.from(utxo.value),
945
+ );
946
+ return TxWitnessInput(stack: [key.signInput(digest), key.getPublic().toHex()]);
947
+ }).toList())
948
+ .toHex();
949
+ tx.outputAddresses = resp.outputId;
950
+
951
+ return tx
952
+ ..addListener((transaction) async {
953
+ final addresses = <String>{};
954
+ transaction.inputAddresses?.forEach((id) async {
955
+ final utxo = mwebUtxosBox.get(id);
956
+ // await mwebUtxosBox.delete(id);// gets deleted in checkMwebUtxosSpent
957
+ if (utxo == null) return;
958
+ final addressRecord = walletAddresses.allAddresses
959
+ .firstWhere((addressRecord) => addressRecord.address == utxo.address);
960
+ if (!addresses.contains(utxo.address)) {
961
+ addresses.add(utxo.address);
962
+ }
963
+ addressRecord.balance -= utxo.value.toInt();
964
+ });
965
+ transaction.inputAddresses?.addAll(addresses);
966
+
967
+ transactionHistory.addOne(transaction);
968
+ await updateUnspent();
969
+ await updateBalance();
970
+ });
971
+ } catch (e, s) {
972
+ print(e);
973
+ print(s);
974
+ if (e.toString().contains("commit failed")) {
975
+ throw Exception("Transaction commit failed (no peers responded), please try again.");
976
+ }
977
+ rethrow;
978
+ }
979
+ }
980
+
981
+ @override
982
+ Future<void> save() async {
983
+ await super.save();
984
+ }
985
+
986
+ @override
987
+ Future<void> close() async {
988
+ _utxoStream?.cancel();
989
+ _feeRatesTimer?.cancel();
990
+ _syncTimer?.cancel();
991
+ _processingTimer?.cancel();
992
+ await stopSync();
993
+ await super.close();
994
+ }
995
+
996
+ Future<void> setMwebEnabled(bool enabled) async {
997
+ if (mwebEnabled == enabled) {
998
+ return;
999
+ }
1000
+
1001
+ mwebEnabled = enabled;
1002
+ (walletAddresses as LitecoinWalletAddresses).mwebEnabled = enabled;
1003
+ await stopSync();
1004
+ await startSync();
1005
+ }
1006
+
1007
+ Future<RpcClient> getStub() async {
1008
+ _stub = await CwMweb.stub();
1009
+ return _stub;
1010
+ }
1011
+
1012
+ Future<StatusResponse> getStatusRequest() async {
1013
+ final resp = await CwMweb.status(StatusRequest());
1014
+ return resp;
1015
+ }
1016
+
1017
@override
1018
Future<String> signMessage(String message, {String? address = null}) async {
1019
final index = address != null