What changed, and why it matters
This commit adds support for resolving human-readable payment names (like DNS-based addresses) inside Sparrow Wallet's 'send to many' dialog. It also refactors how the OK button and CSV loading work, and adds a cache so resolved addresses don't need to be looked up repeatedly. There is no clear security bug in the diff, but the change involves network lookups and address resolution, so it deserves normal review care.
Treat as a normal feature commit. Reviewers should verify that DnsPaymentResolver validates DNSSEC/TLS where applicable, that resolved addresses are not silently substituted, that the cache cannot be poisoned by colliding keys, and that the async task handles cancellation and errors without leaking partial payment lists. No immediate security patch appears required from this diff alone.
Security signals we found
Network resolution of user-supplied identifiers (DNS payment HRNs) now occurs in a bulk-payment dialog.
Resolution results are cached; cache keys are derived from resolved Bitcoin addresses or silent-payment addresses.
User is prompted to connect to the configured server when offline and an HRN needs resolution.
CSV parsing accepts HRN strings and defers address validation until resolution time.
No input sanitization beyond existing DnsPayment.getHrn() helper is visible in this diff.
Evidence from the diff
The patch extends SendToManyDialog to accept DNS payment human-readable names (HRNs) in addition to plain addresses and silent-payment addresses. It introduces DnsPayment/DnsPaymentResolver usage, a DnsPaymentCache, asynchronous resolution via a JavaFX Service, and offline-connection prompts. PaymentController is updated to use the same shared DnsPayment.getHrn() helper and cache. The OK-button handling is changed from a result converter to an event filter that triggers async payment creation. CSV loading now wraps rows in a new SendToPayment record so HRNs can be preserved until resolution time.
Changed components
src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.javasrc/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.javasrc/main/java/com/sparrowwallet/sparrow/glyphfont/GlyphUtils.javadrongo (dependency, referenced for DnsPayment classes)Inspect captured patch +201 / −73
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java
index d1cd0d7..1f7e6f7 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java
@@ -5,14 +5,26 @@ import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
+import com.sparrowwallet.drongo.dns.DnsPayment;
+import com.sparrowwallet.drongo.dns.DnsPaymentCache;
+import com.sparrowwallet.drongo.dns.DnsPaymentResolver;
+import com.sparrowwallet.drongo.dns.DnsPaymentValidationException;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
+import com.sparrowwallet.drongo.uri.BitcoinURIParseException;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.sparrow.AppServices;
-import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
+import com.sparrowwallet.sparrow.EventManager;
+import com.sparrowwallet.sparrow.event.RequestConnectEvent;
+import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
+import com.sparrowwallet.sparrow.io.Config;
+import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
+import javafx.concurrent.Service;
+import javafx.concurrent.Task;
+import javafx.event.ActionEvent;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
@@ -20,12 +32,13 @@ import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
import org.controlsfx.control.spreadsheet.*;
-import org.controlsfx.glyphfont.Glyph;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -70,14 +83,16 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
dialogPane.setContent(stackPane);
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
+ Button okButton = (Button) dialogPane.lookupButton(ButtonType.OK);
+ okButton.addEventFilter(ActionEvent.ACTION, event -> {
+ getPayments();
+ event.consume();
+ });
final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load CSV", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(loadCsvButtonType);
- setResultConverter((dialogButton) -> {
- ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
- return data == ButtonBar.ButtonData.OK_DONE ? getPayments() : null;
- });
+ setResultConverter((_) -> null);
dialogPane.setPrefWidth(850);
dialogPane.setPrefHeight(500);
@@ -87,19 +102,24 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
private Grid getGrid(List<Payment> payments) {
- int rowCount = payments.size();
+ return createGrid(payments.stream().map(payment -> new SendToPayment(payment, SendToAddress.fromPayment(payment))).collect(Collectors.toList()));
+ }
+
+ private Grid createGrid(List<SendToPayment> sendToPayments) {
+ int rowCount = sendToPayments.size();
int columnCount = 3;
GridBase grid = new GridBase(rowCount, columnCount);
ObservableList<ObservableList<SpreadsheetCell>> rows = FXCollections.observableArrayList();
for(int row = 0; row < grid.getRowCount(); ++row) {
+ SendToPayment sendToPayment = sendToPayments.get(row);
final ObservableList<SpreadsheetCell> list = FXCollections.observableArrayList();
- SendToAddress sendToAddress = SendToAddress.fromPayment(payments.get(row));
+ SendToAddress sendToAddress = sendToPayment.sendToAddress();
SpreadsheetCell addressCell = SEND_TO_ADDRESS.createCell(row, 0, 1, 1, sendToAddress);
addressCell.getStyleClass().add("fixed-width");
list.add(addressCell);
- double amount = (double)payments.get(row).getAmount();
+ double amount = (double)sendToPayment.payment().getAmount();
if(bitcoinUnit == BitcoinUnit.BTC) {
amount = amount / Transaction.SATOSHIS_PER_BITCOIN;
}
@@ -111,7 +131,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
list.add(amountCell);
- list.add(SpreadsheetCellType.STRING.createCell(row, 2, 1, 1, payments.get(row).getLabel()));
+ list.add(SpreadsheetCellType.STRING.createCell(row, 2, 1, 1, sendToPayment.payment().getLabel()));
rows.add(list);
}
grid.setRows(rows);
@@ -120,32 +140,49 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
return grid;
}
- private List<Payment> getPayments() {
- List<Payment> payments = new ArrayList<>();
- Grid grid = spreadsheetView.getGrid();
- String firstLabel = null;
- for(int row = 0; row < grid.getRowCount(); row++) {
- ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
- SendToAddress sendToAddress = (SendToAddress)rowCells.get(0).getItem();
- Double value = (Double)rowCells.get(1).getItem();
- String label = (String)rowCells.get(2).getItem();
- if(firstLabel == null) {
- firstLabel = label;
- }
- if(label == null || label.isEmpty()) {
- label = firstLabel;
+ private void getPayments() {
+ if(needsResolution() && Config.get().hasServer() && !AppServices.isConnected() && !AppServices.isConnecting()) {
+ if(Config.get().getConnectToResolve() == null || Config.get().getConnectToResolve() == Boolean.FALSE) {
+ Platform.runLater(() -> {
+ ConfirmationAlert confirmationAlert = new ConfirmationAlert("Connect to resolve?", "You are currently offline. Connect to resolve the addresses?", ButtonType.NO, ButtonType.YES);
+ Optional<ButtonType> optType = confirmationAlert.showAndWait();
+ if(confirmationAlert.isDontAskAgain() && optType.isPresent()) {
+ Config.get().setConnectToResolve(optType.get() == ButtonType.YES);
+ }
+ if(optType.isPresent() && optType.get() == ButtonType.YES) {
+ EventManager.get().post(new RequestConnectEvent());
+ }
+ });
+ } else {
+ Platform.runLater(() -> EventManager.get().post(new RequestConnectEvent()));
}
+ return;
+ }
- if(sendToAddress != null && value != null) {
- if(bitcoinUnit == BitcoinUnit.BTC) {
- value = value * Transaction.SATOSHIS_PER_BITCOIN;
- }
+ CreatePaymentsService createPaymentsService = new CreatePaymentsService();
+ createPaymentsService.setOnSucceeded(_ -> {
+ List<Payment> payments = createPaymentsService.getValue();
+ if(payments != null) {
+ setResult(payments);
+ }
+ });
+ createPaymentsService.setOnFailed(event -> {
+ Throwable ex = event.getSource().getException();
+ AppServices.showErrorDialog("Error creating payments", ex.getMessage());
+ });
+ createPaymentsService.start();
+ }
- payments.add(sendToAddress.toPayment(label, value.longValue(), false));
+ private boolean needsResolution() {
+ for(int row = 0; row < spreadsheetView.getGrid().getRowCount(); row++) {
+ ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
+ SendToAddress sendToAddress = (SendToAddress)rowCells.getFirst().getItem();
+ if(sendToAddress.hrn != null && DnsPaymentCache.getDnsPayment(sendToAddress.hrn) == null) {
+ return true;
}
}
- return payments;
+ return false;
}
private class SendToManyDialogPane extends DialogPane {
@@ -155,7 +192,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button loadButton = new Button(buttonType.getText());
loadButton.setGraphicTextGap(5);
- loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP));
+ loadButton.setGraphic(GlyphUtils.getUpArrowGlyph());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(loadButton, buttonData);
loadButton.setOnAction(event -> {
@@ -170,7 +207,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
File file = fileChooser.showOpenDialog(this.getScene().getWindow());
if(file != null) {
try {
- List<Payment> csvPayments = new ArrayList<>();
+ List<SendToPayment> csvPayments = new ArrayList<>();
try(Reader reader = new FileReader(file, StandardCharsets.UTF_8)) {
CsvReader csvReader = new CsvReader(reader);
while(csvReader.readRecord()) {
@@ -187,12 +224,20 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
amount = Long.parseLong(csvReader.get(1).replace(",", ""));
}
String label = csvReader.get(2);
- try {
- SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(csvReader.get(0));
- csvPayments.add(new SilentPayment(silentPaymentAddress, label, amount, false));
- } catch(Exception e) {
- Address address = Address.fromString(csvReader.get(0));
- csvPayments.add(new Payment(address, label, amount, false));
+ Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(csvReader.get(0));
+ if(optDnsPaymentHrn.isPresent()) {
+ Payment payment = new Payment(null, label, amount, false);
+ csvPayments.add(new SendToPayment(payment, new SendToAddress(optDnsPaymentHrn.get())));
+ } else {
+ try {
+ SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(csvReader.get(0));
+ Payment payment = new SilentPayment(silentPaymentAddress, label, amount, false);
+ csvPayments.add(new SendToPayment(payment, SendToAddress.fromPayment(payment)));
+ } catch(Exception e) {
+ Address address = Address.fromString(csvReader.get(0));
+ Payment payment = new Payment(address, label, amount, false);
+ csvPayments.add(new SendToPayment(payment, SendToAddress.fromPayment(payment)));
+ }
}
} catch(NumberFormatException e) {
//ignore and continue - probably a header line
@@ -206,7 +251,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
return;
}
- spreadsheetView.setGrid(getGrid(csvPayments));
+ spreadsheetView.setGrid(createGrid(csvPayments));
}
} catch(IOException e) {
AppServices.showErrorDialog("Cannot load CSV", e.getMessage());
@@ -221,12 +266,6 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
return button;
}
-
- private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
- Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
- glyph.setFontSize(11);
- return glyph;
- }
}
public static class SendToAddressCellType extends SpreadsheetCellType<SendToAddress> {
@@ -321,34 +360,78 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
};
public static class SendToAddress {
+ private final String hrn;
private final Address address;
private final SilentPaymentAddress silentPaymentAddress;
+ public SendToAddress(String hrn) {
+ this.hrn = hrn;
+ this.address = null;
+ this.silentPaymentAddress = null;
+ }
+
public SendToAddress(Address address) {
+ this.hrn = null;
this.address = address;
this.silentPaymentAddress = null;
}
public SendToAddress(SilentPaymentAddress silentPaymentAddress) {
+ this.hrn = null;
this.address = null;
this.silentPaymentAddress = silentPaymentAddress;
}
public String toString() {
- return silentPaymentAddress == null ? (address == null ? null : address.toString()) : silentPaymentAddress.toString();
+ return hrn == null ? silentPaymentAddress == null ? (address == null ? null : address.toString()) : silentPaymentAddress.toString() : hrn;
}
public static SendToAddress fromPayment(Payment payment) {
+ DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment);
+ if(dnsPayment != null) {
+ return new SendToAddress(dnsPayment.hrn());
+ }
return payment instanceof SilentPayment ? new SendToAddress(((SilentPayment)payment).getSilentPaymentAddress()) : new SendToAddress(payment.getAddress());
}
- public Payment toPayment(String label, long value, boolean sendMax) {
+ public Payment toPayment(String label, long value, boolean sendMax) throws DnsPaymentValidationException, IOException, ExecutionException, InterruptedException, BitcoinURIParseException {
+ if(hrn != null) {
+ DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(hrn);
+ if(dnsPayment == null) {
+ DnsPaymentResolver resolver = new DnsPaymentResolver(hrn);
+ Optional<DnsPayment> optDnsPayment = resolver.resolve();
+ if(optDnsPayment.isPresent()) {
+ dnsPayment = optDnsPayment.get();
+ if(dnsPayment.hasAddress()) {
+ DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getAddress(), dnsPayment);
+ } else if(dnsPayment.hasSilentPaymentAddress()) {
+ DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getSilentPaymentAddress(), dnsPayment);
+ }
+ return getPayment(optDnsPayment.get(), label, value, sendMax);
+ } else {
+ throw new IllegalArgumentException("Payment to " + hrn + " could not be resolved.");
+ }
+ } else {
+ return getPayment(dnsPayment, label, value, sendMax);
+ }
+ }
+
if(silentPaymentAddress != null) {
return new SilentPayment(silentPaymentAddress, label, value, sendMax);
} else {
return new Payment(address, label, value, sendMax);
}
}
+
+ private static Payment getPayment(DnsPayment dnsPayment, String label, long value, boolean sendMax) {
+ if(dnsPayment.hasAddress()) {
+ return new Payment(dnsPayment.bitcoinURI().getAddress(), label, value, sendMax);
+ } else if(dnsPayment.hasSilentPaymentAddress()) {
+ return new SilentPayment(dnsPayment.bitcoinURI().getSilentPaymentAddress(), label, value, sendMax);
+ } else {
+ throw new IllegalArgumentException("Payment to " + dnsPayment + " has no associated address.");
+ }
+ }
}
private static class SendToAddressStringConverter extends StringConverter<SendToAddress> {
@@ -356,6 +439,11 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
@Override
public SendToAddress fromString(String value) {
+ Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(value);
+ if(optDnsPaymentHrn.isPresent()) {
+ return new SendToAddress(optDnsPaymentHrn.get());
+ }
+
try {
SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(value);
return new SendToAddress(silentPaymentAddress);
@@ -370,4 +458,46 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
return value.toString();
}
}
+
+ private class CreatePaymentsService extends Service<List<Payment>> {
+ @Override
+ protected Task<List<Payment>> createTask() {
+ return new Task<>() {
+ @Override
+ protected List<Payment> call() throws Exception {
+ return getPayments();
+ }
+ };
+ }
+
+ private List<Payment> getPayments() throws DnsPaymentValidationException, IOException, ExecutionException, InterruptedException, BitcoinURIParseException {
+ List<Payment> payments = new ArrayList<>();
+ Grid grid = spreadsheetView.getGrid();
+ String firstLabel = null;
+ for(int row = 0; row < grid.getRowCount(); row++) {
+ ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
+ SendToAddress sendToAddress = (SendToAddress)rowCells.get(0).getItem();
+ Double value = (Double)rowCells.get(1).getItem();
+ String label = (String)rowCells.get(2).getItem();
+ if(firstLabel == null) {
+ firstLabel = label;
+ }
+ if(label == null || label.isEmpty()) {
+ label = firstLabel;
+ }
+
+ if(sendToAddress != null && value != null) {
+ if(bitcoinUnit == BitcoinUnit.BTC) {
+ value = value * Transaction.SATOSHIS_PER_BITCOIN;
+ }
+
+ payments.add(sendToAddress.toPayment(label, value.longValue(), false));
+ }
+ }
+
+ return payments;
+ }
+ }
+
+ private record SendToPayment(Payment payment, SendToAddress sendToAddress) {}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/GlyphUtils.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/GlyphUtils.java
index bb4a227..ef263c8 100644
--- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/GlyphUtils.java
+++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/GlyphUtils.java
@@ -213,6 +213,13 @@ public class GlyphUtils {
return busyGlyph;
}
+ public static Glyph getUpArrowGlyph() {
+ Glyph upGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_UP);
+ upGlyph.getStyleClass().add("arrow-up");
+ upGlyph.setFontSize(12);
+ return upGlyph;
+ }
+
public static Glyph getDownArrowGlyph() {
Glyph downGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
downGlyph.getStyleClass().add("arrow-down");
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
index 01229b8..7d8f30b 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
@@ -188,12 +188,19 @@ public class PaymentController extends WalletFormController implements Initializ
//ignore, not a URI
}
- String dnsPaymentHrn = getDnsPaymentHrn(newValue);
- if(dnsPaymentHrn != null) {
+ Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(newValue);
+ if(optDnsPaymentHrn.isPresent()) {
+ String dnsPaymentHrn = optDnsPaymentHrn.get();
+ DnsPayment cachedDnsPayment = DnsPaymentCache.getDnsPayment(dnsPaymentHrn);
+ if(cachedDnsPayment != null) {
+ setDnsPayment(cachedDnsPayment);
+ return;
+ }
+
if(Config.get().hasServer() && !AppServices.isConnected() && !AppServices.isConnecting()) {
- if(Config.get().getConnectToResolve() == null) {
+ if(Config.get().getConnectToResolve() == null || Config.get().getConnectToResolve() == Boolean.FALSE) {
Platform.runLater(() -> {
- ConfirmationAlert confirmationAlert = new ConfirmationAlert("Connect to resolve?", "Connect to the configured server to resolve the address?", ButtonType.NO, ButtonType.YES);
+ ConfirmationAlert confirmationAlert = new ConfirmationAlert("Connect to resolve?", "You are currently offline. Connect to resolve the address?", ButtonType.NO, ButtonType.YES);
Optional<ButtonType> optType = confirmationAlert.showAndWait();
if(confirmationAlert.isDontAskAgain() && optType.isPresent()) {
Config.get().setConnectToResolve(optType.get() == ButtonType.YES);
@@ -202,7 +209,7 @@ public class PaymentController extends WalletFormController implements Initializ
EventManager.get().post(new RequestConnectEvent());
}
});
- } else if(Config.get().getConnectToResolve()) {
+ } else {
Platform.runLater(() -> EventManager.get().post(new RequestConnectEvent()));
}
return;
@@ -555,25 +562,6 @@ 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);
@@ -692,7 +680,10 @@ public class PaymentController extends WalletFormController implements Initializ
public void setPayment(Payment payment) {
if(getRecipientValueSats() == null || payment.getAmount() != getRecipientValueSats()) {
if(payment.getAddress() != null) {
- if(payment instanceof SilentPayment silentPayment) {
+ DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment);
+ if(dnsPayment != null) {
+ address.setText(dnsPayment.hrn());
+ } else if(payment instanceof SilentPayment silentPayment) {
address.setText(silentPayment.getSilentPaymentAddress().getAddress());
} else {
address.setText(payment.getAddress().toString());
Why this scored 26/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.