implement silent payments change outputs and other sp related fixes
What changed, and why it matters
This commit adds support for silent payment change outputs in the Sparrow Wallet and fixes related silent payment issues. Silent payments are a newer Bitcoin privacy feature that lets someone receive payments without publicly revealing their receiving address on the blockchain. The changes touch how transactions are displayed, how change is created, and how the wallet interacts with Bitcoin Core. There is no clear security bug being fixed in the diff itself; it looks like a feature/enhancement commit with some defensive checks, such as blocking Bitcoin Core scanning for silent payment wallets because that is not yet supported.
Review the updated drongo submodule diff separately to confirm the silent payment cryptographic logic is correct. Test silent payment change output creation and ensure the Bitcoin Core scanning guard works as intended. Monitor for any follow-up commits or advisories from the Sparrow Wallet team regarding silent payment security.
Security signals we found
New feature: silent payments change output support
Defensive check: blocks Bitcoin Core scanning for SINGLE_SP silent payment wallets
Address derivation change: getFreshNode replaced with getNode in several places
UI/transaction labeling changes for silent payment change outputs
Submodule update to drongo (silent payments library changes not visible in diff)
Evidence from the diff
The commit implements silent payment (SP) change outputs and related fixes across the Sparrow UI and transaction logic. Key changes include: switching from getFreshNode to getNode for change/receive address derivation in fee/CPFP calculations; creating SilentPayment change outputs for SINGLE_SP policy wallets; adding UI rendering and labeling for silent payment change outputs in TransactionDiagram and TransactionDiagramLabel; adding a new SilentPaymentChangeOutput output type in OutputController and OutputForm; and preventing Cormorant/Bitcoin Core wallet scanning when any open wallet uses PolicyType.SINGLE_SP. The drongo submodule is also updated. No explicit vulnerability or CVE is referenced, and the diff does not show a clear exploitable flaw.
Changed components
EntryCell.javaTransactionDiagram.javaTransactionDiagramLabel.javaCormorant.javaOutputController.javaOutputForm.javaSendController.javadrongo submoduleInspect captured patch +69 / −15
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java
index 7df69be..356e263 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java
@@ -250,7 +250,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
double vSize = tx.getVirtualSize();
if(changeTotal == 0) {
//Add change output length to vSize if change was not present on the original transaction
- TransactionOutput changeOutput = new TransactionOutput(new Transaction(), 1L, transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getOutputScript());
+ TransactionOutput changeOutput = new TransactionOutput(new Transaction(), 1L, transactionEntry.getWallet().getNode(KeyPurpose.CHANGE).getOutputScript());
vSize += changeOutput.getLength();
}
double inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? (double)tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
@@ -335,8 +335,10 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
if(cancelTransaction) {
Payment existing = payments.get(0);
- Address address = transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getAddress();
- Payment payment = new Payment(address, existing.getLabel(), existing.getAmount(), true);
+ Payment payment = transactionEntry.getWallet().getPolicyType() == PolicyType.SINGLE_SP ?
+ new SilentPayment(transactionEntry.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getChangeAddress().getSilentPaymentAddress(),
+ existing.getLabel(), existing.getAmount(), true) :
+ new Payment(transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getAddress(), existing.getLabel(), existing.getAmount(), true);
payments.clear();
payments.add(payment);
opReturns.clear();
@@ -370,10 +372,10 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
BlockTransactionHashIndex cpfpUtxo = ourOutputs.get(0);
- Address freshAddress = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress();
- TransactionOutput txOutput = new TransactionOutput(new Transaction(), cpfpUtxo.getValue(), freshAddress.getOutputScript());
- long dustThreshold = freshAddress.getScriptType().getDustThreshold(txOutput, Transaction.DUST_RELAY_TX_FEE);
- double inputSize = freshAddress.getScriptType().getInputVbytes();
+ Address receiveAddress = transactionEntry.getWallet().getNode(KeyPurpose.RECEIVE).getAddress();
+ TransactionOutput txOutput = new TransactionOutput(new Transaction(), cpfpUtxo.getValue(), receiveAddress.getOutputScript());
+ long dustThreshold = receiveAddress.getScriptType().getDustThreshold(txOutput, Transaction.DUST_RELAY_TX_FEE);
+ double inputSize = receiveAddress.getScriptType().getInputVbytes();
double vSize = inputSize + txOutput.getLength();
List<TxoFilter> txoFilters = List.of(new ExcludeTxoFilter(List.of(cpfpUtxo)), new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(transactionEntry.getWallet()));
@@ -397,7 +399,10 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
label += (label.isEmpty() ? "" : " ") + "(CPFP)";
- Payment payment = new Payment(freshAddress, label, inputTotal, true);
+ Payment payment = transactionEntry.getWallet().getPolicyType() == PolicyType.SINGLE_SP ?
+ new SilentPayment(transactionEntry.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getChangeAddress().getSilentPaymentAddress(),
+ label, inputTotal, true) :
+ new Payment(transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getAddress(), label, inputTotal, true);
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null, true)));
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java
index b832983..af8a9a8 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
+import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
@@ -696,7 +697,7 @@ public class TransactionDiagram extends GridPane {
List<Long> values = walletTx.getOutputs().stream().filter(output -> !(output instanceof WalletTransaction.NonAddressOutput))
.map(output -> output.getTransactionOutput().getValue()).collect(Collectors.toList());
values.add(walletTx.getFee());
- int numOutputs = displayedPayments.size() + walletTx.getChangeMap().size() + 1;
+ int numOutputs = displayedPayments.size() + walletTx.getChangeMap().size() + walletTx.getSilentPaymentChangeOutputs().size() + 1;
for(int i = 1; i <= numOutputs; i++) {
CubicCurve curve = new CubicCurve();
curve.getStyleClass().add("output-line");
@@ -789,8 +790,8 @@ public class TransactionDiagram extends GridPane {
Set<Integer> seenIndexes = new HashSet<>();
for(Map.Entry<WalletNode, Long> changeEntry : walletTx.getChangeMap().entrySet()) {
WalletNode changeNode = changeEntry.getKey();
- WalletNode defaultChangeNode = walletTx.getWallet().getFreshNode(KeyPurpose.CHANGE);
- boolean overGapLimit = (changeNode.getIndex() - defaultChangeNode.getIndex()) > walletTx.getWallet().getGapLimit();
+ boolean overGapLimit = walletTx.getWallet().getPolicyType() != PolicyType.SINGLE_SP &&
+ (changeNode.getIndex() - walletTx.getWallet().getFreshNode(KeyPurpose.CHANGE).getIndex()) > walletTx.getWallet().getGapLimit();
HBox actionBox = new HBox();
actionBox.setAlignment(Pos.CENTER_LEFT);
@@ -845,6 +846,37 @@ public class TransactionDiagram extends GridPane {
outputNodes.add(changeIndex, new OutputNode(actionBox, changeAddress, changeEntry.getValue()));
}
+ for(WalletTransaction.SilentPaymentChangeOutput spChangeOutput : walletTx.getSilentPaymentChangeOutputs()) {
+ HBox actionBox = new HBox();
+ actionBox.setAlignment(Pos.CENTER_LEFT);
+ SilentPayment silentPayment = spChangeOutput.getSilentPayment();
+ SilentPaymentAddress spAddress = silentPayment.getSilentPaymentAddress();
+ String changeDesc = spAddress.toString().substring(0, 8) + "...";
+ Label changeLabel = new Label(changeDesc, getChangeGlyph());
+ changeLabel.getStyleClass().addAll("output-label", "change-label");
+ changeLabel.setSkin(new AddressLabelSkin(changeLabel));
+ Tooltip changeTooltip = new Tooltip("Change of " + getCoinValue(silentPayment.getAmount()) + "\n" + spAddress);
+ changeTooltip.getStyleClass().add("change-label");
+ changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
+ changeTooltip.setShowDuration(Duration.INDEFINITE);
+ changeTooltip.setSkin(new AddressTooltipSkin(changeTooltip));
+ changeLabel.setTooltip(changeTooltip);
+ actionBox.getChildren().add(changeLabel);
+
+ if(isExpanded()) {
+ changeLabel.setMinWidth(120);
+ Region region = new Region();
+ region.setMinWidth(20);
+ HBox.setHgrow(region, Priority.ALWAYS);
+ CoinLabel amountLabel = new CoinLabel();
+ amountLabel.setValue(silentPayment.getAmount());
+ amountLabel.setMinWidth(TextUtils.computeTextWidth(amountLabel.getFont(), amountLabel.getText(), 0.0D) + 2);
+ actionBox.getChildren().addAll(region, amountLabel);
+ }
+
+ outputNodes.add(new OutputNode(actionBox, silentPayment.isAddressComputed() ? silentPayment.getAddress() : null, silentPayment.getAmount(), null, spAddress));
+ }
+
for(OutputNode outputNode : outputNodes) {
outputsBox.getChildren().add(outputNode.outputLabel);
outputsBox.getChildren().add(createSpacer());
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java
index e3ff77b..56010c3 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagramLabel.java
@@ -112,6 +112,7 @@ public class TransactionDiagramLabel extends HBox {
Map<WalletNode, Long> changeMap = walletTx.getChangeMap();
outputLabels.addAll(changeMap.entrySet().stream().map(changeEntry -> getOutputLabel(transactionDiagram, changeEntry)).collect(Collectors.toList()));
+ outputLabels.addAll(walletTx.getSilentPaymentChangeOutputs().stream().map(spChange -> getOutputLabel(transactionDiagram, spChange)).collect(Collectors.toList()));
OutputLabel feeOutputLabel = getFeeOutputLabel(transactionDiagram);
if(feeOutputLabel != null) {
@@ -220,6 +221,13 @@ public class TransactionDiagramLabel extends HBox {
return getOutputLabel(glyph, text);
}
+ private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, WalletTransaction.SilentPaymentChangeOutput spChangeOutput) {
+ Glyph glyph = GlyphUtils.getChangeGlyph();
+ String text = "Change of " + transactionDiagram.getCoinValue(spChangeOutput.getSilentPayment().getAmount()) + " to " + spChangeOutput.getSilentPayment();
+
+ return getOutputLabel(glyph, text);
+ }
+
private OutputLabel getFeeOutputLabel(TransactionDiagram transactionDiagram) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
if(walletTx.getFee() < 0) {
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/Cormorant.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/Cormorant.java
index 54219c4..f39d736 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/Cormorant.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/Cormorant.java
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.net.cormorant;
import com.google.common.eventbus.EventBus;
import com.sparrowwallet.drongo.address.Address;
+import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Server;
@@ -35,6 +36,10 @@ public class Cormorant {
}
public Server start() throws CormorantBitcoindException {
+ if(useWallets && AppServices.get().getOpenWallets().keySet().stream().anyMatch(wallet -> wallet.getPolicyType() == PolicyType.SINGLE_SP)) {
+ throw new CormorantBitcoindException("Scanning silent payment wallets is not currently supported with Bitcoin Core");
+ }
+
bitcoindClient = new BitcoindClient(useWallets);
bitcoindClient.initialize();
diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java
index 40f55c5..75c2ccc 100644
--- a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputController.java
@@ -126,6 +126,8 @@ public class OutputController extends TransactionFormController implements Initi
WalletTransaction.Output output = outputs.get(outputForm.getIndex());
if(output instanceof WalletTransaction.NonAddressOutput) {
outputFieldset.setText(baseText);
+ } else if(output instanceof WalletTransaction.SilentPaymentChangeOutput) {
+ outputFieldset.setText(baseText + " - Silent Payment Change");
} else if(output instanceof WalletTransaction.SilentPaymentOutput) {
outputFieldset.setText(baseText + " - Silent Payment");
} else if(output instanceof WalletTransaction.ConsolidationOutput) {
diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java
index 95a451d..0e8b432 100644
--- a/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java
+++ b/src/main/java/com/sparrowwallet/sparrow/transaction/OutputForm.java
@@ -87,6 +87,8 @@ public class OutputForm extends IndexedTransactionForm {
} else {
return new Label("Output #" + getIndex(), GlyphUtils.getOpcodeGlyph());
}
+ } else if(output instanceof WalletTransaction.SilentPaymentChangeOutput) {
+ return new Label("Change", GlyphUtils.getChangeGlyph());
} else if(output instanceof WalletTransaction.PaymentOutput paymentOutput) {
Payment payment = paymentOutput.getPayment();
return new Label(payment.getLabel() != null && payment.getType() != Payment.Type.FAKE_MIX && payment.getType() != Payment.Type.MIX ? payment.getLabel() : payment.toString(),
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
index 286cee8..5b0d476 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
@@ -676,7 +676,7 @@ public class SendController extends WalletFormController implements Initializabl
OptimizationStrategy optimizationStrategy = (OptimizationStrategy)optimizationToggleGroup.getSelectedToggle().getUserData();
if(optimizationStrategy == OptimizationStrategy.PRIVACY
&& payments.size() == 1
- && (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType())) {
+ && (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getNode(KeyPurpose.RECEIVE).getAddress().getScriptType())) {
selectors.add(new StonewallUtxoSelector(payments.get(0).getAddress().getScriptType(), noInputsFee));
}
@@ -1008,7 +1008,7 @@ public class SendController extends WalletFormController implements Initializabl
private boolean isFakeMixPossible(List<Payment> payments) {
return utxoSelectorProperty.get() == null && payments.size() == 1
- && (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType())
+ && (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getNode(KeyPurpose.RECEIVE).getAddress().getScriptType())
&& AppServices.getPayjoinURI(payments.get(0).getAddress()) == null;
}
@@ -1652,7 +1652,7 @@ public class SendController extends WalletFormController implements Initializabl
OptimizationStrategy optimizationStrategy = getPreferredOptimizationStrategy();
boolean fakeMixPresent = payments.stream().anyMatch(payment -> payment.getType() == Payment.Type.FAKE_MIX);
boolean roundPaymentAmounts = userPayments.stream().anyMatch(payment -> payment.getAmount() % 100 == 0);
- boolean mixedAddressTypes = userPayments.stream().anyMatch(payment -> payment.getAddress().getScriptType() != getWalletForm().getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType());
+ boolean mixedAddressTypes = userPayments.stream().anyMatch(payment -> payment.getAddress().getScriptType() != getWalletForm().getWallet().getNode(KeyPurpose.RECEIVE).getAddress().getScriptType());
boolean addressReuse = walletNodePayments.stream().anyMatch(walletNodePayment -> !walletNodePayment.getWalletNode().getTransactionOutputs().isEmpty());
boolean payjoinPresent = userPayments.stream().anyMatch(payment -> AppServices.getPayjoinURI(payment.getAddress()) != null);
Why this scored 27/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.