feat: implement silent payment derivation paths and master HD wallet integration (#2708)

* feat: implement silent payment derivation paths and master HD wallet integration - Added default derivation paths for silent payments (mainnet and testnet). - Introduced `_masterHD` for handling seed-based key derivations. - Updated silent payment address record to include spend derivation path. - Refactored UTXO handling and scanning logic to support multiple receivers with unique derivation paths. - Improved logging for better debugging of silent payment workflows. * refactor: remove debug prints

Konstantin Ullrich committed Dec 11, 2025 at 15:01 UTC 89863e3becb967d79cdcdc31a7bf84bcc3009c29
4 files changed +87 -49
cw_bitcoin/lib/bitcoin_address_record.dart
+7 -1
@@ -1,4 +1,5 @@
1 import 'dart:convert';
2 +import 'package:cw_bitcoin/electrum_derivations.dart';
3 import 'package:cw_core/utils/print_verbose.dart';
4 import 'package:mobx/mobx.dart';
5
@@ -133,7 +134,8 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord {
134 required this.silentPaymentTweak,
135 required super.network,
136 required super.type,
136 - }) : super();
137 + this.spendDerivationPath = SILENT_PAYMENTS_SPEND_PATH_TESTNET,
138 + });
139
140 factory BitcoinSilentPaymentAddressRecord.fromJSON(String jsonSource,
141 {BasedUtxoNetwork? network}) {
@@ -155,10 +157,13 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord {
157 ? BitcoinAddressType.values
158 .firstWhere((type) => type.toString() == decoded['type'] as String)
159 : SilentPaymentsAddresType.p2sp,
160 + spendDerivationPath:
161 + decoded['spend_derivation_path'] as String? ?? SILENT_PAYMENTS_SPEND_PATH_TESTNET,
162 );
163 }
164
165 final String? silentPaymentTweak;
166 + final String spendDerivationPath;
167
168 @override
169 String toJSON() => json.encode({
@@ -172,5 +177,6 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord {
177 'type': type.toString(),
178 'network': network?.value,
179 'silent_payment_tweak': silentPaymentTweak,
180 + 'spend_derivation_path': spendDerivationPath,
181 });
182 }
cw_bitcoin/lib/electrum_derivations.dart
+5
@@ -1,5 +1,10 @@
1 import 'package:cw_core/wallet_info.dart';
2
3 +const SILENT_PAYMENTS_SCAN_PATH = "m/352'/0'/0'/1'/0";
4 +const SILENT_PAYMENTS_SPEND_PATH = "m/352'/0'/0'/0'/0";
5 +const SILENT_PAYMENTS_SCAN_PATH_TESTNET = "m/352'/1'/0'/1'/0";
6 +const SILENT_PAYMENTS_SPEND_PATH_TESTNET = "m/352'/1'/0'/0'/0";
7 +
8 Map<DerivationType, List<DerivationInfo>> electrum_derivations = {
9 DerivationType.electrum: [
10 DerivationInfo(
cw_bitcoin/lib/electrum_wallet.dart
+70 -46
@@ -4,14 +4,6 @@ import 'dart:io';
4 import 'dart:isolate';
5
6 import 'package:bitcoin_base/bitcoin_base.dart';
7 -import 'package:cw_core/hardware/hardware_wallet_service.dart';
8 -import 'package:cw_core/root_dir.dart';
9 -import 'package:cw_core/utils/proxy_wrapper.dart';
10 -import 'package:cw_bitcoin/bitcoin_amount_format.dart';
11 -import 'package:cw_core/utils/print_verbose.dart';
12 -import 'package:cw_bitcoin/bitcoin_wallet.dart';
13 -import 'package:cw_bitcoin/litecoin_wallet.dart';
14 -import 'package:shared_preferences/shared_preferences.dart';
7 import 'package:blockchain_utils/blockchain_utils.dart';
8 import 'package:collection/collection.dart';
9 import 'package:cw_bitcoin/address_from_output.dart';
@@ -19,6 +11,7 @@ import 'package:cw_bitcoin/bitcoin_address_record.dart';
11 import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
12 import 'package:cw_bitcoin/bitcoin_transaction_priority.dart';
13 import 'package:cw_bitcoin/bitcoin_unspent.dart';
14 +import 'package:cw_bitcoin/bitcoin_wallet.dart';
15 import 'package:cw_bitcoin/bitcoin_wallet_keys.dart';
16 import 'package:cw_bitcoin/electrum.dart' as electrum;
17 import 'package:cw_bitcoin/electrum_balance.dart';
@@ -27,31 +20,37 @@ import 'package:cw_bitcoin/electrum_transaction_history.dart';
20 import 'package:cw_bitcoin/electrum_transaction_info.dart';
21 import 'package:cw_bitcoin/electrum_wallet_addresses.dart';
22 import 'package:cw_bitcoin/exceptions.dart';
23 +import 'package:cw_bitcoin/litecoin_wallet.dart';
24 import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
25 import 'package:cw_bitcoin/utils.dart';
26 import 'package:cw_core/crypto_currency.dart';
27 import 'package:cw_core/encryption_file_utils.dart';
28 import 'package:cw_core/get_height_by_date.dart';
29 +import 'package:cw_core/hardware/hardware_wallet_service.dart';
30 import 'package:cw_core/node.dart';
31 +import 'package:cw_core/output_info.dart';
32 import 'package:cw_core/pathForWallet.dart';
33 import 'package:cw_core/pending_transaction.dart';
34 +import 'package:cw_core/root_dir.dart';
35 import 'package:cw_core/sync_status.dart';
36 import 'package:cw_core/transaction_direction.dart';
37 import 'package:cw_core/transaction_priority.dart';
38 +import 'package:cw_core/unspent_coin_type.dart';
39 import 'package:cw_core/unspent_coins_info.dart';
40 +import 'package:cw_core/utils/print_verbose.dart';
41 +import 'package:cw_core/utils/proxy_wrapper.dart';
42 +import 'package:cw_core/utils/socket_health_logger.dart';
43 import 'package:cw_core/wallet_base.dart';
44 import 'package:cw_core/wallet_info.dart';
45 import 'package:cw_core/wallet_keys_file.dart';
46 import 'package:cw_core/wallet_type.dart';
46 -import 'package:cw_core/unspent_coin_type.dart';
47 -import 'package:cw_core/output_info.dart';
47 import 'package:flutter/foundation.dart';
48 +import 'package:hex/hex.dart';
49 import 'package:hive/hive.dart';
50 import 'package:mobx/mobx.dart';
51 import 'package:rxdart/subjects.dart';
52 +import 'package:shared_preferences/shared_preferences.dart';
53 import 'package:sp_scanner/sp_scanner.dart';
53 -import 'package:hex/hex.dart';
54 -import 'package:cw_core/utils/socket_health_logger.dart';
54
55 part 'electrum_wallet.g.dart';
56
@@ -76,8 +75,9 @@ abstract class ElectrumWalletBase
75 ElectrumBalance? initialBalance,
76 CryptoCurrency? currency,
77 bool? alwaysScan,
79 - }) : accountHD =
80 - getAccountHDWallet(currency, network, seedBytes, xpub, derivationInfo, walletInfo.hardwareWalletType),
78 + }) : _masterHD = getMasterHD(seedBytes, network, walletInfo.hardwareWalletType),
79 + accountHD = getAccountHDWallet(
80 + currency, network, seedBytes, xpub, derivationInfo, walletInfo.hardwareWalletType),
81 syncStatus = NotConnectedSyncStatus(),
82 _password = password,
83 _feeRates = <int>[],
@@ -171,9 +171,18 @@ abstract class ElectrumWalletBase
171 }
172 }
173
174 + static Bip32Slip10Secp256k1? getMasterHD(Uint8List? seedBytes,
175 + [BasedUtxoNetwork? network, HardwareWalletType? hardwareWalletType]) {
176 + if (seedBytes == null) return null;
177 +
178 + return Bip32Slip10Secp256k1.fromSeed(
179 + seedBytes, network != null ? getKeyNetVersion(network, hardwareWalletType) : null);
180 + }
181 +
182 @observable
183 bool? alwaysScan;
184
185 + final Bip32Slip10Secp256k1? _masterHD;
186 final Bip32Slip10Secp256k1 accountHD;
187 final String? _mnemonic;
188
@@ -383,6 +392,7 @@ abstract class ElectrumWalletBase
392 ScanData(
393 sendPort: receivePort.sendPort,
394 silentAddress: walletAddresses.silentAddress!,
395 + masterHD: _masterHD!,
396 network: network,
397 height: height,
398 chainTip: chainTip,
@@ -745,10 +755,10 @@ abstract class ElectrumWalletBase
755
756 if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) {
757 final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord;
748 - privkey = walletAddresses.silentAddress!.b_spend.tweakAdd(
749 - BigintUtils.fromBytes(
750 - BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!),
751 - ),
758 + privkey = ECPrivate.fromHex(
759 + _masterHD!.derivePath(unspentAddress.spendDerivationPath).privateKey.toHex())
760 + .tweakAdd(
761 + BigintUtils.fromBytes(BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!)),
762 );
763 spendsSilentPayment = true;
764 isSilentPayment = true;
@@ -772,7 +782,6 @@ abstract class ElectrumWalletBase
782 pubKeyHex = hd.childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index)).publicKey.toHex();
783 }
784
775 -
785 final derivationPath =
786 "${_hardenedDerivationPath(derivationInfo.derivationPath ?? electrum_path)}"
787 "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}"
@@ -1141,7 +1150,6 @@ abstract class ElectrumWalletBase
1150 bool hasSilentPayment = false,
1151 UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
1152 }) async {
1144 -
1153 final utxoDetailsAll = _createUTXOS(
1154 sendAll: true,
1155 paysToSilentPayment: hasSilentPayment,
@@ -2768,8 +2776,8 @@ abstract class ElectrumWalletBase
2776
2777 @override
2778 String formatCryptoAmount(String amount) {
2771 - final amountInt = int.parse(amount);
2772 - return bitcoinAmountToString(amount: amountInt);
2779 + final amountBigInt = BigInt.parse(amount);
2780 + return currency.formatAmount(amountBigInt);
2781 }
2782
2783 /// Checks the health of the socket connection
@@ -2930,6 +2938,7 @@ class ScanNode {
2938 class ScanData {
2939 final SendPort sendPort;
2940 final SilentPaymentOwner silentAddress;
2941 + final Bip32Slip10Secp256k1 masterHD;
2942 final int height;
2943 final ScanNode? node;
2944 final BasedUtxoNetwork network;
@@ -2945,6 +2954,7 @@ class ScanData {
2954 ScanData({
2955 required this.sendPort,
2956 required this.silentAddress,
2957 + required this.masterHD,
2958 required this.height,
2959 required this.node,
2960 required this.network,
@@ -2962,6 +2972,7 @@ class ScanData {
2972 return ScanData(
2973 sendPort: scanData.sendPort,
2974 silentAddress: scanData.silentAddress,
2975 + masterHD: scanData.masterHD,
2976 height: newHeight,
2977 node: scanData.node,
2978 network: scanData.network,
@@ -3003,16 +3014,29 @@ Future<void> _handleScanSilentPayments(ScanData scanData) async {
3014
3015 log("connected to ${node.toString()}", LogLevel.info);
3016
3006 - final receiver = Receiver(
3007 - scanData.silentAddress.b_scan.toHex(),
3008 - scanData.silentAddress.B_spend.toHex(),
3009 - scanData.network == BitcoinNetwork.testnet,
3010 - scanData.labelIndexes,
3011 - scanData.labelIndexes.length,
3012 - );
3017 + final receivers = [
3018 + Receiver(
3019 + scanData.silentAddress.b_scan.toHex(),
3020 + scanData.silentAddress.B_spend.toHex(),
3021 + scanData.network == BitcoinNetwork.testnet,
3022 + scanData.labelIndexes,
3023 + scanData.labelIndexes.length,
3024 + ),
3025 + Receiver(
3026 + scanData.masterHD.derivePath(SILENT_PAYMENTS_SCAN_PATH_TESTNET).privateKey.toHex(),
3027 + scanData.masterHD.derivePath(SILENT_PAYMENTS_SPEND_PATH_TESTNET).publicKey.toHex(),
3028 + scanData.network == BitcoinNetwork.testnet,
3029 + scanData.labelIndexes,
3030 + scanData.labelIndexes.length,
3031 + )
3032 + ];
3033
3034 log(
3015 - "using receiver: b_scan: ${scanData.silentAddress.b_scan.toHex()}, B_scan: ${scanData.silentAddress.B_spend.toHex()}, b_spend: ${scanData.silentAddress.B_spend.toHex()}, B_spend: ${scanData.silentAddress.B_spend.toHex()}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}",
3035 + "using receiver: b_scan: ${scanData.silentAddress.b_scan.toHex()}, b_spend: ${scanData.silentAddress.B_spend.toHex()}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}",
3036 + LogLevel.info,
3037 + );
3038 + log(
3039 + "using receiver: b_scan: ${receivers[1].bScan}, b_spend: ${receivers[1].BSpend}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}",
3040 LogLevel.info,
3041 );
3042
@@ -3140,22 +3164,20 @@ Future<void> _handleScanSilentPayments(ScanData scanData) async {
3164 final tweak = tweakData.tweak;
3165
3166 try {
3143 - final addToWallet = {};
3167 + final addToWallet = <String, dynamic>{};
3168
3145 - // receivers.forEach((receiver) {
3146 - // NOTE: scanOutputs, from sp_scanner package, called from rust here
3147 - final scanResult = scanOutputs([outputPubkeys.keys.toList()], tweak, receiver);
3169 + receivers.forEach((receiver) {
3170 + // NOTE: scanOutputs, from sp_scanner package, called from rust here
3171 + final scanResult = scanOutputs([outputPubkeys.keys.toList()], tweak, receiver);
3172
3149 - if (scanResult.isEmpty) {
3150 - continue;
3151 - }
3173 + if (scanResult.isEmpty) return;
3174
3153 - if (addToWallet[receiver.BSpend] == null) {
3154 - addToWallet[receiver.BSpend] = scanResult;
3155 - } else {
3156 - addToWallet[receiver.BSpend].addAll(scanResult);
3157 - }
3158 - // });
3175 + if (addToWallet[receiver.BSpend] == null) {
3176 + addToWallet[receiver.BSpend] = scanResult;
3177 + } else {
3178 + addToWallet[receiver.BSpend].addAll(scanResult);
3179 + }
3180 + });
3181
3182 if (addToWallet.isEmpty) {
3183 // no results tx, continue to next tx
@@ -3237,9 +3259,8 @@ Future<void> _handleScanSilentPayments(ScanData scanData) async {
3259 final pos = matchingOutput.vout;
3260 final spent = matchingOutput.spendingInput;
3261
3240 - // final matchingSPWallet = scanData.silentPaymentsWallets.firstWhere(
3241 - // (receiver) => receiver.B_spend.toHex() == BSpend.toString(),
3242 - // );
3262 + final matchingReceiver =
3263 + receivers.indexWhere((receiver) => receiver.BSpend == BSpend);
3264
3265 // final labelIndex = labelValue != null ? scanData.labels[label] : 0;
3266 // final balance = ElectrumBalance();
@@ -3255,6 +3276,9 @@ Future<void> _handleScanSilentPayments(ScanData scanData) async {
3276 type: SegwitAddresType.p2tr,
3277 txCount: 1,
3278 balance: amount,
3279 + spendDerivationPath: matchingReceiver == 0
3280 + ? SILENT_PAYMENTS_SPEND_PATH
3281 + : SILENT_PAYMENTS_SPEND_PATH_TESTNET,
3282 );
3283
3284 final unspent = BitcoinSilentPaymentsUnspent(
cw_bitcoin/lib/electrum_wallet_addresses.dart
+5 -2
@@ -3,6 +3,7 @@ import 'dart:io' show Platform;
3 import 'package:bitcoin_base/bitcoin_base.dart';
4 import 'package:blockchain_utils/blockchain_utils.dart';
5 import 'package:cw_bitcoin/bitcoin_address_record.dart';
6 +import 'package:cw_bitcoin/electrum_derivations.dart';
7 import 'package:cw_core/unspent_coin_type.dart';
8 import 'package:cw_core/utils/print_verbose.dart';
9 import 'package:cw_bitcoin/bitcoin_unspent.dart';
@@ -75,8 +76,10 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
76 super(walletInfo) {
77 if (masterHd != null) {
78 silentAddress = SilentPaymentOwner.fromPrivateKeys(
78 - b_scan: ECPrivate.fromHex(masterHd.derivePath(SCAN_PATH).privateKey.toHex()),
79 - b_spend: ECPrivate.fromHex(masterHd.derivePath(SPEND_PATH).privateKey.toHex()),
79 + b_scan:
80 + ECPrivate.fromHex(masterHd.derivePath(SILENT_PAYMENTS_SCAN_PATH).privateKey.toHex()),
81 + b_spend:
82 + ECPrivate.fromHex(masterHd.derivePath(SILENT_PAYMENTS_SPEND_PATH).privateKey.toHex()),
83 network: network,
84 );
85