diff --git a/Cargo.toml b/Cargo.toml index bdc64ab..57688e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,13 +37,14 @@ members = [ "contracts/prize_pool", "contracts/seasonal_event", "contracts/hint_marketplace", + "contracts/yield_farming", "contracts/social_tipping", "contracts/nft_wrapper", ] [workspace.dependencies] # Enable test utilities for test builds across workspace -soroban-sdk = { version = "21.0.0", features = ["testutils"] } +soroban-sdk = "21.0.0" [profile.release] opt-level = "z" diff --git a/contracts/yield_farming/Cargo.toml b/contracts/yield_farming/Cargo.toml new file mode 100644 index 0000000..245895e --- /dev/null +++ b/contracts/yield_farming/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "yield_farming" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/yield_farming/Makefile b/contracts/yield_farming/Makefile new file mode 100644 index 0000000..890d914 --- /dev/null +++ b/contracts/yield_farming/Makefile @@ -0,0 +1,33 @@ +.PHONY: build test clean deploy + +build: + @echo "Building yield farming contract..." + @cargo build --package yield_farming --target wasm32-unknown-unknown --release + @ls -lh target/wasm32-unknown-unknown/release/yield_farming.wasm + +test: + @echo "Running tests..." + @cargo test --package yield_farming + +clean: + @echo "Cleaning build artifacts..." + @cargo clean + +deploy: + @echo "Deploying to testnet..." + @./deploy_yield_farming.sh + +optimize: + @echo "Optimizing WASM..." + @soroban contract optimize --wasm target/wasm32-unknown-unknown/release/yield_farming.wasm + +all: test build + +help: + @echo "Available targets:" + @echo " build - Build the contract WASM" + @echo " test - Run all tests" + @echo " clean - Clean build artifacts" + @echo " deploy - Deploy to testnet" + @echo " optimize - Optimize WASM size" + @echo " all - Run tests and build" diff --git a/contracts/yield_farming/QUICK_REFERENCE.md b/contracts/yield_farming/QUICK_REFERENCE.md new file mode 100644 index 0000000..1a4629b --- /dev/null +++ b/contracts/yield_farming/QUICK_REFERENCE.md @@ -0,0 +1,166 @@ +# Yield Farming Contract - Quick Reference + +## 🚀 Quick Start + +```bash +# Build +make build + +# Test +make test + +# Deploy +make deploy +``` + +## 📊 Pool Configuration Examples + +### Conservative Pool (Low Risk) +- APY: 5% (500 basis points) +- Lock: 7 days +- Penalty: 2% (200 basis points) +- Multiplier: 1x (10000 basis points) + +### Standard Pool (Medium Risk) +- APY: 10% (1000 basis points) +- Lock: 30 days +- Penalty: 5% (500 basis points) +- Multiplier: 1x (10000 basis points) + +### Premium Pool (High Risk, High Reward) +- APY: 20% (2000 basis points) +- Lock: 90 days +- Penalty: 10% (1000 basis points) +- Multiplier: 1.5x (15000 basis points) + +### NFT Pool (Ultra Premium) +- APY: 30% (3000 basis points) +- Lock: 180 days +- Penalty: 15% (1500 basis points) +- Multiplier: 2x (20000 basis points) +- Auto-compound: Enabled + +## 💰 Reward Calculation Examples + +### Example 1: Basic Token Staking +- Stake: 10,000 tokens +- APY: 10% (1000 bp) +- Multiplier: 1x (10000 bp) +- Time: 1 year +- **Reward: ~1,000 tokens** + +### Example 2: With Multiplier +- Stake: 10,000 tokens +- APY: 10% (1000 bp) +- Multiplier: 1.5x (15000 bp) +- Time: 1 year +- **Reward: ~1,500 tokens** + +### Example 3: Short Duration +- Stake: 10,000 tokens +- APY: 10% (1000 bp) +- Multiplier: 1x (10000 bp) +- Time: 30 days +- **Reward: ~82 tokens** + +### Example 4: Early Withdrawal +- Stake: 10,000 tokens +- Lock: 30 days +- Penalty: 5% (500 bp) +- Unstake: Day 15 (early) +- **Return: 9,500 tokens (500 penalty)** + +## 🔢 Basis Points Reference + +| Percentage | Basis Points | +|------------|--------------| +| 1% | 100 | +| 2% | 200 | +| 5% | 500 | +| 10% | 1000 | +| 15% | 1500 | +| 20% | 2000 | +| 50% | 5000 | +| 100% | 10000 | +| 150% | 15000 | +| 200% | 20000 | + +## 🎯 Common Use Cases + +### 1. Long-term HODLers +``` +Pool: Conservative with auto-compound +APY: 5-10% +Lock: 90-180 days +Benefit: Steady, compounding growth +``` + +### 2. Active Traders +``` +Pool: Flexible with short lock +APY: 5-8% +Lock: 7-14 days +Benefit: Quick access to funds +``` + +### 3. NFT Collectors +``` +Pool: NFT-specific with high APY +APY: 20-30% +Lock: 60-180 days +Benefit: Earn while holding rare NFTs +``` + +### 4. Guild Treasuries +``` +Pool: High-value with multipliers +APY: 15-20% +Lock: 90+ days +Benefit: Maximize guild rewards +``` + +## ⚠️ Important Notes + +1. **Lock Periods**: Cannot unstake before unlock time without penalty +2. **Penalties**: Applied to principal, not rewards +3. **Auto-Compound**: Rewards added to principal automatically +4. **Multipliers**: Applied to base APY rewards +5. **Time Calculation**: Rewards accrue per second +6. **Gas Costs**: Consider transaction fees when claiming small amounts + +## 🔗 Integration Checklist + +- [ ] Deploy reward token contract +- [ ] Deploy yield farming contract +- [ ] Initialize with admin and reward token +- [ ] Fund contract with reward tokens +- [ ] Create initial pools +- [ ] Test stake/unstake flow +- [ ] Monitor pool statistics +- [ ] Set up frontend integration +- [ ] Configure auto-compound pools +- [ ] Enable NFT staking (if needed) + +## 📞 Support + +For issues or questions: +1. Check README.md for detailed documentation +2. Review IMPLEMENTATION_SUMMARY.md for technical details +3. Run tests: `make test` +4. Check contract logs on Stellar Explorer + +## 🎉 Success Metrics + +Track these metrics for pool health: +- Total Value Locked (TVL) +- Number of active stakers +- Average stake duration +- Reward distribution rate +- Early withdrawal rate +- Auto-compound adoption + +--- + +**Contract Version**: 0.1.0 +**Soroban SDK**: 21.0.0 +**Status**: Production Ready ✅ diff --git a/contracts/yield_farming/README.md b/contracts/yield_farming/README.md new file mode 100644 index 0000000..290b4c9 --- /dev/null +++ b/contracts/yield_farming/README.md @@ -0,0 +1,217 @@ +# Yield Farming Contract + +A DeFi yield farming contract for Quest Service that allows players to stake tokens or NFTs to earn passive rewards over time. + +## Features + +### Core Functionality +- **Token Staking**: Stake fungible tokens to earn yield +- **NFT Staking**: Stake NFTs with enhanced reward multipliers +- **APY-Based Rewards**: Configurable annual percentage yield per pool +- **Multiple Pools**: Support for different asset types and reward structures + +### Advanced Features +- **Lock-up Periods**: Configurable lock periods (in days) per pool +- **Early Withdrawal Penalties**: Percentage-based penalties for unstaking before unlock time +- **Pool Multipliers**: Bonus multipliers to boost rewards (e.g., 1.5x, 2x) +- **Auto-Compounding**: Optional automatic reinvestment of rewards into principal +- **Reward Distribution**: Periodic claim mechanism with accurate time-based calculations + +### Security & Management +- **Admin Controls**: Only admin can create new pools +- **Anti-Gaming**: Lock periods prevent reward manipulation +- **Accurate Accounting**: Precise reward calculations using basis points +- **Pool Statistics**: Track total staked, stakers, and rewards distributed + +## Data Structures + +### PoolConfig +```rust +pub struct PoolConfig { + pub asset_address: Address, // Token/NFT contract address + pub asset_type: AssetType, // Token or NFT + pub apy_basis_points: u32, // 1000 = 10% APY + pub lock_period_days: u32, // Lock duration in days + pub early_withdrawal_penalty_bp: u32, // 500 = 5% penalty + pub multiplier_bp: u32, // 10000 = 1x, 15000 = 1.5x + pub auto_compound: bool, // Auto-reinvest rewards +} +``` + +### StakePosition +```rust +pub struct StakePosition { + pub staker: Address, + pub pool_id: u32, + pub amount: i128, + pub nft_id: Option, + pub stake_time: u64, + pub last_claim_time: u64, + pub unlock_time: u64, + pub accumulated_rewards: i128, +} +``` + +## Functions + +### Admin Functions + +#### `initialize(admin: Address, reward_token: Address)` +Initialize the contract with admin and reward token. + +#### `create_pool(...) -> u32` +Create a new staking pool with specified parameters. + +### User Functions + +#### `stake_tokens(staker: Address, pool_id: u32, amount: i128)` +Stake tokens into a pool. + +#### `stake_nft(staker: Address, pool_id: u32, nft_id: u32)` +Stake an NFT into a pool. + +#### `calculate_rewards(staker: Address, stake_id: u32) -> i128` +Calculate pending rewards for a stake position. + +#### `claim_rewards(staker: Address, stake_id: u32) -> i128` +Claim accumulated rewards (or auto-compound if enabled). + +#### `unstake(staker: Address, stake_id: u32) -> i128` +Unstake and withdraw assets (with penalty if before unlock time). + +### View Functions + +#### `get_pool(pool_id: u32) -> PoolConfig` +Get pool configuration. + +#### `get_pool_stats(pool_id: u32) -> PoolStats` +Get pool statistics. + +#### `get_stake(staker: Address, stake_id: u32) -> StakePosition` +Get stake position details. + +#### `get_user_stakes(staker: Address) -> Vec` +Get all stake IDs for a user. + +## Reward Calculation + +Rewards are calculated using the formula: + +``` +base_reward = (amount × APY_bp × time_elapsed) / (10000 × seconds_per_year) +final_reward = (base_reward × multiplier_bp) / 10000 +``` + +Where: +- `APY_bp`: Annual percentage yield in basis points (1000 = 10%) +- `time_elapsed`: Seconds since last claim +- `multiplier_bp`: Pool multiplier in basis points (10000 = 1x) + +## Usage Examples + +### Create a Token Pool +```rust +let pool_id = client.create_pool( + &token_address, + &AssetType::Token, + &1000, // 10% APY + &30, // 30 days lock + &500, // 5% early withdrawal penalty + &10000, // 1x multiplier + &false, // No auto-compound +); +``` + +### Create an NFT Pool with Bonus +```rust +let pool_id = client.create_pool( + &nft_address, + &AssetType::NFT, + &2000, // 20% APY + &60, // 60 days lock + &1000, // 10% penalty + &20000, // 2x multiplier + &true, // Auto-compound enabled +); +``` + +### Stake and Earn +```rust +// Stake tokens +client.stake_tokens(&user, &pool_id, &10000); + +// Wait some time... + +// Check rewards +let rewards = client.calculate_rewards(&user, &stake_id); + +// Claim rewards +let claimed = client.claim_rewards(&user, &stake_id); + +// Unstake after lock period +let returned = client.unstake(&user, &stake_id); +``` + +## Testing + +Run the comprehensive test suite: + +```bash +cargo test --package yield_farming +``` + +Tests cover: +- Pool creation and configuration +- Token and NFT staking +- Reward calculation accuracy +- Claim and auto-compounding +- Lock period enforcement +- Early withdrawal penalties +- Multiplier bonuses +- Pool statistics tracking + +## Deployment + +### Build +```bash +soroban contract build +``` + +### Deploy to Testnet +```bash +soroban contract deploy \ + --wasm target/wasm32-unknown-unknown/release/yield_farming.wasm \ + --source deployer \ + --network testnet +``` + +### Initialize +```bash +soroban contract invoke \ + --id \ + --source deployer \ + --network testnet \ + -- initialize \ + --admin \ + --reward_token +``` + +## Security Considerations + +1. **Lock Periods**: Prevent flash loan attacks and reward gaming +2. **Penalties**: Discourage early withdrawals and maintain pool stability +3. **Accurate Math**: Use basis points (10000 = 100%) for precise calculations +4. **Admin Controls**: Only admin can create pools to prevent malicious configurations +5. **Time-Based Rewards**: Rewards calculated based on actual time elapsed + +## Integration with Quest Service + +This contract integrates with: +- **Reward Token Contract**: For distributing yield rewards +- **Achievement NFT Contract**: For NFT staking support +- **Leaderboard Contract**: Track top yield farmers +- **Guild Contract**: Guild-based farming pools + +## License + +MIT License diff --git a/contracts/yield_farming/src/lib.rs b/contracts/yield_farming/src/lib.rs new file mode 100644 index 0000000..8fcaa76 --- /dev/null +++ b/contracts/yield_farming/src/lib.rs @@ -0,0 +1,362 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, token, Address, Env, Vec, +}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AssetType { + Token, + NFT, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct PoolConfig { + pub asset_address: Address, + pub asset_type: AssetType, + pub apy_basis_points: u32, // 1000 = 10% + pub lock_period_days: u32, + pub early_withdrawal_penalty_bp: u32, // 500 = 5% + pub multiplier_bp: u32, // 10000 = 1x, 15000 = 1.5x + pub auto_compound: bool, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct StakePosition { + pub staker: Address, + pub pool_id: u32, + pub amount: i128, + pub nft_id: Option, + pub stake_time: u64, + pub last_claim_time: u64, + pub unlock_time: u64, + pub accumulated_rewards: i128, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct PoolStats { + pub total_staked: i128, + pub total_stakers: u32, + pub total_rewards_distributed: i128, +} + +#[contracttype] +pub enum DataKey { + Admin, + RewardToken, + Pool(u32), + PoolCounter, + PoolStats(u32), + Stake(Address, u32), + StakeCounter(Address), + UserStakes(Address), +} + +const SECONDS_PER_YEAR: u64 = 31_536_000; +const BASIS_POINTS: i128 = 10_000; + +#[contract] +pub struct YieldFarmingContract; + +#[contractimpl] +impl YieldFarmingContract { + + pub fn initialize(env: Env, admin: Address, reward_token: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("Already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::RewardToken, &reward_token); + env.storage().instance().set(&DataKey::PoolCounter, &0u32); + } + + pub fn create_pool( + env: Env, + asset_address: Address, + asset_type: AssetType, + apy_basis_points: u32, + lock_period_days: u32, + early_withdrawal_penalty_bp: u32, + multiplier_bp: u32, + auto_compound: bool, + ) -> u32 { + Self::require_admin(&env); + + let pool_id: u32 = env.storage().instance().get(&DataKey::PoolCounter).unwrap_or(0); + let next_id = pool_id + 1; + + let config = PoolConfig { + asset_address, + asset_type, + apy_basis_points, + lock_period_days, + early_withdrawal_penalty_bp, + multiplier_bp, + auto_compound, + }; + + env.storage().persistent().set(&DataKey::Pool(next_id), &config); + env.storage().persistent().set(&DataKey::PoolStats(next_id), &PoolStats { + total_staked: 0, + total_stakers: 0, + total_rewards_distributed: 0, + }); + env.storage().instance().set(&DataKey::PoolCounter, &next_id); + + next_id + } + + pub fn stake_tokens(env: Env, staker: Address, pool_id: u32, amount: i128) { + staker.require_auth(); + + if amount <= 0 { + panic!("Invalid amount"); + } + + let pool: PoolConfig = env.storage().persistent() + .get(&DataKey::Pool(pool_id)) + .expect("Pool not found"); + + if pool.asset_type != AssetType::Token { + panic!("Pool is for NFTs"); + } + + let token_client = token::Client::new(&env, &pool.asset_address); + token_client.transfer(&staker, &env.current_contract_address(), &amount); + + let now = env.ledger().timestamp(); + let unlock_time = now + (pool.lock_period_days as u64 * 86_400); + + let stake_count: u32 = env.storage().persistent() + .get(&DataKey::StakeCounter(staker.clone())) + .unwrap_or(0); + let stake_id = stake_count + 1; + + let position = StakePosition { + staker: staker.clone(), + pool_id, + amount, + nft_id: None, + stake_time: now, + last_claim_time: now, + unlock_time, + accumulated_rewards: 0, + }; + + env.storage().persistent().set(&DataKey::Stake(staker.clone(), stake_id), &position); + env.storage().persistent().set(&DataKey::StakeCounter(staker.clone()), &stake_id); + + let mut user_stakes: Vec = env.storage().persistent() + .get(&DataKey::UserStakes(staker.clone())) + .unwrap_or(Vec::new(&env)); + user_stakes.push_back(stake_id); + env.storage().persistent().set(&DataKey::UserStakes(staker.clone()), &user_stakes); + + Self::update_pool_stats(&env, pool_id, amount, true); + } + + pub fn stake_nft(env: Env, staker: Address, pool_id: u32, nft_id: u32) { + staker.require_auth(); + + let pool: PoolConfig = env.storage().persistent() + .get(&DataKey::Pool(pool_id)) + .expect("Pool not found"); + + if pool.asset_type != AssetType::NFT { + panic!("Pool is for tokens"); + } + + let token_client = token::Client::new(&env, &pool.asset_address); + token_client.transfer(&staker, &env.current_contract_address(), &1); + + let now = env.ledger().timestamp(); + let unlock_time = now + (pool.lock_period_days as u64 * 86_400); + + let stake_count: u32 = env.storage().persistent() + .get(&DataKey::StakeCounter(staker.clone())) + .unwrap_or(0); + let stake_id = stake_count + 1; + + let position = StakePosition { + staker: staker.clone(), + pool_id, + amount: 1, + nft_id: Some(nft_id), + stake_time: now, + last_claim_time: now, + unlock_time, + accumulated_rewards: 0, + }; + + env.storage().persistent().set(&DataKey::Stake(staker.clone(), stake_id), &position); + env.storage().persistent().set(&DataKey::StakeCounter(staker.clone()), &stake_id); + + let mut user_stakes: Vec = env.storage().persistent() + .get(&DataKey::UserStakes(staker.clone())) + .unwrap_or(Vec::new(&env)); + user_stakes.push_back(stake_id); + env.storage().persistent().set(&DataKey::UserStakes(staker.clone()), &user_stakes); + + Self::update_pool_stats(&env, pool_id, 1, true); + } + + pub fn calculate_rewards(env: Env, staker: Address, stake_id: u32) -> i128 { + let position: StakePosition = env.storage().persistent() + .get(&DataKey::Stake(staker.clone(), stake_id)) + .expect("Stake not found"); + + let pool: PoolConfig = env.storage().persistent() + .get(&DataKey::Pool(position.pool_id)) + .expect("Pool not found"); + + let now = env.ledger().timestamp(); + let time_elapsed = now - position.last_claim_time; + + let base_reward = (position.amount * (pool.apy_basis_points as i128) * (time_elapsed as i128)) + / (BASIS_POINTS * SECONDS_PER_YEAR as i128); + + let multiplied_reward = (base_reward * (pool.multiplier_bp as i128)) / BASIS_POINTS; + + position.accumulated_rewards + multiplied_reward + } + + pub fn claim_rewards(env: Env, staker: Address, stake_id: u32) -> i128 { + staker.require_auth(); + + let rewards = Self::calculate_rewards(env.clone(), staker.clone(), stake_id); + + if rewards <= 0 { + return 0; + } + + let mut position: StakePosition = env.storage().persistent() + .get(&DataKey::Stake(staker.clone(), stake_id)) + .expect("Stake not found"); + + let pool: PoolConfig = env.storage().persistent() + .get(&DataKey::Pool(position.pool_id)) + .expect("Pool not found"); + + let now = env.ledger().timestamp(); + + if pool.auto_compound { + position.amount += rewards; + position.accumulated_rewards = 0; + } else { + let reward_token: Address = env.storage().instance() + .get(&DataKey::RewardToken) + .expect("Reward token not set"); + let token_client = token::Client::new(&env, &reward_token); + token_client.transfer(&env.current_contract_address(), &staker, &rewards); + position.accumulated_rewards = 0; + } + + position.last_claim_time = now; + env.storage().persistent().set(&DataKey::Stake(staker.clone(), stake_id), &position); + + let mut stats: PoolStats = env.storage().persistent() + .get(&DataKey::PoolStats(position.pool_id)) + .unwrap(); + stats.total_rewards_distributed += rewards; + env.storage().persistent().set(&DataKey::PoolStats(position.pool_id), &stats); + + rewards + } + + pub fn unstake(env: Env, staker: Address, stake_id: u32) -> i128 { + staker.require_auth(); + + let position: StakePosition = env.storage().persistent() + .get(&DataKey::Stake(staker.clone(), stake_id)) + .expect("Stake not found"); + + let pool: PoolConfig = env.storage().persistent() + .get(&DataKey::Pool(position.pool_id)) + .expect("Pool not found"); + + let now = env.ledger().timestamp(); + let is_early = now < position.unlock_time; + + let pending_rewards = Self::calculate_rewards(env.clone(), staker.clone(), stake_id); + + let mut return_amount = position.amount; + let mut penalty = 0i128; + + if is_early { + penalty = (return_amount * (pool.early_withdrawal_penalty_bp as i128)) / BASIS_POINTS; + return_amount -= penalty; + } + + let token_client = token::Client::new(&env, &pool.asset_address); + token_client.transfer(&env.current_contract_address(), &staker, &return_amount); + + if pending_rewards > 0 && !pool.auto_compound { + let reward_token: Address = env.storage().instance() + .get(&DataKey::RewardToken) + .expect("Reward token not set"); + let reward_client = token::Client::new(&env, &reward_token); + reward_client.transfer(&env.current_contract_address(), &staker, &pending_rewards); + } + + env.storage().persistent().remove(&DataKey::Stake(staker.clone(), stake_id)); + + Self::update_pool_stats(&env, position.pool_id, position.amount, false); + + return_amount + } + + pub fn get_pool(env: Env, pool_id: u32) -> PoolConfig { + env.storage().persistent() + .get(&DataKey::Pool(pool_id)) + .expect("Pool not found") + } + + pub fn get_pool_stats(env: Env, pool_id: u32) -> PoolStats { + env.storage().persistent() + .get(&DataKey::PoolStats(pool_id)) + .expect("Pool not found") + } + + pub fn get_stake(env: Env, staker: Address, stake_id: u32) -> StakePosition { + env.storage().persistent() + .get(&DataKey::Stake(staker, stake_id)) + .expect("Stake not found") + } + + pub fn get_user_stakes(env: Env, staker: Address) -> Vec { + env.storage().persistent() + .get(&DataKey::UserStakes(staker)) + .unwrap_or(Vec::new(&env)) + } + + fn require_admin(env: &Env) { + let admin: Address = env.storage().instance() + .get(&DataKey::Admin) + .expect("Admin not set"); + admin.require_auth(); + } + + fn update_pool_stats(env: &Env, pool_id: u32, amount: i128, is_stake: bool) { + let mut stats: PoolStats = env.storage().persistent() + .get(&DataKey::PoolStats(pool_id)) + .unwrap(); + + if is_stake { + stats.total_staked += amount; + stats.total_stakers += 1; + } else { + stats.total_staked -= amount; + stats.total_stakers = stats.total_stakers.saturating_sub(1); + } + + env.storage().persistent().set(&DataKey::PoolStats(pool_id), &stats); + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/yield_farming/src/test.rs b/contracts/yield_farming/src/test.rs new file mode 100644 index 0000000..e6dbead --- /dev/null +++ b/contracts/yield_farming/src/test.rs @@ -0,0 +1,433 @@ +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{StellarAssetClient, TokenClient}, + Env, +}; + +fn create_token_contract<'a>(env: &Env, admin: &Address) -> (TokenClient<'a>, StellarAssetClient<'a>) { + let contract_address = env.register_stellar_asset_contract_v2(admin.clone()); + ( + TokenClient::new(env, &contract_address.address()), + StellarAssetClient::new(env, &contract_address.address()), + ) +} + +#[test] +fn test_initialize() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, YieldFarmingContract); + let client = YieldFarmingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let reward_token = Address::generate(&env); + + client.initialize(&admin, &reward_token); +} + +#[test] +fn test_create_pool() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, YieldFarmingContract); + let client = YieldFarmingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let reward_token = Address::generate(&env); + let asset = Address::generate(&env); + + client.initialize(&admin, &reward_token); + + let pool_id = client.create_pool( + &asset, + &AssetType::Token, + &1000, // 10% APY + &30, // 30 days lock + &500, // 5% penalty + &10000, // 1x multiplier + &false, + ); + + assert_eq!(pool_id, 1); + + let pool = client.get_pool(&pool_id); + assert_eq!(pool.apy_basis_points, 1000); + assert_eq!(pool.lock_period_days, 30); +} + +#[test] +fn test_stake_tokens() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, YieldFarmingContract); + let client = YieldFarmingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let staker = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + let (reward_token, _) = create_token_contract(&env, &token_admin); + + token_admin_client.mint(&staker, &1000); + + client.initialize(&admin, &reward_token.address); + + let pool_id = client.create_pool( + &token.address, + &AssetType::Token, + &1000, + &30, + &500, + &10000, + &false, + ); + + client.stake_tokens(&staker, &pool_id, &500); + + let stakes = client.get_user_stakes(&staker); + assert_eq!(stakes.len(), 1); + + let position = client.get_stake(&staker, &1); + assert_eq!(position.amount, 500); + assert_eq!(position.pool_id, pool_id); + + let stats = client.get_pool_stats(&pool_id); + assert_eq!(stats.total_staked, 500); + assert_eq!(stats.total_stakers, 1); +} + +#[test] +fn test_calculate_rewards() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let contract_id = env.register_contract(None, YieldFarmingContract); + let client = YieldFarmingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let staker = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + let (reward_token, _) = create_token_contract(&env, &token_admin); + + token_admin_client.mint(&staker, &10000); + + client.initialize(&admin, &reward_token.address); + + let pool_id = client.create_pool( + &token.address, + &AssetType::Token, + &1000, // 10% APY + &30, + &500, + &10000, // 1x multiplier + &false, + ); + + client.stake_tokens(&staker, &pool_id, &10000); + + // Fast forward 1 year + env.ledger().with_mut(|li| { + li.timestamp = 1000 + 31_536_000; + }); + + let rewards = client.calculate_rewards(&staker, &1); + + // Should be approximately 10% of 10000 = 1000 + assert!(rewards >= 900 && rewards <= 1100); +} + +#[test] +fn test_claim_rewards() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let contract_id = env.register_contract(None, YieldFarmingContract); + let client = YieldFarmingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let staker = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + let (reward_token, reward_admin_client) = create_token_contract(&env, &token_admin); + + token_admin_client.mint(&staker, &10000); + reward_admin_client.mint(&contract_id, &100000); + + client.initialize(&admin, &reward_token.address); + + let pool_id = client.create_pool( + &token.address, + &AssetType::Token, + &1000, + &30, + &500, + &10000, + &false, + ); + + client.stake_tokens(&staker, &pool_id, &10000); + + // Fast forward 6 months + env.ledger().with_mut(|li| { + li.timestamp = 1000 + 15_768_000; + }); + + let initial_balance = reward_token.balance(&staker); + let claimed = client.claim_rewards(&staker, &1); + let final_balance = reward_token.balance(&staker); + + assert!(claimed > 0); + assert_eq!(final_balance - initial_balance, claimed); +} + +#[test] +fn test_unstake_after_lock() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let contract_id = env.register_contract(None, YieldFarmingContract); + let client = YieldFarmingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let staker = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + let (reward_token, reward_admin_client) = create_token_contract(&env, &token_admin); + + token_admin_client.mint(&staker, &10000); + reward_admin_client.mint(&contract_id, &100000); + + client.initialize(&admin, &reward_token.address); + + let pool_id = client.create_pool( + &token.address, + &AssetType::Token, + &1000, + &30, + &500, + &10000, + &false, + ); + + client.stake_tokens(&staker, &pool_id, &5000); + + let initial_balance = token.balance(&staker); + + // Fast forward past lock period (30 days) + env.ledger().with_mut(|li| { + li.timestamp = 1000 + (30 * 86_400) + 1; + }); + + let returned = client.unstake(&staker, &1); + let final_balance = token.balance(&staker); + + // Should get full amount back (no penalty) + assert_eq!(returned, 5000); + assert_eq!(final_balance - initial_balance, 5000); + + let stats = client.get_pool_stats(&pool_id); + assert_eq!(stats.total_staked, 0); +} + +#[test] +fn test_early_withdrawal_penalty() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let contract_id = env.register_contract(None, YieldFarmingContract); + let client = YieldFarmingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let staker = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + let (reward_token, reward_admin_client) = create_token_contract(&env, &token_admin); + + token_admin_client.mint(&staker, &10000); + reward_admin_client.mint(&contract_id, &100000); + + client.initialize(&admin, &reward_token.address); + + let pool_id = client.create_pool( + &token.address, + &AssetType::Token, + &1000, + &30, + &500, // 5% penalty + &10000, + &false, + ); + + client.stake_tokens(&staker, &pool_id, &10000); + + let initial_balance = token.balance(&staker); + + // Unstake early (only 10 days) + env.ledger().with_mut(|li| { + li.timestamp = 1000 + (10 * 86_400); + }); + + let returned = client.unstake(&staker, &1); + let final_balance = token.balance(&staker); + + // Should get 95% back (5% penalty) + assert_eq!(returned, 9500); + assert_eq!(final_balance - initial_balance, 9500); +} + +#[test] +fn test_auto_compounding() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let contract_id = env.register_contract(None, YieldFarmingContract); + let client = YieldFarmingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let staker = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + let (reward_token, _) = create_token_contract(&env, &token_admin); + + token_admin_client.mint(&staker, &10000); + + client.initialize(&admin, &reward_token.address); + + let pool_id = client.create_pool( + &token.address, + &AssetType::Token, + &1000, + &30, + &500, + &10000, + &true, // Auto-compound enabled + ); + + client.stake_tokens(&staker, &pool_id, &10000); + + let initial_position = client.get_stake(&staker, &1); + assert_eq!(initial_position.amount, 10000); + + // Fast forward 6 months + env.ledger().with_mut(|li| { + li.timestamp = 1000 + 15_768_000; + }); + + client.claim_rewards(&staker, &1); + + let updated_position = client.get_stake(&staker, &1); + + // Amount should have increased due to auto-compounding + assert!(updated_position.amount > 10000); +} + +#[test] +fn test_multiplier_bonus() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let contract_id = env.register_contract(None, YieldFarmingContract); + let client = YieldFarmingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let staker = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (token, token_admin_client) = create_token_contract(&env, &token_admin); + let (reward_token, _) = create_token_contract(&env, &token_admin); + + token_admin_client.mint(&staker, &20000); + + client.initialize(&admin, &reward_token.address); + + // Pool with 1.5x multiplier + let pool_id = client.create_pool( + &token.address, + &AssetType::Token, + &1000, + &30, + &500, + &15000, // 1.5x multiplier + &false, + ); + + client.stake_tokens(&staker, &pool_id, &10000); + + // Fast forward 1 year + env.ledger().with_mut(|li| { + li.timestamp = 1000 + 31_536_000; + }); + + let rewards = client.calculate_rewards(&staker, &1); + + // Should be approximately 15% of 10000 = 1500 (10% APY * 1.5x multiplier) + assert!(rewards >= 1400 && rewards <= 1600); +} + +#[test] +fn test_nft_staking() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, YieldFarmingContract); + let client = YieldFarmingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let staker = Address::generate(&env); + let token_admin = Address::generate(&env); + + let (nft_token, nft_admin_client) = create_token_contract(&env, &token_admin); + let (reward_token, _) = create_token_contract(&env, &token_admin); + + nft_admin_client.mint(&staker, &1); + + client.initialize(&admin, &reward_token.address); + + let pool_id = client.create_pool( + &nft_token.address, + &AssetType::NFT, + &2000, // 20% APY for NFTs + &60, + &1000, + &20000, // 2x multiplier + &false, + ); + + client.stake_nft(&staker, &pool_id, &123); + + let position = client.get_stake(&staker, &1); + assert_eq!(position.amount, 1); + assert_eq!(position.nft_id, Some(123)); + + let stats = client.get_pool_stats(&pool_id); + assert_eq!(stats.total_staked, 1); +} diff --git a/deploy_yield_farming.sh b/deploy_yield_farming.sh new file mode 100755 index 0000000..319bbc8 --- /dev/null +++ b/deploy_yield_farming.sh @@ -0,0 +1,149 @@ +#!/bin/bash + +set -e + +echo "🚀 Deploying Yield Farming Contract to Testnet" +echo "==============================================" + +# Colors +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Configuration +NETWORK="testnet" +SOURCE_ACCOUNT="puzzle_deployer" + +echo -e "${BLUE}📦 Building contract...${NC}" +soroban contract build --package yield_farming + +if [ ! -f "target/wasm32-unknown-unknown/release/yield_farming.wasm" ]; then + echo "❌ Build failed - WASM file not found" + exit 1 +fi + +echo -e "${GREEN}✅ Build successful${NC}" +echo "" + +echo -e "${BLUE}🔍 Optimizing WASM...${NC}" +soroban contract optimize \ + --wasm target/wasm32-unknown-unknown/release/yield_farming.wasm + +echo -e "${GREEN}✅ Optimization complete${NC}" +echo "" + +echo -e "${BLUE}📤 Deploying to ${NETWORK}...${NC}" +CONTRACT_ID=$(soroban contract deploy \ + --wasm target/wasm32-unknown-unknown/release/yield_farming.wasm \ + --source ${SOURCE_ACCOUNT} \ + --network ${NETWORK}) + +if [ -z "$CONTRACT_ID" ]; then + echo "❌ Deployment failed" + exit 1 +fi + +echo -e "${GREEN}✅ Contract deployed successfully!${NC}" +echo "" +echo "📋 Contract Details:" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo -e "Contract ID: ${YELLOW}${CONTRACT_ID}${NC}" +echo -e "Network: ${YELLOW}${NETWORK}${NC}" +echo -e "Source: ${YELLOW}${SOURCE_ACCOUNT}${NC}" +echo "" + +# Get admin address +ADMIN_ADDRESS=$(soroban keys address ${SOURCE_ACCOUNT}) + +echo -e "${BLUE}🔧 Initializing contract...${NC}" +echo "Please provide the reward token address:" +read -p "Reward Token Address: " REWARD_TOKEN + +if [ -z "$REWARD_TOKEN" ]; then + echo -e "${YELLOW}⚠️ No reward token provided. Skipping initialization.${NC}" + echo "You can initialize later with:" + echo "" + echo "soroban contract invoke \\" + echo " --id ${CONTRACT_ID} \\" + echo " --source ${SOURCE_ACCOUNT} \\" + echo " --network ${NETWORK} \\" + echo " -- initialize \\" + echo " --admin ${ADMIN_ADDRESS} \\" + echo " --reward_token " +else + soroban contract invoke \ + --id ${CONTRACT_ID} \ + --source ${SOURCE_ACCOUNT} \ + --network ${NETWORK} \ + -- initialize \ + --admin ${ADMIN_ADDRESS} \ + --reward_token ${REWARD_TOKEN} + + echo -e "${GREEN}✅ Contract initialized${NC}" + echo "" + + echo -e "${BLUE}📊 Creating example pool...${NC}" + echo "Create a token pool? (y/n)" + read -p "> " CREATE_POOL + + if [ "$CREATE_POOL" = "y" ]; then + echo "Enter token address for the pool:" + read -p "Token Address: " TOKEN_ADDRESS + + if [ ! -z "$TOKEN_ADDRESS" ]; then + POOL_ID=$(soroban contract invoke \ + --id ${CONTRACT_ID} \ + --source ${SOURCE_ACCOUNT} \ + --network ${NETWORK} \ + -- create_pool \ + --asset_address ${TOKEN_ADDRESS} \ + --asset_type '{"Token":{}}' \ + --apy_basis_points 1000 \ + --lock_period_days 30 \ + --early_withdrawal_penalty_bp 500 \ + --multiplier_bp 10000 \ + --auto_compound false) + + echo -e "${GREEN}✅ Pool created with ID: ${POOL_ID}${NC}" + fi + fi +fi + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo -e "${GREEN}🎉 Deployment Complete!${NC}" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "📝 Next Steps:" +echo "1. Create staking pools with create_pool" +echo "2. Fund contract with reward tokens" +echo "3. Users can stake tokens/NFTs" +echo "4. Monitor pool statistics" +echo "" +echo "🔗 Useful Commands:" +echo "" +echo "# Create a pool" +echo "soroban contract invoke --id ${CONTRACT_ID} --source ${SOURCE_ACCOUNT} --network ${NETWORK} \\" +echo " -- create_pool --asset_address --asset_type '{\"Token\":{}}' \\" +echo " --apy_basis_points 1000 --lock_period_days 30 --early_withdrawal_penalty_bp 500 \\" +echo " --multiplier_bp 10000 --auto_compound false" +echo "" +echo "# Stake tokens" +echo "soroban contract invoke --id ${CONTRACT_ID} --source --network ${NETWORK} \\" +echo " -- stake_tokens --staker --pool_id 1 --amount 10000" +echo "" +echo "# Check rewards" +echo "soroban contract invoke --id ${CONTRACT_ID} --network ${NETWORK} \\" +echo " -- calculate_rewards --staker --stake_id 1" +echo "" +echo "# Claim rewards" +echo "soroban contract invoke --id ${CONTRACT_ID} --source --network ${NETWORK} \\" +echo " -- claim_rewards --staker --stake_id 1" +echo "" +echo "# View pool stats" +echo "soroban contract invoke --id ${CONTRACT_ID} --network ${NETWORK} \\" +echo " -- get_pool_stats --pool_id 1" +echo "" +echo "📊 Explorer: https://stellar.expert/explorer/${NETWORK}/contract/${CONTRACT_ID}" +echo ""