diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index c647ec9..89d6ec8 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1857,6 +1857,47 @@ impl Escrow { env.events().publish((symbol_short!("rate_rst"),), agent); } + /// Read the configured usage-alert threshold, or `0` (alerting + /// disabled) when unset. See [`Self::set_usage_alert_threshold`] for + /// what crossing this value does. + pub fn get_usage_alert_threshold(env: Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::UsageAlertThreshold) + .unwrap_or(0) + } + + /// Admin sets the global usage-alert threshold consulted by + /// `record_usage`. Pass `0` to disable alerting (the default). + /// + /// `record_usage` emits a `usage_hi(agent, service_id, total)` event + /// the first time a `(agent, service_id)` pair's accumulated usage + /// crosses this value from below to at/above it (edge-triggered — it + /// does not re-fire on every subsequent call while already above the + /// threshold, and re-arms after `settle` drains the pair below it + /// again). See `docs/escrow/events.md` for the full edge-trigger + /// semantics. + /// + /// Before this entrypoint existed, `UsageAlertThreshold` had no + /// setter, so the `usage_hi` path could never fire on a live deploy — + /// this closes that gap. + /// + /// Emits a `cfg_set` event with data `(alert_thr, threshold)` after the + /// storage write, consistent with every other scalar admin setting. + /// (The tag is `alert_thr`, deliberately distinct from the `usage_hi` + /// event topic that firing this threshold later triggers, so the two + /// are never conflated by a listener.) + pub fn set_usage_alert_threshold(env: Env, threshold: u32) { + require_admin(&env); + env.storage() + .persistent() + .set(&DataKey::UsageAlertThreshold, &threshold); + env.events().publish( + (symbol_short!("cfg_set"),), + (symbol_short!("alert_thr"), threshold), + ); + } + /// Admin sets the per-call upper bound on `requests` accepted by /// `record_usage`. Pass `u32::MAX` to effectively disable the cap. /// diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index acc83e0..f3659e4 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -1101,16 +1101,21 @@ fn test_transfer_service_ownership_genuine_transfer_emits_event() { let new_owner = Address::generate(&env); let desc = String::from_str(&env, "inference service"); client.set_service_metadata(&svc, &desc, &owner); - // Capture event count before transfer. - let events_before = env.events().all(); - let count_before = events_before.len(); // Perform genuine transfer. client.transfer_service_ownership(&owner, &svc, &new_owner); - // Exactly one new event (owner_chg). + // env.events().all() reflects only the most recent contract invocation + // (confirmed by every other event test in this file, e.g. + // assert_usage_event_count re-checking a count of 1 after each of + // several sequential record_usage calls), not a running total since + // the start of the test. The prior `set_service_metadata` call's + // `meta_set` event is therefore not present here — only this + // transfer's own event is. Comparing against a pre-call count of + // events from a *different* invocation was the bug; checking this + // call's own event count directly is the fix. let events_after = env.events().all(); assert_eq!( events_after.len(), - count_before + 1, + 1, "genuine transfer must emit exactly one event" ); let (_addr, topics, data) = events_after.last().unwrap(); @@ -3923,6 +3928,118 @@ fn test_reset_rate_window_idempotent() { client.reset_rate_window(&agent); } +// ── Usage-alert threshold (get/set_usage_alert_threshold) ─────────────────── +// +// `UsageAlertThreshold` previously had no setter, so the edge-triggered +// `usage_hi` alert in `record_usage` could never fire on a live deploy. +// These tests cover the new setter/getter and prove the alert now fires. + +#[test] +fn test_get_usage_alert_threshold_defaults_to_zero() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + assert_eq!(client.get_usage_alert_threshold(), 0); +} +#[test] +fn test_set_usage_alert_threshold_round_trip() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + client.set_usage_alert_threshold(&50u32); + assert_eq!(client.get_usage_alert_threshold(), 50u32); +} +#[test] +fn test_set_usage_alert_threshold_emits_cfg_set_event() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + client.set_usage_alert_threshold(&50u32); + let (_, topics, data) = env.events().all().last().unwrap(); + let expected: soroban_sdk::Vec = (symbol_short!("cfg_set"),).into_val(&env); + assert_eq!(topics, expected); + let decoded: (Symbol, u32) = data.into_val(&env); + assert_eq!(decoded, (symbol_short!("alert_thr"), 50u32)); +} +#[test] +#[should_panic(expected = "Unauthorized")] +fn test_set_usage_alert_threshold_requires_admin_auth() { + let env = Env::default(); + let contract_id = env.register_contract(None, Escrow); + let client = EscrowClient::new(&env, &contract_id); + let admin = Address::generate(&env); + env.mock_all_auths(); + client.init(&admin); + env.set_auths(&[]); + client.set_usage_alert_threshold(&50u32); +} +#[test] +fn test_usage_hi_fires_once_threshold_now_settable() { + // With the threshold configured, the crossing call must emit usage_hi; + // calls before and after the crossing edge must not. + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + let usage_hi_topics: soroban_sdk::Vec = + (symbol_short!("usage_hi"),).into_val(&env); + + client.set_usage_alert_threshold(&100u32); + + // Below threshold: no usage_hi event. + client.record_usage(&agent, &svc, &60u32); + let events = env.events().all(); + assert_eq!( + events + .iter() + .filter(|(_, t, _)| t == &usage_hi_topics) + .count(), + 0 + ); + + // Crosses the threshold (60 -> 110): exactly one usage_hi event. + client.record_usage(&agent, &svc, &50u32); + let events = env.events().all(); + let matching: alloc::vec::Vec<_> = events + .iter() + .filter(|(_, t, _)| t == &usage_hi_topics) + .collect(); + assert_eq!(matching.len(), 1); + let (_, _, data) = &matching[0]; + let decoded: (Address, Symbol, u32) = data.into_val(&env); + assert_eq!(decoded, (agent.clone(), svc.clone(), 110u32)); + + // Already above threshold: no further usage_hi event (edge-triggered). + client.record_usage(&agent, &svc, &5u32); + let events = env.events().all(); + assert_eq!( + events + .iter() + .filter(|(_, t, _)| t == &usage_hi_topics) + .count(), + 0 + ); +} +#[test] +fn test_usage_hi_disabled_by_default_zero_threshold() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "infer"); + let usage_hi_topics: soroban_sdk::Vec = + (symbol_short!("usage_hi"),).into_val(&env); + + // Threshold left at its default (0): alerting is disabled regardless + // of how much usage accumulates. + client.record_usage(&agent, &svc, &1_000_000u32); + + let events = env.events().all(); + assert_eq!( + events + .iter() + .filter(|(_, t, _)| t == &usage_hi_topics) + .count(), + 0 + ); +} + // ── compute_billing tests ──────────────────────────────────────────────────── // // `compute_billing(agent, service_id)` returns `accumulated_requests * price_per_request`