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
115 changes: 114 additions & 1 deletion soroban/contracts/farming-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -287,6 +287,19 @@ fn is_user_whitelisted(env: &Env, user: &Address) -> bool {
ok
}

fn get_whitelisted_users_list(env: &Env) -> Vec<Address> {
env.storage()
.instance()
.get(&DataKey::WhitelistedUsers)
.unwrap_or(Vec::new(env))
}

fn set_whitelisted_users_list(env: &Env, users: &Vec<Address>) {
env.storage()
.instance()
.set(&DataKey::WhitelistedUsers, users);
}

// ── Boost calculation ─────────────────────────────────────────────────────────

/// Compute the effective total stake for credit accrual.
Expand Down Expand Up @@ -354,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);
Expand Down Expand Up @@ -507,6 +542,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<u32, PoolError> {
require_initialized(&env)?;
get_admin(&env)?.require_auth();
Expand Down Expand Up @@ -868,6 +920,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(())
}

Expand All @@ -879,6 +937,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<Address> = Vec::new(&env);
for u in users.iter() {
if u != user {
new_users.push_back(u);
}
}
set_whitelisted_users_list(&env, &new_users);
Ok(())
}

Expand All @@ -888,18 +955,54 @@ 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<ListWhitelistedResponse, PoolError> {
require_initialized(&env)?;
bump_instance(&env);

let all = get_whitelisted_users_list(&env);
let total = all.len();
let mut page: Vec<Address> = 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<Address>) -> Result<(), PoolError> {
require_initialized(&env)?;
get_admin(&env)?.require_auth();
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(())
}

Expand All @@ -914,10 +1017,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<Address> = 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(())
}

Expand Down
14 changes: 13 additions & 1 deletion soroban/contracts/farming-pool/src/types.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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<Address>,
/// Total number of whitelisted addresses.
pub total: u32,
}
13 changes: 9 additions & 4 deletions soroban/contracts/vesting-wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -274,15 +279,15 @@ impl VestingWallet {
if unvested > 0 {
token::TokenClient::new(&env, &get_token(&env)).transfer(
&env.current_contract_address(),
&admin,
&funder,
&unvested,
);
}

#[allow(deprecated)]
env.events().publish(
(symbol_short!("vest"), symbol_short!("revoked")),
(admin, vested, unvested),
(funder, vested, unvested),
);

Ok(())
Expand Down
2 changes: 2 additions & 0 deletions soroban/contracts/vesting-wallet/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down