verify silent payment output scripts before device signing, combining and export
What changed, and why it matters
This commit adds safety checks in the Sparrow Bitcoin wallet to verify Silent Payment output scripts before exporting, copying, combining, or sending a transaction to a hardware device for signing. Silent Payments are a privacy feature that lets someone generate a unique receiving address from a public 'silent' address. The change makes sure the wallet can prove the claimed payment address is correct before the user signs or shares the transaction, reducing the risk of sending funds to a wrong or attacker-controlled address.
Treat this as a security hardening fix and include it in the next release. Users who create or sign transactions involving Silent Payments should upgrade. Wallet developers should review the drongo submodule change to confirm the verification algorithm matches the BIP352 specification and that error handling cannot be bypassed.
Security signals we found
Adds explicit validation of silent payment output scripts before signing/export
Catches and surfaces InvalidSilentPaymentException to the user as an error dialog
Verifies combined PSBTs after merge and after device signing, not just the original PSBT
Submodule bump suggests new underlying cryptographic verification logic in drongo
Evidence from the diff
The patch introduces calls to Wallet.verifySilentPaymentScripts(PSBT) at points where a PSBT is saved, copied, shown as QR, merged/combined, or sent to a signing device. It also updates the drongo submodule to a newer commit that presumably implements the verification logic and a new InvalidSilentPaymentException. The verification is meant to ensure that any silent-payment output script in the PSBT is backed by metadata proving the claimed silent payment address, preventing acceptance of unprovable outputs during signing and export workflows.
Changed components
Sparrow Wallet desktop applicationdrongo submodule/libraryAppController PSBT save/copy/show/merge flowsHeadersController PSBT save/show/device-sign flowsInspect captured patch +70 / −3
### drongo
@@ -1 +1 @@
-Subproject commit d66694369f492fc79827758987c5a1c4968f1a9d
+Subproject commit 10561731c7399c96e53396e91b32f9b5700e0c74
### src/main/java/com/sparrowwallet/sparrow/AppController.java
@@ -10,6 +10,7 @@
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.*;
+import com.sparrowwallet.drongo.silentpayments.InvalidSilentPaymentException;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.UR;
@@ -821,6 +822,9 @@ public void savePSBT(boolean asText, boolean includeXpubs) {
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
+ if(!verifyPSBT(transactionTabData.getTransactionData().getSigningWallet(), transactionTabData.getPsbt())) {
+ return;
+ }
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
@@ -876,6 +880,10 @@ public void copyPSBT(boolean asBase64) {
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
+ if(!verifyPSBT(transactionTabData.getTransactionData().getSigningWallet(), transactionTabData.getPsbt())) {
+ return;
+ }
+
String data = asBase64 ? transactionTabData.getPsbt().getForExport().toBase64String() : transactionTabData.getPsbt().getForExport().toString();
ClipboardContent content = new ClipboardContent();
@@ -889,6 +897,9 @@ public void showPSBT(ActionEvent event) {
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
+ if(!verifyPSBT(transactionTabData.getTransactionData().getSigningWallet(), transactionTabData.getPsbt())) {
+ return;
+ }
byte[] psbtBytes = transactionTabData.getPsbt().getForExport().serialize();
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
@@ -2185,12 +2196,16 @@ private void handleTransactionMerge(TransactionTabData transactionTabData, PSBT
if(!psbt.isFinalized()) {
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
try {
- currentPsbt.verifyCombinedSignatures(psbt);
+ PSBT combinedPsbt = currentPsbt.verifyCombinedSignatures(psbt);
+ //A combine can resolve a silent payment output script, which is only valid if the metadata provided with it proves the claimed address
+ verifySilentPaymentScripts(transactionTabData.getTransactionData().getSigningWallet(), combinedPsbt);
currentPsbt.combine(psbt);
setTabName(tab, name);
EventManager.get().post(new PSBTCombinedEvent(currentPsbt));
} catch(PSBTSignatureException e) {
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
+ } catch(InvalidSilentPaymentException e) {
+ AppServices.showErrorDialog("Unverified Silent Payment Outputs", e.getMessage());
}
} else {
//If the new PSBT is finalized, copy the finalized fields to the existing unfinalized PSBT
@@ -2203,6 +2218,23 @@ private void handleTransactionMerge(TransactionTabData transactionTabData, PSBT
tabs.getSelectionModel().select(tab);
}
+ private boolean verifyPSBT(Wallet signingWallet, PSBT psbt) {
+ try {
+ verifySilentPaymentScripts(signingWallet, psbt);
+ } catch(InvalidSilentPaymentException e) {
+ showErrorDialog("Unverified Silent Payment Outputs", e.getMessage());
+ return false;
+ }
+
+ return true;
+ }
+
+ private void verifySilentPaymentScripts(Wallet signingWallet, PSBT psbt) throws InvalidSilentPaymentException {
+ if(signingWallet != null) {
+ signingWallet.verifySilentPaymentScripts(psbt);
+ }
+ }
+
private boolean verifyTransactionContext(PSBT contextPsbt, Transaction transaction, PSBT psbt, String source) {
if(contextPsbt == null || matchesOpenTransactionTab(transaction, psbt)) {
return true;
### src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -7,6 +7,7 @@
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.*;
+import com.sparrowwallet.drongo.silentpayments.InvalidSilentPaymentException;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURI;
@@ -1098,6 +1099,10 @@ public void showPSBT(ActionEvent event) {
ToggleButton toggleButton = (ToggleButton)event.getSource();
toggleButton.setSelected(false);
+ if(!verifyPSBT(headersForm.getSigningWallet(), headersForm.getPsbt())) {
+ return;
+ }
+
//TODO: Remove once Cobo Vault support has been removed
boolean addLegacyEncodingOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().showLegacyQR());
boolean addBbqrOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().showBbqr());
@@ -1171,6 +1176,10 @@ public void savePSBT(ActionEvent event) {
ToggleButton toggleButton = (ToggleButton)event.getSource();
toggleButton.setSelected(false);
+ if(!verifyPSBT(headersForm.getSigningWallet(), headersForm.getPsbt())) {
+ return;
+ }
+
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
@@ -1276,6 +1285,11 @@ private void signDeviceKeystores() {
return;
}
+ //Software signing verifies the silent payment outputs it does not compute, so do the same before the PSBT is sent to a device
+ if(!verifyPSBT(headersForm.getSigningWallet(), headersForm.getPsbt())) {
+ return;
+ }
+
DeviceSignDialog dlg = new DeviceSignDialog(headersForm.getSigningWallet(), fingerprints, headersForm.getPsbt());
dlg.initOwner(signButton.getScene().getWindow());
dlg.initModality(Modality.NONE);
@@ -1286,15 +1300,36 @@ private void signDeviceKeystores() {
if(optionalSignedPsbt.isPresent()) {
PSBT signedPsbt = optionalSignedPsbt.get();
try {
- headersForm.getPsbt().verifyCombinedSignatures(signedPsbt);
+ PSBT combinedPsbt = headersForm.getPsbt().verifyCombinedSignatures(signedPsbt);
+ //A device can resolve a silent payment output script, which is only valid if the metadata provided with it proves the claimed address
+ verifySilentPaymentScripts(headersForm.getSigningWallet(), combinedPsbt);
headersForm.getPsbt().combine(signedPsbt);
EventManager.get().post(new PSBTCombinedEvent(headersForm.getPsbt()));
} catch(PSBTSignatureException e) {
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
+ } catch(InvalidSilentPaymentException e) {
+ AppServices.showErrorDialog("Unverified Silent Payment Outputs", e.getMessage());
}
}
}
+ private boolean verifyPSBT(Wallet signingWallet, PSBT psbt) {
+ try {
+ verifySilentPaymentScripts(signingWallet, psbt);
+ } catch(InvalidSilentPaymentException e) {
+ showErrorDialog("Unverified Silent Payment Outputs", e.getMessage());
+ return false;
+ }
+
+ return true;
+ }
+
+ private void verifySilentPaymentScripts(Wallet signingWallet, PSBT psbt) throws InvalidSilentPaymentException {
+ if(signingWallet != null) {
+ signingWallet.verifySilentPaymentScripts(psbt);
+ }
+ }
+
private void updateSignedKeystores(Wallet signingWallet) {
Map<?, Map<TransactionSignature, Keystore>> signedKeystoresMap = headersForm.getPsbt() == null ? signingWallet.getSignedKeystores(headersForm.getTransaction()) : signingWallet.getSignedKeystores(headersForm.getPsbt());
Optional<Map<TransactionSignature, Keystore>> optSignedKeystores = signedKeystoresMap.values().stream().filter(map -> !map.isEmpty()).min(Comparator.comparingInt(Map::size));Why this scored 68/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.