Skip to content

discovery,transport: discv5 peering + replace lsquic with zquic v1.2.1#42

Merged
ch4r10t33r merged 10 commits into
mainfrom
discovery/peering-discv5
Apr 12, 2026
Merged

discovery,transport: discv5 peering + replace lsquic with zquic v1.2.1#42
ch4r10t33r merged 10 commits into
mainfrom
discovery/peering-discv5

Conversation

@ch4r10t33r
Copy link
Copy Markdown
Collaborator

Summary

  • Add discv5-based peer discovery and duty-aware peering layer
  • Replace lsquic C FFI with pure-Zig zquic v1.2.1 QUIC stack
  • Shared UDP socket for discv5 and QUIC on the same port
  • Update docs (UPSTREAM.md, README.md) and CI for zquic

Test plan

  • zig build test --summary all — 141/141 tests pass
  • zig build test-quic — 17/17 QUIC tests pass (handshake, bidi, uni, BCAST+SESS framing)
  • CI green

ch4r10t33r and others added 10 commits April 5, 2026 19:26
Implements the full discovery layer design for zig-ethp2p, grounded in the
ethp2p spec §Peering and §Transport design decisions.

ENR layer (src/discovery/enr/):
- enr.zig: RLP encode/decode, Enr struct with key-value lookup, EIP-778 300B limit
- standard.zig: standard field decoders (secp256k1, ip, udp, tcp)
- ethp2p.zig: eth-ec capability field (version, EC scheme bitmask, geo-hint,
  custody columns); EthEcField.supportsScheme() ties to layer/ec_scheme.zig

discv5 layer (src/discovery/discv5/):
- table.zig: 256-bucket Kademlia routing table (k=16), XOR distance,
  closest-N query, bucket LRS eviction
- crypto.zig: AES-128-GCM encrypt/decrypt (std.crypto), HKDF-SHA256 session
  key derivation; secp256k1 ECDH stubbed with TODO (requires BoringSSL)
- packet.zig: Ordinary / WHOAREYOU / Handshake wire type constants and
  masking-key derivation (SHA256)
- session.zig: per-peer session state (idle/awaiting/established), nonce
  counter, SessionTable keyed by NodeId
- protocol.zig: PING/PONG/FINDNODE/NODES/TALKREQ/TALKRES message types and
  protocol constants (max_nodes_per_response=4, request_timeout_ms=500)
- node.zig: poll-driven drive loop (timer expiry, bucket refresh), bootstrap,
  closest lookup, capability-aware peer query

Peering layer (src/discovery/peering/):
- score.zig: RTT-first composite scoring with EMA smoothing, event-triggered
  updates, time-based half-life decay; latency tiers (inner<60ms, mid<120ms,
  outer) aligned with broadcast chunk dispatch ordering
- duty.zig: DutyKind enum (proposer/attester/aggregator/sync/ptc/das);
  per-duty slot capacities (16/64/16/8/16/128 selfish + 50 altruistic);
  connection pool tier capacities (20/80/100); SelectionReq per duty type
- table.zig: SelfishTable (evicts lowest-scoring when full), AltruisticTable,
  combined PeerTable; selectForDuty with RTT filter
- pool.zig: connection pool tracking hot/warm/cold warmth state with 0-RTT
  session ticket storage
- warmup.zig: 12s slot phases (block 0-2s, attest 2-4s, agg 4-8s, idle 8-12s);
  lookahead_slots=2 warmup scheduler; requests accepted only during idle phase

All modules have tests. Wired into src/root.zig as pub const discovery.
secp256k1 is the only unimplemented primitive (clearly stubbed with TODO).
Three functions that were previously stubbed are now implemented using
the BoringSSL EC_KEY / ECDH APIs that are already vendored via lsquic_zig:

- generateEphemeralKeypair: EC_KEY_new_by_curve_name(NID_secp256k1) +
  EC_KEY_generate_key, extracts compressed pubkey and raw scalar.
- ecdhSharedSecret: BN_bin2bn + EC_POINT_oct2point + ECDH_compute_key,
  returns the raw x-coordinate (32 bytes) as required by discv5 §6.
- nodeIdFromPubkey: decompresses to 65-byte uncompressed form then hashes
  bytes [1..] with std.crypto.sha3.Keccak256 per discv5 §4.1.

build.zig: store the BoringSSL openssl_include LazyPath in QuicLinkBundle
and propagate it to the root zig_ethp2p module via wireZigEthP2pModule so
that @cImport("openssl/...") in crypto.zig resolves when -Denable-quic is
set.  Without the flag ossl compiles as an empty struct and the three
functions return Secp256k1Error, which the tests skip via SkipZigTest.

New tests: keygen+ECDH roundtrip (verifies ECDH(a,B)==ECDH(b,A)) and
nodeId uniqueness, both gated on build_opts.enable_quic.
secp256k1 is a fundamental discv5 primitive — it is needed regardless of
whether the QUIC transport shim is active.  Previously it was gated on
-Denable-quic which forced callers to pass a flag unrelated to discovery.

build.zig:
- Split QuicLinkBundle into BoringSslBundle (always built, contains
  ssl_lib, crypto_lib, openssl_include) + QuicLinkBundle (lsquic only,
  still behind -Denable-quic).
- addBoringSsl() extracts BoringSSL from lsquic_zig unconditionally.
- addLsquicQuicModule() takes BoringSslBundle and adds lsquic on top.
- wireZigEthP2pModule() always adds openssl_include to the root module.
- linkCryptoLibs() (new) links ssl+crypto into every compile step.
- linkQuicLibs() now only links lsquic + zlib/pthread/m.

crypto.zig:
- Drop build_opts import and all if (build_opts.enable_quic) guards.
- @cImport is unconditional — headers are always available.
- generateEphemeralKeypair, ecdhSharedSecret, nodeIdFromPubkey are fully
  implemented with no runtime stubs.
- Tests no longer have SkipZigTest guards; all four tests run in every
  plain `zig build test` invocation.
…uic flag

lsquic and BoringSSL are fundamental — secp256k1 (discv5) and QUIC
transport are both always needed.  Keeping a conditional flag created
unnecessary complexity and stub code.

build.zig:
- Single LsquicBundle replaces the two-struct BoringSslBundle/QuicLinkBundle
  split.  addLsquicBundle() builds everything unconditionally.
- wireModule() and linkLibs() have no optional parameters.
- The -Denable-quic b.option() is removed entirely; so is the zig_opts /
  zig_ethp2p_options module (its only consumer was enable_quic).
- Windows targets panic at build time (lsquic_zig is not Windows focused).

eth_ec_quic.zig:
- All if (comptime !build_opts.enable_quic) stubs removed.
- build_opts import removed.
- EthEcQuicListener.inner flattened to direct ep/port fields.
- listen/dial/pollListener/logInit call through directly with no guards.
- Last test block unconditionally pulls in eth_ec_quic_enabled.zig.

Docs and CI:
- README: QUIC transport section no longer marked optional; -Denable-quic
  removed from build table, build examples, and all anchors.
- ci.yml / justfile: test-quic command drops -Denable-quic flag.
- UPSTREAM.md, root.zig, eth_ec_quic_enabled.zig, ci_root_quic.zig,
  crypto.zig: comments updated to remove flag references.

Result: plain `zig build test` now runs all 77 tests including the full
QUIC handshake and stream-framing integration tests.
… std.crypto

secp256k1 ECDSA and ECDH are now implemented using Zig 0.15's own
std.crypto.ecc.Secp256k1 and std.crypto.sign.ecdsa, eliminating the
BoringSSL C FFI from crypto.zig entirely.

Changes by file:
- crypto.zig: drop @cImport; use EcdsaK=Ecdsa(Secp256k1,Keccak256);
  streaming signer/verifier for id-nonce multi-part hash; pure Zig ECDH
  via point.mul + affineCoordinates().x
- enr/enr.zig: fix rlpDecode to return full item (prefix + content);
  add rlpEncodeUint64, rlpEncodeList, EnrBuilder.sign, verifyV4
- discv5/packet.zig: full ORDINARY/WHOAREYOU/HANDSHAKE codec with
  AES-128-CTR masking; fix decode to unmask entire header in one CTR pass
- discv5/protocol.zig: complete RLP encode/decode for all six message
  types (PING/PONG/FINDNODE/NODES/TALKREQ/TALKRES)
- discv5/table.zig: add getEntry and refreshNode helpers
- discv5/node.zig: UDP socket, non-blocking recv loop, iterative
  FINDNODE, queryByCapability, bucket refresh timer
- peering/pool.zig: free session_ticket when promoteHot overwrites warm entry
- peering/score.zig: fix signed-integer division and u8 saturating add
- discovery/peer_manager.zig (new): bridges discv5 → warmup scheduler
  → eth_ec_quic.dial → PeerTable registration and score-based eviction
- transport/eth_ec_quic_peer.zig: implement dispatchInboundUniStream
  with on_sess_stream / on_chunk_stream callbacks; cancel unknown streams
- src/root.zig: add discovery to the test block (was missing)

141 tests pass, 0 leaks.
… std.crypto

secp256k1 ECDSA and ECDH are now implemented using Zig 0.15's own
std.crypto.ecc.Secp256k1 and std.crypto.sign.ecdsa, eliminating the
BoringSSL C FFI from crypto.zig entirely.

Changes by file:
- crypto.zig: drop @cImport; use EcdsaK=Ecdsa(Secp256k1,Keccak256);
  streaming signer/verifier for id-nonce multi-part hash; pure Zig ECDH
  via point.mul + affineCoordinates().x
- enr/enr.zig: fix rlpDecode to return full item (prefix + content);
  add rlpEncodeUint64, rlpEncodeList, EnrBuilder.sign, verifyV4
- discv5/packet.zig: full ORDINARY/WHOAREYOU/HANDSHAKE codec with
  AES-128-CTR masking; fix decode to unmask entire header in one CTR pass
- discv5/protocol.zig: complete RLP encode/decode for all six message
  types (PING/PONG/FINDNODE/NODES/TALKREQ/TALKRES)
- discv5/table.zig: add getEntry and refreshNode helpers
- discv5/node.zig: UDP socket, non-blocking recv loop, iterative
  FINDNODE, queryByCapability, bucket refresh timer
- peering/pool.zig: free session_ticket when promoteHot overwrites warm entry
- peering/score.zig: fix signed-integer division and u8 saturating add
- transport/eth_ec_quic_peer.zig: implement dispatchInboundUniStream
  with on_sess_stream / on_chunk_stream callbacks; cancel unknown streams
- src/root.zig: add discovery to the test block (was missing)

141 tests pass, 0 leaks.
Discovery crypto (secp256k1, ECDSA, ECDH) now uses pure std.crypto,
so wireModule no longer needs to add the openssl include path.
BoringSSL headers are only required by lsquic_quic_shim.zig.
…ile errors

Add an optional `on_peer_dialed` callback (with opaque context pointer) to
`PeerManager` so that callers can react when a QUIC dial succeeds.  The
callback is fired from `dialPeer` before the peer is registered in the
connection pool.

Also fix three pre-existing compile errors that prevented the module from
building:
- `duty_mod.score_eviction_floor` did not exist; add a local constant (-100)
- `discv5_node.table` is not `pub`; change to a direct import of `discv5/table.zig`
- `std.time.nanoTimestamp()` returns `i128` but `promoteHot` expects `u64`; add `@intCast`
Add SharedUdpSocket (src/transport/shared_udp_socket.zig) that owns a
single bound UDP socket and demultiplexes inbound datagrams between the
discv5 discovery layer and the lsquic QUIC transport:

- Try discv5 first via Node.injectDatagram (new); AES header decode
  reliably rejects non-discv5 packets, returning false so the caller
  can forward to QUIC.
- Forward rejected packets to lsquic via feedPacket (new public wrapper
  around lsquic_engine_packet_in).
- Run QUIC timer processing separately via processEngineOnly (new).

Node changes (discv5/node.zig):
- Add owns_socket bool to track fd ownership.
- Add startFromFd(fd) — attach external fd without binding.
- stop/deinit skip close when owns_socket = false.
- poll skips recvLoop when using external fd.
- Add pub injectDatagram(data, from) -> bool.

lsquic_quic_shim.zig:
- Add owns_sock bool to QuicEndpoint.
- Add endpointInitFromFd — create engine on existing fd.
- endpointDeinit respects owns_sock.
- Add feedPacket and processEngineOnly.

eth_ec_quic.zig + eth_ec_quic_enabled.zig:
- Add listenOnFd, feedPacket, processEngineOnly wrappers.
- Re-export QuicEndpoint so callers outside the package can name it.
Drop the lsquic C shim and all BoringSSL/zlib/pthread link steps.
The QUIC stack is now zquic (https://github.com/ch4r10t33r/zquic),
fetched as a Zig package from the v1.2.1 release tarball.

New shim (zquic_quic_shim.zig) re-implements the same QuicEndpoint /
QuicConnection / QuicStream public API on top of zquic's embedder
I/O surface (feedPacket, processPendingWork, raw application streams,
sendRawStreamData, startHandshake).

Server TLS identity switches from inline DER to PEM file paths
(zquic loads cert/key from disk). Test certs regenerated as P-256 PEM
for 127.0.0.1.
@ch4r10t33r ch4r10t33r merged commit 2769ab0 into main Apr 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant