Skip to content
Open
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
4 changes: 4 additions & 0 deletions investment_vault/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,8 @@ pub struct WithdrawalWindowSet {

pub fn withdrawal_window_set(env: &Env, ledgers: u32) {
WithdrawalWindowSet { ledgers }.publish(env);
}

/// Emitted when the admin opens a funding round (#38).
#[contractevent]
pub struct FundingRoundStarted {}
Expand All @@ -518,6 +520,8 @@ pub struct FundingRoundEnded {}

pub fn funding_round_ended(env: &Env) {
FundingRoundEnded {}.publish(env);
}

/// Emitted when the admin changes the per-project investment cap (#32).
#[contractevent]
pub struct InvestmentCapSet {
Expand Down
50 changes: 44 additions & 6 deletions investment_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ use stellar_tokens::fungible::{Base, FungibleToken};
/// in share calculations and caps single-user concentration risk (#112).
const MAX_DEPOSIT: i128 = 1_000_000_000 * 10_000_000;

/// Persistent-storage TTL management (#317). Soroban's TTL model does not
/// auto-extend entries on write, so long-idle vaults would otherwise risk
/// archiving yield/queue/insurance/credit state. Persistent entries are
/// extended to 30 days whenever their remaining TTL drops below 1 day.
const PERSISTENT_TTL_THRESHOLD: u32 = 17280; // 1 day in ledgers (5s/ledger)
const PERSISTENT_TTL_EXTEND_TO: u32 = 518400; // 30 days in ledgers

/// Minimum deposit amount: 100 USDC (7 decimals) — prevents dust attacks that
/// could manipulate share price via rounding or inflate storage costs (#13).
const MIN_DEPOSIT: i128 = 100_0000000;
Expand Down Expand Up @@ -432,6 +439,9 @@ impl InvestmentVault {
env.storage()
.persistent()
.set(&VaultKey::InsuranceFund, &(ins + premium));
env.storage()
.persistent()
.extend_ttl(&VaultKey::InsuranceFund, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);

// Transfer management fee to recipient if non-zero (#7)
if fee_amount > 0 {
Expand Down Expand Up @@ -593,6 +603,9 @@ impl InvestmentVault {
usdc_owed: usdc_returned,
},
);
env.storage()
.persistent()
.extend_ttl(&VaultKey::QueueEntry(tail), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
env.storage()
.persistent()
.set(&VaultKey::QueueTail, &(tail + 1));
Expand Down Expand Up @@ -750,6 +763,9 @@ impl InvestmentVault {
env.storage()
.persistent()
.set(&VaultKey::YieldDebt(from.clone()), &accum);
env.storage()
.persistent()
.extend_ttl(&VaultKey::YieldDebt(from.clone()), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);

let usdc_sac: Address = env.storage().instance().get(&VaultKey::UsdcSac).unwrap();
let liquid = soroban_sdk::token::TokenClient::new(&env, &usdc_sac)
Expand Down Expand Up @@ -1127,6 +1143,8 @@ impl InvestmentVault {
.instance()
.get(&VaultKey::WithdrawalWindowLedgers)
.unwrap_or(1)
}

// ── Dynamic fee structure (#39) ───────────────────────────────────────────

/// Configure a two-tier volume-discount fee schedule for deposits (#39).
Expand Down Expand Up @@ -1179,6 +1197,8 @@ impl InvestmentVault {
.get(&VaultKey::VolumeTierFeeBps)
.unwrap_or(0);
(threshold, bps)
}

// ── Per-project investment cap (#32) ──────────────────────────────────────

/// Set the maximum total USDC the vault may invest in any single project. Admin-only.
Expand Down Expand Up @@ -1569,6 +1589,9 @@ impl InvestmentVault {
&VaultKey::CarbonCreditBalance(to.clone()),
&(prev + calc.credits),
);
env.storage()
.persistent()
.extend_ttl(&VaultKey::CarbonCreditBalance(to.clone()), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);

calc.credits
}
Expand Down Expand Up @@ -1601,10 +1624,16 @@ impl InvestmentVault {
&VaultKey::CarbonCreditBalance(from.clone()),
&(prev_from - amount),
);
env.storage()
.persistent()
.extend_ttl(&VaultKey::CarbonCreditBalance(from.clone()), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
env.storage().persistent().set(
&VaultKey::CarbonCreditBalance(to.clone()),
&(prev_to + amount),
);
env.storage()
.persistent()
.extend_ttl(&VaultKey::CarbonCreditBalance(to.clone()), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);

events::carbon_credits_transferred(&env, &from, &to, amount);
}
Expand Down Expand Up @@ -1668,6 +1697,9 @@ impl InvestmentVault {
env.storage()
.persistent()
.set(&VaultKey::ComplianceEvent(seq), &event);
env.storage()
.persistent()
.extend_ttl(&VaultKey::ComplianceEvent(seq), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
env.storage()
.instance()
.set(&VaultKey::ComplianceEventCounter, &seq);
Expand Down Expand Up @@ -1876,6 +1908,9 @@ fn fund_project_internal(env: Env, project_id: u32, amount: i128) {
env.storage()
.persistent()
.set(&VaultKey::ProjectInvestment(project_id), &(prev + amount));
env.storage()
.persistent()
.extend_ttl(&VaultKey::ProjectInvestment(project_id), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);

// Record the first funding timestamp for time-weighted returns (#34).
// Only set once — subsequent fund_project calls don't shift the origin.
Expand Down Expand Up @@ -1923,6 +1958,9 @@ fn receive_yield_internal(env: Env, from: Address, amount: i128) {
env.storage()
.persistent()
.set(&VaultKey::YieldPerShareAccum, &(accum + delta));
env.storage()
.persistent()
.extend_ttl(&VaultKey::YieldPerShareAccum, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);

events::yield_received(&env, &from, amount);
}
Expand Down Expand Up @@ -1954,6 +1992,9 @@ fn claim_insurance_internal(env: Env, project_id: u32, recipient: Address, amoun
env.storage()
.persistent()
.set(&VaultKey::InsuranceFund, &(fund - amount));
env.storage()
.persistent()
.extend_ttl(&VaultKey::InsuranceFund, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);

let usdc_sac: Address = env.storage().instance().get(&VaultKey::UsdcSac).unwrap();
soroban_sdk::token::TokenClient::new(&env, &usdc_sac).transfer(
Expand Down Expand Up @@ -2083,6 +2124,9 @@ fn lock_deposit(env: &Env, address: &Address) {
&VaultKey::LastDeposit(address.clone()),
&env.ledger().timestamp(),
);
env.storage()
.persistent()
.extend_ttl(&VaultKey::LastDeposit(address.clone()), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
}

/// Reject a withdrawal if the caller's deposit lock has not yet expired (#33).
Expand All @@ -2092,12 +2136,6 @@ fn check_deposit_lock(env: &Env, address: &Address) {
.persistent()
.get::<_, u64>(&VaultKey::LastDeposit(address.clone()))
{
let window: u32 = env
.storage()
.instance()
.get(&VaultKey::WithdrawalWindowLedgers)
.unwrap_or(1);
if env.ledger().sequence() < last_seq.saturating_add(window) {
if env.ledger().timestamp() < deposited_at + MIN_LOCK_PERIOD {
panic_with_error!(env, VaultError::DepositLocked);
}
Expand Down
2 changes: 2 additions & 0 deletions investment_vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2586,6 +2586,8 @@ fn test_volume_fee_tier_is_admin_only() {
},
}]);
s.vault_client.set_volume_fee_tier(&500_0000000i128, &50u32);
}

// ── #179: convert_to_shares() overflow guard on extremely large deposits ──────

/// Verify that `convert_to_shares` panics (rather than silently wrapping) when
Expand Down
4 changes: 2 additions & 2 deletions investment_vault/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ pub enum VaultError {
/// batch_deposit received an empty investor list (#178).
EmptyBatchDeposit = 41,
/// Share transfers are blocked because a funding round is active (#38).
FundingRoundActive = 41,
FundingRoundActive = 42,
/// Funding would push cumulative investment in a project above its per-project cap (#32).
InvestmentCapExceeded = 41,
InvestmentCapExceeded = 43,
}

#[contracttype]
Expand Down
Loading