Skip to main content

Loyalty Tokens

A closed-loop token is a token that can only be used within specific workflows you define. Unlike standard Coin types that users can freely transfer and trade, closed-loop tokens restrict what actions holders can perform. You control minting, spending, and transfer through a TokenPolicy that enforces your rules onchain (Closed-Loop Token standard).

Using the Sui Closed-Loop Token standard, you can create tokens that are valid only for a specific service, like an airline that wants to grant tokens to frequent flyers to purchase tickets or upgrades.

In this example, the Admin sends LOYALTY tokens to the users of your service as a reward for their continued business. The example creates a GiftShop where holders can spend LOYALTY tokens to buy Gifts.

The loyalty.move source file contains the examples::loyalty module code that creates the loyalty token. The module includes the One-Time Witness (OTW), a struct with the same name as the module (LOYALTY) that has only the drop ability and no fields. The OTW ensures that the currency can only be created once, during the module's init function.

/// The OTW for the Token / Coin.
public struct LOYALTY has drop {}

The init function of the module uses the LOYALTY OTW to create the token. It makes use of the OTW LOYALTY type defined previously in its call to create_currency. The function also defines a policy, sending both the policy capability and treasury capability to the address associated with the publish event. The holder of these transferable capabilities can mint new LOYALTY tokens and modify their policies.

info

init functions run only during the package publish event.

#[allow(deprecated_usage)]
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);

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));
}

The LOYALTY minting function is called reward_user. The holder of the TreasuryCap can call this function to mint new loyalty tokens. The function uses the token::mint function to create the token and token::transfer to send it to the intended recipient.

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);
}

Lastly, a buy_a_gift function handles the redemption of LOYALTY tokens for Gift types. The function ensures the gift price matches the number of loyalty tokens spent, then uses the token::spend function to handle the treasury bookkeeping.

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);

token::add_approval(GiftShop {}, &mut req, ctx);

(gift, req)
}

Implementation considerations

When you implement loyalty tokens, consider the following:

  • Protect the TreasuryCap. The holder of the TreasuryCap can mint unlimited tokens. Store it in a multisig address or a dedicated custody solution, not a single hot wallet (Security Best Practices).
  • Gate the spend action with rules. Without a rule on the spend action, any holder can spend tokens without authorization. The GiftShop witness pattern shown above ensures tokens can only be spent through the buy_a_gift function. See Spending for details.
  • Handle insufficient balance. If a user tries to spend more tokens than they hold, the transaction aborts. Your frontend should check the user's token balance before constructing the spend transaction.

Token lifecycle

Loyalty tokens follow this lifecycle:

  1. Minting: The TreasuryCap holder calls reward_user to mint tokens to a recipient.
  2. Spending: The recipient calls buy_a_gift to spend tokens. The token::spend function consumes the token and creates an ActionRequest.
  3. Settlement: The ActionRequest is confirmed against the TokenPolicy, which accumulates the spent balance.
  4. Burning: The TreasuryCap holder can remove the accumulated spent balance from the TokenPolicy, effectively burning the tokens.

View the full example on GitHub.