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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -413,6 +420,32 @@ fn compute_billing_tiered(requests: u32, tiers: &Vec<PriceTier>) -> 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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
104 changes: 104 additions & 0 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion docs/escrow/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
23 changes: 20 additions & 3 deletions docs/escrow/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading