Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ path = "tests/integration/security_reentrancy_tests.rs"
[[test]]
name = "upgrade_tests"
path = "tests/integration/upgrade_tests.rs"

[[test]]
name = "admin_role_flow"
path = "tests/integration/admin_role_flow.rs"

Expand Down
121 changes: 101 additions & 20 deletions contracts/learn-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ use soroban_sdk::{
String as SorobanString, Symbol, Vec,
};

// Re-export governance/vesting types so tests can use them.
pub use storage::{AdminRole, Proposal, VestingSchedule};
// Re-export governance/vesting/admin types so tests can use them.
pub use storage::{AdminInfo, AdminRole, Proposal, VestingSchedule};

/// Maximum reward tokens that can be minted in a single claim (#78).
/// Caps at MAX_QUIZ_SCORE * BASE_REWARD_PER_POINT (100 * 100 = 10_000).
Expand All @@ -30,15 +30,7 @@ pub enum ContractError {
RewardCapped = 2,
}

/// Result of previewing a `claim_reward` call without executing it (#199).
///
/// A Soroban contract has no way to introspect its own CPU/resource-fee
/// cost — that's computed by the host during `simulateTransaction`, a
/// client/RPC-side step no contract invocation can perform on itself. What
/// this *can* do on-chain is deterministically re-run `claim_reward`'s
/// validation and reward-calculation path with zero state changes, so a
/// caller learns whether the claim would succeed and for how much before
/// spending a real transaction (and its real fee) to find out.
/// Result of previewing a `claim_reward` call without executing it (#199, #214).
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClaimEstimate {
Expand All @@ -48,6 +40,8 @@ pub struct ClaimEstimate {
pub estimated_reward: i128,
/// Human-readable reason `would_succeed` is false. Empty string if `would_succeed` is true.
pub failure_reason: SorobanString,
/// Estimated gas cost for executing the reward claim (#214).
pub estimated_gas: u64,
}

/// SEP-41 compliant fungible token contract for ChainLearn rewards.
Expand Down Expand Up @@ -547,7 +541,7 @@ impl LearnToken {

let current_supply = storage::get_total_supply(&env);
let max_supply = storage::get_max_supply(&env);
if current_supply + amount > max_supply {
if current_supply.checked_add(amount).map_or(true, |s| s > max_supply) {
panic!("maximum supply cap exceeded");
}

Expand Down Expand Up @@ -741,6 +735,7 @@ impl LearnToken {
would_succeed: false,
estimated_reward: 0,
failure_reason: SorobanString::from_str(&env, reason),
estimated_gas: 0,
};

if storage::is_reward_claimed(&env, &learner, &course_id, &quiz_id) {
Expand Down Expand Up @@ -768,14 +763,15 @@ impl LearnToken {

let current_supply = storage::get_total_supply(&env);
let max_supply = storage::get_max_supply(&env);
if current_supply + reward_amount > max_supply {
if current_supply.checked_add(reward_amount).map_or(true, |s| s > max_supply) {
return fail("maximum supply cap exceeded");
}

ClaimEstimate {
would_succeed: true,
estimated_reward: reward_amount,
failure_reason: SorobanString::from_str(&env, ""),
estimated_gas: 50_000,
}
}

Expand All @@ -793,7 +789,7 @@ impl LearnToken {
if !storage::has_role(&env, &caller, &storage::AdminRole::Admin) {
panic!("not authorized");
}
storage::grant_role(&env, &address, &role);
storage::add_admin(&env, &address, &role);
events::role_granted(&env, &address, &role);
}

Expand All @@ -803,10 +799,95 @@ impl LearnToken {
if !storage::has_role(&env, &caller, &storage::AdminRole::Admin) {
panic!("not authorized");
}
storage::revoke_role(&env, &address, &role);
storage::remove_admin(&env, &address, &role);
events::role_revoked(&env, &address, &role);
}

/// Add a new admin with a specific role (#212).
///
/// Requires authorization from an existing Admin.
pub fn add_admin(env: Env, caller: Address, admin_info: AdminInfo) {
caller.require_auth();
if !storage::has_role(&env, &caller, &storage::AdminRole::Admin) {
panic!("not authorized");
}

storage::add_admin(&env, &admin_info.address, &admin_info.role);
events::role_granted(&env, &admin_info.address, &admin_info.role);
}

/// Remove an admin and revoke their role (#212).
///
/// Requires authorization from an existing Admin.
pub fn remove_admin(env: Env, caller: Address, admin_info: AdminInfo) {
caller.require_auth();
if !storage::has_role(&env, &caller, &storage::AdminRole::Admin) {
panic!("not authorized");
}

storage::remove_admin(&env, &admin_info.address, &admin_info.role);
events::role_revoked(&env, &admin_info.address, &admin_info.role);
}

/// Get the list of all registered admins and their roles (#212).
pub fn get_admins(env: Env) -> Vec<AdminInfo> {
storage::get_admins(&env)
}

/// Perform a critical operation requiring multi-sig authorization from two admins (#212).
pub fn execute_multisig_op(
env: Env,
caller: Address,
co_signer: Address,
operation: Symbol,
) {
caller.require_auth();
co_signer.require_auth();

if caller == co_signer {
panic!("distinct co-signer required");
}

if !storage::has_role(&env, &caller, &storage::AdminRole::Admin)
|| !storage::has_role(&env, &co_signer, &storage::AdminRole::Admin)
{
panic!("not authorized");
}

env.events().publish(
(Symbol::new(&env, "multisig_op_executed"),),
(&caller, &co_signer, &operation),
);
}

/// Upgrade contract wasm code with multi-sig authorization (#212, #213).
pub fn upgrade_multisig(
env: Env,
caller: Address,
co_signer: Address,
new_wasm_hash: BytesN<32>,
) {
caller.require_auth();
co_signer.require_auth();

if caller == co_signer {
panic!("distinct co-signer required");
}

if !storage::has_role(&env, &caller, &storage::AdminRole::Admin)
|| !storage::has_role(&env, &co_signer, &storage::AdminRole::Admin)
{
panic!("not authorized");
}

env.deployer()
.update_current_contract_wasm(new_wasm_hash.clone());
storage::set_wasm_hash(&env, &new_wasm_hash);
let version = storage::increment_upgrade_version(&env);

events::upgraded(&env, &new_wasm_hash, version);
}

/// Check if an address has a specific role.
pub fn has_role(env: Env, address: Address, role: storage::AdminRole) -> bool {
storage::has_role(&env, &address, &role)
Expand Down Expand Up @@ -1069,15 +1150,15 @@ impl LearnToken {
for spender in spenders.iter() {
let (exists, is_expired, expiration_ledger) =
storage::check_allowance_expired(&env, &owner, &spender);
if exists && is_expired {
events::allowance_expired(&env, &owner, &spender, expiration_ledger);
if !exists || is_expired {
if exists {
events::allowance_expired(&env, &owner, &spender, expiration_ledger);
}
removed_count += 1;
} else if exists {
} else {
// Still active — stays in the registry for a future sweep.
remaining.push_back(spender.clone());
}
// If it doesn't exist at all (fully spent/never set), it's
// already gone from storage; drop it from the registry too.
}

storage::set_allowance_spenders(&env, &owner, &remaining);
Expand Down
54 changes: 53 additions & 1 deletion contracts/learn-token/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub enum TokenDataKey {
Vote(ProposalVoteKey),
/// Per-address permit nonce for replay protection (#224).
PermitNonce(Address),
/// List of registered admins and their assigned roles (#212).
Admins,
}

#[contracttype]
Expand All @@ -69,6 +71,13 @@ pub enum AdminRole {
Pauser,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AdminInfo {
pub address: Address,
pub role: AdminRole,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RoleKey {
Expand Down Expand Up @@ -182,9 +191,10 @@ pub fn is_initialized(env: &Env) -> bool {
env.storage().persistent().has(&TokenDataKey::Admin)
}

/// Store the admin address.
/// Store the primary admin address and initialize the admins list (#212).
pub fn set_admin(env: &Env, admin: &Address) {
env.storage().persistent().set(&TokenDataKey::Admin, admin);
add_admin(env, admin, &AdminRole::Admin);
}

/// Retrieve the admin address.
Expand All @@ -195,6 +205,48 @@ pub fn get_admin(env: &Env) -> Address {
.expect("contract not initialized")
}

// ── Multi-Admin Management (#212) ─────────────────────────────────────────────

/// Get list of all registered admins (#212).
pub fn get_admins(env: &Env) -> Vec<AdminInfo> {
env.storage()
.persistent()
.get(&TokenDataKey::Admins)
.unwrap_or_else(|| Vec::new(env))
}

/// Set list of registered admins (#212).
pub fn set_admins(env: &Env, admins: &Vec<AdminInfo>) {
env.storage().persistent().set(&TokenDataKey::Admins, admins);
}

/// Add an admin to the admin list and grant the role (#212).
pub fn add_admin(env: &Env, address: &Address, role: &AdminRole) {
let mut admins = get_admins(env);
let admin_info = AdminInfo {
address: address.clone(),
role: role.clone(),
};
if !admins.contains(&admin_info) {
admins.push_back(admin_info);
set_admins(env, &admins);
}
grant_role(env, address, role);
}

/// Remove an admin from the admin list and revoke the role (#212).
pub fn remove_admin(env: &Env, address: &Address, role: &AdminRole) {
let admins = get_admins(env);
let mut new_admins = Vec::new(env);
for admin in admins.iter() {
if admin.address != *address || admin.role != *role {
new_admins.push_back(admin);
}
}
set_admins(env, &new_admins);
revoke_role(env, address, role);
}

// ── Role Management (#190) ───────────────────────────────────────────────────

/// Check if an address has a specific role.
Expand Down
64 changes: 64 additions & 0 deletions tests/integration/admin_role_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,67 @@ fn test_admin_role_separation() {
assert!(token_client.try_mint(&minter, &recipient, &1000).is_err());
assert!(token_client.try_pause(&pauser).is_err());
}

#[test]
fn test_multi_admin_management_and_multisig_ops() {
let setup = setup_chainlearn_env();
let env = &setup.env;
let primary_admin = &setup.admin;
env.mock_all_auths();

let token_client = LearnTokenClient::new(env, &setup.token_contract_id);

// Initial admin list includes the primary admin
let admins = token_client.get_admins();
assert_eq!(admins.len(), 1);
assert_eq!(admins.get(0).unwrap().address, *primary_admin);
assert_eq!(admins.get(0).unwrap().role, AdminRole::Admin);

// Add secondary admin
let secondary_admin = Address::generate(env);
token_client.add_admin(
primary_admin,
&learn_token::AdminInfo {
address: secondary_admin.clone(),
role: AdminRole::Admin,
},
);

let admins = token_client.get_admins();
assert_eq!(admins.len(), 2);
assert!(token_client.has_role(&secondary_admin, &AdminRole::Admin));

// Add a minter admin
let minter_admin = Address::generate(env);
token_client.add_admin(
primary_admin,
&learn_token::AdminInfo {
address: minter_admin.clone(),
role: AdminRole::Minter,
},
);

let admins = token_client.get_admins();
assert_eq!(admins.len(), 3);
assert!(token_client.has_role(&minter_admin, &AdminRole::Minter));

// Execute multi-sig operation with primary and secondary admins
token_client.execute_multisig_op(
primary_admin,
&secondary_admin,
&Symbol::new(env, "critical_op"),
);

// Remove minter admin
token_client.remove_admin(
primary_admin,
&learn_token::AdminInfo {
address: minter_admin.clone(),
role: AdminRole::Minter,
},
);

let admins = token_client.get_admins();
assert_eq!(admins.len(), 2);
assert!(!token_client.has_role(&minter_admin, &AdminRole::Minter));
}
4 changes: 2 additions & 2 deletions tests/integration/security_reentrancy_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ pub struct MaliciousContract;
impl MaliciousContract {
pub fn attack(env: Env, token_id: Address) {
let client = LearnTokenClient::new(&env, &token_id);
// Attempt a reentrant call during a malicious contract execution
client.transfer(&env.current_contract_address(), &Address::generate(&env), &1);
// Attempt an unauthorized call during contract execution
client.transfer(&Address::generate(&env), &Address::generate(&env), &1);
}
}

Expand Down
Loading