From d5d1212a446ab1a3ffdfcc98cae8bc8ec02f9fe4 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 5 Jul 2026 04:38:24 -0400 Subject: [PATCH] Goblin: fetch fiat rate live on view, kill the 48h price cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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; 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. --- locales/de.yml | 1 + locales/en.yml | 1 + locales/fr.yml | 1 + locales/ru.yml | 1 + locales/tr.yml | 1 + locales/zh-CN.yml | 1 + src/gui/views/goblin/mod.rs | 52 +++++--- src/gui/views/goblin/send.rs | 2 +- src/gui/views/goblin/widgets.rs | 29 ++++- src/http/mod.rs | 2 +- src/http/price.rs | 203 ++++++++++++++++++++++++-------- src/lib.rs | 3 - src/settings/config.rs | 55 ++++----- 13 files changed, 246 insertions(+), 106 deletions(-) diff --git a/locales/de.yml b/locales/de.yml index 43edbe39..fb8085c7 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -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…" diff --git a/locales/en.yml b/locales/en.yml index 18eb2618..bbeacd89 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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…" diff --git a/locales/fr.yml b/locales/fr.yml index 65006c3b..c5d84309 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -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…" diff --git a/locales/ru.yml b/locales/ru.yml index ed609ba2..531a9de6 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -367,6 +367,7 @@ goblin: syncing: "Синхронизация…" balance_updating: "Баланс обновляется…" balance_stale: "Узел недоступен · последний известный баланс" + fiat_unavailable: "Курс недоступен" listening: "Ожидание платежей" block: "Блок %{height}" waiting_for_chain: "Ожидание цепочки…" diff --git a/locales/tr.yml b/locales/tr.yml index cfdef5a2..00bd02cf 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -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…" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 475f6a11..22df0d24 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -367,6 +367,7 @@ goblin: syncing: "同步中…" balance_updating: "余额更新中…" balance_stale: "无法连接节点 · 上次已知余额" + fiat_unavailable: "汇率不可用" listening: "正在监听付款" block: "区块 %{height}" waiting_for_chain: "等待链数据…" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index e41568be..9e0ccfe7 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -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::() { - 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) -> Option { +fn fiat_line(data: &Option) -> Option { + 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 { +fn pairing_preview(grin: f64, ctx: &egui::Context) -> Option { + 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. diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 0a0eb196..9ba6f296 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -1046,7 +1046,7 @@ impl SendFlow { w::amount_text_centered(ui, &display, 64.0); } if let Ok(grin) = display.parse::() { - 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( diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index f32abfaf..c12a1624 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -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, 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), ); diff --git a/src/http/mod.rs b/src/http/mod.rs index 010d5ccf..583ba872 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -19,4 +19,4 @@ mod release; pub use release::*; pub(crate) mod price; -pub use price::grin_rate; +pub use price::{RateState, grin_rate}; diff --git a/src/http/price.rs b/src/http/price.rs index 4915d2ff..4dc79481 100644 --- a/src/http/price.rs +++ b/src/http/price.rs @@ -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> = RwLock::new(HashMap::new()); /// Currencies with a fetch currently in flight. static ref FETCHING: RwLock> = RwLock::new(HashSet::new()); /// Last fetch attempt per currency (unix secs). static ref LAST_TRY: RwLock> = 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> = 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 { - 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 { 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 = serde_json::from_str::(&body) @@ -203,3 +256,59 @@ async fn fetch_rate(vs: &str) -> Option { } 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7c5d0db2..6e025d42 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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. diff --git a/src/settings/config.rs b/src/settings/config.rs index b8808a6d..3e25b38c 100644 --- a/src/settings/config.rs +++ b/src/settings/config.rs @@ -101,15 +101,6 @@ pub struct AppConfig { /// Last-known-good Nym IPR exit recipient (the `.@` string), so a /// warm reconnect can try the exit that worked last time before auto-selecting. nym_last_ipr: Option, - - /// 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, - /// The `vs_currency` the cached [`last_rate`] was priced against. - last_rate_vs: Option, - /// Unix-seconds timestamp the cached [`last_rate`] was fetched at. - last_rate_at: Option, } /// 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::(&toml); + assert!( + parsed.is_ok(), + "old config with removed price-cache fields should still load" + ); + } +}