-
Notifications
You must be signed in to change notification settings - Fork 1
feat: loyalty unlock example #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" } |
| 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" } |
| 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()); | ||
| } |
| 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() | ||
| } |
| 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( | ||
| registry: &RedeemRegistry, | ||
| policy: &Policy<Balance<LOYALTY_COIN>>, | ||
| mut request: Request<UnlockFunds<Balance<LOYALTY_COIN>>>, | ||
| ctx: &mut TxContext, | ||
| ) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be better to return
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since
approve_redeemis public, why do we need this function at all? I'd expect thjis to be called from a PTBThere was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.