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        let response = app
119            .execute_block(&txs, &ctx)
120            .c(d!("execute_block failed"))?;
121
122        app.on_commit(block, &ctx)
123            .c(d!("application commit failed"))?;
124
125        // When the application does not track state roots, carry the block's
126        // authoritative app_hash forward so the engine state stays coherent
127        // with the chain even when NoopApplication always returns GENESIS.
128        last_app_hash = if app.tracks_app_hash() {
129            response.app_hash
130        } else {
131            block.app_hash
132        };
133
134        if !response.validator_updates.is_empty() {
135            let new_vs = current_epoch
136                .validator_set
137                .apply_updates(&response.validator_updates);
138            let epoch_start = ViewNumber(block.view.as_u64() + 2);
139            pending_epoch = Some(Epoch::new(current_epoch.number.next(), epoch_start, new_vs));
140        }
141
142        *last_committed_height = block.height;
143    }
144
145    Ok(CommitResult {
146        committed_blocks: to_commit,
147        commit_qc: double_cert.inner_qc.clone(),
148        pending_epoch,
149        last_app_hash,
150    })
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::application::NoopApplication;
157    use crate::store::MemoryBlockStore;
158    use hotmint_types::crypto::PublicKey;
159    use hotmint_types::validator::{ValidatorInfo, ValidatorSet};
160    use hotmint_types::{AggregateSignature, QuorumCertificate, ValidatorId, ViewNumber};
161
162    fn make_block(height: u64, parent: BlockHash) -> Block {
163        let hash = BlockHash([height as u8; 32]);
164        Block {
165            height: Height(height),
166            parent_hash: parent,
167            view: ViewNumber(height),
168            proposer: ValidatorId(0),
169            payload: vec![],
170            app_hash: BlockHash::GENESIS,
171            hash,
172        }
173    }
174
175    fn make_qc(hash: BlockHash, view: u64) -> QuorumCertificate {
176        QuorumCertificate {
177            block_hash: hash,
178            view: ViewNumber(view),
179            aggregate_signature: AggregateSignature::new(4),
180        }
181    }
182
183    fn make_epoch() -> Epoch {
184        let vs = ValidatorSet::new(vec![ValidatorInfo {
185            id: ValidatorId(0),
186            public_key: PublicKey(vec![0]),
187            power: 1,
188        }]);
189        Epoch::genesis(vs)
190    }
191
192    #[test]
193    fn test_commit_single_block() {
194        let mut store = MemoryBlockStore::new();
195        let app = NoopApplication;
196        let epoch = make_epoch();
197        let b1 = make_block(1, BlockHash::GENESIS);
198        store.put_block(b1.clone());
199
200        let dc = DoubleCertificate {
201            inner_qc: make_qc(b1.hash, 1),
202            outer_qc: make_qc(b1.hash, 1),
203        };
204
205        let mut last = Height::GENESIS;
206        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
207        assert_eq!(result.committed_blocks.len(), 1);
208        assert_eq!(result.committed_blocks[0].height, Height(1));
209        assert_eq!(last, Height(1));
210        assert!(result.pending_epoch.is_none());
211    }
212
213    #[test]
214    fn test_commit_chain_of_blocks() {
215        let mut store = MemoryBlockStore::new();
216        let app = NoopApplication;
217        let epoch = make_epoch();
218        let b1 = make_block(1, BlockHash::GENESIS);
219        let b2 = make_block(2, b1.hash);
220        let b3 = make_block(3, b2.hash);
221        store.put_block(b1);
222        store.put_block(b2);
223        store.put_block(b3.clone());
224
225        let dc = DoubleCertificate {
226            inner_qc: make_qc(b3.hash, 3),
227            outer_qc: make_qc(b3.hash, 3),
228        };
229
230        let mut last = Height::GENESIS;
231        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
232        assert_eq!(result.committed_blocks.len(), 3);
233        assert_eq!(result.committed_blocks[0].height, Height(1));
234        assert_eq!(result.committed_blocks[1].height, Height(2));
235        assert_eq!(result.committed_blocks[2].height, Height(3));
236        assert_eq!(last, Height(3));
237    }
238
239    #[test]
240    fn test_commit_already_committed() {
241        let mut store = MemoryBlockStore::new();
242        let app = NoopApplication;
243        let epoch = make_epoch();
244        let b1 = make_block(1, BlockHash::GENESIS);
245        store.put_block(b1.clone());
246
247        let dc = DoubleCertificate {
248            inner_qc: make_qc(b1.hash, 1),
249            outer_qc: make_qc(b1.hash, 1),
250        };
251
252        let mut last = Height(1);
253        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
254        assert!(result.committed_blocks.is_empty());
255    }
256
257    #[test]
258    fn test_commit_partial_chain() {
259        let mut store = MemoryBlockStore::new();
260        let app = NoopApplication;
261        let epoch = make_epoch();
262        let b1 = make_block(1, BlockHash::GENESIS);
263        let b2 = make_block(2, b1.hash);
264        let b3 = make_block(3, b2.hash);
265        store.put_block(b1);
266        store.put_block(b2);
267        store.put_block(b3.clone());
268
269        let dc = DoubleCertificate {
270            inner_qc: make_qc(b3.hash, 3),
271            outer_qc: make_qc(b3.hash, 3),
272        };
273
274        let mut last = Height(1);
275        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
276        assert_eq!(result.committed_blocks.len(), 2);
277        assert_eq!(result.committed_blocks[0].height, Height(2));
278        assert_eq!(result.committed_blocks[1].height, Height(3));
279    }
280
281    #[test]
282    fn test_commit_missing_block() {
283        let store = MemoryBlockStore::new();
284        let app = NoopApplication;
285        let epoch = make_epoch();
286        let dc = DoubleCertificate {
287            inner_qc: make_qc(BlockHash([99u8; 32]), 1),
288            outer_qc: make_qc(BlockHash([99u8; 32]), 1),
289        };
290        let mut last = Height::GENESIS;
291        assert!(try_commit(&dc, &store, &app, &mut last, &epoch).is_err());
292    }
293
294    #[test]
295    fn test_decode_payload_empty() {
296        assert!(decode_payload(&[]).is_empty());
297    }
298
299    #[test]
300    fn test_decode_payload_roundtrip() {
301        // Encode: 4-byte LE length prefix + data
302        let mut payload = Vec::new();
303        let tx1 = b"hello";
304        let tx2 = b"world";
305        payload.extend_from_slice(&(tx1.len() as u32).to_le_bytes());
306        payload.extend_from_slice(tx1);
307        payload.extend_from_slice(&(tx2.len() as u32).to_le_bytes());
308        payload.extend_from_slice(tx2);
309
310        let txs = decode_payload(&payload);
311        assert_eq!(txs.len(), 2);
312        assert_eq!(txs[0], b"hello");
313        assert_eq!(txs[1], b"world");
314    }
315}