diff --git a/contracts/proof_registry/src/lib.rs b/contracts/proof_registry/src/lib.rs index 4d0aa8ea..830b8ffa 100644 --- a/contracts/proof_registry/src/lib.rs +++ b/contracts/proof_registry/src/lib.rs @@ -140,6 +140,23 @@ pub struct ProofRecord { pub vk_version: u32, } +/// Aggregate, per-credential-type usage counters (see #397). `total_submitted` +/// is a monotonic lifetime count of successful verifications; `active` is a +/// best-effort count of currently-occupied `(holder, credential_type)` slots. +/// `active` is bumped only the first time a slot is filled (a holder +/// resubmitting/refreshing the same credential type does not double-count), +/// and is decremented on explicit revocation. It intentionally does NOT +/// track passive expiry — an expired-but-never-revoked slot still counts as +/// "active" until someone calls `revoke`/`revoke_proof`/`revoke_all` on it, +/// matching this contract's existing lazy-expiry model (see `is_verified`) +/// rather than requiring a background sweep. +#[contracttype] +#[derive(Clone)] +pub struct CredentialTypeCounters { + pub total_submitted: u64, + pub active: u64, +} + #[contracttype] #[derive(Clone)] pub struct LegacyProofRecord { @@ -167,6 +184,8 @@ pub enum DataKey { IssuerRegistry, Paused, Proof(Address, Symbol), + /// Aggregate usage counters for a credential type (see #397). + TypeCounters(Symbol), } #[contracterror] @@ -305,6 +324,7 @@ impl ProofRegistry { } let key = DataKey::Proof(holder.clone(), credential_type.clone()); + let is_new_slot = !env.storage().persistent().has(&key); let record = ProofRecord { verified_at: env.ledger().timestamp(), expiry, @@ -315,6 +335,7 @@ impl ProofRegistry { }; env.storage().persistent().set(&key, &record); Self::bump_ttl(&env, &key, expiry); + Self::record_submission(&env, &credential_type, is_new_slot); env.events().publish( ( @@ -389,6 +410,7 @@ impl ProofRegistry { } let key = DataKey::Proof(holder.clone(), sub.credential_type.clone()); + let is_new_slot = !env.storage().persistent().has(&key); let effective_version = sub.vk_version.unwrap_or(0); let record = ProofRecord { verified_at: now, @@ -404,6 +426,7 @@ impl ProofRegistry { }; env.storage().persistent().set(&key, &record); Self::bump_ttl(&env, &key, sub.expiry); + Self::record_submission(&env, &sub.credential_type, is_new_slot); env.events().publish( ( @@ -563,6 +586,12 @@ impl ProofRegistry { } } + /// Aggregate usage counters for `credential_type` (see #397). Returns + /// zeroed counters for a type that has never had a proof submitted. + pub fn get_type_counters(env: Env, credential_type: Symbol) -> CredentialTypeCounters { + Self::read_counters(&env, &credential_type) + } + pub fn get_record(env: Env, holder: Address, credential_type: Symbol) -> Option { env.storage() .persistent() @@ -604,9 +633,12 @@ impl ProofRegistry { /// Revoke a cached proof. The holder authorizes their own revocation. pub fn revoke_proof(env: Env, holder: Address, credential_type: Symbol) { holder.require_auth(); - env.storage() - .persistent() - .remove(&DataKey::Proof(holder, credential_type)); + let key = DataKey::Proof(holder, credential_type.clone()); + let existed = env.storage().persistent().has(&key); + env.storage().persistent().remove(&key); + if existed { + Self::record_revocation(&env, &credential_type); + } } pub fn revoke_all(env: Env, holder: Address) { @@ -621,9 +653,12 @@ impl ProofRegistry { Symbol::new(&env, "employment"), ]; for t in types { - env.storage() - .persistent() - .remove(&DataKey::Proof(holder.clone(), t)); + let key = DataKey::Proof(holder.clone(), t.clone()); + let existed = env.storage().persistent().has(&key); + env.storage().persistent().remove(&key); + if existed { + Self::record_revocation(&env, &t); + } } } @@ -642,11 +677,15 @@ impl ProofRegistry { .persistent() .get(&key) .unwrap_or_else(|| panic_with_error!(&env, Error::ProofNotFound)); + let was_active = !record.revoked && record.expiry > env.ledger().timestamp(); record.revoked = true; env.storage().persistent().set(&key, &record); env.storage() .persistent() .extend_ttl(&key, PROOF_BUMP_THRESHOLD, PROOF_TTL); + if was_active { + Self::record_revocation(&env, &credential_type); + } // Emit: topics = ("proof_reg", "revoked", credential_type) // data = EventProofRevoked { holder, issuer, revoked_at } @@ -773,6 +812,7 @@ impl ProofRegistry { issuer: Address, ) { let key = DataKey::Proof(holder.clone(), credential_type.clone()); + let is_new_slot = !env.storage().persistent().has(&key); let record = ProofRecord { verified_at, expiry, @@ -783,6 +823,7 @@ impl ProofRegistry { }; env.storage().persistent().set(&key, &record); Self::bump_ttl(env, &key, expiry); + Self::record_submission(env, credential_type, is_new_slot); } fn bump_ttl(env: &Env, key: &DataKey, expiry: u64) { @@ -830,6 +871,50 @@ impl ProofRegistry { } } + fn counters_key(credential_type: &Symbol) -> DataKey { + DataKey::TypeCounters(credential_type.clone()) + } + + fn read_counters(env: &Env, credential_type: &Symbol) -> CredentialTypeCounters { + env.storage() + .persistent() + .get(&Self::counters_key(credential_type)) + .unwrap_or(CredentialTypeCounters { + total_submitted: 0, + active: 0, + }) + } + + fn write_counters(env: &Env, credential_type: &Symbol, counters: &CredentialTypeCounters) { + let key = Self::counters_key(credential_type); + env.storage().persistent().set(&key, counters); + env.storage() + .persistent() + .extend_ttl(&key, PROOF_BUMP_THRESHOLD, PROOF_TTL); + } + + /// Records a successful verification for `credential_type`. `is_new_slot` + /// is whether the `(holder, credential_type)` storage key existed before + /// this write — pass the result of `!env.storage().persistent().has(&key)` + /// checked before the record is written. + fn record_submission(env: &Env, credential_type: &Symbol, is_new_slot: bool) { + let mut counters = Self::read_counters(env, credential_type); + counters.total_submitted = counters.total_submitted.saturating_add(1); + if is_new_slot { + counters.active = counters.active.saturating_add(1); + } + Self::write_counters(env, credential_type, &counters); + } + + /// Records an explicit revocation of a previously-occupied slot for + /// `credential_type`. Only call when the slot existed prior to the + /// revoking operation. + fn record_revocation(env: &Env, credential_type: &Symbol) { + let mut counters = Self::read_counters(env, credential_type); + counters.active = counters.active.saturating_sub(1); + Self::write_counters(env, credential_type, &counters); + } + fn issuer_registry(env: &Env) -> Address { env.storage() .instance() diff --git a/contracts/proof_registry/src/test.rs b/contracts/proof_registry/src/test.rs index 9d6a507c..a00e9d80 100644 --- a/contracts/proof_registry/src/test.rs +++ b/contracts/proof_registry/src/test.rs @@ -720,4 +720,94 @@ fn aggregate_rejects_over_max_expiry_in_any_slot() { .is_verified(&holder, &symbol_short!("kyc"), &None) .0 ); +} + +// ── #397: per-credential-type analytics counters ──────────────────────────── + +#[test] +fn counters_start_at_zero_for_an_unused_type() { + let env = Env::default(); + env.mock_all_auths(); + let h = deploy(&env); + let c = h.registry.get_type_counters(&symbol_short!("kyc")); + assert_eq!(c.total_submitted, 0); + assert_eq!(c.active, 0); +} + +#[test] +fn submit_increments_total_and_active_once_per_holder_slot() { + let env = Env::default(); + env.mock_all_auths(); + let h = deploy(&env); + let holder1 = Address::generate(&env); + let holder2 = Address::generate(&env); + + submit(&env, &h, &holder1, 9999); + let c = h.registry.get_type_counters(&symbol_short!("kyc")); + assert_eq!(c.total_submitted, 1); + assert_eq!(c.active, 1); + + // A second holder occupies a new slot: both counters advance. + submit(&env, &h, &holder2, 9999); + let c = h.registry.get_type_counters(&symbol_short!("kyc")); + assert_eq!(c.total_submitted, 2); + assert_eq!(c.active, 2); + + // The first holder resubmitting (refreshing) the same credential type + // advances total_submitted (another verification happened) but not + // active (no new slot was occupied). + submit(&env, &h, &holder1, 9999); + let c = h.registry.get_type_counters(&symbol_short!("kyc")); + assert_eq!(c.total_submitted, 3); + assert_eq!(c.active, 2); +} + +#[test] +fn holder_self_revoke_decrements_active_not_total() { + let env = Env::default(); + env.mock_all_auths(); + let h = deploy(&env); + let holder = Address::generate(&env); + + submit(&env, &h, &holder, 9999); + h.registry.revoke_proof(&holder, &symbol_short!("kyc")); + + let c = h.registry.get_type_counters(&symbol_short!("kyc")); + assert_eq!(c.total_submitted, 1); + assert_eq!(c.active, 0); + + // Revoking again (nothing to revoke) must not underflow/double-decrement. + h.registry.revoke_proof(&holder, &symbol_short!("kyc")); + let c = h.registry.get_type_counters(&symbol_short!("kyc")); + assert_eq!(c.active, 0); +} + +#[test] +fn issuer_revoke_decrements_active() { + let env = Env::default(); + env.mock_all_auths(); + let h = deploy(&env); + let holder = Address::generate(&env); + + submit(&env, &h, &holder, 9999); + h.registry.revoke(&h.issuer, &holder, &symbol_short!("kyc")); + + let c = h.registry.get_type_counters(&symbol_short!("kyc")); + assert_eq!(c.total_submitted, 1); + assert_eq!(c.active, 0); +} + +#[test] +fn revoke_all_decrements_active_for_every_occupied_type() { + let env = Env::default(); + env.mock_all_auths(); + let h = deploy(&env); + let holder = Address::generate(&env); + + submit(&env, &h, &holder, 9999); + h.registry.revoke_all(&holder); + + let c = h.registry.get_type_counters(&symbol_short!("kyc")); + assert_eq!(c.active, 0); + assert_eq!(c.total_submitted, 1); } \ No newline at end of file