Decode Sui Closed Loop Token Standard

Decode Sui Closed Loop Token Standard

Tags
Published
February 22, 2025
Author

Overview

The Closed-Loop Token in Sui aims to provide a secure and efficient method of defining restrictive token within the Sui ecosystem. It allows token creator to define arbitrary rules regarding actions of that token. Like rules on the transfer action, spend action, burn action, etc.

Key Concepts:

  • Closed-Loop Token is a token that can only be transferred within a specific closed system or "loop," meaning it cannot be easily moved outside of this loop without explicit actions.
  • This loop can be a set of pre-defined parties, such as users, dApps, or entities that are allowed to interact with the token, making it ideal for scenarios where you want to restrict the token's circulation to only trusted participants or specific use cases.

Problem It Solves:

The main problem Closed-Loop Tokens address is the security and usability issue of transferring tokens in ecosystems where you want to prevent malicious behavior, unauthorized transfers, or ensure that tokens are only used within specific, trusted environments.
Some of the problems it solves include:
  • Reducing Unintended Token Movement: By restricting token transfers to a closed group, it reduces the risk of tokens being transferred to unauthorized or malicious addresses.
  • Improving User Experience for dApps: It helps dApp developers create more secure token models where transfers are more predictable and controlled within their ecosystems.

Comparision Between Token and Coin

  1. Key Abilities:
      • Sui Coin: Has both key and store abilities, allowing free transfer and storage.
      • Sui Token: Has only key ability, restricting it from being freely transferred or stored unless custom policies allow.
  1. Policies:
      • Sui Coin: No inherent policies, unrestricted usage.
      • Sui Token: Custom TokenPolicies can define rules for transfers, spends, and conversions.
  1. Conversion:
      • Sui Token: Can be converted to/from coins via token::to_coin and token::from_coin.

Definition

Token's definition is similar to Coin execept that it has no store ability. This means token can’t be transferred freely using system public_transfer function in other module. (Recall that public_transfer can only transfer object with store ability.)
Also, the transfer function defined in the same module creates an ActionRequest instance which has no drop ability(hot-potato). So that this ActionRequest instance should be handled correctly in other functions where rules will be checked to authority the transfer action.
This definition ensures Token can only be owned by wallet and has restrictive transfer ability.
/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// A single `Token` with `Balance` inside. Can only be owned by an address, /// and actions performed on it must be confirmed in a matching `TokenPolicy`. public struct Token<phantom T> has key { id: UID, /// The Balance of the `Token`. balance: Balance<T>, } /// Transfer a `Token` to a `recipient`. Creates an `ActionRequest` for the /// "transfer" action. The `ActionRequest` contains the `recipient` field /// to be used in verification. public fun transfer<T>(t: Token<T>, recipient: address, ctx: &mut TxContext): ActionRequest<T> { let amount = t.balance.value(); transfer::transfer(t, recipient); new_request( transfer_action(), amount, option::some(recipient), option::none(), ctx, ) } /// A request to perform an "Action" on a token. Stores the information /// about the action to be performed and must be consumed by the `confirm_request` /// or `confirm_request_mut` functions when the Rules are satisfied. public struct ActionRequest<phantom T> { /// Name of the Action to look up in the Policy. Name can be one of the /// default actions: `transfer`, `spend`, `to_coin`, `from_coin` or a /// custom action. name: String, /// Amount is present in all of the txs amount: u64, /// Sender is a permanent field always sender: address, /// Recipient is only available in `transfer` action. recipient: Option<address>, /// The balance to be "spent" in the `TokenPolicy`, only available /// in the `spend` action. spent_balance: Option<Balance<T>>, /// Collected approvals (stamps) from completed `Rules`. They're matched /// against `TokenPolicy.rules` to determine if the request can be /// confirmed. approvals: VecSet<TypeName>, } /// Create a new `ActionRequest`. /// Publicly available method to allow for custom actions. public fun new_request<T>( name: String, amount: u64, recipient: Option<address>, spent_balance: Option<Balance<T>>, ctx: &TxContext, ): ActionRequest<T> { ActionRequest { name, amount, recipient, spent_balance, sender: ctx.sender(), approvals: vec_set::empty(), } }

How can Token support arbitrary rules?

  1. There is a shared TokenPolicy object where rules on actions are defined.
  1. Each action like transfer will generate an ActionRequest hot potato which is needed to be handled in the tx. (What’s hot potato in sui)
  1. Those functions handle the ActionRequest checks whether the ActionRequest has gathered all approvals for that action’s rules to authority the action request.
  1. TokenPolicyCap owner can add rules for action. And only module defines the rule can approve the ActionRequest . So that module can define arbitrary logic to check ActionRequest and approve the rule.
  1. After having gathered enough approvals from rule modules, ActionRequest can be destroyed can and the tx can be executed.
  1. Additionally, TreasuryCap or TokenPolicyCap has authority to bypass rules to authorize action.
notion image

spent_balance in TokenPolicy and ActionRequest

spent_balance means amount of token has been used or burnt.
During action authorization, the burnt token amount will be recorded in TokenPolicy rather than incur update on the TreasuryCap directly because TreasuryCap is not shared and user doesn’t have access to it.
Later TreasuryCap owner can call sui::token::flush to update the total supply of recorded in TreasuryCap according to TokenPolicy.spent_balance.

Token Policy

TokenPolicy is an object used to define rules on actions.
TokenPolicy.rules is a VecMap defines rules on actions. The key is action, value is a set of rules on that action. This means each action can define multiple rules.
/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// `TokenPolicy` represents a set of rules that define what actions can be /// performed on a `Token` and which `Rules` must be satisfied for the /// action to succeed. /// /// - For the sake of availability, `TokenPolicy` is a `key`-only object. /// - Each `TokenPolicy` is managed by a matching `TokenPolicyCap`. /// - For an action to become available, there needs to be a record in the /// `rules` VecMap. To allow an action to be performed freely, there's an /// `allow` function that can be called by the `TokenPolicyCap` owner. public struct TokenPolicy<phantom T> has key { id: UID, /// The balance that is effectively spent by the user on the "spend" /// action. However, actual decrease of the supply can only be done by /// the `TreasuryCap` owner when `flush` is called. /// /// This balance is effectively spent and cannot be accessed by anyone /// but the `TreasuryCap` owner. spent_balance: Balance<T>, /// The set of rules that define what actions can be performed on the /// token. For each "action" there's a set of Rules that must be /// satisfied for the `ActionRequest` to be confirmed. rules: VecMap<String, VecSet<TypeName>>, }

Create TokenPolicy

notion image
Only token’s TreasuryCap owner can call new_policy create token policy of that token.
It constructs two instances:
  1. TokenPolicy
      • It defines rules on actions. And it will be shared, every action authorization function requires TokenPolicy to check policy.
  1. TokenPolicyCap
      • It grants ability to modify rules in TokenPolicy
Multiple TokenPolicys for single token is allowed. TokenPolicyCap uses `for` to identify corresponding TokenPolicy it controls.
/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// A Capability that manages a single `TokenPolicy` specified in the `for` /// field. Created together with `TokenPolicy` in the `new` function. public struct TokenPolicyCap<phantom T> has key, store { id: UID, `for`: ID } /// Create a new `TokenPolicy` and a matching `TokenPolicyCap`. /// The `TokenPolicy` must then be shared using the `share_policy` method. /// /// `TreasuryCap` guarantees full ownership over the currency, and is unique, /// hence it is safe to use it for authorization. public fun new_policy<T>( _treasury_cap: &TreasuryCap<T>, ctx: &mut TxContext, ): (TokenPolicy<T>, TokenPolicyCap<T>) { let policy = TokenPolicy { id: object::new(ctx), spent_balance: balance::zero(), rules: vec_map::empty(), }; let cap = TokenPolicyCap { id: object::new(ctx), `for`: object::id(&policy), }; (policy, cap) }

Add Rule

notion image
sui::token::add_rule_for_action can add rule on action of certain token.
  • Only corresponding TokenPolicyCap owner can add rule for the action of TokenPolicy.
  • Rule is witness (it only has drop ability). It ensures only module defines the Rule can add the Rule and authorize the Rule for action.
  • If there is no such action, first call allow to register it.
  • Insert the Rule into action’s rule list.
/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// Adds a Rule for an action with `name` in the `TokenPolicy`. /// /// Aborts if the `TokenPolicyCap` is not matching the `TokenPolicy`. public fun add_rule_for_action<T, Rule: drop>( self: &mut TokenPolicy<T>, cap: &TokenPolicyCap<T>, action: String, ctx: &mut TxContext, ) { assert!(object::id(self) == cap.`for`, ENotAuthorized); if (!self.rules.contains(&action)) { allow(self, cap, action, ctx); }; self.rules.get_mut(&action).insert(type_name::get<Rule>()) } /// Allows an `action` to be performed on the `Token` freely by adding an /// empty set of `Rules` for the `action`. /// /// Aborts if the `TokenPolicyCap` is not matching the `TokenPolicy`. public fun allow<T>( self: &mut TokenPolicy<T>, cap: &TokenPolicyCap<T>, action: String, _ctx: &mut TxContext, ) { assert!(object::id(self) == cap.`for`, ENotAuthorized); self.rules.insert(action, vec_set::empty()); }

Approve Rule

Module defines the Rule can add approval for that rule. add_approval inserts approval into ActionRequest.approvals.
This design is based on witness pattern. Because Rule only has drop ability, so that it can’t be stored anywhere. Only the module defines the Rule can create it.
This means, module defines Rule can implement method to accept ActionRequest, do some check, and approve it. Also the only way ActionRequest can get approved by that rule is to go through the module defines the Rule to get approval.
/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// Add an "approval" to the `ActionRequest` by providing a Witness. /// Intended to be used by Rules to add their own approvals, however, can /// be used to add arbitrary approvals to the request (not only the ones /// required by the `TokenPolicy`). public fun add_approval<T, W: drop>(_t: W, request: &mut ActionRequest<T>, _ctx: &mut TxContext) { request.approvals.insert(type_name::get<W>()) }

Allow

allow registers empty rule set for the action, which means the action can be executed freely.
/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// Allows an `action` to be performed on the `Token` freely by adding an /// empty set of `Rules` for the `action`. /// /// Aborts if the `TokenPolicyCap` is not matching the `TokenPolicy`. public fun allow<T>( self: &mut TokenPolicy<T>, cap: &TokenPolicyCap<T>, action: String, _ctx: &mut TxContext, ) { assert!(object::id(self) == cap.`for`, ENotAuthorized); self.rules.insert(action, vec_set::empty()); }

Disallow

disallow delete the action which means that action is baned.
/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// Completely disallows an `action` on the `Token` by removing the record /// from the `TokenPolicy.rules`. /// /// Aborts if the `TokenPolicyCap` is not matching the `TokenPolicy`. public fun disallow<T>( self: &mut TokenPolicy<T>, cap: &TokenPolicyCap<T>, action: String, _ctx: &mut TxContext, ) { assert!(object::id(self) == cap.`for`, ENotAuthorized); self.rules.remove(&action); }

Rule Config

Token standard supports to add some Config to each rule. This is useful when the rule depends on some data, such as black address list in rule to deny certain transfer. But in fact this Config also can be stored in module defines the Rule .
  1. TokenPolicyCap can add/remove rule Config.
  1. Config only requires store ability, so the structure of Config is flexible.
  1. It uses dynamic field to store the Config
  1. Each rule can only have one Config because the key is determined by Rule

Add Rule Config

/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// Add a `Config` for a `Rule` in the `TokenPolicy`. Rule configuration is /// independent from the `TokenPolicy.rules` and needs to be managed by the /// Rule itself. Configuration is stored per `Rule` and not per `Rule` per /// `Action` to allow reuse in different actions. /// /// - Rule witness guarantees that the `Config` is approved by the Rule. /// - `TokenPolicyCap` guarantees that the `Config` setup is initiated by /// the `TokenPolicy` owner. public fun add_rule_config<T, Rule: drop, Config: store>( _rule: Rule, self: &mut TokenPolicy<T>, cap: &TokenPolicyCap<T>, config: Config, _ctx: &mut TxContext, ) { assert!(object::id(self) == cap.`for`, ENotAuthorized); df::add(&mut self.id, key<Rule>(), config) }

Remove Rule Config

/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// Remove a `Config` for a `Rule` in the `TokenPolicy`. /// Unlike the `add_rule_config`, this function does not require a `Rule` /// witness, hence can be performed by the `TokenPolicy` owner on their own. /// /// Rules need to make sure that the `Config` is present when performing /// verification of the `ActionRequest`. /// /// Aborts if: /// - the Config is not present /// - `TokenPolicyCap` is not matching the `TokenPolicy` public fun remove_rule_config<T, Rule, Config: store>( self: &mut TokenPolicy<T>, cap: &TokenPolicyCap<T>, _ctx: &mut TxContext, ): Config { assert!(has_rule_config_with_type<T, Rule, Config>(self), ENoConfig); assert!(object::id(self) == cap.`for`, ENotAuthorized); df::remove(&mut self.id, key<Rule>()) }

Handle ActionRequest

There are four ways to handle ActionRequest.

Confirm Request

confirm_request checks the request against the TokenPolicy and destroy the ActionRequest instance to authorize the action.
Process:
  1. check there is no spent balance in this action
  1. check the action is registered by checking whether policy.rules has corresponding action key.
  1. destroy spent_balance.
    1. spent_balance includes vector of Balance , because Balance can’t be dropped, spent_balance also can’t be dropped. So it needs to unpack spent_balance and destroy the vector.
  1. get all rules of the action
  1. check whether each rule has corresponding approval.
  1. unpack ActionRequest to authorize the action.
/// Confirm the request against the `TokenPolicy` and return the parameters /// of the request: (Name, Amount, Sender, Recipient). /// /// Cannot be used for `spend` and similar actions that deliver `spent_balance` /// to the `TokenPolicy`. For those actions use `confirm_request_mut`. /// /// Aborts if: /// - the action is not allowed (missing record in `rules`) /// - action contains `spent_balance` (use `confirm_request_mut`) /// - the `ActionRequest` does not meet the `TokenPolicy` rules for the action public fun confirm_request<T>( policy: &TokenPolicy<T>, request: ActionRequest<T>, _ctx: &mut TxContext, ): (String, u64, address, Option<address>) { assert!(request.spent_balance.is_none(), ECantConsumeBalance); assert!(policy.rules.contains(&request.name), EUnknownAction); let ActionRequest { name, approvals, spent_balance, amount, sender, recipient, } = request; spent_balance.destroy_none(); let rules = &(*policy.rules.get(&name)).into_keys(); let rules_len = rules.length(); let mut i = 0; while (i < rules_len) { let rule = &rules[i]; assert!(approvals.contains(rule), ENotApproved); i = i + 1; }; (name, amount, sender, recipient) } /// A request to perform an "Action" on a token. Stores the information /// about the action to be performed and must be consumed by the `confirm_request` /// or `confirm_request_mut` functions when the Rules are satisfied. public struct ActionRequest<phantom T> { /// Name of the Action to look up in the Policy. Name can be one of the /// default actions: `transfer`, `spend`, `to_coin`, `from_coin` or a /// custom action. name: String, /// Amount is present in all of the txs amount: u64, /// Sender is a permanent field always sender: address, /// Recipient is only available in `transfer` action. recipient: Option<address>, /// The balance to be "spent" in the `TokenPolicy`, only available /// in the `spend` action. spent_balance: Option<Balance<T>>, /// Collected approvals (stamps) from completed `Rules`. They're matched /// against `TokenPolicy.rules` to determine if the request can be /// confirmed. approvals: VecSet<TypeName>, } /// --- crates/sui-framework/packages/sui-framework/../move-stdlib/sources/option.move --- /// Abstraction of a value that may or may not be present. Implemented with a vector of size /// zero or one because Move bytecode does not have ADTs. public struct Option<Element> has copy, drop, store { vec: vector<Element>, }

Confirm Request Mut

The difference between confirm_request_mut and confirm_request is that confirm_request_mut receives a mutable TokenPolicy , requires ActionRequest has some spent balance, and update TokenPolicy's spent_balance.
/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// Confirm the request against the `TokenPolicy` and return the parameters /// of the request: (Name, Amount, Sender, Recipient). /// /// Unlike `confirm_request` this function requires mutable access to the /// `TokenPolicy` and must be used on `spend` action. After dealing with the /// spent balance it calls `confirm_request` internally. /// /// See `confirm_request` for the list of abort conditions. public fun confirm_request_mut<T>( policy: &mut TokenPolicy<T>, mut request: ActionRequest<T>, ctx: &mut TxContext, ): (String, u64, address, Option<address>) { assert!(policy.rules.contains(&request.name), EUnknownAction); assert!(request.spent_balance.is_some(), EUseImmutableConfirm); policy.spent_balance.join(request.spent_balance.extract()); confirm_request(policy, request, ctx) }

confirm_with_policy_cap

TokenPolicyCap owner can bypass action rules to authorize ActionRequest, the premise is that the ActionRequest has no spent balance.
/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// Confirm an `ActionRequest` as the `TokenPolicyCap` owner. This function /// allows `TokenPolicy` owner to perform Capability-gated actions ignoring /// the ruleset specified in the `TokenPolicy`. /// /// Aborts if request contains `spent_balance` due to inability of the /// `TokenPolicyCap` to decrease supply. For scenarios like this a /// `TreasuryCap` is required (see `confirm_with_treasury_cap`). public fun confirm_with_policy_cap<T>( _policy_cap: &TokenPolicyCap<T>, request: ActionRequest<T>, _ctx: &mut TxContext, ): (String, u64, address, Option<address>) { assert!(request.spent_balance.is_none(), ECantConsumeBalance); let ActionRequest { name, amount, sender, recipient, approvals: _, spent_balance, } = request; spent_balance.destroy_none(); (name, amount, sender, recipient) }

confirm_with_treasury_cap

TreasuryCap owner can bypass action rules to authorize ActionRequest.
If there is spent balance in that action request, it updates total supply.
/// --- crates/sui-framework/packages/sui-framework/sources/token.move --- /// Confirm an `ActionRequest` as the `TreasuryCap` owner. This function /// allows `TreasuryCap` owner to perform Capability-gated actions ignoring /// the ruleset specified in the `TokenPolicy`. /// /// Unlike `confirm_with_policy_cap` this function allows `spent_balance` /// to be consumed, decreasing the `total_supply` of the `Token`. public fun confirm_with_treasury_cap<T>( treasury_cap: &mut TreasuryCap<T>, request: ActionRequest<T>, _ctx: &mut TxContext, ): (String, u64, address, Option<address>) { let ActionRequest { name, amount, sender, recipient, approvals: _, spent_balance, } = request; if (spent_balance.is_some()) { treasury_cap.supply_mut().decrease_supply(spent_balance.destroy_some()); } else { spent_balance.destroy_none(); }; (name, amount, sender, recipient) }

Example

Loyalty

This loyalty system aims to create loyalty token which can’t be transferred, can only be used to exchange for gift.
 
loyalty module creates a token in the init function, where:
  1. create TokenPolicy
  1. add GiftShop rule on spend action
  1. share TokenPolicy
/// --- examples/move/token/sources/loyalty.move --- // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 /// This module illustrates a Closed Loop Loyalty Token. The `Token` is sent to /// users as a reward for their loyalty by the application Admin. The `Token` /// can be used to buy a `Gift` in the shop. /// /// Actions: /// - spend - spend the token in the shop module examples::loyalty; use sui::{coin::{Self, TreasuryCap}, token::{Self, ActionRequest, Token}}; /// Token amount does not match the `GIFT_PRICE`. const EIncorrectAmount: u64 = 0; /// The price for the `Gift`. const GIFT_PRICE: u64 = 10; /// The OTW for the Token / Coin. public struct LOYALTY has drop {} /// This is the Rule requirement for the `GiftShop`. The Rules don't need /// to be separate applications, some rules make sense to be part of the /// application itself, like this one. public struct GiftShop has drop {} /// The Gift object - can be purchased for 10 tokens. public struct Gift has key, store { id: UID, } // Create a new LOYALTY currency, create a `TokenPolicy` for it and allow // everyone to spend `Token`s if they were `reward`ed. fun init(otw: LOYALTY, ctx: &mut TxContext) { let (treasury_cap, coin_metadata) = coin::create_currency( otw, 0, // no decimals b"LOY", // symbol b"Loyalty Token", // name b"Token for Loyalty", // description option::none(), // url ctx, ); let (mut policy, policy_cap) = token::new_policy(&treasury_cap, ctx); // but we constrain spend by this shop: token::add_rule_for_action<LOYALTY, GiftShop>( &mut policy, &policy_cap, token::spend_action(), ctx, ); token::share_policy(policy); transfer::public_freeze_object(coin_metadata); transfer::public_transfer(policy_cap, tx_context::sender(ctx)); transfer::public_transfer(treasury_cap, tx_context::sender(ctx)); }

Reward User

reward_user allows TreasuryCap owner to mint loyalty token to user.
token::transfer returns a ActionRequest needed to be authorized, it uses TreasuryCap to bypass all rules and authorize the request.
/// --- examples/move/token/sources/loyalty.move --- /// Handy function to reward users. Can be called by the application admin /// to reward users for their loyalty :) /// /// `Mint` is available to the holder of the `TreasuryCap` by default and /// hence does not need to be confirmed; however, the `transfer` action /// does require a confirmation and can be confirmed with `TreasuryCap`. public fun reward_user( cap: &mut TreasuryCap<LOYALTY>, amount: u64, recipient: address, ctx: &mut TxContext, ) { let token = token::mint(cap, amount, ctx); let req = token::transfer(token, recipient, ctx); token::confirm_with_treasury_cap(cap, req, ctx); }

Buy a Gift

We want to enable user to burn loyalty token to purchase NFT. So we need to use the spend api of token module.
The spend action has rule GiftShop, so it calls add_approval to approve the rule first. Then we need to pass ActionRequest req to token::confirm_request to authorize that action.
Here the action constructor buy_a_gift and Rule are defined in same module for simplicity. But we can also define Rule in separated module.
/// --- examples/move/token/sources/loyalty.move --- /// Buy a gift for 10 tokens. The `Gift` is received, and the `Token` is /// spent (stored in the `ActionRequest`'s `burned_balance` field). public fun buy_a_gift(token: Token<LOYALTY>, ctx: &mut TxContext): (Gift, ActionRequest<LOYALTY>) { assert!(token::value(&token) == GIFT_PRICE, EIncorrectAmount); let gift = Gift { id: object::new(ctx) }; let mut req = token::spend(token, ctx); // only required because we've set this rule token::add_approval(GiftShop {}, &mut req, ctx); (gift, req) }