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: 2 additions & 2 deletions adr/002-storage-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Rationale: instance storage is bumped automatically when any function is invoked
- `VaultKey::ProjectInvestment(id)` — USDC invested per project
- `VaultKey::TotalInvestments` — aggregate investment counter

Rationale: project records and investment ledgers must survive indefinitely. Rent is implicitly paid when the entries are read or written during normal operation; the CI enforces that contract size stays small so deployment + rent costs remain low.
Rationale: project records and investment ledgers must survive indefinitely. Writes do **not** auto-extend TTL beyond the network minimum; each persistent write must call `extend_ttl` (vault: `storage::set_persistent`; registry: `write_project`). The CI enforces that contract size stays small so deployment + rent costs remain low.

**Temporary storage** is not currently used. It would be appropriate for short-lived proof-of-intent or nonce entries if added in future.

Expand All @@ -41,5 +41,5 @@ Rationale: project records and investment ledgers must survive indefinitely. Ren
- Clean separation: adding a new config value → instance; adding a new per-entity record → persistent.

**Negative / trade-offs:**
- Persistent entries can be evicted if a project is never touched for a long time. Operators must either invoke the contract periodically or monitor for approaching TTL expiry.
- Persistent entries can still be evicted if a key is never rewritten for longer than its remaining TTL (~30 days after the last write under the current policy). Operators must invoke a state-changing entrypoint that rewrites the relevant keys, or restore archived entries.
- `ProjectCounter` in instance storage means it is trivially readable but also updated on every project creation, slightly increasing instance storage cost over time (Soroban charges for updated bytes).
43 changes: 42 additions & 1 deletion docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ All configuration and global aggregate caches are in instance storage.

### Persistent storage

Writes go through `storage::set_persistent`, which calls `extend_ttl` so rent is
refreshed on every write. See [TTL policy](#persistent-ttl-policy-317) below.

| Key | Rust type | Key bytes | Value bytes | Description |
|-----|-----------|-----------|-------------|-------------|
| `VaultKey::TotalInvestments` | `i128` | ~21 | 16 | Cumulative USDC sent to projects |
Expand All @@ -229,7 +232,45 @@ All configuration and global aggregate caches are in instance storage.
| `VaultKey::QueueEntry(u64)` | `QueuedClaim` | ~15 | ~48 | A queued redemption by index |
| `VaultKey::CarbonCreditBalance(Address)` | `i128` | ~30 | 16 | Carbon credit balance per address |
| `VaultKey::ComplianceEvent(u64)` | `ComplianceEventData` | ~22 | ~100+ | A compliance event record |
| `VaultKey::InsuranceClaimed(u32)` | `bool` | ~23 | 1 | One-time insurance claim flag per project |
| `VaultKey::LastDeposit(Address)` | `u64` | ~24 | 8 | Timestamp of the investor's last deposit (#33) |
| `VaultKey::InvestmentTimestamp(u32)` | `u64` | ~28 | 8 | Ledger timestamp of first funding for a project (#34) |
| `BridgeDataKey::TrustedEmitter(u32, BytesN<32>)` | `bool` | ~50 | 1 | Trusted Wormhole emitter flag |
| `BridgeDataKey::ConsumedVaa(BytesN<32>)` | `bool` | ~40 | 1 | Replay-guard flag for a consumed VAA |

### Persistent TTL policy (#317)

Soroban does **not** auto-extend persistent TTL on read or write beyond the
network's default minimum. A write that only calls `.set()` leaves the entry at
that minimum; a vault left idle for weeks can archive yield, queue, insurance,
and carbon-credit state, and the next access then requires an explicit restore.

Every persistent write in `investment_vault` therefore goes through
`storage::set_persistent`, which sets the value and then calls `extend_ttl`
with the policy below. Instance storage is unchanged — it is bumped
automatically on any contract invocation.

| Constant | Value (ledgers) | Wall-clock at 5 s/ledger | Meaning |
|----------|-----------------|--------------------------|---------|
| `TTL_EXTEND_THRESHOLD_LEDGERS` | 17 280 | ~1 day | Remaining TTL below which a write re-extends |
| `TTL_EXTEND_TO_LEDGERS` | 518 400 | ~30 days | Target live window after each write |

| Key | Why it must stay live | Refresh trigger |
|-----|----------------------|-----------------|
| `YieldPerShareAccum` | Global yield accounting; archival would strand unclaimed yield | `receive_yield` |
| `YieldDebt(Address)` | Per-investor claim checkpoint | `claim_yield` |
| `ProjectInvestment(u32)` | Per-project deployed capital | `fund_project` |
| `InsuranceFund` | Premium reserve for default claims | `deposit`, `claim_insurance` |
| `InsuranceClaimed(u32)` | Prevents double-paying a default | `claim_insurance` |
| `QueueEntry(u64)` / `QueueHead` / `QueueTail` | FIFO redemption claims | `withdraw` (enqueue), `claim` (dequeue) |
| `ComplianceEvent(u64)` | Audit trail | `record_compliance_event` |
| `CarbonCreditBalance(Address)` | Issued credit balances | `issue_carbon_credits`, `transfer_carbon_credits` |
| `LastDeposit(Address)` | Withdrawal lock origin | `deposit`, `bridge_mint`, share `transfer` |
| `TotalInvestments` / `TotalDeposited(Address)` / `InvestmentTimestamp(u32)` | Portfolio / returns accounting | `fund_project` / `deposit` |
| `TrustedEmitter` / `ConsumedVaa` | Bridge trust + replay guard | `set_trusted_emitter` / `complete_bridge_transfer` |

Reads do **not** extend TTL. An operator whose vault is idle for approaching
30 days should invoke a state-changing entrypoint that rewrites the relevant
keys so rent is paid before archival.

---

Expand Down
115 changes: 49 additions & 66 deletions investment_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,6 @@ const MIN_LOCK_PERIOD: u64 = 86_400;
/// Seconds in one year, used for time-weighted expected-returns (#34).
const ANNUAL_PERIOD_SECS: i128 = 31_536_000;

/// Minimum remaining TTL in ledgers before extending persistent storage rent (#388).
/// At 5 s/ledger this equals ~1 day (17 280 ledgers).
const TTL_EXTEND_THRESHOLD_LEDGERS: u32 = 17_280;

/// Target TTL in ledgers after extension (#388).
/// At 5 s/ledger this equals ~30 days (518 400 ledgers).
const TTL_EXTEND_TO_LEDGERS: u32 = 518_400;

mod composability;
mod events;
mod logic;
Expand Down Expand Up @@ -202,9 +194,7 @@ impl InvestmentVault {
.set(&VaultKey::StateVersion, &STATE_VERSION);
env.storage().instance().set(&VaultKey::UsdcSac, &usdc_sac);
env.storage().instance().set(&VaultKey::Registry, &registry);
env.storage()
.persistent()
.set(&VaultKey::TotalInvestments, &0i128);
storage::set_persistent(&env, &VaultKey::TotalInvestments, &0i128);
// CachedTotalAssets lives in instance storage: read on almost every call,
// auto-bumped with the instance TTL, no separate rent needed (#85).
env.storage()
Expand Down Expand Up @@ -325,8 +315,7 @@ impl InvestmentVault {
.unwrap_or(0);
if investment > 0 {
let project = registry.get_project(&i);
let score_rate =
project.credit_quality as i128 + project.green_impact as i128;
let score_rate = project.credit_quality as i128 + project.green_impact as i128;

let funded_at: u64 = env
.storage()
Expand All @@ -337,8 +326,7 @@ impl InvestmentVault {
if funded_at > 0 && now > funded_at {
// Time-weighted: accrue interest over elapsed time (#34).
let elapsed = (now - funded_at) as i128;
expected +=
investment * score_rate * elapsed / (200 * ANNUAL_PERIOD_SECS);
expected += investment * score_rate * elapsed / (200 * ANNUAL_PERIOD_SECS);
} else {
// Static fallback for pre-existing investments without a timestamp.
expected += investment * score_rate / 200;
Expand Down Expand Up @@ -463,9 +451,7 @@ impl InvestmentVault {
.persistent()
.get(&VaultKey::InsuranceFund)
.unwrap_or(0);
env.storage()
.persistent()
.set(&VaultKey::InsuranceFund, &(ins + premium));
storage::set_persistent(&env, &VaultKey::InsuranceFund, &(ins + premium));

// Transfer management fee to recipient if non-zero (#7)
if fee_amount > 0 {
Expand All @@ -483,11 +469,11 @@ impl InvestmentVault {
.persistent()
.get(&VaultKey::TotalDeposited(from.clone()))
.unwrap_or(0);
let key = VaultKey::TotalDeposited(from.clone());
env.storage()
.persistent()
.set(&key, &(prev_dep + usdc_amount));
env.storage().persistent().extend_ttl(&key, TTL_EXTEND_THRESHOLD_LEDGERS, TTL_EXTEND_TO_LEDGERS); // Add rent check/extend
storage::set_persistent(
&env,
&VaultKey::TotalDeposited(from.clone()),
&(prev_dep + usdc_amount),
);

// Update cached total assets: liquid increases by full usdc_amount (#81, #85)
let cached_ta: i128 = env
Expand Down Expand Up @@ -627,16 +613,15 @@ impl InvestmentVault {
.persistent()
.get(&VaultKey::QueueTail)
.unwrap_or(0);
env.storage().persistent().set(
storage::set_persistent(
&env,
&VaultKey::QueueEntry(tail),
&QueuedClaim {
from: from.clone(),
usdc_owed: usdc_returned,
},
);
env.storage()
.persistent()
.set(&VaultKey::QueueTail, &(tail + 1));
storage::set_persistent(&env, &VaultKey::QueueTail, &(tail + 1));
events::withdraw_queued(&env, &from, shares_amount, usdc_returned);
return 0;
}
Expand Down Expand Up @@ -721,7 +706,7 @@ impl InvestmentVault {
}

if idx != head {
env.storage().persistent().set(&VaultKey::QueueHead, &idx);
storage::set_persistent(&env, &VaultKey::QueueHead, &idx);
}

// Update cached total assets: liquid decreased by total_paid (#81, #85)
Expand Down Expand Up @@ -804,9 +789,7 @@ impl InvestmentVault {
}

// Update debt checkpoint before transfer (CEI)
env.storage()
.persistent()
.set(&VaultKey::YieldDebt(from.clone()), &accum);
storage::set_persistent(&env, &VaultKey::YieldDebt(from.clone()), &accum);

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 @@ -1216,9 +1199,7 @@ impl InvestmentVault {
env.storage()
.instance()
.remove(&VaultKey::VolumeTierThreshold);
env.storage()
.instance()
.remove(&VaultKey::VolumeTierFeeBps);
env.storage().instance().remove(&VaultKey::VolumeTierFeeBps);
return;
}
env.storage()
Expand Down Expand Up @@ -1259,7 +1240,11 @@ impl InvestmentVault {
if cap < 0 {
panic_with_error!(&env, VaultError::AmountNotPositive);
}
let stored_cap = if cap == 0 { MAX_INVESTMENT_PER_PROJECT } else { cap };
let stored_cap = if cap == 0 {
MAX_INVESTMENT_PER_PROJECT
} else {
cap
};
env.storage()
.instance()
.set(&VaultKey::MaxInvestmentPerProject, &stored_cap);
Expand All @@ -1283,7 +1268,11 @@ impl InvestmentVault {
.get(&VaultKey::ProjectInvestment(project_id))
.unwrap_or(0);
let remaining = cap - invested;
if remaining < 0 { 0 } else { remaining }
if remaining < 0 {
0
} else {
remaining
}
}

// ── Deposit lock-up expiry query (#33) ────────────────────────────────────
Expand Down Expand Up @@ -1376,7 +1365,8 @@ impl InvestmentVault {
) {
require_not_paused(&env);
require_current_state(&env);
env.storage().persistent().set(
storage::set_persistent(
&env,
&BridgeDataKey::TrustedEmitter(chain_id, emitter_address.clone()),
&trusted,
);
Expand Down Expand Up @@ -1455,7 +1445,8 @@ impl InvestmentVault {
// than silently ignored — otherwise a VAA about a different asset would
// be accepted and minted as HBS anyway if the emitter is ever reused for
// a multi-asset bridge.
if transfer.token_address != wormhole::address_to_bytes32(&env, &env.current_contract_address())
if transfer.token_address
!= wormhole::address_to_bytes32(&env, &env.current_contract_address())
{
panic_with_error!(&env, VaultError::BridgeTokenMismatch);
}
Expand All @@ -1467,9 +1458,7 @@ impl InvestmentVault {
{
panic_with_error!(&env, VaultError::VaaAlreadyConsumed);
}
env.storage()
.persistent()
.set(&BridgeDataKey::ConsumedVaa(digest), &true);
storage::set_persistent(&env, &BridgeDataKey::ConsumedVaa(digest), &true);

let to = wormhole::bytes32_to_address(&env, &transfer.recipient);
if Base::total_supply(&env) + transfer.amount > MAX_HBS_SUPPLY {
Expand Down Expand Up @@ -1689,7 +1678,8 @@ impl InvestmentVault {
.persistent()
.get(&VaultKey::CarbonCreditBalance(to.clone()))
.unwrap_or(0);
env.storage().persistent().set(
storage::set_persistent(
&env,
&VaultKey::CarbonCreditBalance(to.clone()),
&(prev + calc.credits),
);
Expand Down Expand Up @@ -1721,11 +1711,13 @@ impl InvestmentVault {
.get(&VaultKey::CarbonCreditBalance(to.clone()))
.unwrap_or(0);

env.storage().persistent().set(
storage::set_persistent(
&env,
&VaultKey::CarbonCreditBalance(from.clone()),
&(prev_from - amount),
);
env.storage().persistent().set(
storage::set_persistent(
&env,
&VaultKey::CarbonCreditBalance(to.clone()),
&(prev_to + amount),
);
Expand Down Expand Up @@ -1790,9 +1782,7 @@ impl InvestmentVault {
data,
};

env.storage()
.persistent()
.set(&VaultKey::ComplianceEvent(seq), &event);
storage::set_persistent(&env, &VaultKey::ComplianceEvent(seq), &event);
env.storage()
.instance()
.set(&VaultKey::ComplianceEventCounter, &seq);
Expand Down Expand Up @@ -2000,27 +1990,25 @@ fn fund_project_internal(env: Env, project_id: u32, amount: i128) {
.persistent()
.get(&VaultKey::ProjectInvestment(project_id))
.unwrap_or(0);
env.storage()
.persistent()
.set(&VaultKey::ProjectInvestment(project_id), &(prev + amount));
storage::set_persistent(
&env,
&VaultKey::ProjectInvestment(project_id),
&(prev + amount),
);

// Record the first funding timestamp for time-weighted returns (#34).
// Only set once — subsequent fund_project calls don't shift the origin.
let ts_key = VaultKey::InvestmentTimestamp(project_id);
if !env.storage().persistent().has(&ts_key) {
env.storage()
.persistent()
.set(&ts_key, &env.ledger().timestamp());
storage::set_persistent(&env, &ts_key, &env.ledger().timestamp());
}

let total_inv: i128 = env
.storage()
.persistent()
.get(&VaultKey::TotalInvestments)
.unwrap_or(0);
env.storage()
.persistent()
.set(&VaultKey::TotalInvestments, &(total_inv + amount));
storage::set_persistent(&env, &VaultKey::TotalInvestments, &(total_inv + amount));

events::project_funded(&env, project_id, amount, &project.owner);
}
Expand Down Expand Up @@ -2048,9 +2036,7 @@ fn receive_yield_internal(env: Env, from: Address, amount: i128) {
.persistent()
.get(&VaultKey::YieldPerShareAccum)
.unwrap_or(0);
env.storage()
.persistent()
.set(&VaultKey::YieldPerShareAccum, &(accum + delta));
storage::set_persistent(&env, &VaultKey::YieldPerShareAccum, &(accum + delta));

events::yield_received(&env, &from, amount);
}
Expand Down Expand Up @@ -2078,12 +2064,8 @@ fn claim_insurance_internal(env: Env, project_id: u32, recipient: Address, amoun
panic_with_error!(&env, VaultError::InsufficientInsurance);
}

env.storage()
.persistent()
.set(&VaultKey::InsuranceClaimed(project_id), &true);
env.storage()
.persistent()
.set(&VaultKey::InsuranceFund, &(fund - amount));
storage::set_persistent(&env, &VaultKey::InsuranceClaimed(project_id), &true);
storage::set_persistent(&env, &VaultKey::InsuranceFund, &(fund - amount));

let usdc_sac: Address = env.storage().instance().get(&VaultKey::UsdcSac).unwrap();
soroban_sdk::token::TokenClient::new(&env, &usdc_sac).transfer(
Expand Down Expand Up @@ -2204,7 +2186,8 @@ fn require_emergency_admin(env: &Env, caller: &Address) {
/// The lock prevents withdrawal for MIN_LOCK_PERIOD seconds after each deposit,
/// blocking flash-deposit-withdraw attacks that could manipulate share pricing.
fn lock_deposit(env: &Env, address: &Address) {
env.storage().persistent().set(
storage::set_persistent(
env,
&VaultKey::LastDeposit(address.clone()),
&env.ledger().timestamp(),
);
Expand Down
Loading
Loading