diff --git a/README.md b/README.md index 97a0cca..f75d683 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,20 @@ the lifetime signal. Off-chain billing pipelines that need the corrected view should subtract the decrement event amount from the lifetime counter when processing the `usage_dec` event. +### Lifetime settled-amount counters + +In addition to lifetime request counters, the escrow contract tracks lifetime +settled value in stroops: + +- `get_total_settled_by_agent(agent)` returns the cross-service amount ever + settled for one agent. +- `get_total_settled_all_time()` returns the protocol-wide amount ever settled. + +These counters are updated by settlement drains, use saturating arithmetic, and +are never reset or decremented by later `settle` calls. They default to `0` +before the first billable settlement, so dashboards can read them without +special-casing new agents or fresh deployments. + ### Per-agent rate limiting (fixed window) `record_usage` supports an optional per-agent rate limit anchored to diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 7c85584..a36a3e2 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -109,6 +109,13 @@ pub enum DataKey { /// Protocol-wide lifetime request counter, written by every /// successful `record_usage`. Useful as a single grafana gauge. TotalRequestsAllTime, + /// Cross-service lifetime settled amount, in stroops, for a given + /// agent. Settlement does NOT reset this counter; it is the lifetime + /// value signal for credit limits, loyalty pricing, and SLA tiering. + TotalSettledByAgent(Address), + /// Protocol-wide lifetime settled amount, in stroops, written by every + /// successful settlement drain. Saturates at `i128::MAX`. + TotalSettledAllTime, /// Ledger timestamp (seconds since unix epoch) at which the last /// `settle` call drained this `(agent, service_id)` pair. Lets /// off-chain SLA monitoring catch stuck settlement cycles. @@ -413,6 +420,32 @@ fn compute_billing_tiered(requests: u32, tiers: &Vec) -> i128 { total } + +/// Add a successful settlement bill to the lifetime settled-amount counters. +/// +/// Non-positive bills leave the counters untouched, preserving the monotonic +/// lifetime invariant while avoiding unnecessary zero-value storage slots. +fn add_settled_totals(env: &Env, agent: &Address, billed: i128) { + if billed <= 0 { + return; + } + + let agent_key = DataKey::TotalSettledByAgent(agent.clone()); + let agent_prev: i128 = env.storage().persistent().get(&agent_key).unwrap_or(0); + env.storage() + .persistent() + .set(&agent_key, &agent_prev.saturating_add(billed)); + + let all_prev: i128 = env + .storage() + .persistent() + .get(&DataKey::TotalSettledAllTime) + .unwrap_or(0); + env.storage().persistent().set( + &DataKey::TotalSettledAllTime, + &all_prev.saturating_add(billed), + ); +} #[contract] pub struct Escrow; @@ -689,6 +722,28 @@ impl Escrow { .unwrap_or(0) } + /// Read the cross-service lifetime settled amount for an agent, in stroops. + /// + /// This counter is written by settlement drains and never decremented or + /// reset by later `settle` calls. + pub fn get_total_settled_by_agent(env: Env, agent: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::TotalSettledByAgent(agent)) + .unwrap_or(0) + } + + /// Read the protocol-wide lifetime settled amount, in stroops. + /// + /// Defaults to `0` before the first billable settlement and saturates at + /// `i128::MAX` instead of overflowing. + pub fn get_total_settled_all_time(env: Env) -> i128 { + env.storage() + .persistent() + .get(&DataKey::TotalSettledAllTime) + .unwrap_or(0) + } + /// Returns the accumulated request count for an `(agent, service_id)` /// pair, or `0` if no usage has been recorded yet. pub fn get_usage(env: Env, agent: Address, service_id: Symbol) -> u32 { @@ -1016,6 +1071,7 @@ impl Escrow { // panicking; off-chain loop treats saturation as an error signal. (requests as i128).saturating_mul(price) }; + add_settled_totals(&env, &agent, billed); env.storage().persistent().set(&usage_key, &0u32); // Prune the service from the agent's index since usage is now zero. // This keeps the index consistent with the underlying counters and @@ -1109,6 +1165,8 @@ impl Escrow { // saturate: mirrors single-settle semantics. let billed = (requests as i128).saturating_mul(price); + add_settled_totals(&env, &agent, billed); + // Drain and stamp even when usage is zero (consistent with // single-settle: every drain updates LastSettlement). env.storage().persistent().set(&usage_key, &0u32); diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 3cf7ac4..7a32cf8 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -1022,6 +1022,110 @@ fn test_settle_zero_usage_returns_zero_stamps_and_emits_event() { assert_eq!(client.get_last_settlement(&agent, &svc), Some(ts)); } +#[test] +fn test_total_settled_getters_default_to_zero() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + + assert_eq!(client.get_total_settled_by_agent(&agent), 0i128); + assert_eq!(client.get_total_settled_all_time(), 0i128); +} + +#[test] +fn test_total_settled_counters_sum_across_settles_and_agents() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent_a = Address::generate(&env); + let agent_b = Address::generate(&env); + let inference = Symbol::new(&env, "infer"); + let storage = Symbol::new(&env, "storage"); + + client.set_service_price(&inference, &10i128); + client.set_service_price(&storage, &7i128); + + client.record_usage(&agent_a, &inference, &4u32); + assert_eq!(client.settle(&agent_a, &inference), 40i128); + assert_eq!(client.get_total_settled_by_agent(&agent_a), 40i128); + assert_eq!(client.get_total_settled_by_agent(&agent_b), 0i128); + assert_eq!(client.get_total_settled_all_time(), 40i128); + + client.record_usage(&agent_a, &storage, &3u32); + assert_eq!(client.settle(&agent_a, &storage), 21i128); + assert_eq!(client.get_total_settled_by_agent(&agent_a), 61i128); + assert_eq!(client.get_total_settled_all_time(), 61i128); + + client.record_usage(&agent_b, &inference, &8u32); + assert_eq!(client.settle(&agent_b, &inference), 80i128); + assert_eq!(client.get_total_settled_by_agent(&agent_a), 61i128); + assert_eq!(client.get_total_settled_by_agent(&agent_b), 80i128); + assert_eq!(client.get_total_settled_all_time(), 141i128); +} + +#[test] +fn test_total_settled_counters_ignore_zero_billed_settles() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + let free = Symbol::new(&env, "free"); + let paid = Symbol::new(&env, "paid"); + + client.record_usage(&agent, &free, &5u32); + assert_eq!(client.settle(&agent, &free), 0i128); + assert_eq!(client.get_total_settled_by_agent(&agent), 0i128); + assert_eq!(client.get_total_settled_all_time(), 0i128); + + client.set_service_price(&paid, &9i128); + client.record_usage(&agent, &paid, &2u32); + assert_eq!(client.settle(&agent, &paid), 18i128); + + assert_eq!(client.settle(&agent, &paid), 0i128); + assert_eq!(client.get_total_settled_by_agent(&agent), 18i128); + assert_eq!(client.get_total_settled_all_time(), 18i128); +} + +#[test] +fn test_total_settled_counters_include_settle_all() { + let env = Env::default(); + let (client, admin) = setup_initialized(&env); + let agent = Address::generate(&env); + let inference = Symbol::new(&env, "infer"); + let storage = Symbol::new(&env, "storage"); + + client.set_service_price(&inference, &10i128); + client.set_service_price(&storage, &25i128); + client.record_usage(&agent, &inference, &2u32); + client.record_usage(&agent, &storage, &3u32); + + let settled = client.settle_all(&admin, &agent); + assert_eq!(settled.len(), 2); + assert_eq!(settled.get(0), Some((inference.clone(), 20i128))); + assert_eq!(settled.get(1), Some((storage.clone(), 75i128))); + assert_eq!(client.get_total_settled_by_agent(&agent), 95i128); + assert_eq!(client.get_total_settled_all_time(), 95i128); +} + +#[test] +fn test_total_settled_counters_saturate_at_i128_max() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + let svc_a = Symbol::new(&env, "svc_a"); + let svc_b = Symbol::new(&env, "svc_b"); + + client.set_service_price(&svc_a, &i128::MAX); + client.record_usage(&agent, &svc_a, &1u32); + assert_eq!(client.settle(&agent, &svc_a), i128::MAX); + assert_eq!(client.get_total_settled_by_agent(&agent), i128::MAX); + assert_eq!(client.get_total_settled_all_time(), i128::MAX); + + client.set_service_price(&svc_b, &i128::MAX); + client.record_usage(&agent, &svc_b, &1u32); + assert_eq!(client.settle(&agent, &svc_b), i128::MAX); + assert_eq!(client.get_total_settled_by_agent(&agent), i128::MAX); + assert_eq!(client.get_total_settled_all_time(), i128::MAX); +} + #[test] fn test_init_stamps_schema_version() { let env = Env::default(); diff --git a/docs/escrow/api.md b/docs/escrow/api.md index 300890f..ad2b8f7 100644 --- a/docs/escrow/api.md +++ b/docs/escrow/api.md @@ -38,7 +38,8 @@ contract is paused. `get_admin`, `get_pending_admin`, `get_usage`, `get_service_price`, `compute_billing`, `get_last_settlement`, `get_total_requests_all_time`, -`get_total_usage_by_agent`, `get_min_requests_per_call`, +`get_total_usage_by_agent`, `get_total_settled_by_agent`, +`get_total_settled_all_time`, `get_min_requests_per_call`, `get_max_requests_per_call`, `is_allowlist_enabled`, `is_agent_allowed`, `is_service_registration_required`, `is_service_registered`, `is_service_disabled`, `is_paused`, `get_service_metadata`, diff --git a/docs/escrow/storage.md b/docs/escrow/storage.md index 9b9407b..c8cce6e 100644 --- a/docs/escrow/storage.md +++ b/docs/escrow/storage.md @@ -22,7 +22,8 @@ Soroban offers three storage tiers — `instance`, `temporary`, and `persistent` The escrow contract stores everything in `persistent()` because: -1. Usage counters (`Usage`, `TotalUsageByAgent`, `TotalRequestsAllTime`) must +1. Usage and settlement counters (`Usage`, `TotalUsageByAgent`, + `TotalRequestsAllTime`, `TotalSettledByAgent`, `TotalSettledAllTime`) must survive between the moment usage is recorded and the moment the off-chain settlement loop reads and drains them — a window that can span many ledger TTL cycles. @@ -68,6 +69,7 @@ per-pair counters regularly to keep storage costs bounded. | `MaxRequestsPerWindow` | `u32` | `0` (limiter disabled) | `set_max_requests_per_window` | No — lifetime | | `WindowSeconds` | `u64` | `0` (limiter disabled) | `set_rate_window_seconds` | No — lifetime | | `TotalRequestsAllTime` | `u64` | `0` | `record_usage` | No — lifetime (never reset) | +| `TotalSettledAllTime` | `i128` (stroops) | `0` | `settle`, `settle_all` | No — lifetime (never reset) | ### Per-service slots — cardinality O(S) @@ -85,6 +87,7 @@ per-pair counters regularly to keep storage costs bounded. | `AgentAllowed(agent)` | `Address` | `bool` | `false` | `set_agent_allowed` | No — lifetime | | `AgentBlocked(agent)` | `Address` | `bool` | `false` | `set_agent_blocked` | No — lifetime | | `TotalUsageByAgent(agent)` | `Address` | `u32` | `0` | `record_usage` | No — lifetime (never reset by `settle`) | +| `TotalSettledByAgent(agent)` | `Address` | `i128` (stroops) | `0` | `settle`, `settle_all` | No — lifetime (never reset by `settle`) | | `RateWindow(agent)` | `Address` | `(u64, u32)` = `(window_start, count)` | `(0, 0)` | `record_usage` (rate-limit path) | No — rolls forward on next call when window expires | ### Per-(agent, service) pair slots — cardinality O(A × S) @@ -112,6 +115,16 @@ touch it — it accumulates forever (saturating at `u32::MAX`). It is intended f analytics and SLA tiering, not for billing. The per-pair `Usage` counter is the billing source of truth. +### `TotalSettledByAgent(agent)` and `TotalSettledAllTime` + +These counters track lifetime settled value in stroops. `settle` and +`settle_all` add each non-zero billed amount via saturating arithmetic and never +subtract from the counters. `get_total_settled_by_agent(agent)` and +`get_total_settled_all_time()` return `0` when the corresponding slot is absent. +Unlike `Usage`, these counters are never drained by settlement and are intended +for credit limits, loyalty pricing, and protocol-level settled-value analytics +without replaying historical `settled` events. + ### `RateWindow(agent)` — fixed-window semantics Stores `(window_start: u64, count: u32)`. On each `record_usage` call (when the @@ -149,8 +162,12 @@ default) for pre-migration contracts. Settlement accounting relies on `Usage` starting at `0` after each `settle` call; any code path that writes `Usage` outside of `record_usage` and `settle` would break billing invariants. -- **`TotalUsageByAgent` and `TotalRequestsAllTime` are never reset.** Downstream - analytics must not treat these as settlement-cycle deltas. +- **Lifetime counters are never reset.** `TotalUsageByAgent`, + `TotalRequestsAllTime`, `TotalSettledByAgent`, and `TotalSettledAllTime` + must not be treated as settlement-cycle deltas by downstream analytics. +- **Settled-value counters are monotonic.** Non-positive bills leave + `TotalSettledByAgent` and `TotalSettledAllTime` unchanged, and positive bills + use saturating addition so overflow clamps at `i128::MAX`. - **`AgentBlocked` takes precedence over `AgentAllowed`.** An agent that is both blocked and allow-listed is rejected. Implementations relying on the allowlist gate must ensure the blocklist is not populated with the same address.