diff --git a/acbu_minting/src/lib.rs b/acbu_minting/src/lib.rs index 1c37ba4f..c091b6e1 100644 --- a/acbu_minting/src/lib.rs +++ b/acbu_minting/src/lib.rs @@ -1298,6 +1298,14 @@ impl MintingContract { env.storage().instance().get(&DATA_KEY.admin).unwrap() } + /// Check if the contract has been initialized. + /// + /// Backend services can call this before invoking other functions to avoid + /// cryptic storage-not-found errors from uninitialized contracts. + pub fn is_initialized(env: Env) -> bool { + env.storage().instance().has(&SharedDataKey::Version) + } + /// Pending successor, if a transfer is in progress. pub fn get_pending_admin(env: Env) -> Option
{ env.storage().instance().get(&DATA_KEY.pending_admin) diff --git a/acbu_reserve_tracker/src/lib.rs b/acbu_reserve_tracker/src/lib.rs index 15b2fed9..309066e1 100644 --- a/acbu_reserve_tracker/src/lib.rs +++ b/acbu_reserve_tracker/src/lib.rs @@ -51,6 +51,7 @@ pub struct DataKey { pub version: Symbol, pub pending_admin: Symbol, pub pending_admin_eligible_at: Symbol, + pub last_verify_call: Symbol, } const DATA_KEY: DataKey = DataKey { @@ -62,6 +63,7 @@ const DATA_KEY: DataKey = DataKey { version: symbol_short!("VERSION"), pending_admin: symbol_short!("PEND_ADM"), pending_admin_eligible_at: symbol_short!("PEND_ETA"), + last_verify_call: symbol_short!("LAST_VFY"), }; /// Admin rotation timelock: the pending admin must wait this long before @@ -69,6 +71,10 @@ const DATA_KEY: DataKey = DataKey { /// or malicious transfer. const ADMIN_TIMELOCK_SECONDS: u64 = 86_400; +/// Rate limit for verify_reserves calls to prevent spam and ledger load. +/// Only one verify_reserves call is allowed per this cooldown period. +const VERIFY_RESERVES_COOLDOWN_SECONDS: u64 = 60; + contractmeta!(key = "version", val = "1"); #[contract] @@ -123,7 +129,20 @@ impl ReserveTrackerContract { /// `acbu_client.balance(&env.current_contract_address())`, which always returned 0 /// because the reserve tracker holds no ACBU — causing reserve checks to be skipped /// entirely (fix for issue #193). + /// + /// Rate limited to prevent spam: only one call per VERIFY_RESERVES_COOLDOWN_SECONDS. pub fn verify_reserves(env: Env) -> bool { + let now = env.ledger().timestamp(); + + let last_call: Option = env.storage().instance().get(&DATA_KEY.last_verify_call); + if let Some(last) = last_call { + if now.saturating_sub(last) < VERIFY_RESERVES_COOLDOWN_SECONDS { + return false; + } + } + + env.storage().instance().set(&DATA_KEY.last_verify_call, &now); + let total_acbu_supply = Self::get_total_supply_from_token(&env); if total_acbu_supply == 0 { env.panic_with_error(ReserveTrackerError::ZeroSupply); @@ -385,6 +404,14 @@ impl ReserveTrackerContract { env.storage().instance().get(&DATA_KEY.admin).unwrap() } + /// Check if the contract has been initialized. + /// + /// Backend services can call this before invoking other functions to avoid + /// cryptic storage-not-found errors from uninitialized contracts. + pub fn is_initialized(env: Env) -> bool { + env.storage().instance().has(&SharedDataKey::Version) + } + /// Pending successor, if a transfer is in progress. pub fn get_pending_admin(env: Env) -> Option
{ env.storage().instance().get(&DATA_KEY.pending_admin) diff --git a/acbu_savings_vault/src/lib.rs b/acbu_savings_vault/src/lib.rs index 6d610f40..b9a4d820 100644 --- a/acbu_savings_vault/src/lib.rs +++ b/acbu_savings_vault/src/lib.rs @@ -492,6 +492,64 @@ impl SavingsVault { ends } + /// Returns deposit lots for a specific term with pagination support. + /// + /// This function prevents exceeding Soroban's return value size limit by allowing + /// callers to request lots in chunks. Use `offset` to skip lots and `limit` to + /// specify the maximum number of lots to return. + /// + /// # Arguments + /// * `user` - The address whose deposit lots to retrieve + /// * `term_seconds` - The term bucket to query + /// * `offset` - Number of lots to skip from the beginning (0-indexed) + /// * `limit` - Maximum number of lots to return (0 defaults to max 100) + /// + /// # Returns + /// A paginated Vec of DepositLot structures + pub fn get_user_lots( + env: Env, + user: Address, + term_seconds: u64, + offset: u32, + limit: u32, + ) -> Vec { + let key = (DEPOSIT_KEY, user, term_seconds); + let lots: Vec = env + .storage() + .temporary() + .get(&key) + .unwrap_or(Vec::new(&env)); + + let total_lots = lots.len(); + let offset_u32 = u32::try_from(offset).unwrap_or(0); + + if offset_u32 >= total_lots { + return Vec::new(&env); + } + + let limit_u32 = if limit == 0 || limit > 100 { + 100 + } else { + limit + }; + + let end_idx = if offset_u32 + limit_u32 < total_lots { + offset_u32 + limit_u32 + } else { + total_lots + }; + let mut result = Vec::new(&env); + + for i in offset_u32..end_idx { + let idx = u32::try_from(i).unwrap_or(0); + if let Some(lot) = lots.get(idx) { + result.push_back(lot); + } + } + + result + } + /// Pause the vault, disabling deposits and withdrawals (admin only). pub fn pause(env: Env) { let admin = Self::load_admin(&env).unwrap_or_else(|e| env.panic_with_error(e)); @@ -554,6 +612,14 @@ impl SavingsVault { .unwrap_or(0) } + /// Check if the contract has been initialized. + /// + /// Backend services can call this before invoking other functions to avoid + /// cryptic storage-not-found errors from uninitialized contracts. + pub fn is_initialized(env: Env) -> bool { + env.storage().instance().has(&SharedDataKey::Version) + } + /// Stage a WASM upgrade to `new_wasm_hash`/`new_version` and start the upgrade /// timelock (admin only). `new_version` must exceed the current version. Apply /// later with [`Self::execute_upgrade`] or abort with [`Self::cancel_upgrade`]. diff --git a/acbu_savings_vault/tests/test.rs b/acbu_savings_vault/tests/test.rs index 03ef9526..a0a24f40 100644 --- a/acbu_savings_vault/tests/test.rs +++ b/acbu_savings_vault/tests/test.rs @@ -486,3 +486,48 @@ fn test_deposit_fails_and_does_not_write_to_storage_when_balance_insufficient() // Verify that NO deposit lot was recorded in storage for the user. assert_eq!(client.get_balance(&user, &term_seconds), 0); } + +/// Issue #336 regression test: get_user_lots with pagination prevents +/// exceeding Soroban's return value size limit when users have many deposit lots. +#[test] +fn test_get_user_lots_pagination() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let acbu_token = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + + let contract_id = env.register_contract(None, SavingsVault); + let client = SavingsVaultClient::new(&env, &contract_id); + + client.initialize(&admin, &acbu_token, &0i128, &0i128); + + let term_seconds = 3600u64; + let num_lots = 150; + + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &acbu_token); + token_admin.mint(&user, &(num_lots as i128 * 1_000_000)); + + for _ in 0..num_lots { + client.deposit(&user, &1_000_000i128, &term_seconds); + } + + let page1 = client.get_user_lots(&user, &term_seconds, &0u32, &100u32); + assert_eq!(page1.len(), 100, "First page should return 100 lots"); + + let page2 = client.get_user_lots(&user, &term_seconds, &100u32, &100u32); + assert_eq!(page2.len(), 50, "Second page should return remaining 50 lots"); + + let page3 = client.get_user_lots(&user, &term_seconds, &200u32, &100u32); + assert_eq!(page3.len(), 0, "Offset beyond available lots should return empty"); + + let page4 = client.get_user_lots(&user, &term_seconds, &0u32, &0u32); + assert_eq!(page4.len(), 100, "Limit 0 should default to max 100"); + + let page5 = client.get_user_lots(&user, &term_seconds, &0u32, &25u32); + assert_eq!(page5.len(), 25, "Custom limit of 25 should return 25 lots"); +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 81c26590..9387cc3c 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -398,3 +398,19 @@ pub fn calculate_deviation(value1: i128, value2: i128) -> i128 { }; (diff * BASIS_POINTS) / value2 } + +/// Check if a contract is initialized by verifying the version key exists. +/// +/// This helper function allows backend services and other contracts to check +/// if a contract has been initialized before calling its functions. This prevents +/// cryptic storage-not-found errors from uninitialized contracts. +/// +/// # Arguments +/// * `env` - The Soroban environment +/// +/// # Returns +/// * `true` if the contract is initialized (version key exists) +/// * `false` if the contract is not initialized +pub fn is_initialized(env: &soroban_sdk::Env) -> bool { + env.storage().instance().has(&DataKey::Version) +} diff --git a/shared/tests/test_utilities.rs b/shared/tests/test_utilities.rs new file mode 100644 index 00000000..7ad87d39 --- /dev/null +++ b/shared/tests/test_utilities.rs @@ -0,0 +1,158 @@ +#![cfg(test)] + +use shared::{calculate_amount_after_fee, calculate_fee, calculate_deviation, BASIS_POINTS}; + +#[test] +fn test_calculate_fee_zero_fee_rate() { + let amount = 10_000_000i128; + let fee_rate = 0i128; + assert_eq!(calculate_fee(amount, fee_rate), 0); +} + +#[test] +fn test_calculate_fee_zero_amount() { + let amount = 0i128; + let fee_rate = 300i128; + assert_eq!(calculate_fee(amount, fee_rate), 0); +} + +#[test] +fn test_calculate_fee_1_percent() { + let amount = 10_000_000i128; + let fee_rate = 100i128; + let expected = 100_000i128; + assert_eq!(calculate_fee(amount, fee_rate), expected); +} + +#[test] +fn test_calculate_fee_3_percent() { + let amount = 10_000_000i128; + let fee_rate = 300i128; + let expected = 300_000i128; + assert_eq!(calculate_fee(amount, fee_rate), expected); +} + +#[test] +fn test_calculate_fee_10_percent() { + let amount = 10_000_000i128; + let fee_rate = 1_000i128; + let expected = 1_000_000i128; + assert_eq!(calculate_fee(amount, fee_rate), expected); +} + +#[test] +fn test_calculate_fee_100_percent() { + let amount = 10_000_000i128; + let fee_rate = BASIS_POINTS; + let expected = 10_000_000i128; + assert_eq!(calculate_fee(amount, fee_rate), expected); +} + +#[test] +fn test_calculate_fee_large_amount() { + let amount = 1_000_000_000_000i128; + let fee_rate = 300i128; + let expected = 30_000_000_000i128; + assert_eq!(calculate_fee(amount, fee_rate), expected); +} + +#[test] +fn test_calculate_fee_small_amount() { + let amount = 1i128; + let fee_rate = 300i128; + assert_eq!(calculate_fee(amount, fee_rate), 0); +} + +#[test] +fn test_calculate_amount_after_fee_basic() { + let amount = 10_000_000i128; + let fee_rate = 300i128; + let fee = 300_000i128; + let expected = 9_700_000i128; + assert_eq!(calculate_amount_after_fee(amount, fee_rate), expected); +} + +#[test] +fn test_calculate_amount_after_fee_zero_fee() { + let amount = 10_000_000i128; + let fee_rate = 0i128; + assert_eq!(calculate_amount_after_fee(amount, fee_rate), amount); +} + +#[test] +fn test_calculate_amount_after_fee_zero_amount() { + let amount = 0i128; + let fee_rate = 300i128; + assert_eq!(calculate_amount_after_fee(amount, fee_rate), 0); +} + +#[test] +fn test_calculate_amount_after_fee_high_fee() { + let amount = 10_000_000i128; + let fee_rate = 5_000i128; + let expected = 5_000_000i128; + assert_eq!(calculate_amount_after_fee(amount, fee_rate), expected); +} + +#[test] +fn test_calculate_deviation_equal_values() { + let value1 = 100i128; + let value2 = 100i128; + assert_eq!(calculate_deviation(value1, value2), 0); +} + +#[test] +fn test_calculate_deviation_value1_greater() { + let value1 = 150i128; + let value2 = 100i128; + assert_eq!(calculate_deviation(value1, value2), 5_000); +} + +#[test] +fn test_calculate_deviation_value2_greater() { + let value1 = 100i128; + let value2 = 150i128; + assert_eq!(calculate_deviation(value1, value2), 3_333); +} + +#[test] +fn test_calculate_deviation_small_deviation() { + let value1 = 101i128; + let value2 = 100i128; + assert_eq!(calculate_deviation(value1, value2), 100); +} + +#[test] +fn test_calculate_deviation_large_deviation() { + let value1 = 200i128; + let value2 = 100i128; + assert_eq!(calculate_deviation(value1, value2), 10_000); +} + +#[test] +fn test_calculate_deviation_zero_divisor() { + let value1 = 100i128; + let value2 = 0i128; + assert_eq!(calculate_deviation(value1, value2), i128::MAX); +} + +#[test] +fn test_calculate_deviation_both_zero() { + let value1 = 0i128; + let value2 = 0i128; + assert_eq!(calculate_deviation(value1, value2), i128::MAX); +} + +#[test] +fn test_calculate_deviation_negative_value1() { + let value1 = -100i128; + let value2 = 100i128; + assert_eq!(calculate_deviation(value1, value2), 20_000); +} + +#[test] +fn test_calculate_deviation_7decimal_rates() { + let value1 = 1_050_000i128; + let value2 = 1_000_000i128; + assert_eq!(calculate_deviation(value1, value2), 500); +}