diff --git a/src/http/mod.rs b/src/http/mod.rs index ead3613..010d5cc 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -18,5 +18,5 @@ pub use client::*; mod release; pub use release::*; -mod price; +pub(crate) mod price; pub use price::grin_rate; diff --git a/src/http/price.rs b/src/http/price.rs index 8cbfd57..0dd931c 100644 --- a/src/http/price.rs +++ b/src/http/price.rs @@ -21,8 +21,9 @@ use lazy_static::lazy_static; use parking_lot::RwLock; use std::collections::{HashMap, HashSet}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use crate::AppConfig; use crate::nym; /// Cache refresh interval (seconds). @@ -32,6 +33,19 @@ const REFRESH_SECS: i64 = 300; /// (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. +const PROBE_TIMEOUT: Duration = Duration::from_secs(12); + +/// How many eager-probe fetch attempts before we conclude the (still-"ready") +/// exit is blackholing HTTP and condemn it. +const PROBE_ATTEMPTS: u32 = 3; + lazy_static! { /// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at). static ref RATES: RwLock> = RwLock::new(HashMap::new()); @@ -87,13 +101,87 @@ fn trigger_refresh(vs: String) { .unwrap(); rt.block_on(async { if let Some(rate) = fetch_rate(&vs).await { - RATES.write().insert(vs.clone(), (rate, now())); + record_rate(&vs, rate); } }); 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`]). +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)); + } + } +} + +/// Kick a refresh for the current pairing's currency the moment the tunnel is +/// ready, bypassing the [`RETRY_SECS`] gate (but keeping the [`FETCHING`] dedupe). +/// 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. +pub fn eager_refresh() { + let vs = match AppConfig::pairing().vs_currency() { + Some(vs) => vs.to_string(), + // Pairing off → nothing to fetch, so no probe either (we never fetch a + // price the user hasn't opted into). The watchdog's own signals govern. + None => return, + }; + { + let mut fetching = FETCHING.write(); + if fetching.contains(&vs) { + return; + } + fetching.insert(vs.clone()); + } + LAST_TRY.write().insert(vs.clone(), now()); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let generation = nym::tunnel_generation(); + let mut ok = false; + for attempt in 1..=PROBE_ATTEMPTS { + match tokio::time::timeout(PROBE_TIMEOUT, fetch_rate(&vs)).await { + Ok(Some(rate)) => { + record_rate(&vs, rate); + ok = true; + break; + } + _ => { + log::warn!( + "price: eager probe fetch {attempt}/{PROBE_ATTEMPTS} failed \ + (vs {vs}, gen {generation})" + ); + } + } + } + // 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 + // probed generation so a reselect that already happened is never hit. + if !ok && nym::is_ready() && nym::tunnel_generation() == generation { + nym::condemn_exit(generation); + } + }); + FETCHING.write().remove(&vs); +} + /// Fetch the GRIN/`vs` rate from CoinGecko over the Nym mixnet. async fn fetch_rate(vs: &str) -> Option { let url = format!( diff --git a/src/lib.rs b/src/lib.rs index 83f4077..3a78cf1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -117,16 +117,20 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe: // would panic on the first TLS handshake. nym uses its own explicit provider, // so this only steers our relay/HTTP TLS. Idempotent (Err if already set). let _ = rustls::crypto::ring::default_provider().install_default(); + // Pre-warm the in-process Nym mixnet tunnel FIRST, before i18n/node setup, so + // the mixnet bootstrap (the long pole on cold start) overlaps everything else + // and price/NIP-05/nostr are ready at first use. All of Goblin's outbound + // traffic egresses through it; nothing clearnet. + nym::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. if AppConfig::autostart_node() { Node::start(); } - // Pre-warm the in-process Nym mixnet tunnel so price/NIP-05/nostr are ready at - // first use. All of Goblin's outbound traffic egresses through it; nothing - // clearnet. - nym::warm_up(); // Launch graphical interface. eframe::run_native("Goblin", options, app_creator) } diff --git a/src/nym/mod.rs b/src/nym/mod.rs index ab11af7..93c1044 100644 --- a/src/nym/mod.rs +++ b/src/nym/mod.rs @@ -48,8 +48,8 @@ use log::{debug, warn}; use tokio::io::{AsyncRead, AsyncWrite}; pub use nymproc::{ - is_ready, report_relay_down, report_relay_live, set_relay_consumer, transport_ready, - tunnel_generation, warm_up, + condemn_exit, is_ready, report_relay_down, report_relay_live, set_relay_consumer, + transport_ready, tunnel_generation, warm_up, }; pub use transport::NymWebSocketTransport; diff --git a/src/nym/nymproc.rs b/src/nym/nymproc.rs index 9c20a04..7ae2e77 100644 --- a/src/nym/nymproc.rs +++ b/src/nym/nymproc.rs @@ -49,6 +49,8 @@ use log::{error, info, warn}; use parking_lot::RwLock; use smolmix::{Recipient, Tunnel}; +use crate::AppConfig; + /// The shared process-lifetime tunnel, set once the mixnet bootstrap finishes. static TUNNEL: RwLock> = RwLock::new(None); @@ -86,6 +88,19 @@ static STARTED: AtomicBool = AtomicBool::new(false); /// FIRST tunnel is published — and never again on a later reselect. static PREWARMED: AtomicBool = AtomicBool::new(false); +/// Guards the one-shot eager price fetch / end-to-end exit probe so it fires +/// exactly once — after the FIRST tunnel is published — and never again on a +/// later reselect. +static PRICE_KICKED: AtomicBool = AtomicBool::new(false); + +/// The highest tunnel generation for which an external caller (the eager price +/// probe) has requested condemnation. The watchdog compares it against the +/// generation it is watching each tick, so a dead exit whose cheap probes still +/// pass but which blackholes real HTTP can be abandoned in seconds. `fetch_max` +/// so a stale request can never move it backwards. Never triggers a reselect on +/// a NEWER generation than the one requested. +static CONDEMN_REQUEST_GEN: AtomicU64 = AtomicU64::new(0); + /// Pre-warm the mixnet tunnel in the background so relays / NIP-05 / price are /// ready by first use. Idempotent — later calls (including the lazy-init path /// in [`wait_for_tunnel`]) are no-ops. @@ -134,6 +149,19 @@ pub fn report_relay_down(generation: u64) { let _ = RELAY_LIVE_GEN.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire); } +/// External condemnation request for `generation`: the end-to-end eager probe +/// found the exit up (cheap probes pass) yet blackholing real HTTP. The watchdog +/// picks this up on its next tick and re-selects a fresh exit. Bounded by design: +/// it only ever condemns the generation it targets, never a newer one, and the +/// probe that calls it is one-shot per tunnel generation. +pub fn condemn_exit(generation: u64) { + if generation == 0 { + return; + } + CONDEMN_REQUEST_GEN.fetch_max(generation, Ordering::AcqRel); + warn!("[timing] nym: eager probe requested condemnation of exit gen {generation}"); +} + /// Bracket a nostr consumer's lifetime: the running `NostrService` sets this /// true while it wants relays and false when it stops. Arms/disarms /// relay-reachability governance of exit health (see [`RELAY_CONSUMER`]). @@ -197,6 +225,22 @@ fn run_tunnel() { // True while a FALLBACK (auto-selected) exit carries the traffic even // though an anchor is configured — makes the ANCHOR RECOVERED log honest. let mut fell_back = false; + // WARM-CONNECT CACHES (biggest cold-connect win). The last-known-good ENTRY + // GATEWAY (item 1) is applied to EVERY build so a warm reconnect skips + // re-picking a random — and possibly dead — first hop; a build timeout/error + // while it was in use drops it for the rest of THIS process (disk untouched, + // since a blip must not throw away a good hint). The last-known-good IPR + // (item 2) is tried once per process as a pin, ordered Anchor -> Cached -> + // Auto by the selector. + let mut cached_gw = AppConfig::nym_entry_gateway(); + let mut cached_ipr = AppConfig::nym_last_ipr().and_then(|s| parse_anchor(&s)); + // Don't double up: if the cached IPR is the configured anchor, the anchor + // slot already covers it. + if let (Some(c), Some(a)) = (cached_ipr, anchor_recipient()) { + if c == a { + cached_ipr = None; + } + } // COLD-START SEQUENCING (reads-first): the TUNNEL bootstraps first and takes // its Nym free-tier bandwidth grant, so interactive reads get the tunnel // ~2-3s sooner. The scoped money-path exit is prewarmed AFTER the first @@ -211,7 +255,7 @@ fn run_tunnel() { // attempt in the cycle. Env re-read each attempt so the timing // harness / a debug session can flip it without a restart. let anchor = anchor_recipient(); - let choice = selector.next_choice(anchor.is_some()); + let choice = selector.next_choice(anchor.is_some(), cached_ipr.is_some()); let pin = match choice { ExitChoice::Anchor => { info!( @@ -219,6 +263,14 @@ fn run_tunnel() { ); anchor } + ExitChoice::Cached => { + info!( + "[timing] nym: CACHED attempt — trying last-known-good IPR exit (attempt {attempt})" + ); + // One-shot for this process: take it so a failure falls through + // to Auto and the slot never re-arms (unlike the anchor). + cached_ipr.take() + } ExitChoice::Auto => None, }; info!( @@ -229,30 +281,48 @@ fn run_tunnel() { // own long "connection response" timeout (~74s measured) before we can // reselect. Abandoning the future drops the half-built tunnel. let build_cap = tunnel_build_timeout(); - let build = match tokio::time::timeout(build_cap, build_tunnel(pin)).await { + let entry_gw = cached_gw.clone(); + let used_cached_gw = entry_gw.is_some(); + let build = match tokio::time::timeout(build_cap, build_tunnel(pin, entry_gw)).await { Ok(result) => result, Err(_) => { - if choice == ExitChoice::Anchor { - // A dead anchor must not delay connectivity: fall back - // to auto-select IMMEDIATELY (no backoff), same cycle. - warn!( - "[timing] nym: ANCHOR DEAD — anchor build exceeded {}s (attempt {attempt}); \ - FALLBACK to auto-select now", - build_cap.as_secs() - ); - continue; + // A cached entry gateway that timed out is not reused for the rest + // of this process (disk kept — it may be a transient blip). + if used_cached_gw { + cached_gw = None; + } + match choice { + ExitChoice::Anchor => { + // A dead anchor must not delay connectivity: fall back + // to auto-select IMMEDIATELY (no backoff), same cycle. + warn!( + "[timing] nym: ANCHOR DEAD — anchor build exceeded {}s (attempt {attempt}); \ + FALLBACK to auto-select now", + build_cap.as_secs() + ); + } + ExitChoice::Cached => { + warn!( + "[timing] nym: CACHED IPR build exceeded {}s (attempt {attempt}); \ + clearing the cached exit and auto-selecting now", + build_cap.as_secs() + ); + AppConfig::set_nym_last_ipr(None); + } + ExitChoice::Auto => { + warn!( + "[timing] nym: DEAD GATEWAY — build_tunnel exceeded {}s (attempt {attempt}); \ + re-selecting immediately", + build_cap.as_secs() + ); + delay = Duration::from_secs(5); + } } - warn!( - "[timing] nym: DEAD GATEWAY — build_tunnel exceeded {}s (attempt {attempt}); \ - re-selecting immediately", - build_cap.as_secs() - ); - delay = Duration::from_secs(5); continue; } }; match build { - Ok(tunnel) => { + Ok((tunnel, used_gw, used_ipr)) => { let build_ms = started.elapsed().as_millis(); info!( "[timing] nym: tunnel BUILT in {build_ms}ms (attempt {attempt}); probing exit liveness" @@ -269,15 +339,22 @@ fn run_tunnel() { (attempt {attempt}); {}", choice.label(), started.elapsed().as_millis(), - if choice == ExitChoice::Anchor { - "FALLBACK to auto-select now" - } else { - "re-selecting immediately" + match choice { + ExitChoice::Anchor => "FALLBACK to auto-select now", + ExitChoice::Cached => + "clearing the cached exit and auto-selecting now", + ExitChoice::Auto => "re-selecting immediately", } ); tunnel.shutdown().await; - if choice == ExitChoice::Auto { - delay = (delay * 2).min(Duration::from_secs(60)); + match choice { + // A cached exit that fails its probe is stale: drop the + // disk hint so we don't keep re-trying a dead IPR. + ExitChoice::Cached => AppConfig::set_nym_last_ipr(None), + ExitChoice::Auto => { + delay = (delay * 2).min(Duration::from_secs(60)); + } + ExitChoice::Anchor => {} } continue; } @@ -306,6 +383,17 @@ fn run_tunnel() { } fell_back = false; } + // A cached exit only wins after the anchor slot was tried this + // cycle, so with an anchor configured this is still a FALLBACK — + // retry the anchor on the next reselect. + ExitChoice::Cached if anchor.is_some() => { + fell_back = true; + info!( + "[timing] nym: running on cached FALLBACK exit (gen {generation}); \ + anchor will be retried on the next reselect" + ); + } + ExitChoice::Cached => {} ExitChoice::Auto if anchor.is_some() => { fell_back = true; info!( @@ -315,6 +403,18 @@ fn run_tunnel() { } ExitChoice::Auto => {} } + // Persist the warm-connect caches for the next cold start: the ENTRY + // GATEWAY (item 1) and the winning IPR (item 2), each only when + // changed so a steady exit doesn't rewrite app.toml on every reselect. + if AppConfig::nym_entry_gateway().as_deref() != Some(used_gw.as_str()) { + info!("[timing] nym: caching entry gateway {used_gw} for warm reconnect"); + AppConfig::set_nym_entry_gateway(Some(used_gw.clone())); + } + cached_gw = Some(used_gw); + let ipr_str = used_ipr.to_string(); + if AppConfig::nym_last_ipr().as_deref() != Some(ipr_str.as_str()) { + AppConfig::set_nym_last_ipr(Some(ipr_str)); + } *TUNNEL.write() = Some(tunnel.clone()); MIXNET_READY.store(true, Ordering::Relaxed); // Prewarm the scoped money-path exit ONCE, now that the tunnel is @@ -327,6 +427,13 @@ fn run_tunnel() { { tokio::spawn(super::streamexit::prewarm()); } + // Eager price fetch the moment the tunnel is ready (item 3) — it + // also serves as the end-to-end exit probe (item 5): if every + // attempt fails while the tunnel still reads ready, the exit is + // blackholing HTTP and gets condemned. One-shot, like the prewarm. + if !PRICE_KICKED.swap(true, Ordering::SeqCst) { + std::thread::spawn(crate::http::price::eager_refresh); + } delay = Duration::from_secs(5); // Hold the exit warm and govern its health. The watchdog weighs TWO // signals: the cheap DNS keepalive (as before) AND — authoritatively, @@ -360,21 +467,36 @@ fn run_tunnel() { } } Err(e) => { - if choice == ExitChoice::Anchor { - // Anchor unreachable (not bonded yet / condemned by the - // network / bad address): fall back to auto-select - // IMMEDIATELY — no backoff, connectivity first. - warn!( - "[timing] nym: ANCHOR failed to build: {e}; FALLBACK to auto-select now" - ); - continue; + // A cached entry gateway that errored is not reused for the rest + // of this process (disk kept — it may be a transient blip). + if used_cached_gw { + cached_gw = None; + } + match choice { + ExitChoice::Anchor => { + // Anchor unreachable (not bonded yet / condemned by the + // network / bad address): fall back to auto-select + // IMMEDIATELY — no backoff, connectivity first. + warn!( + "[timing] nym: ANCHOR failed to build: {e}; FALLBACK to auto-select now" + ); + } + ExitChoice::Cached => { + warn!( + "[timing] nym: CACHED IPR failed to build: {e}; \ + clearing the cached exit and auto-selecting now" + ); + AppConfig::set_nym_last_ipr(None); + } + ExitChoice::Auto => { + error!( + "nym: mixnet tunnel failed to start: {e}; retrying in {}s", + delay.as_secs() + ); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(60)); + } } - error!( - "nym: mixnet tunnel failed to start: {e}; retrying in {}s", - delay.as_secs() - ); - tokio::time::sleep(delay).await; - delay = (delay * 2).min(Duration::from_secs(60)); } } } @@ -480,6 +602,19 @@ async fn watch_tunnel(tunnel: &smolmix::Tunnel, generation: u64) { let mut relay_lost: Option = None; loop { tokio::time::sleep(WATCH_TICK).await; + // (0) External condemnation request — the eager end-to-end probe found this + // exit up (cheap probes pass) yet blackholing real HTTP. Honor it only for + // THIS generation (never a newer one): abandon the exit now so a fresh one + // is selected in seconds instead of the minutes a blackhole would otherwise + // cost. The MIN_EXIT_LIFETIME rebuild floor still bounds the reselect rate. + if CONDEMN_REQUEST_GEN.load(Ordering::Acquire) >= generation { + warn!( + "[timing] nym: CONDEMN gen {generation} reason=eager-probe-blackhole; \ + exit lived {}s, re-selecting", + published.elapsed().as_secs() + ); + return; + } // (1) Relay reachability — authoritative, but ONLY when a nostr consumer // actually wants relays on this exit. No consumer → the DNS keepalive // below is the sole health signal, exactly as before this hardening. @@ -550,6 +685,9 @@ enum ExitChoice { /// own co-located exit is the separate scoped-MixnetStream egress; this /// selector governs only the public-IPR fallback layer.) Anchor, + /// The last-known-good IPR from a previous run, tried once per process after + /// the anchor and before pure auto-select — a warm-connect hint, not a pin. + Cached, /// A public exit auto-selected from the network pool — the FALLBACK. Auto, } @@ -559,6 +697,7 @@ impl ExitChoice { fn label(self) -> &'static str { match self { ExitChoice::Anchor => "ANCHOR", + ExitChoice::Cached => "cached", ExitChoice::Auto => "auto-selected", } } @@ -584,27 +723,38 @@ impl ExitChoice { struct ExitSelector { /// Whether the anchor has been tried in the current select cycle. anchor_tried: bool, + /// Whether the cached last-known-good IPR has been tried. Unlike the anchor + /// this is ONCE PER PROCESS — a warm-connect hint spends itself and never + /// re-arms, so it can't keep re-pinning a possibly-stale exit on every cycle. + cached_tried: bool, } impl ExitSelector { const fn new() -> Self { Self { anchor_tried: false, + cached_tried: false, } } - /// The exit to target for the next build attempt. - fn next_choice(&mut self, anchor_available: bool) -> ExitChoice { + /// The exit to target for the next build attempt. Order per cycle: + /// anchor (if configured, once per cycle) → cached (if available, once per + /// process) → auto-select. + fn next_choice(&mut self, anchor_available: bool, cached_available: bool) -> ExitChoice { if anchor_available && !self.anchor_tried { self.anchor_tried = true; ExitChoice::Anchor + } else if cached_available && !self.cached_tried { + self.cached_tried = true; + ExitChoice::Cached } else { ExitChoice::Auto } } /// A tunnel was published: the select cycle is over. Re-arms the anchor for - /// the next cycle. + /// the next cycle. The cached slot is NOT re-armed — it is a one-shot + /// warm-connect hint (see [`cached_tried`](Self::cached_tried)). fn tunnel_published(&mut self) { self.anchor_tried = false; } @@ -647,13 +797,28 @@ fn parse_anchor(raw: &str) -> Option { } /// Build the tunnel — pinned to the anchor's IPR when `pin` is set, otherwise -/// with an auto-selected exit. Ephemeral in-memory keys (a fresh mixnet -/// identity per run — no sqlite, no persisted gateway). +/// with an auto-selected exit. When `entry_gateway` is set, the client REQUESTS +/// that specific first-hop gateway (a warm-connect hint) instead of a random one. +/// +/// Keys stay EPHEMERAL — a fresh mixnet identity per run, no sqlite, nothing +/// persisted about the client itself. The ONLY thing that persists across runs is +/// the gateway CHOICE (and the exit IPR), remembered by [`run_tunnel`] so a warm +/// reconnect skips re-picking a possibly-dead first hop; the requested gateway +/// resolves to `GatewaySelectionSpecification::Specified` while storage stays +/// ephemeral, so no gateway keys are written to disk. +/// +/// Returns the built tunnel PLUS the ENTRY GATEWAY it actually used (base58) and +/// the EXIT IPR recipient it rode — both captured so `run_tunnel` can persist the +/// last-known-good pair. The gateway is read from the client's own nym-address +/// BEFORE [`IpMixStream::from_client`] consumes the client. /// /// NEVER make the anchor the ONLY exit: `pin` must always be allowed to fall /// back to `None` (see [`ExitSelector`]) or the single-exit SPOF — and a /// single party seeing all exit traffic — comes back. -async fn build_tunnel(pin: Option) -> Result { +async fn build_tunnel( + pin: Option, + entry_gateway: Option, +) -> Result<(Tunnel, String, Recipient), smolmix::SmolmixError> { use nym_sdk::DebugConfig; use nym_sdk::ipr_wrapper::IpMixStream; use nym_sdk::mixnet::MixnetClientBuilder; @@ -676,21 +841,28 @@ async fn build_tunnel(pin: Option) -> Result); - let client = MixnetClientBuilder::new_ephemeral() - .debug_config(cfg) - .build()? - .connect_to_mixnet() - .await?; + let mut builder = MixnetClientBuilder::new_ephemeral().debug_config(cfg); + // Warm-connect: ask for last run's entry gateway. With ephemeral storage this + // is a Specified gateway selection with no persisted keys. + if let Some(gw) = entry_gateway { + builder = builder.request_gateway(gw); + } + let client = builder.build()?.connect_to_mixnet().await?; - // Pinned anchor when provided, else the auto-selected best public IPR — the - // same discovery the untuned `IpMixStream::new` path used, so anchor/fallback - // selection in `run_tunnel` is unchanged. + // Capture the ENTRY GATEWAY actually used, from the client's own nym-address, + // BEFORE `from_client` consumes the client. + let entry_gw = client.nym_address().gateway().to_base58_string(); + + // Pinned anchor/cached exit when provided, else the auto-selected best public + // IPR — the same discovery the untuned `IpMixStream::new` path used, so + // anchor/fallback selection in `run_tunnel` is unchanged. let ipr = match pin { Some(recipient) => recipient, None => IpMixStream::best_ipr().await?, }; let stream = IpMixStream::from_client(client, ipr).await?; - Tunnel::from_stream(stream).await + let tunnel = Tunnel::from_stream(stream).await?; + Ok((tunnel, entry_gw, ipr)) } #[cfg(test)] @@ -701,50 +873,82 @@ mod tests { fn no_anchor_is_pure_auto_select() { let mut s = ExitSelector::new(); for _ in 0..5 { - assert_eq!(s.next_choice(false), ExitChoice::Auto); + assert_eq!(s.next_choice(false, false), ExitChoice::Auto); } // Publishing changes nothing without an anchor. s.tunnel_published(); - assert_eq!(s.next_choice(false), ExitChoice::Auto); + assert_eq!(s.next_choice(false, false), ExitChoice::Auto); } #[test] fn anchor_first_then_auto_within_a_cycle() { let mut s = ExitSelector::new(); - assert_eq!(s.next_choice(true), ExitChoice::Anchor); + assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); // Anchor failed — every further attempt in the cycle falls back. - assert_eq!(s.next_choice(true), ExitChoice::Auto); - assert_eq!(s.next_choice(true), ExitChoice::Auto); + assert_eq!(s.next_choice(true, false), ExitChoice::Auto); + assert_eq!(s.next_choice(true, false), ExitChoice::Auto); } #[test] fn anchor_retried_on_the_next_cycle_after_a_fallback() { let mut s = ExitSelector::new(); // Cycle 1: anchor fails, a fallback exit gets published. - assert_eq!(s.next_choice(true), ExitChoice::Anchor); - assert_eq!(s.next_choice(true), ExitChoice::Auto); + assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); + assert_eq!(s.next_choice(true, false), ExitChoice::Auto); s.tunnel_published(); // Cycle 2 (the reselect after the fallback): anchor first again. - assert_eq!(s.next_choice(true), ExitChoice::Anchor); + assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); } #[test] fn anchor_publish_also_rearms_the_anchor() { let mut s = ExitSelector::new(); - assert_eq!(s.next_choice(true), ExitChoice::Anchor); + assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); s.tunnel_published(); // the anchor itself came up // Condemned later → next cycle prefers the anchor again. - assert_eq!(s.next_choice(true), ExitChoice::Anchor); + assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); } #[test] fn anchor_appearing_mid_cycle_is_tried() { let mut s = ExitSelector::new(); // No anchor yet (env unset / invalid): auto, without burning the try. - assert_eq!(s.next_choice(false), ExitChoice::Auto); + assert_eq!(s.next_choice(false, false), ExitChoice::Auto); // Anchor becomes available (env fixed mid-run): tried on the next attempt. - assert_eq!(s.next_choice(true), ExitChoice::Anchor); - assert_eq!(s.next_choice(true), ExitChoice::Auto); + assert_eq!(s.next_choice(true, false), ExitChoice::Anchor); + assert_eq!(s.next_choice(true, false), ExitChoice::Auto); + } + + #[test] + fn cached_after_anchor_then_auto_within_a_cycle() { + let mut s = ExitSelector::new(); + // Order per cycle: anchor → cached → auto. + assert_eq!(s.next_choice(true, true), ExitChoice::Anchor); + assert_eq!(s.next_choice(true, true), ExitChoice::Cached); + assert_eq!(s.next_choice(true, true), ExitChoice::Auto); + assert_eq!(s.next_choice(true, true), ExitChoice::Auto); + } + + #[test] + fn cached_tried_before_auto_when_no_anchor() { + let mut s = ExitSelector::new(); + // No anchor, but a cached hint exists: cached first, then auto. + assert_eq!(s.next_choice(false, true), ExitChoice::Cached); + assert_eq!(s.next_choice(false, true), ExitChoice::Auto); + } + + #[test] + fn cached_is_one_shot_across_the_whole_process() { + let mut s = ExitSelector::new(); + // Spend the cached hint in cycle 1. + assert_eq!(s.next_choice(false, true), ExitChoice::Cached); + assert_eq!(s.next_choice(false, true), ExitChoice::Auto); + s.tunnel_published(); + // Cycle 2: the cached slot never re-arms, even if still "available". + assert_eq!(s.next_choice(false, true), ExitChoice::Auto); + // And with an anchor present the anchor is still retried each cycle. + assert_eq!(s.next_choice(true, true), ExitChoice::Anchor); + assert_eq!(s.next_choice(true, true), ExitChoice::Auto); } #[test] diff --git a/src/settings/config.rs b/src/settings/config.rs index d0d2909..b8808a6 100644 --- a/src/settings/config.rs +++ b/src/settings/config.rs @@ -93,6 +93,23 @@ pub struct AppConfig { check_updates: Option, /// Application update information. app_update: Option, + + /// Last-known-good Nym ENTRY gateway (base58 identity). Only the gateway + /// CHOICE is remembered — the mixnet keys stay ephemeral — so a warm reconnect + /// can skip re-picking a (possibly dead) random first hop. + nym_entry_gateway: Option, + /// 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. @@ -204,6 +221,11 @@ impl Default for AppConfig { // update check — payments, relays and identity still stay mixnet-only. check_updates: Some(true), app_update: None, + nym_entry_gateway: None, + nym_last_ipr: None, + last_rate: None, + last_rate_vs: None, + last_rate_at: None, } } } @@ -487,6 +509,55 @@ impl AppConfig { w_config.save(); } + /// Get the last-known-good Nym ENTRY gateway (base58 identity), if any. + pub fn nym_entry_gateway() -> Option { + let r_config = Settings::app_config_to_read(); + r_config.nym_entry_gateway.clone() + } + + /// Save (or clear) the last-known-good Nym ENTRY gateway. + pub fn set_nym_entry_gateway(gw: Option) { + let mut w_config = Settings::app_config_to_update(); + w_config.nym_entry_gateway = gw; + w_config.save(); + } + + /// Get the last-known-good Nym IPR exit recipient string, if any. + pub fn nym_last_ipr() -> Option { + let r_config = Settings::app_config_to_read(); + r_config.nym_last_ipr.clone() + } + + /// Save (or clear) the last-known-good Nym IPR exit recipient string. + pub fn set_nym_last_ipr(ipr: Option) { + let mut w_config = Settings::app_config_to_update(); + w_config.nym_last_ipr = ipr; + 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();