Skip to main content

entropa_core/
chain.rs

1//! The chain — an append-only, post-quantum-signed constellation of blocks.
2//!
3//! Genesis is the "Big Bang" block. Each subsequent block is *proposed* by a Probe,
4//! which signs it with its ML-DSA key. [`Chain::verify`] re-checks the entire history:
5//! sequential indices, matching links, recomputed hashes, and every post-quantum
6//! signature. Any tampering — a rewritten transaction, a forged signature — fails it.
7//!
8//! [`Chain::draft`] builds and signs a block *without* appending (so a proposer can
9//! broadcast the exact block it appends); [`Chain::try_append`] validates a block
10//! (structure + PQC signature) against the head and appends it. Consensus rules —
11//! *who* is allowed to propose in a round — live in the `entropa-node` layer.
12
13use crate::block::{block_digest, Block, Transaction};
14use crate::pqc::{probe_id, verify_hex, Probe};
15
16/// The prev-hash of the genesis block.
17pub const BIG_BANG: &str = "BIGBANG";
18
19#[derive(Debug, thiserror::Error, PartialEq, Eq)]
20pub enum ChainError {
21    #[error("block {0}: index out of sequence")]
22    BadIndex(u64),
23    #[error("block {0}: prev_hash does not link to the previous block")]
24    BrokenLink(u64),
25    #[error("block {0}: hash does not match recomputed digest")]
26    BadHash(u64),
27    #[error("block {0}: proposer_id does not match proposer_pubkey")]
28    ForgedProposer(u64),
29    #[error("block {0}: post-quantum signature is invalid")]
30    BadSignature(u64),
31    #[error("chain is empty")]
32    Empty,
33}
34
35/// An append-only chain of post-quantum-signed blocks.
36#[derive(Debug, Default, Clone)]
37pub struct Chain {
38    pub blocks: Vec<Block>,
39}
40
41impl Chain {
42    /// Create a new chain, proposing the genesis ("Big Bang") block with `founder`.
43    pub fn genesis(founder: &Probe, timestamp: u64, beacon: impl Into<String>) -> Self {
44        let mut chain = Chain { blocks: Vec::new() };
45        chain.propose(founder, timestamp, beacon, Vec::new());
46        chain
47    }
48
49    /// Build and post-quantum-sign the next block **without appending it**.
50    ///
51    /// Lets a proposer broadcast the exact block it will append. The block links to the
52    /// current head (or `BIG_BANG` if the chain is empty).
53    pub fn draft(
54        &self,
55        proposer: &Probe,
56        timestamp: u64,
57        beacon: impl Into<String>,
58        transactions: Vec<Transaction>,
59    ) -> Block {
60        let index = self.blocks.len() as u64;
61        let prev_hash = self
62            .blocks
63            .last()
64            .map(|b| b.hash.clone())
65            .unwrap_or_else(|| BIG_BANG.to_string());
66        let beacon = beacon.into();
67        let proposer_id = proposer.id();
68        let proposer_pubkey = proposer.pubkey_hex();
69        let digest = block_digest(
70            index,
71            timestamp,
72            &prev_hash,
73            &beacon,
74            &transactions,
75            &proposer_id,
76        );
77        let signature = proposer.sign_hex(&digest);
78        Block {
79            index,
80            timestamp,
81            prev_hash,
82            beacon,
83            transactions,
84            proposer_id,
85            proposer_pubkey,
86            hash: hex::encode(digest),
87            signature,
88        }
89    }
90
91    /// Validate a block against the current head (index, link, hash, proposer
92    /// fingerprint, PQC signature) and append it. Does **not** enforce consensus rules
93    /// (who may propose) — that is the node layer's job.
94    pub fn try_append(&mut self, block: Block) -> Result<(), ChainError> {
95        let expected = self.blocks.len() as u64;
96        let prev = self
97            .blocks
98            .last()
99            .map(|b| b.hash.as_str())
100            .unwrap_or(BIG_BANG);
101        if block.index != expected {
102            return Err(ChainError::BadIndex(expected));
103        }
104        if block.prev_hash != prev {
105            return Err(ChainError::BrokenLink(expected));
106        }
107        if probe_id(&block.proposer_pubkey) != block.proposer_id {
108            return Err(ChainError::ForgedProposer(expected));
109        }
110        let digest = block_digest(
111            block.index,
112            block.timestamp,
113            &block.prev_hash,
114            &block.beacon,
115            &block.transactions,
116            &block.proposer_id,
117        );
118        if hex::encode(digest) != block.hash {
119            return Err(ChainError::BadHash(expected));
120        }
121        if !verify_hex(&block.proposer_pubkey, &digest, &block.signature) {
122            return Err(ChainError::BadSignature(expected));
123        }
124        self.blocks.push(block);
125        Ok(())
126    }
127
128    /// Propose (draft + append) the next block with `proposer`. Returns its index.
129    pub fn propose(
130        &mut self,
131        proposer: &Probe,
132        timestamp: u64,
133        beacon: impl Into<String>,
134        transactions: Vec<Transaction>,
135    ) -> u64 {
136        let block = self.draft(proposer, timestamp, beacon, transactions);
137        let index = block.index;
138        self.try_append(block)
139            .expect("a self-drafted block is always valid");
140        index
141    }
142
143    /// Verify the entire chain: links, hashes, and every post-quantum signature.
144    pub fn verify(&self) -> Result<(), ChainError> {
145        if self.blocks.is_empty() {
146            return Err(ChainError::Empty);
147        }
148        let mut prev = BIG_BANG.to_string();
149        for (i, b) in self.blocks.iter().enumerate() {
150            let i = i as u64;
151            if b.index != i {
152                return Err(ChainError::BadIndex(i));
153            }
154            if b.prev_hash != prev {
155                return Err(ChainError::BrokenLink(i));
156            }
157            if probe_id(&b.proposer_pubkey) != b.proposer_id {
158                return Err(ChainError::ForgedProposer(i));
159            }
160            let digest = block_digest(
161                b.index,
162                b.timestamp,
163                &b.prev_hash,
164                &b.beacon,
165                &b.transactions,
166                &b.proposer_id,
167            );
168            if hex::encode(digest) != b.hash {
169                return Err(ChainError::BadHash(i));
170            }
171            if !verify_hex(&b.proposer_pubkey, &digest, &b.signature) {
172                return Err(ChainError::BadSignature(i));
173            }
174            prev = b.hash.clone();
175        }
176        Ok(())
177    }
178
179    pub fn len(&self) -> usize {
180        self.blocks.len()
181    }
182
183    pub fn is_empty(&self) -> bool {
184        self.blocks.is_empty()
185    }
186
187    pub fn head(&self) -> Option<&Block> {
188        self.blocks.last()
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::beacon;
196
197    fn sample_chain() -> (Chain, Probe, Probe) {
198        let founder = Probe::spawn();
199        let alice = Probe::spawn();
200        let mut chain = Chain::genesis(&founder, 1_000, beacon::sample(0));
201        chain.propose(
202            &alice,
203            1_001,
204            beacon::sample(1),
205            vec![Transaction::new(
206                alice.id(),
207                "transfer",
208                "10 -> PROBE-DEADBEEF",
209            )],
210        );
211        chain.propose(
212            &founder,
213            1_002,
214            beacon::sample(2),
215            vec![Transaction::new(
216                "oracle",
217                "attest",
218                "cosmic beacon round 2",
219            )],
220        );
221        (chain, founder, alice)
222    }
223
224    #[test]
225    fn builds_and_verifies() {
226        let (chain, _, _) = sample_chain();
227        assert_eq!(chain.len(), 3);
228        assert_eq!(chain.verify(), Ok(()));
229    }
230
231    #[test]
232    fn detects_tampered_transaction() {
233        let (mut chain, _, _) = sample_chain();
234        chain.blocks[1].transactions[0].payload = "9000 -> PROBE-ATTACKER".into();
235        assert_eq!(chain.verify(), Err(ChainError::BadHash(1)));
236    }
237
238    #[test]
239    fn detects_forged_signature() {
240        let (mut chain, _, _) = sample_chain();
241        let attacker = Probe::spawn();
242        let digest = block_digest(
243            chain.blocks[2].index,
244            chain.blocks[2].timestamp,
245            &chain.blocks[2].prev_hash,
246            &chain.blocks[2].beacon,
247            &chain.blocks[2].transactions,
248            &chain.blocks[2].proposer_id,
249        );
250        chain.blocks[2].signature = attacker.sign_hex(&digest);
251        assert_eq!(chain.verify(), Err(ChainError::BadSignature(2)));
252    }
253
254    #[test]
255    fn detects_broken_link() {
256        let (mut chain, _, _) = sample_chain();
257        chain.blocks[1].prev_hash = "0".repeat(64);
258        assert_eq!(chain.verify(), Err(ChainError::BrokenLink(1)));
259    }
260
261    #[test]
262    fn try_append_rejects_foreign_block() {
263        // A block drafted against a different (empty) chain won't link to a 3-block head.
264        let (mut chain, _, _) = sample_chain();
265        let rogue = Probe::spawn();
266        let foreign = Chain::default().draft(&rogue, 5, beacon::sample(9), Vec::new());
267        assert_eq!(chain.try_append(foreign), Err(ChainError::BadIndex(3)));
268    }
269
270    #[test]
271    fn serde_round_trip() {
272        let (chain, _, _) = sample_chain();
273        let json = serde_json::to_string(&chain.blocks).unwrap();
274        let back: Vec<Block> = serde_json::from_str(&json).unwrap();
275        assert_eq!(back.len(), 3);
276    }
277}