From 8a29244741052d4ecbc876d6ba0997e865b906e4 Mon Sep 17 00:00:00 2001 From: ASIDISG Date: Wed, 26 Aug 2026 23:51:18 +0100 Subject: [PATCH] feat(wrapper): track pending (not-yet-compounded) rewards (#718) Adds a PendingRewards instance-storage slot to the wrapper contract, synced in distribute_rewards() after the reward transfer: it accumulates the cumulative amount distributed that has not yet been compounded, as a running total separate from total_assets/share price. The sync uses checked_add and releases the reentrancy lock before returning on overflow, matching the early-return pattern used elsewhere in this file for guards that fire after acquire_lock(). Adds a pending_rewards() getter (mirroring total_assets()/supply()'s style) and a matching TypeScript SDK getPendingRewards() method. Co-Authored-By: Claude Sonnet 5 --- contracts/wrapper/src/lib.rs | 52 ++++++++++++++- contracts/wrapper/src/test.rs | 118 ++++++++++++++++++++++++++++++++++ sdk/src/wrapperClient.test.ts | 1 + sdk/src/wrapperClient.ts | 12 ++++ 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/contracts/wrapper/src/lib.rs b/contracts/wrapper/src/lib.rs index 176e2e1e..0e434ebf 100644 --- a/contracts/wrapper/src/lib.rs +++ b/contracts/wrapper/src/lib.rs @@ -46,6 +46,11 @@ pub enum DataKey { /// Total vault share supply in circulation. Stored in instance storage /// and updated on every mint (`wrap`) and burn (`unwrap`, `burn`, `burn_from`). Supply, + /// Cumulative underlying tokens received via `distribute_rewards` that have + /// not yet been compounded. Stored in instance storage and incremented on + /// every `distribute_rewards` call; nothing in this contract consumes or + /// resets it yet — that is a later step in the Yield-Bearing Fee Vaults epic. + PendingRewards, /// Per-account wrapped balance. Balance(Address), /// Per-account allowance: (owner, spender) → amount. @@ -224,6 +229,24 @@ impl WrapperContract { env.storage().instance().set(&DataKey::Supply, &supply); } + /// Reads the cumulative amount of undistributed (not-yet-compounded) rewards. + /// + /// @notice Defaults to 0 when never written (e.g. before the first + /// `distribute_rewards` call). + fn read_pending_rewards(env: &Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::PendingRewards) + .unwrap_or(0) + } + + /// Writes the cumulative amount of undistributed (not-yet-compounded) rewards. + fn write_pending_rewards(env: &Env, pending_rewards: i128) { + env.storage() + .instance() + .set(&DataKey::PendingRewards, &pending_rewards); + } + fn read_allowance(env: &Env, from: &Address, spender: &Address) -> i128 { // Check expiration first if let Some(exp) = env @@ -539,7 +562,8 @@ impl WrapperContract { /// # Errors /// * Returns [`WrapperError::NotInitialized`] if contract is uninitialized. /// * Returns [`WrapperError::ContractPaused`] if operations are paused. - /// * Returns [`WrapperError::InvalidAmount`] if amount is non-positive. + /// * Returns [`WrapperError::InvalidAmount`] if amount is non-positive, or if + /// syncing `pending_rewards` would overflow `i128`. pub fn distribute_rewards(env: Env, caller: Address, amount: i128) -> Result<(), WrapperError> { Self::ensure_initialized(&env)?; Self::ensure_not_paused(&env)?; @@ -563,6 +587,20 @@ impl WrapperContract { &amount, ); + // Track this distribution as not-yet-compounded (#718). Nothing reads + // this back into the exchange rate yet — `total_assets`/`calculate_share_price` + // already reflect the transfer above via the underlying token balance; + // this is a parallel running total for whatever later compounding step + // the epic adds. + let pending_rewards = match Self::read_pending_rewards(&env).checked_add(amount) { + Some(v) => v, + None => { + Self::release_lock(&env); + return Err(WrapperError::InvalidAmount); + } + }; + Self::write_pending_rewards(&env, pending_rewards); + Self::release_lock(&env); events::emit_distribute_rewards(&env, &caller, amount); Ok(()) @@ -574,6 +612,18 @@ impl WrapperContract { Self::read_total_assets(&env) } + /// Returns the cumulative underlying tokens distributed via + /// [`WrapperContract::distribute_rewards`] that have not yet been compounded. + /// + /// @notice This is a running total incremented on every `distribute_rewards` + /// call; nothing in this contract consumes or resets it yet. + /// @param env The Soroban environment. + /// @return The pending (not-yet-compounded) reward amount, in underlying tokens. + pub fn pending_rewards(env: Env) -> i128 { + Self::panic_on_err(&env, Self::ensure_initialized(&env)); + Self::read_pending_rewards(&env) + } + /// Calculates the current vault share price: `total_assets / total_shares`. /// /// The share price is the amount of underlying tokens each outstanding diff --git a/contracts/wrapper/src/test.rs b/contracts/wrapper/src/test.rs index dd6a97a2..e1070b42 100644 --- a/contracts/wrapper/src/test.rs +++ b/contracts/wrapper/src/test.rs @@ -116,6 +116,7 @@ fn test_uninitialized_access_panics() { assert!(client.try_symbol().is_err()); assert!(client.try_decimals().is_err()); assert!(client.try_supply().is_err()); + assert!(client.try_pending_rewards().is_err()); } #[test] @@ -852,6 +853,123 @@ fn test_distribute_rewards_when_paused_fails() { ); } +#[test] +fn test_pending_rewards_starts_at_zero() { + let env = Env::default(); + env.mock_all_auths(); + let (wrapper, _underlying, _admin, _user, _wrapper_id) = setup(&env); + + assert_eq!(wrapper.pending_rewards(), 0); +} + +#[test] +fn test_pending_rewards_syncs_after_distribute_rewards() { + let env = Env::default(); + env.mock_all_auths(); + let (wrapper, underlying, admin, user) = setup_and_fund(&env); + let wrapper_id = wrapper.address.clone(); + let rewarder = Address::generate(&env); + + underlying.mint(&admin, &rewarder, &1_000_000); + underlying.approve(&rewarder, &wrapper_id, &1_000_000, &u32::MAX); + + wrapper.wrap(&user, &2_000_000); + assert_eq!(wrapper.pending_rewards(), 0); + + wrapper.distribute_rewards(&rewarder, &1_000_000); + assert_eq!(wrapper.pending_rewards(), 1_000_000); +} + +#[test] +fn test_pending_rewards_accumulates_across_multiple_distributions() { + let env = Env::default(); + env.mock_all_auths(); + let (wrapper, underlying, admin, user) = setup_and_fund(&env); + let wrapper_id = wrapper.address.clone(); + let rewarder = Address::generate(&env); + + underlying.mint(&admin, &rewarder, &3_000_000); + underlying.approve(&rewarder, &wrapper_id, &3_000_000, &u32::MAX); + + wrapper.wrap(&user, &1_000_000); + wrapper.distribute_rewards(&rewarder, &500_000); + assert_eq!(wrapper.pending_rewards(), 500_000); + + wrapper.distribute_rewards(&rewarder, &1_500_000); + assert_eq!(wrapper.pending_rewards(), 2_000_000); +} + +#[test] +fn test_pending_rewards_unaffected_by_wrap_unwrap_and_withdraw() { + let env = Env::default(); + env.mock_all_auths(); + let (wrapper, underlying, admin, user) = setup_and_fund(&env); + let wrapper_id = wrapper.address.clone(); + let rewarder = Address::generate(&env); + + underlying.mint(&admin, &rewarder, &1_000_000); + underlying.approve(&rewarder, &wrapper_id, &1_000_000, &u32::MAX); + + wrapper.wrap(&user, &2_000_000); + wrapper.distribute_rewards(&rewarder, &1_000_000); + assert_eq!(wrapper.pending_rewards(), 1_000_000); + + // Wrapping, unwrapping, and withdrawing move shares/assets but are not + // themselves reward distributions — pending_rewards must not move. + wrapper.wrap(&user, &500_000); + assert_eq!(wrapper.pending_rewards(), 1_000_000); + + wrapper.unwrap(&user, &200_000); + assert_eq!(wrapper.pending_rewards(), 1_000_000); + + wrapper.withdraw(&user, &100_000); + assert_eq!(wrapper.pending_rewards(), 1_000_000); +} + +#[test] +fn test_pending_rewards_not_synced_when_distribute_rewards_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (wrapper, underlying, admin, user) = setup_and_fund(&env); + let wrapper_id = wrapper.address.clone(); + let rewarder = Address::generate(&env); + + underlying.mint(&admin, &rewarder, &1_000_000); + underlying.approve(&rewarder, &wrapper_id, &1_000_000, &u32::MAX); + wrapper.wrap(&user, &1_000_000); + + // A rejected invalid-amount call must not sync pending_rewards. + assert_eq!( + wrapper.try_distribute_rewards(&rewarder, &0), + Err(Ok(WrapperError::InvalidAmount)) + ); + assert_eq!(wrapper.pending_rewards(), 0); + + // Nor must a call rejected for being paused. + wrapper.pause(); + assert_eq!( + wrapper.try_distribute_rewards(&rewarder, &1_000_000), + Err(Ok(WrapperError::ContractPaused)) + ); + assert_eq!(wrapper.pending_rewards(), 0); + + // Confirm the contract is still usable afterward: the reentrancy lock was + // not left held by either rejected call. + wrapper.unpause(); + wrapper.distribute_rewards(&rewarder, &1_000_000); + assert_eq!(wrapper.pending_rewards(), 1_000_000); +} + +#[test] +fn test_pending_rewards_uninitialized_fails() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(WrapperContract, ()); + let client = WrapperContractClient::new(&env, &contract_id); + + assert!(client.try_pending_rewards().is_err()); +} + #[test] fn test_withdraw_returns_proportional_tokens_plus_yield() { let env = Env::default(); diff --git a/sdk/src/wrapperClient.test.ts b/sdk/src/wrapperClient.test.ts index 89110414..d4552391 100644 --- a/sdk/src/wrapperClient.test.ts +++ b/sdk/src/wrapperClient.test.ts @@ -15,6 +15,7 @@ describe('WrapperClient surface', () => { expect(typeof client.getTotalAssets).toBe('function'); expect(typeof client.calculateSharePrice).toBe('function'); expect(typeof client.calculateRewards).toBe('function'); + expect(typeof client.getPendingRewards).toBe('function'); expect(typeof client.wrap).toBe('function'); expect(typeof client.unwrap).toBe('function'); expect(typeof client.withdraw).toBe('function'); diff --git a/sdk/src/wrapperClient.ts b/sdk/src/wrapperClient.ts index d947675c..bc43abf2 100644 --- a/sdk/src/wrapperClient.ts +++ b/sdk/src/wrapperClient.ts @@ -87,6 +87,18 @@ export class WrapperClient { return BigInt(scValToNative(result) as string | number | bigint); } + /** + * Get the cumulative underlying tokens distributed via `distributeRewards` + * that have not yet been compounded. + * + * This is a running total incremented on every `distributeRewards` call; + * nothing on the contract consumes or resets it yet. + */ + async getPendingRewards(): Promise { + const result = await this.queryContract('pending_rewards', []); + return BigInt(scValToNative(result) as string | number | bigint); + } + /** * Calculate the current vault share price (total assets / total shares). *