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
52 changes: 51 additions & 1 deletion contracts/wrapper/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)?;
Expand All @@ -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(())
Expand All @@ -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
Expand Down
118 changes: 118 additions & 0 deletions contracts/wrapper/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions sdk/src/wrapperClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 12 additions & 0 deletions sdk/src/wrapperClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<bigint> {
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).
*
Expand Down
Loading