Audit finding: SBT reservations can be overwritten
Project: MantaNetwork
Auditor: Veridise
Location: pallets/manta-sbt/src/lib.rs
Impact: Medium
Vulnerability class (audit): Logic Error
Vulnerability class (Scout): Business Logic
Status: Fixed

4.1 Detailed Description of Issues

V-MSBT-VUL-001: SBT reservations can be overwritten

Severity: Medium
Type: Logic Error
Commit: ceb9e46
Status: Fixed
File(s): pallets/manta-sbt/src/lib.rs
Location(s): reserve_sbt

The manta-sbt pallet exposes a method reserve_sbt whereby callers pay MANTA tokens to reserve the right to mint N SBTs. This reservation is enforced by allocating reservation ids to the caller of the method so that when users mint an SBT, Manta Chain uses one of the reserved ids to track the number reserved. In particular, Manta Chain maintains a map called ReservedIds which maps users to an interval of reservation ids such that the length of the interval indicates the number of SBTs they can mint.

However, reserve_sbt does not check whether a user has already reserved SBTs to mint when calling the method and simply updates the mapping to a new interval of length N. This can be seen in the method implementation below:

/// Reserves AssetIds to be used subsequently in 'to_private' above.

/// Increments AssetManager’s AssetId counter.

#[pallet::call_index(1)]
#[pallet::weight(<T as pallet::Config>::WeightInfo::reserve_sbt())]
#[transactional]
pub fn reserve_sbt(origin: OriginFor<T>) -> DispatchResult {
  let who = ensure_signed(origin)?;

  // Charges fee to reserve AssetIds
  <T as pallet::Config>::Currency::transfer(
    &who,
    &Self::account_id(),
    T::ReservePrice::get(),
    ExistenceRequirement::KeepAlive,
  )?;

  // Reserves unique AssetIds to be used later to mint SBTs
  let asset_id_range: Vec<StandardAssetId> = (0..T::MintsPerReserve::get())
    .map(|_| Self::next_sbt_id_and_increment())
    .collect::<Result<Vec<StandardAssetId>, _>>()?;

  // The range of 'AssetIds' that are reserved as SBTs
  let start_id: StandardAssetId = *asset_id_range.first().ok_or(Error::<T>::ZeroMints)?;
  let stop_id: StandardAssetId = *asset_id_range.last().ok_or(Error::<T>::ZeroMints)?;

  ReservedIds::<T>::insert(&who, (start_id, stop_id));
  Self::deposit_event(Event::<T>::SBTReserved { who, start_id, stop_id });
  Ok(())
}

Thus, if a user calls the method M times to reserve M*N SBTs, then they will only be able to mint N.

Impact: Users can lose money because they may think they have reserved M*N SBTs when they in fact can only mint N. Furthermore, if a user calls reserve_sbt M times in a row, then M*(N-1) SBTs can no longer be minted. This is due to the fact that reserved ids are always incremented.

For example, suppose someone sets up a relayer account which users could use to purchase zkSBTs for themselves without being linked to the transaction. That relayer account would only receive SBTs on its last call reserve_sbt. Implementations in which the relayer reserves and sends the SBTs separately may be error-prone.

Recommendation: If at most N SBTs can be reserved at a time for a user, then reserve_sbt should check whether a user has already reserved SBTs. If the protocol permits more than N SBTs to be reserved at a time for a user, then the mapping should be changed. One option might be to map each user to a set of intervals corresponding to ids reserved for them.

Developer Response: The developers have acknowledged the issue and a fix has been proposed in this pull request. The fix changes the function to revert if the user has SBT ids already reserved.

Summary: SBT reservations can be overwritten, causing users to lose reserved tokens.