fix: silent payments scanning using v10 (#2355)

* fix: silent payments scanning using v10 * chore: no http * fix: - always scan shows the toggle as disabled after restart - refresh cancels sp scanning * Update cw_bitcoin/lib/electrum_wallet.dart [skip ci] * Update cw_bitcoin/lib/electrum_wallet.dart [skip ci] * fix: get estimated date of sp tx --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

rafael_xmr committed Jul 8, 2025 at 23:17 UTC 7d0f9323c871affd655075ffe9dfdbc739e0e475
6 files changed +283 -43
cw_bitcoin/lib/electrum_wallet.dart
+274 -37
@@ -6,7 +6,6 @@ import 'dart:isolate';
6 import 'package:bitcoin_base/bitcoin_base.dart';
7 import 'package:cw_core/utils/proxy_wrapper.dart';
8 import 'package:cw_bitcoin/bitcoin_amount_format.dart';
9 -import 'package:cw_core/format_amount.dart';
9 import 'package:cw_core/utils/print_verbose.dart';
10 import 'package:cw_bitcoin/bitcoin_wallet.dart';
11 import 'package:cw_bitcoin/litecoin_wallet.dart';
@@ -19,7 +18,7 @@ import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
18 import 'package:cw_bitcoin/bitcoin_transaction_priority.dart';
19 import 'package:cw_bitcoin/bitcoin_unspent.dart';
20 import 'package:cw_bitcoin/bitcoin_wallet_keys.dart';
22 -import 'package:cw_bitcoin/electrum.dart';
21 +import 'package:cw_bitcoin/electrum.dart' as electrum;
22 import 'package:cw_bitcoin/electrum_balance.dart';
23 import 'package:cw_bitcoin/electrum_derivations.dart';
24 import 'package:cw_bitcoin/electrum_transaction_history.dart';
@@ -69,10 +68,10 @@ abstract class ElectrumWalletBase
68 Uint8List? seedBytes,
69 this.passphrase,
70 List<BitcoinAddressRecord>? initialAddresses,
72 - ElectrumClient? electrumClient,
71 + electrum.ElectrumClient? electrumClient,
72 ElectrumBalance? initialBalance,
73 CryptoCurrency? currency,
75 - this.alwaysScan,
74 + bool? alwaysScan,
75 }) : accountHD =
76 getAccountHDWallet(currency, network, seedBytes, xpub, walletInfo.derivationInfo),
77 syncStatus = NotConnectedSyncStatus(),
@@ -82,6 +81,8 @@ abstract class ElectrumWalletBase
81 isEnabledAutoGenerateSubaddress = true,
82 unspentCoins = [],
83 _scripthashesUpdateSubject = {},
84 + this.alwaysScan = alwaysScan,
85 + silentPaymentsScanningActive = alwaysScan ?? false,
86 balance = ObservableMap<CryptoCurrency, ElectrumBalance>.of(currency != null
87 ? {
88 currency: initialBalance ??
@@ -96,7 +97,7 @@ abstract class ElectrumWalletBase
97 this.isTestnet = !network.isMainnet,
98 this._mnemonic = mnemonic,
99 super(walletInfo) {
99 - this.electrumClient = electrumClient ?? ElectrumClient();
100 + this.electrumClient = electrumClient ?? electrum.ElectrumClient();
101 this.walletInfo = walletInfo;
102 transactionHistory = ElectrumTransactionHistory(
103 walletInfo: walletInfo,
@@ -167,7 +168,7 @@ abstract class ElectrumWalletBase
168 @observable
169 bool isEnabledAutoGenerateSubaddress;
170
170 - late ElectrumClient electrumClient;
171 + late electrum.ElectrumClient electrumClient;
172 Box<UnspentCoinsInfo> unspentCoinsInfo;
173
174 @override
@@ -333,14 +334,14 @@ abstract class ElectrumWalletBase
334
335 final receivePort = ReceivePort();
336 _isolate = Isolate.spawn(
336 - startRefresh,
337 + _handleScanSilentPayments,
338 ScanData(
339 sendPort: receivePort.sendPort,
340 silentAddress: walletAddresses.silentAddress!,
341 network: network,
342 height: height,
343 chainTip: chainTip,
343 - electrumClient: ElectrumClient(),
344 + electrumClient: electrum.ElectrumClient(),
345 transactionHistoryIds: transactionHistory.transactions.keys.toList(),
346 node: (await getNodeSupportsSilentPayments()) == true
347 ? ScanNode(node!.uri, node!.useSSL)
@@ -439,7 +440,6 @@ abstract class ElectrumWalletBase
440 BigintUtils.fromBytes(BytesUtils.fromHexString(unspent.silentPaymentLabel!)),
441 )
442 : silentAddress.B_spend,
442 - network: network,
443 );
444
445 final addressRecord = walletAddresses.silentAddresses
@@ -463,6 +463,7 @@ abstract class ElectrumWalletBase
463 syncStatus = SyncronizingSyncStatus();
464
465 if (hasSilentPaymentsScanning) {
466 + silentPaymentsScanningActive = alwaysScan ?? false;
467 await _setInitialHeight();
468 }
469
@@ -477,7 +478,7 @@ abstract class ElectrumWalletBase
478 Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates());
479
480 if (alwaysScan == true) {
480 - _setListeners(walletInfo.restoreHeight);
481 + setSilentPaymentsScanning(true);
482 } else {
483 if (syncStatus is LostConnectionSyncStatus) return;
484 syncStatus = SyncedSyncStatus();
@@ -493,9 +494,10 @@ abstract class ElectrumWalletBase
494 Future<void> updateFeeRates() async {
495 if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) {
496 try {
496 - final response = await ProxyWrapper()
497 - .get(clearnetUri: Uri.parse("https://mempool.cakewallet.com/api/v1/fees/recommended"))
498 - .timeout(Duration(seconds: 15));
497 + final response = await ProxyWrapper()
498 + .get(clearnetUri: Uri.parse("https://mempool.cakewallet.com/api/v1/fees/recommended"))
499 + .timeout(Duration(seconds: 15));
500 +
501 final result = json.decode(response.body) as Map<String, dynamic>;
502 final slowFee = (result['economyFee'] as num?)?.toInt() ?? 0;
503 int mediumFee = (result['hourFee'] as num?)?.toInt() ?? 0;
@@ -563,7 +565,7 @@ abstract class ElectrumWalletBase
565 node!.save();
566 return node!.supportsSilentPayments!;
567 }
566 - } on RequestFailedTimeoutException catch (_) {
568 + } on electrum.RequestFailedTimeoutException catch (_) {
569 node!.supportsSilentPayments = false;
570 node!.save();
571 return node!.supportsSilentPayments!;
@@ -1877,17 +1879,20 @@ abstract class ElectrumWalletBase
1879
1880 if (height != null && height > 0 && await checkIfMempoolAPIIsEnabled()) {
1881 try {
1880 - final blockHash = await ProxyWrapper()
1881 - .get(clearnetUri: Uri.parse("https://mempool.cakewallet.com/api/v1/block-height/$height"))
1882 - .timeout(Duration(seconds: 15));
1882 + final blockHash = await ProxyWrapper().get(
1883 + clearnetUri: Uri.parse(
1884 + "https://mempool.cakewallet.com/api/v1/block-height/$height",
1885 + ),
1886 + ).timeout(Duration(seconds: 15));
1887
1888 if (blockHash.statusCode == 200 &&
1889 blockHash.body.isNotEmpty &&
1890 jsonDecode(blockHash.body) != null) {
1887 - final blockResponse = await ProxyWrapper()
1888 - .get(clearnetUri: Uri.parse("https://mempool.cakewallet.com/api/v1/block/${blockHash}"))
1889 - .timeout(Duration(seconds: 15));
1890 -
1891 + final blockResponse = await ProxyWrapper().get(
1892 + clearnetUri: Uri.parse(
1893 + "https://mempool.cakewallet.com/api/v1/block/${blockHash.body}",
1894 + ),
1895 + ).timeout(Duration(seconds: 15));
1896 if (blockResponse.statusCode == 200 &&
1897 blockResponse.body.isNotEmpty &&
1898 jsonDecode(blockResponse.body)['timestamp'] != null) {
@@ -2390,9 +2395,9 @@ abstract class ElectrumWalletBase
2395 derivationPath.substring(0, derivationPath.lastIndexOf("'") + 1);
2396
2397 @action
2393 - void _onConnectionStatusChange(ConnectionStatus status) {
2398 + void _onConnectionStatusChange(electrum.ConnectionStatus status) {
2399 switch (status) {
2395 - case ConnectionStatus.connected:
2400 + case electrum.ConnectionStatus.connected:
2401 if (syncStatus is NotConnectedSyncStatus ||
2402 syncStatus is LostConnectionSyncStatus ||
2403 syncStatus is ConnectingSyncStatus) {
@@ -2400,19 +2405,19 @@ abstract class ElectrumWalletBase
2405 }
2406
2407 break;
2403 - case ConnectionStatus.disconnected:
2408 + case electrum.ConnectionStatus.disconnected:
2409 if (syncStatus is! NotConnectedSyncStatus &&
2410 syncStatus is! ConnectingSyncStatus &&
2411 syncStatus is! SyncronizingSyncStatus) {
2412 syncStatus = NotConnectedSyncStatus();
2413 }
2414 break;
2410 - case ConnectionStatus.failed:
2415 + case electrum.ConnectionStatus.failed:
2416 if (syncStatus is! LostConnectionSyncStatus) {
2417 syncStatus = LostConnectionSyncStatus();
2418 }
2419 break;
2415 - case ConnectionStatus.connecting:
2420 + case electrum.ConnectionStatus.connecting:
2421 if (syncStatus is! ConnectingSyncStatus) {
2422 syncStatus = ConnectingSyncStatus();
2423 }
@@ -2524,7 +2529,7 @@ class ScanData {
2529 final ScanNode? node;
2530 final BasedUtxoNetwork network;
2531 final int chainTip;
2527 - final ElectrumClient electrumClient;
2532 + final electrum.ElectrumClient electrumClient;
2533 final List<String> transactionHistoryIds;
2534 final Map<String, String> labels;
2535 final List<int> labelIndexes;
@@ -2568,6 +2573,236 @@ class SyncResponse {
2573 SyncResponse(this.height, this.syncStatus);
2574 }
2575
2576 +Future<void> _handleScanSilentPayments(ScanData scanData) async {
2577 + try {
2578 + // if (scanData.shouldSwitchNodes) {
2579 + var scanningClient = await ElectrumProvider.connect(
2580 + ElectrumTCPService.connect(
2581 + Uri.parse("tcp://electrs.cakewallet.com:50001"),
2582 + ),
2583 + );
2584 + // }
2585 +
2586 + int syncHeight = scanData.height;
2587 + int initialSyncHeight = syncHeight;
2588 +
2589 + final receiver = Receiver(
2590 + scanData.silentAddress.b_scan.toHex(),
2591 + scanData.silentAddress.B_spend.toHex(),
2592 + scanData.network == BitcoinNetwork.testnet,
2593 + scanData.labelIndexes,
2594 + scanData.labelIndexes.length,
2595 + );
2596 +
2597 + int getCountToScanPerRequest(int syncHeight) {
2598 + if (scanData.isSingleScan) {
2599 + return 1;
2600 + }
2601 +
2602 + final amountLeft = scanData.chainTip - syncHeight + 1;
2603 + return amountLeft;
2604 + }
2605 +
2606 + // Initial status UI update, send how many blocks in total to scan
2607 + scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight)));
2608 +
2609 + final req = ElectrumTweaksSubscribe(
2610 + height: syncHeight,
2611 + count: getCountToScanPerRequest(syncHeight),
2612 + historicalMode: false,
2613 + );
2614 +
2615 + var _scanningStream = await scanningClient.subscribe(req);
2616 +
2617 + void listenFn(Map<String, dynamic> event, ElectrumTweaksSubscribe req) {
2618 + final response = req.onResponse(event);
2619 +
2620 + if (response == null || _scanningStream == null) {
2621 + return;
2622 + }
2623 +
2624 + // is success or error msg
2625 + final noData = response.message != null;
2626 +
2627 + if (noData) {
2628 + if (scanData.isSingleScan) {
2629 + return;
2630 + }
2631 +
2632 + // re-subscribe to continue receiving messages, starting from the next unscanned height
2633 + final nextHeight = syncHeight + 1;
2634 +
2635 + if (nextHeight <= scanData.chainTip) {
2636 + final nextStream = scanningClient.subscribe(
2637 + ElectrumTweaksSubscribe(
2638 + height: nextHeight,
2639 + count: getCountToScanPerRequest(nextHeight),
2640 + historicalMode: false,
2641 + ),
2642 + );
2643 +
2644 + if (nextStream != null) {
2645 + nextStream.listen((event) => listenFn(event, req));
2646 + } else {
2647 + scanData.sendPort.send(
2648 + SyncResponse(scanData.height, LostConnectionSyncStatus()),
2649 + );
2650 + }
2651 + }
2652 +
2653 + return;
2654 + }
2655 +
2656 + final tweakHeight = response.block;
2657 +
2658 + if (initialSyncHeight < tweakHeight) initialSyncHeight = tweakHeight;
2659 +
2660 + // Continuous status UI update, send how many blocks left to scan
2661 + final syncingStatus = scanData.isSingleScan
2662 + ? SyncingSyncStatus(1, 0)
2663 + : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, tweakHeight);
2664 +
2665 + scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus));
2666 +
2667 + try {
2668 + final blockTweaks = response.blockTweaks;
2669 +
2670 + for (final txid in blockTweaks.keys) {
2671 + final tweakData = blockTweaks[txid];
2672 + final outputPubkeys = tweakData!.outputPubkeys;
2673 + final tweak = tweakData.tweak;
2674 +
2675 + try {
2676 + final addToWallet = {};
2677 +
2678 + // receivers.forEach((receiver) {
2679 + // NOTE: scanOutputs, from sp_scanner package, called from rust here
2680 + final scanResult = scanOutputs([outputPubkeys.keys.toList()], tweak, receiver);
2681 +
2682 + if (scanResult.isEmpty) {
2683 + continue;
2684 + }
2685 +
2686 + if (addToWallet[receiver.BSpend] == null) {
2687 + addToWallet[receiver.BSpend] = scanResult;
2688 + } else {
2689 + addToWallet[receiver.BSpend].addAll(scanResult);
2690 + }
2691 + // });
2692 +
2693 + if (addToWallet.isEmpty) {
2694 + // no results tx, continue to next tx
2695 + continue;
2696 + }
2697 +
2698 + // initial placeholder ElectrumTransactionInfo object to update values based on new scanned unspent(s) on the following loop
2699 + final txInfo = ElectrumTransactionInfo(
2700 + WalletType.bitcoin,
2701 + id: txid,
2702 + height: tweakHeight,
2703 + amount: 0,
2704 + fee: 0,
2705 + direction: TransactionDirection.incoming,
2706 + isReplaced: false,
2707 + // TODO: fetch block data and get the date from it
2708 + date: scanData.network == BitcoinNetwork.mainnet
2709 + ? getDateByBitcoinHeight(tweakHeight)
2710 + : DateTime.now(),
2711 + confirmations: scanData.chainTip - tweakHeight + 1,
2712 + isReceivedSilentPayment: true,
2713 + isPending: false,
2714 + unspents: [],
2715 + );
2716 +
2717 + List<BitcoinUnspent> unspents = [];
2718 +
2719 + addToWallet.forEach((BSpend, scanResultPerLabel) {
2720 + scanResultPerLabel.forEach((label, scanOutput) {
2721 + final labelValue = label == "None" ? null : label.toString();
2722 +
2723 + (scanOutput as Map<String, dynamic>).forEach((outputPubkey, tweak) {
2724 + final t_k = tweak as String;
2725 +
2726 + final receivingOutputAddress = ECPublic.fromHex(outputPubkey)
2727 + .toTaprootAddress(tweak: false)
2728 + .toAddress(scanData.network);
2729 +
2730 + final matchingOutput = outputPubkeys[outputPubkey]!;
2731 + final amount = matchingOutput.amount;
2732 + final pos = matchingOutput.vout;
2733 +
2734 + // final matchingSPWallet = scanData.silentPaymentsWallets.firstWhere(
2735 + // (receiver) => receiver.B_spend.toHex() == BSpend.toString(),
2736 + // );
2737 +
2738 + // final labelIndex = labelValue != null ? scanData.labels[label] : 0;
2739 + // final balance = ElectrumBalance();
2740 + // balance.confirmed = amount;
2741 +
2742 + final receivedAddressRecord = BitcoinSilentPaymentAddressRecord(
2743 + receivingOutputAddress,
2744 + index: 0,
2745 + isHidden: false,
2746 + isUsed: true,
2747 + network: scanData.network,
2748 + silentPaymentTweak: t_k,
2749 + type: SegwitAddresType.p2tr,
2750 + txCount: 1,
2751 + balance: amount,
2752 + );
2753 +
2754 + final unspent = BitcoinSilentPaymentsUnspent(
2755 + receivedAddressRecord,
2756 + txid,
2757 + amount,
2758 + pos,
2759 + silentPaymentTweak: t_k,
2760 + silentPaymentLabel: labelValue,
2761 + );
2762 +
2763 + unspents.add(unspent);
2764 + txInfo.unspents!.add(unspent);
2765 + txInfo.amount += unspent.value;
2766 + });
2767 + });
2768 + });
2769 +
2770 + scanData.sendPort.send({txInfo.id: txInfo});
2771 + } catch (e, stacktrace) {
2772 + printV(stacktrace);
2773 + printV(e.toString());
2774 + }
2775 + }
2776 + } catch (e, stacktrace) {
2777 + printV(stacktrace);
2778 + printV(e.toString());
2779 + }
2780 +
2781 + syncHeight = tweakHeight;
2782 +
2783 + if ((tweakHeight >= scanData.chainTip) || scanData.isSingleScan) {
2784 + if (tweakHeight >= scanData.chainTip)
2785 + scanData.sendPort.send(
2786 + SyncResponse(syncHeight, SyncedTipSyncStatus(scanData.chainTip)),
2787 + );
2788 +
2789 + if (scanData.isSingleScan) {
2790 + scanData.sendPort.send(SyncResponse(syncHeight, SyncedSyncStatus()));
2791 + }
2792 +
2793 + _scanningStream?.close();
2794 + _scanningStream = null;
2795 + return;
2796 + }
2797 + }
2798 +
2799 + _scanningStream?.listen((event) => listenFn(event, req));
2800 + } catch (e) {
2801 + printV("Error in _handleScanSilentPayments: $e");
2802 + scanData.sendPort.send(SyncResponse(scanData.height, LostConnectionSyncStatus()));
2803 + }
2804 +}
2805 +
2806 Future<void> startRefresh(ScanData scanData) async {
2807 int syncHeight = scanData.height;
2808 int initialSyncHeight = syncHeight;
@@ -2580,7 +2815,7 @@ Future<void> startRefresh(ScanData scanData) async {
2815 useSSL: scanData.node?.useSSL ?? false,
2816 );
2817
2583 - int getCountPerRequest(int syncHeight) {
2818 + int getCountToScanPerRequest(int syncHeight) {
2819 if (scanData.isSingleScan) {
2820 return 1;
2821 }
@@ -2599,7 +2834,7 @@ Future<void> startRefresh(ScanData scanData) async {
2834 );
2835
2836 // Initial status UI update, send how many blocks in total to scan
2602 - final initialCount = getCountPerRequest(syncHeight);
2837 + final initialCount = getCountToScanPerRequest(syncHeight);
2838 scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight)));
2839
2840 tweaksSubscription = await electrumClient.tweaksSubscribe(
@@ -2610,22 +2845,24 @@ Future<void> startRefresh(ScanData scanData) async {
2845 Future<void> listenFn(t) async {
2846 final tweaks = t as Map<String, dynamic>;
2847 final msg = tweaks["message"];
2613 - // success or error msg
2848 +
2849 + // is success or error msg
2850 final noData = msg != null;
2851
2852 if (noData) {
2853 + if (scanData.isSingleScan) {
2854 + return;
2855 + }
2856 +
2857 // re-subscribe to continue receiving messages, starting from the next unscanned height
2858 final nextHeight = syncHeight + 1;
2619 - final nextCount = getCountPerRequest(nextHeight);
2620 -
2621 - if (nextCount > 0) {
2622 - tweaksSubscription?.close();
2859
2624 - final nextTweaksSubscription = electrumClient.tweaksSubscribe(
2860 + if (nextHeight <= scanData.chainTip) {
2861 + final nextStream = electrumClient.tweaksSubscribe(
2862 height: nextHeight,
2626 - count: nextCount,
2863 + count: getCountToScanPerRequest(nextHeight),
2864 );
2628 - nextTweaksSubscription?.listen(listenFn);
2865 + nextStream?.listen(listenFn);
2866 }
2867
2868 return;
cw_bitcoin/pubspec.lock
+2 -2
@@ -84,8 +84,8 @@ packages:
84 dependency: "direct overridden"
85 description:
86 path: "."
87 - ref: cake-update-v9
88 - resolved-ref: bb4318511312a454fd91bf49042e25ecc855e4ac
87 + ref: cake-update-v10
88 + resolved-ref: "545bcade1e8c7691553bbd811f51bc54c333b19f"
89 url: "https://github.com/cake-tech/bitcoin_base"
90 source: git
91 version: "4.7.0"
cw_bitcoin/pubspec.yaml
+1 -1
@@ -73,7 +73,7 @@ dependency_overrides:
73 bitcoin_base:
74 git:
75 url: https://github.com/cake-tech/bitcoin_base
76 - ref: cake-update-v9
76 + ref: cake-update-v10
77 pointycastle: 3.7.4
78 ffi: 2.1.0
79
cw_bitcoin_cash/pubspec.yaml
+1 -1
@@ -42,7 +42,7 @@ dependency_overrides:
42 bitcoin_base:
43 git:
44 url: https://github.com/cake-tech/bitcoin_base
45 - ref: cake-update-v9
45 + ref: cake-update-v10
46
47 # For information on the generic Dart part of this file, see the
48 # following page: https://dart.dev/tools/pub/pubspec
lib/view_model/dashboard/dashboard_view_model.dart
+4 -1
@@ -38,7 +38,6 @@ import 'package:cake_wallet/view_model/dashboard/payjoin_transaction_list_item.d
38 import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart';
39 import 'package:cake_wallet/view_model/dashboard/transaction_list_item.dart';
40 import 'package:cake_wallet/view_model/settings/sync_mode.dart';
41 -import 'package:cake_wallet/wownero/wownero.dart' as wow;
41 import 'package:cryptography/cryptography.dart';
42 import 'package:cw_core/balance.dart';
43 import 'package:cw_core/cake_hive.dart';
@@ -890,6 +889,10 @@ abstract class DashboardViewModelBase with Store {
889 final powNode = settingsStore.getCurrentPowNode(wallet.type);
890 await wallet.connectToPowNode(node: powNode);
891 }
892 +
893 + if (hasSilentPayments) {
894 + bitcoin!.setScanningActive(wallet, silentPaymentsScanningActive);
895 + }
896 }
897
898 @action
pubspec_base.yaml
+1 -1
@@ -164,7 +164,7 @@ dependency_overrides:
164 bitcoin_base:
165 git:
166 url: https://github.com/cake-tech/bitcoin_base
167 - ref: cake-update-v9
167 + ref: cake-update-v10
168 ffi: 2.1.0
169 ledger_flutter_plus:
170 git: