fix: handle duplicate redeemer pointers in script context
Signed-off-by: Eric Torreborre <[email protected]>
Signed-off-by: Eric Torreborre <[email protected]>
There is no need to have a separate type class for a common concept that we aready have for all eras that is provided by `EraTest`. This commit also hides unnecessary for public view implementation detail: `EraRuleProof` and `UnliftRules`. Thanks to `EraRulesWithFailures` type family and relocation of predicate failure CBOR roundtrip tests into `ledgerTestMain` we are guaranteed that these tests will run for all eras. Moreover, this changes is in line with overall tests hierarchy that we are converging onto.
Static config + dynamic toggle for the per-node `Behaviour` introduced in the shared-consensus commit. net-node - `NodeConfig.behaviour: Option<BehaviourSpec>` (TOML field). Applied in `Consensus::new` to all three state machines (Praos, Leios, Mempool) before the consensus task starts. - `DynamicConfigUpdate.behaviour: Option<BehaviourSpec>` for runtime swap via the existing stdin JSON channel. `Consensus::set_behaviour` applies it across the live state machines. - Per-layer `set_behaviour` setters on `PraosConsensus`, `LeiosConsensus`, and `Mempool` materialise the spec via `shared_consensus::behaviour::build`. net-cluster - `ClusterConfig.behaviour: Option<BehaviourSpec>` plus `behaviour_nodes: Vec<usize>` for per-node selection. Empty `behaviour_nodes` with a non-`None` spec attaches to every node. - `NodeTopology` carries the resolved per-node spec; `render_overlay` serialises it through the `toml` crate so the [behaviour] table stays in sync with serde's view of `BehaviourSpec`. - Sample config `sample-cluster-equivocator.toml` runs a 10-node cluster with node-3 as the `RbEquivocator`. Smoke-verified: the generated `node-3.toml` carries `[behaviour]\nkind = "rb-equivocator"`, peer overlays carry none, and node-3's startup log emits `installing per-node behaviour spec=RbEquivocator`. Tests: 119 net-node + 43 net-cluster + 316 net-core + 263 shared-consensus pass; sim-rs still builds. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The wFA-LS default (480 PV + 120 NPV) assumes mainnet-scale pool counts. At the smoke cluster's 9 pool nodes only PV is allocatable (CIP-0164 partitions PV/NPV pool sets disjointly, and every pool wins at least one PV seat at this scale), so the achievable max is ~480 vs the threshold of 0.75 × 600 = 450 — only ~30 seats of margin above the threshold. Losing a single pool's PV seats drops the cluster below quorum, masking the abstention-budget behaviour the lazy-voter sample is meant to exercise. Switch the shared base (`mainnet.toml`) to top-stake-fraction selection: every pool covered by the top 99% of stake casts a weight-1 vote. The committee size now matches the actual voter count (9), so the threshold (`floor(0.75 × 9) = 6`) is proportional to who can actually vote. Lazy-voter sweep on the 30/9-pool sample, 5 min each, identical seed/topology (push-on-admit fanout already in tree): fraction=0.0 → 0 / 9 lazy → 9 honest → 92% cert fraction=0.1 → 1 / 9 lazy → 8 honest → 92% cert fraction=0.2 → 2 / 9 lazy → 7 honest → 92% cert fraction=0.25 → 3 / 9 lazy → 6 honest → 92% cert (at threshold) fraction=0.34 → 4 / 9 lazy → 5 honest → 0% cert (below threshold) The 13/13 EB-generation count is deterministic across all five runs. The 12/13 ceiling (and the 5 residual MissingTX seen in every run) is producer-rotation residue — same artefact across the sweep. Also trims the sample-cluster-lazy-voter docstring to match the TSF math. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Move `note_header_for_equivocation` onto the ChainSync announce path
(`PraosState::on_tip_advanced`) in addition to the existing
block-arrival path (`on_block_received` and `register_self_produced`).
Why: an adversary that advertises divergent RB headers to disjoint
peer subsets (CIP-0164 RB-header equivocation) can keep honest peers
unaware of the divergence indefinitely. Each peer fetches one
variant, sees no `(slot, issuer)` collision, and never bothers
fetching the sibling because Praos chain selection picks the first /
lowest-hash header at that slot and treats the alternative as a
discarded fork. Without a block fetch, `on_block_received` never
fires, so the existing tracker never trips even when the peer has
already received the divergent header.
Detection from the header alone is enough — the equivocation predicate
keys on `(slot, issuer)` against a set of distinct header hashes, all
of which are derivable from the ChainSync `MsgRollForward` payload.
`on_tip_advanced` gains a `header_issuer: &[u8]` parameter; the
net-node wrapper passes `info.issuer` from the parsed header.
Also: an `info!` line on the first detection per slot ("RB-header
equivocation detected") so operators can see the tracker fire from a
log, and a small `was_new` guard so the log fires only when the slot
transitions from non-equivocating to equivocating.
Verified end-to-end on a 10-node cluster with one node running
`RbHeaderEquivocator { ways = 2 }`: every slot the equivocator wins
its bucket-1 peer detects the divergence and inserts the slot into
`equivocating_rb_slots`. Two test fixtures in `praos::tests` were
updated for the new signature.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Mirrors `LinearWithTxReferences`'s CIP-0164 receiver gate: an EB can only be validated once every referenced tx body is in the local mempool. Two side maps thread the dependency: - `eb_pending_txs: EBId → BTreeSet<TxId>` — per-EB still-missing set - `missing_tx_index: TxId → BTreeSet<EBId>` — reverse index for the tx-admit release path `finish_validating_eb_header` now computes `missing` against `mempool.has_tx` (union of free pool + EB-pinned bodies) and either schedules `EBBlockValidated` immediately or stashes the EB pending in both maps. `acknowledge_tx_for_pending_ebs` walks the reverse index after every successful `admit_validated` and releases EBs whose set reaches empty. Chain-prune sweeps both maps in lockstep with the existing `self.ebs` retention. Empirical note: NA,0.350 / wfa-ls / 750n / -s 1500 produces bit-identical results with and without the gate (88/15/33/42084 either way), because tx diffusion is fast enough in this sim that every receiver already has the bodies in mempool before the EB arrives. The gate matters in regimes where Message::Tx lags Message::EB (cluster-scale net-rs, withheld-tx attacks, or once the upcoming sim message split surfaces a real manifest-only intermediate state). It's the protocol-correct behaviour either way — without it, an EB could in principle validate and open voting before any voter has the bodies, an outcome the CIP-0164 MissingTX predicate is supposed to prevent at validation time, not just at decide-vote time. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Renames the two committee-size config fields to match con-rs's
internal naming and the CIP-0164 terminology:
persistent-vote-generation-probability → persistent-voters
non-persistent-vote-generation-probability → non-persistent-voters
Old YAML keys keep working via `#[serde(alias = ...)]` so the 70+
existing experiment configs round-trip unchanged. The "probability"
framing was always misleading — these have always been expected
committee sizes (defaults 400 / 100, not 0.4 / 0.1).
SimConfiguration grows `persistent_voters` / `non_persistent_voters`
alongside the existing `vote_probability` (= their sum). Linear's
VRF lottery keeps consuming the sum unchanged. The con-rs adapter
now wires both into `CommitteeSelection::WfaLs { persistent_voters,
non_persistent_voters }` — previously NPV was hard-zeroed because
the sim collapsed both into one number.
Empirical at NA,0.350 / wfa-ls / 750n / -s 500:
bundles: 2093 PV-only + 207 NPV-only + 1593 both
total: 3893 bundles, 11748 votes, mean 3.018/bundle
uncertified: 6/26, endorsements: 9
NPV-only bundles previously were 0 across every sim run; the 207
new ones are voters without a PV seat who won the per-EB Bernoulli
trial against their stake share — exactly the CIP-0164 contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Replaces ad-hoc adversarial flags (sim-rs's `ib_equivocation`,
`withhold_txs`) with a pluggable `Behaviour` trait under
`shared-rs/consensus/src/behaviour/`. Each state machine
(LeiosState, PraosState, MempoolState) owns a `Box<dyn Behaviour>`
and consults it at every effect-emitting handler via a small
take/restore helper.
Hook surface:
- Reactive `on_*` hooks return `BehaviourOutcome<E>` —
`Continue` (honest unchanged), `Replace(Vec<E>)`
(substitute the effect list) or `Append(Vec<E>)` (run honest
AND add extras — the equivocation pattern).
- Decision hook `decide_vote` returns `DecisionOutcome<T>` —
`Continue` (use honest predicate) or `Override(T)`.
- Strategy hook `rb_production_strategy` returns
`RbProductionStrategy { Normal, Suppress, Equivocate }` for the
wrapper to act on (RB construction stays consumer-side).
Wired entry points: `on_slot`, `on_eb_offered`, `on_eb_txs_offered`,
`on_votes_offered`, `on_eb_received`, `on_votes_received`,
`decide_vote` (Leios); `on_tip_advanced`, `on_block_received`,
`on_peer_disconnected` (Praos); `on_tx_received`, `on_tx_validated`
(Mempool).
Two concrete behaviours land alongside the trait, each in its own
file under `behaviour/behaviours/`:
- `RbEquivocator` — returns `RbProductionStrategy::Equivocate`.
- `LazyVoter` — overrides `decide_vote` to always abstain.
`BehaviourSpec` serde enum + `build()` factory in `registry.rs`
deserialises a behaviour from per-node config; `CompositeBehaviour`
composes children with first-non-`Continue` wins. `DelayQueue<E>`
helper for slot-granular delay behaviours that hold and re-emit
effects on later slot ticks (no scheduler in shared-consensus).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Carry CIP-0164's `certified_eb` flag through `ParsedHeaderInfo` / `CachedBlock`, plus a new `PraosState::parent_announced_eb_for_cert` that resolves a freshly-applied cert RB back to (parent EB slot, parent EB hash) using only block-cache state. `Consensus::on_validation_outcome` calls `LeiosState::on_chain_endorsement` for the resolved EB on every successful `Applied`. The gate fires when a peer's cert RB lands before the EB body diffuses locally; the next own-production attempt sees `has_endorsed_unvalidated_eb()` and emits an empty body. Self-produced cert RBs flow through the same hook for symmetry (and are no-ops in practice, since cert formation requires local validation). Sim adapter's `parsed_header_from_rb` now populates `certified_eb` from `rb.endorsement.is_some()`. Its existing direct calls to `leios.on_chain_endorsement` from `try_produce_rb` / `finish_applying_rb` remain — same end-state, different path that matches the sim's explicit endorsement reference. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The per-component dump is synchronous on the slot tick (reads /proc/self/status, walks every BTreeMap on node 0) and shows up as a regular CPU heartbeat in htop. Off by default; pass --memory-stats on sim-cli to opt in. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The `Message`, `CpuTask`, and `TimedEvent` enums sat inside `linear_leios.rs` but were consumed by both adapters that implement Linear Leios in the sim — `linear_leios.rs` itself and the newer `con_rs.rs`. Splitting them into `sim/linear_wire.rs` makes the ownership match reality: the wire shape is the union both adapters exchange, neither adapter owns it. Pure refactor: enum variants unchanged, `SimMessage`/`SimCpuTask` impls move with their enums, all 55 sim-core tests pass. The adapter-specific dispatch (linear's bundle path; con-rs to follow with per-vote variants in the next commit) stays in each adapter's `handle_message` / `handle_cpu_task`. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
CIP-0164 requires voters to abstain from voting for any EB associated with a slot at which RB-header equivocation has been detected: > "It has not detected any equivocating RB header for the same > slot." > "By waiting 3 L_hdr slots before voting begins, the protocol > ensures that if any equivocation occurred soon enough to > matter, all honest nodes will have detected it and will refuse > to vote for any EB from an RB slot where equivocation was > detected." Previously the 3·Δhdr `EquivocationCheck` pipeline phase was just a waiting state with no detection logic — both linear and con-rs had the timing window but neither implemented the actual abstention rule. Item 15 of the diff plan was misclassified as locus-only. This adds: - `ParsedHeaderInfo.issuer: Vec<u8>` — producer identity (Shelley+ issuer_vkey in net-rs; NodeId bytes in sim-rs). Empty bytes disable detection for that header (preserves legacy behavior for callers that don't populate it yet). - `PraosState.header_hashes_by_slot_issuer` — per (slot, issuer) pair, the set of distinct RB header hashes observed. Fed from `on_block_received` and `register_self_produced` on every parsed-header arrival. - `PraosState.equivocating_rb_slots` — slots flagged because the same issuer signed multiple distinct headers there. Exposed via `is_equivocating_slot(slot)` and the public `equivocating_rb_slots` set for wrapper consumption. - `ChainTipContext.equivocating_slots` — wrapper-fed BTreeSet populated on every chain-tip refresh by cloning the PraosState set. Cheap clone — bounded by the pipeline-window size. - `NoVoteReason::EquivocatingRB` in con-rs and `EquivocatingRB` in sim-rs's `model::NoVoteReason`. - New voting predicate in `LeiosState::decide_vote`, run after the existing WrongEB / LateRBHeader checks: if `eb_slot` is in `chain_tip_ctx.equivocating_slots`, return Err(EquivocatingRB). Consumer wiring: - sim adapter: `parsed_header_from_rb` populates `issuer` from `h.id.producer` (NodeId → little-endian u64 bytes). `update_chain_tip_ctx` clones `praos.equivocating_rb_slots` into the refreshed ChainTipContext. - net-rs: `parse_header` in `consensus/praos/mod.rs` populates `issuer` from `i.issuer_vkey.to_vec()`. `refresh_chain_tip_ctx` in `consensus/mod.rs` clones the equivocation set from praos. Added `PraosConsensus::equivocating_rb_slots()` accessor. Two valid RBs from different producers winning the same slot via VRF is a legitimate Praos fork — different (slot, issuer) pairs — not an equivocation. Praos's existing fork-resolution handles that case; this detection is specifically for same-producer signing of multiple headers at one slot. Tests: - con-rs: 5 new equivocation-tracking tests in praos.rs (same-issuer flags, different-issuer doesn't, empty-issuer disabled, duplicate-hash no-op, self-produced equivocation), 1 new voting test in leios.rs (`no_vote_equivocating_rb_slot`). - Existing tests in both crates updated for the new `issuer` / `equivocating_slots` fields via `..Default::default()` literals. Verification: con-rs 237 tests pass (+6), sim-core 55, net-rs workspace 119. Behaviorally invisible in benign sweeps — no node equivocates in current scenarios — so the in-flight NA,0.350 sanity check (pinned to the pre-equivocation commit anyway) is unaffected. Material under adversarial / threat-model studies. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The cluster config gains an optional top-level `tx_rate` that, when set, overrides `[transactions] tx_rate` in every node's overlay. Lets cluster scenarios drive EB production without authoring a dedicated base config — e.g. abstention-pressure experiments that need the mempool busy enough to overflow into EBs. Plumbed through the same `control_fields` / `read_node_config_defaults` path as `rb_generation_probability`, so future UI work can read or change it dynamically. Also adds `sample-cluster-leios-baseline.toml`: 30-node `mainnet-shaped` cluster, `rb_generation_probability = 0.05` (the mainnet-realistic minimum at which Leios still has a chance), no adversarial behaviour. Useful as the apples-to-apples comparison point for any subsequent behaviour-bearing scenarios. Empirical note (not encoded in any test, just for context): smoking this baseline at `tx_rate = 1.0` doesn't currently reach quorum on its own — 100% of NoVotes are `MissingTX`, confirming the net-rs EB-tx fetch hub-spoke ceiling is the dominant failure mode at this tx rate. That's an upstream net-core issue tracked separately (`project_netrs_vs_sim_NA200`); the cluster knob just exposes it. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds `VotingConfig.retry_vote_in_window` (default `true`). The flag gates both in-window retry paths uniformly: - predicate failure (`WrongEB` / `LateRBHeader` / `MissingTX`): a later slot in the window may see chain-tip / mempool state that flips the predicate to success; - lottery loss (no PV seat and no NPV win): the NPV trial re-runs with a fresh per-slot VRF input, so a non-seated voter has up to `vote_window` independent chances to win. `LateEB` stays permanent regardless — `eb_seen_slot` is fixed. `true` matches the CIP-0164 reading that the voting window is the licence to vote at any in-window moment. `false` gives one decision per (voter, EB) and one NPV trial — useful for adversarial sims and for like-for-like comparison against linear_leios.rs. Surfaced as `retry-vote-in-window` in sim-rs's RawParameters. net-rs hardcodes `true` (production stays on CIP semantics). Empirical: at NA,0.350 / wfa-ls / 750n / -s 1500 the flag doesn't move the aggregate (this workload's WfaLs committee is pure PV; the NPV path is dormant). The real divergence vs linear_leios turned out to be that linear's WfaLs is a per-EB VRF lottery rather than the CIP-0164 stake-weighted Hare-quota seat allocation con-rs uses — covered separately by item 1 in the diff plan. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`Elections::on_slot` dropped every election whose `phase_for_elapsed`
returned `None` — i.e. once `elapsed >= 3*delta_hdr + L_vote +
L_diff + dedup_window`. Quorum-reached entries went out with the
rest, which made a parent-tied cert-selection adapter unable to
assemble a cert for any parent EB older than `dedup_window` slots
past CertEligible start — even though the cert was provably valid
from accumulated votes.
A quorum-reached election is an assemblable cert; pruning it is a
correctness loss disguised as bookkeeping. Linear's pruning
("drop only when a strictly newer EB has been endorsed") keeps such
entries available until something supersedes them. Match that
semantic by keeping `quorum_reached` entries past pipeline expiry;
non-quorum entries still drop on schedule.
Diagnostic confirmation: sim-rs adapter at NA,0.350 seed 1 with
parent-tied cert selection went from 14 endorsements (the buggy
state where ~27/99 attempts hit `no_quorum` with sums of 467-477
against threshold 450 because the election had been pruned) to 28
endorsements (within seed-noise of linear's 29), closing the +21%
EB inflation gap that has tracked con-rs vs linear since the W2.6
sweep.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
README adds module entries for lottery / bitmap / fetch / mempool / production / behaviour, four flow-specific sequence diagrams (transactions, RBs, EBs, votes), an events-and-effects graph for MempoolState, and summary sections for the Behaviour and Fetch subsystems. CLAUDE picks up the new modules in the map and adds a Behaviours section pointing contributors at the subsystem README. src/behaviour/README.md is new: hook taxonomy (reactive / decision / strategy / notification), outcome enums, BehaviourHandle ownership, composition rules, registry shape (BehaviourSpec, build, swap_handle), outbound transforms, DelayQueue, shipped behaviours, and the recipe for adding a new one — all under the same determinism contract. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Wires the `AnnounceRBHeader → RequestRBHeader → RBHeader → AnnounceRB → RequestRB → RB` handshake on the receive side, with the same FIFO state-machine shape as `linear_leios`: HeaderPending → Pending → Requested → Received Self-produced blocks bypass the early states and land directly in `Received` (servable on `RequestRB`). The unified `rbs` map replaces the prior `produced_rbs` side-table — same `BlockId` space whether locally produced or peer-received, which makes the header / body fetch handlers symmetric. `CpuTask::RBHeaderValidated` and `RBBlockValidated` are now hooked up. On `RBBlockValidated` the adapter calls `MempoolState::on_block_applied` for the included tx ids and prunes the local `tx_arcs` mirror — without it, txs that have gone on-chain would still be served on `RequestTx` and accounted in the mempool. Slot-battle resolution and `PraosState` integration (chain selection, fork-choice, parent tracking) are deliberately omitted in this slice — they land alongside `PraosState` in a follow-up. EB body fetch and vote propagation are likewise next-slice work. Build green; 55 sim-core tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Introduce a top-level `con-rs` crate as a peer of `net-rs` and `sim-rs`, holding the protocol pieces both implementations should agree on. This is step 1 of a planned extraction: lift the pure modules verbatim, no API redesign yet. Lifted from net-node/src/consensus/leios: - pipeline.rs: PipelinePhase, EbElection, PipelineConfig (CIP-0164 phase math). Drops EbElection.eb_point — only used for tracing — to avoid pulling minicbor into con-rs. Aggregation logging now formats slot + hash-prefix directly. - wfa.rs: persistent committee allocation, NPV eligibility signature + Bernoulli win count, build_committee, expected_committee_size. - aggregation.rs: record_vote / QuorumFormed. Lifted from net-node/src/config.rs: - CommitteeSelection (WfaLs / EveryoneVotes / StakeCentile) and its serde defaults; StakeEntry. net-node still imports these from `crate::config` via `pub use con_rs::*`. con-rs is sans-IO: deps are just serde / rand / blake2b_simd / tracing. No tokio, no net-core. Both consumers will reference it via path; sim-rs adoption follows in a later commit. Net-node delta: add con-rs path dep, replace the four module declarations with `use con_rs::*`, drop the eb_point construction site. voting.rs is unchanged — `super::wfa` resolves through mod.rs's re-import — and is deliberately left in net-node for now since it sends NetworkCommand and belongs in step 2's sans-IO redesign. Verified: cargo build, cargo test (28 con-rs + 544 net-rs workspace), cargo clippy --all-targets (11 warnings, all pre-existing — no regression). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`try_produce_rb` drives [`BodyPath::decide`] over the local mempool on every Praos lottery win and assembles a sim `LinearRankingBlock`: - `BodyPath::Inline` → txs go straight into the RB body; the mempool's free pool has already been drained by `decide`. - `BodyPath::Eb` → manifest is committed via `MempoolState::produce_eb` under a deterministic synthesized hash (sim doesn't model wire-byte Blake2b), and a `LinearEndorserBlock` is built carrying the same txs as a sim side-band. The RB header announces the EB. The pair is dispatched through `CpuTask::RBBlockGenerated`, matching `linear_leios`'s timing model. `finish_generating_rb` records the RB/EB in `produced_rbs` / `produced_ebs` side-tables and emits `AnnounceRBHeader` (+ `AnnounceEB` when applicable) to every consumer. Parent chain-tip tracking, endorsement assembly, and the receive-side RB/EB handlers are still stubs — they land alongside the `PraosState` integration in the next slice. Build green; 55 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two small lifts that remove con-rs's last dependencies on net-core:
- `peer::PeerId` (`pub struct PeerId(pub u64)` + Display). Picks up
`PartialOrd, Ord` so it can key BTreeMaps in the upcoming PraosState.
net-core re-exports via `pub use con_rs::PeerId`.
- `peer_chain::{PeerChain, PeerChainEntry, PeerChainAnchor}` — the
per-peer ordered fragment used by Praos chain selection. Lifted
verbatim with visibilities widened from pub(crate)/pub(super) to pub.
Adds `is_empty` to satisfy clippy.
Net-node's `consensus/praos/peer_chain.rs` is deleted; imports rewrite
to `con_rs::peer_chain::*` in praos/{mod,selection}.rs. No semantic
change; this is scaffolding for the next commit, which moves the pure
parts of `selection.rs` (walk_ancestors_hybrid, select_chain_once,
try_switch_to) into a `PraosState` struct in con-rs.
Verified: 58 con-rs tests pass (was 57 — +1 peer_chain test moved in),
528 net-rs tests pass (was 529 — same test moved out), cargo clippy
--all-targets shows 11 warnings in net-rs (baseline) and 0 in con-rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
When `remove_peer` aborts a peer task whose `PeerEvent::Connected` was already buffered in the fan-in channel, the coordinator was processing the Connected message after the peer was gone from `self.peers`, emitting a spurious `NetworkEvent::PeerConnected` with an empty address (the `unwrap_or_default` fallback) ordered right after the corresponding `PeerDisconnected`. Found while diagnosing the topology UI's edge-flash colour ordering: an edge would show red→green where green→red was expected, because each peer task's death produced a leftover Connected at the tail of the event sequence. Fix: in `handle_peer_event`'s `Connected` arm, return early when `self.peers.get(&peer_id)` is None. The early return both skips the misleading event emission and avoids the empty-address fallback path entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Pulls net-node/src/mempool.rs's queue, capacity-bounded eviction,
per-peer advertised set, and tx-id ↔ body lookup into
con-rs/src/mempool.rs as MempoolState — a peer of PraosState and
LeiosState. Validation crosses the boundary in the same shape as
Praos block apply and Leios EB / vote validation:
on_tx_received(tx_id, body) → emit ValidateTx
(wrapper validates)
on_tx_validated(tx_id, size) → admit + caller pulls advertise list
on_tx_validation_failed(.., r) → emit TxRejected(ValidationFailed)
No MempoolValidator trait — the consistent effect/on_xx pattern is
what makes the eventual Acropolis port a wrapper swap rather than a
trait migration. admit_validated bypasses the dance for locally-
generated txs the wrapper has already validated.
MempoolEffect::TxRejected covers all three drop reasons (QueueFull on
oldest-evict, ValidationFailed, AlreadyKnown on duplicate arrival)
so consumers get parity telemetry. No AdvertiseTx effect: the pull-
based peek_unannounced_for_peer query suffices for both net-rs's
TxSubmission server and the eventual sim-rs announce loop. All
internal state is BTreeMap / BTreeSet for deterministic iteration.
Net-rs side:
- net-node/src/mempool.rs reduced from ~650 to ~330 lines. Mempool
is now a one-field newtype over MempoolState; public methods
translate net_core::peer::PeerId ↔ con_rs::peer::PeerId and
net_core::PendingTx ↔ con_rs::mempool::PendingTx at the boundary.
- spawn_tx_generator and spawn_tx_validator stay (they're I/O-side
actors). Validator currently uses admit_validated to bypass the
effect path; the dance is exercised via direct con-rs unit tests
until a real validator service lands.
- Algorithmic mempool tests removed (lived in net-node's old test
block, now covered by con-rs's 23 tests on MempoolState directly).
Wrapper-specific tests (translation, fake-tx hashing, exp_sample,
validator integration) kept.
Verification: 178 con-rs tests (+23 new on MempoolState) and 478
net-rs tests pass; both crates clippy-clean under -D warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`derive_committee_selection` was multiplying `vote_probability` by the node count, which overshot the expected total committee weight by a factor of N: con-rs ran with persistent_voters in the thousands and produced ~13000 total votes per EB against linear_leios's ~1538 on the same config. The fix is one line — drop the `* total_nodes` factor. Both quantities are dimensioned as "expected total committee weight per EB": - sim's `vrf_probabilities(p)` runs `p` VRF trials per voter at stake-weighted success rate, so the per-EB total across voters sums to `p`. - con-rs's `WfaLs.persistent_voters` is the seat count distributed across pools by stake — same per-EB total. The dimension note is captured in the helper's doc comment and the module-level YAML knob table. W2.5 sanity check: byte-identical event streams across two runs (simple.yaml, 300 slots, default seed, sequential engine, 18542 events each). W2.6 equivalence on simple.yaml/600-slot brings con-rs total vote weight from 13000 down to 1000 (sim baseline: 1538); RB counts match exactly (31 vs 31), EB / endorsement counts match within one (4 vs 3, 1 vs 2). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
After the con-rs extraction, 17 of the ~31 leios consensus tests in net-node were exercising pure state-machine behaviour through a thin async wrapper — every shape change in con-rs (e.g., adding peer_id to LeiosBlockOffered, adding peers: Vec<PeerId> to FetchLeiosBlock) required a parallel edit here for the same assertion. Deleted (pure pipeline / election / quorum / EB-tx-match logic, all covered in con-rs's own #[cfg(test)] blocks): eb_creates_election election_advances_to_voting election_advances_through_all_phases duplicate_eb_deduped old_election_pruned multiple_ebs_concurrent eb_arriving_late_starts_in_correct_phase expired_eb_not_tracked no_vote_during_equivocation_check duplicate_block_offer_dedup duplicate_voter_not_counted quorum_reached_after_enough_voters pruned_election_drops_eb_manifests match_eb_tx_response_keeps_only_manifest_hashes_in_order match_eb_tx_response_with_unknown_manifest_passes_through match_eb_tx_response_pending_bitmap_cleared_after_match retry_eb_tx_fetch_with_empty_bitmap_is_noop Kept: tests that exercise wrapper translation specifically — effect → NetworkCommand dispatch, vote-body construction, validator submissions, EmitTelemetry → NodeEvent mapping, mempool-aware bitmap computation, and the cross-state-machine plumbing (validated-eb → election → vote-validated → quorum). These have no con-rs equivalent. net-rs goes from 512 to 495 tests; con-rs's 155 still own the deleted ground. cargo test green; clippy unchanged from the pre-existing manual_is_multiple_of in leios_store.rs. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>