From f099ed4fe413d2f20872732924615b53f08eff9f Mon Sep 17 00:00:00 2001 From: oreoluwa <126897231+prismn@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:35:38 +0000 Subject: [PATCH] feat(contracts): add emergency pause mechanism to all four contracts - Add Paused variant to DataKey enum in loyalty_token, payment, order, and restaurant_registry contracts - Add pause_contract() and unpause_contract() admin-only functions to each contract, emitting timestamped ctrl/pause and ctrl/unpause events - Add assert_not_paused_for() guard: non-admin callers are rejected with 'contract is paused'; admin can always operate - Guard all state-mutating public functions in each contract - Add 4 pause-related unit tests per contract (32 total tests all pass) Closes #288 --- contracts/.gitignore | 3 + contracts/loyalty_token/src/lib.rs | 81 +++++++++++++++++++++ contracts/order/src/lib.rs | 79 ++++++++++++++++++++ contracts/payment/src/lib.rs | 89 ++++++++++++++++++++++- contracts/restaurant_registry/src/lib.rs | 92 ++++++++++++++++++++++++ 5 files changed, 341 insertions(+), 3 deletions(-) diff --git a/contracts/.gitignore b/contracts/.gitignore index 8e0729c..5a975a2 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -1,2 +1,5 @@ # Rust build artifacts target/ + +# Soroban test snapshots +**/test_snapshots/ diff --git a/contracts/loyalty_token/src/lib.rs b/contracts/loyalty_token/src/lib.rs index 9418054..c543831 100644 --- a/contracts/loyalty_token/src/lib.rs +++ b/contracts/loyalty_token/src/lib.rs @@ -43,6 +43,8 @@ pub enum DataKey { Balance(Address), /// Allowances: (owner, spender) → (amount, expiration_ledger). Allowance(Address, Address), + /// Whether the contract is paused. + Paused, } // --------------------------------------------------------------------------- @@ -117,6 +119,7 @@ impl LoyaltyToken { pub fn mint(env: Env, caller: Address, to: Address, amount: i128) { caller.require_auth(); Self::assert_admin_or_minter(&env, &caller); + Self::assert_not_paused_for(&env, &caller); if amount <= 0 { panic!("amount must be positive"); @@ -139,6 +142,31 @@ impl LoyaltyToken { .publish((symbol_short!("mint"), symbol_short!("BITE")), (to, amount)); } + // ----------------------------------------------------------------------- + // Pause / unpause + // ----------------------------------------------------------------------- + + /// Pause the contract (admin only). All state-mutating calls by non-admins + /// will be rejected while paused. + pub fn pause_contract(env: Env, caller: Address) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + env.storage().instance().set(&DataKey::Paused, &true); + env.storage().instance().extend_ttl(17_280, 17_280); + env.events() + .publish((symbol_short!("ctrl"), symbol_short!("pause")), env.ledger().timestamp()); + } + + /// Unpause the contract (admin only). + pub fn unpause_contract(env: Env, caller: Address) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + env.storage().instance().set(&DataKey::Paused, &false); + env.storage().instance().extend_ttl(17_280, 17_280); + env.events() + .publish((symbol_short!("ctrl"), symbol_short!("unpause")), env.ledger().timestamp()); + } + /// Update the authorised minter address (admin only). pub fn set_minter(env: Env, caller: Address, new_minter: Address) { caller.require_auth(); @@ -167,6 +195,7 @@ impl LoyaltyToken { /// Transfer `amount` BITE from `from` to `to`. pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { from.require_auth(); + Self::assert_not_paused_for(&env, &from); Self::do_transfer(&env, &from, &to, amount); } @@ -187,6 +216,7 @@ impl LoyaltyToken { expiration_ledger: u32, ) { from.require_auth(); + Self::assert_not_paused_for(&env, &from); if amount < 0 { panic!("allowance amount cannot be negative"); } @@ -217,6 +247,7 @@ 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(); + Self::assert_not_paused_for(&env, &spender); let current = Self::get_allowance(&env, &from, &spender); if current < amount { @@ -242,12 +273,14 @@ impl LoyaltyToken { /// Burn `amount` BITE from `from`'s account. pub fn burn(env: Env, from: Address, amount: i128) { from.require_auth(); + Self::assert_not_paused_for(&env, &from); 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(); + Self::assert_not_paused_for(&env, &spender); let current = Self::get_allowance(&env, &from, &spender); if current < amount { @@ -384,6 +417,16 @@ impl LoyaltyToken { } } + fn assert_not_paused_for(env: &Env, caller: &Address) { + let paused: bool = env.storage().instance().get(&DataKey::Paused).unwrap_or(false); + if paused { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if caller != &admin { + panic!("contract is paused"); + } + } + } + fn assert_admin_or_minter(env: &Env, caller: &Address) { let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); let minter: Address = env.storage().instance().get(&DataKey::Minter).unwrap(); @@ -492,4 +535,42 @@ mod test { let rando = Address::generate(&env); client.mint(&rando, &rando, &1_000_000); } + + #[test] + fn test_pause_blocks_user_and_admin_can_still_act() { + let (env, client, admin) = setup(); + let user = Address::generate(&env); + + client.mint(&admin, &user, &1_000_000); + client.pause_contract(&admin); + + // Admin can still mint while paused. + client.mint(&admin, &user, &500_000); + assert_eq!(client.balance(&user), 1_500_000); + } + + #[test] + #[should_panic(expected = "contract is paused")] + fn test_pause_blocks_user_transfer() { + let (env, client, admin) = setup(); + let user = Address::generate(&env); + let other = Address::generate(&env); + + client.mint(&admin, &user, &1_000_000); + client.pause_contract(&admin); + client.transfer(&user, &other, &100_000); + } + + #[test] + fn test_unpause_restores_user_operations() { + let (env, client, admin) = setup(); + let user = Address::generate(&env); + let other = Address::generate(&env); + + client.mint(&admin, &user, &1_000_000); + client.pause_contract(&admin); + client.unpause_contract(&admin); + client.transfer(&user, &other, &100_000); + assert_eq!(client.balance(&other), 100_000); + } } diff --git a/contracts/order/src/lib.rs b/contracts/order/src/lib.rs index f390f13..58efd93 100644 --- a/contracts/order/src/lib.rs +++ b/contracts/order/src/lib.rs @@ -83,6 +83,8 @@ pub enum DataKey { RestaurantOrders(u64), /// Ordered list of order IDs for a customer. CustomerOrders(Address), + /// Whether the contract is paused. + Paused, } // --------------------------------------------------------------------------- @@ -130,6 +132,7 @@ impl OrderContract { notes: String, ) -> u64 { customer.require_auth(); + Self::assert_not_paused_for(&env, &customer); if items.is_empty() { panic!("order must contain at least one item"); @@ -191,6 +194,7 @@ impl OrderContract { /// - The admin may cancel at any time (for dispute resolution). pub fn cancel_order(env: Env, caller: Address, order_id: u64) { caller.require_auth(); + Self::assert_not_paused_for(&env, &caller); let mut order = Self::load_order(&env, order_id); let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); @@ -332,6 +336,40 @@ impl OrderContract { } } + fn assert_not_paused_for(env: &Env, caller: &Address) { + let paused: bool = env.storage().instance().get(&DataKey::Paused).unwrap_or(false); + if paused { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if caller != &admin { + panic!("contract is paused"); + } + } + } + + // ----------------------------------------------------------------------- + // Pause / unpause + // ----------------------------------------------------------------------- + + /// Pause the contract (admin only). + pub fn pause_contract(env: Env, caller: Address) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + env.storage().instance().set(&DataKey::Paused, &true); + env.storage().instance().extend_ttl(17_280, 17_280); + env.events() + .publish((symbol_short!("ctrl"), symbol_short!("pause")), env.ledger().timestamp()); + } + + /// Unpause the contract (admin only). + pub fn unpause_contract(env: Env, caller: Address) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + env.storage().instance().set(&DataKey::Paused, &false); + env.storage().instance().extend_ttl(17_280, 17_280); + env.events() + .publish((symbol_short!("ctrl"), symbol_short!("unpause")), env.ledger().timestamp()); + } + fn append_to_list(env: &Env, key: DataKey, id: u64, ttl: u32) { let mut list: Vec = env .storage() @@ -458,4 +496,45 @@ mod test { let orders = client.get_restaurant_orders(&7); assert_eq!(orders.len(), 2); } + + #[test] + #[should_panic(expected = "contract is paused")] + fn test_pause_blocks_place_order() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let customer = Address::generate(&env); + client.initialize(&admin); + client.pause_contract(&admin); + + let items = vec![&env, make_item(&env, 1, 1, 5_000_000)]; + client.place_order(&customer, &1, &items, &String::from_str(&env, "")); + } + + #[test] + fn test_pause_admin_can_still_advance() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let customer = Address::generate(&env); + client.initialize(&admin); + + let items = vec![&env, make_item(&env, 1, 1, 5_000_000)]; + let id = client.place_order(&customer, &1, &items, &String::from_str(&env, "")); + client.pause_contract(&admin); + client.advance_status(&admin, &id); + assert_eq!(client.get_order(&id).status, OrderStatus::Confirmed); + } + + #[test] + fn test_unpause_restores_place_order() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let customer = Address::generate(&env); + client.initialize(&admin); + client.pause_contract(&admin); + client.unpause_contract(&admin); + + let items = vec![&env, make_item(&env, 1, 1, 5_000_000)]; + let id = client.place_order(&customer, &1, &items, &String::from_str(&env, "")); + assert_eq!(client.get_order(&id).status, OrderStatus::Pending); + } } diff --git a/contracts/payment/src/lib.rs b/contracts/payment/src/lib.rs index 54c9faf..1aaaa42 100644 --- a/contracts/payment/src/lib.rs +++ b/contracts/payment/src/lib.rs @@ -75,6 +75,8 @@ pub enum DataKey { /// Fee in basis points (100 bps = 1 %). Default: 100 (1 %). FeeBps, Payment(u64), + /// Whether the contract is paused. + Paused, } // --------------------------------------------------------------------------- @@ -134,6 +136,7 @@ impl PaymentContract { amount: i128, ) { payer.require_auth(); + Self::assert_not_paused_for(&env, &payer); if env.storage().persistent().has(&DataKey::Payment(order_id)) { panic!("payment already exists for this order"); @@ -189,6 +192,7 @@ impl PaymentContract { /// restaurant wallet. pub fn release_payment(env: Env, caller: Address, order_id: u64) { caller.require_auth(); + Self::assert_not_paused_for(&env, &caller); let mut payment: Payment = env .storage() @@ -248,6 +252,7 @@ impl PaymentContract { pub fn refund_payment(env: Env, caller: Address, order_id: u64) { caller.require_auth(); Self::assert_admin_or_panic(&env, &caller); + // Admin can always refund, even while paused – no pause guard here. let mut payment: Payment = env .storage() @@ -335,10 +340,41 @@ impl PaymentContract { panic!("unauthorized: admin only"); } } -} -// --------------------------------------------------------------------------- -// Tests + fn assert_not_paused_for(env: &Env, caller: &Address) { + let paused: bool = env.storage().instance().get(&DataKey::Paused).unwrap_or(false); + if paused { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if caller != &admin { + panic!("contract is paused"); + } + } + } + + // ----------------------------------------------------------------------- + // Pause / unpause + // ----------------------------------------------------------------------- + + /// Pause the contract (admin only). + pub fn pause_contract(env: Env, caller: Address) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + env.storage().instance().set(&DataKey::Paused, &true); + env.storage().instance().extend_ttl(17_280, 17_280); + env.events() + .publish((symbol_short!("ctrl"), symbol_short!("pause")), env.ledger().timestamp()); + } + + /// Unpause the contract (admin only). + pub fn unpause_contract(env: Env, caller: Address) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + env.storage().instance().set(&DataKey::Paused, &false); + env.storage().instance().extend_ttl(17_280, 17_280); + env.events() + .publish((symbol_short!("ctrl"), symbol_short!("unpause")), env.ledger().timestamp()); + } +} // --------------------------------------------------------------------------- #[cfg(test)] @@ -436,4 +472,51 @@ mod test { client.escrow_payment(&payer, &3, &restaurant, &token_addr, &20_000_000); client.escrow_payment(&payer, &3, &restaurant, &token_addr, &20_000_000); } + + #[test] + #[should_panic(expected = "contract is paused")] + fn test_pause_blocks_escrow() { + let (env, client, admin, _treasury, _cid) = setup(); + let token_admin = Address::generate(&env); + let payer = Address::generate(&env); + let restaurant = Address::generate(&env); + + let (token_addr, sac) = create_token(&env, &token_admin); + sac.mint(&payer, &50_000_000); + + client.pause_contract(&admin); + client.escrow_payment(&payer, &10, &restaurant, &token_addr, &10_000_000); + } + + #[test] + fn test_pause_admin_can_still_release() { + let (env, client, admin, _treasury, _cid) = setup(); + let token_admin = Address::generate(&env); + let payer = Address::generate(&env); + let restaurant = Address::generate(&env); + + let (token_addr, sac) = create_token(&env, &token_admin); + sac.mint(&payer, &50_000_000); + + client.escrow_payment(&payer, &11, &restaurant, &token_addr, &50_000_000); + client.pause_contract(&admin); + client.release_payment(&admin, &11); + assert_eq!(client.get_payment(&11).status, PaymentStatus::Released); + } + + #[test] + fn test_unpause_restores_escrow() { + let (env, client, admin, _treasury, _cid) = setup(); + let token_admin = Address::generate(&env); + let payer = Address::generate(&env); + let restaurant = Address::generate(&env); + + let (token_addr, sac) = create_token(&env, &token_admin); + sac.mint(&payer, &50_000_000); + + client.pause_contract(&admin); + client.unpause_contract(&admin); + client.escrow_payment(&payer, &12, &restaurant, &token_addr, &10_000_000); + assert_eq!(client.get_payment(&12).status, PaymentStatus::Escrowed); + } } diff --git a/contracts/restaurant_registry/src/lib.rs b/contracts/restaurant_registry/src/lib.rs index b3aee88..cfb17db 100644 --- a/contracts/restaurant_registry/src/lib.rs +++ b/contracts/restaurant_registry/src/lib.rs @@ -46,6 +46,8 @@ pub enum DataKey { Restaurant(u64), /// Reverse lookup: owner address → restaurant ID. OwnerToId(Address), + /// Whether the contract is paused. + Paused, } // --------------------------------------------------------------------------- @@ -87,6 +89,7 @@ impl RestaurantRegistry { /// - If the owner already has a registered restaurant. pub fn register_restaurant(env: Env, owner: Address, name: String, slug: String) -> u64 { owner.require_auth(); + Self::assert_not_paused_for(&env, &owner); if env .storage() @@ -146,6 +149,7 @@ impl RestaurantRegistry { slug: String, ) { caller.require_auth(); + Self::assert_not_paused_for(&env, &caller); let mut restaurant: Restaurant = env .storage() @@ -180,6 +184,7 @@ impl RestaurantRegistry { /// Only the owner or admin may change the active flag. pub fn set_active(env: Env, caller: Address, restaurant_id: u64, active: bool) { caller.require_auth(); + Self::assert_not_paused_for(&env, &caller); let mut restaurant: Restaurant = env .storage() @@ -237,6 +242,47 @@ impl RestaurantRegistry { pub fn admin(env: Env) -> Address { env.storage().instance().get(&DataKey::Admin).unwrap() } + + // ----------------------------------------------------------------------- + // Pause / unpause + // ----------------------------------------------------------------------- + + /// Pause the contract (admin only). + pub fn pause_contract(env: Env, caller: Address) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + env.storage().instance().set(&DataKey::Paused, &true); + env.storage().instance().extend_ttl(17_280, 17_280); + env.events() + .publish((symbol_short!("ctrl"), symbol_short!("pause")), env.ledger().timestamp()); + } + + /// Unpause the contract (admin only). + pub fn unpause_contract(env: Env, caller: Address) { + caller.require_auth(); + Self::assert_admin_or_panic(&env, &caller); + env.storage().instance().set(&DataKey::Paused, &false); + env.storage().instance().extend_ttl(17_280, 17_280); + env.events() + .publish((symbol_short!("ctrl"), symbol_short!("unpause")), env.ledger().timestamp()); + } + + fn assert_admin_or_panic(env: &Env, caller: &Address) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if caller != &admin { + panic!("unauthorized: admin only"); + } + } + + fn assert_not_paused_for(env: &Env, caller: &Address) { + let paused: bool = env.storage().instance().get(&DataKey::Paused).unwrap_or(false); + if paused { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if caller != &admin { + panic!("contract is paused"); + } + } + } } // --------------------------------------------------------------------------- @@ -347,4 +393,50 @@ mod test { &String::from_str(&env, "second"), ); } + + #[test] + #[should_panic(expected = "contract is paused")] + fn test_pause_blocks_register() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + client.initialize(&admin); + client.pause_contract(&admin); + client.register_restaurant( + &owner, + &String::from_str(&env, "Blocked"), + &String::from_str(&env, "blocked"), + ); + } + + #[test] + fn test_pause_admin_can_still_register() { + let (env, client) = setup(); + let admin = Address::generate(&env); + client.initialize(&admin); + client.pause_contract(&admin); + // Admin can still register while paused. + let id = client.register_restaurant( + &admin, + &String::from_str(&env, "Admin Rest"), + &String::from_str(&env, "admin-rest"), + ); + assert_eq!(id, 1); + } + + #[test] + fn test_unpause_restores_register() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let owner = Address::generate(&env); + client.initialize(&admin); + client.pause_contract(&admin); + client.unpause_contract(&admin); + let id = client.register_restaurant( + &owner, + &String::from_str(&env, "Back Open"), + &String::from_str(&env, "back-open"), + ); + assert!(id > 0); + } }