From fdad8933c461f4c8068465f5b16cdaedccfd484e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 11:30:34 +0200 Subject: [PATCH] fix(network-config): require ESPLORA_URL + ESPLORA_WS_URL on Mainnet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NETWORK_CONFIG` silently fell back to the Mutinynet defaults (`https://mutinynet.com/api`, `wss://mutinynet.com/api/v1/ws`) when the env var was missing, regardless of `IS_MAINNET`. On DEV that matches the chain. On Mainnet it's a silent footgun: - An HTTP-only mismatch panics quickly on the first publisher round-trip and the operator sees the breakage immediately. - The new event-driven scanner (#84) instead subscribes to Mutinynet block events and tries to fetch them from the Mainnet HTTP Esplora. Every `get_block_txids` returns 404 and `scanner_runtime` enters a 5 s HTTP-retry loop that never updates `processed_blocks`: the service stays up, `/health/ready` reports green, no chain ingestion happens, no on-chain mint or send commit is ever picked up. The binary never self-heals after restart because no env-derived state has changed. `ESPLORA_URL` and `ESPLORA_WS_URL` are now both **required env vars when `IS_MAINNET=true`** (panic with diagnostic message, mirroring the existing `PUBLISHER_KEY` / `USERNAME_DOMAIN` / `DATABASE_URL` idiom in the same file). Empty / whitespace-only values are treated as unset so a `ESPLORA_URL=` line in a compose file panics with the same message instead of leaving `EsploraConfig.url = ""`. The `IS_MAINNET=false` (DEV / Mutinynet) path is unchanged — both URLs keep their Mutinynet defaults, the pre-push hook and the M3 Ultra coverage gate are unaffected. Implementation: pulled the env-resolution out of the `lazy_static!` block into a pure `build_network_config_from_env(env: F)` so the panic rules are unit-testable without `std::env::set_var` (which would poison the `NETWORK_CONFIG` cell across tests in the same binary). Seven new `#[test]`s cover the headline shapes plus the empty-string and whitespace-only rejection paths. The guard is enforced at the `NETWORK_CONFIG` access path only. `scanner_ws::ScannerWsConfig::from_env` and `publisher.rs` still read `ESPLORA_WS_URL` independently with the Mutinynet fallback — in the main binary the panic in this builder fires first (main.rs dereferences `NETWORK_CONFIG` during bootstrap, before any scanner or publisher env read), so the structural bypass is unreachable today. Closing that bypass by having those sites consume `NETWORK_CONFIG.ws_url` directly is tracked as a follow-up. --- node/src/lib.rs | 118 ++++++++++++++++++++++++++----- node/src/main_tests.rs | 157 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 247 insertions(+), 28 deletions(-) diff --git a/node/src/lib.rs b/node/src/lib.rs index a3f43208..e91e02c3 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -44,24 +44,108 @@ use sqlx::PgPool; use std::str::FromStr; use zkcoins_program::hash::HashDigest; -lazy_static! { - pub static ref NETWORK_CONFIG: EsploraConfig = { - let url = std::env::var("ESPLORA_URL") - .unwrap_or_else(|_| "https://mutinynet.com/api".to_string()); - let is_mainnet = std::env::var("IS_MAINNET") - .map(|v| v == "true") - .unwrap_or(false); - let network_name = std::env::var("NETWORK_NAME") - .unwrap_or_else(|_| if is_mainnet { "Mainnet".to_string() } else { "Mutinynet".to_string() }); - let ws_url = std::env::var("ESPLORA_WS_URL").ok(); - println!( - "Network config: {} ({}) ws={}", - network_name, - url, - ws_url.as_deref().unwrap_or(crate::scanner_ws::DEFAULT_ESPLORA_WS_URL) - ); - EsploraConfig { url, is_mainnet, network_name, ws_url, track_tx_timeout: None } +/// Pure builder for `NETWORK_CONFIG`. Extracted so the env-resolution +/// logic — in particular the panic-on-missing rules below — is +/// unit-testable without touching the process-wide environment or the +/// `lazy_static` cell (whose state would leak across tests in the same +/// binary). +/// +/// ## Mainnet vs Mutinynet defaults +/// +/// `ESPLORA_URL` and `ESPLORA_WS_URL` have Mutinynet defaults +/// (`https://mutinynet.com/api`, `wss://mutinynet.com/api/v1/ws`) +/// throughout the codebase. They are convenient for DEV (Mutinynet +/// the chain) and harmless for unit/integration tests. But on Mainnet +/// they are silent footguns: an `IS_MAINNET=true` deployment that +/// forgets to set either env publishes Mutinynet block events into +/// the scanner and / or fetches the wrong chain over REST. The +/// failure mode is asymmetric — an HTTP-only mismatch panics quickly +/// on the first publisher round-trip, but the event-driven scanner +/// (#84) sits in a 5 s HTTP-retry loop with no forward progress and +/// a green `/health/ready`. +/// +/// To remove the footgun, both URLs are **required env vars when +/// `IS_MAINNET=true`** — the panic mirrors the existing pattern for +/// `PUBLISHER_KEY`, `USERNAME_DOMAIN`, and `DATABASE_URL`. Empty- +/// string values are treated as unset on the Mainnet path so a +/// misconfigured compose file (`ESPLORA_URL=`) panics with the same +/// diagnostic instead of silently producing `EsploraConfig.url = ""`. +/// When `IS_MAINNET` is unset or `false`, the Mutinynet defaults +/// continue to apply — DEV, the pre-push hook, and the M3 Ultra +/// coverage gate are all unaffected. +/// +/// ## Scope of the guard +/// +/// Only the `NETWORK_CONFIG` access path is hardened here. +/// `scanner_ws::ScannerWsConfig::from_env` and `publisher.rs` still +/// call `std::env::var("ESPLORA_WS_URL")` independently with a +/// Mutinynet fallback. In the production binary the panic in this +/// builder fires before any of those reads — `main.rs` dereferences +/// `NETWORK_CONFIG` during bootstrap — so the structural bypass is +/// unreachable today. A follow-up that has those sites consume +/// `NETWORK_CONFIG.ws_url` (or an explicit `&EsploraConfig`) directly +/// would close the bypass for future entry points and is tracked as +/// a separate refactor. +pub fn build_network_config_from_env(env: F) -> EsploraConfig +where + F: Fn(&str) -> Option, +{ + // Treat empty strings as "unset" on the Mainnet path. Without + // this, `ESPLORA_URL=` in a compose file bypasses the `expect` + // below and leaves `EsploraConfig.url = ""` — the same class of + // silent misconfiguration the panic is designed to surface. + let env_or_unset = |k: &str| env(k).filter(|v| !v.trim().is_empty()); + let is_mainnet = env_or_unset("IS_MAINNET").as_deref() == Some("true"); + let url = if is_mainnet { + env_or_unset("ESPLORA_URL").expect( + "IS_MAINNET=true requires ESPLORA_URL to be set to a non-empty value — \ + the Mutinynet default is unsafe on Mainnet. Set ESPLORA_URL \ + to a Mainnet HTTP Esplora endpoint (e.g. http://electrs-mainnet:3000 \ + on the DFX Mainnet stack, or https://mempool.space/api)", + ) + } else { + env_or_unset("ESPLORA_URL").unwrap_or_else(|| "https://mutinynet.com/api".to_string()) + }; + let ws_url = if is_mainnet { + Some(env_or_unset("ESPLORA_WS_URL").expect( + "IS_MAINNET=true requires ESPLORA_WS_URL to be set to a non-empty value — \ + the Mutinynet default (wss://mutinynet.com/api/v1/ws) is unsafe on \ + Mainnet: the event-driven scanner subscribes to Mutinynet block \ + events and 404s against the Mainnet HTTP Esplora in a 5 s retry \ + loop with no forward progress (zk-coins/node #84). Set \ + ESPLORA_WS_URL to a Mainnet mempool.space-compatible WebSocket \ + (e.g. wss://mempool.space/api/v1/ws)", + )) + } else { + env_or_unset("ESPLORA_WS_URL") }; + let network_name = env_or_unset("NETWORK_NAME").unwrap_or_else(|| { + if is_mainnet { + "Mainnet".to_string() + } else { + "Mutinynet".to_string() + } + }); + println!( + "Network config: {} ({}) ws={}", + network_name, + url, + ws_url + .as_deref() + .unwrap_or(crate::scanner_ws::DEFAULT_ESPLORA_WS_URL) + ); + EsploraConfig { + url, + is_mainnet, + network_name, + ws_url, + track_tx_timeout: None, + } +} + +lazy_static! { + pub static ref NETWORK_CONFIG: EsploraConfig = + build_network_config_from_env(|k| std::env::var(k).ok()); /// Domain used by the client to render `@`. /// Distinct from `network_name` because the same Bitcoin network diff --git a/node/src/main_tests.rs b/node/src/main_tests.rs index 8f2e003f..490b358a 100644 --- a/node/src/main_tests.rs +++ b/node/src/main_tests.rs @@ -1,19 +1,154 @@ -// Bootstrap-level tests for `main.rs`. -// -// Today the only thing here is regression coverage for the -// `block_in_place(block_on(...))` bridge used inside the scanner's -// synchronous `InscriptionCallback`. Without `block_in_place`, the -// naive `Handle::current().block_on(persist_state_tx(…))` form panics -// at runtime on the multi_thread tokio runtime (the default for -// `#[tokio::main]`) — and "runtime" here means "the first time the -// scanner sees a real inscription on Mutinynet". CI did not catch the -// original form because no integration test ever drove the sync -// callback through a real multi_thread worker; this test does. +// Bootstrap-level tests for `main.rs` and the lib-root helpers it +// invokes (`build_network_config_from_env`, the +// `persist_state_from_sync_context` bridge). use super::*; use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; use testcontainers_modules::postgres::Postgres; +// --- build_network_config_from_env ------------------------------- +// +// These tests cover the panic-on-missing rules for the Mainnet path. +// They use a fake `env` closure rather than `std::env::set_var` so +// the panic side-effect cannot poison the `NETWORK_CONFIG` +// lazy_static cell (shared across tests in this binary) and so the +// tests do not race other test threads via the process-wide +// environment. + +/// Build a closure-shaped env from a slice so the tests read like a +/// table. Returns the first matching value or `None`. +fn fake_env(entries: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |k| { + entries.iter().find_map(|(name, value)| { + if *name == k { + Some((*value).to_string()) + } else { + None + } + }) + } +} + +#[test] +fn build_network_config_defaults_to_mutinynet_when_is_mainnet_unset() { + let cfg = build_network_config_from_env(fake_env(&[])); + assert!(!cfg.is_mainnet); + assert_eq!(cfg.url, "https://mutinynet.com/api"); + assert_eq!(cfg.network_name, "Mutinynet"); + assert!(cfg.ws_url.is_none()); +} + +#[test] +fn build_network_config_defaults_to_mutinynet_when_is_mainnet_is_not_true() { + // Any value other than the literal string "true" is treated as + // "not mainnet" — same semantics as the legacy `.map(|v| v == "true")` + // pattern. Guards against accidental "TRUE" / "1" / "yes" thinking + // it switches the network. + let cfg = build_network_config_from_env(fake_env(&[("IS_MAINNET", "1")])); + assert!(!cfg.is_mainnet); + assert_eq!(cfg.url, "https://mutinynet.com/api"); + assert!(cfg.ws_url.is_none()); +} + +#[test] +fn build_network_config_respects_explicit_urls_on_mutinynet() { + let cfg = build_network_config_from_env(fake_env(&[ + ("ESPLORA_URL", "http://electrs-mutinynet:3000"), + ("ESPLORA_WS_URL", "wss://example.test/ws"), + ("NETWORK_NAME", "Custom"), + ])); + assert!(!cfg.is_mainnet); + assert_eq!(cfg.url, "http://electrs-mutinynet:3000"); + assert_eq!(cfg.ws_url.as_deref(), Some("wss://example.test/ws")); + assert_eq!(cfg.network_name, "Custom"); +} + +#[test] +fn build_network_config_full_mainnet() { + let cfg = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_URL", "http://electrs-mainnet:3000"), + ("ESPLORA_WS_URL", "wss://mempool.space/api/v1/ws"), + ])); + assert!(cfg.is_mainnet); + assert_eq!(cfg.url, "http://electrs-mainnet:3000"); + assert_eq!(cfg.ws_url.as_deref(), Some("wss://mempool.space/api/v1/ws")); + assert_eq!(cfg.network_name, "Mainnet"); +} + +#[test] +fn build_network_config_mainnet_with_explicit_network_name() { + // Mainnet path with `NETWORK_NAME` set: the override must win + // over the `if is_mainnet { "Mainnet" } else { "Mutinynet" }` + // default branch. Documents that operators can rename the chain + // label (e.g. "Mainnet-Canary") without changing IS_MAINNET. + let cfg = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_URL", "http://electrs-mainnet:3000"), + ("ESPLORA_WS_URL", "wss://mempool.space/api/v1/ws"), + ("NETWORK_NAME", "Mainnet-Canary"), + ])); + assert!(cfg.is_mainnet); + assert_eq!(cfg.network_name, "Mainnet-Canary"); +} + +#[test] +#[should_panic(expected = "IS_MAINNET=true requires ESPLORA_URL")] +fn build_network_config_panics_on_mainnet_missing_esplora_url() { + let _ = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_WS_URL", "wss://mempool.space/api/v1/ws"), + ])); +} + +#[test] +#[should_panic(expected = "IS_MAINNET=true requires ESPLORA_URL")] +fn build_network_config_panics_on_mainnet_empty_esplora_url() { + // `ESPLORA_URL=` in a compose file resolves to `Some("")`. Without + // the empty-string filter in `env_or_unset`, the `expect` would + // be bypassed and `EsploraConfig.url` would be left as `""` — + // exactly the silent-misconfiguration class the panic is meant + // to catch. + let _ = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_URL", ""), + ("ESPLORA_WS_URL", "wss://mempool.space/api/v1/ws"), + ])); +} + +#[test] +#[should_panic(expected = "IS_MAINNET=true requires ESPLORA_WS_URL")] +fn build_network_config_panics_on_mainnet_missing_esplora_ws_url() { + let _ = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_URL", "http://electrs-mainnet:3000"), + ])); +} + +#[test] +#[should_panic(expected = "IS_MAINNET=true requires ESPLORA_WS_URL")] +fn build_network_config_panics_on_mainnet_whitespace_esplora_ws_url() { + // Whitespace-only values are also rejected — same misconfiguration + // class as the empty string, just easier to miss in a diff. + let _ = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_URL", "http://electrs-mainnet:3000"), + ("ESPLORA_WS_URL", " "), + ])); +} + +// --- persist_state_from_sync_context ----------------------------- +// +// Regression coverage for the `block_in_place(block_on(...))` bridge +// used inside the scanner's synchronous `InscriptionCallback`. +// Without `block_in_place`, the naive +// `Handle::current().block_on(persist_state_tx(…))` form panics at +// runtime on the multi_thread tokio runtime (the default for +// `#[tokio::main]`) — and "runtime" here means "the first time the +// scanner sees a real inscription on Mutinynet". CI did not catch +// the original form because no integration test ever drove the sync +// callback through a real multi_thread worker; this test does. + /// Spin up a fresh `postgres:17` container, run all migrations, and /// return the live pool. Mirrors `db_tests::setup_pool` but lives in /// this file so the `main.rs` test module stays self-contained.