diff --git a/soroban/contracts/factory/src/lib.rs b/soroban/contracts/factory/src/lib.rs index d3024a9..b3a4457 100644 --- a/soroban/contracts/factory/src/lib.rs +++ b/soroban/contracts/factory/src/lib.rs @@ -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, diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index c5b19ec..c2664ec 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -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::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 { let key = DataKey::UserPosition(user.clone()); let value: Option = env.storage().persistent().get(&key); @@ -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); @@ -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); @@ -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 { + require_initialized(&env)?; + bump_instance(&env); + Ok(env + .storage() + .instance() + .get(&DataKey::TotalDistributedCredits) + .unwrap_or(0)) + } } mod test; diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs index db575a6..7fcd6c1 100644 --- a/soroban/contracts/farming-pool/src/test.rs +++ b/soroban/contracts/farming-pool/src/test.rs @@ -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(); @@ -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`) diff --git a/soroban/contracts/farming-pool/src/types.rs b/soroban/contracts/farming-pool/src/types.rs index 4a4970b..b232378 100644 --- a/soroban/contracts/farming-pool/src/types.rs +++ b/soroban/contracts/farming-pool/src/types.rs @@ -104,4 +104,6 @@ pub enum DataKey { Whitelisted(Address), MinStakeAmount, TotalStaked, + /// Cumulative credits committed to users since pool initialization. + TotalDistributedCredits, } diff --git a/soroban/contracts/vesting-wallet/src/lib.rs b/soroban/contracts/vesting-wallet/src/lib.rs index 666e0d2..8c07fce 100644 --- a/soroban/contracts/vesting-wallet/src/lib.rs +++ b/soroban/contracts/vesting-wallet/src/lib.rs @@ -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; @@ -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 { + 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 diff --git a/soroban/contracts/vesting-wallet/src/test.rs b/soroban/contracts/vesting-wallet/src/test.rs index 63ddd7c..210f278 100644 --- a/soroban/contracts/vesting-wallet/src/test.rs +++ b/soroban/contracts/vesting-wallet/src/test.rs @@ -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)) + )); +} diff --git a/soroban/contracts/vesting-wallet/src/types.rs b/soroban/contracts/vesting-wallet/src/types.rs index 52a3ee1..1a39774 100644 --- a/soroban/contracts/vesting-wallet/src/types.rs +++ b/soroban/contracts/vesting-wallet/src/types.rs @@ -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, +}