massa_versioning/
consensus_signature.rs

1//! Consensus signature layout gating (F90 / PDF #11).
2//!
3//! Endorsements and block headers used to be signed with a chain-agnostic
4//! hash: the signed hash did not include the chain id, so two legitimate
5//! same-(slot, index) signatures produced by the same validator on two
6//! different chains (e.g. MAINNET + LABNET) could be combined into a
7//! denunciation on either chain and slash the validator for entirely normal
8//! cross-chain operation.
9//!
10//! The fix folds the chain id into the signed hash, deployed through the
11//! existing MIP-0002 network-versioning mechanism (the release that bumps
12//! `MipComponent::Execution` to `MIP_0002_EXECUTION_VERSION`), so
13//! upgraded and un-upgraded nodes keep agreeing on signature validity
14//! across the activation boundary - no flag-day change.
15//!
16//! This module exposes the single helper that decides, for a given slot,
17//! which signed-hash layout to use:
18//! * `None`  -> legacy (chain-agnostic) layout, identical to the pre-fix one.
19//! * `Some(chain_id)` -> chain-scoped layout, folding `chain_id` into the
20//!   signed hash.
21//!
22//! The decision is based on the network-agreed active `Execution` component
23//! version at the message's slot timestamp - not any self-claimed header
24//! field. Signing, on-receive verification, denunciation creation and
25//! denunciation execution must all call this with the same
26//! `(mip_store, chain_id, slot_ts)` so they flip layouts together at the
27//! deterministic activation slot.
28
29use massa_time::MassaTime;
30
31use crate::mips::MIP_0002_EXECUTION_VERSION;
32use crate::versioning::{MipComponent, MipStore};
33
34/// Returns the `sig_chain_id` to use for signing / verifying an endorsement
35/// or block header whose slot timestamp is `slot_ts` on the local chain
36/// identified by `chain_id`.
37///
38/// Returns `Some(chain_id)` once the `Execution` component is active at
39/// version >= [`MIP_0002_EXECUTION_VERSION`] for that slot, `None`
40/// otherwise (legacy layout).
41///
42/// Every signing / verification / denunciation-reconstruction site must
43/// call this with the message's own slot timestamp so all nodes switch
44/// layouts simultaneously at the activation slot (no partition).
45pub fn sig_chain_id_for_slot(
46    mip_store: &MipStore,
47    chain_id: u64,
48    slot_ts: MassaTime,
49) -> Option<u64> {
50    if mip_store.get_latest_component_version_at(&MipComponent::Execution, slot_ts)
51        >= MIP_0002_EXECUTION_VERSION
52    {
53        Some(chain_id)
54    } else {
55        None
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use std::collections::BTreeMap;
63
64    use massa_hash::Hash;
65    use massa_models::block_header::{BlockHeader, BlockHeaderSerializer};
66    use massa_models::block_id::BlockId;
67    use massa_models::config::{CHAINID, MIP_STORE_STATS_BLOCK_CONSIDERED};
68    use massa_models::denunciation::Denunciation;
69    use massa_models::endorsement::{Endorsement, EndorsementSerializer};
70    use massa_models::secure_share::{Id, SecureShareContent};
71    use massa_models::slot::Slot;
72    use massa_signature::KeyPair;
73    use num::rational::Ratio;
74
75    use crate::test_helpers::versioning_helpers::advance_state_until;
76    use crate::versioning::{ComponentState, MipComponent, MipInfo, MipStatsConfig, MipStore};
77
78    /// MIP-0002-shaped store: Execution v2 is currently Active. Layout is still
79    /// decided per timestamp via `state_at`, so slots before `start` stay legacy.
80    fn store_with_execution_v2_active() -> (MipStore, MassaTime, MassaTime) {
81        let start = MassaTime::from_millis(10_000);
82        let timeout = MassaTime::from_millis(100_000);
83        let activation_delay = MassaTime::from_millis(5_000);
84        let mi = MipInfo {
85            name: "MIP-0002-BugFix".to_string(),
86            version: 2,
87            components: BTreeMap::from([(MipComponent::Execution, MIP_0002_EXECUTION_VERSION)]),
88            start,
89            timeout,
90            activation_delay,
91        };
92        let ms = advance_state_until(ComponentState::active(MassaTime::from_millis(0)), &mi);
93        let stats = MipStatsConfig {
94            block_count_considered: MIP_STORE_STATS_BLOCK_CONSIDERED,
95            warn_announced_version_ratio: Ratio::new_raw(30, 100),
96        };
97        let store = MipStore::try_from(([(mi, ms)], stats)).expect("mip store");
98
99        let before = start.saturating_sub(MassaTime::from_millis(1));
100        let active_at = (0..400)
101            .map(|i| start.saturating_add(MassaTime::from_millis(i * 100)))
102            .find(|ts| {
103                store.get_latest_component_version_at(&MipComponent::Execution, *ts)
104                    >= MIP_0002_EXECUTION_VERSION
105            })
106            .expect("Execution v2 should become Active after start");
107        (store, before, active_at)
108    }
109
110    fn make_endorsement(
111        keypair: &KeyPair,
112        slot: Slot,
113        tag: &str,
114        chain_id: u64,
115        sig_chain_id: Option<u64>,
116    ) -> massa_models::endorsement::SecureShareEndorsement {
117        Endorsement::new_verifiable(
118            Endorsement {
119                slot,
120                index: 0,
121                endorsed_block: BlockId::new(Hash::compute_from(tag.as_bytes())),
122            },
123            EndorsementSerializer::new(),
124            keypair,
125            chain_id,
126            sig_chain_id,
127        )
128        .unwrap()
129    }
130
131    fn make_header(
132        keypair: &KeyPair,
133        slot: Slot,
134        tag: &str,
135        chain_id: u64,
136        sig_chain_id: Option<u64>,
137    ) -> massa_models::block_header::SecuredHeader {
138        BlockHeader::new_verifiable(
139            BlockHeader {
140                current_version: 0,
141                announced_version: None,
142                slot,
143                parents: Vec::new(),
144                operation_merkle_root: Hash::compute_from(tag.as_bytes()),
145                endorsements: Vec::new(),
146                denunciations: Vec::new(),
147            },
148            BlockHeaderSerializer::new(),
149            keypair,
150            chain_id,
151            sig_chain_id,
152        )
153        .unwrap()
154    }
155
156    #[test]
157    fn test_sig_chain_id_for_slot_flips_at_execution_v2_active() {
158        let chain = *CHAINID;
159        let (store, before, active_at) = store_with_execution_v2_active();
160
161        assert_eq!(sig_chain_id_for_slot(&store, chain, before), None);
162        assert_eq!(sig_chain_id_for_slot(&store, chain, active_at), Some(chain));
163    }
164
165    /// Full migration: the helper, signing, and denunciation reconstruction
166    /// all use the same layout at a given slot timestamp, so nodes that query
167    /// the same MipStore agree (no partition). Covers endorsements and headers.
168    #[test]
169    fn test_mip_store_migration_endorsement_and_header_no_partition() {
170        let chain = *CHAINID;
171        let other_chain = chain.wrapping_add(1);
172        let (store, before, active_at) = store_with_execution_v2_active();
173        let keypair = KeyPair::generate(0).unwrap();
174        let slot = Slot::new(3, 7);
175
176        // --- pre-activation: helper says None; legacy denunciations verify ---
177        let pre = sig_chain_id_for_slot(&store, chain, before);
178        assert_eq!(pre, None);
179
180        let e1 = make_endorsement(&keypair, slot, "e_pre_1", chain, pre);
181        let e2 = make_endorsement(&keypair, slot, "e_pre_2", chain, pre);
182        let de_e_pre = Denunciation::try_from((&e1, &e2, pre)).unwrap();
183        assert!(de_e_pre.is_valid_with_chain(pre));
184        assert!(!de_e_pre.is_valid_with_chain(Some(chain)));
185
186        let h1 = make_header(&keypair, slot, "h_pre_1", chain, pre);
187        let h2 = make_header(&keypair, slot, "h_pre_2", chain, pre);
188        let de_h_pre = Denunciation::try_from((&h1, &h2, pre)).unwrap();
189        assert!(de_h_pre.is_valid_with_chain(pre));
190        assert!(!de_h_pre.is_valid_with_chain(Some(chain)));
191
192        // --- post-activation: helper says Some(chain); scoped denunciations verify ---
193        let post = sig_chain_id_for_slot(&store, chain, active_at);
194        assert_eq!(post, Some(chain));
195
196        let e3 = make_endorsement(&keypair, slot, "e_post_1", chain, post);
197        let e4 = make_endorsement(&keypair, slot, "e_post_2", chain, post);
198        let de_e_post = Denunciation::try_from((&e3, &e4, post)).unwrap();
199        assert!(de_e_post.is_valid_with_chain(post));
200        assert!(!de_e_post.is_valid_with_chain(None));
201
202        let h3 = make_header(&keypair, slot, "h_post_1", chain, post);
203        let h4 = make_header(&keypair, slot, "h_post_2", chain, post);
204        let de_h_post = Denunciation::try_from((&h3, &h4, post)).unwrap();
205        assert!(de_h_post.is_valid_with_chain(post));
206        assert!(!de_h_post.is_valid_with_chain(None));
207
208        // Cross-chain combination fails once the helper is in the scoped layout.
209        let e_other = make_endorsement(&keypair, slot, "e_other", other_chain, Some(other_chain));
210        assert!(Denunciation::try_from((&e3, &e_other, post)).is_err());
211        let h_other = make_header(&keypair, slot, "h_other", other_chain, Some(other_chain));
212        assert!(Denunciation::try_from((&h3, &h_other, post)).is_err());
213    }
214}