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
52 changes: 47 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,15 @@ fn get_max_sponsors(env) -> Result<u32, Error>;
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`

Expand Down Expand Up @@ -279,6 +283,14 @@ fn get_max_sponsors(env) -> Result<u32, Error>;
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`

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
83 changes: 69 additions & 14 deletions contracts/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<K>(env: &Env, key: &K, target_timestamp: u64)
where
K: IntoVal<Env, Val>,
{
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
Expand All @@ -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<K>(env: &Env, key: &K)
where
K: IntoVal<Env, Val>,
{
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);
}
30 changes: 26 additions & 4 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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);
}
139 changes: 138 additions & 1 deletion contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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]
Expand Down
Loading