Skip to main content

miden_client/transaction/
chain_anchor.rs

1use alloc::collections::BTreeMap;
2use alloc::format;
3use alloc::string::ToString;
4
5use miden_protocol::block::{BlockHeader, BlockNumber};
6use miden_protocol::crypto::merkle::mmr::PartialMmr;
7use miden_protocol::transaction::PartialBlockchain;
8use miden_protocol::{MAX_INPUT_NOTES_PER_TX, Word};
9use miden_tx::utils::serde::{
10    ByteReader,
11    ByteWriter,
12    Deserializable,
13    DeserializationError,
14    Serializable,
15};
16use thiserror::Error;
17
18// CHAIN ANCHOR
19// ================================================================================================
20
21/// A self-contained, verifiable anchor for executing a transaction against a specific reference
22/// block instead of the client's current sync height.
23///
24/// The anchor bundles the reference [`BlockHeader`] with a [`PartialBlockchain`] consistent with
25/// it — exactly the chain data `TransactionInputs` requires: `chain_length()` equals the header's
26/// block number and the peaks hash to the header's chain commitment. Both invariants are enforced
27/// on construction (including deserialization), so an anchor received from an untrusted party only
28/// needs its [`Self::block_commitment`] checked against an independently trusted value — e.g. the
29/// `BLOCK_COMMITMENT` word bound into a signed [`TransactionSummary`] — to be safe to execute
30/// against.
31///
32/// Since protocol 0.16 the signed transaction summary binds the reference block commitment, so a
33/// summary produced at one block cannot be reproduced by re-executing at another. Flows that
34/// collect signatures over a summary and execute later (e.g. multisig) capture an anchor at the
35/// block the summary was built at ([`crate::Client::chain_anchor_for_request`]), ship it with the
36/// signed data, and replay the transaction with [`crate::Client::execute_transaction_at`] so the
37/// summary — and with it the signature advice keys — reproduces exactly.
38///
39/// When the transaction consumes authenticated notes, the anchor's [`PartialBlockchain`] must
40/// track each note's creation block; [`crate::Client::chain_anchor_for_request`] captures an
41/// anchor tracking the blocks of a request's authenticated input notes.
42///
43/// [`TransactionSummary`]: miden_protocol::transaction::TransactionSummary
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ChainAnchor {
46    header: BlockHeader,
47    chain: PartialBlockchain,
48}
49
50impl ChainAnchor {
51    /// Returns a new anchor after validating that `chain` is consistent with `header`.
52    ///
53    /// # Errors
54    ///
55    /// - The partial blockchain's length does not match the header's block number.
56    /// - The partial blockchain's peaks do not hash to the header's chain commitment.
57    /// - The partial blockchain tracks more blocks than a transaction can reference.
58    pub fn new(header: BlockHeader, chain: PartialBlockchain) -> Result<Self, ChainAnchorError> {
59        if chain.chain_length() != header.block_num() {
60            return Err(ChainAnchorError::ChainLengthMismatch {
61                chain_length: chain.chain_length(),
62                block_num: header.block_num(),
63            });
64        }
65
66        if chain.peaks().hash_peaks() != header.chain_commitment() {
67            return Err(ChainAnchorError::ChainCommitmentMismatch {
68                block_num: header.block_num(),
69            });
70        }
71
72        // A transaction references at most one creation block per input note, so a chain tracking
73        // more blocks than that is not an anchor any honest peer would produce.
74        if chain.num_tracked_blocks() > MAX_INPUT_NOTES_PER_TX {
75            return Err(ChainAnchorError::TooManyTrackedBlocks {
76                count: chain.num_tracked_blocks(),
77                max: MAX_INPUT_NOTES_PER_TX,
78            });
79        }
80
81        Ok(Self { header, chain })
82    }
83
84    /// Returns the number of the anchored reference block.
85    pub fn block_num(&self) -> BlockNumber {
86        self.header.block_num()
87    }
88
89    /// Returns the commitment of the anchored reference block.
90    ///
91    /// Callers holding an anchor from an untrusted source should compare this against an
92    /// independently trusted commitment (e.g. the block commitment bound into a signed
93    /// transaction summary) before executing with the anchor.
94    pub fn block_commitment(&self) -> Word {
95        self.header.commitment()
96    }
97
98    /// Returns the anchored reference block header.
99    pub fn header(&self) -> &BlockHeader {
100        &self.header
101    }
102
103    /// Returns the partial blockchain at the anchored reference block.
104    pub fn partial_blockchain(&self) -> &PartialBlockchain {
105        &self.chain
106    }
107
108    /// Consumes the anchor and returns its parts.
109    pub fn into_parts(self) -> (BlockHeader, PartialBlockchain) {
110        (self.header, self.chain)
111    }
112}
113
114impl Serializable for ChainAnchor {
115    fn write_into<W: ByteWriter>(&self, target: &mut W) {
116        self.header.write_into(target);
117        self.chain.write_into(target);
118    }
119}
120
121impl Deserializable for ChainAnchor {
122    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
123        let header = BlockHeader::read_from(source)?;
124
125        // Read the partial blockchain's parts rather than calling `PartialBlockchain::read_from`:
126        // that path `expect`s on `PartialMmr::open`, which fails when a tracked leaf's ancestor
127        // sibling is absent — a remotely triggerable panic, since anchor bytes come from another
128        // party. Opening every tracked block here turns it into a rejected deserialization.
129        let mmr = PartialMmr::read_from(source)?;
130        let blocks = BTreeMap::<BlockNumber, BlockHeader>::read_from(source)?;
131
132        // `Self::new` enforces this too, but only after opening and proving every block; rejecting
133        // early bounds the work an oversized anchor can buy.
134        if blocks.len() > MAX_INPUT_NOTES_PER_TX {
135            return Err(DeserializationError::InvalidValue(
136                ChainAnchorError::TooManyTrackedBlocks {
137                    count: blocks.len(),
138                    max: MAX_INPUT_NOTES_PER_TX,
139                }
140                .to_string(),
141            ));
142        }
143
144        for (block_num, header) in &blocks {
145            // The constructor re-derives each position from the header, so the key must agree
146            // with it — otherwise a crafted anchor could aim the `open` below at a harmless
147            // position while the constructor opens the dangerous one.
148            if block_num != &header.block_num() {
149                return Err(DeserializationError::InvalidValue(format!(
150                    "block map key {block_num} does not match the block number {} of the header it maps to",
151                    header.block_num()
152                )));
153            }
154
155            mmr.open(header.block_num().as_usize())
156                .map_err(|err| DeserializationError::InvalidValue(err.to_string()))?;
157        }
158        let chain = PartialBlockchain::new(mmr, blocks.into_values())
159            .map_err(|err| DeserializationError::InvalidValue(err.to_string()))?;
160
161        Self::new(header, chain).map_err(|err| DeserializationError::InvalidValue(err.to_string()))
162    }
163}
164
165// CHAIN ANCHOR ERROR
166// ================================================================================================
167
168#[derive(Debug, Error)]
169pub enum ChainAnchorError {
170    #[error(
171        "partial blockchain length {chain_length} does not match the anchor block number {block_num}"
172    )]
173    ChainLengthMismatch {
174        chain_length: BlockNumber,
175        block_num: BlockNumber,
176    },
177    #[error(
178        "partial blockchain peaks do not hash to the chain commitment of anchor block {block_num}"
179    )]
180    ChainCommitmentMismatch { block_num: BlockNumber },
181    #[error(
182        "block {block_num} is not tracked by the anchor's partial blockchain; capture the anchor with the blocks of all authenticated input notes"
183    )]
184    BlockNotTracked { block_num: BlockNumber },
185    #[error("the anchor tracks {count} blocks, more than the {max} a transaction can reference")]
186    TooManyTrackedBlocks { count: usize, max: usize },
187    #[error("transaction reference block {requested} does not match the anchor block {anchor}")]
188    ReferenceBlockMismatch {
189        requested: BlockNumber,
190        anchor: BlockNumber,
191    },
192    #[error(
193        "the anchored transaction expires at block {expiration}, which the chain has already reached (sync height {sync_height}); it would be rejected by the network, so re-capture the anchor closer to the tip or raise the request's expiration delta"
194    )]
195    AnchoredTransactionExpired {
196        expiration: BlockNumber,
197        sync_height: BlockNumber,
198    },
199}
200
201// TESTS
202// ================================================================================================
203
204#[cfg(test)]
205mod tests {
206    use alloc::vec::Vec;
207
208    use miden_protocol::Word;
209    use miden_protocol::block::BlockHeader;
210    use miden_protocol::crypto::merkle::mmr::{Mmr, PartialMmr};
211    use miden_protocol::transaction::PartialBlockchain;
212    use miden_tx::utils::serde::{Deserializable, DeserializationError, Serializable};
213
214    use super::{ChainAnchor, ChainAnchorError};
215
216    /// Returns a partial blockchain of length `chain_length` tracking the given block numbers,
217    /// alongside a header whose block number and chain commitment are consistent with it.
218    fn anchor_parts(chain_length: usize, tracked: &[usize]) -> (BlockHeader, PartialBlockchain) {
219        let mut mmr = Mmr::default();
220        let mut headers = Vec::with_capacity(chain_length);
221        for block_num in 0..chain_length {
222            let header = BlockHeader::mock(
223                u32::try_from(block_num).unwrap(),
224                None,
225                None,
226                &[],
227                Word::empty(),
228            );
229            mmr.add(header.commitment()).unwrap();
230            headers.push(header);
231        }
232
233        let peaks = mmr.peaks();
234        let mut partial_mmr = PartialMmr::from_peaks(peaks.clone());
235        let mut tracked_headers = Vec::new();
236        for &pos in tracked {
237            partial_mmr
238                .track(pos, mmr.get(pos).unwrap(), mmr.open(pos).unwrap().merkle_path())
239                .unwrap();
240            tracked_headers.push(headers[pos].clone());
241        }
242
243        let chain = PartialBlockchain::new(partial_mmr, tracked_headers).unwrap();
244        let header = BlockHeader::mock(
245            u32::try_from(chain_length).unwrap(),
246            Some(peaks.hash_peaks()),
247            None,
248            &[],
249            Word::empty(),
250        );
251
252        (header, chain)
253    }
254
255    #[test]
256    fn new_accepts_a_consistent_header_and_chain() {
257        let (header, chain) = anchor_parts(8, &[3]);
258        let block_num = header.block_num();
259
260        let anchor = ChainAnchor::new(header, chain).unwrap();
261
262        assert_eq!(anchor.block_num(), block_num);
263    }
264
265    #[test]
266    fn new_rejects_a_chain_length_that_does_not_match_the_header() {
267        let (_, chain) = anchor_parts(8, &[3]);
268        // Commit to the right chain, so the block number is the only defect.
269        let header =
270            BlockHeader::mock(9, Some(chain.peaks().hash_peaks()), None, &[], Word::empty());
271
272        let err = ChainAnchor::new(header, chain).unwrap_err();
273
274        assert!(matches!(err, ChainAnchorError::ChainLengthMismatch { .. }), "got {err:?}");
275    }
276
277    #[test]
278    fn new_rejects_peaks_that_do_not_hash_to_the_chain_commitment() {
279        let (_, chain) = anchor_parts(8, &[3]);
280        // Right block number, but a header committing to an unrelated chain commitment.
281        let header = BlockHeader::mock(8, None, None, &[], Word::empty());
282
283        let err = ChainAnchor::new(header, chain).unwrap_err();
284
285        assert!(matches!(err, ChainAnchorError::ChainCommitmentMismatch { .. }), "got {err:?}");
286    }
287
288    #[test]
289    fn serialization_round_trips() {
290        let (header, chain) = anchor_parts(8, &[3]);
291        let anchor = ChainAnchor::new(header, chain).unwrap();
292
293        let deserialized = ChainAnchor::read_from_bytes(&anchor.to_bytes()).unwrap();
294        assert_eq!(anchor, deserialized);
295    }
296
297    #[test]
298    fn deserialization_rejects_truncated_and_garbage_input() {
299        let (header, chain) = anchor_parts(8, &[3]);
300        let bytes = ChainAnchor::new(header, chain).unwrap().to_bytes();
301
302        assert!(ChainAnchor::read_from_bytes(&bytes[..bytes.len() - 1]).is_err());
303        assert!(ChainAnchor::read_from_bytes(&[0xaa; 64]).is_err());
304    }
305
306    /// A tracked leaf whose ancestor siblings are absent makes `PartialBlockchain::new` panic on
307    /// the `expect` around `PartialMmr::open`; deserialization must reject it instead.
308    #[test]
309    fn deserialization_rejects_a_tracked_leaf_with_a_missing_sibling() {
310        use alloc::collections::{BTreeMap, BTreeSet};
311
312        use miden_protocol::crypto::merkle::mmr::InOrderIndex;
313
314        let mut mmr = Mmr::default();
315        let mut headers = Vec::new();
316        for block_num in 0..4u32 {
317            let header = BlockHeader::mock(block_num, None, None, &[], Word::empty());
318            mmr.add(header.commitment()).unwrap();
319            headers.push(header);
320        }
321        let peaks = mmr.peaks();
322
323        // Only the tracked leaf itself, none of its authentication path.
324        let mut nodes = BTreeMap::new();
325        nodes.insert(InOrderIndex::from_leaf_pos(3), headers[3].commitment());
326
327        let partial_mmr =
328            PartialMmr::from_parts(peaks.clone(), nodes, BTreeSet::from([3])).unwrap();
329
330        let bytes = {
331            let mut buf = Vec::new();
332            let header = BlockHeader::mock(4, Some(peaks.hash_peaks()), None, &[], Word::empty());
333            header.write_into(&mut buf);
334            PartialBlockchain::new_unchecked(partial_mmr, [headers[3].clone()])
335                .unwrap()
336                .write_into(&mut buf);
337            buf
338        };
339
340        assert!(ChainAnchor::read_from_bytes(&bytes).is_err());
341    }
342
343    /// A block-map key that disagrees with its header would let a crafted anchor aim the
344    /// pre-flight `open` at a harmless position, so deserialization must reject it.
345    #[test]
346    fn deserialization_rejects_a_block_key_that_disagrees_with_its_header() {
347        use alloc::collections::BTreeMap;
348
349        use miden_protocol::block::BlockNumber;
350
351        // A fully valid chain, so the disagreeing key is the payload's only defect.
352        let (header, chain) = anchor_parts(8, &[3]);
353        let tracked = chain.get_block(BlockNumber::from(3u32)).unwrap().clone();
354
355        let mut blocks = BTreeMap::new();
356        blocks.insert(BlockNumber::from(0u32), tracked);
357
358        let bytes = {
359            let mut buf = Vec::new();
360            header.write_into(&mut buf);
361            chain.mmr().write_into(&mut buf);
362            blocks.write_into(&mut buf);
363            buf
364        };
365
366        let err = ChainAnchor::read_from_bytes(&bytes).unwrap_err();
367        assert!(
368            matches!(&err, DeserializationError::InvalidValue(msg) if msg.contains("does not match the block number")),
369            "got {err:?}"
370        );
371    }
372}