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
- Deposit into the vault, then take no further action against it for longer than Soroban's default persistent-entry TTL.
- Call
get_position(address) — it silently returns 0 (via unwrap_or(0)) once the entry archives, indistinguishable from having no position.
- 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 |
Description
None of the three contracts (
vault,blend-adapter,defindex-adapter) ever callextend_ttlorbump_instance_ttl. Soroban archives persistent and instance storage entries whose time-to-live lapses; persistent entries become inaccessible until an explicitRestoreFootprintoperation 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, andDataKey::Principalentries 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. Theirwithdrawthen fails at the footprint stage until they discover and pay for a restore they have no reason to know exists, andget_position'sunwrap_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
get_position(address)— it silently returns0(viaunwrap_or(0)) once the entry archives, indistinguishable from having no position.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
RestoreFootprintoperation.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
Transaction Details (if on-chain)
Logs / Screenshots
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:
Call
extend_instance(&env)as the first statement ofdeposit,withdraw,set_paused,set_admin,set_adapter, andmigrate_adapter; callextend_positionat the end ofdeposit/withdrawand insideget_position. Mirrorextend_instancein both adapters'deposit,withdraw, andaccrue.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:
Scope