Skip to content
Open
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: 5 additions & 1 deletion lending_pool/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ pub fn emergency_withdraw(
env.events().publish(topics, (amount, shares_burned));
}

#[allow(dead_code)]
pub fn yield_distributed(env: &Env, token: Address, amount: i128) {
let topics = (Symbol::new(env, "YieldDistributed"), token);
env.events().publish(topics, amount);
}

pub fn loan_manager_updated(env: &Env, token: Address, loan_manager: Address) {
let topics = (Symbol::new(env, "LoanManagerUpdated"), token);
env.events().publish(topics, loan_manager);
}

pub fn deposit_cap_updated(
env: &Env,
token: Address,
Expand Down
217 changes: 199 additions & 18 deletions lending_pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,27 @@ pub enum PoolError {
///
/// v2 replaces the accumulator-style keys (Deposit, RewardDebt, ClaimableYield,
/// AccYieldPerDeposit, UnclaimedYieldPool) with a share-based (LP-token) model.
/// Yield is now implicit in the exchange rate between shares and underlying
/// assets — no separate accumulation or claim step is required.
/// Yield is implicit in the exchange rate between shares and underlying assets —
/// no separate accumulation or claim step is required.
///
/// ## Accounting source of truth (v3, issue #2)
///
/// Share value is derived **exclusively** from `TotalManagedAssets`, an
/// internally-tracked figure equal to deposited principal plus realized yield,
/// net of withdrawals. It is deliberately *not* derived from the contract's raw
/// `TokenClient::balance`, because the raw balance:
/// * drops while principal is out on loan (the principal is still a pool asset,
/// just a receivable — share value must not swing with utilisation), and
/// * can be inflated by anyone transferring tokens directly to the pool
/// address (a donation / inflation attack that would otherwise let an
/// attacker arbitrarily move existing holders' redeemable value).
///
/// The raw balance is used only as a *liquidity* gate: a redemption that cannot
/// currently be serviced from on-hand tokens fails with `InsufficientLiquidity`
/// rather than mis-pricing shares. Realized yield (e.g. loan interest repaid
/// into the pool) is folded into `TotalManagedAssets` exclusively through the
/// admin-gated `record_yield` entry point, so unsolicited transfers never change
/// the exchange rate.
///
/// All per-token keys carry the token address so one contract instance can
/// serve multiple token liquidity pools.
Expand All @@ -47,8 +66,21 @@ pub enum DataKey {
/// (provider, token) → ledger sequence of the most recent deposit
DepositTimestamp(Address, Address),
/// token → total principal deposited (net of withdrawals); used for
/// utilisation stats and the MaxPoolSize cap
/// utilisation stats and the MaxPoolSize cap. Tracks principal only — it is
/// never moved by yield, so it cannot drift above what was actually
/// deposited.
TotalDeposits(Address),
/// token → total underlying assets backing LP shares (principal + realized
/// yield, net of withdrawals). Single source of truth for the share↔asset
/// exchange rate. Unaffected by direct token transfers or by principal
/// temporarily out on loan. See the module-level accounting note.
TotalManagedAssets(Address),
/// token → address authorized to report realized yield via `record_yield`
/// (normally the LoanManager that repayments flow through). Lets the
/// repayment path credit interest to LPs automatically while keeping
/// `record_yield` gated, so arbitrary callers still cannot move the share
/// price.
LoanManager(Address),
/// token → number of active depositors
DepositorCount(Address),
ProposedAdmin,
Expand Down Expand Up @@ -77,7 +109,7 @@ impl LendingPool {
const INSTANCE_TTL_BUMP: u32 = 518400;
const PERSISTENT_TTL_THRESHOLD: u32 = 17280;
const PERSISTENT_TTL_BUMP: u32 = 518400;
const CURRENT_VERSION: u32 = 3;
const CURRENT_VERSION: u32 = 4;
const DEFAULT_WITHDRAWAL_COOLDOWN: u32 = 1_440;
const SHARE_PRICE_SCALE: i128 = 1_000_000;
const MAX_WITHDRAWAL_COOLDOWN_LEDGERS: u32 = 17_280 * 30;
Expand Down Expand Up @@ -128,6 +160,32 @@ impl LendingPool {
.unwrap_or(0)
}

/// Total underlying assets backing LP shares (principal + realized yield,
/// net of withdrawals). The single source of truth for the share↔asset
/// exchange rate — see the module-level accounting note.
fn total_managed_assets(env: &Env, token: &Address) -> i128 {
Self::bump_instance_ttl(env);
env.storage()
.instance()
.get(&DataKey::TotalManagedAssets(token.clone()))
.unwrap_or(0)
}

fn set_total_managed_assets(env: &Env, token: &Address, value: i128) {
env.storage()
.instance()
.set(&DataKey::TotalManagedAssets(token.clone()), &value);
Self::bump_instance_ttl(env);
}

/// Address authorized to report realized yield for `token`, if configured.
fn loan_manager(env: &Env, token: &Address) -> Option<Address> {
Self::bump_instance_ttl(env);
env.storage()
.instance()
.get(&DataKey::LoanManager(token.clone()))
}

fn read_shares(env: &Env, provider: &Address, token: &Address) -> i128 {
let key = DataKey::Shares(provider.clone(), token.clone());
let shares: i128 = env.storage().persistent().get(&key).unwrap_or(0);
Expand Down Expand Up @@ -198,9 +256,20 @@ impl LendingPool {
total_assets_before: i128,
cur_total_shares: i128,
) -> i128 {
if cur_total_shares == 0 || total_assets_before == 0 {
if cur_total_shares == 0 {
// First depositor into an empty share pool: 1:1 allocation.
amount
} else {
// Invariant: once shares exist, managed assets are strictly positive.
// deposit and redeem move shares and managed assets together, and
// record_yield only ever increases managed assets (and requires
// shares > 0), so `total_assets_before == 0` here is unreachable. The
// checked_div below would surface any violation as a panic rather
// than silently minting a diluting 1:1 allocation.
debug_assert!(
total_assets_before > 0,
"managed assets must be positive while shares are outstanding"
);
amount
.checked_mul(cur_total_shares)
.and_then(|v| v.checked_div(total_assets_before))
Expand Down Expand Up @@ -272,18 +341,31 @@ impl LendingPool {
}

let cur_total_shares = Self::total_shares(env, token);
let total_assets = Self::read_pool_balance(env, token);
// Redemption value is derived from internally-tracked managed assets, not
// the raw token balance, so it cannot be manipulated by direct transfers
// and does not swing while principal is out on loan (issue #2).
let total_assets = Self::total_managed_assets(env, token);
let assets_to_return = Self::calc_assets_to_redeem(shares, total_assets, cur_total_shares)?;

if assets_to_return <= 0 {
return Err(PoolError::InvalidAmount);
}

TokenClient::new(env, token).transfer(
&env.current_contract_address(),
provider,
&assets_to_return,
);
// Liquidity gate: the redemption must be serviceable from tokens the pool
// physically holds. If principal is currently out on loan the share value
// is unchanged, but the redemption is deferred rather than mis-priced.
let liquid_balance = Self::read_pool_balance(env, token);
if liquid_balance < assets_to_return {
return Err(PoolError::InsufficientLiquidity);
}

// Principal portion being redeemed, used to keep TotalDeposits tracking
// principal only (it must not absorb the yield portion of the payout).
let cur_total_deposits = Self::total_deposits(env, token);
let principal_redeemed = shares
.checked_mul(cur_total_deposits)
.and_then(|v| v.checked_div(cur_total_shares))
.expect("principal redeem overflow");

let share_key = DataKey::Shares(provider.clone(), token.clone());
let deposit_key = DataKey::DepositTimestamp(provider.clone(), token.clone());
Expand All @@ -309,12 +391,29 @@ impl LendingPool {
.instance()
.set(&DataKey::TotalShares(token.clone()), &new_total_shares);

let new_total_deposits = Self::total_deposits(env, token).saturating_sub(assets_to_return);
// Managed assets shrink by the full payout (principal + yield portion).
let new_managed_assets = total_assets
.checked_sub(assets_to_return)
.expect("managed assets underflow");
Self::set_total_managed_assets(env, token, new_managed_assets);

// TotalDeposits shrinks by the principal portion only, so it never drifts
// away from actual net principal (issue #2, acceptance criterion 4).
let new_total_deposits = cur_total_deposits.saturating_sub(principal_redeemed);
env.storage()
.instance()
.set(&DataKey::TotalDeposits(token.clone()), &new_total_deposits);

Self::bump_instance_ttl(env);

// Interaction last (checks-effects-interactions): all accounting is
// committed before the token leaves the pool.
TokenClient::new(env, token).transfer(
&env.current_contract_address(),
provider,
&assets_to_return,
);

Ok(assets_to_return)
}

Expand Down Expand Up @@ -426,6 +525,14 @@ impl LendingPool {
Self::total_shares(&env, &token)
}

/// Total underlying assets backing LP shares (principal + realized yield),
/// i.e. the value the outstanding shares collectively redeem to. This is the
/// accounting figure that drives the share price — distinct from
/// `pool_balance` (raw on-hand tokens) and `get_total_deposits` (principal).
pub fn get_total_managed_assets(env: Env, token: Address) -> i128 {
Self::total_managed_assets(&env, &token)
}

pub fn get_withdrawal_cooldown(env: Env) -> u32 {
Self::withdrawal_cooldown(&env)
}
Expand Down Expand Up @@ -464,9 +571,11 @@ impl LendingPool {
}
}

// Snapshot pool state *before* the transfer so the share price
// reflects the pre-deposit pool composition.
let total_assets_before = Self::read_pool_balance(&env, &token);
// Snapshot pool state *before* the transfer so the share price reflects
// the pre-deposit pool composition. Uses internally-tracked managed
// assets (not the raw balance) so a direct transfer made just before the
// deposit cannot dilute or inflate the minted shares (issue #2).
let total_assets_before = Self::total_managed_assets(&env, &token);
let cur_total_shares = Self::total_shares(&env, &token);

// Issue #1: first depositor must commit at least MINIMUM_INITIAL_DEPOSIT
Expand Down Expand Up @@ -524,6 +633,12 @@ impl LendingPool {
.instance()
.set(&DataKey::TotalDeposits(token.clone()), &new_total_deposits);

// Deposited principal joins the managed-asset base 1:1.
let new_managed_assets = total_assets_before
.checked_add(amount)
.expect("managed assets overflow");
Self::set_total_managed_assets(&env, &token, new_managed_assets);

Self::bump_instance_ttl(&env);
deposit(
&env,
Expand Down Expand Up @@ -551,7 +666,7 @@ impl LendingPool {
}
let asset_value = Self::calc_assets_to_redeem(
shares,
Self::read_pool_balance(&env, &token),
Self::total_managed_assets(&env, &token),
cur_total_shares,
)
.unwrap_or(0);
Expand All @@ -570,7 +685,7 @@ impl LendingPool {
}
Self::calc_assets_to_redeem(
shares,
Self::read_pool_balance(&env, &token),
Self::total_managed_assets(&env, &token),
cur_total_shares,
)
.unwrap_or(0)
Expand All @@ -583,13 +698,16 @@ impl LendingPool {

/// Current LP share price scaled by `SHARE_PRICE_SCALE`.
/// `1_000_000` means 1.0 underlying asset per share.
///
/// Derived from internally-tracked managed assets, so it is stable while
/// principal is out on loan and immune to direct-transfer manipulation.
pub fn get_share_price(env: Env, token: Address) -> i128 {
let total_shares = Self::total_shares(&env, &token);
if total_shares <= 0 {
return Self::SHARE_PRICE_SCALE;
}

Self::read_pool_balance(&env, &token)
Self::total_managed_assets(&env, &token)
.checked_mul(Self::SHARE_PRICE_SCALE)
.and_then(|v| v.checked_div(total_shares))
.expect("share price overflow")
Expand Down Expand Up @@ -644,6 +762,69 @@ impl LendingPool {
Ok(())
}

/// Authorize `loan_manager` to report realized yield for `token` via
/// [`record_yield`](Self::record_yield). Set this to the LoanManager that
/// repayments flow through so interest is credited to LPs automatically.
/// Admin-gated.
pub fn set_loan_manager(env: Env, token: Address, loan_manager: Address) {
Self::admin(&env).require_auth();
env.storage()
.instance()
.set(&DataKey::LoanManager(token.clone()), &loan_manager);
Self::bump_instance_ttl(&env);
loan_manager_updated(&env, token, loan_manager);
}

/// The address authorized to report yield for `token`, if any.
pub fn get_loan_manager(env: Env, token: Address) -> Option<Address> {
Self::loan_manager(&env, &token)
}

/// Record `amount` of realized yield (e.g. loan interest repaid into the
/// pool), raising every outstanding share's value pro-rata.
///
/// This is the *only* way assets enter share value besides deposits. Because
/// a contract cannot distinguish a legitimate interest repayment from an
/// unsolicited donation by looking at its balance, yield is recognised
/// through this deliberate, access-gated call rather than by reading the raw
/// balance. That is precisely what stops a direct transfer from arbitrarily
/// changing existing holders' redeemable value (issue #2).
///
/// Authorization: the configured [`LoanManager`] reporter for `token` (so the
/// repayment path can credit interest automatically), or the admin when no
/// reporter is configured (manual / keeper operation).
///
/// Yield is not principal, so it does not move `TotalDeposits` or count
/// against the `MaxPoolSize` cap. The caller is responsible for ensuring the
/// corresponding tokens are actually present in the pool; otherwise later
/// redemptions will hit the `InsufficientLiquidity` gate.
pub fn record_yield(env: Env, token: Address, amount: i128) -> Result<(), PoolError> {
// Gate on the configured yield reporter, falling back to admin so the
// function is still usable manually before a LoanManager is wired up.
match Self::loan_manager(&env, &token) {
Some(reporter) => reporter.require_auth(),
None => Self::admin(&env).require_auth(),
}
Self::assert_not_paused(&env)?;

if amount <= 0 {
return Err(PoolError::InvalidAmount);
}

// Yield is only meaningful once shares exist to distribute it to.
if Self::total_shares(&env, &token) <= 0 {
return Err(PoolError::InvalidAmount);
}

let new_managed_assets = Self::total_managed_assets(&env, &token)
.checked_add(amount)
.expect("managed assets overflow");
Self::set_total_managed_assets(&env, &token, new_managed_assets);

yield_distributed(&env, token, amount);
Ok(())
}

// ── Queries ───────────────────────────────────────────────────────────

pub fn get_pool_stats(env: Env, token: Address) -> PoolStats {
Expand Down
Loading
Loading