Skip to main content

miden_protocol/asset/vault/
partial.rs

1use alloc::collections::{BTreeMap, BTreeSet};
2use alloc::string::ToString;
3use alloc::vec::Vec;
4
5use miden_crypto::merkle::smt::{PartialSmt, SmtLeaf, SmtProof};
6use miden_crypto::merkle::{InnerNodeInfo, MerkleError};
7
8use super::{AssetId, AssetVault};
9use crate::Word;
10use crate::asset::{Asset, AssetWitness};
11use crate::errors::PartialAssetVaultError;
12use crate::utils::serde::{
13    ByteReader,
14    ByteWriter,
15    Deserializable,
16    DeserializationError,
17    Serializable,
18};
19
20/// A partial representation of an [`AssetVault`], containing only proofs for a subset of assets.
21///
22/// Partial vault is used to provide verifiable access to specific assets in a vault
23/// without the need to provide the full vault data. It contains all required data for loading
24/// vault data into the transaction kernel for transaction execution.
25///
26/// ## Guarantees
27///
28/// This type guarantees that the raw ID-value pairs it contains are all present in the contained
29/// partial SMT (under their hashed form). Note that the inverse is not necessarily true: the SMT
30/// may contain more entries than the map because to prove inclusion of a given raw ID A an
31/// [`SmtLeaf::Multiple`] may be present that contains both SMT keys hash(A) and hash(B). However, B
32/// may not be present in the ID-value pairs and this is a valid state.
33#[derive(Clone, Debug, PartialEq, Eq, Default)]
34pub struct PartialVault {
35    /// An SMT with a partial view into an account's full [`AssetVault`], keyed by hashed
36    /// [`AssetId`]s.
37    partial_smt: PartialSmt,
38    /// Raw [`AssetId`]s -> asset value words, kept consistent with `partial_smt`.
39    entries: BTreeMap<AssetId, Word>,
40}
41
42impl PartialVault {
43    // CONSTRUCTORS
44    // --------------------------------------------------------------------------------------------
45
46    /// Constructs a [`PartialVault`] from an [`AssetVault`] root.
47    ///
48    /// For conversion from an [`AssetVault`], prefer [`Self::new_minimal`] to be more explicit.
49    pub fn new(root: Word) -> Self {
50        PartialVault {
51            partial_smt: PartialSmt::new(root),
52            entries: BTreeMap::new(),
53        }
54    }
55
56    /// Returns a new [`PartialVault`] with all provided witnesses added to it.
57    pub fn with_witnesses(
58        witnesses: impl IntoIterator<Item = AssetWitness>,
59    ) -> Result<Self, PartialAssetVaultError> {
60        let mut entries = BTreeMap::new();
61
62        let partial_smt = PartialSmt::from_proofs(witnesses.into_iter().map(|witness| {
63            // Skip empty values so `entries` only ever tracks valid assets (mirrors
64            // `AssetVault::new`).
65            entries.extend(
66                witness
67                    .entries()
68                    .filter(|(_, value)| !value.is_empty())
69                    .map(|(id, value)| (*id, *value)),
70            );
71            SmtProof::from(witness)
72        }))
73        .map_err(PartialAssetVaultError::FailedToAddProof)?;
74
75        Ok(PartialVault { partial_smt, entries })
76    }
77
78    /// Converts an [`AssetVault`] into a partial vault representation.
79    ///
80    /// The resulting [`PartialVault`] will contain the _full_ merkle paths and entries of the
81    /// original asset vault.
82    pub fn new_full(vault: AssetVault) -> Self {
83        let partial_smt = PartialSmt::from(vault.asset_tree);
84        let entries = vault.entries;
85
86        PartialVault { partial_smt, entries }
87    }
88
89    /// Converts an [`AssetVault`] into a partial vault representation.
90    ///
91    /// The resulting [`PartialVault`] will represent the root of the asset vault, but not track any
92    /// ID-value pairs, which means it is the most _minimal_ representation of the asset vault.
93    pub fn new_minimal(vault: &AssetVault) -> Self {
94        PartialVault::new(vault.root())
95    }
96
97    /// Constructs a [`PartialVault`] from a [`PartialSmt`] and the raw [`AssetId`]s whose
98    /// values are looked up from the SMT.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if:
103    /// - any ID's hashed form is not present in the partial SMT.
104    /// - any of the resulting `(asset_id, value)` pairs does not form a valid asset.
105    pub fn try_from_parts(
106        partial_smt: PartialSmt,
107        ids: impl IntoIterator<Item = AssetId>,
108    ) -> Result<Self, PartialAssetVaultError> {
109        let mut entries = BTreeMap::new();
110        let mut seen_ids = BTreeSet::new();
111
112        for id in ids {
113            if !seen_ids.insert(id) {
114                return Err(PartialAssetVaultError::DuplicateAssetId(id));
115            }
116
117            let value = partial_smt
118                .get_value(&id.hash().as_word())
119                .map_err(PartialAssetVaultError::UntrackedAsset)?;
120
121            // Validate that the (id, value) pair forms a valid asset, even when the value is
122            // empty: an empty value paired with e.g. a non-fungible ID carrying a non-zero asset
123            // class is malformed and must be rejected rather than silently tracked.
124            Asset::new(id, value).map_err(|source| PartialAssetVaultError::InvalidAssetForId {
125                id,
126                value,
127                source,
128            })?;
129
130            // Skip empty values so `entries` stays in sync with the SMT, which treats empty values
131            // as no-ops (mirrors `AssetVault::new`).
132            if !value.is_empty() {
133                entries.insert(id, value);
134            }
135        }
136
137        Ok(Self { partial_smt, entries })
138    }
139
140    // ACCESSORS
141    // --------------------------------------------------------------------------------------------
142
143    /// Returns the root of the partial vault.
144    pub fn root(&self) -> Word {
145        self.partial_smt.root()
146    }
147
148    /// Returns the partial SMT underlying this vault.
149    pub fn partial_smt(&self) -> &PartialSmt {
150        &self.partial_smt
151    }
152
153    /// Returns an iterator over all inner nodes in the Sparse Merkle Tree proofs.
154    ///
155    /// This is useful for reconstructing parts of the Sparse Merkle Tree or for
156    /// verification purposes.
157    pub fn inner_nodes(&self) -> impl Iterator<Item = InnerNodeInfo> + '_ {
158        self.partial_smt.inner_nodes()
159    }
160
161    /// Returns an iterator over all leaves of the underlying [`PartialSmt`].
162    pub fn leaves(&self) -> impl Iterator<Item = &SmtLeaf> {
163        self.partial_smt.leaves().map(|(_, leaf)| leaf)
164    }
165
166    /// Returns an iterator over the [`Asset`]s tracked by this partial vault.
167    pub fn assets(&self) -> impl Iterator<Item = Asset> + '_ {
168        self.entries.iter().map(|(id, value)| {
169            Asset::new(*id, *value).expect("partial vault should only track valid assets")
170        })
171    }
172
173    /// Returns an iterator over the asset IDs tracked by this partial vault.
174    pub fn asset_ids(&self) -> impl Iterator<Item = AssetId> + '_ {
175        self.entries.keys().copied()
176    }
177
178    /// Returns an iterator over the raw `(asset_id, value)` pairs tracked by this partial vault.
179    #[cfg(test)]
180    pub(super) fn entries(&self) -> impl Iterator<Item = (&AssetId, &Word)> {
181        self.entries.iter()
182    }
183
184    /// Returns an opening of the leaf associated with `asset_id`.
185    ///
186    /// The `asset_id` can be obtained with [`Asset::id`].
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if:
191    /// - the asset ID is not tracked by this partial vault.
192    pub fn open(&self, asset_id: AssetId) -> Result<AssetWitness, PartialAssetVaultError> {
193        let smt_proof = self
194            .partial_smt
195            .open(&asset_id.hash().as_word())
196            .map_err(PartialAssetVaultError::UntrackedAsset)?;
197        let value = self.entries.get(&asset_id).copied().unwrap_or_default();
198
199        // SAFETY: The ID-value pair is guaranteed to be present in the proof since we open its
200        // hashed form, and the partial vault only tracks valid assets.
201        Ok(AssetWitness::new_unchecked(smt_proof, [(asset_id, value)]))
202    }
203
204    /// Returns the [`Asset`] associated with the given `asset_id`.
205    ///
206    /// The return value is `None` if the asset does not exist in the vault.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if:
211    /// - the asset ID is not tracked by this partial SMT.
212    pub fn get(&self, asset_id: AssetId) -> Result<Option<Asset>, MerkleError> {
213        let value = self.partial_smt.get_value(&asset_id.hash().as_word())?;
214        if value.is_empty() {
215            Ok(None)
216        } else {
217            Ok(Some(
218                Asset::new(asset_id, value).expect("partial vault should only track valid assets"),
219            ))
220        }
221    }
222
223    // MUTATORS
224    // --------------------------------------------------------------------------------------------
225
226    /// Adds an [`AssetWitness`] to this [`PartialVault`].
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if:
231    /// - the new root after the insertion of the leaf and the path does not match the existing root
232    ///   (except when the first leaf is added).
233    pub fn add(&mut self, witness: AssetWitness) -> Result<(), PartialAssetVaultError> {
234        // Take ownership of the witness' entries up front so that, if `add_proof` fails, no
235        // partial state escapes into `self.entries`. The type-level guarantee (entries are a
236        // subset of partial_smt) must hold even after an error.
237        let (proof, new_entries) = witness.into_parts();
238        self.partial_smt
239            .add_proof(proof)
240            .map_err(PartialAssetVaultError::FailedToAddProof)?;
241        // Skip empty values so `entries` only ever tracks valid assets (mirrors `AssetVault::new`).
242        self.entries
243            .extend(new_entries.into_iter().filter(|(_, value)| !value.is_empty()));
244        Ok(())
245    }
246}
247
248impl Serializable for PartialVault {
249    fn write_into<W: ByteWriter>(&self, target: &mut W) {
250        target.write(&self.partial_smt);
251        target.write_usize(self.entries.len());
252        target.write_many(self.entries.keys());
253    }
254}
255
256impl Deserializable for PartialVault {
257    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
258        let partial_smt: PartialSmt = source.read()?;
259        let num_entries: usize = source.read()?;
260        let ids = source.read_many_iter::<AssetId>(num_entries)?.collect::<Result<Vec<_>, _>>()?;
261
262        Self::try_from_parts(partial_smt, ids)
263            .map_err(|err| DeserializationError::InvalidValue(err.to_string()))
264    }
265}
266
267// TESTS
268// ================================================================================================
269
270#[cfg(test)]
271mod tests {
272    use alloc::vec::Vec;
273
274    use assert_matches::assert_matches;
275    use miden_crypto::merkle::smt::Smt;
276
277    use super::*;
278    use crate::asset::{FungibleAsset, NonFungibleAsset};
279    use crate::testing::account_id::ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET;
280
281    #[test]
282    fn partial_smt_accessor_returns_vault_smt() {
283        let root = Word::from([1_u32, 2, 3, 4]);
284        let vault = PartialVault::new(root);
285
286        assert_eq!(vault.partial_smt().root(), root);
287    }
288
289    #[test]
290    fn partial_vault_open_returns_correct_asset_after_full_conversion() -> anyhow::Result<()> {
291        let asset = FungibleAsset::mock(500);
292        let vault = AssetVault::new(&[asset])?;
293        let partial = PartialVault::new_full(vault.clone());
294
295        let id = asset.id();
296        let witness = partial.open(id)?;
297
298        assert!(witness.authenticates_asset_id(id));
299        assert_eq!(witness.find(id), Some(asset));
300        assert_eq!(partial.root(), vault.root());
301
302        Ok(())
303    }
304
305    #[test]
306    fn partial_vault_open_fails_for_untracked_id() -> anyhow::Result<()> {
307        let asset = FungibleAsset::mock(500);
308        let vault = AssetVault::new(&[asset])?;
309        // `new_minimal` carries the root but no entries.
310        let partial = PartialVault::new_minimal(&vault);
311
312        let err = partial.open(asset.id()).unwrap_err();
313        assert_matches!(err, PartialAssetVaultError::UntrackedAsset(_));
314
315        Ok(())
316    }
317
318    #[test]
319    fn partial_vault_with_witnesses_round_trips() -> anyhow::Result<()> {
320        let fungible = FungibleAsset::mock(500);
321        let non_fungible = NonFungibleAsset::mock(&[1, 2, 3]);
322        let vault = AssetVault::new(&[fungible, non_fungible])?;
323
324        let witnesses = [vault.open(fungible.id()), vault.open(non_fungible.id())];
325        let partial = PartialVault::with_witnesses(witnesses)?;
326
327        assert_eq!(partial.root(), vault.root());
328        assert_eq!(partial.entries().count(), 2);
329
330        // Round-trip serialization preserves equality.
331        let bytes = partial.to_bytes();
332        let roundtripped = PartialVault::read_from_bytes(&bytes)?;
333        assert_eq!(partial, roundtripped);
334
335        Ok(())
336    }
337
338    #[test]
339    fn partial_vault_with_witnesses_fails_on_root_mismatch() -> anyhow::Result<()> {
340        // Two single-asset vaults rooted at different SMT roots.
341        let asset_a = FungibleAsset::mock(500);
342        let asset_b: Asset =
343            FungibleAsset::new(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?, 100)?.into();
344        let vault_a = AssetVault::new(&[asset_a])?;
345        let vault_b = AssetVault::new(&[asset_b])?;
346        assert_ne!(vault_a.root(), vault_b.root());
347
348        let witness_a = vault_a.open(asset_a.id());
349        let witness_b = vault_b.open(asset_b.id());
350
351        let err = PartialVault::with_witnesses([witness_a, witness_b]).unwrap_err();
352        assert_matches!(err, PartialAssetVaultError::FailedToAddProof(_));
353
354        Ok(())
355    }
356
357    #[test]
358    fn partial_vault_add_extends_with_new_witness() -> anyhow::Result<()> {
359        let fungible = FungibleAsset::mock(500);
360        let non_fungible = NonFungibleAsset::mock(&[7, 8, 9]);
361        let vault = AssetVault::new(&[fungible, non_fungible])?;
362
363        let mut partial = PartialVault::with_witnesses([vault.open(fungible.id())])?;
364        assert_eq!(partial.entries().count(), 1);
365
366        partial.add(vault.open(non_fungible.id()))?;
367
368        assert_eq!(partial.root(), vault.root());
369        assert_eq!(partial.entries().count(), 2);
370        assert_eq!(partial.open(fungible.id())?.find(fungible.id()), Some(fungible));
371        assert_eq!(partial.open(non_fungible.id())?.find(non_fungible.id()), Some(non_fungible),);
372
373        Ok(())
374    }
375
376    #[test]
377    fn partial_vault_add_is_atomic_on_failure() -> anyhow::Result<()> {
378        // Build two distinct vaults so the second witness's root disagrees with the first.
379        let asset_a = FungibleAsset::mock(500);
380        let asset_b: Asset =
381            FungibleAsset::new(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?, 100)?.into();
382        let vault_a = AssetVault::new(&[asset_a])?;
383        let vault_b = AssetVault::new(&[asset_b])?;
384
385        let mut partial = PartialVault::with_witnesses([vault_a.open(asset_a.id())])?;
386        let entries_before: Vec<_> = partial.entries().map(|(k, v)| (*k, *v)).collect();
387        let root_before = partial.root();
388
389        let err = partial.add(vault_b.open(asset_b.id())).unwrap_err();
390        assert_matches!(err, PartialAssetVaultError::FailedToAddProof(_));
391
392        // Atomicity: failed `add` must not leak entries or shift the root.
393        let entries_after: Vec<_> = partial.entries().map(|(k, v)| (*k, *v)).collect();
394        assert_eq!(entries_before, entries_after);
395        assert_eq!(partial.root(), root_before);
396
397        Ok(())
398    }
399
400    #[test]
401    fn try_from_parts_rejects_inconsistent_asset() -> anyhow::Result<()> {
402        let fungible = FungibleAsset::mock(500);
403        let non_fungible = NonFungibleAsset::mock(&[4, 5, 6]);
404
405        // Build an SMT that stores a non-fungible value under a fungible ID's hashed slot, then
406        // wrap it in a partial SMT covering that ID.
407        let fungible_id = fungible.id();
408        let inconsistent_smt =
409            Smt::with_entries([(fungible_id.hash().as_word(), non_fungible.to_value_word())])?;
410        let proof = inconsistent_smt.open(&fungible_id.hash().as_word());
411        let partial_smt = PartialSmt::from_proofs([proof])?;
412
413        let err = PartialVault::try_from_parts(partial_smt, [fungible_id]).unwrap_err();
414        assert_matches!(err, PartialAssetVaultError::InvalidAssetForId { .. });
415
416        Ok(())
417    }
418
419    #[test]
420    fn try_from_parts_preserves_unrelated_partial_smt_material() -> anyhow::Result<()> {
421        let tracked_asset = FungibleAsset::mock(500);
422        let extra_asset = NonFungibleAsset::mock(&[1, 2, 3]);
423        let vault = AssetVault::new(&[tracked_asset, extra_asset])?;
424        let partial_smt = PartialSmt::from_proofs([
425            vault.open(tracked_asset.id()).into(),
426            vault.open(extra_asset.id()).into(),
427        ])?;
428
429        let partial_vault = PartialVault::try_from_parts(partial_smt, [tracked_asset.id()])?;
430
431        assert_eq!(partial_vault.asset_ids().collect::<Vec<_>>(), [tracked_asset.id()]);
432        assert_eq!(partial_vault.get(extra_asset.id())?, Some(extra_asset));
433
434        Ok(())
435    }
436
437    #[test]
438    fn try_from_parts_rejects_duplicate_asset_ids() -> anyhow::Result<()> {
439        let asset = FungibleAsset::mock(500);
440        let vault = AssetVault::new(&[asset])?;
441        let partial_smt = PartialSmt::from_proofs([vault.open(asset.id()).into()])?;
442
443        let result = PartialVault::try_from_parts(partial_smt, [asset.id(), asset.id()]);
444
445        assert_matches!(result, Err(PartialAssetVaultError::DuplicateAssetId(id)) if id == asset.id());
446
447        Ok(())
448    }
449
450    #[test]
451    fn try_from_parts_rejects_untracked_asset_ids() {
452        let asset_id = FungibleAsset::mock(500).id();
453        let result = PartialVault::try_from_parts(PartialSmt::new(Word::empty()), [asset_id]);
454
455        assert_matches!(
456            result,
457            Err(PartialAssetVaultError::UntrackedAsset(MerkleError::UntrackedKey(hashed_id)))
458                if hashed_id == asset_id.hash().as_word()
459        );
460    }
461}