Skip to content

fix(consensus): harden attestation and view-change signing payloads (WP §1568-1596)#53

Open
LucienSong wants to merge 4 commits into
ShellDAO:mainfrom
LucienSong:fix/consensus-signing-hardening
Open

fix(consensus): harden attestation and view-change signing payloads (WP §1568-1596)#53
LucienSong wants to merge 4 commits into
ShellDAO:mainfrom
LucienSong:fix/consensus-signing-hardening

Conversation

@LucienSong
Copy link
Copy Markdown
Contributor

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)

  • chain_id binding — prevents cross-network replay (e.g. testnet attestation accepted on mainnet)
  • parent_hash binding — prevents cross-fork replay (validators cannot reuse attestation on a reorged chain)
  • epoch field — aligns with WP §1568 epoch-based batching constant (ATTESTATION_EPOCH_BLOCKS = 1000)
  • round field — future-proofs the payload for wPoA round tracking (Phase 2 will populate from block header)

New fields added to Attestation struct: chain_id, parent_hash, round.
create_attestation() looks up parent_hash from chain_store; uses self.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)

  • chain_id binding — prevents cross-chain view-change injection
  • highest_qc_hash — binds the vote to the validator's known fork tip, preventing equivocation across forks

New fields added to ViewChangeMessage struct: chain_id, highest_qc_hash.
ViewChangeState gains chain_id field; record_view_change() rejects messages with mismatched chain ID.

Witness pruning grace (WP §storage)

proof_replacement_grace for Full and Light storage profiles changed from 0 (immediate deletion) to 128 (MIN_AMENDMENT_DEPTH). Prevents witness bundles being deleted before on-chain amendments at those heights can be confirmed.

Testing

  • All 220 shell-consensus + shell-node tests pass
  • Test helpers updated: make_att() in finality.rs wraps the new 7-arg constructor with zero defaults for quorum-only tests

Phase

Phase 1 of 6 — white-paper alignment plan. No testnet restart required; rolling validator upgrade is sufficient.

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>
Copilot AI review requested due to automatic review settings May 27, 2026 20:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_grace defaults 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 thread crates/node/src/node/p2p_handlers.rs Outdated
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 thread crates/node/src/pruning.rs Outdated
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 thread crates/node/src/pruning.rs Outdated
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>,
}
LucienSong and others added 3 commits May 28, 2026 14:58
- 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>
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.

2 participants