From c67008840d3b6cd8322701ef97b3b193ae015d20 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Thu, 23 Apr 2026 12:23:40 +0200 Subject: [PATCH] implement silent payments change outputs and other sp related fixes --- drongo | 2 +- .../sparrow/control/EntryCell.java | 21 ++++++---- .../sparrow/control/TransactionDiagram.java | 38 +++++++++++++++++-- .../control/TransactionDiagramLabel.java | 8 ++++ .../sparrow/net/cormorant/Cormorant.java | 5 +++ .../sparrow/transaction/OutputController.java | 2 + .../sparrow/transaction/OutputForm.java | 2 + .../sparrow/wallet/SendController.java | 6 +-- 8 files changed, 69 insertions(+), 15 deletions(-) diff --git a/drongo b/drongo index bc526c90..854eb513 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit bc526c90e1a4fa2988df1538d6925ea7fdceb5c3 +Subproject commit 854eb513e9212e6d1e7662e20833d9fda00573b6 diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java index 7df69bed..356e2638 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 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 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 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 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 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 b832983b..af8a9a8e 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 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 seenIndexes = new HashSet<>(); for(Map.Entry 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 e3ff77ba..56010c35 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 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 54219c4c..f39d7368 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 40f55c52..75c2ccc3 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 95a451db..0e8b4327 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 286cee8d..5b0d4765 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 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);