From 1b6150aae4bae07651e768392a9b366ab453869e Mon Sep 17 00:00:00 2001
From: 2rdzy <232089760+2rdzy@users.noreply.github.com>
Date: Thu, 15 Oct 2026 10:07:36 +0200
Subject: [PATCH 1/3] Show a fiat value again, from Neoxa's rate for this chain
Every exchange source quoted the SHA256d chain, so #39 turned fiat off. Neoxa
quotes this chain's coin as BTCB2_USDC, and is now the only exchange rate
source, used by default. A stored Coinbase, Coingecko or mempool.space choice
reads back as Neoxa, so the wrong rate cannot return; None is kept.
Other currencies are converted from USD through a second setting, Currency
conversion (Coinbase, Coingecko or mempool.space, mempool.space by default).
Only the ratio between its price of a bitcoin in two currencies is used, so
its price of a bitcoin cancels out.
Historical values in the transaction CSV and BIP329 label exports are chosen
by block height: a transaction below 961632, the first block not shared with
the SHA256d chain, takes that chain's price for the day, and one from 961632
on takes Neoxa's. Neoxa's daily candles start on 2026-09-01, so a transaction
between the split and then has no value rather than a wrong one.
Closes #55
---
.../sparrowwallet/sparrow/AppServices.java | 2 +-
.../com/sparrowwallet/sparrow/io/Config.java | 19 +-
.../sparrow/io/WalletLabels.java | 26 +--
.../sparrow/io/WalletTransactions.java | 12 +-
.../sparrow/net/ExchangeSource.java | 221 ++++++++++++++++++
.../sparrow/net/HistoricalRates.java | 51 ++++
.../settings/GeneralSettingsController.java | 27 ++-
.../terminal/settings/GeneralDialog.java | 17 +-
.../sparrow/settings/general.fxml | 16 +-
.../sparrow/io/ExchangeSourceGateTest.java | 72 +++++-
.../sparrow/net/HistoricalRatesTest.java | 60 +++++
.../sparrow/net/NeoxaExchangeSourceTest.java | 99 ++++++++
12 files changed, 571 insertions(+), 51 deletions(-)
create mode 100644 src/main/java/com/sparrowwallet/sparrow/net/HistoricalRates.java
create mode 100644 src/test/java/com/sparrowwallet/sparrow/net/HistoricalRatesTest.java
create mode 100644 src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java
diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
index 663189b5e8e..7c6eae623ee 100644
--- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java
+++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
@@ -98,7 +98,7 @@ public class AppServices {
private static final int CONNECTION_DELAY_SECS = 2;
private static final int RATES_DELAY_SECS_DEFAULT = 2;
private static final int RATES_DELAY_SECS_WINDOWS = 5;
- private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO;
+ private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.NEOXA;
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Config.java b/src/main/java/com/sparrowwallet/sparrow/io/Config.java
index 83de81ba773..4c2f6b48da0 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/Config.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/Config.java
@@ -39,6 +39,7 @@ public class Config {
private OptimizationStrategy sendOptimizationStrategy;
private Currency fiatCurrency;
private ExchangeSource exchangeSource;
+ private ExchangeSource conversionSource;
private boolean loadRecentWallets = true;
private boolean validateDerivationPaths = true;
private boolean groupByAddress = true;
@@ -237,10 +238,11 @@ public boolean isFetchRates() {
return getExchangeSource() != ExchangeSource.NONE;
}
- //Every source quotes the SHA256d chain, which is not what this wallet holds. The stored
- //preference is still written, so it returns as the user set it once a real rate exists.
+ //Neoxa is the only source that quotes this chain's coin. Every other source quotes the SHA256d chain, which
+ //is not what this wallet holds, so a config that names one of them (carried over from before the split, or
+ //from Sparrow) asks for a rate and gets Neoxa's. Only a choice of no rate at all is kept as it was.
public ExchangeSource getExchangeSource() {
- return ExchangeSource.NONE;
+ return exchangeSource == ExchangeSource.NONE ? ExchangeSource.NONE : ExchangeSource.NEOXA;
}
public void setExchangeSource(ExchangeSource exchangeSource) {
@@ -248,6 +250,17 @@ public void setExchangeSource(ExchangeSource exchangeSource) {
flush();
}
+ //The source Neoxa's USD rate is converted to other currencies through. Only the ratio between two of its
+ //currencies is used, never its price of a bitcoin.
+ public ExchangeSource getConversionSource() {
+ return conversionSource != null && ExchangeSource.CONVERSION_SOURCES.contains(conversionSource) ? conversionSource : ExchangeSource.DEFAULT_CONVERSION_SOURCE;
+ }
+
+ public void setConversionSource(ExchangeSource conversionSource) {
+ this.conversionSource = conversionSource;
+ flush();
+ }
+
public boolean isLoadRecentWallets() {
return loadRecentWallets;
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java b/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java
index 8f0193aa43c..c3344cd332a 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java
@@ -15,8 +15,8 @@
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
import com.sparrowwallet.sparrow.net.ExchangeSource;
+import com.sparrowwallet.sparrow.net.HistoricalRates;
import com.sparrowwallet.sparrow.wallet.*;
-import org.apache.commons.lang3.time.DateUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -61,7 +61,7 @@ public WalletModel getWalletModel() {
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
List labels = new ArrayList<>();
- Map fiatRates = getFiatRates(walletForms);
+ HistoricalRates fiatRates = getFiatRates(walletForms);
for(WalletForm exportWalletForm : walletForms) {
Wallet exportWallet = exportWalletForm.getWallet();
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet);
@@ -85,7 +85,7 @@ public void exportWallet(Wallet wallet, OutputStream outputStream, String passwo
labels.add(new TransactionLabel(blkTx.getHashAsString(), blkTx.getLabel(), origin,
txEntry.isConfirming() ? null : blkTx.getHeight(), blkTx.getDate(),
getFee(walletTransactionsEntry.getWallet(), blkTx), txEntry.getValue(),
- getFiatValue(blkTx.getDate(), Transaction.SATOSHIS_PER_BITCOIN, fiatRates)));
+ getFiatValue(blkTx.getHeight(), blkTx.getDate(), Transaction.SATOSHIS_PER_BITCOIN, fiatRates)));
if(txEntry.isConfirming()) {
confirmingTxs.add(blkTx.getHash());
}
@@ -392,10 +392,10 @@ private Long getFee(Wallet wallet, BlockTransaction blockTransaction) {
return fee;
}
- private Map getFiatRates(List walletForms) {
+ private HistoricalRates getFiatRates(List walletForms) {
ExchangeSource exchangeSource = getExchangeSource();
Currency fiatCurrency = getFiatCurrency();
- Map fiatRates = new HashMap<>();
+ HistoricalRates fiatRates = new HistoricalRates(Collections.emptyMap(), Collections.emptyMap(), null);
if(fiatCurrency != null) {
long min = Long.MAX_VALUE;
long max = Long.MIN_VALUE;
@@ -413,7 +413,7 @@ private Map getFiatRates(List walletForms) {
}
if(max > min) {
- fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(min - ONE_DAY), new Date(max));
+ fiatRates = exchangeSource.getHistoricalRates(fiatCurrency, new Date(min - ONE_DAY), new Date(max));
}
}
@@ -421,22 +421,22 @@ private Map getFiatRates(List walletForms) {
}
private static ExchangeSource getExchangeSource() {
- return Config.get().getExchangeSource() == null ? ExchangeSource.COINGECKO : Config.get().getExchangeSource();
+ return Config.get().getExchangeSource() == null ? ExchangeSource.NEOXA : Config.get().getExchangeSource();
}
private static Currency getFiatCurrency() {
return getExchangeSource() == ExchangeSource.NONE || !AppServices.onlineProperty().get() ? null : Config.get().getFiatCurrency();
}
- private Map getFiatValue(TransactionEntry txEntry, Map fiatRates) {
- return getFiatValue(txEntry.getBlockTransaction().getDate(), txEntry.getValue(), fiatRates);
+ private Map getFiatValue(TransactionEntry txEntry, HistoricalRates fiatRates) {
+ return getFiatValue(txEntry.getBlockTransaction().getHeight(), txEntry.getBlockTransaction().getDate(), txEntry.getValue(), fiatRates);
}
- private Map getFiatValue(BlockTransactionHashIndex ref, Map fiatRates) {
- return getFiatValue(ref.getDate(), ref.getValue(), fiatRates);
+ private Map getFiatValue(BlockTransactionHashIndex ref, HistoricalRates fiatRates) {
+ return getFiatValue(ref.getHeight(), ref.getDate(), ref.getValue(), fiatRates);
}
- private Map getFiatValue(Date date, long value, Map fiatRates) {
+ private Map getFiatValue(int height, Date date, long value, HistoricalRates fiatRates) {
Currency fiatCurrency = getFiatCurrency();
if(fiatCurrency != null) {
Double dayRate = null;
@@ -445,7 +445,7 @@ private Map getFiatValue(Date date, long value, Map fiatRates = new HashMap<>();
+ HistoricalRates fiatRates = new HistoricalRates(Collections.emptyMap(), Collections.emptyMap(), null);
if(fiatCurrency != null && !walletTransactionsEntry.getChildren().isEmpty()) {
LongSummaryStatistics stats = walletTransactionsEntry.getChildren().stream()
.map(entry -> ((TransactionEntry)entry).getBlockTransaction().getDate())
.filter(Objects::nonNull)
.collect(Collectors.summarizingLong(Date::getTime));
- fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(stats.getMin() - ONE_DAY), new Date(stats.getMax()));
+ fiatRates = exchangeSource.getHistoricalRates(fiatCurrency, new Date(stats.getMin() - ONE_DAY), new Date(stats.getMax()));
}
BitcoinUnit bitcoinUnit = Config.get().getBitcoinUnit();
@@ -146,14 +146,14 @@ private String getFiatValue(Double value) {
return format.tableFormatCurrencyValue(value);
}
- private Double getFiatValue(TransactionEntry txEntry, Map fiatRates) {
+ private Double getFiatValue(TransactionEntry txEntry, HistoricalRates fiatRates) {
Double dayRate = null;
if(txEntry.getBlockTransaction().getDate() == null) {
if(AppServices.getFiatCurrencyExchangeRate() != null) {
dayRate = AppServices.getFiatCurrencyExchangeRate().getBtcRate();
}
} else {
- dayRate = fiatRates.get(DateUtils.truncate(txEntry.getBlockTransaction().getDate(), Calendar.DAY_OF_MONTH));
+ dayRate = fiatRates.getRate(txEntry.getBlockTransaction().getHeight(), txEntry.getBlockTransaction().getDate());
}
if(dayRate != null) {
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java b/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java
index e025b984f2f..8c92e74b742 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java
@@ -2,9 +2,12 @@
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
+import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.tern.http.client.HttpResponseException;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
@@ -59,6 +62,11 @@ public Double getExchangeRate(Currency currency) {
return null;
}
+ @Override
+ protected Map getAllExchangeRates() {
+ return toCurrencyRates(getRates().data.rates);
+ }
+
private CoinbaseRates getRates() {
String url = "https://api.coinbase.com/v2/exchange-rates?currency=BTC";
@@ -143,6 +151,17 @@ public Double getExchangeRate(Currency currency) {
return null;
}
+ @Override
+ protected Map getAllExchangeRates() {
+ Map rates = new LinkedHashMap<>();
+ getRates().rates.forEach((code, rate) -> {
+ if("fiat".equals(rate.type)) {
+ rates.put(code, rate.value);
+ }
+ });
+ return toCurrencyRates(rates);
+ }
+
private CoinGeckoRates getRates() {
String url = "https://api.coingecko.com/api/v3/exchange_rates";
@@ -216,6 +235,11 @@ public Double getExchangeRate(Currency currency) {
return null;
}
+ @Override
+ protected Map getAllExchangeRates() {
+ return toCurrencyRates(getRates().rates);
+ }
+
private MempoolSpaceRates getRates() {
String url = AppServices.isUsingProxy() ? "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1/prices" : "https://mempool.space/api/v1/prices";
@@ -266,8 +290,113 @@ public Map getHistoricalExchangeRates(Currency currency, Date star
return historicalRates;
}
+ },
+ NEOXA("Neoxa", "Historical rates from Sep 2026") {
+ //Neoxa quotes this chain's coin against USDC only. Any other currency is reached through the conversion
+ //source, which quotes the SHA256d chain: dividing its price in one currency by its price in USD leaves the
+ //rate between the two currencies, and the SHA256d chain's own price cancels out.
+ @Override
+ public List getSupportedCurrencies() {
+ //An empty list says the currencies could not be retrieved, and keeps the one the user chose
+ List currencies = new ArrayList<>(getConversionSource().getSupportedCurrencies());
+ if(!currencies.isEmpty() && !currencies.contains(USD)) {
+ currencies.addFirst(USD);
+ }
+
+ return currencies;
+ }
+
+ @Override
+ public Double getExchangeRate(Currency currency) {
+ Double usdRate = getTicker().ticker.lastPrice;
+ if(usdRate == null || USD.equals(currency)) {
+ return usdRate;
+ }
+
+ return convertFromUsd(usdRate, getConversionSource().getAllExchangeRates(), currency);
+ }
+
+ private NeoxaTicker getTicker() {
+ String url = NEOXA_URL + "/api/exchange/ticker/" + NEOXA_PAIR;
+
+ if(log.isInfoEnabled()) {
+ log.info("Requesting exchange rates from " + url);
+ }
+
+ HttpClientService httpClientService = AppServices.getHttpClientService();
+ try {
+ return httpClientService.requestJson(url, NeoxaTicker.class, null);
+ } catch(Exception e) {
+ if(log.isDebugEnabled()) {
+ log.warn("Error retrieving currency rates", e);
+ } else {
+ log.warn("Error retrieving currency rates (" + e.getMessage() + ")");
+ }
+ return new NeoxaTicker();
+ }
+ }
+
+ @Override
+ public Map getHistoricalExchangeRates(Currency currency, Date start, Date end) {
+ Map usdRates = getUsdHistoricalExchangeRates(start, end);
+ if(USD.equals(currency) || usdRates.isEmpty()) {
+ return usdRates;
+ }
+
+ ExchangeSource conversionSource = getConversionSource();
+ return convertFromUsd(usdRates, conversionSource.getHistoricalExchangeRates(currency, start, end), conversionSource.getHistoricalExchangeRates(USD, start, end));
+ }
+
+ @Override
+ public HistoricalRates getHistoricalRates(Currency currency, Date start, Date end) {
+ Integer splitHeight = HistoricalRates.getSplitHeight(Network.get());
+ ExchangeSource conversionSource = getConversionSource();
+ Map btcRates = conversionSource.getHistoricalExchangeRates(currency, start, end);
+ Map usdRates = getUsdHistoricalExchangeRates(start, end);
+ if(!USD.equals(currency) && !usdRates.isEmpty()) {
+ usdRates = convertFromUsd(usdRates, btcRates, conversionSource.getHistoricalExchangeRates(USD, start, end));
+ }
+
+ return new HistoricalRates(splitHeight == null ? Collections.emptyMap() : btcRates, usdRates, splitHeight);
+ }
+
+ private Map getUsdHistoricalExchangeRates(Date start, Date end) {
+ //The candles endpoint takes a count rather than a range, and returns the most recent ones
+ long days = ChronoUnit.DAYS.between(start.toInstant(), Instant.now()) + 2;
+ String url = NEOXA_URL + "/api/exchange/candles/" + NEOXA_PAIR + "?interval=1d&limit=" + Math.max(1, Math.min(days, 10000));
+
+ if(log.isInfoEnabled()) {
+ log.info("Requesting historical exchange rates from " + url);
+ }
+
+ HttpClientService httpClientService = AppServices.getHttpClientService();
+ try {
+ NeoxaCandles neoxaCandles = httpClientService.requestJson(url, NeoxaCandles.class, null);
+ return toDailyRates(neoxaCandles, start, end);
+ } catch(Exception e) {
+ if(log.isDebugEnabled()) {
+ log.warn("Error retrieving historical currency rates", e);
+ } else {
+ log.warn("Error retrieving historical currency rates (" + e.getMessage() + ")");
+ }
+ }
+
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public ExchangeSource getConversionSource() {
+ return Config.get().getConversionSource();
+ }
};
+ public static final ExchangeSource DEFAULT_CONVERSION_SOURCE = MEMPOOL_SPACE;
+ public static final List CONVERSION_SOURCES = List.of(COINBASE, COINGECKO, MEMPOOL_SPACE);
+
+ private static final Currency USD = Currency.getInstance("USD");
+ private static final String NEOXA_URL = "https://neoxa.exchange";
+ private static final String NEOXA_PAIR = "BTCB2_USDC";
+
private static final Logger log = LoggerFactory.getLogger(ExchangeSource.class);
private static final Map HTTP_HEADERS = Map.of("User-Agent", "Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)", "Accept", "*/*");
@@ -285,6 +414,77 @@ public Map getHistoricalExchangeRates(Currency currency, Date star
public abstract Map getHistoricalExchangeRates(Currency currency, Date start, Date end);
+ /**
+ * Daily rates for valuing past transactions. Only a source that follows this chain tells a coin made before the
+ * split from one made after it, so every other source values them all the same way.
+ */
+ public HistoricalRates getHistoricalRates(Currency currency, Date start, Date end) {
+ return new HistoricalRates(Collections.emptyMap(), getHistoricalExchangeRates(currency, start, end), null);
+ }
+
+ /**
+ * The price of a bitcoin in every currency this source quotes, from a single response so that all of them are
+ * taken at the same moment.
+ */
+ protected Map getAllExchangeRates() {
+ return Collections.emptyMap();
+ }
+
+ /**
+ * The source used to turn a USD rate into another currency, or null where the rate is quoted directly.
+ */
+ public ExchangeSource getConversionSource() {
+ return null;
+ }
+
+ static Map toCurrencyRates(Map rates) {
+ Map currencyRates = new LinkedHashMap<>();
+ rates.forEach((code, rate) -> {
+ String upperCode = code.toUpperCase(Locale.ROOT);
+ if(rate != null && isValidISO4217Code(upperCode)) {
+ currencyRates.put(Currency.getInstance(upperCode), rate);
+ }
+ });
+
+ return currencyRates;
+ }
+
+ static Double convertFromUsd(Double usdRate, Map btcRates, Currency currency) {
+ Double btcUsd = btcRates.get(USD);
+ Double btcCurrency = btcRates.get(currency);
+ if(usdRate == null || btcUsd == null || btcCurrency == null || btcUsd <= 0.0 || btcCurrency <= 0.0) {
+ return null;
+ }
+
+ return usdRate * btcCurrency / btcUsd;
+ }
+
+ static Map convertFromUsd(Map usdRates, Map btcCurrencyRates, Map btcUsdRates) {
+ Map rates = new TreeMap<>();
+ usdRates.forEach((date, usdRate) -> {
+ Double btcCurrency = btcCurrencyRates.get(date);
+ Double btcUsd = btcUsdRates.get(date);
+ //mempool.space marks a day it has no rate for with -1
+ if(usdRate != null && btcCurrency != null && btcUsd != null && btcUsd > 0.0 && btcCurrency > 0.0) {
+ rates.put(date, usdRate * btcCurrency / btcUsd);
+ }
+ });
+
+ return rates;
+ }
+
+ static Map toDailyRates(NeoxaCandles neoxaCandles, Date start, Date end) {
+ Map rates = new TreeMap<>();
+ for(NeoxaCandle candle : neoxaCandles.candles) {
+ Date date = new Date(candle.time * 1000);
+ if(candle.close != null && !date.before(DateUtils.truncate(start, Calendar.DAY_OF_MONTH)) && !date.after(end)) {
+ rates.put(DateUtils.truncate(date, Calendar.DAY_OF_MONTH), candle.close);
+ }
+ }
+
+ return rates;
+ }
+
private static boolean isValidISO4217Code(String code) {
try {
Currency currency = Currency.getInstance(code);
@@ -403,4 +603,25 @@ public void setPrice(String name, Double value) {
private static class MempoolSpaceHistoricalRates {
public List prices = new ArrayList<>();
}
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ static class NeoxaTicker {
+ public NeoxaTickerData ticker = new NeoxaTickerData();
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ static class NeoxaTickerData {
+ public Double lastPrice;
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ static class NeoxaCandles {
+ public List candles = new ArrayList<>();
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ static class NeoxaCandle {
+ public long time;
+ public Double close;
+ }
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/HistoricalRates.java b/src/main/java/com/sparrowwallet/sparrow/net/HistoricalRates.java
new file mode 100644
index 00000000000..4613f80b137
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/net/HistoricalRates.java
@@ -0,0 +1,51 @@
+package com.sparrowwallet.sparrow.net;
+
+import com.sparrowwallet.drongo.Network;
+import org.apache.commons.lang3.time.DateUtils;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * Daily rates for valuing past transactions, split at the block where this chain left the SHA256d one.
+ *
+ * A transaction confirmed before that block exists on both chains, and at the time it was worth what a bitcoin was,
+ * so it takes the SHA256d chain's rate. A transaction confirmed from that block on only exists on this chain, and
+ * takes this chain's own rate. Splitting on height rather than date keeps the day of the split right, since blocks
+ * on both sides of it were mined that day.
+ */
+public class HistoricalRates {
+ //The first block that Knots follows and the SHA256d chain does not
+ private static final int MAINNET_SPLIT_HEIGHT = 961632;
+
+ private final Map preSplitRates;
+ private final Map postSplitRates;
+ private final Integer splitHeight;
+
+ public HistoricalRates(Map preSplitRates, Map postSplitRates, Integer splitHeight) {
+ this.preSplitRates = preSplitRates;
+ this.postSplitRates = postSplitRates;
+ this.splitHeight = splitHeight;
+ }
+
+ /**
+ * The height of the first block not shared with the SHA256d chain, or null where this network has no such split.
+ */
+ public static Integer getSplitHeight(Network network) {
+ return network == Network.MAINNET ? MAINNET_SPLIT_HEIGHT : null;
+ }
+
+ /**
+ * The rate on the day a transaction confirmed, or null where it has no date or no rate is known for that day.
+ */
+ public Double getRate(Integer height, Date date) {
+ if(date == null) {
+ return null;
+ }
+
+ Date day = DateUtils.truncate(date, Calendar.DAY_OF_MONTH);
+ boolean preSplit = splitHeight != null && height != null && height > 0 && height < splitHeight;
+ return preSplit ? preSplitRates.get(day) : postSplitRates.get(day);
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/settings/GeneralSettingsController.java b/src/main/java/com/sparrowwallet/sparrow/settings/GeneralSettingsController.java
index 7a5566b644d..06084ee197f 100644
--- a/src/main/java/com/sparrowwallet/sparrow/settings/GeneralSettingsController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/settings/GeneralSettingsController.java
@@ -22,7 +22,6 @@
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
-import javafx.scene.control.Tooltip;
import javafx.util.StringConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -50,6 +49,9 @@ public class GeneralSettingsController extends SettingsDetailController {
@FXML
private ComboBox exchangeSource;
+ @FXML
+ private ComboBox conversionSource;
+
@FXML
private Label currenciesLoadWarning;
@@ -143,27 +145,28 @@ public void initializeView(Config config) {
blockExplorers.getSelectionModel().select(0);
}
- if(config.getExchangeSource() != null) {
- exchangeSource.setValue(config.getExchangeSource());
- } else {
- exchangeSource.getSelectionModel().select(2);
- config.setExchangeSource(exchangeSource.getValue());
- }
+ exchangeSource.setValue(config.getExchangeSource());
exchangeSource.setButtonCell(new ExchangeSourceButtonCell());
exchangeSource.setCellFactory(_ -> new ExchangeSourceListCell());
exchangeSource.valueProperty().addListener((observable, oldValue, source) -> {
config.setExchangeSource(source);
+ conversionSource.setDisable(source == ExchangeSource.NONE);
updateCurrencies(source);
});
- updateCurrencies(exchangeSource.getSelectionModel().getSelectedItem());
+ conversionSource.setValue(config.getConversionSource());
+ conversionSource.setDisable(config.getExchangeSource() == ExchangeSource.NONE);
+ conversionSource.setButtonCell(new ExchangeSourceButtonCell());
+ conversionSource.setCellFactory(_ -> new ExchangeSourceButtonCell());
+
+ conversionSource.valueProperty().addListener((observable, oldValue, source) -> {
+ config.setConversionSource(source);
+ updateCurrencies(exchangeSource.getValue());
+ });
- //updateCurrencies() already disables fiatCurrency when the source supplies none, so only
- //the source itself needs disabling here.
- exchangeSource.setDisable(true);
- exchangeSource.setTooltip(new Tooltip("No exchange rate is available yet, so no fiat value is shown."));
+ updateCurrencies(exchangeSource.getSelectionModel().getSelectedItem());
loadRecentWallets.setSelected(config.isLoadRecentWallets());
loadRecentWallets.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/settings/GeneralDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/settings/GeneralDialog.java
index fc6f1209ac8..291c13f561b 100644
--- a/src/main/java/com/sparrowwallet/sparrow/terminal/settings/GeneralDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/terminal/settings/GeneralDialog.java
@@ -27,6 +27,7 @@ public class GeneralDialog extends DialogWindow {
private final ComboBox fiatCurrency;
private final ComboBox exchangeSource;
+ private final ComboBox conversionSource;
private final ComboBox.Listener fiatCurrencyListener = new ComboBox.Listener() {
@Override
@@ -65,16 +66,22 @@ public GeneralDialog() {
mainPanel.addComponent(fiatCurrency);
mainPanel.addComponent(new Label("Exchange rate source"));
- exchangeSource = new ComboBox<>(ExchangeSource.values());
+ exchangeSource = new ComboBox<>(ExchangeSource.NONE, ExchangeSource.NEOXA);
mainPanel.addComponent(exchangeSource);
+ mainPanel.addComponent(new Label("Currency conversion"));
+ conversionSource = new ComboBox<>(ExchangeSource.CONVERSION_SOURCES);
+ mainPanel.addComponent(conversionSource);
+
bitcoinUnit.setSelectedItem(Config.get().getBitcoinUnit() == null ? BitcoinUnit.AUTO : Config.get().getBitcoinUnit());
unitFormat.setSelectedIndex(Config.get().getUnitFormat() == UnitFormat.COMMA ? 1 : 0);
if(Config.get().getExchangeSource() == null) {
- Config.get().setExchangeSource(ExchangeSource.COINGECKO);
+ Config.get().setExchangeSource(ExchangeSource.NEOXA);
}
exchangeSource.setSelectedItem(Config.get().getExchangeSource());
+ conversionSource.setSelectedItem(Config.get().getConversionSource());
+ conversionSource.setEnabled(Config.get().getExchangeSource() != ExchangeSource.NONE);
bitcoinUnit.addListener((int selectedIndex, int previousSelection, boolean changedByUserInteraction) -> {
BitcoinUnit newValue = bitcoinUnit.getSelectedItem();
@@ -95,9 +102,15 @@ public GeneralDialog() {
exchangeSource.addListener((int selectedIndex, int previousSelection, boolean changedByUserInteraction) -> {
ExchangeSource source = exchangeSource.getSelectedItem();
Config.get().setExchangeSource(source);
+ conversionSource.setEnabled(source != ExchangeSource.NONE);
updateCurrencies(source);
});
+ conversionSource.addListener((int selectedIndex, int previousSelection, boolean changedByUserInteraction) -> {
+ Config.get().setConversionSource(conversionSource.getSelectedItem());
+ updateCurrencies(exchangeSource.getSelectedItem());
+ });
+
updateCurrencies(exchangeSource.getSelectedItem());
Panel buttonPanel = new Panel();
diff --git a/src/main/resources/com/sparrowwallet/sparrow/settings/general.fxml b/src/main/resources/com/sparrowwallet/sparrow/settings/general.fxml
index 19a2186f12e..b9bd86f3865 100644
--- a/src/main/resources/com/sparrowwallet/sparrow/settings/general.fxml
+++ b/src/main/resources/com/sparrowwallet/sparrow/settings/general.fxml
@@ -56,9 +56,7 @@
-
-
-
+
@@ -68,6 +66,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java b/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java
index ad9ce6c8ea9..81d1461eff0 100644
--- a/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java
+++ b/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java
@@ -1,29 +1,79 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.sparrow.net.ExchangeSource;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.lang.reflect.Field;
+
/**
- * Every exchange source quotes the SHA256d chain, which is not what this wallet holds, so no fiat
- * value is shown at all. Every display site and the rates service decide that by reading
- * Config.getExchangeSource(), so this pins the one place the decision is made.
+ * Neoxa is the only source that quotes this chain's coin. Every other exchange source quotes the SHA256d chain, which
+ * is not what this wallet holds, and using one of them overstated every fiat value by a couple of hundred times.
+ * Every display site and the rates service decide which source to use by reading Config.getExchangeSource(), so this
+ * pins the one place the decision is made.
*/
public class ExchangeSourceGateTest {
+ private ExchangeSource storedExchangeSource;
+ private ExchangeSource storedConversionSource;
+
+ @BeforeEach
+ public void saveConfig() throws ReflectiveOperationException {
+ storedExchangeSource = (ExchangeSource)getField("exchangeSource").get(Config.get());
+ storedConversionSource = (ExchangeSource)getField("conversionSource").get(Config.get());
+ }
+
+ @AfterEach
+ public void restoreConfig() {
+ Config.get().setExchangeSource(storedExchangeSource);
+ Config.get().setConversionSource(storedConversionSource);
+ }
+
@Test
- public void testNoExchangeSourceIsUsed() {
- Assertions.assertEquals(ExchangeSource.NONE, Config.get().getExchangeSource());
- Assertions.assertFalse(Config.get().isFetchRates(), "rates would be fetched and displayed");
+ public void testNeoxaIsUsedByDefault() {
+ Config.get().setExchangeSource(null);
+
+ Assertions.assertEquals(ExchangeSource.NEOXA, Config.get().getExchangeSource());
+ Assertions.assertTrue(Config.get().isFetchRates());
+ }
+
+ @Test
+ public void testAStoredSha256dSourceDoesNotReopenIt() {
+ //A config carried over from before the split, or from Sparrow, must not bring the wrong rate back with it
+ for(ExchangeSource stored : ExchangeSource.CONVERSION_SOURCES) {
+ Config.get().setExchangeSource(stored);
+ Assertions.assertEquals(ExchangeSource.NEOXA, Config.get().getExchangeSource(), stored + " was used for the rate");
+ }
}
@Test
- public void testAStoredPreferenceDoesNotReopenIt() {
- //A config carried over from before this change, or written by a future build that has a
- //source worth using, must not bring the wrong rate back with it.
- ExchangeSource stored = ExchangeSource.COINGECKO;
- Config.get().setExchangeSource(stored);
+ public void testNoneIsKept() {
+ Config.get().setExchangeSource(ExchangeSource.NONE);
Assertions.assertEquals(ExchangeSource.NONE, Config.get().getExchangeSource());
Assertions.assertFalse(Config.get().isFetchRates());
}
+
+ @Test
+ public void testConversionSourceDefaultsToMempoolSpace() {
+ Config.get().setConversionSource(null);
+ Assertions.assertEquals(ExchangeSource.MEMPOOL_SPACE, Config.get().getConversionSource());
+
+ //Neither of these quotes a bitcoin in more than one currency, so neither can convert
+ Config.get().setConversionSource(ExchangeSource.NEOXA);
+ Assertions.assertEquals(ExchangeSource.MEMPOOL_SPACE, Config.get().getConversionSource());
+ Config.get().setConversionSource(ExchangeSource.NONE);
+ Assertions.assertEquals(ExchangeSource.MEMPOOL_SPACE, Config.get().getConversionSource());
+
+ Config.get().setConversionSource(ExchangeSource.COINBASE);
+ Assertions.assertEquals(ExchangeSource.COINBASE, Config.get().getConversionSource());
+ Assertions.assertEquals(ExchangeSource.COINBASE, ExchangeSource.NEOXA.getConversionSource());
+ }
+
+ private static Field getField(String name) throws NoSuchFieldException {
+ Field field = Config.class.getDeclaredField(name);
+ field.setAccessible(true);
+ return field;
+ }
}
diff --git a/src/test/java/com/sparrowwallet/sparrow/net/HistoricalRatesTest.java b/src/test/java/com/sparrowwallet/sparrow/net/HistoricalRatesTest.java
new file mode 100644
index 00000000000..8bce6a06d73
--- /dev/null
+++ b/src/test/java/com/sparrowwallet/sparrow/net/HistoricalRatesTest.java
@@ -0,0 +1,60 @@
+package com.sparrowwallet.sparrow.net;
+
+import com.sparrowwallet.drongo.Network;
+import org.apache.commons.lang3.time.DateUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.Map;
+
+/**
+ * A transaction confirmed before the split exists on both chains and takes the SHA256d chain's rate; one confirmed
+ * from the split on takes this chain's. The choice is made on height, since blocks on both sides of the split were
+ * mined on the same day.
+ */
+public class HistoricalRatesTest {
+ private static final Date SPLIT_DAY = DateUtils.truncate(new GregorianCalendar(2026, Calendar.AUGUST, 8, 12, 0).getTime(), Calendar.DAY_OF_MONTH);
+
+ private final HistoricalRates rates = new HistoricalRates(Map.of(SPLIT_DAY, 100000.0), Map.of(SPLIT_DAY, 400.0), 961632);
+
+ @Test
+ public void testSplitHeight() {
+ Assertions.assertEquals(961632, HistoricalRates.getSplitHeight(Network.MAINNET));
+ Assertions.assertNull(HistoricalRates.getSplitHeight(Network.TESTNET));
+ Assertions.assertNull(HistoricalRates.getSplitHeight(Network.REGTEST));
+ }
+
+ @Test
+ public void testSameDayIsSplitByHeight() {
+ Date morning = new GregorianCalendar(2026, Calendar.AUGUST, 8, 9, 30).getTime();
+ Date evening = new GregorianCalendar(2026, Calendar.AUGUST, 8, 21, 15).getTime();
+
+ Assertions.assertEquals(100000.0, rates.getRate(961631, morning));
+ Assertions.assertEquals(400.0, rates.getRate(961632, morning));
+ Assertions.assertEquals(400.0, rates.getRate(961700, evening));
+ }
+
+ @Test
+ public void testMissingDayHasNoRate() {
+ Date later = new GregorianCalendar(2026, Calendar.AUGUST, 20, 12, 0).getTime();
+
+ Assertions.assertNull(rates.getRate(961631, later));
+ Assertions.assertNull(rates.getRate(962000, later));
+ Assertions.assertNull(rates.getRate(962000, null));
+ }
+
+ @Test
+ public void testUnconfirmedTakesThisChainsRate() {
+ Assertions.assertEquals(400.0, rates.getRate(0, SPLIT_DAY));
+ Assertions.assertEquals(400.0, rates.getRate(-1, SPLIT_DAY));
+ }
+
+ @Test
+ public void testNoSplitTakesThisChainsRate() {
+ HistoricalRates noSplit = new HistoricalRates(Map.of(), Map.of(SPLIT_DAY, 400.0), null);
+ Assertions.assertEquals(400.0, noSplit.getRate(1000, SPLIT_DAY));
+ }
+}
diff --git a/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java b/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java
new file mode 100644
index 00000000000..04399984ece
--- /dev/null
+++ b/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java
@@ -0,0 +1,99 @@
+package com.sparrowwallet.sparrow.net;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.lang3.time.DateUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Calendar;
+import java.util.Currency;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Neoxa quotes this chain's coin in USD only. Other currencies come from the conversion source, using only the ratio
+ * between its price of a bitcoin in that currency and in USD, so its price of a bitcoin cancels out.
+ */
+public class NeoxaExchangeSourceTest {
+ private static final Currency USD = Currency.getInstance("USD");
+ private static final Currency EUR = Currency.getInstance("EUR");
+
+ //Trimmed from https://neoxa.exchange/api/exchange/ticker/BTCB2_USDC
+ private static final String TICKER = "{\"success\":true,\"pair\":\"BTCB2_USDC\",\"ticker\":{\"lastPrice\":737.02,\"change\":-39.34,\"high24h\":820.37,\"low24h\":697,\"volume24h\":434.86,\"bestBid\":737.02,\"bestAsk\":741.46}}";
+ //Trimmed from https://neoxa.exchange/api/exchange/candles/BTCB2_USDC?interval=1d&limit=3
+ private static final String CANDLES = "{\"success\":true,\"pair\":\"BTCB2_USDC\",\"interval\":\"1d\",\"candles\":[" +
+ "{\"time\":1788220800,\"open\":13.515415,\"high\":220,\"low\":13.515415,\"close\":90,\"volume\":442.69}," +
+ "{\"time\":1788307200,\"open\":87,\"high\":299.6,\"low\":80,\"close\":250.5,\"volume\":100.1}," +
+ "{\"time\":1788393600,\"open\":250.5,\"high\":300,\"low\":240,\"close\":280,\"volume\":50.2}]}";
+
+ @Test
+ public void testTickerParses() throws Exception {
+ ExchangeSource.NeoxaTicker ticker = new ObjectMapper().readValue(TICKER, ExchangeSource.NeoxaTicker.class);
+ Assertions.assertEquals(737.02, ticker.ticker.lastPrice);
+ }
+
+ @Test
+ public void testCandlesParseToDailyCloses() throws Exception {
+ ExchangeSource.NeoxaCandles candles = new ObjectMapper().readValue(CANDLES, ExchangeSource.NeoxaCandles.class);
+ Date start = new Date(1788220800L * 1000);
+ Date end = new Date(1788393600L * 1000);
+
+ Map rates = ExchangeSource.toDailyRates(candles, start, end);
+ Assertions.assertEquals(3, rates.size());
+ Assertions.assertEquals(90.0, rates.get(DateUtils.truncate(start, Calendar.DAY_OF_MONTH)));
+ Assertions.assertEquals(280.0, rates.get(DateUtils.truncate(end, Calendar.DAY_OF_MONTH)));
+
+ //Candles outside the requested range are left out
+ Map lastDay = ExchangeSource.toDailyRates(candles, end, end);
+ Assertions.assertEquals(1, lastDay.size());
+ }
+
+ @Test
+ public void testConversionUsesOnlyTheRatio() {
+ Map btcRates = Map.of(USD, 80000.0, EUR, 72000.0);
+ Assertions.assertEquals(663.318, ExchangeSource.convertFromUsd(737.02, btcRates, EUR), 0.0001);
+
+ //Doubling the SHA256d chain's price in every currency changes nothing
+ Map doubled = Map.of(USD, 160000.0, EUR, 144000.0);
+ Assertions.assertEquals(ExchangeSource.convertFromUsd(737.02, btcRates, EUR), ExchangeSource.convertFromUsd(737.02, doubled, EUR), 0.0001);
+ }
+
+ @Test
+ public void testConversionNeedsBothCurrencies() {
+ Assertions.assertNull(ExchangeSource.convertFromUsd(737.02, Map.of(EUR, 72000.0), EUR));
+ Assertions.assertNull(ExchangeSource.convertFromUsd(737.02, Map.of(USD, 80000.0), EUR));
+ Assertions.assertNull(ExchangeSource.convertFromUsd(737.02, Map.of(USD, 0.0, EUR, 72000.0), EUR));
+ Assertions.assertNull(ExchangeSource.convertFromUsd(null, Map.of(USD, 80000.0, EUR, 72000.0), EUR));
+ Assertions.assertNull(ExchangeSource.convertFromUsd(737.02, Map.of(USD, 80000.0, EUR, -1.0), EUR));
+ }
+
+ @Test
+ public void testHistoricalConversionSkipsDaysWithoutBothRates() {
+ Date day1 = new Date(1788220800L * 1000);
+ Date day2 = new Date(1788307200L * 1000);
+ Map usdRates = new LinkedHashMap<>();
+ usdRates.put(day1, 100.0);
+ usdRates.put(day2, 200.0);
+
+ Map converted = ExchangeSource.convertFromUsd(usdRates, Map.of(day1, 90000.0, day2, 45000.0), Map.of(day1, 100000.0));
+ Assertions.assertEquals(1, converted.size());
+ Assertions.assertEquals(90.0, converted.get(day1), 0.0001);
+
+ //mempool.space marks a day it has no rate for with -1
+ Map unknown = ExchangeSource.convertFromUsd(usdRates, Map.of(day1, -1.0, day2, -1.0), Map.of(day1, 100000.0, day2, 100000.0));
+ Assertions.assertTrue(unknown.isEmpty());
+ }
+
+ @Test
+ public void testCurrencyCodesAreValidated() {
+ Map rates = new LinkedHashMap<>();
+ rates.put("usd", 80000.0);
+ rates.put("EUR", 72000.0);
+ rates.put("xyz", 1.0);
+ rates.put("time", 1788220800.0);
+
+ Map currencyRates = ExchangeSource.toCurrencyRates(rates);
+ Assertions.assertEquals(Map.of(USD, 80000.0, EUR, 72000.0), currencyRates);
+ }
+}
From 382db05328270cfb61c3483bcab27a11f799db6c Mon Sep 17 00:00:00 2001
From: 2rdzy <232089760+2rdzy@users.noreply.github.com>
Date: Fri, 16 Oct 2026 17:45:12 +0200
Subject: [PATCH 2/3] Make Neoxa an opt-in, USD-only source
As asked in review: the default stays None for new and existing installs, and
Neoxa is used only when chosen. Any other stored source still reads back as
None. Neoxa offers USD only, its BTCB2_USDC price; the currency conversion
setting and HistoricalRates are dropped. Exports take Neoxa's daily closes, so
a transaction from before 2026-09-01 has no fiat value.
---
.../sparrowwallet/sparrow/AppServices.java | 2 +-
.../com/sparrowwallet/sparrow/io/Config.java | 20 +--
.../sparrow/io/WalletLabels.java | 26 ++--
.../sparrow/io/WalletTransactions.java | 12 +-
.../sparrow/net/ExchangeSource.java | 143 ++----------------
.../sparrow/net/HistoricalRates.java | 51 -------
.../settings/GeneralSettingsController.java | 14 --
.../terminal/settings/GeneralDialog.java | 15 +-
.../sparrow/settings/general.fxml | 12 --
.../sparrow/io/ExchangeSourceGateTest.java | 56 +++----
.../sparrow/net/HistoricalRatesTest.java | 60 --------
.../sparrow/net/NeoxaExchangeSourceTest.java | 65 ++------
12 files changed, 64 insertions(+), 412 deletions(-)
delete mode 100644 src/main/java/com/sparrowwallet/sparrow/net/HistoricalRates.java
delete mode 100644 src/test/java/com/sparrowwallet/sparrow/net/HistoricalRatesTest.java
diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
index 7c6eae623ee..663189b5e8e 100644
--- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java
+++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
@@ -98,7 +98,7 @@ public class AppServices {
private static final int CONNECTION_DELAY_SECS = 2;
private static final int RATES_DELAY_SECS_DEFAULT = 2;
private static final int RATES_DELAY_SECS_WINDOWS = 5;
- private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.NEOXA;
+ private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO;
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Config.java b/src/main/java/com/sparrowwallet/sparrow/io/Config.java
index 4c2f6b48da0..290884079fd 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/Config.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/Config.java
@@ -39,7 +39,6 @@ public class Config {
private OptimizationStrategy sendOptimizationStrategy;
private Currency fiatCurrency;
private ExchangeSource exchangeSource;
- private ExchangeSource conversionSource;
private boolean loadRecentWallets = true;
private boolean validateDerivationPaths = true;
private boolean groupByAddress = true;
@@ -238,11 +237,11 @@ public boolean isFetchRates() {
return getExchangeSource() != ExchangeSource.NONE;
}
- //Neoxa is the only source that quotes this chain's coin. Every other source quotes the SHA256d chain, which
- //is not what this wallet holds, so a config that names one of them (carried over from before the split, or
- //from Sparrow) asks for a rate and gets Neoxa's. Only a choice of no rate at all is kept as it was.
+ //Neoxa is the only source that quotes this chain's coin, and is opt-in. Every other source quotes the SHA256d
+ //chain, which is not what this wallet holds, so a config that names one of them (carried over from before the
+ //split, or from Sparrow) shows no fiat value rather than the wrong one.
public ExchangeSource getExchangeSource() {
- return exchangeSource == ExchangeSource.NONE ? ExchangeSource.NONE : ExchangeSource.NEOXA;
+ return exchangeSource == ExchangeSource.NEOXA ? ExchangeSource.NEOXA : ExchangeSource.NONE;
}
public void setExchangeSource(ExchangeSource exchangeSource) {
@@ -250,17 +249,6 @@ public void setExchangeSource(ExchangeSource exchangeSource) {
flush();
}
- //The source Neoxa's USD rate is converted to other currencies through. Only the ratio between two of its
- //currencies is used, never its price of a bitcoin.
- public ExchangeSource getConversionSource() {
- return conversionSource != null && ExchangeSource.CONVERSION_SOURCES.contains(conversionSource) ? conversionSource : ExchangeSource.DEFAULT_CONVERSION_SOURCE;
- }
-
- public void setConversionSource(ExchangeSource conversionSource) {
- this.conversionSource = conversionSource;
- flush();
- }
-
public boolean isLoadRecentWallets() {
return loadRecentWallets;
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java b/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java
index c3344cd332a..8f0193aa43c 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java
@@ -15,8 +15,8 @@
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
import com.sparrowwallet.sparrow.net.ExchangeSource;
-import com.sparrowwallet.sparrow.net.HistoricalRates;
import com.sparrowwallet.sparrow.wallet.*;
+import org.apache.commons.lang3.time.DateUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -61,7 +61,7 @@ public WalletModel getWalletModel() {
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
List labels = new ArrayList<>();
- HistoricalRates fiatRates = getFiatRates(walletForms);
+ Map fiatRates = getFiatRates(walletForms);
for(WalletForm exportWalletForm : walletForms) {
Wallet exportWallet = exportWalletForm.getWallet();
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet);
@@ -85,7 +85,7 @@ public void exportWallet(Wallet wallet, OutputStream outputStream, String passwo
labels.add(new TransactionLabel(blkTx.getHashAsString(), blkTx.getLabel(), origin,
txEntry.isConfirming() ? null : blkTx.getHeight(), blkTx.getDate(),
getFee(walletTransactionsEntry.getWallet(), blkTx), txEntry.getValue(),
- getFiatValue(blkTx.getHeight(), blkTx.getDate(), Transaction.SATOSHIS_PER_BITCOIN, fiatRates)));
+ getFiatValue(blkTx.getDate(), Transaction.SATOSHIS_PER_BITCOIN, fiatRates)));
if(txEntry.isConfirming()) {
confirmingTxs.add(blkTx.getHash());
}
@@ -392,10 +392,10 @@ private Long getFee(Wallet wallet, BlockTransaction blockTransaction) {
return fee;
}
- private HistoricalRates getFiatRates(List walletForms) {
+ private Map getFiatRates(List walletForms) {
ExchangeSource exchangeSource = getExchangeSource();
Currency fiatCurrency = getFiatCurrency();
- HistoricalRates fiatRates = new HistoricalRates(Collections.emptyMap(), Collections.emptyMap(), null);
+ Map fiatRates = new HashMap<>();
if(fiatCurrency != null) {
long min = Long.MAX_VALUE;
long max = Long.MIN_VALUE;
@@ -413,7 +413,7 @@ private HistoricalRates getFiatRates(List walletForms) {
}
if(max > min) {
- fiatRates = exchangeSource.getHistoricalRates(fiatCurrency, new Date(min - ONE_DAY), new Date(max));
+ fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(min - ONE_DAY), new Date(max));
}
}
@@ -421,22 +421,22 @@ private HistoricalRates getFiatRates(List walletForms) {
}
private static ExchangeSource getExchangeSource() {
- return Config.get().getExchangeSource() == null ? ExchangeSource.NEOXA : Config.get().getExchangeSource();
+ return Config.get().getExchangeSource() == null ? ExchangeSource.COINGECKO : Config.get().getExchangeSource();
}
private static Currency getFiatCurrency() {
return getExchangeSource() == ExchangeSource.NONE || !AppServices.onlineProperty().get() ? null : Config.get().getFiatCurrency();
}
- private Map getFiatValue(TransactionEntry txEntry, HistoricalRates fiatRates) {
- return getFiatValue(txEntry.getBlockTransaction().getHeight(), txEntry.getBlockTransaction().getDate(), txEntry.getValue(), fiatRates);
+ private Map getFiatValue(TransactionEntry txEntry, Map fiatRates) {
+ return getFiatValue(txEntry.getBlockTransaction().getDate(), txEntry.getValue(), fiatRates);
}
- private Map getFiatValue(BlockTransactionHashIndex ref, HistoricalRates fiatRates) {
- return getFiatValue(ref.getHeight(), ref.getDate(), ref.getValue(), fiatRates);
+ private Map getFiatValue(BlockTransactionHashIndex ref, Map fiatRates) {
+ return getFiatValue(ref.getDate(), ref.getValue(), fiatRates);
}
- private Map getFiatValue(int height, Date date, long value, HistoricalRates fiatRates) {
+ private Map getFiatValue(Date date, long value, Map fiatRates) {
Currency fiatCurrency = getFiatCurrency();
if(fiatCurrency != null) {
Double dayRate = null;
@@ -445,7 +445,7 @@ private Map getFiatValue(int height, Date date, long value
dayRate = AppServices.getFiatCurrencyExchangeRate().getBtcRate();
}
} else {
- dayRate = fiatRates.getRate(height, date);
+ dayRate = fiatRates.get(DateUtils.truncate(date, Calendar.DAY_OF_MONTH));
}
if(dayRate != null) {
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/WalletTransactions.java b/src/main/java/com/sparrowwallet/sparrow/io/WalletTransactions.java
index a85ade580d6..87e82a34a2c 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/WalletTransactions.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/WalletTransactions.java
@@ -11,11 +11,11 @@
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.net.ExchangeSource;
-import com.sparrowwallet.sparrow.net.HistoricalRates;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
+import org.apache.commons.lang3.time.DateUtils;
import java.io.IOException;
import java.io.OutputStream;
@@ -55,17 +55,17 @@ public void exportWallet(Wallet wallet, OutputStream outputStream, String passwo
ExchangeSource exchangeSource = Config.get().getExchangeSource();
if(Config.get().getExchangeSource() == null) {
- exchangeSource = ExchangeSource.NEOXA;
+ exchangeSource = ExchangeSource.COINGECKO;
}
Currency fiatCurrency = (exchangeSource == ExchangeSource.NONE || !AppServices.onlineProperty().get() ? null : Config.get().getFiatCurrency());
- HistoricalRates fiatRates = new HistoricalRates(Collections.emptyMap(), Collections.emptyMap(), null);
+ Map fiatRates = new HashMap<>();
if(fiatCurrency != null && !walletTransactionsEntry.getChildren().isEmpty()) {
LongSummaryStatistics stats = walletTransactionsEntry.getChildren().stream()
.map(entry -> ((TransactionEntry)entry).getBlockTransaction().getDate())
.filter(Objects::nonNull)
.collect(Collectors.summarizingLong(Date::getTime));
- fiatRates = exchangeSource.getHistoricalRates(fiatCurrency, new Date(stats.getMin() - ONE_DAY), new Date(stats.getMax()));
+ fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(stats.getMin() - ONE_DAY), new Date(stats.getMax()));
}
BitcoinUnit bitcoinUnit = Config.get().getBitcoinUnit();
@@ -146,14 +146,14 @@ private String getFiatValue(Double value) {
return format.tableFormatCurrencyValue(value);
}
- private Double getFiatValue(TransactionEntry txEntry, HistoricalRates fiatRates) {
+ private Double getFiatValue(TransactionEntry txEntry, Map fiatRates) {
Double dayRate = null;
if(txEntry.getBlockTransaction().getDate() == null) {
if(AppServices.getFiatCurrencyExchangeRate() != null) {
dayRate = AppServices.getFiatCurrencyExchangeRate().getBtcRate();
}
} else {
- dayRate = fiatRates.getRate(txEntry.getBlockTransaction().getHeight(), txEntry.getBlockTransaction().getDate());
+ dayRate = fiatRates.get(DateUtils.truncate(txEntry.getBlockTransaction().getDate(), Calendar.DAY_OF_MONTH));
}
if(dayRate != null) {
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java b/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java
index 8c92e74b742..8e36bb703be 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java
@@ -3,11 +3,9 @@
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
-import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
-import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.tern.http.client.HttpResponseException;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
@@ -62,11 +60,6 @@ public Double getExchangeRate(Currency currency) {
return null;
}
- @Override
- protected Map getAllExchangeRates() {
- return toCurrencyRates(getRates().data.rates);
- }
-
private CoinbaseRates getRates() {
String url = "https://api.coinbase.com/v2/exchange-rates?currency=BTC";
@@ -151,17 +144,6 @@ public Double getExchangeRate(Currency currency) {
return null;
}
- @Override
- protected Map getAllExchangeRates() {
- Map rates = new LinkedHashMap<>();
- getRates().rates.forEach((code, rate) -> {
- if("fiat".equals(rate.type)) {
- rates.put(code, rate.value);
- }
- });
- return toCurrencyRates(rates);
- }
-
private CoinGeckoRates getRates() {
String url = "https://api.coingecko.com/api/v3/exchange_rates";
@@ -235,11 +217,6 @@ public Double getExchangeRate(Currency currency) {
return null;
}
- @Override
- protected Map getAllExchangeRates() {
- return toCurrencyRates(getRates().rates);
- }
-
private MempoolSpaceRates getRates() {
String url = AppServices.isUsingProxy() ? "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1/prices" : "https://mempool.space/api/v1/prices";
@@ -291,32 +268,19 @@ public Map getHistoricalExchangeRates(Currency currency, Date star
return historicalRates;
}
},
- NEOXA("Neoxa", "Historical rates from Sep 2026") {
- //Neoxa quotes this chain's coin against USDC only. Any other currency is reached through the conversion
- //source, which quotes the SHA256d chain: dividing its price in one currency by its price in USD leaves the
- //rate between the two currencies, and the SHA256d chain's own price cancels out.
+ NEOXA("Neoxa", "USD only, historical rates from Sep 2026") {
+ //Neoxa quotes this chain's coin against USDC only, so USD is the one currency offered
@Override
public List getSupportedCurrencies() {
- //An empty list says the currencies could not be retrieved, and keeps the one the user chose
- List currencies = new ArrayList<>(getConversionSource().getSupportedCurrencies());
- if(!currencies.isEmpty() && !currencies.contains(USD)) {
- currencies.addFirst(USD);
- }
-
- return currencies;
+ return List.of(USD);
}
@Override
public Double getExchangeRate(Currency currency) {
- Double usdRate = getTicker().ticker.lastPrice;
- if(usdRate == null || USD.equals(currency)) {
- return usdRate;
+ if(!USD.equals(currency)) {
+ return null;
}
- return convertFromUsd(usdRate, getConversionSource().getAllExchangeRates(), currency);
- }
-
- private NeoxaTicker getTicker() {
String url = NEOXA_URL + "/api/exchange/ticker/" + NEOXA_PAIR;
if(log.isInfoEnabled()) {
@@ -325,42 +289,23 @@ private NeoxaTicker getTicker() {
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
- return httpClientService.requestJson(url, NeoxaTicker.class, null);
+ return httpClientService.requestJson(url, NeoxaTicker.class, null).ticker.lastPrice;
} catch(Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving currency rates", e);
} else {
log.warn("Error retrieving currency rates (" + e.getMessage() + ")");
}
- return new NeoxaTicker();
+ return null;
}
}
@Override
public Map getHistoricalExchangeRates(Currency currency, Date start, Date end) {
- Map usdRates = getUsdHistoricalExchangeRates(start, end);
- if(USD.equals(currency) || usdRates.isEmpty()) {
- return usdRates;
- }
-
- ExchangeSource conversionSource = getConversionSource();
- return convertFromUsd(usdRates, conversionSource.getHistoricalExchangeRates(currency, start, end), conversionSource.getHistoricalExchangeRates(USD, start, end));
- }
-
- @Override
- public HistoricalRates getHistoricalRates(Currency currency, Date start, Date end) {
- Integer splitHeight = HistoricalRates.getSplitHeight(Network.get());
- ExchangeSource conversionSource = getConversionSource();
- Map btcRates = conversionSource.getHistoricalExchangeRates(currency, start, end);
- Map usdRates = getUsdHistoricalExchangeRates(start, end);
- if(!USD.equals(currency) && !usdRates.isEmpty()) {
- usdRates = convertFromUsd(usdRates, btcRates, conversionSource.getHistoricalExchangeRates(USD, start, end));
+ if(!USD.equals(currency)) {
+ return Collections.emptyMap();
}
- return new HistoricalRates(splitHeight == null ? Collections.emptyMap() : btcRates, usdRates, splitHeight);
- }
-
- private Map getUsdHistoricalExchangeRates(Date start, Date end) {
//The candles endpoint takes a count rather than a range, and returns the most recent ones
long days = ChronoUnit.DAYS.between(start.toInstant(), Instant.now()) + 2;
String url = NEOXA_URL + "/api/exchange/candles/" + NEOXA_PAIR + "?interval=1d&limit=" + Math.max(1, Math.min(days, 10000));
@@ -371,8 +316,7 @@ private Map getUsdHistoricalExchangeRates(Date start, Date end) {
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
- NeoxaCandles neoxaCandles = httpClientService.requestJson(url, NeoxaCandles.class, null);
- return toDailyRates(neoxaCandles, start, end);
+ return toDailyRates(httpClientService.requestJson(url, NeoxaCandles.class, null), start, end);
} catch(Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving historical currency rates", e);
@@ -383,16 +327,8 @@ private Map getUsdHistoricalExchangeRates(Date start, Date end) {
return Collections.emptyMap();
}
-
- @Override
- public ExchangeSource getConversionSource() {
- return Config.get().getConversionSource();
- }
};
- public static final ExchangeSource DEFAULT_CONVERSION_SOURCE = MEMPOOL_SPACE;
- public static final List CONVERSION_SOURCES = List.of(COINBASE, COINGECKO, MEMPOOL_SPACE);
-
private static final Currency USD = Currency.getInstance("USD");
private static final String NEOXA_URL = "https://neoxa.exchange";
private static final String NEOXA_PAIR = "BTCB2_USDC";
@@ -414,65 +350,6 @@ public ExchangeSource getConversionSource() {
public abstract Map getHistoricalExchangeRates(Currency currency, Date start, Date end);
- /**
- * Daily rates for valuing past transactions. Only a source that follows this chain tells a coin made before the
- * split from one made after it, so every other source values them all the same way.
- */
- public HistoricalRates getHistoricalRates(Currency currency, Date start, Date end) {
- return new HistoricalRates(Collections.emptyMap(), getHistoricalExchangeRates(currency, start, end), null);
- }
-
- /**
- * The price of a bitcoin in every currency this source quotes, from a single response so that all of them are
- * taken at the same moment.
- */
- protected Map getAllExchangeRates() {
- return Collections.emptyMap();
- }
-
- /**
- * The source used to turn a USD rate into another currency, or null where the rate is quoted directly.
- */
- public ExchangeSource getConversionSource() {
- return null;
- }
-
- static Map toCurrencyRates(Map rates) {
- Map currencyRates = new LinkedHashMap<>();
- rates.forEach((code, rate) -> {
- String upperCode = code.toUpperCase(Locale.ROOT);
- if(rate != null && isValidISO4217Code(upperCode)) {
- currencyRates.put(Currency.getInstance(upperCode), rate);
- }
- });
-
- return currencyRates;
- }
-
- static Double convertFromUsd(Double usdRate, Map btcRates, Currency currency) {
- Double btcUsd = btcRates.get(USD);
- Double btcCurrency = btcRates.get(currency);
- if(usdRate == null || btcUsd == null || btcCurrency == null || btcUsd <= 0.0 || btcCurrency <= 0.0) {
- return null;
- }
-
- return usdRate * btcCurrency / btcUsd;
- }
-
- static Map convertFromUsd(Map usdRates, Map btcCurrencyRates, Map btcUsdRates) {
- Map rates = new TreeMap<>();
- usdRates.forEach((date, usdRate) -> {
- Double btcCurrency = btcCurrencyRates.get(date);
- Double btcUsd = btcUsdRates.get(date);
- //mempool.space marks a day it has no rate for with -1
- if(usdRate != null && btcCurrency != null && btcUsd != null && btcUsd > 0.0 && btcCurrency > 0.0) {
- rates.put(date, usdRate * btcCurrency / btcUsd);
- }
- });
-
- return rates;
- }
-
static Map toDailyRates(NeoxaCandles neoxaCandles, Date start, Date end) {
Map rates = new TreeMap<>();
for(NeoxaCandle candle : neoxaCandles.candles) {
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/HistoricalRates.java b/src/main/java/com/sparrowwallet/sparrow/net/HistoricalRates.java
deleted file mode 100644
index 4613f80b137..00000000000
--- a/src/main/java/com/sparrowwallet/sparrow/net/HistoricalRates.java
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.sparrowwallet.sparrow.net;
-
-import com.sparrowwallet.drongo.Network;
-import org.apache.commons.lang3.time.DateUtils;
-
-import java.util.Calendar;
-import java.util.Date;
-import java.util.Map;
-
-/**
- * Daily rates for valuing past transactions, split at the block where this chain left the SHA256d one.
- *
- * A transaction confirmed before that block exists on both chains, and at the time it was worth what a bitcoin was,
- * so it takes the SHA256d chain's rate. A transaction confirmed from that block on only exists on this chain, and
- * takes this chain's own rate. Splitting on height rather than date keeps the day of the split right, since blocks
- * on both sides of it were mined that day.
- */
-public class HistoricalRates {
- //The first block that Knots follows and the SHA256d chain does not
- private static final int MAINNET_SPLIT_HEIGHT = 961632;
-
- private final Map preSplitRates;
- private final Map postSplitRates;
- private final Integer splitHeight;
-
- public HistoricalRates(Map preSplitRates, Map postSplitRates, Integer splitHeight) {
- this.preSplitRates = preSplitRates;
- this.postSplitRates = postSplitRates;
- this.splitHeight = splitHeight;
- }
-
- /**
- * The height of the first block not shared with the SHA256d chain, or null where this network has no such split.
- */
- public static Integer getSplitHeight(Network network) {
- return network == Network.MAINNET ? MAINNET_SPLIT_HEIGHT : null;
- }
-
- /**
- * The rate on the day a transaction confirmed, or null where it has no date or no rate is known for that day.
- */
- public Double getRate(Integer height, Date date) {
- if(date == null) {
- return null;
- }
-
- Date day = DateUtils.truncate(date, Calendar.DAY_OF_MONTH);
- boolean preSplit = splitHeight != null && height != null && height > 0 && height < splitHeight;
- return preSplit ? preSplitRates.get(day) : postSplitRates.get(day);
- }
-}
diff --git a/src/main/java/com/sparrowwallet/sparrow/settings/GeneralSettingsController.java b/src/main/java/com/sparrowwallet/sparrow/settings/GeneralSettingsController.java
index 06084ee197f..1764ea5f0b4 100644
--- a/src/main/java/com/sparrowwallet/sparrow/settings/GeneralSettingsController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/settings/GeneralSettingsController.java
@@ -49,9 +49,6 @@ public class GeneralSettingsController extends SettingsDetailController {
@FXML
private ComboBox exchangeSource;
- @FXML
- private ComboBox conversionSource;
-
@FXML
private Label currenciesLoadWarning;
@@ -152,20 +149,9 @@ public void initializeView(Config config) {
exchangeSource.valueProperty().addListener((observable, oldValue, source) -> {
config.setExchangeSource(source);
- conversionSource.setDisable(source == ExchangeSource.NONE);
updateCurrencies(source);
});
- conversionSource.setValue(config.getConversionSource());
- conversionSource.setDisable(config.getExchangeSource() == ExchangeSource.NONE);
- conversionSource.setButtonCell(new ExchangeSourceButtonCell());
- conversionSource.setCellFactory(_ -> new ExchangeSourceButtonCell());
-
- conversionSource.valueProperty().addListener((observable, oldValue, source) -> {
- config.setConversionSource(source);
- updateCurrencies(exchangeSource.getValue());
- });
-
updateCurrencies(exchangeSource.getSelectionModel().getSelectedItem());
loadRecentWallets.setSelected(config.isLoadRecentWallets());
diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/settings/GeneralDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/settings/GeneralDialog.java
index 291c13f561b..aebcd680697 100644
--- a/src/main/java/com/sparrowwallet/sparrow/terminal/settings/GeneralDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/terminal/settings/GeneralDialog.java
@@ -27,7 +27,6 @@ public class GeneralDialog extends DialogWindow {
private final ComboBox fiatCurrency;
private final ComboBox exchangeSource;
- private final ComboBox conversionSource;
private final ComboBox.Listener fiatCurrencyListener = new ComboBox.Listener() {
@Override
@@ -69,19 +68,13 @@ public GeneralDialog() {
exchangeSource = new ComboBox<>(ExchangeSource.NONE, ExchangeSource.NEOXA);
mainPanel.addComponent(exchangeSource);
- mainPanel.addComponent(new Label("Currency conversion"));
- conversionSource = new ComboBox<>(ExchangeSource.CONVERSION_SOURCES);
- mainPanel.addComponent(conversionSource);
-
bitcoinUnit.setSelectedItem(Config.get().getBitcoinUnit() == null ? BitcoinUnit.AUTO : Config.get().getBitcoinUnit());
unitFormat.setSelectedIndex(Config.get().getUnitFormat() == UnitFormat.COMMA ? 1 : 0);
if(Config.get().getExchangeSource() == null) {
- Config.get().setExchangeSource(ExchangeSource.NEOXA);
+ Config.get().setExchangeSource(ExchangeSource.NONE);
}
exchangeSource.setSelectedItem(Config.get().getExchangeSource());
- conversionSource.setSelectedItem(Config.get().getConversionSource());
- conversionSource.setEnabled(Config.get().getExchangeSource() != ExchangeSource.NONE);
bitcoinUnit.addListener((int selectedIndex, int previousSelection, boolean changedByUserInteraction) -> {
BitcoinUnit newValue = bitcoinUnit.getSelectedItem();
@@ -102,15 +95,9 @@ public GeneralDialog() {
exchangeSource.addListener((int selectedIndex, int previousSelection, boolean changedByUserInteraction) -> {
ExchangeSource source = exchangeSource.getSelectedItem();
Config.get().setExchangeSource(source);
- conversionSource.setEnabled(source != ExchangeSource.NONE);
updateCurrencies(source);
});
- conversionSource.addListener((int selectedIndex, int previousSelection, boolean changedByUserInteraction) -> {
- Config.get().setConversionSource(conversionSource.getSelectedItem());
- updateCurrencies(exchangeSource.getSelectedItem());
- });
-
updateCurrencies(exchangeSource.getSelectedItem());
Panel buttonPanel = new Panel();
diff --git a/src/main/resources/com/sparrowwallet/sparrow/settings/general.fxml b/src/main/resources/com/sparrowwallet/sparrow/settings/general.fxml
index b9bd86f3865..0de251ba568 100644
--- a/src/main/resources/com/sparrowwallet/sparrow/settings/general.fxml
+++ b/src/main/resources/com/sparrowwallet/sparrow/settings/general.fxml
@@ -66,18 +66,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java b/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java
index 81d1461eff0..ed798fb3fbd 100644
--- a/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java
+++ b/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java
@@ -9,71 +9,49 @@
import java.lang.reflect.Field;
/**
- * Neoxa is the only source that quotes this chain's coin. Every other exchange source quotes the SHA256d chain, which
- * is not what this wallet holds, and using one of them overstated every fiat value by a couple of hundred times.
- * Every display site and the rates service decide which source to use by reading Config.getExchangeSource(), so this
- * pins the one place the decision is made.
+ * Neoxa is the only source that quotes this chain's coin, and is opt-in. Every other exchange source quotes the
+ * SHA256d chain, which is not what this wallet holds, and using one of them overstated every fiat value by a couple
+ * of hundred times. Every display site and the rates service decide which source to use by reading
+ * Config.getExchangeSource(), so this pins the one place the decision is made.
*/
public class ExchangeSourceGateTest {
private ExchangeSource storedExchangeSource;
- private ExchangeSource storedConversionSource;
@BeforeEach
public void saveConfig() throws ReflectiveOperationException {
- storedExchangeSource = (ExchangeSource)getField("exchangeSource").get(Config.get());
- storedConversionSource = (ExchangeSource)getField("conversionSource").get(Config.get());
+ Field field = Config.class.getDeclaredField("exchangeSource");
+ field.setAccessible(true);
+ storedExchangeSource = (ExchangeSource)field.get(Config.get());
}
@AfterEach
public void restoreConfig() {
Config.get().setExchangeSource(storedExchangeSource);
- Config.get().setConversionSource(storedConversionSource);
}
@Test
- public void testNeoxaIsUsedByDefault() {
+ public void testNoExchangeSourceByDefault() {
Config.get().setExchangeSource(null);
- Assertions.assertEquals(ExchangeSource.NEOXA, Config.get().getExchangeSource());
- Assertions.assertTrue(Config.get().isFetchRates());
+ Assertions.assertEquals(ExchangeSource.NONE, Config.get().getExchangeSource());
+ Assertions.assertFalse(Config.get().isFetchRates(), "rates would be fetched and displayed");
}
@Test
public void testAStoredSha256dSourceDoesNotReopenIt() {
//A config carried over from before the split, or from Sparrow, must not bring the wrong rate back with it
- for(ExchangeSource stored : ExchangeSource.CONVERSION_SOURCES) {
+ for(ExchangeSource stored : new ExchangeSource[] {ExchangeSource.COINBASE, ExchangeSource.COINGECKO, ExchangeSource.MEMPOOL_SPACE}) {
Config.get().setExchangeSource(stored);
- Assertions.assertEquals(ExchangeSource.NEOXA, Config.get().getExchangeSource(), stored + " was used for the rate");
+ Assertions.assertEquals(ExchangeSource.NONE, Config.get().getExchangeSource(), stored + " was used for the rate");
+ Assertions.assertFalse(Config.get().isFetchRates());
}
}
@Test
- public void testNoneIsKept() {
- Config.get().setExchangeSource(ExchangeSource.NONE);
+ public void testNeoxaIsUsedWhenChosen() {
+ Config.get().setExchangeSource(ExchangeSource.NEOXA);
- Assertions.assertEquals(ExchangeSource.NONE, Config.get().getExchangeSource());
- Assertions.assertFalse(Config.get().isFetchRates());
- }
-
- @Test
- public void testConversionSourceDefaultsToMempoolSpace() {
- Config.get().setConversionSource(null);
- Assertions.assertEquals(ExchangeSource.MEMPOOL_SPACE, Config.get().getConversionSource());
-
- //Neither of these quotes a bitcoin in more than one currency, so neither can convert
- Config.get().setConversionSource(ExchangeSource.NEOXA);
- Assertions.assertEquals(ExchangeSource.MEMPOOL_SPACE, Config.get().getConversionSource());
- Config.get().setConversionSource(ExchangeSource.NONE);
- Assertions.assertEquals(ExchangeSource.MEMPOOL_SPACE, Config.get().getConversionSource());
-
- Config.get().setConversionSource(ExchangeSource.COINBASE);
- Assertions.assertEquals(ExchangeSource.COINBASE, Config.get().getConversionSource());
- Assertions.assertEquals(ExchangeSource.COINBASE, ExchangeSource.NEOXA.getConversionSource());
- }
-
- private static Field getField(String name) throws NoSuchFieldException {
- Field field = Config.class.getDeclaredField(name);
- field.setAccessible(true);
- return field;
+ Assertions.assertEquals(ExchangeSource.NEOXA, Config.get().getExchangeSource());
+ Assertions.assertTrue(Config.get().isFetchRates());
}
}
diff --git a/src/test/java/com/sparrowwallet/sparrow/net/HistoricalRatesTest.java b/src/test/java/com/sparrowwallet/sparrow/net/HistoricalRatesTest.java
deleted file mode 100644
index 8bce6a06d73..00000000000
--- a/src/test/java/com/sparrowwallet/sparrow/net/HistoricalRatesTest.java
+++ /dev/null
@@ -1,60 +0,0 @@
-package com.sparrowwallet.sparrow.net;
-
-import com.sparrowwallet.drongo.Network;
-import org.apache.commons.lang3.time.DateUtils;
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-import java.util.Calendar;
-import java.util.Date;
-import java.util.GregorianCalendar;
-import java.util.Map;
-
-/**
- * A transaction confirmed before the split exists on both chains and takes the SHA256d chain's rate; one confirmed
- * from the split on takes this chain's. The choice is made on height, since blocks on both sides of the split were
- * mined on the same day.
- */
-public class HistoricalRatesTest {
- private static final Date SPLIT_DAY = DateUtils.truncate(new GregorianCalendar(2026, Calendar.AUGUST, 8, 12, 0).getTime(), Calendar.DAY_OF_MONTH);
-
- private final HistoricalRates rates = new HistoricalRates(Map.of(SPLIT_DAY, 100000.0), Map.of(SPLIT_DAY, 400.0), 961632);
-
- @Test
- public void testSplitHeight() {
- Assertions.assertEquals(961632, HistoricalRates.getSplitHeight(Network.MAINNET));
- Assertions.assertNull(HistoricalRates.getSplitHeight(Network.TESTNET));
- Assertions.assertNull(HistoricalRates.getSplitHeight(Network.REGTEST));
- }
-
- @Test
- public void testSameDayIsSplitByHeight() {
- Date morning = new GregorianCalendar(2026, Calendar.AUGUST, 8, 9, 30).getTime();
- Date evening = new GregorianCalendar(2026, Calendar.AUGUST, 8, 21, 15).getTime();
-
- Assertions.assertEquals(100000.0, rates.getRate(961631, morning));
- Assertions.assertEquals(400.0, rates.getRate(961632, morning));
- Assertions.assertEquals(400.0, rates.getRate(961700, evening));
- }
-
- @Test
- public void testMissingDayHasNoRate() {
- Date later = new GregorianCalendar(2026, Calendar.AUGUST, 20, 12, 0).getTime();
-
- Assertions.assertNull(rates.getRate(961631, later));
- Assertions.assertNull(rates.getRate(962000, later));
- Assertions.assertNull(rates.getRate(962000, null));
- }
-
- @Test
- public void testUnconfirmedTakesThisChainsRate() {
- Assertions.assertEquals(400.0, rates.getRate(0, SPLIT_DAY));
- Assertions.assertEquals(400.0, rates.getRate(-1, SPLIT_DAY));
- }
-
- @Test
- public void testNoSplitTakesThisChainsRate() {
- HistoricalRates noSplit = new HistoricalRates(Map.of(), Map.of(SPLIT_DAY, 400.0), null);
- Assertions.assertEquals(400.0, noSplit.getRate(1000, SPLIT_DAY));
- }
-}
diff --git a/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java b/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java
index 04399984ece..eb518b9a3dc 100644
--- a/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java
+++ b/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java
@@ -8,12 +8,11 @@
import java.util.Calendar;
import java.util.Currency;
import java.util.Date;
-import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
/**
- * Neoxa quotes this chain's coin in USD only. Other currencies come from the conversion source, using only the ratio
- * between its price of a bitcoin in that currency and in USD, so its price of a bitcoin cancels out.
+ * Neoxa quotes this chain's coin against USDC only, so USD is the one currency it offers.
*/
public class NeoxaExchangeSourceTest {
private static final Currency USD = Currency.getInstance("USD");
@@ -27,6 +26,15 @@ public class NeoxaExchangeSourceTest {
"{\"time\":1788307200,\"open\":87,\"high\":299.6,\"low\":80,\"close\":250.5,\"volume\":100.1}," +
"{\"time\":1788393600,\"open\":250.5,\"high\":300,\"low\":240,\"close\":280,\"volume\":50.2}]}";
+ @Test
+ public void testOnlyUsdIsOffered() {
+ Assertions.assertEquals(List.of(USD), ExchangeSource.NEOXA.getSupportedCurrencies());
+
+ //Answered without a request, since there is nothing to ask for
+ Assertions.assertNull(ExchangeSource.NEOXA.getExchangeRate(EUR));
+ Assertions.assertTrue(ExchangeSource.NEOXA.getHistoricalExchangeRates(EUR, new Date(0), new Date()).isEmpty());
+ }
+
@Test
public void testTickerParses() throws Exception {
ExchangeSource.NeoxaTicker ticker = new ObjectMapper().readValue(TICKER, ExchangeSource.NeoxaTicker.class);
@@ -45,55 +53,6 @@ public void testCandlesParseToDailyCloses() throws Exception {
Assertions.assertEquals(280.0, rates.get(DateUtils.truncate(end, Calendar.DAY_OF_MONTH)));
//Candles outside the requested range are left out
- Map lastDay = ExchangeSource.toDailyRates(candles, end, end);
- Assertions.assertEquals(1, lastDay.size());
- }
-
- @Test
- public void testConversionUsesOnlyTheRatio() {
- Map btcRates = Map.of(USD, 80000.0, EUR, 72000.0);
- Assertions.assertEquals(663.318, ExchangeSource.convertFromUsd(737.02, btcRates, EUR), 0.0001);
-
- //Doubling the SHA256d chain's price in every currency changes nothing
- Map doubled = Map.of(USD, 160000.0, EUR, 144000.0);
- Assertions.assertEquals(ExchangeSource.convertFromUsd(737.02, btcRates, EUR), ExchangeSource.convertFromUsd(737.02, doubled, EUR), 0.0001);
- }
-
- @Test
- public void testConversionNeedsBothCurrencies() {
- Assertions.assertNull(ExchangeSource.convertFromUsd(737.02, Map.of(EUR, 72000.0), EUR));
- Assertions.assertNull(ExchangeSource.convertFromUsd(737.02, Map.of(USD, 80000.0), EUR));
- Assertions.assertNull(ExchangeSource.convertFromUsd(737.02, Map.of(USD, 0.0, EUR, 72000.0), EUR));
- Assertions.assertNull(ExchangeSource.convertFromUsd(null, Map.of(USD, 80000.0, EUR, 72000.0), EUR));
- Assertions.assertNull(ExchangeSource.convertFromUsd(737.02, Map.of(USD, 80000.0, EUR, -1.0), EUR));
- }
-
- @Test
- public void testHistoricalConversionSkipsDaysWithoutBothRates() {
- Date day1 = new Date(1788220800L * 1000);
- Date day2 = new Date(1788307200L * 1000);
- Map usdRates = new LinkedHashMap<>();
- usdRates.put(day1, 100.0);
- usdRates.put(day2, 200.0);
-
- Map converted = ExchangeSource.convertFromUsd(usdRates, Map.of(day1, 90000.0, day2, 45000.0), Map.of(day1, 100000.0));
- Assertions.assertEquals(1, converted.size());
- Assertions.assertEquals(90.0, converted.get(day1), 0.0001);
-
- //mempool.space marks a day it has no rate for with -1
- Map unknown = ExchangeSource.convertFromUsd(usdRates, Map.of(day1, -1.0, day2, -1.0), Map.of(day1, 100000.0, day2, 100000.0));
- Assertions.assertTrue(unknown.isEmpty());
- }
-
- @Test
- public void testCurrencyCodesAreValidated() {
- Map rates = new LinkedHashMap<>();
- rates.put("usd", 80000.0);
- rates.put("EUR", 72000.0);
- rates.put("xyz", 1.0);
- rates.put("time", 1788220800.0);
-
- Map currencyRates = ExchangeSource.toCurrencyRates(rates);
- Assertions.assertEquals(Map.of(USD, 80000.0, EUR, 72000.0), currencyRates);
+ Assertions.assertEquals(1, ExchangeSource.toDailyRates(candles, end, end).size());
}
}
From 9967bf9520cb7ca50c425a5ff2f8665f526703b1 Mon Sep 17 00:00:00 2001
From: 2rdzy <232089760+2rdzy@users.noreply.github.com>
Date: Sat, 17 Oct 2026 06:25:52 +0200
Subject: [PATCH 3/3] Key Neoxa closes by UTC date, count candles from Sep 1,
check prices
As asked in review:
- A candle opens at UTC midnight, so truncating it in local time moved every
close to the previous day west of UTC. Each close is now keyed by its UTC
date, at local midnight to match how transaction dates are looked up.
- The candle count was worked out from the export's start date, which told
Neoxa when the wallet's first transaction was. It is now always counted
from 2026-09-01, when Neoxa's data starts, so it depends only on the day.
- Only finite, positive prices are used, from the ticker and the candles.
---
.../sparrow/net/ExchangeSource.java | 35 ++++++++---
.../sparrow/net/NeoxaExchangeSourceTest.java | 63 +++++++++++++++++--
2 files changed, 87 insertions(+), 11 deletions(-)
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java b/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java
index 8e36bb703be..8b0a9b5ab74 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java
@@ -20,7 +20,9 @@
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.Instant;
+import java.time.LocalDate;
import java.time.ZoneId;
+import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@@ -289,7 +291,7 @@ public Double getExchangeRate(Currency currency) {
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
- return httpClientService.requestJson(url, NeoxaTicker.class, null).ticker.lastPrice;
+ return toValidRate(httpClientService.requestJson(url, NeoxaTicker.class, null).ticker.lastPrice);
} catch(Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving currency rates", e);
@@ -306,9 +308,7 @@ public Map getHistoricalExchangeRates(Currency currency, Date star
return Collections.emptyMap();
}
- //The candles endpoint takes a count rather than a range, and returns the most recent ones
- long days = ChronoUnit.DAYS.between(start.toInstant(), Instant.now()) + 2;
- String url = NEOXA_URL + "/api/exchange/candles/" + NEOXA_PAIR + "?interval=1d&limit=" + Math.max(1, Math.min(days, 10000));
+ String url = NEOXA_URL + "/api/exchange/candles/" + NEOXA_PAIR + "?interval=1d&limit=" + getCandlesLimit(Instant.now());
if(log.isInfoEnabled()) {
log.info("Requesting historical exchange rates from " + url);
@@ -332,6 +332,7 @@ public Map getHistoricalExchangeRates(Currency currency, Date star
private static final Currency USD = Currency.getInstance("USD");
private static final String NEOXA_URL = "https://neoxa.exchange";
private static final String NEOXA_PAIR = "BTCB2_USDC";
+ private static final Instant NEOXA_DATA_START = Instant.parse("2026-09-01T00:00:00Z");
private static final Logger log = LoggerFactory.getLogger(ExchangeSource.class);
private static final Map HTTP_HEADERS = Map.of("User-Agent", "Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)", "Accept", "*/*");
@@ -350,18 +351,38 @@ public Map getHistoricalExchangeRates(Currency currency, Date star
public abstract Map getHistoricalExchangeRates(Currency currency, Date start, Date end);
+ //The candles endpoint takes a count of the most recent candles rather than a range. A count worked out from the
+ //export's start date would tell Neoxa when the wallet's first transaction was, so every request asks for all of
+ //Neoxa's data, counted from the day it starts: whole days since then, plus today's candle, plus one spare.
+ static long getCandlesLimit(Instant now) {
+ return ChronoUnit.DAYS.between(NEOXA_DATA_START, now) + 2;
+ }
+
+ //Each candle opens at UTC midnight and covers that UTC day. Its close is keyed by that UTC date, at local midnight
+ //so it matches a transaction's date truncated in local time; truncating the candle's own instant would move every
+ //close a day earlier west of UTC.
static Map toDailyRates(NeoxaCandles neoxaCandles, Date start, Date end) {
+ ZoneId zone = ZoneId.systemDefault();
+ LocalDate startDate = start.toInstant().atZone(zone).toLocalDate();
+ LocalDate endDate = end.toInstant().atZone(zone).toLocalDate();
+
Map rates = new TreeMap<>();
for(NeoxaCandle candle : neoxaCandles.candles) {
- Date date = new Date(candle.time * 1000);
- if(candle.close != null && !date.before(DateUtils.truncate(start, Calendar.DAY_OF_MONTH)) && !date.after(end)) {
- rates.put(DateUtils.truncate(date, Calendar.DAY_OF_MONTH), candle.close);
+ LocalDate day = Instant.ofEpochSecond(candle.time).atZone(ZoneOffset.UTC).toLocalDate();
+ Double close = toValidRate(candle.close);
+ if(close != null && !day.isBefore(startDate) && !day.isAfter(endDate)) {
+ rates.put(Date.from(day.atStartOfDay(zone).toInstant()), close);
}
}
return rates;
}
+ //An infinite price breaks rendering, and a zero or negative one corrupts exports
+ static Double toValidRate(Double rate) {
+ return rate != null && Double.isFinite(rate) && rate > 0.0 ? rate : null;
+ }
+
private static boolean isValidISO4217Code(String code) {
try {
Currency currency = Currency.getInstance(code);
diff --git a/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java b/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java
index eb518b9a3dc..b4da28ea157 100644
--- a/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java
+++ b/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java
@@ -5,11 +5,15 @@
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
import java.util.Calendar;
import java.util.Currency;
import java.util.Date;
import java.util.List;
import java.util.Map;
+import java.util.TimeZone;
/**
* Neoxa quotes this chain's coin against USDC only, so USD is the one currency it offers.
@@ -44,15 +48,66 @@ public void testTickerParses() throws Exception {
@Test
public void testCandlesParseToDailyCloses() throws Exception {
ExchangeSource.NeoxaCandles candles = new ObjectMapper().readValue(CANDLES, ExchangeSource.NeoxaCandles.class);
- Date start = new Date(1788220800L * 1000);
- Date end = new Date(1788393600L * 1000);
+ Date start = localMidnight(LocalDate.of(2026, 9, 1));
+ Date end = localMidnight(LocalDate.of(2026, 9, 3));
Map rates = ExchangeSource.toDailyRates(candles, start, end);
Assertions.assertEquals(3, rates.size());
- Assertions.assertEquals(90.0, rates.get(DateUtils.truncate(start, Calendar.DAY_OF_MONTH)));
- Assertions.assertEquals(280.0, rates.get(DateUtils.truncate(end, Calendar.DAY_OF_MONTH)));
+ Assertions.assertEquals(90.0, rates.get(localMidnight(LocalDate.of(2026, 9, 1))));
+ Assertions.assertEquals(280.0, rates.get(localMidnight(LocalDate.of(2026, 9, 3))));
//Candles outside the requested range are left out
Assertions.assertEquals(1, ExchangeSource.toDailyRates(candles, end, end).size());
}
+
+ @Test
+ public void testCandlesAreKeyedByTheirUtcDateInEveryZone() throws Exception {
+ //A candle opens at UTC midnight. West of UTC that instant falls on the previous local day, and truncating it
+ //there would give a transaction on 1 Sep the close of 31 Aug's candle, or none at all
+ ExchangeSource.NeoxaCandles candles = new ObjectMapper().readValue(CANDLES, ExchangeSource.NeoxaCandles.class);
+ TimeZone defaultZone = TimeZone.getDefault();
+ try {
+ for(String zone : new String[] {"America/Los_Angeles", "UTC", "Europe/Madrid", "Pacific/Auckland"}) {
+ TimeZone.setDefault(TimeZone.getTimeZone(zone));
+ Date txDate = Date.from(LocalDate.of(2026, 9, 2).atTime(12, 0).atZone(ZoneId.systemDefault()).toInstant());
+ Map rates = ExchangeSource.toDailyRates(candles, localMidnight(LocalDate.of(2026, 8, 31)), localMidnight(LocalDate.of(2026, 9, 4)));
+
+ Assertions.assertEquals(250.5, rates.get(DateUtils.truncate(txDate, Calendar.DAY_OF_MONTH)), zone);
+ }
+ } finally {
+ TimeZone.setDefault(defaultZone);
+ }
+ }
+
+ @Test
+ public void testCandlesLimitCoversAllDataAndNothingElse() {
+ //The count depends only on today, never on the wallet, so it cannot tell Neoxa when its first transaction was.
+ //On 17 Oct 46 whole days have passed since 1 Sep, and 47 candles exist, 1 Sep to 17 Oct
+ Assertions.assertEquals(48, ExchangeSource.getCandlesLimit(Instant.parse("2026-10-17T12:00:00Z")));
+ Assertions.assertEquals(2, ExchangeSource.getCandlesLimit(Instant.parse("2026-09-01T08:00:00Z")));
+ }
+
+ @Test
+ public void testOnlyFinitePositivePricesAreUsed() {
+ Assertions.assertEquals(737.02, ExchangeSource.toValidRate(737.02));
+ Assertions.assertNull(ExchangeSource.toValidRate(null));
+ Assertions.assertNull(ExchangeSource.toValidRate(0.0));
+ Assertions.assertNull(ExchangeSource.toValidRate(-1.0));
+ Assertions.assertNull(ExchangeSource.toValidRate(Double.POSITIVE_INFINITY));
+ Assertions.assertNull(ExchangeSource.toValidRate(Double.NaN));
+ }
+
+ @Test
+ public void testInvalidClosesAreLeftOut() throws Exception {
+ //1e400 does not fit in a double and parses as Infinity
+ String candlesJson = "{\"candles\":[{\"time\":1788220800,\"close\":1e400},{\"time\":1788307200,\"close\":0},{\"time\":1788393600,\"close\":280}]}";
+ ExchangeSource.NeoxaCandles candles = new ObjectMapper().readValue(candlesJson, ExchangeSource.NeoxaCandles.class);
+
+ Map rates = ExchangeSource.toDailyRates(candles, localMidnight(LocalDate.of(2026, 9, 1)), localMidnight(LocalDate.of(2026, 9, 3)));
+ Assertions.assertEquals(Map.of(localMidnight(LocalDate.of(2026, 9, 3)), 280.0), rates);
+ }
+
+ private static Date localMidnight(LocalDate date) {
+ return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
+ }
}