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    /// If an epoch transition was triggered by end_block, the new epoch (start_view is placeholder)
14    pub pending_epoch: Option<Epoch>,
15}
16
17/// Decode length-prefixed transactions from a block payload.
18pub fn decode_payload(payload: &[u8]) -> Vec<&[u8]> {
19    let mut txs = Vec::new();
20    let mut offset = 0;
21    while offset + 4 <= payload.len() {
22        let len = u32::from_le_bytes(payload[offset..offset + 4].try_into().unwrap()) as usize;
23        offset += 4;
24        if offset + len > payload.len() {
25            break;
26        }
27        txs.push(&payload[offset..offset + len]);
28        offset += len;
29    }
30    txs
31}
32
33/// Execute the two-chain commit rule:
34/// When we get C_v(C_v(B_k)), commit the inner QC's block and all uncommitted ancestors.
35///
36/// For each committed block, runs the full application lifecycle:
37/// begin_block → deliver_tx (×N) → end_block → on_commit
38pub fn try_commit(
39    double_cert: &DoubleCertificate,
40    store: &dyn BlockStore,
41    app: &dyn Application,
42    last_committed_height: &mut Height,
43    current_epoch: &Epoch,
44) -> Result<CommitResult> {
45    let commit_hash = double_cert.inner_qc.block_hash;
46    let commit_block = store
47        .get_block(&commit_hash)
48        .c(d!("block to commit not found"))?;
49
50    if commit_block.height <= *last_committed_height {
51        return Ok(CommitResult {
52            committed_blocks: vec![],
53            pending_epoch: None,
54        });
55    }
56
57    // Collect all uncommitted ancestors (from highest to lowest)
58    let mut to_commit = Vec::new();
59    let mut current = commit_block;
60    loop {
61        if current.height <= *last_committed_height {
62            break;
63        }
64        let parent_hash = current.parent_hash;
65        to_commit.push(current);
66        if parent_hash == BlockHash::GENESIS {
67            break;
68        }
69        match store.get_block(&parent_hash) {
70            Some(parent) => current = parent,
71            None => break,
72        }
73    }
74
75    // Commit from lowest height to highest
76    to_commit.reverse();
77
78    let mut pending_epoch = None;
79
80    for block in &to_commit {
81        let ctx = BlockContext {
82            height: block.height,
83            view: block.view,
84            proposer: block.proposer,
85            epoch: current_epoch.number,
86            validator_set: &current_epoch.validator_set,
87        };
88
89        info!(height = block.height.as_u64(), hash = %block.hash, "committing block");
90
91        let txs = decode_payload(&block.payload);
92        let response = app
93            .execute_block(&txs, &ctx)
94            .c(d!("execute_block failed"))?;
95
96        app.on_commit(block, &ctx)
97            .c(d!("application commit failed"))?;
98
99        if !response.validator_updates.is_empty() {
100            let new_vs = current_epoch
101                .validator_set
102                .apply_updates(&response.validator_updates);
103            // The new epoch starts 2 views after the committing block's view.
104            // This deterministic gap gives lagging nodes time to catch up and
105            // ensures all honest nodes apply the epoch at the same view.
106            let epoch_start = ViewNumber(block.view.as_u64() + 2);
107            pending_epoch = Some(Epoch::new(current_epoch.number.next(), epoch_start, new_vs));
108        }
109
110        *last_committed_height = block.height;
111    }
112
113    Ok(CommitResult {
114        committed_blocks: to_commit,
115        pending_epoch,
116    })
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::application::NoopApplication;
123    use crate::store::MemoryBlockStore;
124    use hotmint_types::crypto::PublicKey;
125    use hotmint_types::validator::{ValidatorInfo, ValidatorSet};
126    use hotmint_types::{AggregateSignature, QuorumCertificate, ValidatorId, ViewNumber};
127
128    fn make_block(height: u64, parent: BlockHash) -> Block {
129        let hash = BlockHash([height as u8; 32]);
130        Block {
131            height: Height(height),
132            parent_hash: parent,
133            view: ViewNumber(height),
134            proposer: ValidatorId(0),
135            payload: vec![],
136            hash,
137        }
138    }
139
140    fn make_qc(hash: BlockHash, view: u64) -> QuorumCertificate {
141        QuorumCertificate {
142            block_hash: hash,
143            view: ViewNumber(view),
144            aggregate_signature: AggregateSignature::new(4),
145        }
146    }
147
148    fn make_epoch() -> Epoch {
149        let vs = ValidatorSet::new(vec![ValidatorInfo {
150            id: ValidatorId(0),
151            public_key: PublicKey(vec![0]),
152            power: 1,
153        }]);
154        Epoch::genesis(vs)
155    }
156
157    #[test]
158    fn test_commit_single_block() {
159        let mut store = MemoryBlockStore::new();
160        let app = NoopApplication;
161        let epoch = make_epoch();
162        let b1 = make_block(1, BlockHash::GENESIS);
163        store.put_block(b1.clone());
164
165        let dc = DoubleCertificate {
166            inner_qc: make_qc(b1.hash, 1),
167            outer_qc: make_qc(b1.hash, 1),
168        };
169
170        let mut last = Height::GENESIS;
171        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
172        assert_eq!(result.committed_blocks.len(), 1);
173        assert_eq!(result.committed_blocks[0].height, Height(1));
174        assert_eq!(last, Height(1));
175        assert!(result.pending_epoch.is_none());
176    }
177
178    #[test]
179    fn test_commit_chain_of_blocks() {
180        let mut store = MemoryBlockStore::new();
181        let app = NoopApplication;
182        let epoch = make_epoch();
183        let b1 = make_block(1, BlockHash::GENESIS);
184        let b2 = make_block(2, b1.hash);
185        let b3 = make_block(3, b2.hash);
186        store.put_block(b1);
187        store.put_block(b2);
188        store.put_block(b3.clone());
189
190        let dc = DoubleCertificate {
191            inner_qc: make_qc(b3.hash, 3),
192            outer_qc: make_qc(b3.hash, 3),
193        };
194
195        let mut last = Height::GENESIS;
196        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
197        assert_eq!(result.committed_blocks.len(), 3);
198        assert_eq!(result.committed_blocks[0].height, Height(1));
199        assert_eq!(result.committed_blocks[1].height, Height(2));
200        assert_eq!(result.committed_blocks[2].height, Height(3));
201        assert_eq!(last, Height(3));
202    }
203
204    #[test]
205    fn test_commit_already_committed() {
206        let mut store = MemoryBlockStore::new();
207        let app = NoopApplication;
208        let epoch = make_epoch();
209        let b1 = make_block(1, BlockHash::GENESIS);
210        store.put_block(b1.clone());
211
212        let dc = DoubleCertificate {
213            inner_qc: make_qc(b1.hash, 1),
214            outer_qc: make_qc(b1.hash, 1),
215        };
216
217        let mut last = Height(1);
218        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
219        assert!(result.committed_blocks.is_empty());
220    }
221
222    #[test]
223    fn test_commit_partial_chain() {
224        let mut store = MemoryBlockStore::new();
225        let app = NoopApplication;
226        let epoch = make_epoch();
227        let b1 = make_block(1, BlockHash::GENESIS);
228        let b2 = make_block(2, b1.hash);
229        let b3 = make_block(3, b2.hash);
230        store.put_block(b1);
231        store.put_block(b2);
232        store.put_block(b3.clone());
233
234        let dc = DoubleCertificate {
235            inner_qc: make_qc(b3.hash, 3),
236            outer_qc: make_qc(b3.hash, 3),
237        };
238
239        let mut last = Height(1);
240        let result = try_commit(&dc, &store, &app, &mut last, &epoch).unwrap();
241        assert_eq!(result.committed_blocks.len(), 2);
242        assert_eq!(result.committed_blocks[0].height, Height(2));
243        assert_eq!(result.committed_blocks[1].height, Height(3));
244    }
245
246    #[test]
247    fn test_commit_missing_block() {
248        let store = MemoryBlockStore::new();
249        let app = NoopApplication;
250        let epoch = make_epoch();
251        let dc = DoubleCertificate {
252            inner_qc: make_qc(BlockHash([99u8; 32]), 1),
253            outer_qc: make_qc(BlockHash([99u8; 32]), 1),
254        };
255        let mut last = Height::GENESIS;
256        assert!(try_commit(&dc, &store, &app, &mut last, &epoch).is_err());
257    }
258
259    #[test]
260    fn test_decode_payload_empty() {
261        assert!(decode_payload(&[]).is_empty());
262    }
263
264    #[test]
265    fn test_decode_payload_roundtrip() {
266        // Encode: 4-byte LE length prefix + data
267        let mut payload = Vec::new();
268        let tx1 = b"hello";
269        let tx2 = b"world";
270        payload.extend_from_slice(&(tx1.len() as u32).to_le_bytes());
271        payload.extend_from_slice(tx1);
272        payload.extend_from_slice(&(tx2.len() as u32).to_le_bytes());
273        payload.extend_from_slice(tx2);
274
275        let txs = decode_payload(&payload);
276        assert_eq!(txs.len(), 2);
277        assert_eq!(txs[0], b"hello");
278        assert_eq!(txs[1], b"world");
279    }
280}