diff --git a/README.md b/README.md index 1bfecd19..3528a3ea 100644 --- a/README.md +++ b/README.md @@ -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`. --- diff --git a/contract/agents/src/lib.rs b/contract/agents/src/lib.rs index 373c2364..79dd86c0 100644 --- a/contract/agents/src/lib.rs +++ b/contract/agents/src/lib.rs @@ -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)] @@ -138,7 +155,7 @@ impl LodestarAgents { .set(&DataKey::RegistryContract, ®istry_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. @@ -146,7 +163,7 @@ impl LodestarAgents { 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. @@ -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; @@ -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; @@ -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 { @@ -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 } @@ -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 { @@ -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) @@ -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) @@ -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) @@ -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 @@ -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) @@ -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 diff --git a/contract/src/lib.rs b/contract/src/lib.rs index a42dc9f0..2629e8b7 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -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 @@ -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. @@ -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 = env .storage() @@ -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 = env .storage() @@ -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, ); @@ -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) { @@ -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()); @@ -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 { diff --git a/docs/storage-layout.md b/docs/storage-layout.md index fa931194..39e733a3 100644 --- a/docs/storage-layout.md +++ b/docs/storage-layout.md @@ -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` | 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` | 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` | 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` | 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 @@ -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
` | 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
` | 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 @@ -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