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
63 changes: 62 additions & 1 deletion contracts/production_escrow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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
Expand Down
100 changes: 47 additions & 53 deletions contracts/production_escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
}

Expand All @@ -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);

Expand Down Expand Up @@ -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");
}

Expand All @@ -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);

Expand All @@ -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 {
Expand All @@ -245,8 +234,7 @@ impl ProductionEscrowContract {
pub fn configure_tranches(env: Env, campaign_id: u64, tranches: Vec<Tranche>) {
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");
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -329,16 +318,17 @@ 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 {
panic!("not authorized to report harvest");
}
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");
}

Expand All @@ -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
Expand All @@ -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");
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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
Expand All @@ -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");
}
Expand All @@ -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<Campaign> {
storage::get_campaign(&env, campaign_id)
}
Expand All @@ -618,7 +612,7 @@ impl ProductionEscrowContract {
}
}

#[cfg(test)]
mod test;
#[cfg(test)]
mod proptest_invariants;
#[cfg(test)]
mod test;
Loading