diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index 1a76e1b..a771be0 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -230,6 +230,29 @@ impl FarmingPool { // ── Lock / Unlock system ───────────────────────────────────────────────── + /// Return the current admin address. + pub fn admin(env: Env) -> Address { + bump_instance(&env); + get_admin(&env) + } + + /// Admin: transfer admin rights to `new_admin`. Current admin must authorise. + /// + /// Supports key rotation and governance handoffs without redeploying the pool. + /// Emits a `("pool", "adm_xfr")` event with `(old_admin, new_admin)`. + pub fn transfer_admin(env: Env, new_admin: Address) { + let current = get_admin(&env); + current.require_auth(); + bump_instance(&env); + + env.storage().instance().set(&DataKey::Admin, &new_admin); + + env.events().publish( + (symbol_short!("pool"), symbol_short!("adm_xfr")), + (current, new_admin), + ); + } + /// Lock `amount` tokens for the caller. If a prior position exists, credits are /// checkpointed first and the new amount is added to the existing position. /// @@ -255,6 +278,7 @@ impl FarmingPool { } }; + token::TokenClient::new(&env, &get_stake_token(&env)).transfer( let stake_token = get_stake_token(&env)?; token::TokenClient::new(&env, &stake_token).transfer( &user, @@ -297,6 +321,7 @@ impl FarmingPool { let total_credits = pos.total_credits; pos.amount -= amount; + token::TokenClient::new(&env, &get_stake_token(&env)).transfer( let stake_token = get_stake_token(&env)?; token::TokenClient::new(&env, &stake_token).transfer( &env.current_contract_address(), @@ -329,6 +354,7 @@ impl FarmingPool { .ledger() .sequence() .saturating_sub(pos.checkpoint_ledger); + pos.total_credits + pos.amount * rate * elapsed as i128 Ok(pos.total_credits + pos.amount * rate * elapsed as i128) } @@ -455,6 +481,7 @@ impl FarmingPool { }; // Pull tokens from caller into the contract. + token::TokenClient::new(&env, &get_stake_token(&env)).transfer( let stake_token = get_stake_token(&env)?; token::TokenClient::new(&env, &stake_token).transfer( &from, @@ -477,6 +504,7 @@ impl FarmingPool { let total_credits = stake.credits_banked; // Return staked tokens to caller. + token::TokenClient::new(&env, &get_stake_token(&env)).transfer( let stake_token = get_stake_token(&env)?; token::TokenClient::new(&env, &stake_token).transfer( &env.current_contract_address(), @@ -569,6 +597,8 @@ impl FarmingPool { let multiplier = get_global_multiplier(&env); let rate = get_credit_rate(&env); let elapsed = env.ledger().sequence().saturating_sub(stake.start_ledger); + stake.credits_banked + + compute_credits(stake.amount, allocation_pct, multiplier, rate, elapsed) Ok(stake.credits_banked + compute_credits(stake.amount, allocation_pct, multiplier, rate, elapsed)) } diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs index 8db358a..8328096 100644 --- a/soroban/contracts/farming-pool/src/test.rs +++ b/soroban/contracts/farming-pool/src/test.rs @@ -2,9 +2,9 @@ use super::*; use soroban_sdk::{ - testutils::{Address as _, Events, Ledger}, + testutils::{Address as _, Events, Ledger, MockAuth, MockAuthInvoke}, token::{StellarAssetClient, TokenClient}, - Address, Env, + Address, Env, IntoVal, }; // ── Test helpers ────────────────────────────────────────────────────────────── @@ -12,6 +12,7 @@ use soroban_sdk::{ struct TestEnv { env: Env, client: FarmingPoolClient<'static>, + contract_id: Address, token: TokenClient<'static>, token_sac: StellarAssetClient<'static>, admin: Address, @@ -79,6 +80,7 @@ fn setup_with_lock_period( TestEnv { env, client, + contract_id, token, token_sac, admin, @@ -86,6 +88,25 @@ fn setup_with_lock_period( } } +fn setup_without_mocked_auth() -> (Env, Address, FarmingPoolClient<'static>, Address, Address) { + let env = Env::default(); + let admin = Address::generate(&env); + let user = Address::generate(&env); + + let token_admin = Address::generate(&env); + let asset = env.register_stellar_asset_contract_v2(token_admin); + + let contract_id = env.register(FarmingPool, ()); + let client = FarmingPoolClient::new(&env, &contract_id); + client.initialize(&admin, &asset.address(), &2u32, &1i128, &0u32); + + let client = unsafe { + core::mem::transmute::, FarmingPoolClient<'static>>(client) + }; + + (env, contract_id, client, admin, user) +} + fn advance_ledgers(env: &Env, by: u32) { let current = env.ledger().sequence(); env.ledger().with_mut(|l| l.sequence_number = current + by); @@ -313,6 +334,124 @@ fn test_get_credits_zero_without_stake() { // ── lock_assets tests ───────────────────────────────────────────────────────── +#[test] +fn test_admin_getter_returns_current_admin() { + let t = setup(2, 1); + assert_eq!(t.client.admin(), t.admin); +} + +#[test] +fn test_transfer_admin_changes_admin() { + let t = setup(2, 1); + let new_admin = Address::generate(&t.env); + t.client.transfer_admin(&new_admin); + assert_eq!(t.client.admin(), new_admin); +} + +#[test] +fn test_transfer_admin_emits_event() { + let t = setup(2, 1); + let new_admin = Address::generate(&t.env); + t.client.transfer_admin(&new_admin); + + assert_eq!( + t.env.events().all(), + soroban_sdk::vec![ + &t.env, + ( + t.contract_id.clone(), + soroban_sdk::vec![ + &t.env, + soroban_sdk::symbol_short!("pool").into_val(&t.env), + soroban_sdk::symbol_short!("adm_xfr").into_val(&t.env) + ], + (t.admin.clone(), new_admin.clone()).into_val(&t.env), + ) + ] + ); +} + +#[test] +fn test_transfer_admin_requires_current_admin_auth() { + let (env, contract_id, client, admin, user) = setup_without_mocked_auth(); + let new_admin = Address::generate(&env); + + let result = client + .mock_auths(&[MockAuth { + address: &user, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "transfer_admin", + args: (&new_admin,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_transfer_admin(&new_admin); + + assert!(result.is_err(), "non-admin transfer_admin must be rejected"); + assert_eq!(client.admin(), admin); +} + +#[test] +fn test_old_admin_loses_privileges_after_transfer() { + let (env, contract_id, client, old_admin, _user) = setup_without_mocked_auth(); + let new_admin = Address::generate(&env); + + client + .mock_auths(&[MockAuth { + address: &old_admin, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "transfer_admin", + args: (&new_admin,).into_val(&env), + sub_invokes: &[], + }, + }]) + .transfer_admin(&new_admin); + + let old_pause = client + .mock_auths(&[MockAuth { + address: &old_admin, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "pause", + args: ().into_val(&env), + sub_invokes: &[], + }, + }]) + .try_pause(); + assert!(old_pause.is_err(), "old admin must not be able to pause"); + + let old_multiplier = client + .mock_auths(&[MockAuth { + address: &old_admin, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "set_global_multiplier", + args: (&3u32,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_set_global_multiplier(&3u32); + assert!( + old_multiplier.is_err(), + "old admin must not be able to set global multiplier" + ); + + client + .mock_auths(&[MockAuth { + address: &new_admin, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "pause", + args: ().into_val(&env), + sub_invokes: &[], + }, + }]) + .pause(); + assert!(client.is_paused(), "new admin should be able to pause"); +} + #[test] fn test_lock_assets_creates_position() { let t = setup(1, 1); @@ -459,6 +598,8 @@ fn test_unlock_blocked_before_min_lock_period() { fn test_unlock_allowed_after_min_lock_period() { let t = setup_with_lock_period(1, 1, 100); t.client.lock_assets(&t.user, &1_000); + advance_ledgers(&t.env, 100); + // Should succeed at exactly the boundary. advance_ledgers(&t.env, 100); // exactly at the boundary // Should succeed — no panic. t.client.unlock_assets(&t.user, &1_000);