diff --git a/Cargo.lock b/Cargo.lock index 33fd0d6..b3e2c50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,9 +124,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.64" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "shlex", @@ -693,9 +693,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" @@ -851,9 +851,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1393,9 +1393,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.49" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", "num-conv", @@ -1413,9 +1413,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.29" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", diff --git a/contracts/stellar-id/src/lib.rs b/contracts/stellar-id/src/lib.rs index 2d7114e..abb35de 100644 --- a/contracts/stellar-id/src/lib.rs +++ b/contracts/stellar-id/src/lib.rs @@ -332,27 +332,8 @@ impl StellarIdContract { duration_seconds: u64, ) -> u64 { issuer.require_auth(); - - let issuer_record: Option = env - .storage() - .persistent() - .get(&DataKey::Issuer(issuer.clone())); - - let effective_trust: u32; - - if let Some(record) = issuer_record { - assert!(record.active, "Issuer is not active"); - effective_trust = record.trust_level; - } else { - panic!("Not a registered issuer"); - } - - let schema: Schema = env - .storage() - .persistent() - .get(&DataKey::Schema(schema_id)) - .expect("Schema not found"); - assert!(schema.active, "Schema is not active"); + let effective_trust = Self::require_active_issuer(&env, &issuer); + Self::require_active_schema(&env, schema_id); let now = env.ledger().timestamp(); let expires_at = if duration_seconds > 0 { @@ -434,6 +415,117 @@ impl StellarIdContract { credential_id } + /// Issues credentials to multiple subjects in a single call. + /// + /// The `issuer` address must authorize the call once; all `subjects` receive + /// a credential for `schema_id` with the same expiry. Returns credential IDs + /// in the same order as the input subjects list. + /// + /// Panics if `issuer` does not authorize the call, is not registered, is + /// inactive, `schema_id` does not exist, the schema is inactive, or the + /// subjects list is empty. + pub fn batch_issue_credentials( + env: Env, + issuer: Address, + subjects: Vec
, + schema_id: u32, + duration_seconds: u64, + ) -> Vec { + issuer.require_auth(); + assert!(!subjects.is_empty(), "subjects list cannot be empty"); + let effective_trust = Self::require_active_issuer(&env, &issuer); + Self::require_active_schema(&env, schema_id); + + let now = env.ledger().timestamp(); + let expires_at = if duration_seconds > 0 { + now + duration_seconds + } else { + 0 + }; + + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::CredentialCount) + .unwrap_or(0); + + let mut credential_ids: Vec = Vec::new(&env); + + for subject in subjects.iter() { + count += 1; + let credential_id = count; + + let credential = Credential { + id: credential_id, + subject: subject.clone(), + issuer: issuer.clone(), + schema_id, + issued_at: now, + expires_at, + revoked: false, + }; + + env.storage() + .persistent() + .set(&DataKey::Credential(credential_id), &credential); + + let mut subject_creds: Vec = env + .storage() + .persistent() + .get(&DataKey::SubjectCredentials(subject.clone())) + .unwrap_or(Vec::new(&env)); + subject_creds.push_back(credential_id); + env.storage().persistent().set( + &DataKey::SubjectCredentials(subject.clone()), + &subject_creds, + ); + + let existing: Option = env + .storage() + .persistent() + .get(&DataKey::Identity(subject.clone())); + let identity = if let Some(mut id) = existing { + id.credential_count += 1; + id.reputation_score = + Self::compute_reputation(id.credential_count, effective_trust); + id + } else { + Identity { + subject: subject.clone(), + credential_count: 1, + reputation_score: effective_trust / 10, + created_at: now, + } + }; + env.storage() + .persistent() + .set(&DataKey::Identity(subject.clone()), &identity); + + credential_ids.push_back(credential_id); + } + + env.storage() + .instance() + .set(&DataKey::CredentialCount, &count); + + let mut issuer_rec: Issuer = env + .storage() + .persistent() + .get(&DataKey::Issuer(issuer.clone())) + .expect("Issuer not found"); + issuer_rec.credential_count += subjects.len() as u64; + env.storage() + .persistent() + .set(&DataKey::Issuer(issuer.clone()), &issuer_rec); + + env.events().publish( + (Symbol::new(&env, "credentials_batch_issued"),), + (subjects.len(), issuer, schema_id), + ); + + credential_ids + } + /// Revokes a credential issued by the caller. /// /// The `issuer` address must authorize the call and must be the original @@ -469,6 +561,57 @@ impl StellarIdContract { ); } + /// Extends the expiry of a non-revoked credential. + /// + /// The `issuer` address must authorize the call and must be the original + /// issuer of `credential_id`. The credential's `expires_at` is incremented + /// by `additional_seconds`. Non-expiring credentials (`expires_at == 0`) + /// cannot be renewed. + /// + /// Panics if `issuer` does not authorize the call, `credential_id` does not + /// exist, `issuer` is not the original issuer, the credential is already + /// revoked, `additional_seconds` is zero, or the credential is non-expiring. + pub fn renew_credential( + env: Env, + issuer: Address, + credential_id: u64, + additional_seconds: u64, + ) { + issuer.require_auth(); + assert!( + additional_seconds > 0, + "additional_seconds must be greater than zero" + ); + + let mut credential: Credential = env + .storage() + .persistent() + .get(&DataKey::Credential(credential_id)) + .expect("Credential not found"); + + assert!( + credential.issuer == issuer, + "Only the original issuer can renew" + ); + assert!(!credential.revoked, "Cannot renew a revoked credential"); + assert!( + credential.expires_at > 0, + "Cannot renew a non-expiring credential" + ); + + credential.expires_at += additional_seconds; + let new_expires_at = credential.expires_at; + + env.storage() + .persistent() + .set(&DataKey::Credential(credential_id), &credential); + + env.events().publish( + (Symbol::new(&env, "credential_renewed"),), + (credential_id, new_expires_at), + ); + } + // -------------------------------------------------------- // Query Functions // -------------------------------------------------------- @@ -639,6 +782,28 @@ impl StellarIdContract { // Internal helpers // -------------------------------------------------------- + fn require_active_issuer(env: &Env, issuer: &Address) -> u32 { + let issuer_record: Option = env + .storage() + .persistent() + .get(&DataKey::Issuer(issuer.clone())); + if let Some(record) = issuer_record { + assert!(record.active, "Issuer is not active"); + record.trust_level + } else { + panic!("Not a registered issuer"); + } + } + + fn require_active_schema(env: &Env, schema_id: u32) { + let schema: Schema = env + .storage() + .persistent() + .get(&DataKey::Schema(schema_id)) + .expect("Schema not found"); + assert!(schema.active, "Schema is not active"); + } + fn compute_reputation(credential_count: u32, trust_level: u32) -> u32 { let base = credential_count * 10; let trust_bonus = trust_level / 10; @@ -654,8 +819,8 @@ impl StellarIdContract { mod tests { use super::*; use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Env, String, + testutils::{Address as _, Events, Ledger}, + Address, Env, String, Vec, }; fn setup(env: &Env) -> (Address, StellarIdContractClient<'_>) { @@ -1103,4 +1268,265 @@ mod tests { let cred_id = client.issue_credential(&issuer, &subject, &schema_id, &0u64); client.revoke_credential(&attacker, &cred_id); } + + // -------------------------------------------------------- + // Batch credential issuance tests + // -------------------------------------------------------- + + #[test] + fn test_batch_issue_credentials() { + let env = Env::default(); + env.ledger().set_timestamp(2000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + + let mut subjects: Vec
= Vec::new(&env); + subjects.push_back(Address::generate(&env)); + subjects.push_back(Address::generate(&env)); + subjects.push_back(Address::generate(&env)); + + let event_count_before = env.events().all().len(); + + let ids = client.batch_issue_credentials(&issuer, &subjects, &schema_id, &0u64); + + let event_count_after = env.events().all().len(); + + // Returns sequential IDs + assert_eq!(ids.len(), 3); + assert_eq!(ids.get(0).unwrap(), 1); + assert_eq!(ids.get(1).unwrap(), 2); + assert_eq!(ids.get(2).unwrap(), 3); + + assert_eq!(client.get_credential_count(), 3); + + // Each credential has correct data + for (i, subject) in subjects.iter().enumerate() { + let cred = client.get_credential(&ids.get(i as u32).unwrap()); + assert_eq!(cred.subject, subject); + assert_eq!(cred.issuer, issuer); + assert_eq!(cred.schema_id, schema_id); + assert!(!cred.revoked); + assert_eq!(cred.expires_at, 0); + } + + // Each subject has an identity + for subject in subjects.iter() { + let identity = client.get_identity(&subject); + assert_eq!(identity.credential_count, 1); + assert_eq!(identity.created_at, 2000); + } + + // Issuer credential count updated + let issuer_record = client.get_issuer(&issuer); + assert_eq!(issuer_record.credential_count, 3); + + // Batch event was emitted + assert!( + event_count_after > event_count_before, + "batch event should have been emitted" + ); + } + + #[test] + fn test_batch_issue_credentials_with_expiry() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + + let mut subjects: Vec
= Vec::new(&env); + subjects.push_back(Address::generate(&env)); + subjects.push_back(Address::generate(&env)); + + let ids = client.batch_issue_credentials(&issuer, &subjects, &schema_id, &3600u64); + + for i in 0..ids.len() { + let cred = client.get_credential(&ids.get(i).unwrap()); + assert_eq!(cred.expires_at, 4600); + } + } + + #[test] + fn test_batch_issue_credentials_updates_multiple_identities() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let s1 = register_schema_helper(&env, &client, &issuer); + let s2 = client.register_schema( + &issuer, + &String::from_str(&env, "Accredited Investor"), + &String::from_str(&env, "Accredited investor status"), + ); + + let mut subjects: Vec
= Vec::new(&env); + subjects.push_back(Address::generate(&env)); + subjects.push_back(Address::generate(&env)); + + // Issue KYC to subject 0, both schemas to subject 1 + let mut single_subject: Vec
= Vec::new(&env); + single_subject.push_back(subjects.get(0).unwrap()); + client.batch_issue_credentials(&issuer, &single_subject, &s1, &0u64); + + client.batch_issue_credentials(&issuer, &subjects, &s2, &0u64); + + // Subject 0: two credentials (one from each batch) + let id0 = client.get_identity(&subjects.get(0).unwrap()); + assert_eq!(id0.credential_count, 2); + + // Subject 1: one credential + let id1 = client.get_identity(&subjects.get(1).unwrap()); + assert_eq!(id1.credential_count, 1); + } + + #[test] + #[should_panic(expected = "Schema is not active")] + fn test_batch_issue_credentials_inactive_schema_panics() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + let mut subjects: Vec
= Vec::new(&env); + subjects.push_back(Address::generate(&env)); + + client.deactivate_schema(&issuer, &schema_id); + client.batch_issue_credentials(&issuer, &subjects, &schema_id, &0u64); + } + + #[test] + #[should_panic(expected = "subjects list cannot be empty")] + fn test_batch_issue_credentials_empty_subjects_panics() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + let subjects: Vec
= Vec::new(&env); + + client.batch_issue_credentials(&issuer, &subjects, &schema_id, &0u64); + } + + // -------------------------------------------------------- + // Credential renewal tests + // -------------------------------------------------------- + + #[test] + fn test_renew_credential() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + let subject = Address::generate(&env); + + let cred_id = client.issue_credential(&issuer, &subject, &schema_id, &3600u64); + assert_eq!(client.get_credential(&cred_id).expires_at, 4600); + + client.renew_credential(&issuer, &cred_id, &7200u64); + + let cred = client.get_credential(&cred_id); + assert_eq!(cred.expires_at, 11800); + assert!(!cred.revoked); + assert!(client.has_valid_credential(&subject, &schema_id)); + } + + #[test] + fn test_renew_credential_multiple_times() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + let subject = Address::generate(&env); + + let cred_id = client.issue_credential(&issuer, &subject, &schema_id, &1000u64); + + client.renew_credential(&issuer, &cred_id, &500u64); + assert_eq!(client.get_credential(&cred_id).expires_at, 2500); + + client.renew_credential(&issuer, &cred_id, &500u64); + assert_eq!(client.get_credential(&cred_id).expires_at, 3000); + } + + #[test] + #[should_panic(expected = "Only the original issuer can renew")] + fn test_renew_credential_non_issuer_rejected() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + let subject = Address::generate(&env); + let attacker = Address::generate(&env); + + let cred_id = client.issue_credential(&issuer, &subject, &schema_id, &3600u64); + client.renew_credential(&attacker, &cred_id, &3600u64); + } + + #[test] + #[should_panic(expected = "Cannot renew a revoked credential")] + fn test_renew_credential_revoked_rejected() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + let subject = Address::generate(&env); + + let cred_id = client.issue_credential(&issuer, &subject, &schema_id, &3600u64); + client.revoke_credential(&issuer, &cred_id); + client.renew_credential(&issuer, &cred_id, &3600u64); + } + + #[test] + #[should_panic(expected = "additional_seconds must be greater than zero")] + fn test_renew_credential_zero_additional_seconds_panics() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + let subject = Address::generate(&env); + + let cred_id = client.issue_credential(&issuer, &subject, &schema_id, &3600u64); + client.renew_credential(&issuer, &cred_id, &0u64); + } + + #[test] + #[should_panic(expected = "Cannot renew a non-expiring credential")] + fn test_renew_credential_non_expiring_panics() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + let subject = Address::generate(&env); + + let cred_id = client.issue_credential(&issuer, &subject, &schema_id, &0u64); + client.renew_credential(&issuer, &cred_id, &3600u64); + } + + #[test] + fn test_renew_credential_restores_validity() { + let env = Env::default(); + env.ledger().set_timestamp(1000); + let (admin, client) = setup(&env); + let issuer = register_issuer_helper(&env, &client, &admin); + let schema_id = register_schema_helper(&env, &client, &issuer); + let subject = Address::generate(&env); + + let cred_id = client.issue_credential(&issuer, &subject, &schema_id, &500u64); + assert!(client.has_valid_credential(&subject, &schema_id)); + + // Advance past expiry + env.ledger().set_timestamp(2000); + assert!(!client.has_valid_credential(&subject, &schema_id)); + + // Renew to extend beyond current time + client.renew_credential(&issuer, &cred_id, &2000u64); + assert!(client.has_valid_credential(&subject, &schema_id)); + } } diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index cf45866..375c82e 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -28,16 +28,19 @@ soroban-sdk = { version = "21.7.6", features = [] } [dev-dependencies] soroban-sdk = { version = "21.7.6", features = ["testutils"] } +stellar-id = { path = "../stellar-id" } ``` #### 2. src/lib.rs ```rust #![no_std] -use soroban_sdk::{ - contract, contractimpl, contractclient, Address, Env, String, Symbol, Vec, -}; +use soroban_sdk::{contract, contractimpl, contractclient, Address, Env}; +// ── External contract interface ───────────────────────────────────────── +// #[contractclient] generates `StellarIdClient` — a cross-contract caller +// that wraps env.invoke_contract(). The trait methods must match the exact +// signatures of the StellarID contract's public functions. #[contractclient(name = "StellarIdClient")] trait StellarId { fn has_valid_credential(env: Env, subject: Address, schema_id: u32) -> bool; @@ -45,6 +48,9 @@ trait StellarId { fn get_identity(env: Env, subject: Address) -> Identity; } +// The Identity type must be replicated so the generated client can decode +// the cross-contract return value. Keep this in sync with the StellarID +// contract definition. #[contracttype] #[derive(Clone, Debug)] pub struct Identity { @@ -54,6 +60,16 @@ pub struct Identity { pub created_at: u64, } +// ── Storage keys ──────────────────────────────────────────────────────── +#[contracttype] +enum DataKey { + Admin, + StellarIdContract, + KycSchemaId, + Balance(Address), +} + +// ── Gated Vault contract ──────────────────────────────────────────────── #[contract] pub struct GatedVault; @@ -90,6 +106,7 @@ impl GatedVault { fn assert_kyc_verified(env: &Env, user: &Address) { let stellar_id_contract: Address = env.storage().instance().get(&DataKey::StellarIdContract).expect("Not initialized"); let kyc_schema_id: u32 = env.storage().instance().get(&DataKey::KycSchemaId).expect("Not initialized"); + // Build a cross-contract client and call has_valid_credential let client = StellarIdClient::new(env, &stellar_id_contract); assert!( client.has_valid_credential(user, &kyc_schema_id), @@ -98,14 +115,6 @@ impl GatedVault { } } -#[contracttype] -enum DataKey { - Admin, - StellarIdContract, - KycSchemaId, - Balance(Address), -} - #[cfg(test)] mod tests { use super::*; @@ -175,35 +184,117 @@ The test code above shows a complete example. ## Backend / SDK Call -From a TypeScript backend using the Stellar SDK: +From a TypeScript backend using the Stellar SDK, you can verify credentials via `simulateTransaction` — no wallet or user signature is required since `has_valid_credential` is read-only. -```typescript -import { Contract, SorobanRpc, TransactionBuilder, Networks } from "@stellar/stellar-sdk"; +### Prerequisites -const server = new SorobanRpc.Server("https://soroban-testnet.stellar.org"); -const STELLAR_ID_CONTRACT = "YOUR_CONTRACT_ID"; +```bash +npm install @stellar/stellar-sdk +``` +### Complete Example + +```typescript +import { + Address, + Contract, + Keypair, + nativeToScVal, + Networks, + scvalToNative, + SorobanRpc, + TransactionBuilder, +} from "@stellar/stellar-sdk"; + +const RPC_URL = "https://soroban-testnet.stellar.org"; +const STELLAR_ID_CONTRACT_ID = "CA3D..."; // Deployed contract ID + +const server = new SorobanRpc.Server(RPC_URL); +const networkPassphrase = Networks.TESTNET; + +/** + * Check whether a subject address holds a valid credential for a given schema. + * + * This is a pure simulate call — no fees, no signatures, no on-chain footprint. + */ async function hasValidCredential( subjectAddress: string, - schemaId: number + schemaId: number, ): Promise { - const contract = new Contract(STELLAR_ID_CONTRACT); - - const result = await server.simulateTransaction( - new TransactionBuilder(/* ... */) - .addOperation( - contract.call( - "has_valid_credential", - // encode subject and schema_id as XDR args - ) - ) - .build() - ); - - return result.result?.retval?.value() === true; + const contract = new Contract(STELLAR_ID_CONTRACT_ID); + + const subjectScVal = Address.fromString(subjectAddress).toScVal(); + const schemaIdScVal = nativeToScVal(schemaId, { type: "u32" }); + + // A random keypair is fine for simulation — the source account is never + // charged because the transaction is never submitted. + const source = Keypair.random(); + + const tx = new TransactionBuilder(source, { + fee: "100", + networkPassphrase, + }) + .addOperation( + contract.call("has_valid_credential", subjectScVal, schemaIdScVal), + ) + .setTimeout(30) + .build(); + + const result = await server.simulateTransaction(tx); + + if (result.error) { + throw new Error(`Simulation failed: ${result.error}`); + } + + const retval = result.result?.retval; + if (!retval) { + throw new Error("No return value — check contract ID and network"); + } + + return scvalToNative(retval) as boolean; +} +``` + +### Usage + +```typescript +const isVerified = await hasValidCredential( + "GBPLP3Y3TPF6T3Q5KNHX6PRFGKELN33NX2K5C4YKP32Y36B6H5XJ7GKL", + 1, // KYC Verified schema +); + +if (isVerified) { + console.log("User is KYC-verified, granting access"); +} else { + console.log("User does not have a valid KYC credential"); } ``` +### Express Middleware Example + +```typescript +import express from "express"; + +const app = express(); + +app.use(async (req, res, next) => { + const userAddress = req.headers["x-stellar-address"] as string; + if (!userAddress) { + return res.status(401).json({ error: "Missing x-stellar-address header" }); + } + + try { + const isVerified = await hasValidCredential(userAddress, 1); + if (!isVerified) { + return res.status(403).json({ error: "KYC verification required" }); + } + next(); + } catch (err) { + return res.status(500).json({ error: "Verification check failed" }); + } +}); +``` + ## Common Schema IDs Contact the issuer or check on-chain to confirm schema IDs for your network deployment.