From 78153a348ccb5cbb30997936a27dbc413f9cd39e Mon Sep 17 00:00:00 2001 From: 0xSlink Date: Thu, 27 Aug 2026 22:49:05 +0100 Subject: [PATCH 1/4] fix(vesting): return unvested tokens to original funder on revoke (#157) Store the original funder address at initialization and use it instead of the current admin when returning unvested tokens on revocation. This ensures that if admin rights are transferred via transfer_admin before revoke is called, the tokens go back to the address that originally funded the vesting schedule. --- soroban/contracts/vesting-wallet/src/lib.rs | 13 +++++++++---- soroban/contracts/vesting-wallet/src/types.rs | 2 ++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/soroban/contracts/vesting-wallet/src/lib.rs b/soroban/contracts/vesting-wallet/src/lib.rs index 64a3cb7..52e1152 100644 --- a/soroban/contracts/vesting-wallet/src/lib.rs +++ b/soroban/contracts/vesting-wallet/src/lib.rs @@ -187,6 +187,7 @@ impl VestingWallet { .instance() .set(&DataKey::Revocable, &revocable); env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Funder, &admin); env.storage() .instance() .set(&DataKey::ReleasedAmount, &0i128); @@ -243,11 +244,14 @@ impl VestingWallet { Ok(releasable) } - /// Admin: cancel the unvested portion and return it to admin. + /// Admin: cancel the unvested portion and return it to the original funder. /// /// Only callable when `revocable = true`. Tokens vested at the time of the /// call remain claimable by the beneficiary via `release()`. The unvested - /// remainder is transferred back to admin immediately. + /// remainder is transferred back to the funder (the address that funded the + /// vesting schedule at initialization), not the current admin. This ensures + /// that if admin rights were transferred via `transfer_admin`, the original + /// funder still receives their unvested tokens. pub fn revoke(env: Env) -> Result<(), VestingError> { require_initialized(&env)?; if !is_revocable(&env) { @@ -261,6 +265,7 @@ impl VestingWallet { admin.require_auth(); bump_instance(&env); + let funder: Address = env.storage().instance().get(&DataKey::Funder).unwrap(); let vested = compute_vested(&env)?; let total = get_total_amount(&env); let unvested = total - vested; @@ -274,7 +279,7 @@ impl VestingWallet { if unvested > 0 { token::TokenClient::new(&env, &get_token(&env)).transfer( &env.current_contract_address(), - &admin, + &funder, &unvested, ); } @@ -282,7 +287,7 @@ impl VestingWallet { #[allow(deprecated)] env.events().publish( (symbol_short!("vest"), symbol_short!("revoked")), - (admin, vested, unvested), + (funder, vested, unvested), ); Ok(()) diff --git a/soroban/contracts/vesting-wallet/src/types.rs b/soroban/contracts/vesting-wallet/src/types.rs index 52a3ee1..9009479 100644 --- a/soroban/contracts/vesting-wallet/src/types.rs +++ b/soroban/contracts/vesting-wallet/src/types.rs @@ -30,6 +30,8 @@ pub enum DataKey { ReleasedAmount, /// Address authorised to revoke (admin). Admin, + /// Original address that funded the vesting schedule (set at init, never changed). + Funder, /// Whether the schedule can be revoked by admin. Revocable, /// Set to true once admin calls revoke(). From a23536798ad509bd89d10110492dc6dcbcf37065 Mon Sep 17 00:00:00 2001 From: 0xSlink Date: Thu, 27 Aug 2026 22:49:35 +0100 Subject: [PATCH 2/4] docs(farming-pool): document migrate as a no-op placeholder (#158) Add doc comment explaining that migrate is a placeholder for future schema migrations, describing the intended pattern once real migrations are needed (match on old version, perform transforms, stamp new version). --- soroban/contracts/farming-pool/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index 76d2e04..0981fb2 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -507,6 +507,23 @@ impl FarmingPool { read_schema_version(&env) } + /// Schema migration entry-point (currently a no-op placeholder). + /// + /// This function exists so that future schema version bumps can perform + /// data migrations inside the same entry-point without changing the ABI. + /// At present `SCHEMA_VERSION == 1` and no stored data needs + /// transformation, so the call simply stamps the current version and + /// returns the previous one. + /// + /// Admin-only. Returns the schema version *before* this call. + /// + /// # Behaviour once real migrations are needed + /// + /// When `SCHEMA_VERSION` is bumped, add a `match` over the old version + /// that performs the necessary storage reads/writes (e.g. re-encoding a + /// stored struct, adding a new field with a default value, etc.) **before** + /// writing the new `SCHEMA_VERSION`. Each migration step must be idempotent + /// and should be tested in isolation. pub fn migrate(env: Env) -> Result { require_initialized(&env)?; get_admin(&env)?.require_auth(); From 0f0e8945b278f1511729e348b21b23d44e34d577 Mon Sep 17 00:00:00 2001 From: 0xSlink Date: Thu, 27 Aug 2026 22:52:04 +0100 Subject: [PATCH 3/4] feat(farming-pool): add get_whitelisted_users with pagination (#160) Maintain an ordered Vec
of whitelisted users in instance storage, updated by add/remove/batch_add/batch_remove. Expose get_whitelisted_users(offset, limit) returning a paginated ListWhitelistedResponse so admins can audit the full whitelist on-chain. --- soroban/contracts/farming-pool/src/lib.rs | 76 ++++++++++++++++++++- soroban/contracts/farming-pool/src/types.rs | 14 +++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index 0981fb2..42d167c 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -7,7 +7,7 @@ mod types; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, BytesN, Env, Vec}; pub use types::PoolError; -use types::{BankedCreditTotals, BoostConfig, DataKey, Position, UserStake}; +use types::{BankedCreditTotals, BoostConfig, DataKey, ListWhitelistedResponse, Position, UserStake}; // Expose compiled WASM bytes so sibling crates (e.g. `factory`) can upload the // real farming-pool contract in their integration tests via: @@ -287,6 +287,19 @@ fn is_user_whitelisted(env: &Env, user: &Address) -> bool { ok } +fn get_whitelisted_users_list(env: &Env) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::WhitelistedUsers) + .unwrap_or(Vec::new(env)) +} + +fn set_whitelisted_users_list(env: &Env, users: &Vec
) { + env.storage() + .instance() + .set(&DataKey::WhitelistedUsers, users); +} + // ── Boost calculation ───────────────────────────────────────────────────────── /// Compute the effective total stake for credit accrual. @@ -885,6 +898,12 @@ impl FarmingPool { let key = DataKey::Whitelisted(user.clone()); env.storage().persistent().set(&key, &true); bump_user(&env, &key); + + let mut users = get_whitelisted_users_list(&env); + if !users.contains(&user) { + users.push_back(user); + set_whitelisted_users_list(&env, &users); + } Ok(()) } @@ -896,6 +915,15 @@ impl FarmingPool { let key = DataKey::Whitelisted(user.clone()); env.storage().persistent().remove(&key); + + let mut users = get_whitelisted_users_list(&env); + let mut new_users: Vec
= Vec::new(&env); + for u in users.iter() { + if u != user { + new_users.push_back(u); + } + } + set_whitelisted_users_list(&env, &new_users); Ok(()) } @@ -905,6 +933,36 @@ impl FarmingPool { is_user_whitelisted(&env, &user) } + /// Return a paginated list of all whitelisted addresses. + /// + /// `offset`: zero-based index of the first address to return. + /// `limit`: maximum number of addresses to return per call. + /// + /// Returns a `ListWhitelistedResponse` containing the requested page and + /// the total number of whitelisted addresses. Call repeatedly with + /// increasing `offset` until `offset >= total` to retrieve the full list. + pub fn get_whitelisted_users( + env: Env, + offset: u32, + limit: u32, + ) -> Result { + require_initialized(&env)?; + bump_instance(&env); + + let all = get_whitelisted_users_list(&env); + let total = all.len(); + let mut page: Vec
= Vec::new(&env); + let mut i = offset; + let mut count = 0u32; + while i < total && count < limit { + page.push_back(all.get(i).unwrap()); + i += 1; + count += 1; + } + + Ok(ListWhitelistedResponse { users: page, total }) + } + /// Admin: batch add multiple `users` to the whitelist. Capped at 50 addresses per call. Admin must authorise. pub fn batch_add_to_whitelist(env: Env, users: Vec
) -> Result<(), PoolError> { require_initialized(&env)?; @@ -912,11 +970,17 @@ impl FarmingPool { assert!(users.len() <= 50, "max 50 addresses per call"); bump_instance(&env); + let mut list = get_whitelisted_users_list(&env); for user in users.iter() { let key = DataKey::Whitelisted(user.clone()); env.storage().persistent().set(&key, &true); bump_user(&env, &key); + + if !list.contains(&user) { + list.push_back(user); + } } + set_whitelisted_users_list(&env, &list); Ok(()) } @@ -931,10 +995,20 @@ impl FarmingPool { assert!(users.len() <= 50, "max 50 addresses per call"); bump_instance(&env); + let mut list = get_whitelisted_users_list(&env); for user in users.iter() { let key = DataKey::Whitelisted(user.clone()); env.storage().persistent().remove(&key); + + let mut new_list: Vec
= Vec::new(&env); + for u in list.iter() { + if u != user { + new_list.push_back(u); + } + } + list = new_list; } + set_whitelisted_users_list(&env, &list); Ok(()) } diff --git a/soroban/contracts/farming-pool/src/types.rs b/soroban/contracts/farming-pool/src/types.rs index 4a4970b..be2bd52 100644 --- a/soroban/contracts/farming-pool/src/types.rs +++ b/soroban/contracts/farming-pool/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracterror, contracttype, Address}; +use soroban_sdk::{contracterror, contracttype, Address, Vec}; /// Error codes returned by the farming pool contract. #[contracterror] @@ -102,6 +102,18 @@ pub enum DataKey { // Whitelist keys WhitelistEnabled, Whitelisted(Address), + /// Ordered list of all currently whitelisted addresses (instance storage). + WhitelistedUsers, MinStakeAmount, TotalStaked, } + +/// Paginated response for `get_whitelisted_users`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ListWhitelistedResponse { + /// Whitelisted addresses in the requested page. + pub users: Vec
, + /// Total number of whitelisted addresses. + pub total: u32, +} From 713ae94bfb68f0afa6c49900a0cdf5926b6471ee Mon Sep 17 00:00:00 2001 From: 0xSlink Date: Thu, 27 Aug 2026 22:52:41 +0100 Subject: [PATCH 4/4] docs(farming-pool): document credit rate checkpoint behavior (#161) Explain that credit_rate and global_multiplier are snapshotted per-user at checkpoint time, so users who checkpoint less frequently may earn credits at a different effective rate than those who checkpoint more often during a rate change window. Document this as an intentional design trade-off that keeps accrual local and avoids O(n) migration. --- soroban/contracts/farming-pool/src/lib.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index 42d167c..c1aa5dc 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -367,6 +367,28 @@ fn compute_stake_accrual( ) } +/// Snapshot the user's current credit accrual and adopt the latest global +/// multiplier and credit rate. +/// +/// This is called internally by `stake`, `unstake`, and `set_boost` to +/// freeze the user's accrued credits under the *old* rate/multiplier before +/// switching them to the *current* values for future accrual. +/// +/// # Design trade-off: rate changes between checkpoints +/// +/// `credit_rate` and `global_multiplier` are global parameters that can be +/// changed by the admin at any time (via `set_credit_rate` / +/// `set_global_multiplier`). Because each user's snapshot is only updated +/// when *they* trigger a checkpoint (stake, unstake, or set_boost), users +/// who checkpoint less frequently may earn credits at a different effective +/// rate than those who checkpoint more often during a rate change window. +/// +/// This is an intentional design choice: it keeps credit accrual fully +/// local to each user's storage entry (no shared counter to synchronise), +/// avoids front-running concerns around rate changes, and ensures that the +/// cost of a rate change is O(1) rather than O(n) in the number of users. +/// Integrators should be aware that a user's on-chain credit balance may +/// temporarily reflect an outdated rate until their next checkpoint. fn checkpoint(env: &Env, user: &Address, stake: &mut UserStake) { let current = env.ledger().sequence(); stake.credits_banked += compute_stake_accrual(env, user, stake, current);