diff --git a/README.md b/README.md index 0cef956..eaf2577 100644 --- a/README.md +++ b/README.md @@ -214,11 +214,15 @@ fn get_max_sponsors(env) -> Result; sponsor, a contributor, or an automated `mergefi-backend` job), and touches only the record's TTL, never `deadline` or `status`. Re-applies the same TTL scaling `extend_deadline` does, toward the record's - *currently-stored* `deadline`. Exists because a single TTL-extension call - is capped at Soroban's own persistent-entry ceiling (~1 year on a - typically-configured network) — a `deadline` set beyond that ceiling - needs this called again periodically to keep surviving toward it, since - no single call can cover unlimited future time. + *currently-stored* `deadline` — and also scales the contract's own + instance storage (Admin/Treasury/FeeBps/MaxSponsors) toward the same + target (#11), since an escrow surviving is useless if the instance + entry backing the whole contract archives out from under it. Exists + because a single TTL-extension call is capped at Soroban's own + persistent-entry ceiling (~1 year on a typically-configured network) — + a `deadline` set beyond that ceiling needs this called again + periodically to keep surviving toward it, since no single call can + cover unlimited future time. ### 2. `contracts/milestones` — `mergefi-milestones` @@ -279,6 +283,14 @@ fn get_max_sponsors(env) -> Result; goes back to the one sponsor, as before). See `docs/milestones-crowdfunding-design.md` for the proportional accounting and why it stays correct across allocate/release cycles. +- `keep_alive`: no authorization required — callable by anyone, and + touches only TTL, never milestone state. Scales the milestone's + persistent-storage TTL (its `Contribution` sub-records, and the + contract's own instance storage) toward the milestone's own stored + `deadline`, mirroring escrow's `keep_alive` (#11) — a milestone whose + release cycle runs long with no `allocate`/`release_issue` activity in + between no longer only gets the flat ~29-day bump every other call + applies. See `docs/ttl-archival-restoration-research.md`. ### 3. `contracts/maintenance-pool` — `mergefi-maintenance-pool` @@ -315,6 +327,16 @@ fn get_version(env) -> u32; merge the way escrow/milestones are; it's off-chain-adjudicated "maintenance credit"). Deducts the fee, rejects if `amount` exceeds the pool's current balance (`InsufficientBalance`). +- `keep_alive`: no authorization required — callable by anyone, and + touches only TTL, never pool state. Unlike escrow/milestones, a pool + has no deadline to scale a TTL bump toward — it's explicitly + open-ended/recurring — so instead of scaling, this always extends the + pool (its `Deposit` sub-records, and the contract's own instance + storage) as far as Soroban allows in a single call + (`env.ledger().max_live_until_ledger()`, ~1 year), so one + permissionless ping roughly once a year keeps a fully quiet pool alive + with zero deposit/withdraw activity (#11). See + `docs/ttl-archival-restoration-research.md`. ## Data models @@ -410,6 +432,26 @@ unreleasable, unrefundable — until someone submits a `RestoreFootprint` operation. Restoring an archived entry is a real Soroban operation but is not automated by anything in this repo's scripts today. +**`milestones`/`maintenance-pool` had the same #56-shaped gap, closed for +#11.** `milestones::keep_alive` previously applied the flat ~29-day bump +unconditionally even though `Milestone` stores its own `deadline` just like +`Escrow` does — it now scales toward that deadline exactly like escrow's +`keep_alive`/`extend_deadline` do. `maintenance-pool` has no deadline at +all (it's explicitly open-ended/recurring), so scaling isn't the right fix +there; instead `maintenance-pool::keep_alive` now always extends as far as +`env.ledger().max_live_until_ledger()` allows in a single call, so one +permissionless ping roughly once a year keeps a fully quiet pool alive with +zero deposit/withdraw activity. All three contracts' `keep_alive` also now +refresh the contract's own **instance storage** (Admin/Treasury/FeeBps/...) +toward the same target — previously only the record itself (and its +sub-records) were kept alive by `keep_alive`, leaving instance storage on +the old flat schedule regardless of how far any individual record's TTL had +been pushed out, which would eventually take the whole contract down even +for pools/milestones/escrows that were themselves being kept alive +correctly. See `docs/ttl-archival-restoration-research.md` for the full +Soroban TTL/archival/restoration research and per-contract time-window +analysis behind this fix. + ## Security model - **Admin / oracle authorization.** Two `Address` values are set at diff --git a/contracts/common/src/lib.rs b/contracts/common/src/lib.rs index b32a225..1fd2037 100644 --- a/contracts/common/src/lib.rs +++ b/contracts/common/src/lib.rs @@ -70,6 +70,35 @@ where /// here, not sub-day precision. See `extend_ttl_for_target`'s docs. const APPROX_SECONDS_PER_LEDGER: u64 = 5; +/// Soroban's real ceiling on how many ledgers a single `extend_ttl` call can +/// add ahead of the *current* sequence (`max_live_until_ledger() - sequence()`), +/// as a `u32` extend-to value. Shared by every "scale the TTL bump" helper +/// below so none of them can push a call past what the network actually +/// allows in one shot (MergeFi/contracts#56, MergeFi/contracts#11). +fn max_extend_to(env: &Env) -> u32 { + let current_sequence = env.ledger().sequence() as u64; + let ceiling = (env.ledger().max_live_until_ledger() as u64).saturating_sub(current_sequence); + u32::try_from(ceiling).unwrap_or(u32::MAX) +} + +/// Converts `target_timestamp - now` into an approximate ledger count at +/// `APPROX_SECONDS_PER_LEDGER`, capped at `max_extend_to`. A +/// `target_timestamp` at or before `now`, or one so far out it would +/// resolve to fewer ledgers than the existing flat bump, still resolves to +/// at least that flat 500_000 baseline — this only ever extends *further* +/// than `extend_ttl` would, never less. +fn scaled_extend_to(env: &Env, target_timestamp: u64) -> u32 { + let now = env.ledger().timestamp(); + let seconds_until_target = target_timestamp.saturating_sub(now); + let ledgers_until_target = seconds_until_target / APPROX_SECONDS_PER_LEDGER; + + // Baseline 500_000 mirrors extend_ttl's own extend_to — never do worse + // than the flat bump every other call site still gets. + let extend_to = ledgers_until_target.max(500_000); + let extend_to = u32::try_from(extend_to).unwrap_or(u32::MAX); + extend_to.min(max_extend_to(env)) +} + /// Extends a persistent entry's TTL to (approximately) survive until /// `target_timestamp`, not just the fixed ~29-day (`500_000`-ledger) bump /// `extend_ttl` always applies regardless of context. @@ -95,24 +124,12 @@ const APPROX_SECONDS_PER_LEDGER: u64 = 5; /// covered by a single call — nothing can, that ceiling is a real Soroban /// limit, not an implementation shortcut. A permissionless "keep alive" /// entry point that re-calls this periodically is the intended mitigation -/// for that residual gap; see `escrow::keep_alive`. +/// for that residual gap; see `escrow::keep_alive` / `milestones::keep_alive`. pub fn extend_ttl_for_target(env: &Env, key: &K, target_timestamp: u64) where K: IntoVal, { - let now = env.ledger().timestamp(); - let seconds_until_target = target_timestamp.saturating_sub(now); - let ledgers_until_target = seconds_until_target / APPROX_SECONDS_PER_LEDGER; - - let current_sequence = env.ledger().sequence() as u64; - let max_extend_to = - (env.ledger().max_live_until_ledger() as u64).saturating_sub(current_sequence); - - // Baseline 500_000 mirrors extend_ttl's own extend_to — never do worse - // than the flat bump every other call site still gets. - let extend_to = ledgers_until_target.max(500_000).min(max_extend_to); - let extend_to = u32::try_from(extend_to).unwrap_or(u32::MAX); - + let extend_to = scaled_extend_to(env, target_timestamp); // threshold == extend_to: "ensure at least extend_to ledgers remain," // rather than extend_ttl's "only bother once within threshold of // expiring" — a caller invoking this because a far-future target just @@ -121,3 +138,41 @@ where .persistent() .extend_ttl(key, extend_to, extend_to); } + +/// Same scaling as `extend_ttl_for_target`, applied to the calling +/// contract's *instance* storage instead of a keyed persistent entry. +/// Instance storage (Admin/Treasury/FeeBps/...) has no domain deadline of +/// its own, but a record-level deadline-scaled extension is only useful if +/// the instance storage backing the whole contract survives at least as +/// long — otherwise every other record in the contract becomes unreachable +/// once instance storage archives, regardless of any individual record's +/// own TTL (MergeFi/contracts#11). +pub fn extend_instance_ttl_for_target(env: &Env, target_timestamp: u64) { + let extend_to = scaled_extend_to(env, target_timestamp); + env.storage().instance().extend_ttl(extend_to, extend_to); +} + +/// Extends a persistent entry's TTL as far as Soroban's own persistent-entry +/// ceiling allows in a single call (`max_live_until_ledger()`), for records +/// with no natural deadline to scale toward at all — e.g. +/// `maintenance-pool`, which is explicitly open-ended/recurring rather than +/// tied to a bounded bounty or release-cycle deadline (MergeFi/contracts#11). +/// Where `extend_ttl_for_target` scales toward a *known* future point, this +/// is for records where the honest answer to "how far out" is +/// "indefinitely" — so it always asks for the maximum a single call can +/// grant. +pub fn extend_ttl_to_max(env: &Env, key: &K) +where + K: IntoVal, +{ + let extend_to = max_extend_to(env); + env.storage() + .persistent() + .extend_ttl(key, extend_to, extend_to); +} + +/// `extend_ttl_to_max`, applied to the calling contract's instance storage. +pub fn extend_instance_ttl_to_max(env: &Env) { + let extend_to = max_extend_to(env); + env.storage().instance().extend_ttl(extend_to, extend_to); +} diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index bd60fff..d07a58e 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -457,13 +457,19 @@ impl EscrowContract { escrow.deadline = new_deadline; env.storage().persistent().set(&key, &escrow); - extend_ttl_for_target(&env, &key, new_deadline.saturating_add(GRACE_PERIOD)); - extend_instance_ttl(&env); + let target = new_deadline.saturating_add(GRACE_PERIOD); + extend_ttl_for_target(&env, &key, target); + // Instance storage (Admin/Treasury/FeeBps/MaxSponsors) backs every + // escrow in this contract, not just this one — but a longer TTL is + // only ever beneficial, never harmful, so scaling it toward + // whichever deadline was just pushed out keeps the contract itself + // from archiving out from under a record that would otherwise + // survive (MergeFi/contracts#11). + extend_instance_ttl_for_target(&env, target); // Extend every contribution sub-record to the same target so they // can't archive ahead of the parent record when the deadline is // pushed far into the future. - let target = new_deadline.saturating_add(GRACE_PERIOD); for i in 0..escrow.contributor_count { extend_ttl_for_target(&env, &DataKey::Contribution(issue_id, i), target); } @@ -486,7 +492,16 @@ impl EscrowContract { /// will archive independently of the parent `Escrow` record if never /// re-extended, silently breaking `refund` for long-lived escrows where /// older contributions have fallen off-ledger while the parent stayed - /// alive via prior `keep_alive` / `extend_deadline` calls. + /// alive via prior `keep_alive` / `extend_deadline` calls. Also refreshes + /// the contract's own instance storage toward the same target, since an + /// escrow record surviving is useless if the contract's Admin/Treasury/ + /// FeeBps instance entry archives out from under it (MergeFi/contracts#11). + /// + /// This is the intended way to keep a genuinely idle escrow (funded with + /// a far-future `deadline` that nobody has touched since) from + /// archiving: called periodically — by the sponsor, any contributor, or + /// an automated `mergefi-backend` job — at least once within any + /// ~1-year window, it needs no deposit/release/refund activity at all. /// /// Callable by anyone and needs no authorization: it can only ever keep /// records alive longer, never change what they hold or who they pay. @@ -500,6 +515,7 @@ impl EscrowContract { let target = escrow.deadline.saturating_add(GRACE_PERIOD); extend_ttl_for_target(&env, &key, target); + extend_instance_ttl_for_target(&env, target); // Keep every contribution sub-record alive toward the same target so // they can't archive independently while the parent Escrow lives on. @@ -704,3 +720,9 @@ pub(crate) fn extend_instance_ttl(env: &Env) { pub(crate) fn extend_ttl_for_target(env: &Env, key: &DataKey, target_timestamp: u64) { mergefi_common::extend_ttl_for_target(env, key, target_timestamp); } + +/// `extend_ttl_for_target`, applied to this contract's instance storage +/// (#11) — see `mergefi_common::extend_instance_ttl_for_target`. +pub(crate) fn extend_instance_ttl_for_target(env: &Env, target_timestamp: u64) { + mergefi_common::extend_instance_ttl_for_target(env, target_timestamp); +} diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index c9ac350..e0af62c 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -2,7 +2,10 @@ use super::*; use soroban_sdk::{ - testutils::{storage::Persistent as _, Address as _, Ledger}, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, Ledger, + }, token, vec, Address, Env, }; @@ -1342,6 +1345,140 @@ fn test_keep_alive_rejects_nonexistent_escrow() { assert_eq!(err, Err(Ok(Error::EscrowNotFound))); } +// ── instance storage kept alive alongside records (#11) ─────────────────── +// +// keep_alive previously only refreshed the Escrow + Contribution records — +// the contract's own instance storage (Admin/Treasury/FeeBps/MaxSponsors) +// still only got the flat ~29-day bump from whichever active call last +// touched it. A quiet escrow kept alive purely via periodic keep_alive +// pings would eventually lose instance storage and take the whole contract +// down with it, even though every individual escrow record survived fine. + +fn instance_ttl(env: &Env, contract_id: &Address) -> u32 { + env.as_contract(contract_id, || env.storage().instance().get_ttl()) +} + +#[test] +fn test_keep_alive_also_scales_instance_ttl_toward_deadline() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &10_000_000_000i128); + + let far_future_deadline: u64 = 200 * 24 * 60 * 60; + client.fund( + &308u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &far_future_deadline, + &None, + ); + + client.keep_alive(&308u64); + + let expected_ledgers = (far_future_deadline + GRACE_PERIOD) / 5; + assert_eq!(instance_ttl(&env, &contract_id), expected_ledgers as u32); +} + +// ── long-idle survival via periodic keep_alive (#11) ─────────────────────── +// +// These simulate "an escrow whose deadline is far in the future and nobody +// touches it" (issue #11's own framing) by advancing the ledger sequence +// number directly via testutils::Ledger rather than waiting in real time. + +#[test] +fn test_escrow_survives_long_idle_period_via_periodic_keep_alive() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &10_000_000_000i128); + + // A one-year-out deadline: the sponsor expects this bounty to still be + // payable a year from now, far beyond the ~29 days `fund` alone grants. + let one_year_secs: u64 = 365 * 24 * 60 * 60; + client.fund( + &309u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &one_year_secs, + &None, + ); + + // Nobody contributes/releases/refunds again. The only activity for the + // rest of the escrow's life is a permissionless keep_alive ping (e.g. + // from an automated mergefi-backend job) every so often — each jump is + // comfortably larger than the old flat 500_000-ledger (~29-day) bump, + // which would have let this escrow archive after the very first one. + let ledgers_per_ping: u32 = 900_000; // ~52 days at 5s/ledger + for _ in 0..5 { + env.ledger().with_mut(|li| li.sequence_number += ledgers_per_ping); + client.keep_alive(&309u64); + } + // Total simulated idle time: ~260 days across 5 pings, roughly 9x what + // the flat bump alone would have survived between any two of them. + + let escrow = client.get_escrow(&309u64); + assert_eq!(escrow.amount, 10_000_000_000i128); + assert_eq!(escrow.status, EscrowStatus::Funded); + // A healthy TTL, not the bare protocol minimum — see the contrasting + // test below for what "not kept alive" looks like under the same gap. + assert!(escrow_ttl(&env, &contract_id, 309u64) > 4096); +} + +#[test] +fn test_escrow_ttl_decays_to_protocol_minimum_without_periodic_keep_alive() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &10_000_000_000i128); + + // fund() alone only grants the flat ~29-day (500_000-ledger) bump, and + // nothing ever calls keep_alive or extend_deadline afterward — exactly + // the "far-future deadline nobody touches" scenario issue #11 describes. + client.fund( + &310u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &(365 * 24 * 60 * 60), + &None, + ); + + // Advance well past the flat bump with zero further interaction. + env.ledger().with_mut(|li| li.sequence_number += 600_000); + + // NOTE on what this assertion does and doesn't prove: soroban_sdk's + // default test `Env` runs its storage host in "recording" footprint + // mode (the mode used to auto-discover a transaction's footprint), which + // silently revives an expired persistent entry to the network's bare + // minimum TTL (`min_persistent_entry_ttl`, 4096 ledgers here) the moment + // anything reads it — it does not hard-fail the read the way a real + // network's "enforcing" mode would for a genuinely archived entry + // lacking an explicit RestoreFootprint operation beforehand. So this + // test can't reproduce an outright panic/error here; what it *can* + // prove is that, without the fix's periodic keep_alive, this record's + // safety margin has collapsed all the way down to that bare minimum — + // the same floor a real restored entry would start from — rather than + // the healthy, months-out TTL periodic keep_alive maintains in the test + // above. + let ttl = escrow_ttl(&env, &contract_id, 310u64); + assert_eq!(ttl, 4096 - 1); +} + // ── target amount (issue #144) ──────────────────────────────────────────── #[test] diff --git a/contracts/maintenance-pool/src/lib.rs b/contracts/maintenance-pool/src/lib.rs index fa83f7c..e478016 100644 --- a/contracts/maintenance-pool/src/lib.rs +++ b/contracts/maintenance-pool/src/lib.rs @@ -273,23 +273,27 @@ impl MaintenancePoolContract { } /// Permissionless TTL refresh: re-extends `pool_id`'s persistent-storage - /// TTL (and those of all its `Deposit` sub-records) by the standard flat - /// bump, without touching any pool state. Exists because individual - /// deposit entries have their own TTL and will archive independently of - /// the parent `MaintenancePool` record if never re-extended. + /// TTL (and those of all its `Deposit` sub-records, and the contract's + /// own instance storage) as far as Soroban allows in a single call — + /// not the standard flat ~29-day bump — without touching any pool + /// state. Exists because individual deposit entries have their own TTL + /// and will archive independently of the parent `MaintenancePool` + /// record if never re-extended. /// /// This matters most for the maintenance pool because it is explicitly - /// designed to be open-ended and long-lived ("it never finishes") — the - /// contract with the longest expected lifetime also has the largest - /// accumulation of historical deposit records, each of which needs its - /// own TTL refreshed to stay queryable. Without periodic `keep_alive` - /// calls, `get_deposit` silently breaks for older records even while the - /// pool itself remains fully active. - /// - /// Unlike `escrow::keep_alive`, pools have no deadline timestamp, so - /// this applies the flat ~29-day bump rather than a deadline-scaled - /// extension. Call it at least once within any ~29-day window to keep - /// the full deposit history alive and enumerable. + /// designed to be open-ended and long-lived ("it never finishes") — a + /// pool for a quiet repo can plausibly go a year or more between + /// maintainer draw-downs. Unlike `escrow`/`milestones`, a pool has no + /// deadline to scale a TTL bump toward, so scaling isn't the fix here + /// (MergeFi/contracts#11): instead this always requests the maximum a + /// single `extend_ttl` call can grant (`max_live_until_ledger()`, + /// roughly a year on a typically-configured network), so one + /// permissionless ping — by any sponsor, maintainer, or an automated + /// `mergefi-backend` cron job — is enough to keep the pool, its full + /// deposit history, and the contract itself alive through a genuinely + /// long idle period with zero deposit/withdraw activity. Call it again + /// at least once within that ceiling's own window for the pool to + /// survive indefinitely. /// /// Callable by anyone and needs no authorization: it can only ever keep /// records alive longer, never change what they hold. @@ -301,12 +305,13 @@ impl MaintenancePoolContract { .get(&pkey) .ok_or(Error::PoolNotFound)?; - extend_ttl(&env, &pkey); + extend_ttl_to_max(&env, &pkey); + extend_instance_ttl_to_max(&env); // Keep every deposit sub-record alive alongside the parent so the // full contribution history advertised by the README stays queryable. for i in 0..pool.deposit_count { - extend_ttl(&env, &DataKey::Deposit(pool_id, i)); + extend_ttl_to_max(&env, &DataKey::Deposit(pool_id, i)); } Ok(()) @@ -516,3 +521,16 @@ fn extend_ttl(env: &Env, key: &DataKey) { fn extend_instance_ttl(env: &Env) { env.storage().instance().extend_ttl(100_000, 500_000); } + +/// Extends the TTL of a persistent entry as far as Soroban's own +/// persistent-entry ceiling allows in a single call — see +/// `mergefi_common::extend_ttl_to_max` (#11). +fn extend_ttl_to_max(env: &Env, key: &DataKey) { + mergefi_common::extend_ttl_to_max(env, key); +} + +/// `extend_ttl_to_max`, applied to this contract's instance storage (#11) +/// — see `mergefi_common::extend_instance_ttl_to_max`. +fn extend_instance_ttl_to_max(env: &Env) { + mergefi_common::extend_instance_ttl_to_max(env); +} diff --git a/contracts/maintenance-pool/src/test.rs b/contracts/maintenance-pool/src/test.rs index 8237822..3ffdeb9 100644 --- a/contracts/maintenance-pool/src/test.rs +++ b/contracts/maintenance-pool/src/test.rs @@ -2,7 +2,10 @@ use super::*; use soroban_sdk::{ - testutils::{Address as _, Ledger as _}, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, Ledger as _, + }, token, Address, Env, }; @@ -656,3 +659,139 @@ fn test_deposit_rejects_when_deposit_count_would_overflow() main let err = client.try_deposit(&10u64, &sponsor, &token_addr, &100i128); assert_eq!(err, Err(Ok(Error::DepositCountOverflow))); main } + +// ── keep_alive extends to the network TTL ceiling (#11) ──────────────────── +// +// Unlike escrow/milestones, a maintenance pool has no deadline to scale a +// TTL bump toward — it's explicitly open-ended/recurring ("it never +// finishes"). keep_alive previously applied the same flat ~29-day +// (500_000-ledger) bump as every other call site, which is fundamentally +// mismatched to that lifecycle: a pool for a quiet repo can easily go a +// year between maintainer draw-downs. The fix always requests the maximum +// a single `extend_ttl` call can grant instead. + +fn pool_ttl(env: &Env, contract_id: &Address, pool_id: u64) -> u32 { + env.as_contract(contract_id, || { + env.storage().persistent().get_ttl(&DataKey::Pool(pool_id)) + }) +} + +fn instance_ttl(env: &Env, contract_id: &Address) -> u32 { + env.as_contract(contract_id, || env.storage().instance().get_ttl()) +} + +#[test] +fn test_keep_alive_extends_ttl_to_the_network_max_ceiling() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + let contract_id = client.address.clone(); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000_000_000i128); + + client.deposit(&501u64, &sponsor, &token_addr, &1_000_000_000i128); + + let before = client.get_pool(&501u64); + client.keep_alive(&501u64); + let after = client.get_pool(&501u64); + assert_eq!(before, after, "keep_alive must not change pool state"); + + let max_extend_to = env.ledger().max_live_until_ledger() - env.ledger().sequence(); + assert_eq!(pool_ttl(&env, &contract_id, 501u64), max_extend_to); + // Comfortably beyond the old flat 500_000-ledger bump — this is the + // network's actual ~1-year ceiling, not a tuned constant. + assert!(max_extend_to > 500_000); + + // Instance storage (Admin/Treasury/FeeBps) is extended the same way, so + // the contract itself can't archive out from under a pool kept alive + // purely via periodic pings. + assert_eq!(instance_ttl(&env, &contract_id), max_extend_to); +} + +#[test] +fn test_keep_alive_rejects_nonexistent_pool() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let err = client.try_keep_alive(&999u64); + assert_eq!(err, Err(Ok(Error::PoolNotFound))); +} + +// ── long-idle survival via periodic keep_alive (#11) ─────────────────────── +// +// Simulates the exact scenario issue #11 names as the hardest case: "a +// maintenance pool for a quiet repo that goes a year without a maintainer +// draw-down" — by advancing the ledger sequence number directly via +// testutils::Ledger rather than waiting in real time. + +#[test] +fn test_pool_survives_a_year_of_inactivity_via_periodic_keep_alive() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + let contract_id = client.address.clone(); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000_000_000i128); + + client.deposit(&502u64, &sponsor, &token_addr, &1_000_000_000i128); + + // No further deposit/withdraw ever happens — a maintainer's own + // permissionless keep_alive ping (or an automated mergefi-backend cron) + // is the only activity, spaced far enough apart that a flat ~29-day + // bump would never have survived between two consecutive pings. + let ledgers_per_ping: u32 = 6_000_000; // ~347 days at 5s/ledger + for _ in 0..3 { + env.ledger().with_mut(|li| li.sequence_number += ledgers_per_ping); + client.keep_alive(&502u64); + } + // Total simulated idle time: ~3 years across 3 pings with zero + // deposit/withdraw activity in between. + + let pool = client.get_pool(&502u64); + assert_eq!(pool.balance, 1_000_000_000i128); + assert_eq!(pool.deposit_count, 1); + // A healthy TTL, not the bare protocol minimum — see the contrasting + // test below for what "not kept alive" looks like over the same gap. + assert!(pool_ttl(&env, &contract_id, 502u64) > 4096); + + // The full deposit history is still individually queryable too. + let deposit = client.get_deposit(&502u64, &0u32); + assert_eq!(deposit.sponsor, sponsor); + assert_eq!(deposit.amount, 1_000_000_000i128); +} + +#[test] +fn test_pool_ttl_decays_to_protocol_minimum_without_periodic_keep_alive() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + let contract_id = client.address.clone(); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &1_000_000_000i128); + + // deposit() alone only grants the flat ~29-day bump, and nothing calls + // keep_alive afterward — a quiet repo's pool sitting untouched. + client.deposit(&503u64, &sponsor, &token_addr, &1_000_000_000i128); + + env.ledger().with_mut(|li| li.sequence_number += 600_000); + + // See the equivalent escrow/milestones tests for why this asserts a + // decayed-to-floor TTL rather than a hard panic: soroban_sdk's default + // test Env runs its storage host in "recording" footprint mode, which + // silently revives an expired persistent entry to the network's bare + // minimum TTL on next touch rather than hard-failing the way a real + // network's "enforcing" mode would for a genuinely archived entry + // lacking an explicit RestoreFootprint operation. + let ttl = pool_ttl(&env, &contract_id, 503u64); + assert_eq!(ttl, 4096 - 1); +} diff --git a/contracts/milestones/src/lib.rs b/contracts/milestones/src/lib.rs index 031925e..e892c9f 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -552,19 +552,25 @@ impl MilestonesContract { } /// Permissionless TTL refresh: re-extends `milestone_id`'s - /// persistent-storage TTL (and those of all its `Contribution` - /// sub-records) by the standard flat bump, without touching any - /// milestone state. Exists because individual contribution entries - /// have their own TTL and will archive independently of the parent - /// `Milestone` record if never re-extended — for a long-lived - /// milestone whose mutating calls (allocate/release_issue) don't touch - /// older contributions, those records can silently fall off-ledger. + /// persistent-storage TTL, scaled toward the milestone's own stored + /// `deadline` (plus `GRACE_PERIOD`, mirroring `escrow::keep_alive`) — + /// not just the standard flat ~29-day bump, which bought no more actual + /// on-chain survivability than a near-future deadline for a milestone + /// whose release cycle runs longer (MergeFi/contracts#11, the same gap + /// MergeFi/contracts#56 fixed for escrow). Also refreshes every + /// `Contribution` sub-record and the contract's own instance storage + /// toward the same target — individual contribution entries have their + /// own TTL and will archive independently of the parent `Milestone` + /// record if never re-extended, and a milestone record surviving is + /// useless if the contract's Admin/Treasury/FeeBps instance entry + /// archives out from under it. /// - /// Unlike `escrow::keep_alive`, milestones have no natural deadline - /// timestamp, so this applies the flat ~29-day bump rather than a - /// deadline-scaled extension. Call it periodically (at least once - /// within any ~29-day window) to keep a long-lived milestone and all - /// its contribution history alive. + /// This is the intended way to keep a genuinely idle milestone (created + /// with a far-future `deadline` that nobody has touched via + /// allocate/release_issue since) from archiving: called periodically — + /// by any contributor or an automated `mergefi-backend` job — at least + /// once within any ~1-year window, it needs no allocation/release + /// activity at all. /// /// Callable by anyone and needs no authorization: it can only ever keep /// records alive longer, never change what they hold. @@ -576,12 +582,14 @@ impl MilestonesContract { .get(&mkey) .ok_or(Error::MilestoneNotFound)?; - extend_ttl(&env, &mkey); + let target = milestone.deadline.saturating_add(GRACE_PERIOD); + extend_ttl_for_target(&env, &mkey, target); + extend_instance_ttl_for_target(&env, target); // Keep every contribution sub-record alive alongside the parent so // they can't archive independently while the milestone stays live. for i in 0..milestone.contributor_count { - extend_ttl(&env, &DataKey::Contribution(milestone_id, i)); + extend_ttl_for_target(&env, &DataKey::Contribution(milestone_id, i), target); } Ok(()) @@ -796,3 +804,16 @@ fn extend_ttl(env: &Env, key: &DataKey) { fn extend_instance_ttl(env: &Env) { env.storage().instance().extend_ttl(100_000, 500_000); } + +/// Extends the TTL of a persistent entry to (approximately) survive until +/// `target_timestamp`, capped at Soroban's own persistent-entry TTL +/// ceiling — see `mergefi_common::extend_ttl_for_target` (#11). +fn extend_ttl_for_target(env: &Env, key: &DataKey, target_timestamp: u64) { + mergefi_common::extend_ttl_for_target(env, key, target_timestamp); +} + +/// `extend_ttl_for_target`, applied to this contract's instance storage +/// (#11) — see `mergefi_common::extend_instance_ttl_for_target`. +fn extend_instance_ttl_for_target(env: &Env, target_timestamp: u64) { + mergefi_common::extend_instance_ttl_for_target(env, target_timestamp); +} diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index 6e3c362..45c0635 100644 --- a/contracts/milestones/src/test.rs +++ b/contracts/milestones/src/test.rs @@ -3,7 +3,10 @@ use super::*; use mergefi_common::MAX_SPONSORS; use soroban_sdk::{ - testutils::{Address as _, Ledger as _}, + testutils::{ + storage::{Instance as _, Persistent as _}, + Address as _, Ledger as _, + }, token, vec, Address, Env, }; @@ -1395,3 +1398,154 @@ fn test_state_machine_allocate_rejects_duplicate_allocation() { assert_eq!(err, Err(Ok(Error::IssueAlreadyAllocated))); main } + +// ── keep_alive TTL scaling toward stored deadline (#11) ──────────────────── +// +// keep_alive previously applied the flat ~29-day (500_000-ledger) bump +// regardless of context, even though Milestone already stores its own +// `deadline` — the exact gap MergeFi/contracts#56 fixed for escrow's +// keep_alive/extend_deadline. These mirror escrow's #56 TTL tests, plus the +// long-idle-survival tests requested by issue #11's own acceptance criteria +// (testutils::Ledger sequence advancement proving survival post-fix). + +fn milestone_ttl(env: &Env, contract_id: &Address, milestone_id: u64) -> u32 { + env.as_contract(contract_id, || { + env.storage() + .persistent() + .get_ttl(&DataKey::Milestone(milestone_id)) + }) +} + +fn instance_ttl(env: &Env, contract_id: &Address) -> u32 { + env.as_contract(contract_id, || env.storage().instance().get_ttl()) +} + +#[test] +fn test_keep_alive_scales_ttl_toward_milestone_deadline() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + let contract_id = client.address.clone(); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &10_000_000_000i128); + + // 90 days out — comfortably under the network's own ~1-year ceiling, so + // this exercises the proportional path, not the cap. + let ninety_days_secs: u64 = 90 * 24 * 60 * 60; + client.create_milestone( + &401u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &ninety_days_secs, + ); + + let before = client.get_milestone(&401u64); + client.keep_alive(&401u64); + let after = client.get_milestone(&401u64); + assert_eq!(before, after, "keep_alive must not change milestone state"); + + let expected_ledgers = (ninety_days_secs + GRACE_PERIOD) / 5; + assert_eq!( + milestone_ttl(&env, &contract_id, 401u64), + expected_ledgers as u32 + ); + // Sanity check against the old, now-wrong expectation: a 90-day deadline + // must buy noticeably more than the flat 500_000-ledger bump. + assert!(expected_ledgers > 500_000); + + // Instance storage (Admin/Treasury/FeeBps/MaxSponsors) is scaled too, so + // the contract itself doesn't archive out from under a milestone that + // would otherwise survive. + assert_eq!(instance_ttl(&env, &contract_id), expected_ledgers as u32); +} + +#[test] +fn test_keep_alive_rejects_nonexistent_milestone() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let err = client.try_keep_alive(&999u64); + assert_eq!(err, Err(Ok(Error::MilestoneNotFound))); +} + +// ── long-idle survival via periodic keep_alive (#11) ─────────────────────── +// +// Simulates "a milestone whose release cycle runs long and nobody touches +// it" by advancing the ledger sequence number directly via +// testutils::Ledger rather than waiting in real time. + +#[test] +fn test_milestone_survives_long_idle_period_via_periodic_keep_alive() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + let contract_id = client.address.clone(); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &10_000_000_000i128); + + let one_year_secs: u64 = 365 * 24 * 60 * 60; + client.create_milestone( + &402u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &one_year_secs, + ); + + // No allocate/release_issue ever follows — the only activity is a + // permissionless keep_alive ping every so often, each jump comfortably + // larger than the old flat 500_000-ledger (~29-day) bump. + let ledgers_per_ping: u32 = 900_000; // ~52 days at 5s/ledger + for _ in 0..5 { + env.ledger().with_mut(|li| li.sequence_number += ledgers_per_ping); + client.keep_alive(&402u64); + } + + let milestone = client.get_milestone(&402u64); + assert_eq!(milestone.total_budget, 10_000_000_000i128); + assert!(!milestone.closed); + assert!(milestone_ttl(&env, &contract_id, 402u64) > 4096); +} + +#[test] +fn test_milestone_ttl_decays_to_protocol_minimum_without_periodic_keep_alive() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + let contract_id = client.address.clone(); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &10_000_000_000i128); + + // create_milestone() alone only grants the flat ~29-day bump, and + // nothing calls keep_alive afterward. + client.create_milestone( + &403u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &(365 * 24 * 60 * 60), + ); + + env.ledger().with_mut(|li| li.sequence_number += 600_000); + + // See the equivalent escrow test for why this asserts a decayed-to-floor + // TTL rather than a hard panic: soroban_sdk's default test Env runs its + // storage host in "recording" footprint mode, which silently revives an + // expired persistent entry to the network's bare minimum TTL on next + // touch rather than hard-failing the way a real network's "enforcing" + // mode would for a genuinely archived entry lacking an explicit + // RestoreFootprint operation. + let ttl = milestone_ttl(&env, &contract_id, 403u64); + assert_eq!(ttl, 4096 - 1); +} diff --git a/docs/ttl-archival-restoration-research.md b/docs/ttl-archival-restoration-research.md new file mode 100644 index 0000000..8f909ef --- /dev/null +++ b/docs/ttl-archival-restoration-research.md @@ -0,0 +1,213 @@ +# Soroban TTL, archival, and restoration: research and per-contract analysis + +Research and fix analysis for [#11](https://github.com/MergeFi/contracts/issues/11). +All three contracts call `extend_ttl`/`extend_instance_ttl` with the same +hardcoded `(100_000, 500_000)` threshold/extend-to pair, regardless of the +entry's actual expected lifetime. This doc covers (1) what Soroban's TTL and +archival model actually does, since it's a Stellar-specific mechanism with +no direct EVM analog, (2) what real-world time window that hardcoded pair +buys per contract, and (3) how that compares to each contract's stated +lifecycle. + +## How Soroban TTL/archival/restoration actually works + +Every Soroban contract-data ledger entry — `persistent` and `temporary` +storage entries, plus a contract's own `instance` entry — carries a +`liveUntilLedgerSeq`: the ledger sequence number after which the entry is no +longer considered live. This is the mechanism behind +[CAP-0046](https://stellar.org/protocol/cap-46) ("state archival"): unlike +Ethereum, where storage is unconditionally permanent once written (and rent +is paid indirectly via gas at write time), Soroban entries have a bounded +lifetime and must be actively kept alive or they leave the *live* ledger +state entirely. + +- **What "archival" means concretely.** Once the current ledger sequence + passes an entry's `liveUntilLedgerSeq`, the entry is evicted from the + live BucketList — the data set validators actively maintain and that a + transaction's footprint is checked against. It isn't deleted outright; + it moves into a colder, non-live archival bucket, but as far as an + ordinary contract invocation is concerned it's gone: any transaction + whose footprint references that ledger key fails at validation/apply + time unless the key has first been restored. +- **What happens calling into an archived entry.** A transaction's + footprint (its declared read/write set) is checked against the live + ledger state before execution. If any key in the footprint is archived, + the transaction is rejected — it never reaches contract logic to + observe a graceful "not found" the way a `None` from `.get()` would + read in application code. Concretely, this surfaces as the transaction + failing at the `InvokeHostFunction` operation with a "entry archived" / + bad footprint condition, not a contract-level error variant this + codebase's `Error` enum could ever express or catch. +- **Restoring an archived entry.** The XDR transaction model exposes a + dedicated `RestoreFootprintOp` operation (`stellar-cli`'s `restore` + subcommand, or the equivalent `sorobanServer.restoreFootprint`-style + call in the various client SDKs). Its footprint declares exactly the + archived ledger key(s) to bring back, and it pays a restoration fee + (see below) proportional to entry size. Once applied, the entry is + live again in the current ledger, at a *freshly re-assigned* + `liveUntilLedgerSeq` set to the network's own minimum for that entry's + durability (`min_persistent_entry_ttl` — see below) — not the value it + had before archival, and with no memory of how far in the future a + contract had once tried to extend it. A subsequent normal + transaction can then read/write it as usual (in the same transaction as + the restore, or a separate one, since the two are independent + operations that can be combined in a single transaction envelope). +- **Is restoration permissionless?** Yes. `RestoreFootprintOp` requires + only the source account's own signature to pay the fee; it does not + require any authorization from whichever address originally wrote the + entry, nor from this codebase's `admin`. Any account holding enough + XLM to pay the restoration fee can restore any archived contract-data + key it knows the identity of. This matters directly for issue #11's + "does restoration require the original writer" question: it does not + — anyone (a sponsor, a maintainer, an unrelated third party, or an + automated `mergefi-backend` job) can restore a stuck escrow/pool/ + milestone once they notice it archived, so "genuinely stuck forever" + requires *nobody* ever noticing and restoring it, not a structural + inability to do so. +- **Eviction/rent-bump cost model.** `extend_ttl`'s resource fee at write + time is proportional to the entry's size in bytes times the number of + ledgers the TTL is extended by — a larger record extended further into + the future costs more, by design (this is Soroban's "rent" mechanism: + you pay up front for however long you want the entry to survive without + further action). Restoration after archival is charged similarly — a + "restore" resource fee proportional to entry size, functionally similar + in shape to the fee an `extend_ttl` call for the same entry would have + cost, plus the archived entry's fixed operation base fee. The **real** + cost of archival is not primarily monetary (a restore is cheap in + absolute Lumens terms even for these contracts' modestly-sized records) + — it's *availability*: every legitimate contract call against that + record fails until someone specifically issues the restore, so the + practical cost is however long it takes for a sponsor, maintainer, or + `mergefi-backend` to notice and act, during which the funds are + inaccessible. +- **Current network parameters** (soroban-sdk 26.1.0's own `testutils` + defaults, which mirror what the SDK ships as "realistic" — verify + against live network state via `stellar network` / a current Horizon + ledger-config query before relying on exact figures, since these are + configurable network parameters, not protocol constants, and the SDK's + own docs note they "could drift"): + - Ledger close time: ~5 seconds (`APPROX_SECONDS_PER_LEDGER` in + `contracts/common/src/lib.rs`). This has trended down over Stellar's + history and is explicitly *not* a fixed protocol guarantee — treat it + as an approximation to re-derive periodically, not a constant to + hardcode forever. + - `min_persistent_entry_ttl`: 4096 ledgers (~5.7 hours at 5s/ledger) — + the TTL a freshly-restored (or freshly-created) persistent entry + starts at if nothing explicitly extends it further. + - `max_entry_ttl`: 6,312,000 ledgers (~365.3 days at 5s/ledger) — the + hard ceiling on how far into the future *any single* `extend_ttl` + call can push an entry's `liveUntilLedgerSeq`, exposed to contract + code as `env.ledger().max_live_until_ledger()`. No single call, no + matter what threshold/extend-to it passes, can ever push an entry's + survivability past this ceiling relative to the current ledger. + +The practical upshot for this codebase: a persistent entry's TTL is a +resource that decays every ledger and must be topped up by *some* +transaction before it hits zero, and the only two levers available are +(a) how far each top-up pushes the TTL out, and (b) how often a top-up +happens at all. Issue #11 is fundamentally about lever (a) being tuned +to a fixed ~29 days regardless of a record's actual expected idle period. + +### A testutils caveat that shaped this fix's test design + +`soroban_sdk::Env::default()` (used throughout this repo's unit tests) runs +its simulated storage host in **recording footprint mode** — the mode +designed to auto-discover a transaction's footprint during simulation, not +to faithfully reproduce a real network's enforcement of archival. Concretely, +`get_with_live_until_ledger` (the internal read path every storage +access — including this SDK's own `get_ttl()` testutils helper — goes +through) silently *revives* an already-expired persistent entry to +`min_persistent_entry_ttl` the moment anything reads it, rather than +failing the read. This means unit tests built on `Env::default()` cannot +reproduce the real network's "archived entry hard-fails until restored" +behavior directly — any test that touches an expired entry implicitly +"restores" it for free, which a real network transaction never does. + +The tests added for this fix work within that constraint deliberately: they +prove periodic `keep_alive` keeps a record's TTL *healthy* across ledger- +sequence jumps that would exceed the old flat bump, and contrast that +against records left untouched, whose TTL collapses all the way down to the +network's bare `min_persistent_entry_ttl` floor over the same idle gap — +the same floor a real restored entry would start from. See the test +comments in each contract's `test.rs` (search for "recording" footprint +mode) for the precise reasoning at each assertion. + +## Quantified time-window analysis + +`extend_ttl(key, 100_000, 500_000)` means: "once fewer than 100,000 ledgers +remain before this entry's TTL expires, bump it back out to 500,000 ledgers +from now." At ~5 seconds/ledger: + +| Value | Ledgers | Real time | +|---|---|---| +| Threshold (100,000) | 100,000 | 500,000s ≈ **5.8 days** | +| Extend-to (500,000) | 500,000 | 2,500,000s ≈ **28.9 days** | +| Network max (`max_entry_ttl`, 6,312,000) | 6,312,000 | 31,560,000s ≈ **365.3 days** | + +So the flat bump buys **~29 days** of survival from whenever it last fired, +and only fires again once the remaining TTL drops under ~5.8 days — as long +as *some* write happens at least once every ~29 days, the entry never +actually gets close to expiring. The gap is exactly the case issue #11 +names: an entry that goes longer than ~29 days with zero writes. + +- **`escrow`.** The contract's own doc comment calls the flat bump + "conservative defaults suitable for a multi-month bounty lifecycle," but + ~29 days is not multi-month — it's about four weeks. A bounty funded + with a `deadline` several months out (a realistic and explicitly + supported case — `fund`/`contribute` take an arbitrary `u64` deadline) + gets no automatic benefit from that far-future deadline: `fund` and + `contribute` still only apply the flat ~29-day bump, and nothing else + touches the record until `release`/`refund`/`extend_deadline`/ + `keep_alive` is called. If nobody calls the latter two and the escrow + sits unresolved (a slow-moving bounty, a disputed issue, a sponsor who + isn't actively managing it), it can archive well before its own + `deadline` timestamp is ever reached — the record most needs to survive + to precisely the moment it becomes least likely to have received a + recent write. `extend_deadline` and `keep_alive` already scale toward + the stored `deadline` (fixed for MergeFi/contracts#56, prior to this + issue); this fix additionally makes sure `keep_alive` also carries the + contract's own instance storage along, closing the last remaining gap + in that path (see below). +- **`milestones`.** Same flat-bump math, and the issue's own framing + ("milestones can plausibly span longer release cycles too") applies + directly — `Milestone` already stores a `deadline` exactly like escrow + does, but unlike escrow, neither `create_milestone`/`contribute` nor + `keep_alive` had ever scaled toward it; `keep_alive`'s own doc comment + incorrectly claimed "milestones have no natural deadline timestamp." + This fix corrects that: `keep_alive` now scales the same way escrow's + does, toward `milestone.deadline + GRACE_PERIOD`, and also refreshes + instance storage. +- **`maintenance-pool`.** This is the sharpest mismatch: the contract is + explicitly designed to be open-ended/recurring ("it never finishes" per + the README), so there is no deadline to scale toward at all — a flat + ~29-day bump is incompatible with *any* fixed constant, however large, + since the lifecycle has no upper bound by design. The only structural + fix is decoupling "how long a single top-up buys" from "how often a + top-up must happen," which is exactly what `keep_alive` (already + present, permissionless, requiring no deposit/withdrawal) is for. This + fix changes what a single `keep_alive` call actually buys: instead of + the same flat ~29-day bump every other call gets, it now requests the + maximum a single `extend_ttl` call can grant — the network's own + `max_live_until_ledger()` ceiling, ~365 days — so one permissionless + ping roughly once a year is enough to keep a fully quiet pool (and its + entire deposit history, and the contract's own instance storage) alive + indefinitely with zero deposit/withdraw activity. + +## What this fix does and does not guarantee + +None of the above eliminates the need for *some* transaction to happen +periodically — Soroban has no on-chain scheduler, so nothing can make an +entry survive forever with truly zero interaction ever again. What this fix +does is (a) make each entry's survival window actually match its contract's +real lifecycle expectations rather than a one-size-fits-all ~29 days, and +(b) make sure the *tool* for bridging longer gaps (`keep_alive`, already +permissionless and already deployed for all three contracts) actually +carries every dependent piece of state — parent record, sub-records, and +instance storage — rather than leaving instance storage on the old flat +schedule while records got the new one. Operationally, closing the +remaining gap end-to-end still requires *something* — a sponsor, a +maintainer, or (most realistically) an automated `mergefi-backend` cron +job — to actually call `keep_alive` at an interval comfortably inside each +contract's now-correct survival window; that operational automation is +outside this repo's contract code and is called out here rather than +silently assumed.