Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/main/java/com/sparrowwallet/sparrow/io/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
119 changes: 119 additions & 0 deletions src/main/java/com/sparrowwallet/sparrow/net/ExchangeSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -266,8 +269,71 @@ public Map<Date, Double> 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<Currency> 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<Date, Double> 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<String, String> HTTP_HEADERS = Map.of("User-Agent", "Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)", "Accept", "*/*");

Expand All @@ -285,6 +351,38 @@ public Map<Date, Double> getHistoricalExchangeRates(Currency currency, Date star

public abstract Map<Date, Double> 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<Date, Double> 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<Date, Double> 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);
Expand Down Expand Up @@ -403,4 +501,25 @@ public void setPrice(String name, Double value) {
private static class MempoolSpaceHistoricalRates {
public List<MempoolSpaceRates> 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<NeoxaCandle> candles = new ArrayList<>();
}

@JsonIgnoreProperties(ignoreUnknown = true)
static class NeoxaCandle {
public long time;
public Double close;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,7 @@
<items>
<FXCollections fx:factory="observableArrayList">
<ExchangeSource fx:constant="NONE" />
<ExchangeSource fx:constant="COINBASE" />
<ExchangeSource fx:constant="COINGECKO" />
<ExchangeSource fx:constant="MEMPOOL_SPACE" />
<ExchangeSource fx:constant="NEOXA" />
</FXCollections>
</items>
</ComboBox>
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading