1
import 'dart:async';
2
import 'dart:convert';
3
import 'dart:io';
4
+import 'dart:isolate';
5
import 'dart:math';
6
7
import 'package:bitcoin_base/bitcoin_base.dart';
7
-import 'package:bitcoin_base/bitcoin_base.dart' as bitcoin_base;
8
import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin;
9
+import 'package:blockchain_utils/blockchain_utils.dart';
10
import 'package:collection/collection.dart';
11
import 'package:cw_bitcoin/address_from_output.dart';
12
import 'package:cw_bitcoin/bitcoin_address_record.dart';
36
import 'package:cw_core/utils/file.dart';
37
import 'package:cw_core/wallet_base.dart';
38
import 'package:cw_core/wallet_info.dart';
39
+import 'package:cw_core/wallet_type.dart';
40
+import 'package:cw_core/get_height_by_date.dart';
41
import 'package:flutter/foundation.dart';
42
import 'package:hive/hive.dart';
43
import 'package:http/http.dart' as http;
44
import 'package:mobx/mobx.dart';
45
import 'package:rxdart/subjects.dart';
46
+import 'package:sp_scanner/sp_scanner.dart';
47
48
part 'electrum_wallet.g.dart';
49
50
class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet;
51
52
+const int TWEAKS_COUNT = 25;
53
+
54
abstract class ElectrumWalletBase
55
extends WalletBase<ElectrumBalance, ElectrumTransactionHistory, ElectrumTransactionInfo>
56
with Store {
51
- ElectrumWalletBase(
52
- {required String password,
53
- required WalletInfo walletInfo,
54
- required Box<UnspentCoinsInfo> unspentCoinsInfo,
55
- required this.networkType,
56
- String? xpub,
57
- String? mnemonic,
58
- Uint8List? seedBytes,
59
- this.passphrase,
60
- List<BitcoinAddressRecord>? initialAddresses,
61
- ElectrumClient? electrumClient,
62
- ElectrumBalance? initialBalance,
63
- CryptoCurrency? currency})
64
- : accountHD =
57
+ ElectrumWalletBase({
58
+ required String password,
59
+ required WalletInfo walletInfo,
60
+ required Box<UnspentCoinsInfo> unspentCoinsInfo,
61
+ required this.networkType,
62
+ String? xpub,
63
+ String? mnemonic,
64
+ Uint8List? seedBytes,
65
+ this.passphrase,
66
+ List<BitcoinAddressRecord>? initialAddresses,
67
+ ElectrumClient? electrumClient,
68
+ ElectrumBalance? initialBalance,
69
+ CryptoCurrency? currency,
70
+ this.alwaysScan,
71
+ }) : accountHD =
72
getAccountHDWallet(currency, networkType, seedBytes, xpub, walletInfo.derivationInfo),
73
syncStatus = NotConnectedSyncStatus(),
74
_password = password,
79
_scripthashesUpdateSubject = {},
80
balance = ObservableMap<CryptoCurrency, ElectrumBalance>.of(currency != null
81
? {
75
- currency:
76
- initialBalance ?? const ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0)
82
+ currency: initialBalance ??
83
+ ElectrumBalance(
84
+ confirmed: 0,
85
+ unconfirmed: 0,
86
+ frozen: 0,
87
+ )
88
}
89
: {}),
90
this.unspentCoinsInfo = unspentCoinsInfo,
95
this.electrumClient = electrumClient ?? ElectrumClient();
96
this.walletInfo = walletInfo;
97
transactionHistory = ElectrumTransactionHistory(walletInfo: walletInfo, password: password);
98
+
99
+ reaction((_) => syncStatus, (SyncStatus syncStatus) {
100
+ if (syncStatus is! AttemptingSyncStatus && syncStatus is! SyncedTipSyncStatus)
101
+ silentPaymentsScanningActive = syncStatus is SyncingSyncStatus;
102
+
103
+ if (syncStatus is NotConnectedSyncStatus) {
104
+ // Needs to re-subscribe to all scripthashes when reconnected
105
+ _scripthashesUpdateSubject = {};
106
+ }
107
+
108
+ // Message is shown on the UI for 3 seconds, revert to synced
109
+ if (syncStatus is SyncedTipSyncStatus) {
110
+ Timer(Duration(seconds: 3), () {
111
+ if (this.syncStatus is SyncedTipSyncStatus) this.syncStatus = SyncedSyncStatus();
112
+ });
113
+ }
114
+ });
115
}
116
117
static bitcoin.HDWallet getAccountHDWallet(
141
static int estimatedTransactionSize(int inputsCount, int outputsCounts) =>
142
inputsCount * 68 + outputsCounts * 34 + 10;
143
144
+ bool? alwaysScan;
145
+
146
final bitcoin.HDWallet accountHD;
147
final String? _mnemonic;
148
167
@observable
168
SyncStatus syncStatus;
169
170
+ Set<String> get addressesSet => walletAddresses.allAddresses.map((addr) => addr.address).toSet();
171
+
172
List<String> get scriptHashes => walletAddresses.addressesByReceiveType
173
.map((addr) => scriptHash(addr.address, network: network))
174
.toList();
189
@override
190
bool? isTestnet;
191
192
+ bool get hasSilentPaymentsScanning => type == WalletType.bitcoin;
193
+
194
+ @observable
195
+ bool nodeSupportsSilentPayments = true;
196
+ @observable
197
+ bool silentPaymentsScanningActive = false;
198
+
199
+ @action
200
+ Future<void> setSilentPaymentsScanning(bool active) async {
201
+ silentPaymentsScanningActive = active;
202
+
203
+ if (active) {
204
+ syncStatus = AttemptingSyncStatus();
205
+
206
+ final tip = await getUpdatedChainTip();
207
+
208
+ if (tip == walletInfo.restoreHeight) {
209
+ syncStatus = SyncedTipSyncStatus(tip);
210
+ }
211
+
212
+ if (tip > walletInfo.restoreHeight) {
213
+ _setListeners(walletInfo.restoreHeight, chainTipParam: _currentChainTip);
214
+ }
215
+ } else {
216
+ alwaysScan = false;
217
+
218
+ (await _isolate)?.kill(priority: Isolate.immediate);
219
+
220
+ if (electrumClient.isConnected) {
221
+ syncStatus = SyncedSyncStatus();
222
+ } else {
223
+ if (electrumClient.uri != null) {
224
+ await electrumClient.connectToUri(electrumClient.uri!);
225
+ startSync();
226
+ }
227
+ }
228
+ }
229
+ }
230
+
231
+ int? _currentChainTip;
232
+
233
+ Future<int> getCurrentChainTip() async {
234
+ if (_currentChainTip != null) {
235
+ return _currentChainTip!;
236
+ }
237
+ _currentChainTip = await electrumClient.getCurrentBlockChainTip() ?? 0;
238
+
239
+ return _currentChainTip!;
240
+ }
241
+
242
+ Future<int> getUpdatedChainTip() async {
243
+ final newTip = await electrumClient.getCurrentBlockChainTip();
244
+ if (newTip != null && newTip > (_currentChainTip ?? 0)) {
245
+ _currentChainTip = newTip;
246
+ }
247
+ return _currentChainTip ?? 0;
248
+ }
249
+
250
@override
251
BitcoinWalletKeys get keys =>
252
BitcoinWalletKeys(wif: hd.wif!, privateKey: hd.privKey!, publicKey: hd.pubKey!);
254
String _password;
255
List<BitcoinUnspent> unspentCoins;
256
List<int> _feeRates;
257
+
258
+ // ignore: prefer_final_fields
259
Map<String, BehaviorSubject<Object>?> _scripthashesUpdateSubject;
260
+
261
+ // ignore: prefer_final_fields
262
+ BehaviorSubject<Object>? _chainTipUpdateSubject;
263
bool _isTransactionUpdating;
264
+ Future<Isolate>? _isolate;
265
266
void Function(FlutterErrorDetails)? _onError;
267
+ Timer? _autoSaveTimer;
268
+ static const int _autoSaveInterval = 30;
269
270
Future<void> init() async {
271
await walletAddresses.init();
272
await transactionHistory.init();
273
await save();
274
+
275
+ _autoSaveTimer =
276
+ Timer.periodic(Duration(seconds: _autoSaveInterval), (_) async => await save());
277
+ }
278
+
279
+ @action
280
+ Future<void> _setListeners(int height, {int? chainTipParam, bool? doSingleScan}) async {
281
+ final chainTip = chainTipParam ?? await getUpdatedChainTip();
282
+
283
+ if (chainTip == height) {
284
+ syncStatus = SyncedSyncStatus();
285
+ return;
286
+ }
287
+
288
+ syncStatus = AttemptingSyncStatus();
289
+
290
+ if (_isolate != null) {
291
+ final runningIsolate = await _isolate!;
292
+ runningIsolate.kill(priority: Isolate.immediate);
293
+ }
294
+
295
+ final receivePort = ReceivePort();
296
+ _isolate = Isolate.spawn(
297
+ startRefresh,
298
+ ScanData(
299
+ sendPort: receivePort.sendPort,
300
+ silentAddress: walletAddresses.silentAddress!,
301
+ network: network,
302
+ height: height,
303
+ chainTip: chainTip,
304
+ electrumClient: ElectrumClient(),
305
+ transactionHistoryIds: transactionHistory.transactions.keys.toList(),
306
+ node: ScanNode(node!.uri, node!.useSSL),
307
+ labels: walletAddresses.labels,
308
+ labelIndexes: walletAddresses.silentAddresses
309
+ .where((addr) => addr.type == SilentPaymentsAddresType.p2sp && addr.index >= 1)
310
+ .map((addr) => addr.index)
311
+ .toList(),
312
+ isSingleScan: doSingleScan ?? false,
313
+ ));
314
+
315
+ await for (var message in receivePort) {
316
+ if (message is Map<String, ElectrumTransactionInfo>) {
317
+ for (final map in message.entries) {
318
+ final txid = map.key;
319
+ final tx = map.value;
320
+
321
+ if (tx.unspents != null) {
322
+ final existingTxInfo = transactionHistory.transactions[txid];
323
+ final txAlreadyExisted = existingTxInfo != null;
324
+
325
+ // Updating tx after re-scanned
326
+ if (txAlreadyExisted) {
327
+ existingTxInfo.amount = tx.amount;
328
+ existingTxInfo.confirmations = tx.confirmations;
329
+ existingTxInfo.height = tx.height;
330
+
331
+ final newUnspents = tx.unspents!
332
+ .where((unspent) => !(existingTxInfo.unspents?.any((element) =>
333
+ element.hash.contains(unspent.hash) &&
334
+ element.vout == unspent.vout &&
335
+ element.value == unspent.value) ??
336
+ false))
337
+ .toList();
338
+
339
+ if (newUnspents.isNotEmpty) {
340
+ newUnspents.forEach(_updateSilentAddressRecord);
341
+
342
+ existingTxInfo.unspents ??= [];
343
+ existingTxInfo.unspents!.addAll(newUnspents);
344
+
345
+ final newAmount = newUnspents.length > 1
346
+ ? newUnspents.map((e) => e.value).reduce((value, unspent) => value + unspent)
347
+ : newUnspents[0].value;
348
+
349
+ if (existingTxInfo.direction == TransactionDirection.incoming) {
350
+ existingTxInfo.amount += newAmount;
351
+ }
352
+
353
+ // Updates existing TX
354
+ transactionHistory.addOne(existingTxInfo);
355
+ // Update balance record
356
+ balance[currency]!.confirmed += newAmount;
357
+ }
358
+ } else {
359
+ // else: First time seeing this TX after scanning
360
+ tx.unspents!.forEach(_updateSilentAddressRecord);
361
+
362
+ // Add new TX record
363
+ transactionHistory.addMany(message);
364
+ // Update balance record
365
+ balance[currency]!.confirmed += tx.amount;
366
+ }
367
+
368
+ await updateAllUnspents();
369
+ }
370
+ }
371
+ }
372
+
373
+ if (message is SyncResponse) {
374
+ if (message.syncStatus is UnsupportedSyncStatus) {
375
+ nodeSupportsSilentPayments = false;
376
+ }
377
+
378
+ syncStatus = message.syncStatus;
379
+ await walletInfo.updateRestoreHeight(message.height);
380
+ }
381
+ }
382
+ }
383
+
384
+ void _updateSilentAddressRecord(BitcoinSilentPaymentsUnspent unspent) {
385
+ final silentAddress = walletAddresses.silentAddress!;
386
+ final silentPaymentAddress = SilentPaymentAddress(
387
+ version: silentAddress.version,
388
+ B_scan: silentAddress.B_scan,
389
+ B_spend: unspent.silentPaymentLabel != null
390
+ ? silentAddress.B_spend.tweakAdd(
391
+ BigintUtils.fromBytes(BytesUtils.fromHexString(unspent.silentPaymentLabel!)),
392
+ )
393
+ : silentAddress.B_spend,
394
+ hrp: silentAddress.hrp,
395
+ );
396
+
397
+ final addressRecord = walletAddresses.silentAddresses
398
+ .firstWhereOrNull((address) => address.address == silentPaymentAddress.toString());
399
+ addressRecord?.txCount += 1;
400
+ addressRecord?.balance += unspent.value;
401
+
402
+ walletAddresses.addSilentAddresses(
403
+ [unspent.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord],
404
+ );
405
}
406
407
@action
408
@override
409
Future<void> startSync() async {
410
try {
182
- syncStatus = AttemptingSyncStatus();
411
+ syncStatus = SyncronizingSyncStatus();
412
+
413
+ if (hasSilentPaymentsScanning) {
414
+ await _setInitialHeight();
415
+ }
416
+
417
+ await _subscribeForUpdates();
418
+
419
await updateTransactions();
184
- _subscribeForUpdates();
185
- await updateUnspent();
420
+ await updateAllUnspents();
421
await updateBalance();
187
- _feeRates = await electrumClient.feeRates(network: network);
422
189
- Timer.periodic(
190
- const Duration(minutes: 1), (timer) async => _feeRates = await electrumClient.feeRates());
423
+ Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates());
424
192
- syncStatus = SyncedSyncStatus();
425
+ if (alwaysScan == true) {
426
+ _setListeners(walletInfo.restoreHeight);
427
+ } else {
428
+ syncStatus = SyncedSyncStatus();
429
+ }
430
} catch (e, stacktrace) {
431
print(stacktrace);
432
print(e.toString());
434
}
435
}
436
437
+ @action
438
+ Future<void> updateFeeRates() async {
439
+ final feeRates = await electrumClient.feeRates(network: network);
440
+ if (feeRates != [0, 0, 0]) {
441
+ _feeRates = feeRates;
442
+ }
443
+ }
444
+
445
+ Node? node;
446
+
447
@action
448
@override
449
Future<void> connectToNode({required Node node}) async {
450
+ this.node = node;
451
+
452
try {
453
syncStatus = ConnectingSyncStatus();
205
- await electrumClient.connectToUri(node.uri);
206
- electrumClient.onConnectionStatusChange = (bool isConnected) {
207
- if (!isConnected) {
454
+
455
+ await electrumClient.close();
456
+
457
+ electrumClient.onConnectionStatusChange = (bool? isConnected) async {
458
+ if (syncStatus is SyncingSyncStatus) return;
459
+
460
+ if (isConnected == true && syncStatus is! SyncedSyncStatus) {
461
+ syncStatus = ConnectedSyncStatus();
462
+ } else if (isConnected == false) {
463
syncStatus = LostConnectionSyncStatus();
464
+ } else if (!(isConnected ?? false) && syncStatus is! ConnectingSyncStatus) {
465
+ syncStatus = NotConnectedSyncStatus();
466
}
467
};
211
- syncStatus = ConnectedSyncStatus();
468
+
469
+ await electrumClient.connectToUri(node.uri, useSSL: node.useSSL);
470
} catch (e) {
471
print(e.toString());
472
syncStatus = FailedSyncStatus();
477
478
bool _isBelowDust(int amount) => amount <= _dustAmount && network != BitcoinNetwork.testnet;
479
222
- Future<EstimatedTxResult> estimateSendAllTx(
223
- List<BitcoinOutput> outputs,
224
- int feeRate, {
225
- String? memo,
226
- int credentialsAmount = 0,
227
- }) async {
228
- final utxos = <UtxoWithAddress>[];
229
- final privateKeys = <ECPrivate>[];
480
+ UtxoDetails _createUTXOS({
481
+ required bool sendAll,
482
+ required int credentialsAmount,
483
+ required bool paysToSilentPayment,
484
+ int? inputsCount,
485
+ }) {
486
+ List<UtxoWithAddress> utxos = [];
487
+ List<Outpoint> vinOutpoints = [];
488
+ List<ECPrivateInfo> inputPrivKeyInfos = [];
489
final publicKeys = <String, PublicKeyWithDerivationPath>{};
231
-
490
int allInputsAmount = 0;
233
-
491
+ bool spendsSilentPayment = false;
492
bool spendsUnconfirmedTX = false;
493
236
- for (int i = 0; i < unspentCoins.length; i++) {
237
- final utx = unspentCoins[i];
494
+ int leftAmount = credentialsAmount;
495
+ final availableInputs = unspentCoins.where((utx) => utx.isSending && !utx.isFrozen).toList();
496
+ final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList();
497
239
- if (utx.isSending && !utx.isFrozen) {
240
- if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0;
498
+ for (int i = 0; i < availableInputs.length; i++) {
499
+ final utx = availableInputs[i];
500
+ if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0;
501
242
- allInputsAmount += utx.value;
502
+ if (paysToSilentPayment) {
503
+ // Check inputs for shared secret derivation
504
+ if (utx.bitcoinAddressRecord.type == SegwitAddresType.p2wsh) {
505
+ throw BitcoinTransactionSilentPaymentsNotSupported();
506
+ }
507
+ }
508
244
- final address = addressTypeFromStr(utx.address, network);
245
- final hd =
246
- utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd;
247
- final derivationPath =
248
- "${_hardenedDerivationPath(walletInfo.derivationInfo?.derivationPath ?? "m/0'")}"
249
- "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}"
250
- "/${utx.bitcoinAddressRecord.index}";
251
- final pubKeyHex = hd.derive(utx.bitcoinAddressRecord.index).pubKey!;
509
+ allInputsAmount += utx.value;
510
+ leftAmount = leftAmount - utx.value;
511
253
- publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath);
512
+ final address = addressTypeFromStr(utx.address, network);
513
+ ECPrivate? privkey;
514
+ bool? isSilentPayment = false;
515
255
- if (!walletInfo.isHardwareWallet) {
256
- final privkey =
257
- generateECPrivate(hd: hd, index: utx.bitcoinAddressRecord.index, network: network);
516
+ final hd =
517
+ utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd;
518
+ final derivationPath =
519
+ "${_hardenedDerivationPath(walletInfo.derivationInfo?.derivationPath ?? "m/0'")}"
520
+ "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}"
521
+ "/${utx.bitcoinAddressRecord.index}";
522
+ final pubKeyHex = hd.derive(utx.bitcoinAddressRecord.index).pubKey!;
523
259
- privateKeys.add(privkey);
260
- }
524
+ publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath);
525
262
- utxos.add(
263
- UtxoWithAddress(
264
- utxo: BitcoinUtxo(
265
- txHash: utx.hash,
266
- value: BigInt.from(utx.value),
267
- vout: utx.vout,
268
- scriptType: _getScriptType(address),
269
- ),
270
- ownerDetails: UtxoAddressDetails(
271
- publicKey: pubKeyHex,
272
- address: address,
273
- ),
526
+ if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) {
527
+ final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord;
528
+ privkey = walletAddresses.silentAddress!.b_spend.tweakAdd(
529
+ BigintUtils.fromBytes(
530
+ BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!),
531
),
532
);
533
+ spendsSilentPayment = true;
534
+ isSilentPayment = true;
535
+ } else {
536
+ privkey =
537
+ generateECPrivate(hd: hd, index: utx.bitcoinAddressRecord.index, network: network);
538
+ }
539
+
540
+ vinOutpoints.add(Outpoint(txid: utx.hash, index: utx.vout));
541
+ inputPrivKeyInfos.add(ECPrivateInfo(
542
+ privkey,
543
+ address.type == SegwitAddresType.p2tr,
544
+ tweak: !isSilentPayment,
545
+ ));
546
+
547
+ utxos.add(
548
+ UtxoWithAddress(
549
+ utxo: BitcoinUtxo(
550
+ txHash: utx.hash,
551
+ value: BigInt.from(utx.value),
552
+ vout: utx.vout,
553
+ scriptType: _getScriptType(address),
554
+ isSilentPayment: isSilentPayment,
555
+ ),
556
+ ownerDetails: UtxoAddressDetails(
557
+ publicKey: privkey.getPublic().toHex(),
558
+ address: address,
559
+ ),
560
+ ),
561
+ );
562
+
563
+ // sendAll continues for all inputs
564
+ if (!sendAll) {
565
+ bool amountIsAcquired = leftAmount <= 0;
566
+ if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) {
567
+ break;
568
+ }
569
}
570
}
571
573
throw BitcoinTransactionNoInputsException();
574
}
575
576
+ return UtxoDetails(
577
+ availableInputs: availableInputs,
578
+ unconfirmedCoins: unconfirmedCoins,
579
+ utxos: utxos,
580
+ vinOutpoints: vinOutpoints,
581
+ inputPrivKeyInfos: inputPrivKeyInfos,
582
+ publicKeys: publicKeys,
583
+ allInputsAmount: allInputsAmount,
584
+ spendsSilentPayment: spendsSilentPayment,
585
+ spendsUnconfirmedTX: spendsUnconfirmedTX,
586
+ );
587
+ }
588
+
589
+ Future<EstimatedTxResult> estimateSendAllTx(
590
+ List<BitcoinOutput> outputs,
591
+ int feeRate, {
592
+ String? memo,
593
+ int credentialsAmount = 0,
594
+ bool hasSilentPayment = false,
595
+ }) async {
596
+ final utxoDetails = _createUTXOS(
597
+ sendAll: true,
598
+ credentialsAmount: credentialsAmount,
599
+ paysToSilentPayment: hasSilentPayment,
600
+ );
601
+
602
int estimatedSize;
603
if (network is BitcoinCashNetwork) {
604
estimatedSize = ForkedTransactionBuilder.estimateTransactionSize(
286
- utxos: utxos,
605
+ utxos: utxoDetails.utxos,
606
outputs: outputs,
607
network: network as BitcoinCashNetwork,
608
memo: memo,
609
);
610
} else {
611
estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize(
293
- utxos: utxos,
612
+ utxos: utxoDetails.utxos,
613
outputs: outputs,
614
network: network,
615
memo: memo,
616
+ inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
617
+ vinOutpoints: utxoDetails.vinOutpoints,
618
);
619
}
620
625
}
626
627
// Here, when sending all, the output amount equals to the input value - fee to fully spend every input on the transaction and have no amount left for change
307
- int amount = allInputsAmount - fee;
628
+ int amount = utxoDetails.allInputsAmount - fee;
629
+
630
+ if (amount <= 0) {
631
+ throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee);
632
+ }
633
634
if (amount <= 0) {
635
throw BitcoinTransactionWrongBalanceException();
648
}
649
}
650
326
- outputs[outputs.length - 1] =
327
- BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount));
651
+ if (outputs.length == 1) {
652
+ outputs[0] = BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount));
653
+ }
654
655
return EstimatedTxResult(
330
- utxos: utxos,
331
- privateKeys: privateKeys,
332
- publicKeys: publicKeys,
656
+ utxos: utxoDetails.utxos,
657
+ inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
658
+ publicKeys: utxoDetails.publicKeys,
659
fee: fee,
660
amount: amount,
661
isSendAll: true,
662
hasChange: false,
663
memo: memo,
338
- spendsUnconfirmedTX: spendsUnconfirmedTX,
664
+ spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
665
+ spendsSilentPayment: utxoDetails.spendsSilentPayment,
666
);
667
}
668
673
int? inputsCount,
674
String? memo,
675
bool? useUnconfirmed,
676
+ bool hasSilentPayment = false,
677
}) async {
350
- final utxos = <UtxoWithAddress>[];
351
- final privateKeys = <ECPrivate>[];
352
- final publicKeys = <String, PublicKeyWithDerivationPath>{};
353
-
354
- int allInputsAmount = 0;
355
- bool spendsUnconfirmedTX = false;
356
-
357
- int leftAmount = credentialsAmount;
358
- final sendingCoins = unspentCoins.where((utx) => utx.isSending && !utx.isFrozen).toList();
359
- final unconfirmedCoins = sendingCoins.where((utx) => utx.confirmations == 0).toList();
360
-
361
- for (int i = 0; i < sendingCoins.length; i++) {
362
- final utx = sendingCoins[i];
363
-
364
- final isUncormirmed = utx.confirmations == 0;
365
- if (useUnconfirmed != true && isUncormirmed) continue;
366
-
367
- if (!spendsUnconfirmedTX) spendsUnconfirmedTX = isUncormirmed;
368
-
369
- allInputsAmount += utx.value;
370
- leftAmount = leftAmount - utx.value;
371
-
372
- final address = addressTypeFromStr(utx.address, network);
373
-
374
- final hd =
375
- utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd;
376
- final derivationPath =
377
- "${_hardenedDerivationPath(walletInfo.derivationInfo?.derivationPath ?? "m/0'")}"
378
- "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}"
379
- "/${utx.bitcoinAddressRecord.index}";
380
- final pubKeyHex = hd.derive(utx.bitcoinAddressRecord.index).pubKey!;
381
-
382
- publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath);
383
-
384
- if (!walletInfo.isHardwareWallet) {
385
- final privkey =
386
- generateECPrivate(hd: hd, index: utx.bitcoinAddressRecord.index, network: network);
387
-
388
- privateKeys.add(privkey);
389
- }
390
-
391
- utxos.add(
392
- UtxoWithAddress(
393
- utxo: BitcoinUtxo(
394
- txHash: utx.hash,
395
- value: BigInt.from(utx.value),
396
- vout: utx.vout,
397
- scriptType: _getScriptType(address),
398
- ),
399
- ownerDetails: UtxoAddressDetails(
400
- publicKey: pubKeyHex,
401
- address: address,
402
- ),
403
- ),
404
- );
405
-
406
- bool amountIsAcquired = leftAmount <= 0;
407
- if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) {
408
- break;
409
- }
410
- }
411
-
412
- if (utxos.isEmpty) {
413
- throw BitcoinTransactionNoInputsException();
414
- }
678
+ final utxoDetails = _createUTXOS(
679
+ sendAll: false,
680
+ credentialsAmount: credentialsAmount,
681
+ inputsCount: inputsCount,
682
+ paysToSilentPayment: hasSilentPayment,
683
+ );
684
416
- final spendingAllCoins = sendingCoins.length == utxos.length;
417
- final spendingAllConfirmedCoins =
418
- !spendsUnconfirmedTX && utxos.length == sendingCoins.length - unconfirmedCoins.length;
685
+ final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length;
686
+ final spendingAllConfirmedCoins = !utxoDetails.spendsUnconfirmedTX &&
687
+ utxoDetails.utxos.length ==
688
+ utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length;
689
690
// How much is being spent - how much is being sent
421
- int amountLeftForChangeAndFee = allInputsAmount - credentialsAmount;
691
+ int amountLeftForChangeAndFee = utxoDetails.allInputsAmount - credentialsAmount;
692
693
if (amountLeftForChangeAndFee <= 0) {
694
if (!spendingAllCoins) {
696
credentialsAmount,
697
outputs,
698
feeRate,
429
- inputsCount: utxos.length + 1,
699
+ inputsCount: utxoDetails.utxos.length + 1,
700
memo: memo,
431
- useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins,
701
+ hasSilentPayment: hasSilentPayment,
702
);
703
}
704
+
705
throw BitcoinTransactionWrongBalanceException();
706
}
707
715
int estimatedSize;
716
if (network is BitcoinCashNetwork) {
717
estimatedSize = ForkedTransactionBuilder.estimateTransactionSize(
447
- utxos: utxos,
718
+ utxos: utxoDetails.utxos,
719
outputs: outputs,
720
network: network as BitcoinCashNetwork,
721
memo: memo,
722
);
723
} else {
724
estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize(
454
- utxos: utxos,
725
+ utxos: utxoDetails.utxos,
726
outputs: outputs,
727
network: network,
728
memo: memo,
729
+ inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
730
+ vinOutpoints: utxoDetails.vinOutpoints,
731
);
732
}
733
755
credentialsAmount,
756
outputs,
757
feeRate,
485
- inputsCount: utxos.length + 1,
758
+ inputsCount: utxoDetails.utxos.length + 1,
759
memo: memo,
760
useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins,
761
);
772
}
773
774
// Estimate to user how much is needed to send to cover the fee
502
- final maxAmountWithReturningChange = allInputsAmount - _dustAmount - fee - 1;
775
+ final maxAmountWithReturningChange = utxoDetails.allInputsAmount - _dustAmount - fee - 1;
776
throw BitcoinTransactionNoDustOnChangeException(
777
bitcoinAmountToString(amount: maxAmountWithReturningChange),
778
bitcoinAmountToString(amount: estimatedSendAll.amount),
790
throw BitcoinTransactionWrongBalanceException();
791
}
792
520
- if (totalAmount > allInputsAmount) {
793
+ if (totalAmount > utxoDetails.allInputsAmount) {
794
if (spendingAllCoins) {
795
throw BitcoinTransactionWrongBalanceException();
796
} else {
524
- if (amountLeftForChangeAndFee > fee) {
525
- outputs.removeLast();
526
- }
527
-
797
+ outputs.removeLast();
798
return estimateTxForAmount(
799
credentialsAmount,
800
outputs,
801
feeRate,
532
- inputsCount: utxos.length + 1,
802
+ inputsCount: utxoDetails.utxos.length + 1,
803
memo: memo,
804
useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins,
805
+ hasSilentPayment: hasSilentPayment,
806
);
807
}
808
}
809
810
return EstimatedTxResult(
540
- utxos: utxos,
541
- privateKeys: privateKeys,
542
- publicKeys: publicKeys,
811
+ utxos: utxoDetails.utxos,
812
+ inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
813
+ publicKeys: utxoDetails.publicKeys,
814
fee: fee,
815
amount: amount,
816
hasChange: true,
817
isSendAll: false,
818
memo: memo,
548
- spendsUnconfirmedTX: spendsUnconfirmedTX,
819
+ spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
820
+ spendsSilentPayment: utxoDetails.spendsSilentPayment,
821
);
822
}
823
831
final memo = transactionCredentials.outputs.first.memo;
832
833
int credentialsAmount = 0;
834
+ bool hasSilentPayment = false;
835
836
for (final out in transactionCredentials.outputs) {
837
final outputAmount = out.formattedCryptoAmount!;
851
final address =
852
addressTypeFromStr(out.isParsedAddress ? out.extractedAddress! : out.address, network);
853
854
+ if (address is SilentPaymentAddress) {
855
+ hasSilentPayment = true;
856
+ }
857
+
858
if (sendAll) {
859
// The value will be changed after estimating the Tx size and deducting the fee from the total to be sent
860
outputs.add(BitcoinOutput(address: address, value: BigInt.from(0)));
874
feeRateInt,
875
memo: memo,
876
credentialsAmount: credentialsAmount,
877
+ hasSilentPayment: hasSilentPayment,
878
);
879
} else {
880
estimatedTx = await estimateTxForAmount(
882
outputs,
883
feeRateInt,
884
memo: memo,
885
+ hasSilentPayment: hasSilentPayment,
886
);
887
}
888
941
bool hasTaprootInputs = false;
942
943
final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) {
665
- final key = estimatedTx.privateKeys
666
- .firstWhereOrNull((element) => element.getPublic().toHex() == publicKey);
944
+ final key = estimatedTx.inputPrivKeyInfos
945
+ .firstWhereOrNull((element) => element.privkey.getPublic().toHex() == publicKey);
946
947
if (key == null) {
948
throw Exception("Cannot find private key");
950
951
if (utxo.utxo.isP2tr()) {
952
hasTaprootInputs = true;
674
- return key.signTapRoot(txDigest, sighash: sighash);
953
+ return key.privkey.signTapRoot(
954
+ txDigest,
955
+ sighash: sighash,
956
+ tweak: utxo.utxo.isSilentPayment != true,
957
+ );
958
} else {
676
- return key.signInput(txDigest, sigHash: sighash);
959
+ return key.privkey.signInput(txDigest, sigHash: sighash);
960
}
961
});
962
973
hasTaprootInputs: hasTaprootInputs,
974
)..addListener((transaction) async {
975
transactionHistory.addOne(transaction);
976
+ if (estimatedTx.spendsSilentPayment) {
977
+ transactionHistory.transactions.values.forEach((tx) {
978
+ tx.unspents?.removeWhere(
979
+ (unspent) => estimatedTx.utxos.any((e) => e.utxo.txHash == unspent.hash));
980
+ transactionHistory.addOne(tx);
981
+ });
982
+ }
983
+
984
await updateBalance();
985
});
986
} catch (e) {
1014
'balance': balance[currency]?.toJSON(),
1015
'derivationTypeIndex': walletInfo.derivationInfo?.derivationType?.index,
1016
'derivationPath': walletInfo.derivationInfo?.derivationPath,
1017
+ 'silent_addresses': walletAddresses.silentAddresses.map((addr) => addr.toJSON()).toList(),
1018
+ 'silent_address_index': walletAddresses.currentSilentAddressIndex.toString(),
1019
});
1020
1021
int feeRate(TransactionPriority priority) {
1120
await transactionHistory.changePassword(password);
1121
}
1122
1123
+ @action
1124
@override
831
- Future<void> rescan({required int height}) async => throw UnimplementedError();
1125
+ Future<void> rescan(
1126
+ {required int height, int? chainTip, ScanData? scanData, bool? doSingleScan}) async {
1127
+ silentPaymentsScanningActive = true;
1128
+ _setListeners(height, doSingleScan: doSingleScan);
1129
+ }
1130
1131
@override
1132
Future<void> close() async {
1133
try {
1134
await electrumClient.close();
1135
} catch (_) {}
1136
+ _autoSaveTimer?.cancel();
1137
}
1138
1139
Future<String> makePath() async => pathForWallet(name: walletInfo.name, type: walletInfo.type);
1140
842
- Future<void> updateUnspent() async {
1141
+ @action
1142
+ Future<void> updateAllUnspents() async {
1143
List<BitcoinUnspent> updatedUnspentCoins = [];
1144
845
- final addressesSet = walletAddresses.allAddresses.map((addr) => addr.address).toSet();
846
-
847
- await Future.wait(walletAddresses.allAddresses.map((address) => electrumClient
848
- .getListUnspentWithAddress(address.address, network)
849
- .then((unspent) => Future.forEach<Map<String, dynamic>>(unspent, (unspent) async {
850
- try {
851
- final coin = BitcoinUnspent.fromJSON(address, unspent);
852
- final tx = await fetchTransactionInfo(
853
- hash: coin.hash, height: 0, myAddresses: addressesSet);
854
- coin.isChange = tx?.direction == TransactionDirection.outgoing;
855
- coin.confirmations = tx?.confirmations;
856
- updatedUnspentCoins.add(coin);
857
- } catch (_) {}
858
- }))));
1145
+ if (hasSilentPaymentsScanning) {
1146
+ // Update unspents stored from scanned silent payment transactions
1147
+ transactionHistory.transactions.values.forEach((tx) {
1148
+ if (tx.unspents != null) {
1149
+ updatedUnspentCoins.addAll(tx.unspents!);
1150
+ }
1151
+ });
1152
+ }
1153
+
1154
+ await Future.wait(walletAddresses.allAddresses.map((address) async {
1155
+ updatedUnspentCoins.addAll(await fetchUnspent(address));
1156
+ }));
1157
1158
unspentCoins = updatedUnspentCoins;
1159
1175
coin.isFrozen = coinInfo.isFrozen;
1176
coin.isSending = coinInfo.isSending;
1177
coin.note = coinInfo.note;
1178
+ if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
1179
+ coin.bitcoinAddressRecord.balance += coinInfo.value;
1180
} else {
1181
_addCoinInfo(coin);
1182
}
1186
await _refreshUnspentCoinsInfo();
1187
}
1188
1189
+ @action
1190
+ Future<void> updateUnspents(BitcoinAddressRecord address) async {
1191
+ final newUnspentCoins = await fetchUnspent(address);
1192
+
1193
+ if (newUnspentCoins.isNotEmpty) {
1194
+ unspentCoins.addAll(newUnspentCoins);
1195
+
1196
+ newUnspentCoins.forEach((coin) {
1197
+ final coinInfoList = unspentCoinsInfo.values.where(
1198
+ (element) =>
1199
+ element.walletId.contains(id) &&
1200
+ element.hash.contains(coin.hash) &&
1201
+ element.vout == coin.vout,
1202
+ );
1203
+
1204
+ if (coinInfoList.isNotEmpty) {
1205
+ final coinInfo = coinInfoList.first;
1206
+
1207
+ coin.isFrozen = coinInfo.isFrozen;
1208
+ coin.isSending = coinInfo.isSending;
1209
+ coin.note = coinInfo.note;
1210
+ if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
1211
+ coin.bitcoinAddressRecord.balance += coinInfo.value;
1212
+ } else {
1213
+ _addCoinInfo(coin);
1214
+ }
1215
+ });
1216
+ }
1217
+ }
1218
+
1219
+ @action
1220
+ Future<List<BitcoinUnspent>> fetchUnspent(BitcoinAddressRecord address) async {
1221
+ final unspents = await electrumClient.getListUnspent(address.getScriptHash(network));
1222
+
1223
+ List<BitcoinUnspent> updatedUnspentCoins = [];
1224
+
1225
+ await Future.wait(unspents.map((unspent) async {
1226
+ try {
1227
+ final coin = BitcoinUnspent.fromJSON(address, unspent);
1228
+ final tx = await fetchTransactionInfo(hash: coin.hash, height: 0);
1229
+ coin.isChange = address.isHidden;
1230
+ coin.confirmations = tx?.confirmations;
1231
+
1232
+ updatedUnspentCoins.add(coin);
1233
+ } catch (_) {}
1234
+ }));
1235
+
1236
+ return updatedUnspentCoins;
1237
+ }
1238
+
1239
+ @action
1240
Future<void> _addCoinInfo(BitcoinUnspent coin) async {
1241
final newInfo = UnspentCoinsInfo(
1242
walletId: id,
1248
value: coin.value,
1249
vout: coin.vout,
1250
isChange: coin.isChange,
1251
+ isSilentPayment: coin is BitcoinSilentPaymentsUnspent,
1252
);
1253
1254
await unspentCoinsInfo.add(newInfo);
1464
(await http.get(Uri.parse("https://blockstream.info/testnet/api/tx/$hash/status"))).body);
1465
1466
time = status["block_time"] as int?;
1115
- final tip = await electrumClient.getCurrentBlockChainTip() ?? 0;
1116
- confirmations = tip - (status["block_height"] as int? ?? 0);
1467
+ final height = status["block_height"] as int? ?? 0;
1468
+ final tip = await getCurrentChainTip();
1469
+ if (tip > 0) confirmations = height > 0 ? tip - height + 1 : 0;
1470
} else {
1471
final verboseTransaction = await electrumClient.getTransactionRaw(hash: hash);
1472
1475
confirmations = verboseTransaction['confirmations'] as int? ?? 0;
1476
}
1477
1125
- final original = bitcoin_base.BtcTransaction.fromRaw(transactionHex);
1126
- final ins = <bitcoin_base.BtcTransaction>[];
1478
+ final original = BtcTransaction.fromRaw(transactionHex);
1479
+ final ins = <BtcTransaction>[];
1480
1481
for (final vin in original.inputs) {
1129
- final txHex = await electrumClient.getTransactionHex(hash: vin.txId);
1130
- final tx = bitcoin_base.BtcTransaction.fromRaw(txHex);
1131
- ins.add(tx);
1482
+ ins.add(BtcTransaction.fromRaw(await electrumClient.getTransactionHex(hash: vin.txId)));
1483
}
1484
1485
return ElectrumTransactionBundle(
1491
}
1492
1493
Future<ElectrumTransactionInfo?> fetchTransactionInfo(
1143
- {required String hash,
1144
- required int height,
1145
- required Set<String> myAddresses,
1146
- bool? retryOnFailure}) async {
1494
+ {required String hash, required int height, bool? retryOnFailure}) async {
1495
try {
1496
return ElectrumTransactionInfo.fromElectrumBundle(
1497
await getTransactionExpanded(hash: hash), walletInfo.type, network,
1150
- addresses: myAddresses, height: height);
1498
+ addresses: addressesSet, height: height);
1499
} catch (e) {
1500
if (e is FormatException && retryOnFailure == true) {
1501
await Future.delayed(const Duration(seconds: 2));
1154
- return fetchTransactionInfo(hash: hash, height: height, myAddresses: myAddresses);
1502
+ return fetchTransactionInfo(hash: hash, height: height);
1503
}
1504
return null;
1505
}
1509
Future<Map<String, ElectrumTransactionInfo>> fetchTransactions() async {
1510
try {
1511
final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
1164
- final addressesSet = walletAddresses.allAddresses.map((addr) => addr.address).toSet();
1165
- final currentHeight = await electrumClient.getCurrentBlockChainTip() ?? 0;
1166
-
1167
- await Future.wait(ADDRESS_TYPES.map((type) {
1168
- final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type);
1169
-
1170
- return Future.wait(addressesByType.map((addressRecord) async {
1171
- final history = await _fetchAddressHistory(addressRecord, addressesSet, currentHeight);
1172
- final balance = await electrumClient.getBalance(addressRecord.scriptHash!);
1173
-
1174
- if (history.isNotEmpty) {
1175
- addressRecord.txCount = history.length;
1176
- addressRecord.balance = balance['confirmed'] as int? ?? 0;
1177
- historiesWithDetails.addAll(history);
1178
-
1179
- final matchedAddresses =
1180
- addressesByType.where((addr) => addr.isHidden == addressRecord.isHidden);
1181
-
1182
- final isLastUsedAddress =
1183
- history.isNotEmpty && addressRecord.address == matchedAddresses.last.address;
1184
-
1185
- if (isLastUsedAddress) {
1186
- await walletAddresses.discoverAddresses(
1187
- matchedAddresses.toList(),
1188
- addressRecord.isHidden,
1189
- (address, addressesSet) =>
1190
- _fetchAddressHistory(address, addressesSet, currentHeight)
1191
- .then((history) => history.isNotEmpty ? address.address : null),
1192
- type: type);
1193
- }
1194
- }
1195
- }));
1196
- }));
1512
+
1513
+ if (type == WalletType.bitcoin) {
1514
+ await Future.wait(ADDRESS_TYPES
1515
+ .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
1516
+ } else if (type == WalletType.bitcoinCash) {
1517
+ await fetchTransactionsForAddressType(historiesWithDetails, P2pkhAddressType.p2pkh);
1518
+ } else if (type == WalletType.litecoin) {
1519
+ await fetchTransactionsForAddressType(historiesWithDetails, SegwitAddresType.p2wpkh);
1520
+ }
1521
1522
return historiesWithDetails;
1523
} catch (e) {
1526
}
1527
}
1528
1529
+ Future<void> fetchTransactionsForAddressType(
1530
+ Map<String, ElectrumTransactionInfo> historiesWithDetails,
1531
+ BitcoinAddressType type,
1532
+ ) async {
1533
+ final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type);
1534
+ final hiddenAddresses = addressesByType.where((addr) => addr.isHidden == true);
1535
+ final receiveAddresses = addressesByType.where((addr) => addr.isHidden == false);
1536
+
1537
+ await Future.wait(addressesByType.map((addressRecord) async {
1538
+ final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip());
1539
+
1540
+ if (history.isNotEmpty) {
1541
+ addressRecord.txCount = history.length;
1542
+ historiesWithDetails.addAll(history);
1543
+
1544
+ final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses;
1545
+ final isUsedAddressUnderGap = matchedAddresses.toList().indexOf(addressRecord) >=
1546
+ matchedAddresses.length -
1547
+ (addressRecord.isHidden
1548
+ ? ElectrumWalletAddressesBase.defaultChangeAddressesCount
1549
+ : ElectrumWalletAddressesBase.defaultReceiveAddressesCount);
1550
+
1551
+ if (isUsedAddressUnderGap) {
1552
+ final prevLength = walletAddresses.allAddresses.length;
1553
+
1554
+ // Discover new addresses for the same address type until the gap limit is respected
1555
+ await walletAddresses.discoverAddresses(
1556
+ matchedAddresses.toList(),
1557
+ addressRecord.isHidden,
1558
+ (address) async {
1559
+ await _subscribeForUpdates();
1560
+ return _fetchAddressHistory(address, await getCurrentChainTip())
1561
+ .then((history) => history.isNotEmpty ? address.address : null);
1562
+ },
1563
+ type: type,
1564
+ );
1565
+
1566
+ final newLength = walletAddresses.allAddresses.length;
1567
+
1568
+ if (newLength > prevLength) {
1569
+ await fetchTransactionsForAddressType(historiesWithDetails, type);
1570
+ }
1571
+ }
1572
+ }
1573
+ }));
1574
+ }
1575
+
1576
Future<Map<String, ElectrumTransactionInfo>> _fetchAddressHistory(
1206
- BitcoinAddressRecord addressRecord, Set<String> addressesSet, int currentHeight) async {
1577
+ BitcoinAddressRecord addressRecord, int? currentHeight) async {
1578
try {
1579
final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
1580
1210
- final history = await electrumClient
1211
- .getHistory(addressRecord.scriptHash ?? addressRecord.updateScriptHash(network));
1581
+ final history = await electrumClient.getHistory(addressRecord.getScriptHash(network));
1582
1583
if (history.isNotEmpty) {
1584
addressRecord.setAsUsed();
1592
if (height > 0) {
1593
storedTx.height = height;
1594
// the tx's block itself is the first confirmation so add 1
1225
- storedTx.confirmations = currentHeight - height + 1;
1595
+ if (currentHeight != null) storedTx.confirmations = currentHeight - height + 1;
1596
storedTx.isPending = storedTx.confirmations == 0;
1597
}
1598
1599
historiesWithDetails[txid] = storedTx;
1600
} else {
1231
- final tx = await fetchTransactionInfo(
1232
- hash: txid, height: height, myAddresses: addressesSet, retryOnFailure: true);
1601
+ final tx = await fetchTransactionInfo(hash: txid, height: height, retryOnFailure: true);
1602
1603
if (tx != null) {
1604
historiesWithDetails[txid] = tx;
1627
return;
1628
}
1629
1630
+ transactionHistory.transactions.values.forEach((tx) async {
1631
+ if (tx.unspents != null && tx.unspents!.isNotEmpty && tx.height > 0) {
1632
+ tx.confirmations = await getCurrentChainTip() - tx.height + 1;
1633
+ }
1634
+ });
1635
+
1636
_isTransactionUpdating = true;
1637
await fetchTransactions();
1638
walletAddresses.updateReceiveAddresses();
1644
}
1645
}
1646
1272
- void _subscribeForUpdates() {
1273
- scriptHashes.forEach((sh) async {
1647
+ Future<void> _subscribeForUpdates() async {
1648
+ final unsubscribedScriptHashes = walletAddresses.allAddresses.where(
1649
+ (address) => !_scripthashesUpdateSubject.containsKey(address.getScriptHash(network)),
1650
+ );
1651
+
1652
+ await Future.wait(unsubscribedScriptHashes.map((address) async {
1653
+ final sh = address.getScriptHash(network);
1654
await _scripthashesUpdateSubject[sh]?.close();
1275
- _scripthashesUpdateSubject[sh] = electrumClient.scripthashUpdate(sh);
1655
+ _scripthashesUpdateSubject[sh] = await electrumClient.scripthashUpdate(sh);
1656
_scripthashesUpdateSubject[sh]?.listen((event) async {
1657
try {
1278
- await updateUnspent();
1658
+ await updateUnspents(address);
1659
+
1660
await updateBalance();
1280
- await updateTransactions();
1661
+
1662
+ await _fetchAddressHistory(address, await getCurrentChainTip());
1663
} catch (e, s) {
1664
print(e.toString());
1665
_onError?.call(FlutterErrorDetails(
1669
));
1670
}
1671
});
1290
- });
1672
+ }));
1673
}
1674
1675
Future<ElectrumBalance> _fetchBalances() async {
1683
}
1684
1685
var totalFrozen = 0;
1304
- unspentCoinsInfo.values.forEach((info) {
1305
- unspentCoins.forEach((element) {
1306
- if (element.hash == info.hash &&
1307
- element.vout == info.vout &&
1308
- info.isFrozen &&
1309
- element.bitcoinAddressRecord.address == info.address &&
1310
- element.value == info.value) {
1311
- totalFrozen += element.value;
1686
+ var totalConfirmed = 0;
1687
+ var totalUnconfirmed = 0;
1688
+
1689
+ if (hasSilentPaymentsScanning) {
1690
+ // Add values from unspent coins that are not fetched by the address list
1691
+ // i.e. scanned silent payments
1692
+ transactionHistory.transactions.values.forEach((tx) {
1693
+ if (tx.unspents != null) {
1694
+ tx.unspents!.forEach((unspent) {
1695
+ if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) {
1696
+ if (unspent.isFrozen) totalFrozen += unspent.value;
1697
+ totalConfirmed += unspent.value;
1698
+ }
1699
+ });
1700
}
1701
});
1314
- });
1702
+ }
1703
1704
final balances = await Future.wait(balanceFutures);
1317
- var totalConfirmed = 0;
1318
- var totalUnconfirmed = 0;
1705
1706
for (var i = 0; i < balances.length; i++) {
1707
final addressRecord = addresses[i];
1749
return base64Encode(HD.signMessage(message));
1750
}
1751
1752
+ Future<void> _setInitialHeight() async {
1753
+ if (_chainTipUpdateSubject != null) return;
1754
+
1755
+ if ((_currentChainTip == null || _currentChainTip! == 0) && walletInfo.restoreHeight == 0) {
1756
+ await getUpdatedChainTip();
1757
+ await walletInfo.updateRestoreHeight(_currentChainTip!);
1758
+ }
1759
+
1760
+ _chainTipUpdateSubject = electrumClient.chainTipSubscribe();
1761
+ _chainTipUpdateSubject?.listen((e) async {
1762
+ final event = e as Map<String, dynamic>;
1763
+ final height = int.tryParse(event['height'].toString());
1764
+
1765
+ if (height != null) {
1766
+ _currentChainTip = height;
1767
+
1768
+ if (alwaysScan == true && syncStatus is SyncedSyncStatus) {
1769
+ _setListeners(walletInfo.restoreHeight);
1770
+ }
1771
+ }
1772
+ });
1773
+ }
1774
+
1775
static BasedUtxoNetwork _getNetwork(bitcoin.NetworkType networkType, CryptoCurrency? currency) {
1776
if (networkType == bitcoin.bitcoin && currency == CryptoCurrency.bch) {
1777
return BitcoinCashNetwork.mainnet;
1792
derivationPath.substring(0, derivationPath.lastIndexOf("'") + 1);
1793
}
1794
1386
-class EstimateTxParams {
1387
- EstimateTxParams(
1388
- {required this.amount,
1389
- required this.feeRate,
1390
- required this.priority,
1391
- required this.outputsCount,
1392
- required this.size});
1795
+class ScanNode {
1796
+ final Uri uri;
1797
+ final bool? useSSL;
1798
1394
- final int amount;
1395
- final int feeRate;
1396
- final TransactionPriority priority;
1397
- final int outputsCount;
1398
- final int size;
1799
+ ScanNode(this.uri, this.useSSL);
1800
+}
1801
+
1802
+class ScanData {
1803
+ final SendPort sendPort;
1804
+ final SilentPaymentOwner silentAddress;
1805
+ final int height;
1806
+ final ScanNode node;
1807
+ final BasedUtxoNetwork network;
1808
+ final int chainTip;
1809
+ final ElectrumClient electrumClient;
1810
+ final List<String> transactionHistoryIds;
1811
+ final Map<String, String> labels;
1812
+ final List<int> labelIndexes;
1813
+ final bool isSingleScan;
1814
+
1815
+ ScanData({
1816
+ required this.sendPort,
1817
+ required this.silentAddress,
1818
+ required this.height,
1819
+ required this.node,
1820
+ required this.network,
1821
+ required this.chainTip,
1822
+ required this.electrumClient,
1823
+ required this.transactionHistoryIds,
1824
+ required this.labels,
1825
+ required this.labelIndexes,
1826
+ required this.isSingleScan,
1827
+ });
1828
+
1829
+ factory ScanData.fromHeight(ScanData scanData, int newHeight) {
1830
+ return ScanData(
1831
+ sendPort: scanData.sendPort,
1832
+ silentAddress: scanData.silentAddress,
1833
+ height: newHeight,
1834
+ node: scanData.node,
1835
+ network: scanData.network,
1836
+ chainTip: scanData.chainTip,
1837
+ transactionHistoryIds: scanData.transactionHistoryIds,
1838
+ electrumClient: scanData.electrumClient,
1839
+ labels: scanData.labels,
1840
+ labelIndexes: scanData.labelIndexes,
1841
+ isSingleScan: scanData.isSingleScan,
1842
+ );
1843
+ }
1844
+}
1845
+
1846
+class SyncResponse {
1847
+ final int height;
1848
+ final SyncStatus syncStatus;
1849
+
1850
+ SyncResponse(this.height, this.syncStatus);
1851
+}
1852
+
1853
+Future<void> startRefresh(ScanData scanData) async {
1854
+ int syncHeight = scanData.height;
1855
+ int initialSyncHeight = syncHeight;
1856
+
1857
+ BehaviorSubject<Object>? tweaksSubscription = null;
1858
+
1859
+ final syncingStatus = scanData.isSingleScan
1860
+ ? SyncingSyncStatus(1, 0)
1861
+ : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, syncHeight);
1862
+
1863
+ // Initial status UI update, send how many blocks left to scan
1864
+ scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus));
1865
+
1866
+ final electrumClient = scanData.electrumClient;
1867
+ await electrumClient.connectToUri(scanData.node.uri, useSSL: scanData.node.useSSL);
1868
+
1869
+ if (tweaksSubscription == null) {
1870
+ final count = scanData.isSingleScan ? 1 : TWEAKS_COUNT;
1871
+ final receiver = Receiver(
1872
+ scanData.silentAddress.b_scan.toHex(),
1873
+ scanData.silentAddress.B_spend.toHex(),
1874
+ scanData.network == BitcoinNetwork.testnet,
1875
+ scanData.labelIndexes,
1876
+ scanData.labelIndexes.length,
1877
+ );
1878
+
1879
+ tweaksSubscription = await electrumClient.tweaksSubscribe(height: syncHeight, count: count);
1880
+ tweaksSubscription?.listen((t) async {
1881
+ final tweaks = t as Map<String, dynamic>;
1882
+
1883
+ if (tweaks["message"] != null) {
1884
+ // re-subscribe to continue receiving messages
1885
+ electrumClient.tweaksSubscribe(height: syncHeight, count: count);
1886
+ return;
1887
+ }
1888
+
1889
+ final blockHeight = tweaks.keys.first;
1890
+ final tweakHeight = int.parse(blockHeight);
1891
+
1892
+ try {
1893
+ final blockTweaks = tweaks[blockHeight] as Map<String, dynamic>;
1894
+
1895
+ for (var j = 0; j < blockTweaks.keys.length; j++) {
1896
+ final txid = blockTweaks.keys.elementAt(j);
1897
+ final details = blockTweaks[txid] as Map<String, dynamic>;
1898
+ final outputPubkeys = (details["output_pubkeys"] as Map<dynamic, dynamic>);
1899
+ final tweak = details["tweak"].toString();
1900
+
1901
+ try {
1902
+ // scanOutputs called from rust here
1903
+ final addToWallet = scanOutputs(
1904
+ outputPubkeys.values.toList(),
1905
+ tweak,
1906
+ receiver,
1907
+ );
1908
+
1909
+ if (addToWallet.isEmpty) {
1910
+ // no results tx, continue to next tx
1911
+ continue;
1912
+ }
1913
+
1914
+ // placeholder ElectrumTransactionInfo object to update values based on new scanned unspent(s)
1915
+ final txInfo = ElectrumTransactionInfo(
1916
+ WalletType.bitcoin,
1917
+ id: txid,
1918
+ height: tweakHeight,
1919
+ amount: 0,
1920
+ fee: 0,
1921
+ direction: TransactionDirection.incoming,
1922
+ isPending: false,
1923
+ date: scanData.network == BitcoinNetwork.mainnet
1924
+ ? getDateByBitcoinHeight(tweakHeight)
1925
+ : DateTime.now(),
1926
+ confirmations: scanData.chainTip - tweakHeight + 1,
1927
+ unspents: [],
1928
+ );
1929
+
1930
+ addToWallet.forEach((label, value) {
1931
+ (value as Map<String, dynamic>).forEach((output, tweak) {
1932
+ final t_k = tweak.toString();
1933
+
1934
+ final receivingOutputAddress = ECPublic.fromHex(output)
1935
+ .toTaprootAddress(tweak: false)
1936
+ .toAddress(scanData.network);
1937
+
1938
+ int? amount;
1939
+ int? pos;
1940
+ outputPubkeys.entries.firstWhere((k) {
1941
+ final isMatchingOutput = k.value[0] == output;
1942
+ if (isMatchingOutput) {
1943
+ amount = int.parse(k.value[1].toString());
1944
+ pos = int.parse(k.key.toString());
1945
+ return true;
1946
+ }
1947
+ return false;
1948
+ });
1949
+
1950
+ final receivedAddressRecord = BitcoinSilentPaymentAddressRecord(
1951
+ receivingOutputAddress,
1952
+ index: 0,
1953
+ isHidden: false,
1954
+ isUsed: true,
1955
+ network: scanData.network,
1956
+ silentPaymentTweak: t_k,
1957
+ type: SegwitAddresType.p2tr,
1958
+ txCount: 1,
1959
+ balance: amount!,
1960
+ );
1961
+
1962
+ final unspent = BitcoinSilentPaymentsUnspent(
1963
+ receivedAddressRecord,
1964
+ txid,
1965
+ amount!,
1966
+ pos!,
1967
+ silentPaymentTweak: t_k,
1968
+ silentPaymentLabel: label == "None" ? null : label,
1969
+ );
1970
+
1971
+ txInfo.unspents!.add(unspent);
1972
+ txInfo.amount += unspent.value;
1973
+ });
1974
+ });
1975
+
1976
+ scanData.sendPort.send({txInfo.id: txInfo});
1977
+ } catch (_) {}
1978
+ }
1979
+ } catch (_) {}
1980
+
1981
+ syncHeight = tweakHeight;
1982
+ scanData.sendPort.send(
1983
+ SyncResponse(
1984
+ syncHeight,
1985
+ SyncingSyncStatus.fromHeightValues(
1986
+ scanData.chainTip,
1987
+ initialSyncHeight,
1988
+ syncHeight,
1989
+ ),
1990
+ ),
1991
+ );
1992
+
1993
+ if (tweakHeight >= scanData.chainTip || scanData.isSingleScan) {
1994
+ if (tweakHeight >= scanData.chainTip)
1995
+ scanData.sendPort.send(SyncResponse(
1996
+ syncHeight,
1997
+ SyncedTipSyncStatus(scanData.chainTip),
1998
+ ));
1999
+
2000
+ if (scanData.isSingleScan) {
2001
+ scanData.sendPort.send(SyncResponse(syncHeight, SyncedSyncStatus()));
2002
+ }
2003
+
2004
+ await tweaksSubscription!.close();
2005
+ await electrumClient.close();
2006
+ }
2007
+ });
2008
+ }
2009
+
2010
+ if (tweaksSubscription == null) {
2011
+ return scanData.sendPort.send(
2012
+ SyncResponse(syncHeight, UnsupportedSyncStatus()),
2013
+ );
2014
+ }
2015
}
2016
2017
class EstimatedTxResult {
2018
EstimatedTxResult({
2019
required this.utxos,
1404
- required this.privateKeys,
2020
+ required this.inputPrivKeyInfos,
2021
required this.publicKeys,
2022
required this.fee,
2023
required this.amount,
2024
required this.hasChange,
2025
required this.isSendAll,
2026
this.memo,
2027
+ required this.spendsSilentPayment,
2028
required this.spendsUnconfirmedTX,
2029
});
2030
2031
final List<UtxoWithAddress> utxos;
1415
- final List<ECPrivate> privateKeys;
2032
+ final List<ECPrivateInfo> inputPrivKeyInfos;
2033
final Map<String, PublicKeyWithDerivationPath> publicKeys; // PubKey to derivationPath
2034
final int fee;
2035
final int amount;
2036
+ final bool spendsSilentPayment;
2037
final bool hasChange;
2038
final bool isSendAll;
2039
final String? memo;
2065
return P2wshAddress.fromAddress(address: address, network: network);
2066
} else if (P2trAddress.regex.hasMatch(address)) {
2067
return P2trAddress.fromAddress(address: address, network: network);
2068
+ } else if (SilentPaymentAddress.regex.hasMatch(address)) {
2069
+ return SilentPaymentAddress.fromAddress(address);
2070
} else {
2071
return P2wpkhAddress.fromAddress(address: address, network: network);
2072
}
2081
return SegwitAddresType.p2wsh;
2082
} else if (type is P2trAddress) {
2083
return SegwitAddresType.p2tr;
2084
+ } else if (type is SilentPaymentsAddresType) {
2085
+ return SilentPaymentsAddresType.p2sp;
2086
} else {
2087
return SegwitAddresType.p2wpkh;
2088
}
2089
}
2090
+
2091
+class UtxoDetails {
2092
+ final List<BitcoinUnspent> availableInputs;
2093
+ final List<BitcoinUnspent> unconfirmedCoins;
2094
+ final List<UtxoWithAddress> utxos;
2095
+ final List<Outpoint> vinOutpoints;
2096
+ final List<ECPrivateInfo> inputPrivKeyInfos;
2097
+ final Map<String, PublicKeyWithDerivationPath> publicKeys; // PubKey to derivationPath
2098
+ final int allInputsAmount;
2099
+ final bool spendsSilentPayment;
2100
+ final bool spendsUnconfirmedTX;
2101
+
2102
+ UtxoDetails({
2103
+ required this.availableInputs,
2104
+ required this.unconfirmedCoins,
2105
+ required this.utxos,
2106
+ required this.vinOutpoints,
2107
+ required this.inputPrivKeyInfos,
2108
+ required this.publicKeys,
2109
+ required this.allInputsAmount,
2110
+ required this.spendsSilentPayment,
2111
+ required this.spendsUnconfirmedTX,
2112
+ });
2113
+}