From 58e9a63e2d0b9eed2179b6c6bcd9d76d9f727403 Mon Sep 17 00:00:00 2001 From: shinzoxD Date: Mon, 24 Aug 2026 23:10:14 +0530 Subject: [PATCH] feat(contracts): keep terminal campaign storage readable Add permissionless touch_campaign on escrow and registry so an indexer can extend TTL on terminal campaign entries without changing state. Document archival, PERSISTENT_* constants, and RestoreFootprintOp in both contract READMEs. Closes #154 --- contracts/production_escrow/README.md | 63 ++++++++- contracts/production_escrow/src/lib.rs | 100 +++++++------- contracts/production_escrow/src/storage.rs | 61 ++++++++- contracts/production_escrow/src/test.rs | 131 +++++++++++++++++- contracts/registry/README.md | 119 ++++++++++++++++ contracts/registry/src/campaign.rs | 16 ++- contracts/registry/src/lib.rs | 14 ++ contracts/registry/src/storage.rs | 70 +++++++++- contracts/registry/src/test.rs | 149 ++++++++++++++++++++- 9 files changed, 648 insertions(+), 75 deletions(-) create mode 100644 contracts/registry/README.md diff --git a/contracts/production_escrow/README.md b/contracts/production_escrow/README.md index a34a2bb..b43b2b0 100644 --- a/contracts/production_escrow/README.md +++ b/contracts/production_escrow/README.md @@ -45,7 +45,8 @@ Alternative terminal states: | `settle_campaign` | Settle campaign and distribute funds | | `mark_failed` | Mark campaign as failed, trigger refunds | | `open_dispute` | Enter dispute state | -| `get_campaign` | Retrieve campaign details | +| `get_campaign` | Retrieve campaign details (`Option`) | +| `touch_campaign` | Permissionless TTL keep-alive (no state change) | ## Trust model: `receive_contribution` @@ -87,6 +88,66 @@ to fully eliminate risk from a two-party collusion. Integrators relying on `receive_contribution` should treat `ContribReconciled` events as requiring off-chain audit, separate from ordinary `ContribReceived` deposit monitoring. +## TTL, archival, and historical reads + +Soroban persistent entries are **not immortal**. Each write (and some reads) +calls `extend_ttl` with: + +| Constant | Value | Meaning | +|----------|-------|---------| +| `PERSISTENT_LIFETIME_THRESHOLD` | `DAY_IN_LEDGERS * 30` (518,400 ledgers, ~30 days) | Remaining TTL must fall below this before a bump happens | +| `PERSISTENT_BUMP_AMOUNT` | `DAY_IN_LEDGERS * 90` (1,555,200 ledgers, ~90 days) | Target remaining TTL after a bump | + +(`DAY_IN_LEDGERS` is 17,280, ~5 seconds per ledger.) Instance storage uses the +same 30/90-day window. + +### Which entries stop being written once a campaign is terminal + +A campaign is terminal in `Settled`, `Failed`, or `Resolved` (and `Disputed` +blocks further tranche releases). After the last mutating call: + +| Key | Last writer | Still bumped on read? | +|-----|-------------|------------------------| +| `DataKey::Campaign(id)` | `settle_campaign` / `mark_failed` / `resolve_dispute` | Yes — `get_campaign` | +| `DataKey::Dispute(id)` | `resolve_dispute` | Yes — `get_dispute` | +| `DataKey::HarvestRecord(id)` | `report_harvest` | Yes — `get_harvest_record` | +| `DataKey::Tranches(id)` | `configure_tranches` / `release_tranche` | **No** — `get_tranches` does not extend TTL | +| `DataKey::Contribution(id, investor)` | `claim_refund` / `claim_return` (zeros the slot) | Yes — `get_contribution` for that investor | + +If nobody reads or writes an entry, its TTL runs out. An archived persistent +entry is unreadable until restored; public getters return `Option` for a +**missing** live key (`None`), but an **archived** key fails at the host. +Mutating methods that require the campaign (`require_campaign`) panic with a +message that points at restore / `touch_campaign`. + +`touch_campaign` does **not** enumerate per-investor `Contribution` keys +(there is no on-chain investor index). Keep those alive by reading +`get_contribution` for known investors, or restore them with +`RestoreFootprintOp` if they archive. + +### Keep-alive: `touch_campaign` + +``` +touch_campaign(campaign_id) +``` + +Permissionless. Extends TTL on `Campaign` and, when present, `Dispute`, +`Tranches`, and `HarvestRecord`, plus the contract instance. It does not +change any stored value. + +**Suggested indexer cadence:** call `touch_campaign` at least once every ~30 +days for every campaign whose history must stay readable. `extend_ttl` is a +no-op while remaining TTL is still above the 30-day threshold, so monthly +calls are cheap and will bump once remaining life drops below the threshold +(~60 days after the last bump). + +### Restore path + +If an entry has already archived, `touch_campaign` cannot revive it. Submit a +[`RestoreFootprintOp`](https://developers.stellar.org/docs/learn/fundamentals/contract-development/storage/state-archival) +for the archived keys (and the contract instance / WASM if those archived too), +then resume periodic `touch_campaign` calls. + ## Building ```bash diff --git a/contracts/production_escrow/src/lib.rs b/contracts/production_escrow/src/lib.rs index 5b2bf27..5dad18d 100644 --- a/contracts/production_escrow/src/lib.rs +++ b/contracts/production_escrow/src/lib.rs @@ -8,9 +8,7 @@ pub use types::*; use events::*; use soroban_sdk::{ - contract, contractimpl, - token::Client as TokenClient, - Address, Env, Symbol, Vec, + contract, contractimpl, token::Client as TokenClient, Address, Env, Symbol, Vec, }; #[contract] @@ -87,7 +85,9 @@ impl ProductionEscrowContract { // With target_amount ≤ i64::MAX, the worst-case product is (i64::MAX)² ≈ 2¹²⁶, // which fits safely in u128 used by the safe_pro_rata helper. if target_amount > i64::MAX as i128 { - panic!("target_amount exceeds safe range for pro-rata arithmetic (must be <= i64::MAX)"); + panic!( + "target_amount exceeds safe range for pro-rata arithmetic (must be <= i64::MAX)" + ); } farmer.require_auth(); @@ -120,11 +120,8 @@ impl ProductionEscrowContract { investor.require_auth(); - let mut campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); - if campaign.status != CampaignStatus::Active - && campaign.status != CampaignStatus::Funding - { + let mut campaign = storage::require_campaign(&env, campaign_id); + if campaign.status != CampaignStatus::Active && campaign.status != CampaignStatus::Funding { panic!("campaign not accepting contributions"); } @@ -150,8 +147,7 @@ impl ProductionEscrowContract { assert_solvent(&env, &campaign); storage::set_campaign(&env, campaign_id, &campaign); - let contributed = - storage::get_contribution(&env, campaign_id, &investor) + amount; + let contributed = storage::get_contribution(&env, campaign_id, &investor) + amount; storage::set_contribution(&env, campaign_id, &investor, contributed); storage::extend_instance_ttl(&env); @@ -181,16 +177,13 @@ impl ProductionEscrowContract { require_admin(&env); - let mut campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); + let mut campaign = storage::require_campaign(&env, campaign_id); // Second signer: the campaign's farmer must also authorize, so a // lone compromised/malicious admin key cannot fabricate contributions. campaign.farmer.require_auth(); - if campaign.status != CampaignStatus::Active - && campaign.status != CampaignStatus::Funding - { + if campaign.status != CampaignStatus::Active && campaign.status != CampaignStatus::Funding { panic!("campaign not accepting contributions"); } @@ -208,8 +201,7 @@ impl ProductionEscrowContract { assert_solvent(&env, &campaign); storage::set_campaign(&env, campaign_id, &campaign); - let contributed = - storage::get_contribution(&env, campaign_id, &investor) + amount; + let contributed = storage::get_contribution(&env, campaign_id, &investor) + amount; storage::set_contribution(&env, campaign_id, &investor, contributed); storage::extend_instance_ttl(&env); @@ -219,11 +211,8 @@ impl ProductionEscrowContract { pub fn complete_funding(env: Env, campaign_id: u64, total_funded: i128) { require_admin(&env); - let mut campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); - if campaign.status != CampaignStatus::Active - && campaign.status != CampaignStatus::Funding - { + let mut campaign = storage::require_campaign(&env, campaign_id); + if campaign.status != CampaignStatus::Active && campaign.status != CampaignStatus::Funding { panic!("campaign not accepting contributions"); } if total_funded != campaign.total_funded { @@ -245,8 +234,7 @@ impl ProductionEscrowContract { pub fn configure_tranches(env: Env, campaign_id: u64, tranches: Vec) { require_admin(&env); - let campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); + let campaign = storage::require_campaign(&env, campaign_id); if campaign.status != CampaignStatus::Funded { panic!("can only configure tranches for a funded campaign"); } @@ -281,13 +269,14 @@ impl ProductionEscrowContract { require_admin(&env); - let mut campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); + let mut campaign = storage::require_campaign(&env, campaign_id); if is_terminal(&campaign.status) { panic!("cannot release tranche: campaign is in a terminal state"); } - if campaign.status != CampaignStatus::Funded && campaign.status != CampaignStatus::InProduction { + if campaign.status != CampaignStatus::Funded + && campaign.status != CampaignStatus::InProduction + { panic!("campaign not funded or in production"); } if amount > escrow_held(&campaign) { @@ -329,8 +318,7 @@ impl ProductionEscrowContract { /// Farmer reports the harvest outcome, moving the campaign to Harvested. /// Only the campaign farmer or admin may call this. pub fn report_harvest(env: Env, campaign_id: u64, farmer: Address, outcome: Symbol) { - let mut campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); + let mut campaign = storage::require_campaign(&env, campaign_id); let is_admin = storage::has_admin(&env) && storage::get_admin(&env) == farmer; if campaign.farmer != farmer && !is_admin { @@ -338,7 +326,9 @@ impl ProductionEscrowContract { } farmer.require_auth(); - if campaign.status != CampaignStatus::Funded && campaign.status != CampaignStatus::InProduction { + if campaign.status != CampaignStatus::Funded + && campaign.status != CampaignStatus::InProduction + { panic!("campaign not funded or in production"); } @@ -358,8 +348,7 @@ impl ProductionEscrowContract { } pub fn open_dispute(env: Env, campaign_id: u64, opener: Address, reason: Symbol) { - let mut campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); + let mut campaign = storage::require_campaign(&env, campaign_id); if campaign.status != CampaignStatus::Active && campaign.status != CampaignStatus::Funding && campaign.status != CampaignStatus::Funded @@ -369,10 +358,8 @@ impl ProductionEscrowContract { } let is_farmer = campaign.farmer == opener; - let is_contributor = - storage::get_contribution(&env, campaign_id, &opener) > 0; - let is_admin = - storage::has_admin(&env) && storage::get_admin(&env) == opener; + let is_contributor = storage::get_contribution(&env, campaign_id, &opener) > 0; + let is_admin = storage::has_admin(&env) && storage::get_admin(&env) == opener; if !is_farmer && !is_contributor && !is_admin { panic!("not authorized to open dispute"); } @@ -404,13 +391,11 @@ impl ProductionEscrowContract { ) { require_admin(&env); - let mut campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); + let mut campaign = storage::require_campaign(&env, campaign_id); if campaign.status != CampaignStatus::Disputed { panic!("campaign not disputed"); } - let mut dispute = storage::get_dispute(&env, campaign_id) - .unwrap_or_else(|| panic!("dispute not found")); + let mut dispute = storage::require_dispute(&env, campaign_id); if dispute.status != DisputeStatus::Open { panic!("dispute already resolved"); } @@ -473,10 +458,8 @@ impl ProductionEscrowContract { /// Across many investors the accumulated dust is typically negligible, but /// integrators should be aware that `sum(claimed) <= refundable`. pub fn claim_refund(env: Env, campaign_id: u64, investor: Address) { - let campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); - if campaign.status != CampaignStatus::Resolved - && campaign.status != CampaignStatus::Failed + let campaign = storage::require_campaign(&env, campaign_id); + if campaign.status != CampaignStatus::Resolved && campaign.status != CampaignStatus::Failed { panic!("no refund available"); } @@ -510,8 +493,7 @@ impl ProductionEscrowContract { require_admin(&env); - let mut campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); + let mut campaign = storage::require_campaign(&env, campaign_id); if campaign.status == CampaignStatus::Disputed { panic!("campaign is disputed"); } @@ -544,8 +526,7 @@ impl ProductionEscrowContract { pub fn mark_failed(env: Env, campaign_id: u64) { require_admin(&env); - let mut campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); + let mut campaign = storage::require_campaign(&env, campaign_id); if campaign.status != CampaignStatus::Active && campaign.status != CampaignStatus::Funding && campaign.status != CampaignStatus::Funded @@ -572,8 +553,7 @@ impl ProductionEscrowContract { /// Across many investors the accumulated dust is typically negligible, but /// integrators should be aware that `sum(claimed) <= returnable`. pub fn claim_return(env: Env, campaign_id: u64, investor: Address) { - let campaign = storage::get_campaign(&env, campaign_id) - .unwrap_or_else(|| panic!("campaign not found")); + let campaign = storage::require_campaign(&env, campaign_id); if campaign.status != CampaignStatus::Settled { panic!("campaign not settled"); } @@ -597,6 +577,20 @@ impl ProductionEscrowContract { storage::extend_instance_ttl(&env); emit_return_claimed(&env, campaign_id, investor, share); } + + /// Permissionless keep-alive for a campaign's persistent storage entries. + /// + /// Extends TTL on `Campaign` and, when present, `Dispute`, `Tranches`, and + /// `HarvestRecord`, plus the contract instance. Does not modify any stored + /// value. An indexer or keeper can call this periodically so settled / + /// failed / resolved campaigns stay readable without a `RestoreFootprintOp`. + /// + /// Once an entry has already been archived, this call cannot revive it — + /// restore the footprint first, then resume touching. + pub fn touch_campaign(env: Env, campaign_id: u64) { + storage::touch_campaign(&env, campaign_id); + } + pub fn get_campaign(env: Env, campaign_id: u64) -> Option { storage::get_campaign(&env, campaign_id) } @@ -618,7 +612,7 @@ impl ProductionEscrowContract { } } -#[cfg(test)] -mod test; #[cfg(test)] mod proptest_invariants; +#[cfg(test)] +mod test; diff --git a/contracts/production_escrow/src/storage.rs b/contracts/production_escrow/src/storage.rs index cf06085..8f4f97a 100644 --- a/contracts/production_escrow/src/storage.rs +++ b/contracts/production_escrow/src/storage.rs @@ -1,11 +1,21 @@ use crate::types::{Campaign, DataKey, Dispute, HarvestRecord, TrancheList}; use soroban_sdk::{Address, Env, Vec}; -const DAY_IN_LEDGERS: u32 = 17280; +/// Approximate number of ledgers in a 24h period on Stellar (~5s/ledger). +pub const DAY_IN_LEDGERS: u32 = 17280; const INSTANCE_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; const INSTANCE_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 90; -const PERSISTENT_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; -const PERSISTENT_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 90; +/// If a persistent entry's remaining TTL is below this many ledgers (~30 days), +/// the next write/touch extends it. See the TTL / archival section of README.md. +pub const PERSISTENT_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; +/// Target remaining TTL after a bump (~90 days of ledgers). +pub const PERSISTENT_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 90; + +/// Panic message used when a mutating path needs a campaign that is either +/// missing or whose persistent entry has been archived. Public getters return +/// `Option` instead; this string is for write-path preconditions. +pub const MISSING_OR_ARCHIVED_CAMPAIGN: &str = "campaign not found (missing or archived; restore with RestoreFootprintOp or keep alive via touch_campaign)"; +pub const MISSING_OR_ARCHIVED_DISPUTE: &str = "dispute not found (missing or archived; restore with RestoreFootprintOp or keep alive via touch_campaign)"; pub fn extend_instance_ttl(env: &Env) { env.storage() @@ -14,9 +24,37 @@ pub fn extend_instance_ttl(env: &Env) { } fn extend_persistent_ttl(env: &Env, key: &DataKey) { - env.storage() - .persistent() - .extend_ttl(key, PERSISTENT_LIFETIME_THRESHOLD, PERSISTENT_BUMP_AMOUNT); + env.storage().persistent().extend_ttl( + key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); +} + +fn extend_if_present(env: &Env, key: &DataKey) { + if env.storage().persistent().has(key) { + extend_persistent_ttl(env, key); + } +} + +/// Permissionless keep-alive: extends TTL on every persistent campaign entry +/// that currently exists (`Campaign`, and if present `Dispute`, `Tranches`, +/// `HarvestRecord`) plus the contract instance. Does not change any stored +/// value. Contribution keys are per-investor and are not enumerated here. +/// +/// Panics if the campaign key is missing (never created) or unreadable +/// because it has already been archived — in the archived case the host +/// fails the read and a `RestoreFootprintOp` is required first. +pub fn touch_campaign(env: &Env, campaign_id: u64) { + let campaign_key = DataKey::Campaign(campaign_id); + if !env.storage().persistent().has(&campaign_key) { + panic!("{}", MISSING_OR_ARCHIVED_CAMPAIGN); + } + extend_persistent_ttl(env, &campaign_key); + extend_if_present(env, &DataKey::Dispute(campaign_id)); + extend_if_present(env, &DataKey::Tranches(campaign_id)); + extend_if_present(env, &DataKey::HarvestRecord(campaign_id)); + extend_instance_ttl(env); } pub fn has_admin(env: &Env) -> bool { @@ -46,6 +84,13 @@ pub fn get_campaign(env: &Env, campaign_id: u64) -> Option { campaign } +/// Like `get_campaign`, but panics with an archival-aware message when the +/// entry is missing. Used by mutating methods that require the campaign to +/// already exist. +pub fn require_campaign(env: &Env, campaign_id: u64) -> Campaign { + get_campaign(env, campaign_id).unwrap_or_else(|| panic!("{}", MISSING_OR_ARCHIVED_CAMPAIGN)) +} + pub fn set_campaign(env: &Env, campaign_id: u64, campaign: &Campaign) { let key = DataKey::Campaign(campaign_id); env.storage().persistent().set(&key, campaign); @@ -61,6 +106,10 @@ pub fn get_dispute(env: &Env, campaign_id: u64) -> Option { dispute } +pub fn require_dispute(env: &Env, campaign_id: u64) -> Dispute { + get_dispute(env, campaign_id).unwrap_or_else(|| panic!("{}", MISSING_OR_ARCHIVED_DISPUTE)) +} + pub fn set_dispute(env: &Env, campaign_id: u64, dispute: &Dispute) { let key = DataKey::Dispute(campaign_id); env.storage().persistent().set(&key, dispute); diff --git a/contracts/production_escrow/src/test.rs b/contracts/production_escrow/src/test.rs index ca9f4bd..bb94e9f 100644 --- a/contracts/production_escrow/src/test.rs +++ b/contracts/production_escrow/src/test.rs @@ -2,7 +2,7 @@ use super::*; use soroban_sdk::{ - testutils::{Address as _, Events}, + testutils::{storage::Persistent as _, Address as _, Events, Ledger}, token::{Client as TokenClient, StellarAssetClient}, Address, Env, IntoVal, Symbol, Vec, }; @@ -1643,3 +1643,132 @@ fn test_get_admin_returns_initialized_admin() { let stored_admin = client.get_admin(); assert_eq!(stored_admin, admin); } + +// ─── touch_campaign / TTL keep-alive ───────────────────────────────────────── + +fn persistent_ttl(env: &Env, contract: &Address, key: &DataKey) -> u32 { + env.as_contract(contract, || env.storage().persistent().get_ttl(key)) +} + +/// Advance the ledger far enough that remaining persistent TTL falls below +/// `PERSISTENT_LIFETIME_THRESHOLD`, so the next `extend_ttl` actually bumps. +fn expire_persistent_ttl_below_threshold(env: &Env) { + let delta = + crate::storage::PERSISTENT_BUMP_AMOUNT - crate::storage::PERSISTENT_LIFETIME_THRESHOLD + 1; + env.ledger().with_mut(|li| { + li.sequence_number += delta; + }); +} + +#[test] +fn test_touch_campaign_extends_ttl_without_state_change() { + let s = token_funded_campaign(); + let key = DataKey::Campaign(s.campaign_id); + let before = s.client.get_campaign(&s.campaign_id).unwrap(); + + assert_eq!( + persistent_ttl(&s.env, &s.client.address, &key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + + expire_persistent_ttl_below_threshold(&s.env); + let ttl_before_touch = persistent_ttl(&s.env, &s.client.address, &key); + assert!(ttl_before_touch < crate::storage::PERSISTENT_LIFETIME_THRESHOLD); + + s.client.touch_campaign(&s.campaign_id); + + assert_eq!( + persistent_ttl(&s.env, &s.client.address, &key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + assert_eq!(s.client.get_campaign(&s.campaign_id).unwrap(), before); +} + +#[test] +fn test_touch_campaign_bumps_related_terminal_keys() { + let s = token_funded_campaign(); + let mut tranches: Vec = Vec::new(&s.env); + tranches.push_back(make_tranche(&s.env, 500, "planting")); + tranches.push_back(make_tranche(&s.env, 500, "harvest")); + s.client.configure_tranches(&s.campaign_id, &tranches); + s.client + .report_harvest(&s.campaign_id, &s.farmer, &Symbol::new(&s.env, "ok")); + s.client.settle_campaign(&s.campaign_id, &s.farmer, &400i128); + + let campaign_key = DataKey::Campaign(s.campaign_id); + let harvest_key = DataKey::HarvestRecord(s.campaign_id); + let tranches_key = DataKey::Tranches(s.campaign_id); + + expire_persistent_ttl_below_threshold(&s.env); + assert!( + persistent_ttl(&s.env, &s.client.address, &campaign_key) + < crate::storage::PERSISTENT_LIFETIME_THRESHOLD + ); + assert!( + persistent_ttl(&s.env, &s.client.address, &harvest_key) + < crate::storage::PERSISTENT_LIFETIME_THRESHOLD + ); + assert!( + persistent_ttl(&s.env, &s.client.address, &tranches_key) + < crate::storage::PERSISTENT_LIFETIME_THRESHOLD + ); + + s.client.touch_campaign(&s.campaign_id); + + assert_eq!( + persistent_ttl(&s.env, &s.client.address, &campaign_key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + assert_eq!( + persistent_ttl(&s.env, &s.client.address, &harvest_key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + assert_eq!( + persistent_ttl(&s.env, &s.client.address, &tranches_key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + assert_eq!( + s.client.get_campaign(&s.campaign_id).unwrap().status, + CampaignStatus::Settled + ); +} + +#[test] +fn test_touch_campaign_bumps_dispute_key() { + let s = funded_campaign(); + s.client.open_dispute( + &s.campaign_id, + &s.investor1, + &Symbol::new(&s.env, "Delay"), + ); + s.client + .resolve_dispute(&s.campaign_id, &DisputeResolution::FullRefund, &0i128); + + let dispute_key = DataKey::Dispute(s.campaign_id); + expire_persistent_ttl_below_threshold(&s.env); + assert!( + persistent_ttl(&s.env, &s.client.address, &dispute_key) + < crate::storage::PERSISTENT_LIFETIME_THRESHOLD + ); + + s.client.touch_campaign(&s.campaign_id); + + assert_eq!( + persistent_ttl(&s.env, &s.client.address, &dispute_key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + let campaign = s.client.get_campaign(&s.campaign_id).unwrap(); + assert_eq!(campaign.status, CampaignStatus::Resolved); +} + +#[test] +#[should_panic(expected = "campaign not found (missing or archived")] +fn test_touch_campaign_missing_panics() { + 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); + client.touch_campaign(&999u64); +} diff --git a/contracts/registry/README.md b/contracts/registry/README.md new file mode 100644 index 0000000..fbb3281 --- /dev/null +++ b/contracts/registry/README.md @@ -0,0 +1,119 @@ +# RegistryContract + +Soroban smart contract for campaign registry, farmer profiles, and activity +audit trails on the Agrocylo platform. + +## Overview + +The `RegistryContract` stores farmer-authored campaign metadata, a +lifecycle-status mirror of the linked `ProductionEscrowContract`, paged +activity logs, and access control (admin + approved contracts). + +See [INTEGRATION.md](../INTEGRATION.md) for how this contract is meant to be +driven alongside the escrow contract. + +## Public Methods + +| Method | Description | +|--------|-------------| +| `initialize` | Set the admin (once) | +| `update_admin` | Replace the admin (admin auth) | +| `approve_contract` / `revoke_contract` | Allowlist for cross-contract callers | +| `register_farmer` / `get_farmer` | Farmer profile | +| `register_campaign` / `get_campaign` | Farmer-authored title/description (`Option`) | +| `link_campaign_escrow` | Bind a campaign to its escrow instance | +| `update_campaign_status` | Mirror escrow lifecycle status | +| `get_campaign_record` | Escrow-linked record (`Option`) | +| `record_activity` / `get_campaign_activities` | Paged activity log | +| `reconcile_campaign_status` | Permissionless status-mirror self-heal | +| `touch_campaign` | Permissionless TTL keep-alive (no state change) | + +## TTL, archival, and historical reads + +Soroban persistent entries are **not immortal**. Each write calls `extend_ttl` +with: + +| Constant | Value | Meaning | +|----------|-------|---------| +| `PERSISTENT_LIFETIME_THRESHOLD` | `DAY_IN_LEDGERS * 30` (518,400 ledgers, ~30 days) | Remaining TTL must fall below this before a bump happens | +| `PERSISTENT_BUMP_AMOUNT` | `DAY_IN_LEDGERS * 90` (1,555,200 ledgers, ~90 days) | Target remaining TTL after a bump | + +(`DAY_IN_LEDGERS` is 17,280, ~5 seconds per ledger.) Instance storage uses the +same 30/90-day window. + +### Which entries stop being written once a campaign is terminal + +After the last `update_campaign_status` (typically to `Settled`, `Failed`, or +`Resolved`) and the last `record_activity` for that campaign, nothing mutates +the campaign's persistent keys again: + +| Key | Last writer | Still bumped on read? | +|-----|-------------|------------------------| +| `DataKey::Campaign(id)` | `register_campaign` (never updated after) | **No** — `get_campaign` does not extend TTL | +| `DataKey::CampaignRecord(id)` | `update_campaign_status` / `reconcile_campaign_status` | Yes — `get_campaign_record` | +| `DataKey::CampaignActivitiesPageCount(id)` | `record_activity` | **No** | +| `DataKey::CampaignActivitiesPage(id, n)` | `record_activity` (only the current last page) | **No** — earlier pages are never rewritten | + +Farmer-keyed entries (`Farmer`, `FarmerCampaignsPage`, +`FarmerCampaignsPageCount`) are not campaign-scoped. They stay alive only +while that farmer is still registering/linking campaigns (or via an explicit +`RestoreFootprintOp`). `touch_campaign` does not bump them. + +If nobody writes those keys, TTL runs out. An archived persistent entry is +unreadable until restored; public getters return `Option` for a **missing** +live key (`None`), but an **archived** key fails at the host. Mutating methods +that require the record (`require_campaign_record`) panic with a message that +points at restore / `touch_campaign`. + +### Keep-alive: `touch_campaign` + +``` +touch_campaign(campaign_id) +``` + +Permissionless. Extends TTL on `Campaign` metadata and/or `CampaignRecord` +when present, every activity page plus the page-count key, and the contract +instance. It does not change any stored value. Succeeds if either the +metadata entry or the escrow-linked record exists. + +**Suggested indexer cadence:** call `touch_campaign` at least once every ~30 +days for every campaign whose history must stay readable. `extend_ttl` is a +no-op while remaining TTL is still above the 30-day threshold, so monthly +calls are cheap and will bump once remaining life drops below the threshold +(~60 days after the last bump). + +### Restore path + +If an entry has already archived, `touch_campaign` cannot revive it. Submit a +[`RestoreFootprintOp`](https://developers.stellar.org/docs/learn/fundamentals/contract-development/storage/state-archival) +for the archived keys (and the contract instance / WASM if those archived too), +then resume periodic `touch_campaign` calls. + +## Building + +```bash +cargo build --target wasm32-unknown-unknown --release -p registry +``` + +## Testing + +```bash +cargo test -p registry +``` + +## Project Structure + +``` +registry/ +├── src/ +│ ├── lib.rs # Contract entry point +│ ├── types.rs # Data types and DataKey +│ ├── storage.rs # Storage helpers and TTL constants +│ ├── admin.rs # Admin / allowlist +│ ├── farmer.rs # Farmer profiles +│ ├── campaign.rs # Campaign metadata, escrow link, status mirror +│ ├── activity.rs # Paged activity log +│ ├── events.rs # Event definitions +│ └── test.rs # Unit test suite +└── Cargo.toml +``` diff --git a/contracts/registry/src/campaign.rs b/contracts/registry/src/campaign.rs index dc78e14..aa407e5 100644 --- a/contracts/registry/src/campaign.rs +++ b/contracts/registry/src/campaign.rs @@ -1,5 +1,5 @@ -use crate::{events, storage}; use crate::types::{CampaignInfo, CampaignRecord, CampaignStatus}; +use crate::{events, storage}; use production_escrow::{CampaignStatus as EscrowCampaignStatus, ProductionEscrowContractClient}; use soroban_sdk::{Address, Env, String, Symbol, Vec}; @@ -34,6 +34,12 @@ pub fn get_campaign(env: &Env, campaign_id: u64) -> Option { storage::get_campaign(env, campaign_id) } +/// Permissionless keep-alive for a campaign's persistent storage entries. +/// Extends TTL only; does not change stored values. See `storage::touch_campaign`. +pub fn touch_campaign(env: &Env, campaign_id: u64) { + storage::touch_campaign(env, campaign_id); +} + /// Links a campaign to its ProductionEscrowContract instance and crop/region /// metadata, and begins tracking its lifecycle status. Distinct from /// `register_campaign`, which stores the farmer-authored title/description. @@ -78,8 +84,7 @@ pub fn update_campaign_status( caller: &Address, new_status: CampaignStatus, ) { - let mut record = storage::get_campaign_record(env, campaign_id) - .unwrap_or_else(|| panic!("campaign record not found")); + let mut record = storage::require_campaign_record(env, campaign_id); let is_admin = storage::get_admin(env) == *caller; let is_registered_escrow = record.escrow_contract == *caller; @@ -139,13 +144,12 @@ fn map_escrow_status(status: &EscrowCampaignStatus) -> CampaignStatus { /// Returns `true` if drift was found and corrected, `false` if the mirror /// already matched. pub fn reconcile_campaign_status(env: &Env, campaign_id: u64) -> bool { - let mut record = storage::get_campaign_record(env, campaign_id) - .unwrap_or_else(|| panic!("campaign record not found")); + let mut record = storage::require_campaign_record(env, campaign_id); let escrow_client = ProductionEscrowContractClient::new(env, &record.escrow_contract); let escrow_campaign = escrow_client .get_campaign(&campaign_id) - .unwrap_or_else(|| panic!("linked escrow campaign not found")); + .unwrap_or_else(|| panic!("linked escrow campaign not found (missing or archived; restore with RestoreFootprintOp or keep alive via touch_campaign)")); let true_status = map_escrow_status(&escrow_campaign.status); if record.status == true_status { diff --git a/contracts/registry/src/lib.rs b/contracts/registry/src/lib.rs index b6304dd..88f813e 100644 --- a/contracts/registry/src/lib.rs +++ b/contracts/registry/src/lib.rs @@ -148,6 +148,20 @@ impl RegistryContract { pub fn reconcile_campaign_status(env: Env, campaign_id: u64) -> bool { campaign::reconcile_campaign_status(&env, campaign_id) } + + /// Permissionless keep-alive for a campaign's persistent storage entries. + /// + /// Extends TTL on `Campaign` metadata, `CampaignRecord`, and all activity + /// pages when present, plus the contract instance. Does not modify any + /// stored value. An indexer or keeper can call this periodically so + /// settled / failed / resolved campaigns stay readable without a + /// `RestoreFootprintOp`. + /// + /// Once an entry has already been archived, this call cannot revive it — + /// restore the footprint first, then resume touching. + pub fn touch_campaign(env: Env, campaign_id: u64) { + campaign::touch_campaign(&env, campaign_id); + } } #[cfg(test)] diff --git a/contracts/registry/src/storage.rs b/contracts/registry/src/storage.rs index b5b56a0..9ca73d4 100644 --- a/contracts/registry/src/storage.rs +++ b/contracts/registry/src/storage.rs @@ -1,11 +1,20 @@ use crate::types::{CampaignInfo, CampaignRecord, DataKey, FarmerProfile}; use soroban_sdk::{Address, Env, Vec}; -const DAY_IN_LEDGERS: u32 = 17280; +/// Approximate number of ledgers in a 24h period on Stellar (~5s/ledger). +pub const DAY_IN_LEDGERS: u32 = 17280; const INSTANCE_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; const INSTANCE_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 90; -const PERSISTENT_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; -const PERSISTENT_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 90; +/// If a persistent entry's remaining TTL is below this many ledgers (~30 days), +/// the next write/touch extends it. See the TTL / archival section of README.md. +pub const PERSISTENT_LIFETIME_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; +/// Target remaining TTL after a bump (~90 days of ledgers). +pub const PERSISTENT_BUMP_AMOUNT: u32 = DAY_IN_LEDGERS * 90; + +/// Panic message used when a mutating path needs a campaign record that is +/// either missing or whose persistent entry has been archived. +pub const MISSING_OR_ARCHIVED_CAMPAIGN: &str = "campaign not found (missing or archived; restore with RestoreFootprintOp or keep alive via touch_campaign)"; +pub const MISSING_OR_ARCHIVED_CAMPAIGN_RECORD: &str = "campaign record not found (missing or archived; restore with RestoreFootprintOp or keep alive via touch_campaign)"; /// Maximum number of `ActivityRecord`s stored per `CampaignActivitiesPage`. /// @@ -31,9 +40,53 @@ pub fn extend_instance_ttl(env: &Env) { } pub fn extend_persistent_ttl(env: &Env, key: &DataKey) { - env.storage() - .persistent() - .extend_ttl(key, PERSISTENT_LIFETIME_THRESHOLD, PERSISTENT_BUMP_AMOUNT); + env.storage().persistent().extend_ttl( + key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); +} + +fn extend_if_present(env: &Env, key: &DataKey) { + if env.storage().persistent().has(key) { + extend_persistent_ttl(env, key); + } +} + +/// Permissionless keep-alive: extends TTL on every persistent campaign entry +/// that currently exists (`Campaign` metadata, `CampaignRecord`, activity +/// page-count and each activity page) plus the contract instance. Does not +/// change any stored value. +/// +/// Farmer-keyed entries (`Farmer`, `FarmerCampaignsPage*`) are not campaign +/// scoped and are not bumped here. +/// +/// Panics if neither the `Campaign` nor `CampaignRecord` key is present. +pub fn touch_campaign(env: &Env, campaign_id: u64) { + let campaign_key = DataKey::Campaign(campaign_id); + let record_key = DataKey::CampaignRecord(campaign_id); + let has_campaign = env.storage().persistent().has(&campaign_key); + let has_record = env.storage().persistent().has(&record_key); + if !has_campaign && !has_record { + panic!("{}", MISSING_OR_ARCHIVED_CAMPAIGN); + } + if has_campaign { + extend_persistent_ttl(env, &campaign_key); + } + if has_record { + extend_persistent_ttl(env, &record_key); + } + + let count_key = DataKey::CampaignActivitiesPageCount(campaign_id); + if env.storage().persistent().has(&count_key) { + let page_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + extend_persistent_ttl(env, &count_key); + for page in 0..page_count { + extend_if_present(env, &DataKey::CampaignActivitiesPage(campaign_id, page)); + } + } + + extend_instance_ttl(env); } pub fn has_admin(env: &Env) -> bool { @@ -88,6 +141,11 @@ pub fn get_campaign(env: &Env, campaign_id: u64) -> Option { env.storage().persistent().get(&key) } +pub fn require_campaign_record(env: &Env, campaign_id: u64) -> CampaignRecord { + get_campaign_record(env, campaign_id) + .unwrap_or_else(|| panic!("{}", MISSING_OR_ARCHIVED_CAMPAIGN_RECORD)) +} + pub fn set_campaign(env: &Env, campaign: &CampaignInfo) { let key = DataKey::Campaign(campaign.id); env.storage().persistent().set(&key, campaign); diff --git a/contracts/registry/src/test.rs b/contracts/registry/src/test.rs index 09264ce..c47d75c 100644 --- a/contracts/registry/src/test.rs +++ b/contracts/registry/src/test.rs @@ -1,6 +1,6 @@ -use crate::{ActivityAction, CampaignStatus, RegistryContract, RegistryContractClient}; +use crate::{ActivityAction, CampaignStatus, DataKey, RegistryContract, RegistryContractClient}; use soroban_sdk::{ - testutils::{Address as _, Events, Ledger, MockAuth, MockAuthInvoke}, + testutils::{storage::Persistent as _, Address as _, Events, Ledger, MockAuth, MockAuthInvoke}, vec, Address, Env, IntoVal, String, Symbol, }; @@ -864,3 +864,148 @@ fn test_farmer_campaigns_paginate_across_multiple_pages() { assert_eq!(campaigns.get(i).unwrap(), i as u64); } } + +// ─── touch_campaign / TTL keep-alive ───────────────────────────────────────── + +fn persistent_ttl(env: &Env, contract: &Address, key: &DataKey) -> u32 { + env.as_contract(contract, || env.storage().persistent().get_ttl(key)) +} + +fn expire_persistent_ttl_below_threshold(env: &Env) { + let delta = + crate::storage::PERSISTENT_BUMP_AMOUNT - crate::storage::PERSISTENT_LIFETIME_THRESHOLD + 1; + env.ledger().with_mut(|li| { + li.sequence_number += delta; + }); +} + +#[test] +fn test_touch_campaign_extends_ttl_without_state_change() { + let (env, admin, user, _, client) = create_test_env(); + client.initialize(&admin); + + let campaign_id = 1u64; + let title = String::from_str(&env, "Coffee Farm"); + let description = String::from_str(&env, "High-quality arabica coffee"); + client.register_campaign(&campaign_id, &user, &title, &description); + + let before = client.get_campaign(&campaign_id).unwrap(); + let key = DataKey::Campaign(campaign_id); + assert_eq!( + persistent_ttl(&env, &client.address, &key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + + expire_persistent_ttl_below_threshold(&env); + assert!( + persistent_ttl(&env, &client.address, &key) < crate::storage::PERSISTENT_LIFETIME_THRESHOLD + ); + + client.touch_campaign(&campaign_id); + + assert_eq!( + persistent_ttl(&env, &client.address, &key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + assert_eq!(client.get_campaign(&campaign_id).unwrap(), before); +} + +#[test] +fn test_touch_campaign_bumps_record_and_activity_pages() { + let (env, admin, user, escrow, client) = create_test_env(); + client.initialize(&admin); + + let campaign_id = 1u64; + client.register_campaign( + &campaign_id, + &user, + &String::from_str(&env, "Coffee Farm"), + &String::from_str(&env, "Arabica"), + ); + client.link_campaign_escrow( + &campaign_id, + &user, + &escrow, + &Symbol::new(&env, "coffee"), + &Symbol::new(&env, "highlands"), + ); + client.update_campaign_status(&campaign_id, &admin, &CampaignStatus::Settled); + client.record_activity(&campaign_id, &admin, &ActivityAction::CampaignSettled); + + let campaign_key = DataKey::Campaign(campaign_id); + let record_key = DataKey::CampaignRecord(campaign_id); + let count_key = DataKey::CampaignActivitiesPageCount(campaign_id); + let page_key = DataKey::CampaignActivitiesPage(campaign_id, 0); + + expire_persistent_ttl_below_threshold(&env); + assert!( + persistent_ttl(&env, &client.address, &campaign_key) + < crate::storage::PERSISTENT_LIFETIME_THRESHOLD + ); + assert!( + persistent_ttl(&env, &client.address, &record_key) + < crate::storage::PERSISTENT_LIFETIME_THRESHOLD + ); + assert!( + persistent_ttl(&env, &client.address, &count_key) + < crate::storage::PERSISTENT_LIFETIME_THRESHOLD + ); + assert!( + persistent_ttl(&env, &client.address, &page_key) + < crate::storage::PERSISTENT_LIFETIME_THRESHOLD + ); + + client.touch_campaign(&campaign_id); + + assert_eq!( + persistent_ttl(&env, &client.address, &campaign_key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + assert_eq!( + persistent_ttl(&env, &client.address, &record_key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + assert_eq!( + persistent_ttl(&env, &client.address, &count_key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + assert_eq!( + persistent_ttl(&env, &client.address, &page_key), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); + assert_eq!( + client.get_campaign_record(&campaign_id).unwrap().status, + CampaignStatus::Settled + ); +} + +#[test] +fn test_touch_campaign_linked_record_only() { + let (env, admin, user, escrow, client) = create_test_env(); + client.initialize(&admin); + + let campaign_id = 1u64; + client.link_campaign_escrow( + &campaign_id, + &user, + &escrow, + &Symbol::new(&env, "coffee"), + &Symbol::new(&env, "highlands"), + ); + + expire_persistent_ttl_below_threshold(&env); + client.touch_campaign(&campaign_id); + + assert_eq!( + persistent_ttl(&env, &client.address, &DataKey::CampaignRecord(campaign_id)), + crate::storage::PERSISTENT_BUMP_AMOUNT + ); +} + +#[test] +#[should_panic(expected = "campaign not found (missing or archived")] +fn test_touch_campaign_missing_panics() { + let (_env, admin, _, _, client) = create_test_env(); + client.initialize(&admin); + client.touch_campaign(&999u64); +}