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);
+}