From 1a21c332c8dd943aa232a5d4ab4acb39fdb83f43 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 24 Aug 2026 19:31:22 -0700 Subject: [PATCH 1/3] =?UTF-8?q?chore(peers):=20stub=20=E2=80=94=20omit=20t?= =?UTF-8?q?he=20wildcard=20address=20for=20a=20relay-reached=20peer=20(#25?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude From 0ae5ced9162754cab136c00fa9d63e6e1c091628 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 24 Aug 2026 19:41:05 -0700 Subject: [PATCH 2/3] fix(peers): omit the wildcard address for a relay-reached peer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `control.peerStatus` emitted `"address": "[::]:0"` for a peer reached over a relay circuit with no configured relay endpoint — the IPv6 unspecified address on port 0, which is the ABSENCE of an endpoint rather than one. The CLI's honest `(no address)` fallback could not fire: `unwrap_or` only catches a MISSING field, never a present string that happens to be meaningless, so `dign peers` rendered the wildcard as if it were a location. Fixed at the producer by applying `net::is_usable_contact` — the predicate the DHT-contact and download paths already trust (#1784) — at the one enumeration site that lacked it. The key is omitted rather than placeholdered, so every consumer's own absence handling works. Co-Authored-By: Claude --- SPEC.md | 7 ++- crates/dig-node-core/src/peer.rs | 87 ++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index f370e97e..e996dc87 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2456,10 +2456,15 @@ which fail unless the parser really accepts the verb and carries its operands th - `peers [list]` → `control.peerStatus` — the live peer status: running flag, connected count, relay reservation, and a **per-peer array** `peers[]`, each element - `{ peer_id, address, via, direction }` where `via ∈ {"direct","relay"}` is the REAL per-peer + `{ peer_id, via, direction }` — plus `address` — where `via ∈ {"direct","relay"}` is the REAL per-peer transport (a peer whose gossip rides the relay's RLY-002 forwarder reports `"relay"`, every other peer `"direct"`, sourced from dig-gossip's `connected_pool_peers_with_via`) and `direction ∈ {"outbound","inbound"}`. + `address` is OPTIONAL and MUST be present only when the pool reported a dialable destination: an + element whose only reported remote is not a destination — an unspecified IP (`::` / `0.0.0.0`) or + port `0`, which is what dig-nat records for a relay-accepted circuit with no configured relay + endpoint — MUST OMIT the key rather than emit the wildcard. A consumer MUST therefore treat a + missing `address` as "this peer has no known dialable address", never as a malformed element. The array is present whenever a peer network is running and omitted (count only) on the in-process FFI path / before bring-up. The per-peer `peer_id` is the machine-checkable proof of a mutual A↔B connection (each side lists the other's `peer_id`). Peer diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index ebb427ac..1b74c585 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -300,7 +300,9 @@ impl PeerStatus { // -- Per-peer enumeration for control.peerStatus (#929) ---------------------------------------------- /// The connected pool as a per-peer JSON array — one object per connected peer: -/// `{ peer_id, address, via, direction }`. This is the machine-checkable proof surface for a mutual +/// `{ peer_id, via, direction }`, plus `address` when the pool reported a dialable destination +/// ([`is_usable_contact`](crate::net::is_usable_contact)). The key is OMITTED — never emitted as a +/// placeholder — for a peer whose only reported remote is the wildcard `[::]:0` (dig-node#253). This is the machine-checkable proof surface for a mutual /// A↔B connection (each side lists the OTHER's `peer_id`), beyond the bare `connected_peers` count. /// /// Sourced from [`connected_pool_peers`](dig_gossip::GossipHandle::connected_pool_peers) (dialable @@ -323,12 +325,21 @@ pub(crate) fn connected_peers_json(handle: &dig_gossip::GossipHandle) -> Vec "relay", _ => "direct", }; - json!({ + let mut row = json!({ "peer_id": hex::encode(peer_id), - "address": addr.to_string(), "via": via, "direction": if outbound { "outbound" } else { "inbound" }, - }) + }); + // `address` is present ONLY when the pool reported a real destination. dig-nat records + // the unspecified wildcard `[::]:0` as the remote of an accepted relayed circuit with no + // configured relay endpoint (#1784), and that is the ABSENCE of an endpoint, not one. + // Emitting it would be a falsehood no consumer can detect — the field is present and + // non-null, so a reader's own "I have no address" fallback never fires. Omitting the key + // lets every consumer say what is true (dig-node#253). + if crate::net::is_usable_contact(&addr) { + row["address"] = json!(addr.to_string()); + } + row }) .collect() } @@ -3816,6 +3827,74 @@ pub(crate) mod tests { assert_eq!(found.len(), 2, "two identities are two candidates"); } + /// **dig-node#253 — a relay-reached peer whose pool address is the wildcard reports NO address, + /// so `dign peers` renders its honest `(no address)` fallback instead of `[::]:0`.** + /// + /// `[::]:0` is the IPv6 *unspecified* address on port 0 — the ABSENCE of an endpoint, which + /// `dig-nat` records as the remote of an accepted relayed circuit with no configured relay + /// endpoint (#1784). Emitting it as the `address` field is a falsehood the renderer cannot + /// catch: `unwrap_or("(no address)")` only fires on a MISSING field, never on a present string + /// that happens to be meaningless. So the producer must omit the key. + /// + /// Fixture design — ONE actor varies. Both peers are RELAY-reached; only their pool address + /// differs. That distinguishes this property from the two nearest wrong implementations: a + /// producer that emits every address unconditionally fails the first assertion, and one that + /// drops the address for anything relayed fails the CONTROL. Asserting through + /// [`connected_peers_json`] rather than a pure helper is what pins the WIRING — a fix placed in + /// the CLI renderer, or a helper written and never called, leaves this red. + #[tokio::test] + async fn a_relay_peer_with_a_wildcard_pool_address_reports_no_address() { + let handle = fresh_pool_handle("wildcard-address", [23u8; 32]).await; + + // The peer seen in the wild: relayed, with dig-nat's unspecified remote. + let (wildcard, _wildcard_server) = loopback_nat_conn( + [0xAB; 32], + "[::]:0".parse().unwrap(), + dig_nat::TraversalKind::Relayed, + ); + handle + .adopt_nat_connection(wildcard) + .await + .expect("a relayed peer is adopted whatever its reported remote"); + + // The truthful control: ALSO relayed, but at a real destination, so the guard cannot be + // satisfied by blanking every relay peer's address. + let (honest, _honest_server) = loopback_nat_conn( + [0xCD; 32], + "198.51.100.8:9444".parse().unwrap(), + dig_nat::TraversalKind::Relayed, + ); + handle + .adopt_nat_connection(honest) + .await + .expect("the control peer is adopted"); + + let peers = connected_peers_json(&handle); + let row = |id: &str| { + peers + .iter() + .find(|p| p["peer_id"] == id) + .unwrap_or_else(|| panic!("{id} is a pool member")) + .clone() + }; + + let wildcard_row = row(&hex::encode([0xAB; 32])); + assert!( + wildcard_row.get("address").is_none(), + "a wildcard pool address is not a destination and must be omitted, not rendered: {wildcard_row}" + ); + assert_eq!( + wildcard_row["peer_id"], hex::encode([0xAB; 32]), + "the peer itself is still enumerated — omitting the address must not drop the peer" + ); + + let honest_row = row(&hex::encode([0xCD; 32])); + assert_eq!( + honest_row["address"], "198.51.100.8:9444", + "a relay peer WITH a real address still reports it" + ); + } + /// #1784, DHT-routing half: the routing feed does NOT pass through the connected pool, so it /// needs its own guard. A pool address that is not a destination yields no contact at all. /// From 671b055c8aedce40bfad688d821d30b2999fbce8 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 24 Aug 2026 19:59:38 -0700 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=200.150.0=20=E2=80=94=20pee?= =?UTF-8?q?rStatus=20`address`=20becomes=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/dig-node-core/src/peer.rs | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 14fe1437..bccb84da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3025,7 +3025,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.149.0" +version = "0.150.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 45fb35cc..1d087e56 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.149.0" +version = "0.150.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 1b74c585..e700270f 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -3881,10 +3881,11 @@ pub(crate) mod tests { let wildcard_row = row(&hex::encode([0xAB; 32])); assert!( wildcard_row.get("address").is_none(), - "a wildcard pool address is not a destination and must be omitted, not rendered: {wildcard_row}" + "a wildcard pool address is not a destination and must be omitted, not rendered: {wildcard_row}" ); assert_eq!( - wildcard_row["peer_id"], hex::encode([0xAB; 32]), + wildcard_row["peer_id"], + hex::encode([0xAB; 32]), "the peer itself is still enumerated — omitting the address must not drop the peer" );