Skip to content

Show a fiat value again, from Neoxa's rate for this chain - #81

Merged
kwsantiago merged 3 commits into
privkeyio:masterfrom
2rdzy:neoxa-exchange-rate
Oct 17, 2026
Merged

kwsantiago merged 3 commits into
privkeyio:masterfrom
2rdzy:neoxa-exchange-rate

Conversation

@2rdzy

@2rdzy 2rdzy commented Oct 15, 2026 •

Copy link
Copy Markdown

Summary

Adds Neoxa, which quotes BTCB2 against USDC, as an opt-in exchange rate source. With it chosen, a USD value is shown again. #39 turned fiat off because every source quoted the SHA256d chain, which overstated balances by a couple of hundred times. The default stays None for new and existing installs. Closes #55.

Problem

Config.getExchangeSource() returns NONE unconditionally (src/main/java/com/sparrowwallet/sparrow/io/Config.java:243 on f2a00271), and the source dropdown is disabled with a tooltip (src/main/java/com/sparrowwallet/sparrow/settings/GeneralSettingsController.java:165-166). No source quoted this chain's coin: ExchangeSource only offers Coinbase, Coingecko and mempool.space, all SHA256d.

Reproduction

On f2a00271, open Settings → General. The exchange rate source and currency are disabled, and no fiat value can be turned on.

Neoxa's public API, no authentication:

GET https://neoxa.exchange/api/exchange/ticker/BTCB2_USDC                       -> {"ticker":{"lastPrice":...}}
GET https://neoxa.exchange/api/exchange/candles/BTCB2_USDC?interval=1d&limit=N  -> daily candles from 2026-09-01

Before

Exchange rate source: [disabled] "No exchange rate is available yet, so no fiat value is shown."
Currency:             [disabled, empty]

Change

  • ExchangeSource.NEOXA supports USD only. The current rate is the ticker's lastPrice, and historical rates are daily candle closes. Any other currency gets no rate, without a request.
  • Config.getExchangeSource() returns NEOXA only if Neoxa was chosen, and NONE otherwise. That includes no choice and a stored Coinbase, Coingecko or mempool.space choice.
  • Settings (desktop and terminal): the exchange rate source offers None and Neoxa. The disable and the tooltip from fiat: stop using the SHA256d chain's exchange rate #39 are removed. The desktop default no longer selects by list index.
  • Exports use Neoxa's daily closes. A transaction from before 2026-09-01 has no fiat value.
  • Each close is keyed by its candle's UTC date, so a transaction gets its own UTC day's close in every time zone. Truncating the candle's UTC-midnight instant in local time would move every close to the previous local day west of UTC.
  • The candles count is always worked out from 2026-09-01, when Neoxa's data starts, not from the export's start date, which told Neoxa when the wallet's first transaction was. Every wallet sends the same count on a given day: whole days since 2026-09-01, plus today's candle, plus one spare.
  • Only finite, positive prices are used, from both the ticker and the candles. Infinity breaks rendering, and a zero or negative price corrupts exports.

After

Live, through the app's HTTP client, on 2026-10-17:

USD rate                           649.37
USD history from 2026-08-06        47 days, first 2026-09-01 = 90.0
EUR                                null, no request made

Test results

  • Base f2a00271. JDK Temurin 25.0.4.1+1. ./gradlew clean test --continue -Djava.awt.headless=true.
  • Before: shrike 591, drongo 705, lark 24. All pass, 0 skipped.
  • After: shrike 599, drongo 705, lark 24. All pass, 0 skipped.
    • ExchangeSourceGateTest: the 2 old tests were replaced by 3 (None by default, stored SHA256d sources read as None, Neoxa when chosen).
    • New NeoxaExchangeSourceTest (7): USD only, ticker parsing, daily closes from candles, closes keyed by UTC date in four time zones (fails with local truncation), the candles count, the finite positive check, and invalid closes left out.
  • HeaderCheckpointVerificationTest (4 tests, tag checkpoint) is excluded by build.gradle in both runs. It needs live Electrum servers and runs through verifyCheckpoint.

Caveats

  • On a fresh install the currency dropdown stays empty while the mode is offline, because the currencies request is skipped in offline mode (upstream b91f7993). It fills once a server is connected and settings are reopened.

Every exchange source quoted the SHA256d chain, so privkeyio#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 privkeyio#55
@kwsantiago

Copy link
Copy Markdown
Collaborator

Thanks, this works. Please slim it to Neoxa as an opt-in USD-only source: keep the default at None for new and existing installs, and drop the conversion source and HistoricalRates. Older transactions without Neoxa data showing no fiat is fine.

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.
@2rdzy

2rdzy commented Oct 16, 2026

Copy link
Copy Markdown
Author

Done: Neoxa is now opt-in and USD only, default None; the conversion source and HistoricalRates are gone.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Candles are UTC midnight; local truncation shifts every close a day west of UTC. Key by the UTC date.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9967bf9: each close is now keyed by its candle's UTC date, at local midnight to match how transaction dates are looked up. A test checks four time zones and fails with the old truncation.

}

//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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This limit reveals the wallet's first transaction date to Neoxa. Use a fixed limit; data starts Sep 2026.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9967bf9: the count is now always worked out from 2026-09-01, when Neoxa's data starts, so it depends only on the day and not on the wallet.


HttpClientService httpClientService = AppServices.getHttpClientService();
try {
return httpClientService.requestJson(url, NeoxaTicker.class, null).ticker.lastPrice;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accept only finite, positive prices here and for candle closes; Infinity breaks rendering, zero or negative corrupts exports.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9967bf9: toValidRate keeps only finite, positive prices, for the ticker and every candle close.

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.
@kwsantiago
kwsantiago merged commit c05faa7 into privkeyio:master Oct 17, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an exchange rate source and show a fiat value again

2 participants