Goblin: fetch fiat rate live on view, kill the 48h price cache

The fiat subline now fetches the CURRENT rate over Tor only while the
balance is actually on screen, and never paints a stale number as if it
were live. No disk cache, no background timer: an idle or payment-
listening wallet never polls (battery).

- price.rs: in-session rate held for a 3-minute freshness window; viewing
  a stale/missing rate kicks a live refetch. New RateState (Fresh/Loading/
  Unavailable) replaces the Option<f64>; failures are tracked so the line
  says "unavailable" instead of spinning or showing an old value. Removed
  the disk seed and the persisted-rate write.
- config.rs: dropped the dead last_rate/last_rate_vs/last_rate_at fields
  and their read/write paths. Old configs still load (serde ignores the
  unknown keys); the fields are simply not written back.
- lib.rs: dropped the cold-start seed_from_disk call.
- GUI: balance hero fiat line renders a subtle "≈ …" while loading and a
  localized "rate unavailable" on failure, requesting a bounded repaint
  while a fetch is pending so the rate pops in on-screen. Pay/send amount
  preview shows nothing until a fresh rate lands.
- New goblin.home.fiat_unavailable string across all six locales.
- Tests: freshness-window bounds, is_fresh boundary, classify state
  machine (fresh/loading/unavailable), and config compat for the removed
  fields.
This commit is contained in:
2ro
2026-07-05 04:38:24 -04:00
parent ab2ac7c3ac
commit d5d1212a44
13 changed files with 246 additions and 106 deletions
+1
View File
@@ -367,6 +367,7 @@ goblin:
syncing: "Synchronisiere…"
balance_updating: "Guthaben wird aktualisiert…"
balance_stale: "Knoten nicht erreichbar · letzter bekannter Saldo"
fiat_unavailable: "Kurs nicht verfügbar"
listening: "Wartet auf Zahlungen"
block: "Block %{height}"
waiting_for_chain: "Warte auf Chain…"
+1
View File
@@ -367,6 +367,7 @@ goblin:
syncing: "Syncing…"
balance_updating: "Balance updating…"
balance_stale: "Can't reach node · last known balance"
fiat_unavailable: "Rate unavailable"
listening: "Listening for payments"
block: "Block %{height}"
waiting_for_chain: "Waiting for chain…"
+1
View File
@@ -367,6 +367,7 @@ goblin:
syncing: "Synchronisation…"
balance_updating: "Solde en cours de mise à jour…"
balance_stale: "Nœud injoignable · dernier solde connu"
fiat_unavailable: "Taux indisponible"
listening: "En attente de paiements"
block: "Bloc %{height}"
waiting_for_chain: "En attente de la chaîne…"
+1
View File
@@ -367,6 +367,7 @@ goblin:
syncing: "Синхронизация…"
balance_updating: "Баланс обновляется…"
balance_stale: "Узел недоступен · последний известный баланс"
fiat_unavailable: "Курс недоступен"
listening: "Ожидание платежей"
block: "Блок %{height}"
waiting_for_chain: "Ожидание цепочки…"
+1
View File
@@ -367,6 +367,7 @@ goblin:
syncing: "Eşitleniyor…"
balance_updating: "Bakiye güncelleniyor…"
balance_stale: "Düğüme ulaşılamıyor · son bilinen bakiye"
fiat_unavailable: "Kur mevcut değil"
listening: "Ödemeler bekleniyor"
block: "Blok %{height}"
waiting_for_chain: "Zincir bekleniyor…"
+1
View File
@@ -367,6 +367,7 @@ goblin:
syncing: "同步中…"
balance_updating: "余额更新中…"
balance_stale: "无法连接节点 · 上次已知余额"
fiat_unavailable: "汇率不可用"
listening: "正在监听付款"
block: "区块 %{height}"
waiting_for_chain: "等待链数据…"
+35 -17
View File
@@ -1034,7 +1034,7 @@ impl GoblinWalletView {
updating,
error,
wallet.info_sync_progress(),
fiat_line(&data).as_deref(),
fiat_line(&data),
56.0,
);
ui.add_space(20.0);
@@ -1258,7 +1258,7 @@ impl GoblinWalletView {
w::amount_text_centered(ui, &display, 76.0);
}
if let Ok(grin) = display.parse::<f64>() {
if let Some(preview) = pairing_preview(grin) {
if let Some(preview) = pairing_preview(grin, ui.ctx()) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
@@ -5218,20 +5218,29 @@ fn fmt_thousands(n: u64) -> String {
out
}
fn fiat_line(data: &Option<WalletData>) -> Option<String> {
fn fiat_line(data: &Option<WalletData>) -> Option<w::FiatLine> {
use crate::http::RateState;
let p = crate::AppConfig::pairing();
let vs = p.vs_currency()?;
let rate = crate::http::grin_rate(vs)?;
let spendable = data
.as_ref()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
let grin = spendable as f64 / 1_000_000_000.0;
Some(format!(
"{} · 1ツ = {}",
fmt_pairing(grin * rate, p),
fmt_pairing(rate, p)
))
// Asking for the rate here (while the balance is on screen) is what kicks a
// live refetch when the in-session rate has aged out; an idle wallet never
// reaches this path.
Some(match crate::http::grin_rate(vs) {
RateState::Fresh(rate) => {
let spendable = data
.as_ref()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
let grin = spendable as f64 / 1_000_000_000.0;
w::FiatLine::Text(format!(
"{} · 1ツ = {}",
fmt_pairing(grin * rate, p),
fmt_pairing(rate, p)
))
}
RateState::Loading => w::FiatLine::Loading,
RateState::Unavailable => w::FiatLine::Unavailable,
})
}
/// Format a value already in the pairing's unit (dollars, BTC, …) with the
@@ -5256,11 +5265,20 @@ fn fmt_pairing(value: f64, p: crate::settings::Pairing) -> String {
/// The "≈ …" amount preview for the current pairing, or `None` when off / no
/// rate yet. Shared by the Pay screen, the send flow, and the balance hero.
fn pairing_preview(grin: f64) -> Option<String> {
fn pairing_preview(grin: f64, ctx: &egui::Context) -> Option<String> {
use crate::http::RateState;
let p = crate::AppConfig::pairing();
let vs = p.vs_currency()?;
let rate = crate::http::grin_rate(vs)?;
Some(format!("{}", fmt_pairing(grin * rate, p)))
match crate::http::grin_rate(vs) {
RateState::Fresh(rate) => Some(format!("{}", fmt_pairing(grin * rate, p))),
// No stale fallback: show nothing until a fresh rate lands. Nudge a repaint
// while loading so the preview appears once the live fetch returns.
RateState::Loading => {
ctx.request_repaint_after(std::time::Duration::from_millis(300));
None
}
RateState::Unavailable => None,
}
}
/// Convert a bech32 npub to hex for short display fallbacks.
+1 -1
View File
@@ -1046,7 +1046,7 @@ impl SendFlow {
w::amount_text_centered(ui, &display, 64.0);
}
if let Ok(grin) = display.parse::<f64>() {
if let Some(preview) = super::pairing_preview(grin) {
if let Some(preview) = super::pairing_preview(grin, ui.ctx()) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
+27 -2
View File
@@ -497,6 +497,18 @@ pub fn balance_subline(total: u64, updating: bool, error: bool) -> BalanceSublin
}
}
/// What the fiat subline should render under the balance. `None` (pairing off)
/// draws no line at all; otherwise the line is honest about its state and never
/// paints a stale rate as if current.
pub enum FiatLine {
/// A ready "≈ … · 1ツ = …" line built from a fresh rate.
Text(String),
/// A live fetch is in flight; show a subtle placeholder, not a number.
Loading,
/// The rate could not be fetched; say so rather than show an old value.
Unavailable,
}
pub fn balance_hero(
ui: &mut Ui,
total: u64,
@@ -504,7 +516,7 @@ pub fn balance_hero(
updating: bool,
error: bool,
sync_pct: u8,
fiat: Option<&str>,
fiat: Option<FiatLine>,
size: f32,
) {
let t = theme::tokens();
@@ -579,10 +591,23 @@ pub fn balance_hero(
BalanceSubline::None => {}
}
if let Some(fiat) = fiat {
// Loading and Unavailable both render a subtle dim line — never a stale
// number. While loading, nudge egui to re-poll so the rate pops in once the
// live fetch lands even if the view is otherwise idle (bounded to the time
// the balance is actually on screen — not a background timer).
let text = match fiat {
FiatLine::Text(s) => s,
FiatLine::Loading => {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(300));
"≈ …".to_string()
}
FiatLine::Unavailable => t!("goblin.home.fiat_unavailable").to_string(),
};
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(fiat)
RichText::new(text)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
+1 -1
View File
@@ -19,4 +19,4 @@ mod release;
pub use release::*;
pub(crate) mod price;
pub use price::grin_rate;
pub use price::{RateState, grin_rate};
+156 -47
View File
@@ -12,11 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! GRIN price preview, cached per currency. Off by default (no pairing → no
//! fetch); only once the user opts into a pairing is the rate fetched — and it
//! goes over the Nym mixnet like everything else, never the clear net. The rate
//! barely moves and CoinGecko's free tier rate-limits frequent polling (the
//! shared exit IP all the more), so it refreshes on a slow cache, not live.
//! GRIN price preview. Off by default (no pairing → no fetch); only once the
//! user opts into a pairing is the rate fetched — and it goes over Tor like
//! everything else, never the clear net.
//!
//! The rate is fetched LIVE, on view: the fiat subline asks for the rate only
//! while the balance is actually on screen, and a fetch is kicked whenever the
//! last one aged past a short freshness window ([`FRESH_SECS`]). There is no
//! disk cache and no background timer — an idle or payment-listening wallet
//! never polls, so it costs nothing on battery. The rate lives in memory for
//! the freshness window so flipping between screens does not refetch, and a
//! fresh session starts blank and fetches on the first view.
use lazy_static::lazy_static;
use parking_lot::RwLock;
@@ -26,17 +32,17 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::AppConfig;
use crate::tor;
/// Cache refresh interval (seconds).
const REFRESH_SECS: i64 = 300;
/// How long an in-memory rate is considered current (seconds). Viewing the
/// balance with a rate older than this kicks a live refetch, and until a fresh
/// rate lands the stale value is NOT painted — the line shows loading, then the
/// new rate (or unavailable on failure). Short enough to track the market, long
/// enough that screen flips within the window reuse the same fetch.
const FRESH_SECS: i64 = 180;
/// Minimum delay between fetch attempts for a currency, so a failing fetch
/// (e.g. no network) does not respawn a thread every frame.
const RETRY_SECS: i64 = 30;
/// How stale a disk-cached rate may be and still be worth painting on cold start
/// (48h). Older than this and we start blank rather than show a very wrong price.
const SEED_MAX_AGE_SECS: i64 = 48 * 3600;
/// Eager-probe per-try timeout. The eager fetch on tunnel-ready doubles as the
/// end-to-end exit probe: a healthy warm fetch is ~800ms, a dead exit hangs until
/// timeout, so a short cap lets us fail fast and condemn a bad exit in seconds.
@@ -47,12 +53,29 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
const PROBE_ATTEMPTS: u32 = 3;
lazy_static! {
/// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at).
/// In-session GRIN rates per `vs_currency`: code -> (rate, fetched_at). Memory
/// only — never persisted, so a fresh session starts empty.
static ref RATES: RwLock<HashMap<String, (f64, i64)>> = RwLock::new(HashMap::new());
/// Currencies with a fetch currently in flight.
static ref FETCHING: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
/// Last fetch attempt per currency (unix secs).
static ref LAST_TRY: RwLock<HashMap<String, i64>> = RwLock::new(HashMap::new());
/// Currencies whose most recent completed attempt failed (with no fresh rate),
/// so the line can honestly say "unavailable" instead of spinning forever.
static ref FAILED: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
}
/// What the fiat line should render for a currency right now.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum RateState {
/// A rate fetched within the freshness window — safe to paint as current.
Fresh(f64),
/// No current rate yet, but a fetch is in flight (or just kicked): show a
/// subtle placeholder, not a stale number.
Loading,
/// The last fetch failed and nothing fresh is available: say so (or hide),
/// never fall back to an old value.
Unavailable,
}
fn now() -> i64 {
@@ -62,22 +85,51 @@ fn now() -> i64 {
.unwrap_or(0)
}
/// Get the cached GRIN rate against `vs` (e.g. "usd", "eur", "btc") if fresh,
/// triggering a background refresh otherwise. Returns `None` until the first
/// successful fetch for that currency.
pub fn grin_rate(vs: &str) -> Option<f64> {
let cached = { RATES.read().get(vs).cloned() };
let needs_refresh = match cached {
Some((_, ts)) => now() - ts > REFRESH_SECS,
None => true,
};
if needs_refresh {
trigger_refresh(vs.to_string());
}
cached.map(|(rate, _)| rate)
/// True if a rate fetched at `fetched_at` is still current as of `now`.
fn is_fresh(fetched_at: i64, now: i64) -> bool {
now - fetched_at <= FRESH_SECS
}
/// Spawn a background refresh for one currency (deduped per code).
/// Pure state decision, factored out for testing: given the (optional) cached
/// rate and the current fetch bookkeeping, decide what to render. A cached rate
/// older than the freshness window is deliberately NOT returned as `Fresh`.
fn classify(cached: Option<(f64, i64)>, now: i64, fetching: bool, failed: bool) -> RateState {
if let Some((rate, ts)) = cached {
if is_fresh(ts, now) {
return RateState::Fresh(rate);
}
}
// No fresh rate. A fetch in flight (or just kicked) reads as loading; a
// recently failed attempt with nothing fresh reads as unavailable.
if fetching {
RateState::Loading
} else if failed {
RateState::Unavailable
} else {
RateState::Loading
}
}
/// Get the render state of the GRIN rate against `vs` (e.g. "usd", "eur",
/// "btc"). Called from the fiat line while the balance is on screen: if the
/// in-session rate is missing or stale it kicks a live refetch over Tor, and it
/// never reports a rate older than the freshness window as current.
pub fn grin_rate(vs: &str) -> RateState {
let cached = { RATES.read().get(vs).cloned() };
let fresh = cached.map(|(_, ts)| is_fresh(ts, now())).unwrap_or(false);
if !fresh {
trigger_refresh(vs.to_string());
}
classify(
cached,
now(),
FETCHING.read().contains(vs),
FAILED.read().contains(vs),
)
}
/// Spawn a background refresh for one currency (deduped per code). Kicked only
/// from a view that is actually asking for the rate — never on a timer.
fn trigger_refresh(vs: String) {
let t = now();
{
@@ -99,33 +151,28 @@ fn trigger_refresh(vs: String) {
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let ok = rt.block_on(async {
if let Some(rate) = fetch_rate(&vs).await {
record_rate(&vs, rate);
true
} else {
false
}
});
if ok {
FAILED.write().remove(&vs);
} else {
FAILED.write().insert(vs.clone());
}
FETCHING.write().remove(&vs);
});
}
/// Record a freshly fetched rate: into the in-memory cache (with `now()`) AND to
/// disk, so the next cold start can paint it instantly (see [`seed_from_disk`]).
/// Record a freshly fetched rate into the in-memory cache (with `now()`) and
/// clear any prior failure flag. Nothing is written to disk.
fn record_rate(vs: &str, rate: f64) {
let t = now();
RATES.write().insert(vs.to_string(), (rate, t));
AppConfig::set_last_rate(vs, rate, t);
}
/// Seed the in-memory cache from the disk-persisted last rate, if it is fresh
/// enough (< 48h). Inserted with its ORIGINAL timestamp so it reads as stale —
/// [`grin_rate`] returns it immediately for an instant preview, yet `needs_refresh`
/// stays true so a live refresh is still kicked. Called once, early in start().
pub fn seed_from_disk() {
if let Some((vs, rate, at)) = AppConfig::last_rate() {
if now() - at <= SEED_MAX_AGE_SECS {
RATES.write().entry(vs).or_insert((rate, at));
}
}
RATES.write().insert(vs.to_string(), (rate, now()));
FAILED.write().remove(vs);
}
/// Kick a refresh for the current pairing's currency the moment the tunnel is
@@ -133,7 +180,8 @@ pub fn seed_from_disk() {
/// It doubles as the end-to-end exit probe: if every attempt fails while the
/// tunnel still reports ready, the exit is blackholing HTTP despite passing the
/// cheap liveness probe, so we condemn it (bounded: at most one condemnation per
/// tunnel generation) rather than let it stall the wallet for minutes.
/// tunnel generation) rather than let it stall the wallet for minutes. This is a
/// one-shot per tunnel connection, not a poll loop.
pub fn eager_refresh() {
let vs = match AppConfig::pairing().vs_currency() {
Some(vs) => vs.to_string(),
@@ -171,6 +219,11 @@ pub fn eager_refresh() {
}
}
}
if ok {
FAILED.write().remove(&vs);
} else {
FAILED.write().insert(vs.clone());
}
// Every attempt failed AND the tunnel still claims ready on the SAME
// generation we probed: the exit is up but blackholing our HTTP. Condemn
// it so a fresh exit is selected in seconds, not minutes. Guarded to the
@@ -182,14 +235,14 @@ pub fn eager_refresh() {
FETCHING.write().remove(&vs);
}
/// Fetch the GRIN/`vs` rate from CoinGecko over the Nym mixnet.
/// Fetch the GRIN/`vs` rate from CoinGecko over Tor.
async fn fetch_rate(vs: &str) -> Option<f64> {
let url = format!(
"https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies={}",
vs
);
// CoinGecko rejects requests without a User-Agent (403). A static,
// non-identifying UA is fine over the mixnet.
// non-identifying UA is fine over Tor.
let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())];
let body = tor::http_request("GET", url, None, headers).await?;
let parsed: Option<f64> = serde_json::from_str::<serde_json::Value>(&body)
@@ -203,3 +256,59 @@ async fn fetch_rate(vs: &str) -> Option<f64> {
}
parsed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn freshness_window_is_a_few_minutes() {
// Guards the ruling: a short window in minutes, not the old 48h cache.
assert!(
(120..=300).contains(&FRESH_SECS),
"FRESH_SECS = {FRESH_SECS}"
);
}
#[test]
fn is_fresh_at_boundary() {
let now = 1_000_000;
assert!(is_fresh(now, now)); // just fetched
assert!(is_fresh(now - FRESH_SECS, now)); // exactly on the edge is still fresh
assert!(!is_fresh(now - FRESH_SECS - 1, now)); // one second past → stale
}
#[test]
fn classify_fresh_rate_is_painted() {
let now = 1_000_000;
let cached = Some((1.23, now - 10));
// Even mid-fetch, a fresh rate wins.
assert_eq!(classify(cached, now, true, false), RateState::Fresh(1.23));
}
#[test]
fn classify_stale_rate_never_painted_shows_loading_while_fetching() {
let now = 1_000_000;
let cached = Some((1.23, now - FRESH_SECS - 60));
assert_eq!(classify(cached, now, true, false), RateState::Loading);
}
#[test]
fn classify_stale_rate_after_failure_is_unavailable() {
let now = 1_000_000;
let cached = Some((1.23, now - FRESH_SECS - 60));
// Not fetching, last attempt failed → honest "unavailable", not the old value.
assert_eq!(classify(cached, now, false, true), RateState::Unavailable);
}
#[test]
fn classify_missing_rate_loads_then_reports_failure() {
let now = 1_000_000;
// First view: nothing cached, fetch just kicked.
assert_eq!(classify(None, now, true, false), RateState::Loading);
// Fetch finished and failed, nothing fresh → unavailable.
assert_eq!(classify(None, now, false, true), RateState::Unavailable);
// Freshly triggered, not yet marked fetching or failed → still loading.
assert_eq!(classify(None, now, false, false), RateState::Loading);
}
}
-3
View File
@@ -129,9 +129,6 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe:
// traffic egresses through Tor; the Grin node stays on the clear internet
// exactly as before (its lazy warm-on-activity polling is untouched).
tor::warm_up();
// Seed the price cache from disk so the amount preview can paint an instant
// (stale-marked) fiat value while the first live fetch is still in flight.
crate::http::price::seed_from_disk();
// Setup translations.
setup_i18n();
// Start integrated node if needed.
+20 -35
View File
@@ -101,15 +101,6 @@ pub struct AppConfig {
/// Last-known-good Nym IPR exit recipient (the `<id>.<enc>@<gw>` string), so a
/// warm reconnect can try the exit that worked last time before auto-selecting.
nym_last_ipr: Option<String>,
/// Last successfully fetched GRIN rate, so the amount preview can paint an
/// instant (stale-marked) fiat value on cold start instead of a blank until the
/// first mixnet fetch lands.
last_rate: Option<f64>,
/// The `vs_currency` the cached [`last_rate`] was priced against.
last_rate_vs: Option<String>,
/// Unix-seconds timestamp the cached [`last_rate`] was fetched at.
last_rate_at: Option<i64>,
}
/// What the amount preview is paired to: nothing, a fiat currency, or bitcoin.
@@ -223,9 +214,6 @@ impl Default for AppConfig {
app_update: None,
nym_entry_gateway: None,
nym_last_ipr: None,
last_rate: None,
last_rate_vs: None,
last_rate_at: None,
}
}
}
@@ -535,29 +523,6 @@ impl AppConfig {
w_config.save();
}
/// Get the cached GRIN rate as `(vs_currency, rate, fetched_at)`, if one was
/// ever persisted. Callers decide whether it is fresh enough to use.
pub fn last_rate() -> Option<(String, f64, i64)> {
let r_config = Settings::app_config_to_read();
match (
r_config.last_rate_vs.clone(),
r_config.last_rate,
r_config.last_rate_at,
) {
(Some(vs), Some(rate), Some(at)) => Some((vs, rate, at)),
_ => None,
}
}
/// Persist the most recent GRIN rate for `vs`, fetched at `at` (unix secs).
pub fn set_last_rate(vs: &str, rate: f64, at: i64) {
let mut w_config = Settings::app_config_to_update();
w_config.last_rate_vs = Some(vs.to_string());
w_config.last_rate = Some(rate);
w_config.last_rate_at = Some(at);
w_config.save();
}
/// Check if proxy for network requests is needed.
pub fn use_proxy() -> bool {
let r_config = Settings::app_config_to_read();
@@ -660,3 +625,23 @@ impl AppConfig {
w_config.save();
}
}
#[cfg(test)]
mod tests {
use super::AppConfig;
/// An old config carrying the now-removed price-cache fields (last_rate /
/// last_rate_vs / last_rate_at) must still load: serde ignores unknown keys,
/// so no migration is needed and the dead fields are simply dropped on the
/// next save.
#[test]
fn loads_config_with_removed_price_cache_fields() {
let mut toml = toml::to_string(&AppConfig::default()).expect("serialize default");
toml.push_str("\nlast_rate = 1.23\nlast_rate_vs = \"usd\"\nlast_rate_at = 1700000000\n");
let parsed = toml::from_str::<AppConfig>(&toml);
assert!(
parsed.is_ok(),
"old config with removed price-cache fields should still load"
);
}
}