hold unit format decimal formatters per thread rather than sharing one instance between the export task and table cells
What changed, and why it matters
This change fixes a thread-safety bug in how Bitcoin amounts are formatted for display and export. Previously, the same number-formatting objects were shared between background export tasks and the on-screen transaction table. Because those formatters are not safe for concurrent use, two threads could corrupt each other's output, potentially causing wrong decimal places, separators, or digits to appear in exported data or on screen. The fix gives each thread its own private formatter.
Treat as a reliability/integrity fix. Review whether any exported transaction history or displayed balances could have been misformatted in prior releases, and consider a patch release. No immediate exploit response is indicated, but users relying on exported data for accounting should verify recent exports.
Security signals we found
Thread-safety fix for shared DecimalFormat instances
Concurrent use between background export task and UI table cells
Potential data corruption or incorrect numeric formatting in outputs
No explicit security disclosure or CVE in commit metadata
Evidence from the diff
UnitFormat.java previously stored DecimalFormat instances as enum instance fields, making them singletons shared across all threads. DecimalFormat is documented as not thread-safe. The patch replaces the shared instances with a ThreadLocal
Changed components
src/main/java/com/sparrowwallet/sparrow/UnitFormat.javaTransaction export formattingUI table cell amount formattingInspect captured patch +50 / −76
### src/main/java/com/sparrowwallet/sparrow/UnitFormat.java
@@ -4,91 +4,48 @@
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
-import java.util.Locale;
public enum UnitFormat {
- DOT {
- private final DecimalFormat btcFormat = new DecimalFormat("0", getDecimalFormatSymbols());
- private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
- private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
- private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
- private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
-
- public DecimalFormat getBtcFormat() {
- btcFormat.setMaximumFractionDigits(8);
- return btcFormat;
- }
+ DOT('.', ','),
+ COMMA(',', '.');
- public DecimalFormat getSatsFormat() {
- return satsFormat;
- }
+ private final char decimalSeparator;
+ private final char groupingSeparator;
- public DecimalFormat getTableBtcFormat() {
- return tableBtcFormat;
- }
+ //DecimalFormat is not thread safe, and amounts are formatted on background threads (such as exports) as well as in UI table cells
+ private final ThreadLocal<Formats> formats = ThreadLocal.withInitial(() -> new Formats(getDecimalFormatSymbols()));
- public DecimalFormat getCurrencyFormat() {
- return currencyFormat;
- }
-
- public DecimalFormat getTableCurrencyFormat() {
- return tableCurrencyFormat;
- }
-
- public DecimalFormatSymbols getDecimalFormatSymbols() {
- DecimalFormatSymbols symbols = new DecimalFormatSymbols();
- symbols.setDecimalSeparator('.');
- symbols.setGroupingSeparator(',');
- return symbols;
- }
- },
- COMMA {
- private final DecimalFormat btcFormat = new DecimalFormat("0", getDecimalFormatSymbols());
- private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
- private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
- private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
- private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
-
- public DecimalFormat getBtcFormat() {
- btcFormat.setMaximumFractionDigits(8);
- return btcFormat;
- }
-
- public DecimalFormat getSatsFormat() {
- return satsFormat;
- }
-
- public DecimalFormat getTableBtcFormat() {
- return tableBtcFormat;
- }
-
- public DecimalFormat getCurrencyFormat() {
- return currencyFormat;
- }
-
- public DecimalFormat getTableCurrencyFormat() {
- return tableCurrencyFormat;
- }
-
- public DecimalFormatSymbols getDecimalFormatSymbols() {
- DecimalFormatSymbols symbols = new DecimalFormatSymbols();
- symbols.setDecimalSeparator(',');
- symbols.setGroupingSeparator('.');
- return symbols;
- }
- };
+ UnitFormat(char decimalSeparator, char groupingSeparator) {
+ this.decimalSeparator = decimalSeparator;
+ this.groupingSeparator = groupingSeparator;
+ }
- public abstract DecimalFormatSymbols getDecimalFormatSymbols();
+ public DecimalFormatSymbols getDecimalFormatSymbols() {
+ DecimalFormatSymbols symbols = new DecimalFormatSymbols();
+ symbols.setDecimalSeparator(decimalSeparator);
+ symbols.setGroupingSeparator(groupingSeparator);
+ return symbols;
+ }
- public abstract DecimalFormat getBtcFormat();
+ public DecimalFormat getBtcFormat() {
+ return formats.get().btcFormat;
+ }
- public abstract DecimalFormat getSatsFormat();
+ public DecimalFormat getSatsFormat() {
+ return formats.get().satsFormat;
+ }
- public abstract DecimalFormat getTableBtcFormat();
+ public DecimalFormat getTableBtcFormat() {
+ return formats.get().tableBtcFormat;
+ }
- public abstract DecimalFormat getCurrencyFormat();
+ public DecimalFormat getCurrencyFormat() {
+ return formats.get().currencyFormat;
+ }
- public abstract DecimalFormat getTableCurrencyFormat();
+ public DecimalFormat getTableCurrencyFormat() {
+ return formats.get().tableCurrencyFormat;
+ }
public String formatBtcValue(Long amount) {
return getBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
@@ -111,10 +68,27 @@ public String tableFormatCurrencyValue(double amount) {
}
public String getGroupingSeparator() {
- return Character.toString(getDecimalFormatSymbols().getGroupingSeparator());
+ return Character.toString(groupingSeparator);
}
public String getDecimalSeparator() {
- return Character.toString(getDecimalFormatSymbols().getDecimalSeparator());
+ return Character.toString(decimalSeparator);
+ }
+
+ private static class Formats {
+ private final DecimalFormat btcFormat;
+ private final DecimalFormat satsFormat;
+ private final DecimalFormat tableBtcFormat;
+ private final DecimalFormat currencyFormat;
+ private final DecimalFormat tableCurrencyFormat;
+
+ private Formats(DecimalFormatSymbols symbols) {
+ btcFormat = new DecimalFormat("0", symbols);
+ btcFormat.setMaximumFractionDigits(8);
+ satsFormat = new DecimalFormat("#,##0", symbols);
+ tableBtcFormat = new DecimalFormat("0.00000000", symbols);
+ currencyFormat = new DecimalFormat("#,##0.00", symbols);
+ tableCurrencyFormat = new DecimalFormat("0.00", symbols);
+ }
}
}Why this scored 34/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.