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