Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4004e6e
feat: versioned VK registry for circuit upgrades without invalidating…
Topmatrixmor2014 Jul 25, 2026
1aa20ab
Merge branch 'main' into feat/vk-versioning
Topmatrixmor2014 Jul 26, 2026
f6f717c
fix: add extend_ttl to DeprecatedVersion to prevent silent expiry
Topmatrixmor2014 Jul 26, 2026
86fd78e
fix: use plain number instead of BigInt for u32 in nativeToScVal
Topmatrixmor2014 Jul 26, 2026
44348ff
fix: reject Some(0) vk_version explicitly instead of silently resolvi…
Topmatrixmor2014 Jul 26, 2026
1754e8d
Merge branch 'main' into feat/vk-versioning
Topmatrixmor2014 Aug 1, 2026
258a772
fix: complete VK versioning write-path so contracts compile and CI pa…
Topmatrixmor2014 Aug 1, 2026
c6286e7
Merge branch 'main' into feat/vk-versioning
Topmatrixmor2014 Aug 3, 2026
7c0281f
fix(contracts): thread vk_version through batch and aggregate verific…
Topmatrixmor2014 Aug 3, 2026
79fe0e6
fix(circuits): regenerate range proof fixtures with corrected commitment
Topmatrixmor2014 Aug 3, 2026
a772a7f
fix(frontend): pass vk_version to on-chain submit_proof
Topmatrixmor2014 Aug 3, 2026
a21104f
fix(credential_verifier): refresh latest-version TTL on any set_vk
Topmatrixmor2014 Aug 3, 2026
009d9a7
fix(credential_verifier): guard VK immutability and add TTL refresh
Topmatrixmor2014 Aug 3, 2026
83e0b85
chore(ci): trigger workflow re-run after dropped push event
Topmatrixmor2014 Aug 3, 2026
9561401
Merge branch 'main' into feat/vk-versioning
Topmatrixmor2014 Aug 4, 2026
1aea47b
fix(gated_pool): restore versioned set_vk and submit_proof calls in t…
Topmatrixmor2014 Aug 4, 2026
50da91f
fix(credential_verifier): refresh VK blob TTL in refresh_latest_versi…
Topmatrixmor2014 Aug 4, 2026
b92ff8c
Merge branch 'main' into feat/vk-versioning
Topmatrixmor2014 Aug 9, 2026
b5f45c4
fix: add missing vk_version and version args in contract tests
Topmatrixmor2014 Aug 9, 2026
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
4 changes: 2 additions & 2 deletions circuits/range_proof/Prover.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
value = "40000"
salt = "2024"
commitment = "10939069837981219911031933662244407434460168120308516075205226378763146828090"
commitment = "11464784431290725290890825015648660771764477860874280613603757226488105877198"
min = "30000"
max = "50000"
issuer_x = [62, 72, 233, 139, 1, 120, 194, 91, 166, 62, 68, 43, 79, 232, 156, 91, 89, 165, 40, 83, 125, 90, 232, 8, 228, 119, 17, 59, 196, 72, 32, 251]
issuer_y = [7, 217, 196, 88, 103, 117, 28, 168, 249, 234, 195, 188, 47, 23, 13, 0, 168, 88, 125, 213, 56, 190, 32, 144, 120, 211, 244, 170, 55, 97, 227, 101]
sig = [6, 105, 8, 213, 3, 158, 238, 245, 119, 101, 102, 155, 46, 22, 172, 87, 38, 148, 247, 73, 236, 31, 63, 131, 118, 151, 67, 188, 119, 51, 198, 25, 123, 53, 204, 249, 188, 186, 132, 223, 10, 143, 113, 160, 33, 167, 46, 176, 255, 56, 190, 210, 121, 163, 161, 185, 239, 161, 229, 62, 250, 49, 216, 164]
sig = [183, 31, 113, 105, 4, 237, 193, 53, 220, 59, 139, 54, 5, 130, 81, 214, 75, 186, 151, 114, 83, 34, 249, 86, 182, 178, 171, 251, 85, 116, 108, 234, 115, 194, 112, 134, 200, 136, 234, 45, 115, 241, 167, 64, 159, 213, 89, 30, 214, 233, 212, 177, 145, 218, 53, 16, 247, 231, 236, 97, 104, 92, 189, 129]
100 changes: 93 additions & 7 deletions contracts/credential_verifier/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,14 @@ const VK_TTL: u32 = 180 * DAY_IN_LEDGERS;
#[contracttype]
pub enum DataKey {
Admin,
/// Verification key bytes, keyed by credential-type symbol.
Vk(Symbol),
/// Verification key bytes, keyed by (credential-type symbol, version).
Vk(Symbol, u32),
/// Tracks the latest VK version registered for a credential type.
LatestVersion(Symbol),
/// Marks a specific (credential_type, version) as deprecated — no longer
/// accepted for new submissions. Old proofs using this version remain
/// readable (the VK is not deleted).
DeprecatedVersion(Symbol, u32),
}

#[contracterror]
Expand All @@ -49,6 +55,9 @@ pub enum Error {
NotInitialized = 1,
VkNotSet = 2,
VkInvalid = 3,
/// The requested VK version has been deprecated by the admin; new
/// submissions against it are rejected.
VersionDeprecated = 4,
}

#[contract]
Expand All @@ -67,17 +76,38 @@ impl CredentialVerifier {
// The idiomatic Soroban v26 replacement is `#[contractevent]`; we use
// value-based publish to stay consistent with the rest of the codebase.
#[allow(deprecated)]
pub fn set_vk(env: Env, credential_type: Symbol, vk: Bytes) {
pub fn set_vk(env: Env, credential_type: Symbol, version: u32, vk: Bytes) {
let admin = Self::require_admin(&env);
// Version 0 is reserved — it is the ProofRecord sentinel meaning
// "no version" (see ProofRegistry), and no VK may be registered at 0.
if version == 0 {
panic_with_error!(&env, Error::VkInvalid);
}
if UltraHonkVerifier::new(&env, &vk).is_err() {
panic_with_error!(&env, Error::VkInvalid);
}
let key = DataKey::Vk(credential_type.clone());
let key = DataKey::Vk(credential_type.clone(), version);
env.storage().persistent().set(&key, &vk);
env.storage()
.persistent()
.extend_ttl(&key, VK_BUMP_THRESHOLD, VK_TTL);

// Auto-advance the latest-version pointer for this credential type so
// `verify_proof(None)` resolves to the newest registered VK. Re-setting
// an older version (out-of-order admin call) never moves it backwards.
let latest_key = DataKey::LatestVersion(credential_type.clone());
let current = env
.storage()
.persistent()
.get::<_, u32>(&latest_key)
.unwrap_or(0);
if version > current {
env.storage().persistent().set(&latest_key, &version);
env.storage()
.persistent()
.extend_ttl(&latest_key, VK_BUMP_THRESHOLD, VK_TTL);
}
Comment thread
Topmatrixmor2014 marked this conversation as resolved.

// Emit: topics = ("cred_ver", "vk_set", credential_type)
// data = EventVkSet { admin }
Comment thread
Topmatrixmor2014 marked this conversation as resolved.
env.events().publish(
Expand All @@ -90,23 +120,79 @@ impl CredentialVerifier {
);
}

/// Admin-only. Marks a specific `(credential_type, version)` VK as
/// deprecated. New submissions against a deprecated version are rejected
/// by `verify_proof` (panics with `VersionDeprecated`), but the VK is not
/// deleted, so existing cached proofs that were verified against that
/// version remain readable in ProofRegistry.
pub fn deprecate_version(env: Env, credential_type: Symbol, version: u32) {
Self::require_admin(&env);

// Only versions that actually have a registered VK can be deprecated.
env.storage()
.persistent()
.get::<_, Bytes>(&DataKey::Vk(credential_type.clone(), version))
.unwrap_or_else(|| panic_with_error!(&env, Error::VkNotSet));

let dep_key = DataKey::DeprecatedVersion(credential_type, version);
env.storage().persistent().set(&dep_key, &true);
env.storage()
.persistent()
.extend_ttl(&dep_key, VK_BUMP_THRESHOLD, VK_TTL);
}

/// Returns the highest VK version registered for `credential_type`, or
/// panics with `VkNotSet` if no VK has been registered for the type yet.
pub fn get_latest_version(env: Env, credential_type: Symbol) -> u32 {
env.storage()
.persistent()
.get(&DataKey::LatestVersion(credential_type))
.unwrap_or_else(|| panic_with_error!(&env, Error::VkNotSet))
}

/// Verify an UltraHonk proof for any registered credential type. Looks up
/// the VK by `credential_type` Symbol and returns true iff the proof is valid.
/// Panics with `VkNotSet` if no VK has been registered for this type.
/// the VK by `(credential_type, vk_version)`. Pass `vk_version = None` to
/// use the latest registered version automatically.
///
/// Returns `true` iff the proof is valid. Returns `false` for malformed
/// inputs or invalid proofs; panics with `VkNotSet` if no VK has been
/// registered for this type/version, or with `VersionDeprecated` if the
/// requested version has been deprecated.
pub fn verify_proof(
env: Env,
credential_type: Symbol,
proof: Bytes,
public_inputs: Bytes,
vk_version: Option<u32>,
) -> bool {
// Proofs are fixed-length; reject early before touching the verifier.
if proof.len() as usize != PROOF_BYTES {
return false;
}

// Version 0 is reserved — it is the ProofRecord sentinel meaning
// "no version", and no VK may be registered at version 0.
// Reject an explicit `Some(0)` so callers don't silently hit latest.
let version = match vk_version {
Some(0) => panic_with_error!(&env, Error::VkNotSet),
Some(v) => v,
None => env
.storage()
.persistent()
.get(&DataKey::LatestVersion(credential_type.clone()))
.unwrap_or_else(|| panic_with_error!(&env, Error::VkNotSet)),
};
Comment thread
Topmatrixmor2014 marked this conversation as resolved.

// Reject submissions against a deprecated VK version.
let dep_key = DataKey::DeprecatedVersion(credential_type.clone(), version);
if env.storage().persistent().get::<_, bool>(&dep_key).unwrap_or(false) {
panic_with_error!(&env, Error::VersionDeprecated);
}

let vk: Bytes = env
.storage()
.persistent()
.get(&DataKey::Vk(credential_type))
.get(&DataKey::Vk(credential_type, version))
.unwrap_or_else(|| panic_with_error!(&env, Error::VkNotSet));

match UltraHonkVerifier::new(&env, &vk) {
Expand Down
106 changes: 99 additions & 7 deletions contracts/credential_verifier/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ fn verifies_kyc() {
let env = Env::default();
env.mock_all_auths();
let c = setup(&env);
c.set_vk(&symbol_short!("kyc"), &Bytes::from_slice(&env, fixture!("kyc", "vk")));
c.set_vk(&symbol_short!("kyc"), &1u32, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
assert!(c.verify_proof(
&symbol_short!("kyc"),
&Bytes::from_slice(&env, fixture!("kyc", "proof")),
&Bytes::from_slice(&env, fixture!("kyc", "public_inputs")),
&None,
));
}

Expand All @@ -46,11 +47,12 @@ fn verifies_age() {
let env = Env::default();
env.mock_all_auths();
let c = setup(&env);
c.set_vk(&symbol_short!("age"), &Bytes::from_slice(&env, fixture!("age", "vk")));
c.set_vk(&symbol_short!("age"), &1u32, &Bytes::from_slice(&env, fixture!("age", "vk")));
assert!(c.verify_proof(
&symbol_short!("age"),
&Bytes::from_slice(&env, fixture!("age", "proof")),
&Bytes::from_slice(&env, fixture!("age", "public_inputs")),
&None,
));
}

Expand All @@ -59,11 +61,12 @@ fn verifies_income() {
let env = Env::default();
env.mock_all_auths();
let c = setup(&env);
c.set_vk(&symbol_short!("income"), &Bytes::from_slice(&env, fixture!("income", "vk")));
c.set_vk(&symbol_short!("income"), &1u32, &Bytes::from_slice(&env, fixture!("income", "vk")));
assert!(c.verify_proof(
&symbol_short!("income"),
&Bytes::from_slice(&env, fixture!("income", "proof")),
&Bytes::from_slice(&env, fixture!("income", "public_inputs")),
&None,
));
}

Expand All @@ -72,11 +75,16 @@ fn verifies_range() {
let env = Env::default();
env.mock_all_auths();
let c = setup(&env);
c.set_vk(&symbol_short!("range"), &Bytes::from_slice(&env, fixture!("range", "vk")));
c.set_vk(
&symbol_short!("range"),
&1u32,
&Bytes::from_slice(&env, fixture!("range", "vk")),
);
assert!(c.verify_proof(
&symbol_short!("range"),
&Bytes::from_slice(&env, fixture!("range", "proof")),
&Bytes::from_slice(&env, fixture!("range", "public_inputs")),
&None,
));
}

Expand All @@ -87,12 +95,14 @@ fn verifies_jurisdiction() {
let c = setup(&env);
c.set_vk(
&Symbol::new(&env, "jurisdiction"),
&1u32,
&Bytes::from_slice(&env, fixture!("jurisdiction", "vk")),
);
assert!(c.verify_proof(
&Symbol::new(&env, "jurisdiction"),
&Bytes::from_slice(&env, fixture!("jurisdiction", "proof")),
&Bytes::from_slice(&env, fixture!("jurisdiction", "public_inputs")),
&None,
));
}

Expand All @@ -103,12 +113,14 @@ fn verifies_employment() {
let c = setup(&env);
c.set_vk(
&Symbol::new(&env, "employment"),
&1u32,
&Bytes::from_slice(&env, fixture!("employment", "vk")),
);
assert!(c.verify_proof(
&Symbol::new(&env, "employment"),
&Bytes::from_slice(&env, fixture!("employment", "proof")),
&Bytes::from_slice(&env, fixture!("employment", "public_inputs")),
&None,
));
}

Expand All @@ -117,14 +129,15 @@ fn rejects_tampered_proof() {
let env = Env::default();
env.mock_all_auths();
let c = setup(&env);
c.set_vk(&symbol_short!("kyc"), &Bytes::from_slice(&env, fixture!("kyc", "vk")));
c.set_vk(&symbol_short!("kyc"), &1u32, &Bytes::from_slice(&env, fixture!("kyc", "vk")));

let mut bad = fixture!("kyc", "proof").to_vec();
bad[5000] ^= 0xff;
assert!(!c.verify_proof(
&symbol_short!("kyc"),
&Bytes::from_slice(&env, &bad),
&Bytes::from_slice(&env, fixture!("kyc", "public_inputs")),
&None,
));
}

Expand All @@ -133,11 +146,12 @@ fn rejects_wrong_length_proof() {
let env = Env::default();
env.mock_all_auths();
let c = setup(&env);
c.set_vk(&symbol_short!("kyc"), &Bytes::from_slice(&env, fixture!("kyc", "vk")));
c.set_vk(&symbol_short!("kyc"), &1u32, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
assert!(!c.verify_proof(
&symbol_short!("kyc"),
&Bytes::from_array(&env, &[0u8; 16]),
&Bytes::from_slice(&env, fixture!("kyc", "public_inputs")),
&None,
));
}

Expand All @@ -147,7 +161,7 @@ fn set_vk_rejects_garbage() {
let env = Env::default();
env.mock_all_auths();
let c = setup(&env);
c.set_vk(&symbol_short!("kyc"), &Bytes::from_array(&env, &[1, 2, 3]));
c.set_vk(&symbol_short!("kyc"), &1u32, &Bytes::from_array(&env, &[1, 2, 3]));
}

#[test]
Expand All @@ -160,6 +174,7 @@ fn panics_without_vk() {
&symbol_short!("age"),
&Bytes::from_slice(&env, fixture!("kyc", "proof")),
&Bytes::from_slice(&env, fixture!("kyc", "public_inputs")),
&None,
);
}

Expand All @@ -173,6 +188,7 @@ fn set_vk_emits_event() {

c.set_vk(
&symbol_short!("kyc"),
&1u32,
&Bytes::from_slice(&env, fixture!("kyc", "vk")),
);

Expand All @@ -193,3 +209,79 @@ fn set_vk_emits_event() {
],
);
}

// ── VK versioning tests (Issue #85) ──────────────────────────────────────────

/// `get_latest_version` tracks the highest version registered per type, and
/// registering an out-of-order older version never moves it backwards.
#[test]
fn latest_version_tracks_updates() {
let env = Env::default();
env.mock_all_auths();
let c = setup(&env);

c.set_vk(&symbol_short!("kyc"), &1u32, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
assert_eq!(c.get_latest_version(&symbol_short!("kyc")), 1);

c.set_vk(&symbol_short!("kyc"), &2u32, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
assert_eq!(c.get_latest_version(&symbol_short!("kyc")), 2);

// Out-of-order registration of an older version must not regress latest.
c.set_vk(&symbol_short!("kyc"), &1u32, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
assert_eq!(c.get_latest_version(&symbol_short!("kyc")), 2);
}

/// After a circuit upgrade (v2 registered), an old proof verified against v1
/// still verifies — the v1 VK is retained, and `None` resolves to the new
/// latest version.
#[test]
fn old_proof_still_verifies_after_upgrade() {
let env = Env::default();
env.mock_all_auths();
let c = setup(&env);

// Register v1 and verify a proof against it explicitly.
c.set_vk(&symbol_short!("kyc"), &1u32, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
assert!(c.verify_proof(
&symbol_short!("kyc"),
&Bytes::from_slice(&env, fixture!("kyc", "proof")),
&Bytes::from_slice(&env, fixture!("kyc", "public_inputs")),
&Some(1),
));

// Upgrade to v2 — the old v1 proof must still verify (VK at (kyc,1) intact).
c.set_vk(&symbol_short!("kyc"), &2u32, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
assert!(c.verify_proof(
&symbol_short!("kyc"),
&Bytes::from_slice(&env, fixture!("kyc", "proof")),
&Bytes::from_slice(&env, fixture!("kyc", "public_inputs")),
&Some(1),
));
// And `None` now resolves to v2 (latest).
assert!(c.verify_proof(
&symbol_short!("kyc"),
&Bytes::from_slice(&env, fixture!("kyc", "proof")),
&Bytes::from_slice(&env, fixture!("kyc", "public_inputs")),
&None,
));
}

/// `deprecate_version` blocks new submissions against the deprecated version.
#[test]
fn deprecated_version_rejects_new_submissions() {
let env = Env::default();
env.mock_all_auths();
let c = setup(&env);

c.set_vk(&symbol_short!("kyc"), &1u32, &Bytes::from_slice(&env, fixture!("kyc", "vk")));
c.deprecate_version(&symbol_short!("kyc"), &1u32);

// New submissions against the deprecated version must be rejected.
let res = c.try_verify_proof(
&symbol_short!("kyc"),
&Bytes::from_slice(&env, fixture!("kyc", "proof")),
&Bytes::from_slice(&env, fixture!("kyc", "public_inputs")),
&Some(1),
);
assert!(res.is_err());
}
Loading
Loading