diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 1fbcc0a..3d750d4 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -59,6 +59,14 @@ Update this file after every completed contract change, fix, or architectural de - `test_repay_installment_zero_amount_rejected`: asserts `InvalidRepaymentAmount` (#13) for zero payment - Total tests: 98 (93 existing + 5 new) — all passing +### Issue #6 — Typed Storage Errors +- Removed all `.expect(...)` and bare `.unwrap()` matches from `contracts/*/src/storage.rs` +- Converted storage getters/readers to typed `Result` paths while preserving intentional zero/false/default semantics +- Added TTL extension after persistent writes for creditline user indexes/active debt, liquidity-pool LP shares, and vendor-registry vendor/count records +- Added missing `NotInitialized` variants to creditline, parameters, and reputation errors without renumbering existing variants +- Added before-initialize regression coverage across all 5 active contracts using generated `try_*` clients +- Verified with `cargo check --offline`, `cargo build --offline`, `cargo test --offline`, and `cargo clippy --offline -- -D warnings` — 230 passed, 0 failed, 4 ignored + --- ## In Progress diff --git a/contracts/creditline-contract/src/access.rs b/contracts/creditline-contract/src/access.rs index c967efd..29222e0 100644 --- a/contracts/creditline-contract/src/access.rs +++ b/contracts/creditline-contract/src/access.rs @@ -5,7 +5,7 @@ use crate::storage; /// Require that the given address is the admin, otherwise panic with NotAdmin error pub fn require_admin(env: &Env, caller: &Address) { - let admin = storage::get_admin(env); + let admin = storage::get_admin(env).unwrap_or_else(|err| panic_with_error!(env, err)); if caller != &admin { panic_with_error!(env, CreditLineError::NotAdmin); diff --git a/contracts/creditline-contract/src/errors.rs b/contracts/creditline-contract/src/errors.rs index aea0157..a639875 100644 --- a/contracts/creditline-contract/src/errors.rs +++ b/contracts/creditline-contract/src/errors.rs @@ -30,4 +30,5 @@ pub enum CreditLineError { InvalidInstallmentIndex = 23, InstallmentAlreadyPaid = 24, InvalidLoanStatus = 25, + NotInitialized = 26, } diff --git a/contracts/creditline-contract/src/lib.rs b/contracts/creditline-contract/src/lib.rs index fa75973..c4dcaf2 100644 --- a/contracts/creditline-contract/src/lib.rs +++ b/contracts/creditline-contract/src/lib.rs @@ -25,7 +25,10 @@ mod storage; mod types; pub use errors::CreditLineError; -pub use types::{default_protocol_parameters, Loan, LoanStatus, LoanType, ProtocolParameters, RepaymentInstallment}; +pub use types::{ + default_protocol_parameters, Loan, LoanStatus, LoanType, ProtocolParameters, + RepaymentInstallment, +}; #[contract] pub struct CreditLineContract; @@ -88,7 +91,8 @@ impl CreditLineContract { ); loan.funded_at = env.ledger().timestamp(); - storage::increase_user_active_debt(&env, &user, loan.remaining_balance); + storage::increase_user_active_debt(&env, &user, loan.remaining_balance) + .unwrap_or_else(|err| panic_with_error!(&env, err)); let loan_id = loan.loan_id; storage::write_loan(&env, &loan); @@ -137,6 +141,7 @@ impl CreditLineContract { ); let token_address = storage::get_token(&env) + .unwrap_or_else(|err| panic_with_error!(&env, err)) .unwrap_or_else(|| panic_with_error!(&env, CreditLineError::TokenNotConfigured)); let token_client = token::Client::new(&env, &token_address); token_client.transfer(&user, &env.current_contract_address(), &guarantee_amount); @@ -159,37 +164,38 @@ impl CreditLineContract { pub fn get_user_loans(env: Env, borrower: Address, start: u64, limit: u32) -> Vec { storage::get_user_loans_paginated(&env, &borrower, start, limit) + .unwrap_or_else(|err| panic_with_error!(&env, err)) } pub fn get_user_loan_count(env: Env, borrower: Address) -> u64 { storage::get_user_loan_count(&env, &borrower) + .unwrap_or_else(|err| panic_with_error!(&env, err)) } pub fn get_user_active_debt(env: Env, borrower: Address) -> i128 { storage::get_user_active_debt(&env, &borrower) + .unwrap_or_else(|err| panic_with_error!(&env, err)) } pub fn get_loan(env: Env, loan_id: u64) -> Loan { - storage::read_loan(&env, loan_id) - .unwrap_or_else(|| panic_with_error!(&env, CreditLineError::LoanNotFound)) + storage::read_loan(&env, loan_id).unwrap_or_else(|err| panic_with_error!(&env, err)) } pub fn set_admin(env: Env, new_admin: Address) { - let old_admin = storage::get_admin(&env); + let old_admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); old_admin.require_auth(); access::require_admin(&env, &old_admin); storage::set_admin(&env, &new_admin); } - /// Upgrade the contract WASM — admin only pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) { - let admin = storage::get_admin(&env); + let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); admin.require_auth(); env.deployer().update_current_contract_wasm(new_wasm_hash); } - pub fn get_admin(env: Env) -> Address { + pub fn get_admin(env: Env) -> Result { storage::get_admin(&env) } @@ -228,7 +234,7 @@ impl CreditLineContract { let params = Self::get_protocol_parameters(env); let min_guarantee = total_amount - .checked_mul(params.min_guarantee_percent as i128) + .checked_mul(params.min_guarantee_percent) .and_then(|v| v.checked_div(100)) .unwrap_or_else(|| panic_with_error!(env, CreditLineError::Overflow)); @@ -239,6 +245,7 @@ impl CreditLineContract { fn validate_vendor(env: &Env, vendor: &Address) { let vendor_registry = storage::get_vendor_registry(env) + .unwrap_or_else(|err| panic_with_error!(env, err)) .unwrap_or_else(|| panic_with_error!(env, CreditLineError::InvalidVendor)); let registry_client = VendorRegistryContractClient::new(env, &vendor_registry); @@ -258,7 +265,8 @@ impl CreditLineContract { fn validate_reputation(env: &Env, user: &Address) -> u32 { let reputation_contract = storage::get_reputation_contract(env) - .unwrap_or_else(|| panic!("Reputation contract not configured")); + .unwrap_or_else(|err| panic_with_error!(env, err)) + .unwrap_or_else(|| panic_with_error!(env, CreditLineError::ParametersUnavailable)); let score: u32 = env.invoke_contract( &reputation_contract, @@ -276,6 +284,7 @@ impl CreditLineContract { fn validate_liquidity(env: &Env, total_amount: i128, guarantee_amount: i128) { let liquidity_pool = storage::get_liquidity_pool(env) + .unwrap_or_else(|err| panic_with_error!(env, err)) .unwrap_or_else(|| panic_with_error!(env, CreditLineError::InsufficientLiquidity)); let required_from_pool = total_amount @@ -302,9 +311,11 @@ impl CreditLineContract { pool_contribution: i128, ) { let liquidity_pool = storage::get_liquidity_pool(env) + .unwrap_or_else(|err| panic_with_error!(env, err)) .unwrap_or_else(|| panic_with_error!(env, CreditLineError::InsufficientLiquidity)); let token_address = storage::get_token(env) + .unwrap_or_else(|err| panic_with_error!(env, err)) .unwrap_or_else(|| panic_with_error!(env, CreditLineError::TokenNotConfigured)); let token_client = token::Client::new(env, &token_address); @@ -312,14 +323,11 @@ impl CreditLineContract { if pool_contribution > 0 { let lp_client = LiquidityPoolContractClient::new(env, &liquidity_pool); - lp_client.fund_loan( - &env.current_contract_address(), - vendor, - &pool_contribution, - ); + lp_client.fund_loan(&env.current_contract_address(), vendor, &pool_contribution); } } + #[allow(clippy::too_many_arguments)] fn build_loan( env: &Env, user: Address, @@ -345,7 +353,8 @@ impl CreditLineContract { .unwrap_or_else(|| panic_with_error!(env, CreditLineError::Overflow)); let credit_limit = Self::credit_limit(score); - let active_debt = storage::get_user_active_debt(env, &user); + let active_debt = storage::get_user_active_debt(env, &user) + .unwrap_or_else(|err| panic_with_error!(env, err)); let next_debt = active_debt .checked_add(remaining_balance) .unwrap_or_else(|| panic_with_error!(env, CreditLineError::Overflow)); @@ -353,7 +362,8 @@ impl CreditLineContract { panic_with_error!(env, CreditLineError::ExposureLimitExceeded); } - let loan_id = storage::increment_loan_counter(env); + let loan_id = + storage::increment_loan_counter(env).unwrap_or_else(|err| panic_with_error!(env, err)); Loan { loan_id, borrower: user, @@ -426,7 +436,7 @@ impl CreditLineContract { /// `LoanNotActive` if the loan is not active. Returns `Ok(())` when the /// warning event was successfully emitted (i.e. the loan is in the grace window). pub fn warn_grace_period(env: Env, loan_id: u64) -> Result<(), CreditLineError> { - let loan = storage::read_loan(&env, loan_id).ok_or(CreditLineError::LoanNotFound)?; + let loan = storage::read_loan(&env, loan_id)?; if loan.status != LoanStatus::Active { return Err(CreditLineError::LoanNotActive); @@ -465,7 +475,7 @@ impl CreditLineContract { } pub fn mark_defaulted(env: Env, loan_id: u64) -> Result<(), CreditLineError> { - let mut loan = storage::read_loan(&env, loan_id).ok_or(CreditLineError::LoanNotFound)?; + let mut loan = storage::read_loan(&env, loan_id)?; if loan.status != LoanStatus::Active { return Err(CreditLineError::LoanNotActive); @@ -500,15 +510,16 @@ impl CreditLineContract { } let lp_address = - storage::get_liquidity_pool(&env).ok_or(CreditLineError::InsufficientLiquidity)?; + storage::get_liquidity_pool(&env)?.ok_or(CreditLineError::InsufficientLiquidity)?; Self::enter_non_reentrant(&env); loan.status = LoanStatus::Defaulted; - storage::decrease_user_active_debt(&env, &loan.borrower, loan.remaining_balance); + storage::decrease_user_active_debt(&env, &loan.borrower, loan.remaining_balance) + .unwrap_or_else(|err| panic_with_error!(&env, err)); storage::write_loan(&env, &loan); - let token_address = storage::get_token(&env).ok_or(CreditLineError::TokenNotConfigured)?; + let token_address = storage::get_token(&env)?.ok_or(CreditLineError::TokenNotConfigured)?; Self::authorize_token_transfer(&env, &token_address, &lp_address, loan.guarantee_amount); let lp_client = LiquidityPoolContractClient::new(&env, &lp_address); @@ -523,7 +534,7 @@ impl CreditLineContract { loan.guarantee_amount, ); - if let Some(reputation_contract) = storage::get_reputation_contract(&env) { + if let Some(reputation_contract) = storage::get_reputation_contract(&env)? { let penalty = Self::calculate_default_penalty(&env, &loan); let updater = env.current_contract_address(); let _ = env.try_invoke_contract::<(), soroban_sdk::Error>( @@ -539,12 +550,12 @@ impl CreditLineContract { pub fn approve_loan(env: Env, loan_id: u64) -> Loan { // 1. Admin auth - must be first - let admin = storage::get_admin(&env); + let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); admin.require_auth(); // 2. Load loan — panic if not found - let mut loan = storage::read_loan(&env, loan_id) - .unwrap_or_else(|| panic_with_error!(&env, CreditLineError::LoanNotFound)); + let mut loan = + storage::read_loan(&env, loan_id).unwrap_or_else(|err| panic_with_error!(&env, err)); // 3. Validate status is Pending if loan.status != LoanStatus::Pending { @@ -566,19 +577,20 @@ impl CreditLineContract { pub fn cancel_loan(env: Env, caller: Address, loan_id: u64) { caller.require_auth(); - let mut loan = storage::read_loan(&env, loan_id) - .unwrap_or_else(|| panic_with_error!(&env, CreditLineError::LoanNotFound)); + let mut loan = + storage::read_loan(&env, loan_id).unwrap_or_else(|err| panic_with_error!(&env, err)); if loan.status != LoanStatus::Pending { panic_with_error!(&env, CreditLineError::LoanNotCancellable); } - let admin = storage::get_admin(&env); + let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); if caller != loan.borrower && caller != admin { panic_with_error!(&env, CreditLineError::UnauthorizedRepayer); } let token_address = storage::get_token(&env) + .unwrap_or_else(|err| panic_with_error!(&env, err)) .unwrap_or_else(|| panic_with_error!(&env, CreditLineError::TokenNotConfigured)); let token_client = token::Client::new(&env, &token_address); token_client.transfer( @@ -595,8 +607,8 @@ impl CreditLineContract { pub fn repay_loan(env: Env, borrower: Address, loan_id: u64, amount: i128) -> i128 { borrower.require_auth(); - let mut loan = storage::read_loan(&env, loan_id) - .unwrap_or_else(|| panic_with_error!(&env, CreditLineError::LoanNotFound)); + let mut loan = + storage::read_loan(&env, loan_id).unwrap_or_else(|err| panic_with_error!(&env, err)); if loan.borrower != borrower { panic_with_error!(&env, CreditLineError::UnauthorizedRepayer); @@ -610,8 +622,15 @@ impl CreditLineContract { // the borrower repays the true current balance (principal + interest + fees + late fees). let accrued_fee = Self::accrue_late_fees_internal(&env, &mut loan); if accrued_fee > 0 { - storage::increase_user_active_debt(&env, &borrower, accrued_fee); - events::emit_late_fee_accrued(&env, &borrower, loan_id, accrued_fee, loan.remaining_balance); + storage::increase_user_active_debt(&env, &borrower, accrued_fee) + .unwrap_or_else(|err| panic_with_error!(&env, err)); + events::emit_late_fee_accrued( + &env, + &borrower, + loan_id, + accrued_fee, + loan.remaining_balance, + ); } if amount <= 0 || amount > loan.remaining_balance { @@ -663,12 +682,15 @@ impl CreditLineContract { loan.status = LoanStatus::Paid; } - storage::decrease_user_active_debt(&env, &borrower, amount); + storage::decrease_user_active_debt(&env, &borrower, amount) + .unwrap_or_else(|err| panic_with_error!(&env, err)); storage::write_loan(&env, &loan); let lp_address = storage::get_liquidity_pool(&env) + .unwrap_or_else(|err| panic_with_error!(&env, err)) .unwrap_or_else(|| panic_with_error!(&env, CreditLineError::InsufficientLiquidity)); let token_address = storage::get_token(&env) + .unwrap_or_else(|err| panic_with_error!(&env, err)) .unwrap_or_else(|| panic_with_error!(&env, CreditLineError::TokenNotConfigured)); let token_client = token::Client::new(&env, &token_address); @@ -703,7 +725,9 @@ impl CreditLineContract { ); if is_fully_repaid { - if let Some(reputation_contract) = storage::get_reputation_contract(&env) { + if let Some(reputation_contract) = storage::get_reputation_contract(&env) + .unwrap_or_else(|err| panic_with_error!(&env, err)) + { let updater = env.current_contract_address(); let payment_date = env.ledger().timestamp(); let due_date = loan @@ -739,8 +763,8 @@ impl CreditLineContract { ) -> i128 { borrower.require_auth(); - let mut loan = storage::read_loan(&env, loan_id) - .unwrap_or_else(|| panic_with_error!(&env, CreditLineError::LoanNotFound)); + let mut loan = + storage::read_loan(&env, loan_id).unwrap_or_else(|err| panic_with_error!(&env, err)); if loan.borrower != borrower { panic_with_error!(&env, CreditLineError::UnauthorizedRepayer); @@ -783,7 +807,8 @@ impl CreditLineContract { loan.status = LoanStatus::Paid; } - storage::decrease_user_active_debt(&env, &borrower, amount); + storage::decrease_user_active_debt(&env, &borrower, amount) + .unwrap_or_else(|err| panic_with_error!(&env, err)); storage::write_loan(&env, &loan); events::emit_installment_paid(&env, loan_id, installment_index, amount, new_balance); @@ -878,8 +903,8 @@ impl CreditLineContract { /// `LOANLTFE` event when fees are accrued; is a no-op when no full day has /// elapsed since the last accrual or when no installment is overdue. pub fn apply_late_fees(env: Env, loan_id: u64) { - let mut loan = storage::read_loan(&env, loan_id) - .unwrap_or_else(|| panic_with_error!(&env, CreditLineError::LoanNotFound)); + let mut loan = + storage::read_loan(&env, loan_id).unwrap_or_else(|err| panic_with_error!(&env, err)); if loan.status != LoanStatus::Active { panic_with_error!(&env, CreditLineError::LoanNotActive); @@ -891,9 +916,16 @@ impl CreditLineContract { return; } - storage::increase_user_active_debt(&env, &loan.borrower, accrued_fee); + storage::increase_user_active_debt(&env, &loan.borrower, accrued_fee) + .unwrap_or_else(|err| panic_with_error!(&env, err)); storage::write_loan(&env, &loan); - events::emit_late_fee_accrued(&env, &loan.borrower, loan_id, accrued_fee, loan.remaining_balance); + events::emit_late_fee_accrued( + &env, + &loan.borrower, + loan_id, + accrued_fee, + loan.remaining_balance, + ); } fn handle_reputation_increase( @@ -913,7 +945,9 @@ impl CreditLineContract { } fn get_protocol_parameters(env: &Env) -> ProtocolParameters { - match storage::get_parameters_contract(env) { + match storage::get_parameters_contract(env) + .unwrap_or_else(|err| panic_with_error!(env, err)) + { Some(address) => env .try_invoke_contract::( &address, @@ -927,7 +961,7 @@ impl CreditLineContract { } fn enter_non_reentrant(env: &Env) { - if storage::is_reentrancy_locked(env) { + if storage::is_reentrancy_locked(env).unwrap_or_else(|err| panic_with_error!(env, err)) { panic_with_error!(env, CreditLineError::ReentrancyDetected); } storage::set_reentrancy_locked(env, true); diff --git a/contracts/creditline-contract/src/storage.rs b/contracts/creditline-contract/src/storage.rs index fb16bbb..d83117b 100644 --- a/contracts/creditline-contract/src/storage.rs +++ b/contracts/creditline-contract/src/storage.rs @@ -1,5 +1,6 @@ use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol, Vec}; +use crate::errors::CreditLineError; use crate::types::Loan; // Storage keys @@ -24,11 +25,11 @@ enum DataKey { } /// Get the admin address from storage -pub fn get_admin(env: &Env) -> Address { +pub fn get_admin(env: &Env) -> Result { env.storage() .instance() .get(&ADMIN_KEY) - .unwrap_or_else(|| panic!("Admin not set")) + .ok_or(CreditLineError::NotInitialized) } /// Set the admin address in storage @@ -37,24 +38,25 @@ pub fn set_admin(env: &Env, admin: &Address) { } /// Get the current loan counter (for generating unique loan IDs) -pub fn get_loan_counter(env: &Env) -> u64 { - env.storage().instance().get(&LOAN_COUNTER).unwrap_or(0) +pub fn get_loan_counter(env: &Env) -> Result { + Ok(env.storage().instance().get(&LOAN_COUNTER).unwrap_or(0)) } /// Increment and return the next loan ID -pub fn increment_loan_counter(env: &Env) -> u64 { - let current = get_loan_counter(env); - let next = current.checked_add(1).expect("Loan counter overflow"); +pub fn increment_loan_counter(env: &Env) -> Result { + let current = get_loan_counter(env)?; + let next = current.checked_add(1).ok_or(CreditLineError::Overflow)?; env.storage().instance().set(&LOAN_COUNTER, &next); - next + Ok(next) } /// Read a loan from storage -pub fn read_loan(env: &Env, loan_id: u64) -> Option { +pub fn read_loan(env: &Env, loan_id: u64) -> Result { let shard = loan_shard(loan_id); env.storage() .persistent() .get(&DataKey::Loan(shard, loan_id)) + .ok_or(CreditLineError::LoanNotFound) } /// Write a loan to storage @@ -70,11 +72,12 @@ pub fn write_loan(env: &Env, loan: &Loan) { } } -pub fn get_user_loan_count(env: &Env, borrower: &Address) -> u64 { - env.storage() +pub fn get_user_loan_count(env: &Env, borrower: &Address) -> Result { + Ok(env + .storage() .persistent() .get(&DataKey::UserLoanCount(borrower.clone())) - .unwrap_or(0) + .unwrap_or(0)) } pub fn get_user_loan_ids_paginated( @@ -82,12 +85,12 @@ pub fn get_user_loan_ids_paginated( borrower: &Address, start: u64, limit: u32, -) -> Vec { - let total = get_user_loan_count(env, borrower); +) -> Result, CreditLineError> { + let total = get_user_loan_count(env, borrower)?; let mut result = Vec::new(env); if limit == 0 || start >= total { - return result; + return Ok(result); } let end = start.saturating_add(limit as u64).min(total); @@ -100,7 +103,7 @@ pub fn get_user_loan_ids_paginated( idx += 1; } - result + Ok(result) } pub fn get_user_loans_paginated( @@ -108,54 +111,68 @@ pub fn get_user_loans_paginated( borrower: &Address, start: u64, limit: u32, -) -> Vec { - let loan_ids = get_user_loan_ids_paginated(env, borrower, start, limit); +) -> Result, CreditLineError> { + let loan_ids = get_user_loan_ids_paginated(env, borrower, start, limit)?; let mut loans = Vec::new(env); for loan_id in loan_ids.iter() { - if let Some(loan) = read_loan(env, loan_id) { - loans.push_back(loan); - } + loans.push_back(read_loan(env, loan_id)?); } - loans + Ok(loans) } -pub fn get_user_active_debt(env: &Env, borrower: &Address) -> i128 { - env.storage() +pub fn get_user_active_debt(env: &Env, borrower: &Address) -> Result { + Ok(env + .storage() .persistent() .get(&DataKey::UserActiveDebt(borrower.clone())) - .unwrap_or(0) + .unwrap_or(0)) } -pub fn increase_user_active_debt(env: &Env, borrower: &Address, amount: i128) { - let current = get_user_active_debt(env, borrower); +pub fn increase_user_active_debt( + env: &Env, + borrower: &Address, + amount: i128, +) -> Result<(), CreditLineError> { + let current = get_user_active_debt(env, borrower)?; let next = current .checked_add(amount) - .expect("User active debt overflow"); - env.storage() - .persistent() - .set(&DataKey::UserActiveDebt(borrower.clone()), &next); + .ok_or(CreditLineError::Overflow)?; + let key = DataKey::UserActiveDebt(borrower.clone()); + env.storage().persistent().set(&key, &next); + extend_persistent_ttl(env, &key); + Ok(()) } -pub fn decrease_user_active_debt(env: &Env, borrower: &Address, amount: i128) { - let current = get_user_active_debt(env, borrower); +pub fn decrease_user_active_debt( + env: &Env, + borrower: &Address, + amount: i128, +) -> Result<(), CreditLineError> { + let current = get_user_active_debt(env, borrower)?; let next = current .checked_sub(amount) - .expect("User active debt underflow"); - env.storage() - .persistent() - .set(&DataKey::UserActiveDebt(borrower.clone()), &next); + .ok_or(CreditLineError::Underflow)?; + let key = DataKey::UserActiveDebt(borrower.clone()); + env.storage().persistent().set(&key, &next); + extend_persistent_ttl(env, &key); + Ok(()) } fn append_user_loan_index(env: &Env, borrower: &Address, loan_id: u64) { - let count = get_user_loan_count(env, borrower); - env.storage() - .persistent() - .set(&DataKey::UserLoanAt(borrower.clone(), count), &loan_id); - env.storage() - .persistent() - .set(&DataKey::UserLoanCount(borrower.clone()), &(count + 1)); + let count = get_user_loan_count(env, borrower) + .unwrap_or_else(|err| soroban_sdk::panic_with_error!(env, err)); + let loan_at_key = DataKey::UserLoanAt(borrower.clone(), count); + env.storage().persistent().set(&loan_at_key, &loan_id); + extend_persistent_ttl(env, &loan_at_key); + + let count_key = DataKey::UserLoanCount(borrower.clone()); + let next_count = count + .checked_add(1) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, CreditLineError::Overflow)); + env.storage().persistent().set(&count_key, &next_count); + extend_persistent_ttl(env, &count_key); } fn loan_shard(loan_id: u64) -> u32 { @@ -163,8 +180,8 @@ fn loan_shard(loan_id: u64) -> u32 { } /// Get the Reputation Contract address -pub fn get_reputation_contract(env: &Env) -> Option
{ - env.storage().instance().get(&REPUTATION_CONTRACT) +pub fn get_reputation_contract(env: &Env) -> Result, CreditLineError> { + Ok(env.storage().instance().get(&REPUTATION_CONTRACT)) } /// Set the Reputation Contract address @@ -173,8 +190,8 @@ pub fn set_reputation_contract(env: &Env, address: &Address) { } /// Get the Vendor Registry Contract address -pub fn get_vendor_registry(env: &Env) -> Option
{ - env.storage().instance().get(&MERCHANT_REGISTRY) +pub fn get_vendor_registry(env: &Env) -> Result, CreditLineError> { + Ok(env.storage().instance().get(&MERCHANT_REGISTRY)) } /// Set the Vendor Registry Contract address @@ -183,8 +200,8 @@ pub fn set_vendor_registry(env: &Env, address: &Address) { } /// Get the Liquidity Pool Contract address -pub fn get_liquidity_pool(env: &Env) -> Option
{ - env.storage().instance().get(&LIQUIDITY_POOL) +pub fn get_liquidity_pool(env: &Env) -> Result, CreditLineError> { + Ok(env.storage().instance().get(&LIQUIDITY_POOL)) } /// Set the Liquidity Pool Contract address @@ -193,8 +210,8 @@ pub fn set_liquidity_pool(env: &Env, address: &Address) { } /// Get the Token Contract address -pub fn get_token(env: &Env) -> Option
{ - env.storage().instance().get(&TOKEN) +pub fn get_token(env: &Env) -> Result, CreditLineError> { + Ok(env.storage().instance().get(&TOKEN)) } /// Set the Token Contract address @@ -203,8 +220,8 @@ pub fn set_token(env: &Env, address: &Address) { } /// Get the Parameters Contract address -pub fn get_parameters_contract(env: &Env) -> Option
{ - env.storage().instance().get(&PARAMETERS_CONTRACT) +pub fn get_parameters_contract(env: &Env) -> Result, CreditLineError> { + Ok(env.storage().instance().get(&PARAMETERS_CONTRACT)) } /// Set the Parameters Contract address @@ -212,11 +229,12 @@ pub fn set_parameters_contract(env: &Env, address: &Address) { env.storage().instance().set(&PARAMETERS_CONTRACT, address); } -pub fn is_reentrancy_locked(env: &Env) -> bool { - env.storage() +pub fn is_reentrancy_locked(env: &Env) -> Result { + Ok(env + .storage() .instance() .get(&REENTRANCY_LOCK) - .unwrap_or(false) + .unwrap_or(false)) } pub fn set_reentrancy_locked(env: &Env, locked: bool) { @@ -229,8 +247,13 @@ pub const PERSISTENT_TTL_THRESHOLD: u32 = 1_036_800; pub const PERSISTENT_TTL_EXTEND_TO: u32 = 2_073_600; /// Extend TTL for a persistent storage entry -pub fn extend_persistent_ttl_loan(env: &Env, key: &DataKey) { +fn extend_persistent_ttl(env: &Env, key: &DataKey) { env.storage() .persistent() .extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); } + +/// Extend TTL for a loan storage entry +fn extend_persistent_ttl_loan(env: &Env, key: &DataKey) { + extend_persistent_ttl(env, key); +} diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs index 98ece82..5f5dd6b 100644 --- a/contracts/creditline-contract/src/tests.rs +++ b/contracts/creditline-contract/src/tests.rs @@ -1,6 +1,8 @@ -use crate::{CreditLineContract, CreditLineContractClient, LoanStatus, LoanType, RepaymentInstallment}; +use crate::{ + CreditLineContract, CreditLineContractClient, CreditLineError, LoanStatus, LoanType, + RepaymentInstallment, +}; use liquidity_pool_contract::{LiquidityPoolContract, LiquidityPoolContractClient, PoolStats}; -use vendor_registry_contract::VendorRegistryContract; use parameters_contract::{ default_parameters, ParametersContract, ParametersContractClient, ProtocolParameters, }; @@ -12,6 +14,7 @@ use soroban_sdk::{ token::Client as TokenClient, Address, Env, String as SorobanString, }; +use vendor_registry_contract::VendorRegistryContract; const DEFAULT_PRINCIPAL: i128 = 1_000; const DEFAULT_GUARANTEE: i128 = 200; @@ -79,14 +82,14 @@ use mock_low_rep::MockReputationLow; // ─── helpers ────────────────────────────────────────────────────────────────── /// Creates a basic TestEnv with MockReputation wired in and the contract -/// initialized. Returns (env, client, admin, rep_id). +/// initialized. struct TestCtx { env: Env, client: CreditLineContractClient<'static>, admin: Address, - rep_id: Address, + _rep_id: Address, token_id: Address, - lp_id: Address, + _lp_id: Address, vendor_registry_id: Address, } @@ -127,9 +130,9 @@ impl TestCtx { env, client, admin, - rep_id, + _rep_id: rep_id, token_id, - lp_id, + _lp_id: lp_id, vendor_registry_id, } } @@ -457,7 +460,14 @@ fn test_create_loan_with_zero_total_amount() { let repayment_schedule = soroban_sdk::Vec::new(&env); // This should panic with InvalidAmount (error code 9) - client.create_loan(&user, &vendor, &0, &0, &repayment_schedule, &LoanType::Standard); + client.create_loan( + &user, + &vendor, + &0, + &0, + &repayment_schedule, + &LoanType::Standard, + ); } #[test] @@ -488,7 +498,14 @@ fn test_create_loan_with_negative_total_amount() { let repayment_schedule = soroban_sdk::Vec::new(&env); // This should panic with InvalidAmount (error code 9) - client.create_loan(&user, &vendor, &-1000, &-200, &repayment_schedule, &LoanType::Standard); + client.create_loan( + &user, + &vendor, + &-1000, + &-200, + &repayment_schedule, + &LoanType::Standard, + ); } #[test] @@ -519,7 +536,14 @@ fn test_create_loan_with_zero_guarantee_amount() { let repayment_schedule = soroban_sdk::Vec::new(&env); // This should panic with InvalidAmount (error code 9) - client.create_loan(&user, &vendor, &1000, &0, &repayment_schedule, &LoanType::Standard); + client.create_loan( + &user, + &vendor, + &1000, + &0, + &repayment_schedule, + &LoanType::Standard, + ); } #[test] @@ -550,7 +574,14 @@ fn test_create_loan_with_insufficient_guarantee_19_percent() { let repayment_schedule = soroban_sdk::Vec::new(&env); // 190 is 19% of 1000, should fail with InsufficientGuarantee (error code 2) - client.create_loan(&user, &vendor, &1000, &190, &repayment_schedule, &LoanType::Standard); + client.create_loan( + &user, + &vendor, + &1000, + &190, + &repayment_schedule, + &LoanType::Standard, + ); } #[test] @@ -581,25 +612,33 @@ fn test_create_loan_with_insufficient_guarantee_10_percent() { let repayment_schedule = soroban_sdk::Vec::new(&env); // 100 is 10% of 1000, should fail with InsufficientGuarantee (error code 2) - client.create_loan(&user, &vendor, &1000, &100, &repayment_schedule, &LoanType::Standard); + client.create_loan( + &user, + &vendor, + &1000, + &100, + &repayment_schedule, + &LoanType::Standard, + ); } // Additional edge case tests #[test] -#[should_panic(expected = "Admin not set")] fn test_get_admin_before_initialization() { let env = Env::default(); let contract_id = env.register(CreditLineContract, ()); let client = CreditLineContractClient::new(&env, &contract_id); - // Try to get admin before initialization - should panic - client.get_admin(); + assert_eq!( + client.try_get_admin(), + Err(Ok(CreditLineError::NotInitialized)) + ); } #[test] -#[should_panic(expected = "Admin not set")] +#[should_panic(expected = "Error(Contract, #26)")] fn test_set_admin_before_initialization() { let env = Env::default(); env.mock_all_auths(); @@ -668,7 +707,14 @@ fn test_create_loan_with_one_less_than_minimum_guarantee() { let repayment_schedule = soroban_sdk::Vec::new(&env); // 199 is 1 less than 20% of 1000, should fail with InsufficientGuarantee (error code 2) - client.create_loan(&user, &vendor, &1000, &199, &repayment_schedule, &LoanType::Standard); + client.create_loan( + &user, + &vendor, + &1000, + &199, + &repayment_schedule, + &LoanType::Standard, + ); } #[test] @@ -730,7 +776,14 @@ fn test_create_loan_with_positive_total_negative_guarantee() { let repayment_schedule = soroban_sdk::Vec::new(&env); // Positive total but negative guarantee should fail with InvalidAmount (error code 9) - client.create_loan(&user, &vendor, &1000, &-200, &repayment_schedule, &LoanType::Standard); + client.create_loan( + &user, + &vendor, + &1000, + &-200, + &repayment_schedule, + &LoanType::Standard, + ); } #[test] @@ -1312,7 +1365,6 @@ fn test_mark_defaulted_triggers_reputation_slash() { /// Helper: register and wire up a ParametersContract with the given grace period. fn setup_parameters_with_grace_period(t: &TestCtx, grace_period_seconds: u64) { - use soroban_sdk::Symbol; let params_id = t.env.register(ParametersContract, ()); let params_client = ParametersContractClient::new(&t.env, ¶ms_id); params_client.initialize( @@ -1340,9 +1392,9 @@ fn test_mark_defaulted_blocked_during_grace_period() { t.env.ledger().set_timestamp(1_000); let schedule = t.single_installment(1_000, due_date); t.mint(&user, 200); - let loan_id = t - .client - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.client + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); // One second past due but still within the grace window. t.env.ledger().set_timestamp(due_date + 1); @@ -1370,9 +1422,9 @@ fn test_mark_defaulted_succeeds_after_grace_period_expires() { t.env.ledger().set_timestamp(1_000); let schedule = t.single_installment(1_000, due_date); t.mint(&user, 200); - let loan_id = t - .client - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.client + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); // One second past the end of the grace window. t.env.ledger().set_timestamp(due_date + grace + 1); @@ -1397,9 +1449,9 @@ fn test_mark_defaulted_at_grace_period_boundary_still_blocked() { t.env.ledger().set_timestamp(1_000); let schedule = t.single_installment(1_000, due_date); t.mint(&user, 200); - let loan_id = t - .client - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.client + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); t.env.ledger().set_timestamp(due_date + grace); let result = t.client.try_mark_defaulted(&loan_id); @@ -1423,9 +1475,9 @@ fn test_warn_grace_period_emits_event() { t.env.ledger().set_timestamp(1_000); let schedule = t.single_installment(1_000, due_date); t.mint(&user, 200); - let loan_id = t - .client - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.client + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); // Advance into the grace window and call warn_grace_period. t.env.ledger().set_timestamp(due_date + 1); @@ -1452,9 +1504,9 @@ fn test_warn_grace_period_fails_before_due_date() { t.env.ledger().set_timestamp(1_000); let schedule = t.single_installment(1_000, due_date); t.mint(&user, 200); - let loan_id = t - .client - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.client + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); // Still before due date — should return LoanNotOverdue. let result = t.client.try_warn_grace_period(&loan_id); @@ -1478,9 +1530,9 @@ fn test_warn_grace_period_fails_after_grace_expires() { t.env.ledger().set_timestamp(1_000); let schedule = t.single_installment(1_000, due_date); t.mint(&user, 200); - let loan_id = t - .client - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.client + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); // Past the grace window — no longer in grace period. t.env.ledger().set_timestamp(due_date + grace + 1); @@ -1505,9 +1557,9 @@ fn test_zero_grace_period_allows_immediate_default() { t.env.ledger().set_timestamp(1_000); let schedule = t.single_installment(1_000, due_date); t.mint(&user, 200); - let loan_id = t - .client - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.client + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); t.env.ledger().set_timestamp(due_date + 1); t.client.mark_defaulted(&loan_id); @@ -1734,9 +1786,14 @@ fn test_unregistered_vendor_loan_is_rejected() { let schedule = t.single_installment(1000, due_date); // This should panic with InvalidVendor error - let _ = t - .client - .create_loan(&user, &unknown_vendor, &1000, &200, &schedule, &LoanType::Standard); + let _ = t.client.create_loan( + &user, + &unknown_vendor, + &1000, + &200, + &schedule, + &LoanType::Standard, + ); } // ─── liquidity pool integration — TDD stubs (Phase 6) ──────────────────────── @@ -1845,12 +1902,22 @@ fn test_multiple_independent_loans_do_not_interfere() { t.mint(&user_a, 200); t.mint(&user_b, 400); - let loan_a = t - .client - .create_loan(&user_a, &vendor, &1000, &200, &schedule_a, &LoanType::Standard); - let loan_b = t - .client - .create_loan(&user_b, &vendor, &2000, &400, &schedule_b, &LoanType::Standard); + let loan_a = t.client.create_loan( + &user_a, + &vendor, + &1000, + &200, + &schedule_a, + &LoanType::Standard, + ); + let loan_b = t.client.create_loan( + &user_b, + &vendor, + &2000, + &400, + &schedule_b, + &LoanType::Standard, + ); // Default loan_a only t.advance_past(5000); @@ -2157,10 +2224,8 @@ impl RealIntegrationCtx { let reputation = ReputationContractClient::new(&env, &reputation_id); let vendor_registry_id = env.register(VendorRegistryContract, ()); - let vendor_registry = vendor_registry_contract::VendorRegistryContractClient::new( - &env, - &vendor_registry_id, - ); + let vendor_registry = + vendor_registry_contract::VendorRegistryContractClient::new(&env, &vendor_registry_id); let pool_id = env.register(LiquidityPoolContract, ()); let pool = LiquidityPoolContractClient::new(&env, &pool_id); @@ -2246,10 +2311,12 @@ impl RealIntegrationCtx { due_date: u64, ) -> soroban_sdk::Vec { let mut schedule = soroban_sdk::Vec::new(&self.env); - schedule.push_back(RepaymentInstallment { due_date, amount, - paid: false, - paid_at: 0, -}); + schedule.push_back(RepaymentInstallment { + due_date, + amount, + paid: false, + paid_at: 0, + }); schedule } } @@ -2273,9 +2340,9 @@ fn test_real_asset_transfers_on_create_and_repay() { let due_date = t.env.ledger().timestamp() + 10_000; let schedule = t.single_installment(1_000, due_date); - let loan_id = t - .creditline - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.creditline + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); assert_eq!(t.balance(&user), user_balance_before - 200); assert_eq!(t.balance(&vendor), vendor_balance_before + 800); @@ -2342,9 +2409,9 @@ fn test_end_to_end_happy_path_across_all_contracts() { let share_price_before = t.pool.get_pool_stats().share_price; let due_date = t.env.ledger().timestamp() + 10_000; let schedule = t.single_installment(1_000, due_date); - let loan_id = t - .creditline - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.creditline + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); let loan_before_repay = t.creditline.get_loan(&loan_id); let total_due = loan_before_repay.remaining_balance; @@ -2382,9 +2449,9 @@ fn test_end_to_end_default_path_guarantee_and_penalty() { t.env.ledger().set_timestamp(1_000); let schedule = t.single_installment(1_000, 5_000); - let loan_id = t - .creditline - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.creditline + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); let creditline_balance_after_loan = t.balance(&t.creditline_id); let pool_balance_after_loan = t.balance(&t.pool.address); @@ -2423,9 +2490,14 @@ fn test_no_late_fee_before_due_date() { let due_date = 50_000_u64; let schedule = t.single_installment(DEFAULT_TOTAL_DUE, due_date); t.mint(&user, DEFAULT_GUARANTEE); - let loan_id = t - .client - .create_loan(&user, &vendor, &DEFAULT_PRINCIPAL, &DEFAULT_GUARANTEE, &schedule, &LoanType::Standard); + let loan_id = t.client.create_loan( + &user, + &vendor, + &DEFAULT_PRINCIPAL, + &DEFAULT_GUARANTEE, + &schedule, + &LoanType::Standard, + ); let loan = t.client.get_loan(&loan_id); assert_eq!(loan.late_fees_outstanding, 0); @@ -2444,9 +2516,14 @@ fn test_apply_late_fees_adds_fee_after_one_day_overdue() { let schedule = t.single_installment(DEFAULT_TOTAL_DUE, due_date); t.env.ledger().set_timestamp(0); t.mint(&user, DEFAULT_GUARANTEE); - let loan_id = t - .client - .create_loan(&user, &vendor, &DEFAULT_PRINCIPAL, &DEFAULT_GUARANTEE, &schedule, &LoanType::Standard); + let loan_id = t.client.create_loan( + &user, + &vendor, + &DEFAULT_PRINCIPAL, + &DEFAULT_GUARANTEE, + &schedule, + &LoanType::Standard, + ); // Advance 1 full day past due_date t.env.ledger().set_timestamp(due_date + SECONDS_PER_DAY); @@ -2470,9 +2547,14 @@ fn test_apply_late_fees_accumulates_over_multiple_days() { let schedule = t.single_installment(DEFAULT_TOTAL_DUE, due_date); t.env.ledger().set_timestamp(0); t.mint(&user, DEFAULT_GUARANTEE); - let loan_id = t - .client - .create_loan(&user, &vendor, &DEFAULT_PRINCIPAL, &DEFAULT_GUARANTEE, &schedule, &LoanType::Standard); + let loan_id = t.client.create_loan( + &user, + &vendor, + &DEFAULT_PRINCIPAL, + &DEFAULT_GUARANTEE, + &schedule, + &LoanType::Standard, + ); t.env.ledger().set_timestamp(due_date + 3 * SECONDS_PER_DAY); t.client.apply_late_fees(&loan_id); @@ -2494,9 +2576,14 @@ fn test_apply_late_fees_is_noop_within_same_day() { let schedule = t.single_installment(DEFAULT_TOTAL_DUE, due_date); t.env.ledger().set_timestamp(0); t.mint(&user, DEFAULT_GUARANTEE); - let loan_id = t - .client - .create_loan(&user, &vendor, &DEFAULT_PRINCIPAL, &DEFAULT_GUARANTEE, &schedule, &LoanType::Standard); + let loan_id = t.client.create_loan( + &user, + &vendor, + &DEFAULT_PRINCIPAL, + &DEFAULT_GUARANTEE, + &schedule, + &LoanType::Standard, + ); t.env.ledger().set_timestamp(due_date + SECONDS_PER_DAY); t.client.apply_late_fees(&loan_id); @@ -2523,9 +2610,14 @@ fn test_apply_late_fees_incremental_across_two_calls() { // due_date == 0, so it's already overdue at creation time; first day starts at ledger 0 // Advance ledger to 1 so due_date < now and create the loan t.env.ledger().set_timestamp(1); - let loan_id = t - .client - .create_loan(&user, &vendor, &DEFAULT_PRINCIPAL, &DEFAULT_GUARANTEE, &schedule, &LoanType::Standard); + let loan_id = t.client.create_loan( + &user, + &vendor, + &DEFAULT_PRINCIPAL, + &DEFAULT_GUARANTEE, + &schedule, + &LoanType::Standard, + ); // First accrual: 1 day after due_date (due_date = 0, now = SECONDS_PER_DAY) t.env.ledger().set_timestamp(SECONDS_PER_DAY); @@ -2571,9 +2663,14 @@ fn test_repay_loan_auto_accrues_late_fees() { let schedule = t.single_installment(DEFAULT_TOTAL_DUE, due_date); t.env.ledger().set_timestamp(0); t.mint(&user, DEFAULT_GUARANTEE); - let loan_id = t - .client - .create_loan(&user, &vendor, &DEFAULT_PRINCIPAL, &DEFAULT_GUARANTEE, &schedule, &LoanType::Standard); + let loan_id = t.client.create_loan( + &user, + &vendor, + &DEFAULT_PRINCIPAL, + &DEFAULT_GUARANTEE, + &schedule, + &LoanType::Standard, + ); // Advance 1 day past due_date and attempt partial payment t.env.ledger().set_timestamp(due_date + SECONDS_PER_DAY); @@ -2601,9 +2698,14 @@ fn test_full_repayment_including_late_fees_sets_paid() { let schedule = t.single_installment(DEFAULT_TOTAL_DUE, due_date); t.env.ledger().set_timestamp(0); t.mint(&user, DEFAULT_GUARANTEE); - let loan_id = t - .client - .create_loan(&user, &vendor, &DEFAULT_PRINCIPAL, &DEFAULT_GUARANTEE, &schedule, &LoanType::Standard); + let loan_id = t.client.create_loan( + &user, + &vendor, + &DEFAULT_PRINCIPAL, + &DEFAULT_GUARANTEE, + &schedule, + &LoanType::Standard, + ); t.env.ledger().set_timestamp(due_date + SECONDS_PER_DAY); t.client.apply_late_fees(&loan_id); @@ -2632,9 +2734,14 @@ fn test_active_debt_includes_late_fees() { let schedule = t.single_installment(DEFAULT_TOTAL_DUE, due_date); t.env.ledger().set_timestamp(0); t.mint(&user, DEFAULT_GUARANTEE); - let loan_id = t - .client - .create_loan(&user, &vendor, &DEFAULT_PRINCIPAL, &DEFAULT_GUARANTEE, &schedule, &LoanType::Standard); + let loan_id = t.client.create_loan( + &user, + &vendor, + &DEFAULT_PRINCIPAL, + &DEFAULT_GUARANTEE, + &schedule, + &LoanType::Standard, + ); let debt_before = t.client.get_user_active_debt(&user); @@ -2662,9 +2769,9 @@ fn test_on_time_full_repayment_increases_score_by_10() { let due_date = 5_000_u64; let schedule = t.single_installment(1_000, due_date); - let loan_id = t - .creditline - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.creditline + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); let loan = t.creditline.get_loan(&loan_id); let total_due = loan.remaining_balance; @@ -2693,9 +2800,9 @@ fn test_early_full_repayment_increases_score_by_15() { t.env.ledger().set_timestamp(1_000); let due_date = 10_000_u64; let schedule = t.single_installment(1_000, due_date); - let loan_id = t - .creditline - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.creditline + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); let loan = t.creditline.get_loan(&loan_id); let total_due = loan.remaining_balance; @@ -2723,9 +2830,9 @@ fn test_partial_repayment_does_not_change_reputation_score() { let due_date = t.env.ledger().timestamp() + 10_000; let schedule = t.single_installment(1_000, due_date); - let loan_id = t - .creditline - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.creditline + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); let loan = t.creditline.get_loan(&loan_id); t.mint(&user, loan.remaining_balance); @@ -2751,17 +2858,16 @@ fn test_reputation_call_failure_does_not_block_repayment() { let due_date = t.env.ledger().timestamp() + 10_000; let schedule = t.single_installment(1_000, due_date); - let loan_id = t - .creditline - .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); + let loan_id = + t.creditline + .create_loan(&user, &vendor, &1_000, &200, &schedule, &LoanType::Standard); let loan = t.creditline.get_loan(&loan_id); let total_due = loan.remaining_balance; t.mint(&user, total_due); // Revoke updater permission so the increase_score call will fail - t.reputation - .set_updater(&t.admin, &t.creditline_id, &false); + t.reputation.set_updater(&t.admin, &t.creditline_id, &false); // Repayment must still succeed despite the reputation call failure t.creditline.repay_loan(&user, &loan_id, &total_due); @@ -2818,7 +2924,10 @@ fn test_approve_loan_not_admin() { t.env.set_auths(&[]); let result = t.client.try_approve_loan(&loan_id); - assert!(result.is_err(), "expected auth error when caller is not admin"); + assert!( + result.is_err(), + "expected auth error when caller is not admin" + ); } // ─── repay_installment tests ────────────────────────────────────────────────── diff --git a/contracts/creditline-contract/src/types.rs b/contracts/creditline-contract/src/types.rs index e010a10..a6830d7 100644 --- a/contracts/creditline-contract/src/types.rs +++ b/contracts/creditline-contract/src/types.rs @@ -61,16 +61,16 @@ pub struct Loan { pub repayment_schedule: soroban_sdk::Vec, pub status: LoanStatus, pub loan_type: LoanType, - pub created_at: u64, // Unix timestamp - pub funded_at: u64, // 0 means not funded yet - pub late_fees_outstanding: i128, // accumulated unpaid late fees - pub late_fee_accrual_timestamp: u64, // last accrual timestamp (0 = never accrued) + pub created_at: u64, // Unix timestamp + pub funded_at: u64, // 0 means not funded yet + pub late_fees_outstanding: i128, // accumulated unpaid late fees + pub late_fee_accrual_timestamp: u64, // last accrual timestamp (0 = never accrued) } pub fn default_protocol_parameters() -> ProtocolParameters { ProtocolParameters { - min_guarantee_percent: 20, - min_reputation_threshold: 50, + min_guarantee_percent: MIN_GUARANTEE_PERCENT, + min_reputation_threshold: MIN_REPUTATION_THRESHOLD, full_repayment_reward: 10, default_penalty: 20, large_loan_threshold: 5_000, diff --git a/contracts/liquidity-pool-contract/src/lib.rs b/contracts/liquidity-pool-contract/src/lib.rs index 6f342c0..fb5d68a 100644 --- a/contracts/liquidity-pool-contract/src/lib.rs +++ b/contracts/liquidity-pool-contract/src/lib.rs @@ -65,20 +65,19 @@ impl LiquidityPoolContract { } pub fn set_admin(env: Env, new_admin: Address) { - let old_admin = storage::get_admin(&env); + let old_admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); old_admin.require_auth(); Self::require_admin(&env, &old_admin); storage::set_admin(&env, &new_admin); } - /// Upgrade the contract WASM — admin only pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) { - let admin = storage::get_admin(&env); + let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); admin.require_auth(); env.deployer().update_current_contract_wasm(new_wasm_hash); } - pub fn get_admin(env: Env) -> Address { + pub fn get_admin(env: Env) -> Result { storage::get_admin(&env) } @@ -95,15 +94,17 @@ impl LiquidityPoolContract { pub fn deposit(env: Env, provider: Address, amount: i128) -> i128 { provider.require_auth(); - if amount <= 0 { + if amount < types::MIN_AMOUNT { panic_with_error!(&env, LiquidityPoolError::InvalidAmount); } Self::enter_non_reentrant(&env); - let token = storage::get_token(&env); - let total_shares = storage::get_total_shares(&env); - let total_liquidity = storage::get_total_liquidity(&env); + let token = storage::get_token(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); + let total_shares = + storage::get_total_shares(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); + let total_liquidity = + storage::get_total_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); // Calculate shares to issue let shares_issued = if total_shares == 0 || total_liquidity == 0 { @@ -123,6 +124,7 @@ impl LiquidityPoolContract { // Update state let new_shares = storage::get_lp_shares(&env, &provider) + .unwrap_or_else(|err| panic_with_error!(&env, err)) .checked_add(shares_issued) .unwrap_or_else(|| panic_with_error!(&env, LiquidityPoolError::Overflow)); storage::set_lp_shares(&env, &provider, new_shares); @@ -155,24 +157,28 @@ impl LiquidityPoolContract { pub fn withdraw(env: Env, provider: Address, shares: i128) -> i128 { provider.require_auth(); - if shares <= 0 { + if shares < types::MIN_AMOUNT { panic_with_error!(&env, LiquidityPoolError::InvalidAmount); } Self::enter_non_reentrant(&env); - let provider_shares = storage::get_lp_shares(&env, &provider); + let provider_shares = storage::get_lp_shares(&env, &provider) + .unwrap_or_else(|err| panic_with_error!(&env, err)); if provider_shares < shares { panic_with_error!(&env, LiquidityPoolError::InsufficientShares); } - let total_shares = storage::get_total_shares(&env); + let total_shares = + storage::get_total_shares(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); if total_shares == 0 { panic_with_error!(&env, LiquidityPoolError::ZeroTotalShares); } - let total_liquidity = storage::get_total_liquidity(&env); - let locked_liquidity = storage::get_locked_liquidity(&env); + let total_liquidity = + storage::get_total_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); + let locked_liquidity = + storage::get_locked_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); let available_liquidity = total_liquidity .checked_sub(locked_liquidity) .unwrap_or_else(|| panic_with_error!(&env, LiquidityPoolError::Underflow)); @@ -205,7 +211,7 @@ impl LiquidityPoolContract { events::emit_liquidity_withdrawn(&env, &provider, shares, amount_returned); // Transfer tokens back to provider after state effects. - let token = storage::get_token(&env); + let token = storage::get_token(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); let token_client = token::Client::new(&env, &token); token_client.transfer(&env.current_contract_address(), &provider, &amount_returned); Self::exit_non_reentrant(&env); @@ -229,8 +235,10 @@ impl LiquidityPoolContract { Self::enter_non_reentrant(&env); - let total_liquidity = storage::get_total_liquidity(&env); - let locked_liquidity = storage::get_locked_liquidity(&env); + let total_liquidity = + storage::get_total_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); + let locked_liquidity = + storage::get_locked_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); let available = total_liquidity .checked_sub(locked_liquidity) .unwrap_or_else(|| panic_with_error!(&env, LiquidityPoolError::Underflow)); @@ -245,7 +253,7 @@ impl LiquidityPoolContract { storage::set_locked_liquidity(&env, new_locked); // Transfer tokens from pool to merchant after accounting has been updated. - let token = storage::get_token(&env); + let token = storage::get_token(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); let token_client = token::Client::new(&env, &token); token_client.transfer(&env.current_contract_address(), &merchant, &amount); @@ -275,14 +283,15 @@ impl LiquidityPoolContract { Self::enter_non_reentrant(&env); // Decrease locked liquidity by the principal - let locked = storage::get_locked_liquidity(&env); + let locked = + storage::get_locked_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); let new_locked = locked .checked_sub(principal) .unwrap_or_else(|| panic_with_error!(&env, LiquidityPoolError::Underflow)); storage::set_locked_liquidity(&env, new_locked); // Pull funds from CreditLine after accounting changes. - let token = storage::get_token(&env); + let token = storage::get_token(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); let token_client = token::Client::new(&env, &token); token_client.transfer(&creditline, &env.current_contract_address(), &total); @@ -309,20 +318,22 @@ impl LiquidityPoolContract { // The defaulted loan principal stays "locked" — the guarantee partially // covers the loss. We reduce locked_liquidity by the guarantee amount // and add it back to total_liquidity (net pool recovers that portion). - let locked = storage::get_locked_liquidity(&env); + let locked = + storage::get_locked_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); let recovered = amount.min(locked); // can't recover more than locked let new_locked = locked .checked_sub(recovered) .unwrap_or_else(|| panic_with_error!(&env, LiquidityPoolError::Underflow)); storage::set_locked_liquidity(&env, new_locked); - let total_liquidity = storage::get_total_liquidity(&env); + let total_liquidity = + storage::get_total_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); let new_total = total_liquidity .checked_add(recovered) .unwrap_or_else(|| panic_with_error!(&env, LiquidityPoolError::Overflow)); storage::set_total_liquidity(&env, new_total); - let token = storage::get_token(&env); + let token = storage::get_token(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); let token_client = token::Client::new(&env, &token); token_client.transfer(&creditline, &env.current_contract_address(), &amount); @@ -354,6 +365,10 @@ impl LiquidityPoolContract { if interest_amount <= 0 { panic_with_error!(env, LiquidityPoolError::InvalidAmount); } + debug_assert_eq!( + types::LP_FEE_BPS + types::PROTOCOL_FEE_BPS + types::MERCHANT_FEE_BPS, + types::TOTAL_BPS + ); // 85% stays in the pool → increases share value let lp_amount = interest_amount @@ -373,12 +388,14 @@ impl LiquidityPoolContract { .and_then(|v| v.checked_sub(protocol_amount)) .unwrap_or_else(|| panic_with_error!(env, LiquidityPoolError::Underflow)); - let token = storage::get_token(env); + let token = storage::get_token(env).unwrap_or_else(|err| panic_with_error!(env, err)); let token_client = token::Client::new(env, &token); // Transfer protocol fee to treasury (if configured) if protocol_amount > 0 { - if let Some(treasury) = storage::get_treasury(env) { + if let Some(treasury) = + storage::get_treasury(env).unwrap_or_else(|err| panic_with_error!(env, err)) + { token_client.transfer(&env.current_contract_address(), &treasury, &protocol_amount); } // If treasury not configured, protocol fee stays in pool (benefits LPs) @@ -386,7 +403,9 @@ impl LiquidityPoolContract { // Transfer merchant incentive to merchant fund (if configured) if merchant_amount > 0 { - if let Some(merchant_fund) = storage::get_merchant_fund(env) { + if let Some(merchant_fund) = + storage::get_merchant_fund(env).unwrap_or_else(|err| panic_with_error!(env, err)) + { token_client.transfer( &env.current_contract_address(), &merchant_fund, @@ -398,7 +417,8 @@ impl LiquidityPoolContract { // LP portion (lp_amount) stays in the pool — no transfer needed. // Update total_liquidity to reflect the added interest (raises share price). - let total_liquidity = storage::get_total_liquidity(env); + let total_liquidity = + storage::get_total_liquidity(env).unwrap_or_else(|err| panic_with_error!(env, err)); let new_total = total_liquidity .checked_add(lp_amount) .unwrap_or_else(|| panic_with_error!(env, LiquidityPoolError::Overflow)); @@ -418,10 +438,13 @@ impl LiquidityPoolContract { // ------------------------------------------------------------------------- pub fn get_pool_stats(env: Env) -> PoolStats { - let total_liquidity = storage::get_total_liquidity(&env); - let locked_liquidity = storage::get_locked_liquidity(&env); + let total_liquidity = + storage::get_total_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); + let locked_liquidity = + storage::get_locked_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); let available_liquidity = total_liquidity.saturating_sub(locked_liquidity); - let total_shares = storage::get_total_shares(&env); + let total_shares = + storage::get_total_shares(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); // Share price in basis points: (total_liquidity × 10000) / total_shares let share_price = if total_shares == 0 { @@ -443,16 +466,18 @@ impl LiquidityPoolContract { } pub fn get_lp_shares(env: Env, provider: Address) -> i128 { - storage::get_lp_shares(&env, &provider) + storage::get_lp_shares(&env, &provider).unwrap_or_else(|err| panic_with_error!(&env, err)) } /// Calculate how many tokens `shares` are worth at the current share price. pub fn calculate_withdrawal(env: Env, shares: i128) -> i128 { - let total_shares = storage::get_total_shares(&env); + let total_shares = + storage::get_total_shares(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); if total_shares == 0 { return 0; } - let total_liquidity = storage::get_total_liquidity(&env); + let total_liquidity = + storage::get_total_liquidity(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); shares .checked_mul(total_liquidity) .and_then(|v| v.checked_div(total_shares)) @@ -464,7 +489,7 @@ impl LiquidityPoolContract { // ------------------------------------------------------------------------- fn require_admin(env: &Env, caller: &Address) { - let admin = storage::get_admin(env); + let admin = storage::get_admin(env).unwrap_or_else(|err| panic_with_error!(env, err)); if admin != *caller { panic_with_error!(env, LiquidityPoolError::NotAdmin); } @@ -472,6 +497,7 @@ impl LiquidityPoolContract { fn require_creditline(env: &Env, caller: &Address) { let creditline = storage::get_creditline(env) + .unwrap_or_else(|err| panic_with_error!(env, err)) .unwrap_or_else(|| panic_with_error!(env, LiquidityPoolError::NotCreditLine)); if creditline != *caller { panic_with_error!(env, LiquidityPoolError::NotCreditLine); @@ -479,7 +505,7 @@ impl LiquidityPoolContract { } fn enter_non_reentrant(env: &Env) { - if storage::is_reentrancy_locked(env) { + if storage::is_reentrancy_locked(env).unwrap_or_else(|err| panic_with_error!(env, err)) { panic_with_error!(env, LiquidityPoolError::ReentrancyDetected); } storage::set_reentrancy_locked(env, true); diff --git a/contracts/liquidity-pool-contract/src/storage.rs b/contracts/liquidity-pool-contract/src/storage.rs index ac45f45..cc35085 100644 --- a/contracts/liquidity-pool-contract/src/storage.rs +++ b/contracts/liquidity-pool-contract/src/storage.rs @@ -1,5 +1,7 @@ use soroban_sdk::{symbol_short, Address, Env, Symbol}; +use crate::errors::LiquidityPoolError; + // Instance storage keys pub const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); pub const TOKEN_KEY: Symbol = symbol_short!("TOKEN"); @@ -13,14 +15,16 @@ pub const REENTRANCY_LOCK_KEY: Symbol = symbol_short!("LOCKED"); // Persistent storage key prefix for LP shares pub const LP_SHARES_PREFIX: Symbol = symbol_short!("LPSHRS"); +pub const PERSISTENT_TTL_THRESHOLD: u32 = 1_036_800; +pub const PERSISTENT_TTL_EXTEND_TO: u32 = 2_073_600; // --- Admin --- -pub fn get_admin(env: &Env) -> Address { +pub fn get_admin(env: &Env) -> Result { env.storage() .instance() .get(&ADMIN_KEY) - .expect("Not initialized") + .ok_or(LiquidityPoolError::NotInitialized) } pub fn set_admin(env: &Env, admin: &Address) { @@ -33,11 +37,11 @@ pub fn has_admin(env: &Env) -> bool { // --- Token --- -pub fn get_token(env: &Env) -> Address { +pub fn get_token(env: &Env) -> Result { env.storage() .instance() .get(&TOKEN_KEY) - .expect("Not initialized") + .ok_or(LiquidityPoolError::NotInitialized) } pub fn set_token(env: &Env, token: &Address) { @@ -46,8 +50,8 @@ pub fn set_token(env: &Env, token: &Address) { // --- CreditLine --- -pub fn get_creditline(env: &Env) -> Option
{ - env.storage().instance().get(&CREDITLINE_KEY) +pub fn get_creditline(env: &Env) -> Result, LiquidityPoolError> { + Ok(env.storage().instance().get(&CREDITLINE_KEY)) } pub fn set_creditline(env: &Env, creditline: &Address) { @@ -56,8 +60,8 @@ pub fn set_creditline(env: &Env, creditline: &Address) { // --- Protocol Treasury --- -pub fn get_treasury(env: &Env) -> Option
{ - env.storage().instance().get(&TREASURY_KEY) +pub fn get_treasury(env: &Env) -> Result, LiquidityPoolError> { + Ok(env.storage().instance().get(&TREASURY_KEY)) } pub fn set_treasury(env: &Env, treasury: &Address) { @@ -66,8 +70,8 @@ pub fn set_treasury(env: &Env, treasury: &Address) { // --- Merchant Incentive Fund --- -pub fn get_merchant_fund(env: &Env) -> Option
{ - env.storage().instance().get(&MERCHANT_FUND_KEY) +pub fn get_merchant_fund(env: &Env) -> Result, LiquidityPoolError> { + Ok(env.storage().instance().get(&MERCHANT_FUND_KEY)) } pub fn set_merchant_fund(env: &Env, merchant_fund: &Address) { @@ -78,8 +82,8 @@ pub fn set_merchant_fund(env: &Env, merchant_fund: &Address) { // --- Total Shares --- -pub fn get_total_shares(env: &Env) -> i128 { - env.storage().instance().get(&TOTAL_SHARES_KEY).unwrap_or(0) +pub fn get_total_shares(env: &Env) -> Result { + Ok(env.storage().instance().get(&TOTAL_SHARES_KEY).unwrap_or(0)) } pub fn set_total_shares(env: &Env, total: i128) { @@ -88,11 +92,12 @@ pub fn set_total_shares(env: &Env, total: i128) { // --- Total Liquidity --- -pub fn get_total_liquidity(env: &Env) -> i128 { - env.storage() +pub fn get_total_liquidity(env: &Env) -> Result { + Ok(env + .storage() .instance() .get(&TOTAL_LIQUIDITY_KEY) - .unwrap_or(0) + .unwrap_or(0)) } pub fn set_total_liquidity(env: &Env, total: i128) { @@ -101,11 +106,12 @@ pub fn set_total_liquidity(env: &Env, total: i128) { // --- Locked Liquidity --- -pub fn get_locked_liquidity(env: &Env) -> i128 { - env.storage() +pub fn get_locked_liquidity(env: &Env) -> Result { + Ok(env + .storage() .instance() .get(&LOCKED_LIQUIDITY_KEY) - .unwrap_or(0) + .unwrap_or(0)) } pub fn set_locked_liquidity(env: &Env, locked: i128) { @@ -114,24 +120,28 @@ pub fn set_locked_liquidity(env: &Env, locked: i128) { // --- LP Shares (persistent per-provider) --- -pub fn get_lp_shares(env: &Env, provider: &Address) -> i128 { - env.storage() +pub fn get_lp_shares(env: &Env, provider: &Address) -> Result { + Ok(env + .storage() .persistent() .get(&(LP_SHARES_PREFIX, provider.clone())) - .unwrap_or(0) + .unwrap_or(0)) } pub fn set_lp_shares(env: &Env, provider: &Address, shares: i128) { + let key = (LP_SHARES_PREFIX, provider.clone()); + env.storage().persistent().set(&key, &shares); env.storage() .persistent() - .set(&(LP_SHARES_PREFIX, provider.clone()), &shares); + .extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); } -pub fn is_reentrancy_locked(env: &Env) -> bool { - env.storage() +pub fn is_reentrancy_locked(env: &Env) -> Result { + Ok(env + .storage() .instance() .get(&REENTRANCY_LOCK_KEY) - .unwrap_or(false) + .unwrap_or(false)) } pub fn set_reentrancy_locked(env: &Env, locked: bool) { diff --git a/contracts/liquidity-pool-contract/src/tests.rs b/contracts/liquidity-pool-contract/src/tests.rs index fb9a0b7..9d7cb50 100644 --- a/contracts/liquidity-pool-contract/src/tests.rs +++ b/contracts/liquidity-pool-contract/src/tests.rs @@ -1,4 +1,4 @@ -use crate::{LiquidityPoolContract, LiquidityPoolContractClient}; +use crate::{LiquidityPoolContract, LiquidityPoolContractClient, LiquidityPoolError}; use soroban_sdk::{ testutils::Address as _, token::{Client as TokenClient, StellarAssetClient}, @@ -79,6 +79,18 @@ fn test_initialize() { assert_eq!(t.client.get_admin(), t.admin); } +#[test] +fn test_get_admin_before_initialize_returns_typed_error() { + let env = Env::default(); + let contract_id = env.register(LiquidityPoolContract, ()); + let client = LiquidityPoolContractClient::new(&env, &contract_id); + + assert_eq!( + client.try_get_admin(), + Err(Ok(LiquidityPoolError::NotInitialized)) + ); +} + #[test] #[should_panic(expected = "Error(Contract, #2)")] fn test_initialize_twice_fails() { diff --git a/contracts/parameters-contract/src/access.rs b/contracts/parameters-contract/src/access.rs index f6860e2..183ab6f 100644 --- a/contracts/parameters-contract/src/access.rs +++ b/contracts/parameters-contract/src/access.rs @@ -3,7 +3,7 @@ use soroban_sdk::{panic_with_error, Address, Env}; use crate::{errors::ParametersError, storage}; pub fn require_admin(env: &Env, caller: &Address) { - let admin = storage::get_admin(env); + let admin = storage::get_admin(env).unwrap_or_else(|err| panic_with_error!(env, err)); if admin != *caller { panic_with_error!(env, ParametersError::NotAdmin); } diff --git a/contracts/parameters-contract/src/errors.rs b/contracts/parameters-contract/src/errors.rs index 12629da..0159c8c 100644 --- a/contracts/parameters-contract/src/errors.rs +++ b/contracts/parameters-contract/src/errors.rs @@ -7,4 +7,5 @@ pub enum ParametersError { AlreadyInitialized = 1, NotAdmin = 2, InvalidParameters = 3, + NotInitialized = 4, } diff --git a/contracts/parameters-contract/src/lib.rs b/contracts/parameters-contract/src/lib.rs index b268621..6f16ce0 100644 --- a/contracts/parameters-contract/src/lib.rs +++ b/contracts/parameters-contract/src/lib.rs @@ -33,19 +33,18 @@ impl ParametersContract { Self::initialize(env, admin, default_parameters()); } - /// Upgrade the contract WASM — admin only pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) { - let admin = storage::get_admin(&env); + let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); admin.require_auth(); env.deployer().update_current_contract_wasm(new_wasm_hash); } - pub fn get_admin(env: Env) -> Address { + pub fn get_admin(env: Env) -> Result { storage::get_admin(&env) } pub fn set_admin(env: Env, new_admin: Address) { - let old_admin = storage::get_admin(&env); + let old_admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err)); old_admin.require_auth(); access::require_admin(&env, &old_admin); @@ -53,7 +52,7 @@ impl ParametersContract { events::emit_admin_updated(&env, &old_admin, &new_admin); } - pub fn get_parameters(env: Env) -> ProtocolParameters { + pub fn get_parameters(env: Env) -> Result { storage::get_parameters(&env) } diff --git a/contracts/parameters-contract/src/storage.rs b/contracts/parameters-contract/src/storage.rs index 857fd9f..ad54818 100644 --- a/contracts/parameters-contract/src/storage.rs +++ b/contracts/parameters-contract/src/storage.rs @@ -1,5 +1,6 @@ use soroban_sdk::{symbol_short, Address, Env, Symbol}; +use crate::errors::ParametersError; use crate::types::ProtocolParameters; pub const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); @@ -9,22 +10,22 @@ pub fn has_admin(env: &Env) -> bool { env.storage().instance().has(&ADMIN_KEY) } -pub fn get_admin(env: &Env) -> Address { +pub fn get_admin(env: &Env) -> Result { env.storage() .instance() .get(&ADMIN_KEY) - .expect("parameters admin not set") + .ok_or(ParametersError::NotInitialized) } pub fn set_admin(env: &Env, admin: &Address) { env.storage().instance().set(&ADMIN_KEY, admin); } -pub fn get_parameters(env: &Env) -> ProtocolParameters { +pub fn get_parameters(env: &Env) -> Result { env.storage() .instance() .get(&PARAMS_KEY) - .expect("parameters not set") + .ok_or(ParametersError::NotInitialized) } pub fn set_parameters(env: &Env, params: &ProtocolParameters) { diff --git a/contracts/parameters-contract/src/tests.rs b/contracts/parameters-contract/src/tests.rs index ee83b24..c31f68e 100644 --- a/contracts/parameters-contract/src/tests.rs +++ b/contracts/parameters-contract/src/tests.rs @@ -1,4 +1,7 @@ -use crate::{default_parameters, ParametersContract, ParametersContractClient, ProtocolParameters}; +use crate::{ + default_parameters, ParametersContract, ParametersContractClient, ParametersError, + ProtocolParameters, +}; use soroban_sdk::{testutils::Address as _, Address, Env}; fn setup() -> (Env, ParametersContractClient<'static>, Address) { @@ -22,6 +25,26 @@ fn test_initialize_defaults() { assert_eq!(client.get_parameters(), default_parameters()); } +#[test] +fn test_get_admin_before_initialize_returns_typed_error() { + let (_env, client, _admin) = setup(); + + assert_eq!( + client.try_get_admin(), + Err(Ok(ParametersError::NotInitialized)) + ); +} + +#[test] +fn test_get_parameters_before_initialize_returns_typed_error() { + let (_env, client, _admin) = setup(); + + assert_eq!( + client.try_get_parameters(), + Err(Ok(ParametersError::NotInitialized)) + ); +} + #[test] fn test_update_parameters() { let (_env, client, admin) = setup(); diff --git a/contracts/reputation-contract/src/access.rs b/contracts/reputation-contract/src/access.rs index 52766f6..959c4c8 100644 --- a/contracts/reputation-contract/src/access.rs +++ b/contracts/reputation-contract/src/access.rs @@ -5,7 +5,7 @@ use crate::storage; /// Require that the given address is the admin, otherwise panic with NotAdmin error pub fn require_admin(env: &Env, caller: &Address) { - let admin = storage::get_admin(env); + let admin = storage::get_admin(env).unwrap_or_else(|err| panic_with_error!(env, err)); if caller != &admin { panic_with_error!(env, ReputationError::NotAdmin); @@ -14,7 +14,7 @@ pub fn require_admin(env: &Env, caller: &Address) { /// Require that the given address is an authorized updater, otherwise panic with NotUpdater error pub fn require_updater(env: &Env, addr: &Address) { - if !storage::is_updater(env, addr) { + if !storage::is_updater(env, addr).unwrap_or_else(|err| panic_with_error!(env, err)) { panic_with_error!(env, ReputationError::NotUpdater); } } diff --git a/contracts/reputation-contract/src/errors.rs b/contracts/reputation-contract/src/errors.rs index 9e4f6f0..fe8bc42 100644 --- a/contracts/reputation-contract/src/errors.rs +++ b/contracts/reputation-contract/src/errors.rs @@ -10,4 +10,5 @@ pub enum ReputationError { OutOfBounds = 3, Overflow = 4, Underflow = 5, + NotInitialized = 6, } diff --git a/contracts/reputation-contract/src/lib.rs b/contracts/reputation-contract/src/lib.rs index fb06703..be77c33 100644 --- a/contracts/reputation-contract/src/lib.rs +++ b/contracts/reputation-contract/src/lib.rs @@ -26,6 +26,7 @@ impl ReputationContract { /// Get the reputation score for a user pub fn get_score(env: Env, user: Address) -> u32 { storage::read_score(&env, &user) + .unwrap_or_else(|err| soroban_sdk::panic_with_error!(&env, err)) } /// Increase a user's reputation score by a given amount @@ -34,7 +35,8 @@ impl ReputationContract { updater.require_auth(); access::require_updater(&env, &updater); - let old_score = storage::read_score(&env, &user); + let old_score = storage::read_score(&env, &user) + .unwrap_or_else(|err| soroban_sdk::panic_with_error!(&env, err)); let new_score = old_score .checked_add(amount) .ok_or(ReputationError::Overflow) @@ -56,7 +58,8 @@ impl ReputationContract { updater.require_auth(); access::require_updater(&env, &updater); - let old_score = storage::read_score(&env, &user); + let old_score = storage::read_score(&env, &user) + .unwrap_or_else(|err| soroban_sdk::panic_with_error!(&env, err)); let new_score = match old_score.checked_sub(amount) { Some(score) => score, None => soroban_sdk::panic_with_error!(&env, ReputationError::Underflow), @@ -78,7 +81,8 @@ impl ReputationContract { soroban_sdk::panic_with_error!(&env, ReputationError::OutOfBounds); } - let old_score = storage::read_score(&env, &user); + let old_score = storage::read_score(&env, &user) + .unwrap_or_else(|err| soroban_sdk::panic_with_error!(&env, err)); storage::write_score(&env, &user, new_score); let reason = symbol_short!("set"); @@ -98,6 +102,7 @@ impl ReputationContract { /// Check if an address is an authorized updater pub fn is_updater(env: Env, addr: Address) -> bool { storage::is_updater(&env, &addr) + .unwrap_or_else(|err| soroban_sdk::panic_with_error!(&env, err)) } /// Set the admin address for this contract @@ -119,15 +124,14 @@ impl ReputationContract { } } - /// Get the current admin address - /// Upgrade the contract WASM — admin only pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) { - let admin = storage::get_admin(&env); + let admin = storage::get_admin(&env) + .unwrap_or_else(|err| soroban_sdk::panic_with_error!(&env, err)); admin.require_auth(); env.deployer().update_current_contract_wasm(new_wasm_hash); } - pub fn get_admin(env: Env) -> Address { + pub fn get_admin(env: Env) -> Result { storage::get_admin(&env) } } diff --git a/contracts/reputation-contract/src/storage.rs b/contracts/reputation-contract/src/storage.rs index 236e4da..47824f4 100644 --- a/contracts/reputation-contract/src/storage.rs +++ b/contracts/reputation-contract/src/storage.rs @@ -1,16 +1,18 @@ use soroban_sdk::{symbol_short, Address, Env, Map, Symbol}; +use crate::errors::ReputationError; + // Storage keys for the reputation contract pub const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); pub const UPDATERS_MAP: Symbol = symbol_short!("UPDATERS"); pub const SCORES_MAP: Symbol = symbol_short!("SCORES"); /// Get the admin address from storage -pub fn get_admin(env: &Env) -> Address { +pub fn get_admin(env: &Env) -> Result { env.storage() .instance() .get(&ADMIN_KEY) - .unwrap_or_else(|| panic!("Admin not set")) + .ok_or(ReputationError::NotInitialized) } /// Set the admin address in storage @@ -19,14 +21,14 @@ pub fn set_admin(env: &Env, admin: &Address) { } /// Read a user's reputation score from storage -pub fn read_score(env: &Env, user: &Address) -> u32 { +pub fn read_score(env: &Env, user: &Address) -> Result { let scores: Map = env .storage() .instance() .get(&SCORES_MAP) .unwrap_or_else(|| Map::new(env)); - scores.get(user.clone()).unwrap_or(0) + Ok(scores.get(user.clone()).unwrap_or(0)) } /// Write a user's reputation score to storage @@ -42,14 +44,14 @@ pub fn write_score(env: &Env, user: &Address, score: u32) { } /// Check if an address is an authorized updater -pub fn is_updater(env: &Env, addr: &Address) -> bool { +pub fn is_updater(env: &Env, addr: &Address) -> Result { let updaters: Map = env .storage() .instance() .get(&UPDATERS_MAP) .unwrap_or_else(|| Map::new(env)); - updaters.get(addr.clone()).unwrap_or(false) + Ok(updaters.get(addr.clone()).unwrap_or(false)) } /// Set an address as an authorized updater diff --git a/contracts/reputation-contract/src/tests.rs b/contracts/reputation-contract/src/tests.rs index 8a4600e..11d881b 100644 --- a/contracts/reputation-contract/src/tests.rs +++ b/contracts/reputation-contract/src/tests.rs @@ -4,8 +4,8 @@ use soroban_sdk::{ Address, Env, IntoVal, Symbol, Val, Vec, }; -use crate::ReputationContract; use crate::ReputationContractClient; +use crate::{ReputationContract, ReputationError}; /// Test: Sets the contract admin #[test] @@ -23,6 +23,19 @@ fn it_sets_admin() { assert_eq!(retrieved_admin, admin); } +#[test] +fn it_returns_typed_error_when_getting_admin_before_initialization() { + let env = Env::default(); + + let contract_id = env.register(ReputationContract, ()); + let client = ReputationContractClient::new(&env, &contract_id); + + assert_eq!( + client.try_get_admin(), + Err(Ok(ReputationError::NotInitialized)) + ); +} + /// Test: Gets the contract admin #[test] fn it_gets_admin() { diff --git a/contracts/vendor-registry-contract/src/access.rs b/contracts/vendor-registry-contract/src/access.rs index e7d5e2c..96ae65f 100644 --- a/contracts/vendor-registry-contract/src/access.rs +++ b/contracts/vendor-registry-contract/src/access.rs @@ -3,7 +3,7 @@ use soroban_sdk::{Address, Env}; pub fn require_admin(env: &Env, admin: &Address) -> Result<(), Error> { admin.require_auth(); - let stored_admin = storage::get_admin(env); + let stored_admin = storage::get_admin(env)?; if *admin != stored_admin { return Err(Error::Unauthorized); } diff --git a/contracts/vendor-registry-contract/src/errors.rs b/contracts/vendor-registry-contract/src/errors.rs index 732b25e..cebbe84 100644 --- a/contracts/vendor-registry-contract/src/errors.rs +++ b/contracts/vendor-registry-contract/src/errors.rs @@ -10,4 +10,5 @@ pub enum Error { VendorNotFound = 4, InvalidName = 5, Unauthorized = 6, + Overflow = 7, } diff --git a/contracts/vendor-registry-contract/src/lib.rs b/contracts/vendor-registry-contract/src/lib.rs index 5480f8d..f22d5ed 100644 --- a/contracts/vendor-registry-contract/src/lib.rs +++ b/contracts/vendor-registry-contract/src/lib.rs @@ -1,282 +1,142 @@ - #![no_std] - - mod access; - mod errors; - mod events; - mod storage; - mod types; - - #[cfg(test)] - mod tests; - - use errors::Error; - use soroban_sdk::{contract, contractimpl, Address, Env, String}; - use types::VendorInfo; - - // Export Error type for external use - pub use errors::Error as VendorRegistryError; - - #[contract] - pub struct VendorRegistryContract; - - #[contractimpl] - impl VendorRegistryContract { - /// Initializes the contract with an admin - pub fn initialize(env: Env, admin: Address) -> Result<(), Error> { - if storage::has_admin(&env) { - return Err(Error::AlreadyInitialized); - } storage::set_admin(&env, &admin); Ok(()) - } - - /// Registers a new vendor - pub fn register_vendor( - env: Env, - admin: Address, - vendor: Address, - name: String, - ) -> Result<(), Error> { - if !storage::has_admin(&env) { - return Err(Error::NotInitialized); - } - - - // ⬇️ Updated to handle the Result - access::require_admin(&env, &admin)?; - - if storage::has_vendor(&env, &vendor) { - return Err(Error::VendorAlreadyRegistered); - } - - - if name.len() == 0 || name.len() > 64 { - + if name.is_empty() || name.len() > 64 { return Err(Error::InvalidName); - } - - let info = VendorInfo { - name: name.clone(), - registration_date: env.ledger().timestamp(), - active: true, - total_sales: 0, - }; - - storage::set_vendor(&env, &vendor, &info); - - storage::increment_vendor_count(&env); - + storage::increment_vendor_count(&env)?; events::publish_vendor_registered(&env, vendor, name); - - Ok(()) - } - - /// Deactivates an existing vendor - pub fn deactivate_vendor(env: Env, admin: Address, vendor: Address) -> Result<(), Error> { - if !storage::has_admin(&env) { - return Err(Error::NotInitialized); - } - - access::require_admin(&env, &admin)?; - - - - let mut info = storage::get_vendor(&env, &vendor).ok_or(Error::VendorNotFound)?; - - - + let mut info = storage::get_vendor(&env, &vendor)?; info.active = false; - storage::set_vendor(&env, &vendor, &info); - events::publish_vendor_status(&env, vendor, false); - - Ok(()) - } - - /// Activates an existing vendor - pub fn activate_vendor(env: Env, admin: Address, vendor: Address) -> Result<(), Error> { - if !storage::has_admin(&env) { - return Err(Error::NotInitialized); - } - - access::require_admin(&env, &admin)?; - - - - let mut info = storage::get_vendor(&env, &vendor).ok_or(Error::VendorNotFound)?; - - - + let mut info = storage::get_vendor(&env, &vendor)?; info.active = true; - storage::set_vendor(&env, &vendor, &info); - events::publish_vendor_status(&env, vendor, true); - - Ok(()) - } - - /// Sets a vendor's active status (admin only). - /// Pass `active = true` to activate, `active = false` to deactivate. - pub fn set_vendor_status( - env: Env, - admin: Address, - vendor: Address, - active: bool, - ) -> Result<(), Error> { - if !storage::has_admin(&env) { - return Err(Error::NotInitialized); - } - - access::require_admin(&env, &admin)?; - - - - let mut info = storage::get_vendor(&env, &vendor).ok_or(Error::VendorNotFound)?; - - - + let mut info = storage::get_vendor(&env, &vendor)?; info.active = active; - storage::set_vendor(&env, &vendor, &info); - events::publish_vendor_status(&env, vendor, active); - - Ok(()) - } - - pub fn is_active(env: Env, vendor: Address) -> bool { - - if let Some(info) = storage::get_vendor(&env, &vendor) { - - info.active - - } else { - - false - - } - + storage::get_vendor(&env, &vendor) + .map(|info| info.active) + .unwrap_or(false) } - - pub fn get_vendor_info(env: Env, vendor: Address) -> Result { + if !storage::has_admin(&env) { + return Err(Error::NotInitialized); + } - storage::get_vendor(&env, &vendor).ok_or(Error::VendorNotFound) - + storage::get_vendor(&env, &vendor) } - - - pub fn get_vendor_count(env: Env) -> u64 { + pub fn get_vendor_count(env: Env) -> Result { + if !storage::has_admin(&env) { + return Err(Error::NotInitialized); + } storage::get_vendor_count(&env) - } - } diff --git a/contracts/vendor-registry-contract/src/storage.rs b/contracts/vendor-registry-contract/src/storage.rs index 8547504..292eb18 100644 --- a/contracts/vendor-registry-contract/src/storage.rs +++ b/contracts/vendor-registry-contract/src/storage.rs @@ -1,12 +1,21 @@ -use crate::types::{DataKey, VendorInfo}; +use crate::{ + errors::Error, + types::{DataKey, VendorInfo}, +}; use soroban_sdk::{Address, Env}; +pub const PERSISTENT_TTL_THRESHOLD: u32 = 1_036_800; +pub const PERSISTENT_TTL_EXTEND_TO: u32 = 2_073_600; + pub fn has_admin(env: &Env) -> bool { env.storage().instance().has(&DataKey::Admin) } -pub fn get_admin(env: &Env) -> Address { - env.storage().instance().get(&DataKey::Admin).unwrap() +pub fn get_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized) } pub fn set_admin(env: &Env, admin: &Address) { @@ -19,28 +28,38 @@ pub fn has_vendor(env: &Env, vendor: &Address) -> bool { .has(&DataKey::Vendor(vendor.clone())) } -pub fn get_vendor(env: &Env, vendor: &Address) -> Option { +pub fn get_vendor(env: &Env, vendor: &Address) -> Result { env.storage() .persistent() .get(&DataKey::Vendor(vendor.clone())) + .ok_or(Error::VendorNotFound) } pub fn set_vendor(env: &Env, vendor: &Address, info: &VendorInfo) { - env.storage() - .persistent() - .set(&DataKey::Vendor(vendor.clone()), info); + let key = DataKey::Vendor(vendor.clone()); + env.storage().persistent().set(&key, info); + extend_persistent_ttl(env, &key); } -pub fn get_vendor_count(env: &Env) -> u64 { - env.storage() +pub fn get_vendor_count(env: &Env) -> Result { + Ok(env + .storage() .persistent() .get(&DataKey::VendorCount) - .unwrap_or(0) + .unwrap_or(0)) +} + +pub fn increment_vendor_count(env: &Env) -> Result<(), Error> { + let count = get_vendor_count(env)?; + let next = count.checked_add(1).ok_or(Error::Overflow)?; + let key = DataKey::VendorCount; + env.storage().persistent().set(&key, &next); + extend_persistent_ttl(env, &key); + Ok(()) } -pub fn increment_vendor_count(env: &Env) { - let count = get_vendor_count(env); +fn extend_persistent_ttl(env: &Env, key: &DataKey) { env.storage() .persistent() - .set(&DataKey::VendorCount, &(count + 1)); + .extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); } diff --git a/contracts/vendor-registry-contract/src/tests.rs b/contracts/vendor-registry-contract/src/tests.rs index c77c5f9..a80b5bb 100644 --- a/contracts/vendor-registry-contract/src/tests.rs +++ b/contracts/vendor-registry-contract/src/tests.rs @@ -36,6 +36,18 @@ fn test_initialization() { assert!(res.is_err()); } +#[test] +fn test_get_vendor_count_before_initialize_returns_typed_error() { + let env = Env::default(); + let contract_id = env.register(VendorRegistryContract, ()); + let client = VendorRegistryContractClient::new(&env, &contract_id); + + assert_eq!( + client.try_get_vendor_count(), + Err(Ok(Error::NotInitialized)) + ); +} + #[test] fn test_registration_flow() { let env = Env::default();