Skip to main content

miden_protocol/asset/vault/
asset_id.rs

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