From eb9ee2c6ffa3a7824a1bd66ab7f398adf98f193b Mon Sep 17 00:00:00 2001 From: temitopehannahbolarin-beep Date: Thu, 16 Jul 2026 16:29:45 +0100 Subject: [PATCH 1/5] feat(credential-revocation): add on-chain credential root revocation --- contracts/agent-passport-validator/src/lib.rs | 53 ++++++- .../agent-passport-validator/src/test.rs | 139 ++++++++++++++++++ sdk/bindings/src/index.ts | 6 +- sdk/src/PassportClient.ts | 3 + sdk/src/passport.ts | 23 +++ 5 files changed, 219 insertions(+), 5 deletions(-) diff --git a/contracts/agent-passport-validator/src/lib.rs b/contracts/agent-passport-validator/src/lib.rs index 58b6aa2..50b192e 100644 --- a/contracts/agent-passport-validator/src/lib.rs +++ b/contracts/agent-passport-validator/src/lib.rs @@ -70,6 +70,8 @@ pub enum Error { UnknownRegistryRoot = 6, /// Batch size exceeds the limit of 8. BatchTooLarge = 7, + /// The credential root has been revoked. + CredentialRevoked = 8, } #[contracttype] @@ -143,6 +145,13 @@ pub struct VerifyResult { pub error: Option, } +#[contracttype] +#[derive(Clone, Debug)] +pub struct CredentialRevokedEvent { + pub root: BytesN<32>, + pub revoked_at_ledger: u32, +} + #[contracttype] enum DataKey { Admin, @@ -159,6 +168,8 @@ enum DataKey { RegistryRoots, AuditEntry(u64), AuditSequence, + /// Revoked credential Merkle root -> revoked (presence == revoked). + RevokedRoot(BytesN<32>), } #[contract] @@ -311,7 +322,10 @@ impl AgentPassportValidator { Error::NullifierUsed => Some(Symbol::new(&env, "NullifierUsed")), Error::InvalidProof => Some(Symbol::new(&env, "InvalidProof")), Error::NotInitialized => Some(Symbol::new(&env, "NotInitialized")), - Error::UnknownRegistryRoot => Some(Symbol::new(&env, "UnknownRegistryRoot")), + Error::UnknownRegistryRoot => { + Some(Symbol::new(&env, "UnknownRegistryRoot")) + } + Error::CredentialRevoked => Some(Symbol::new(&env, "CredentialRevoked")), _ => Some(Symbol::new(&env, "Error")), }; results.push_back(VerifyResult { @@ -546,7 +560,12 @@ impl AgentPassportValidator { ) -> Result { actor.require_auth(); + // Check if the credential root has been revoked. let instance = env.storage().instance(); + if instance.has(&DataKey::RevokedRoot(root.clone())) { + return Err(Error::CredentialRevoked); + } + let seq: u64 = instance.get(&DataKey::AuditSequence).unwrap_or(0); let action = if success { @@ -574,12 +593,34 @@ impl AgentPassportValidator { Ok(success) } + /// Admin-only: Mark a credential Merkle root as revoked. + /// Once revoked, any verify_credential call with this root will fail + /// with CredentialRevoked. pub fn revoke_credential(env: Env, actor: Address, root: BytesN<32>) -> Result<(), Error> { - actor.require_auth(); + // Admin-only check. + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + admin.require_auth(); let instance = env.storage().instance(); - let seq: u64 = instance.get(&DataKey::AuditSequence).unwrap_or(0); + // Mark the root as revoked. + instance.set(&DataKey::RevokedRoot(root.clone()), &true); + + // Emit the credential_revoked event. + env.events().publish( + (Symbol::new(&env, "credential_revoked"),), + CredentialRevokedEvent { + root: root.clone(), + revoked_at_ledger: env.ledger().sequence(), + }, + ); + + // Audit log entry for the revoke action. + let seq: u64 = instance.get(&DataKey::AuditSequence).unwrap_or(0); let record = AuditRecord { action: Symbol::new(&env, "revoke"), actor: actor.clone(), @@ -587,7 +628,6 @@ impl AgentPassportValidator { ledger: env.ledger().sequence(), success: true, }; - let persistent = env.storage().persistent(); let key = DataKey::AuditEntry(seq); persistent.set(&key, &record); @@ -599,6 +639,11 @@ impl AgentPassportValidator { Ok(()) } + /// Public read function: check if a credential root has been revoked. + pub fn is_revoked(env: Env, root: BytesN<32>) -> bool { + env.storage().instance().has(&DataKey::RevokedRoot(root)) + } + pub fn get_audit_entry(env: Env, seq: u64) -> Option { env.storage().persistent().get(&DataKey::AuditEntry(seq)) } diff --git a/contracts/agent-passport-validator/src/test.rs b/contracts/agent-passport-validator/src/test.rs index 4428181..250903d 100644 --- a/contracts/agent-passport-validator/src/test.rs +++ b/contracts/agent-passport-validator/src/test.rs @@ -511,3 +511,142 @@ fn test_audit_logging() { assert_eq!(entry3.root, root); assert_eq!(entry3.success, true); } + +// --------------------------------------------------------------------------- +// Credential revocation tests +// --------------------------------------------------------------------------- + +#[test] +fn admin_can_revoke_credential_root() { + let env = Env::default(); + let client = setup(&env, u256(&env, PI_ROOT)); + + let root = BytesN::from_array(&env, &[1u8; 32]); + assert!(!client.is_revoked(&root)); + + env.mock_all_auths(); + client.revoke_credential(&Address::generate(&env), &root); + + assert!(client.is_revoked(&root)); +} + +#[test] +fn verify_credential_fails_with_credential_revoked_after_revoke() { + let env = Env::default(); + let client = setup(&env, u256(&env, PI_ROOT)); + + let actor = Address::generate(&env); + let root = BytesN::from_array(&env, &[2u8; 32]); + + env.mock_all_auths(); + + // First, verify succeeds + let res = client.verify_credential(&actor, &root, &true); + assert_eq!(res, true); + + // Now revoke the root + client.revoke_credential(&actor, &root); + + // Subsequent verify_credential with that root fails with CredentialRevoked + let res = client.try_verify_credential(&actor, &root, &true); + assert_eq!(res, Err(Ok(Error::CredentialRevoked))); +} + +#[test] +fn is_revoked_returns_true_for_revoked_roots() { + let env = Env::default(); + let client = setup(&env, u256(&env, PI_ROOT)); + + let root = BytesN::from_array(&env, &[3u8; 32]); + assert!(!client.is_revoked(&root)); + + env.mock_all_auths(); + client.revoke_credential(&Address::generate(&env), &root); + + assert!(client.is_revoked(&root)); +} + +#[test] +fn issue_verify_revoke_verify_flow() { + let env = Env::default(); + let client = setup(&env, u256(&env, PI_ROOT)); + + let actor = Address::generate(&env); + let root = BytesN::from_array(&env, &[4u8; 32]); + + env.mock_all_auths(); + + // Issue → verify (passes) + client.issue_credential(&actor, &root); + let res = client.verify_credential(&actor, &root, &true); + assert_eq!(res, true); + + // Revoke + client.revoke_credential(&actor, &root); + + // Verify fails with CredentialRevoked + let res = client.try_verify_credential(&actor, &root, &true); + assert_eq!(res, Err(Ok(Error::CredentialRevoked))); +} + +#[test] +fn credential_revoked_event_emitted_with_correct_fields() { + let env = Env::default(); + let (validator_addr, _, client) = setup_with_id(&env, u256(&env, PI_ROOT)); + + let actor = Address::generate(&env); + let root = BytesN::from_array(&env, &[5u8; 32]); + + env.ledger().set_sequence_number(5000); + + env.mock_all_auths(); + client.revoke_credential(&actor, &root); + + let events = env.events().all().filter_by_contract(&validator_addr); + // We expect at least the credential_revoked event (there may also be audit events) + assert!(events.events().len() >= 1, "expected at least one event"); +} + +#[test] +fn non_admin_call_to_revoke_credential_returns_unauthorized() { + let env = Env::default(); + let client = setup(&env, u256(&env, PI_ROOT)); + + // A non-admin-generated address calling revoke_credential should fail + // because require_auth() will panic when the address hasn't been authorized. + let non_admin = Address::generate(&env); + let root = BytesN::from_array(&env, &[6u8; 32]); + + let res = client.try_revoke_credential(&non_admin, &root); + assert!(res.is_err()); +} + +#[test] +fn revoked_root_remains_revoked_across_multiple_checks() { + let env = Env::default(); + let client = setup(&env, u256(&env, PI_ROOT)); + + let root = BytesN::from_array(&env, &[7u8; 32]); + + env.mock_all_auths(); + client.revoke_credential(&Address::generate(&env), &root); + + assert!(client.is_revoked(&root)); + assert!(client.is_revoked(&root)); + assert!(client.is_revoked(&root)); +} + +#[test] +fn different_roots_are_independent_for_revocation() { + let env = Env::default(); + let client = setup(&env, u256(&env, PI_ROOT)); + + let root_a = BytesN::from_array(&env, &[8u8; 32]); + let root_b = BytesN::from_array(&env, &[9u8; 32]); + + env.mock_all_auths(); + client.revoke_credential(&Address::generate(&env), &root_a); + + assert!(client.is_revoked(&root_a)); + assert!(!client.is_revoked(&root_b)); +} diff --git a/sdk/bindings/src/index.ts b/sdk/bindings/src/index.ts index 905a076..eb804cc 100644 --- a/sdk/bindings/src/index.ts +++ b/sdk/bindings/src/index.ts @@ -54,7 +54,11 @@ export const Errors = { */ 5: {message:"InvalidProof"}, 6: {message:"BatchTooLarge"}, - 7: {message:"UnknownRegistryRoot"} + 7: {message:"UnknownRegistryRoot"}, + /** + * The credential root has been revoked. + */ + 8: {message:"CredentialRevoked"} } diff --git a/sdk/src/PassportClient.ts b/sdk/src/PassportClient.ts index ddffd46..48aa7b2 100644 --- a/sdk/src/PassportClient.ts +++ b/sdk/src/PassportClient.ts @@ -11,6 +11,7 @@ export enum PassportError { InvalidProof = "InvalidProof", BatchTooLarge = "BatchTooLarge", UnknownRegistryRoot = "UnknownRegistryRoot", + CredentialRevoked = "CredentialRevoked", Unknown = "Unknown", } @@ -49,6 +50,8 @@ const mapSymbolToPassportError = (err: unknown): PassportError | undefined => { return PassportError.BatchTooLarge; case "UnknownRegistryRoot": return PassportError.UnknownRegistryRoot; + case "CredentialRevoked": + return PassportError.CredentialRevoked; default: return PassportError.Unknown; } diff --git a/sdk/src/passport.ts b/sdk/src/passport.ts index 16f7ae8..ca671d0 100644 --- a/sdk/src/passport.ts +++ b/sdk/src/passport.ts @@ -209,6 +209,29 @@ function utcWeekStart(ts: number): number { return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), mon); } +/** + * Check if an error is a credential revocation error. + * Works with PassportError enum values, contract error symbols, and error objects. + */ +export function isRevocationError(error: unknown): boolean { + if (typeof error === "string") { + return error === "CredentialRevoked"; + } + if (error && typeof error === "object") { + const obj = error as Record; + if (typeof obj.error === "string") { + return obj.error === "CredentialRevoked"; + } + if (typeof obj.message === "string") { + return obj.message === "CredentialRevoked"; + } + if (typeof obj.code === "number") { + return obj.code === 8; + } + } + return false; +} + export function authorizePassportSpend( agentId: string, amount: number, From 77dcfd69e59858d89aca74ea7e7fd41dde672c41 Mon Sep 17 00:00:00 2001 From: temitopehannahbolarin-beep Date: Thu, 16 Jul 2026 16:39:38 +0100 Subject: [PATCH 2/5] chore: trigger CI checks From a140ec6bdbc75a36170e9c61b08faf408a4bf870 Mon Sep 17 00:00:00 2001 From: temitopehannahbolarin-beep Date: Thu, 16 Jul 2026 18:19:43 +0100 Subject: [PATCH 3/5] fix: resolve PR #208 review comments - CredentialRevoked=9, verify order, no conflict markers --- contracts/agent-passport-validator/src/lib.rs | 9 +++++---- sdk/bindings/src/index.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/contracts/agent-passport-validator/src/lib.rs b/contracts/agent-passport-validator/src/lib.rs index 50b192e..dd7328f 100644 --- a/contracts/agent-passport-validator/src/lib.rs +++ b/contracts/agent-passport-validator/src/lib.rs @@ -71,7 +71,7 @@ pub enum Error { /// Batch size exceeds the limit of 8. BatchTooLarge = 7, /// The credential root has been revoked. - CredentialRevoked = 8, + CredentialRevoked = 9, } #[contracttype] @@ -558,14 +558,15 @@ impl AgentPassportValidator { root: BytesN<32>, success: bool, ) -> Result { - actor.require_auth(); - - // Check if the credential root has been revoked. + // Check if the credential root has been revoked first, + // so revoked roots don't consume caller quota/rate-limit budget. let instance = env.storage().instance(); if instance.has(&DataKey::RevokedRoot(root.clone())) { return Err(Error::CredentialRevoked); } + actor.require_auth(); + let seq: u64 = instance.get(&DataKey::AuditSequence).unwrap_or(0); let action = if success { diff --git a/sdk/bindings/src/index.ts b/sdk/bindings/src/index.ts index eb804cc..0f3fc94 100644 --- a/sdk/bindings/src/index.ts +++ b/sdk/bindings/src/index.ts @@ -58,7 +58,7 @@ export const Errors = { /** * The credential root has been revoked. */ - 8: {message:"CredentialRevoked"} + 9: {message:"CredentialRevoked"} } From 2bf03f4ae59be15d914783c9381b6bd766c73b16 Mon Sep 17 00:00:00 2001 From: temitopehannahbolarin-beep Date: Fri, 17 Jul 2026 02:57:25 +0100 Subject: [PATCH 4/5] chore: update test snapshots --- .../test_snapshots/test/can_manage_registry_roots.1.json | 2 +- .../test_snapshots/test/init_is_one_shot.1.json | 2 +- .../test_snapshots/test/mixed_batch_results.1.json | 2 +- .../test_snapshots/test/registers_a_valid_passport.1.json | 2 +- .../test_snapshots/test/rejects_batch_too_large.1.json | 2 +- .../test_snapshots/test/rejects_nullifier_replay.1.json | 2 +- .../test_snapshots/test/rejects_tampered_public_input.1.json | 2 +- .../test_snapshots/test/rejects_unknown_registry_root.1.json | 2 +- .../test_snapshots/test/rejects_wrong_input_count.1.json | 2 +- .../test_snapshots/test/verifies_a_batch_of_passports.1.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/contracts/agent-passport-validator/test_snapshots/test/can_manage_registry_roots.1.json b/contracts/agent-passport-validator/test_snapshots/test/can_manage_registry_roots.1.json index 8cd0aee..feaedf3 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/can_manage_registry_roots.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/can_manage_registry_roots.1.json @@ -74,7 +74,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/init_is_one_shot.1.json b/contracts/agent-passport-validator/test_snapshots/test/init_is_one_shot.1.json index 2a47ff0..bd1478f 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/init_is_one_shot.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/init_is_one_shot.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/mixed_batch_results.1.json b/contracts/agent-passport-validator/test_snapshots/test/mixed_batch_results.1.json index 4c729a1..b474f03 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/mixed_batch_results.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/mixed_batch_results.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/registers_a_valid_passport.1.json b/contracts/agent-passport-validator/test_snapshots/test/registers_a_valid_passport.1.json index 36bf4da..7293dcd 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/registers_a_valid_passport.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/registers_a_valid_passport.1.json @@ -36,7 +36,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 1000, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/rejects_batch_too_large.1.json b/contracts/agent-passport-validator/test_snapshots/test/rejects_batch_too_large.1.json index 0c4ce04..af825a1 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/rejects_batch_too_large.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/rejects_batch_too_large.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/rejects_nullifier_replay.1.json b/contracts/agent-passport-validator/test_snapshots/test/rejects_nullifier_replay.1.json index 4613280..5a85527 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/rejects_nullifier_replay.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/rejects_nullifier_replay.1.json @@ -33,7 +33,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/rejects_tampered_public_input.1.json b/contracts/agent-passport-validator/test_snapshots/test/rejects_tampered_public_input.1.json index 9fe10db..8884a7f 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/rejects_tampered_public_input.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/rejects_tampered_public_input.1.json @@ -33,7 +33,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/rejects_unknown_registry_root.1.json b/contracts/agent-passport-validator/test_snapshots/test/rejects_unknown_registry_root.1.json index 92113d7..7c8e33d 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/rejects_unknown_registry_root.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/rejects_unknown_registry_root.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/rejects_wrong_input_count.1.json b/contracts/agent-passport-validator/test_snapshots/test/rejects_wrong_input_count.1.json index 0c4ce04..af825a1 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/rejects_wrong_input_count.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/rejects_wrong_input_count.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/verifies_a_batch_of_passports.1.json b/contracts/agent-passport-validator/test_snapshots/test/verifies_a_batch_of_passports.1.json index cd6b99a..df6fc7a 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/verifies_a_batch_of_passports.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/verifies_a_batch_of_passports.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", From 504568485a8ac94785c90fb49759e318c878c6cc Mon Sep 17 00:00:00 2001 From: temitopehannahbolarin-beep Date: Fri, 17 Jul 2026 02:59:27 +0100 Subject: [PATCH 5/5] chore: update SDK lockfile --- sdk/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 1a4d203..7d256ee 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -504,7 +504,7 @@ }, "node_modules/@types/node": { "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.1.1.tgz", "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT",