Skip to content

[Bug] No TTL management anywhere; depositor state and the contracts themselves can be archived #553

Description

@collinsezedike

Description

None of the three contracts (vault, blend-adapter, defindex-adapter) ever call extend_ttl or bump_instance_ttl. Soroban archives persistent and instance storage entries whose time-to-live lapses; persistent entries become inaccessible until an explicit RestoreFootprint operation runs, and instance storage archival takes the entire contract out of service.

Two failure modes follow from this. First, a depositor's DataKey::Balance, DataKey::Entry, and DataKey::Principal entries are written on deposit and never touched again until the depositor next acts. A saver who deposits and leaves the position alone — the entire premise of a savings product aimed at long-horizon users — has their entries archived after the default persistent lifetime elapses. Their withdraw then fails at the footprint stage until they discover and pay for a restore they have no reason to know exists, and get_position's unwrap_or(0) makes the balance display as zero in the meantime, indistinguishable from total loss.

Second, every vault configuration value (ADMIN, ADAPTER, TOTAL_SH, ADPT_SH) and each adapter's pool/vault wiring lives in instance storage. If the contracts see no traffic for the instance lifetime, the whole deployment archives at once, and with no upgrade path recovery means a full redeployment plus manual reconstruction of every depositor position.

Steps to Reproduce

  1. Deposit into the vault, then take no further action against it for longer than Soroban's default persistent-entry TTL.
  2. Call get_position(address) — it silently returns 0 (via unwrap_or(0)) once the entry archives, indistinguishable from having no position.
  3. Attempt withdraw — it fails at the ledger footprint stage rather than with a typed contract error, since the entry no longer exists on-ledger.

Expected Behavior

A depositor who takes no action should still be able to read and withdraw their position at any point in the future without needing to know about or manually run a RestoreFootprint operation.

Actual Behavior

Positions silently archive and read as zero; the contract itself provides no mechanism to keep state alive or to signal that archival, not loss, is what happened.

Environment

Field Value
Network testnet / mainnet
Wallet N/A (contract-level)
Protocol affected None (all three contracts)
Browser (if frontend) N/A
Node.js version N/A
pnpm version N/A

Transaction Details (if on-chain)

  • Transaction hash: N/A — takes months to manifest under real traffic patterns, not yet observed
  • Ledger: N/A
  • Soroban RPC endpoint used: N/A

Logs / Screenshots

N/A — static analysis finding: zero occurrences of extend_ttl / bump_instance_ttl
across packages/contracts/vault, blend-adapter, defindex-adapter

Possible Cause / Fix

Add ledger-denominated TTL constants, extend instance storage on every state-changing entry point, and extend a depositor's three persistent entries whenever they're read or written:

// One ledger closes in roughly five seconds, so ~17 280 ledgers per day.
const DAY_IN_LEDGERS: u32 = 17_280;

const INSTANCE_BUMP: u32 = 30 * DAY_IN_LEDGERS;
const INSTANCE_THRESHOLD: u32 = INSTANCE_BUMP - DAY_IN_LEDGERS;

// Positions are bumped far harder than config: a saver who does nothing for
// a quarter is the target user, not an edge case.
const POSITION_BUMP: u32 = 120 * DAY_IN_LEDGERS;
const POSITION_THRESHOLD: u32 = POSITION_BUMP - 7 * DAY_IN_LEDGERS;

fn extend_instance(env: &Env) {
    env.storage().instance().extend_ttl(INSTANCE_THRESHOLD, INSTANCE_BUMP);
}

fn extend_position(env: &Env, address: &Address) {
    let storage = env.storage().persistent();
    for key in [
        DataKey::Balance(address.clone()),
        DataKey::Entry(address.clone()),
        DataKey::Principal(address.clone()),
    ] {
        if storage.has(&key) {
            storage.extend_ttl(&key, POSITION_THRESHOLD, POSITION_BUMP);
        }
    }
}

Call extend_instance(&env) as the first statement of deposit, withdraw, set_paused, set_admin, set_adapter, and migrate_adapter; call extend_position at the end of deposit/withdraw and inside get_position. Mirror extend_instance in both adapters' deposit, withdraw, and accrue.

Add a permissionless keeper entry point so a position can be kept alive without its owner transacting, which is what makes the guarantee real for a passive saver:

/// Permissionless. Lets a keeper (or anyone) refresh a depositor's state
/// lifetime without the depositor needing to submit a transaction. Extending
/// a TTL cannot change any balance, so no authorization is required.
pub fn extend_position_ttl(env: Env, address: Address) {
    extend_instance(&env);
    extend_position(&env, &address);
}

Scope

Field Value
Area Contracts
Protocol affected None
Network testnet, mainnet
Breaking change? No — additive, no ABI removal

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Stellar WaveIssues in the Stellar wave programbugSomething isn't workingcontractsInvolves writing or testing Rust/Soroban contracts in packages/contractshardComplex implementation spanning multiple packages or involving Soroban contractssecuritySecurity hardening, vulnerability fixes, or audit-related worksorobanInvolves Soroban smart contract invocations or Soroban RPC calls

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions