diff --git a/contracts/admin/src/lib.rs b/contracts/admin/src/lib.rs index b75bdc24..4c084e80 100644 --- a/contracts/admin/src/lib.rs +++ b/contracts/admin/src/lib.rs @@ -824,7 +824,6 @@ pub fn mark_executed(env: &Env, proposal_id: u64) { #[cfg(test)] mod tests { - use super::*; use soroban_sdk::testutils::Address as _; use soroban_sdk::testutils::Events as _; use soroban_sdk::testutils::Ledger; @@ -1013,6 +1012,73 @@ mod tests { } #[test] + fn test_super_admin_can_grant_minter() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(AdminContract, ()); + let client = AdminContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let super_admin = Address::generate(&env); + let minter = Address::generate(&env); + + client.set_admin(&admin); + client.grant_role(&admin, &Role::SuperAdmin, &super_admin); + client.grant_role(&super_admin, &Role::Minter, &minter); + + assert!(client.has_role(&Role::Minter, &minter)); + } + + #[test] + fn test_non_super_admin_cannot_grant_minter() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(AdminContract, ()); + let client = AdminContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let caller = Address::generate(&env); + let target = Address::generate(&env); + + client.set_admin(&admin); + + let result = client.try_grant_role(&caller, &Role::Minter, &target); + assert_eq!(result, Err(Ok(soroban_sdk::Error::from_contract_error(3)))); + assert!(!client.has_role(&Role::Minter, &target)); + } + + #[test] + fn test_super_admin_can_grant_pauser() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(AdminContract, ()); + let client = AdminContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let super_admin = Address::generate(&env); + let pauser = Address::generate(&env); + + client.set_admin(&admin); + client.grant_role(&admin, &Role::SuperAdmin, &super_admin); + client.grant_role(&super_admin, &Role::Pauser, &pauser); + + assert!(client.has_role(&Role::Pauser, &pauser)); + } + + #[test] + fn test_admin_can_grant_pauser() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(AdminContract, ()); + let client = AdminContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let pauser = Address::generate(&env); + + client.set_admin(&admin); + client.grant_role(&admin, &Role::Pauser, &pauser); + + assert!(client.has_role(&Role::Pauser, &pauser)); + } + + #[test] + fn test_non_privileged_caller_cannot_grant_pauser() { fn test_super_admin_can_grant_super_admin() { let env = Env::default(); env.mock_all_auths(); @@ -2641,3 +2707,6 @@ mod tests { assert!(result.is_err()); } } + +#[cfg(test)] +mod proptest; diff --git a/contracts/admin/src/proptest.rs b/contracts/admin/src/proptest.rs new file mode 100644 index 00000000..8a3983bc --- /dev/null +++ b/contracts/admin/src/proptest.rs @@ -0,0 +1,75 @@ +//! Property-based tests for the admin access-control module. + +#![cfg(test)] + +use proptest::prelude::*; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Env}; +use crate::{AdminContract, AdminContractClient, Role}; + +fn setup_admin(env: &Env) -> (AdminContractClient<'_>, Address) { + env.mock_all_auths(); + let contract_id = env.register(AdminContract, ()); + let client = AdminContractClient::new(env, &contract_id); + let admin = Address::generate(env); + client.set_admin(&admin); + (client, admin) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(50))] + + /// Verifies that grant_role makes has_role return true for random addresses. + #[test] + fn test_grant_role_with_random_addresses( + _admin_seed in any::<[u8; 32]>(), + _holder_seed in any::<[u8; 32]>(), + _target_seed in any::<[u8; 32]>(), + ) { + let env = Env::default(); + let (client, admin) = setup_admin(&env); + + let holder = Address::generate(&env); + let target = Address::generate(&env); + + client.grant_role(&admin, &Role::Minter, &holder); + client.grant_role(&admin, &Role::Minter, &target); + + prop_assert!(client.has_role(&Role::Minter, &holder)); + prop_assert!(client.has_role(&Role::Minter, &target)); + } + + /// Verifies that a random address does not hold a role it was never granted. + #[test] + fn test_random_address_lacks_ungranted_role( + _admin_seed in any::<[u8; 32]>(), + _stranger_seed in any::<[u8; 32]>(), + ) { + let env = Env::default(); + let (client, _admin) = setup_admin(&env); + + let stranger = Address::generate(&env); + + prop_assert!(!client.has_role(&Role::Minter, &stranger)); + prop_assert!(!client.has_role(&Role::Pauser, &stranger)); + prop_assert!(!client.has_role(&Role::SuperAdmin, &stranger)); + } + + /// Verifies that revoking a role from a random address makes has_role return false. + #[test] + fn test_revoke_role_with_random_address( + _admin_seed in any::<[u8; 32]>(), + _holder_seed in any::<[u8; 32]>(), + ) { + let env = Env::default(); + let (client, admin) = setup_admin(&env); + + let holder = Address::generate(&env); + + client.grant_role(&admin, &Role::Minter, &holder); + prop_assert!(client.has_role(&Role::Minter, &holder)); + + client.revoke_role(&Role::Minter, &holder); + prop_assert!(!client.has_role(&Role::Minter, &holder)); + } +} \ No newline at end of file diff --git a/contracts/lifecycle/src/lib.rs b/contracts/lifecycle/src/lib.rs index e88061a2..80592665 100644 --- a/contracts/lifecycle/src/lib.rs +++ b/contracts/lifecycle/src/lib.rs @@ -84,6 +84,16 @@ pub fn require_not_paused(env: &Env) { } } +/// Sets the paused state directly without performing auth checks. +/// +/// This helper is intended to be called by a parent contract (e.g., the +/// token contract) after it has already validated that the caller is +/// authorized to change the pause state. +pub fn set_paused(env: &Env, paused: bool) { + env.storage().instance().set(&LifecycleKey::Paused, &paused); + extend_instance_ttl(env); +} + #[cfg(test)] mod tests { use super::*; diff --git a/contracts/token/src/lib.rs b/contracts/token/src/lib.rs index 4d1e4731..89868fda 100644 --- a/contracts/token/src/lib.rs +++ b/contracts/token/src/lib.rs @@ -524,6 +524,25 @@ impl BcForgeToken { Ok(()) } + pub fn pause(env: Env, caller: Address) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + let admin_address = admin::get_admin(&env); + + if caller != admin_address && !admin::has_role(&env, admin::Role::Pauser, &caller) { + return Err(TokenError::ContractPaused); + } + + if caller == admin_address { + admin_address.require_auth(); + } else { + caller.require_auth(); + } + + if bc_forge_lifecycle::is_paused(&env) { + panic!("contract is already paused"); + } + + bc_forge_lifecycle::set_paused(&env, true); /// Pauses the contract. /// /// @notice Pauses all token operations. Only the admin (or SuperAdmin/Pauser role holder) can call this function. @@ -540,6 +559,25 @@ impl BcForgeToken { Ok(()) } + pub fn unpause(env: Env, caller: Address) -> Result<(), TokenError> { + Self::ensure_initialized(&env)?; + let admin_address = admin::get_admin(&env); + + if caller != admin_address && !admin::has_role(&env, admin::Role::Pauser, &caller) { + return Err(TokenError::ContractPaused); + } + + if caller == admin_address { + admin_address.require_auth(); + } else { + caller.require_auth(); + } + + if !bc_forge_lifecycle::is_paused(&env) { + panic!("contract is not paused"); + } + + bc_forge_lifecycle::set_paused(&env, false); /// Unpauses the contract. /// /// @notice Resumes all token operations. Only the admin (or SuperAdmin/Pauser role holder) can call this function. diff --git a/contracts/token/src/test.rs b/contracts/token/src/test.rs index e253743a..f03cfeeb 100644 --- a/contracts/token/src/test.rs +++ b/contracts/token/src/test.rs @@ -158,6 +158,22 @@ fn test_batch_transfer_rejects_insufficient_balance_before_moving_tokens() { assert_eq!(client.balance(&recipient_b), 0); } +#[test] +fn test_batch_transfer_while_paused_returns_error() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let from = Address::generate(&env); + let recipient = Address::generate(&env); + + client.mint(&admin, &from, &100); + client.pause(&admin); + + let recipients = vec![&env, (recipient, 10_i128)]; + let result = client.try_batch_transfer(&from, &recipients); + assert!(result.is_err()); +} + #[test] fn test_stranger_lacks_super_admin_role_required_by_upgrade_guard() { // Soroban's test host converts any escaped guest panic into a generic @@ -312,6 +328,86 @@ fn test_set_max_supply_rejects_negative() { } #[test] +fn test_pauser_can_pause() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let pauser = Address::generate(&env); + + env.as_contract(&client.address, || { + bc_forge_admin::grant_role(&env, &admin, bc_forge_admin::Role::Pauser, &pauser); + }); + + client.pause(&pauser); + assert!(bc_forge_lifecycle::is_paused(&env)); +} + +#[test] +fn test_pauser_can_unpause() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let pauser = Address::generate(&env); + + env.as_contract(&client.address, || { + bc_forge_admin::grant_role(&env, &admin, bc_forge_admin::Role::Pauser, &pauser); + }); + + client.pause(&admin); + assert!(bc_forge_lifecycle::is_paused(&env)); + + client.unpause(&pauser); + assert!(!bc_forge_lifecycle::is_paused(&env)); +} + +#[test] +fn test_non_pauser_cannot_pause() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + let stranger = Address::generate(&env); + + let result = client.try_pause(&stranger); + assert_eq!(result, Err(Ok(TokenError::ContractPaused))); + assert!(!bc_forge_lifecycle::is_paused(&env)); +} + +#[test] +fn test_non_pauser_cannot_unpause() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let stranger = Address::generate(&env); + + client.pause(&admin); + assert!(bc_forge_lifecycle::is_paused(&env)); + + let result = client.try_unpause(&stranger); + assert_eq!(result, Err(Ok(TokenError::ContractPaused))); + assert!(bc_forge_lifecycle::is_paused(&env)); +} + +#[test] +fn test_revoked_pauser_cannot_unpause() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let pauser = Address::generate(&env); + + env.as_contract(&client.address, || { + bc_forge_admin::grant_role(&env, &admin, bc_forge_admin::Role::Pauser, &pauser); + }); + + client.pause(&admin); + assert!(bc_forge_lifecycle::is_paused(&env)); + + env.as_contract(&client.address, || { + bc_forge_admin::revoke_role(&env, bc_forge_admin::Role::Pauser, &pauser); + }); + + let result = client.try_unpause(&pauser); + assert_eq!(result, Err(Ok(TokenError::ContractPaused))); + assert!(bc_forge_lifecycle::is_paused(&env)); fn test_revoked_minter_cannot_mint() { let env = Env::default(); env.mock_all_auths(); diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 867a1929..392ec5cc 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -402,21 +402,21 @@ export class bcForgeClient { } /** - * Pause all token operations. Admin-only. + * Pause all token operations. Admin or Pauser-only. * - * @param source - Admin keypair + * @param source - Admin or Pauser keypair */ - async pause(source?: Keypair): Promise { - return this.invokeContract('pause', [], source); + async pause(source: Keypair): Promise { + return this.invokeContract('pause', [addressToScVal(source.publicKey())], source); } /** - * Unpause token operations. Admin-only. + * Unpause token operations. Admin or Pauser-only. * - * @param source - Admin keypair + * @param source - Admin or Pauser keypair */ - async unpause(source?: Keypair): Promise { - return this.invokeContract('unpause', [], source); + async unpause(source: Keypair): Promise { + return this.invokeContract('unpause', [addressToScVal(source.publicKey())], source); } // ─── Offline Transaction Builders ──────────────────────────────────────────