From f38ed6c4d3681636d13156939a623c9deec14c2d Mon Sep 17 00:00:00 2001 From: Alheriii Date: Mon, 24 Aug 2026 15:56:46 +0100 Subject: [PATCH] feat(usage-meter): link usage records to verified attestations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A usage record is only as trustworthy as its link back to the evidence behind it. Metering could happen with no attestation and attestations could exist with nothing metered against them, so an invoice could not be traced to what justified it — which is the entire pitch. Every record now carries the attestation it was priced against, and that reference is verified against audit-registry at write time rather than taken from the caller. Verification checks the attestation exists, is not superseded, and belongs to the claimed payer; the registry's own verify_attestation is the single predicate, so the two contracts cannot drift on what "valid" means. The interface into audit-registry is declared locally as a #[contractclient] trait rather than by depending on that crate. Depending on the contract crate would pull its #[contractimpl]-generated WASM exports (initialize, version) into this binary and collide at link time with this contract's own exports of the same names. Double-metering is rejected: an attestation id is burned on first use and a second record against it fails with AlreadyMetered. Only a *verified* attestation is burned — an id that failed verification has metered nothing, so marking it would let a typo or a race against the registry write permanently block the real attestation. Unattested usage is configurable per payer and defaults to the strict option. An unset policy is Reject, so a payer is only ever billed for usage nothing vouches for after explicitly opting in. That switch is authorized by the payer rather than the admin — it decides whether the payer can be billed on trust, so it is not the admin's call. Opted-in records are flagged attested: false, have the unverified reference dropped so they do not read as sourced, and accumulate against a separate total so downstream pricing can treat them differently without re-deriving which was which. Cost of the cross-contract call, measured rather than assumed (soroban-sdk 27.0.6, attested write vs unattested write, same storage writes otherwise): unattested (no call) cpu=186,318 mem=77,223 attested (one call) cpu=256,825 mem=97,795 verification cpu= 70,507 mem=20,572 About 0.07% of the 100,000,000-instruction transaction budget, so the per-record call is affordable and the batch-root alternative the issue raises is not needed at this cost. The measurement lives in a test so it stays honest across SDK upgrades. Authorization follows the pattern from #74: the payer's signature is bound to the attestation id and the unit count with require_auth_for_args, so an intermediate cannot take a signature given for one metering and spend it on a larger one. usage-meter is the caller on this edge and verify_attestation is an unauthorized read, so no allowlist is needed here. Also replaces the Symbol admin placeholder with a real Address, adds typed errors, checked arithmetic on the unit totals (a wrapped total silently zeroes an invoice rather than failing it), TTL extension on every persistent write, and #[contractevent] types for the observable state changes. Tests: 13. The two that are about authorization do not use mock_all_auths. fmt, clippy -D warnings, cargo test --workspace and the wasm32v1-none release build all pass. --- usage-meter/src/lib.rs | 343 +++++++++++++++++++++++++++++++-- usage-meter/src/test.rs | 413 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 743 insertions(+), 13 deletions(-) create mode 100644 usage-meter/src/test.rs diff --git a/usage-meter/src/lib.rs b/usage-meter/src/lib.rs index ff82a9e..431c435 100644 --- a/usage-meter/src/lib.rs +++ b/usage-meter/src/lib.rs @@ -1,5 +1,119 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, symbol_short, Env, Symbol}; +//! Metered billing units, linked to the attestations that justify them. +//! +//! A usage record here is only as trustworthy as its link back to +//! `audit-registry`. Metering with no attestation, or an attestation with no +//! metering, leaves an invoice that cannot be traced to the evidence behind it +//! — so every record carries the attestation it was priced against, verified +//! at write time rather than asserted by the caller. + +use soroban_sdk::{ + contract, contractclient, contracterror, contractevent, contractimpl, contracttype, + panic_with_error, Address, BytesN, Env, IntoVal, +}; + +/// Persistent entries are bumped to ~30 days, renewed once inside ~15 days. +const PERSISTENT_TTL: u32 = 518_400; +const PERSISTENT_THRESHOLD: u32 = 259_200; + +/// The one method this contract needs from `audit-registry`, declared locally +/// rather than by depending on that crate. Depending on the contract crate +/// would pull its `#[contractimpl]`-generated WASM exports (`initialize`, +/// `version`, ...) into this binary and collide at link time with this +/// contract's own exports of the same names. +#[contractclient(name = "AuditRegistryClient")] +pub trait AuditRegistryInterface { + /// True when the attestation exists, is not superseded, and belongs to + /// `subject`. A read, so it needs no authorization of its own. + fn verify_attestation(env: Env, id: BytesN<32>, subject: Address) -> bool; +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + /// No `audit-registry` address configured, so nothing can be verified. + RegistryNotSet = 3, + /// This attestation has already been metered. Usage is billed once per + /// attestation; a second record against the same id would be double billing. + AlreadyMetered = 4, + /// Usage arrived without a verifiable attestation and the payer's policy is + /// the strict default. + AttestationRequired = 5, + ZeroUnits = 6, + RecordNotFound = 7, + UnitsOverflow = 8, +} + +/// What to do with usage that has no verifiable attestation behind it. +/// +/// The default is `Reject` for every payer, chosen rather than inherited: an +/// unset policy is the strict one, so a payer only ever accepts unattested +/// billing by explicitly opting into it. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UnattestedPolicy { + /// Refuse the write. The default. + Reject, + /// Record it, flagged `attested: false`, and keep it in a separate total so + /// it can be priced on different terms downstream. + RecordUnattested, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + Registry, + Policy(Address), + Record(u64), + /// Presence marks an attestation id as already metered. + Metered(BytesN<32>), + Counter, + AttestedUnits(Address), + UnattestedUnits(Address), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UsageRecord { + pub id: u64, + pub payer: Address, + /// The attestation this usage was priced against. `None` only ever appears + /// on a record whose payer opted into `RecordUnattested`. + pub attestation_id: Option>, + pub units: u64, + pub attested: bool, + pub ledger: u32, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UsageRecorded { + #[topic] + pub payer: Address, + #[topic] + pub attested: bool, + pub id: u64, + pub units: u64, + pub attestation_id: Option>, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PolicySet { + #[topic] + pub payer: Address, + pub policy: UnattestedPolicy, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RegistrySet { + pub registry: Address, +} /// Metered billing units and quotas. #[contract] @@ -7,24 +121,227 @@ pub struct UsageMeter; #[contractimpl] impl UsageMeter { - /// One-time initialization (scaffold — replace with auth in production). - pub fn initialize(env: Env, admin: Symbol) { - if env.storage().instance().has(&symbol_short!("admin")) { - panic!("already initialized"); + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic_with_error!(&env, Error::AlreadyInitialized); } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + Self::bump_instance(&env); + } + + /// Point this meter at an `audit-registry` deployment. Admin only, and + /// changeable — the registry is an upgrade seam, not a constant. + pub fn set_registry(env: Env, registry: Address) { + Self::admin(&env).require_auth(); + env.storage().instance().set(&DataKey::Registry, ®istry); + Self::bump_instance(&env); + RegistrySet { registry }.publish(&env); + } + + /// Set a payer's policy for usage with no verifiable attestation. + /// + /// Authorized by the payer, not the admin: this decides whether the payer + /// can be billed for usage nothing on-chain vouches for, so it is the + /// payer's call to make and no one else's. + pub fn set_unattested_policy(env: Env, payer: Address, policy: UnattestedPolicy) { + payer.require_auth_for_args((policy,).into_val(&env)); env.storage() - .instance() - .set(&symbol_short!("admin"), &admin); + .persistent() + .set(&DataKey::Policy(payer.clone()), &policy); + env.storage().persistent().extend_ttl( + &DataKey::Policy(payer.clone()), + PERSISTENT_THRESHOLD, + PERSISTENT_TTL, + ); + PolicySet { payer, policy }.publish(&env); } - /// Protocol ping — extend with domain logic. - pub fn ping(env: Env, marker: Symbol) -> Symbol { - let _ = env; - marker + /// A payer's effective policy. Unset means `Reject`. + pub fn unattested_policy(env: Env, payer: Address) -> UnattestedPolicy { + env.storage() + .persistent() + .get(&DataKey::Policy(payer)) + .unwrap_or(UnattestedPolicy::Reject) + } + + /// Record metered usage for `payer`. + /// + /// The payer's signature is bound to the attestation id and the unit count + /// with `require_auth_for_args`, so an intermediate cannot take a signature + /// given for one metering and spend it on a larger one — see the + /// cross-contract authorization pattern this workspace follows. + /// + /// Verification happens here, against the registry, at write time. The + /// caller's claim that an attestation exists is never taken at face value. + pub fn record_usage( + env: Env, + payer: Address, + attestation_id: Option>, + units: u64, + ) -> u64 { + if units == 0 { + panic_with_error!(&env, Error::ZeroUnits); + } + payer.require_auth_for_args((attestation_id.clone(), units).into_val(&env)); + + let attested = match &attestation_id { + Some(id) => { + // Checked before the cross-contract call: a replay is the cheap + // case to reject and there is no reason to pay for a verify to + // find that out. + if env + .storage() + .persistent() + .has(&DataKey::Metered(id.clone())) + { + panic_with_error!(&env, Error::AlreadyMetered); + } + Self::verify(&env, id, &payer) + } + None => false, + }; + + if !attested + && Self::unattested_policy(env.clone(), payer.clone()) == UnattestedPolicy::Reject + { + panic_with_error!(&env, Error::AttestationRequired); + } + + // Only a *verified* attestation is burned. An id that failed + // verification has metered nothing, so marking it would let a typo + // permanently block the real attestation that shares the id. + if attested { + if let Some(id) = &attestation_id { + let key = DataKey::Metered(id.clone()); + env.storage().persistent().set(&key, &()); + env.storage() + .persistent() + .extend_ttl(&key, PERSISTENT_THRESHOLD, PERSISTENT_TTL); + } + } + + let id = Self::next_id(&env); + let record = UsageRecord { + id, + payer: payer.clone(), + // A record that failed verification does not get to keep the + // reference — carrying it would make an unattested record look + // sourced when read back. + attestation_id: if attested { + attestation_id.clone() + } else { + None + }, + units, + attested, + ledger: env.ledger().sequence(), + }; + + let key = DataKey::Record(id); + env.storage().persistent().set(&key, &record); + env.storage() + .persistent() + .extend_ttl(&key, PERSISTENT_THRESHOLD, PERSISTENT_TTL); + + Self::add_units(&env, &payer, units, attested); + Self::bump_instance(&env); + + UsageRecorded { + payer, + attested, + id, + units, + attestation_id: record.attestation_id, + } + .publish(&env); + + id + } + + pub fn get_usage(env: Env, id: u64) -> UsageRecord { + match env.storage().persistent().get(&DataKey::Record(id)) { + Some(r) => r, + None => panic_with_error!(&env, Error::RecordNotFound), + } + } + + /// True once an attestation has been metered. Exposed so a submitter can + /// check before paying for a call that would be rejected. + pub fn is_metered(env: Env, attestation_id: BytesN<32>) -> bool { + env.storage() + .persistent() + .has(&DataKey::Metered(attestation_id)) + } + + /// Units backed by a verified attestation. Kept apart from the unattested + /// total so the two can be priced on different terms. + pub fn attested_units(env: Env, payer: Address) -> u64 { + env.storage() + .persistent() + .get(&DataKey::AttestedUnits(payer)) + .unwrap_or(0) + } + + pub fn unattested_units(env: Env, payer: Address) -> u64 { + env.storage() + .persistent() + .get(&DataKey::UnattestedUnits(payer)) + .unwrap_or(0) } - /// Contract ABI / deployment marker for integrators. pub fn version(_env: Env) -> u32 { - 1 + 2 + } + + // ----------------------------------------------------------------------- + + fn verify(env: &Env, id: &BytesN<32>, payer: &Address) -> bool { + let registry: Address = match env.storage().instance().get(&DataKey::Registry) { + Some(r) => r, + None => panic_with_error!(env, Error::RegistryNotSet), + }; + AuditRegistryClient::new(env, ®istry).verify_attestation(id, payer) + } + + fn admin(env: &Env) -> Address { + match env.storage().instance().get(&DataKey::Admin) { + Some(a) => a, + None => panic_with_error!(env, Error::NotInitialized), + } + } + + fn next_id(env: &Env) -> u64 { + let next: u64 = env.storage().instance().get(&DataKey::Counter).unwrap_or(0) + 1; + env.storage().instance().set(&DataKey::Counter, &next); + next + } + + fn add_units(env: &Env, payer: &Address, units: u64, attested: bool) { + let key = if attested { + DataKey::AttestedUnits(payer.clone()) + } else { + DataKey::UnattestedUnits(payer.clone()) + }; + let current: u64 = env.storage().persistent().get(&key).unwrap_or(0); + // Metering totals must not wrap: a wrapped total silently zeroes an + // invoice rather than failing it. + let updated = match current.checked_add(units) { + Some(v) => v, + None => panic_with_error!(env, Error::UnitsOverflow), + }; + env.storage().persistent().set(&key, &updated); + env.storage() + .persistent() + .extend_ttl(&key, PERSISTENT_THRESHOLD, PERSISTENT_TTL); + } + + fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(PERSISTENT_THRESHOLD, PERSISTENT_TTL); } } + +#[cfg(test)] +mod test; diff --git a/usage-meter/src/test.rs b/usage-meter/src/test.rs new file mode 100644 index 0000000..361c902 --- /dev/null +++ b/usage-meter/src/test.rs @@ -0,0 +1,413 @@ +#![cfg(test)] +extern crate std; + +use super::*; +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, MockAuth, MockAuthInvoke}, + Env, +}; + +/// Stands in for `audit-registry`, implementing only the one method this +/// contract calls. Using a stub rather than depending on the real crate keeps +/// this test suite about the *link* — what happens when verification passes, +/// fails, or is replayed — rather than about attestation semantics that belong +/// to the registry's own tests. +#[contract] +pub struct StubRegistry; + +#[contractimpl] +impl StubRegistry { + pub fn attest(env: Env, id: BytesN<32>, subject: Address) { + env.storage().persistent().set(&id, &subject); + } + + pub fn verify_attestation(env: Env, id: BytesN<32>, subject: Address) -> bool { + match env.storage().persistent().get::<_, Address>(&id) { + Some(s) => s == subject, + None => false, + } + } +} + +struct Fixture { + env: Env, + meter: Address, + registry: Address, +} + +fn setup() -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let meter = env.register(UsageMeter, ()); + let registry = env.register(StubRegistry, ()); + + let client = UsageMeterClient::new(&env, &meter); + client.initialize(&admin); + client.set_registry(®istry); + + Fixture { + env, + meter, + registry, + } +} + +impl Fixture { + fn client(&self) -> UsageMeterClient<'_> { + UsageMeterClient::new(&self.env, &self.meter) + } + + fn stub(&self) -> StubRegistryClient<'_> { + StubRegistryClient::new(&self.env, &self.registry) + } + + /// Register a valid attestation for `payer` in the stub registry. + fn attest(&self, id: &BytesN<32>, payer: &Address) { + self.stub().attest(id, payer); + } +} + +/// Errors are raised with `panic_with_error!`, so the generated client +/// surfaces them as host errors carrying the contract error code. +fn raised(e: Error) -> soroban_sdk::Error { + soroban_sdk::Error::from_contract_error(e as u32) +} + +fn bytes(env: &Env, b: u8) -> BytesN<32> { + BytesN::from_array(env, &[b; 32]) +} + +// --------------------------------------------------------------------------- +// The link itself +// --------------------------------------------------------------------------- + +#[test] +fn test_usage_backed_by_a_verified_attestation_is_recorded_as_attested() { + let f = setup(); + let payer = Address::generate(&f.env); + let id = bytes(&f.env, 1); + f.attest(&id, &payer); + + let record_id = f.client().record_usage(&payer, &Some(id.clone()), &1_000); + let record = f.client().get_usage(&record_id); + + assert!(record.attested); + assert_eq!(record.attestation_id, Some(id.clone())); + assert_eq!(record.units, 1_000); + assert_eq!(f.client().attested_units(&payer), 1_000); + assert_eq!(f.client().unattested_units(&payer), 0); + assert!(f.client().is_metered(&id)); +} + +#[test] +fn test_the_same_attestation_cannot_be_metered_twice() { + let f = setup(); + let payer = Address::generate(&f.env); + let id = bytes(&f.env, 2); + f.attest(&id, &payer); + + f.client().record_usage(&payer, &Some(id.clone()), &500); + let result = f.client().try_record_usage(&payer, &Some(id.clone()), &500); + + assert_eq!(result, Err(Ok(raised(Error::AlreadyMetered)))); + // The first record stands; the replay added nothing. + assert_eq!(f.client().attested_units(&payer), 500); +} + +#[test] +fn test_an_attestation_belonging_to_someone_else_does_not_verify() { + let f = setup(); + let payer = Address::generate(&f.env); + let other = Address::generate(&f.env); + let id = bytes(&f.env, 3); + f.attest(&id, &other); + + // Verification is against the claimed payer, so an attestation that exists + // but belongs elsewhere is no better than none — under the strict default + // that means rejected, not silently billed. + let result = f.client().try_record_usage(&payer, &Some(id.clone()), &10); + + assert_eq!(result, Err(Ok(raised(Error::AttestationRequired)))); + assert!(!f.client().is_metered(&id)); +} + +#[test] +fn test_an_unknown_attestation_id_is_not_burned_by_the_failed_attempt() { + let f = setup(); + let payer = Address::generate(&f.env); + let id = bytes(&f.env, 4); + + // Metering an id that does not verify must not mark it used — otherwise a + // typo, or a race against the registry write, would permanently block the + // real attestation that later occupies that id. + assert!(f + .client() + .try_record_usage(&payer, &Some(id.clone()), &10) + .is_err()); + assert!(!f.client().is_metered(&id)); + + f.attest(&id, &payer); + let record_id = f.client().record_usage(&payer, &Some(id.clone()), &10); + assert!(f.client().get_usage(&record_id).attested); +} + +// --------------------------------------------------------------------------- +// Unattested policy +// --------------------------------------------------------------------------- + +#[test] +fn test_unattested_usage_is_rejected_by_default() { + let f = setup(); + let payer = Address::generate(&f.env); + + // Nothing was configured for this payer. The default has to be the strict + // one — a payer must opt in to being billed for usage nothing vouches for. + assert_eq!( + f.client().unattested_policy(&payer), + UnattestedPolicy::Reject + ); + assert_eq!( + f.client().try_record_usage(&payer, &None, &10), + Err(Ok(raised(Error::AttestationRequired))) + ); +} + +#[test] +fn test_opting_in_records_unattested_usage_against_a_separate_total() { + let f = setup(); + let payer = Address::generate(&f.env); + let id = bytes(&f.env, 5); + f.attest(&id, &payer); + + f.client() + .set_unattested_policy(&payer, &UnattestedPolicy::RecordUnattested); + + f.client().record_usage(&payer, &Some(id), &700); + let unattested_id = f.client().record_usage(&payer, &None, &300); + let record = f.client().get_usage(&unattested_id); + + assert!(!record.attested); + assert_eq!(record.attestation_id, None); + // The two totals stay apart so downstream pricing can treat them + // differently rather than having to re-derive which was which. + assert_eq!(f.client().attested_units(&payer), 700); + assert_eq!(f.client().unattested_units(&payer), 300); +} + +#[test] +fn test_a_failed_verification_under_the_lenient_policy_drops_the_reference() { + let f = setup(); + let payer = Address::generate(&f.env); + let id = bytes(&f.env, 6); + + f.client() + .set_unattested_policy(&payer, &UnattestedPolicy::RecordUnattested); + let record_id = f.client().record_usage(&payer, &Some(id.clone()), &42); + let record = f.client().get_usage(&record_id); + + // Recorded, but not as attested, and without keeping the id it failed to + // verify — a record that carried the reference would read as sourced. + assert!(!record.attested); + assert_eq!(record.attestation_id, None); + assert_eq!(f.client().unattested_units(&payer), 42); + assert!(!f.client().is_metered(&id)); +} + +#[test] +fn test_policy_is_per_payer_and_does_not_leak_across_payers() { + let f = setup(); + let lenient = Address::generate(&f.env); + let strict = Address::generate(&f.env); + + f.client() + .set_unattested_policy(&lenient, &UnattestedPolicy::RecordUnattested); + + assert_eq!( + f.client().unattested_policy(&strict), + UnattestedPolicy::Reject + ); + f.client().record_usage(&lenient, &None, &5); + assert_eq!( + f.client().try_record_usage(&strict, &None, &5), + Err(Ok(raised(Error::AttestationRequired))) + ); +} + +// --------------------------------------------------------------------------- +// Authorization +// +// These two do not use mock_all_auths: they are about who may authorize what, +// and mock_all_auths approves every require_auth in the transaction, which +// would make them pass no matter what the contract checked. +// --------------------------------------------------------------------------- + +#[test] +fn test_the_payer_signature_is_bound_to_the_unit_count() { + let env = Env::default(); + let admin = Address::generate(&env); + let meter = env.register(UsageMeter, ()); + let registry = env.register(StubRegistry, ()); + let payer = Address::generate(&env); + let id = bytes(&env, 7); + + env.mock_all_auths(); + let client = UsageMeterClient::new(&env, &meter); + client.initialize(&admin); + client.set_registry(®istry); + StubRegistryClient::new(&env, ®istry).attest(&id, &payer); + + // Signed for 100 units. + env.mock_auths(&[MockAuth { + address: &payer, + invoke: &MockAuthInvoke { + contract: &meter, + fn_name: "record_usage", + args: (Some(id.clone()), 100u64).into_val(&env), + sub_invokes: &[], + }, + }]); + + // Submitted for 10_000. An intermediate that inflates the meter reading + // after the payer signed is exactly what require_auth_for_args stops. + assert!(client + .try_record_usage(&payer, &Some(id.clone()), &10_000) + .is_err()); + + env.mock_auths(&[MockAuth { + address: &payer, + invoke: &MockAuthInvoke { + contract: &meter, + fn_name: "record_usage", + args: (Some(id.clone()), 100u64).into_val(&env), + sub_invokes: &[], + }, + }]); + client.record_usage(&payer, &Some(id), &100); + assert_eq!(client.attested_units(&payer), 100); +} + +#[test] +fn test_only_the_payer_can_relax_their_own_policy() { + let env = Env::default(); + let admin = Address::generate(&env); + let meter = env.register(UsageMeter, ()); + let payer = Address::generate(&env); + + env.mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &meter, + fn_name: "initialize", + args: (admin.clone(),).into_val(&env), + sub_invokes: &[], + }, + }]); + let client = UsageMeterClient::new(&env, &meter); + client.initialize(&admin); + + // The admin signs. The policy decides whether this payer can be billed for + // unvouched usage, so the admin's signature must not be enough. + env.mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &meter, + fn_name: "set_unattested_policy", + args: (UnattestedPolicy::RecordUnattested,).into_val(&env), + sub_invokes: &[], + }, + }]); + assert!(client + .try_set_unattested_policy(&payer, &UnattestedPolicy::RecordUnattested) + .is_err()); + assert_eq!(client.unattested_policy(&payer), UnattestedPolicy::Reject); +} + +// --------------------------------------------------------------------------- +// Guards and cost +// --------------------------------------------------------------------------- + +#[test] +fn test_zero_units_is_rejected() { + let f = setup(); + let payer = Address::generate(&f.env); + let id = bytes(&f.env, 8); + f.attest(&id, &payer); + + assert_eq!( + f.client().try_record_usage(&payer, &Some(id), &0), + Err(Ok(raised(Error::ZeroUnits))) + ); +} + +#[test] +fn test_recording_without_a_registry_configured_is_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let meter = env.register(UsageMeter, ()); + let client = UsageMeterClient::new(&env, &meter); + client.initialize(&admin); + + let payer = Address::generate(&env); + assert_eq!( + client.try_record_usage(&payer, &Some(bytes(&env, 9)), &10), + Err(Ok(raised(Error::RegistryNotSet))) + ); +} + +/// Measures what the cross-contract verification actually costs, which the +/// issue asks to be quantified before this link is relied on. +/// +/// Measured on soroban-sdk 27.0.6, comparing an attested write (one +/// cross-contract call into the registry) against an unattested one (no call), +/// same contract, same storage writes otherwise: +/// +/// | write | CPU instructions | memory bytes | +/// |---|---|---| +/// | unattested (no call) | 186,318 | 77,223 | +/// | attested (one call) | 256,825 | 97,795 | +/// | **verification** | **70,507** | **20,572** | +/// +/// About 0.07% of the 100,000,000-instruction transaction budget, so the +/// per-record call is affordable and the batch-root alternative the issue +/// raises is not needed at this cost. The assertion below is a +/// direction-and-magnitude check rather than an exact figure, so an SDK upgrade +/// does not fail CI on a number — run with `--nocapture` for current values. +#[test] +fn test_cross_contract_verification_cost_is_measured() { + let f = setup(); + let payer = Address::generate(&f.env); + let id = bytes(&f.env, 10); + f.attest(&id, &payer); + f.client() + .set_unattested_policy(&payer, &UnattestedPolicy::RecordUnattested); + + let mut budget = f.env.cost_estimate().budget(); + + budget.reset_default(); + f.client().record_usage(&payer, &None, &1); + let without_cpu = budget.cpu_instruction_cost(); + let without_mem = budget.memory_bytes_cost(); + + budget.reset_default(); + f.client().record_usage(&payer, &Some(id), &1); + let with_cpu = budget.cpu_instruction_cost(); + let with_mem = budget.memory_bytes_cost(); + + std::println!("unattested write: cpu={without_cpu} mem={without_mem}"); + std::println!("attested write: cpu={with_cpu} mem={with_mem}"); + std::println!( + "verification cost: cpu={} mem={}", + with_cpu - without_cpu, + with_mem - without_mem + ); + + // The call is not free, and it is not so expensive that a single + // verification would crowd out the rest of an invocation's budget. If this + // ever fails, the batch-root alternative in the issue is worth revisiting. + assert!(with_cpu > without_cpu); + assert!(with_cpu - without_cpu < 10_000_000); +}