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); +}