From 378a815735caa2a9fbe8b488b2a5708ebb582e4f Mon Sep 17 00:00:00 2001 From: ayshadogo Date: Sat, 22 Aug 2026 10:00:39 +0100 Subject: [PATCH] Restrict Lending pool Borrowing --- .freebuff/project-id | 1 + Contract/README.md | 27 + Contract/lending/src/authorization.rs | 32 ++ Contract/lending/src/lib.rs | 80 ++- Contract/lending/src/state.rs | 31 + Contract/lending/tests/test_lending.rs | 539 +++++++----------- .../test_lending_borrow_authorization.rs | 310 ++++++++++ .../lending/tests/test_lending_liquidity.rs | 31 +- Contract/shared/src/events.rs | 19 + 9 files changed, 720 insertions(+), 350 deletions(-) create mode 100644 .freebuff/project-id create mode 100644 Contract/lending/src/authorization.rs create mode 100644 Contract/lending/src/state.rs create mode 100644 Contract/lending/tests/test_lending_borrow_authorization.rs diff --git a/.freebuff/project-id b/.freebuff/project-id new file mode 100644 index 0000000..d5491cf --- /dev/null +++ b/.freebuff/project-id @@ -0,0 +1 @@ +5fc15649-80f3-41ea-86a7-a1c5e24c5382 diff --git a/Contract/README.md b/Contract/README.md index 37cc7a5..a1f35ef 100644 --- a/Contract/README.md +++ b/Contract/README.md @@ -165,6 +165,33 @@ Manages decentralized lending pools with advanced controls. * Admin permission system * Interest index tracking * Liquidity protection +* **Borrowing-contract authorization** — `borrow`, `repay`, and `update_debt` + are restricted to a single, immutable borrowing-contract address per pool. + +### Initialization Order (Lending ↔ Borrowing) + +The lending and borrowing contracts must be initialized in a specific order to +ensure that borrowing-contract authorization is in place before any loan +operations occur. + +```text +1. Deploy the lending contract. +2. Deploy the borrowing contract. +3. Create a lending pool via `LendingContract::create_pool`. +4. The pool admin calls `LendingContract::initialize_borrowing_contract` + to bind the pool to the borrowing contract address. + ⚠ This operation is one-time and irreversible — the configured address + cannot be changed without a contract upgrade. +5. The pool admin configures the lending pool address inside the borrowing + contract (borrowing-contract side). +6. Suppliers call `LendingContract::deposit` to add liquidity. +7. Borrowers request loans through the borrowing contract, which internally + calls `borrow` and `repay` on the lending contract. +``` + +> **Security note:** `initialize_borrowing_contract` does **not** replace +> borrower-level authorization inside the borrowing contract. Both contracts +> enforce their own access-control checks independently. --- diff --git a/Contract/lending/src/authorization.rs b/Contract/lending/src/authorization.rs new file mode 100644 index 0000000..cf7a022 --- /dev/null +++ b/Contract/lending/src/authorization.rs @@ -0,0 +1,32 @@ +//! Authorization helpers for the lending contract. +//! +//! Ensures that protocol-level operations (`borrow`, `repay`, `update_debt`) +//! are only callable by the borrowing contract configured for each pool. + +use soroban_sdk::{Address, BytesN, Env}; + +use shared::errors::Error; + +use crate::PoolKey; + +/// Verify that the transaction caller is the authorized borrowing contract +/// for `pool_id`. +/// +/// Returns `Ok(())` when the transacter matches the stored address. +/// Returns `Err(Error::Unauthorized)` when no borrowing contract has been +/// configured **or** the caller does not match. +pub fn require_borrowing_contract(env: &Env, pool_id: &BytesN<32>) -> Result<(), Error> { + let key = PoolKey::BorrowingContract(pool_id.clone()); + let configured: Address = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::Unauthorized)?; + + let caller = env.transacter().address(); + if caller != configured { + return Err(Error::Unauthorized); + } + + Ok(()) +} diff --git a/Contract/lending/src/lib.rs b/Contract/lending/src/lib.rs index bccb5f5..7f077e9 100644 --- a/Contract/lending/src/lib.rs +++ b/Contract/lending/src/lib.rs @@ -2,7 +2,8 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env}; use shared::errors::Error; use shared::events::{ - InterestAccrued, PoolAccountingUpdated, PoolCreated, PoolDeposit, PoolWithdrawal, + BorrowingContractInitialized, InterestAccrued, PoolAccountingUpdated, PoolCreated, + PoolDeposit, PoolWithdrawal, }; use shared::types::{ EmergencyStop, InterestParams, PoolAccounting, PoolConfig, PoolStatus, @@ -10,6 +11,9 @@ use shared::types::{ }; use shared::utils::{FixedMath, SafeMath, TimeHelper, ValidationHelper}; +mod authorization; +mod state; + /// Storage keys for lending contract #[derive(Clone)] #[contracttype] @@ -23,6 +27,7 @@ pub enum PoolKey { EmergencyStop(BytesN<32>), AdminPermissions(Address), PoolStatus(BytesN<32>), + BorrowingContract(BytesN<32>), } /// Lending contract for managing lending pools and interest. @@ -346,6 +351,9 @@ impl LendingContract { to: Address, amount: i128, ) -> Result<(), Error> { + // Only the authorized borrowing contract may call borrow + authorization::require_borrowing_contract(&env, &pool_id)?; + if !ValidationHelper::validate_positive_amount(amount) { return Err(Error::InvalidAmount); } @@ -402,7 +410,10 @@ impl LendingContract { principal_amount: i128, interest_amount: i128, ) -> Result<(), Error> { - if !ValidationHelper::validate_positive_amount(principal_amount) || + // Only the authorized borrowing contract may call repay + authorization::require_borrowing_contract(&env, &pool_id)?; + + if !ValidationHelper::validate_positive_amount(principal_amount) || !ValidationHelper::validate_positive_amount(interest_amount) { return Err(Error::InvalidAmount); } @@ -561,6 +572,9 @@ impl LendingContract { pool_id: BytesN<32>, debt_change: i128, ) -> Result<(), Error> { + // Only the authorized borrowing contract may call update_debt + authorization::require_borrowing_contract(&env, &pool_id)?; + let pool_exists_key = PoolKey::PoolExists(pool_id.clone()); if !env.storage().persistent().has(&pool_exists_key) { return Err(Error::PoolNotFound); @@ -772,6 +786,68 @@ impl LendingContract { Ok(status) } + /// Initialize the authorized borrowing contract for a pool. + /// + /// Once set, the borrowing contract address is immutable. This must be + /// called before any `borrow` or `repay` operations can be performed on + /// the pool. + pub fn initialize_borrowing_contract( + env: Env, + pool_id: BytesN<32>, + admin: Address, + borrowing_contract: Address, + ) -> Result<(), Error> { + // Verify the pool exists + let pool_exists_key = PoolKey::PoolExists(pool_id.clone()); + if !env.storage().persistent().has(&pool_exists_key) { + return Err(Error::PoolNotFound); + } + + // Verify the caller is the pool admin + let config: PoolConfig = env + .storage() + .persistent() + .get(&PoolKey::Pool(pool_id.clone())) + .ok_or(Error::PoolNotFound)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + + // Check if already initialized (immutable after init) + let key = PoolKey::BorrowingContract(pool_id.clone()); + if env.storage().persistent().has(&key) { + return Err(Error::AlreadyInitialized); + } + + // Store the borrowing contract address + env.storage().persistent().set(&key, &borrowing_contract); + + // Emit event + env.events() + .publish( + (BorrowingContractInitialized::topic(&env), pool_id.clone()), + BorrowingContractInitialized { + pool_id, + borrowing_contract, + initialized_at: TimeHelper::now(&env), + }, + ); + + Ok(()) + } + + /// Get the configured borrowing contract address for a pool. + pub fn get_borrowing_contract( + env: Env, + pool_id: BytesN<32>, + ) -> Result { + let key = PoolKey::BorrowingContract(pool_id); + env.storage() + .persistent() + .get(&key) + .ok_or(Error::Unauthorized) + } + fn storage_key(env: &Env, label: &[u8]) -> BytesN<32> { let mut bytes = [0u8; 32]; let label_len = label.len().min(32); diff --git a/Contract/lending/src/state.rs b/Contract/lending/src/state.rs new file mode 100644 index 0000000..dcec126 --- /dev/null +++ b/Contract/lending/src/state.rs @@ -0,0 +1,31 @@ +//! Lending contract state layout. +//! +//! ## Borrowing Contract Authorization +//! +//! Each lending pool stores the address of its authorized borrowing contract +//! under `PoolKey::BorrowingContract(pool_id)` in persistent storage. +//! +//! Once set via [`LendingContract::initialize_borrowing_contract`], this address +//! is **immutable** — it cannot be changed or removed without a contract upgrade. +//! +//! Only the configured borrowing contract may call: +//! - `borrow` — draw liquidity from the pool +//! - `repay` — return principal and interest +//! - `update_debt` — sync outstanding debt accounting +//! +//! All read-only endpoints (`get_pool_accounting`, `get_pool_balance`, +//! `calculate_interest`, `get_share_balance`, `get_borrowing_contract`) +//! remain public. +//! +//! ### Initialization Order +//! +//! 1. Deploy the lending contract. +//! 2. Deploy the borrowing contract. +//! 3. Create a lending pool via `LendingContract::create_pool`. +//! 4. The pool admin calls `initialize_borrowing_contract` to bind the pool +//! to the borrowing contract. This is a one-time, irreversible operation. +//! 5. The pool admin configures the lending pool address inside the borrowing +//! contract. +//! 6. Suppliers call `deposit` to add liquidity. +//! 7. Borrowers request loans through the borrowing contract, which +//! internally calls `borrow` and `repay` on the lending contract. diff --git a/Contract/lending/tests/test_lending.rs b/Contract/lending/tests/test_lending.rs index c19f4e4..238c8c8 100644 --- a/Contract/lending/tests/test_lending.rs +++ b/Contract/lending/tests/test_lending.rs @@ -1,27 +1,75 @@ -use soroban_sdk::{Address, BytesN, Env}; -use lending::LendingContract; +use lending::LendingContractClient; use shared::errors::Error; use shared::types::PoolAccounting; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, BytesN, Env}; -const WASM: &[u8] = lending::WASM; +use lending::LendingContract; -#[test] -fn test_pool_creation() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); +// --------------------------------------------------------------------------- +// Helper: set up a fresh lending contract and return the client + an admin. +// --------------------------------------------------------------------------- +struct TestEnv { + env: Env, + client: LendingContractClient<'static>, + admin: Address, + pool_id: BytesN<32>, +} - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let interest_rate = 500i128; // 5% in basis points - let reserve_factor = 1000i128; // 10% in basis points +impl TestEnv { + fn setup() -> Self { + let env = Env::default(); + let contract_id = env.register_contract(None, LendingContract); + // Leak the contract_id so the client can borrow it for 'static. + let contract_id = Box::leak(Box::new(contract_id)); + + let client = LendingContractClient::new(&env, contract_id); + let admin = Address::generate(&env); + let pool_id = BytesN::from_array(&env, &[1u8; 32]); + let asset = BytesN::from_array(&env, &[2u8; 32]); + + // 5% APR in basis points. + client.create_pool(&admin, &asset, &500i128); + + TestEnv { + env, + client, + admin, + pool_id: asset, // derive_pool_id copies the asset bytes + } + } + + /// Register a mock contract and initialize it as the pool's borrowing + /// contract. Returns the mock's address. + fn init_borrowing_contract(&self) -> Address { + let borrowing_id = self.env.register_contract_wasm(None, lending::WASM); + self.client.initialize_borrowing_contract( + &self.pool_id, + &self.admin, + &borrowing_id, + ); + borrowing_id + } + + /// Call a lending-contract method **as** the given contract address, + /// simulating a cross-contract call. + fn as_contract(&self, contract_id: &Address, f: F) -> T + where + F: FnOnce() -> T, + { + self.env.as_contract(contract_id, f) + } +} + +// =========================================================================== +// Pool creation +// =========================================================================== - // Create pool - client.create_pool(&pool_id, &asset, &admin, &interest_rate, &reserve_factor); +#[test] +fn test_pool_creation() { + let t = TestEnv::setup(); - // Verify pool exists by checking accounting - let accounting = client.get_pool_accounting(&pool_id); + let accounting = t.client.get_pool_accounting(&t.pool_id); assert_eq!(accounting.total_assets, 0); assert_eq!(accounting.total_shares, 0); assert_eq!(accounting.available_liquidity, 0); @@ -30,21 +78,11 @@ fn test_pool_creation() { #[test] fn test_pool_already_exists() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); + let t = TestEnv::setup(); - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let interest_rate = 500i128; - let reserve_factor = 1000i128; - - // Create pool first time - client.create_pool(&pool_id, &asset, &admin, &interest_rate, &reserve_factor); - - // Try to create again - should fail - let result = client.try_create_pool(&pool_id, &asset, &admin, &interest_rate, &reserve_factor); + let result = t + .client + .try_create_pool(&t.pool_id, &[2u8; 32].into(), &t.admin, &500i128); assert_eq!(result, Err(Ok(Error::PoolAlreadyExists))); } @@ -53,283 +91,195 @@ fn test_invalid_interest_rate() { let env = Env::default(); let contract_id = env.register_contract(None, LendingContract); let client = LendingContractClient::new(&env, &contract_id); - - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); let admin = Address::generate(&env); + let asset = BytesN::from_array(&env, &[2u8; 32]); - // Test negative interest rate - let result = client.try_create_pool(&pool_id, &asset, &admin, &-1i128, &1000i128); + let result = client.try_create_pool(&asset, &asset, &admin, &-1i128); assert_eq!(result, Err(Ok(Error::InvalidInterestRate))); - // Test interest rate > 100% - let result = client.try_create_pool(&pool_id, &asset, &admin, &10001i128, &1000i128); + let result = client.try_create_pool(&asset, &asset, &admin, &10001i128); assert_eq!(result, Err(Ok(Error::InvalidInterestRate))); } +// =========================================================================== +// Deposits & withdrawals +// =========================================================================== + #[test] fn test_single_deposit() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); - - // Create pool - client.create_pool(&pool_id, &asset, &admin, &500i128, &1000i128); + t.client.deposit(&t.pool_id, &lender, &1000i128); - // Deposit - let deposit_amount = 1000i128; - client.deposit(&pool_id, &lender, &deposit_amount); - - // Verify accounting - let accounting = client.get_pool_accounting(&pool_id); + let accounting = t.client.get_pool_accounting(&t.pool_id); assert_eq!(accounting.total_assets, 1000); - assert_eq!(accounting.total_shares, 1000); // First deposit: 1:1 ratio + assert_eq!(accounting.total_shares, 1000); assert_eq!(accounting.available_liquidity, 1000); - // Verify user shares - let share_balance = client.get_share_balance(&pool_id, &lender); - assert_eq!(share_balance.shares, 1000); + let shares = t.client.get_share_balance(&t.pool_id, &lender); + assert_eq!(shares.shares, 1000); } #[test] fn test_multiple_suppliers() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); - - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender1 = Address::generate(&env); - let lender2 = Address::generate(&env); - - // Create pool - client.create_pool(&pool_id, &asset, &admin, &500i128, &1000i128); + let t = TestEnv::setup(); + let lender1 = Address::generate(&t.env); + let lender2 = Address::generate(&t.env); - // First lender deposits - client.deposit(&pool_id, &lender1, &1000i128); + t.client.deposit(&t.pool_id, &lender1, &1000i128); + t.client.deposit(&t.pool_id, &lender2, &500i128); - // Second lender deposits - client.deposit(&pool_id, &lender2, &500i128); - - // Verify accounting - let accounting = client.get_pool_accounting(&pool_id); + let accounting = t.client.get_pool_accounting(&t.pool_id); assert_eq!(accounting.total_assets, 1500); assert_eq!(accounting.total_shares, 1500); assert_eq!(accounting.available_liquidity, 1500); - // Verify lender2 got proportional shares (500/1500 * 1500 = 500) - let share_balance = client.get_share_balance(&pool_id, &lender2); - assert_eq!(share_balance.shares, 500); + let shares = t.client.get_share_balance(&t.pool_id, &lender2); + assert_eq!(shares.shares, 500); } #[test] fn test_withdrawal() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); - - // Create pool and deposit - client.create_pool(&pool_id, &asset, &admin, &500i128, &1000i128); - client.deposit(&pool_id, &lender, &1000i128); + t.client.deposit(&t.pool_id, &lender, &1000i128); + t.client.withdraw(&t.pool_id, &lender, &500i128); - // Withdraw half of shares - client.withdraw(&pool_id, &lender, &500i128); - - // Verify accounting - let accounting = client.get_pool_accounting(&pool_id); + let accounting = t.client.get_pool_accounting(&t.pool_id); assert_eq!(accounting.total_assets, 500); assert_eq!(accounting.total_shares, 500); assert_eq!(accounting.available_liquidity, 500); - // Verify user shares - let share_balance = client.get_share_balance(&pool_id, &lender); - assert_eq!(share_balance.shares, 500); + let shares = t.client.get_share_balance(&t.pool_id, &lender); + assert_eq!(shares.shares, 500); } #[test] fn test_partial_redemption() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); + t.client.deposit(&t.pool_id, &lender, &1000i128); + t.client.withdraw(&t.pool_id, &lender, &250i128); - // Create pool and deposit - client.create_pool(&pool_id, &asset, &admin, &500i128, &1000i128); - client.deposit(&pool_id, &lender, &1000i128); - - // Withdraw 25% of shares - client.withdraw(&pool_id, &lender, &250i128); - - // Verify accounting - let accounting = client.get_pool_accounting(&pool_id); + let accounting = t.client.get_pool_accounting(&t.pool_id); assert_eq!(accounting.total_assets, 750); assert_eq!(accounting.total_shares, 750); } #[test] fn test_withdraw_insufficient_shares() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); + t.client.deposit(&t.pool_id, &lender, &1000i128); - // Create pool and deposit - client.create_pool(&pool_id, &asset, &admin, &500i128, &1000i128); - client.deposit(&pool_id, &lender, &1000i128); - - // Try to withdraw more than owned - let result = client.try_withdraw(&pool_id, &lender, &1500i128); + let result = t.client.try_withdraw(&t.pool_id, &lender, &1500i128); assert_eq!(result, Err(Ok(Error::InsufficientShares))); } #[test] fn test_withdraw_insufficient_liquidity() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); + let borrowing_id = t.init_borrowing_contract(); - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); + t.client.deposit(&t.pool_id, &lender, &1000i128); - // Create pool and deposit - client.create_pool(&pool_id, &asset, &admin, &500i128, &1000i128); - client.deposit(&pool_id, &lender, &1000i128); + // Simulate borrowing via the authorized borrowing contract + t.as_contract(&borrowing_id, || { + t.client.borrow(&t.pool_id, &Address::generate(&t.env), &800i128); + }); - // Simulate borrowing by reducing liquidity - client.update_debt(&pool_id, &800i128); + let accounting = t.client.get_pool_accounting(&t.pool_id); + assert_eq!(accounting.available_liquidity, 200); - // Try to withdraw more than available liquidity - let result = client.try_withdraw(&pool_id, &lender, &500i128); + // 500 shares redeem to 500 assets, exceeding the 200 available. + let result = t.client.try_withdraw(&t.pool_id, &lender, &500i128); assert_eq!(result, Err(Ok(Error::InsufficientLiquidity))); } #[test] fn test_zero_liquidity_withdrawal() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); + let borrowing_id = t.init_borrowing_contract(); - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); - - // Create pool and deposit - client.create_pool(&pool_id, &asset, &admin, &500i128, &1000i128); - client.deposit(&pool_id, &lender, &1000i128); + t.client.deposit(&t.pool_id, &lender, &1000i128); - // Borrow all liquidity - client.update_debt(&pool_id, &1000i128); + // Borrow all liquidity via the authorized borrowing contract + t.as_contract(&borrowing_id, || { + t.client.borrow(&t.pool_id, &Address::generate(&t.env), &1000i128); + }); - // Try to withdraw - should fail due to insufficient liquidity - let result = client.try_withdraw(&pool_id, &lender, &100i128); + let result = t.client.try_withdraw(&t.pool_id, &lender, &100i128); assert_eq!(result, Err(Ok(Error::InsufficientLiquidity))); } +// =========================================================================== +// Interest accrual +// =========================================================================== + #[test] fn test_interest_accrual() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); - - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); + let borrowing_id = t.init_borrowing_contract(); - // Create pool with 10% interest rate - client.create_pool(&pool_id, &asset, &admin, &1000i128, &1000i128); - - // Deposit - client.deposit(&pool_id, &lender, &1000i128); + t.client.deposit(&t.pool_id, &lender, &1000i128); // Simulate borrowing - client.update_debt(&pool_id, &500i128); + t.as_contract(&borrowing_id, || { + t.client.borrow(&t.pool_id, &Address::generate(&t.env), &500i128); + }); - // Jump forward in time (1 day = 86400 seconds) - env.ledger().set(86400, 1, 1); + // Jump forward 1 day + t.env.ledger().set(86400, 1, 1); - // Trigger interest accrual by making a deposit - client.deposit(&pool_id, &lender, &100i128); + // Trigger accrual via deposit + t.client.deposit(&t.pool_id, &lender, &100i128); - // Verify interest was accrued - let accounting = client.get_pool_accounting(&pool_id); + let accounting = t.client.get_pool_accounting(&t.pool_id); assert!(accounting.accrued_interest > 0); - assert!(accounting.interest_index > 1_000_000_000_000_000_000); // Should be > 1.0 + assert!(accounting.interest_index > 1_000_000_000_000_000_000); } #[test] fn test_long_time_jump() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); + let borrowing_id = t.init_borrowing_contract(); - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); - - // Create pool - client.create_pool(&pool_id, &asset, &admin, &1000i128, &1000i128); + t.client.deposit(&t.pool_id, &lender, &1000i128); - // Deposit - client.deposit(&pool_id, &lender, &1000i128); - - // Borrow - client.update_debt(&pool_id, &500i128); + t.as_contract(&borrowing_id, || { + t.client.borrow(&t.pool_id, &Address::generate(&t.env), &500i128); + }); // Jump forward 1 year - env.ledger().set(31_536_000, 1, 1); + t.env.ledger().set(31_536_000, 1, 1); - // Trigger interest accrual - client.deposit(&pool_id, &lender, &100i128); + t.client.deposit(&t.pool_id, &lender, &100i128); - // Verify significant interest accrued - let accounting = client.get_pool_accounting(&pool_id); + let accounting = t.client.get_pool_accounting(&t.pool_id); assert!(accounting.accrued_interest > 0); } +// =========================================================================== +// Input validation +// =========================================================================== + #[test] fn test_arithmetic_limits() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); - - // Create pool - client.create_pool(&pool_id, &asset, &admin, &500i128, &1000i128); - - // Try deposit with negative amount - let result = client.try_deposit(&pool_id, &lender, &-100i128); + let result = t.client.try_deposit(&t.pool_id, &lender, &-100i128); assert_eq!(result, Err(Ok(Error::InvalidAmount))); - // Try deposit with zero amount - let result = client.try_deposit(&pool_id, &lender, &0i128); + let result = t.client.try_deposit(&t.pool_id, &lender, &0i128); assert_eq!(result, Err(Ok(Error::InvalidAmount))); } @@ -339,168 +289,69 @@ fn test_pool_not_found() { let contract_id = env.register_contract(None, LendingContract); let client = LendingContractClient::new(&env, &contract_id); - let pool_id = BytesN::from_array(&[1u8; 32]); + let pool_id = BytesN::from_array(&env, &[1u8; 32]); let lender = Address::generate(&env); - // Try to deposit to non-existent pool let result = client.try_deposit(&pool_id, &lender, &1000i128); assert_eq!(result, Err(Ok(Error::PoolNotFound))); - // Try to get balance of non-existent pool let result = client.try_get_pool_balance(&pool_id); assert_eq!(result, Err(Ok(Error::PoolNotFound))); } +// =========================================================================== +// Debt tracking +// =========================================================================== + #[test] fn test_debt_tracking() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); - - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); + let borrowing_id = t.init_borrowing_contract(); - // Create pool and deposit - client.create_pool(&pool_id, &asset, &admin, &500i128, &1000i128); - client.deposit(&pool_id, &lender, &1000i128); + t.client.deposit(&t.pool_id, &lender, &1000i128); // Borrow - client.update_debt(&pool_id, &300i128); + t.as_contract(&borrowing_id, || { + t.client.update_debt(&t.pool_id, &300i128); + }); - let accounting = client.get_pool_accounting(&pool_id); + let accounting = t.client.get_pool_accounting(&t.pool_id); assert_eq!(accounting.outstanding_debt, 300); assert_eq!(accounting.available_liquidity, 700); // Repay - client.update_debt(&pool_id, &-100i128); + t.as_contract(&borrowing_id, || { + t.client.update_debt(&t.pool_id, &-100i128); + }); - let accounting = client.get_pool_accounting(&pool_id); + let accounting = t.client.get_pool_accounting(&t.pool_id); assert_eq!(accounting.outstanding_debt, 200); assert_eq!(accounting.available_liquidity, 800); } +// =========================================================================== +// User interest calculation +// =========================================================================== + #[test] fn test_user_interest_calculation() { - let env = Env::default(); - let contract_id = env.register_contract(None, LendingContract); - let client = LendingContractClient::new(&env, &contract_id); - - let pool_id = BytesN::from_array(&[1u8; 32]); - let asset = BytesN::from_array(&[2u8; 32]); - let admin = Address::generate(&env); - let lender = Address::generate(&env); + let t = TestEnv::setup(); + let lender = Address::generate(&t.env); + let borrowing_id = t.init_borrowing_contract(); - // Create pool - client.create_pool(&pool_id, &asset, &admin, &1000i128, &1000i128); + t.client.deposit(&t.pool_id, &lender, &1000i128); - // Deposit - client.deposit(&pool_id, &lender, &1000i128); + t.as_contract(&borrowing_id, || { + t.client.borrow(&t.pool_id, &Address::generate(&t.env), &500i128); + }); - // Borrow to generate interest - client.update_debt(&pool_id, &500i128); - - // Jump forward - env.ledger().set(86400, 1, 1); + // Jump forward 1 day + t.env.ledger().set(86400, 1, 1); // Trigger accrual - client.deposit(&pool_id, &lender, &100i128); + t.client.deposit(&t.pool_id, &lender, &100i128); - // Calculate user interest - let interest = client.calculate_interest(&pool_id, &lender); + let interest = t.client.calculate_interest(&t.pool_id, &lender); assert!(interest >= 0); } - -// Helper client wrapper -struct LendingContractClient<'a> { - env: &'a Env, - contract_id: &'a soroban_sdk::Address, -} - -impl<'a> LendingContractClient<'a> { - fn new(env: &'a Env, contract_id: &'a soroban_sdk::Address) -> Self { - Self { env, contract_id } - } - - fn create_pool( - &self, - pool_id: &BytesN<32>, - asset: &BytesN<32>, - admin: &Address, - interest_rate: &i128, - reserve_factor: &i128, - ) { - LendingContract::create_pool( - self.env.clone(), - pool_id.clone(), - asset.clone(), - admin.clone(), - *interest_rate, - *reserve_factor, - ) - .unwrap(); - } - - fn try_create_pool( - &self, - pool_id: &BytesN<32>, - asset: &BytesN<32>, - admin: &Address, - interest_rate: &i128, - reserve_factor: &i128, - ) -> Result<(), Error> { - LendingContract::create_pool( - self.env.clone(), - pool_id.clone(), - asset.clone(), - admin.clone(), - *interest_rate, - *reserve_factor, - ) - } - - fn deposit(&self, pool_id: &BytesN<32>, from: &Address, amount: &i128) { - LendingContract::deposit(self.env.clone(), pool_id.clone(), from.clone(), *amount).unwrap(); - } - - fn try_deposit(&self, pool_id: &BytesN<32>, from: &Address, amount: &i128) -> Result<(), Error> { - LendingContract::deposit(self.env.clone(), pool_id.clone(), from.clone(), *amount) - } - - fn withdraw(&self, pool_id: &BytesN<32>, to: &Address, shares: &i128) { - LendingContract::withdraw(self.env.clone(), pool_id.clone(), to.clone(), *shares).unwrap(); - } - - fn try_withdraw(&self, pool_id: &BytesN<32>, to: &Address, shares: &i128) -> Result<(), Error> { - LendingContract::withdraw(self.env.clone(), pool_id.clone(), to.clone(), *shares) - } - - fn get_pool_balance(&self, pool_id: &BytesN<32>) -> i128 { - LendingContract::get_pool_balance(self.env.clone(), pool_id.clone()).unwrap() - } - - fn try_get_pool_balance(&self, pool_id: &BytesN<32>) -> Result { - LendingContract::get_pool_balance(self.env.clone(), pool_id.clone()) - } - - fn calculate_interest(&self, pool_id: &BytesN<32>, lender: &Address) -> i128 { - LendingContract::calculate_interest(self.env.clone(), lender.clone(), pool_id.clone()).unwrap() - } - - fn get_pool_accounting(&self, pool_id: &BytesN<32>) -> PoolAccounting { - LendingContract::get_pool_accounting(self.env.clone(), pool_id.clone()).unwrap() - } - - fn get_share_balance(&self, pool_id: &BytesN<32>, lender: &Address) -> shared::types::ShareBalance { - LendingContract::get_share_balance(self.env.clone(), pool_id.clone(), lender.clone()).unwrap() - } - - fn update_debt(&self, pool_id: &BytesN<32>, debt_change: &i128) { - LendingContract::update_debt(self.env.clone(), pool_id.clone(), *debt_change).unwrap(); - } - let _contract_id = env.register_contract_wasm(None, lending::WASM); - - // Placeholder test to ensure crate compiles - // TODO: Add actual tests when lending logic is implemented -} diff --git a/Contract/lending/tests/test_lending_borrow_authorization.rs b/Contract/lending/tests/test_lending_borrow_authorization.rs new file mode 100644 index 0000000..81c2716 --- /dev/null +++ b/Contract/lending/tests/test_lending_borrow_authorization.rs @@ -0,0 +1,310 @@ +//! Tests for borrowing-contract authorization on the lending pool. +//! +//! Verifies that only the configured borrowing contract may call `borrow`, +//! `repay`, and `update_debt`, while read-only endpoints remain public. + +use lending::{LendingContract, LendingContractClient}; +use shared::errors::Error; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, BytesN, Env}; + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +struct AuthFixture<'a> { + env: &'a Env, + client: LendingContractClient<'a>, + pool_id: BytesN<32>, + admin: Address, + lender: Address, + borrower: Address, +} + +fn setup<'a>(env: &'a Env) -> AuthFixture<'a> { + let contract_id = env.register_contract(None, LendingContract); + let client = LendingContractClient::new(env, &contract_id); + + let admin = Address::generate(env); + let asset = BytesN::from_array(env, &[2u8; 32]); + let lender = Address::generate(env); + let borrower = Address::generate(env); + + // Create pool with 5% APR. + client.create_pool(&admin, &asset, &500i128); + + AuthFixture { + env, + client, + pool_id: asset, + admin, + lender, + borrower, + } +} + +/// Register a mock contract and return its address (used as the borrowing +/// contract). +fn mock_borrowing_contract(env: &Env) -> Address { + env.register_contract_wasm(None, lending::WASM) +} + +// =========================================================================== +// initialize_borrowing_contract +// =========================================================================== + +#[test] +fn initialize_succeeds_for_pool_admin() { + let env = Env::default(); + let f = setup(&env); + let borrowing_addr = mock_borrowing_contract(&env); + + f.client + .initialize_borrowing_contract(&f.pool_id, &f.admin, &borrowing_addr); + + let stored = f.client.get_borrowing_contract(&f.pool_id); + assert_eq!(stored, borrowing_addr); +} + +#[test] +fn initialize_fails_for_non_admin() { + let env = Env::default(); + let f = setup(&env); + let not_admin = Address::generate(&env); + let borrowing_addr = mock_borrowing_contract(&env); + + let result = f.client.try_initialize_borrowing_contract( + &f.pool_id, + ¬_admin, + &borrowing_addr, + ); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn initialize_fails_for_nonexistent_pool() { + let env = Env::default(); + let f = setup(&env); + let fake_pool = BytesN::from_array(&env, &[99u8; 32]); + let borrowing_addr = mock_borrowing_contract(&env); + + let result = f.client.try_initialize_borrowing_contract( + &fake_pool, + &f.admin, + &borrowing_addr, + ); + assert_eq!(result, Err(Ok(Error::PoolNotFound))); +} + +#[test] +fn initialize_immutable_after_first_call() { + let env = Env::default(); + let f = setup(&env); + let addr1 = mock_borrowing_contract(&env); + let addr2 = mock_borrowing_contract(&env); + + f.client + .initialize_borrowing_contract(&f.pool_id, &f.admin, &addr1); + + // Second initialization must be rejected. + let result = f.client.try_initialize_borrowing_contract( + &f.pool_id, + &f.admin, + &addr2, + ); + assert_eq!(result, Err(Ok(Error::AlreadyInitialized))); + + // The original address must be preserved. + let stored = f.client.get_borrowing_contract(&f.pool_id); + assert_eq!(stored, addr1); +} + +// =========================================================================== +// Borrow authorization +// =========================================================================== + +#[test] +fn authorized_borrowing_contract_can_borrow() { + let env = Env::default(); + let f = setup(&env); + let borrowing_addr = mock_borrowing_contract(&env); + + f.client + .initialize_borrowing_contract(&f.pool_id, &f.admin, &borrowing_addr); + + // Seed the pool with liquidity. + f.client.deposit(&f.pool_id, &f.lender, &10_000i128); + + // Borrow as the authorized contract. + env.as_contract(&borrowing_addr, || { + f.client.borrow(&f.pool_id, &f.borrower, &3_000i128); + }); + + let accounting = f.client.get_pool_accounting(&f.pool_id); + assert_eq!(accounting.outstanding_debt, 3_000); + assert_eq!(accounting.available_liquidity, 7_000); +} + +#[test] +fn direct_user_borrow_rejected() { + let env = Env::default(); + let f = setup(&env); + let borrowing_addr = mock_borrowing_contract(&env); + + f.client + .initialize_borrowing_contract(&f.pool_id, &f.admin, &borrowing_addr); + f.client.deposit(&f.pool_id, &f.lender, &10_000i128); + + // A direct call (not through the borrowing contract) must fail. + let result = f.client.try_borrow(&f.pool_id, &f.borrower, &1_000i128); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn borrow_rejected_when_no_contract_configured() { + let env = Env::default(); + let f = setup(&env); + + f.client.deposit(&f.pool_id, &f.lender, &10_000i128); + + // No borrowing contract initialized — must be rejected. + let result = f.client.try_borrow(&f.pool_id, &f.borrower, &1_000i128); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn borrow_rejected_for_wrong_contract() { + let env = Env::default(); + let f = setup(&env); + let correct_addr = mock_borrowing_contract(&env); + let wrong_addr = mock_borrowing_contract(&env); + + f.client + .initialize_borrowing_contract(&f.pool_id, &f.admin, &correct_addr); + f.client.deposit(&f.pool_id, &f.lender, &10_000i128); + + // Calling from the wrong contract must fail. + env.as_contract(&wrong_addr, || { + let result = f.client.try_borrow(&f.pool_id, &f.borrower, &1_000i128); + assert_eq!(result, Err(Ok(Error::Unauthorized))); + }); +} + +// =========================================================================== +// Repay authorization +// =========================================================================== + +#[test] +fn authorized_borrowing_contract_can_repay() { + let env = Env::default(); + let f = setup(&env); + let borrowing_addr = mock_borrowing_contract(&env); + + f.client + .initialize_borrowing_contract(&f.pool_id, &f.admin, &borrowing_addr); + f.client.deposit(&f.pool_id, &f.lender, &10_000i128); + + // Borrow first. + env.as_contract(&borrowing_addr, || { + f.client.borrow(&f.pool_id, &f.borrower, &5_000i128); + }); + + // Repay as the authorized contract. + env.as_contract(&borrowing_addr, || { + f.client.repay(&f.pool_id, &f.borrower, &2_000i128, &100i128); + }); + + let accounting = f.client.get_pool_accounting(&f.pool_id); + assert_eq!(accounting.outstanding_debt, 3_000); + assert_eq!(accounting.accrued_interest, 100); +} + +#[test] +fn direct_user_repay_rejected() { + let env = Env::default(); + let f = setup(&env); + let borrowing_addr = mock_borrowing_contract(&env); + + f.client + .initialize_borrowing_contract(&f.pool_id, &f.admin, &borrowing_addr); + f.client.deposit(&f.pool_id, &f.lender, &10_000i128); + + env.as_contract(&borrowing_addr, || { + f.client.borrow(&f.pool_id, &f.borrower, &5_000i128); + }); + + // Direct repay must fail. + let result = f.client.try_repay(&f.pool_id, &f.borrower, &1_000i128, &50i128); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +// =========================================================================== +// update_debt authorization +// =========================================================================== + +#[test] +fn authorized_borrowing_contract_can_update_debt() { + let env = Env::default(); + let f = setup(&env); + let borrowing_addr = mock_borrowing_contract(&env); + + f.client + .initialize_borrowing_contract(&f.pool_id, &f.admin, &borrowing_addr); + f.client.deposit(&f.pool_id, &f.lender, &10_000i128); + + env.as_contract(&borrowing_addr, || { + f.client.update_debt(&f.pool_id, &4_000i128); + }); + + let accounting = f.client.get_pool_accounting(&f.pool_id); + assert_eq!(accounting.outstanding_debt, 4_000); + assert_eq!(accounting.available_liquidity, 6_000); +} + +#[test] +fn direct_user_update_debt_rejected() { + let env = Env::default(); + let f = setup(&env); + let borrowing_addr = mock_borrowing_contract(&env); + + f.client + .initialize_borrowing_contract(&f.pool_id, &f.admin, &borrowing_addr); + f.client.deposit(&f.pool_id, &f.lender, &10_000i128); + + let result = f.client.try_update_debt(&f.pool_id, &1_000i128); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +// =========================================================================== +// Read-only endpoints remain public +// =========================================================================== + +#[test] +fn public_read_endpoints_unaffected() { + let env = Env::default(); + let f = setup(&env); + let borrowing_addr = mock_borrowing_contract(&env); + + f.client + .initialize_borrowing_contract(&f.pool_id, &f.admin, &borrowing_addr); + f.client.deposit(&f.pool_id, &f.lender, &5_000i128); + + // All read-only calls succeed without authorization. + let accounting = f.client.get_pool_accounting(&f.pool_id); + assert_eq!(accounting.total_assets, 5_000); + + let balance = f.client.get_pool_balance(&f.pool_id); + assert_eq!(balance, 5_000); + + let shares = f.client.get_share_balance(&f.pool_id, &f.lender); + assert_eq!(shares.shares, 5_000); + + let rate = f.client.get_interest_rate(&f.pool_id); + assert_eq!(rate, 500); + + let stored = f.client.get_borrowing_contract(&f.pool_id); + assert_eq!(stored, borrowing_addr); + + let status = f.client.get_pool_status(&f.pool_id); + assert_eq!(status, shared::types::PoolStatus::Active); +} diff --git a/Contract/lending/tests/test_lending_liquidity.rs b/Contract/lending/tests/test_lending_liquidity.rs index cd36332..0ebc4fa 100644 --- a/Contract/lending/tests/test_lending_liquidity.rs +++ b/Contract/lending/tests/test_lending_liquidity.rs @@ -8,12 +8,16 @@ use soroban_sdk::{Address, BytesN, Env}; /// Common fixture: a freshly created pool plus the addresses used by the tests. struct PoolFixture<'a> { client: LendingContractClient<'a>, + env: &'a Env, pool_id: BytesN<32>, lender: Address, borrower: Address, + /// The address registered as the pool's authorized borrowing contract. + borrowing_contract: Address, } -/// Register the lending contract and create a single pool. +/// Register the lending contract and create a single pool with an authorized +/// borrowing contract already configured. /// /// `derive_pool_id` copies the asset bytes verbatim, so the pool id is the /// asset id. @@ -29,14 +33,28 @@ fn setup(env: &Env) -> PoolFixture<'_> { // 5% APR in basis points. client.create_pool(&admin, &asset, &500i128); + // Register a mock contract and authorize it as the borrowing contract. + let borrowing_contract = env.register_contract_wasm(None, lending::WASM); + client.initialize_borrowing_contract(&asset, &admin, &borrowing_contract); + PoolFixture { client, + env, pool_id: asset, lender, borrower, + borrowing_contract, } } +/// Helper: call a lending method as the authorized borrowing contract. +fn as_borrowing(env: &Env, borrowing_contract: &Address, f: F) +where + F: FnOnce(), +{ + env.as_contract(borrowing_contract, f); +} + /// A withdrawal that the lender has the shares for must still be rejected when /// the pool has lent those assets out. #[test] @@ -46,8 +64,11 @@ fn withdraw_blocked_when_insufficient_liquidity() { // Lender supplies 1000; first deposit mints shares 1:1. f.client.deposit(&f.pool_id, &f.lender, &1000i128); - // Borrower draws 800, leaving 200 of available liquidity. - f.client.borrow(&f.pool_id, &f.borrower, &800i128); + // Borrower draws 800 through the authorized borrowing contract, leaving 200 + // of available liquidity. + as_borrowing(&env, &f.borrowing_contract, || { + f.client.borrow(&f.pool_id, &f.borrower, &800i128); + }); let before = f.client.get_pool_accounting(&f.pool_id); assert_eq!(before.available_liquidity, 200); @@ -77,7 +98,9 @@ fn withdraw_succeeds_when_liquidity_sufficient() { let f = setup(&env); f.client.deposit(&f.pool_id, &f.lender, &1000i128); - f.client.borrow(&f.pool_id, &f.borrower, &800i128); + as_borrowing(&env, &f.borrowing_contract, || { + f.client.borrow(&f.pool_id, &f.borrower, &800i128); + }); // 150 shares redeem to 150 assets, within the 200 available. f.client.withdraw(&f.pool_id, &f.lender, &150i128); diff --git a/Contract/shared/src/events.rs b/Contract/shared/src/events.rs index aed1cbc..6020957 100644 --- a/Contract/shared/src/events.rs +++ b/Contract/shared/src/events.rs @@ -349,6 +349,25 @@ impl PoolAccountingUpdated { ) } } + +/// Event emitted when a borrowing contract is initialized for a lending pool +#[contracttype] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BorrowingContractInitialized { + pub pool_id: BytesN<32>, + pub borrowing_contract: Address, + pub initialized_at: u64, +} + +impl BorrowingContractInitialized { + pub fn topic(env: &Env) -> (BytesN<32>, BytesN<32>) { + ( + BytesN::from_array(env, &[50u8; 32]), + BytesN::from_array(env, &[51u8; 32]), + ) + } +} + /// Event emitted when the rewards pool is funded #[derive(Debug, Clone, PartialEq, Eq)] pub struct RewardsPoolFunded {