Skip to main content

miden_protocol/account/delta/
vault.rs

1use alloc::collections::BTreeMap;
2use alloc::collections::btree_map::Entry;
3use alloc::string::ToString;
4use alloc::vec::Vec;
5
6use super::{
7    AccountDeltaError,
8    ByteReader,
9    ByteWriter,
10    Deserializable,
11    DeserializationError,
12    Serializable,
13};
14use crate::asset::{Asset, AssetVaultKey, FungibleAsset, NonFungibleAsset};
15use crate::{Felt, ONE, ZERO};
16
17// ACCOUNT VAULT DELTA
18// ================================================================================================
19
20/// The domain for the assets in the delta commitment.
21const DOMAIN_ASSET: Felt = Felt::ONE;
22
23/// [AccountVaultDelta] stores the difference between the initial and final account vault states.
24///
25/// The difference is represented as follows:
26/// - fungible: a binary tree map of fungible asset balance changes in the account vault.
27/// - non_fungible: a binary tree map of non-fungible assets that were added to or removed from the
28///   account vault.
29#[derive(Clone, Debug, Default, PartialEq, Eq)]
30pub struct AccountVaultDelta {
31    fungible: FungibleAssetDelta,
32    non_fungible: NonFungibleAssetDelta,
33}
34
35impl AccountVaultDelta {
36    /// Validates and creates an [AccountVaultDelta] with the given fungible and non-fungible asset
37    /// deltas.
38    ///
39    /// # Errors
40    /// Returns an error if the delta does not pass the validation.
41    pub const fn new(fungible: FungibleAssetDelta, non_fungible: NonFungibleAssetDelta) -> Self {
42        Self { fungible, non_fungible }
43    }
44
45    /// Returns a reference to the fungible asset delta.
46    pub fn fungible(&self) -> &FungibleAssetDelta {
47        &self.fungible
48    }
49
50    /// Returns a reference to the non-fungible asset delta.
51    pub fn non_fungible(&self) -> &NonFungibleAssetDelta {
52        &self.non_fungible
53    }
54
55    /// Returns true if this vault delta contains no updates.
56    pub fn is_empty(&self) -> bool {
57        self.fungible.is_empty() && self.non_fungible.is_empty()
58    }
59
60    /// Tracks asset addition.
61    pub fn add_asset(&mut self, asset: Asset) -> Result<(), AccountDeltaError> {
62        match asset {
63            Asset::Fungible(asset) => self.fungible.add(asset),
64            Asset::NonFungible(asset) => self.non_fungible.add(asset),
65        }
66    }
67
68    /// Tracks asset removal.
69    pub fn remove_asset(&mut self, asset: Asset) -> Result<(), AccountDeltaError> {
70        match asset {
71            Asset::Fungible(asset) => self.fungible.remove(asset),
72            Asset::NonFungible(asset) => self.non_fungible.remove(asset),
73        }
74    }
75
76    /// Merges another delta into this one, overwriting any existing values.
77    ///
78    /// The result is validated as part of the merge.
79    ///
80    /// # Errors
81    /// Returns an error if the resulted delta does not pass the validation.
82    pub fn merge(&mut self, other: Self) -> Result<(), AccountDeltaError> {
83        self.non_fungible.merge(other.non_fungible)?;
84        self.fungible.merge(other.fungible)
85    }
86
87    /// Appends the vault delta to the given `elements` from which the delta commitment will be
88    /// computed.
89    pub(super) fn append_delta_elements(&self, elements: &mut Vec<Felt>) {
90        self.fungible().append_delta_elements(elements);
91        self.non_fungible().append_delta_elements(elements);
92    }
93}
94
95#[cfg(any(feature = "testing", test))]
96impl AccountVaultDelta {
97    /// Creates an [AccountVaultDelta] from the given iterators.
98    pub fn from_iters(
99        added_assets: impl IntoIterator<Item = crate::asset::Asset>,
100        removed_assets: impl IntoIterator<Item = crate::asset::Asset>,
101    ) -> Self {
102        let mut fungible = FungibleAssetDelta::default();
103        let mut non_fungible = NonFungibleAssetDelta::default();
104
105        for asset in added_assets {
106            match asset {
107                Asset::Fungible(asset) => {
108                    fungible.add(asset).unwrap();
109                },
110                Asset::NonFungible(asset) => {
111                    non_fungible.add(asset).unwrap();
112                },
113            }
114        }
115
116        for asset in removed_assets {
117            match asset {
118                Asset::Fungible(asset) => {
119                    fungible.remove(asset).unwrap();
120                },
121                Asset::NonFungible(asset) => {
122                    non_fungible.remove(asset).unwrap();
123                },
124            }
125        }
126
127        Self { fungible, non_fungible }
128    }
129
130    /// Returns an iterator over the added assets in this delta.
131    pub fn added_assets(&self) -> impl Iterator<Item = crate::asset::Asset> + '_ {
132        self.fungible
133            .0
134            .iter()
135            .filter(|&(_, &value)| value >= 0)
136            .map(|(vault_key, &diff)| {
137                Asset::Fungible(
138                    FungibleAsset::new(vault_key.faucet_id(), diff.unsigned_abs())
139                        .unwrap()
140                        .with_callbacks(vault_key.callback_flag()),
141                )
142            })
143            .chain(
144                self.non_fungible
145                    .filter_by_action(NonFungibleDeltaAction::Add)
146                    .map(Asset::NonFungible),
147            )
148    }
149
150    /// Returns an iterator over the removed assets in this delta.
151    pub fn removed_assets(&self) -> impl Iterator<Item = crate::asset::Asset> + '_ {
152        self.fungible
153            .0
154            .iter()
155            .filter(|&(_, &value)| value < 0)
156            .map(|(vault_key, &diff)| {
157                Asset::Fungible(
158                    FungibleAsset::new(vault_key.faucet_id(), diff.unsigned_abs())
159                        .unwrap()
160                        .with_callbacks(vault_key.callback_flag()),
161                )
162            })
163            .chain(
164                self.non_fungible
165                    .filter_by_action(NonFungibleDeltaAction::Remove)
166                    .map(Asset::NonFungible),
167            )
168    }
169}
170
171impl Serializable for AccountVaultDelta {
172    fn write_into<W: ByteWriter>(&self, target: &mut W) {
173        target.write(&self.fungible);
174        target.write(&self.non_fungible);
175    }
176
177    fn get_size_hint(&self) -> usize {
178        self.fungible.get_size_hint() + self.non_fungible.get_size_hint()
179    }
180}
181
182impl Deserializable for AccountVaultDelta {
183    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
184        let fungible = source.read()?;
185        let non_fungible = source.read()?;
186
187        Ok(Self::new(fungible, non_fungible))
188    }
189}
190
191// FUNGIBLE ASSET DELTA
192// ================================================================================================
193
194/// A binary tree map of fungible asset balance changes in the account vault.
195///
196/// The [`AssetVaultKey`] orders the assets in the same way as the in-kernel account delta which
197/// uses a link map.
198#[derive(Clone, Debug, Default, PartialEq, Eq)]
199pub struct FungibleAssetDelta(BTreeMap<AssetVaultKey, i64>);
200
201impl FungibleAssetDelta {
202    /// Validates and creates a new fungible asset delta.
203    ///
204    /// # Errors
205    /// Returns an error if the delta does not pass the validation.
206    pub fn new(map: BTreeMap<AssetVaultKey, i64>) -> Result<Self, AccountDeltaError> {
207        Self::validate(&map)?;
208
209        Ok(Self(map))
210    }
211
212    /// Adds a new fungible asset to the delta.
213    ///
214    /// # Errors
215    /// Returns an error if the delta would overflow.
216    pub fn add(&mut self, asset: FungibleAsset) -> Result<(), AccountDeltaError> {
217        let amount: i64 = asset.amount().as_i64();
218        self.add_delta(asset.vault_key(), amount)
219    }
220
221    /// Removes a fungible asset from the delta.
222    ///
223    /// # Errors
224    /// Returns an error if the delta would overflow.
225    pub fn remove(&mut self, asset: FungibleAsset) -> Result<(), AccountDeltaError> {
226        let amount: i64 = asset.amount().as_i64();
227        self.add_delta(asset.vault_key(), -amount)
228    }
229
230    /// Returns the amount of the fungible asset with the given vault key.
231    pub fn amount(&self, vault_key: &AssetVaultKey) -> Option<i64> {
232        self.0.get(vault_key).copied()
233    }
234
235    /// Returns the number of fungible assets affected in the delta.
236    pub fn num_assets(&self) -> usize {
237        self.0.len()
238    }
239
240    /// Returns true if this vault delta contains no updates.
241    pub fn is_empty(&self) -> bool {
242        self.0.is_empty()
243    }
244
245    /// Returns an iterator over the (key, value) pairs of the map.
246    pub fn iter(&self) -> impl Iterator<Item = (&AssetVaultKey, &i64)> {
247        self.0.iter()
248    }
249
250    /// Merges another delta into this one, overwriting any existing values.
251    ///
252    /// The result is validated as part of the merge.
253    ///
254    /// # Errors
255    /// Returns an error if the result did not pass validation.
256    pub fn merge(&mut self, other: Self) -> Result<(), AccountDeltaError> {
257        // Merge fungible assets.
258        //
259        // Track fungible asset amounts - positive and negative. `i64` is not lossy while
260        // fungibles are restricted to 2^63-1. Overflow is still possible but we check for that.
261
262        for (&vault_key, &amount) in other.0.iter() {
263            self.add_delta(vault_key, amount)?;
264        }
265
266        Ok(())
267    }
268
269    // HELPER FUNCTIONS
270    // ---------------------------------------------------------------------------------------------
271
272    /// Updates the provided map with the provided key and amount. If the final amount is 0,
273    /// the entry is removed.
274    ///
275    /// # Errors
276    /// Returns an error if the delta would overflow.
277    fn add_delta(&mut self, vault_key: AssetVaultKey, delta: i64) -> Result<(), AccountDeltaError> {
278        match self.0.entry(vault_key) {
279            Entry::Vacant(entry) => {
280                // Only track non-zero amounts.
281                if delta != 0 {
282                    entry.insert(delta);
283                }
284            },
285            Entry::Occupied(mut entry) => {
286                let old = *entry.get();
287                let new = old.checked_add(delta).ok_or(
288                    AccountDeltaError::FungibleAssetDeltaOverflow {
289                        faucet_id: vault_key.faucet_id(),
290                        current: old,
291                        delta,
292                    },
293                )?;
294
295                if new == 0 {
296                    entry.remove();
297                } else {
298                    *entry.get_mut() = new;
299                }
300            },
301        }
302
303        Ok(())
304    }
305
306    /// Checks whether this vault delta is valid.
307    ///
308    /// # Errors
309    /// Returns an error if one or more fungible assets' faucet IDs are invalid.
310    fn validate(map: &BTreeMap<AssetVaultKey, i64>) -> Result<(), AccountDeltaError> {
311        for vault_key in map.keys() {
312            if !vault_key.composition().is_fungible() {
313                return Err(AccountDeltaError::NotAFungibleFaucetId(vault_key.faucet_id()));
314            }
315        }
316
317        Ok(())
318    }
319
320    /// Appends the fungible asset vault delta to the given `elements` from which the delta
321    /// commitment will be computed.
322    ///
323    /// Note that the order in which elements are appended should be the link map key ordering. This
324    /// is fulfilled here because the link map key's most significant element takes precedence over
325    /// less significant ones. The most significant element in the fungible asset delta is the
326    /// faucet ID prefix and the delta happens to be sorted by vault keys. Since the faucet ID
327    /// prefix is unique, it will always decide on the ordering of a link map key, so less
328    /// significant elements are unimportant. This implicit sort should therefore always match the
329    /// link map key ordering, however this is subtle and fragile.
330    pub(super) fn append_delta_elements(&self, elements: &mut Vec<Felt>) {
331        for (vault_key, amount_delta) in self.iter() {
332            // Note that this iterator is guaranteed to never yield zero amounts, so we don't have
333            // to exclude those explicitly.
334            debug_assert_ne!(
335                *amount_delta, 0,
336                "fungible asset iterator should never yield amount deltas of 0"
337            );
338
339            let was_added = if *amount_delta > 0 { ONE } else { ZERO };
340            let amount_delta = Felt::try_from(amount_delta.unsigned_abs())
341                .expect("amount delta should be less than i64::MAX");
342
343            let key_word = vault_key.to_word();
344            elements.extend_from_slice(&[
345                DOMAIN_ASSET,
346                was_added,
347                key_word[2], // faucet_id_suffix_and_metadata
348                key_word[3], // faucet_id_prefix
349            ]);
350            elements.extend_from_slice(&[amount_delta, ZERO, ZERO, ZERO]);
351        }
352    }
353}
354
355impl Serializable for FungibleAssetDelta {
356    fn write_into<W: ByteWriter>(&self, target: &mut W) {
357        target.write_usize(self.0.len());
358        // TODO: We save `i64` as `u64` since winter utils only supports unsigned integers for now.
359        //   We should update this code (and deserialization as well) once it supports signed
360        //   integers.
361        // TODO: If we keep this code, optimize by not serializing asset ID (which is always 0).
362        target.write_many(self.0.iter().map(|(vault_key, &delta)| (*vault_key, delta as u64)));
363    }
364
365    fn get_size_hint(&self) -> usize {
366        const ENTRY_SIZE: usize = AssetVaultKey::SERIALIZED_SIZE + core::mem::size_of::<u64>();
367        self.0.len().get_size_hint() + self.0.len() * ENTRY_SIZE
368    }
369}
370
371impl Deserializable for FungibleAssetDelta {
372    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
373        let num_fungible_assets = source.read_usize()?;
374        // TODO: We save `i64` as `u64` since winter utils only supports unsigned integers for now.
375        //   We should update this code (and serialization as well) once it supports signed
376        //   integers.
377        let map = source
378            .read_many_iter::<(AssetVaultKey, u64)>(num_fungible_assets)?
379            .map(|result| result.map(|(vault_key, delta_as_u64)| (vault_key, delta_as_u64 as i64)))
380            .collect::<Result<_, _>>()?;
381
382        Self::new(map).map_err(|err| DeserializationError::InvalidValue(err.to_string()))
383    }
384}
385
386// NON-FUNGIBLE ASSET DELTA
387// ================================================================================================
388
389/// A binary tree map of non-fungible asset changes (addition and removal) in the account vault.
390///
391/// The [`AssetVaultKey`] orders the assets in the same way as the in-kernel account delta which
392/// uses a link map.
393#[derive(Clone, Debug, Default, PartialEq, Eq)]
394pub struct NonFungibleAssetDelta(
395    BTreeMap<AssetVaultKey, (NonFungibleAsset, NonFungibleDeltaAction)>,
396);
397
398impl NonFungibleAssetDelta {
399    /// Creates a new non-fungible asset delta.
400    pub const fn new(
401        map: BTreeMap<AssetVaultKey, (NonFungibleAsset, NonFungibleDeltaAction)>,
402    ) -> Self {
403        Self(map)
404    }
405
406    /// Adds a new non-fungible asset to the delta.
407    ///
408    /// # Errors
409    /// Returns an error if the delta already contains the asset addition.
410    pub fn add(&mut self, asset: NonFungibleAsset) -> Result<(), AccountDeltaError> {
411        self.apply_action(asset, NonFungibleDeltaAction::Add)
412    }
413
414    /// Removes a non-fungible asset from the delta.
415    ///
416    /// # Errors
417    /// Returns an error if the delta already contains the asset removal.
418    pub fn remove(&mut self, asset: NonFungibleAsset) -> Result<(), AccountDeltaError> {
419        self.apply_action(asset, NonFungibleDeltaAction::Remove)
420    }
421
422    /// Returns the number of non-fungible assets affected in the delta.
423    pub fn num_assets(&self) -> usize {
424        self.0.len()
425    }
426
427    /// Returns true if this vault delta contains no updates.
428    pub fn is_empty(&self) -> bool {
429        self.0.is_empty()
430    }
431
432    /// Returns an iterator over the (key, value) pairs of the map.
433    pub fn iter(&self) -> impl Iterator<Item = (&NonFungibleAsset, &NonFungibleDeltaAction)> {
434        self.0
435            .iter()
436            .map(|(_key, (non_fungible_asset, delta_action))| (non_fungible_asset, delta_action))
437    }
438
439    /// Merges another delta into this one, overwriting any existing values.
440    ///
441    /// The result is validated as part of the merge.
442    ///
443    /// # Errors
444    /// Returns an error if duplicate non-fungible assets are added or removed.
445    pub fn merge(&mut self, other: Self) -> Result<(), AccountDeltaError> {
446        // Merge non-fungible assets. Each non-fungible asset can cancel others out.
447        for (&asset, &action) in other.iter() {
448            self.apply_action(asset, action)?;
449        }
450
451        Ok(())
452    }
453
454    // HELPER FUNCTIONS
455    // ---------------------------------------------------------------------------------------------
456
457    /// Updates the provided map with the provided key and action.
458    /// If the action is the opposite to the previous one, the entry is removed.
459    ///
460    /// # Errors
461    /// Returns an error if the delta already contains the provided key and action.
462    fn apply_action(
463        &mut self,
464        asset: NonFungibleAsset,
465        action: NonFungibleDeltaAction,
466    ) -> Result<(), AccountDeltaError> {
467        match self.0.entry(asset.vault_key()) {
468            Entry::Vacant(entry) => {
469                entry.insert((asset, action));
470            },
471            Entry::Occupied(entry) => {
472                let (_prev_asset, previous_action) = *entry.get();
473                if previous_action == action {
474                    // Asset cannot be added nor removed twice.
475                    return Err(AccountDeltaError::DuplicateNonFungibleVaultUpdate(asset));
476                }
477                // Otherwise they cancel out.
478                entry.remove();
479            },
480        }
481
482        Ok(())
483    }
484
485    /// Returns an iterator over all keys that have the provided action.
486    fn filter_by_action(
487        &self,
488        action: NonFungibleDeltaAction,
489    ) -> impl Iterator<Item = NonFungibleAsset> + '_ {
490        self.0
491            .iter()
492            .filter(move |&(_, (_asset, cur_action))| cur_action == &action)
493            .map(|(_key, (asset, _action))| *asset)
494    }
495
496    /// Appends the non-fungible asset vault delta to the given `elements` from which the delta
497    /// commitment will be computed.
498    pub(super) fn append_delta_elements(&self, elements: &mut Vec<Felt>) {
499        for (asset, action) in self.iter() {
500            let was_added = match action {
501                NonFungibleDeltaAction::Remove => ZERO,
502                NonFungibleDeltaAction::Add => ONE,
503            };
504
505            let key_word = asset.vault_key().to_word();
506            elements.extend_from_slice(&[
507                DOMAIN_ASSET,
508                was_added,
509                key_word[2], // faucet_id_suffix_and_metadata
510                key_word[3], // faucet_id_prefix
511            ]);
512            elements.extend_from_slice(asset.to_value_word().as_elements());
513        }
514    }
515}
516
517impl Serializable for NonFungibleAssetDelta {
518    fn write_into<W: ByteWriter>(&self, target: &mut W) {
519        let added: Vec<_> = self.filter_by_action(NonFungibleDeltaAction::Add).collect();
520        let removed: Vec<_> = self.filter_by_action(NonFungibleDeltaAction::Remove).collect();
521
522        target.write_usize(added.len());
523        target.write_many(added.iter());
524
525        target.write_usize(removed.len());
526        target.write_many(removed.iter());
527    }
528
529    fn get_size_hint(&self) -> usize {
530        let added = self.filter_by_action(NonFungibleDeltaAction::Add).count();
531        let removed = self.filter_by_action(NonFungibleDeltaAction::Remove).count();
532
533        added.get_size_hint()
534            + removed.get_size_hint()
535            + added * NonFungibleAsset::SERIALIZED_SIZE
536            + removed * NonFungibleAsset::SERIALIZED_SIZE
537    }
538}
539
540impl Deserializable for NonFungibleAssetDelta {
541    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
542        let mut map = BTreeMap::new();
543
544        let num_added = source.read_usize()?;
545        for _ in 0..num_added {
546            let added_asset: NonFungibleAsset = source.read()?;
547            map.insert(added_asset.vault_key(), (added_asset, NonFungibleDeltaAction::Add));
548        }
549
550        let num_removed = source.read_usize()?;
551        for _ in 0..num_removed {
552            let removed_asset: NonFungibleAsset = source.read()?;
553            map.insert(removed_asset.vault_key(), (removed_asset, NonFungibleDeltaAction::Remove));
554        }
555
556        Ok(Self::new(map))
557    }
558}
559
560#[derive(Clone, Copy, Debug, PartialEq, Eq)]
561pub enum NonFungibleDeltaAction {
562    Add,
563    Remove,
564}
565
566// TESTS
567// ================================================================================================
568
569#[cfg(test)]
570mod tests {
571    use super::{AccountVaultDelta, Deserializable, Serializable};
572    use crate::account::AccountId;
573    use crate::asset::{Asset, FungibleAsset, NonFungibleAsset, NonFungibleAssetDetails};
574    use crate::testing::account_id::{
575        ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
576        ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
577    };
578
579    #[test]
580    fn test_serde_account_vault() {
581        let asset_0 = FungibleAsset::mock(100);
582        let asset_1 = NonFungibleAsset::mock(&[10, 21, 32, 43]);
583        let delta = AccountVaultDelta::from_iters([asset_0], [asset_1]);
584
585        let serialized = delta.to_bytes();
586        let deserialized = AccountVaultDelta::read_from_bytes(&serialized).unwrap();
587        assert_eq!(deserialized, delta);
588    }
589
590    #[test]
591    fn test_is_empty_account_vault() {
592        let faucet = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap();
593        let asset: Asset = FungibleAsset::new(faucet, 123).unwrap().into();
594
595        assert!(AccountVaultDelta::default().is_empty());
596        assert!(!AccountVaultDelta::from_iters([asset], []).is_empty());
597        assert!(!AccountVaultDelta::from_iters([], [asset]).is_empty());
598    }
599
600    #[rstest::rstest]
601    #[case::pos_pos(50, 50, Some(100))]
602    #[case::neg_neg(-50, -50, Some(-100))]
603    #[case::empty_pos(0, 50, Some(50))]
604    #[case::empty_neg(0, -50, Some(-50))]
605    #[case::nullify_pos_neg(100, -100, Some(0))]
606    #[case::nullify_neg_pos(-100, 100, Some(0))]
607    #[case::overflow(FungibleAsset::MAX_AMOUNT.as_i64(), FungibleAsset::MAX_AMOUNT.as_i64(), None)]
608    #[case::underflow(-(FungibleAsset::MAX_AMOUNT.as_i64()), -(FungibleAsset::MAX_AMOUNT.as_i64()), None)]
609    #[test]
610    fn merge_fungible_aggregation(#[case] x: i64, #[case] y: i64, #[case] expected: Option<i64>) {
611        /// Creates an [AccountVaultDelta] with a single [FungibleAsset] delta. This delta will
612        /// be added if `amount > 0`, removed if `amount < 0` or entirely missing if `amount == 0`.
613        fn create_delta_with_fungible(account_id: AccountId, amount: i64) -> AccountVaultDelta {
614            let asset = FungibleAsset::new(account_id, amount.unsigned_abs()).unwrap().into();
615            match amount {
616                0 => AccountVaultDelta::default(),
617                x if x.is_positive() => AccountVaultDelta::from_iters([asset], []),
618                _ => AccountVaultDelta::from_iters([], [asset]),
619            }
620        }
621
622        let account_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
623
624        let mut delta_x = create_delta_with_fungible(account_id, x);
625        let delta_y = create_delta_with_fungible(account_id, y);
626
627        let result = delta_x.merge(delta_y);
628
629        // None is used to indicate an error is expected.
630        if let Some(expected) = expected {
631            let expected = create_delta_with_fungible(account_id, expected);
632            assert_eq!(result.map(|_| delta_x).unwrap(), expected);
633        } else {
634            assert!(result.is_err());
635        }
636    }
637
638    #[rstest::rstest]
639    #[case::empty_removed(None, Some(false), Ok(Some(false)))]
640    #[case::empty_added(None, Some(true), Ok(Some(true)))]
641    #[case::add_remove(Some(true), Some(false), Ok(None))]
642    #[case::remove_add(Some(false), Some(true), Ok(None))]
643    #[case::double_add(Some(true), Some(true), Err(()))]
644    #[case::double_remove(Some(false), Some(false), Err(()))]
645    #[test]
646    fn merge_non_fungible_aggregation(
647        #[case] x: Option<bool>,
648        #[case] y: Option<bool>,
649        #[case] expected: Result<Option<bool>, ()>,
650    ) {
651        /// Creates an [AccountVaultDelta] with an optional [NonFungibleAsset] delta. This delta
652        /// will be added if `Some(true)`, removed for `Some(false)` and missing for `None`.
653        fn create_delta_with_non_fungible(
654            account_id: AccountId,
655            added: Option<bool>,
656        ) -> AccountVaultDelta {
657            let asset: Asset =
658                NonFungibleAsset::new(&NonFungibleAssetDetails::new(account_id, vec![1, 2, 3]))
659                    .into();
660
661            match added {
662                Some(true) => AccountVaultDelta::from_iters([asset], []),
663                Some(false) => AccountVaultDelta::from_iters([], [asset]),
664                None => AccountVaultDelta::default(),
665            }
666        }
667
668        let account_id = NonFungibleAsset::mock_issuer();
669
670        let mut delta_x = create_delta_with_non_fungible(account_id, x);
671        let delta_y = create_delta_with_non_fungible(account_id, y);
672
673        let result = delta_x.merge(delta_y);
674
675        if let Ok(expected) = expected {
676            let expected = create_delta_with_non_fungible(account_id, expected);
677            assert_eq!(result.map(|_| delta_x).unwrap(), expected);
678        } else {
679            assert!(result.is_err());
680        }
681    }
682}