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 + + + + + + + + + + + +
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 - - - - - - - - - - - -
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()); + } }