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:
+1
-1
@@ -18,5 +18,5 @@ pub use client::*;
|
|||||||
mod release;
|
mod release;
|
||||||
pub use release::*;
|
pub use release::*;
|
||||||
|
|
||||||
mod price;
|
pub(crate) mod price;
|
||||||
pub use price::grin_rate;
|
pub use price::grin_rate;
|
||||||
|
|||||||
+90
-2
@@ -21,8 +21,9 @@
|
|||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::AppConfig;
|
||||||
use crate::nym;
|
use crate::nym;
|
||||||
|
|
||||||
/// Cache refresh interval (seconds).
|
/// Cache refresh interval (seconds).
|
||||||
@@ -32,6 +33,19 @@ const REFRESH_SECS: i64 = 300;
|
|||||||
/// (e.g. no network) does not respawn a thread every frame.
|
/// (e.g. no network) does not respawn a thread every frame.
|
||||||
const RETRY_SECS: i64 = 30;
|
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! {
|
lazy_static! {
|
||||||
/// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at).
|
/// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at).
|
||||||
static ref RATES: RwLock<HashMap<String, (f64, i64)>> = RwLock::new(HashMap::new());
|
static ref RATES: RwLock<HashMap<String, (f64, i64)>> = RwLock::new(HashMap::new());
|
||||||
@@ -87,13 +101,87 @@ fn trigger_refresh(vs: String) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
if let Some(rate) = fetch_rate(&vs).await {
|
if let Some(rate) = fetch_rate(&vs).await {
|
||||||
RATES.write().insert(vs.clone(), (rate, now()));
|
record_rate(&vs, rate);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
FETCHING.write().remove(&vs);
|
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.
|
/// Fetch the GRIN/`vs` rate from CoinGecko over the Nym mixnet.
|
||||||
async fn fetch_rate(vs: &str) -> Option<f64> {
|
async fn fetch_rate(vs: &str) -> Option<f64> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
|
|||||||
+8
-4
@@ -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,
|
// 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).
|
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
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 translations.
|
||||||
setup_i18n();
|
setup_i18n();
|
||||||
// Start integrated node if needed.
|
// Start integrated node if needed.
|
||||||
if AppConfig::autostart_node() {
|
if AppConfig::autostart_node() {
|
||||||
Node::start();
|
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.
|
// Launch graphical interface.
|
||||||
eframe::run_native("Goblin", options, app_creator)
|
eframe::run_native("Goblin", options, app_creator)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -48,8 +48,8 @@ use log::{debug, warn};
|
|||||||
use tokio::io::{AsyncRead, AsyncWrite};
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
|
|
||||||
pub use nymproc::{
|
pub use nymproc::{
|
||||||
is_ready, report_relay_down, report_relay_live, set_relay_consumer, transport_ready,
|
condemn_exit, is_ready, report_relay_down, report_relay_live, set_relay_consumer,
|
||||||
tunnel_generation, warm_up,
|
transport_ready, tunnel_generation, warm_up,
|
||||||
};
|
};
|
||||||
pub use transport::NymWebSocketTransport;
|
pub use transport::NymWebSocketTransport;
|
||||||
|
|
||||||
|
|||||||
+270
-66
@@ -49,6 +49,8 @@ use log::{error, info, warn};
|
|||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use smolmix::{Recipient, Tunnel};
|
use smolmix::{Recipient, Tunnel};
|
||||||
|
|
||||||
|
use crate::AppConfig;
|
||||||
|
|
||||||
/// The shared process-lifetime tunnel, set once the mixnet bootstrap finishes.
|
/// The shared process-lifetime tunnel, set once the mixnet bootstrap finishes.
|
||||||
static TUNNEL: RwLock<Option<Tunnel>> = RwLock::new(None);
|
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.
|
/// FIRST tunnel is published — and never again on a later reselect.
|
||||||
static PREWARMED: AtomicBool = AtomicBool::new(false);
|
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
|
/// 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
|
/// ready by first use. Idempotent — later calls (including the lazy-init path
|
||||||
/// in [`wait_for_tunnel`]) are no-ops.
|
/// 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);
|
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
|
/// Bracket a nostr consumer's lifetime: the running `NostrService` sets this
|
||||||
/// true while it wants relays and false when it stops. Arms/disarms
|
/// true while it wants relays and false when it stops. Arms/disarms
|
||||||
/// relay-reachability governance of exit health (see [`RELAY_CONSUMER`]).
|
/// 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
|
// True while a FALLBACK (auto-selected) exit carries the traffic even
|
||||||
// though an anchor is configured — makes the ANCHOR RECOVERED log honest.
|
// though an anchor is configured — makes the ANCHOR RECOVERED log honest.
|
||||||
let mut fell_back = false;
|
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
|
// COLD-START SEQUENCING (reads-first): the TUNNEL bootstraps first and takes
|
||||||
// its Nym free-tier bandwidth grant, so interactive reads get the tunnel
|
// 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
|
// ~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
|
// attempt in the cycle. Env re-read each attempt so the timing
|
||||||
// harness / a debug session can flip it without a restart.
|
// harness / a debug session can flip it without a restart.
|
||||||
let anchor = anchor_recipient();
|
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 {
|
let pin = match choice {
|
||||||
ExitChoice::Anchor => {
|
ExitChoice::Anchor => {
|
||||||
info!(
|
info!(
|
||||||
@@ -219,6 +263,14 @@ fn run_tunnel() {
|
|||||||
);
|
);
|
||||||
anchor
|
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,
|
ExitChoice::Auto => None,
|
||||||
};
|
};
|
||||||
info!(
|
info!(
|
||||||
@@ -229,30 +281,48 @@ fn run_tunnel() {
|
|||||||
// own long "connection response" timeout (~74s measured) before we can
|
// own long "connection response" timeout (~74s measured) before we can
|
||||||
// reselect. Abandoning the future drops the half-built tunnel.
|
// reselect. Abandoning the future drops the half-built tunnel.
|
||||||
let build_cap = tunnel_build_timeout();
|
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,
|
Ok(result) => result,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
if choice == ExitChoice::Anchor {
|
// A cached entry gateway that timed out is not reused for the rest
|
||||||
// A dead anchor must not delay connectivity: fall back
|
// of this process (disk kept — it may be a transient blip).
|
||||||
// to auto-select IMMEDIATELY (no backoff), same cycle.
|
if used_cached_gw {
|
||||||
warn!(
|
cached_gw = None;
|
||||||
"[timing] nym: ANCHOR DEAD — anchor build exceeded {}s (attempt {attempt}); \
|
}
|
||||||
FALLBACK to auto-select now",
|
match choice {
|
||||||
build_cap.as_secs()
|
ExitChoice::Anchor => {
|
||||||
);
|
// A dead anchor must not delay connectivity: fall back
|
||||||
continue;
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match build {
|
match build {
|
||||||
Ok(tunnel) => {
|
Ok((tunnel, used_gw, used_ipr)) => {
|
||||||
let build_ms = started.elapsed().as_millis();
|
let build_ms = started.elapsed().as_millis();
|
||||||
info!(
|
info!(
|
||||||
"[timing] nym: tunnel BUILT in {build_ms}ms (attempt {attempt}); probing exit liveness"
|
"[timing] nym: tunnel BUILT in {build_ms}ms (attempt {attempt}); probing exit liveness"
|
||||||
@@ -269,15 +339,22 @@ fn run_tunnel() {
|
|||||||
(attempt {attempt}); {}",
|
(attempt {attempt}); {}",
|
||||||
choice.label(),
|
choice.label(),
|
||||||
started.elapsed().as_millis(),
|
started.elapsed().as_millis(),
|
||||||
if choice == ExitChoice::Anchor {
|
match choice {
|
||||||
"FALLBACK to auto-select now"
|
ExitChoice::Anchor => "FALLBACK to auto-select now",
|
||||||
} else {
|
ExitChoice::Cached =>
|
||||||
"re-selecting immediately"
|
"clearing the cached exit and auto-selecting now",
|
||||||
|
ExitChoice::Auto => "re-selecting immediately",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
tunnel.shutdown().await;
|
tunnel.shutdown().await;
|
||||||
if choice == ExitChoice::Auto {
|
match choice {
|
||||||
delay = (delay * 2).min(Duration::from_secs(60));
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -306,6 +383,17 @@ fn run_tunnel() {
|
|||||||
}
|
}
|
||||||
fell_back = false;
|
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() => {
|
ExitChoice::Auto if anchor.is_some() => {
|
||||||
fell_back = true;
|
fell_back = true;
|
||||||
info!(
|
info!(
|
||||||
@@ -315,6 +403,18 @@ fn run_tunnel() {
|
|||||||
}
|
}
|
||||||
ExitChoice::Auto => {}
|
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());
|
*TUNNEL.write() = Some(tunnel.clone());
|
||||||
MIXNET_READY.store(true, Ordering::Relaxed);
|
MIXNET_READY.store(true, Ordering::Relaxed);
|
||||||
// Prewarm the scoped money-path exit ONCE, now that the tunnel is
|
// Prewarm the scoped money-path exit ONCE, now that the tunnel is
|
||||||
@@ -327,6 +427,13 @@ fn run_tunnel() {
|
|||||||
{
|
{
|
||||||
tokio::spawn(super::streamexit::prewarm());
|
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);
|
delay = Duration::from_secs(5);
|
||||||
// Hold the exit warm and govern its health. The watchdog weighs TWO
|
// Hold the exit warm and govern its health. The watchdog weighs TWO
|
||||||
// signals: the cheap DNS keepalive (as before) AND — authoritatively,
|
// signals: the cheap DNS keepalive (as before) AND — authoritatively,
|
||||||
@@ -360,21 +467,36 @@ fn run_tunnel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if choice == ExitChoice::Anchor {
|
// A cached entry gateway that errored is not reused for the rest
|
||||||
// Anchor unreachable (not bonded yet / condemned by the
|
// of this process (disk kept — it may be a transient blip).
|
||||||
// network / bad address): fall back to auto-select
|
if used_cached_gw {
|
||||||
// IMMEDIATELY — no backoff, connectivity first.
|
cached_gw = None;
|
||||||
warn!(
|
}
|
||||||
"[timing] nym: ANCHOR failed to build: {e}; FALLBACK to auto-select now"
|
match choice {
|
||||||
);
|
ExitChoice::Anchor => {
|
||||||
continue;
|
// 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;
|
let mut relay_lost: Option<Instant> = None;
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(WATCH_TICK).await;
|
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
|
// (1) Relay reachability — authoritative, but ONLY when a nostr consumer
|
||||||
// actually wants relays on this exit. No consumer → the DNS keepalive
|
// actually wants relays on this exit. No consumer → the DNS keepalive
|
||||||
// below is the sole health signal, exactly as before this hardening.
|
// 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
|
/// own co-located exit is the separate scoped-MixnetStream egress; this
|
||||||
/// selector governs only the public-IPR fallback layer.)
|
/// selector governs only the public-IPR fallback layer.)
|
||||||
Anchor,
|
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.
|
/// A public exit auto-selected from the network pool — the FALLBACK.
|
||||||
Auto,
|
Auto,
|
||||||
}
|
}
|
||||||
@@ -559,6 +697,7 @@ impl ExitChoice {
|
|||||||
fn label(self) -> &'static str {
|
fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
ExitChoice::Anchor => "ANCHOR",
|
ExitChoice::Anchor => "ANCHOR",
|
||||||
|
ExitChoice::Cached => "cached",
|
||||||
ExitChoice::Auto => "auto-selected",
|
ExitChoice::Auto => "auto-selected",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,27 +723,38 @@ impl ExitChoice {
|
|||||||
struct ExitSelector {
|
struct ExitSelector {
|
||||||
/// Whether the anchor has been tried in the current select cycle.
|
/// Whether the anchor has been tried in the current select cycle.
|
||||||
anchor_tried: bool,
|
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 {
|
impl ExitSelector {
|
||||||
const fn new() -> Self {
|
const fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
anchor_tried: false,
|
anchor_tried: false,
|
||||||
|
cached_tried: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The exit to target for the next build attempt.
|
/// The exit to target for the next build attempt. Order per cycle:
|
||||||
fn next_choice(&mut self, anchor_available: bool) -> ExitChoice {
|
/// 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 {
|
if anchor_available && !self.anchor_tried {
|
||||||
self.anchor_tried = true;
|
self.anchor_tried = true;
|
||||||
ExitChoice::Anchor
|
ExitChoice::Anchor
|
||||||
|
} else if cached_available && !self.cached_tried {
|
||||||
|
self.cached_tried = true;
|
||||||
|
ExitChoice::Cached
|
||||||
} else {
|
} else {
|
||||||
ExitChoice::Auto
|
ExitChoice::Auto
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tunnel was published: the select cycle is over. Re-arms the anchor for
|
/// 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) {
|
fn tunnel_published(&mut self) {
|
||||||
self.anchor_tried = false;
|
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
|
/// 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
|
/// with an auto-selected exit. When `entry_gateway` is set, the client REQUESTS
|
||||||
/// identity per run — no sqlite, no persisted gateway).
|
/// 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
|
/// 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
|
/// back to `None` (see [`ExitSelector`]) or the single-exit SPOF — and a
|
||||||
/// single party seeing all exit traffic — comes back.
|
/// 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::DebugConfig;
|
||||||
use nym_sdk::ipr_wrapper::IpMixStream;
|
use nym_sdk::ipr_wrapper::IpMixStream;
|
||||||
use nym_sdk::mixnet::MixnetClientBuilder;
|
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.
|
// Mirror the mainnet env setup the SDK's own constructors run before connect.
|
||||||
nym_sdk::setup_env(None::<&std::path::Path>);
|
nym_sdk::setup_env(None::<&std::path::Path>);
|
||||||
let client = MixnetClientBuilder::new_ephemeral()
|
let mut builder = MixnetClientBuilder::new_ephemeral().debug_config(cfg);
|
||||||
.debug_config(cfg)
|
// Warm-connect: ask for last run's entry gateway. With ephemeral storage this
|
||||||
.build()?
|
// is a Specified gateway selection with no persisted keys.
|
||||||
.connect_to_mixnet()
|
if let Some(gw) = entry_gateway {
|
||||||
.await?;
|
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
|
// Capture the ENTRY GATEWAY actually used, from the client's own nym-address,
|
||||||
// same discovery the untuned `IpMixStream::new` path used, so anchor/fallback
|
// BEFORE `from_client` consumes the client.
|
||||||
// selection in `run_tunnel` is unchanged.
|
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 {
|
let ipr = match pin {
|
||||||
Some(recipient) => recipient,
|
Some(recipient) => recipient,
|
||||||
None => IpMixStream::best_ipr().await?,
|
None => IpMixStream::best_ipr().await?,
|
||||||
};
|
};
|
||||||
let stream = IpMixStream::from_client(client, 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)]
|
#[cfg(test)]
|
||||||
@@ -701,50 +873,82 @@ mod tests {
|
|||||||
fn no_anchor_is_pure_auto_select() {
|
fn no_anchor_is_pure_auto_select() {
|
||||||
let mut s = ExitSelector::new();
|
let mut s = ExitSelector::new();
|
||||||
for _ in 0..5 {
|
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.
|
// Publishing changes nothing without an anchor.
|
||||||
s.tunnel_published();
|
s.tunnel_published();
|
||||||
assert_eq!(s.next_choice(false), ExitChoice::Auto);
|
assert_eq!(s.next_choice(false, false), ExitChoice::Auto);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn anchor_first_then_auto_within_a_cycle() {
|
fn anchor_first_then_auto_within_a_cycle() {
|
||||||
let mut s = ExitSelector::new();
|
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.
|
// Anchor failed — every further attempt in the cycle falls back.
|
||||||
assert_eq!(s.next_choice(true), ExitChoice::Auto);
|
assert_eq!(s.next_choice(true, false), ExitChoice::Auto);
|
||||||
assert_eq!(s.next_choice(true), ExitChoice::Auto);
|
assert_eq!(s.next_choice(true, false), ExitChoice::Auto);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn anchor_retried_on_the_next_cycle_after_a_fallback() {
|
fn anchor_retried_on_the_next_cycle_after_a_fallback() {
|
||||||
let mut s = ExitSelector::new();
|
let mut s = ExitSelector::new();
|
||||||
// Cycle 1: anchor fails, a fallback exit gets published.
|
// Cycle 1: anchor fails, a fallback exit gets published.
|
||||||
assert_eq!(s.next_choice(true), ExitChoice::Anchor);
|
assert_eq!(s.next_choice(true, false), ExitChoice::Anchor);
|
||||||
assert_eq!(s.next_choice(true), ExitChoice::Auto);
|
assert_eq!(s.next_choice(true, false), ExitChoice::Auto);
|
||||||
s.tunnel_published();
|
s.tunnel_published();
|
||||||
// Cycle 2 (the reselect after the fallback): anchor first again.
|
// 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]
|
#[test]
|
||||||
fn anchor_publish_also_rearms_the_anchor() {
|
fn anchor_publish_also_rearms_the_anchor() {
|
||||||
let mut s = ExitSelector::new();
|
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
|
s.tunnel_published(); // the anchor itself came up
|
||||||
// Condemned later → next cycle prefers the anchor again.
|
// 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]
|
#[test]
|
||||||
fn anchor_appearing_mid_cycle_is_tried() {
|
fn anchor_appearing_mid_cycle_is_tried() {
|
||||||
let mut s = ExitSelector::new();
|
let mut s = ExitSelector::new();
|
||||||
// No anchor yet (env unset / invalid): auto, without burning the try.
|
// 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.
|
// 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, false), ExitChoice::Anchor);
|
||||||
assert_eq!(s.next_choice(true), ExitChoice::Auto);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -93,6 +93,23 @@ pub struct AppConfig {
|
|||||||
check_updates: Option<bool>,
|
check_updates: Option<bool>,
|
||||||
/// Application update information.
|
/// Application update information.
|
||||||
app_update: Option<AppUpdate>,
|
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.
|
/// 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.
|
// update check — payments, relays and identity still stay mixnet-only.
|
||||||
check_updates: Some(true),
|
check_updates: Some(true),
|
||||||
app_update: None,
|
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();
|
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.
|
/// Check if proxy for network requests is needed.
|
||||||
pub fn use_proxy() -> bool {
|
pub fn use_proxy() -> bool {
|
||||||
let r_config = Settings::app_config_to_read();
|
let r_config = Settings::app_config_to_read();
|
||||||
|
|||||||
Reference in New Issue
Block a user