Skip to main content

miden_node_store/
blocks.rs

1//! File-based storage for raw block data and block proofs.
2//!
3//! Block data is stored under `{store_dir}/{epoch:04x}/block_{block_num:08x}.dat`, and proof data
4//! for proven blocks is stored under `{store_dir}/{epoch:04x}/proof_{block_num:08x}.dat`.
5//!
6//! The epoch is derived from the 16 most significant bits of the block number (i.e.,
7//! `block_num >> 16`), and both the epoch and block number are formatted as zero-padded
8//! hexadecimal strings.
9
10use std::io::ErrorKind;
11use std::ops::Not;
12use std::path::{Path, PathBuf};
13
14use miden_node_persistence::generated::BlockStoreFormat;
15use miden_node_persistence::prost::Message;
16use miden_node_tracing::miden_instrument;
17use miden_protocol::block::BlockNumber;
18
19use crate::COMPONENT;
20use crate::genesis::GenesisBlock;
21
22#[derive(Clone, Debug)]
23pub struct BlockStore {
24    store_dir: PathBuf,
25}
26
27impl BlockStore {
28    /// Creates a new [`BlockStore`], creating the directory, inserting the genesis block data
29    /// and initializing the proven tip file.
30    ///
31    /// This _does not_ create any parent directories, so it is expected that the caller has already
32    /// created these.
33    ///
34    /// # Errors
35    ///
36    /// Uses [`std::fs::create_dir`] and therefore has the same error conditions.
37    #[miden_instrument(
38        target = COMPONENT,
39        name = "store.block_store.bootstrap",
40        err,
41        fields(
42            path = store_dir,
43        ),
44    )]
45    pub fn bootstrap(store_dir: PathBuf, genesis_block: &GenesisBlock) -> std::io::Result<Self> {
46        fs_err::create_dir(&store_dir)?;
47
48        let block_store = Self { store_dir };
49        fs_err::write(
50            block_store.store_dir.join("format.pb"),
51            BlockStoreFormat { version: 1 }.encode_to_vec(),
52        )?;
53        block_store.save_block_blocking(
54            BlockNumber::GENESIS,
55            &miden_node_persistence::encode(genesis_block.inner()),
56        )?;
57
58        // The genesis block is never proven, but is treated as such.
59        block_store.save_proven_tip(BlockNumber::GENESIS)?;
60
61        Ok(block_store)
62    }
63
64    /// Loads an existing [`BlockStore`].
65    ///
66    /// A new [`BlockStore`] can be created using [`BlockStore::bootstrap`].
67    ///
68    /// A best effort is made to ensure the directory exists and is accessible, but will still run
69    /// afoul of TOCTOU issues as these are impossible to rule out.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if:
74    ///   - the directory does not exist, or
75    ///   - the directory is not accessible, or
76    ///   - it is not a directory, or
77    ///   - its format marker is missing or invalid
78    ///
79    /// See also: [`std::fs::metadata`].
80    pub fn load(store_dir: PathBuf) -> std::io::Result<Self> {
81        let meta = fs_err::metadata(&store_dir)?;
82        if meta.is_dir().not() {
83            return Err(ErrorKind::NotADirectory.into());
84        }
85
86        let bytes = fs_err::read(store_dir.join("format.pb")).map_err(|error| {
87            std::io::Error::new(
88                error.kind(),
89                format!(
90                    "block store format marker is unavailable; recreate the block store: {error}"
91                ),
92            )
93        })?;
94        let marker = BlockStoreFormat::decode(bytes.as_slice())
95            .map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?;
96        if marker.version != 1 {
97            let error = miden_node_persistence::PersistenceError::UnsupportedVersion {
98                format: "block store",
99                version: marker.version,
100            };
101            return Err(std::io::Error::new(ErrorKind::InvalidData, error));
102        }
103        Ok(Self { store_dir })
104    }
105
106    pub async fn load_block(&self, block_num: BlockNumber) -> std::io::Result<Option<Vec<u8>>> {
107        match tokio::fs::read(self.block_path(block_num)).await {
108            Ok(data) => Ok(Some(data)),
109            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
110            Err(err) => Err(err),
111        }
112    }
113
114    #[miden_instrument(
115        target = COMPONENT,
116        name = "store.block_store.save_block",
117        err,
118        fields(
119            block.number = block_num,
120            block.size = data.len(),
121        ),
122    )]
123    pub async fn save_block(&self, block_num: BlockNumber, data: &[u8]) -> std::io::Result<()> {
124        let (epoch_path, block_path) = self.epoch_block_path(block_num)?;
125        if !epoch_path.exists() {
126            tokio::fs::create_dir_all(epoch_path).await?;
127        }
128
129        tokio::fs::write(block_path, data).await
130    }
131
132    pub fn save_block_blocking(&self, block_num: BlockNumber, data: &[u8]) -> std::io::Result<()> {
133        let (epoch_path, block_path) = self.epoch_block_path(block_num)?;
134        if !epoch_path.exists() {
135            fs_err::create_dir_all(epoch_path)?;
136        }
137
138        fs_err::write(block_path, data)
139    }
140
141    // PROOF STORAGE
142    // --------------------------------------------------------------------------------------------
143
144    #[miden_instrument(
145        target = COMPONENT,
146        name = "store.block_store.save_proof",
147        err,
148        fields(
149            block.number = block_num,
150            proof_size = data.len()
151        ),
152    )]
153    async fn save_proof(&self, block_num: BlockNumber, data: &[u8]) -> std::io::Result<()> {
154        let (epoch_path, proof_path) = self.epoch_proof_path(block_num)?;
155        if !epoch_path.exists() {
156            tokio::fs::create_dir_all(epoch_path).await?;
157        }
158
159        tokio::fs::write(proof_path, data).await
160    }
161
162    pub async fn load_proof(&self, block_num: BlockNumber) -> std::io::Result<Option<Vec<u8>>> {
163        match tokio::fs::read(self.proof_path(block_num)).await {
164            Ok(data) => Ok(Some(data)),
165            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
166            Err(err) => Err(err),
167        }
168    }
169
170    // PROVING INPUTS STORAGE
171    // --------------------------------------------------------------------------------------------
172
173    #[miden_instrument(
174        target = COMPONENT,
175        name = "store.block_store.save_proving_inputs",
176        err,
177        fields(
178            block.number = block_num,
179            inputs_size = data.len()
180        ),
181    )]
182    pub async fn save_proving_inputs(
183        &self,
184        block_num: BlockNumber,
185        data: &[u8],
186    ) -> std::io::Result<()> {
187        let (epoch_path, inputs_path) = self.epoch_inputs_path(block_num)?;
188        if !epoch_path.exists() {
189            tokio::fs::create_dir_all(epoch_path).await?;
190        }
191        tokio::fs::write(inputs_path, data).await
192    }
193
194    pub async fn load_proving_inputs(
195        &self,
196        block_num: BlockNumber,
197    ) -> std::io::Result<Option<Vec<u8>>> {
198        match tokio::fs::read(self.inputs_path(block_num)).await {
199            Ok(data) => Ok(Some(data)),
200            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
201            Err(err) => Err(err),
202        }
203    }
204
205    pub async fn delete_proving_inputs(&self, block_num: BlockNumber) -> std::io::Result<()> {
206        match tokio::fs::remove_file(self.inputs_path(block_num)).await {
207            Ok(()) => Ok(()),
208            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
209            Err(err) => Err(err),
210        }
211    }
212
213    // HELPER FUNCTIONS
214    // --------------------------------------------------------------------------------------------
215
216    fn block_path(&self, block_num: BlockNumber) -> PathBuf {
217        let block_num = block_num.as_u32();
218        let epoch = block_num >> 16;
219        let epoch_dir = self.store_dir.join(format!("{epoch:04x}"));
220        epoch_dir.join(format!("block_{block_num:08x}.dat"))
221    }
222
223    fn proof_path(&self, block_num: BlockNumber) -> PathBuf {
224        let block_num = block_num.as_u32();
225        let epoch = block_num >> 16;
226        let epoch_dir = self.store_dir.join(format!("{epoch:04x}"));
227        epoch_dir.join(format!("proof_{block_num:08x}.dat"))
228    }
229
230    fn epoch_block_path(&self, block_num: BlockNumber) -> std::io::Result<(PathBuf, PathBuf)> {
231        let block_path = self.block_path(block_num);
232        let epoch_path = block_path.parent().ok_or(std::io::Error::from(ErrorKind::NotFound))?;
233
234        Ok((epoch_path.to_path_buf(), block_path))
235    }
236
237    fn epoch_proof_path(&self, block_num: BlockNumber) -> std::io::Result<(PathBuf, PathBuf)> {
238        let proof_path = self.proof_path(block_num);
239        let epoch_path = proof_path.parent().ok_or(std::io::Error::from(ErrorKind::NotFound))?;
240
241        Ok((epoch_path.to_path_buf(), proof_path))
242    }
243
244    fn inputs_path(&self, block_num: BlockNumber) -> PathBuf {
245        let block_num = block_num.as_u32();
246        let epoch = block_num >> 16;
247        let epoch_dir = self.store_dir.join(format!("{epoch:04x}"));
248        epoch_dir.join(format!("inputs_{block_num:08x}.dat"))
249    }
250
251    fn epoch_inputs_path(&self, block_num: BlockNumber) -> std::io::Result<(PathBuf, PathBuf)> {
252        let inputs_path = self.inputs_path(block_num);
253        let epoch_path = inputs_path.parent().ok_or(std::io::Error::from(ErrorKind::NotFound))?;
254
255        Ok((epoch_path.to_path_buf(), inputs_path))
256    }
257
258    // PROVEN TIP STORAGE
259    // --------------------------------------------------------------------------------------------
260
261    /// Saves the proof, advances the proven tip, and deletes the proving inputs.
262    ///
263    /// Must be called in strictly ascending [`BlockNumber`] order: the proven tip file records
264    /// the highest consecutive proven block, so committing out of order would leave a gap.
265    pub async fn commit_proof(&self, block_num: BlockNumber, proof: &[u8]) -> std::io::Result<()> {
266        self.save_proof(block_num, proof).await?;
267        self.save_proven_tip(block_num)?;
268        self.delete_proving_inputs(block_num).await
269    }
270
271    /// Reads the proven tip from disk and returns it.
272    pub fn load_proven_tip(&self) -> std::io::Result<BlockNumber> {
273        Self::read_proven_tip_from(&self.proven_tip_path())
274    }
275
276    /// Atomically writes `tip` to the proven tip file (write to temp, then rename).
277    fn save_proven_tip(&self, tip: BlockNumber) -> std::io::Result<()> {
278        let path = self.proven_tip_path();
279        let tmp = path.with_extension("tmp");
280        fs_err::write(&tmp, tip.as_u32().to_le_bytes())?;
281        fs_err::rename(&tmp, &path)
282    }
283
284    fn proven_tip_path(&self) -> PathBuf {
285        self.store_dir.join("proven_tip")
286    }
287
288    fn read_proven_tip_from(path: &Path) -> std::io::Result<BlockNumber> {
289        let bytes = fs_err::read(path)?;
290        let arr: [u8; 4] = bytes.try_into().map_err(|_| {
291            std::io::Error::new(
292                ErrorKind::InvalidData,
293                "proven tip file has unexpected size (expected 4 bytes)",
294            )
295        })?;
296        Ok(BlockNumber::from(u32::from_le_bytes(arr)))
297    }
298
299    pub fn display(&self) -> std::path::Display<'_> {
300        self.store_dir.display()
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn rejects_legacy_and_invalid_markers_without_changing_files() {
310        let root = tempfile::tempdir().unwrap();
311        let legacy = root.path().join("blocks");
312        fs_err::create_dir(&legacy).unwrap();
313        fs_err::write(legacy.join("proven_tip"), [0; 4]).unwrap();
314        assert!(BlockStore::load(legacy.clone()).is_err());
315        assert!(!legacy.join("format.pb").exists());
316        for bytes in [
317            vec![0xff],
318            BlockStoreFormat { version: 0 }.encode_to_vec(),
319            BlockStoreFormat { version: 2 }.encode_to_vec(),
320        ] {
321            fs_err::write(legacy.join("format.pb"), &bytes).unwrap();
322            assert!(BlockStore::load(legacy.clone()).is_err());
323            assert_eq!(fs_err::read(legacy.join("format.pb")).unwrap(), bytes);
324            assert_eq!(fs_err::read(legacy.join("proven_tip")).unwrap(), [0; 4]);
325        }
326    }
327
328    #[test]
329    fn proven_tip_uses_four_little_endian_bytes() {
330        let root = tempfile::tempdir().unwrap();
331        let store = BlockStore { store_dir: root.path().to_path_buf() };
332        fs_err::write(store.proven_tip_path(), [0x78, 0x56, 0x34, 0x12]).unwrap();
333        assert_eq!(store.load_proven_tip().unwrap(), BlockNumber::from(0x1234_5678));
334
335        store.save_proven_tip(BlockNumber::from(0x90ab_cdef)).unwrap();
336        assert_eq!(fs_err::read(store.proven_tip_path()).unwrap(), [0xef, 0xcd, 0xab, 0x90]);
337    }
338
339    #[test]
340    fn rejects_invalid_proven_tip_lengths() {
341        let root = tempfile::tempdir().unwrap();
342        let store = BlockStore { store_dir: root.path().to_path_buf() };
343        for len in [0, 1, 2, 3, 5, 8] {
344            fs_err::write(store.proven_tip_path(), vec![0; len]).unwrap();
345            assert_eq!(store.load_proven_tip().unwrap_err().kind(), ErrorKind::InvalidData);
346        }
347    }
348
349    #[tokio::test]
350    async fn reopens_proven_tip_and_preserves_raw_proof_bytes() {
351        let root = tempfile::tempdir().unwrap();
352        let store_dir = root.path().join("blocks");
353        let genesis = crate::GenesisState::new(
354            Vec::new(),
355            miden_node_utils::fee::test_fee_params(),
356            0,
357            miden_protocol::block::ValidatorConfig::new(
358                vec![miden_protocol::testing::random_secret_key::random_secret_key().public_key()],
359                1,
360            )
361            .unwrap(),
362            miden_node_utils::fee::test_protocol_config(),
363        )
364        .into_block()
365        .unwrap();
366        let store = BlockStore::bootstrap(store_dir.clone(), &genesis).unwrap();
367        let block: miden_protocol::block::SignedBlock = miden_node_persistence::decode(
368            &store.load_block(BlockNumber::GENESIS).await.unwrap().unwrap(),
369        )
370        .unwrap();
371        assert_eq!(&block, genesis.inner());
372        assert!(BlockStore::bootstrap(store_dir.clone(), &genesis).is_err());
373        let tip = BlockNumber::from(1);
374        let proof = miden_protocol::testing::dummy_execution_proof().to_bytes();
375        store.save_proving_inputs(tip, &[1, 2, 3]).await.unwrap();
376        store.commit_proof(tip, &proof).await.unwrap();
377        let reopened = BlockStore::load(store_dir).unwrap();
378        assert_eq!(reopened.load_proven_tip().unwrap(), tip);
379        assert_eq!(reopened.load_proof(tip).await.unwrap().unwrap(), proof);
380        assert_eq!(reopened.load_proving_inputs(tip).await.unwrap(), None);
381        assert!(!reopened.proven_tip_path().with_extension("tmp").exists());
382    }
383}