feat: implement silent payment derivation paths and master HD wallet integration (#2708)
What changed, and why it matters
This commit adds support for Bitcoin "silent payments" in Cake Wallet. It introduces new derivation paths for scanning and spending, stores a spend derivation path on each silent payment address record, and changes how private keys are derived when spending silent-payment UTXOs. The change is a feature implementation/refactor rather than a clearly labeled security fix. There is no direct evidence in the commit that it fixes an active vulnerability, but it touches sensitive key-derivation and UTXO-handling code, so correctness matters for funds safety.
Treat this as a high-sensitivity code change requiring focused review and regression testing of silent payment scanning and spending. Verify that: (1) the new derivation paths match the intended BIP-352 specification for the correct network; (2) the default testnet fallback for `spendDerivationPath` cannot cause mainnet wallets to derive/record testnet paths; (3) scanning both mainnet and testnet receivers does not leak metadata or produce false-positive UTXOs; (4) the tweak-add operation uses the correct private key and cannot be tricked by a malformed tweak; (5) existing silent payment address records deserialize safely and remain spendable. Consider requesting test vectors or a security write-up from the vendor.
Security signals we found
Silent payment key derivation paths added and used for scan/spend private keys
Spend private key derivation moved from `silentAddress.b_spend.tweakAdd` to `_masterHD.derivePath(spendDerivationPath).tweakAdd(tweak)`
Two receivers (mainnet and testnet derivation paths) are both scanned regardless of current network
New `spendDerivationPath` field persisted in address JSON records with a testnet default fallback
Large amount formatting switched from int to BigInt, potentially preventing overflow-related formatting issues
Evidence from the diff
The patch implements BIP-352-style silent payment derivation paths (m/352’/0’/0’/1’/0 for scan, m/352’/0’/0’/0’/0 for spend on mainnet; 352’/1’ variants for testnet). It adds a _masterHD Bip32Slip10Secp256k1 field to ElectrumWalletBase, passes it into the isolate-based silent payment scanner, and updates BitcoinSilentPaymentAddressRecord to persist a spendDerivationPath. Spending logic now derives the spend private key via _masterHD!.derivePath(unspentAddress.spendDerivationPath) and tweaks it with the stored silent-payment tweak, instead of using the previous silentAddress.b_spend.tweakAdd(...) path. Scanning now creates two Receiver objects (mainnet and testnet paths) and iterates over them. A formatCryptoAmount change switches int.parse to BigInt.parse.
Changed components
cw_bitcoin/lib/bitcoin_address_record.dartcw_bitcoin/lib/electrum_derivations.dartcw_bitcoin/lib/electrum_wallet.dartcw_bitcoin/lib/electrum_wallet_addresses.dartInspect captured patch +87 / −49
diff --git a/cw_bitcoin/lib/bitcoin_address_record.dart b/cw_bitcoin/lib/bitcoin_address_record.dart
index 834cbbb..37413dc 100644
--- a/cw_bitcoin/lib/bitcoin_address_record.dart
+++ b/cw_bitcoin/lib/bitcoin_address_record.dart
@@ -1,4 +1,5 @@
import 'dart:convert';
+import 'package:cw_bitcoin/electrum_derivations.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:mobx/mobx.dart';
@@ -133,7 +134,8 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord {
required this.silentPaymentTweak,
required super.network,
required super.type,
- }) : super();
+ this.spendDerivationPath = SILENT_PAYMENTS_SPEND_PATH_TESTNET,
+ });
factory BitcoinSilentPaymentAddressRecord.fromJSON(String jsonSource,
{BasedUtxoNetwork? network}) {
@@ -155,10 +157,13 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord {
? BitcoinAddressType.values
.firstWhere((type) => type.toString() == decoded['type'] as String)
: SilentPaymentsAddresType.p2sp,
+ spendDerivationPath:
+ decoded['spend_derivation_path'] as String? ?? SILENT_PAYMENTS_SPEND_PATH_TESTNET,
);
}
final String? silentPaymentTweak;
+ final String spendDerivationPath;
@override
String toJSON() => json.encode({
@@ -172,5 +177,6 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord {
'type': type.toString(),
'network': network?.value,
'silent_payment_tweak': silentPaymentTweak,
+ 'spend_derivation_path': spendDerivationPath,
});
}
diff --git a/cw_bitcoin/lib/electrum_derivations.dart b/cw_bitcoin/lib/electrum_derivations.dart
index 81a3626..26dc1f8 100644
--- a/cw_bitcoin/lib/electrum_derivations.dart
+++ b/cw_bitcoin/lib/electrum_derivations.dart
@@ -1,5 +1,10 @@
import 'package:cw_core/wallet_info.dart';
+const SILENT_PAYMENTS_SCAN_PATH = "m/352'/0'/0'/1'/0";
+const SILENT_PAYMENTS_SPEND_PATH = "m/352'/0'/0'/0'/0";
+const SILENT_PAYMENTS_SCAN_PATH_TESTNET = "m/352'/1'/0'/1'/0";
+const SILENT_PAYMENTS_SPEND_PATH_TESTNET = "m/352'/1'/0'/0'/0";
+
Map<DerivationType, List<DerivationInfo>> electrum_derivations = {
DerivationType.electrum: [
DerivationInfo(
diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart
index 477544e..a285ff9 100644
--- a/cw_bitcoin/lib/electrum_wallet.dart
+++ b/cw_bitcoin/lib/electrum_wallet.dart
@@ -4,14 +4,6 @@ import 'dart:io';
import 'dart:isolate';
import 'package:bitcoin_base/bitcoin_base.dart';
-import 'package:cw_core/hardware/hardware_wallet_service.dart';
-import 'package:cw_core/root_dir.dart';
-import 'package:cw_core/utils/proxy_wrapper.dart';
-import 'package:cw_bitcoin/bitcoin_amount_format.dart';
-import 'package:cw_core/utils/print_verbose.dart';
-import 'package:cw_bitcoin/bitcoin_wallet.dart';
-import 'package:cw_bitcoin/litecoin_wallet.dart';
-import 'package:shared_preferences/shared_preferences.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:collection/collection.dart';
import 'package:cw_bitcoin/address_from_output.dart';
@@ -19,6 +11,7 @@ import 'package:cw_bitcoin/bitcoin_address_record.dart';
import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
import 'package:cw_bitcoin/bitcoin_transaction_priority.dart';
import 'package:cw_bitcoin/bitcoin_unspent.dart';
+import 'package:cw_bitcoin/bitcoin_wallet.dart';
import 'package:cw_bitcoin/bitcoin_wallet_keys.dart';
import 'package:cw_bitcoin/electrum.dart' as electrum;
import 'package:cw_bitcoin/electrum_balance.dart';
@@ -27,31 +20,37 @@ import 'package:cw_bitcoin/electrum_transaction_history.dart';
import 'package:cw_bitcoin/electrum_transaction_info.dart';
import 'package:cw_bitcoin/electrum_wallet_addresses.dart';
import 'package:cw_bitcoin/exceptions.dart';
+import 'package:cw_bitcoin/litecoin_wallet.dart';
import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
import 'package:cw_bitcoin/utils.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_core/get_height_by_date.dart';
+import 'package:cw_core/hardware/hardware_wallet_service.dart';
import 'package:cw_core/node.dart';
+import 'package:cw_core/output_info.dart';
import 'package:cw_core/pathForWallet.dart';
import 'package:cw_core/pending_transaction.dart';
+import 'package:cw_core/root_dir.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/transaction_priority.dart';
+import 'package:cw_core/unspent_coin_type.dart';
import 'package:cw_core/unspent_coins_info.dart';
+import 'package:cw_core/utils/print_verbose.dart';
+import 'package:cw_core/utils/proxy_wrapper.dart';
+import 'package:cw_core/utils/socket_health_logger.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_keys_file.dart';
import 'package:cw_core/wallet_type.dart';
-import 'package:cw_core/unspent_coin_type.dart';
-import 'package:cw_core/output_info.dart';
import 'package:flutter/foundation.dart';
+import 'package:hex/hex.dart';
import 'package:hive/hive.dart';
import 'package:mobx/mobx.dart';
import 'package:rxdart/subjects.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import 'package:sp_scanner/sp_scanner.dart';
-import 'package:hex/hex.dart';
-import 'package:cw_core/utils/socket_health_logger.dart';
part 'electrum_wallet.g.dart';
@@ -76,8 +75,9 @@ abstract class ElectrumWalletBase
ElectrumBalance? initialBalance,
CryptoCurrency? currency,
bool? alwaysScan,
- }) : accountHD =
- getAccountHDWallet(currency, network, seedBytes, xpub, derivationInfo, walletInfo.hardwareWalletType),
+ }) : _masterHD = getMasterHD(seedBytes, network, walletInfo.hardwareWalletType),
+ accountHD = getAccountHDWallet(
+ currency, network, seedBytes, xpub, derivationInfo, walletInfo.hardwareWalletType),
syncStatus = NotConnectedSyncStatus(),
_password = password,
_feeRates = <int>[],
@@ -171,9 +171,18 @@ abstract class ElectrumWalletBase
}
}
+ static Bip32Slip10Secp256k1? getMasterHD(Uint8List? seedBytes,
+ [BasedUtxoNetwork? network, HardwareWalletType? hardwareWalletType]) {
+ if (seedBytes == null) return null;
+
+ return Bip32Slip10Secp256k1.fromSeed(
+ seedBytes, network != null ? getKeyNetVersion(network, hardwareWalletType) : null);
+ }
+
@observable
bool? alwaysScan;
+ final Bip32Slip10Secp256k1? _masterHD;
final Bip32Slip10Secp256k1 accountHD;
final String? _mnemonic;
@@ -383,6 +392,7 @@ abstract class ElectrumWalletBase
ScanData(
sendPort: receivePort.sendPort,
silentAddress: walletAddresses.silentAddress!,
+ masterHD: _masterHD!,
network: network,
height: height,
chainTip: chainTip,
@@ -745,10 +755,10 @@ abstract class ElectrumWalletBase
if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) {
final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord;
- privkey = walletAddresses.silentAddress!.b_spend.tweakAdd(
- BigintUtils.fromBytes(
- BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!),
- ),
+ privkey = ECPrivate.fromHex(
+ _masterHD!.derivePath(unspentAddress.spendDerivationPath).privateKey.toHex())
+ .tweakAdd(
+ BigintUtils.fromBytes(BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!)),
);
spendsSilentPayment = true;
isSilentPayment = true;
@@ -772,7 +782,6 @@ abstract class ElectrumWalletBase
pubKeyHex = hd.childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index)).publicKey.toHex();
}
-
final derivationPath =
"${_hardenedDerivationPath(derivationInfo.derivationPath ?? electrum_path)}"
"/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}"
@@ -1141,7 +1150,6 @@ abstract class ElectrumWalletBase
bool hasSilentPayment = false,
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
}) async {
-
final utxoDetailsAll = _createUTXOS(
sendAll: true,
paysToSilentPayment: hasSilentPayment,
@@ -2768,8 +2776,8 @@ abstract class ElectrumWalletBase
@override
String formatCryptoAmount(String amount) {
- final amountInt = int.parse(amount);
- return bitcoinAmountToString(amount: amountInt);
+ final amountBigInt = BigInt.parse(amount);
+ return currency.formatAmount(amountBigInt);
}
/// Checks the health of the socket connection
@@ -2930,6 +2938,7 @@ class ScanNode {
class ScanData {
final SendPort sendPort;
final SilentPaymentOwner silentAddress;
+ final Bip32Slip10Secp256k1 masterHD;
final int height;
final ScanNode? node;
final BasedUtxoNetwork network;
@@ -2945,6 +2954,7 @@ class ScanData {
ScanData({
required this.sendPort,
required this.silentAddress,
+ required this.masterHD,
required this.height,
required this.node,
required this.network,
@@ -2962,6 +2972,7 @@ class ScanData {
return ScanData(
sendPort: scanData.sendPort,
silentAddress: scanData.silentAddress,
+ masterHD: scanData.masterHD,
height: newHeight,
node: scanData.node,
network: scanData.network,
@@ -3003,16 +3014,29 @@ Future<void> _handleScanSilentPayments(ScanData scanData) async {
log("connected to ${node.toString()}", LogLevel.info);
- final receiver = Receiver(
- scanData.silentAddress.b_scan.toHex(),
- scanData.silentAddress.B_spend.toHex(),
- scanData.network == BitcoinNetwork.testnet,
- scanData.labelIndexes,
- scanData.labelIndexes.length,
- );
+ final receivers = [
+ Receiver(
+ scanData.silentAddress.b_scan.toHex(),
+ scanData.silentAddress.B_spend.toHex(),
+ scanData.network == BitcoinNetwork.testnet,
+ scanData.labelIndexes,
+ scanData.labelIndexes.length,
+ ),
+ Receiver(
+ scanData.masterHD.derivePath(SILENT_PAYMENTS_SCAN_PATH_TESTNET).privateKey.toHex(),
+ scanData.masterHD.derivePath(SILENT_PAYMENTS_SPEND_PATH_TESTNET).publicKey.toHex(),
+ scanData.network == BitcoinNetwork.testnet,
+ scanData.labelIndexes,
+ scanData.labelIndexes.length,
+ )
+ ];
log(
- "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}",
+ "using receiver: b_scan: ${scanData.silentAddress.b_scan.toHex()}, b_spend: ${scanData.silentAddress.B_spend.toHex()}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}",
+ LogLevel.info,
+ );
+ log(
+ "using receiver: b_scan: ${receivers[1].bScan}, b_spend: ${receivers[1].BSpend}, network: ${scanData.network.value}, labelIndexes: ${scanData.labelIndexes}",
LogLevel.info,
);
@@ -3140,22 +3164,20 @@ Future<void> _handleScanSilentPayments(ScanData scanData) async {
final tweak = tweakData.tweak;
try {
- final addToWallet = {};
+ final addToWallet = <String, dynamic>{};
- // receivers.forEach((receiver) {
- // NOTE: scanOutputs, from sp_scanner package, called from rust here
- final scanResult = scanOutputs([outputPubkeys.keys.toList()], tweak, receiver);
+ receivers.forEach((receiver) {
+ // NOTE: scanOutputs, from sp_scanner package, called from rust here
+ final scanResult = scanOutputs([outputPubkeys.keys.toList()], tweak, receiver);
- if (scanResult.isEmpty) {
- continue;
- }
+ if (scanResult.isEmpty) return;
- if (addToWallet[receiver.BSpend] == null) {
- addToWallet[receiver.BSpend] = scanResult;
- } else {
- addToWallet[receiver.BSpend].addAll(scanResult);
- }
- // });
+ if (addToWallet[receiver.BSpend] == null) {
+ addToWallet[receiver.BSpend] = scanResult;
+ } else {
+ addToWallet[receiver.BSpend].addAll(scanResult);
+ }
+ });
if (addToWallet.isEmpty) {
// no results tx, continue to next tx
@@ -3237,9 +3259,8 @@ Future<void> _handleScanSilentPayments(ScanData scanData) async {
final pos = matchingOutput.vout;
final spent = matchingOutput.spendingInput;
- // final matchingSPWallet = scanData.silentPaymentsWallets.firstWhere(
- // (receiver) => receiver.B_spend.toHex() == BSpend.toString(),
- // );
+ final matchingReceiver =
+ receivers.indexWhere((receiver) => receiver.BSpend == BSpend);
// final labelIndex = labelValue != null ? scanData.labels[label] : 0;
// final balance = ElectrumBalance();
@@ -3255,6 +3276,9 @@ Future<void> _handleScanSilentPayments(ScanData scanData) async {
type: SegwitAddresType.p2tr,
txCount: 1,
balance: amount,
+ spendDerivationPath: matchingReceiver == 0
+ ? SILENT_PAYMENTS_SPEND_PATH
+ : SILENT_PAYMENTS_SPEND_PATH_TESTNET,
);
final unspent = BitcoinSilentPaymentsUnspent(
diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart
index 18d2898..e914287 100644
--- a/cw_bitcoin/lib/electrum_wallet_addresses.dart
+++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart
@@ -3,6 +3,7 @@ import 'dart:io' show Platform;
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_bitcoin/bitcoin_address_record.dart';
+import 'package:cw_bitcoin/electrum_derivations.dart';
import 'package:cw_core/unspent_coin_type.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_bitcoin/bitcoin_unspent.dart';
@@ -75,8 +76,10 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
super(walletInfo) {
if (masterHd != null) {
silentAddress = SilentPaymentOwner.fromPrivateKeys(
- b_scan: ECPrivate.fromHex(masterHd.derivePath(SCAN_PATH).privateKey.toHex()),
- b_spend: ECPrivate.fromHex(masterHd.derivePath(SPEND_PATH).privateKey.toHex()),
+ b_scan:
+ ECPrivate.fromHex(masterHd.derivePath(SILENT_PAYMENTS_SCAN_PATH).privateKey.toHex()),
+ b_spend:
+ ECPrivate.fromHex(masterHd.derivePath(SILENT_PAYMENTS_SPEND_PATH).privateKey.toHex()),
network: network,
);
Why this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.