Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions acbu_minting/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address> {
env.storage().instance().get(&DATA_KEY.pending_admin)
Expand Down
27 changes: 27 additions & 0 deletions acbu_reserve_tracker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -62,13 +63,18 @@ 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
/// claiming ownership, giving the current admin a window to cancel a mistaken
/// 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]
Expand Down Expand Up @@ -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<u64> = 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);
Expand Down Expand Up @@ -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<Address> {
env.storage().instance().get(&DATA_KEY.pending_admin)
Expand Down
66 changes: 66 additions & 0 deletions acbu_savings_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DepositLot> {
let key = (DEPOSIT_KEY, user, term_seconds);
let lots: Vec<DepositLot> = 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);
Comment thread
nnennaokoye marked this conversation as resolved.
}
}

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));
Expand Down Expand Up @@ -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`].
Expand Down
45 changes: 45 additions & 0 deletions acbu_savings_vault/tests/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
16 changes: 16 additions & 0 deletions shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
158 changes: 158 additions & 0 deletions shared/tests/test_utilities.rs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading