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
9 changes: 9 additions & 0 deletions soroban/contracts/factory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ impl Factory {
if env.storage().instance().has(&DataKey::Admin) {
return Err(FactoryError::AlreadyInitialized);
}
if pool_wasm_hash == BytesN::from_array(&env, &[0u8; 32]) {
return Err(FactoryError::InvalidWasmHash);
}
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage()
.instance()
Expand Down Expand Up @@ -578,6 +581,8 @@ impl Factory {
/// Allows the admin to point future pool deployments at a corrected or upgraded
/// farming-pool build without redeploying the factory itself. Existing deployed
/// pools are unaffected — Soroban contract bytecode is immutable once deployed.
/// Validates that `new_hash` is non-zero. Callers/admins must verify that the target
/// WASM has been uploaded to the chain before calling this function.
///
/// Emits a `wasm_set` event with `(old_hash, new_hash)` so that the previous
/// hash is discoverable off-chain for rollback scenarios.
Expand All @@ -587,6 +592,10 @@ impl Factory {
admin.require_auth();
bump_instance(&env);

if new_hash == BytesN::from_array(&env, &[0u8; 32]) {
return Err(FactoryError::InvalidWasmHash);
}

let old_hash: BytesN<32> = env.storage().instance().get(&DataKey::WasmHash).unwrap();
env.storage().instance().set(&DataKey::WasmHash, &new_hash);
#[allow(deprecated)]
Expand Down
21 changes: 21 additions & 0 deletions soroban/contracts/factory/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,27 @@ fn test_pool_wasm_hash_returns_stored_hash() {
assert_eq!(t.client.pool_wasm_hash(), t.wasm_hash);
}

#[test]
fn test_initialize_rejects_zero_wasm_hash() {
let (env, client) = setup_uninitialized();
let admin = Address::generate(&env);
let zero_hash = BytesN::from_array(&env, &[0u8; 32]);
assert_eq!(
client.try_initialize(&admin, &zero_hash),
Err(Ok(FactoryError::InvalidWasmHash))
);
}

#[test]
fn test_set_pool_wasm_hash_rejects_zero_hash() {
let t = setup();
let zero_hash = BytesN::from_array(&t.env, &[0u8; 32]);
assert_eq!(
t.client.try_set_pool_wasm_hash(&zero_hash),
Err(Ok(FactoryError::InvalidWasmHash))
);
}

// ── pool_count ────────────────────────────────────────────────────────────────

#[test]
Expand Down
49 changes: 49 additions & 0 deletions soroban/contracts/farming-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,34 @@ fn subtract_total_staked(env: &Env, amount: i128) {
);
}

fn is_user_staked(env: &Env, user: &Address) -> bool {
get_position(env, user).is_some() || get_user_stake(env, user).is_some()
}

fn increment_staked_user_count(env: &Env) {
let count: u32 = env
.storage()
.instance()
.get(&DataKey::StakedUserCount)
.unwrap_or(0);
env.storage()
.instance()
.set(&DataKey::StakedUserCount, &(count + 1));
}

fn decrement_staked_user_count(env: &Env) {
let count: u32 = env
.storage()
.instance()
.get(&DataKey::StakedUserCount)
.unwrap_or(0);
if count > 0 {
env.storage()
.instance()
.set(&DataKey::StakedUserCount, &(count - 1));
}
}

fn set_banked_credits(env: &Env, user: &Address, totals: BankedCreditTotals) {
let key = DataKey::BankedCredits(user.clone());
env.storage().persistent().set(&key, &totals);
Expand Down Expand Up @@ -624,6 +652,7 @@ impl FarmingPool {

bump_instance(&env);

let was_staked = is_user_staked(&env, &user);
let current = env.ledger().sequence();
let mut position = if let Some(mut existing) = get_position(&env, &user) {
checkpoint_position(&env, &user, &mut existing);
Expand Down Expand Up @@ -654,6 +683,9 @@ impl FarmingPool {
// rolled back with it — Soroban's per-invocation atomicity, not
// manual sequencing, is what keeps this safe on failure. See #69.
set_position(&env, &user, &position);
if !was_staked && is_user_staked(&env, &user) {
increment_staked_user_count(&env);
}
add_total_staked(&env, amount);

let stake_token = get_stake_token(&env)?;
Expand All @@ -678,6 +710,7 @@ impl FarmingPool {
assert!(amount > 0, "amount must be positive");
bump_instance(&env);

let was_staked = is_user_staked(&env, &user);
let mut position = get_position(&env, &user).expect("no active position");
assert!(amount <= position.amount, "insufficient locked balance");

Expand Down Expand Up @@ -706,6 +739,9 @@ impl FarmingPool {
} else {
set_position(&env, &user, &position);
}
if was_staked && !is_user_staked(&env, &user) {
decrement_staked_user_count(&env);
}
subtract_total_staked(&env, amount);

let stake_token = get_stake_token(&env)?;
Expand Down Expand Up @@ -862,6 +898,7 @@ impl FarmingPool {
}
bump_instance(&env);

let was_staked = is_user_staked(&env, &user);
let mut total_returned = 0i128;
let mut position_credits = 0i128;
let mut stake_credits = 0i128;
Expand All @@ -884,6 +921,10 @@ impl FarmingPool {
remove_user_stake(&env, &user);
}

if was_staked && !is_user_staked(&env, &user) {
decrement_staked_user_count(&env);
}

if total_returned == 0 {
return Err(PoolError::NoActiveStake);
}
Expand Down Expand Up @@ -1121,6 +1162,7 @@ impl FarmingPool {

bump_instance(&env);

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) {
checkpoint(&env, &from, &mut existing);
Expand All @@ -1141,6 +1183,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 !was_staked && is_user_staked(&env, &from) {
increment_staked_user_count(&env);
}
add_total_staked(&env, amount);

// Pull tokens from caller into the contract.
Expand Down Expand Up @@ -1171,6 +1216,7 @@ impl FarmingPool {
require_withdrawals_not_paused(&env)?;
bump_instance(&env);

let was_staked = is_user_staked(&env, &from);
let mut stake = get_user_stake(&env, &from).expect("no active stake");
checkpoint(&env, &from, &mut stake);
let total_credits = stake.credits_banked;
Expand All @@ -1189,6 +1235,9 @@ impl FarmingPool {
);

remove_user_stake(&env, &from);
if was_staked && !is_user_staked(&env, &from) {
decrement_staked_user_count(&env);
}
subtract_total_staked(&env, stake.amount);
Ok(total_credits)
}
Expand Down
67 changes: 67 additions & 0 deletions soroban/contracts/farming-pool/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2694,3 +2694,70 @@ fn test_emergency_withdraw_reverts_entirely_if_stake_token_naively_reenters() {
let stake = client.get_stake(&user).unwrap();
assert_eq!(stake.amount, 300);
}

#[test]
fn test_staked_user_count_increments_and_decrements_correctly() {
let t = setup(1, 10);
assert_eq!(t.client.staked_user_count(), 0);
assert_eq!(t.client.get_staked_user_count(), 0);

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

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

// User 1 stakes more: count stays 1
t.client.stake(&t.user, &500);
assert_eq!(t.client.staked_user_count(), 1);

// User 2 locks position: count becomes 2
t.client.lock_assets(&user2, &2_000);
assert_eq!(t.client.staked_user_count(), 2);

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

// User 2 unlocks position completely: count becomes 0
advance_ledgers(&t.env, 10);
t.client.unlock_assets(&user2, &2_000);
assert_eq!(t.client.staked_user_count(), 0);
}

#[test]
fn test_lock_assets_top_up_extends_unlock_ledger() {
let t = setup(1, 10);
let start_ledger = t.env.ledger().sequence();

// Initial lock of 1,000 for 10 ledgers
t.client.lock_assets(&t.user, &1_000);
let pos1 = t.client.get_user_position(&t.user).unwrap();
assert_eq!(pos1.unlock_ledger, start_ledger + 10);

// Advance ledgers by 5
advance_ledgers(&t.env, 5);

// Top-up lock of 500: fresh lock period extends unlock_ledger to start_ledger + 5 + 10 = start_ledger + 15
t.client.lock_assets(&t.user, &500);
let pos2 = t.client.get_user_position(&t.user).unwrap();
assert_eq!(pos2.amount, 1_500);
assert_eq!(pos2.unlock_ledger, start_ledger + 15);

// Trying to unlock at ledger start_ledger + 12 should fail
advance_ledgers(&t.env, 7); // now sequence is start_ledger + 12
match t.client.try_unlock_assets(&t.user, &1_500) {
Err(Ok(PoolError::MinimumLockNotElapsed)) => {}
other => assert!(
other.is_err(),
"unlock before extended lock period must fail"
),
}

// Advancing past start_ledger + 15 allows full unlock
advance_ledgers(&t.env, 3); // now sequence is start_ledger + 15
t.client.unlock_assets(&t.user, &1_500);
}


3 changes: 2 additions & 1 deletion soroban/contracts/vesting-wallet/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

use super::*;
use soroban_sdk::{
symbol_short,
testutils::{Address as _, Events, Ledger},
token::{StellarAssetClient, TokenClient},
Address, Env,
Address, Env, IntoVal,
};

// ── Test helpers ──────────────────────────────────────────────────────────────
Expand Down