soft3/audit/chain/net — wire and node wiring of the phase-1 root chain, implementation receipt.md

chain/net — implementation receipt, 2026-10-15

What specs/chain-wire.md asked for, what crate/src/chain/net/ and the node wiring deliver, and the evidence: unit/conformance tests, the three-node harness, the adversarial tests. Branch feat/chain-net, PR soft3#187, built in stand .stands/N2 against soft3 main at 8525b17 (after #184/#185), radio main 50600cc (≥ bc8b633), hemera/mudra/tade/bbg stand defaults.

modules

file spec lines
chain/net/codec.rs chain.md §1–2: 168-byte header, four tx kinds, block id, txid/statement, compact target, work, tx root, topic ids 640
chain/net/frames.rs chain-wire §2–3: the tade dialect urn:cyberia:chain:1, frames (*,k) + (#, letter), strict bounded decoders 400
chain/net/chain_view.rs the operations the wire asks of the kernel (trait; swap point) 150
chain/net/devchain.rs minimal chain behind the trait for the harness: real PoW/MTP/heaviest chain/reorg bound/tx rules; constant target, digest root (dev only); genesis file codec §4.1; blocks.tade persistence 760
chain/net/mempool.rs chain-wire §4: admission order, nonce window and per-neuron order, fee floor, 8192/16 MiB eviction by fee per byte, 24 h expiry, revalidation after a tip, templates 330
chain/net/peers.rs chain-wire §5: token buckets (64 headers/s, 256 txs/s, 4 MiB/s, 16 streams/s; one second of tokens at start, 4× burst), score with 1/min decay, ban ≥ 100 for 1 h, 8 outbound / 64 inbound, /16 diversity (loopback exempt), bootstrap floor, anchors 300
chain/net/transport.rs chain-wire §1: radio endpoint, ALPN cyber/chain/1, radio.key, bounded frame IO, one request per stream with 30 s timeout 200
chain/net/serve.rs chain-wire §3: request streams s g r b t k, x codes 1–4, 8 streams per direction 210
chain/net/gossip.rs chain-wire §2: headers/txs/checkpoints topics, relay only after validation (neighbor scope, so the relay is explicit), body on gossip +100, 4,096-byte cap 220
chain/net/sync.rs chain-wire §3.1–3.2: status exchange, locator g loop (≤ 2,000, 64 rounds), bodies by id along the best header chain in ranges of 128, 4 in flight, 512-block buffer, strict height order, catching-up 230
chain/net/discovery.rs chain-wire §6 + deploy/chain peers.toml v1: bootstrap file, anchors, the graph as address book (antenna/socket/follow kind-2 links, wire.rs packing), the dialer 300
chain/net/mod.rs Net: one endpoint, three topics, router, the tasks 290
node/chain_node.rs ChainNode: chain + mempool + net + CPU miner (sync-quorum gate, --solo), /status cybermark fields of §7 220
node/chain_routes.rs chain-wire §7: every route of the table; /template, /submit, /tip in the reference miner's shapes (soft3#186); legacy unsigned ingress → 410; /dev/* switches on dev chains only 330
bin/soft3.rs soft3 node --chain … --radio --peers, soft3 node id, `soft3 chain genesis-dev key
scripts/chain-fleet.nu the three-node harness (ops skeleton extended) 300
tests/chain_adversary.rs adversarial wire tests against a live node 320

Dependencies added to crate/Cargo.toml: cyber-radio and iroh-gossip (path, radio stand), tade, cyber-mudra (bridge, shared with the wallet), tokio, n0-future, rand, toml, bytes; cyber-hemera moved to the path dependency every sibling uses (one copy).

conformance vectors (chain-conformance.md), in unit tests

Sections 1 (hemera anchors), 2 (compact target, all 13 rows), 3 (work, 4 rows + the 3-block chainwork), 5 (test header bdfbbff9…, mined header d1eec40d… with nonce 40,165,933 valid at GENESIS_BITS and rejected at 2^228 and at nonce − 1), 6 (coinbase txid d7662bdb…, leaf c2cb6adb…, node 3685e0a5…, transfer statement c7e4a223…, cyberlink 257 bytes / valence 2 / p == q, encoding rejections), 7 (topic id 1f003a48…), 11 (status 138 bytes, locator heights for tip 1,000, count = 129 → x 3, body on gossip +100, 65th header dropped, wrong-chain → x 4 + disconnect, POST /tx bad signature → 400, legacy → 410) — all green. Section 4 (ASERT), 8 (bbg state), 9 (checkpoints/orphan pool) and 10 (genesis pipeline) are the kernel's.

cargo test -p soft3 (lib)      133 passed   (chain:: 31)
cargo test -p soft3 (bin)       11 passed
cargo test --test chain_adversary  2 passed

Decoder fuzzing (no external fuzzer; xorshift random and mutated inputs, 60,000 cases per run): Header::decode, Tx::decode, decode_body, decode_bits, Frame::decode, frames::split never panic.

three-node harness — nu scripts/chain-fleet.nu

Dev chain (soft3 chain genesis-dev, target 2^252, 1 s block pacing), four processes on one machine, separate homes, HTTP 17780–17783, radio UDP 17790–17793, peers.toml v1 with real endpoint ids from soft3 node id. Only the PIDs the script spawned are killed.

  ok    binary speaks the phase-1 flags
  ok    ports free: http 17780,17781,17782,17783 radio 17790,17791,17792,17793
  ok    homes + genesis + peers under /tmp/chain-fleet.anSylo; wallet aaaeb678a…
  ok    all 3 nodes answer /status
  ok    peers connected: node-1=2 node-2=2 node-3=2
  ok    heights agree at 11 across 3 nodes
  ok    late joiner synced to height 12 (IBD by ranges)
  ok    tx 9ec834607… submitted to node-1, mined at 15 by aa6877b76… (node-2 mines, node-1 paused), bob=5 on node-3
  ok    fork of depth 3 from 15: main 18 reorged to the heavier chain 20, tip 9efb629c9… on every node
  ok    node-3 recovered from disk: height 20 before the kill, 20 after restart, conserved
chain-fleet: GREEN

Scenarios: peers connect (bootstrap dial + inbound), headers gossip and bodies are fetched by id from the announcer (never pushed), a late-joining node downloads the bodies by ranges and reports catching-up: false, a transaction signed with the harness wallet crosses node 1 → mempool gossip → mined by node 2 → indexed on node 3 (/tx/{txid}, /balance), an isolated node mines a 5-block fork against the fleet's 3 and the fleet reorgs (depth 3) to it on reconnection, a SIGKILLed node replays blocks.tade and rejoins with conserved: true.

Bugs the harness found and fixed on the way: the s reply rejected as a request frame; the responder's dialect frame unexpected; gossip-only peer entries (kept for scoring) blocking chain-level dials on both sides; loopback peers counted against the /16 diversity cap; body fetch tied to the announcer's connection; peer chainwork going stale between status exchanges; a missing parent during a concurrent fork download marked the fork invalid.

adversarial — cargo test --test chain_adversary

Against a live soft3 node --chain dev --mine --solo, from a radio endpoint and an iroh-gossip instance in the test:

case result
well-formed s answered with the node's status, score 0
r with count = 129 x code 3
stream whose first frame is e (no dialect) closed, +10
malformed request frame (truncated r) +10 (score 20)
frame declaring a 10 MiB payload +100, banned, connection closed; a reconnect is refused
s with another chain id x code 4 delivered, then +100 and disconnect
PoW-invalid header on the headers topic +100, banned; the malicious server saw zero body requests (header-first held)
valid-PoW header announced, garbage body served on b body requested, +10, peer kept (one bad block is not a ban)
400 junk transactions in a burst rate limit drops the excess (+1 each), decode/signature failures +10 → banned; node healthy
a body frame pushed on the headers topic +100, banned
after all of it /health ok, conserved: true, still mining

open integration points

  1. Kernel swap. feat/chain-kernel exposes chain::api::ChainApi (&mut self: accept_header, accept_block(&Block, now), accept_checkpoint, validate_tx(bytes, pending), header_template(to, time, mempool), take_events, status(), locator, headers(from, count), blocks(from, count), balance, nonce, supply, finalized_height). The wire codes against net::chain_view::ChainView (&self); the swap is one adapter Mutex<impl ChainApi> → ChainView plus: missing_bodies (walk the best header chain back to the first held body), headers_from_locator (locator → ancestor_at + headers), spendable/balances/ tx_location/counts/conserved (kernel state queries or an index kept by the node), events() (poll take_events into a broadcast). Mempool admission can then call validate_tx instead of its own balance checks. devchain stays as the harness chain until then.
  2. /v2/history entries carry height, block, txid and the raw tx as chain-tx/v1-hex; CGOP-v1-hex for cyberlink entries needs the kernel's cybergraph apply.
  3. The compiled bootstrap set (three cybernode endpoint ids) is empty until soft3 node id runs on the machines (deploy/chain README step A7); the peers file is the floor meanwhile. Entries with an empty id are skipped (QUIC needs the id).
  4. Address announcement: discovery::address_links builds the two cyberlinks; publishing them from the node's neuron (fee 514 units) is not automatic yet — it needs a funded neuron key on the node (--miner mines to it, maturity 100 blocks).
  5. Checkpoints: verified against genesis.checkpoint_key, stored, relayed, served; the kernel's accept_checkpoint (finality veto) is the consumer.
  6. Relay-after-validation is implemented with iroh-gossip's neighbor scope (a validated message is re-broadcast to neighbors; echoes are Known and not charged for 120 s). Swarm-scope plumtree would forward before validation, which chain-wire §2 forbids.
  7. Spec notes for chain-wire.md: the responder's side of a request stream also opens with (*, k); s is both a request and a reply frame; chain frames are (#, letter) since tade frames carry a sigil; the r get-range is served but IBD uses b by the validated header ids (exact, no header-mismatch reassignments); loopback addresses are exempt from the /16 rule so a fleet on one machine can form.
  8. soft3 mine --backend cpu (soft3#186) is not merged; the harness mines with the node's built-in CPU loop (--mine), which serves the same /template + /submit shapes the reference miner speaks.

Graph