From 9162fb1dc963b2bef44eb9d1de3fddf645356bba Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 00:51:56 -0700 Subject: [PATCH 1/4] test(peer): stub for genesis bring-up e2e (#240) Co-Authored-By: Claude --- crates/dig-node-core/tests/genesis_bringup.rs | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 crates/dig-node-core/tests/genesis_bringup.rs diff --git a/crates/dig-node-core/tests/genesis_bringup.rs b/crates/dig-node-core/tests/genesis_bringup.rs new file mode 100644 index 00000000..795e4114 --- /dev/null +++ b/crates/dig-node-core/tests/genesis_bringup.rs @@ -0,0 +1,2 @@ +//! WIP placeholder: end-to-end proof that peer-network bring-up reaches the +//! downstream engines with the real default genesis. See dig-node#240. From cfbea04341bb2a1735db9137af1474f36f3b5e04 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 01:06:21 -0700 Subject: [PATCH 2/4] test(peer): prove bring-up installs the downstream engines on the real genesis (#240) The acceptance test dig-node#240 has been open for: with the default DIG mainnet genesis and no DIG_NETWORK_GENESIS override, the peer-network bring-up gets past gossip-config validation and installs the pool handle, the P2P content engine and the DHT inventory-refresh hook, then binds the mTLS peer-RPC listener. Asserts the DOWNSTREAM post-conditions rather than peerStatus.running, which is set before GossipService::new and so holds even when the pool, DHT, content engine and PEX all fail. Also corrects a stale doc-comment that still described the genesis as a pre-launch placeholder that invalidates the gossip config -- the reason the #213 test sees no P2P convergence is environmental (relay off, loopback only), not a rejected config. Co-Authored-By: Claude --- crates/dig-node-core/src/lib.rs | 12 +- .../src/seams/dig_peer/peer_network.rs | 10 + crates/dig-node-core/tests/genesis_bringup.rs | 189 +++++++++++++++++- 3 files changed, 205 insertions(+), 6 deletions(-) diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index d155ea0e..2dce9901 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -6119,10 +6119,14 @@ mod tests { /// **Proves (#213):** driving the REAL peer-network bring-up the OS service now invokes /// ([`peer::spawn_peer_network`]) starts the §14 chain-watch loop, which PROACTIVELY pulls a /// subscribed store's missing generation from a local "peer" (a real auth-required §21 remote) - /// with NO client read triggering the miss — EVEN THOUGH the P2P pool/DHT bring-up cannot come up - /// in this env (the pre-launch placeholder network genesis makes the gossip config invalid). That - /// is the whole point of the §14 decoupling: autonomous sync must run regardless of the P2P - /// layer's health. Hermetic + mainnet-safe: relay OFF, ephemeral peer port, a deterministic mock + /// with NO client read triggering the miss — INDEPENDENTLY of the P2P pool/DHT bring-up, which in + /// this env has no peer to reach (relay OFF, loopback only) and so converges on nothing. That is + /// for ENVIRONMENTAL reasons, NOT a rejected gossip config: the default network genesis is a REAL + /// non-zero value ([`peer::genesis_challenge_from_env`], see `peer.rs`), so the config is valid and + /// the bring-up itself proceeds — proved separately by + /// `tests/genesis_bringup.rs::default_genesis_brings_up_the_pool_dht_content_engine_and_peer_rpc_listener` + /// (dig-node#240). That independence is the whole point of the §14 decoupling: autonomous sync must + /// run regardless of the P2P layer's health. Hermetic + mainnet-safe: relay OFF, ephemeral peer port, a deterministic mock /// anchored-root resolver, a 1 s watch tick, the upstream a real §21 remote holding the generation. /// **Catches:** the exact #213 gap — chain-watch gated behind a pool/DHT bring-up that fails, so /// autonomous sync never actually runs even after the service wires the call. diff --git a/crates/dig-node-core/src/seams/dig_peer/peer_network.rs b/crates/dig-node-core/src/seams/dig_peer/peer_network.rs index 3b9f1587..1b9720c6 100644 --- a/crates/dig-node-core/src/seams/dig_peer/peer_network.rs +++ b/crates/dig-node-core/src/seams/dig_peer/peer_network.rs @@ -32,6 +32,12 @@ pub trait PeerNetwork: Send + Sync { /// the FFI path never does). Idempotent — a second install is ignored. fn set_inventory_refresher(&self, refresher: InventoryRefresher); + /// Whether the DHT inventory-refresh hook is installed -- i.e. whether the bring-up got as far + /// as step 4c. Observable so an integration test can assert the DOWNSTREAM post-condition of a + /// successful pool + DHT bring-up rather than the `running` flag, which is set before the pool + /// exists and therefore holds even when every later step fails (dig-node#240). + fn has_inventory_refresher(&self) -> bool; + /// Retain the live gossip pool handle (the standalone peer-network bring-up calls this once with /// the [`dig_gossip::GossipHandle`] it starts; the FFI path never does). Idempotent — a second /// install is ignored. Enables the control surface to dial peers + enumerate the connected pool. @@ -66,6 +72,10 @@ impl PeerNetwork for Node { let _ = self.inventory_refresher.set(refresher); } + fn has_inventory_refresher(&self) -> bool { + self.inventory_refresher.get().is_some() + } + fn set_gossip_handle(&self, handle: dig_gossip::GossipHandle) { let _ = self.gossip.set(handle); } diff --git a/crates/dig-node-core/tests/genesis_bringup.rs b/crates/dig-node-core/tests/genesis_bringup.rs index 795e4114..081f55bc 100644 --- a/crates/dig-node-core/tests/genesis_bringup.rs +++ b/crates/dig-node-core/tests/genesis_bringup.rs @@ -1,2 +1,187 @@ -//! WIP placeholder: end-to-end proof that peer-network bring-up reaches the -//! downstream engines with the real default genesis. See dig-node#240. +//! End-to-end proof that the peer-network bring-up reaches its DOWNSTREAM engines against the +//! REAL default DIG mainnet genesis, with no `DIG_NETWORK_GENESIS` override (dig-node#240). +//! +//! # What this asserts, and why it is the right decision point +//! +//! `run_peer_network` proceeds: derive the node identity -> `status.set_running` -> build the +//! `GossipConfig` with `network_id = ` -> `GossipService::new` -> `.start()` -> pool -> +//! DHT -> `set_p2p_content` -> `set_inventory_refresher` -> mTLS peer-RPC listener -> PEX. +//! +//! `running == true` is set BEFORE `GossipService::new` and so holds even when the pool, the DHT, +//! the content engine and PEX all fail. It is not evidence of anything downstream and is +//! deliberately NOT asserted here. Instead this test asserts the post-conditions that are strictly +//! downstream of a SUCCESSFUL `GossipService::new().start()`: +//! +//! * `Node::gossip_handle()` is `Some` -- the pool started (step 3 is past the gossip-config +//! validation the old "placeholder genesis" world failed at). +//! * `Node::p2p_content()` is `Some` -- the P2P content engine is installed, which is wired ONLY +//! when the DHT is up (steps 4a/4b). +//! * `Node::has_inventory_refresher()` -- the DHT inventory-refresh hook is installed (step 4c). +//! * the mTLS peer-RPC listener ACCEPTS a loopback TCP connection (step 5), which is bound after +//! every step above. +//! +//! # What this does NOT prove, stated plainly +//! +//! * **PEX behaviour.** `PexServing` is constructed and threaded onto the listener this test +//! connects to, so it is built; no peer exchange happens with zero peers, and none is asserted. +//! * **Peers, discovery, or provider records.** The run is hermetic: the relay/introducer is OFF +//! (`DIG_RELAY_URL=off`, which also means no STUN and no relay reservation), so the pool has +//! zero members, the DHT bootstrap finds nobody, and the initial inventory announce reaches +//! nothing. The DHT COMES UP; it does not converge. Proving convergence needs more than one +//! host and is the multi-node e2e's job, not this test's. +//! * **The relay reservation and the relay accept loop**, which are not wired with the relay off. +//! +//! # Hermetic +//! +//! No real network: relay off, upstream pinned at an unroutable loopback port, identity + cert + +//! cache dirs in `TempDir`s (removed on unwind), listeners on OS-allocated free ports. Nothing here +//! reaches mainnet or any DIG host. +//! +//! # Env +//! +//! This file is its OWN test binary and holds exactly ONE test, so the process-global env it sets +//! cannot contaminate a sibling test. `DIG_NETWORK_GENESIS` is deliberately REMOVED rather than +//! set: the whole claim is about the DEFAULT. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dig_node_core::peer::{genesis_challenge_from_env, install_crypto_provider, spawn_peer_network}; +use dig_node_core::seams::dig_peer::peer_network::PeerNetwork; +use dig_node_core::Node; + +/// An OS-allocated free TCP port. Bound then dropped, so the port is free when the node claims it. +/// A racing binder could steal it; that would surface as a bring-up error, never as a false PASS. +fn free_port() -> u16 { + let l = std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port"); + let port = l.local_addr().expect("a bound addr").port(); + drop(l); + port +} + +/// Whether the mTLS peer-RPC listener accepts a plain TCP connection on `port`. A TCP connect is +/// enough: the question is whether the listener is BOUND (step 5), not what it speaks. Both +/// loopback families are tried because the listener is a dual-stack `[::]` bind. +fn listener_accepts(port: u16) -> bool { + let timeout = Duration::from_millis(500); + for addr in [ + std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, port)), + std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port)), + ] { + if std::net::TcpStream::connect_timeout(&addr, timeout).is_ok() { + return true; + } + } + false +} + +/// **Proves (#240):** with the REAL default DIG mainnet genesis and NO `DIG_NETWORK_GENESIS` +/// override, the bring-up gets past gossip-config validation and installs every downstream engine +/// -- the gossip pool handle, the P2P content engine, the DHT inventory-refresh hook -- and binds +/// the mTLS peer-RPC listener. +/// +/// **Catches:** the world this ticket was filed in, where the genesis was an all-zero placeholder +/// that `GossipService::new` rejects, so steps 3-8 never ran. It also catches any future change +/// that hands gossip an invalid `network_id` by default. It is NOT satisfied by +/// `control.peerStatus.running`, which is set before the pool exists. +#[test] +fn default_genesis_brings_up_the_pool_dht_content_engine_and_peer_rpc_listener() { + let cache = tempfile::Builder::new() + .prefix("dig-240-cache-") + .tempdir() + .expect("a cache dir"); + let identity = tempfile::Builder::new() + .prefix("dig-240-identity-") + .tempdir() + .expect("an identity dir"); + + let peer_port = free_port(); + let gossip_port = free_port(); + + // Hermetic + mainnet-safe. The relay off-token also disables the introducer, STUN and the + // relay reservation, so nothing in this run resolves or dials a DIG host. + std::env::set_var("DIG_NODE_CACHE", cache.path()); + std::env::set_var("DIG_IDENTITY_DIR", identity.path()); + std::env::set_var("DIG_RELAY_URL", "off"); + std::env::set_var("DIG_BOOTSTRAP_PEERS", "off"); + std::env::set_var("DIG_NODE_UPSTREAM", "http://127.0.0.1:1/"); + std::env::set_var("DIG_PEER_PORT", peer_port.to_string()); + std::env::set_var("DIG_GOSSIP_PORT", gossip_port.to_string()); + // The claim under test is about the DEFAULT genesis, so the override must be ABSENT. Note that + // an all-zero override would NOT reproduce the old failure anyway: `genesis_challenge_from` + // collapses every invalid value, all-zero included, back to the real genesis. + std::env::remove_var("DIG_NETWORK_GENESIS"); + std::env::remove_var("DIG_NETWORK_ID"); + std::env::remove_var("DIG_PEER_NETWORK"); // unset -> default ON + + // The precondition, restated at the point of use: the effective genesis really is the canonical + // non-zero DIG mainnet value, so the gossip config this bring-up builds is a valid one. + let genesis = genesis_challenge_from_env(); + assert_eq!( + genesis, + dig_constants::DIG_MAINNET.genesis_challenge(), + "no override is set, so the effective genesis is the canonical DIG mainnet genesis" + ); + assert_ne!( + genesis.to_bytes(), + [0u8; 32], + "the canonical genesis is non-zero, and all-zero is the only network_id dig-gossip rejects" + ); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("a tokio runtime"); + rt.block_on(async move { + let node: Arc = Node::from_env(); + + assert!( + node.gossip_handle().is_none() && node.p2p_content().is_none(), + "nothing is installed before the bring-up runs" + ); + + install_crypto_provider(); + spawn_peer_network(node.clone()); + + // `run_peer_network` never returns (it ends in the accept loop), so poll the + // post-conditions rather than awaiting it. + let deadline = Instant::now() + Duration::from_secs(90); + let mut ready = false; + while Instant::now() < deadline { + if node.gossip_handle().is_some() + && node.p2p_content().is_some() + && node.has_inventory_refresher() + { + ready = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + assert!( + ready, + "the bring-up must install the pool handle, the P2P content engine and the DHT \ + inventory-refresh hook against the default genesis; pool={} content={} refresher={}", + node.gossip_handle().is_some(), + node.p2p_content().is_some(), + node.has_inventory_refresher(), + ); + + // Step 5: the mTLS peer-RPC listener is bound AFTER every assertion above, so this is the + // last observable point of the bring-up sequence. + let deadline = Instant::now() + Duration::from_secs(30); + let mut bound = false; + while Instant::now() < deadline { + if listener_accepts(peer_port) { + bound = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + bound, + "the mTLS peer-RPC listener must be bound on port {peer_port} once bring-up is past \ + the content engine" + ); + }); +} From 84aff4bce8ecfdbb7ad5398d03def60e99b8a5f6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 01:17:59 -0700 Subject: [PATCH 3/4] style(peer): rustfmt the #240 bring-up test Co-Authored-By: Claude --- crates/dig-node-core/tests/genesis_bringup.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-core/tests/genesis_bringup.rs b/crates/dig-node-core/tests/genesis_bringup.rs index 081f55bc..26719a85 100644 --- a/crates/dig-node-core/tests/genesis_bringup.rs +++ b/crates/dig-node-core/tests/genesis_bringup.rs @@ -46,7 +46,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; -use dig_node_core::peer::{genesis_challenge_from_env, install_crypto_provider, spawn_peer_network}; +use dig_node_core::peer::{ + genesis_challenge_from_env, install_crypto_provider, spawn_peer_network, +}; use dig_node_core::seams::dig_peer::peer_network::PeerNetwork; use dig_node_core::Node; From 8fe9cfcca3780a8457e887fca0caf822ad1d45df Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 3 Sep 2026 08:21:41 -0700 Subject: [PATCH 4/4] chore(release): v0.253.5 Merge origin/main into loop/240-genesis-bringup and take the next available patch version (0.253.0-0.253.2 are already claimed by concurrent open PRs #524/#528/#518). Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbee5a4a..0ae71f3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.252.97" +version = "0.253.5" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index ae90e3d5..84acede4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.252.97" +version = "0.253.5" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over