scope payjoin endpoints to the payment tab and transaction instead of the destination address

This commit is contained in:
Craig Raw
2026-08-26 12:54:08 +02:00
parent d8ea42643a
commit 7da2ebc760
7 changed files with 197 additions and 51 deletions
@@ -19,6 +19,7 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.Auth47;
import com.sparrowwallet.drongo.protocol.BlockHeader;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.uri.BitcoinURI;
@@ -151,7 +152,7 @@ public class AppServices {
private static final List<URI> argUris = new ArrayList<>();
private static final Map<Address, BitcoinURI> payjoinURIs = new HashMap<>();
private static final Map<Sha256Hash, BitcoinURI> payjoinURIs = new HashMap<>();
private final ChangeListener<Boolean> onlineServicesListener = new ChangeListener<>() {
@Override
@@ -870,19 +871,21 @@ public class AppServices {
return devices == null ? new ArrayList<>() : devices;
}
public static BitcoinURI getPayjoinURI(Address address) {
return payjoinURIs.get(address);
public static BitcoinURI getPayjoinURI(PSBT psbt) {
return psbt == null ? null : payjoinURIs.get(psbt.getTransaction().calculateTxId(false));
}
public static void addPayjoinURI(BitcoinURI bitcoinURI) {
public static void addPayjoinURI(PSBT psbt, BitcoinURI bitcoinURI) {
if(bitcoinURI.getPayjoinUrl() == null || bitcoinURI.getAddress() == null) {
throw new IllegalArgumentException("Not a valid payjoin URI");
}
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
payjoinURIs.put(psbt.getTransaction().calculateTxId(false), bitcoinURI);
}
public static void clearPayjoinURI(Address address) {
payjoinURIs.remove(address);
public static void clearPayjoinURI(PSBT psbt) {
if(psbt != null) {
payjoinURIs.remove(psbt.getTransaction().calculateTxId(false));
}
}
public static void clearTransactionHistoryCache(Wallet wallet) {
@@ -1123,7 +1126,7 @@ public class AppServices {
if(wallet != null) {
final Wallet sendingWallet = wallet;
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getSpendableUtxos().keySet()), true));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()))));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()), bitcoinURI)));
}
} catch(Exception e) {
showErrorDialog("Not a valid bitcoin URI", e.getMessage());
@@ -77,6 +77,7 @@ public class TransactionDiagram extends GridPane {
private final BooleanProperty finalProperty = new SimpleBooleanProperty(false);
private final ObjectProperty<TransactionDiagramLabel> labelProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<OptimizationStrategy> optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY);
private final ObjectProperty<BitcoinURI> payjoinURIProperty = new SimpleObjectProperty<>(null);
private boolean expanded;
private TransactionDiagram expandedDiagram;
private ContextMenu contextMenu;
@@ -224,6 +225,7 @@ public class TransactionDiagram extends GridPane {
private void updateDerivedDiagram(TransactionDiagram diagram) {
diagram.setOptimizationStrategy(getOptimizationStrategy());
diagram.setPayjoinURI(getPayjoinURI());
diagram.walletTx = walletTx;
if(diagram.isExpanded()) {
@@ -342,20 +344,12 @@ public class TransactionDiagram extends GridPane {
}
}
private BitcoinURI getPayjoinURI() {
for(Payment payment : walletTx.getPayments()) {
try {
Address address = payment.getAddress();
BitcoinURI bitcoinURI = AppServices.getPayjoinURI(address);
if(bitcoinURI != null) {
return bitcoinURI;
}
} catch(Exception e) {
//ignore
}
}
public BitcoinURI getPayjoinURI() {
return payjoinURIProperty.get();
}
return null;
public void setPayjoinURI(BitcoinURI payjoinURI) {
this.payjoinURIProperty.set(payjoinURI);
}
private Pane getInputsType(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -8,10 +9,16 @@ import java.util.List;
public class SendPaymentsEvent {
private final Wallet wallet;
private final List<Payment> payments;
private final BitcoinURI bitcoinURI;
public SendPaymentsEvent(Wallet wallet, List<Payment> payments) {
this(wallet, payments, null);
}
public SendPaymentsEvent(Wallet wallet, List<Payment> payments, BitcoinURI bitcoinURI) {
this.wallet = wallet;
this.payments = payments;
this.bitcoinURI = bitcoinURI;
}
public Wallet getWallet() {
@@ -21,4 +28,8 @@ public class SendPaymentsEvent {
public List<Payment> getPayments() {
return payments;
}
public BitcoinURI getBitcoinURI() {
return bitcoinURI;
}
}
@@ -251,6 +251,8 @@ public class HeadersController extends TransactionFormController implements Init
private ElectrumServer.TransactionMempoolService transactionMempoolService;
private BitcoinURI payjoinURI;
private final Map<Integer, String> outputIndexLabels = new TreeMap<>();
@Override
@@ -459,6 +461,9 @@ public class HeadersController extends TransactionFormController implements Init
updateFee(feeAmt);
}
payjoinURI = getPayjoinURI();
transactionDiagram.setPayjoinURI(payjoinURI);
headersForm.walletTransactionProperty().addListener((observable, oldValue, walletTransaction) -> {
transactionDiagram.update(walletTransaction);
});
@@ -487,7 +492,6 @@ public class HeadersController extends TransactionFormController implements Init
saveFinalButton.visibleProperty().bind(broadcastButton.visibleProperty().not());
broadcastButton.visibleProperty().bind(AppServices.onlineProperty());
BitcoinURI payjoinURI = getPayjoinURI();
boolean isPayjoinOriginalTx = payjoinURI != null && headersForm.getPsbt() != null && headersForm.getPsbt().getPsbtInputs().stream().noneMatch(PSBTInput::isFinalized);
payjoinButton.managedProperty().bind(payjoinButton.visibleProperty());
payjoinButton.visibleProperty().set(isPayjoinOriginalTx);
@@ -906,21 +910,13 @@ public class HeadersController extends TransactionFormController implements Init
}
private BitcoinURI getPayjoinURI() {
if(headersForm.getPsbt() != null) {
for(TransactionOutput txOutput : headersForm.getPsbt().getTransaction().getOutputs()) {
try {
Address address = txOutput.getScript().getToAddresses()[0];
BitcoinURI bitcoinURI = AppServices.getPayjoinURI(address);
if(bitcoinURI != null) {
return bitcoinURI;
}
} catch(Exception e) {
//ignore
}
}
}
return AppServices.getPayjoinURI(headersForm.getPsbt());
}
return null;
private void registerPayjoinURI() {
if(payjoinURI != null) {
AppServices.addPayjoinURI(headersForm.getPsbt(), payjoinURI);
}
}
private static class BlockHeightContextMenu extends ContextMenu {
@@ -1283,6 +1279,9 @@ public class HeadersController extends TransactionFormController implements Init
ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(headersForm.getTransaction(), fee.getValue());
broadcastTransactionService.setOnSucceeded(workerStateEvent -> {
AppServices.clearPayjoinURI(headersForm.getPsbt());
payjoinButton.setVisible(false);
//Although we wait for WalletNodeHistoryChangedEvent to indicate tx is in mempool, start a scheduled service to check the script hashes should notifications fail
if(headersForm.getSigningWallet() != null) {
if(transactionMempoolService != null) {
@@ -1438,12 +1437,12 @@ public class HeadersController extends TransactionFormController implements Init
}
public void getPayjoinTransaction(ActionEvent event) {
BitcoinURI payjoinURI = getPayjoinURI();
if(payjoinURI == null) {
BitcoinURI currentPayjoinURI = getPayjoinURI();
if(currentPayjoinURI == null) {
throw new IllegalStateException("No valid Payjoin URI");
}
Payjoin payjoin = new Payjoin(payjoinURI, headersForm.getSigningWallet(), headersForm.getPsbt());
Payjoin payjoin = new Payjoin(currentPayjoinURI, headersForm.getSigningWallet(), headersForm.getPsbt());
Payjoin.RequestPayjoinPSBTService requestPayjoinPSBTService = new Payjoin.RequestPayjoinPSBTService(payjoin, true);
requestPayjoinPSBTService.setOnSucceeded(successEvent -> {
PSBT proposalPsbt = requestPayjoinPSBTService.getValue();
@@ -1502,6 +1501,7 @@ public class HeadersController extends TransactionFormController implements Init
if(headersForm.getTransaction().equals(event.getTransaction())) {
updateTxId();
updateEditable(headersForm.isEditable());
registerPayjoinURI();
}
}
@@ -1847,6 +1847,7 @@ public class HeadersController extends TransactionFormController implements Init
if(event.getPsbt().equals(headersForm.getPsbt())) {
updateTxId();
headersForm.setWalletTransaction(getWalletTransaction(headersForm.getInputTransactions()));
registerPayjoinURI();
}
}
@@ -151,6 +151,8 @@ public class PaymentController extends WalletFormController implements Initializ
private final ObjectProperty<DnsPayment> dnsPaymentProperty = new SimpleObjectProperty<>();
private final ObjectProperty<BitcoinURI> payjoinURIProperty = new SimpleObjectProperty<>();
private static final Wallet payNymWallet = new Wallet() {
@Override
public String getFullDisplayName() {
@@ -186,6 +188,10 @@ public class PaymentController extends WalletFormController implements Initializ
silentPaymentAddressProperty.set(null);
}
if(payjoinURIProperty.get() != null && !newValue.equals(payjoinURIProperty.get().getAddress().toString())) {
payjoinURIProperty.set(null);
}
try {
BitcoinURI bitcoinURI = new BitcoinURI(newValue);
Platform.runLater(() -> updateFromURI(bitcoinURI));
@@ -685,6 +691,10 @@ public class PaymentController extends WalletFormController implements Initializ
field.textProperty().addListener(listener);
}
public BitcoinURI getPayjoinURI() {
return payjoinURIProperty.get();
}
public boolean isValidPayment() {
try {
getPayment();
@@ -752,12 +762,6 @@ public class PaymentController extends WalletFormController implements Initializ
}
public void clear() {
try {
AppServices.clearPayjoinURI(getRecipientAddress());
} catch(InvalidAddressException e) {
//ignore
}
address.setText("");
label.setText("");
@@ -773,6 +777,7 @@ public class PaymentController extends WalletFormController implements Initializ
payNymProperty.set(null);
dnsPaymentProperty.set(null);
silentPaymentAddressProperty.set(null);
payjoinURIProperty.set(null);
}
public void setMaxInput(ActionEvent event) {
@@ -832,12 +837,16 @@ public class PaymentController extends WalletFormController implements Initializ
setRecipientValueSats(bitcoinURI.getAmount());
setFiatAmount(AppServices.getFiatCurrencyExchangeRate(), bitcoinURI.getAmount());
}
if(bitcoinURI.getAddress() != null && bitcoinURI.getPayjoinUrl() != null) {
AppServices.addPayjoinURI(bitcoinURI);
}
setPayjoinURI(bitcoinURI);
sendController.updateTransaction();
}
public void setPayjoinURI(BitcoinURI bitcoinURI) {
if(bitcoinURI.getAddress() != null && bitcoinURI.getPayjoinUrl() != null) {
payjoinURIProperty.set(bitcoinURI);
}
}
private List<Address> getOtherAddresses() {
List<Address> otherAddresses = new ArrayList<>();
for(Tab tab : sendController.getPaymentTabs().getTabs()) {
@@ -12,6 +12,7 @@ import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.*;
import com.sparrowwallet.sparrow.control.*;
@@ -414,6 +415,7 @@ public class SendController extends WalletFormController implements Initializabl
setFeeRate(feeRate);
}
transactionDiagram.setPayjoinURI(walletTransaction == null ? null : getPayjoinURI(walletTransaction.getPayments()));
transactionDiagram.update(walletTransaction);
updatePrivacyAnalysis(walletTransaction);
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate());
@@ -978,12 +980,35 @@ public class SendController extends WalletFormController implements Initializabl
private boolean isPayjoinTx() {
if(walletTransactionProperty.get() != null) {
return walletTransactionProperty.get().getPayments().stream().anyMatch(payment -> AppServices.getPayjoinURI(payment.getAddress()) != null);
return getPayjoinURI(walletTransactionProperty.get().getPayments()) != null;
}
return false;
}
private BitcoinURI getPayjoinURI(List<Payment> payments) {
for(Payment payment : payments) {
BitcoinURI payjoinURI = getPayjoinURI(payment.getAddress());
if(payjoinURI != null) {
return payjoinURI;
}
}
return null;
}
private BitcoinURI getPayjoinURI(Address address) {
for(Tab tab : paymentTabs.getTabs()) {
PaymentController controller = (PaymentController)tab.getUserData();
BitcoinURI payjoinURI = controller.getPayjoinURI();
if(payjoinURI != null && payjoinURI.getAddress().equals(address)) {
return payjoinURI;
}
}
return null;
}
private Node getSliderThumb() {
return targetBlocks.lookup(".thumb");
}
@@ -1011,7 +1036,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().getNode(KeyPurpose.RECEIVE).getAddress().getScriptType())
&& AppServices.getPayjoinURI(payments.get(0).getAddress()) == null;
&& getPayjoinURI(payments.get(0).getAddress()) == null;
}
private void updateOptimizationButtons(List<Payment> payments) {
@@ -1170,6 +1195,10 @@ public class SendController extends WalletFormController implements Initializabl
addWalletTransactionNodes();
walletForm.setCreatedWalletTransaction(walletTransaction);
PSBT psbt = walletTransaction.createPSBT();
BitcoinURI payjoinURI = getPayjoinURI(walletTransaction.getPayments());
if(payjoinURI != null) {
AppServices.addPayjoinURI(psbt, payjoinURI);
}
EventManager.get().post(new ViewPSBTEvent(createButton.getScene().getWindow(), walletTransaction.getPayments().get(0).getLabel(), null, psbt));
}
@@ -1511,6 +1540,10 @@ public class SendController extends WalletFormController implements Initializabl
clear(null);
Platform.runLater(() -> {
setPayments(event.getPayments());
if(event.getBitcoinURI() != null) {
PaymentController controller = (PaymentController)paymentTabs.getTabs().get(0).getUserData();
controller.setPayjoinURI(event.getBitcoinURI());
}
updateTransaction(event.getPayments() == null || event.getPayments().stream().anyMatch(Payment::isSendMax));
});
}
@@ -1661,7 +1694,7 @@ public class SendController extends WalletFormController implements Initializabl
boolean roundPaymentAmounts = userPayments.stream().anyMatch(payment -> payment.getAmount() % 100 == 0);
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);
boolean payjoinPresent = getPayjoinURI(userPayments) != null;
if(optimizationStrategy == OptimizationStrategy.PRIVACY) {
if(fakeMixPresent) {
@@ -0,0 +1,95 @@
package com.sparrowwallet.sparrow.payjoin;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.protocol.TransactionWitness;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.sparrow.AppServices;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.util.List;
public class PayjoinURITest {
private static final ECKey SENDER_KEY = ECKey.fromPrivate(BigInteger.valueOf(1001));
private static final ECKey CHANGE_KEY = ECKey.fromPrivate(BigInteger.valueOf(1002));
private static final ECKey PAYMENT_KEY = ECKey.fromPrivate(BigInteger.valueOf(1003));
private static final Sha256Hash SENDER_UTXO_HASH = Sha256Hash.wrap("1111111111111111111111111111111111111111111111111111111111111111");
private static final Sha256Hash OTHER_UTXO_HASH = Sha256Hash.wrap("3333333333333333333333333333333333333333333333333333333333333333");
private static final long SENDER_UTXO_VALUE = 200000L;
private static final long PAYMENT_VALUE = 100000L;
private static final long CHANGE_VALUE = 90000L;
@AfterEach
public void clearPayjoinURIs() {
AppServices.clearPayjoinURI(getOriginalPSBT(SENDER_UTXO_HASH));
AppServices.clearPayjoinURI(getOriginalPSBT(OTHER_UTXO_HASH));
}
@Test
public void signedTransactionReturnedForBroadcastRetrievesPayjoinURI() throws Exception {
PSBT original = getOriginalPSBT(SENDER_UTXO_HASH);
AppServices.addPayjoinURI(original, getPayjoinURI());
//The signing device is sent the exported PSBT, and returns it signed
PSBT exported = PSBT.fromString(original.getForExport().toBase64String());
finalise(exported.getPsbtInputs().get(0), exported.getTransaction());
PSBT signed = PSBT.fromString(exported.toBase64String());
Assertions.assertNotNull(AppServices.getPayjoinURI(signed));
}
@Test
public void laterTransactionToTheSameAddressDoesNotRetrievePayjoinURI() throws Exception {
PSBT original = getOriginalPSBT(SENDER_UTXO_HASH);
AppServices.addPayjoinURI(original, getPayjoinURI());
//A later payment to the same address spending a different utxo
PSBT later = getOriginalPSBT(OTHER_UTXO_HASH);
Assertions.assertNull(AppServices.getPayjoinURI(later));
}
@Test
public void clearedPayjoinURIIsNotRetrieved() throws Exception {
PSBT original = getOriginalPSBT(SENDER_UTXO_HASH);
AppServices.addPayjoinURI(original, getPayjoinURI());
Assertions.assertNotNull(AppServices.getPayjoinURI(original));
AppServices.clearPayjoinURI(original);
Assertions.assertNull(AppServices.getPayjoinURI(original));
}
private BitcoinURI getPayjoinURI() throws Exception {
return new BitcoinURI("bitcoin:" + ScriptType.P2WPKH.getAddress(PAYMENT_KEY.getPubKeyHash()) + "?pj=https://payjoin.example.com/pj");
}
private PSBT getOriginalPSBT(Sha256Hash utxoHash) {
Transaction transaction = new Transaction();
transaction.setVersion(2);
transaction.addInput(utxoHash, 0, new Script(new byte[0]));
transaction.addOutput(PAYMENT_VALUE, ScriptType.P2WPKH.getOutputScript(PAYMENT_KEY.getPubKeyHash()));
transaction.addOutput(CHANGE_VALUE, ScriptType.P2WPKH.getOutputScript(CHANGE_KEY.getPubKeyHash()));
//Sparrow creates PSBTv2, which is exported as PSBTv0 where no silent payments are present
PSBT psbt = new PSBT(transaction);
psbt.getPsbtInputs().get(0).setWitnessUtxo(new TransactionOutput(null, SENDER_UTXO_VALUE, ScriptType.P2WPKH.getOutputScript(SENDER_KEY.getPubKeyHash())));
return psbt;
}
private void finalise(PSBTInput psbtInput, Transaction transaction) {
psbtInput.setFinalScriptSig(new Script(new byte[0]));
psbtInput.setFinalScriptWitness(new TransactionWitness(transaction, List.of(new byte[71], SENDER_KEY.getPubKey())));
}
}