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 it
25/// — 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/// The anchor's [`PartialBlockchain`] must track each block that the transaction authenticates.
40/// [`crate::Client::chain_anchor_for_request`] captures the blocks declared by the request and the
41/// creation blocks of its 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        // Bound the work required to validate an anchor received from an untrusted source.
73        if chain.num_tracked_blocks() > MAX_INPUT_NOTES_PER_TX {
74            return Err(ChainAnchorError::TooManyTrackedBlocks {
75                count: chain.num_tracked_blocks(),
76                max: MAX_INPUT_NOTES_PER_TX,
77            });
78        }
79
80        Ok(Self { header, chain })
81    }
82
83    /// Returns the number of the anchored reference block.
84    pub fn block_num(&self) -> BlockNumber {
85        self.header.block_num()
86    }
87
88    /// Returns the commitment of the anchored reference block.
89    ///
90    /// Callers holding an anchor from an untrusted source should compare this against an
91    /// independently trusted commitment (e.g. the block commitment bound into a signed transaction
92    /// summary) before executing with the anchor.
93    pub fn block_commitment(&self) -> Word {
94        self.header.commitment()
95    }
96
97    /// Returns the anchored reference block header.
98    pub fn header(&self) -> &BlockHeader {
99        &self.header
100    }
101
102    /// Returns the partial blockchain at the anchored reference block.
103    pub fn partial_blockchain(&self) -> &PartialBlockchain {
104        &self.chain
105    }
106
107    /// Consumes the anchor and returns its parts.
108    pub fn into_parts(self) -> (BlockHeader, PartialBlockchain) {
109        (self.header, self.chain)
110    }
111}
112
113impl Serializable for ChainAnchor {
114    fn write_into<W: ByteWriter>(&self, target: &mut W) {
115        self.header.write_into(target);
116        self.chain.write_into(target);
117    }
118}
119
120impl Deserializable for ChainAnchor {
121    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
122        let header = BlockHeader::read_from(source)?;
123
124        // Read the partial blockchain's parts rather than calling `PartialBlockchain::read_from`:
125        // that path `expect`s on `PartialMmr::open`, which fails when a tracked leaf's ancestor
126        // sibling is absent — a remotely triggerable panic, since anchor bytes come from another
127        // party. Opening every tracked block here turns it into a rejected deserialization.
128        let mmr = PartialMmr::read_from(source)?;
129        let blocks = BTreeMap::<BlockNumber, BlockHeader>::read_from(source)?;
130
131        // `Self::new` enforces this too, but only after opening and proving every block; rejecting
132        // early bounds the work an oversized anchor can buy.
133        if blocks.len() > MAX_INPUT_NOTES_PER_TX {
134            return Err(DeserializationError::InvalidValue(
135                ChainAnchorError::TooManyTrackedBlocks {
136                    count: blocks.len(),
137                    max: MAX_INPUT_NOTES_PER_TX,
138                }
139                .to_string(),
140            ));
141        }
142
143        for (block_num, header) in &blocks {
144            // The constructor re-derives each position from the header, so the key must agree with
145            // it — otherwise a crafted anchor could aim the `open` below at a harmless position
146            // while the constructor opens the dangerous one.
147            if block_num != &header.block_num() {
148                return Err(DeserializationError::InvalidValue(format!(
149                    "block map key {block_num} does not match the block number {} of the header it maps to",
150                    header.block_num()
151                )));
152            }
153
154            mmr.open(header.block_num().as_usize())
155                .map_err(|err| DeserializationError::InvalidValue(err.to_string()))?;
156        }
157        let chain = PartialBlockchain::new(mmr, blocks.into_values())
158            .map_err(|err| DeserializationError::InvalidValue(err.to_string()))?;
159
160        Self::new(header, chain).map_err(|err| DeserializationError::InvalidValue(err.to_string()))
161    }
162}
163
164// CHAIN ANCHOR ERROR
165// ================================================================================================
166
167#[derive(Debug, Error)]
168pub enum ChainAnchorError {
169    #[error(
170        "partial blockchain length {chain_length} does not match the anchor block number {block_num}"
171    )]
172    ChainLengthMismatch {
173        chain_length: BlockNumber,
174        block_num: BlockNumber,
175    },
176    #[error(
177        "partial blockchain peaks do not hash to the chain commitment of anchor block {block_num}"
178    )]
179    ChainCommitmentMismatch { block_num: BlockNumber },
180    #[error(
181        "block {block_num} is not tracked by the anchor's partial blockchain; capture the anchor with the blocks of all authenticated input notes"
182    )]
183    BlockNotTracked { block_num: BlockNumber },
184    #[error("the anchor tracks {count} blocks, more than the {max} a transaction can reference")]
185    TooManyTrackedBlocks { count: usize, max: usize },
186    #[error("transaction reference block {requested} does not match the anchor block {anchor}")]
187    ReferenceBlockMismatch {
188        requested: BlockNumber,
189        anchor: BlockNumber,
190    },
191    #[error(
192        "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"
193    )]
194    AnchoredTransactionExpired {
195        expiration: BlockNumber,
196        sync_height: BlockNumber,
197    },
198}
199
200// TESTS
201// ================================================================================================
202
203#[cfg(test)]
204mod tests {
205    use alloc::vec::Vec;
206
207    use miden_protocol::block::BlockHeader;
208    use miden_protocol::crypto::merkle::mmr::{Mmr, PartialMmr};
209    use miden_protocol::transaction::PartialBlockchain;
210    use miden_tx::utils::serde::{Deserializable, DeserializationError, Serializable};
211
212    use super::{ChainAnchor, ChainAnchorError};
213
214    /// Returns a partial blockchain of length `chain_length` tracking the given block numbers,
215    /// alongside a header whose block number and chain commitment are consistent with it.
216    fn anchor_parts(chain_length: usize, tracked: &[usize]) -> (BlockHeader, PartialBlockchain) {
217        let mut mmr = Mmr::default();
218        let mut headers = Vec::with_capacity(chain_length);
219        for block_num in 0..chain_length {
220            let header = BlockHeader::mock(u32::try_from(block_num).unwrap(), None, None, &[]);
221            mmr.add(header.commitment()).unwrap();
222            headers.push(header);
223        }
224
225        let peaks = mmr.peaks();
226        let mut partial_mmr = PartialMmr::from_peaks(peaks.clone());
227        let mut tracked_headers = Vec::new();
228        for &pos in tracked {
229            partial_mmr
230                .track(pos, mmr.get(pos).unwrap(), mmr.open(pos).unwrap().merkle_path())
231                .unwrap();
232            tracked_headers.push(headers[pos].clone());
233        }
234
235        let chain = PartialBlockchain::new(partial_mmr, tracked_headers).unwrap();
236        let header = BlockHeader::mock(
237            u32::try_from(chain_length).unwrap(),
238            Some(peaks.hash_peaks()),
239            None,
240            &[],
241        );
242
243        (header, chain)
244    }
245
246    #[test]
247    fn new_accepts_a_consistent_header_and_chain() {
248        let (header, chain) = anchor_parts(8, &[3]);
249        let block_num = header.block_num();
250
251        let anchor = ChainAnchor::new(header, chain).unwrap();
252
253        assert_eq!(anchor.block_num(), block_num);
254    }
255
256    #[test]
257    fn new_rejects_a_chain_length_that_does_not_match_the_header() {
258        let (_, chain) = anchor_parts(8, &[3]);
259        // Commit to the right chain, so the block number is the only defect.
260        let header = BlockHeader::mock(9, Some(chain.peaks().hash_peaks()), None, &[]);
261
262        let err = ChainAnchor::new(header, chain).unwrap_err();
263
264        assert!(matches!(err, ChainAnchorError::ChainLengthMismatch { .. }), "got {err:?}");
265    }
266
267    #[test]
268    fn new_rejects_peaks_that_do_not_hash_to_the_chain_commitment() {
269        let (_, chain) = anchor_parts(8, &[3]);
270        // Right block number, but a header committing to an unrelated chain commitment.
271        let header = BlockHeader::mock(8, None, None, &[]);
272
273        let err = ChainAnchor::new(header, chain).unwrap_err();
274
275        assert!(matches!(err, ChainAnchorError::ChainCommitmentMismatch { .. }), "got {err:?}");
276    }
277
278    #[test]
279    fn serialization_round_trips() {
280        let (header, chain) = anchor_parts(8, &[3]);
281        let anchor = ChainAnchor::new(header, chain).unwrap();
282
283        let deserialized = ChainAnchor::read_from_bytes(&anchor.to_bytes()).unwrap();
284        assert_eq!(anchor, deserialized);
285    }
286
287    #[test]
288    fn deserialization_rejects_truncated_and_garbage_input() {
289        let (header, chain) = anchor_parts(8, &[3]);
290        let bytes = ChainAnchor::new(header, chain).unwrap().to_bytes();
291
292        assert!(ChainAnchor::read_from_bytes(&bytes[..bytes.len() - 1]).is_err());
293        assert!(ChainAnchor::read_from_bytes(&[0xaa; 64]).is_err());
294    }
295
296    /// A tracked leaf whose ancestor siblings are absent makes `PartialBlockchain::new` panic on
297    /// the `expect` around `PartialMmr::open`; deserialization must reject it instead.
298    #[test]
299    fn deserialization_rejects_a_tracked_leaf_with_a_missing_sibling() {
300        use alloc::collections::{BTreeMap, BTreeSet};
301
302        use miden_protocol::crypto::merkle::mmr::InOrderIndex;
303
304        let mut mmr = Mmr::default();
305        let mut headers = Vec::new();
306        for block_num in 0..4u32 {
307            let header = BlockHeader::mock(block_num, None, None, &[]);
308            mmr.add(header.commitment()).unwrap();
309            headers.push(header);
310        }
311        let peaks = mmr.peaks();
312
313        // Only the tracked leaf itself, none of its authentication path.
314        let mut nodes = BTreeMap::new();
315        nodes.insert(InOrderIndex::from_leaf_pos(3), headers[3].commitment());
316
317        // `from_parts` rejects the missing authentication path, so the malformed value has to be
318        // built unchecked to reach the deserialization path under test.
319        let partial_mmr =
320            PartialMmr::from_parts_unchecked(peaks.clone(), nodes, BTreeSet::from([3]));
321
322        let bytes = {
323            let mut buf = Vec::new();
324            let header = BlockHeader::mock(4, Some(peaks.hash_peaks()), None, &[]);
325            header.write_into(&mut buf);
326            PartialBlockchain::new_unchecked(partial_mmr, [headers[3].clone()])
327                .unwrap()
328                .write_into(&mut buf);
329            buf
330        };
331
332        assert!(ChainAnchor::read_from_bytes(&bytes).is_err());
333    }
334
335    /// A block-map key that disagrees with its header would let a crafted anchor aim the pre-flight
336    /// `open` at a harmless position, so deserialization must reject it.
337    #[test]
338    fn deserialization_rejects_a_block_key_that_disagrees_with_its_header() {
339        use alloc::collections::BTreeMap;
340
341        use miden_protocol::block::BlockNumber;
342
343        // A fully valid chain, so the disagreeing key is the payload's only defect.
344        let (header, chain) = anchor_parts(8, &[3]);
345        let tracked = chain.get_block(BlockNumber::from(3u32)).unwrap().clone();
346
347        let mut blocks = BTreeMap::new();
348        blocks.insert(BlockNumber::from(0u32), tracked);
349
350        let bytes = {
351            let mut buf = Vec::new();
352            header.write_into(&mut buf);
353            chain.mmr().write_into(&mut buf);
354            blocks.write_into(&mut buf);
355            buf
356        };
357
358        let err = ChainAnchor::read_from_bytes(&bytes).unwrap_err();
359        assert!(
360            matches!(&err, DeserializationError::InvalidValue(msg) if msg.contains("does not match the block number")),
361            "got {err:?}"
362        );
363    }
364}