Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 101 additions & 17 deletions node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F>(env: F) -> EsploraConfig
where
F: Fn(&str) -> Option<String>,
{
// 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 `<hex|username>@<domain>`.
/// Distinct from `network_name` because the same Bitcoin network
Expand Down
157 changes: 146 additions & 11 deletions node/src/main_tests.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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.
Expand Down
Loading