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/// When the transaction consumes authenticated notes, the anchor's [`PartialBlockchain`] must track
40/// each note's creation block; [`crate::Client::chain_anchor_for_request`] captures an anchor
41/// 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 transaction
93    /// 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 with
146            // it — otherwise a crafted anchor could aim the `open` below at a harmless position
147            // 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::block::BlockHeader;
209    use miden_protocol::crypto::merkle::mmr::{Mmr, PartialMmr};
210    use miden_protocol::transaction::PartialBlockchain;
211    use miden_tx::utils::serde::{Deserializable, DeserializationError, Serializable};
212
213    use super::{ChainAnchor, ChainAnchorError};
214
215    /// Returns a partial blockchain of length `chain_length` tracking the given block numbers,
216    /// alongside a header whose block number and chain commitment are consistent with it.
217    fn anchor_parts(chain_length: usize, tracked: &[usize]) -> (BlockHeader, PartialBlockchain) {
218        let mut mmr = Mmr::default();
219        let mut headers = Vec::with_capacity(chain_length);
220        for block_num in 0..chain_length {
221            let header = BlockHeader::mock(u32::try_from(block_num).unwrap(), None, None, &[]);
222            mmr.add(header.commitment()).unwrap();
223            headers.push(header);
224        }
225
226        let peaks = mmr.peaks();
227        let mut partial_mmr = PartialMmr::from_peaks(peaks.clone());
228        let mut tracked_headers = Vec::new();
229        for &pos in tracked {
230            partial_mmr
231                .track(pos, mmr.get(pos).unwrap(), mmr.open(pos).unwrap().merkle_path())
232                .unwrap();
233            tracked_headers.push(headers[pos].clone());
234        }
235
236        let chain = PartialBlockchain::new(partial_mmr, tracked_headers).unwrap();
237        let header = BlockHeader::mock(
238            u32::try_from(chain_length).unwrap(),
239            Some(peaks.hash_peaks()),
240            None,
241            &[],
242        );
243
244        (header, chain)
245    }
246
247    #[test]
248    fn new_accepts_a_consistent_header_and_chain() {
249        let (header, chain) = anchor_parts(8, &[3]);
250        let block_num = header.block_num();
251
252        let anchor = ChainAnchor::new(header, chain).unwrap();
253
254        assert_eq!(anchor.block_num(), block_num);
255    }
256
257    #[test]
258    fn new_rejects_a_chain_length_that_does_not_match_the_header() {
259        let (_, chain) = anchor_parts(8, &[3]);
260        // Commit to the right chain, so the block number is the only defect.
261        let header = BlockHeader::mock(9, Some(chain.peaks().hash_peaks()), None, &[]);
262
263        let err = ChainAnchor::new(header, chain).unwrap_err();
264
265        assert!(matches!(err, ChainAnchorError::ChainLengthMismatch { .. }), "got {err:?}");
266    }
267
268    #[test]
269    fn new_rejects_peaks_that_do_not_hash_to_the_chain_commitment() {
270        let (_, chain) = anchor_parts(8, &[3]);
271        // Right block number, but a header committing to an unrelated chain commitment.
272        let header = BlockHeader::mock(8, None, None, &[]);
273
274        let err = ChainAnchor::new(header, chain).unwrap_err();
275
276        assert!(matches!(err, ChainAnchorError::ChainCommitmentMismatch { .. }), "got {err:?}");
277    }
278
279    #[test]
280    fn serialization_round_trips() {
281        let (header, chain) = anchor_parts(8, &[3]);
282        let anchor = ChainAnchor::new(header, chain).unwrap();
283
284        let deserialized = ChainAnchor::read_from_bytes(&anchor.to_bytes()).unwrap();
285        assert_eq!(anchor, deserialized);
286    }
287
288    #[test]
289    fn deserialization_rejects_truncated_and_garbage_input() {
290        let (header, chain) = anchor_parts(8, &[3]);
291        let bytes = ChainAnchor::new(header, chain).unwrap().to_bytes();
292
293        assert!(ChainAnchor::read_from_bytes(&bytes[..bytes.len() - 1]).is_err());
294        assert!(ChainAnchor::read_from_bytes(&[0xaa; 64]).is_err());
295    }
296
297    /// A tracked leaf whose ancestor siblings are absent makes `PartialBlockchain::new` panic on
298    /// the `expect` around `PartialMmr::open`; deserialization must reject it instead.
299    #[test]
300    fn deserialization_rejects_a_tracked_leaf_with_a_missing_sibling() {
301        use alloc::collections::{BTreeMap, BTreeSet};
302
303        use miden_protocol::crypto::merkle::mmr::InOrderIndex;
304
305        let mut mmr = Mmr::default();
306        let mut headers = Vec::new();
307        for block_num in 0..4u32 {
308            let header = BlockHeader::mock(block_num, None, None, &[]);
309            mmr.add(header.commitment()).unwrap();
310            headers.push(header);
311        }
312        let peaks = mmr.peaks();
313
314        // Only the tracked leaf itself, none of its authentication path.
315        let mut nodes = BTreeMap::new();
316        nodes.insert(InOrderIndex::from_leaf_pos(3), headers[3].commitment());
317
318        // `from_parts` rejects the missing authentication path, so the malformed value has to be
319        // built unchecked to reach the deserialization path under test.
320        let partial_mmr =
321            PartialMmr::from_parts_unchecked(peaks.clone(), nodes, BTreeSet::from([3]));
322
323        let bytes = {
324            let mut buf = Vec::new();
325            let header = BlockHeader::mock(4, Some(peaks.hash_peaks()), None, &[]);
326            header.write_into(&mut buf);
327            PartialBlockchain::new_unchecked(partial_mmr, [headers[3].clone()])
328                .unwrap()
329                .write_into(&mut buf);
330            buf
331        };
332
333        assert!(ChainAnchor::read_from_bytes(&bytes).is_err());
334    }
335
336    /// A block-map key that disagrees with its header would let a crafted anchor aim the pre-flight
337    /// `open` at a harmless position, so deserialization must reject it.
338    #[test]
339    fn deserialization_rejects_a_block_key_that_disagrees_with_its_header() {
340        use alloc::collections::BTreeMap;
341
342        use miden_protocol::block::BlockNumber;
343
344        // A fully valid chain, so the disagreeing key is the payload's only defect.
345        let (header, chain) = anchor_parts(8, &[3]);
346        let tracked = chain.get_block(BlockNumber::from(3u32)).unwrap().clone();
347
348        let mut blocks = BTreeMap::new();
349        blocks.insert(BlockNumber::from(0u32), tracked);
350
351        let bytes = {
352            let mut buf = Vec::new();
353            header.write_into(&mut buf);
354            chain.mmr().write_into(&mut buf);
355            blocks.write_into(&mut buf);
356            buf
357        };
358
359        let err = ChainAnchor::read_from_bytes(&bytes).unwrap_err();
360        assert!(
361            matches!(&err, DeserializationError::InvalidValue(msg) if msg.contains("does not match the block number")),
362            "got {err:?}"
363        );
364    }
365}