disable payjoin on transactions with silent payment outputs
What changed, and why it matters
This commit disables the Payjoin privacy feature when a Bitcoin transaction includes 'silent payment' outputs. Silent payments let someone receive Bitcoin without publicly revealing their address. The change prevents a risky combination where a Payjoin receiver adds their own inputs to the transaction, which would silently break the math that lets the real recipient detect and claim the silent payment. In short, the wallet now refuses to mix these two features so users don't accidentally send money to an address the recipient can no longer find.
Treat this as a defensive security hardening fix. Users should upgrade to the version containing this commit. Wallet developers should review whether any other coin-selection or transaction-modifying flows (RBF, fee bumping, mixing) can alter input sets for silent payment transactions and apply similar guards.
Security signals we found
Payjoin disabled for silent payment outputs to prevent undetectable outputs
RBF disabled for single silent-payment wallets and any silent payment payments
BitcoinURI silent payment address now correctly populated in payment form
New IllegalArgumentException guard in Payjoin constructor
Test added verifying original PSBT with silent payment output is rejected
Evidence from the diff
The patch blocks Payjoin (BIP78) when the original PSBT contains silent payment (BIP352) outputs, or when the wallet policy type is SINGLE_SP or any payment is a SilentPayment. It also fixes a UI bug where a BitcoinURI carrying a silent payment address was not populating the address field. The core issue is that a Payjoin proposal may add receiver inputs and change the transaction’s input set; silent payment output scripts are derived from all transaction inputs, so adding inputs invalidates the recipient’s ability to scan/detect the output. The drongo submodule bump likely carries related silent-payment logic.
Changed components
Payjoin.javaSendController.javaPaymentController.javaHeadersController.javadrongo submodulePayjoinTest.javaInspect captured patch +30 / −6
### drongo
@@ -1 +1 @@
-Subproject commit 0d8aaac106b52e71510b5e0bfa97e7103c8aefbc
+Subproject commit b36afb730923e212eb9a1c3a998a22904edef0ec
### src/main/java/com/sparrowwallet/sparrow/payjoin/Payjoin.java
@@ -42,6 +42,10 @@ public Payjoin(BitcoinURI payjoinURI, Wallet wallet, PSBT psbt) {
throw new IllegalArgumentException("Payjoin URI must have an address");
}
+ if(psbt.getPsbtOutputs().stream().anyMatch(psbtOutput -> psbtOutput.getSilentPaymentAddress() != null)) {
+ throw new IllegalArgumentException("Original PSBT for payjoin transaction cannot contain silent payment outputs");
+ }
+
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
if(psbtInput.getUtxo() == null) {
throw new IllegalArgumentException("Original PSBT for payjoin transaction must have non_witness_utxo or witness_utxo fields for all inputs");
### src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -461,7 +461,8 @@ public LocalDate fromString(String value) {
updateFee(feeAmt);
}
- payjoinURI = getPayjoinURI();
+ boolean silentPaymentOutput = headersForm.getPsbt() != null && headersForm.getPsbt().getPsbtOutputs().stream().anyMatch(o -> o.getSilentPaymentAddress() != null);
+ payjoinURI = silentPaymentOutput ? null : getPayjoinURI();
transactionDiagram.setPayjoinURI(payjoinURI);
headersForm.walletTransactionProperty().addListener((observable, oldValue, walletTransaction) -> {
@@ -529,7 +530,6 @@ public LocalDate fromString(String value) {
noWalletsWarningLink.visibleProperty().bind(noWalletsWarning.visibleProperty());
boolean taprootInput = psbt.getPsbtInputs().stream().anyMatch(PSBTInput::isTaproot);
- boolean silentPaymentOutput = psbt.getPsbtOutputs().stream().anyMatch(o -> o.getSilentPaymentAddress() != null);
SigHash requiredSigHash = taprootInput ? SigHash.DEFAULT : SigHash.ALL;
SigHash psbtSigHash = silentPaymentOutput ? requiredSigHash : psbt.getPsbtInputs().stream().map(PSBTInput::getSigHash).filter(Objects::nonNull).findFirst().orElse(requiredSigHash);
sigHash.setItems(FXCollections.observableList(silentPaymentOutput ? List.of(requiredSigHash) : (taprootInput ? SigHash.TAPROOT_SIGNING_TYPES : SigHash.LEGACY_SIGNING_TYPES)));
### src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
@@ -829,6 +829,8 @@ public void scanQrAddress(ActionEvent event) {
private void updateFromURI(BitcoinURI bitcoinURI) {
if(bitcoinURI.getAddress() != null) {
address.setText(bitcoinURI.getAddress().toString());
+ } else if(bitcoinURI.getSilentPaymentAddress() != null) {
+ address.setText(bitcoinURI.getSilentPaymentAddress().getAddress());
}
if(bitcoinURI.getLabel() != null) {
label.setText(bitcoinURI.getLabel());
### src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
@@ -9,6 +9,7 @@
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.bip47.SecretPoint;
import com.sparrowwallet.drongo.crypto.ECKey;
+import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
@@ -619,9 +620,9 @@ public void updateTransaction(List<Payment> transactionPayments) {
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
BlockTransaction replacedTransaction = replacedTransactionProperty.get();
- //Disable RBF for silent payments, as we can't guarantee RBF won't be attempted on another device without knowledge to recompute the address if necessary
+ //Disable RBF for silent payments (incl change), as we can't guarantee RBF won't be attempted on another device without knowledge to recompute the address if necessary
boolean allowRbf = (replacedTransaction == null || replacedTransaction.getTransaction().isReplaceByFee())
- && payments.stream().noneMatch(payment -> payment instanceof SilentPayment);
+ && wallet.getPolicyType() != PolicyType.SINGLE_SP && payments.stream().noneMatch(payment -> payment instanceof SilentPayment);
TransactionParameters params = new TransactionParameters(getUtxoSelectors(payments), getTxoFilters(),
payments, opReturnsList, excludedChangeNodes,
@@ -987,6 +988,10 @@ private boolean isPayjoinTx() {
}
private BitcoinURI getPayjoinURI(List<Payment> payments) {
+ if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP || payments.stream().anyMatch(payment -> payment instanceof SilentPayment)) {
+ return null;
+ }
+
for(Payment payment : payments) {
BitcoinURI payjoinURI = getPayjoinURI(payment.getAddress());
if(payjoinURI != null) {
@@ -1036,7 +1041,7 @@ private void updateMaxClearButtons(UtxoSelector utxoSelector, TxoFilter txoFilte
private boolean isFakeMixPossible(List<Payment> payments) {
return utxoSelectorProperty.get() == null && payments.size() == 1
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getNode(KeyPurpose.RECEIVE).getAddress().getScriptType())
- && getPayjoinURI(payments.get(0).getAddress()) == null;
+ && getPayjoinURI(payments) == null;
}
private void updateOptimizationButtons(List<Payment> payments) {
### src/test/java/com/sparrowwallet/sparrow/payjoin/PayjoinTest.java
@@ -9,6 +9,7 @@
import com.sparrowwallet.drongo.protocol.TransactionWitness;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.junit.jupiter.api.Assertions;
@@ -23,6 +24,8 @@ public class PayjoinTest {
private static final ECKey PAYMENT_KEY = ECKey.fromPrivate(BigInteger.valueOf(1003));
private static final ECKey SUBSTITUTE_KEY = ECKey.fromPrivate(BigInteger.valueOf(1004));
private static final ECKey RECEIVER_KEY = ECKey.fromPrivate(BigInteger.valueOf(1005));
+ private static final ECKey SCAN_KEY = ECKey.fromPrivate(BigInteger.valueOf(1006));
+ private static final ECKey SPEND_KEY = ECKey.fromPrivate(BigInteger.valueOf(1007));
private static final Sha256Hash SENDER_UTXO_HASH = Sha256Hash.wrap("1111111111111111111111111111111111111111111111111111111111111111");
private static final Sha256Hash RECEIVER_UTXO_HASH = Sha256Hash.wrap("2222222222222222222222222222222222222222222222222222222222222222");
@@ -102,6 +105,16 @@ public void proposalWithOversizedReceiverWitnessIsRejected() throws Exception {
payjoin.checkProposal(original, getProposalPSBT(getPaymentScript(), PAYMENT_VALUE + RECEIVER_UTXO_VALUE, CHANGE_VALUE, 10000), CHANGE_OUTPUT_INDEX, MAX_ADDITIONAL_FEE_CONTRIBUTION, MIN_FEE_RATE, true);
}
+ @Test
+ public void originalWithSilentPaymentOutputIsRejected() throws Exception {
+ PSBT original = getOriginalPSBT();
+ //The silent payment output script is derived from the original inputs, so the receiver adding an input would leave it undetectable to its recipient
+ original.getPsbtOutputs().get(0).setSilentPaymentAddress(new SilentPaymentAddress(SCAN_KEY, SPEND_KEY));
+
+ IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, () -> getPayjoin(original));
+ Assertions.assertEquals("Original PSBT for payjoin transaction cannot contain silent payment outputs", e.getMessage());
+ }
+
private Payjoin getPayjoin(PSBT original) throws Exception {
Wallet wallet = new Wallet();
wallet.setScriptType(ScriptType.P2WPKH);Why this scored 59/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.