Sui.

Post

Share your knowledge.

article banner.
harry phan.
Jun 18, 2025
Article

Building a Marketplace with Heterogeneous Assets

When building a marketplace on a blockchain using the Move programming language, one of the most intriguing challenges is managing assets with different ability constraints in a single collection. Whether you're dealing with transferable NFTs, non-transferable soulbound tokens, or custom assets with unique transfer restrictions, Move's strict type system demands careful design to ensure type safety and efficiency. In this post, we'll dive into how ability constraints interact with dynamic fields in heterogeneous collections, explore practical solutions, and share a robust approach to building a marketplace that handles diverse asset types.

Understanding Move's Abilities

Move, designed for blockchains like Sui and Aptos, uses abilities to define what operations a type supports. The two key abilities relevant to our marketplace are:

  • key: Allows a type to be stored in global storage as an object.
  • store: Permits a type to be embedded within another object, such as a struct or collection.

Our marketplace needs to handle:

  • Regular NFTs: Have key + store, making them transferable and storable.
  • Soulbound Tokens: Have only key, meaning they’re non-transferable and cannot be stored in other objects.
  • Custom Assets: Have varying abilities, potentially with transfer restrictions.

The goal is to store these assets in a single collection, like a Bag, and manage their listings while respecting Move's type system.

The Challenge: Dynamic Fields and Ability Constraints

Move's dynamic_field::add<K, V>() function allows adding fields to objects dynamically, which seems ideal for a heterogeneous collection. However, the value type V must have the store ability. This poses a problem for soulbound tokens, which lack store. So, how do we store and manage assets with different ability sets in a marketplace?

Key Questions

  1. Does V in dynamic fields always need store? Can we use wrapper types to work around this?
  2. Can a single Bag store objects with different abilities (e.g., key + store vs. key)?
  3. How do we maintain type safety with dynamic fields' type erasure?
  4. How do phantom types and the witness pattern help manage heterogeneous assets?

Let’s tackle each question and build a solution.

Ability Requirements for Dynamic Fields

The Move documentation confirms that dynamic_field::add<K, V>() requires V to have the store ability. This is because dynamic fields are stored within an object, and Move enforces that embedded values must be storable. For regular NFTs with key + store, this is straightforward—we can store them directly in a Bag or dynamic field.

For soulbound tokens with only key, direct storage is impossible. A wrapper type, like struct Wrapper has store { asset: T }, won’t work because T lacks store. Instead, we can store metadata, such as the asset’s ID and listing details, which does have store. For example:

struct Metadata has store {
    id: ID,
    price: u64,
    asset_type: String,
}

Heterogeneous Storage in a Single Collection

A Bag in Move is designed to store values with the store ability, but all values must conform to the same type constraints. This means a single Bag cannot store both NFTs (key + store) and soulbound token metadata unless they’re wrapped in a common type with store. However, mixing types in one collection often leads to complexity and potential type safety issues.

A better approach is to use separate collections for different ability sets:

  • Assets: Store the assets directly in a vector or Bag.
  • Soulbound Tokens: Store their IDs and metadata in a vector.

This separation respects Move’s ability constraints while keeping the system modular and maintainable.

Maintaining Type Safety

Dynamic fields erase type information at runtime, so retrieving a value requires specifying the type at compile time, like dynamic_field::remove<K, V>(). This ensures type safety but complicates handling heterogeneous types. To manage different asset types, store a type tag (e.g., a String like "NFT" or "SoulboundToken") in the metadata. At runtime, check the tag to determine how to process the listing.

For example:

public struct ListingMetadata has store {
    asset_id: ID,
    price: u64,
    asset_type: String,
}

When retrieving, use the asset_type to decide whether to treat the asset as an NFT or soulbound token, ensuring correct handling while maintaining compile-time type safety for the stored metadata.

Phantom Types and the Witness Pattern

Phantom types in Move, like struct Asset, are useful for tagging different asset types without runtime overhead. For instance, you might define Asset and Asset to distinguish variants. However, the struct itself must still have store to be stored in a collection, and type information is erased at runtime.

To extract type info later, store a metadata field like asset_type alongside the asset or its ID. This allows you to differentiate Asset from Asset during processing, such as when executing transfers or displaying listings.

A Practical Marketplace Design

Here’s a practical implementation for a marketplace that handles both transferable and soulbound assets:

Marketplace Structure

 use sui::object::{Self, UID, ID};
    use sui::vec_map::{Self, VecMap};

    struct Marketplace has key {
        id: UID,
        transferable_listings: VecMap<ID, ListingWithAsset>,
        soulbound_listings: VecMap<ID, ListingMetadata>,
    }

    struct ListingWithAsset has store {
        asset: T,  // T must have key + store
        price: u64,
    }

    struct ListingMetadata has store {
        asset_id: ID,
        price: u64,
        asset_type: String,
    }

Transferable Assets:

public fun list_transferable<T: key + store>(
    marketplace: &mut Marketplace,
    asset: T,
    price: u64
) {
    let id = object::id(&asset);
    let listing = ListingWithAsset { asset, price };
    vec_map::insert(&mut marketplace.transferable_listings, id, listing);
}

Soulbound Tokens:

ublic fun list_soulbound<T: key>(
    marketplace: &mut Marketplace,
    asset: &T,
    price: u64
) {
    let id = object::id(asset);
    let listing = ListingMetadata { asset_id: id, price, asset_type: "SoulboundToken" };
    vec_map::insert(&mut marketplace.soulbound_listings, id, listing);
}

Building a marketplace in Move taught me the importance of aligning with the language’s type system. Abilities like store are non-negotiable for dynamic fields, so planning for metadata storage early is critical for non-storable assets. Separate collections simplify handling different ability sets, while metadata tags enable runtime flexibility. Phantom types are great for compile-time distinctions, but runtime type handling requires explicit metadata.

  • Sui
2
Share
Comments
.

Sui is a Layer 1 protocol blockchain designed as the first internet-scale programmable blockchain platform.

400Posts559Answers
Sui.X.Peera.

Earn Your Share of 1000 Sui

Gain Reputation Points & Get Rewards for Helping the Sui Community Grow.

Reward CampaignJuly