Skip to main content

miden_protocol/asset/vault/
asset_id.rs

1use alloc::boxed::Box;
2use alloc::string::ToString;
3use core::fmt;
4
5use miden_crypto::merkle::smt::LeafIndex;
6use miden_crypto_derive::WordWrapper;
7
8use crate::account::{AccountId, AssetCallbackFlag};
9use crate::asset::vault::AssetClass;
10use crate::asset::{Asset, AssetComposition, FungibleAsset, NonFungibleAsset};
11use crate::crypto::merkle::smt::SMT_DEPTH;
12use crate::errors::AssetError;
13use crate::utils::serde::{
14    ByteReader,
15    ByteWriter,
16    Deserializable,
17    DeserializationError,
18    Serializable,
19};
20use crate::{Felt, Hasher, Word};
21
22type AssetIdVersion = u8;
23
24/// The unique identifier of an [`Asset`] in the [`AssetVault`](crate::asset::AssetVault).
25///
26/// Its [`Word`] layout is:
27/// ```text
28/// [
29///   asset_class_suffix (64 bits),
30///   asset_class_prefix (64 bits),
31///   [faucet_id_suffix (56 bits) | reserved (2 bits) | composition (2 bits) | version (4 bits)],
32///   faucet_id_prefix (64 bits)
33/// ]
34/// ```
35///
36/// The version determines how the remainder of the asset is decoded and so it is placed at a
37/// static offset so it can be read first independent of the version. Version 0 is invalid, which
38/// guarantees that an empty word is not a valid asset ID.
39///
40/// Use [`AssetId::hash`] to produce the corresponding [`AssetIdHash`] that is used as
41/// the key in the asset vault's underlying SMT. Hashing ensures a uniform distribution across
42/// leaves regardless of how faucet IDs or asset classes are chosen.
43#[derive(Debug, PartialEq, Eq, Clone, Copy)]
44pub struct AssetId {
45    /// The asset class of the asset ID.
46    asset_class: AssetClass,
47
48    /// The ID of the faucet that issued the asset.
49    faucet_id: AccountId,
50
51    /// The composition of the asset.
52    composition: AssetComposition,
53}
54
55impl AssetId {
56    /// The serialized size of an [`AssetId`] with [`AssetComposition::Fungible`] in bytes.
57    ///
58    /// The asset class of a fungible asset is always empty and so it is not serialized.
59    const FUNGIBLE_SERIALIZED_SIZE: usize = core::mem::size_of::<AssetIdVersion>()
60        + AssetComposition::SERIALIZED_SIZE
61        + AccountId::SERIALIZED_SIZE;
62
63    /// The serialized size of an [`AssetId`] with any other [`AssetComposition`] in bytes.
64    const NON_FUNGIBLE_SERIALIZED_SIZE: usize =
65        Self::FUNGIBLE_SERIALIZED_SIZE + AssetClass::SERIALIZED_SIZE;
66
67    // BIT LAYOUT CONSTANTS
68    // --------------------------------------------------------------------------------------------
69
70    /// The metadata byte occupies the lower 8 bits of the third element of the asset ID word.
71    pub(in crate::asset) const METADATA_BYTE_MASK: u8 = 0xff;
72
73    /// Version 1 of the asset ID encoding.
74    pub(in crate::asset) const VERSION_1: u8 = 1;
75
76    /// Bits 0-3 of the metadata byte encode the version.
77    pub(in crate::asset) const VERSION_MASK: u8 = 0b1111;
78
79    /// Bits 4-5 of the metadata byte encode the [`AssetComposition`].
80    pub(in crate::asset) const COMPOSITION_SHIFT: u8 = 4;
81
82    /// Bits 6-7 of the metadata byte are reserved and must be zero.
83    pub(in crate::asset) const METADATA_RESERVED_MASK: u8 = 0b1100_0000;
84
85    // CONSTRUCTORS
86    // --------------------------------------------------------------------------------------------
87
88    /// Creates an [`AssetId`] from its parts with the given [`AssetComposition`].
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if:
93    /// - the asset class limbs are not zero when `composition` is [`AssetComposition::Fungible`].
94    /// - the composition is [`AssetComposition::Custom`], which is disallowed until its support is
95    ///   enabled in the tx kernel.
96    pub fn new(
97        asset_class: AssetClass,
98        faucet_id: AccountId,
99        composition: AssetComposition,
100    ) -> Result<Self, AssetError> {
101        // For now, reject custom composition.
102        if composition.is_custom() {
103            return Err(AssetError::UnsupportedAssetComposition(AssetComposition::Custom));
104        }
105
106        if composition.is_fungible() && !asset_class.is_empty() {
107            return Err(AssetError::FungibleAssetClassMustBeZero(asset_class));
108        }
109
110        Ok(Self { asset_class, faucet_id, composition })
111    }
112
113    /// Constructs a fungible asset's ID from a faucet ID.
114    pub fn new_fungible(faucet_id: AccountId) -> Self {
115        Self::new(AssetClass::default(), faucet_id, AssetComposition::Fungible).expect(
116            "passing AssetComposition::Fungible together with AssetClass::default should be valid",
117        )
118    }
119
120    // PUBLIC ACCESSORS
121    // --------------------------------------------------------------------------------------------
122
123    /// Returns the word representation of the asset ID.
124    ///
125    /// See the type-level documentation for details.
126    pub fn to_word(&self) -> Word {
127        let faucet_suffix = self.faucet_id.suffix().as_canonical_u64();
128        // The lower 8 bits of the faucet suffix are guaranteed to be zero and so it is used to
129        // encode the asset metadata.
130        debug_assert!(
131            faucet_suffix & Self::METADATA_BYTE_MASK as u64 == 0,
132            "lower 8 bits of faucet suffix must be zero",
133        );
134        let metadata_byte = Self::encode_metadata(self.composition);
135        let faucet_id_suffix_and_metadata = faucet_suffix | metadata_byte as u64;
136        let faucet_id_suffix_and_metadata = Felt::try_from(faucet_id_suffix_and_metadata)
137            .expect("highest bit should still be zero resulting in a valid felt");
138
139        Word::new([
140            self.asset_class.suffix(),
141            self.asset_class.prefix(),
142            faucet_id_suffix_and_metadata,
143            self.faucet_id.prefix().as_felt(),
144        ])
145    }
146
147    /// Returns the [`AssetClass`] of the asset ID that distinguishes different assets issued by
148    /// the same faucet.
149    pub fn asset_class(&self) -> AssetClass {
150        self.asset_class
151    }
152
153    /// Returns the [`AccountId`] of the faucet that issued the asset.
154    pub fn faucet_id(&self) -> AccountId {
155        self.faucet_id
156    }
157
158    /// Returns the [`AssetCallbackFlag`] of the faucet that issued the asset.
159    pub fn callback_flag(&self) -> AssetCallbackFlag {
160        self.faucet_id.asset_callback_flag()
161    }
162
163    /// Returns the [`AssetComposition`] of the asset ID.
164    pub fn composition(&self) -> AssetComposition {
165        self.composition
166    }
167
168    /// Hashes this raw asset ID to produce the [`AssetIdHash`] used as the key in the asset
169    /// vault's underlying SMT.
170    pub fn hash(&self) -> AssetIdHash {
171        AssetIdHash::from_raw(Hasher::hash_elements(self.to_word().as_elements()))
172    }
173
174    // HELPERS
175    // --------------------------------------------------------------------------------------------
176
177    /// Encodes the given composition into a metadata byte of the current version.
178    pub(in crate::asset) fn encode_metadata(composition: AssetComposition) -> u8 {
179        (composition.as_u8() << Self::COMPOSITION_SHIFT) | Self::VERSION_1
180    }
181}
182
183// ASSET ID HASH
184// ================================================================================================
185
186/// A hashed [`AssetId`].
187///
188/// This is produced by hashing an [`AssetId`] and is used as the actual key in the
189/// underlying SMT.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, WordWrapper)]
191pub struct AssetIdHash(Word);
192
193impl AssetIdHash {
194    /// Returns the leaf index in the SMT for this hashed key.
195    pub fn to_leaf_index(&self) -> LeafIndex<SMT_DEPTH> {
196        self.0.into()
197    }
198}
199
200impl From<AssetIdHash> for Word {
201    fn from(id_hash: AssetIdHash) -> Self {
202        id_hash.0
203    }
204}
205
206impl From<AssetId> for AssetIdHash {
207    fn from(id: AssetId) -> Self {
208        id.hash()
209    }
210}
211
212// CONVERSIONS
213// ================================================================================================
214
215impl From<AssetId> for Word {
216    fn from(asset_id: AssetId) -> Self {
217        asset_id.to_word()
218    }
219}
220
221impl Ord for AssetId {
222    /// Implements comparison based on the [`Word`] representation.
223    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
224        self.to_word().cmp(&other.to_word())
225    }
226}
227
228impl PartialOrd for AssetId {
229    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
230        Some(self.cmp(other))
231    }
232}
233
234impl TryFrom<Word> for AssetId {
235    type Error = AssetError;
236
237    /// Attempts to convert the provided [`Word`] into an [`AssetId`].
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if:
242    /// - the version encoded in the metadata byte is unknown.
243    /// - the metadata byte has reserved bits set.
244    /// - the composition encoded in the metadata byte is invalid.
245    /// - the asset class limbs are not zero when asset composition is
246    ///   [`AssetComposition::Fungible`].
247    fn try_from(id: Word) -> Result<Self, Self::Error> {
248        let asset_class_suffix = id[0];
249        let asset_class_prefix = id[1];
250        let faucet_id_suffix_and_metadata = id[2];
251        let faucet_id_prefix = id[3];
252
253        let raw = faucet_id_suffix_and_metadata.as_canonical_u64();
254        let metadata_byte = (raw & Self::METADATA_BYTE_MASK as u64) as u8;
255
256        // The version defines how the rest of the metadata is decoded, so check it first.
257        let version = metadata_byte & Self::VERSION_MASK;
258        if version != Self::VERSION_1 {
259            return Err(AssetError::UnknownAssetIdVersion(version));
260        }
261
262        // Make sure the reserved bits of the metadata are zero.
263        if metadata_byte & Self::METADATA_RESERVED_MASK != 0 {
264            return Err(AssetError::ReservedAssetMetadata(metadata_byte));
265        }
266
267        let composition = AssetComposition::try_from(metadata_byte >> Self::COMPOSITION_SHIFT)?;
268
269        let faucet_id_suffix = Felt::try_from(raw & !(Self::METADATA_BYTE_MASK as u64))
270            .expect("clearing lower bits should not produce an invalid felt");
271
272        let asset_class = AssetClass::new(asset_class_suffix, asset_class_prefix);
273        let faucet_id = AccountId::try_from_elements(faucet_id_suffix, faucet_id_prefix)
274            .map_err(|err| AssetError::InvalidFaucetAccountId(Box::new(err)))?;
275
276        Self::new(asset_class, faucet_id, composition)
277    }
278}
279
280impl fmt::Display for AssetId {
281    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282        f.write_str(&self.to_word().to_hex())
283    }
284}
285
286impl From<Asset> for AssetId {
287    fn from(asset: Asset) -> Self {
288        asset.id()
289    }
290}
291
292impl From<FungibleAsset> for AssetId {
293    fn from(fungible_asset: FungibleAsset) -> Self {
294        fungible_asset.id()
295    }
296}
297
298impl From<NonFungibleAsset> for AssetId {
299    fn from(non_fungible_asset: NonFungibleAsset) -> Self {
300        non_fungible_asset.id()
301    }
302}
303
304// SERIALIZATION
305// ================================================================================================
306
307impl Serializable for AssetId {
308    /// Serializes the ID from its parts rather than from its [`Word`] representation. Because the
309    /// asset class of a fungible asset is always empty, it is not written, saving
310    /// [`AssetClass::SERIALIZED_SIZE`] bytes per fungible ID.
311    fn write_into<W: ByteWriter>(&self, target: &mut W) {
312        target.write(AssetId::VERSION_1);
313        target.write(self.composition);
314        target.write(self.faucet_id);
315
316        if !self.composition.is_fungible() {
317            target.write(self.asset_class);
318        }
319    }
320
321    fn get_size_hint(&self) -> usize {
322        if self.composition.is_fungible() {
323            Self::FUNGIBLE_SERIALIZED_SIZE
324        } else {
325            Self::NON_FUNGIBLE_SERIALIZED_SIZE
326        }
327    }
328}
329
330impl Deserializable for AssetId {
331    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
332        let version: u8 = source.read()?;
333
334        if version != Self::VERSION_1 {
335            return Err(DeserializationError::InvalidValue(format!(
336                "asset version is {} but only version {} is supported",
337                version,
338                Self::VERSION_1,
339            )));
340        }
341
342        let composition: AssetComposition = source.read()?;
343        let faucet_id: AccountId = source.read()?;
344        let asset_class = if composition.is_fungible() {
345            AssetClass::default()
346        } else {
347            source.read()?
348        };
349
350        Self::new(asset_class, faucet_id, composition)
351            .map_err(|err| DeserializationError::InvalidValue(err.to_string()))
352    }
353}
354
355// TESTS
356// ================================================================================================
357
358#[cfg(test)]
359mod tests {
360    use assert_matches::assert_matches;
361
362    use super::*;
363    use crate::asset::AssetComposition;
364    use crate::asset::tests::{asset_metadata, set_asset_metadata};
365    use crate::testing::account_id::{
366        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
367        ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET,
368    };
369
370    #[test]
371    fn asset_id_word_roundtrip() -> anyhow::Result<()> {
372        let fungible_faucet = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?;
373        let nonfungible_faucet = AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET)?;
374
375        // Fungible: asset_class must be zero.
376        let id = AssetId::new(AssetClass::default(), fungible_faucet, AssetComposition::Fungible)?;
377        assert_eq!(id.composition(), AssetComposition::Fungible);
378        let roundtripped = AssetId::try_from(id.to_word())?;
379        assert_eq!(id, roundtripped);
380        assert_eq!(id, AssetId::read_from_bytes(&id.to_bytes())?);
381        assert_eq!(id.to_bytes().len(), AssetId::FUNGIBLE_SERIALIZED_SIZE);
382        assert_eq!(id.to_bytes().len(), id.get_size_hint());
383
384        // Non-fungible: asset_class can be non-zero.
385        let id = AssetId::new(
386            AssetClass::new(Felt::from(42u32), Felt::from(99u32)),
387            nonfungible_faucet,
388            AssetComposition::None,
389        )?;
390        assert_eq!(id.composition(), AssetComposition::None);
391        let roundtripped = AssetId::try_from(id.to_word())?;
392        assert_eq!(id, roundtripped);
393        assert_eq!(id, AssetId::read_from_bytes(&id.to_bytes())?);
394        assert_eq!(id.to_bytes().len(), AssetId::NON_FUNGIBLE_SERIALIZED_SIZE);
395        assert_eq!(id.to_bytes().len(), id.get_size_hint());
396
397        Ok(())
398    }
399
400    /// Version 0 is never valid, so the all-zero word cannot decode into an asset ID.
401    #[rstest::rstest]
402    #[case::version_zero(0, AssetError::UnknownAssetIdVersion(0))]
403    #[case::unknown_version(AssetId::VERSION_1 + 1, AssetError::UnknownAssetIdVersion(2))]
404    #[case::reserved_bits_set(
405        AssetId::encode_metadata(AssetComposition::Fungible) | AssetId::METADATA_RESERVED_MASK,
406        AssetError::ReservedAssetMetadata(0b1101_0001)
407    )]
408    // Composition value 3 is the unused bit pattern within the 2-bit field.
409    #[case::unknown_composition(
410        0b0011_0000 | AssetId::VERSION_1,
411        AssetError::UnknownAssetComposition(0b11)
412    )]
413    fn decoding_word_with_invalid_metadata_fails(
414        #[case] metadata: u8,
415        #[case] expected_err: AssetError,
416    ) -> anyhow::Result<()> {
417        let word = set_asset_metadata(FungibleAsset::mock(42).id(), metadata);
418
419        let err = AssetId::try_from(word).unwrap_err();
420        assert_eq!(err.to_string(), expected_err.to_string());
421
422        Ok(())
423    }
424
425    #[test]
426    fn metadata_encodes_version_and_composition() -> anyhow::Result<()> {
427        let fungible =
428            AssetId::new_fungible(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?);
429        assert_eq!(asset_metadata(fungible), 0b0001_0001);
430
431        let non_fungible = AssetId::new(
432            AssetClass::new(Felt::from(42u32), Felt::from(99u32)),
433            AccountId::try_from(ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET)?,
434            AssetComposition::None,
435        )?;
436        assert_eq!(asset_metadata(non_fungible), 0b0000_0001);
437
438        Ok(())
439    }
440
441    #[test]
442    fn asset_id_deserialization_rejects_unsupported_version() {
443        let error = AssetId::read_from_bytes(&[0]).unwrap_err();
444
445        assert_matches!(error, DeserializationError::InvalidValue(message) => {
446            assert!(message.contains("asset version is 0"));
447        });
448    }
449}