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
6 changes: 6 additions & 0 deletions soroban/contracts/factory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,12 @@ impl Factory {
/// `global_multiplier`, and `min_lock_period` alongside `pool_id` and
/// `pool_address` so off-chain indexers can reconstruct the full pool
/// state without a follow-up RPC call.
///
/// On failure, no event is emitted: a validation failure reverts this
/// invocation, and Soroban discards contract events published by reverted
/// calls. Callers must handle the returned `FactoryError` directly (and,
/// off-chain, can monitor for failed creation attempts via failed
/// transaction diagnostics rather than contract events).
pub fn create_pool(
env: Env,
asset: Address,
Expand Down
38 changes: 37 additions & 1 deletion soroban/contracts/farming-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,18 @@ fn set_banked_credits(env: &Env, user: &Address, totals: BankedCreditTotals) {
bump_user(env, &key);
}

fn add_total_distributed_credits(env: &Env, amount: i128) {
let total = env
.storage()
.instance()
.get::<DataKey, i128>(&DataKey::TotalDistributedCredits)
.unwrap_or(0);
env.storage().instance().set(
&DataKey::TotalDistributedCredits,
&total.checked_add(amount).expect("total credits overflow"),
);
}

fn get_position(env: &Env, user: &Address) -> Option<Position> {
let key = DataKey::UserPosition(user.clone());
let value: Option<Position> = env.storage().persistent().get(&key);
Expand Down Expand Up @@ -356,7 +368,9 @@ fn compute_stake_accrual(

fn checkpoint(env: &Env, user: &Address, stake: &mut UserStake) {
let current = env.ledger().sequence();
stake.credits_banked += compute_stake_accrual(env, user, stake, current);
let accrued = compute_stake_accrual(env, user, stake, current);
stake.credits_banked += accrued;
add_total_distributed_credits(env, accrued);
stake.start_ledger = current;
stake.credit_rate = read_credit_rate(env);
stake.multiplier = read_global_multiplier(env);
Expand Down Expand Up @@ -426,6 +440,9 @@ impl FarmingPool {
.instance()
.set(&DataKey::MinStakeAmount, &min_stake);
env.storage().instance().set(&DataKey::TotalStaked, &0i128);
env.storage()
.instance()
.set(&DataKey::TotalDistributedCredits, &0i128);
env.storage()
.instance()
.set(&DataKey::SchemaVersion, &SCHEMA_VERSION);
Expand Down Expand Up @@ -1303,6 +1320,25 @@ impl FarmingPool {
.get(&DataKey::TotalStaked)
.unwrap_or(0))
}

/// Return the cumulative number of credits distributed to all users since
/// the pool was initialized.
///
/// The counter grows as credits are committed (banked) for users — at each
/// `checkpoint`/`checkpoint_position` that occurs on stake, lock, boost,
/// unlock, and unstake operations — rather than on every read-only
/// accrual view. It therefore always reflects the sum a protocol-wide
/// `get_credits` aggregation converges to as users interact, and is the
/// companion of `total_staked` for reward-rate and inflation analytics.
pub fn total_distributed_credits(env: Env) -> Result<i128, PoolError> {
require_initialized(&env)?;
bump_instance(&env);
Ok(env
.storage()
.instance()
.get(&DataKey::TotalDistributedCredits)
.unwrap_or(0))
}
}

mod test;
83 changes: 83 additions & 0 deletions soroban/contracts/farming-pool/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,64 @@ fn test_total_staked_tracks_locked_and_flexible_positions() {
assert_eq!(t.client.total_staked(), 0);
}

// ── total_distributed_credits tests ───────────────────────────────────────────

#[test]
fn test_total_distributed_credits_starts_at_zero() {
let t = setup(2, 1);
assert_eq!(t.client.total_distributed_credits(), 0);
}

#[test]
fn test_total_distributed_credits_counts_banked_stake_accrual_on_checkpoint() {
let t = setup(2, 1);
t.client.stake(&t.user, &1_000);
assert_eq!(t.client.total_distributed_credits(), 0);

advance_ledgers(&t.env, 10);
// Read-only views must not commit anything to the aggregate.
assert_eq!(t.client.get_credits(&t.user), 10_000);
assert_eq!(t.client.total_distributed_credits(), 0);

// unstake checkpoints and banks 10_000 credits.
let banked = t.client.unstake(&t.user);
assert_eq!(banked, 10_000);
assert_eq!(t.client.total_distributed_credits(), 10_000);
}

#[test]
fn test_total_distributed_credits_counts_position_accrual_on_unlock() {
let t = setup_with_lock_period(1, 1, 0);
assert_eq!(t.client.total_distributed_credits(), 0);

t.client.lock_assets(&t.user, &1_000);
assert_eq!(t.client.total_distributed_credits(), 0);

advance_ledgers(&t.env, 10);
// Partial unlock checkpoints the position and banks 1_000 * 1 * 10.
t.client.unlock_assets(&t.user, &500);
assert_eq!(t.client.total_distributed_credits(), 10_000);
}

#[test]
fn test_total_distributed_credits_accumulates_across_users_and_systems() {
let t = setup(2, 1);
let other = Address::generate(&t.env);
t.token_sac.mint(&other, &1_000_000_000i128);

// User A flexible stake: 10 ledgers unbooted → 10_000 credits.
t.client.stake(&t.user, &1_000);
advance_ledgers(&t.env, 10);
t.client.stake(&t.user, &100); // checkpoints 10_000
assert_eq!(t.client.total_distributed_credits(), 10_000);

// User B locked position: 5 more ledgers → 500 credits.
t.client.lock_assets(&other, &100);
advance_ledgers(&t.env, 5);
t.client.unlock_assets(&other, &100); // checkpoints 500
assert_eq!(t.client.total_distributed_credits(), 10_500);
}

#[test]
fn test_pause_uninitialized_returns_not_initialized() {
let (_env, client, _user) = setup_uninitialized();
Expand Down Expand Up @@ -642,6 +700,31 @@ fn test_admin_multiplier_change_applies_to_existing_stake_without_manual_checkpo
assert_eq!(t.client.get_credits(&t.user), 35_000);
}

#[test]
fn test_get_credits_matches_checkpoint_accrual_after_multiplier_change() {
// Regression for #223: get_credits and checkpoint must use the same
// multiplier source, so an un-checkpointed read equals exactly what the
// next checkpointing operation banks.
let t = setup(2, 1);
t.client.stake(&t.user, &1_000);
t.client.set_boost(&t.user, &50u32);
advance_ledgers(&t.env, 10);

t.client.set_global_multiplier(&3u32);
advance_ledgers(&t.env, 10);

// Read-only view: must not mutate stake state.
let viewed = t.client.get_credits(&t.user);
assert_eq!(viewed, 35_000);

// unstake checkpoints → banked credits must equal the viewed total.
let banked = t.client.unstake(&t.user);
assert_eq!(banked, viewed);

// The aggregate counter must agree with the banked amount too.
assert_eq!(t.client.total_distributed_credits(), banked);
}

#[test]
fn test_admin_multiplier_rejects_zero() {
// Updated for #89: the old bare `assert!` (matched via `should_panic`)
Expand Down
2 changes: 2 additions & 0 deletions soroban/contracts/farming-pool/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,6 @@ pub enum DataKey {
Whitelisted(Address),
MinStakeAmount,
TotalStaked,
/// Cumulative credits committed to users since pool initialization.
TotalDistributedCredits,
}
22 changes: 21 additions & 1 deletion soroban/contracts/vesting-wallet/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ mod types;

use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env};
use types::DataKey;
pub use types::{AdminTransferred, VestingError};
pub use types::{AdminTransferred, VestingError, VestingSchedule};

// Persistent-storage TTL: extend to ~60 days if below ~30 days (at ~5 s/ledger).
const TTL_THRESHOLD: u32 = 518_400;
Expand Down Expand Up @@ -309,6 +309,26 @@ impl VestingWallet {
Ok(compute_vested(&env)? - get_released(&env))
}

/// Return the full vesting schedule parameters in a single call.
///
/// Frontends need `beneficiary`, `token`, `total_amount`, `start_ledger`,
/// `cliff_ledger`, `end_ledger`, and `revocable` together to render a
/// schedule; previously each required a separate read. Returns
/// `NotInitialized` if the wallet has not been initialized.
pub fn get_vesting_schedule(env: Env) -> Result<VestingSchedule, VestingError> {
require_initialized(&env)?;
bump_instance(&env);
Ok(VestingSchedule {
beneficiary: get_beneficiary(&env),
token: get_token(&env),
total_amount: get_total_amount(&env),
start_ledger: get_start_ledger(&env),
cliff_ledger: get_cliff_ledger(&env),
end_ledger: get_end_ledger(&env),
revocable: is_revocable(&env),
})
}

/// Emergency recovery that deliberately bypasses vesting arithmetic.
///
/// Admin-only. Transfers the wallet's raw token balance to the admin and
Expand Down
45 changes: 45 additions & 0 deletions soroban/contracts/vesting-wallet/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,48 @@ fn test_released_amount_tracks_cumulative_releases() {
assert_eq!(t.client.released_amount(), 500);
assert_eq!(t.token.balance(&t.beneficiary), 500);
}

// ── get_vesting_schedule tests ────────────────────────────────────────────────

#[test]
fn test_get_vesting_schedule_returns_all_parameters() {
let t = setup_schedule(50, 200, 1_000, true);

let schedule = t.client.get_vesting_schedule();

assert_eq!(schedule.beneficiary, t.beneficiary);
assert_eq!(schedule.token, t.token_address);
assert_eq!(schedule.total_amount, 1_000);
assert_eq!(schedule.start_ledger, t.start);
assert_eq!(schedule.cliff_ledger, t.start + 50);
assert_eq!(schedule.end_ledger, t.start + 250);
assert!(schedule.revocable);
}

#[test]
fn test_get_vesting_schedule_reflects_transferred_beneficiary() {
let t = setup(0, 100, 1_000);

let new_beneficiary = Address::generate(&t.env);
t.client.transfer_beneficiary(&new_beneficiary);

let schedule = t.client.get_vesting_schedule();
assert_eq!(schedule.beneficiary, new_beneficiary);
assert_eq!(schedule.total_amount, 1_000);
assert_eq!(schedule.start_ledger, t.start);
assert_eq!(schedule.end_ledger, t.start + 100);
assert!(!schedule.revocable);
}

#[test]
fn test_get_vesting_schedule_uninitialized_returns_not_initialized() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(VestingWallet, ());
let client = VestingWalletClient::new(&env, &contract_id);

assert!(matches!(
client.try_get_vesting_schedule(),
Err(Ok(VestingError::NotInitialized))
));
}
13 changes: 13 additions & 0 deletions soroban/contracts/vesting-wallet/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,16 @@ pub struct AdminTransferred {
pub old_admin: Address,
pub new_admin: Address,
}

/// Full vesting schedule parameters, returned by `get_vesting_schedule`.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VestingSchedule {
pub beneficiary: Address,
pub token: Address,
pub total_amount: i128,
pub start_ledger: u32,
pub cliff_ledger: u32,
pub end_ledger: u32,
pub revocable: bool,
}