Skip to content
Merged
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>` 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

Expand Down
52 changes: 47 additions & 5 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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;

Expand Down Expand Up @@ -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<u32> {
if pairs.len() > MAX_BATCH_READ {
panic_with_error!(&env, EscrowError::BatchTooLarge);
}
let mut results: Vec<u32> = 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.
Expand Down
134 changes: 133 additions & 1 deletion contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Loading