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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ Lodestar addresses all three brief requirements:

**Bazaar-enabled facilitator** — The backend wraps the x402 facilitator and exposes a `/api/demo-run` endpoint that drives a full payment cycle: 402 → sign → retry → data. The demo page visualizes this step by step in real time.

**Mainnet-ready infrastructure** — The Soroban contract uses persistent storage with maximum TTL to prevent archival. The backend is production-grade (pino logging, env validation, proper error codes). The frontend is Next.js 14 with TypeScript strict mode. Switching to mainnet requires changing one env var: `STELLAR_NETWORK=mainnet`.
**Mainnet-ready infrastructure** — The Soroban contract uses persistent storage with a low-watermark TTL threshold (`extend_ttl(&key, LOW_WATERMARK, MAX_TTL)`) to prevent archival while only charging rent when TTL has genuinely decayed — halving the worst-case cost on the hot `update_reputation` path. The backend is production-grade (pino logging, env validation, proper error codes). The frontend is Next.js 14 with TypeScript strict mode. Switching to mainnet requires changing one env var: `STELLAR_NETWORK=mainnet`.

---

Expand Down
45 changes: 31 additions & 14 deletions contract/agents/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,24 @@ use soroban_sdk::{
};

// ── Constants ────────────────────────────────────────────────────────────────
const MAX_TTL: u32 = 100_000_000; // extended for tests/CI stability
// Maximum TTL for persistent storage entries. Set to 100_000_000 ledgers for
// test/CI stability; on a live network this should mirror the registry value
// (3_110_400 ≈ 180 days at 5 s/ledger).
const MAX_TTL: u32 = 100_000_000;

// Low-watermark threshold for TTL bumps: half of MAX_TTL.
//
// Rationale: passing LOW_WATERMARK as the `threshold` argument of `extend_ttl`
// means the host only charges for the bump when the remaining TTL has actually
// decayed below this value. On the hot `record_payment` path (two entries
// bumped per call: Agent + Policy), this can halve the rent cost compared to
// the previous unconditional MAX_TTL, MAX_TTL bump.
//
// We keep it at ½ × MAX_TTL so the safety margin is symmetric: an entry
// that has not been touched for half the max window is still half the window
// away from archival, giving enough runway for the next interaction to refresh it.
const LOW_WATERMARK: u32 = 50_000_000;

#[cfg(not(test))]
const DAY_LEDGERS: u64 = 17_280; // 86400 / 5
#[cfg(test)]
Expand Down Expand Up @@ -138,15 +155,15 @@ impl LodestarAgents {
.set(&DataKey::RegistryContract, &registry_contract);
env.storage()
.persistent()
.extend_ttl(&DataKey::RegistryContract, MAX_TTL, MAX_TTL);
.extend_ttl(&DataKey::RegistryContract, LOW_WATERMARK, MAX_TTL);
}

/// Deploy-time setup: store the admin address for privileged operations.
pub fn __constructor(env: Env, admin: Address) {
env.storage().persistent().set(&DataKey::Admin, &admin);
env.storage()
.persistent()
.extend_ttl(&DataKey::Admin, MAX_TTL, MAX_TTL);
.extend_ttl(&DataKey::Admin, LOW_WATERMARK, MAX_TTL);
}

// Register a new agent.
Expand Down Expand Up @@ -185,7 +202,7 @@ impl LodestarAgents {
};

env.storage().persistent().set(&key, &entry);
env.storage().persistent().extend_ttl(&key, MAX_TTL, MAX_TTL);
env.storage().persistent().extend_ttl(&key, LOW_WATERMARK, MAX_TTL);

// Update agent IDs list
let ids_key = DataKey::AgentIds;
Expand All @@ -198,7 +215,7 @@ impl LodestarAgents {
env.storage().persistent().set(&ids_key, &ids);
env.storage()
.persistent()
.extend_ttl(&ids_key, MAX_TTL, MAX_TTL);
.extend_ttl(&ids_key, LOW_WATERMARK, MAX_TTL);

// Update count
let count_key = DataKey::AgentCount;
Expand All @@ -211,7 +228,7 @@ impl LodestarAgents {
env.storage().persistent().set(&count_key, &new_count);
env.storage()
.persistent()
.extend_ttl(&count_key, MAX_TTL, MAX_TTL);
.extend_ttl(&count_key, LOW_WATERMARK, MAX_TTL);

// Default spending policy
let policy = SpendingPolicy {
Expand All @@ -227,7 +244,7 @@ impl LodestarAgents {
env.storage().persistent().set(&policy_key, &policy);
env.storage()
.persistent()
.extend_ttl(&policy_key, MAX_TTL, MAX_TTL);
.extend_ttl(&policy_key, LOW_WATERMARK, MAX_TTL);

new_count
}
Expand Down Expand Up @@ -371,7 +388,7 @@ impl LodestarAgents {
env.storage().persistent().set(&agent_key, &agent);
env.storage()
.persistent()
.extend_ttl(&agent_key, MAX_TTL, MAX_TTL);
.extend_ttl(&agent_key, LOW_WATERMARK, MAX_TTL);

// Update daily spend in policy using helper
let updated_policy = if success {
Expand All @@ -389,7 +406,7 @@ impl LodestarAgents {
env.storage().persistent().set(&policy_key, &updated_policy);
env.storage()
.persistent()
.extend_ttl(&policy_key, MAX_TTL, MAX_TTL);
.extend_ttl(&policy_key, LOW_WATERMARK, MAX_TTL);
}

// Flag an agent (admin-only)
Expand Down Expand Up @@ -420,7 +437,7 @@ impl LodestarAgents {
env.storage().persistent().set(&key, &agent);
env.storage()
.persistent()
.extend_ttl(&key, MAX_TTL, MAX_TTL);
.extend_ttl(&key, LOW_WATERMARK, MAX_TTL);
}

// Deactivate agent (owner only)
Expand All @@ -442,7 +459,7 @@ impl LodestarAgents {
env.storage().persistent().set(&key, &agent);
env.storage()
.persistent()
.extend_ttl(&key, MAX_TTL, MAX_TTL);
.extend_ttl(&key, LOW_WATERMARK, MAX_TTL);
}

// Admin deactivate agent (can deactivate any agent regardless of ownership)
Expand Down Expand Up @@ -470,7 +487,7 @@ impl LodestarAgents {
env.storage().persistent().set(&key, &agent);
env.storage()
.persistent()
.extend_ttl(&key, MAX_TTL, MAX_TTL);
.extend_ttl(&key, LOW_WATERMARK, MAX_TTL);
}

// Get the current admin address
Expand Down Expand Up @@ -500,7 +517,7 @@ impl LodestarAgents {
.set(&DataKey::Admin, &new_admin);
env.storage()
.persistent()
.extend_ttl(&DataKey::Admin, MAX_TTL, MAX_TTL);
.extend_ttl(&DataKey::Admin, LOW_WATERMARK, MAX_TTL);
}

// List agents (paginated by limit)
Expand Down Expand Up @@ -608,7 +625,7 @@ impl LodestarAgents {
env.storage().persistent().set(&policy_key, &policy);
env.storage()
.persistent()
.extend_ttl(&policy_key, MAX_TTL, MAX_TTL);
.extend_ttl(&policy_key, LOW_WATERMARK, MAX_TTL);
}

// Get the current scoring configuration constants
Expand Down
39 changes: 29 additions & 10 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,26 @@ use soroban_sdk::{
contract, contractimpl, contracttype, vec, Address, Env, IntoVal, String, Symbol, Vec,
};

const MAX_TTL: u32 = 3110400;
// Maximum TTL for persistent storage entries: 3_110_400 ledgers ≈ 180 days at
// 5 s per ledger (the current Stellar mainnet target). This is the ceiling
// passed as the `extend_to` argument of `extend_ttl` — entries will never be
// bumped beyond this value.
const MAX_TTL: u32 = 3_110_400;

// Low-watermark threshold for TTL bumps: 1_555_200 ledgers ≈ 90 days.
//
// Rationale: extending TTL to MAX_TTL on *every* write charges the caller for
// the full 180-day window even when the entry's TTL is, say, 179 days and
// almost nothing would actually decay. By passing LOW_WATERMARK as the
// `threshold` argument of `extend_ttl`, the host only performs (and charges
// for) the bump when the remaining TTL has genuinely decayed below 90 days.
// This halves the worst-case rent cost on the hot `update_reputation` path,
// where two entries (Service and LastVote) are bumped on every vote.
//
// Choosing half of MAX_TTL keeps the safety margin generous: an entry that
// hasn't been touched for 90 days is still 90 days away from archival, which
// is ample time for the next interaction to refresh it.
const LOW_WATERMARK: u32 = 1_555_200;

// Minimum number of ledgers that must elapse before the same agent may vote on
// the same service again. ~1 hour at 5 s/ledger. This caps how fast any single
Expand Down Expand Up @@ -88,7 +107,7 @@ impl LodestarRegistry {
.set(&DataKey::AgentsContract, &agents_contract);
env.storage()
.persistent()
.extend_ttl(&DataKey::AgentsContract, MAX_TTL, MAX_TTL);
.extend_ttl(&DataKey::AgentsContract, LOW_WATERMARK, MAX_TTL);
}

/// Address of the LodestarAgents contract this registry was deployed against.
Expand Down Expand Up @@ -165,12 +184,12 @@ impl LodestarRegistry {
.set(&DataKey::Service(new_id), &entry);
env.storage()
.persistent()
.extend_ttl(&DataKey::Service(new_id), MAX_TTL, MAX_TTL);
.extend_ttl(&DataKey::Service(new_id), LOW_WATERMARK, MAX_TTL);

env.storage().persistent().set(&DataKey::Counter, &new_id);
env.storage()
.persistent()
.extend_ttl(&DataKey::Counter, MAX_TTL, MAX_TTL);
.extend_ttl(&DataKey::Counter, LOW_WATERMARK, MAX_TTL);

let mut ids: Vec<u64> = env
.storage()
Expand All @@ -181,7 +200,7 @@ impl LodestarRegistry {
env.storage().persistent().set(&DataKey::ServiceIds, &ids);
env.storage()
.persistent()
.extend_ttl(&DataKey::ServiceIds, MAX_TTL, MAX_TTL);
.extend_ttl(&DataKey::ServiceIds, LOW_WATERMARK, MAX_TTL);

let mut cat_ids: Vec<u64> = env
.storage()
Expand All @@ -194,7 +213,7 @@ impl LodestarRegistry {
.set(&DataKey::ServiceIdsByCategory(cat.clone()), &cat_ids);
env.storage().persistent().extend_ttl(
&DataKey::ServiceIdsByCategory(cat),
MAX_TTL,
LOW_WATERMARK,
MAX_TTL,
);

Expand Down Expand Up @@ -366,12 +385,12 @@ impl LodestarRegistry {
.set(&DataKey::Service(id), &entry);
env.storage()
.persistent()
.extend_ttl(&DataKey::Service(id), MAX_TTL, MAX_TTL);
.extend_ttl(&DataKey::Service(id), LOW_WATERMARK, MAX_TTL);

env.storage().persistent().set(&vote_key, &now);
env.storage()
.persistent()
.extend_ttl(&vote_key, MAX_TTL, MAX_TTL);
.extend_ttl(&vote_key, LOW_WATERMARK, MAX_TTL);
}

pub fn deactivate_service(env: Env, provider: Address, id: u64) {
Expand All @@ -394,7 +413,7 @@ impl LodestarRegistry {
.set(&DataKey::Service(id), &entry);
env.storage()
.persistent()
.extend_ttl(&DataKey::Service(id), MAX_TTL, MAX_TTL);
.extend_ttl(&DataKey::Service(id), LOW_WATERMARK, MAX_TTL);

// Remove from category index
let cat_key = DataKey::ServiceIdsByCategory(entry.category.clone());
Expand All @@ -412,7 +431,7 @@ impl LodestarRegistry {
env.storage().persistent().set(&cat_key, &updated);
env.storage()
.persistent()
.extend_ttl(&cat_key, MAX_TTL, MAX_TTL);
.extend_ttl(&cat_key, LOW_WATERMARK, MAX_TTL);
}

pub fn get_service_count(env: Env) -> u64 {
Expand Down
49 changes: 30 additions & 19 deletions docs/storage-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ temporary storage today; adding either should be recorded here.

| Key | Value | Cardinality | Growth | TTL |
| --- | --- | --- | --- | --- |
| `Counter` | `u64` | 1 | fixed | extended to `MAX_TTL` on each service registration |
| `ServiceIds` | `Vec<u64>` | 1 | **grows with every service ever registered** | extended on registration |
| `Service(u64)` | `ServiceEntry` | one per service | linear in services | extended on registration and on every reputation change |
| `ServiceIdsByCategory(String)` | `Vec<u64>` | one per category in use | linear in categories; each entry grows with services in that category | extended on registration |
| `AgentsContract` | `Address` | 1 | fixed | extended at construction |
| `LastVote(u64, Address)` | `u64` (ledger sequence) | **one per (service, voting agent) pair** | quadratic in the worst case: services × agents | extended on each vote |

`MAX_TTL = 3_110_400` ledgers (~180 days at 5s ledgers).
| `Counter` | `u64` | 1 | fixed | threshold-bumped to `MAX_TTL` when below `LOW_WATERMARK` on each service registration |
| `ServiceIds` | `Vec<u64>` | 1 | **grows with every service ever registered** | threshold-bumped to `MAX_TTL` on registration |
| `Service(u64)` | `ServiceEntry` | one per service | linear in services | threshold-bumped to `MAX_TTL` on registration and on every reputation change |
| `ServiceIdsByCategory(String)` | `Vec<u64>` | one per category in use | linear in categories; each entry grows with services in that category | threshold-bumped to `MAX_TTL` on registration |
| `AgentsContract` | `Address` | 1 | fixed | threshold-bumped to `MAX_TTL` at construction |
| `LastVote(u64, Address)` | `u64` (ledger sequence) | **one per (service, voting agent) pair** | quadratic in the worst case: services × agents | threshold-bumped to `MAX_TTL` on each vote |

`MAX_TTL = 3_110_400` ledgers (~180 days at 5 s/ledger).
`LOW_WATERMARK = 1_555_200` ledgers (~90 days) — the threshold below which a TTL
bump is triggered. If remaining TTL exceeds `LOW_WATERMARK`, `extend_ttl` is a
no-op and the caller pays nothing for the bump.

### Notes

Expand All @@ -43,15 +46,17 @@ temporary storage today; adding either should be recorded here.

| Key | Value | Cardinality | Growth | TTL |
| --- | --- | --- | --- | --- |
| `AgentCount` | `u64` | 1 | fixed | extended on registration |
| `AgentIds` | `Vec<Address>` | 1 | **grows with every agent ever registered** | extended on registration |
| `Agent(Address)` | `AgentEntry` | one per agent | linear in agents | extended on registration and on every score update |
| `Policy(Address)` | spending policy | one per agent with a policy | linear in agents | extended on policy write |
| `RegistryContract` | `Address` | 1 | fixed | extended at construction |
| `Admin` | `Address` | 1 | fixed | extended at construction |
| `AgentCount` | `u64` | 1 | fixed | threshold-bumped to `MAX_TTL` on registration |
| `AgentIds` | `Vec<Address>` | 1 | **grows with every agent ever registered** | threshold-bumped to `MAX_TTL` on registration |
| `Agent(Address)` | `AgentEntry` | one per agent | linear in agents | threshold-bumped to `MAX_TTL` on registration and on every score update |
| `Policy(Address)` | spending policy | one per agent with a policy | linear in agents | threshold-bumped to `MAX_TTL` on policy write |
| `RegistryContract` | `Address` | 1 | fixed | threshold-bumped to `MAX_TTL` at construction |
| `Admin` | `Address` | 1 | fixed | threshold-bumped to `MAX_TTL` at construction |

`MAX_TTL = 100_000_000` ledgers — deliberately large for test and CI stability;
worth revisiting before a mainnet deploy, since TTL is rent.
`LOW_WATERMARK = 50_000_000` ledgers (½ × `MAX_TTL`) — bump only fires when
remaining TTL has decayed below this value.

### Notes

Expand All @@ -64,11 +69,17 @@ worth revisiting before a mainnet deploy, since TTL is rent.

## TTL classes

Both contracts extend to their `MAX_TTL` on every write, so any key that is
written regularly effectively never expires. Keys written **once** —
`AgentsContract`, `RegistryContract`, `Admin` — are the ones at risk of
archival on a quiet network, and are also the ones whose loss would break the
cross-contract call entirely.
Both contracts use a threshold-based TTL bump: `extend_ttl(&key, LOW_WATERMARK, MAX_TTL)`.
The host skips the bump (and charges nothing) when the entry's remaining TTL is
already above `LOW_WATERMARK`. This makes the bump a no-op on every call except
those that arrive more than half the max window after the previous write — which
cuts the worst-case rent cost on the hot `update_reputation` and `record_payment`
paths roughly in half.

Keys written **once** — `AgentsContract`, `RegistryContract`, `Admin` — are the
ones at highest archival risk on a quiet network, and their loss would break all
cross-contract calls. The threshold form is safe for them too: they will be
refreshed on the first interaction after their TTL drops below `LOW_WATERMARK`.

## What a migration must preserve

Expand Down