From 0803ba2705ceb78fd59057f01ec92352724295d8 Mon Sep 17 00:00:00 2001 From: Armagan Akcann Date: Wed, 1 Jul 2026 18:38:09 +0300 Subject: [PATCH] feat: add admin transfer to contracts --- contracts/dividend/Cargo.toml | 3 ++ contracts/dividend/src/lib.rs | 83 ++++++++++++++++++++++++++++++++- contracts/governance/Cargo.toml | 3 ++ contracts/governance/src/lib.rs | 68 +++++++++++++++++++++++++++ contracts/loan/src/lib.rs | 70 +++++++++++++++++++++++++++ contracts/treasury/src/lib.rs | 35 ++++++++++++++ contracts/voting/src/lib.rs | 57 ++++++++++++++++++++++ 7 files changed, 318 insertions(+), 1 deletion(-) diff --git a/contracts/dividend/Cargo.toml b/contracts/dividend/Cargo.toml index 4636991..6a4e3c0 100644 --- a/contracts/dividend/Cargo.toml +++ b/contracts/dividend/Cargo.toml @@ -8,3 +8,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/dividend/src/lib.rs b/contracts/dividend/src/lib.rs index 10b3a9e..bcb3ac2 100644 --- a/contracts/dividend/src/lib.rs +++ b/contracts/dividend/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, token, Address, Env, Symbol, Vec, + contract, contractimpl, contracttype, token, Address, Env, String, Symbol, Vec, }; #[contracttype] @@ -41,6 +41,18 @@ impl DividendContract { .set(&DataKey::Distributions, &Vec::::new(&env)); } + /// Transfer admin rights to a new address. + pub fn transfer_admin(env: Env, current_admin: Address, new_admin: Address) { + current_admin.require_auth(); + Self::require_admin(&env, ¤t_admin); + + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.events().publish( + (Symbol::new(&env, "admin_transferred"),), + (current_admin, new_admin), + ); + } + /// Distribute profit proportionally based on each member's share weight. /// /// `recipients` and `shares` must be equal length. @@ -120,3 +132,72 @@ impl DividendContract { if admin != *caller { panic!("unauthorized"); } } } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{token::StellarAssetClient, Env, String, Vec}; + + fn setup() -> (Env, DividendContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, DividendContract); + let client = DividendContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let token_admin = Address::generate(&env); + let asset = env.register_stellar_asset_contract_v2(token_admin); + let asset_address = asset.address(); + + StellarAssetClient::new(&env, &asset_address).mint(&contract_id, &1_000_0000000i128); + client.initialize(&admin, &asset_address, &treasury); + + (env, client, admin) + } + + fn single_recipient_distribution(env: &Env) -> (Vec
, Vec) { + let recipient = Address::generate(env); + let mut recipients = Vec::new(env); + let mut shares = Vec::new(env); + recipients.push_back(recipient); + shares.push_back(1i128); + (recipients, shares) + } + + #[test] + fn test_transfer_admin_allows_new_admin_to_distribute() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + let (recipients, shares) = single_recipient_distribution(&env); + + client.transfer_admin(&admin, &new_admin); + let distribution_id = client.distribute( + &new_admin, + &recipients, + &shares, + &100_0000000i128, + &String::from_str(&env, "2026-07"), + ); + + assert_eq!(distribution_id, 1); + assert_eq!(client.get_distributions().len(), 1); + } + + #[test] + #[should_panic] + fn test_old_admin_cannot_call_admin_only_after_transfer() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + let (recipients, shares) = single_recipient_distribution(&env); + client.transfer_admin(&admin, &new_admin); + + client.distribute( + &admin, + &recipients, + &shares, + &100_0000000i128, + &String::from_str(&env, "2026-07"), + ); + } +} diff --git a/contracts/governance/Cargo.toml b/contracts/governance/Cargo.toml index 22c10a5..e5f06d0 100644 --- a/contracts/governance/Cargo.toml +++ b/contracts/governance/Cargo.toml @@ -8,3 +8,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/governance/src/lib.rs b/contracts/governance/src/lib.rs index 7d9476b..a39d87a 100644 --- a/contracts/governance/src/lib.rs +++ b/contracts/governance/src/lib.rs @@ -57,6 +57,17 @@ impl GovernanceContract { env.storage().instance().set(&DataKey::Rules, &rules); } + pub fn transfer_admin(env: Env, current_admin: Address, new_admin: Address) { + current_admin.require_auth(); + Self::require_admin(&env, ¤t_admin); + + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.events().publish( + (Symbol::new(&env, "admin_transferred"),), + (current_admin, new_admin), + ); + } + pub fn update_rules(env: Env, admin: Address, rules: CoopRules) { admin.require_auth(); Self::require_admin(&env, &admin); @@ -73,3 +84,60 @@ impl GovernanceContract { if admin != *caller { panic!("unauthorized"); } } } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::Env; + + fn setup() -> (Env, GovernanceContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, GovernanceContract); + let client = GovernanceContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let voting = Address::generate(&env); + let loan = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &voting, &loan, &treasury); + + (env, client, admin) + } + + fn replacement_rules() -> CoopRules { + CoopRules { + min_contribution: 20_0000000i128, + contribution_period_days: 14, + max_loan_multiplier: 2, + loan_interest_bps: 300, + voting_quorum: 2, + voting_period_days: 5, + late_penalty_bps: 100, + } + } + + #[test] + fn test_transfer_admin_allows_new_admin_to_update_rules() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + let rules = replacement_rules(); + + client.transfer_admin(&admin, &new_admin); + client.update_rules(&new_admin, &rules); + + let stored = client.get_rules(); + assert_eq!(stored.min_contribution, rules.min_contribution); + assert_eq!(stored.voting_period_days, rules.voting_period_days); + } + + #[test] + #[should_panic] + fn test_old_admin_cannot_call_admin_only_after_transfer() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + client.transfer_admin(&admin, &new_admin); + + client.update_rules(&admin, &replacement_rules()); + } +} diff --git a/contracts/loan/src/lib.rs b/contracts/loan/src/lib.rs index e08ceae..ff2d44d 100644 --- a/contracts/loan/src/lib.rs +++ b/contracts/loan/src/lib.rs @@ -56,6 +56,18 @@ impl LoanContract { env.storage().instance().set(&DataKey::Loans, &Vec::::new(&env)); } + /// Transfer admin rights to a new address. + pub fn transfer_admin(env: Env, current_admin: Address, new_admin: Address) { + current_admin.require_auth(); + Self::require_admin(&env, ¤t_admin); + + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.events().publish( + (Symbol::new(&env, "admin_transferred"),), + (current_admin, new_admin), + ); + } + /// Member submits a loan request. pub fn request_loan( env: Env, @@ -201,3 +213,61 @@ impl LoanContract { panic!("loan not found"); } } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{token::StellarAssetClient, Env, String}; + + fn setup() -> (Env, LoanContractClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, LoanContract); + let client = LoanContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let borrower = Address::generate(&env); + let treasury = Address::generate(&env); + let token_admin = Address::generate(&env); + let asset = env.register_stellar_asset_contract_v2(token_admin); + let asset_address = asset.address(); + + StellarAssetClient::new(&env, &asset_address).mint(&contract_id, &1_000_0000000i128); + client.initialize(&admin, &treasury, &asset_address); + + (env, client, admin, borrower) + } + + #[test] + fn test_transfer_admin_allows_new_admin_to_approve_loan() { + let (env, client, admin, borrower) = setup(); + let new_admin = Address::generate(&env); + client.transfer_admin(&admin, &new_admin); + + let loan_id = client.request_loan( + &borrower, + &100_0000000i128, + &String::from_str(&env, "Working capital"), + &30, + ); + client.approve_loan(&new_admin, &loan_id); + + assert_eq!(client.get_loan(&loan_id).status, LoanStatus::Approved); + } + + #[test] + #[should_panic] + fn test_old_admin_cannot_call_admin_only_after_transfer() { + let (env, client, admin, borrower) = setup(); + let new_admin = Address::generate(&env); + client.transfer_admin(&admin, &new_admin); + + let loan_id = client.request_loan( + &borrower, + &100_0000000i128, + &String::from_str(&env, "Working capital"), + &30, + ); + client.approve_loan(&admin, &loan_id); + } +} diff --git a/contracts/treasury/src/lib.rs b/contracts/treasury/src/lib.rs index 786f8f7..900781a 100644 --- a/contracts/treasury/src/lib.rs +++ b/contracts/treasury/src/lib.rs @@ -91,6 +91,18 @@ impl TreasuryContract { } } + /// Transfer admin rights to a new address. + pub fn transfer_admin(env: Env, current_admin: Address, new_admin: Address) { + current_admin.require_auth(); + Self::require_admin(&env, ¤t_admin); + + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.events().publish( + (Symbol::new(&env, "admin_transferred"),), + (current_admin, new_admin), + ); + } + /// Add a new member to the cooperative. pub fn add_member(env: Env, admin: Address, member: Address) { admin.require_auth(); @@ -354,6 +366,29 @@ mod tests { // ── contribute edge cases ──────────────────────────────────────────────── + #[test] + fn test_transfer_admin_updates_admin() { + let (env, client, admin, _, asset) = setup(); + let new_admin = Address::generate(&env); + client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); + + client.transfer_admin(&admin, &new_admin); + + assert_eq!(client.get_info().admin, new_admin); + } + + #[test] + #[should_panic] + fn test_old_admin_cannot_call_admin_only_after_transfer() { + let (env, client, admin, _, asset) = setup(); + let new_admin = Address::generate(&env); + let member = Address::generate(&env); + client.initialize(&admin, &String::from_str(&env, "Test Coop"), &asset); + client.transfer_admin(&admin, &new_admin); + + client.add_member(&admin, &member); + } + #[test] #[should_panic] fn test_contribute_zero_amount() { diff --git a/contracts/voting/src/lib.rs b/contracts/voting/src/lib.rs index f9a22f6..c71c21c 100644 --- a/contracts/voting/src/lib.rs +++ b/contracts/voting/src/lib.rs @@ -64,6 +64,18 @@ impl VotingContract { env.storage().instance().set(&DataKey::Proposals, &Vec::::new(&env)); } + /// Transfer admin rights to a new address. + pub fn transfer_admin(env: Env, current_admin: Address, new_admin: Address) { + current_admin.require_auth(); + Self::require_admin(&env, ¤t_admin); + + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.events().publish( + (Symbol::new(&env, "admin_transferred"),), + (current_admin, new_admin), + ); + } + /// Create a new governance proposal. pub fn create_proposal( env: Env, @@ -204,6 +216,11 @@ impl VotingContract { .unwrap_or(Map::new(&env)) } + fn require_admin(env: &Env, caller: &Address) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if admin != *caller { panic!("unauthorized"); } + } + fn find_proposal_idx(proposals: &Vec, id: u32) -> u32 { for i in 0..proposals.len() { if proposals.get(i).unwrap().id == id { return i; } @@ -211,3 +228,43 @@ impl VotingContract { panic!("proposal not found"); } } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::Env; + + fn setup() -> (Env, VotingContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, VotingContract); + let client = VotingContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury); + + (env, client, admin) + } + + #[test] + fn test_transfer_admin_allows_new_admin_to_transfer_again() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + let final_admin = Address::generate(&env); + + client.transfer_admin(&admin, &new_admin); + client.transfer_admin(&new_admin, &final_admin); + } + + #[test] + #[should_panic] + fn test_old_admin_cannot_call_admin_only_after_transfer() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + let final_admin = Address::generate(&env); + client.transfer_admin(&admin, &new_admin); + + client.transfer_admin(&admin, &final_admin); + } +}