ignore amount digits beyond the selected unit precision in the send tab amount and fee fields and the send to many grid, instead of truncating them in the payment
What changed, and why it matters
This commit fixes a UI bug in the Sparrow Bitcoin wallet where typing or pasting too many decimal digits into amount or fee fields could be silently truncated, potentially causing a user to send a different amount than they saw on screen. The fix now ignores extra digits beyond the selected unit's precision (for example, satoshis have no decimal places) instead of truncating them in the final payment. It is a user-experience and correctness fix rather than a remote-exploitable vulnerability.
Treat as a low-severity correctness fix. Users should upgrade to avoid the small risk of sending an unintended amount or fee due to truncated decimals. No immediate incident response is required; no remote exploitation path is evident from the diff.
Security signals we found
Precision-loss / truncation bug in financial input fields
User-facing amount/fee mismatch between displayed value and parsed value
Input validation now tied to unit-specific precision (satoshis indivisible)
No cryptographic, network, or remote-code-execution changes
Evidence from the diff
The patch changes amount/fee input formatting across the send tab and the ‘send to many’ grid. Previously, CoinTextFormatter only knew the locale unit format (dot/comma). It now also receives the active BitcoinUnit (BTC or satoshis). For satoshis, fractional input is rejected/ignored rather than parsed and truncated. For BTC, digits beyond the 8th decimal place are ignored while typing/pasting instead of being silently dropped in the payment. The change touches CoinTextFormatter, CoinAxisFormatter, SendToManyDialog, PaymentController, and SendController. The core risk is a precision-loss bug that could lead to an unintended transaction amount or fee.
Changed components
Send tab amount field (PaymentController)Send tab fee field (SendController)Send-to-many grid amount cells (SendToManyDialog)CoinTextFormatter / CoinAxisFormatter input formatting classesInspect captured patch +48 / −31
### src/main/java/com/sparrowwallet/sparrow/control/CoinAxisFormatter.java
@@ -24,13 +24,13 @@ public String toString(Number object) {
}
Double value = bitcoinUnit.getValue(object.longValue());
- return new CoinTextFormatter(unitFormat).getCoinFormat().format(value);
+ return new CoinTextFormatter(unitFormat, bitcoinUnit).getCoinFormat().format(value);
}
@Override
public Number fromString(String string) {
try {
- Number number = new CoinTextFormatter(unitFormat).getCoinFormat().parse(string);
+ Number number = new CoinTextFormatter(unitFormat, bitcoinUnit).getCoinFormat().parse(string);
return bitcoinUnit.getSatsValue(number.doubleValue());
} catch (ParseException e) {
throw new RuntimeException(e);
### src/main/java/com/sparrowwallet/sparrow/control/CoinTextFormatter.java
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
+import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import javafx.scene.control.TextFormatter;
import javafx.scene.control.TextInputControl;
@@ -11,8 +12,8 @@
import java.util.regex.Pattern;
public class CoinTextFormatter extends TextFormatter<String> {
- public CoinTextFormatter(UnitFormat unitFormat) {
- super(new CoinFilter(unitFormat == null ? UnitFormat.DOT : unitFormat));
+ public CoinTextFormatter(UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
+ super(new CoinFilter(unitFormat == null ? UnitFormat.DOT : unitFormat, bitcoinUnit));
}
public UnitFormat getUnitFormat() {
@@ -27,11 +28,16 @@ private static class CoinFilter implements UnaryOperator<Change> {
private final UnitFormat unitFormat;
private final DecimalFormat coinFormat;
private final Pattern coinValidation;
+ private final Pattern anyPrecisionAmount;
- public CoinFilter(UnitFormat unitFormat) {
+ public CoinFilter(UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
this.unitFormat = unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", unitFormat.getDecimalFormatSymbols());
- this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?");
+ String integer = "[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*";
+ //A satoshi is indivisible, so a sats amount has no fractional part to validate
+ String fraction = bitcoinUnit == BitcoinUnit.SATOSHIS ? "" : "(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?";
+ this.coinValidation = Pattern.compile(integer + fraction);
+ this.anyPrecisionAmount = Pattern.compile(integer + "(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d*)?");
}
@Override
@@ -51,12 +57,13 @@ public Change apply(Change change) {
commasRemoved = newText.length() - noFractionCommaText.length();
}
- Matcher matcher = coinValidation.matcher(noFractionCommaText);
- boolean validAmount = matcher.matches();
+ boolean validAmount = coinValidation.matcher(noFractionCommaText).matches();
if(!validAmount) {
- matcher.reset();
- if(matcher.find()) {
- noFractionCommaText = matcher.group();
+ //The amount a pasted text starts with is taken, unless it is more precise than the unit allows - that is ignored rather than truncated,
+ //so a digit typed beyond the last place leaves the field as it was
+ Matcher leadingAmount = anyPrecisionAmount.matcher(noFractionCommaText);
+ if(leadingAmount.find() && coinValidation.matcher(leadingAmount.group()).matches()) {
+ noFractionCommaText = leadingAmount.group();
} else {
return null;
}
@@ -78,12 +85,13 @@ public Change apply(Change change) {
Number value = coinFormat.parse(noFractionCommaText);
String correct = coinFormat.format(value.doubleValue());
+ //Trailing fractional zeros and a trailing separator are left as typed so the fraction can still be entered, but only where the entire text is a valid amount
String compare = newText;
- if(compare.contains(unitFormat.getDecimalSeparator()) && compare.endsWith("0")) {
+ if(validAmount && compare.contains(unitFormat.getDecimalSeparator()) && compare.endsWith("0")) {
compare = compare.replaceAll("0*$", "");
}
- if(compare.endsWith(unitFormat.getDecimalSeparator())) {
+ if(validAmount && compare.endsWith(unitFormat.getDecimalSeparator())) {
compare = compare.substring(0, compare.length() - 1);
}
### src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java
@@ -59,7 +59,7 @@ public SendToManyDialog(Wallet wallet, BitcoinUnit bitcoinUnit, UnitFormat unitF
this.wallet = wallet;
this.bitcoinUnit = bitcoinUnit;
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
- this.amountCellType = new UnitFormatDoubleCellType(this.unitFormat);
+ this.amountCellType = new UnitFormatDoubleCellType(this.unitFormat, bitcoinUnit);
final DialogPane dialogPane = new SendToManyDialogPane();
setDialogPane(dialogPane);
@@ -373,10 +373,12 @@ public String toString(SendToAddress item, String format) {
private static class UnitFormatDoubleCellType extends SpreadsheetCellType<Double> {
private final UnitFormat unitFormat;
+ private final BitcoinUnit bitcoinUnit;
- UnitFormatDoubleCellType(UnitFormat unitFormat) {
- super(new UnitFormatDoubleConverter(unitFormat));
+ UnitFormatDoubleCellType(UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
+ super(new UnitFormatDoubleConverter(unitFormat, bitcoinUnit));
this.unitFormat = unitFormat;
+ this.bitcoinUnit = bitcoinUnit;
}
@Override
@@ -392,7 +394,7 @@ public SpreadsheetCell createCell(int row, int column, int rowSpan, int columnSp
@Override
public SpreadsheetCellEditor createEditor(SpreadsheetView view) {
- return new UnitFormatDoubleEditor(view, unitFormat);
+ return new UnitFormatDoubleEditor(view, unitFormat, bitcoinUnit);
}
@Override
@@ -432,21 +434,26 @@ public String toString(Double item, String format) {
private static class UnitFormatDoubleConverter extends StringConverterWithFormat<Double> {
private final UnitFormat unitFormat;
+ private final BitcoinUnit bitcoinUnit;
- UnitFormatDoubleConverter(UnitFormat unitFormat) {
+ UnitFormatDoubleConverter(UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
this.unitFormat = unitFormat;
+ this.bitcoinUnit = bitcoinUnit;
}
@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()), ".");
+ String groupingStripped = str.trim().replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "");
try {
- return Double.valueOf(normalised);
+ //A sats amount with a fraction is not read, as in a CSV import, rather than truncated when paid - a paste of it clears the cell like any text that is not an amount
+ if(bitcoinUnit == BitcoinUnit.SATOSHIS) {
+ return (double)Long.parseLong(groupingStripped);
+ }
+
+ return Double.valueOf(groupingStripped.replaceAll(Pattern.quote(unitFormat.getDecimalSeparator()), "."));
} catch(NumberFormatException e) {
return null;
}
@@ -473,11 +480,11 @@ private static class UnitFormatDoubleEditor extends SpreadsheetCellEditor {
private final UnitFormat unitFormat;
private final TextField textField;
- UnitFormatDoubleEditor(SpreadsheetView view, UnitFormat unitFormat) {
+ UnitFormatDoubleEditor(SpreadsheetView view, UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
super(view);
this.unitFormat = unitFormat;
this.textField = new TextField();
- this.textField.setTextFormatter(new CoinTextFormatter(unitFormat));
+ this.textField.setTextFormatter(new CoinTextFormatter(unitFormat, bitcoinUnit));
}
@Override
### src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
@@ -410,12 +410,13 @@ protected void updateItem(Wallet wallet, boolean empty) {
sendController.updateTransaction();
});
- amount.setTextFormatter(new CoinTextFormatter(Config.get().getUnitFormat()));
+ amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(sendController.getBitcoinUnit(Config.get().getBitcoinUnit())) ? 0 : 1);
+ amount.setTextFormatter(new CoinTextFormatter(Config.get().getUnitFormat(), amountUnit.getValue()));
amount.textProperty().addListener(amountListener);
- amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(sendController.getBitcoinUnit(Config.get().getBitcoinUnit())) ? 0 : 1);
amountUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
Long value = getRecipientValueSats(oldValue);
+ amount.setTextFormatter(new CoinTextFormatter(Config.get().getUnitFormat(), newValue));
if(value != null) {
UnitFormat unitFormat = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
DecimalFormat df = new DecimalFormat("#.#", unitFormat.getDecimalFormatSymbols());
@@ -939,7 +940,7 @@ public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
public void unitFormatChanged(UnitFormatChangedEvent event) {
if(amount.getTextFormatter() instanceof CoinTextFormatter coinTextFormatter && coinTextFormatter.getUnitFormat() != event.getUnitFormat()) {
Long value = getRecipientValueSats(coinTextFormatter.getUnitFormat(), amountUnit.getSelectionModel().getSelectedItem());
- amount.setTextFormatter(new CoinTextFormatter(event.getUnitFormat()));
+ amount.setTextFormatter(new CoinTextFormatter(event.getUnitFormat(), amountUnit.getValue()));
if(value != null) {
setRecipientValueSats(value);
### src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
@@ -362,13 +362,14 @@ public Double fromString(String string) {
};
});
- fee.setTextFormatter(new CoinTextFormatter(Config.get().getUnitFormat()));
- fee.textProperty().addListener(feeListener);
-
BitcoinUnit unit = getBitcoinUnit(Config.get().getBitcoinUnit());
feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
+ fee.setTextFormatter(new CoinTextFormatter(Config.get().getUnitFormat(), feeAmountUnit.getValue()));
+ fee.textProperty().addListener(feeListener);
+
feeAmountUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
Long value = getFeeValueSats(oldValue);
+ fee.setTextFormatter(new CoinTextFormatter(Config.get().getUnitFormat(), newValue));
if(value != null) {
setFeeValueSats(value);
}
@@ -1568,7 +1569,7 @@ public void unitFormatChanged(UnitFormatChangedEvent event) {
setFeeRate(getFeeRate());
if(fee.getTextFormatter() instanceof CoinTextFormatter coinTextFormatter && coinTextFormatter.getUnitFormat() != event.getUnitFormat()) {
Long value = getFeeValueSats(coinTextFormatter.getUnitFormat(), feeAmountUnit.getSelectionModel().getSelectedItem());
- fee.setTextFormatter(new CoinTextFormatter(event.getUnitFormat()));
+ fee.setTextFormatter(new CoinTextFormatter(event.getUnitFormat(), feeAmountUnit.getValue()));
if(value != null) {
setFeeValueSats(value);Why this scored 37/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.