fix(consensus): harden attestation and view-change signing payloads (WP §1568-1596)#53
Open
LucienSong wants to merge 4 commits into
Open
fix(consensus): harden attestation and view-change signing payloads (WP §1568-1596)#53LucienSong wants to merge 4 commits into
LucienSong wants to merge 4 commits into
Conversation
Implements white-paper §1568-1596 signing payload requirements: Attestation (WP §1568-1582): - Extended signing payload from 40 bytes (block_hash||block_number) to 112 bytes: SHELL_ATTEST_V1\0 || chain_id(8) || epoch(8) || parent_hash(32) || block_hash(32) || block_number(8) || round(8) - Added chain_id, parent_hash, round fields to Attestation struct - Added ATTESTATION_EPOCH_BLOCKS = 1000 constant - chain_id binding prevents cross-network replay attacks - parent_hash binding prevents cross-fork replay attacks ViewChangeMessage (WP §1585-1596): - Extended signing payload from 16 bytes (view||block_number) to 56 bytes: SHELL_VIEWCHG_V1 || chain_id(8) || block_number(8) || view(8) || highest_qc_hash(32) - Added chain_id, highest_qc_hash fields to ViewChangeMessage struct - ViewChangeState gains chain_id field; record_view_change rejects messages from foreign chains Witness pruning (WP §storage): - proof_replacement_grace: Full 0→128, Light 0→128 (MIN_AMENDMENT_DEPTH) - Prevents witness deletion before amendments can be confirmed All 220 consensus + node tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates consensus-critical signing preimages for attestations and wPoA view-change messages to align with the whitepaper (WP §1568–1596), and adjusts node witness pruning grace to avoid premature witness deletion.
Changes:
- Expand attestation signing payload (domain tag + chain_id/epoch/parent_hash/round) and update signing/verification call sites.
- Expand view-change signing payload (domain tag + chain_id + highest_qc_hash) and add chain-id filtering in
ViewChangeState. - Set
proof_replacement_gracedefaults for Full/Light storage profiles to 128 (intended MIN_AMENDMENT_DEPTH).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/node/src/pruning.rs | Updates storage profile defaults/tests and docs to use a non-zero proof replacement grace. |
| crates/node/src/node/p2p_handlers.rs | Switches signature verification to use message-owned signing payloads; updates attestation creation to include new fields. |
| crates/node/src/node/mod.rs | Adjusts node serde test for the updated ViewChangeMessage::new signature. |
| crates/node/src/node/event_loop.rs | Updates view-change signing to include chain_id and highest_qc_hash. |
| crates/consensus/src/wpoa.rs | Updates tests/imports for the expanded view-change message struct. |
| crates/consensus/src/view_change.rs | Adds domain-separated, chain-bound view-change signing payload + chain_id tracking in view-change state. |
| crates/consensus/src/finality.rs | Adds domain-separated, chain-bound attestation signing payload + epoch derivation, and updates tests/helpers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
60
to
+64
| NodeError::Startup(format!("unknown attestation validator: {:?}", validator)) | ||
| })?; | ||
|
|
||
| // Verify the attestation signature. | ||
| let msg = Attestation::signing_message(&block_hash, block_number); | ||
| // Verify the attestation signature using the payload that was signed. | ||
| let msg = attestation.own_signing_message(); |
Comment on lines
+158
to
+161
| .ok() | ||
| .flatten() | ||
| .map(|h| h.parent_hash) | ||
| .unwrap_or(ShellHash::ZERO); |
Comment on lines
640
to
644
| })?; | ||
|
|
||
| let signing_message = ViewChangeMessage::signing_message(msg.view, msg.block_number); | ||
| let signing_message = msg.own_signing_message(); | ||
| let sig_type = shell_crypto::infer_signature_type_from_address(pubkey, &msg.validator) | ||
| .ok_or_else(|| { |
Comment on lines
+51
to
65
| /// The canonical signing payload (WP §1585-1596): | ||
| /// SHELL_VIEWCHG_V1 || chain_id(8 BE) || block_number(8 BE) || view(8 BE) || highest_qc_hash(32) | ||
| pub fn signing_message( | ||
| chain_id: u64, | ||
| block_number: u64, | ||
| view: u64, | ||
| highest_qc_hash: &ShellHash, | ||
| ) -> Vec<u8> { | ||
| let mut msg = Vec::with_capacity(56); | ||
| msg.extend_from_slice(VIEWCHG_DOMAIN); | ||
| msg.extend_from_slice(&chain_id.to_be_bytes()); | ||
| msg.extend_from_slice(&block_number.to_be_bytes()); | ||
| msg.extend_from_slice(&view.to_be_bytes()); | ||
| msg.extend_from_slice(highest_qc_hash.as_bytes()); | ||
| msg |
Comment on lines
82
to
+111
| @@ -51,13 +93,22 @@ impl ViewChangeState { | |||
| quorum_weight: 1, | |||
| validator_weights: HashMap::new(), | |||
| pending_view_change_weights: HashMap::new(), | |||
| chain_id: 0, | |||
| } | |||
| } | |||
|
|
|||
| /// Set the chain ID to enforce on incoming view-change messages. | |||
| pub fn set_chain_id(&mut self, chain_id: u64) { | |||
| self.chain_id = chain_id; | |||
| } | |||
|
|
|||
| pub fn record_view_change(&mut self, msg: ViewChangeMessage) -> bool { | |||
| if msg.view != self.current_view { | |||
| return false; | |||
| } | |||
| if self.chain_id != 0 && msg.chain_id != self.chain_id { | |||
| return false; | |||
| } | |||
Comment on lines
+28
to
+32
| /// | Profile (WP name) | body_retention | witness_retention | proof_replacement_grace | keep_recent | | ||
| /// |-------------------------|---------------|------------------|------------------------|-------------| | ||
| /// | Archive | 0 (forever) | 0 (forever) | u64::MAX (never) | 0 (forever) | | ||
| /// | Full | 0 (forever) | 128 | 0 (immediate) | 0 (forever) | | ||
| /// | Pruned / Rolling / Light| 4 096 | 64 | 0 (immediate) | 4 096 | | ||
| /// | Full | 0 (forever) | 128 | 128 (MIN_AMENDMENT_DEPTH) | 0 (forever) | | ||
| /// | Pruned / Rolling / Light| 4 096 | 64 | 128 (MIN_AMENDMENT_DEPTH) | 4 096 | |
Comment on lines
+28
to
+32
| /// | Profile (WP name) | body_retention | witness_retention | proof_replacement_grace | keep_recent | | ||
| /// |-------------------------|---------------|------------------|------------------------|-------------| | ||
| /// | Archive | 0 (forever) | 0 (forever) | u64::MAX (never) | 0 (forever) | | ||
| /// | Full | 0 (forever) | 128 | 0 (immediate) | 0 (forever) | | ||
| /// | Pruned / Rolling / Light| 4 096 | 64 | 0 (immediate) | 4 096 | | ||
| /// | Full | 0 (forever) | 128 | 128 (MIN_AMENDMENT_DEPTH) | 0 (forever) | | ||
| /// | Pruned / Rolling / Light| 4 096 | 64 | 128 (MIN_AMENDMENT_DEPTH) | 4 096 | |
Comment on lines
+51
to
+71
| /// The canonical signing payload (WP §1585-1596): | ||
| /// SHELL_VIEWCHG_V1 || chain_id(8 BE) || block_number(8 BE) || view(8 BE) || highest_qc_hash(32) | ||
| pub fn signing_message( | ||
| chain_id: u64, | ||
| block_number: u64, | ||
| view: u64, | ||
| highest_qc_hash: &ShellHash, | ||
| ) -> Vec<u8> { | ||
| let mut msg = Vec::with_capacity(56); | ||
| msg.extend_from_slice(VIEWCHG_DOMAIN); | ||
| msg.extend_from_slice(&chain_id.to_be_bytes()); | ||
| msg.extend_from_slice(&block_number.to_be_bytes()); | ||
| msg.extend_from_slice(&view.to_be_bytes()); | ||
| msg.extend_from_slice(highest_qc_hash.as_bytes()); | ||
| msg | ||
| } | ||
|
|
||
| /// Reconstruct the signing message from this message's own fields. | ||
| pub fn own_signing_message(&self) -> Vec<u8> { | ||
| Self::signing_message(self.chain_id, self.block_number, self.view, &self.highest_qc_hash) | ||
| } |
Comment on lines
29
to
45
| #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | ||
| pub struct Attestation { | ||
| /// Chain ID — binds the attestation to a specific network. | ||
| pub chain_id: u64, | ||
| /// Hash of the parent block — prevents cross-fork replays. | ||
| pub parent_hash: ShellHash, | ||
| /// Hash of the attested block. | ||
| pub block_hash: ShellHash, | ||
| /// Number of the attested block. | ||
| pub block_number: u64, | ||
| /// Address of the attesting validator. | ||
| pub validator: Address, | ||
| /// PQ signature over (block_hash || block_number) by the validator. | ||
| /// Consensus round (view) in which this block was produced. | ||
| /// Always 0 for standard PoA; set to the wPoA view after a view-change. | ||
| pub round: u64, | ||
| /// PQ signature over the signing payload. | ||
| pub signature: Vec<u8>, |
Comment on lines
+12
to
30
| /// A validator's request to advance the consensus view (rotate the proposer). | ||
| /// | ||
| /// Signing payload (WP §1585-1596): | ||
| /// SHELL_VIEWCHG_V1 || chain_id(8 BE) || block_number(8 BE) || view(8 BE) || highest_qc_hash(32) | ||
| #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | ||
| pub struct ViewChangeMessage { | ||
| pub view: u64, | ||
| /// Chain ID — binds the message to a specific network. | ||
| pub chain_id: u64, | ||
| /// Block number at which the view change is requested. | ||
| pub block_number: u64, | ||
| /// Requested view number. | ||
| pub view: u64, | ||
| /// Hash of the highest QC seen by this validator (last finalized block hash). | ||
| pub highest_qc_hash: ShellHash, | ||
| /// Address of the validator requesting the view change. | ||
| pub validator: Address, | ||
| /// PQ signature over the signing payload. | ||
| pub signature: Vec<u8>, | ||
| } |
4 tasks
- Add chain_id field to WPoaConfig (defaults to 0 for backwards compat) - Call view_change_state.set_chain_id() in WPoaEngine::new() when chain_id != 0 - Add #[serde(default)] to Attestation.chain_id/parent_hash/round and ViewChangeMessage.chain_id/highest_qc_hash for rolling-upgrade safety - Fix ViewChangeMessage signing_message capacity: 56 → 72 bytes - Add signing_message_has_correct_layout_and_length unit test - create_attestation() now returns Err on missing header instead of using ZERO - handle_attestation() and handle_wpoa_view_change() reject cross-chain messages - Add MIN_AMENDMENT_DEPTH constant; fix pruning doc table column order Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…import clippy error Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the canonical signing payload requirements from the white paper (WP §1568-1596) for attestations and view-change messages. Also fixes the witness-pruning grace default to respect MIN_AMENDMENT_DEPTH.
Changes
Attestation signing payload (WP §1568-1582)
Old payload: 40 bytes —
block_hash(32) || block_number(8)New payload: 112 bytes —
SHELL_ATTEST_V1\0(16) || chain_id(8) || epoch(8) || parent_hash(32) || block_hash(32) || block_number(8) || round(8)ATTESTATION_EPOCH_BLOCKS = 1000)New fields added to
Attestationstruct:chain_id,parent_hash,round.create_attestation()looks upparent_hashfromchain_store; usesself.config.chain_id.ViewChange signing payload (WP §1585-1596)
Old payload: 16 bytes —
view(8) || block_number(8)New payload: 56 bytes —
SHELL_VIEWCHG_V1(16) || chain_id(8) || block_number(8) || view(8) || highest_qc_hash(32)New fields added to
ViewChangeMessagestruct:chain_id,highest_qc_hash.ViewChangeStategainschain_idfield;record_view_change()rejects messages with mismatched chain ID.Witness pruning grace (WP §storage)
proof_replacement_graceforFullandLightstorage profiles changed from0(immediate deletion) to128(MIN_AMENDMENT_DEPTH). Prevents witness bundles being deleted before on-chain amendments at those heights can be confirmed.Testing
shell-consensus+shell-nodetests passmake_att()infinality.rswraps the new 7-arg constructor with zero defaults for quorum-only testsPhase
Phase 1 of 6 — white-paper alignment plan. No testnet restart required; rolling validator upgrade is sufficient.