Skip to main content

hotmint_consensus/
commit.rs

1use ruc::*;
2
3use crate::application::Application;
4use crate::store::BlockStore;
5use hotmint_types::context::BlockContext;
6use hotmint_types::epoch::Epoch;
7use hotmint_types::{Block, BlockHash, DoubleCertificate, EndBlockResponse, Height, ViewNumber};
8use tracing::info;
9
10/// Result of a commit operation
11pub struct CommitResult {
12    pub committed_blocks: Vec<Block>,
13    /// The QC that certified the committed block (for sync protocol).
14    pub commit_qc: hotmint_types::QuorumCertificate,
15    /// If an epoch transition was triggered by end_block, the new epoch (start_view is placeholder)
16    pub pending_epoch: Option<Epoch>,
17    /// Application state root after executing the last committed block.
18    pub last_app_hash: BlockHash,
19    /// EndBlockResponse for each committed block (same order as committed_blocks).
20    pub block_responses: Vec<EndBlockResponse>,
21}
22
23/// Decode length-prefixed transactions from a block payload.
24pub fn decode_payload(payload: &[u8]) -> Vec<&[u8]> {
25    let mut txs = Vec::new();
26    let mut offset = 0;
27    while offset + 4 <= payload.len() {
28        let len = u32::from_le_bytes(payload[offset..offset + 4].try_into().unwrap()) as usize;
29        offset += 4;
30        if offset + len > payload.len() {
31            tracing::warn!(
32                offset,
33                claimed_len = len,
34                payload_len = payload.len(),
35                "decode_payload: length prefix exceeds remaining bytes, truncating"
36            );
37            break;
38        }
39        txs.push(&payload[offset..offset + len]);
40        offset += len;
41    }
42    txs
43}
44
45/// Execute the two-chain commit rule:
46/// When we get C_v(C_v(B_k)), commit the inner QC's block and all uncommitted ancestors.
47///
48/// For each committed block, runs the full application lifecycle:
49/// begin_block → deliver_tx (×N) → end_block → on_commit
50///
51/// # Safety
52/// Caller MUST verify both inner_qc and outer_qc aggregate signatures
53/// and quorum counts before calling this function. This function trusts
54/// the DoubleCertificate completely and performs no cryptographic checks.
55pub fn try_commit(
56    double_cert: &DoubleCertificate,
57    store: &dyn BlockStore,
58    app: &dyn Application,
59    last_committed_height: &mut Height,
60    current_epoch: &Epoch,
61) -> Result<CommitResult> {
62    let commit_hash = double_cert.inner_qc.block_hash;
63    let commit_block = store
64        .get_block(&commit_hash)
65        .c(d!("block to commit not found"))?;
66
67    if commit_block.height <= *last_committed_height {
68        return Ok(CommitResult {
69            committed_blocks: vec![],
70            commit_qc: double_cert.inner_qc.clone(),
71            pending_epoch: None,
72            last_app_hash: BlockHash::GENESIS,
73            block_responses: vec![],
74        });
75    }
76
77    // Collect all uncommitted ancestors (from highest to lowest)
78    let mut to_commit = Vec::new();
79    let mut current = commit_block;
80    loop {
81        if current.height <= *last_committed_height {
82            break;
83        }
84        let parent_hash = current.parent_hash;
85        let current_height = current.height;
86        to_commit.push(current);
87        if parent_hash == BlockHash::GENESIS {
88            break;
89        }
90        match store.get_block(&parent_hash) {
91            Some(parent) => current = parent,
92            None => {
93                // If the missing ancestor is above last committed + 1, the store
94                // is corrupt or incomplete — we must not silently skip blocks.
95                if current_height > Height(last_committed_height.as_u64() + 1) {
96                    return Err(eg!(
97                        "missing ancestor block {} for height {} (last committed: {})",
98                        parent_hash,
99                        current_height,
100                        last_committed_height
101                    ));
102                }
103                break;
104            }
105        }
106    }
107
108    // Commit from lowest height to highest
109    to_commit.reverse();
110
111    let mut pending_epoch: Option<Epoch> = None;
112    let mut last_app_hash = BlockHash::GENESIS;
113    let mut block_responses = Vec::with_capacity(to_commit.len());
114
115    for block in &to_commit {
116        let ctx = BlockContext {
117            height: block.height,
118            view: block.view,
119            proposer: block.proposer,
120            epoch: current_epoch.number,
121            epoch_start_view: current_epoch.start_view,
122            validator_set: &current_epoch.validator_set,
123            vote_extensions: vec![],
124        };
125
126        info!(height = block.height.as_u64(), hash = %block.hash, "committing block");
127
128        let txs = decode_payload(&block.payload);
129        // A committed block MUST be executed successfully. If the application
130        // returns an error here, the node's state is irrecoverably corrupted
131        // (partial batch commit). Panicking causes a restart from persistent
132        // state, which is safer than continuing with a diverged app_hash.
133        let response = app.execute_block(&txs, &ctx).unwrap_or_else(|e| {
134            panic!(
135                "FATAL: execute_block failed for committed block height={} hash={}: {:?}. \
136                 Node state is corrupt; restart from last committed height.",
137                block.height, block.hash, e
138            )
139        });
140
141        app.on_commit(block, &ctx).unwrap_or_else(|e| {
142            panic!(
143                "FATAL: on_commit failed for committed block height={} hash={}: {:?}. \
144                 Node state is corrupt; restart from last committed height.",
145                block.height, block.hash, e
146            )
147        });
148
149        // Process embedded evidence — notify the application layer for each
150        // proof so it can apply slashing deterministically (C-3).
151        for proof in &block.evidence {
152            if let Err(e) = app.on_evidence(proof) {
153                tracing::warn!(
154                    validator = %proof.validator,
155                    error = %e,
156                    "on_evidence failed for embedded proof"
157                );
158            }
159        }
160
161        // When the application does not track state roots, carry the block's
162        // authoritative app_hash forward so the engine state stays coherent
163        // with the chain even when NoopApplication always returns GENESIS.
164        last_app_hash = if app.tracks_app_hash() {
165            response.app_hash
166        } else {
167            block.app_hash
168        };
169
170        if !response.validator_updates.is_empty() {
171            // Chain epoch transitions: if a prior block in this batch already
172            // produced a pending epoch, apply the new updates on top of that
173            // intermediate validator set rather than the original epoch's set.
174            let base_vs = if let Some(ref ep) = pending_epoch {
175                &ep.validator_set
176            } else {
177                &current_epoch.validator_set
178            };
179            let base_num = if let Some(ref ep) = pending_epoch {
180                ep.number
181            } else {
182                current_epoch.number
183            };
184            let new_vs = base_vs.apply_updates(&response.validator_updates);
185            let epoch_start = ViewNumber(block.view.as_u64() + 2);
186            pending_epoch = Some(Epoch::new(base_num.next(), epoch_start, new_vs));
187        }
188
189        block_responses.push(response);
190        *last_committed_height = block.height;
191    }
192
193    Ok(CommitResult {
194        committed_blocks: to_commit,
195        commit_qc: double_cert.inner_qc.clone(),
196        pending_epoch,
197        last_app_hash,
198        block_responses,
199    })
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::application::NoopApplication;
206    use crate::store::MemoryBlockStore;
207    use hotmint_types::crypto::PublicKey;
208    use hotmint_types::epoch::EpochNumber;
209    use hotmint_types::validator::{ValidatorInfo, ValidatorSet};
210    use hotmint_types::{AggregateSignature, QuorumCertificate, ValidatorId, ViewNumber};
211
212    fn make_block(height: u64, parent: BlockHash) -> Block {
213        let hash = BlockHash([height as u8; 32]);
214        Block {
215            height: Height(height),
216            parent_hash: parent,
217            view: ViewNumber(height),
218            proposer: ValidatorId(0),
219            timestamp: 0,
220            payload: vec![],
221            app_hash: BlockHash::GENESIS,
222            evidence: Vec::new(),
223            hash,
224        }
225    }
226
227    fn make_qc(hash: BlockHash, view: u64) -> QuorumCertificate {
228        QuorumCertificate {
229            block_hash: hash,
230            view: ViewNumber(view),
231            aggregate_signature: AggregateSignature::new(4),
232            epoch: EpochNumber(0),
233        }
234    }
235
236    fn make_epoch() -> Epoch {
237        let vs = ValidatorSet::new(vec![ValidatorInfo {
238            id: ValidatorId(0),
239            public_key: PublicKey(vec![0]),
240            power: 1,
241        }]);
242        Epoch::genesis(vs)
243    }
244
245    #[test]
246    fn test_commit_single_block() {
247        let mut store = MemoryBlockStore::new();
248        let app = NoopApplication;
249        let epoch = make_epoch();
250        let b1 = make_block(1, BlockHash::GENESIS);
251        store.put_block(b1.clone());
252
253        let dc = DoubleCertificate {
254            vote_extensions: vec![],
255            inner_qc: make_qc(b1.hash, 1),
256            outer_qc: make_qc(b1.hash, 1),
257        };
258
259        let mut last = Height::GENESIS;
260        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
261        assert_eq!(result.committed_blocks.len(), 1);
262        assert_eq!(result.committed_blocks[0].height, Height(1));
263        assert_eq!(last, Height(1));
264        assert!(result.pending_epoch.is_none());
265    }
266
267    #[test]
268    fn test_commit_chain_of_blocks() {
269        let mut store = MemoryBlockStore::new();
270        let app = NoopApplication;
271        let epoch = make_epoch();
272        let b1 = make_block(1, BlockHash::GENESIS);
273        let b2 = make_block(2, b1.hash);
274        let b3 = make_block(3, b2.hash);
275        store.put_block(b1);
276        store.put_block(b2);
277        store.put_block(b3.clone());
278
279        let dc = DoubleCertificate {
280            vote_extensions: vec![],
281            inner_qc: make_qc(b3.hash, 3),
282            outer_qc: make_qc(b3.hash, 3),
283        };
284
285        let mut last = Height::GENESIS;
286        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
287        assert_eq!(result.committed_blocks.len(), 3);
288        assert_eq!(result.committed_blocks[0].height, Height(1));
289        assert_eq!(result.committed_blocks[1].height, Height(2));
290        assert_eq!(result.committed_blocks[2].height, Height(3));
291        assert_eq!(last, Height(3));
292    }
293
294    #[test]
295    fn test_commit_already_committed() {
296        let mut store = MemoryBlockStore::new();
297        let app = NoopApplication;
298        let epoch = make_epoch();
299        let b1 = make_block(1, BlockHash::GENESIS);
300        store.put_block(b1.clone());
301
302        let dc = DoubleCertificate {
303            vote_extensions: vec![],
304            inner_qc: make_qc(b1.hash, 1),
305            outer_qc: make_qc(b1.hash, 1),
306        };
307
308        let mut last = Height(1);
309        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
310        assert!(result.committed_blocks.is_empty());
311    }
312
313    #[test]
314    fn test_commit_partial_chain() {
315        let mut store = MemoryBlockStore::new();
316        let app = NoopApplication;
317        let epoch = make_epoch();
318        let b1 = make_block(1, BlockHash::GENESIS);
319        let b2 = make_block(2, b1.hash);
320        let b3 = make_block(3, b2.hash);
321        store.put_block(b1);
322        store.put_block(b2);
323        store.put_block(b3.clone());
324
325        let dc = DoubleCertificate {
326            vote_extensions: vec![],
327            inner_qc: make_qc(b3.hash, 3),
328            outer_qc: make_qc(b3.hash, 3),
329        };
330
331        let mut last = Height(1);
332        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
333        assert_eq!(result.committed_blocks.len(), 2);
334        assert_eq!(result.committed_blocks[0].height, Height(2));
335        assert_eq!(result.committed_blocks[1].height, Height(3));
336    }
337
338    #[test]
339    fn test_commit_missing_block() {
340        let store = MemoryBlockStore::new();
341        let app = NoopApplication;
342        let epoch = make_epoch();
343        let dc = DoubleCertificate {
344            vote_extensions: vec![],
345            inner_qc: make_qc(BlockHash([99u8; 32]), 1),
346            outer_qc: make_qc(BlockHash([99u8; 32]), 1),
347        };
348        let mut last = Height::GENESIS;
349        assert!(try_commit(&dc, &store, &app, &mut last, &epoch).is_err());
350    }
351
352    #[test]
353    fn test_decode_payload_empty() {
354        assert!(decode_payload(&[]).is_empty());
355    }
356
357    #[test]
358    fn test_decode_payload_roundtrip() {
359        // Encode: 4-byte LE length prefix + data
360        let mut payload = Vec::new();
361        let tx1 = b"hello";
362        let tx2 = b"world";
363        payload.extend_from_slice(&(tx1.len() as u32).to_le_bytes());
364        payload.extend_from_slice(tx1);
365        payload.extend_from_slice(&(tx2.len() as u32).to_le_bytes());
366        payload.extend_from_slice(tx2);
367
368        let txs = decode_payload(&payload);
369        assert_eq!(txs.len(), 2);
370        assert_eq!(txs[0], b"hello");
371        assert_eq!(txs[1], b"world");
372    }
373}