Skip to content

fix: stop peer gossip from poisoning the CW seam parent height - #607

Open
blacks1ne wants to merge 1 commit into
QuilibriumNetwork:v2.1.0.25from
blacks1ne:blacks1ne/fix-cw-seam-parent-height-poisoning
Open

fix: stop peer gossip from poisoning the CW seam parent height#607
blacks1ne wants to merge 1 commit into
QuilibriumNetwork:v2.1.0.25from
blacks1ne:blacks1ne/fix-cw-seam-parent-height-poisoning

Conversation

@blacks1ne

@blacks1ne blacks1ne commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Base: 932045a3

Scope narrowed on rebase. This originally fixed the same primitive on both consensus seams. At this tip the app-shard half is no longer reachable: cw_app_seams' verify only writes block_meta, and AppConsensusEngine::prove_next_state takes the height as _prior_frame_number and resolves the parent from the shard clock store instead. With no consumer, a poisoned app-side entry does nothing. The global seam is unchanged and still vulnerablecw_global_seams.rs:148 reads block_meta to resolve the Simplex parent, and the global engine must build on exactly that height.

The vector. The consensus digest is Poseidon(header.output) and commits to nothing else — not frame_number. Channel-3 block ingress validated nothing before recording digest → frame_number, so any peer can pair a real frame's output with an arbitrary height and land it on that frame's digest. propose then builds at the height that index reports. A nullified view does not advance the Simplex parent, so the poisoned entry stays the parent forever — a permanent halt. The global channel 3 is not even sender-attributed (cw_consensus_bridge.rs:102).

The fix. Ingress stores block bytes and nothing else — the activation closure no longer holds the proposer at all, so the property is structural rather than a rule. The index takes validated writes only (propose, verify, the activation seed). Applied to both seams, the app seam as defence in depth so the primitive cannot come back if a consumer is reintroduced.

Also: propose no longer swallows prove_next_state errors. That silence is why the halt left no on-disk signal — and it is already earning its keep, since worker_active_storage_attestation's pre-existing failure on this tip is a mute 90-second timeout without it.

Tests. global_consensus_cw_survives_forged_parent_height_gossip fails on pre-fix source (chain dead after one forged message, 60s) and passes after (0.21s). The rig is new — activate_global_consensus_cw had no harness. It drives a single-member committee and attacks through the real public ingest_block, keyed off the block broadcast rather than finalization, since a finalize-keyed attacker is ~2 views late on a fast chain and proves nothing. Unit tests on both seams cover ingress-cannot-forge-the-height and ingress-still-delivers-bytes.

Not fixed here: the extra-view-per-frame cost from the #593 review (a design change, not a revert), and a candidate-swap race — BlockStore::put replaces unsealed candidates, so a peer can swap a mutated body under the same digest before verify reads it.

Refs #591.

The consensus digest is `Poseidon(header.output)` and commits to nothing
else — not `frame_number`. Channel-3 block ingress validates nothing before
recording digest→frame_number, so any peer can pair a real frame's `output`
with an arbitrary height and land it on that frame's digest.

On the GLOBAL chain that is a live halt. `propose` reads the index as
authoritative, so a poisoned entry is a HIT (not the miss the clock-store
fallback covers) and `prove_next_state` fails on every view this node leads:

    cw propose: prove_next_state failed — cannot build a proposal
    view=3289 prior_frame_number=1000001 error=not found: needs sync:
    consensus parent is frame 1, told 1000001

Channel 3 is not even sender-attributed there (`CwInboundRouter::route`
forwards it without resolving the peer), so one unauthenticated message is
enough.

Ingress now stores block bytes and nothing else, on both seams — the
activation closures no longer hold the proposer at all, so the property is
structural rather than a rule. The index keeps taking validated writes only
(`propose`, `verify`, the activation seed), which is what the restart
fallback and the genesis seed already rely on.

Scope note: on the APP seam this is now defence-in-depth rather than a live
fix. `resolve build errors, unified tree model, fix sync` (932045a) deleted
`app_header_extends_simplex_parent`, dropped `parent_digest` from
`AppSeamProposer::verify`, and made `propose`'s height advisory
(`unwrap_or(0)`, with `AppLeaderProvider::prove_next_state` ignoring it), so
the forged height has no reachable consumer on that seam today. The ingress
write remains the primitive, so it is removed there too. Dropped from the
original version of this change accordingly: the identity-checked
`resolve_parent_frame_number` fallback and its `clock_store` plumbing (they
would reinstate the nullify-on-miss gate 932045a deliberately removed), the
`verify` height gate (the code is gone), and the app-shard e2e attack test
(it can no longer distinguish fixed from unfixed source; the two app-seam
unit tests guard the primitive directly).

`propose` also no longer swallows `prove_next_state` errors — that silence is
why the halt left no on-disk signal.

The global e2e rig is new: `activate_global_consensus_cw` had no harness. It
drives a single-member committee and attacks through the real public
`ingest_block`, keyed off the block broadcast rather than finalization (a
finalize-keyed attacker is ~2 views late on a fast chain and proves nothing).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@blacks1ne
blacks1ne force-pushed the blacks1ne/fix-cw-seam-parent-height-poisoning branch from 5c8456d to e1eaf77 Compare August 18, 2026 10:35
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