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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,22 @@ full reference.
result. Identity fields are sent once to the provider and never stored.
4. **Proof expiry.** `ProofRegistry` uses persistent storage with an explicit
`expiry` (checked against ledger time) plus TTL extension.
5. **Contract upgradeability.** `ProofRegistry` supports an admin-controlled upgrade path using Soroban's native `update_current_contract_wasm` capability. The administrative key is initialized at deployment time and can be subsequently transferred to a multisig wallet or DAO.
5. **Rent and archival.** Soroban persistent entries carry their own TTL,
separate from `expiry` — an entry whose TTL reaches zero is archived
(evicted from live state) regardless of whether the credential it caches
is still valid. `submit_proof` / `submit_proofs_batch` extend a proof's
TTL to cover at least its `expiry` (converted from a ledger timestamp to a
ledger count, capped at the network's max allowed entry TTL — currently
~1 year on mainnet), so a long-lived credential doesn't outlive its
storage. Because that cap can be shorter than a credential's remaining
lifetime, `bump_claim(holder, credential_type)` is exposed as a
permissionless entry point — no `require_auth` — that anyone (the holder,
a relying protocol, or unrelated rent-keeper automation) can call to top
up a still-valid claim's TTL without resubmitting a proof. See the
"Rent and archival" doc comment at the top of
[`contracts/proof_registry/src/lib.rs`](contracts/proof_registry/src/lib.rs)
for the full model.
6. **Contract upgradeability.** `ProofRegistry` supports an admin-controlled upgrade path using Soroban's native `update_current_contract_wasm` capability. The administrative key is initialized at deployment time and can be subsequently transferred to a multisig wallet or DAO.

---

Expand Down
123 changes: 115 additions & 8 deletions contracts/proof_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,66 @@
//! `submit_proofs_batch` accepts up to 5 `ProofSubmission` entries and verifies
//! and stores all of them atomically: if any single proof fails the entire call
//! reverts, saving the holder from multiple wallet confirmations and fee payments.
//!
//! ## Rent and archival
//!
//! Soroban persistent storage isn't free forever: every entry has a TTL
//! (time-to-live), expressed as a number of ledgers, not a wall-clock
//! duration. Once an entry's TTL reaches zero it is "archived" — evicted from
//! the live ledger state — and any read of it fails until someone pays to
//! restore it. A `ProofRecord` that gets archived while its credential is
//! still logically valid would make `is_verified` / `check_claim` behave as
//! if the holder had never proved anything, even though nothing about the
//! proof itself changed.
//!
//! `expiry` (the field on `ProofRecord`) and TTL are two independent clocks:
//! `expiry` is a ledger *timestamp* (seconds) the contract compares against
//! `env.ledger().timestamp()` to decide whether a claim is still valid;
//! TTL is a ledger *count* the network uses to decide whether the entry is
//! still resident at all. A record can be logically valid (`expiry` in the
//! future) yet physically gone (TTL hit zero) — that's the failure mode this
//! module guards against:
//!
//! - `submit_proof` / `submit_proofs_batch` extend the entry's TTL to cover
//! at least `expiry` (translated from seconds to ledgers via
//! `ttl_for_expiry`), not just a fixed default — a long-lived credential
//! (e.g. a 1-year expiry) gets a TTL long enough to actually survive to
//! its own expiry, bounded by the network's max allowed entry TTL
//! (`env.storage().max_ttl()`; currently ~1 year on Stellar mainnet, so a
//! sufficiently long-lived credential's proof entry may still need
//! periodic top-ups — see `bump_claim` below).
//! - `bump_claim` is a permissionless entry point — no `require_auth`, since
//! topping up rent benefits the holder and costs the caller only gas, not
//! the holder's assets or authority — that anyone (the holder, the
//! protocol relying on the claim, or unrelated rent-keeper automation) can
//! call to refresh a still-valid claim's TTL without resubmitting a proof.
//! This matters because the ledger-per-second conversion `ttl_for_expiry`
//! uses is an approximation (`SECONDS_PER_LEDGER`), and because the
//! network's max entry TTL can itself be shorter than a credential's
//! remaining lifetime, requiring a later top-up as the ledger advances.
//! - `extend_ttl`'s `threshold` parameter (`PROOF_BUMP_THRESHOLD`) means both
//! paths are no-ops once the entry already has enough TTL headroom — so
//! calling `bump_claim` speculatively, or resubmitting a proof early, never
//! wastes a network write.

use soroban_sdk::{
contract, contractclient, contracterror, contractimpl, contracttype, panic_with_error,
symbol_short, Address, Bytes, BytesN, Env, Symbol, Vec,
};

// Persistent-entry lifetime management (~5s ledgers).
const DAY_IN_LEDGERS: u32 = 17280;
// Persistent-entry lifetime management. Ledgers close roughly every 5
// seconds on Stellar — used only to translate a credential's `expiry` (a
// wall-clock ledger *timestamp*, seconds) into the ledger *count* delta
// `extend_ttl` expects. This is an approximation, not a network guarantee;
// `bump_claim` exists so a claim's TTL can be topped up later if the
// estimate runs short before the credential itself expires.
const SECONDS_PER_LEDGER: u64 = 5;
const DAY_IN_LEDGERS: u32 = (86_400 / SECONDS_PER_LEDGER) as u32;
const PROOF_BUMP_THRESHOLD: u32 = DAY_IN_LEDGERS;
// Floor TTL applied on every submission, independent of `expiry` — keeps a
// short-lived claim's entry (and, on `revoke`, its revocation tombstone)
// resident for a reasonable minimum window even when the credential itself
// expires sooner than that.
const PROOF_TTL: u32 = 90 * DAY_IN_LEDGERS;

/// Maximum number of submissions accepted by `submit_proofs_batch`.
Expand Down Expand Up @@ -118,6 +169,9 @@ pub enum Error {
/// Two or more submissions in the batch share the same `credential_type`;
/// only the last write would survive, so the batch is rejected outright.
DuplicateCredentialType = 9,
/// `bump_claim` was called for a claim that exists but is no longer
/// valid (revoked or expired) — there is nothing worth keeping alive.
ClaimNotValid = 10,
}

#[contract]
Expand Down Expand Up @@ -219,9 +273,11 @@ impl ProofRegistry {
issuer: Some(issuer_id),
};
env.storage().persistent().set(&key, &record);
env.storage()
.persistent()
.extend_ttl(&key, PROOF_BUMP_THRESHOLD, PROOF_TTL);
env.storage().persistent().extend_ttl(
&key,
PROOF_BUMP_THRESHOLD,
Self::ttl_for_expiry(&env, expiry),
);

// Emit an event matching the event emission shape in the batch-proof path.
env.events().publish(
Expand Down Expand Up @@ -299,9 +355,11 @@ impl ProofRegistry {
issuer: Some(sub.issuer_id.clone()),
};
env.storage().persistent().set(&key, &record);
env.storage()
.persistent()
.extend_ttl(&key, PROOF_BUMP_THRESHOLD, PROOF_TTL);
env.storage().persistent().extend_ttl(
&key,
PROOF_BUMP_THRESHOLD,
Self::ttl_for_expiry(&env, sub.expiry),
);

// Emit one event per credential, matching the shape callers already
// expect from the single-proof path.
Expand Down Expand Up @@ -432,6 +490,38 @@ impl ProofRegistry {
);
}

/// Top up the TTL of a still-valid claim so its `ProofRecord` doesn't get
/// archived while the credential it caches remains valid. Permissionless
/// by design — no `require_auth` — since extending storage rent costs
/// only the caller's transaction fee and cannot affect the holder's
/// claim in any other way; anyone (the holder, a relying protocol, or
/// unrelated rent-keeper automation) can call this to keep a claim alive.
/// See the module-level "Rent and archival" doc for why this is needed
/// even though `submit_proof` already extends the TTL on write.
///
/// Panics with `ProofNotFound` if there's no claim for `(holder,
/// credential_type)`, or `ClaimNotValid` if one exists but is revoked or
/// past its `expiry` — bumping rent for a claim nothing should be
/// trusting anymore would just waste the caller's fee.
pub fn bump_claim(env: Env, holder: Address, credential_type: Symbol) {
let key = DataKey::Proof(holder, credential_type);
let record: ProofRecord = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| panic_with_error!(&env, Error::ProofNotFound));

if record.revoked || record.expiry <= env.ledger().timestamp() {
panic_with_error!(&env, Error::ClaimNotValid);
}

env.storage().persistent().extend_ttl(
&key,
PROOF_BUMP_THRESHOLD,
Self::ttl_for_expiry(&env, record.expiry),
);
}

pub fn verifier_address(env: Env) -> Address {
Self::verifier(&env)
}
Expand Down Expand Up @@ -486,6 +576,23 @@ impl ProofRegistry {
true
}

/// Ledger-count TTL that covers `expiry` (a ledger timestamp, seconds),
/// with a day of headroom so the entry doesn't archive right at the
/// moment the credential itself expires, floored at `PROOF_TTL` (so a
/// short-lived credential's entry still gets a reasonable minimum
/// residency) and capped at the network's max allowed entry TTL — see
/// the module-level "Rent and archival" doc.
fn ttl_for_expiry(env: &Env, expiry: u64) -> u32 {
let now = env.ledger().timestamp();
let seconds_remaining = expiry.saturating_sub(now);
let ledgers_remaining = seconds_remaining / SECONDS_PER_LEDGER;
let desired = ledgers_remaining
.saturating_add(DAY_IN_LEDGERS as u64)
.max(PROOF_TTL as u64);
let max_ttl = env.storage().max_ttl() as u64;
desired.min(max_ttl) as u32
}

fn verifier(env: &Env) -> Address {
env.storage()
.instance()
Expand Down
183 changes: 181 additions & 2 deletions contracts/proof_registry/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ use credential_verifier::{CredentialVerifier, CredentialVerifierClient};
use issuer_registry::{IssuerRegistry, IssuerRegistryClient};
use soroban_sdk::{
symbol_short,
testutils::{Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke},
vec, Address, BytesN, Bytes, Env, IntoVal,
testutils::{
storage::Persistent as _, Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke,
},
vec, Address, Bytes, BytesN, Env, IntoVal,
};

// Real UltraHonk artifacts (kyc_proof circuit, Noir beta.9 + bb 0.87.0), so
Expand Down Expand Up @@ -947,3 +949,180 @@ fn legacy_record_missing_issuer_key_fails_to_read() {
let result = h.registry.try_is_verified(&holder, &symbol_short!("kyc"), &None);
assert!(result.is_err());
}

// ---------------------------------------------------------------------------
// TTL / rent management
// ---------------------------------------------------------------------------

const SECONDS_PER_DAY: u64 = 24 * 60 * 60;

fn proof_ttl(env: &Env, registry_id: &Address, holder: &Address) -> u32 {
let key = DataKey::Proof(holder.clone(), symbol_short!("kyc"));
env.as_contract(registry_id, || env.storage().persistent().get_ttl(&key))
}

#[test]
fn submit_proof_extends_ttl_to_cover_long_expiry() {
let env = Env::default();
env.mock_all_auths();
let h = deploy(&env);
let holder = Address::generate(&env);

let now = env.ledger().timestamp();
// Well beyond the 90-day floor, but comfortably under the network's max
// entry TTL (~1 year) so nothing gets clamped in this assertion.
let expiry = now + 200 * SECONDS_PER_DAY;

submit(&env, &h, &holder, expiry);

let ttl = proof_ttl(&env, &h.registry_id, &holder);
let expected = env.as_contract(&h.registry_id, || {
ProofRegistry::ttl_for_expiry(&env, expiry)
});

assert_eq!(
ttl, expected,
"TTL after submit_proof should exactly match ttl_for_expiry"
);
assert!(
ttl > PROOF_TTL,
"a 200-day credential should get a TTL beyond the 90-day floor (got {ttl}, floor is {PROOF_TTL})"
);
// Roughly covers the credential's remaining lifetime (within a day of
// rounding from the seconds->ledgers conversion).
let min_expected_ledgers = ((expiry - now) / SECONDS_PER_LEDGER) as u32;
assert!(ttl >= min_expected_ledgers);
}

#[test]
fn submit_proof_floors_ttl_at_90_days_for_short_expiry() {
let env = Env::default();
env.mock_all_auths();
let h = deploy(&env);
let holder = Address::generate(&env);

let now = env.ledger().timestamp();
let expiry = now + 1000; // seconds — far under the 90-day floor

submit(&env, &h, &holder, expiry);

let ttl = proof_ttl(&env, &h.registry_id, &holder);
assert_eq!(ttl, PROOF_TTL);
}

#[test]
fn submit_proof_caps_ttl_at_network_max() {
let env = Env::default();
env.mock_all_auths();
let h = deploy(&env);
let holder = Address::generate(&env);

let now = env.ledger().timestamp();
// Absurdly far out — well beyond anything the network will actually let
// an entry live for.
let expiry = now + 10_000 * SECONDS_PER_DAY;

submit(&env, &h, &holder, expiry);

let ttl = proof_ttl(&env, &h.registry_id, &holder);
let max_ttl = env.as_contract(&h.registry_id, || env.storage().max_ttl());
assert_eq!(
ttl, max_ttl,
"TTL should be capped at the network's max entry TTL"
);
}

#[test]
fn bump_claim_extends_ttl_for_valid_claim() {
let env = Env::default();
env.mock_all_auths();
let h = deploy(&env);
let holder = Address::generate(&env);

let now = env.ledger().timestamp();
let expiry = now + 200 * SECONDS_PER_DAY;
submit(&env, &h, &holder, expiry);

let ttl_after_submit = proof_ttl(&env, &h.registry_id, &holder);

// Advance the ledger *sequence* (not the timestamp — the claim is still
// valid) so the entry's remaining TTL drops just under the bump
// threshold, simulating rent nearly running out.
env.ledger()
.with_mut(|li| li.sequence_number += ttl_after_submit - 100);
assert!(proof_ttl(&env, &h.registry_id, &holder) < PROOF_BUMP_THRESHOLD);

h.registry.bump_claim(&holder, &symbol_short!("kyc"));

let ttl_after_bump = proof_ttl(&env, &h.registry_id, &holder);
let expected = env.as_contract(&h.registry_id, || {
ProofRegistry::ttl_for_expiry(&env, expiry)
});
assert_eq!(ttl_after_bump, expected);
assert!(
ttl_after_bump > 1000,
"TTL should have grown back up from the near-expiry value"
);
}

#[test]
fn bump_claim_is_noop_when_ttl_already_high() {
// extend_ttl only applies below `threshold` — calling bump_claim right
// after submit_proof (when TTL is already high) must not panic and must
// leave the TTL unchanged.
let env = Env::default();
env.mock_all_auths();
let h = deploy(&env);
let holder = Address::generate(&env);

submit(
&env,
&h,
&holder,
env.ledger().timestamp() + 200 * SECONDS_PER_DAY,
);
let ttl_before = proof_ttl(&env, &h.registry_id, &holder);

h.registry.bump_claim(&holder, &symbol_short!("kyc"));

assert_eq!(proof_ttl(&env, &h.registry_id, &holder), ttl_before);
}

#[test]
fn bump_claim_panics_for_unknown_claim() {
let env = Env::default();
env.mock_all_auths();
let h = deploy(&env);
let stranger = Address::generate(&env);

let res = h.registry.try_bump_claim(&stranger, &symbol_short!("kyc"));
assert!(res.is_err());
}

#[test]
fn bump_claim_panics_for_expired_claim() {
let env = Env::default();
env.mock_all_auths();
let h = deploy(&env);
let holder = Address::generate(&env);

submit(&env, &h, &holder, 1000);
env.ledger().with_mut(|li| li.timestamp = 2000); // past expiry

let res = h.registry.try_bump_claim(&holder, &symbol_short!("kyc"));
assert!(res.is_err());
}

#[test]
fn bump_claim_panics_for_revoked_claim() {
let env = Env::default();
env.mock_all_auths();
let h = deploy(&env);
let holder = Address::generate(&env);

submit(&env, &h, &holder, 1_000_000_000); // valid for a very long time
h.registry.revoke(&h.issuer, &holder, &symbol_short!("kyc"));

let res = h.registry.try_bump_claim(&holder, &symbol_short!("kyc"));
assert!(res.is_err());
}
Loading
Loading