From 6cb4ec2030a77b92a20659238c39df13b67e31fa Mon Sep 17 00:00:00 2001 From: Haris Katimertzis Date: Fri, 13 Mar 2026 16:05:00 +0200 Subject: [PATCH 1/4] feat: loyalty unlock example --- packages/examples/loyalty/Move.lock | 35 +++++++ packages/examples/loyalty/Move.toml | 7 ++ .../loyalty/sources/loyalty_coin.move | 22 +++++ .../loyalty/sources/loyalty_manager.move | 85 +++++++++++++++++ .../examples/loyalty/sources/treasury.move | 93 +++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 packages/examples/loyalty/Move.lock create mode 100644 packages/examples/loyalty/Move.toml create mode 100644 packages/examples/loyalty/sources/loyalty_coin.move create mode 100644 packages/examples/loyalty/sources/loyalty_manager.move create mode 100644 packages/examples/loyalty/sources/treasury.move diff --git a/packages/examples/loyalty/Move.lock b/packages/examples/loyalty/Move.lock new file mode 100644 index 0000000..e46341b --- /dev/null +++ b/packages/examples/loyalty/Move.lock @@ -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" } diff --git a/packages/examples/loyalty/Move.toml b/packages/examples/loyalty/Move.toml new file mode 100644 index 0000000..d30fedb --- /dev/null +++ b/packages/examples/loyalty/Move.toml @@ -0,0 +1,7 @@ +[package] +name = "loyalty" +edition = "2024.beta" + +[dependencies] +pas = { local = "../../pas" } +ptb = { local = "../../ptb" } diff --git a/packages/examples/loyalty/sources/loyalty_coin.move b/packages/examples/loyalty/sources/loyalty_coin.move new file mode 100644 index 0000000..b933e5f --- /dev/null +++ b/packages/examples/loyalty/sources/loyalty_coin.move @@ -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()); +} diff --git a/packages/examples/loyalty/sources/loyalty_manager.move b/packages/examples/loyalty/sources/loyalty_manager.move new file mode 100644 index 0000000..eaf759e --- /dev/null +++ b/packages/examples/loyalty/sources/loyalty_manager.move @@ -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 = 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
, +} + +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>>) { + request.approve(TransferApproval()); +} + +/// Approve a redemption (unlock) request if the owner is eligible. +/// +/// WARNING: after the unlock resolves, the resulting Balance is +/// unrestricted — it can be sent to any address +/// without any further restrictions. +public fun approve_redeem( + registry: &RedeemRegistry, + request: &mut Request>>, +) { + 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 { + internal::permit() +} diff --git a/packages/examples/loyalty/sources/treasury.move b/packages/examples/loyalty/sources/treasury.move new file mode 100644 index 0000000..caccdd7 --- /dev/null +++ b/packages/examples/loyalty/sources/treasury.move @@ -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, + 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(); + + // 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("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, 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 +/// 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( + registry: &RedeemRegistry, + policy: &Policy>, + mut request: Request>>, + ctx: &mut TxContext, +) { + // Check eligibility and approve the redemption + loyalty_manager::approve_redeem(registry, &mut request); + + // Resolve against policy — returns raw Balance + let balance = unlock_funds::resolve(request, policy); + + // The balance is now completely outside the managed system. + // Converting to Coin and transferring to the sender — no further restrictions apply. + transfer::public_transfer(balance.into_coin(ctx), ctx.sender()); +} From 15b766e33a8bb4eedde662e5092c5d6999fba92f Mon Sep 17 00:00:00 2001 From: Haris Katimertzis Date: Fri, 13 Mar 2026 17:24:31 +0200 Subject: [PATCH 2/4] fix: use address balances --- packages/examples/loyalty/sources/treasury.move | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/examples/loyalty/sources/treasury.move b/packages/examples/loyalty/sources/treasury.move index caccdd7..10e2404 100644 --- a/packages/examples/loyalty/sources/treasury.move +++ b/packages/examples/loyalty/sources/treasury.move @@ -89,5 +89,5 @@ public fun redeem( // The balance is now completely outside the managed system. // Converting to Coin and transferring to the sender — no further restrictions apply. - transfer::public_transfer(balance.into_coin(ctx), ctx.sender()); + balance.send_funds(ctx.sender()); } From 48a66086d91ded5c6b313eb4cda5aeda8f2d1955 Mon Sep 17 00:00:00 2001 From: Haris Katimertzis Date: Fri, 13 Mar 2026 17:26:25 +0200 Subject: [PATCH 3/4] chore: comments --- packages/examples/loyalty/sources/treasury.move | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/examples/loyalty/sources/treasury.move b/packages/examples/loyalty/sources/treasury.move index 10e2404..3ddf16b 100644 --- a/packages/examples/loyalty/sources/treasury.move +++ b/packages/examples/loyalty/sources/treasury.move @@ -88,6 +88,6 @@ public fun redeem( let balance = unlock_funds::resolve(request, policy); // The balance is now completely outside the managed system. - // Converting to Coin and transferring to the sender — no further restrictions apply. + // Sending as balance to the sender's address — no further restrictions apply. balance.send_funds(ctx.sender()); } From 90d76b08c701bc41844f3aa27937ce1288fd5080 Mon Sep 17 00:00:00 2001 From: Haris Katimertzis Date: Tue, 17 Mar 2026 18:08:18 +0200 Subject: [PATCH 4/4] feat: change approve_redeem visibility to public(package) --- packages/examples/loyalty/sources/loyalty_manager.move | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/examples/loyalty/sources/loyalty_manager.move b/packages/examples/loyalty/sources/loyalty_manager.move index eaf759e..e05e5ab 100644 --- a/packages/examples/loyalty/sources/loyalty_manager.move +++ b/packages/examples/loyalty/sources/loyalty_manager.move @@ -59,7 +59,7 @@ public fun approve_transfer(request: &mut Request is /// unrestricted — it can be sent to any address /// without any further restrictions. -public fun approve_redeem( +public(package) fun approve_redeem( registry: &RedeemRegistry, request: &mut Request>>, ) {