implement silent payments change outputs and other sp related fixes

This commit is contained in:
Craig Raw
2026-04-23 12:23:40 +02:00
parent 17a04510fd
commit c67008840d
8 changed files with 69 additions and 15 deletions
@@ -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)));
@@ -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());
@@ -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) {
@@ -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();
@@ -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) {
@@ -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(),
@@ -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);