From 18ecc54bb750bd56a215809c5285e2997bab3e3b Mon Sep 17 00:00:00 2001 From: Baskarayelu Date: Wed, 24 Jun 2026 10:04:58 +0530 Subject: [PATCH] feat: add batched get_usage_batch read for many pairs Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 11 +++ contracts/escrow/src/lib.rs | 52 ++++++++++++-- contracts/escrow/src/test.rs | 134 ++++++++++++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5d82d8d..ddf1486 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,17 @@ deployed contract reports `get_schema_version() == 2` without ever running a migration. A legacy contract deployed before this change carries the implicit v1 default and must call `migrate_v1_to_v2()` to reach v2; calling that migration on a fresh v2 deploy panics with `MigrationVersionMismatch`. +### Batched usage reads + +`get_usage_batch(pairs)` reads the accumulated usage counter for many +`(agent, service_id)` pairs in a single call, returning a `Vec` in the same +order as the input. It is a pure read: no `require_auth` and no pause gate, so +off-chain dashboards and settlement loops can fan out efficiently. Unknown pairs +return `0` and duplicate pairs yield the same value at each position, matching +`get_usage`. To keep the read loop bounded and the host's storage-read budget +predictable, the batch is capped at `MAX_BATCH_READ` (100) pairs; a request above +the bound panics with `BatchTooLarge`. Callers needing more pairs should page the +requests. ## Prerequisites diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 82ccfaf..2d7e6c3 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -2,12 +2,20 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, - Env, String, Symbol, + Env, String, Symbol, Vec, }; /// Current on-chain storage schema version stamped at init. const CURRENT_SCHEMA: u32 = 2; +/// Maximum number of `(agent, service_id)` pairs accepted by a single +/// `get_usage_batch` call. Chosen at 100 as a conservative cap: the batch +/// read iterates the input once doing one persistent read per pair, so the +/// bound keeps the loop (and the host's storage-read budget) predictable and +/// prevents a single call from triggering an unboundedly large amount of work. +/// Callers needing more pairs should page the requests. +pub const MAX_BATCH_READ: u32 = 100; + /// Free-form metadata about a service. Stored under /// `DataKey::ServiceMetadata(service_id)` so dashboards and clients can /// resolve a service to a human-readable description and owner without @@ -124,6 +132,8 @@ pub enum EscrowError { /// proposed new admin — a no-op handover that is rejected to surface /// caller mistakes early. InvalidAdminProposal = 14, + /// `get_usage_batch` was called with more than `MAX_BATCH_READ` pairs. + BatchTooLarge = 15, } #[contracttype] @@ -149,6 +159,17 @@ fn write_flag(env: &Env, key: &DataKey, value: bool) { env.storage().persistent().set(key, &value); } +/// Read the accumulated usage counter for an `(agent, service_id)` pair, +/// defaulting to `0` when no usage has been recorded. Centralising this +/// read keeps the single-pair `get_usage` and the batched +/// `get_usage_batch` from drifting in their default/key semantics. +fn read_usage(env: &Env, agent: &Address, service_id: &Symbol) -> u32 { + env.storage() + .persistent() + .get(&DataKey::Usage(agent.clone(), service_id.clone())) + .unwrap_or(0) +} + #[contract] pub struct Escrow; @@ -295,10 +316,31 @@ impl Escrow { /// 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 { - env.storage() - .persistent() - .get(&DataKey::Usage(agent, service_id)) - .unwrap_or(0) + read_usage(&env, &agent, &service_id) + } + + /// Batched usage read: returns the accumulated request count for each + /// input `(agent, service_id)` pair, in the same order as `pairs`. + /// + /// Pure read — no `require_auth`, no pause gate — so off-chain + /// dashboards and settlement loops can fetch many counters in one call. + /// Each entry is resolved with the same `read_usage` helper as + /// [`Escrow::get_usage`], so unknown pairs return `0` and duplicate + /// pairs simply yield the same value at each position. + /// + /// Panics with [`EscrowError::BatchTooLarge`] when + /// `pairs.len() > MAX_BATCH_READ`. Rejecting oversized requests keeps + /// the read loop bounded and the host's storage-read budget + /// predictable; callers should page larger queries. + pub fn get_usage_batch(env: Env, pairs: Vec<(Address, Symbol)>) -> Vec { + if pairs.len() > MAX_BATCH_READ { + panic_with_error!(&env, EscrowError::BatchTooLarge); + } + let mut results: Vec = Vec::new(&env); + for (agent, service_id) in pairs.iter() { + results.push_back(read_usage(&env, &agent, &service_id)); + } + results } /// Set the per-request price (in stroops) for a service. diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 2d924db..f2a5ab1 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -4,7 +4,7 @@ use super::*; use soroban_sdk::{ testutils::{Address as _, Events, Ledger}, - Address, IntoVal, Symbol, + Address, IntoVal, Symbol, Vec, }; fn setup_initialized(env: &Env) -> (EscrowClient<'_>, Address) { @@ -697,3 +697,135 @@ fn test_pause_pause_unpause_ends_unpaused() { assert!(!client.is_paused()); } + +#[test] +fn test_get_usage_batch_preserves_order() { + 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"); + let svc_c = Symbol::new(&env, "svc_c"); + + client.record_usage(&agent, &svc_a, &10u32); + client.record_usage(&agent, &svc_b, &20u32); + client.record_usage(&agent, &svc_c, &30u32); + + let mut pairs: Vec<(Address, Symbol)> = Vec::new(&env); + pairs.push_back((agent.clone(), svc_b.clone())); + pairs.push_back((agent.clone(), svc_a.clone())); + pairs.push_back((agent.clone(), svc_c.clone())); + + let out = client.get_usage_batch(&pairs); + assert_eq!(out.len(), 3); + assert_eq!(out.get(0), Some(20)); + assert_eq!(out.get(1), Some(10)); + assert_eq!(out.get(2), Some(30)); +} + +#[test] +fn test_get_usage_batch_unknown_pairs_return_zero() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "never_used"); + + let mut pairs: Vec<(Address, Symbol)> = Vec::new(&env); + pairs.push_back((agent.clone(), svc.clone())); + + let out = client.get_usage_batch(&pairs); + assert_eq!(out.len(), 1); + assert_eq!(out.get(0), Some(0)); +} + +#[test] +fn test_get_usage_batch_mix_known_and_unknown() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + + let agent = Address::generate(&env); + let known = Symbol::new(&env, "known"); + let unknown = Symbol::new(&env, "unknown"); + + client.record_usage(&agent, &known, &7u32); + + let mut pairs: Vec<(Address, Symbol)> = Vec::new(&env); + pairs.push_back((agent.clone(), unknown.clone())); + pairs.push_back((agent.clone(), known.clone())); + + let out = client.get_usage_batch(&pairs); + assert_eq!(out.get(0), Some(0)); + assert_eq!(out.get(1), Some(7)); +} + +#[test] +fn test_get_usage_batch_duplicate_pairs() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "dup_svc"); + client.record_usage(&agent, &svc, &42u32); + + let mut pairs: Vec<(Address, Symbol)> = Vec::new(&env); + pairs.push_back((agent.clone(), svc.clone())); + pairs.push_back((agent.clone(), svc.clone())); + pairs.push_back((agent.clone(), svc.clone())); + + let out = client.get_usage_batch(&pairs); + assert_eq!(out.len(), 3); + assert_eq!(out.get(0), Some(42)); + assert_eq!(out.get(1), Some(42)); + assert_eq!(out.get(2), Some(42)); +} + +#[test] +fn test_get_usage_batch_empty_returns_empty() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + + let pairs: Vec<(Address, Symbol)> = Vec::new(&env); + let out = client.get_usage_batch(&pairs); + assert_eq!(out.len(), 0); +} + +#[test] +fn test_get_usage_batch_at_bound_succeeds() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "bound_svc"); + client.record_usage(&agent, &svc, &5u32); + + let mut pairs: Vec<(Address, Symbol)> = Vec::new(&env); + for _ in 0..MAX_BATCH_READ { + pairs.push_back((agent.clone(), svc.clone())); + } + assert_eq!(pairs.len(), MAX_BATCH_READ); + + let out = client.get_usage_batch(&pairs); + assert_eq!(out.len(), MAX_BATCH_READ); + assert_eq!(out.get(0), Some(5)); + assert_eq!(out.get(MAX_BATCH_READ - 1), Some(5)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #15)")] +fn test_get_usage_batch_oversized_panics() { + let env = Env::default(); + let (client, _admin) = setup_initialized(&env); + + let agent = Address::generate(&env); + let svc = Symbol::new(&env, "over_svc"); + + let mut pairs: Vec<(Address, Symbol)> = Vec::new(&env); + for _ in 0..(MAX_BATCH_READ + 1) { + pairs.push_back((agent.clone(), svc.clone())); + } + assert_eq!(pairs.len(), MAX_BATCH_READ + 1); + + client.get_usage_batch(&pairs); +}