use configured unit format in send to many dialog instead of jvm default
What changed, and why it matters
This commit fixes a formatting bug in Sparrow Wallet's 'send to many' dialog. Previously, the dialog ignored the user's chosen number format (for example, comma vs. dot as the decimal separator) and used the Java default. This could cause confusion or errors when entering or importing Bitcoin amounts, especially from CSV files. The change makes the dialog consistently use the wallet's configured unit format.
Treat as a low-severity correctness/usability fix. Verify that CSV import and manual entry behave correctly under all supported UnitFormat settings (dot/comma decimal separators, space/comma grouping). No urgent security response is indicated.
Security signals we found
Locale-dependent numeric parsing in CSV import could misinterpret user-entered amounts
Mismatch between displayed and parsed decimal separators may lead to incorrect payment amounts
Addition of null guard in DNS payment resolution loop
Evidence from the diff
The patch updates SendToManyDialog to accept and use the configured UnitFormat rather than relying on the JVM default locale. It introduces a custom SpreadsheetCellType/Converter/Editor chain (UnitFormatDoubleCellType, UnitFormatDoubleConverter, UnitFormatDoubleEditor) that respects the configured grouping and decimal separators. CSV import now strips the configured grouping separator and normalizes the configured decimal separator to a dot before parsing. A null check for sendToAddress was also added when scanning for unresolved DNS payments.
Changed components
SendToManyDialogAppController send-to-many invocationCSV import parsing in SendToManyDialogAmount cell editing/rendering in SendToManyDialog spreadsheetInspect captured patch +174 / −11
diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java
index 899f7c7..1f9af26 100644
--- a/src/main/java/com/sparrowwallet/sparrow/AppController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java
@@ -1511,7 +1511,7 @@ public class AppController implements Initializable {
bitcoinUnit = wallet.getAutoUnit();
}
- sendToManyDialog = new SendToManyDialog(bitcoinUnit, initialPayments);
+ sendToManyDialog = new SendToManyDialog(bitcoinUnit, Config.get().getUnitFormat(), initialPayments);
sendToManyDialog.initModality(Modality.NONE);
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
sendToManyDialog = null;
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java
index 40a2c57..a9a6e45 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java
@@ -9,13 +9,13 @@ 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.EventManager;
+import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.event.RequestConnectEvent;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
@@ -28,6 +28,8 @@ import javafx.event.ActionEvent;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
+import javafx.scene.input.KeyCode;
+import javafx.scene.input.KeyEvent;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
@@ -35,20 +37,26 @@ import org.controlsfx.control.spreadsheet.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
+import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class SendToManyDialog extends Dialog<List<Payment>> {
private final BitcoinUnit bitcoinUnit;
+ private final UnitFormat unitFormat;
+ private final UnitFormatDoubleCellType amountCellType;
private final SpreadsheetView spreadsheetView;
public static final SendToAddressCellType SEND_TO_ADDRESS = new SendToAddressCellType();
- public SendToManyDialog(BitcoinUnit bitcoinUnit, List<Payment> payments) {
+ public SendToManyDialog(BitcoinUnit bitcoinUnit, UnitFormat unitFormat, List<Payment> payments) {
this.bitcoinUnit = bitcoinUnit;
+ this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
+ this.amountCellType = new UnitFormatDoubleCellType(this.unitFormat);
final DialogPane dialogPane = new SendToManyDialogPane();
setDialogPane(dialogPane);
@@ -119,11 +127,9 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
addressCell.getStyleClass().add("fixed-width");
list.add(addressCell);
- double amount = (double)sendToPayment.payment().getAmount();
- if(bitcoinUnit == BitcoinUnit.BTC) {
- amount = amount / Transaction.SATOSHIS_PER_BITCOIN;
- }
- SpreadsheetCell amountCell = SpreadsheetCellType.DOUBLE.createCell(row, 1, 1, 1, amount < 0 ? null : amount);
+ long rawAmount = sendToPayment.payment().getAmount();
+ Double amount = rawAmount < 0 ? null : bitcoinUnit.getValue(rawAmount);
+ SpreadsheetCell amountCell = amountCellType.createCell(row, 1, 1, 1, amount);
amountCell.setFormat(bitcoinUnit == BitcoinUnit.BTC ? "0.00000000" : "###,###");
amountCell.getStyleClass().add("number-value");
if(OsType.getCurrent() == OsType.MACOS) {
@@ -177,7 +183,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
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) {
+ if(sendToAddress != null && sendToAddress.hrn != null && DnsPaymentCache.getDnsPayment(sendToAddress.hrn) == null) {
return true;
}
}
@@ -216,12 +222,15 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
try {
+ String rawAmount = csvReader.get(1).trim();
+ String groupingStripped = rawAmount.replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "");
long amount;
if(bitcoinUnit == BitcoinUnit.BTC) {
- double doubleAmount = Double.parseDouble(csvReader.get(1).replace(",", ""));
+ String normalised = groupingStripped.replaceAll(Pattern.quote(unitFormat.getDecimalSeparator()), ".");
+ double doubleAmount = Double.parseDouble(normalised);
amount = bitcoinUnit.getSatsValue(doubleAmount);
} else {
- amount = Long.parseLong(csvReader.get(1).replace(",", ""));
+ amount = Long.parseLong(groupingStripped);
}
String label = csvReader.get(2);
Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(csvReader.get(0));
@@ -359,6 +368,160 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
};
+ private static class UnitFormatDoubleCellType extends SpreadsheetCellType<Double> {
+ private final UnitFormat unitFormat;
+
+ UnitFormatDoubleCellType(UnitFormat unitFormat) {
+ super(new UnitFormatDoubleConverter(unitFormat));
+ this.unitFormat = unitFormat;
+ }
+
+ @Override
+ public String toString() {
+ return "double";
+ }
+
+ public SpreadsheetCell createCell(int row, int column, int rowSpan, int columnSpan, Double value) {
+ SpreadsheetCell cell = new SpreadsheetCellBase(row, column, rowSpan, columnSpan, this);
+ cell.setItem(value);
+ return cell;
+ }
+
+ @Override
+ public SpreadsheetCellEditor createEditor(SpreadsheetView view) {
+ return new UnitFormatDoubleEditor(view, unitFormat);
+ }
+
+ @Override
+ public boolean match(Object value, Object... options) {
+ if(value == null || value instanceof Number) {
+ return true;
+ }
+ try {
+ String s = value.toString();
+ return s == null || s.isEmpty() || converter.fromString(s) != null;
+ } catch(Exception e) {
+ return false;
+ }
+ }
+
+ @Override
+ public Double convertValue(Object value) {
+ if(value instanceof Double d) {
+ return d;
+ }
+ if(value instanceof Number n) {
+ return n.doubleValue();
+ }
+ return converter.fromString(value == null ? null : value.toString());
+ }
+
+ @Override
+ public String toString(Double item) {
+ return converter.toString(item);
+ }
+
+ @Override
+ public String toString(Double item, String format) {
+ return ((StringConverterWithFormat<Double>)converter).toStringFormat(item, format);
+ }
+ }
+
+ private static class UnitFormatDoubleConverter extends StringConverterWithFormat<Double> {
+ private final UnitFormat unitFormat;
+
+ UnitFormatDoubleConverter(UnitFormat unitFormat) {
+ this.unitFormat = unitFormat;
+ }
+
+ @Override
+ public Double fromString(String str) {
+ if(str == null || str.isEmpty()) {
+ return null;
+ }
+ String normalised = str.trim()
+ .replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "")
+ .replaceAll(Pattern.quote(unitFormat.getDecimalSeparator()), ".");
+ try {
+ return Double.valueOf(normalised);
+ } catch(NumberFormatException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public String toString(Double item) {
+ return toStringFormat(item, "");
+ }
+
+ @Override
+ public String toStringFormat(Double item, String format) {
+ if(item == null || item.isNaN()) {
+ return "";
+ }
+ if(format == null || format.isEmpty()) {
+ return Double.toString(item);
+ }
+ return new DecimalFormat(format, unitFormat.getDecimalFormatSymbols()).format(item);
+ }
+ }
+
+ private static class UnitFormatDoubleEditor extends SpreadsheetCellEditor {
+ private final UnitFormat unitFormat;
+ private final TextField textField;
+
+ UnitFormatDoubleEditor(SpreadsheetView view, UnitFormat unitFormat) {
+ super(view);
+ this.unitFormat = unitFormat;
+ this.textField = new TextField();
+ this.textField.setTextFormatter(new CoinTextFormatter(unitFormat));
+ }
+
+ @Override
+ public void startEdit(Object item, String format, Object... options) {
+ if(item instanceof Double d && !d.isNaN()) {
+ String text = (format == null || format.isEmpty())
+ ? Double.toString(d)
+ : new DecimalFormat(format, unitFormat.getDecimalFormatSymbols()).format(d);
+ textField.setText(text);
+ } else {
+ textField.setText("");
+ }
+ textField.getStyleClass().removeAll("error");
+ textField.setOnKeyPressed(this::onKeyPressed);
+ textField.requestFocus();
+ textField.selectAll();
+ }
+
+ @Override
+ public void end() {
+ textField.setOnKeyPressed(null);
+ textField.setOnKeyReleased(null);
+ textField.getStyleClass().removeAll("error");
+ }
+
+ @Override
+ public TextField getEditor() {
+ return textField;
+ }
+
+ @Override
+ public String getControlValue() {
+ String raw = textField.getText();
+ return raw == null ? "" : raw.trim();
+ }
+
+ private void onKeyPressed(KeyEvent event) {
+ if(event.getCode() == KeyCode.ENTER) {
+ endEdit(true);
+ event.consume();
+ } else if(event.getCode() == KeyCode.ESCAPE) {
+ endEdit(false);
+ event.consume();
+ }
+ }
+ }
+
public static class SendToAddress {
private final String hrn;
private final Address address;
Why this scored 19/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.