Skip to content
Merged
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
22 changes: 22 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions contracts/production_escrow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
17 changes: 17 additions & 0 deletions contracts/production_escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,23 @@ impl ProductionEscrowContract {
pub fn get_harvest_record(env: Env, campaign_id: u64) -> Option<HarvestRecord> {
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)]
Expand Down
36 changes: 32 additions & 4 deletions contracts/production_escrow/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
}
220 changes: 220 additions & 0 deletions contracts/production_escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tranche> = 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<Tranche> = 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");
}
Loading