Skip to content
Open
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
97 changes: 91 additions & 6 deletions contracts/proof_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -167,6 +184,8 @@ pub enum DataKey {
IssuerRegistry,
Paused,
Proof(Address, Symbol),
/// Aggregate usage counters for a credential type (see #397).
TypeCounters(Symbol),
}

#[contracterror]
Expand Down Expand Up @@ -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,
Expand All @@ -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(
(
Expand Down Expand Up @@ -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,
Expand All @@ -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(
(
Expand Down Expand Up @@ -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<ProofRecord> {
env.storage()
.persistent()
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}
}

Expand All @@ -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 }
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
90 changes: 90 additions & 0 deletions contracts/proof_registry/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading