Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/examples/loyalty/Move.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by move; do not edit
# This file should be checked in.

[move]
version = 4

[pinned.testnet.MoveStdlib]
source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "655576caa3d7faaed39ebbc15d1bcc91d0761aee" }
use_environment = "testnet"
manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363"
deps = {}

[pinned.testnet.Sui]
source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "655576caa3d7faaed39ebbc15d1bcc91d0761aee" }
use_environment = "testnet"
manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C"
deps = { MoveStdlib = "MoveStdlib" }

[pinned.testnet.loyalty]
source = { root = true }
use_environment = "testnet"
manifest_digest = "F42F13A0483640C91598349E0E0C125899BD5635A850639255F6EC2E598ADAF8"
deps = { pas = "pas", ptb = "ptb", std = "MoveStdlib", sui = "Sui" }

[pinned.testnet.pas]
source = { local = "../../pas" }
use_environment = "testnet"
manifest_digest = "38AA62656ABE7551C444DA427ADBAA7751CB67250663D39FCDE36E938138EA7D"
deps = { ptb = "ptb", std = "MoveStdlib", sui = "Sui" }

[pinned.testnet.ptb]
source = { local = "../../ptb" }
use_environment = "testnet"
manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87"
deps = { std = "MoveStdlib", sui = "Sui" }
7 changes: 7 additions & 0 deletions packages/examples/loyalty/Move.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "loyalty"
edition = "2024.beta"

[dependencies]
pas = { local = "../../pas" }
ptb = { local = "../../ptb" }
22 changes: 22 additions & 0 deletions packages/examples/loyalty/sources/loyalty_coin.move
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/// LOYALTY_COIN currency type definition and creation.
module loyalty::loyalty_coin;

use sui::coin_registry;

public struct LOYALTY_COIN has drop {}

fun init(otw: LOYALTY_COIN, ctx: &mut TxContext) {
let (initializer, cap) = coin_registry::new_currency_with_otw(
otw,
6,
b"LYL".to_string(),
b"Loyalty Points".to_string(),
b"Example loyalty points demonstrating unlock behavior".to_string(),
b"https://example.com".to_string(),
ctx,
);
let metadata = initializer.finalize(ctx);

transfer::public_transfer(cap, ctx.sender());
transfer::public_transfer(metadata, ctx.sender());
}
85 changes: 85 additions & 0 deletions packages/examples/loyalty/sources/loyalty_manager.move
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/// Loyalty manager: gates transfers and redemptions (unlocks).
///
/// Transfers between accounts are freely allowed.
/// Redemptions (unlocks) are gated per-address — the manager marks which
/// addresses are eligible to redeem their points.
///
/// IMPORTANT: once a redemption is approved and resolved, the resulting
/// Balance / Coin is completely unrestricted. The manager has no further
/// control over those funds.
module loyalty::loyalty_manager;

use loyalty::loyalty_coin::LOYALTY_COIN;
use pas::request::Request;
use pas::send_funds::SendFunds;
use pas::unlock_funds::UnlockFunds;
use sui::balance::Balance;
use sui::vec_set::{Self, VecSet};

// ==== Error Codes ====

#[error(code = 0)]
const ENotRedeemable: vector<u8> = b"Address is not eligible for redemption";

// ==== Structs ====

/// Witness stamp for approved transfers.
public struct TransferApproval() has drop;

/// Witness stamp for approved redemptions (unlocks).
public struct RedeemApproval() has drop;

/// Admin capability for managing the loyalty program.
public struct ManagerCap has key, store { id: UID }

/// Shared registry of addresses eligible to redeem (unlock) their points.
public struct RedeemRegistry has key {
id: UID,
redeemable: VecSet<address>,
}

fun init(ctx: &mut TxContext) {
transfer::transfer(ManagerCap { id: object::new(ctx) }, ctx.sender());
transfer::share_object(RedeemRegistry {
id: object::new(ctx),
redeemable: vec_set::empty(),
});
}

// ==== Public ====

/// Approve an account-to-account transfer. Transfers are freely allowed —
/// points stay within the managed system regardless of who receives them.
public fun approve_transfer(request: &mut Request<SendFunds<Balance<LOYALTY_COIN>>>) {
request.approve(TransferApproval());
}

/// Approve a redemption (unlock) request if the owner is eligible.
///
/// WARNING: after the unlock resolves, the resulting Balance<LOYALTY_COIN> is
/// unrestricted — it can be sent to any address
/// without any further restrictions.
public(package) fun approve_redeem(
registry: &RedeemRegistry,
request: &mut Request<UnlockFunds<Balance<LOYALTY_COIN>>>,
) {
assert!(registry.redeemable.contains(&request.data().owner()), ENotRedeemable);
request.approve(RedeemApproval());
}

/// Mark an address as eligible for redemption.
public fun allow_redeem(registry: &mut RedeemRegistry, _cap: &ManagerCap, user: address) {
registry.redeemable.insert(user);
}

/// Revoke redemption eligibility for an address.
public fun disallow_redeem(registry: &mut RedeemRegistry, _cap: &ManagerCap, user: address) {
registry.redeemable.remove(&user);
}

// ==== Package ====

/// Permit for registering the TransferApproval template command.
public(package) fun transfer_approval_permit(): internal::Permit<TransferApproval> {
internal::permit()
}
93 changes: 93 additions & 0 deletions packages/examples/loyalty/sources/treasury.move
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/// Treasury operations for LOYALTY_COIN.
///
/// Handles minting (deposit into Account) and redemption (unlock) resolution.
/// Demonstrates that once funds are redeemed (unlocked), they leave the managed
/// system and become unrestricted — the loyalty manager loses control.
module loyalty::treasury;

use loyalty::loyalty_coin::LOYALTY_COIN;
use loyalty::loyalty_manager::{Self, RedeemRegistry, TransferApproval, RedeemApproval};
use pas::account::Account;
use pas::namespace::Namespace;
use pas::policy::{Self, Policy};
use pas::request::Request;
use pas::templates::{PAS, Templates};
use pas::unlock_funds::{Self, UnlockFunds};
use ptb::ptb;
use std::type_name;
use sui::balance::Balance;
use sui::coin::TreasuryCap;

// ==== Setup ====

/// One-time setup: PAS policy + approval templates.
/// Call after publishing (TreasuryCap is created in `loyalty_coin::init`).
#[allow(lint(self_transfer))]
public fun setup(
namespace: &mut Namespace,
templates: &mut Templates,
treasury_cap: &mut TreasuryCap<LOYALTY_COIN>,
ctx: &mut TxContext,
) {
// 1. Create policy — clawback disabled (not the focus of this example)
let (mut policy, policy_cap) = policy::new_for_currency(
namespace,
treasury_cap,
false,
);

// 2. Set required approvals per action
policy.set_required_approval<_, TransferApproval>(&policy_cap, "send_funds");
policy.set_required_approval<_, RedeemApproval>(&policy_cap, "unlock_funds");

// 3. Register template commands so the SDK can auto-construct approval calls
let type_name = type_name::with_defining_ids<LOYALTY_COIN>();

// Template for transfer approval (permissionless — no cap needed)
let transfer_cmd = ptb::move_call(
type_name.address_string().to_string(),
"loyalty_manager",
"approve_transfer",
vector[ptb::ext_input<PAS>("request")],
vector[],
);
templates.set_template_command(loyalty_manager::transfer_approval_permit(), transfer_cmd);

policy.share();
transfer::public_transfer(policy_cap, ctx.sender());
}

// ==== Mint & Redeem ====

/// Mint loyalty points and deposit into a user's Account.
public fun mint(cap: &mut TreasuryCap<LOYALTY_COIN>, to_account: &Account, amount: u64) {
to_account.deposit_balance(cap.mint_balance(amount));
}

/// Approve and resolve a redemption (unlock) request.
///
/// CAUTION: After this function returns, the resulting Coin<LOYALTY_COIN>
/// is a regular, unrestricted coin. It can be:
/// - Transferred to ANY address (no manager approval needed)
/// - Split, merged, or used in DeFi protocols
/// - Sent to addresses that would otherwise fail compliance checks
///
/// Issuers should carefully consider whether enabling `unlock_funds`
/// is appropriate for their token's compliance requirements.
#[allow(lint(self_transfer))]
public fun redeem(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since approve_redeem is public, why do we need this function at all? I'd expect thjis to be called from a PTB

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, for this exact case it is not necessary, but I prefer to show the unlock flow directly in Move rather than only through a PTB. There are cases where the redeemed balance should not be sent back to ctx.sender(), but instead transferred in a controlled way to a specific recipient address or even kept in the contracts. I think it’s useful to demonstrate this flow in Move. What do you think?

I can make approve_redeem public(package) so callers are required to go through treasury::redeem, which performs the resolve and send steps. We could also change the destination of the funds if needed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am ok with either direction, but not having both public!

Copy link
Collaborator Author

@chariskms chariskms Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered adding a whitelist of treasury recipient addresses, but for an example it adds complexity and contradicts the idea of an unlock (may confuse).

Instead, I made approve_redeem as public(package) so redemptions must go through treasury::redeem, and the resolved balance is still sent to ctx.sender(). I think this keeps the flow controlled while clearly showing that the balance is fully unlocked.

registry: &RedeemRegistry,
policy: &Policy<Balance<LOYALTY_COIN>>,
mut request: Request<UnlockFunds<Balance<LOYALTY_COIN>>>,
ctx: &mut TxContext,
) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be better to return ): Balance<LOYALTY_COIN> to keep it composable?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could remove this function entirely, or use the balance to send the funds in a controlled way to another destination.

// Check eligibility and approve the redemption
loyalty_manager::approve_redeem(registry, &mut request);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to instead return a witness from the loyalty manager since we're acutally doing the unlock here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if we go with the public(package) approach


// Resolve against policy — returns raw Balance<LOYALTY_COIN>
let balance = unlock_funds::resolve(request, policy);

// The balance is now completely outside the managed system.
// Sending as balance to the sender's address — no further restrictions apply.
balance.send_funds(ctx.sender());
}