support sending to and displaying bip353 human readable names and include dnssec proof in associated psbts

This commit is contained in:
Craig Raw
2025-07-24 14:36:11 +02:00
parent 9dcf210762
commit 5f62523710
29 changed files with 351 additions and 94 deletions
@@ -1233,7 +1233,7 @@ public class AppServices {
}
public static Font getMonospaceFont() {
return Font.font("Roboto Mono", 13);
return Font.font("Fragment Mono Regular", 13);
}
public static boolean isOnWayland() {
@@ -113,8 +113,8 @@ public class SparrowDesktop extends Application {
private void initializeFonts() {
GlyphFontRegistry.register(new FontAwesome5());
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Italic.ttf"), 11);
Font.loadFont(AppServices.class.getResourceAsStream("/font/FragmentMono-Regular.ttf"), 13);
Font.loadFont(AppServices.class.getResourceAsStream("/font/FragmentMono-Italic.ttf"), 11);
if(OsType.getCurrent() == OsType.MACOS) {
Font.loadFont(AppServices.class.getResourceAsStream("/font/LiberationSans-Regular.ttf"), 13);
}
@@ -6,10 +6,16 @@ import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.controlsfx.control.textfield.CustomTextField;
import java.util.List;
public class ComboBoxTextField extends CustomTextField {
private final ObjectProperty<ComboBox<?>> comboProperty = new SimpleObjectProperty<>();
@@ -68,4 +74,53 @@ public class ComboBoxTextField extends CustomTextField {
public void setComboProperty(ComboBox<?> comboProperty) {
this.comboProperty.set(comboProperty);
}
public ContextMenu getCustomContextMenu(List<MenuItem> customItems) {
return new CustomContextMenu(customItems);
}
public class CustomContextMenu extends ContextMenu {
public CustomContextMenu(List<MenuItem> customItems) {
super();
setFont(null);
MenuItem undo = new MenuItem("Undo");
undo.setOnAction(_ -> undo());
MenuItem redo = new MenuItem("Redo");
redo.setOnAction(_ -> redo());
MenuItem cut = new MenuItem("Cut");
cut.setOnAction(_ -> cut());
MenuItem copy = new MenuItem("Copy");
copy.setOnAction(_ -> copy());
MenuItem paste = new MenuItem("Paste");
paste.setOnAction(_ -> paste());
MenuItem delete = new MenuItem("Delete");
delete.setOnAction(_ -> deleteText(getSelection()));
MenuItem selectAll = new MenuItem("Select All");
selectAll.setOnAction(_ -> selectAll());
getItems().addAll(undo, redo, new SeparatorMenuItem(), cut, copy, paste, delete, new SeparatorMenuItem(), selectAll);
getItems().addAll(customItems);
setOnShowing(_ -> {
boolean hasSelection = getSelection().getLength() > 0;
boolean hasText = getText() != null && !getText().isEmpty();
boolean clipboardHasContent = Clipboard.getSystemClipboard().hasString();
undo.setDisable(!isUndoable());
redo.setDisable(!isRedoable());
cut.setDisable(!isEditable() || !hasSelection);
copy.setDisable(!hasSelection);
paste.setDisable(!isEditable() || !clipboardHasContent);
delete.setDisable(!hasSelection);
selectAll.setDisable(!hasText);
});
}
}
}
@@ -4,6 +4,8 @@ import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OsType;
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.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
@@ -693,9 +695,10 @@ public class TransactionDiagram extends GridPane {
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
Wallet toBip47Wallet = getBip47SendWallet(payment);
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment.getAddress());
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
+ getSatsValue(payment.getAmount()) + " sats to "
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString())
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (dnsPayment == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : dnsPayment.toString()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString())
+ (walletTx.isDuplicateAddress(payment) ? " (Duplicate)" : ""));
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
@@ -1,5 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -206,7 +208,9 @@ public class TransactionDiagramLabel extends HBox {
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment);
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment.getAddress());
String recipient = dnsPayment == null ? payment.getAddress().toString() : dnsPayment.toString();
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + recipient;
return getOutputLabel(glyph, text);
}
@@ -240,7 +244,7 @@ public class TransactionDiagramLabel extends HBox {
icon.setGraphic(glyph);
CopyableLabel label = new CopyableLabel();
label.setFont(Font.font("Roboto Mono Italic", 13));
label.setFont(Font.font("Fragment Mono Italic", 13));
label.setText(text);
HBox output = new HBox(5);
@@ -40,6 +40,10 @@ public class Payjoin {
this.wallet = wallet;
this.psbt = psbt;
if(payjoinURI.getAddress() == null) {
throw new IllegalArgumentException("Payjoin URI must have an address");
}
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");
@@ -180,6 +180,9 @@ public class HeadersController extends TransactionFormController implements Init
@FXML
private CopyableLabel blockTimestamp;
@FXML
private Form blockchainSpacerForm;
@FXML
private Form signingWalletForm;
@@ -451,6 +454,7 @@ public class HeadersController extends TransactionFormController implements Init
headersForm.setWalletTransaction(getWalletTransaction(headersForm.getInputTransactions()));
blockchainForm.managedProperty().bind(blockchainForm.visibleProperty());
blockchainSpacerForm.managedProperty().bind(blockchainForm.managedProperty());
signingWalletForm.managedProperty().bind(signingWalletForm.visibleProperty());
sigHashForm.managedProperty().bind(sigHashForm.visibleProperty());
@@ -9,15 +9,13 @@ import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.bip47.InvalidPaymentCodeException;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.*;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -25,6 +23,8 @@ import com.sparrowwallet.sparrow.io.CardApi;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentResolver;
import com.sparrowwallet.sparrow.paynym.PayNym;
import com.sparrowwallet.sparrow.paynym.PayNymDialog;
import javafx.application.Platform;
@@ -36,21 +36,32 @@ import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.HBox;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.util.*;
import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -131,6 +142,8 @@ public class PaymentController extends WalletFormController implements Initializ
private final ObjectProperty<PayNym> payNymProperty = new SimpleObjectProperty<>();
private final ObjectProperty<DnsPayment> dnsPaymentProperty = new SimpleObjectProperty<>();
private static final Wallet payNymWallet = new Wallet() {
@Override
public String getFullDisplayName() {
@@ -145,6 +158,76 @@ public class PaymentController extends WalletFormController implements Initializ
}
};
private final ChangeListener<String> addressListener = new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
address.leftProperty().set(null);
if(payNymProperty.get() != null && !newValue.equals(payNymProperty.get().nymName())) {
payNymProperty.set(null);
}
if(dnsPaymentProperty.get() != null && !newValue.equals(dnsPaymentProperty.get().hrn())) {
dnsPaymentProperty.set(null);
}
try {
BitcoinURI bitcoinURI = new BitcoinURI(newValue);
Platform.runLater(() -> updateFromURI(bitcoinURI));
return;
} catch(Exception e) {
//ignore, not a URI
}
String dnsPaymentHrn = getDnsPaymentHrn(newValue);
if(dnsPaymentHrn != null) {
DnsPaymentService dnsPaymentService = new DnsPaymentService(dnsPaymentHrn);
dnsPaymentService.setOnSucceeded(_ -> dnsPaymentService.getValue().ifPresent(dnsPayment -> setDnsPayment(dnsPayment)));
dnsPaymentService.setOnFailed(failEvent -> {
if(failEvent.getSource().getException() != null && !(failEvent.getSource().getException().getCause() instanceof TimeoutException)) {
AppServices.showErrorDialog("Validation failed for " + dnsPaymentHrn, failEvent.getSource().getException().getMessage());
}
});
dnsPaymentService.start();
return;
}
if(sendController.getWalletForm().getWallet().hasPaymentCode()) {
try {
PaymentCode paymentCode = new PaymentCode(newValue);
Wallet recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, sendController.getWalletForm().getWallet().getScriptType());
if(recipientBip47Wallet == null && sendController.getWalletForm().getWallet().getScriptType() != ScriptType.P2PKH) {
recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, ScriptType.P2PKH);
}
if(recipientBip47Wallet != null) {
PayNym payNym = PayNym.fromWallet(recipientBip47Wallet);
Platform.runLater(() -> setPayNym(payNym));
} else if(!paymentCode.equals(sendController.getWalletForm().getWallet().getPaymentCode())) {
ButtonType previewType = new ButtonType("Preview Transaction", ButtonBar.ButtonData.YES);
Optional<ButtonType> optButton = AppServices.showAlertDialog("Send notification transaction?", "This payment code is not yet linked with a notification transaction. Send a notification transaction?", Alert.AlertType.CONFIRMATION, ButtonType.CANCEL, previewType);
if(optButton.isPresent() && optButton.get() == previewType) {
Payment payment = new Payment(paymentCode.getNotificationAddress(), "Link " + paymentCode.toAbbreviatedString(), MINIMUM_P2PKH_OUTPUT_SATS, false);
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(sendController.getWalletForm().getWallet(), List.of(payment), List.of(new byte[80]), paymentCode)));
} else {
Platform.runLater(() -> address.setText(""));
}
}
} catch(Exception e) {
//ignore, not a payment code
}
}
revalidateAmount();
maxButton.setDisable(!isMaxButtonEnabled());
sendController.updateTransaction();
if(validationSupport != null) {
validationSupport.setErrorDecorationEnabled(true);
}
}
};
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -210,6 +293,24 @@ public class PaymentController extends WalletFormController implements Initializ
revalidateAmount();
});
dnsPaymentProperty.addListener((observable, oldValue, dnsPayment) -> {
if(dnsPayment != null) {
MenuItem copyMenuItem = new MenuItem("Copy URI");
copyMenuItem.setOnAction(e -> {
ClipboardContent content = new ClipboardContent();
content.putString(dnsPayment.bitcoinURI().toURIString());
Clipboard.getSystemClipboard().setContent(content);
});
address.setContextMenu(address.getCustomContextMenu(List.of(copyMenuItem)));
} else {
address.setContextMenu(address.getCustomContextMenu(Collections.emptyList()));
}
revalidateAmount();
maxButton.setDisable(!isMaxButtonEnabled());
sendController.updateTransaction();
});
address.setTextFormatter(new TextFormatter<>(change -> {
String controlNewText = change.getControlNewText();
if(!controlNewText.equals(controlNewText.trim())) {
@@ -222,55 +323,8 @@ public class PaymentController extends WalletFormController implements Initializ
return change;
}));
address.textProperty().addListener((observable, oldValue, newValue) -> {
address.leftProperty().set(null);
if(payNymProperty.get() != null && !newValue.equals(payNymProperty.get().nymName())) {
payNymProperty.set(null);
}
try {
BitcoinURI bitcoinURI = new BitcoinURI(newValue);
Platform.runLater(() -> updateFromURI(bitcoinURI));
return;
} catch(Exception e) {
//ignore, not a URI
}
if(sendController.getWalletForm().getWallet().hasPaymentCode()) {
try {
PaymentCode paymentCode = new PaymentCode(newValue);
Wallet recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, sendController.getWalletForm().getWallet().getScriptType());
if(recipientBip47Wallet == null && sendController.getWalletForm().getWallet().getScriptType() != ScriptType.P2PKH) {
recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, ScriptType.P2PKH);
}
if(recipientBip47Wallet != null) {
PayNym payNym = PayNym.fromWallet(recipientBip47Wallet);
Platform.runLater(() -> setPayNym(payNym));
} else if(!paymentCode.equals(sendController.getWalletForm().getWallet().getPaymentCode())) {
ButtonType previewType = new ButtonType("Preview Transaction", ButtonBar.ButtonData.YES);
Optional<ButtonType> optButton = AppServices.showAlertDialog("Send notification transaction?", "This payment code is not yet linked with a notification transaction. Send a notification transaction?", Alert.AlertType.CONFIRMATION, ButtonType.CANCEL, previewType);
if(optButton.isPresent() && optButton.get() == previewType) {
Payment payment = new Payment(paymentCode.getNotificationAddress(), "Link " + paymentCode.toAbbreviatedString(), MINIMUM_P2PKH_OUTPUT_SATS, false);
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(sendController.getWalletForm().getWallet(), List.of(payment), List.of(new byte[80]), paymentCode)));
} else {
Platform.runLater(() -> address.setText(""));
}
}
} catch(Exception e) {
//ignore, not a payment code
}
}
revalidateAmount();
maxButton.setDisable(!isMaxButtonEnabled());
sendController.updateTransaction();
if(validationSupport != null) {
validationSupport.setErrorDecorationEnabled(true);
}
});
address.textProperty().addListener(addressListener);
address.setContextMenu(address.getCustomContextMenu(Collections.emptyList()));
label.textProperty().addListener((observable, oldValue, newValue) -> {
maxButton.setDisable(!isMaxButtonEnabled());
@@ -328,6 +382,23 @@ public class PaymentController extends WalletFormController implements Initializ
}
}
public void setDnsPayment(DnsPayment dnsPayment) {
if(dnsPayment.bitcoinURI().getAddress() == null) {
AppServices.showWarningDialog("No Address Provided", "The DNS payment instruction for " + dnsPayment.hrn() + " resolved correctly but did not contain a Bitcoin address.");
return;
}
DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getAddress(), dnsPayment);
dnsPaymentProperty.set(dnsPayment);
address.setText(dnsPayment.hrn());
revalidate(address, addressListener);
address.leftProperty().set(getBitcoinCharacter());
if(label.getText().isEmpty()) {
label.setText("To " + dnsPayment);
}
label.requestFocus();
}
private void updateOpenWallets() {
updateOpenWallets(AppServices.get().getOpenWallets().keySet());
}
@@ -399,6 +470,11 @@ public class PaymentController extends WalletFormController implements Initializ
}
private Address getRecipientAddress() throws InvalidAddressException {
DnsPayment dnsPayment = dnsPaymentProperty.get();
if(dnsPayment != null) {
return dnsPayment.bitcoinURI().getAddress();
}
PayNym payNym = payNymProperty.get();
if(payNym == null) {
return Address.fromString(address.getText());
@@ -422,6 +498,25 @@ public class PaymentController extends WalletFormController implements Initializ
throw new InvalidAddressException();
}
private String getDnsPaymentHrn(String value) {
String hrn = value;
if(value.endsWith(".")) {
return null;
}
if(hrn.startsWith("")) {
hrn = hrn.substring(1);
}
String[] addressParts = hrn.split("@");
if(addressParts.length == 2 && addressParts[1].indexOf('.') > -1 && addressParts[1].substring(addressParts[1].indexOf('.') + 1).length() > 1 &&
StandardCharsets.US_ASCII.newEncoder().canEncode(hrn)) {
return hrn;
}
return null;
}
private Wallet getWalletForPayNym(PayNym payNym) throws InvalidPaymentCodeException {
Wallet masterWallet = sendController.getWalletForm().getMasterWallet();
return masterWallet.getChildWallet(new PaymentCode(payNym.paymentCode().toString()), payNym.segwit() ? ScriptType.P2WPKH : ScriptType.P2PKH);
@@ -565,6 +660,7 @@ public class PaymentController extends WalletFormController implements Initializ
dustAmountProperty.set(false);
payNymProperty.set(null);
dnsPaymentProperty.set(null);
}
public void setMaxInput(ActionEvent event) {
@@ -625,7 +721,7 @@ public class PaymentController extends WalletFormController implements Initializ
setRecipientValueSats(bitcoinURI.getAmount());
setFiatAmount(AppServices.getFiatCurrencyExchangeRate(), bitcoinURI.getAmount());
}
if(bitcoinURI.getPayjoinUrl() != null) {
if(bitcoinURI.getAddress() != null && bitcoinURI.getPayjoinUrl() != null) {
AppServices.addPayjoinURI(bitcoinURI);
}
sendController.updateTransaction();
@@ -676,10 +772,33 @@ public class PaymentController extends WalletFormController implements Initializ
public static Glyph getPayNymGlyph() {
Glyph payNymGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ROBOT);
payNymGlyph.getStyleClass().add("paynym-icon");
payNymGlyph.setFontSize(12);
payNymGlyph.setFontSize(10);
return payNymGlyph;
}
public static Node getBitcoinCharacter() {
try {
URL url;
if(Config.get().getTheme() == Theme.DARK) {
url = AppServices.class.getResource("/image/bitcoin-character-invert.svg");
} else {
url = AppServices.class.getResource("/image/bitcoin-character.svg");
}
if(url != null) {
SVGImage svgImage = SVGLoader.load(url);
HBox hBox = new HBox();
hBox.setAlignment(Pos.CENTER);
hBox.getChildren().add(svgImage);
hBox.setPadding(new Insets(0, 2, 0, 4));
return hBox;
}
} catch(Exception e) {
log.error("Could not load bitcoin character");
}
return null;
}
public static Glyph getNfcCardGlyph() {
Glyph nfcCardGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI);
nfcCardGlyph.getStyleClass().add("nfccard-icon");
@@ -723,4 +842,23 @@ public class PaymentController extends WalletFormController implements Initializ
public void openWallets(OpenWalletsEvent event) {
updateOpenWallets(event.getWallets());
}
private static class DnsPaymentService extends Service<Optional<DnsPayment>> {
private final String hrn;
public DnsPaymentService(String hrn) {
this.hrn = hrn;
}
@Override
protected Task<Optional<DnsPayment>> createTask() {
return new Task<>() {
@Override
protected Optional<DnsPayment> call() throws Exception {
DnsPaymentResolver resolver = new DnsPaymentResolver(hrn);
return resolver.resolve();
}
};
}
}
}