diff --git a/.gitignore b/.gitignore index 09d65852..d240d3e9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ claude.md plan.md # Soroban test snapshot files generated by the test runner -creator-keys/test_snapshots/ +**/test_snapshots/ issue.md pr.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c56abbc8..ad20e8f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -792,6 +792,13 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lp-reward-contract" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "memchr" version = "2.8.0" diff --git a/Cargo.toml b/Cargo.toml index 1337e989..5a23aa5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["creator-keys", "creator-keys-factory", "referral-contract"] +members = ["creator-keys", "creator-keys-factory", "referral-contract", "lp-reward-contract"] resolver = "2" [workspace.package] diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 83000dc3..1d12b80e 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -15,6 +15,7 @@ pub mod acl_limits_merge_sunset; /// and could not be called. pub mod curve_subscriptions_swaps; pub mod events; +pub mod lp_reward; pub mod ratings_royalties_dividends; pub mod test_feature_impl; @@ -15291,6 +15292,67 @@ impl CreatorKeysContract { Ok(new_expires_at) } + + // ========================================================================= + // Issue #1002: Liquidity provider reward contract for key pairs + // ========================================================================= + + /// Locks tokens as liquidity and records LP share. + pub fn add_liquidity( + env: Env, + key_id: Address, + provider: Address, + amount: i128, + ) -> Result { + lp_reward::add_liquidity(&env, key_id, provider, amount) + } + + /// Convenience alias with (provider, key_id, amount) parameter order. + pub fn add_liquidity_for( + env: Env, + provider: Address, + key_id: Address, + amount: i128, + ) -> Result { + lp_reward::add_liquidity(&env, key_id, provider, amount) + } + + /// Returns tokens plus accrued fee rewards, closing the position. + pub fn remove_liquidity(env: Env, lp_id: u64) -> Result { + lp_reward::remove_liquidity(&env, lp_id) + } + + /// Claims rewards without removing liquidity. + pub fn claim_lp_rewards(env: Env, lp_id: u64) -> Result { + lp_reward::claim_lp_rewards(&env, lp_id) + } + + /// Returns contribution, share, and pending rewards at any point. + pub fn get_lp_position( + env: Env, + lp_id: u64, + ) -> Result { + lp_reward::get_lp_position(&env, lp_id) + } + + /// Accrues fee rewards to key pair pool proportional to trading volume. + pub fn accrue_lp_trading_fee( + env: Env, + key_id: Address, + fee_amount: i128, + ) -> Result<(), lp_reward::LpRewardError> { + lp_reward::accrue_trading_fee(&env, key_id, fee_amount) + } + + /// Read-only view of total pool liquidity for a key pair. + pub fn get_lp_total_liquidity(env: Env, key_id: Address) -> i128 { + lp_reward::get_total_liquidity(&env, key_id) + } + + /// Read-only view of total collected trading rewards for a key pair. + pub fn get_lp_pool_rewards(env: Env, key_id: Address) -> i128 { + lp_reward::get_pool_rewards(&env, key_id) + } } // ============================================================================ @@ -17979,3 +18041,6 @@ mod test_issues_904_905_906_908; #[cfg(test)] mod test_unique_traders; + +#[cfg(test)] +mod test_lp_reward; diff --git a/creator-keys/src/lp_reward.rs b/creator-keys/src/lp_reward.rs new file mode 100644 index 00000000..166893a8 --- /dev/null +++ b/creator-keys/src/lp_reward.rs @@ -0,0 +1,423 @@ +use soroban_sdk::{contracterror, contracttype, symbol_short, Address, Env, Symbol}; + +pub const SCALE: i128 = 1_000_000_000_000; // 1e12 for fixed-point fee accumulator +pub const BPS_SCALE: i128 = 10_000; + +pub const LP_ADDED_EVENT_NAME: Symbol = symbol_short!("LPAdded"); +pub const LP_CLAIMED_EVENT_NAME: Symbol = symbol_short!("lp_claim"); +pub const LP_REMOVED_EVENT_NAME: Symbol = symbol_short!("lp_rem"); + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum LpRewardError { + NotPositiveAmount = 1, + PositionNotFound = 2, + Unauthorized = 3, + NoRewardsPending = 4, + ZeroLiquidityPool = 5, + PositionAlreadyClosed = 6, + ArithmeticOverflow = 7, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LpPosition { + pub lp_id: u64, + pub provider: Address, + pub key_id: Address, + pub contribution: i128, + pub share: i128, // Proportional share in basis points (0..=10_000) + pub pending_rewards: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct StoredLpPosition { + pub lp_id: u64, + pub provider: Address, + pub key_id: Address, + pub contribution: i128, + pub reward_debt: i128, + pub accumulated_rewards: i128, + pub is_closed: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LpPool { + pub key_id: Address, + pub total_liquidity: i128, + pub acc_reward_per_share: i128, + pub total_rewards_collected: i128, + pub total_rewards_claimed: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LPAdded { + pub lp_id: u64, + pub key_id: Address, + pub provider: Address, + pub amount: i128, + pub share: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LPRewardClaimed { + pub lp_id: u64, + pub provider: Address, + pub key_id: Address, + pub amount: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LPRemoved { + pub lp_id: u64, + pub provider: Address, + pub key_id: Address, + pub principal: i128, + pub rewards: i128, + pub total_returned: i128, +} + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + NextLpId, + Position(u64), + Pool(Address), + TotalPositions, +} + +fn read_next_lp_id(env: &Env) -> u64 { + env.storage() + .persistent() + .get(&DataKey::NextLpId) + .unwrap_or(1) +} + +fn write_next_lp_id(env: &Env, next_id: u64) { + env.storage().persistent().set(&DataKey::NextLpId, &next_id); +} + +fn read_pool(env: &Env, key_id: &Address) -> LpPool { + env.storage() + .persistent() + .get(&DataKey::Pool(key_id.clone())) + .unwrap_or(LpPool { + key_id: key_id.clone(), + total_liquidity: 0, + acc_reward_per_share: 0, + total_rewards_collected: 0, + total_rewards_claimed: 0, + }) +} + +fn write_pool(env: &Env, pool: &LpPool) { + env.storage() + .persistent() + .set(&DataKey::Pool(pool.key_id.clone()), pool); +} + +fn read_position(env: &Env, lp_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Position(lp_id)) + .ok_or(LpRewardError::PositionNotFound) +} + +fn write_position(env: &Env, position: &StoredLpPosition) { + env.storage() + .persistent() + .set(&DataKey::Position(position.lp_id), position); +} + +pub fn add_liquidity( + env: &Env, + key_id: Address, + provider: Address, + amount: i128, +) -> Result { + provider.require_auth(); + + if amount <= 0 { + return Err(LpRewardError::NotPositiveAmount); + } + + let mut pool = read_pool(env, &key_id); + let new_total_liquidity = pool + .total_liquidity + .checked_add(amount) + .ok_or(LpRewardError::ArithmeticOverflow)?; + pool.total_liquidity = new_total_liquidity; + + let share_bps = (amount + .checked_mul(BPS_SCALE) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / new_total_liquidity; + + let lp_id = read_next_lp_id(env); + let next_id = lp_id + .checked_add(1) + .ok_or(LpRewardError::ArithmeticOverflow)?; + write_next_lp_id(env, next_id); + + let reward_debt = (amount + .checked_mul(pool.acc_reward_per_share) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / SCALE; + + let position = StoredLpPosition { + lp_id, + provider: provider.clone(), + key_id: key_id.clone(), + contribution: amount, + reward_debt, + accumulated_rewards: 0, + is_closed: false, + }; + + write_position(env, &position); + write_pool(env, &pool); + + let total_pos: u64 = env + .storage() + .persistent() + .get(&DataKey::TotalPositions) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::TotalPositions, &(total_pos + 1)); + + env.events().publish( + (LP_ADDED_EVENT_NAME, key_id.clone(), provider.clone()), + LPAdded { + lp_id, + key_id, + provider, + amount, + share: share_bps, + }, + ); + + Ok(lp_id) +} + +pub fn accrue_trading_fee( + env: &Env, + key_id: Address, + fee_amount: i128, +) -> Result<(), LpRewardError> { + if fee_amount <= 0 { + return Err(LpRewardError::NotPositiveAmount); + } + + let mut pool = read_pool(env, &key_id); + if pool.total_liquidity == 0 { + return Err(LpRewardError::ZeroLiquidityPool); + } + + let delta = (fee_amount + .checked_mul(SCALE) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / pool.total_liquidity; + + pool.acc_reward_per_share = pool + .acc_reward_per_share + .checked_add(delta) + .ok_or(LpRewardError::ArithmeticOverflow)?; + pool.total_rewards_collected = pool + .total_rewards_collected + .checked_add(fee_amount) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + write_pool(env, &pool); + Ok(()) +} + +pub fn remove_liquidity(env: &Env, lp_id: u64) -> Result { + let mut position = read_position(env, lp_id)?; + position.provider.require_auth(); + + if position.is_closed { + return Err(LpRewardError::PositionAlreadyClosed); + } + + let mut pool = read_pool(env, &position.key_id); + + let accumulated = (position + .contribution + .checked_mul(pool.acc_reward_per_share) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / SCALE; + let pending = position + .accumulated_rewards + .checked_add(accumulated.saturating_sub(position.reward_debt)) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + let principal = position.contribution; + let total_return = principal + .checked_add(pending) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + pool.total_liquidity = pool.total_liquidity.saturating_sub(principal); + pool.total_rewards_claimed = pool + .total_rewards_claimed + .checked_add(pending) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + position.is_closed = true; + position.contribution = 0; + position.accumulated_rewards = 0; + position.reward_debt = 0; + + write_position(env, &position); + write_pool(env, &pool); + + if pending > 0 { + env.events().publish( + ( + LP_CLAIMED_EVENT_NAME, + position.key_id.clone(), + position.provider.clone(), + ), + LPRewardClaimed { + lp_id, + provider: position.provider.clone(), + key_id: position.key_id.clone(), + amount: pending, + }, + ); + } + + env.events().publish( + ( + LP_REMOVED_EVENT_NAME, + position.key_id.clone(), + position.provider.clone(), + ), + LPRemoved { + lp_id, + provider: position.provider, + key_id: position.key_id, + principal, + rewards: pending, + total_returned: total_return, + }, + ); + + Ok(total_return) +} + +pub fn claim_lp_rewards(env: &Env, lp_id: u64) -> Result { + let mut position = read_position(env, lp_id)?; + position.provider.require_auth(); + + if position.is_closed { + return Err(LpRewardError::PositionAlreadyClosed); + } + + let mut pool = read_pool(env, &position.key_id); + + let accumulated = (position + .contribution + .checked_mul(pool.acc_reward_per_share) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / SCALE; + let pending = position + .accumulated_rewards + .checked_add(accumulated.saturating_sub(position.reward_debt)) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + if pending == 0 { + return Ok(0); + } + + position.accumulated_rewards = 0; + position.reward_debt = (position + .contribution + .checked_mul(pool.acc_reward_per_share) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / SCALE; + + pool.total_rewards_claimed = pool + .total_rewards_claimed + .checked_add(pending) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + write_position(env, &position); + write_pool(env, &pool); + + env.events().publish( + ( + LP_CLAIMED_EVENT_NAME, + position.key_id.clone(), + position.provider.clone(), + ), + LPRewardClaimed { + lp_id, + provider: position.provider, + key_id: position.key_id, + amount: pending, + }, + ); + + Ok(pending) +} + +pub fn get_lp_position(env: &Env, lp_id: u64) -> Result { + let position = read_position(env, lp_id)?; + if position.is_closed { + return Ok(LpPosition { + lp_id, + provider: position.provider, + key_id: position.key_id, + contribution: 0, + share: 0, + pending_rewards: 0, + }); + } + + let pool = read_pool(env, &position.key_id); + let accumulated = (position + .contribution + .checked_mul(pool.acc_reward_per_share) + .unwrap_or(0)) + / SCALE; + let pending = position + .accumulated_rewards + .saturating_add(accumulated.saturating_sub(position.reward_debt)); + + let share = if pool.total_liquidity > 0 { + (position.contribution.saturating_mul(BPS_SCALE)) / pool.total_liquidity + } else { + 0 + }; + + Ok(LpPosition { + lp_id, + provider: position.provider, + key_id: position.key_id, + contribution: position.contribution, + share, + pending_rewards: pending, + }) +} + +pub fn get_total_liquidity(env: &Env, key_id: Address) -> i128 { + read_pool(env, &key_id).total_liquidity +} + +pub fn get_pool_rewards(env: &Env, key_id: Address) -> i128 { + read_pool(env, &key_id).total_rewards_collected +} + +pub fn get_lp_count(env: &Env) -> u64 { + env.storage() + .persistent() + .get(&DataKey::TotalPositions) + .unwrap_or(0) +} diff --git a/creator-keys/src/test_lp_reward.rs b/creator-keys/src/test_lp_reward.rs new file mode 100644 index 00000000..cd8f5046 --- /dev/null +++ b/creator-keys/src/test_lp_reward.rs @@ -0,0 +1,171 @@ +#![cfg(test)] + +use crate::{ + lp_reward::{ + LPAdded, LPRewardClaimed, LpRewardError, LP_ADDED_EVENT_NAME, LP_CLAIMED_EVENT_NAME, + }, + CreatorKeysContract, CreatorKeysContractClient, RegisterCreatorParams, +}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, Env, IntoVal, String, Symbol, TryFromVal, Val, Vec, +}; + +fn setup_test() -> (Env, CreatorKeysContractClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.set_protocol_admin(&admin, &admin); + let creator = Address::generate(&env); + client.register_creator( + &RegisterCreatorParams { + creator: creator.clone(), + handle: String::from_str(&env, "alice_lp"), + }, + &None, + &None, + &None, + &None, + &None, + &None, + ); + (env, client, admin, creator) +} + +fn get_events(env: &Env, topic_symbol: Symbol) -> Vec +where + T: IntoVal + TryFromVal, +{ + let mut matching = Vec::new(env); + for (_, topics, data) in env.events().all().iter() { + if let Some(first) = topics.get(0) { + if Symbol::try_from_val(env, &first) == Ok(topic_symbol.clone()) { + if let Ok(event) = T::try_from_val(env, &data) { + matching.push_back(event); + } + } + } + } + matching +} + +#[test] +fn test_creator_keys_add_liquidity_and_proportional_share() { + let (env, client, _admin, creator) = setup_test(); + let provider1 = Address::generate(&env); + let provider2 = Address::generate(&env); + + let lp1 = client.add_liquidity(&creator, &provider1, &2_000); + assert_eq!(lp1, 1); + assert_eq!(client.get_lp_total_liquidity(&creator), 2_000); + + let p1 = client.get_lp_position(&lp1); + assert_eq!(p1.contribution, 2_000); + assert_eq!(p1.share, 10_000); // 100% + + // Second provider adds 6,000 (total = 8,000) + let lp2 = client.add_liquidity_for(&provider2, &creator, &6_000); + assert_eq!(lp2, 2); + + // Check LPAdded event immediately + let add_events: Vec = get_events(&env, LP_ADDED_EVENT_NAME); + assert_eq!(add_events.len(), 1); + let ev = add_events.get(0).unwrap(); + assert_eq!(ev.lp_id, lp2); + assert_eq!(ev.key_id, creator); + assert_eq!(ev.provider, provider2); + assert_eq!(ev.amount, 6_000); + + assert_eq!(client.get_lp_total_liquidity(&creator), 8_000); + + // Shares: 25% and 75% + assert_eq!(client.get_lp_position(&lp1).share, 2_500); + assert_eq!(client.get_lp_position(&lp2).share, 7_500); +} + +#[test] +fn test_creator_keys_accrue_trading_fees_and_claim_rewards() { + let (env, client, _admin, creator) = setup_test(); + let p1 = Address::generate(&env); + let p2 = Address::generate(&env); + + let lp1 = client.add_liquidity(&creator, &p1, &1_000); + let lp2 = client.add_liquidity(&creator, &p2, &3_000); + + // Accrue 400 fee from trading volume + client.accrue_lp_trading_fee(&creator, &400); + + // 25% to p1 (100), 75% to p2 (300) + assert_eq!(client.get_lp_position(&lp1).pending_rewards, 100); + assert_eq!(client.get_lp_position(&lp2).pending_rewards, 300); + + // P1 claims rewards + let claimed = client.claim_lp_rewards(&lp1); + assert_eq!(claimed, 100); + + // Verify LPRewardClaimed event emitted immediately after claim + let claim_events: Vec = get_events(&env, LP_CLAIMED_EVENT_NAME); + assert_eq!(claim_events.len(), 1); + let ev = claim_events.get(0).unwrap(); + assert_eq!(ev.lp_id, lp1); + assert_eq!(ev.amount, 100); + assert_eq!(ev.provider, p1); + + // After claim, pending is 0 + assert_eq!(client.get_lp_position(&lp1).pending_rewards, 0); + assert_eq!(client.get_lp_position(&lp1).contribution, 1_000); +} + +#[test] +fn test_creator_keys_remove_liquidity_returns_principal_plus_rewards() { + let (env, client, _admin, creator) = setup_test(); + let provider = Address::generate(&env); + + let lp = client.add_liquidity(&creator, &provider, &10_000); + client.accrue_lp_trading_fee(&creator, &1_500); + + // Total return = 10,000 + 1,500 = 11,500 + let total = client.remove_liquidity(&lp); + assert_eq!(total, 11_500); + assert_eq!(client.get_lp_total_liquidity(&creator), 0); + + let pos = client.get_lp_position(&lp); + assert_eq!(pos.contribution, 0); + assert_eq!(pos.share, 0); + assert_eq!(pos.pending_rewards, 0); + + let re_remove = client.try_remove_liquidity(&lp); + assert_eq!(re_remove, Err(Ok(LpRewardError::PositionAlreadyClosed))); +} + +#[test] +fn test_creator_keys_validation_errors() { + let (env, client, _admin, creator) = setup_test(); + let provider = Address::generate(&env); + + // Non-positive amount + assert_eq!( + client.try_add_liquidity(&creator, &provider, &0), + Err(Ok(LpRewardError::NotPositiveAmount)) + ); + assert_eq!( + client.try_add_liquidity(&creator, &provider, &-100), + Err(Ok(LpRewardError::NotPositiveAmount)) + ); + + // Unknown position + assert_eq!( + client.try_get_lp_position(&999), + Err(Ok(LpRewardError::PositionNotFound)) + ); + assert_eq!( + client.try_claim_lp_rewards(&999), + Err(Ok(LpRewardError::PositionNotFound)) + ); + assert_eq!( + client.try_remove_liquidity(&999), + Err(Ok(LpRewardError::PositionNotFound)) + ); +} diff --git a/lp-reward-contract/Cargo.toml b/lp-reward-contract/Cargo.toml new file mode 100644 index 00000000..50522b12 --- /dev/null +++ b/lp-reward-contract/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lp-reward-contract" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/lp-reward-contract/src/lib.rs b/lp-reward-contract/src/lib.rs new file mode 100644 index 00000000..9f25e5f8 --- /dev/null +++ b/lp-reward-contract/src/lib.rs @@ -0,0 +1,457 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol, +}; + +#[cfg(test)] +mod test; + +pub const SCALE: i128 = 1_000_000_000_000; // 1e12 for fixed-point fee accumulator +pub const BPS_SCALE: i128 = 10_000; + +pub const LP_ADDED_EVENT_NAME: Symbol = symbol_short!("LPAdded"); +pub const LP_CLAIMED_EVENT_NAME: Symbol = symbol_short!("lp_claim"); +pub const LP_REMOVED_EVENT_NAME: Symbol = symbol_short!("lp_rem"); + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum LpRewardError { + NotPositiveAmount = 1, + PositionNotFound = 2, + Unauthorized = 3, + NoRewardsPending = 4, + ZeroLiquidityPool = 5, + PositionAlreadyClosed = 6, + ArithmeticOverflow = 7, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LpPosition { + pub lp_id: u64, + pub provider: Address, + pub key_id: Address, + pub contribution: i128, + pub share: i128, // Proportional share in basis points (0..=10_000) + pub pending_rewards: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct StoredLpPosition { + pub lp_id: u64, + pub provider: Address, + pub key_id: Address, + pub contribution: i128, + pub reward_debt: i128, + pub accumulated_rewards: i128, + pub is_closed: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LpPool { + pub key_id: Address, + pub total_liquidity: i128, + pub acc_reward_per_share: i128, + pub total_rewards_collected: i128, + pub total_rewards_claimed: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LPAdded { + pub lp_id: u64, + pub key_id: Address, + pub provider: Address, + pub amount: i128, + pub share: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LPRewardClaimed { + pub lp_id: u64, + pub provider: Address, + pub key_id: Address, + pub amount: i128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct LPRemoved { + pub lp_id: u64, + pub provider: Address, + pub key_id: Address, + pub principal: i128, + pub rewards: i128, + pub total_returned: i128, +} + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + NextLpId, + Position(u64), + Pool(Address), + TotalPositions, +} + +#[contract] +pub struct LpRewardContract; + +fn read_next_lp_id(env: &Env) -> u64 { + env.storage() + .persistent() + .get(&DataKey::NextLpId) + .unwrap_or(1) +} + +fn write_next_lp_id(env: &Env, next_id: u64) { + env.storage().persistent().set(&DataKey::NextLpId, &next_id); +} + +fn read_pool(env: &Env, key_id: &Address) -> LpPool { + env.storage() + .persistent() + .get(&DataKey::Pool(key_id.clone())) + .unwrap_or(LpPool { + key_id: key_id.clone(), + total_liquidity: 0, + acc_reward_per_share: 0, + total_rewards_collected: 0, + total_rewards_claimed: 0, + }) +} + +fn write_pool(env: &Env, pool: &LpPool) { + env.storage() + .persistent() + .set(&DataKey::Pool(pool.key_id.clone()), pool); +} + +fn read_position(env: &Env, lp_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Position(lp_id)) + .ok_or(LpRewardError::PositionNotFound) +} + +fn write_position(env: &Env, position: &StoredLpPosition) { + env.storage() + .persistent() + .set(&DataKey::Position(position.lp_id), position); +} + +#[contractimpl] +impl LpRewardContract { + /// Locks tokens as liquidity and records LP share. + /// Returns the unique `lp_id` identifying the position. + pub fn add_liquidity( + env: Env, + key_id: Address, + provider: Address, + amount: i128, + ) -> Result { + provider.require_auth(); + + if amount <= 0 { + return Err(LpRewardError::NotPositiveAmount); + } + + let mut pool = read_pool(&env, &key_id); + let new_total_liquidity = pool + .total_liquidity + .checked_add(amount) + .ok_or(LpRewardError::ArithmeticOverflow)?; + pool.total_liquidity = new_total_liquidity; + + // Proportional share computed from total liquidity pool in basis points (10000 = 100%) + let share_bps = (amount + .checked_mul(BPS_SCALE) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / new_total_liquidity; + + let lp_id = read_next_lp_id(&env); + let next_id = lp_id + .checked_add(1) + .ok_or(LpRewardError::ArithmeticOverflow)?; + write_next_lp_id(&env, next_id); + + let reward_debt = (amount + .checked_mul(pool.acc_reward_per_share) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / SCALE; + + let position = StoredLpPosition { + lp_id, + provider: provider.clone(), + key_id: key_id.clone(), + contribution: amount, + reward_debt, + accumulated_rewards: 0, + is_closed: false, + }; + + write_position(&env, &position); + write_pool(&env, &pool); + + let total_pos: u64 = env + .storage() + .persistent() + .get(&DataKey::TotalPositions) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::TotalPositions, &(total_pos + 1)); + + // Emit LPAdded event + env.events().publish( + (LP_ADDED_EVENT_NAME, key_id.clone(), provider.clone()), + LPAdded { + lp_id, + key_id, + provider, + amount, + share: share_bps, + }, + ); + + Ok(lp_id) + } + + /// Convenience alias with (provider, key_id, amount) parameter order. + pub fn add_liquidity_for( + env: Env, + provider: Address, + key_id: Address, + amount: i128, + ) -> Result { + Self::add_liquidity(env, key_id, provider, amount) + } + + /// Accrues fee rewards to key pair pool proportional to trading volume. + pub fn accrue_trading_fee( + env: Env, + key_id: Address, + fee_amount: i128, + ) -> Result<(), LpRewardError> { + if fee_amount <= 0 { + return Err(LpRewardError::NotPositiveAmount); + } + + let mut pool = read_pool(&env, &key_id); + if pool.total_liquidity == 0 { + return Err(LpRewardError::ZeroLiquidityPool); + } + + let delta = (fee_amount + .checked_mul(SCALE) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / pool.total_liquidity; + + pool.acc_reward_per_share = pool + .acc_reward_per_share + .checked_add(delta) + .ok_or(LpRewardError::ArithmeticOverflow)?; + pool.total_rewards_collected = pool + .total_rewards_collected + .checked_add(fee_amount) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + write_pool(&env, &pool); + Ok(()) + } + + /// Returns tokens plus accrued fee rewards, closing the position. + pub fn remove_liquidity(env: Env, lp_id: u64) -> Result { + let mut position = read_position(&env, lp_id)?; + position.provider.require_auth(); + + if position.is_closed { + return Err(LpRewardError::PositionAlreadyClosed); + } + + let mut pool = read_pool(&env, &position.key_id); + + let accumulated = (position + .contribution + .checked_mul(pool.acc_reward_per_share) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / SCALE; + let pending = position + .accumulated_rewards + .checked_add(accumulated.saturating_sub(position.reward_debt)) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + let principal = position.contribution; + let total_return = principal + .checked_add(pending) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + pool.total_liquidity = pool.total_liquidity.saturating_sub(principal); + pool.total_rewards_claimed = pool + .total_rewards_claimed + .checked_add(pending) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + position.is_closed = true; + position.contribution = 0; + position.accumulated_rewards = 0; + position.reward_debt = 0; + + write_position(&env, &position); + write_pool(&env, &pool); + + if pending > 0 { + env.events().publish( + ( + LP_CLAIMED_EVENT_NAME, + position.key_id.clone(), + position.provider.clone(), + ), + LPRewardClaimed { + lp_id, + provider: position.provider.clone(), + key_id: position.key_id.clone(), + amount: pending, + }, + ); + } + + env.events().publish( + ( + LP_REMOVED_EVENT_NAME, + position.key_id.clone(), + position.provider.clone(), + ), + LPRemoved { + lp_id, + provider: position.provider, + key_id: position.key_id, + principal, + rewards: pending, + total_returned: total_return, + }, + ); + + Ok(total_return) + } + + /// Claims rewards without removing liquidity. + pub fn claim_lp_rewards(env: Env, lp_id: u64) -> Result { + let mut position = read_position(&env, lp_id)?; + position.provider.require_auth(); + + if position.is_closed { + return Err(LpRewardError::PositionAlreadyClosed); + } + + let mut pool = read_pool(&env, &position.key_id); + + let accumulated = (position + .contribution + .checked_mul(pool.acc_reward_per_share) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / SCALE; + let pending = position + .accumulated_rewards + .checked_add(accumulated.saturating_sub(position.reward_debt)) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + if pending == 0 { + return Ok(0); + } + + position.accumulated_rewards = 0; + position.reward_debt = (position + .contribution + .checked_mul(pool.acc_reward_per_share) + .ok_or(LpRewardError::ArithmeticOverflow)?) + / SCALE; + + pool.total_rewards_claimed = pool + .total_rewards_claimed + .checked_add(pending) + .ok_or(LpRewardError::ArithmeticOverflow)?; + + write_position(&env, &position); + write_pool(&env, &pool); + + env.events().publish( + ( + LP_CLAIMED_EVENT_NAME, + position.key_id.clone(), + position.provider.clone(), + ), + LPRewardClaimed { + lp_id, + provider: position.provider, + key_id: position.key_id, + amount: pending, + }, + ); + + Ok(pending) + } + + /// Returns contribution, share, and pending rewards at any point. + pub fn get_lp_position(env: Env, lp_id: u64) -> Result { + let position = read_position(&env, lp_id)?; + if position.is_closed { + return Ok(LpPosition { + lp_id, + provider: position.provider, + key_id: position.key_id, + contribution: 0, + share: 0, + pending_rewards: 0, + }); + } + + let pool = read_pool(&env, &position.key_id); + let accumulated = (position + .contribution + .checked_mul(pool.acc_reward_per_share) + .unwrap_or(0)) + / SCALE; + let pending = position + .accumulated_rewards + .saturating_add(accumulated.saturating_sub(position.reward_debt)); + + let share = if pool.total_liquidity > 0 { + (position.contribution.saturating_mul(BPS_SCALE)) / pool.total_liquidity + } else { + 0 + }; + + Ok(LpPosition { + lp_id, + provider: position.provider, + key_id: position.key_id, + contribution: position.contribution, + share, + pending_rewards: pending, + }) + } + + /// Read-only view of total pool liquidity for a key pair. + pub fn get_total_liquidity(env: Env, key_id: Address) -> i128 { + read_pool(&env, &key_id).total_liquidity + } + + /// Read-only view of total collected trading rewards for a key pair. + pub fn get_pool_rewards(env: Env, key_id: Address) -> i128 { + read_pool(&env, &key_id).total_rewards_collected + } + + /// Total LP positions ever created. + pub fn get_lp_count(env: Env) -> u64 { + env.storage() + .persistent() + .get(&DataKey::TotalPositions) + .unwrap_or(0) + } +} diff --git a/lp-reward-contract/src/test.rs b/lp-reward-contract/src/test.rs new file mode 100644 index 00000000..8bbc2cf7 --- /dev/null +++ b/lp-reward-contract/src/test.rs @@ -0,0 +1,279 @@ +#![cfg(test)] + +use crate::{ + LPAdded, LPRewardClaimed, LpRewardContract, LpRewardContractClient, LpRewardError, + LP_ADDED_EVENT_NAME, LP_CLAIMED_EVENT_NAME, +}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, Env, IntoVal, Symbol, TryFromVal, Val, Vec, +}; + +fn setup_env() -> (Env, LpRewardContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(LpRewardContract, ()); + let client = LpRewardContractClient::new(&env, &contract_id); + let key_id = Address::generate(&env); + (env, client, key_id) +} + +fn get_events(env: &Env, topic_symbol: Symbol) -> Vec +where + T: IntoVal + TryFromVal, +{ + let mut matching = Vec::new(env); + for (_, topics, data) in env.events().all().iter() { + if let Some(first) = topics.get(0) { + if Symbol::try_from_val(env, &first) == Ok(topic_symbol.clone()) { + if let Ok(event) = T::try_from_val(env, &data) { + matching.push_back(event); + } + } + } + } + matching +} + +#[test] +fn test_lp_share_computed_proportionally_from_total_liquidity_pool() { + let (env, client, key_id) = setup_env(); + let provider1 = Address::generate(&env); + let provider2 = Address::generate(&env); + let provider3 = Address::generate(&env); + + // Initial pool liquidity is zero + assert_eq!(client.get_total_liquidity(&key_id), 0); + + // Provider 1 adds 1,000 tokens (100% of pool) + let lp1 = client.add_liquidity(&key_id, &provider1, &1_000); + assert_eq!(lp1, 1); + assert_eq!(client.get_total_liquidity(&key_id), 1_000); + + let pos1 = client.get_lp_position(&lp1); + assert_eq!(pos1.contribution, 1_000); + assert_eq!(pos1.share, 10_000); // 100% = 10,000 bps + + // Provider 2 adds 3,000 tokens (Total = 4,000 tokens) + let lp2 = client.add_liquidity(&key_id, &provider2, &3_000); + assert_eq!(lp2, 2); + assert_eq!(client.get_total_liquidity(&key_id), 4_000); + + // Dynamic proportional shares + let pos1_updated = client.get_lp_position(&lp1); + let pos2 = client.get_lp_position(&lp2); + assert_eq!(pos1_updated.share, 2_500); // 1,000 / 4,000 = 25% = 2,500 bps + assert_eq!(pos2.share, 7_500); // 3,000 / 4,000 = 75% = 7,500 bps + + // Provider 3 adds 6,000 tokens (Total = 10,000 tokens) + let lp3 = client.add_liquidity_for(&provider3, &key_id, &6_000); + assert_eq!(lp3, 3); + assert_eq!(client.get_total_liquidity(&key_id), 10_000); + + assert_eq!(client.get_lp_position(&lp1).share, 1_000); // 10% + assert_eq!(client.get_lp_position(&lp2).share, 3_000); // 30% + assert_eq!(client.get_lp_position(&lp3).share, 6_000); // 60% +} + +#[test] +fn test_lp_added_event_emitted() { + let (env, client, key_id) = setup_env(); + let provider = Address::generate(&env); + + let lp_id = client.add_liquidity(&key_id, &provider, &5_000); + let events: Vec = get_events(&env, LP_ADDED_EVENT_NAME); + + assert_eq!(events.len(), 1); + let event = events.get(0).unwrap(); + assert_eq!(event.lp_id, lp_id); + assert_eq!(event.key_id, key_id); + assert_eq!(event.provider, provider); + assert_eq!(event.amount, 5_000); + assert_eq!(event.share, 10_000); +} + +#[test] +fn test_rewards_accrue_correctly_proportional_to_share_and_trading_volume() { + let (env, client, key_id) = setup_env(); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + // Alice provides 200, Bob provides 600 (Total = 800: Alice 25%, Bob 75%) + let lp_alice = client.add_liquidity(&key_id, &alice, &200); + let lp_bob = client.add_liquidity(&key_id, &bob, &600); + + // Trading volume fee 1: 400 tokens + client.accrue_trading_fee(&key_id, &400); + + // Alice should accrue 25% of 400 = 100 + // Bob should accrue 75% of 400 = 300 + let pos_alice = client.get_lp_position(&lp_alice); + let pos_bob = client.get_lp_position(&lp_bob); + assert_eq!(pos_alice.pending_rewards, 100); + assert_eq!(pos_bob.pending_rewards, 300); + + // Additional trading volume fee 2: 800 tokens (Total fees = 1,200) + client.accrue_trading_fee(&key_id, &800); + + // Alice total pending: 25% of 1,200 = 300 + // Bob total pending: 75% of 1,200 = 900 + assert_eq!(client.get_lp_position(&lp_alice).pending_rewards, 300); + assert_eq!(client.get_lp_position(&lp_bob).pending_rewards, 900); +} + +#[test] +fn test_claim_lp_rewards_transfers_correct_pending_amount() { + let (env, client, key_id) = setup_env(); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let lp_alice = client.add_liquidity(&key_id, &alice, &500); + let lp_bob = client.add_liquidity(&key_id, &bob, &500); + + client.accrue_trading_fee(&key_id, &200); + + // Alice claims rewards + let claimed_alice = client.claim_lp_rewards(&lp_alice); + assert_eq!(claimed_alice, 100); + + // Verify LPRewardClaimed event emitted immediately after claim + let claim_events: Vec = get_events(&env, LP_CLAIMED_EVENT_NAME); + assert_eq!(claim_events.len(), 1); + let ev = claim_events.get(0).unwrap(); + assert_eq!(ev.lp_id, lp_alice); + assert_eq!(ev.amount, 100); + assert_eq!(ev.provider, alice); + + // Alice's pending rewards are reset to 0, contribution still intact + let pos_alice = client.get_lp_position(&lp_alice); + assert_eq!(pos_alice.pending_rewards, 0); + assert_eq!(pos_alice.contribution, 500); + + // Bob has not claimed, still has 100 pending + let pos_bob = client.get_lp_position(&lp_bob); + assert_eq!(pos_bob.pending_rewards, 100); + + // Calling claim again when zero pending returns 0 + let claimed_again = client.claim_lp_rewards(&lp_alice); + assert_eq!(claimed_again, 0); + + // New fee accrues: both get 50 each + client.accrue_trading_fee(&key_id, &100); + assert_eq!(client.get_lp_position(&lp_alice).pending_rewards, 50); + assert_eq!(client.get_lp_position(&lp_bob).pending_rewards, 150); +} + +#[test] +fn test_remove_liquidity_returns_correct_principal_plus_rewards() { + let (env, client, key_id) = setup_env(); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let lp_alice = client.add_liquidity(&key_id, &alice, &1_000); + let lp_bob = client.add_liquidity(&key_id, &bob, &3_000); + + client.accrue_trading_fee(&key_id, &800); + + // Alice accrued 25% of 800 = 200 rewards. + // Principal = 1,000. + // Total returned should be 1,000 + 200 = 1,200. + let returned = client.remove_liquidity(&lp_alice); + assert_eq!(returned, 1_200); + + // Total liquidity in pool decreases by 1,000 to 3,000 + assert_eq!(client.get_total_liquidity(&key_id), 3_000); + + // Alice's position is closed + let pos_alice = client.get_lp_position(&lp_alice); + assert_eq!(pos_alice.contribution, 0); + assert_eq!(pos_alice.share, 0); + assert_eq!(pos_alice.pending_rewards, 0); + + // Removing again fails with PositionAlreadyClosed + let err = client.try_remove_liquidity(&lp_alice); + assert_eq!(err, Err(Ok(LpRewardError::PositionAlreadyClosed))); + + // Bob now owns 100% of remaining pool + let pos_bob = client.get_lp_position(&lp_bob); + assert_eq!(pos_bob.share, 10_000); + assert_eq!(pos_bob.pending_rewards, 600); + + // Bob removes liquidity: 3,000 + 600 = 3,600 + let bob_returned = client.remove_liquidity(&lp_bob); + assert_eq!(bob_returned, 3_600); + assert_eq!(client.get_total_liquidity(&key_id), 0); +} + +#[test] +fn test_get_lp_position_returns_accurate_values_at_any_point() { + let (env, client, key_id) = setup_env(); + let provider = Address::generate(&env); + + let lp = client.add_liquidity(&key_id, &provider, &2_000); + + // Point 1: Immediately after add + let p1 = client.get_lp_position(&lp); + assert_eq!(p1.lp_id, lp); + assert_eq!(p1.provider, provider); + assert_eq!(p1.key_id, key_id); + assert_eq!(p1.contribution, 2_000); + assert_eq!(p1.share, 10_000); + assert_eq!(p1.pending_rewards, 0); + + // Point 2: After trading fee accrues + client.accrue_trading_fee(&key_id, &500); + let p2 = client.get_lp_position(&lp); + assert_eq!(p2.contribution, 2_000); + assert_eq!(p2.pending_rewards, 500); + + // Point 3: After second provider joins + let provider2 = Address::generate(&env); + let _lp2 = client.add_liquidity(&key_id, &provider2, &2_000); + let p3 = client.get_lp_position(&lp); + assert_eq!(p3.share, 5_000); // Now 50% + assert_eq!(p3.pending_rewards, 500); // Existing rewards preserved + + // Point 4: After claiming rewards + client.claim_lp_rewards(&lp); + let p4 = client.get_lp_position(&lp); + assert_eq!(p4.contribution, 2_000); + assert_eq!(p4.pending_rewards, 0); + + // Point 5: After removal + client.remove_liquidity(&lp); + let p5 = client.get_lp_position(&lp); + assert_eq!(p5.contribution, 0); + assert_eq!(p5.share, 0); + assert_eq!(p5.pending_rewards, 0); +} + +#[test] +fn test_validation_errors() { + let (env, client, key_id) = setup_env(); + let provider = Address::generate(&env); + + // Zero / negative amount rejected + let err_zero = client.try_add_liquidity(&key_id, &provider, &0); + assert_eq!(err_zero, Err(Ok(LpRewardError::NotPositiveAmount))); + + let err_neg = client.try_add_liquidity(&key_id, &provider, &-50); + assert_eq!(err_neg, Err(Ok(LpRewardError::NotPositiveAmount))); + + // Unknown position + let err_nf = client.try_get_lp_position(&999); + assert_eq!(err_nf, Err(Ok(LpRewardError::PositionNotFound))); + + let err_rem_nf = client.try_remove_liquidity(&999); + assert_eq!(err_rem_nf, Err(Ok(LpRewardError::PositionNotFound))); + + let err_claim_nf = client.try_claim_lp_rewards(&999); + assert_eq!(err_claim_nf, Err(Ok(LpRewardError::PositionNotFound))); + + // Accruing fee on empty pool + let err_fee_empty = client.try_accrue_trading_fee(&key_id, &100); + assert_eq!(err_fee_empty, Err(Ok(LpRewardError::ZeroLiquidityPool))); + + // Negative fee + let err_fee_neg = client.try_accrue_trading_fee(&key_id, &-10); + assert_eq!(err_fee_neg, Err(Ok(LpRewardError::NotPositiveAmount))); +}