Skip to main content

miden_client_sqlite_store/
chain_data.rs

1#![allow(clippy::items_after_statements)]
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::num::NonZeroUsize;
5use std::rc::Rc;
6use std::vec::Vec;
7
8use miden_client::Word;
9use miden_client::block::BlockHeader;
10use miden_client::crypto::{Forest, InOrderIndex, MmrPeaks};
11use miden_client::note::BlockNumber;
12use miden_client::store::{BlockRelevance, PartialBlockchainFilter, StoreError};
13use miden_client::utils::{Deserializable, Serializable};
14use rusqlite::types::Value;
15use rusqlite::{Connection, OptionalExtension, Transaction, params, params_from_iter};
16
17use super::SqliteStore;
18use crate::sql_error::SqlResultExt;
19use crate::{insert_sql, subst};
20
21struct SerializedBlockHeaderData {
22    block_num: u32,
23    header: Vec<u8>,
24    has_client_notes: bool,
25}
26struct SerializedBlockHeaderParts {
27    _block_num: u64,
28    header: Vec<u8>,
29    has_client_notes: bool,
30}
31
32struct SerializedPartialBlockchainNodeData {
33    id: i64,
34    node: Vec<u8>,
35}
36struct SerializedPartialBlockchainNodeParts {
37    id: u64,
38    node: Vec<u8>,
39}
40
41impl SqliteStore {
42    pub(crate) fn get_block_headers(
43        conn: &mut Connection,
44        block_numbers: &BTreeSet<BlockNumber>,
45    ) -> Result<Vec<(BlockHeader, BlockRelevance)>, StoreError> {
46        let block_number_list = block_numbers
47            .iter()
48            .map(|block_number| Value::Integer(i64::from(block_number.as_u32())))
49            .collect::<Vec<Value>>();
50
51        const QUERY: &str = "SELECT block_num, header, has_client_notes FROM block_headers WHERE block_num IN rarray(?)";
52
53        conn.prepare(QUERY)
54            .into_store_error()?
55            .query_map(params![Rc::new(block_number_list)], parse_block_headers_columns)
56            .into_store_error()?
57            .map(|result| {
58                let serialized_block_header_parts: SerializedBlockHeaderParts =
59                    result.into_store_error()?;
60                parse_block_header(&serialized_block_header_parts)
61            })
62            .collect()
63    }
64
65    pub(crate) fn get_tracked_block_headers(
66        conn: &mut Connection,
67    ) -> Result<Vec<BlockHeader>, StoreError> {
68        const QUERY: &str = "SELECT block_num, header, has_client_notes FROM block_headers WHERE has_client_notes=true";
69        conn.prepare(QUERY)
70            .into_store_error()?
71            .query_map(params![], parse_block_headers_columns)
72            .into_store_error()?
73            .map(|result| {
74                let serialized_block_header_parts: SerializedBlockHeaderParts =
75                    result.into_store_error()?;
76                parse_block_header(&serialized_block_header_parts).map(|(block, _)| block)
77            })
78            .collect()
79    }
80
81    pub(crate) fn get_tracked_block_header_numbers(
82        conn: &mut Connection,
83    ) -> Result<BTreeSet<usize>, StoreError> {
84        const QUERY: &str = "SELECT block_num FROM block_headers WHERE has_client_notes=true";
85        conn.prepare(QUERY)
86            .into_store_error()?
87            .query_map(params![], |row| row.get::<_, u32>(0))
88            .into_store_error()?
89            .map(|result| {
90                let block_num: u32 = result.into_store_error()?;
91                Ok(block_num as usize)
92            })
93            .collect()
94    }
95
96    pub(crate) fn get_partial_blockchain_nodes(
97        conn: &mut Connection,
98        filter: &PartialBlockchainFilter,
99    ) -> Result<BTreeMap<InOrderIndex, Word>, StoreError> {
100        match filter {
101            PartialBlockchainFilter::All => query_partial_blockchain_nodes(
102                conn,
103                "SELECT id, node FROM partial_blockchain_nodes",
104                params![],
105            ),
106
107            PartialBlockchainFilter::List(ids) if ids.is_empty() => Ok(BTreeMap::new()),
108            PartialBlockchainFilter::List(ids) => {
109                let id_values = ids
110                    .iter()
111                    .map(|id| Value::Integer(i64::try_from(id.inner()).expect("id is a valid i64")))
112                    .collect::<Vec<_>>();
113
114                query_partial_blockchain_nodes(
115                    conn,
116                    "SELECT id, node FROM partial_blockchain_nodes WHERE id IN rarray(?)",
117                    params_from_iter([Rc::new(id_values)]),
118                )
119            },
120
121            PartialBlockchainFilter::Forest(forest) if forest.is_empty() => Ok(BTreeMap::new()),
122            PartialBlockchainFilter::Forest(forest) => {
123                let max_index = i64::try_from(forest.rightmost_in_order_index().inner())
124                    .expect("id is a valid i64");
125
126                query_partial_blockchain_nodes(
127                    conn,
128                    "SELECT id, node FROM partial_blockchain_nodes WHERE id <= ?",
129                    params![max_index],
130                )
131            },
132        }
133    }
134
135    pub(crate) fn get_current_blockchain_peaks(
136        conn: &mut Connection,
137    ) -> Result<MmrPeaks, StoreError> {
138        const QUERY: &str =
139            "SELECT block_num, partial_blockchain_peaks FROM blockchain_checkpoint LIMIT 1";
140
141        let row: Option<(u32, Vec<u8>)> = conn
142            .prepare(QUERY)
143            .into_store_error()?
144            .query_row(params![], |row| Ok((row.get(0)?, row.get(1)?)))
145            .optional()
146            .into_store_error()?;
147
148        match row {
149            Some((block_num, peaks_bytes)) if !peaks_bytes.is_empty() => {
150                parse_partial_blockchain_peaks(block_num, &peaks_bytes)
151            },
152            _ => Ok(MmrPeaks::new(Forest::empty(), vec![])?),
153        }
154    }
155
156    pub(crate) fn insert_block_header(
157        conn: &mut Connection,
158        block_header: &BlockHeader,
159        nodes: &[(InOrderIndex, Word)],
160        has_client_notes: bool,
161    ) -> Result<(), StoreError> {
162        let tx = conn.transaction().into_store_error()?;
163
164        Self::insert_block_header_tx(&tx, block_header, has_client_notes)?;
165        Self::insert_partial_blockchain_nodes_tx(&tx, nodes)?;
166        tx.commit().into_store_error()?;
167        Ok(())
168    }
169
170    /// Inserts a list of MMR authentication nodes to the Partial Blockchain nodes table.
171    pub(crate) fn insert_partial_blockchain_nodes_tx(
172        tx: &Transaction<'_>,
173        nodes: &[(InOrderIndex, Word)],
174    ) -> Result<(), StoreError> {
175        for (index, node) in nodes {
176            insert_partial_blockchain_node(tx, *index, *node)?;
177        }
178        Ok(())
179    }
180
181    /// Inserts a block header using a [`rusqlite::Transaction`].
182    ///
183    /// If the block header exists and `has_client_notes` is `true` then the `has_client_notes`
184    /// column is updated to `true` to signify that the block now contains a relevant note.
185    pub(crate) fn insert_block_header_tx(
186        tx: &Transaction<'_>,
187        block_header: &BlockHeader,
188        has_client_notes: bool,
189    ) -> Result<(), StoreError> {
190        let SerializedBlockHeaderData { block_num, header, has_client_notes } =
191            serialize_block_header(block_header, has_client_notes);
192        const QUERY: &str =
193            insert_sql!(block_headers { block_num, header, has_client_notes } | IGNORE);
194        tx.execute(QUERY, params![block_num, header, has_client_notes])
195            .into_store_error()?;
196
197        set_block_header_has_client_notes(tx, u64::from(block_num), has_client_notes)?;
198        Ok(())
199    }
200
201    /// Prunes irrelevant block data from the store.
202    ///
203    /// This performs three operations in a single transaction:
204    /// 1. Deletes MMR authentication nodes at the given `node_indices`.
205    /// 2. Sets `has_client_notes = false` for `blocks_to_untrack`.
206    /// 3. Deletes block headers with `has_client_notes = false` that are not the genesis or
207    ///    sync-height block.
208    pub fn prune_irrelevant_blocks(
209        conn: &mut Connection,
210        blocks_to_untrack: &[BlockNumber],
211        node_indices_to_remove: &[InOrderIndex],
212    ) -> Result<(), StoreError> {
213        let tx = conn.transaction().into_store_error()?;
214
215        // 1. Delete stale MMR authentication nodes.
216        if !node_indices_to_remove.is_empty() {
217            let id_values = node_indices_to_remove
218                .iter()
219                .map(|id| Value::Integer(i64::try_from(id.inner()).expect("id is a valid i64")))
220                .collect::<Vec<_>>();
221
222            tx.execute(
223                "DELETE FROM partial_blockchain_nodes WHERE id IN rarray(?)",
224                params![Rc::new(id_values)],
225            )
226            .into_store_error()?;
227        }
228
229        // 2. Mark untracked blocks as irrelevant.
230        if !blocks_to_untrack.is_empty() {
231            let block_values = blocks_to_untrack
232                .iter()
233                .map(|b| Value::Integer(i64::from(b.as_u32())))
234                .collect::<Vec<_>>();
235
236            tx.execute(
237                "UPDATE block_headers SET has_client_notes = 0 WHERE block_num IN rarray(?)",
238                params![Rc::new(block_values)],
239            )
240            .into_store_error()?;
241        }
242
243        // 3. Delete irrelevant block headers.
244        let genesis: u32 = BlockNumber::GENESIS.as_u32();
245
246        let sync_block: Option<u32> = tx
247            .query_row("SELECT block_num FROM blockchain_checkpoint LIMIT 1", [], |r| r.get(0))
248            .optional()
249            .into_store_error()?;
250
251        if let Some(sync_height) = sync_block {
252            tx.execute(
253                "DELETE FROM block_headers \
254                 WHERE has_client_notes = 0 \
255                 AND block_num > ?1 \
256                 AND block_num < ?2",
257                rusqlite::params![genesis, sync_height],
258            )
259            .into_store_error()?;
260        }
261
262        tx.commit().into_store_error()
263    }
264}
265
266// HELPERS
267// ================================================================================================
268
269/// Inserts a node represented by its in-order index and the node value.
270fn insert_partial_blockchain_node(
271    tx: &Transaction<'_>,
272    id: InOrderIndex,
273    node: Word,
274) -> Result<(), StoreError> {
275    let SerializedPartialBlockchainNodeData { id, node } =
276        serialize_partial_blockchain_node(id, node);
277    const QUERY: &str = insert_sql!(partial_blockchain_nodes { id, node } | IGNORE);
278    tx.execute(QUERY, params![id, node]).into_store_error()?;
279    Ok(())
280}
281
282fn query_partial_blockchain_nodes<P: rusqlite::Params>(
283    conn: &mut Connection,
284    sql: &str,
285    params: P,
286) -> Result<BTreeMap<InOrderIndex, Word>, StoreError> {
287    let mut stmt = conn.prepare_cached(sql).into_store_error()?;
288
289    stmt.query_map(params, parse_partial_blockchain_nodes_columns)
290        .into_store_error()?
291        .map(|row_res| {
292            let parts: SerializedPartialBlockchainNodeParts = row_res.into_store_error()?;
293            parse_partial_blockchain_nodes(&parts)
294        })
295        .collect()
296}
297
298fn parse_partial_blockchain_peaks(forest: u32, peaks_nodes: &[u8]) -> Result<MmrPeaks, StoreError> {
299    let mmr_peaks_nodes = Vec::<Word>::read_from_bytes(peaks_nodes)?;
300
301    let forest_size = usize::try_from(forest).expect("u64 should fit in usize");
302    let forest = Forest::new(forest_size).map_err(|err| {
303        StoreError::DataDeserializationError(
304            miden_protocol::utils::serde::DeserializationError::InvalidValue(format!(
305                "invalid forest size {forest_size}: {err}"
306            )),
307        )
308    })?;
309    MmrPeaks::new(forest, mmr_peaks_nodes).map_err(StoreError::MmrError)
310}
311
312fn serialize_block_header(
313    block_header: &BlockHeader,
314    has_client_notes: bool,
315) -> SerializedBlockHeaderData {
316    let block_num = block_header.block_num();
317    let header = block_header.to_bytes();
318
319    SerializedBlockHeaderData {
320        block_num: block_num.as_u32(),
321        header,
322        has_client_notes,
323    }
324}
325
326fn parse_block_headers_columns(
327    row: &rusqlite::Row<'_>,
328) -> Result<SerializedBlockHeaderParts, rusqlite::Error> {
329    let block_num: u32 = row.get(0)?;
330    let header: Vec<u8> = row.get(1)?;
331    let has_client_notes: bool = row.get(2)?;
332
333    Ok(SerializedBlockHeaderParts {
334        _block_num: u64::from(block_num),
335        header,
336        has_client_notes,
337    })
338}
339
340fn parse_block_header(
341    serialized_block_header_parts: &SerializedBlockHeaderParts,
342) -> Result<(BlockHeader, BlockRelevance), StoreError> {
343    Ok((
344        BlockHeader::read_from_bytes(&serialized_block_header_parts.header)?,
345        serialized_block_header_parts.has_client_notes.into(),
346    ))
347}
348
349fn serialize_partial_blockchain_node(
350    id: InOrderIndex,
351    node: Word,
352) -> SerializedPartialBlockchainNodeData {
353    let id = i64::try_from(id.inner()).expect("id is a valid i64");
354    let node = node.to_bytes();
355    SerializedPartialBlockchainNodeData { id, node }
356}
357
358fn parse_partial_blockchain_nodes_columns(
359    row: &rusqlite::Row<'_>,
360) -> Result<SerializedPartialBlockchainNodeParts, rusqlite::Error> {
361    let id: u64 = row.get(0)?;
362    let node = row.get(1)?;
363    Ok(SerializedPartialBlockchainNodeParts { id, node })
364}
365
366fn parse_partial_blockchain_nodes(
367    serialized_partial_blockchain_node_parts: &SerializedPartialBlockchainNodeParts,
368) -> Result<(InOrderIndex, Word), StoreError> {
369    let id = InOrderIndex::new(
370        NonZeroUsize::new(
371            usize::try_from(serialized_partial_blockchain_node_parts.id)
372                .expect("id is u64, should not fail"),
373        )
374        .unwrap(),
375    );
376    let node: Word = Word::read_from_bytes(&serialized_partial_blockchain_node_parts.node)?;
377    Ok((id, node))
378}
379
380pub(crate) fn set_block_header_has_client_notes(
381    tx: &Transaction<'_>,
382    block_num: u64,
383    has_client_notes: bool,
384) -> Result<(), StoreError> {
385    // Only update to change has_client_notes to true if it was false previously
386    const QUERY: &str = "\
387        UPDATE block_headers
388        SET has_client_notes=?
389        WHERE block_num=? AND has_client_notes=FALSE;";
390    tx.execute(QUERY, params![has_client_notes, block_num]).into_store_error()?;
391    Ok(())
392}
393
394#[cfg(test)]
395mod test {
396    use std::collections::{BTreeMap, BTreeSet};
397    use std::vec::Vec;
398
399    use miden_client::Word;
400    use miden_client::block::BlockHeader;
401    use miden_client::crypto::{Forest, InOrderIndex, MmrPeaks};
402    use miden_client::note::BlockNumber;
403    use miden_client::store::{PartialBlockchainFilter, Store};
404    use miden_client::utils::Serializable;
405    use miden_protocol::crypto::merkle::mmr::Mmr;
406    use miden_protocol::transaction::TransactionKernel;
407    use rusqlite::params;
408
409    use crate::SqliteStore;
410    use crate::tests::create_test_store;
411
412    async fn insert_dummy_block_headers(store: &mut SqliteStore) -> Vec<BlockHeader> {
413        let block_headers: Vec<BlockHeader> = (0..5)
414            .map(|block_num| {
415                BlockHeader::mock(block_num, None, None, &[], TransactionKernel.to_commitment())
416            })
417            .collect();
418
419        let block_headers_clone = block_headers.clone();
420        store
421            .interact_with_connection(move |conn| {
422                let tx = conn.transaction().unwrap();
423                (0..5).for_each(|block_num| {
424                    SqliteStore::insert_block_header_tx(
425                        &tx,
426                        &block_headers_clone[block_num],
427                        false,
428                    )
429                    .unwrap();
430                });
431                tx.commit().unwrap();
432                Ok(())
433            })
434            .await
435            .unwrap();
436
437        block_headers
438    }
439
440    #[tokio::test]
441    async fn insert_and_get_block_headers_by_number() {
442        let mut store = create_test_store().await;
443        let block_headers = insert_dummy_block_headers(&mut store).await;
444
445        let block_header = Store::get_block_header_by_num(&store, 3.into()).await.unwrap().unwrap();
446        assert_eq!(block_headers[3], block_header.0);
447    }
448
449    #[tokio::test]
450    async fn insert_and_get_block_headers_by_list() {
451        let mut store = create_test_store().await;
452        let mock_block_headers = insert_dummy_block_headers(&mut store).await;
453
454        let block_headers: Vec<BlockHeader> =
455            Store::get_block_headers(&store, &[1.into(), 3.into()].into_iter().collect())
456                .await
457                .unwrap()
458                .into_iter()
459                .map(|(block_header, _has_notes)| block_header)
460                .collect();
461        assert_eq!(
462            &[mock_block_headers[1].clone(), mock_block_headers[3].clone()],
463            &block_headers[..]
464        );
465    }
466
467    /// Tests that `insert_block_header` persists the tracked header and its MMR
468    /// authentication nodes in the same call, so both are retrievable afterwards.
469    #[tokio::test]
470    async fn insert_block_header_stores_header_and_nodes() {
471        let store = create_test_store().await;
472        const TOTAL_BLOCKS: usize = 8;
473        let tx_kernel = TransactionKernel.to_commitment();
474
475        let headers: Vec<BlockHeader> = (0..TOTAL_BLOCKS)
476            .map(|n| BlockHeader::mock(u32::try_from(n).unwrap(), None, None, &[], tx_kernel))
477            .collect();
478        let mut mmr = Mmr::default();
479        for header in &headers {
480            mmr.add(header.commitment()).expect("valid MMR append");
481        }
482
483        let tracked: BTreeSet<usize> = [5].into();
484        let auth_nodes = collect_auth_nodes(&mmr, &headers, &tracked);
485        let header = headers[5].clone();
486
487        Store::insert_block_header(&store, &header, &auth_nodes, true).await.unwrap();
488
489        // The header is stored and marked as tracked.
490        let stored = Store::get_block_headers(&store, &[5.into()].into_iter().collect())
491            .await
492            .unwrap();
493        assert_eq!(stored.len(), 1);
494        assert_eq!(stored[0].0, header);
495
496        let tracked = Store::get_tracked_block_header_numbers(&store).await.unwrap();
497        assert_eq!(tracked, [5].into());
498
499        // Every authentication node was stored by the same call.
500        let stored_nodes =
501            Store::get_partial_blockchain_nodes(&store, PartialBlockchainFilter::All)
502                .await
503                .unwrap();
504        let expected: BTreeMap<InOrderIndex, Word> = auth_nodes.iter().copied().collect();
505        assert_eq!(stored_nodes, expected);
506    }
507
508    /// Tests that a failure inserting the MMR nodes rolls back the block header written in the
509    /// same call, proving both land in a single transaction.
510    #[tokio::test]
511    async fn insert_block_header_rolls_back_header_when_nodes_fail() {
512        let store = create_test_store().await;
513        let header = BlockHeader::mock(5, None, None, &[], TransactionKernel.to_commitment());
514        // One node so the node insert actually runs (an empty slice would be a no-op).
515        let nodes = [(InOrderIndex::from_leaf_pos(5), header.commitment())];
516
517        // Force the node insert (the second statement in the transaction) to fail.
518        store
519            .interact_with_connection(|conn| {
520                conn.execute("DROP TABLE partial_blockchain_nodes", []).unwrap();
521                Ok(())
522            })
523            .await
524            .unwrap();
525
526        let result = Store::insert_block_header(&store, &header, &nodes, true).await;
527        assert!(result.is_err(), "node insert must fail against the dropped table");
528
529        // The header must not survive: a non-atomic two-transaction insert would leave it behind.
530        let stored = Store::get_block_headers(&store, &[5.into()].into_iter().collect())
531            .await
532            .unwrap();
533        assert!(stored.is_empty(), "header must roll back when the node insert fails");
534    }
535
536    /// Tests that large stored MMRs are built consistently throughout multiple prunes
537    #[tokio::test]
538    async fn partial_mmr_reconstructs_after_multiple_prune() {
539        // Setup (mock a large MMR to work with, with a partial tracked set)
540        // ----------------------------------------------------------------------------------------
541
542        let store = create_test_store().await;
543        const TOTAL_BLOCKS: usize = 7300;
544
545        let tx_kernel_commitment = TransactionKernel.to_commitment();
546        let block_headers: Vec<BlockHeader> = (0..TOTAL_BLOCKS)
547            .map(|block_num| {
548                BlockHeader::mock(
549                    u32::try_from(block_num).unwrap(),
550                    None,
551                    None,
552                    &[],
553                    tx_kernel_commitment,
554                )
555            })
556            .collect();
557
558        let mut mmr = Mmr::default();
559        for header in &block_headers {
560            mmr.add(header.commitment()).expect("valid MMR append");
561        }
562
563        let mut tracked_set: BTreeSet<usize> = (0..(TOTAL_BLOCKS - 1)).step_by(97).collect();
564        tracked_set.insert(TOTAL_BLOCKS - 2);
565        let tracked_blocks: Vec<usize> = tracked_set.iter().copied().collect();
566
567        let mut tracked_nodes: BTreeMap<InOrderIndex, Word> = BTreeMap::new();
568        for &block_num in &tracked_blocks {
569            let header = &block_headers[block_num];
570            tracked_nodes.insert(InOrderIndex::from_leaf_pos(block_num), header.commitment());
571
572            let proof = mmr.open(block_num).expect("valid proof");
573            let mut idx = InOrderIndex::from_leaf_pos(block_num);
574            for node in proof.merkle_path().nodes() {
575                tracked_nodes.insert(idx.sibling(), *node);
576                idx = idx.parent();
577            }
578        }
579        let tracked_nodes: Vec<(InOrderIndex, Word)> = tracked_nodes.into_iter().collect();
580
581        let peaks_by_block: Vec<MmrPeaks> = (0..TOTAL_BLOCKS)
582            .map(|block_num| {
583                mmr.peaks_at(Forest::new(block_num).expect("valid forest"))
584                    .expect("valid peaks")
585            })
586            .collect();
587
588        // Save blocks and nodes
589        store
590            .interact_with_connection(move |conn| {
591                let tx = conn.transaction().unwrap();
592                for (block_num, block_header) in block_headers.iter().enumerate() {
593                    let has_notes = tracked_set.contains(&block_num);
594                    SqliteStore::insert_block_header_tx(&tx, block_header, has_notes).unwrap();
595                }
596
597                SqliteStore::insert_partial_blockchain_nodes_tx(&tx, &tracked_nodes).unwrap();
598                tx.commit().unwrap();
599                Ok(())
600            })
601            .await
602            .unwrap();
603
604        let prune_heights = [
605            TOTAL_BLOCKS / 5,
606            (TOTAL_BLOCKS * 2) / 5,
607            (TOTAL_BLOCKS * 3) / 5,
608            TOTAL_BLOCKS - 1,
609        ];
610
611        // Tests/assertions
612        // ----------------------------------------------------------------------------------------
613
614        let mut previous_remaining: Option<i64> = None;
615        for height in prune_heights {
616            let height_i64 = i64::try_from(height).expect("fits in i64");
617            let peaks_bytes = peaks_by_block[height].peaks().to_vec().to_bytes();
618
619            // Update sync height (and the matching MMR peaks) to simulate having synced further
620            store
621                .interact_with_connection(move |conn| {
622                    conn.execute(
623                        "UPDATE blockchain_checkpoint SET block_num = ?, partial_blockchain_peaks = ?",
624                        params![height_i64, peaks_bytes],
625                    )
626                    .unwrap();
627                    Ok(())
628                })
629                .await
630                .unwrap();
631
632            // Prune
633            store.untrack_and_prune_irrelevant_blocks(&[], &[]).await.unwrap();
634
635            // Assert blocks
636            let remaining_headers: i64 = store
637                .interact_with_connection(|conn| {
638                    let count = conn
639                        .query_row("SELECT COUNT(*) FROM block_headers", [], |row| row.get(0))
640                        .unwrap();
641                    Ok(count)
642                })
643                .await
644                .unwrap();
645            if let Some(previous) = previous_remaining {
646                assert!(remaining_headers < previous);
647            } else {
648                assert!(remaining_headers < i64::try_from(TOTAL_BLOCKS).unwrap());
649            }
650            previous_remaining = Some(remaining_headers);
651        }
652
653        // Try build MMR
654        let partial_mmr = Store::get_current_partial_mmr(&store).await.unwrap();
655        assert_eq!(partial_mmr.peaks().hash_peaks(), mmr.peaks().hash_peaks());
656
657        for block_num in tracked_blocks {
658            let partial_proof = partial_mmr.open(block_num).expect("partial mmr query succeeds");
659            assert!(partial_proof.is_some());
660            assert_eq!(
661                partial_proof.unwrap().merkle_path(),
662                mmr.open(block_num).unwrap().merkle_path()
663            );
664        }
665    }
666
667    /// Collects authentication nodes for a set of tracked leaves in an MMR.
668    fn collect_auth_nodes(
669        mmr: &Mmr,
670        block_headers: &[BlockHeader],
671        tracked: &BTreeSet<usize>,
672    ) -> Vec<(InOrderIndex, Word)> {
673        let mut nodes: BTreeMap<InOrderIndex, Word> = BTreeMap::new();
674        for &block_num in tracked {
675            nodes.insert(
676                InOrderIndex::from_leaf_pos(block_num),
677                block_headers[block_num].commitment(),
678            );
679            let proof = mmr.open(block_num).expect("valid proof");
680            let mut idx = InOrderIndex::from_leaf_pos(block_num);
681            for node in proof.merkle_path().nodes() {
682                nodes.insert(idx.sibling(), *node);
683                idx = idx.parent();
684            }
685        }
686        nodes.into_iter().collect()
687    }
688
689    /// Tests that `untrack_and_prune_irrelevant_blocks` removes redundant authentication nodes
690    /// for untracked blocks while preserving nodes needed by blocks that remain tracked.
691    #[tokio::test]
692    async fn prune_irrelevant_blocks_removes_redundant_auth_nodes() {
693        let store = create_test_store().await;
694        const TOTAL_BLOCKS: usize = 16;
695        let tx_kernel = TransactionKernel.to_commitment();
696
697        let headers: Vec<BlockHeader> = (0..TOTAL_BLOCKS)
698            .map(|n| BlockHeader::mock(u32::try_from(n).unwrap(), None, None, &[], tx_kernel))
699            .collect();
700        let mut mmr = Mmr::default();
701        for h in &headers {
702            mmr.add(h.commitment()).expect("valid MMR append");
703        }
704
705        // Track blocks 3 and 10; we will untrack 3 later.
706        let tracked: BTreeSet<usize> = [3, 10].into();
707        let auth_nodes = collect_auth_nodes(&mmr, &headers, &tracked);
708        let tip_peaks_bytes = mmr
709            .peaks_at(Forest::new(TOTAL_BLOCKS - 1).expect("valid forest"))
710            .unwrap()
711            .peaks()
712            .to_vec()
713            .to_bytes();
714
715        // Persist everything.
716        let headers_clone = headers.clone();
717        store
718            .interact_with_connection(move |conn| {
719                let tx = conn.transaction().unwrap();
720                for (i, header) in headers_clone.iter().enumerate().take(TOTAL_BLOCKS) {
721                    SqliteStore::insert_block_header_tx(&tx, header, tracked.contains(&i)).unwrap();
722                }
723                SqliteStore::insert_partial_blockchain_nodes_tx(&tx, &auth_nodes).unwrap();
724                tx.execute(
725                    "UPDATE blockchain_checkpoint SET block_num = ?, partial_blockchain_peaks = ?",
726                    params![i64::try_from(TOTAL_BLOCKS - 1).unwrap(), tip_peaks_bytes],
727                )
728                .unwrap();
729                tx.commit().unwrap();
730                Ok(())
731            })
732            .await
733            .unwrap();
734
735        // Untrack block 3 via the PartialMmr, then prune.
736        let mut partial_mmr = Store::get_current_partial_mmr(&store).await.unwrap();
737        let removed: Vec<InOrderIndex> =
738            partial_mmr.untrack(3).into_iter().map(|(idx, _)| idx).collect();
739        assert!(!removed.is_empty(), "untracking should remove at least one node");
740
741        store
742            .untrack_and_prune_irrelevant_blocks(&[BlockNumber::from(3u32)], &removed)
743            .await
744            .unwrap();
745
746        // Block 3 header should be deleted, block 10 should still be provable.
747        let rebuilt = Store::get_current_partial_mmr(&store).await.unwrap();
748        assert_eq!(rebuilt.peaks().hash_peaks(), mmr.peaks().hash_peaks());
749
750        let proof_10 = rebuilt.open(10).expect("open succeeds");
751        assert!(proof_10.is_some(), "block 10 should still be provable");
752
753        let proof_3 = rebuilt.open(3).expect("open succeeds");
754        assert!(proof_3.is_none(), "block 3 should no longer be provable");
755    }
756}