diff --git a/contracts/loyalty_token/src/lib.rs b/contracts/loyalty_token/src/lib.rs index 9418054..f16c22e 100644 --- a/contracts/loyalty_token/src/lib.rs +++ b/contracts/loyalty_token/src/lib.rs @@ -1,54 +1,18 @@ -//! # Loyalty Token Contract (BITE) -//! -//! A SEP-41 compatible fungible token that powers the restaurant platform's -//! customer loyalty programme. -//! -//! ## Token details -//! | Field | Value | -//! |---------|---------------| -//! | Name | Bite Rewards | -//! | Symbol | BITE | -//! | Decimals| 7 | -//! -//! ## Earning BITE -//! The admin (or an authorised minter – typically the Order contract) calls -//! `mint` after an order is marked *Delivered*. A suggested policy is: -//! **1 BITE per 10 000 stroops (0.001 XLM) spent**. -//! -//! ## Redeeming BITE -//! A customer `burn`s their BITE tokens and the backend applies a discount to -//! the next order. The redemption rate is managed off-chain. -//! -//! ## SEP-41 surface -//! Implements the full `token::Interface` trait so the token appears correctly -//! in Stellar wallets. - #![no_std] use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String}; -// --------------------------------------------------------------------------- -// Storage keys -// --------------------------------------------------------------------------- - #[contracttype] pub enum DataKey { - /// The platform admin who controls minting. Admin, - /// Optional secondary minter (e.g. the Order contract address). + /// Proposed but not yet confirmed new admin (two-step transfer). + PendingAdmin, Minter, - /// Total tokens in circulation. TotalSupply, - /// Per-account balances. Balance(Address), - /// Allowances: (owner, spender) → (amount, expiration_ledger). Allowance(Address, Address), } -// --------------------------------------------------------------------------- -// Token metadata (stored once at init) -// --------------------------------------------------------------------------- - #[contracttype] #[derive(Clone)] pub struct TokenMeta { @@ -62,10 +26,6 @@ pub enum MetaKey { Meta, } -// --------------------------------------------------------------------------- -// Allowance helper struct -// --------------------------------------------------------------------------- - #[contracttype] #[derive(Clone)] pub struct AllowanceData { @@ -73,24 +33,11 @@ pub struct AllowanceData { pub expiration_ledger: u32, } -// --------------------------------------------------------------------------- -// Contract -// --------------------------------------------------------------------------- - #[contract] pub struct LoyaltyToken; #[contractimpl] impl LoyaltyToken { - // ----------------------------------------------------------------------- - // Initialisation - // ----------------------------------------------------------------------- - - /// Deploy the BITE token. - /// - /// # Arguments - /// - `admin` – address with mint authority. - /// - `minter` – optional secondary minter (pass `admin` to disable). pub fn initialize(env: Env, admin: Address, minter: Address) { if env.storage().instance().has(&DataKey::Admin) { panic!("already initialized"); @@ -109,11 +56,6 @@ impl LoyaltyToken { env.storage().instance().extend_ttl(17_280, 17_280); } - // ----------------------------------------------------------------------- - // Admin / Minter actions - // ----------------------------------------------------------------------- - - /// Mint `amount` BITE to `to`. Only callable by admin or minter. pub fn mint(env: Env, caller: Address, to: Address, amount: i128) { caller.require_auth(); Self::assert_admin_or_minter(&env, &caller); @@ -125,21 +67,13 @@ impl LoyaltyToken { let new_balance = Self::balance_of(&env, &to) + amount; Self::set_balance(&env, &to, new_balance); - let supply: i128 = env - .storage() - .instance() - .get(&DataKey::TotalSupply) - .unwrap_or(0); - env.storage() - .instance() - .set(&DataKey::TotalSupply, &(supply + amount)); + let supply: i128 = env.storage().instance().get(&DataKey::TotalSupply).unwrap_or(0); + env.storage().instance().set(&DataKey::TotalSupply, &(supply + amount)); env.storage().instance().extend_ttl(17_280, 17_280); - env.events() - .publish((symbol_short!("mint"), symbol_short!("BITE")), (to, amount)); + env.events().publish((symbol_short!("mint"), symbol_short!("BITE")), (to, amount)); } - /// Update the authorised minter address (admin only). pub fn set_minter(env: Env, caller: Address, new_minter: Address) { caller.require_auth(); Self::assert_admin_or_panic(&env, &caller); @@ -147,45 +81,69 @@ impl LoyaltyToken { env.storage().instance().extend_ttl(17_280, 17_280); } - /// Transfer the admin role. + /// Step 1: propose a new admin. Does NOT change the active admin. + /// + /// Emits `(symbol_short!("admin"), symbol_short!("proposed"))`. + /// The new admin must call `accept_admin` to take effect. pub fn transfer_admin(env: Env, caller: Address, new_admin: Address) { caller.require_auth(); Self::assert_admin_or_panic(&env, &caller); - env.storage().instance().set(&DataKey::Admin, &new_admin); + env.storage().instance().set(&DataKey::PendingAdmin, &new_admin); + env.storage().instance().extend_ttl(17_280, 17_280); + env.events().publish( + (symbol_short!("admin"), symbol_short!("proposed")), + (caller, new_admin), + ); + } + + /// Step 2: accept a pending admin transfer. Caller must be `PendingAdmin`. + /// + /// Promotes caller to `Admin`, clears `PendingAdmin`, + /// and emits `(symbol_short!("admin"), symbol_short!("accepted"))`. + pub fn accept_admin(env: Env, caller: Address) { + caller.require_auth(); + let pending: Address = env + .storage() + .instance() + .get(&DataKey::PendingAdmin) + .expect("not pending admin"); + if caller != pending { + panic!("not pending admin"); + } + env.storage().instance().set(&DataKey::Admin, &caller); + env.storage().instance().remove(&DataKey::PendingAdmin); env.storage().instance().extend_ttl(17_280, 17_280); + env.events().publish( + (symbol_short!("admin"), symbol_short!("accepted")), + caller, + ); } - // ----------------------------------------------------------------------- - // SEP-41 token interface - // ----------------------------------------------------------------------- + /// Cancel a pending admin transfer. Only the current admin may call this. + /// + /// Clears `PendingAdmin` without changing the active admin. + pub fn cancel_transfer(env: Env, caller: Address) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + env.storage().instance().remove(&DataKey::PendingAdmin); + env.storage().instance().extend_ttl(17_280, 17_280); + } + - /// Return the token balance of `account`. pub fn balance(env: Env, account: Address) -> i128 { Self::balance_of(&env, &account) } - /// Transfer `amount` BITE from `from` to `to`. pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { from.require_auth(); Self::do_transfer(&env, &from, &to, amount); } - /// Return the current allowance for `spender` to spend on behalf of `from`. pub fn allowance(env: Env, from: Address, spender: Address) -> i128 { Self::get_allowance(&env, &from, &spender) } - /// Approve `spender` to transfer up to `amount` on behalf of `from`. - /// - /// `expiration_ledger` is the last ledger at which the approval is valid. - /// Pass `0` to revoke. - pub fn approve( - env: Env, - from: Address, - spender: Address, - amount: i128, - expiration_ledger: u32, - ) { + pub fn approve(env: Env, from: Address, spender: Address, amount: i128, expiration_ledger: u32) { from.require_auth(); if amount < 0 { panic!("allowance amount cannot be negative"); @@ -193,19 +151,12 @@ impl LoyaltyToken { if amount > 0 && expiration_ledger < env.ledger().sequence() { panic!("expiration_ledger is in the past"); } - let data = AllowanceData { - amount, - expiration_ledger, - }; + let data = AllowanceData { amount, expiration_ledger }; let ttl = expiration_ledger.saturating_sub(env.ledger().sequence()); - env.storage() - .temporary() - .set(&DataKey::Allowance(from.clone(), spender.clone()), &data); + env.storage().temporary().set(&DataKey::Allowance(from.clone(), spender.clone()), &data); if ttl > 0 { env.storage().temporary().extend_ttl( - &DataKey::Allowance(from.clone(), spender.clone()), - ttl, - ttl, + &DataKey::Allowance(from.clone(), spender.clone()), ttl, ttl, ); } env.events().publish( @@ -214,7 +165,6 @@ impl LoyaltyToken { ); } - /// Transfer `amount` on behalf of `from` using a prior allowance. pub fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) { spender.require_auth(); @@ -223,29 +173,20 @@ impl LoyaltyToken { panic!("insufficient allowance"); } - // Decrement allowance. let allowance_key = DataKey::Allowance(from.clone(), spender.clone()); - let mut data: AllowanceData = - env.storage() - .temporary() - .get(&allowance_key) - .unwrap_or(AllowanceData { - amount: 0, - expiration_ledger: 0, - }); + let mut data: AllowanceData = env.storage().temporary().get(&allowance_key) + .unwrap_or(AllowanceData { amount: 0, expiration_ledger: 0 }); data.amount -= amount; env.storage().temporary().set(&allowance_key, &data); Self::do_transfer(&env, &from, &to, amount); } - /// Burn `amount` BITE from `from`'s account. pub fn burn(env: Env, from: Address, amount: i128) { from.require_auth(); Self::do_burn(&env, &from, amount); } - /// Burn `amount` BITE from `from` using a spender's allowance. pub fn burn_from(env: Env, spender: Address, from: Address, amount: i128) { spender.require_auth(); @@ -255,24 +196,14 @@ impl LoyaltyToken { } let allowance_key = DataKey::Allowance(from.clone(), spender.clone()); - let mut data: AllowanceData = - env.storage() - .temporary() - .get(&allowance_key) - .unwrap_or(AllowanceData { - amount: 0, - expiration_ledger: 0, - }); + let mut data: AllowanceData = env.storage().temporary().get(&allowance_key) + .unwrap_or(AllowanceData { amount: 0, expiration_ledger: 0 }); data.amount -= amount; env.storage().temporary().set(&allowance_key, &data); Self::do_burn(&env, &from, amount); } - // ----------------------------------------------------------------------- - // Token metadata (SEP-41) - // ----------------------------------------------------------------------- - pub fn name(env: Env) -> String { let meta: TokenMeta = env.storage().instance().get(&MetaKey::Meta).unwrap(); meta.name @@ -289,31 +220,17 @@ impl LoyaltyToken { } pub fn total_supply(env: Env) -> i128 { - env.storage() - .instance() - .get(&DataKey::TotalSupply) - .unwrap_or(0) + env.storage().instance().get(&DataKey::TotalSupply).unwrap_or(0) } - // ----------------------------------------------------------------------- - // Private helpers - // ----------------------------------------------------------------------- - fn balance_of(env: &Env, account: &Address) -> i128 { - env.storage() - .persistent() - .get(&DataKey::Balance(account.clone())) - .unwrap_or(0) + env.storage().persistent().get(&DataKey::Balance(account.clone())).unwrap_or(0) } fn set_balance(env: &Env, account: &Address, amount: i128) { let ttl: u32 = 2_073_600; - env.storage() - .persistent() - .set(&DataKey::Balance(account.clone()), &amount); - env.storage() - .persistent() - .extend_ttl(&DataKey::Balance(account.clone()), ttl, ttl); + env.storage().persistent().set(&DataKey::Balance(account.clone()), &amount); + env.storage().persistent().extend_ttl(&DataKey::Balance(account.clone()), ttl, ttl); } fn do_transfer(env: &Env, from: &Address, to: &Address, amount: i128) { @@ -343,36 +260,20 @@ impl LoyaltyToken { } Self::set_balance(env, from, bal - amount); - let supply: i128 = env - .storage() - .instance() - .get(&DataKey::TotalSupply) - .unwrap_or(0); - env.storage() - .instance() - .set(&DataKey::TotalSupply, &(supply - amount)); + let supply: i128 = env.storage().instance().get(&DataKey::TotalSupply).unwrap_or(0); + env.storage().instance().set(&DataKey::TotalSupply, &(supply - amount)); env.storage().instance().extend_ttl(17_280, 17_280); - env.events().publish( - (symbol_short!("burn"), symbol_short!("BITE")), - (from.clone(), amount), - ); + env.events().publish((symbol_short!("burn"), symbol_short!("BITE")), (from.clone(), amount)); } fn get_allowance(env: &Env, from: &Address, spender: &Address) -> i128 { - let data: Option = env - .storage() - .temporary() + let data: Option = env.storage().temporary() .get(&DataKey::Allowance(from.clone(), spender.clone())); - match data { None => 0, Some(d) => { - if env.ledger().sequence() > d.expiration_ledger { - 0 - } else { - d.amount - } + if env.ledger().sequence() > d.expiration_ledger { 0 } else { d.amount } } } } @@ -392,10 +293,7 @@ impl LoyaltyToken { } } } - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- + #[cfg(test)] mod test { @@ -409,7 +307,7 @@ mod test { let cid = env.register(LoyaltyToken, ()); let client = LoyaltyTokenClient::new(&env, &cid); let admin = Address::generate(&env); - client.initialize(&admin, &admin); // admin is also minter + client.initialize(&admin, &admin); (env, client, admin) } @@ -425,7 +323,6 @@ mod test { fn test_mint_and_balance() { let (env, client, admin) = setup(); let user = Address::generate(&env); - client.mint(&admin, &user, &1_000_000); assert_eq!(client.balance(&user), 1_000_000); assert_eq!(client.total_supply(), 1_000_000); @@ -436,10 +333,8 @@ mod test { let (env, client, admin) = setup(); let alice = Address::generate(&env); let bob = Address::generate(&env); - client.mint(&admin, &alice, &500_000); client.transfer(&alice, &bob, &200_000); - assert_eq!(client.balance(&alice), 300_000); assert_eq!(client.balance(&bob), 200_000); } @@ -449,14 +344,10 @@ mod test { let (env, client, admin) = setup(); let alice = Address::generate(&env); let bob = Address::generate(&env); - client.mint(&admin, &alice, &1_000_000); - - // Alice approves bob to spend 300_000 for 1000 ledgers. let expiry = env.ledger().sequence() + 1_000; client.approve(&alice, &bob, &300_000, &expiry); assert_eq!(client.allowance(&alice, &bob), 300_000); - client.transfer_from(&bob, &alice, &bob, &100_000); assert_eq!(client.balance(&bob), 100_000); assert_eq!(client.allowance(&alice, &bob), 200_000); @@ -466,10 +357,8 @@ mod test { fn test_burn() { let (env, client, admin) = setup(); let user = Address::generate(&env); - client.mint(&admin, &user, &500_000); client.burn(&user, &200_000); - assert_eq!(client.balance(&user), 300_000); assert_eq!(client.total_supply(), 300_000); } @@ -480,7 +369,6 @@ mod test { let (env, client, admin) = setup(); let alice = Address::generate(&env); let bob = Address::generate(&env); - client.mint(&admin, &alice, &100_000); client.transfer(&alice, &bob, &200_000); } @@ -492,4 +380,87 @@ mod test { let rando = Address::generate(&env); client.mint(&rando, &rando, &1_000_000); } + + // ── Two-step admin transfer tests ────────────────────────────────────────── + + #[test] + fn test_transfer_admin_does_not_change_active_admin() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + // propose but do not accept + client.transfer_admin(&admin, &new_admin); + // old admin can still mint + let user = Address::generate(&env); + client.mint(&admin, &user, &100); + assert_eq!(client.balance(&user), 100); + } + + #[test] + fn test_happy_path_propose_accept_old_admin_locked_out() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + + // Step 1: propose + client.transfer_admin(&admin, &new_admin); + + // Step 2: accept + client.accept_admin(&new_admin); + + // new admin can mint + let user = Address::generate(&env); + client.mint(&new_admin, &user, &500); + assert_eq!(client.balance(&user), 500); + } + + #[test] + #[should_panic(expected = "unauthorized: admin only")] + fn test_old_admin_cannot_mint_after_accept() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + client.transfer_admin(&admin, &new_admin); + client.accept_admin(&new_admin); + + // old admin should fail + let user = Address::generate(&env); + client.mint(&admin, &user, &100); + } + + #[test] + #[should_panic(expected = "not pending admin")] + fn test_accept_admin_wrong_address_panics() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + let rando = Address::generate(&env); + + client.transfer_admin(&admin, &new_admin); + // rando tries to accept + client.accept_admin(&rando); + } + + #[test] + fn test_cancel_transfer_clears_pending_admin() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + + // propose then cancel + client.transfer_admin(&admin, &new_admin); + client.cancel_transfer(&admin); + + // new_admin can no longer accept (no pending admin stored) + // old admin still active + let user = Address::generate(&env); + client.mint(&admin, &user, &100); + assert_eq!(client.balance(&user), 100); + } + + #[test] + #[should_panic(expected = "not pending admin")] + fn test_accept_after_cancel_panics() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + client.transfer_admin(&admin, &new_admin); + client.cancel_transfer(&admin); + // accept should now panic since PendingAdmin is cleared + client.accept_admin(&new_admin); + } }