Skip to main content

miden_protocol/asset/
nonfungible.rs

1use alloc::vec::Vec;
2use core::fmt;
3
4use super::vault::AssetId;
5use super::{Asset, AssetComposition, AssetError, Word};
6use crate::Hasher;
7use crate::account::AccountId;
8use crate::asset::vault::AssetClass;
9use crate::utils::serde::{
10    ByteReader,
11    ByteWriter,
12    Deserializable,
13    DeserializationError,
14    Serializable,
15};
16
17// NON-FUNGIBLE ASSET
18// ================================================================================================
19
20/// A commitment to a non-fungible asset.
21///
22/// This is the standard interpretation of an [`Asset`] with [`AssetComposition::None`]: its value
23/// is the hash of the asset's data, compressing an asset of arbitrary length to 4 field elements,
24/// and its asset class is the first two elements of that hash. The protocol itself does not enforce
25/// this interpretation, see [`Asset`].
26///
27/// The collision resistance of non-fungible assets issued by the same faucet is ~2^64, due to the
28/// 128-bit asset class that is unique per non-fungible asset. In other words, two non-fungible
29/// assets issued by the same faucet are very unlikely to have the same asset ID and thus should
30/// not collide when stored in the same account's vault.
31///
32/// [`NonFungibleAsset`] itself does not contain the actual asset data. The container for this data
33/// is [`NonFungibleAssetDetails`].
34///
35/// Whether the asset triggers callbacks to the faucet is an immutable property of the faucet's
36/// [`AccountId`], see [`AccountId::asset_callback_flag`].
37#[derive(Debug, Copy, Clone, PartialEq, Eq)]
38pub struct NonFungibleAsset {
39    faucet_id: AccountId,
40    value: Word,
41}
42
43impl NonFungibleAsset {
44    // CONSTANTS
45    // --------------------------------------------------------------------------------------------
46
47    /// The serialized size of a [`NonFungibleAsset`] in bytes.
48    ///
49    /// A composition byte (u8) plus an account ID (15 bytes) plus a word (32 bytes).
50    pub const SERIALIZED_SIZE: usize =
51        AssetComposition::SERIALIZED_SIZE + AccountId::SERIALIZED_SIZE + Word::SERIALIZED_SIZE;
52
53    // CONSTRUCTORS
54    // --------------------------------------------------------------------------------------------
55
56    /// Returns a non-fungible asset created from the specified asset details.
57    pub fn new(details: &NonFungibleAssetDetails) -> Self {
58        let data_hash = Hasher::hash(details.asset_data());
59        Self::from_parts(details.faucet_id(), data_hash)
60    }
61
62    /// Return a non-fungible asset created from the specified faucet and using the provided
63    /// hash of the asset's data.
64    ///
65    /// Hash of the asset's data is expected to be computed from the binary representation of the
66    /// asset's data.
67    pub fn from_parts(faucet_id: AccountId, value: Word) -> Self {
68        Self { faucet_id, value }
69    }
70
71    /// Creates a non-fungible asset from the provided ID and value.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if:
76    /// - The provided ID does not have [`AssetComposition::None`] set.
77    /// - The provided ID's asset class limbs are not equal to the provided value's first and second
78    ///   element.
79    /// - The faucet ID is not a non-fungible faucet ID.
80    pub fn from_id_and_value(id: AssetId, value: Word) -> Result<Self, AssetError> {
81        if !id.composition().is_none() {
82            return Err(AssetError::AssetCompositionMismatch {
83                faucet_id: id.faucet_id(),
84                expected: AssetComposition::None,
85                actual: id.composition(),
86            });
87        }
88
89        if id.asset_class().suffix() != value[0] || id.asset_class().prefix() != value[1] {
90            return Err(AssetError::NonFungibleAssetClassMustMatchValue {
91                asset_class: id.asset_class(),
92                value,
93            });
94        }
95
96        Ok(Self::from_parts(id.faucet_id(), value))
97    }
98
99    /// Creates a non-fungible asset from the provided ID and value.
100    ///
101    /// Prefer [`Self::from_id_and_value`] for more type safety.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if:
106    /// - [`Self::from_id_and_value`] fails.
107    pub fn from_id_and_value_words(id: Word, value: Word) -> Result<Self, AssetError> {
108        let asset_id = AssetId::try_from(id)?;
109        Self::from_id_and_value(asset_id, value)
110    }
111
112    // ACCESSORS
113    // --------------------------------------------------------------------------------------------
114
115    /// Returns the [`AssetId`] which uniquely identifies this [`NonFungibleAsset`].
116    ///
117    /// See [`Asset`] docs for details on the asset ID.
118    pub fn id(&self) -> AssetId {
119        let asset_class_suffix = self.value[0];
120        let asset_class_prefix = self.value[1];
121        let asset_class = AssetClass::new(asset_class_suffix, asset_class_prefix);
122
123        AssetId::new(asset_class, self.faucet_id, AssetComposition::None)
124            .expect("non-fungible composition is always valid")
125    }
126
127    /// Returns the ID of the faucet which issued this asset.
128    pub fn faucet_id(&self) -> AccountId {
129        self.faucet_id
130    }
131
132    /// Returns the asset's [`AssetId`] encoded to a [`Word`].
133    pub fn to_id_word(&self) -> Word {
134        self.id().to_word()
135    }
136
137    /// Returns the asset's value encoded to a [`Word`].
138    pub fn to_value_word(&self) -> Word {
139        self.value
140    }
141}
142
143impl fmt::Display for NonFungibleAsset {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        // TODO: Replace with hex representation?
146        write!(f, "{self:?}")
147    }
148}
149
150impl From<NonFungibleAsset> for Asset {
151    fn from(asset: NonFungibleAsset) -> Self {
152        Asset::new(asset.id(), asset.to_value_word())
153            .expect("non-fungible asset should be a valid asset")
154    }
155}
156
157// SERIALIZATION
158// ================================================================================================
159
160impl Serializable for NonFungibleAsset {
161    fn write_into<W: ByteWriter>(&self, target: &mut W) {
162        // Lead with the asset composition byte to distinguish asset types on the wire.
163        target.write(AssetComposition::None);
164        target.write(self.faucet_id());
165        target.write(self.value);
166    }
167
168    fn get_size_hint(&self) -> usize {
169        AssetComposition::SERIALIZED_SIZE
170            + self.faucet_id.get_size_hint()
171            + self.value.get_size_hint()
172    }
173}
174
175impl Deserializable for NonFungibleAsset {
176    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
177        let composition: AssetComposition = source.read()?;
178        if !composition.is_none() {
179            return Err(DeserializationError::InvalidValue(format!(
180                "expected non-fungible asset composition but found {composition:?}"
181            )));
182        }
183
184        let faucet_id: AccountId = source.read()?;
185        let value: Word = source.read()?;
186
187        Ok(NonFungibleAsset::from_parts(faucet_id, value))
188    }
189}
190
191// NON-FUNGIBLE ASSET DETAILS
192// ================================================================================================
193
194/// Details about a non-fungible asset.
195///
196/// Unlike [NonFungibleAsset] struct, this struct contains full details of a non-fungible asset.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct NonFungibleAssetDetails {
199    faucet_id: AccountId,
200    asset_data: Vec<u8>,
201}
202
203impl NonFungibleAssetDetails {
204    /// Returns asset details instantiated from the specified faucet ID and asset data.
205    pub fn new(faucet_id: AccountId, asset_data: Vec<u8>) -> Self {
206        Self { faucet_id, asset_data }
207    }
208
209    /// Returns ID of the faucet which issued this asset.
210    pub fn faucet_id(&self) -> AccountId {
211        self.faucet_id
212    }
213
214    /// Returns asset data in binary format.
215    pub fn asset_data(&self) -> &[u8] {
216        &self.asset_data
217    }
218}
219
220// TESTS
221// ================================================================================================
222
223#[cfg(test)]
224mod tests {
225    use assert_matches::assert_matches;
226
227    use super::*;
228    use crate::Felt;
229    use crate::account::AccountId;
230    use crate::asset::FungibleAsset;
231    use crate::testing::account_id::{
232        ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET,
233        ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET,
234        ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET_1,
235    };
236
237    #[test]
238    fn non_fungible_asset_from_id_and_value_words_fails_on_invalid_composition()
239    -> anyhow::Result<()> {
240        // Use a fungible asset's ID-value words where the the composition is set to `Fungible`.
241        let asset = FungibleAsset::mock(20);
242
243        let err =
244            NonFungibleAsset::from_id_and_value_words(asset.to_id_word(), asset.to_value_word())
245                .unwrap_err();
246        assert_matches!(err, AssetError::AssetCompositionMismatch {
247                faucet_id: _, expected, actual,
248            } => {
249                assert_eq!(actual, AssetComposition::Fungible);
250                assert_eq!(expected, AssetComposition::None);
251        });
252
253        Ok(())
254    }
255
256    #[test]
257    fn non_fungible_asset_from_id_and_value_fails_on_invalid_asset_class() -> anyhow::Result<()> {
258        let invalid_id = AssetId::new(
259            AssetClass::new(Felt::from(1u32), Felt::from(2u32)),
260            ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET.try_into()?,
261            AssetComposition::None,
262        )?;
263        let err = NonFungibleAsset::from_id_and_value(invalid_id, Word::from([4, 5, 6, 7u32]))
264            .unwrap_err();
265
266        assert_matches!(err, AssetError::NonFungibleAssetClassMustMatchValue { .. });
267
268        Ok(())
269    }
270
271    #[test]
272    fn test_non_fungible_asset_serde() -> anyhow::Result<()> {
273        for non_fungible_account_id in [
274            ACCOUNT_ID_PRIVATE_NON_FUNGIBLE_FAUCET,
275            ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET,
276            ACCOUNT_ID_PUBLIC_NON_FUNGIBLE_FAUCET_1,
277        ] {
278            let account_id = AccountId::try_from(non_fungible_account_id).unwrap();
279            let details = NonFungibleAssetDetails::new(account_id, vec![1, 2, 3]);
280            let non_fungible_asset = NonFungibleAsset::new(&details);
281            assert_eq!(
282                non_fungible_asset,
283                NonFungibleAsset::read_from_bytes(&non_fungible_asset.to_bytes()).unwrap()
284            );
285            assert_eq!(non_fungible_asset.to_bytes().len(), non_fungible_asset.get_size_hint());
286
287            assert_eq!(
288                non_fungible_asset,
289                NonFungibleAsset::from_id_and_value_words(
290                    non_fungible_asset.to_id_word(),
291                    non_fungible_asset.to_value_word()
292                )?
293            )
294        }
295
296        let fungible_asset = FungibleAsset::mock(42);
297        let err = NonFungibleAsset::read_from_bytes(&fungible_asset.to_bytes()).unwrap_err();
298        assert_matches!(err, DeserializationError::InvalidValue(msg) => {
299            assert!(msg.contains("expected non-fungible asset composition but found Fungible"));
300        });
301
302        Ok(())
303    }
304
305    #[test]
306    fn test_asset_id_for_non_fungible_asset() {
307        let asset = NonFungibleAsset::mock(&[42]);
308
309        assert_eq!(asset.id().faucet_id(), NonFungibleAsset::mock_issuer());
310        assert_eq!(asset.id().asset_class().suffix(), asset.to_value_word()[0]);
311        assert_eq!(asset.id().asset_class().prefix(), asset.to_value_word()[1]);
312    }
313}