From 573fc5983505999996a3f033f0c1dba3ef0f8c16 Mon Sep 17 00:00:00 2001 From: ProtonsAndElectrons Date: Thu, 25 Jun 2026 03:03:36 +0200 Subject: [PATCH 1/2] add farming pool admin transfer --- soroban/contracts/farming-pool/src/lib.rs | 98 ++++++--- soroban/contracts/farming-pool/src/test.rs | 228 +++++++++++++++++++-- 2 files changed, 277 insertions(+), 49 deletions(-) diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index 2349d6f..3cbf02d 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -2,9 +2,7 @@ mod types; -use soroban_sdk::{ - contract, contractimpl, symbol_short, token, Address, Env, -}; +use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env}; use types::{BoostConfig, DataKey, Position, UserStake}; // Persistent-storage TTL: extend to ~60 days if below ~30 days (at ~5s/ledger). @@ -149,7 +147,8 @@ fn checkpoint(env: &Env, user: &Address, stake: &mut UserStake) { let rate = get_credit_rate(env); let current = env.ledger().sequence(); let elapsed = current.saturating_sub(stake.start_ledger); - stake.credits_banked += compute_credits(stake.amount, allocation_pct, multiplier, rate, elapsed); + stake.credits_banked += + compute_credits(stake.amount, allocation_pct, multiplier, rate, elapsed); stake.start_ledger = current; } @@ -189,15 +188,46 @@ impl FarmingPool { assert!(credit_rate > 0, "credit_rate must be positive"); env.storage().instance().set(&DataKey::Admin, &admin); - env.storage().instance().set(&DataKey::StakeToken, &stake_token); - env.storage().instance().set(&DataKey::GlobalMultiplier, &global_multiplier); - env.storage().instance().set(&DataKey::CreditRate, &credit_rate); - env.storage().instance().set(&DataKey::MinLockPeriod, &min_lock_period); + env.storage() + .instance() + .set(&DataKey::StakeToken, &stake_token); + env.storage() + .instance() + .set(&DataKey::GlobalMultiplier, &global_multiplier); + env.storage() + .instance() + .set(&DataKey::CreditRate, &credit_rate); + env.storage() + .instance() + .set(&DataKey::MinLockPeriod, &min_lock_period); bump_instance(&env); } // ── 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. /// @@ -222,8 +252,11 @@ impl FarmingPool { } }; - token::TokenClient::new(&env, &get_stake_token(&env)) - .transfer(&user, &env.current_contract_address(), &amount); + token::TokenClient::new(&env, &get_stake_token(&env)).transfer( + &user, + &env.current_contract_address(), + &amount, + ); set_position(&env, &user, &pos); @@ -258,8 +291,11 @@ impl FarmingPool { let total_credits = pos.total_credits; pos.amount -= amount; - token::TokenClient::new(&env, &get_stake_token(&env)) - .transfer(&env.current_contract_address(), &user, &amount); + token::TokenClient::new(&env, &get_stake_token(&env)).transfer( + &env.current_contract_address(), + &user, + &amount, + ); if pos.amount == 0 { remove_position(&env, &user); @@ -280,7 +316,10 @@ impl FarmingPool { return 0; }; let rate = get_credit_rate(&env); - let elapsed = env.ledger().sequence().saturating_sub(pos.checkpoint_ledger); + let elapsed = env + .ledger() + .sequence() + .saturating_sub(pos.checkpoint_ledger); pos.total_credits + pos.amount * rate * elapsed as i128 } @@ -299,10 +338,8 @@ impl FarmingPool { get_admin(&env).require_auth(); bump_instance(&env); env.storage().instance().set(&DataKey::Paused, &true); - env.events().publish( - (symbol_short!("pool"), symbol_short!("paused")), - (), - ); + env.events() + .publish((symbol_short!("pool"), symbol_short!("paused")), ()); } /// Admin: unpause the pool, restoring normal operation. @@ -312,10 +349,8 @@ impl FarmingPool { get_admin(&env).require_auth(); bump_instance(&env); env.storage().instance().set(&DataKey::Paused, &false); - env.events().publish( - (symbol_short!("pool"), symbol_short!("unpaused")), - (), - ); + env.events() + .publish((symbol_short!("pool"), symbol_short!("unpaused")), ()); } /// Return whether the pool is currently paused. @@ -346,8 +381,11 @@ impl FarmingPool { }; // Pull tokens from caller into the contract. - token::TokenClient::new(&env, &get_stake_token(&env)) - .transfer(&from, &env.current_contract_address(), &amount); + token::TokenClient::new(&env, &get_stake_token(&env)).transfer( + &from, + &env.current_contract_address(), + &amount, + ); set_user_stake(&env, &from, &new_stake); } @@ -362,8 +400,11 @@ impl FarmingPool { let total_credits = stake.credits_banked; // Return staked tokens to caller. - token::TokenClient::new(&env, &get_stake_token(&env)) - .transfer(&env.current_contract_address(), &from, &stake.amount); + token::TokenClient::new(&env, &get_stake_token(&env)).transfer( + &env.current_contract_address(), + &from, + &stake.amount, + ); remove_user_stake(&env, &from); total_credits @@ -422,7 +463,9 @@ impl FarmingPool { assert!(multiplier >= 1, "multiplier must be >= 1"); bump_instance(&env); - env.storage().instance().set(&DataKey::GlobalMultiplier, &multiplier); + env.storage() + .instance() + .set(&DataKey::GlobalMultiplier, &multiplier); env.events().publish( (symbol_short!("boost"), symbol_short!("mult_set")), @@ -440,7 +483,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) + stake.credits_banked + + compute_credits(stake.amount, allocation_pct, multiplier, rate, elapsed) } /// Return the current stake record for `user`, or `None` if not staked. diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs index 072d297..a4c54cd 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, @@ -22,12 +23,16 @@ fn setup(global_multiplier: u32, credit_rate: i128) -> TestEnv { setup_with_lock_period(global_multiplier, credit_rate, 0) } -fn setup_with_lock_period(global_multiplier: u32, credit_rate: i128, min_lock_period: u32) -> TestEnv { +fn setup_with_lock_period( + global_multiplier: u32, + credit_rate: i128, + min_lock_period: u32, +) -> TestEnv { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); - let user = Address::generate(&env); + let user = Address::generate(&env); // Deploy a Stellar Asset Contract for the stake token. let token_admin = Address::generate(&env); @@ -37,7 +42,13 @@ fn setup_with_lock_period(global_multiplier: u32, credit_rate: i128, min_lock_pe let contract_id = env.register(FarmingPool, ()); let client = FarmingPoolClient::new(&env, &contract_id); - client.initialize(&admin, &asset.address(), &global_multiplier, &credit_rate, &min_lock_period); + client.initialize( + &admin, + &asset.address(), + &global_multiplier, + &credit_rate, + &min_lock_period, + ); let token = TokenClient::new(&env, &asset.address()); @@ -46,14 +57,39 @@ fn setup_with_lock_period(global_multiplier: u32, credit_rate: i128, min_lock_pe let client = unsafe { core::mem::transmute::, FarmingPoolClient<'static>>(client) }; - let token = unsafe { - core::mem::transmute::, TokenClient<'static>>(token) - }; + let token = unsafe { core::mem::transmute::, TokenClient<'static>>(token) }; let token_sac = unsafe { core::mem::transmute::, StellarAssetClient<'static>>(token_sac) }; - TestEnv { env, client, token, token_sac, admin, user } + TestEnv { + env, + client, + contract_id, + token, + token_sac, + admin, + user, + } +} + +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) { @@ -107,7 +143,10 @@ fn test_set_boost_and_get_config() { t.client.stake(&t.user, &1_000); t.client.set_boost(&t.user, &50u32); - let cfg = t.client.get_boost_config(&t.user).expect("boost config should be set"); + let cfg = t + .client + .get_boost_config(&t.user) + .expect("boost config should be set"); assert_eq!(cfg.allocation_pct, 50); assert_eq!(cfg.multiplier, 2); } @@ -262,13 +301,134 @@ 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); let initial_balance = t.token.balance(&t.user); t.client.lock_assets(&t.user, &500); - let pos = t.client.get_user_position(&t.user).expect("position should exist"); + let pos = t + .client + .get_user_position(&t.user) + .expect("position should exist"); assert_eq!(pos.amount, 500); assert_eq!(pos.total_credits, 0); assert_eq!(t.token.balance(&t.user), initial_balance - 500); @@ -283,7 +443,10 @@ fn test_lock_assets_additional_lock_checkpoints_credits() { advance_ledgers(&t.env, 10); t.client.lock_assets(&t.user, &500); // triggers checkpoint - let pos = t.client.get_user_position(&t.user).expect("position should exist"); + let pos = t + .client + .get_user_position(&t.user) + .expect("position should exist"); assert_eq!(pos.amount, 1_500); assert_eq!(pos.total_credits, 10_000); // 1000 * 10 } @@ -304,14 +467,20 @@ fn test_lock_assets_rejects_negative_amount() { fn test_lock_assets_rejects_insufficient_balance() { let t = setup(1, 1); // User only has 1_000_000_000 tokens; try to lock more. - assert!(t.client.try_lock_assets(&t.user, &2_000_000_000i128).is_err()); + assert!(t + .client + .try_lock_assets(&t.user, &2_000_000_000i128) + .is_err()); } #[test] fn test_lock_assets_emits_event() { let t = setup(1, 1); t.client.lock_assets(&t.user, &1_000); - assert!(!t.env.events().all().events().is_empty(), "lock event not emitted"); + assert!( + !t.env.events().all().events().is_empty(), + "lock event not emitted" + ); } // ── unlock_assets tests ─────────────────────────────────────────────────────── @@ -340,7 +509,10 @@ fn test_unlock_assets_partial_keeps_remaining_position() { t.client.unlock_assets(&t.user, &400); // partial unlock - let pos = t.client.get_user_position(&t.user).expect("position should still exist"); + let pos = t + .client + .get_user_position(&t.user) + .expect("position should still exist"); assert_eq!(pos.amount, 600); // 1000 * 10 = 10000 credits banked during checkpoint assert_eq!(pos.total_credits, 10_000); @@ -373,7 +545,10 @@ fn test_unlock_assets_emits_event() { t.client.lock_assets(&t.user, &1_000); advance_ledgers(&t.env, 5); t.client.unlock_assets(&t.user, &1_000); - assert!(!t.env.events().all().events().is_empty(), "unlock event not emitted"); + assert!( + !t.env.events().all().events().is_empty(), + "unlock event not emitted" + ); } // ── minimum lock period tests ───────────────────────────────────────────────── @@ -390,8 +565,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); // exactly at the boundary - // Should succeed — no panic. + advance_ledgers(&t.env, 100); + // Should succeed at exactly the boundary. t.client.unlock_assets(&t.user, &1_000); assert!(t.client.get_user_position(&t.user).is_none()); } @@ -515,7 +690,10 @@ fn test_unpause_restores_operations() { fn test_pause_emits_event() { let t = setup(1, 1); t.client.pause(); - assert!(!t.env.events().all().events().is_empty(), "pause event not emitted"); + assert!( + !t.env.events().all().events().is_empty(), + "pause event not emitted" + ); } #[test] @@ -523,7 +701,10 @@ fn test_unpause_emits_event() { let t = setup(1, 1); t.client.pause(); t.client.unpause(); - assert!(!t.env.events().all().events().is_empty(), "unpause event not emitted"); + assert!( + !t.env.events().all().events().is_empty(), + "unpause event not emitted" + ); } // ── multi-user isolation ────────────────────────────────────────────────────── @@ -540,7 +721,7 @@ fn test_multiple_users_independent_positions() { // Each user's credits are independent. assert_eq!(t.client.calculate_credits(&t.user), 10_000); // 1000 * 10 - assert_eq!(t.client.calculate_credits(&user2), 20_000); // 2000 * 10 + assert_eq!(t.client.calculate_credits(&user2), 20_000); // 2000 * 10 } #[test] @@ -556,6 +737,9 @@ fn test_one_user_unlock_does_not_affect_another() { t.client.unlock_assets(&t.user, &1_000); // user2's position is untouched. - let pos2 = t.client.get_user_position(&user2).expect("user2 position should exist"); + let pos2 = t + .client + .get_user_position(&user2) + .expect("user2 position should exist"); assert_eq!(pos2.amount, 2_000); } From 313b5e73034e65a55c15319a8ec8b5ae14f11c12 Mon Sep 17 00:00:00 2001 From: ProtonsAndElectrons Date: Thu, 25 Jun 2026 03:08:39 +0200 Subject: [PATCH 2/2] ci: build soroban wasm32v1 target --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3bc82e..165e518 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - targets: wasm32-unknown-unknown + targets: wasm32v1-none - name: Cache cargo registry and build output uses: actions/cache@v6 @@ -37,11 +37,11 @@ jobs: - name: Build release WASM working-directory: soroban - run: cargo build --target wasm32-unknown-unknown --release + run: cargo build --target wasm32v1-none --release - name: Upload WASM artifacts uses: actions/upload-artifact@v7 with: name: wasm-${{ github.sha }} - path: soroban/target/wasm32-unknown-unknown/release/*.wasm + path: soroban/target/wasm32v1-none/release/*.wasm if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e52bd8c..886a256 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - targets: wasm32-unknown-unknown + targets: wasm32v1-none - name: Cache cargo registry and build output uses: actions/cache@v6 @@ -39,17 +39,17 @@ jobs: - name: Build release WASM working-directory: soroban - run: cargo build --target wasm32-unknown-unknown --release + run: cargo build --target wasm32v1-none --release - name: Generate SHA256 sums working-directory: soroban run: | - cd target/wasm32-unknown-unknown/release + cd target/wasm32v1-none/release sha256sum *.wasm > sha256sums.txt - name: Publish GitHub Release uses: softprops/action-gh-release@v3 with: files: | - soroban/target/wasm32-unknown-unknown/release/*.wasm - soroban/target/wasm32-unknown-unknown/release/sha256sums.txt + soroban/target/wasm32v1-none/release/*.wasm + soroban/target/wasm32v1-none/release/sha256sums.txt