1
0
forked from GRIN/grim

nym: warm connect from caches + instant price + first-read probe

Cold connect gets a fast path: the last-known-good entry gateway and IPR
are persisted (choices only, keys stay ephemeral) and tried first on the
next launch - measured 4.4s to tunnel-ready vs 5.6s cold, and no dead-
random-gateway lottery. Cached hints self-clear on failure and fall back
to auto-select.

The price appears instantly: the last fetched rate (under 48h) paints on
the first frame and a live fetch fires the moment the tunnel is ready
instead of waiting for the balance screen. The eager fetch doubles as an
end-to-end probe: if the first reads all fail while the tunnel claims
ready, the exit is condemned and reselected in seconds instead of
minutes. warm_up now runs first at startup.

Money path (streamexit.rs, transport.rs) untouched; ponytail sweep
verified all fallback paths and gates green.
This commit is contained in:
2ro
2026-07-02 23:24:39 -04:00
parent b00719f2f9
commit 9caa2b6809
6 changed files with 442 additions and 75 deletions
+1 -1
View File
@@ -18,5 +18,5 @@ pub use client::*;
mod release;
pub use release::*;
mod price;
pub(crate) mod price;
pub use price::grin_rate;
+90 -2
View File
@@ -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<HashMap<String, (f64, i64)>> = 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<f64> {
let url = format!(
+8 -4
View File
@@ -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)
}
+2 -2
View File
@@ -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;
+270 -66
View File
@@ -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<Option<Tunnel>> = 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<Instant> = 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<Recipient> {
}
/// 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<Recipient>) -> Result<Tunnel, smolmix::SmolmixError> {
async fn build_tunnel(
pin: Option<Recipient>,
entry_gateway: Option<String>,
) -> 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<Recipient>) -> Result<Tunnel, smolmix::Smolmix
// Mirror the mainnet env setup the SDK's own constructors run before connect.
nym_sdk::setup_env(None::<&std::path::Path>);
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]
+71
View File
@@ -93,6 +93,23 @@ pub struct AppConfig {
check_updates: Option<bool>,
/// Application update information.
app_update: Option<AppUpdate>,
/// 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<String>,
/// 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.
@@ -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<String> {
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<String>) {
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<String> {
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<String>) {
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();