diff --git a/contracts/teachlink/Cargo.toml b/contracts/teachlink/Cargo.toml index a993468a..cb1ba84a 100644 --- a/contracts/teachlink/Cargo.toml +++ b/contracts/teachlink/Cargo.toml @@ -40,3 +40,7 @@ path = "tests/test_cross_contract_interactions.rs" [[test]] name = "test_gas_benchmarks" path = "tests/test_gas_benchmarks.rs" + +[[test]] +name = "test_liquidity_rewards" +path = "tests/test_liquidity_rewards.rs" diff --git a/contracts/teachlink/src/events.rs b/contracts/teachlink/src/events.rs index e5f79ca6..2ac541cf 100644 --- a/contracts/teachlink/src/events.rs +++ b/contracts/teachlink/src/events.rs @@ -267,6 +267,14 @@ pub struct FeeUpdatedEvent { pub multiplier: u32, } +#[contractevent] +#[derive(Clone, Debug)] +pub struct FeeRevenueRecordedEvent { + pub chain_id: u32, + pub amount: i128, + pub accumulated_fees: i128, +} + // ================= Message Passing Events ================= #[contractevent] diff --git a/contracts/teachlink/src/lib.rs b/contracts/teachlink/src/lib.rs index 2c536bc4..82613aaf 100644 --- a/contracts/teachlink/src/lib.rs +++ b/contracts/teachlink/src/lib.rs @@ -797,6 +797,16 @@ impl TeachLinkBridge { liquidity::LiquidityManager::get_available_liquidity(&env, chain_id) } + /// Record fee revenue collected by a pool (admin only) + pub fn record_fee_revenue(env: Env, chain_id: u32, amount: i128) -> Result { + liquidity::LiquidityManager::record_fee_revenue(&env, chain_id, amount) + } + + /// Get accumulated fee revenue for a pool + pub fn get_accumulated_fees(env: Env, chain_id: u32) -> i128 { + liquidity::LiquidityManager::get_accumulated_fees(&env, chain_id) + } + // ========== Message Passing Functions ========== /// Send a cross-chain packet diff --git a/contracts/teachlink/src/liquidity.rs b/contracts/teachlink/src/liquidity.rs index 8dcbe59b..34b72196 100644 --- a/contracts/teachlink/src/liquidity.rs +++ b/contracts/teachlink/src/liquidity.rs @@ -52,24 +52,27 @@ //! //! # LP Reward Calculation //! -//! Rewards use scaled integer arithmetic to avoid precision loss: +//! Rewards are drawn from the pool's **real fee revenue** (`accumulated_fees`) +//! and weighted by how long the position has been held, so flash liquidity is +//! not rewarded as generously as long-term provision. //! //! ```text -//! reward = (position.amount * SCALE / total_liquidity) * position.amount / SCALE -//! ≈ position.amount² / total_liquidity +//! fee_share = accumulated_fees * position.amount / total_liquidity +//! time_multiplier = 1.0x, growing +1.0x every 30 days, capped at 3.0x +//! reward = min(fee_share * time_multiplier, accumulated_fees) //! ``` //! -//! where `SCALE = 1_000_000`. This is equivalent to `amount * (amount / total)` -//! but avoids the inner division truncating to zero for small positions. +//! All arithmetic is scaled (`SCALE = 1_000_000`) to avoid precision loss, and +//! rewards are hard-bounded by the fees the pool has actually collected. //! //! # TODO -//! - Implement time-weighted LP rewards so long-term providers earn more than -//! flash liquidity providers. //! - Add slippage protection for large bridge transactions relative to pool size. use crate::errors::BridgeError; -use crate::events::{FeeUpdatedEvent, LiquidityAddedEvent, LiquidityRemovedEvent}; -use crate::storage::{FEE_STRUCTURE, LIQUIDITY_POOLS, LP_POSITIONS}; +use crate::events::{ + FeeRevenueRecordedEvent, FeeUpdatedEvent, LiquidityAddedEvent, LiquidityRemovedEvent, +}; +use crate::storage::{ADMIN, FEE_STRUCTURE, LIQUIDITY_POOLS, LP_POSITIONS}; use crate::types::{BridgeFeeStructure, LPPosition, LiquidityPool}; use crate::validation::NumberValidator; use soroban_sdk::{Address, Env, Map, Vec}; @@ -88,6 +91,15 @@ pub const CONGESTION_STEP_1: u32 = 5000; // 50% utilization pub const CONGESTION_STEP_2: u32 = 7000; // 70% utilization pub const CONGESTION_STEP_3: u32 = 9000; // 90% utilization +/// Scaling factor for LP reward arithmetic (1_000_000 = 1.0). +pub const REWARD_SCALE: i128 = 1_000_000; +/// Base time-in-pool multiplier (1_000_000 = 1.0x) applied at deposit time. +pub const TIME_SCALE: i128 = 1_000_000; +/// Maximum time-in-pool multiplier (3_000_000 = 3.0x). +pub const MAX_TIME_MULTIPLIER: i128 = 3_000_000; +/// Seconds per full multiplier step: the multiplier grows +1.0x every 30 days. +pub const TIME_MULTIPLIER_STEP_SECS: i128 = 30 * 24 * 60 * 60; + /// Liquidity Manager pub struct LiquidityManager; @@ -100,6 +112,7 @@ impl LiquidityManager { total_liquidity: 0, available_liquidity: 0, locked_liquidity: 0, + accumulated_fees: 0, lp_providers: Map::new(env), }; @@ -213,8 +226,9 @@ impl LiquidityManager { return Err(BridgeError::InsufficientBalance); } - // Calculate rewards - let rewards = Self::calculate_lp_rewards(env, &position, pool.total_liquidity); + // Calculate rewards from the pool's real fee revenue and time in pool + let rewards = + Self::calculate_lp_rewards(env, &position, pool.total_liquidity, pool.accumulated_fees); // Update position position.amount -= amount; @@ -310,6 +324,49 @@ impl LiquidityManager { Ok(()) } + /// Record fee revenue collected by a pool (admin only). + /// + /// Credits `amount` to the pool's `accumulated_fees` balance. LP rewards + /// are drawn from this balance, so recorded fees must correspond to fees + /// actually collected by the bridge for the pool's token. + pub fn record_fee_revenue(env: &Env, chain_id: u32, amount: i128) -> Result { + Self::require_admin(env); + + NumberValidator::validate_amount(amount).map_err(|_| BridgeError::AmountMustBePositive)?; + + let mut pools: Map = env + .storage() + .instance() + .get(&LIQUIDITY_POOLS) + .unwrap_or_else(|| Map::new(env)); + let mut pool = pools + .get(chain_id) + .ok_or(BridgeError::DestinationChainNotSupported)?; + + pool.accumulated_fees += amount; + pools.set(chain_id, pool.clone()); + env.storage().instance().set(&LIQUIDITY_POOLS, &pools); + + // Emit event + FeeRevenueRecordedEvent { + chain_id, + amount, + accumulated_fees: pool.accumulated_fees, + } + .publish(env); + + Ok(pool.accumulated_fees) + } + + /// Get the accumulated fee revenue for a pool. + pub fn get_accumulated_fees(env: &Env, chain_id: u32) -> i128 { + if let Some(pool) = Self::get_pool(env, chain_id) { + pool.accumulated_fees + } else { + 0 + } + } + /// Calculate dynamic bridge fee. /// /// # Algorithm @@ -496,43 +553,63 @@ impl LiquidityManager { discount } - /// Calculate LP rewards based on position and pool performance. + /// Calculate LP rewards based on real fee revenue and time in pool. /// /// # Algorithm /// - /// Uses scaled integer arithmetic to avoid precision loss from integer - /// division truncating `share_factor` to zero for small positions: + /// Rewards are derived from the pool's actual collected fee revenue rather + /// than a synthetic proportional formula: /// /// ```text - /// reward = (position.amount * SCALE / total_liquidity) * position.amount / SCALE - /// ≈ position.amount² / total_liquidity + /// fee_share = accumulated_fees * position.amount / total_liquidity + /// time_multiplier = 1.0x at deposit, +1.0x per 30 days held, capped at 3.0x + /// reward = min(fee_share * time_multiplier, accumulated_fees) /// ``` /// - /// where `SCALE = 1_000_000`. This is mathematically equivalent to - /// `amount * (amount / total)` but avoids the inner division flooring to - /// zero when `amount << total`. - /// - /// # Note - /// This is a simplified proportional reward model. In production, rewards - /// should also factor in time-in-pool and accumulated fee revenue. - /// - /// # TODO - /// - Integrate actual fee revenue collected by the pool so LP rewards - /// reflect real earnings rather than a synthetic proportional amount. - /// - Add time-weighting: providers who stay longer earn a multiplier. - fn calculate_lp_rewards(_env: &Env, position: &LPPosition, total_liquidity: i128) -> i128 { - if total_liquidity == 0 || position.amount == 0 { + /// Scaled integer arithmetic (`REWARD_SCALE = 1_000_000`) avoids precision + /// loss from the inner division truncating small shares to zero. The final + /// reward is hard-bounded by the fees the pool has actually collected, so + /// payouts can never exceed real protocol income. + fn calculate_lp_rewards( + env: &Env, + position: &LPPosition, + total_liquidity: i128, + accumulated_fees: i128, + ) -> i128 { + if total_liquidity == 0 || position.amount == 0 || accumulated_fees == 0 { return 0; } - // Scale numerator before dividing to preserve precision: - // reward = (position.amount^2 * SCALE) / (total_liquidity * SCALE_DIVISOR) - // equivalent to: position.amount * (position.amount / total_liquidity) - // but avoids share_factor flooring to 0 for small positions. - const SCALE: i128 = 1_000_000; - let reward = (position.amount * SCALE) / total_liquidity * position.amount / SCALE; + // Provider's share of the pool, scaled to preserve precision. + let share_factor = (position.amount * REWARD_SCALE) / total_liquidity; + let fee_share = (accumulated_fees * share_factor) / REWARD_SCALE; - reward + // Time-in-pool multiplier so long-term providers earn more than flash + // liquidity providers of equal size. + let time_multiplier = Self::time_in_pool_multiplier(env, position); + + let reward = (fee_share * time_multiplier) / TIME_SCALE; + + // Rewards stay bounded by the fees actually collected by the pool. + reward.min(accumulated_fees) + } + + /// Time-in-pool multiplier for a position. + /// + /// Starts at 1.0x at deposit time and grows linearly by +1.0x for every + /// [`TIME_MULTIPLIER_STEP_SECS`] (30 days) the position is held, capped at + /// [`MAX_TIME_MULTIPLIER`] (3.0x). + fn time_in_pool_multiplier(env: &Env, position: &LPPosition) -> i128 { + let now = env.ledger().timestamp(); + let elapsed = now.saturating_sub(position.deposited_at) as i128; + let growth = (elapsed * TIME_SCALE) / TIME_MULTIPLIER_STEP_SECS; + (TIME_SCALE + growth).min(MAX_TIME_MULTIPLIER) + } + + /// Require the contract admin to authorize the call. + fn require_admin(env: &Env) { + let admin: Address = env.storage().instance().get(&ADMIN).expect("admin not set"); + admin.require_auth(); } /// Default volume discount tiers @@ -592,3 +669,131 @@ impl LiquidityManager { Self::get_available_liquidity(env, chain_id) >= amount } } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; + + const DAY: u64 = 24 * 60 * 60; + + fn set_timestamp(env: &Env, timestamp: u64) { + env.ledger().set(LedgerInfo { + timestamp, + protocol_version: 25, + sequence_number: 10, + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 10, + min_persistent_entry_ttl: 10, + max_entry_ttl: 2_000_000, + }); + } + + fn position(env: &Env, amount: i128, deposited_at: u64) -> LPPosition { + LPPosition { + provider: Address::generate(env), + amount, + share_percentage: 0, + deposited_at, + rewards_earned: 0, + } + } + + #[test] + fn rewards_are_zero_without_fee_revenue() { + let env = Env::default(); + set_timestamp(&env, 1_000_000); + let pos = position(&env, 100_000, 0); + + // No fees collected yet: even a large, long-held position earns nothing, + // because rewards must be backed by real fee income. + let rewards = LiquidityManager::calculate_lp_rewards(&env, &pos, 200_000, 0); + assert_eq!(rewards, 0); + } + + #[test] + fn rewards_are_proportional_to_fee_revenue() { + let env = Env::default(); + set_timestamp(&env, 1_000_000); + // Provider holds 50% of the pool and deposited at the current time. + let pos = position(&env, 50_000, 1_000_000); + + // 50% share of 10_000 collected fees at 1.0x time multiplier. + let rewards = LiquidityManager::calculate_lp_rewards(&env, &pos, 100_000, 10_000); + assert_eq!(rewards, 5_000); + } + + #[test] + fn long_term_position_earns_more_than_flash_position() { + let env = Env::default(); + let now = 10_000_000u64; + set_timestamp(&env, now); + + // Two equally-sized positions: one deposited now (flash), one 30 days ago. + let flash = position(&env, 100_000, now); + let long = position(&env, 100_000, now - 30 * DAY); + let total_liquidity = 200_000; + let accumulated_fees = 10_000; + + let flash_rewards = + LiquidityManager::calculate_lp_rewards(&env, &flash, total_liquidity, accumulated_fees); + let long_rewards = + LiquidityManager::calculate_lp_rewards(&env, &long, total_liquidity, accumulated_fees); + + // Flash position earns its flat 50% share (1.0x); the 30-day position + // earns the same share scaled by the 2.0x time multiplier. + assert_eq!(flash_rewards, 5_000); + assert_eq!(long_rewards, 10_000); + assert!(long_rewards > flash_rewards); + } + + #[test] + fn rewards_stay_bounded_by_collected_fees() { + let env = Env::default(); + let now = 10_000_000u64; + set_timestamp(&env, now); + + // 100% share, held 100 days (max 3.0x multiplier): the raw time-weighted + // reward would be 3x the fees, but it must be clamped to the collected + // fees so payouts never exceed real protocol income. + let pos = position(&env, 100_000, now - 100 * DAY); + let rewards = LiquidityManager::calculate_lp_rewards(&env, &pos, 100_000, 10_000); + assert_eq!(rewards, 10_000); + assert!(rewards <= 10_000); + } + + #[test] + fn time_multiplier_grows_with_duration_and_caps() { + let env = Env::default(); + let now = 10_000_000u64; + + // At deposit time: 1.0x + set_timestamp(&env, now); + assert_eq!( + LiquidityManager::time_in_pool_multiplier(&env, &position(&env, 1_000, now)), + TIME_SCALE + ); + + // 30 days held: 2.0x + set_timestamp(&env, now + 30 * DAY); + assert_eq!( + LiquidityManager::time_in_pool_multiplier(&env, &position(&env, 1_000, now)), + 2 * TIME_SCALE + ); + + // 60 days held: 3.0x + set_timestamp(&env, now + 60 * DAY); + assert_eq!( + LiquidityManager::time_in_pool_multiplier(&env, &position(&env, 1_000, now)), + 3 * TIME_SCALE + ); + + // 100 days held: capped at 3.0x + set_timestamp(&env, now + 100 * DAY); + assert_eq!( + LiquidityManager::time_in_pool_multiplier(&env, &position(&env, 1_000, now)), + MAX_TIME_MULTIPLIER + ); + } +} diff --git a/contracts/teachlink/src/types.rs b/contracts/teachlink/src/types.rs index 2be1b515..73f6987e 100644 --- a/contracts/teachlink/src/types.rs +++ b/contracts/teachlink/src/types.rs @@ -270,6 +270,10 @@ pub struct LiquidityPool { pub total_liquidity: i128, pub available_liquidity: i128, pub locked_liquidity: i128, + /// Real fee revenue collected by the pool. LP rewards are drawn from this + /// balance so payouts are backed by actual protocol income rather than a + /// synthetic formula. + pub accumulated_fees: i128, pub lp_providers: Map, } diff --git a/contracts/teachlink/tests/test_liquidity_rewards.rs b/contracts/teachlink/tests/test_liquidity_rewards.rs new file mode 100644 index 00000000..c5e88b4b --- /dev/null +++ b/contracts/teachlink/tests/test_liquidity_rewards.rs @@ -0,0 +1,109 @@ +//! LP Reward Integration Tests +//! +//! Verifies end-to-end that LP rewards are drawn from real recorded fee +//! revenue and weighted by time in pool (issue #494): +//! - Rewards are zero until fee revenue is recorded. +//! - Long-held positions earn more than equally-sized flash positions. +//! - Rewards never exceed the fees the pool actually collected. + +#![cfg(test)] +#![allow(clippy::needless_pass_by_value)] +#![allow(clippy::unreadable_literal)] + +mod common; + +use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; +use soroban_sdk::{Address, Env}; +use teachlink_contract::{TeachLinkBridge, TeachLinkBridgeClient}; + +use common::test_env; + +const DAY: u64 = 24 * 60 * 60; + +fn set_timestamp(env: &Env, timestamp: u64) { + env.ledger().set(LedgerInfo { + timestamp, + protocol_version: 25, + sequence_number: 10, + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 10, + min_persistent_entry_ttl: 10, + max_entry_ttl: 2_000_000, + }); +} + +/// Bridge-initialized environment with a liquidity pool for chain 1. +fn setup_with_pool(env: &Env) -> (TeachLinkBridgeClient<'_>, Address, Address) { + let contract_id = env.register(TeachLinkBridge, ()); + let client = TeachLinkBridgeClient::new(env, &contract_id); + let token = Address::generate(env); + let admin = Address::generate(env); + let fee_recipient = Address::generate(env); + client.initialize(&token, &admin, &1, &fee_recipient); + client.add_supported_chain(&1); + client.initialize_liquidity_pool(&1, &token); + (client, admin, token) +} + +#[test] +fn rewards_are_zero_until_fee_revenue_is_recorded() { + let env = test_env(); + let (client, _admin, _token) = setup_with_pool(&env); + let provider = Address::generate(&env); + + client.add_liquidity(&provider, &1, &100_000); + assert_eq!(client.get_accumulated_fees(&1), 0); + + // No fees recorded: removing liquidity pays out no rewards. + let returned = client.remove_liquidity(&provider, &1, &100_000); + assert_eq!(returned, 100_000); +} + +#[test] +fn long_term_provider_earns_more_than_flash_provider() { + let env = test_env(); + let (client, _admin, _token) = setup_with_pool(&env); + let flash = Address::generate(&env); + let long = Address::generate(&env); + + // Long-term provider deposits 30 days before the flash provider. + set_timestamp(&env, 1_000_000); + client.add_liquidity(&long, &1, &100_000); + set_timestamp(&env, 1_000_000 + 30 * DAY); + client.add_liquidity(&flash, &1, &100_000); + + // Pool collects 10_000 in real fee revenue. + client.record_fee_revenue(&1, &10_000); + assert_eq!(client.get_accumulated_fees(&1), 10_000); + + // Flash provider (0 seconds in pool, 1.0x): 50% share of fees = 5_000. + let flash_returned = client.remove_liquidity(&flash, &1, &100_000); + assert_eq!(flash_returned, 105_000); + + // Long-term provider (30 days in pool, 2.0x): earns more than the flash + // provider for an equally-sized position. + let long_returned = client.remove_liquidity(&long, &1, &100_000); + assert_eq!(long_returned, 110_000); + assert!(long_returned - 100_000 > flash_returned - 100_000); +} + +#[test] +fn rewards_never_exceed_collected_fees() { + let env = test_env(); + let (client, _admin, _token) = setup_with_pool(&env); + let provider = Address::generate(&env); + + // Provider holds 100% of the pool for 100 days (max 3.0x multiplier). + set_timestamp(&env, 1_000_000); + client.add_liquidity(&provider, &1, &100_000); + client.record_fee_revenue(&1, &10_000); + set_timestamp(&env, 1_000_000 + 100 * DAY); + + // The time-weighted reward would be 30_000, but it must be clamped to the + // 10_000 of fees the pool actually collected. + let returned = client.remove_liquidity(&provider, &1, &100_000); + let rewards = returned - 100_000; + assert_eq!(rewards, 10_000); + assert!(rewards <= client.get_accumulated_fees(&1)); +}