diff --git a/contracts/README.md b/contracts/README.md index 66ae0a6..c97a8ff 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -49,6 +49,28 @@ Activity records track important campaign lifecycle events including: - Approved contracts can perform registry operations without additional authorization - Activity records can be created by admin, approved contracts, or authorized users +**Storage expiry & keep-alives:** + +The registry stores per-campaign records (`CampaignInfo`, `CampaignRecord`, +paged farmer-campaign index entries, and paged activity logs) as Soroban +**persistent** entries. Persistent entries carry a TTL in ledgers; when the TTL +lapses an entry is **archived** and unreadable until restored +(`RestoreFootprintOp`). The registry extends the TTL of every entry it reads or +writes (see `registry/src/storage.rs`; thresholds/bumps are 30/90 days' worth +of ledgers), so entries in active use stay alive automatically. + +Once a campaign settles or ends, no write path touches its registry records +again, and they are only kept readable if queried. The registry does **not** +expose a keep-alive method today (unlike `production_escrow::touch_campaign`); +to keep historical campaign activity logs and records readable indefinitely, +an indexer/ops job must either periodically read the affected keys or issue a +`RestoreFootprintOp` before their TTL lapses. Operators should record the +`PERSISTENT_LIFETIME_THRESHOLD` / `PERSISTENT_BUMP_AMOUNT` constants in +`registry/src/storage.rs` (30/90 days) and re-assert read access (or restore) +at a cadence comfortably shorter than 30 days of ledgers. If an entry has +already been archived, a keep-alive read cannot resurrect it — it must be +restored first. + ## Integration See [INTEGRATION.md](./INTEGRATION.md) for the full integration guide covering: diff --git a/contracts/production_escrow/README.md b/contracts/production_escrow/README.md index 19533e5..6c26eca 100644 --- a/contracts/production_escrow/README.md +++ b/contracts/production_escrow/README.md @@ -78,6 +78,7 @@ no privileged signer. | `settle_campaign` | Settle campaign and distribute funds | | `mark_failed` | Mark campaign as failed, trigger refunds | | `open_dispute` | Enter dispute state | +| `touch_campaign` | Permissionless keep-alive: extends TTL of a campaign's persistent storage entries without changing state (see "Storage expiry & keep-alives") | | `get_campaign` | Retrieve campaign details | ## Trust model: `receive_contribution` diff --git a/contracts/production_escrow/src/lib.rs b/contracts/production_escrow/src/lib.rs index 9f1069e..4c8b02f 100644 --- a/contracts/production_escrow/src/lib.rs +++ b/contracts/production_escrow/src/lib.rs @@ -820,6 +820,23 @@ impl ProductionEscrowContract { pub fn get_harvest_record(env: Env, campaign_id: u64) -> Option { storage::get_harvest_record(&env, campaign_id) } + + /// Permissionless keep-alive that extends the TTL of a campaign's persistent + /// storage entries (`Campaign`, `Dispute`, `Tranches`, `HarvestRecord`) + /// without reading or changing any state. + /// + /// Once a campaign reaches a terminal state (`Settled`, `Failed`, + /// `Resolved`) no write path touches its entries again, so without periodic + /// extension its persistent entries eventually lapse and become archived — + /// unreadable until explicitly restored. This method is intended to be + /// called on a regular cadence (e.g. by an indexer or keeper job) so that + /// settled/historical campaigns remain readable indefinitely. It is a + /// no-op for a nonexistent campaign. See the README's "Storage expiry & + /// keep-alives" section for the full operational guidance. + pub fn touch_campaign(env: Env, campaign_id: u64) { + storage::touch_campaign(&env, campaign_id); + storage::extend_instance_ttl(&env); + } } #[cfg(test)] diff --git a/contracts/production_escrow/src/storage.rs b/contracts/production_escrow/src/storage.rs index 47a8cd9..b9b41cc 100644 --- a/contracts/production_escrow/src/storage.rs +++ b/contracts/production_escrow/src/storage.rs @@ -94,10 +94,11 @@ pub fn set_contribution(env: &Env, campaign_id: u64, investor: &Address, amount: pub fn get_tranches(env: &Env, campaign_id: u64) -> TrancheList { let key = DataKey::Tranches(campaign_id); - env.storage() - .persistent() - .get(&key) - .unwrap_or_else(|| Vec::new(env)) + let tranches = env.storage().persistent().get(&key); + if tranches.is_some() { + extend_persistent_ttl(env, &key); + } + tranches.unwrap_or_else(|| Vec::new(env)) } pub fn set_tranches(env: &Env, campaign_id: u64, tranches: &TrancheList) { @@ -120,3 +121,30 @@ pub fn set_harvest_record(env: &Env, campaign_id: u64, record: &HarvestRecord) { env.storage().persistent().set(&key, record); extend_persistent_ttl(env, &key); } + +/// Extends the TTL of every persistent entry associated with `campaign_id` +/// (`Campaign`, `Dispute`, `Tranches`, `HarvestRecord`) without reading or +/// mutating any of their values. +/// +/// This is the keep-alive used by `touch_campaign`: once a campaign reaches a +/// terminal state (`Settled`, `Failed`, `Resolved`) no write path touches its +/// storage entries again, so without periodic extension they would lapse and +/// be archived, becoming unreadable (see the contract README's +/// "Storage expiry & keep-alives" section). Nothing is extended if an entry +/// is already archived (its `has` is then false and it must be restored via +/// `RestoreFootprintOp` first). +pub fn touch_campaign(env: &Env, campaign_id: u64) { + for key in [ + DataKey::Campaign(campaign_id), + DataKey::Dispute(campaign_id), + DataKey::Tranches(campaign_id), + DataKey::HarvestRecord(campaign_id), + ] { + if env.storage().persistent().has(&key) { + extend_persistent_ttl(env, &key); + } + } + // Per-investor contributions are keyed by investor address, so they cannot + // be enumerated here; a keeper that needs to keep contribution records + // alive must touch them through the ordinary reads/writes on that address. +} diff --git a/contracts/production_escrow/src/test.rs b/contracts/production_escrow/src/test.rs index b79ff3d..ac6424c 100644 --- a/contracts/production_escrow/src/test.rs +++ b/contracts/production_escrow/src/test.rs @@ -1952,3 +1952,223 @@ fn test_get_admin_returns_initialized_admin() { let stored_admin = client.get_admin(); assert_eq!(stored_admin, admin); } + +// ─── TTL keep-alive (touch_campaign) tests ────────────────────────────────── + +/// One ledger per ~5 seconds; these mirror the lifetime constants in +/// storage.rs (threshold 30d, bump 90d) so the test can reliably push entries +/// below the extension threshold and observe the keep-alive raising them again. +const DAY_IN_LEDGERS: u32 = 17280; +const PERSISTENT_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; +const PERSISTENT_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 90; + +/// Advances the ledger far enough that a freshly written persistent entry's +/// remaining TTL drops below the extension threshold (but stays positive), so +/// a subsequent keep-alive is observable. +fn age_persistent_entries(env: &Env) { + let degrade = PERSISTENT_BUMP_AMOUNT - (PERSISTENT_LIFETIME_THRESHOLD / 2); + env.ledger() + .set_sequence_number(env.ledger().sequence() + degrade); +} + +#[test] +fn test_touch_campaign_extends_ttl_of_campaign_entries() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, ProductionEscrowContract); + let client = ProductionEscrowContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let farmer = Address::generate(&env); + let investor = Address::generate(&env); + let campaign_id = 1u64; + let (token_address, sac) = create_token(&env, &admin); + sac.mint(&investor, &1000i128); + + client.initialize(&admin); + client.create_campaign( + &campaign_id, + &farmer, + &1000i128, + &token_address, + &1_000_000u64, + &Symbol::new(&env, "wheat"), + ); + client.fund_campaign(&campaign_id, &investor, &1000i128); + + // Give the campaign the tranches and harvest-record persistent entries + // (a dispute is exercised in its own test below because opening a dispute + // moves the campaign out of the states that allow reporting a harvest). + let mut tranches: Vec = Vec::new(&env); + tranches.push_back(Tranche { + amount: 1000i128, + milestone: Symbol::new(&env, "planting"), + released: false, + }); + client.configure_tranches(&campaign_id, &tranches); + client.report_harvest(&campaign_id, &farmer, &Symbol::new(&env, "good")); + + let campaign_key = DataKey::Campaign(campaign_id); + let tranches_key = DataKey::Tranches(campaign_id); + let harvest_key = DataKey::HarvestRecord(campaign_id); + + // Age all entries so their TTLs degrade below the extension threshold, then + // measure the degraded TTL as the baseline. + age_persistent_entries(&env); + let before = env.as_contract(&contract_id, || { + ( + env.storage().persistent().get_ttl(&campaign_key), + env.storage().persistent().get_ttl(&tranches_key), + env.storage().persistent().get_ttl(&harvest_key), + ) + }); + + // A keeper calls touch_campaign to keep the (potentially settled) history + // readable. It must not panic and must re-extend every campaign entry. + client.touch_campaign(&campaign_id); + + let after = env.as_contract(&contract_id, || { + ( + env.storage().persistent().get_ttl(&campaign_key), + env.storage().persistent().get_ttl(&tranches_key), + env.storage().persistent().get_ttl(&harvest_key), + ) + }); + + assert!( + after.0 > before.0, + "campaign TTL not extended: before={} after={}", + before.0, + after.0 + ); + assert!( + after.1 > before.1, + "tranches TTL not extended: before={} after={}", + before.1, + after.1 + ); + assert!( + after.2 > before.2, + "harvest record TTL not extended: before={} after={}", + before.2, + after.2 + ); +} + +#[test] +fn test_touch_campaign_extends_ttl_of_dispute() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, ProductionEscrowContract); + let client = ProductionEscrowContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let farmer = Address::generate(&env); + let investor1 = Address::generate(&env); + let campaign_id = 1u64; + let (token_address, sac) = create_token(&env, &admin); + // Tokens reconciled off-chain must exist in the contract for the solvency + // check on receive_contribution. + sac.mint(&contract_id, &1000i128); + + client.initialize(&admin); + client.create_campaign( + &campaign_id, + &farmer, + &1000i128, + &token_address, + &1_000_000u64, + &Symbol::new(&env, "wheat"), + ); + client.receive_contribution(&campaign_id, &investor1, &1000i128); + client.open_dispute(&campaign_id, &investor1, &Symbol::new(&env, "Delay")); + + let dispute_key = DataKey::Dispute(campaign_id); + age_persistent_entries(&env); + let before = env.as_contract(&contract_id, || { + env.storage().persistent().get_ttl(&dispute_key) + }); + + client.touch_campaign(&campaign_id); + + let after = env.as_contract(&contract_id, || { + env.storage().persistent().get_ttl(&dispute_key) + }); + assert!( + after > before, + "dispute TTL not extended: before={} after={}", + before, + after + ); +} + +#[test] +fn test_touch_campaign_does_not_change_campaign_state() { + let s = token_funded_campaign(); + let campaign = s.client.get_campaign(&s.campaign_id).unwrap(); + + s.client.touch_campaign(&s.campaign_id); + + let after = s.client.get_campaign(&s.campaign_id).unwrap(); + assert_eq!(after, campaign); +} + +#[test] +fn test_touch_campaign_nonexistent_campaign_is_noop() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, ProductionEscrowContract); + let client = ProductionEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize(&admin); + + // Should not panic even though campaign 99 does not exist. + client.touch_campaign(&99u64); +} + +#[test] +fn test_get_tranches_extends_ttl() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, ProductionEscrowContract); + let client = ProductionEscrowContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let farmer = Address::generate(&env); + let investor = Address::generate(&env); + let campaign_id = 1u64; + let (token_address, sac) = create_token(&env, &admin); + sac.mint(&investor, &1000i128); + + client.initialize(&admin); + client.create_campaign( + &campaign_id, + &farmer, + &1000i128, + &token_address, + &1_000_000u64, + &Symbol::new(&env, "wheat"), + ); + client.fund_campaign(&campaign_id, &investor, &1000i128); + + let mut tranches: Vec = Vec::new(&env); + tranches.push_back(Tranche { + amount: 1000i128, + milestone: Symbol::new(&env, "planting"), + released: false, + }); + client.configure_tranches(&campaign_id, &tranches); + + let key = DataKey::Tranches(campaign_id); + age_persistent_entries(&env); + let before = env.as_contract(&contract_id, || env.storage().persistent().get_ttl(&key)); + + // Reading via the public getter must also extend the tranches TTL. + let read = client.get_tranches(&campaign_id); + assert_eq!(read.len(), 1); + let after = env.as_contract(&contract_id, || env.storage().persistent().get_ttl(&key)); + assert!(after > before, "get_tranches did not extend TTL"); +}