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