Skip to main content

miden_protocol/asset/
fungible.rs

1use alloc::string::ToString;
2use core::fmt;
3
4use super::vault::AssetId;
5use super::{Asset, AssetAmount, AssetComposition, AssetError, Word};
6use crate::Felt;
7use crate::account::{AccountId, AssetCallbackFlag};
8use crate::asset::AssetClass;
9use crate::utils::serde::{
10    ByteReader,
11    ByteWriter,
12    Deserializable,
13    DeserializationError,
14    Serializable,
15};
16
17// FUNGIBLE ASSET
18// ================================================================================================
19/// A fungible asset.
20///
21/// A fungible asset consists of a faucet ID of the faucet which issued the asset as well as the
22/// asset amount. Asset amount is guaranteed to be 2^63 - 1 or smaller.
23///
24/// Whether the asset triggers callbacks to the faucet is an immutable property of the faucet's
25/// [`AccountId`], see [`AccountId::asset_callback_flag`].
26#[derive(Debug, Copy, Clone, PartialEq, Eq)]
27pub struct FungibleAsset {
28    faucet_id: AccountId,
29    amount: AssetAmount,
30}
31
32impl FungibleAsset {
33    // CONSTANTS
34    // --------------------------------------------------------------------------------------------
35    /// Specifies the maximum amount a fungible asset can represent.
36    ///
37    /// This number was chosen so that it can be represented as a positive and negative number in a
38    /// field element. See `account_update.masm` for more details on how this number was chosen.
39    pub const MAX_AMOUNT: AssetAmount = AssetAmount::MAX;
40
41    /// The serialized size of a [`FungibleAsset`] in bytes.
42    ///
43    /// A composition byte (u8) plus an account ID (15 bytes) plus an amount (u64).
44    pub const SERIALIZED_SIZE: usize = AssetComposition::SERIALIZED_SIZE
45        + AccountId::SERIALIZED_SIZE
46        + core::mem::size_of::<u64>();
47
48    // CONSTRUCTOR
49    // --------------------------------------------------------------------------------------------
50
51    /// Returns a fungible asset instantiated with the provided faucet ID and amount.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if:
56    /// - The provided amount is greater than [`FungibleAsset::MAX_AMOUNT`].
57    pub fn new(faucet_id: AccountId, amount: u64) -> Result<Self, AssetError> {
58        // TODO: Take AssetAmount as input, then make the function infallible.
59        let amount = AssetAmount::new(amount)?;
60
61        Ok(Self { faucet_id, amount })
62    }
63
64    /// Creates a fungible asset from the provided ID and value.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if:
69    /// - The provided ID does not contain a valid faucet ID.
70    /// - The provided ID does not have [`AssetComposition::Fungible`] set.
71    /// - The provided ID's asset class limbs are not zero.
72    /// - The provided value's amount is greater than [`FungibleAsset::MAX_AMOUNT`] or its three
73    ///   most significant elements are not zero.
74    pub fn from_id_and_value(id: AssetId, value: Word) -> Result<Self, AssetError> {
75        if !id.composition().is_fungible() {
76            return Err(AssetError::AssetCompositionMismatch {
77                faucet_id: id.faucet_id(),
78                expected: AssetComposition::Fungible,
79                actual: id.composition(),
80            });
81        }
82
83        if !id.asset_class().is_empty() {
84            return Err(AssetError::FungibleAssetClassMustBeZero(id.asset_class()));
85        }
86
87        if value[1] != Felt::ZERO || value[2] != Felt::ZERO || value[3] != Felt::ZERO {
88            return Err(AssetError::FungibleAssetValueMostSignificantElementsMustBeZero(value));
89        }
90
91        Self::new(id.faucet_id(), value[0].as_canonical_u64())
92    }
93
94    /// Creates a fungible asset from the provided ID and value.
95    ///
96    /// Prefer [`Self::from_id_and_value`] for more type safety.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if:
101    /// - [`Self::from_id_and_value`] fails.
102    pub fn from_id_and_value_words(id: Word, value: Word) -> Result<Self, AssetError> {
103        let asset_id = AssetId::try_from(id)?;
104        Self::from_id_and_value(asset_id, value)
105    }
106
107    // PUBLIC ACCESSORS
108    // --------------------------------------------------------------------------------------------
109
110    /// Return ID of the faucet which issued this asset.
111    pub fn faucet_id(&self) -> AccountId {
112        self.faucet_id
113    }
114
115    /// Returns the [`AssetCallbackFlag`] of the faucet which issued this asset.
116    pub fn callbacks(&self) -> AssetCallbackFlag {
117        self.faucet_id.asset_callback_flag()
118    }
119
120    /// Returns the amount of this asset.
121    pub fn amount(&self) -> AssetAmount {
122        self.amount
123    }
124
125    /// Returns true if this and the other asset were issued from the same faucet.
126    pub fn is_same(&self, other: &Self) -> bool {
127        self.id() == other.id()
128    }
129
130    /// Returns the [`AssetId`] which uniquely identifies this asset in the account vault.
131    pub fn id(&self) -> AssetId {
132        AssetId::new(AssetClass::default(), self.faucet_id, AssetComposition::Fungible)
133            .expect("default asset class should be valid for fungible composition")
134    }
135
136    /// Returns the asset's [`AssetId`] encoded to a [`Word`].
137    pub fn to_id_word(&self) -> Word {
138        self.id().to_word()
139    }
140
141    /// Returns the asset's value encoded to a [`Word`].
142    pub fn to_value_word(&self) -> Word {
143        Word::new([Felt::from(self.amount), Felt::ZERO, Felt::ZERO, Felt::ZERO])
144    }
145
146    // OPERATIONS
147    // --------------------------------------------------------------------------------------------
148
149    /// Adds two fungible assets together and returns the result.
150    ///
151    /// # Errors
152    /// Returns an error if:
153    /// - The assets do not have the same asset ID (i.e. different faucet).
154    /// - The total value of assets is greater than or equal to 2^63.
155    #[allow(clippy::should_implement_trait)]
156    pub fn add(self, other: Self) -> Result<Self, AssetError> {
157        if !self.is_same(&other) {
158            return Err(AssetError::FungibleAssetInconsistentIds {
159                original_id: self.id(),
160                other_id: other.id(),
161            });
162        }
163
164        let amount = (self.amount + other.amount)?;
165
166        Ok(Self { faucet_id: self.faucet_id, amount })
167    }
168
169    /// Subtracts a fungible asset from another and returns the result.
170    ///
171    /// # Errors
172    /// Returns an error if:
173    /// - The assets do not have the same asset ID (i.e. different faucet).
174    /// - The final amount would be negative.
175    #[allow(clippy::should_implement_trait)]
176    pub fn sub(self, other: Self) -> Result<Self, AssetError> {
177        if !self.is_same(&other) {
178            return Err(AssetError::FungibleAssetInconsistentIds {
179                original_id: self.id(),
180                other_id: other.id(),
181            });
182        }
183
184        let amount = (self.amount - other.amount)?;
185
186        Ok(FungibleAsset { faucet_id: self.faucet_id, amount })
187    }
188}
189
190impl From<FungibleAsset> for Asset {
191    fn from(asset: FungibleAsset) -> Self {
192        Asset::Fungible(asset)
193    }
194}
195
196impl fmt::Display for FungibleAsset {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        // TODO: Replace with hex representation?
199        write!(f, "{self:?}")
200    }
201}
202
203// SERIALIZATION
204// ================================================================================================
205
206impl Serializable for FungibleAsset {
207    fn write_into<W: ByteWriter>(&self, target: &mut W) {
208        // Lead with the asset composition byte to distinguish asset types on the wire.
209        target.write(AssetComposition::Fungible);
210        target.write(self.faucet_id);
211        target.write(self.amount.as_u64());
212    }
213
214    fn get_size_hint(&self) -> usize {
215        AssetComposition::SERIALIZED_SIZE
216            + self.faucet_id.get_size_hint()
217            + self.amount.as_u64().get_size_hint()
218    }
219}
220
221impl Deserializable for FungibleAsset {
222    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
223        let composition: AssetComposition = source.read()?;
224        if !composition.is_fungible() {
225            return Err(DeserializationError::InvalidValue(format!(
226                "expected fungible asset composition but found {composition:?}"
227            )));
228        }
229        FungibleAsset::deserialize_body(source)
230    }
231}
232
233impl FungibleAsset {
234    /// Reads the remaining body of a fungible asset, after the leading composition byte has
235    /// already been consumed.
236    pub(super) fn deserialize_body<R: ByteReader>(
237        source: &mut R,
238    ) -> Result<Self, DeserializationError> {
239        let faucet_id: AccountId = source.read()?;
240        let amount: u64 = source.read()?;
241
242        FungibleAsset::new(faucet_id, amount)
243            .map_err(|err| DeserializationError::InvalidValue(err.to_string()))
244    }
245}
246
247// TESTS
248// ================================================================================================
249
250#[cfg(test)]
251mod tests {
252    use assert_matches::assert_matches;
253
254    use super::*;
255    use crate::account::AccountId;
256    use crate::asset::NonFungibleAsset;
257    use crate::asset::tests::set_asset_metadata;
258    use crate::testing::account_id::{
259        ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
260        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
261        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1,
262        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2,
263        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3,
264    };
265
266    #[test]
267    fn fungible_asset_from_id_and_value_words_fails_on_invalid_composition() -> anyhow::Result<()> {
268        let asset_id =
269            set_asset_metadata(FungibleAsset::mock(25).id(), AssetComposition::None.as_u8());
270
271        let err = FungibleAsset::from_id_and_value_words(
272            asset_id,
273            FungibleAsset::mock(5).to_value_word(),
274        )
275        .unwrap_err();
276        assert_matches!(err, AssetError::AssetCompositionMismatch {
277                faucet_id: _, expected, actual: _
278            } => {
279                assert_eq!(expected, AssetComposition::Fungible);
280        });
281
282        Ok(())
283    }
284
285    #[test]
286    fn fungible_asset_from_id_and_value_words_fails_on_invalid_asset_class() -> anyhow::Result<()> {
287        let faucet_id: AccountId = ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET.try_into()?;
288        let mut asset_id =
289            AssetId::new(AssetClass::default(), faucet_id, AssetComposition::Fungible)?.to_word();
290        asset_id[0] = Felt::from(1u32);
291        asset_id[1] = Felt::from(2u32);
292
293        let err = FungibleAsset::from_id_and_value_words(
294            asset_id,
295            FungibleAsset::mock(5).to_value_word(),
296        )
297        .unwrap_err();
298        assert_matches!(err, AssetError::FungibleAssetClassMustBeZero(_));
299
300        Ok(())
301    }
302
303    #[test]
304    fn fungible_asset_from_id_and_value_fails_on_invalid_value() -> anyhow::Result<()> {
305        let asset = FungibleAsset::mock(42);
306        let mut invalid_value = asset.to_value_word();
307        invalid_value[2] = Felt::from(5u32);
308
309        let err = FungibleAsset::from_id_and_value(asset.id(), invalid_value).unwrap_err();
310        assert_matches!(err, AssetError::FungibleAssetValueMostSignificantElementsMustBeZero(_));
311
312        Ok(())
313    }
314
315    #[test]
316    fn test_fungible_asset_serde() -> anyhow::Result<()> {
317        for fungible_account_id in [
318            ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
319            ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
320            ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1,
321            ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2,
322            ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3,
323        ] {
324            let account_id = AccountId::try_from(fungible_account_id).unwrap();
325            let fungible_asset = FungibleAsset::new(account_id, 10).unwrap();
326            assert_eq!(
327                fungible_asset,
328                FungibleAsset::read_from_bytes(&fungible_asset.to_bytes()).unwrap()
329            );
330            assert_eq!(fungible_asset.to_bytes().len(), fungible_asset.get_size_hint());
331
332            assert_eq!(
333                fungible_asset,
334                FungibleAsset::from_id_and_value_words(
335                    fungible_asset.to_id_word(),
336                    fungible_asset.to_value_word()
337                )?
338            )
339        }
340
341        let non_fungible_asset = NonFungibleAsset::mock(&[4]);
342        let err = FungibleAsset::read_from_bytes(&non_fungible_asset.to_bytes()).unwrap_err();
343        assert_matches!(err, DeserializationError::InvalidValue(msg) => {
344            assert!(msg.contains("expected fungible asset composition but found None"));
345        });
346
347        Ok(())
348    }
349
350    #[test]
351    fn test_asset_id_for_fungible_asset() {
352        let asset = FungibleAsset::mock(34);
353
354        assert_eq!(asset.id().faucet_id(), FungibleAsset::mock_issuer());
355        assert_eq!(asset.id().asset_class().prefix().as_canonical_u64(), 0);
356        assert_eq!(asset.id().asset_class().suffix().as_canonical_u64(), 0);
357    }
358}