Decode Sui Coin Standard

Decode Sui Coin Standard

Tags
Web3
Sui
Published
February 22, 2025
Author

Overview

Sui coins are built using the Move language and follow an object-oriented model. They rely on a TreasuryCap to manage minting and burning, and CoinMetadata to store details like name, symbol, and decimals.
  • Minting: Performed by the TreasuryCap, which tracks total supply and can increase it by minting new coins.
  • Burning: A coin is destroyed by deleting the object and reducing the total supply in the TreasuryCap.
  • Splitting/Joining: Coins can be split into smaller units or joined to combine their balances.
  • Zero Coins: Sui allows creation and destruction of zero-value coins for placeholders.
  • Regulated Coins: Sui supports regulatory control, such as deny lists and global pause capabilities to prevent certain addresses from using or receiving coins.

Comparison with ERC20 Tokens

1. Platform & Language

  • Sui: Built with the Move language and an object-centric model (coins as distinct objects).
  • ERC20: Built for Ethereum using Solidity and maintains a mapping of balances in a contract.

2. Minting/Burning

  • Sui: Minting and burning are controlled via TreasuryCap, which ensures a single minting authority per coin type.
  • ERC20: Minting and burning are defined within the contract, typically controlled by an owner or admin role.

3. State Management

  • Sui: Coins are objects with unique IDs, allowing individual manipulation (splitting, joining, burning).
  • ERC20: Tokens are stored in a contract's internal ledger (mapping), where balances are simply updated.

4. Advanced Features

  • Sui: Provides built-in regulatory features, such as DenyCapV2 for managing deny lists and global pauses.
  • ERC20: Does not include regulatory features by default; additional functionality must be implemented in separate contracts.

5. Limitation

  • Sui: Coin doesn’t support approval operation like in ERC20. If you own an object, you are the only party able to transfer it. There is no way for another entity to withdraw that object from your wallet.

Create Currency

notion image
create_currency is used to create coin.
It creates:
  1. TreasuryCap
      • It’s the authority of the token, the owner can mint new token.
      • It records total supply of the token
  1. CoinMetadata
    1. It’s the metadata of token, which records info like decimal, name, and logo’s url.
Note it accepts one-time witness to ensure each package can only create one coin. So the TreasuryCap and CoinMetadata for each coin is unique.
/// --- /sui-framework/packages/sui-framework/sources/coin.move --- /// Capability allowing the bearer to mint and burn /// coins of type `T`. Transferable public struct TreasuryCap<phantom T> has key, store { id: UID, total_supply: Supply<T>, } /// Each Coin type T created through `create_currency` function will have a /// unique instance of CoinMetadata<T> that stores the metadata for this coin type. public struct CoinMetadata<phantom T> has key, store { id: UID, /// Number of decimal places the coin uses. /// A coin with `value ` N and `decimals` D should be shown as N / 10^D /// E.g., a coin with `value` 7002 and decimals 3 should be displayed as 7.002 /// This is metadata for display usage only. decimals: u8, /// Name for the token name: string::String, /// Symbol for the token symbol: ascii::String, /// Description of the token description: string::String, /// URL for the token logo icon_url: Option<Url>, } // === Registering new coin types and managing the coin supply === /// Create a new currency type `T` as and return the `TreasuryCap` for /// `T` to the caller. Can only be called with a `one-time-witness` /// type, ensuring that there's only one `TreasuryCap` per `T`. public fun create_currency<T: drop>( witness: T, decimals: u8, symbol: vector<u8>, name: vector<u8>, description: vector<u8>, icon_url: Option<Url>, ctx: &mut TxContext, ): (TreasuryCap<T>, CoinMetadata<T>) { // Make sure there's only one instance of the type T assert!(sui::types::is_one_time_witness(&witness), EBadWitness); ( TreasuryCap { id: object::new(ctx), total_supply: balance::create_supply(witness), }, CoinMetadata { id: object::new(ctx), decimals, name: string::utf8(name), symbol: ascii::string(symbol), description: string::utf8(description), icon_url, }, ) } /// --- crates/sui-framework/packages/sui-framework/sources/balance.move --- /// A Supply of T. Used for minting and burning. /// Wrapped into a `TreasuryCap` in the `Coin` module. public struct Supply<phantom T> has store { value: u64, }

Mint

sui::coin::mint mints new token.
  • It requires TreasuryCap to mint.
  • It creates Coin object
    • Coin object records balance.
/// --- crates/sui-framework/packages/sui-framework/sources/coin.move --- /// A coin of type `T` worth `value`. Transferable and storable public struct Coin<phantom T> has key, store { id: UID, balance: Balance<T>, } /// Create a coin worth `value` and increase the total supply /// in `cap` accordingly. public fun mint<T>(cap: &mut TreasuryCap<T>, value: u64, ctx: &mut TxContext): Coin<T> { Coin { id: object::new(ctx), balance: cap.total_supply.increase_supply(value), } } /// --- crates/sui-framework/packages/sui-framework/sources/balance.move --- /// Storable balance - an inner struct of a Coin type. /// Can be used to store coins which don't need the key ability. public struct Balance<phantom T> has store { value: u64, } /// Increase supply by `value` and create a new `Balance<T>` with this value. public fun increase_supply<T>(self: &mut Supply<T>, value: u64): Balance<T> { assert!(value < (18446744073709551615u64 - self.value), EOverflow); self.value = self.value + value; Balance { value } }

Transfer

coin transfer relies on sui’s object transfer system.
Other module can use public_transfer to transfer coin
/// --- crates/sui-framework/packages/sui-framework/sources/transfer.move --- /// Transfer ownership of `obj` to `recipient`. `obj` must have the `key` attribute, /// which (in turn) ensures that `obj` has a globally unique ID. Note that if the recipient /// address represents an object ID, the `obj` sent will be inaccessible after the transfer /// (though they will be retrievable at a future date once new features are added). /// The object must have `store` to be transferred outside of its module. public fun public_transfer<T: key + store>(obj: T, recipient: address) { transfer_impl(obj, recipient) }

Burn

sui::coin::burn burns coin.
  • It requires TreasuryCap<T>
  • It delete the Coin object
  • It update TreasuryCap.total_supply
/// --- crates/sui-framework/packages/sui-framework/sources/coin.move --- /// Destroy the coin `c` and decrease the total supply in `cap` /// accordingly. public entry fun burn<T>(cap: &mut TreasuryCap<T>, c: Coin<T>): u64 { let Coin { id, balance } = c; id.delete(); cap.total_supply.decrease_supply(balance) } /// --- crates/sui-framework/packages/sui-framework/sources/balance.move --- /// Burn a Balance<T> and decrease Supply<T>. public fun decrease_supply<T>(self: &mut Supply<T>, balance: Balance<T>): u64 { let Balance { value } = balance; assert!(self.value >= value, EOverflow); self.value = self.value - value; value } /// --- crates/sui-framework/packages/sui-framework/sources/object.move -- /// Delete the object and its `UID`. This is the only way to eliminate a `UID`. /// This exists to inform Sui of object deletions. When an object /// gets unpacked, the programmer will have to do something with its /// `UID`. The implementation of this function emits a deleted /// system event so Sui knows to process the object deletion public fun delete(id: UID) { let UID { id: ID { bytes } } = id; delete_impl(bytes) } // helper for delete native fun delete_impl(id: address);

Join

sui::coin::join combines two coins together.
/// --- crates/sui-framework/packages/sui-framework/sources/coin.move --- /// Consume the coin `c` and add its value to `self`. /// Aborts if `c.value + self.value > U64_MAX` public entry fun join<T>(self: &mut Coin<T>, c: Coin<T>) { let Coin { id, balance } = c; id.delete(); self.balance.join(balance); } /// --- crates/sui-framework/packages/sui-framework/sources/balance.move --- /// Storable balance - an inner struct of a Coin type. /// Can be used to store coins which don't need the key ability. public struct Balance<phantom T> has store { value: u64, } /// Join two balances together. public fun join<T>(self: &mut Balance<T>, balance: Balance<T>): u64 { let Balance { value } = balance; self.value = self.value + value; self.value }

Split

sui::coin::split splits one token to two tokens.
/// --- crates/sui-framework/packages/sui-framework/sources/coin.move --- /// Split coin `self` to two coins, one with balance `split_amount`, /// and the remaining balance is left is `self`. public fun split<T>(self: &mut Coin<T>, split_amount: u64, ctx: &mut TxContext): Coin<T> { take(&mut self.balance, split_amount, ctx) } /// Take a `Coin` worth of `value` from `Balance`. /// Aborts if `value > balance.value` public fun take<T>(balance: &mut Balance<T>, value: u64, ctx: &mut TxContext): Coin<T> { Coin { id: object::new(ctx), balance: balance.split(value), } } /// --- crates/sui-framework/packages/sui-framework/sources/balance.move --- /// Split a `Balance` and take a sub balance from it. public fun split<T>(self: &mut Balance<T>, value: u64): Balance<T> { assert!(self.value >= value, ENotEnough); self.value = self.value - value; Balance { value } }
 

Devide Coin into N Coins

Split coin into n - 1 coins with equal balances. The remainder is left in the original coin.
/// --- crates/sui-framework/packages/sui-framework/sources/coin.move --- /// Split coin `self` into `n - 1` coins with equal balances. The remainder is left in /// `self`. Return newly created coins. public fun divide_into_n<T>(self: &mut Coin<T>, n: u64, ctx: &mut TxContext): vector<Coin<T>> { assert!(n > 0, EInvalidArg); assert!(n <= value(self), ENotEnough); let mut vec = vector[]; let mut i = 0; let split_amount = value(self) / n; while (i < n - 1) { vec.push_back(self.split(split_amount, ctx)); i = i + 1; }; vec } /// --- crates/sui-framework/packages/sui-framework/sources/balance.move --- // === Balance <-> Coin accessors and type morphing === /// Storable balance - an inner struct of a Coin type. /// Can be used to store coins which don't need the key ability. public struct Balance<phantom T> has store { value: u64, } /// Public getter for the coin's value public fun value<T>(self: &Coin<T>): u64 { self.balance.value() } /// Get the amount stored in a `Balance`. public fun value<T>(self: &Balance<T>): u64 { self.value } /// --- crates/sui-framework/packages/sui-framework/../move-stdlib/sources/vector.move --- #[bytecode_instruction] /// Add element `e` to the end of the vector `v`. public native fun push_back<Element>(v: &mut vector<Element>, e: Element);

Create Zero Coin

/// --- crates/sui-framework/packages/sui-framework/sources/coin.move --- /// Make any Coin with a zero value. Useful for placeholding /// bids/payments or preemptively making empty balances. public fun zero<T>(ctx: &mut TxContext): Coin<T> { Coin { id: object::new(ctx), balance: balance::zero() } } /// --- crates/sui-framework/packages/sui-framework/sources/balance.move --- /// Create a zero `Balance` for type `T`. public fun zero<T>(): Balance<T> { Balance { value: 0 } }

Destroy Zero Coin

/// --- crates/sui-framework/packages/sui-framework/sources/coin.move --- /// Destroy a coin with value zero public fun destroy_zero<T>(c: Coin<T>) { let Coin { id, balance } = c; id.delete(); balance.destroy_zero() } /// --- crates/sui-framework/packages/sui-framework/sources/balance.move --- /// Destroy a zero `Balance`. public fun destroy_zero<T>(balance: Balance<T>) { assert!(balance.value == 0, ENonZero); let Balance { value: _ } = balance; }

Create Regulated Currency V2

sui::coin::create_regulated_currency_v2 creates coin with deny ability.
DenyCapV2 holder has capability to update deny list. Address in deny list can’t use coin as input in transaction, also can’t receive coin.
The list of addresses that aren't able to use a particular regulated coin is held within a system-created DenyList shared object. If you have access to the DenyCap, then you can use the coin::deny_list_v2_add and coin::deny_list_v2_remove functions to add and remove addresses.
/// --- crates/sui-framework/packages/sui-framework/sources/coin.move --- /// Capability allowing the bearer to deny addresses from using the currency's coins-- /// immediately preventing those addresses from interacting with the coin as an input to a /// transaction and at the start of the next preventing them from receiving the coin. /// If `allow_global_pause` is true, the bearer can enable a global pause that behaves as if /// all addresses were added to the deny list. public struct DenyCapV2<phantom T> has key, store { id: UID, allow_global_pause: bool, } /// Similar to CoinMetadata, but created only for regulated coins that use the DenyList. /// This object is always immutable. public struct RegulatedCoinMetadata<phantom T> has key { id: UID, /// The ID of the coin's CoinMetadata object. coin_metadata_object: ID, /// The ID of the coin's DenyCap object. deny_cap_object: ID, } /// This creates a new currency, via `create_currency`, but with an extra capability that /// allows for specific addresses to have their coins frozen. When an address is added to the /// deny list, it is immediately unable to interact with the currency's coin as input objects. /// Additionally at the start of the next epoch, they will be unable to receive the currency's /// coin. /// The `allow_global_pause` flag enables an additional API that will cause all addresses to /// be denied. Note however, that this doesn't affect per-address entries of the deny list and /// will not change the result of the "contains" APIs. public fun create_regulated_currency_v2<T: drop>( witness: T, decimals: u8, symbol: vector<u8>, name: vector<u8>, description: vector<u8>, icon_url: Option<Url>, allow_global_pause: bool, ctx: &mut TxContext, ): (TreasuryCap<T>, DenyCapV2<T>, CoinMetadata<T>) { let (treasury_cap, metadata) = create_currency( witness, decimals, symbol, name, description, icon_url, ctx, ); let deny_cap = DenyCapV2 { id: object::new(ctx), allow_global_pause, }; transfer::freeze_object(RegulatedCoinMetadata<T> { id: object::new(ctx), coin_metadata_object: object::id(&metadata), deny_cap_object: object::id(&deny_cap), }); (treasury_cap, deny_cap, metadata) } /// --- crates/sui-framework/packages/sui-framework/sources/object.move --- /// Get the underlying `ID` of `obj` public fun id<T: key>(obj: &T): ID { borrow_uid(obj).id } /// Get the `UID` for `obj`. /// Safe because Sui has an extra bytecode verifier pass that forces every struct with /// the `key` ability to have a distinguished `UID` field. /// Cannot be made public as the access to `UID` for a given object must be privileged, and /// restrictable in the object's module. native fun borrow_uid<T: key>(obj: &T): &UID;

Global pause switch

Regulated coin objects include an allow_global_pause Boolean field. When set to true, the bearer of the DenyCapV2 object for the coin type can use the coin::deny_list_v2_enable_global_pause function to pause coin activity indefinitely. Immediately upon the bearer initiating the pause, the network disallows the coin type as input for any transactions. At the start of the next epoch (epochs last ~24 hours), the network additionally disallows all addresses from receiving the coin type.
When the bearer of the DenyCapV2 object for the coin type removes the pause using coin::deny_list_v2_disable_global_pause, the coins are immediately available to use again as transaction inputs. Addresses cannot receive the coin type, however, until the following epoch.
The global pause functionality does not affect the deny list for the coin. After clearing the pause for the coin, any addresses included in the deny list are still unable to interact with the coin.

Deny List

/// --- crates/sui-framework/packages/sui-framework/sources/deny_list.move --- /// A shared object that stores the addresses that are blocked for a given core type. public struct DenyList has key { id: UID, /// The individual deny lists. lists: Bag, } #[allow(unused_function)] /// Creation of the deny list object is restricted to the system address /// via a system transaction. fun create(ctx: &mut TxContext) { assert!(ctx.sender() == @0x0, ENotSystemAddress); let mut lists = bag::new(ctx); lists.add(COIN_INDEX, per_type_list(ctx)); let deny_list_object = DenyList { id: object::sui_deny_list_object_id(), lists, }; transfer::share_object(deny_list_object); }

Add V2 Address into Deny List

deny_list_v2_add adds an address into deny list.
/// --- crates/sui-framework/packages/sui-framework/sources/coin.move --- /// The index into the deny list vector for the `sui::coin::Coin` type. const DENY_LIST_COIN_INDEX: u64 = 0; // TODO public(package) const /// Adds the given address to the deny list, preventing it from interacting with the specified /// coin type as an input to a transaction. Additionally at the start of the next epoch, the /// address will be unable to receive objects of this coin type. public fun deny_list_v2_add<T>( deny_list: &mut DenyList, _deny_cap: &mut DenyCapV2<T>, addr: address, ctx: &mut TxContext, ) { let ty = type_name::get_with_original_ids<T>().into_string().into_bytes(); deny_list.v2_add(DENY_LIST_COIN_INDEX, ty, addr, ctx) } /// --- crates/sui-framework/packages/sui-framework/../move-stdlib/sources/type_name.move --- public struct TypeName has copy, drop, store { /// String representation of the type. All types are represented /// using their source syntax: /// "u8", "u64", "bool", "address", "vector", and so on for primitive types. /// Struct types are represented as fully qualified type names; e.g. /// `00000000000000000000000000000001::string::String` or /// `0000000000000000000000000000000a::module_name1::type_name1<0000000000000000000000000000000a::module_name2::type_name2<u64>>` /// Addresses are hex-encoded lowercase values of length ADDRESS_LENGTH (16, 20, or 32 depending on the Move platform) name: String, } /// Return a value representation of the type `T`. Package IDs /// that appear in fully qualified type names in the output from /// this function are original IDs (the ID of the first version of /// the package, even if the type in question was introduced in a /// later upgrade). public native fun get_with_original_ids<T>(): TypeName;

Remove Address from Deny List

deny_list_v2_add removes an address from deny list.
/// --- crates/sui-framework/packages/sui-framework/sources/coin.move --- /// Removes an address from the deny list. Similar to `deny_list_v2_add`, the effect for input /// objects will be immediate, but the effect for receiving objects will be delayed until the /// next epoch. public fun deny_list_v2_remove<T>( deny_list: &mut DenyList, _deny_cap: &mut DenyCapV2<T>, addr: address, ctx: &mut TxContext, ) { let ty = type_name::get_with_original_ids<T>().into_string().into_bytes(); deny_list.v2_remove(DENY_LIST_COIN_INDEX, ty, addr, ctx) }