diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Config.java b/src/main/java/com/sparrowwallet/sparrow/io/Config.java index 83de81ba773..290884079fd 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Config.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Config.java @@ -237,10 +237,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, 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.NONE; + return exchangeSource == ExchangeSource.NEOXA ? ExchangeSource.NEOXA : ExchangeSource.NONE; } public void setExchangeSource(ExchangeSource exchangeSource) { diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java b/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java index e025b984f2f..8b0a9b5ab74 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.SparrowWallet; import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent; @@ -19,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; @@ -266,8 +269,71 @@ public Map getHistoricalExchangeRates(Currency currency, Date star return historicalRates; } + }, + 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() { + return List.of(USD); + } + + @Override + public Double getExchangeRate(Currency currency) { + if(!USD.equals(currency)) { + return null; + } + + 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 toValidRate(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 null; + } + } + + @Override + public Map getHistoricalExchangeRates(Currency currency, Date start, Date end) { + if(!USD.equals(currency)) { + return Collections.emptyMap(); + } + + 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); + } + + HttpClientService httpClientService = AppServices.getHttpClientService(); + try { + return toDailyRates(httpClientService.requestJson(url, NeoxaCandles.class, null), 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(); + } }; + 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", "*/*"); @@ -285,6 +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) { + 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); @@ -403,4 +501,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/settings/GeneralSettingsController.java b/src/main/java/com/sparrowwallet/sparrow/settings/GeneralSettingsController.java index 7a5566b644d..1764ea5f0b4 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; @@ -143,12 +142,7 @@ 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()); @@ -160,11 +154,6 @@ public void initializeView(Config config) { updateCurrencies(exchangeSource.getSelectionModel().getSelectedItem()); - //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.")); - loadRecentWallets.setSelected(config.isLoadRecentWallets()); loadRecentWallets.selectedProperty().addListener((observableValue, oldValue, newValue) -> { config.setLoadRecentWallets(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..aebcd680697 100644 --- a/src/main/java/com/sparrowwallet/sparrow/terminal/settings/GeneralDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/terminal/settings/GeneralDialog.java @@ -65,14 +65,14 @@ 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); 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.NONE); } exchangeSource.setSelectedItem(Config.get().getExchangeSource()); diff --git a/src/main/resources/com/sparrowwallet/sparrow/settings/general.fxml b/src/main/resources/com/sparrowwallet/sparrow/settings/general.fxml index 19a2186f12e..0de251ba568 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 @@ - - - + diff --git a/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java b/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java index ad9ce6c8ea9..ed798fb3fbd 100644 --- a/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java +++ b/src/test/java/com/sparrowwallet/sparrow/io/ExchangeSourceGateTest.java @@ -1,29 +1,57 @@ 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 + * 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; + + @BeforeEach + public void saveConfig() throws ReflectiveOperationException { + Field field = Config.class.getDeclaredField("exchangeSource"); + field.setAccessible(true); + storedExchangeSource = (ExchangeSource)field.get(Config.get()); + } + + @AfterEach + public void restoreConfig() { + Config.get().setExchangeSource(storedExchangeSource); + } + @Test - public void testNoExchangeSourceIsUsed() { + public void testNoExchangeSourceByDefault() { + Config.get().setExchangeSource(null); + Assertions.assertEquals(ExchangeSource.NONE, Config.get().getExchangeSource()); Assertions.assertFalse(Config.get().isFetchRates(), "rates would be fetched and displayed"); } @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 testAStoredSha256dSourceDoesNotReopenIt() { + //A config carried over from before the split, or from Sparrow, must not bring the wrong rate back with it + for(ExchangeSource stored : new ExchangeSource[] {ExchangeSource.COINBASE, ExchangeSource.COINGECKO, ExchangeSource.MEMPOOL_SPACE}) { + Config.get().setExchangeSource(stored); + Assertions.assertEquals(ExchangeSource.NONE, Config.get().getExchangeSource(), stored + " was used for the rate"); + Assertions.assertFalse(Config.get().isFetchRates()); + } + } - Assertions.assertEquals(ExchangeSource.NONE, Config.get().getExchangeSource()); - Assertions.assertFalse(Config.get().isFetchRates()); + @Test + public void testNeoxaIsUsedWhenChosen() { + Config.get().setExchangeSource(ExchangeSource.NEOXA); + + Assertions.assertEquals(ExchangeSource.NEOXA, Config.get().getExchangeSource()); + Assertions.assertTrue(Config.get().isFetchRates()); } } 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..b4da28ea157 --- /dev/null +++ b/src/test/java/com/sparrowwallet/sparrow/net/NeoxaExchangeSourceTest.java @@ -0,0 +1,113 @@ +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.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. + */ +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 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); + 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 = 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(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()); + } +}