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
2 changes: 2 additions & 0 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Emitted by `lock_assets` when a user deposits assets into the pool.
| :--- | :--- | :--- |
| `user` | `Address` | The wallet address that locked assets. |
| `amount` | `i128` | The quantity of assets deposited in this call. |
| `total_position` | `i128` | The total quantity of assets locked in the user's position after this call. |

### `unlocked`
Emitted by `unlock_assets` when a user withdraws assets from the pool.
Expand Down Expand Up @@ -137,6 +138,7 @@ Emitted by `set_credit_rate`.
| :--- | :--- | :--- |
| `old_rate` | `i128` | Credit rate before the update. |
| `new_rate` | `i128` | Credit rate after the update. |
| `ledger_sequence` | `u32` | The ledger sequence at the time of the update. |

### `lock_set`
Emitted by `set_min_lock_period`.
Expand Down
8 changes: 7 additions & 1 deletion soroban/contracts/factory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,11 +536,12 @@ impl Factory {
records.push_back((pool_id, record));
}
}
let has_more = next_start_id < count;
return Ok(ListPoolsResponse {
records,
next_start_id,
total: count,
has_more: next_start_id < count,
has_more,
});
}

Expand Down Expand Up @@ -1062,6 +1063,11 @@ impl Factory {
) -> Result<u32, FactoryError> {
require_initialized(&env)?;
let admin = load_admin(&env)?;
// Reject a zero-address admin before any auth checks to avoid misleading Unauthorized errors.
let zero_admin = Address::from_string(&String::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"));
if admin == zero_admin {
return Err(FactoryError::InvalidAdmin);
}
admin.require_auth();
bump_instance(&env);

Expand Down
4 changes: 3 additions & 1 deletion soroban/contracts/factory/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ pub enum DataKey {
Pool(u32),
/// Flag indicating if pool creation is currently paused.
PoolCreationPaused,
/// Pool IDs matching a specific asset address.
AssetPools(Address),
/// Running count of admin transfers performed.
AdminTransferCount,
/// Running total of successful `upgrade_pool` calls, for version tracking (#258).
Expand Down Expand Up @@ -136,7 +138,7 @@ pub enum FactoryError {
InvalidWasmHash = 14,
/// `create_pool`'s minimum lock period is below the minimum allowed threshold.
MinLockPeriodTooShort = 15,
/// `initialize` was called with an invalid admin address.
/// `initialize` was called with a zero-address admin, which would permanently lock the factory.
InvalidAdmin = 16,
/// A pool's TVL could not be read during `total_tvl` maintenance because the
/// deployed pool did not answer the `total_staked` getter (e.g. a pool
Expand Down
108 changes: 76 additions & 32 deletions soroban/contracts/farming-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,44 @@ fn increment_unstake_count(env: &Env) {
.instance()
.set(&DataKey::UnstakeCount, &(count + 1));
}

fn read_active_stake_count(env: &Env) -> u32 {
env.storage()
.instance()
.get(&DataKey::ActiveStakeCount)
.unwrap_or(0)
}

fn increment_active_stake_count(env: &Env) {
let count = read_active_stake_count(env);
env.storage()
.instance()
.set(&DataKey::ActiveStakeCount, &(count + 1));
}

fn decrement_active_stake_count(env: &Env) {
let count = read_active_stake_count(env);
if count > 0 {
env.storage()
.instance()
.set(&DataKey::ActiveStakeCount, &(count - 1));
}
}

fn read_credit_rate_change_count(env: &Env) -> u32 {
env.storage()
.instance()
.get(&DataKey::CreditRateChangeCount)
.unwrap_or(0)
}

fn increment_credit_rate_change_count(env: &Env) {
let count = read_credit_rate_change_count(env);
env.storage()
.instance()
.set(&DataKey::CreditRateChangeCount, &(count + 1));
}

fn get_emergency_withdrawal_count(env: &Env) -> u32 {
env.storage()
.instance()
Expand Down Expand Up @@ -380,11 +418,7 @@ fn add_total_distributed_credits(env: &Env, amount: i128) {
}

fn add_total_credits(env: &Env, amount: i128) {
let total = env
.storage()
.instance()
.get::<DataKey, i128>(&DataKey::TotalCredits)
.unwrap_or(0);
let total = read_total_credits(env);
env.storage().instance().set(
&DataKey::TotalCredits,
&total.checked_add(amount).expect("total credits overflow"),
Expand Down Expand Up @@ -688,7 +722,7 @@ fn checkpoint(env: &Env, user: &Address, stake: &mut UserStake) {
}
}

fn checkpoint_position(env: &Env, user: &Address, position: &mut Position) {
fn checkpoint_position(env: &Env, _user: &Address, position: &mut Position) {
let current = env.ledger().sequence();
let elapsed = current.saturating_sub(position.checkpoint_ledger);
let delta = position.amount * position.credit_rate * elapsed as i128;
Expand Down Expand Up @@ -988,7 +1022,7 @@ impl FarmingPool {

env.events().publish(
(symbol_short!("pool"), symbol_short!("locked")),
(user, amount, position.unlock_ledger),
(user, amount, position.amount),
);
Ok(())
}
Expand Down Expand Up @@ -1065,13 +1099,7 @@ impl FarmingPool {
.ledger()
.sequence()
.saturating_sub(position.checkpoint_ledger);
let allocation_pct = get_user_boost(&env, &user).unwrap_or(0);
let effective_amount = compute_total_stake(
position.amount,
allocation_pct,
read_global_multiplier(&env),
);
Ok(position.total_credits + effective_amount * position.credit_rate * elapsed as i128)
Ok(position.total_credits + position.amount * position.credit_rate * elapsed as i128)
}

/// Return current accrued credits for a user's time-locked `Position`.
Expand All @@ -1089,13 +1117,7 @@ impl FarmingPool {
};
let current = env.ledger().sequence();
let elapsed = current.saturating_sub(position.checkpoint_ledger);
let allocation_pct = get_user_boost(&env, &user).unwrap_or(0);
let effective_amount = compute_total_stake(
position.amount,
allocation_pct,
read_global_multiplier(&env),
);
position.total_credits += effective_amount * position.credit_rate * elapsed as i128;
position.total_credits += position.amount * position.credit_rate * elapsed as i128;
position.checkpoint_ledger = current;
position.credit_rate = read_credit_rate(&env);
Ok(Some(position))
Expand Down Expand Up @@ -1243,6 +1265,7 @@ impl FarmingPool {
subtract_total_staked(&env, stake.amount);
stake_credits = stake.credits_banked;
remove_user_stake(&env, &user);
decrement_active_stake_count(&env);
}

if was_staked && !is_user_staked(&env, &user) {
Expand Down Expand Up @@ -1504,6 +1527,7 @@ impl FarmingPool {

bump_instance(&env);

let is_first_stake = get_user_stake(&env, &from).is_none();
let was_staked = is_user_staked(&env, &from);
let current = env.ledger().sequence();
let mut new_stake = if let Some(mut existing) = get_user_stake(&env, &from) {
Expand All @@ -1525,6 +1549,9 @@ impl FarmingPool {
// Checks-effects-interactions: persist state *before* the external
// token transfer below, consistent with `lock_assets`. See #69, #217.
set_user_stake(&env, &from, &new_stake);
if is_first_stake {
increment_active_stake_count(&env);
}
if !was_staked && is_user_staked(&env, &from) {
increment_staked_user_count(&env);
}
Expand Down Expand Up @@ -1581,6 +1608,7 @@ impl FarmingPool {
);

remove_user_stake(&env, &from);
decrement_active_stake_count(&env);
if was_staked && !is_user_staked(&env, &from) {
decrement_staked_user_count(&env);
}
Expand All @@ -1600,10 +1628,9 @@ impl FarmingPool {
);
bump_instance(&env);

if let Some(mut stake) = get_user_stake(&env, &user) {
checkpoint(&env, &user, &mut stake);
set_user_stake(&env, &user, &stake);
}
let mut stake = get_user_stake(&env, &user).ok_or(PoolError::NoActiveStake)?;
checkpoint(&env, &user, &mut stake);
set_user_stake(&env, &user, &stake);

let old_alloc: u32 = get_user_boost(&env, &user).unwrap_or(0);
if old_alloc == 0 {
Expand Down Expand Up @@ -1697,6 +1724,7 @@ impl FarmingPool {
env.storage()
.instance()
.set(&DataKey::CreditRate, &new_rate);
increment_credit_rate_change_count(&env);
env.events().publish(
(symbol_short!("pool"), symbol_short!("rate_set")),
(old_rate, new_rate, env.ledger().sequence()),
Expand Down Expand Up @@ -1789,13 +1817,7 @@ impl FarmingPool {
.ledger()
.sequence()
.saturating_sub(position.checkpoint_ledger);
let allocation_pct = get_user_boost(&env, &user).unwrap_or(0);
let effective_amount = compute_total_stake(
position.amount,
allocation_pct,
read_global_multiplier(&env),
);
position.total_credits + effective_amount * position.credit_rate * elapsed as i128
position.total_credits + position.amount * position.credit_rate * elapsed as i128
})
.unwrap_or(0);

Expand Down Expand Up @@ -2012,6 +2034,28 @@ impl FarmingPool {
Self::total_locked(env)
}

/// Return the count of currently active stakes in the pool.
pub fn active_stake_count(env: Env) -> Result<u32, PoolError> {
require_initialized(&env)?;
bump_instance(&env);
Ok(read_active_stake_count(&env))
}

pub fn get_active_stake_count(env: Env) -> Result<u32, PoolError> {
Self::active_stake_count(env)
}

/// Return the total number of credit rate changes performed on the pool.
pub fn credit_rate_change_count(env: Env) -> Result<u32, PoolError> {
require_initialized(&env)?;
bump_instance(&env);
Ok(read_credit_rate_change_count(&env))
}

pub fn get_credit_rate_change_count(env: Env) -> Result<u32, PoolError> {
Self::credit_rate_change_count(env)
}

/// Return the number of addresses currently on the whitelist (#248).
///
/// Admins use this for capacity planning without paging the full list via
Expand Down
91 changes: 89 additions & 2 deletions soroban/contracts/farming-pool/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,13 @@ fn test_boost_can_be_updated_repeatedly_without_losing_credits() {
assert_eq!(t.client.get_credits(&t.user), 15_000 + 20_000);
}

#[test]
fn test_set_boost_rejects_without_active_stake() {
let t = setup(2, 1);
let res = t.client.try_set_boost(&t.user, &50u32);
assert_eq!(res, Err(Ok(PoolError::NoActiveStake)));
}

#[test]
fn test_set_boost_rejects_zero_allocation() {
// Soroban host wraps contract panics in HostError; use try_ client variants to inspect them.
Expand Down Expand Up @@ -933,9 +940,9 @@ fn test_flash_stake_unstake_in_same_ledger_yields_no_credits() {
// credits, i.e. flash-staking provides no reward and no leverage.
let t = setup(2, 1);
let initial_balance = t.token.balance(&t.user);
t.client.set_boost(&t.user, &100u32);

t.client.stake(&t.user, &1_000);
t.client.set_boost(&t.user, &100u32);
let credits = t.client.unstake(&t.user);

assert_eq!(credits, 0, "flash staking must not mint credits");
Expand Down Expand Up @@ -1376,7 +1383,26 @@ fn test_lock_assets_emits_event() {
soroban_sdk::symbol_short!("pool").into_val(&t.env),
soroban_sdk::symbol_short!("locked").into_val(&t.env)
],
(t.user.clone(), 1_000i128, 0u32).into_val(&t.env),
(t.user.clone(), 1_000i128, 1_000i128).into_val(&t.env),
)
]
);

// Top up with another 500 tokens; event should include 500 as lock amount and 1500 as total position.
// Soroban test env only retains the most recent invocation's events.
t.client.lock_assets(&t.user, &500);
assert_eq!(
t.env.events().all().filter_by_contract(&t.contract_id),
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!("locked").into_val(&t.env)
],
(t.user.clone(), 500i128, 1_500i128).into_val(&t.env),
)
]
);
Expand Down Expand Up @@ -2958,6 +2984,67 @@ fn test_checkpoint_emits_chkpt_event() {
assert_ne!(events, soroban_sdk::vec![&t.env]);
}

#[test]
fn test_active_stake_count_lifecycle() {
let t = setup(1, 10);
assert_eq!(t.client.active_stake_count(), 0);
assert_eq!(t.client.get_active_stake_count(), 0);

let user2 = Address::generate(&t.env);
t.token_sac.mint(&user2, &10_000);

// User 1 stakes: active_stake_count becomes 1
t.client.stake(&t.user, &1_000);
assert_eq!(t.client.active_stake_count(), 1);
assert_eq!(t.client.get_active_stake_count(), 1);

// User 1 stakes more (top up): active_stake_count remains 1
t.client.stake(&t.user, &500);
assert_eq!(t.client.active_stake_count(), 1);

// User 2 stakes: active_stake_count becomes 2
t.client.stake(&user2, &2_000);
assert_eq!(t.client.active_stake_count(), 2);

// User 3 locks position: locked position does not increment active_stake_count
let user3 = Address::generate(&t.env);
t.token_sac.mint(&user3, &10_000);
t.client.lock_assets(&user3, &1_000);
assert_eq!(t.client.active_stake_count(), 2);

// User 1 unstakes: active_stake_count becomes 1
t.client.unstake(&t.user);
assert_eq!(t.client.active_stake_count(), 1);

// Pool pauses, User 2 emergency withdraws: active_stake_count becomes 0
t.client.pause();
t.client.emergency_withdraw(&user2);
assert_eq!(t.client.active_stake_count(), 0);
assert_eq!(t.client.get_active_stake_count(), 0);
}

#[test]
fn test_credit_rate_change_count_tracking() {
let t = setup(1, 10);
assert_eq!(t.client.credit_rate_change_count(), 0);
assert_eq!(t.client.get_credit_rate_change_count(), 0);

// First rate change
t.client.set_credit_rate(&5i128);
assert_eq!(t.client.credit_rate_change_count(), 1);
assert_eq!(t.client.get_credit_rate_change_count(), 1);

// Second rate change
t.client.set_credit_rate(&10i128);
assert_eq!(t.client.credit_rate_change_count(), 2);
assert_eq!(t.client.get_credit_rate_change_count(), 2);

// Invalid rate change does not increment count
assert!(t.client.try_set_credit_rate(&0i128).is_err());
assert_eq!(t.client.credit_rate_change_count(), 2);
assert_eq!(t.client.get_credit_rate_change_count(), 2);
}

#[test]
fn test_migrate_schema_version_framework() {
let t = setup(1, 10);
Expand Down
4 changes: 4 additions & 0 deletions soroban/contracts/farming-pool/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ pub enum DataKey {
UnstakeCount,
TotalCredits,
EmergencyWithdrawalCount,
/// Running count of active stakes.
ActiveStakeCount,
/// Running count of credit rate updates performed.
CreditRateChangeCount,
/// Running count of `set_boost` calls performed (#230).
BoostCount,
/// Total tokens currently locked in time-locked `Position`s (#232).
Expand Down
Loading