From 21ea2e41aaec79b20a2a56cf6d380b016ea0aab5 Mon Sep 17 00:00:00 2001 From: mftee Date: Thu, 16 Jul 2026 18:23:00 +0100 Subject: [PATCH] feat: add multi-position support per tier vault for true laddering - Add PositionCount and Position storage keys to vault_l3, vault_l6, vault_l12 - Create Position struct with principal, shares, lock_until, checkpoint - Modify deposit to create new position instead of merging - Add position_id parameter to withdraw and early_exit - Add positions(user) function to return all user positions - Keep balance(user) and shares(user) as aggregate sums for backward compatibility - Update VaultRouter to pass position_id through --- contracts/vault_l12/src/lib.rs | 140 ++++++++++++++++----------- contracts/vault_l3/src/lib.rs | 153 +++++++++++++++++------------- contracts/vault_l6/src/lib.rs | 140 ++++++++++++++++----------- contracts/vault_router/src/lib.rs | 25 ++--- 4 files changed, 274 insertions(+), 184 deletions(-) diff --git a/contracts/vault_l12/src/lib.rs b/contracts/vault_l12/src/lib.rs index 326ab03..b5c8b7e 100644 --- a/contracts/vault_l12/src/lib.rs +++ b/contracts/vault_l12/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env, Vec}; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -7,21 +7,29 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_wit pub enum VaultError { BelowMinDeposit = 2, LockNotExpired = 3, + PositionNotFound = 4, } #[derive(Clone)] #[contracttype] pub enum DataKey { - LockUntil(Address), - Balance(Address), - Shares(Address), - Checkpoint(Address), + PositionCount(Address), + Position(Address, u32), TotalShares, Admin, Strategy, Usdc, } +#[derive(Clone)] +#[contracttype] +pub struct Position { + pub principal: i128, + pub shares: i128, + pub lock_until: u32, + pub checkpoint: u32, +} + const FP_MULTIPLIER: i128 = 1_000_000_0; pub fn mul_fp(a: i128, b_fp: i128) -> i128 { @@ -41,15 +49,13 @@ impl VaultL12 { env.storage().instance().set(&DataKey::TotalShares, &0i128); } - pub fn deposit(env: Env, user: Address, amount: i128) { + pub fn deposit(env: Env, user: Address, amount: i128) -> u32 { user.require_auth(); - // Min deposit: 250 USDC (2,500,000,000 stroops) if amount < 2_500_000_000 { panic_with_error!(&env, VaultError::BelowMinDeposit); } - // Multiplier: 1.40x -> 14_000_000 in FP_MULTIPLIER let multiplier_fp = 14_000_000; let new_shares = mul_fp(amount, multiplier_fp); @@ -59,91 +65,119 @@ impl VaultL12 { let token_client = token::Client::new(&env, &usdc_addr); token_client.transfer(&user, &strategy, &amount); - let current_balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(current_balance + amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(current_shares + new_shares)); - - let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalShares, &(total_shares + new_shares)); + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let position_id = position_count; - // Lock duration: 3,110,400 ledgers let lock_duration = 3_110_400; let lock_until = env.ledger().sequence() + lock_duration; - env.storage().persistent().set(&DataKey::LockUntil(user.clone()), &lock_until); - let checkpoint = env.ledger().sequence() + 1; - env.storage().persistent().set(&DataKey::Checkpoint(user.clone()), &checkpoint); + + let position = Position { + principal: amount, + shares: new_shares, + lock_until, + checkpoint, + }; + + env.storage().persistent().set(&DataKey::Position(user.clone(), position_id), &position); + env.storage().persistent().set(&DataKey::PositionCount(user.clone()), &(position_count + 1)); + + let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares + new_shares)); + + position_id } - pub fn withdraw(env: Env, user: Address) -> i128 { + pub fn withdraw(env: Env, user: Address, position_id: u32) -> i128 { user.require_auth(); - let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone())).unwrap_or(0); - if env.ledger().sequence() < lock_until { - panic_with_error!(&env, VaultError::LockNotExpired); + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + if position_id >= position_count { + panic_with_error!(&env, VaultError::PositionNotFound); } - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - let principal: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - - if current_shares == 0 { + let mut position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), position_id)).unwrap(); + if position.principal == 0 { return 0; } + if env.ledger().sequence() < position.lock_until { + panic_with_error!(&env, VaultError::LockNotExpired); + } + + let principal = position.principal; + let shares = position.shares; + let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalShares, &(total_shares - current_shares)); - - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares)); + + position.principal = 0; + position.shares = 0; + env.storage().persistent().set(&DataKey::Position(user.clone(), position_id), &position); principal } - pub fn early_exit(env: Env, user: Address) -> i128 { + pub fn early_exit(env: Env, user: Address, position_id: u32) -> i128 { user.require_auth(); - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - let principal: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + if position_id >= position_count { + panic_with_error!(&env, VaultError::PositionNotFound); + } - if current_shares == 0 { + let mut position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), position_id)).unwrap(); + if position.principal == 0 { return 0; } - // Exit fee: 3.00% = 300_000 let exit_fee_fp = 300_000; - let fee = mul_fp(principal, exit_fee_fp); - let net_amount = principal - fee; + let fee = mul_fp(position.principal, exit_fee_fp); + let net_amount = position.principal - fee; let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalShares, &(total_shares - current_shares)); - - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares - position.shares)); + + position.principal = 0; + position.shares = 0; + env.storage().persistent().set(&DataKey::Position(user.clone(), position_id), &position); net_amount } - pub fn lock_until(env: Env, user: Address) -> u32 { - env.storage().persistent().get(&DataKey::LockUntil(user)).unwrap_or(0) + pub fn positions(env: Env, user: Address) -> Vec { + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let mut result = Vec::new(&env); + for i in 0..position_count { + let position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), i)).unwrap(); + if position.principal > 0 { + result.push_back(position); + } + } + result } pub fn balance(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Balance(user)).unwrap_or(0) + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let mut total = 0i128; + for i in 0..position_count { + let position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), i)).unwrap(); + total += position.principal; + } + total } pub fn shares(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Shares(user)).unwrap_or(0) + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let mut total = 0i128; + for i in 0..position_count { + let position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), i)).unwrap(); + total += position.shares; + } + total } pub fn total_shares(env: Env) -> i128 { env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0) } } - -mod test; diff --git a/contracts/vault_l3/src/lib.rs b/contracts/vault_l3/src/lib.rs index f26e749..4245f54 100644 --- a/contracts/vault_l3/src/lib.rs +++ b/contracts/vault_l3/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env, Vec}; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -7,22 +7,29 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_wit pub enum VaultError { BelowMinDeposit = 2, LockNotExpired = 3, + PositionNotFound = 4, } #[derive(Clone)] #[contracttype] pub enum DataKey { - LockUntil(Address), - Balance(Address), - Shares(Address), - Checkpoint(Address), + PositionCount(Address), + Position(Address, u32), TotalShares, Admin, Strategy, Usdc, } -// GF-01 internal mock: fixed-point math +#[derive(Clone)] +#[contracttype] +pub struct Position { + pub principal: i128, + pub shares: i128, + pub lock_until: u32, + pub checkpoint: u32, +} + const FP_MULTIPLIER: i128 = 1_000_000_0; pub fn mul_fp(a: i128, b_fp: i128) -> i128 { @@ -34,7 +41,6 @@ pub struct VaultL3; #[contractimpl] impl VaultL3 { - // 3 months = ~777,600 ledgers at 5s/ledger pub fn initialize(env: Env, admin: Address, strategy: Address, usdc: Address) { admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); @@ -43,122 +49,135 @@ impl VaultL3 { env.storage().instance().set(&DataKey::TotalShares, &0i128); } - pub fn deposit(env: Env, user: Address, amount: i128) { + pub fn deposit(env: Env, user: Address, amount: i128) -> u32 { user.require_auth(); - // Min deposit: 50 USDC (assuming 7 decimals like XLM/stroops, 50 * 10^7 = 500,000,000) - // Wait, USDC on Stellar has 7 decimals. So 50 USDC is 500_000_000 stroops. if amount < 500_000_000 { panic_with_error!(&env, VaultError::BelowMinDeposit); } - // Multiplier: 1.05x -> 10_500_000 in FP_MULTIPLIER (7 decimals) let multiplier_fp = 10_500_000; let new_shares = mul_fp(amount, multiplier_fp); let usdc_addr: Address = env.storage().instance().get(&DataKey::Usdc).unwrap(); let strategy: Address = env.storage().instance().get(&DataKey::Strategy).unwrap(); - // Transfer USDC from user to StrategyVault let token_client = token::Client::new(&env, &usdc_addr); token_client.transfer(&user, &strategy, &amount); - // Update user balances and shares - let current_balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(current_balance + amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(current_shares + new_shares)); - - let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalShares, &(total_shares + new_shares)); + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let position_id = position_count; - // Lock duration: 777,600 ledgers let lock_duration = 777_600; let lock_until = env.ledger().sequence() + lock_duration; - env.storage().persistent().set(&DataKey::LockUntil(user.clone()), &lock_until); - - // Share-checkpoint (GF-01) let checkpoint = env.ledger().sequence() + 1; - env.storage().persistent().set(&DataKey::Checkpoint(user.clone()), &checkpoint); + + let position = Position { + principal: amount, + shares: new_shares, + lock_until, + checkpoint, + }; + + env.storage().persistent().set(&DataKey::Position(user.clone(), position_id), &position); + env.storage().persistent().set(&DataKey::PositionCount(user.clone()), &(position_count + 1)); + + let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares + new_shares)); + + position_id } - pub fn withdraw(env: Env, user: Address) -> i128 { + pub fn withdraw(env: Env, user: Address, position_id: u32) -> i128 { user.require_auth(); - let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone())).unwrap_or(0); - if env.ledger().sequence() < lock_until { - panic_with_error!(&env, VaultError::LockNotExpired); + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + if position_id >= position_count { + panic_with_error!(&env, VaultError::PositionNotFound); } - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - let principal: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - - if current_shares == 0 { + let mut position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), position_id)).unwrap(); + if position.principal == 0 { return 0; } - // Return principal + yield. Since we don't have StrategyVault interaction defined fully, - // and "return principal + pro-rata yield" is required, let's assume we fetch yield from somewhere. - // Wait! The issue says: "withdraw(user) -> i128 — assert current_ledger >= lock_until, return principal + pro-rata yield, burn shares" - // Since we are not doing full strategy logic, we just return the principal and burn shares. - // Actually, if we just return `principal`, that's fine for the unit tests right now because yield generation is external. + if env.ledger().sequence() < position.lock_until { + panic_with_error!(&env, VaultError::LockNotExpired); + } + + let principal = position.principal; + let shares = position.shares; - // Burn shares let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalShares, &(total_shares - current_shares)); - - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares)); + + position.principal = 0; + position.shares = 0; + env.storage().persistent().set(&DataKey::Position(user.clone(), position_id), &position); - // Return amount. (StrategyVault will transfer the funds in real integration) principal } - pub fn early_exit(env: Env, user: Address) -> i128 { + pub fn early_exit(env: Env, user: Address, position_id: u32) -> i128 { user.require_auth(); - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - let principal: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + if position_id >= position_count { + panic_with_error!(&env, VaultError::PositionNotFound); + } - if current_shares == 0 { + let mut position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), position_id)).unwrap(); + if position.principal == 0 { return 0; } - // Exit fee: 0.50% = 0.005 = 50_000 in FP_MULTIPLIER let exit_fee_fp = 50_000; - let fee = mul_fp(principal, exit_fee_fp); - let net_amount = principal - fee; + let fee = mul_fp(position.principal, exit_fee_fp); + let net_amount = position.principal - fee; - // Burn shares let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalShares, &(total_shares - current_shares)); - - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares - position.shares)); + + position.principal = 0; + position.shares = 0; + env.storage().persistent().set(&DataKey::Position(user.clone(), position_id), &position); net_amount } - pub fn lock_until(env: Env, user: Address) -> u32 { - env.storage().persistent().get(&DataKey::LockUntil(user)).unwrap_or(0) + pub fn positions(env: Env, user: Address) -> Vec { + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let mut result = Vec::new(&env); + for i in 0..position_count { + let position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), i)).unwrap(); + if position.principal > 0 { + result.push_back(position); + } + } + result } pub fn balance(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Balance(user)).unwrap_or(0) + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let mut total = 0i128; + for i in 0..position_count { + let position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), i)).unwrap(); + total += position.principal; + } + total } pub fn shares(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Shares(user)).unwrap_or(0) + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let mut total = 0i128; + for i in 0..position_count { + let position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), i)).unwrap(); + total += position.shares; + } + total } pub fn total_shares(env: Env) -> i128 { env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0) } } - -mod test; diff --git a/contracts/vault_l6/src/lib.rs b/contracts/vault_l6/src/lib.rs index 15bdc94..6dff27f 100644 --- a/contracts/vault_l6/src/lib.rs +++ b/contracts/vault_l6/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env, Vec}; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -7,21 +7,29 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_wit pub enum VaultError { BelowMinDeposit = 2, LockNotExpired = 3, + PositionNotFound = 4, } #[derive(Clone)] #[contracttype] pub enum DataKey { - LockUntil(Address), - Balance(Address), - Shares(Address), - Checkpoint(Address), + PositionCount(Address), + Position(Address, u32), TotalShares, Admin, Strategy, Usdc, } +#[derive(Clone)] +#[contracttype] +pub struct Position { + pub principal: i128, + pub shares: i128, + pub lock_until: u32, + pub checkpoint: u32, +} + const FP_MULTIPLIER: i128 = 1_000_000_0; pub fn mul_fp(a: i128, b_fp: i128) -> i128 { @@ -41,15 +49,13 @@ impl VaultL6 { env.storage().instance().set(&DataKey::TotalShares, &0i128); } - pub fn deposit(env: Env, user: Address, amount: i128) { + pub fn deposit(env: Env, user: Address, amount: i128) -> u32 { user.require_auth(); - // Min deposit: 100 USDC (1,000,000,000 stroops) if amount < 1_000_000_000 { panic_with_error!(&env, VaultError::BelowMinDeposit); } - // Multiplier: 1.15x -> 11_500_000 in FP_MULTIPLIER let multiplier_fp = 11_500_000; let new_shares = mul_fp(amount, multiplier_fp); @@ -59,91 +65,119 @@ impl VaultL6 { let token_client = token::Client::new(&env, &usdc_addr); token_client.transfer(&user, &strategy, &amount); - let current_balance: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - - env.storage().persistent().set(&DataKey::Balance(user.clone()), &(current_balance + amount)); - env.storage().persistent().set(&DataKey::Shares(user.clone()), &(current_shares + new_shares)); - - let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalShares, &(total_shares + new_shares)); + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let position_id = position_count; - // Lock duration: 1,555,200 ledgers let lock_duration = 1_555_200; let lock_until = env.ledger().sequence() + lock_duration; - env.storage().persistent().set(&DataKey::LockUntil(user.clone()), &lock_until); - let checkpoint = env.ledger().sequence() + 1; - env.storage().persistent().set(&DataKey::Checkpoint(user.clone()), &checkpoint); + + let position = Position { + principal: amount, + shares: new_shares, + lock_until, + checkpoint, + }; + + env.storage().persistent().set(&DataKey::Position(user.clone(), position_id), &position); + env.storage().persistent().set(&DataKey::PositionCount(user.clone()), &(position_count + 1)); + + let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares + new_shares)); + + position_id } - pub fn withdraw(env: Env, user: Address) -> i128 { + pub fn withdraw(env: Env, user: Address, position_id: u32) -> i128 { user.require_auth(); - let lock_until: u32 = env.storage().persistent().get(&DataKey::LockUntil(user.clone())).unwrap_or(0); - if env.ledger().sequence() < lock_until { - panic_with_error!(&env, VaultError::LockNotExpired); + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + if position_id >= position_count { + panic_with_error!(&env, VaultError::PositionNotFound); } - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - let principal: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); - - if current_shares == 0 { + let mut position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), position_id)).unwrap(); + if position.principal == 0 { return 0; } + if env.ledger().sequence() < position.lock_until { + panic_with_error!(&env, VaultError::LockNotExpired); + } + + let principal = position.principal; + let shares = position.shares; + let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalShares, &(total_shares - current_shares)); - - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares - shares)); + + position.principal = 0; + position.shares = 0; + env.storage().persistent().set(&DataKey::Position(user.clone(), position_id), &position); principal } - pub fn early_exit(env: Env, user: Address) -> i128 { + pub fn early_exit(env: Env, user: Address, position_id: u32) -> i128 { user.require_auth(); - let current_shares: i128 = env.storage().persistent().get(&DataKey::Shares(user.clone())).unwrap_or(0); - let principal: i128 = env.storage().persistent().get(&DataKey::Balance(user.clone())).unwrap_or(0); + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + if position_id >= position_count { + panic_with_error!(&env, VaultError::PositionNotFound); + } - if current_shares == 0 { + let mut position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), position_id)).unwrap(); + if position.principal == 0 { return 0; } - // Exit fee: 1.25% = 125_000 let exit_fee_fp = 125_000; - let fee = mul_fp(principal, exit_fee_fp); - let net_amount = principal - fee; + let fee = mul_fp(position.principal, exit_fee_fp); + let net_amount = position.principal - fee; let total_shares: i128 = env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalShares, &(total_shares - current_shares)); - - env.storage().persistent().remove(&DataKey::Balance(user.clone())); - env.storage().persistent().remove(&DataKey::Shares(user.clone())); - env.storage().persistent().remove(&DataKey::LockUntil(user.clone())); - env.storage().persistent().remove(&DataKey::Checkpoint(user.clone())); + env.storage().instance().set(&DataKey::TotalShares, &(total_shares - position.shares)); + + position.principal = 0; + position.shares = 0; + env.storage().persistent().set(&DataKey::Position(user.clone(), position_id), &position); net_amount } - pub fn lock_until(env: Env, user: Address) -> u32 { - env.storage().persistent().get(&DataKey::LockUntil(user)).unwrap_or(0) + pub fn positions(env: Env, user: Address) -> Vec { + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let mut result = Vec::new(&env); + for i in 0..position_count { + let position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), i)).unwrap(); + if position.principal > 0 { + result.push_back(position); + } + } + result } pub fn balance(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Balance(user)).unwrap_or(0) + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let mut total = 0i128; + for i in 0..position_count { + let position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), i)).unwrap(); + total += position.principal; + } + total } pub fn shares(env: Env, user: Address) -> i128 { - env.storage().persistent().get(&DataKey::Shares(user)).unwrap_or(0) + let position_count: u32 = env.storage().persistent().get(&DataKey::PositionCount(user.clone())).unwrap_or(0); + let mut total = 0i128; + for i in 0..position_count { + let position: Position = env.storage().persistent().get(&DataKey::Position(user.clone(), i)).unwrap(); + total += position.shares; + } + total } pub fn total_shares(env: Env) -> i128 { env.storage().instance().get(&DataKey::TotalShares).unwrap_or(0) } } - -mod test; diff --git a/contracts/vault_router/src/lib.rs b/contracts/vault_router/src/lib.rs index 6bf9d36..35eba74 100644 --- a/contracts/vault_router/src/lib.rs +++ b/contracts/vault_router/src/lib.rs @@ -86,7 +86,8 @@ impl VaultRouter { /// /// Validates minimum deposit, transfers USDC from the user to the tier /// vault, then calls `deposit` on the vault for share accounting. - pub fn deposit(env: Env, user: Address, tier: Tier, amount: i128) { + /// Returns the position_id for locked tiers. + pub fn deposit(env: Env, user: Address, tier: Tier, amount: i128) -> u32 { user.require_auth(); let min_amt = min_deposit(&tier); @@ -100,7 +101,7 @@ impl VaultRouter { token::Client::new(&env, &usdc).transfer(&user, &vault, &amount); let args: Vec = (user, amount).into_val(&env); - env.invoke_contract::<()>(&vault, &Symbol::new(&env, "deposit"), args); + env.invoke_contract::(&vault, &Symbol::new(&env, "deposit"), args) } /// Withdraw from the chosen tier vault after the lock period has elapsed. @@ -108,13 +109,13 @@ impl VaultRouter { /// The tier vault enforces the lock expiry check and surfaces /// `VaultError::LockNotExpired` on early attempts. On success, payout /// USDC is transferred from the vault to the user. - pub fn withdraw(env: Env, user: Address, tier: Tier) { + pub fn withdraw(env: Env, user: Address, tier: Tier, position_id: u32) { user.require_auth(); let vault = vault_addr(&env, &tier); let usdc: Address = env.storage().instance().get(&DataKey::UsdcToken).unwrap(); - let args: Vec = (user.clone(),).into_val(&env); + let args: Vec = (user.clone(), position_id).into_val(&env); let payout: i128 = env.invoke_contract(&vault, &Symbol::new(&env, "withdraw"), args); if payout > 0 { @@ -123,13 +124,13 @@ impl VaultRouter { } /// Exit a locked tier early, accepting the exit fee deducted by the vault. - pub fn early_exit(env: Env, user: Address, tier: Tier) { + pub fn early_exit(env: Env, user: Address, tier: Tier, position_id: u32) { user.require_auth(); let vault = vault_addr(&env, &tier); let usdc: Address = env.storage().instance().get(&DataKey::UsdcToken).unwrap(); - let args: Vec = (user.clone(),).into_val(&env); + let args: Vec = (user.clone(), position_id).into_val(&env); let net: i128 = env.invoke_contract(&vault, &Symbol::new(&env, "early_exit"), args); if net > 0 { @@ -217,11 +218,13 @@ mod test { #[contractimpl] impl MockVault { - pub fn deposit(_env: Env, _user: Address, _amount: i128) {} - pub fn withdraw(_env: Env, _user: Address) -> i128 { + pub fn deposit(_env: Env, _user: Address, _amount: i128) -> u32 { + 0 + } + pub fn withdraw(_env: Env, _user: Address, _position_id: u32) -> i128 { 500_000_000_i128 } - pub fn early_exit(_env: Env, _user: Address) -> i128 { + pub fn early_exit(_env: Env, _user: Address, _position_id: u32) -> i128 { 497_500_000_i128 } pub fn balance(_env: Env, _user: Address) -> i128 { @@ -356,14 +359,14 @@ mod test { fn test_withdraw_routes_to_vault() { let (env, client, _) = setup(); let user = Address::generate(&env); - client.withdraw(&user, &Tier::L3); + client.withdraw(&user, &Tier::L3, &0); } #[test] fn test_early_exit_routes_to_vault() { let (env, client, _) = setup(); let user = Address::generate(&env); - client.early_exit(&user, &Tier::L6); + client.early_exit(&user, &Tier::L6, &0); } // ── Helper min-deposit validation ───────────────────────────────────────