From dec941a9c65cefaeef627a0d2212e1b41651b563 Mon Sep 17 00:00:00 2001 From: OlaGreat Date: Wed, 24 Jun 2026 09:09:55 +0100 Subject: [PATCH 1/2] docs: add vc-schema-registry contract with README and inline rustdoc Implements the vc-schema-registry Soroban contract and its full documentation as required by issue #7: - contracts/vc-schema-registry/: complete contract implementation (lib.rs, contract.rs, error.rs, storage.rs, events.rs, test.rs) with /// rustdoc on every public function and //! module-level docs - contracts/vc-schema-registry/README.md: mirrors vc-issuer-registry structure; covers storage layout, SchemaRecord, entry points with auth column, schema ID derivation (sha256 of XDR-encoded triple), versioning & deprecation semantics, error codes, and events tables - Root README.md: adds vc-schema-registry to the contracts table - Cargo.toml: adds vc-schema-registry to workspace members All 17 tests pass; cargo build (workspace) succeeds. --- Cargo.toml | 1 + README.md | 7 + contracts/vc-schema-registry/Cargo.toml | 15 + contracts/vc-schema-registry/README.md | 101 ++++++ contracts/vc-schema-registry/src/contract.rs | 179 ++++++++++ contracts/vc-schema-registry/src/error.rs | 21 ++ contracts/vc-schema-registry/src/events.rs | 41 +++ contracts/vc-schema-registry/src/lib.rs | 17 + contracts/vc-schema-registry/src/storage.rs | 81 +++++ contracts/vc-schema-registry/src/test.rs | 325 +++++++++++++++++++ 10 files changed, 788 insertions(+) create mode 100644 contracts/vc-schema-registry/Cargo.toml create mode 100644 contracts/vc-schema-registry/README.md create mode 100644 contracts/vc-schema-registry/src/contract.rs create mode 100644 contracts/vc-schema-registry/src/error.rs create mode 100644 contracts/vc-schema-registry/src/events.rs create mode 100644 contracts/vc-schema-registry/src/lib.rs create mode 100644 contracts/vc-schema-registry/src/storage.rs create mode 100644 contracts/vc-schema-registry/src/test.rs diff --git a/Cargo.toml b/Cargo.toml index 48dc03f..609f531 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "contracts/vc-issuer-registry", + "contracts/vc-schema-registry", ] [workspace.package] diff --git a/README.md b/README.md index fff69c9..f96e949 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ This repository contains various contract implementations, proof-of-concepts, an - `docs/` - Documentation and specifications - `scripts/` - Build and deployment utilities +## Contracts + +| Contract | Description | +| -------- | ----------- | +| [`vc-issuer-registry`](contracts/vc-issuer-registry/README.md) | On-chain allowlist and metadata registry for VC issuers | +| [`vc-schema-registry`](contracts/vc-schema-registry/README.md) | On-chain registry for Verifiable Credential schema definitions | + ## Building ```bash diff --git a/contracts/vc-schema-registry/Cargo.toml b/contracts/vc-schema-registry/Cargo.toml new file mode 100644 index 0000000..84fce21 --- /dev/null +++ b/contracts/vc-schema-registry/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "vc-schema-registry-contract" +version = "0.1.0" +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } + +[lib] +crate-type = ["rlib", "cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/vc-schema-registry/README.md b/contracts/vc-schema-registry/README.md new file mode 100644 index 0000000..f12d1c2 --- /dev/null +++ b/contracts/vc-schema-registry/README.md @@ -0,0 +1,101 @@ +# vc-schema-registry + +A Stellar smart contract that provides an on-chain registry for Verifiable Credential schema definitions. + +## Overview + +`vc-schema-registry` lets schema authors publish versioned JSON Schema (or other byte-encoded) definitions on-chain and tie them permanently to their address. Downstream contracts and clients — such as `vc-issuer-registry` — can reference a schema ID to prove that a credential type has a published, auditable definition. + +Each schema is identified by a deterministic 32-byte ID derived from the author address, schema name, and version. Schemas can be deprecated by the admin or the original author without being deleted, preserving the historical record while signalling that new credentials should not reference that version. + +## Storage layout + +| Key | Storage type | Description | +| --------------------- | ------------ | ------------------------------------------- | +| `Admin` | Instance | Contract admin address | +| `Schema(BytesN<32>)` | Persistent | `SchemaRecord` keyed by schema ID | + +### SchemaRecord + +```rust +pub struct SchemaRecord { + pub author: Address, + pub name: Symbol, + pub version: Symbol, + pub definition: Bytes, + pub deprecated: bool, +} +``` + +## Entry points + +| Function | Auth | Description | +| --------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------ | +| `initialize(admin)` | admin | One-time init; stores admin address | +| `register_schema(author, name, version, definition)` | author | Publish a new schema; returns the computed `schema_id` | +| `deprecate_schema(schema_id, caller)` | admin or author | Mark a schema deprecated (non-destructive) | +| `get_schema(schema_id)` | — | Return full `SchemaRecord`; panics with `SchemaNotFound` if missing | +| `schema_exists(schema_id)` | — | Return `true` if a schema with that ID exists (includes deprecated) | +| `schema_id(author, name, version)` | — | Compute and return a schema ID without writing to storage | +| `admin()` | — | Return current admin address | +| `version()` | — | Return crate version string | + +### Auth model + +`register_schema` requires the `author` address to authorize the call. This prevents one party from registering schemas under another party's address. + +`deprecate_schema` accepts a `caller` address that must authorize and be either the contract admin **or** the author of that specific schema. Passing any other address panics with `Unauthorized`. + +## Schema ID derivation + +A schema ID is the SHA-256 hash of the XDR-encoded author address, name, and version concatenated in order: + +``` +schema_id = sha256( xdr(author) || xdr(name) || xdr(version) ) +``` + +XDR encoding is used for each component so the preimage matches the canonical `ScVal` serialization. This means the same ID can be reproduced off-chain using any XDR-aware Stellar SDK by serializing the same three values and hashing the result. + +The `schema_id` entry point exposes this computation as a read-only call, making it easy for frontends and tooling to predict the ID before submitting a `register_schema` transaction. + +## Versioning & deprecation + +Every `(author, name, version)` triple maps to exactly one schema ID. To publish a revised schema, the author registers a new version (e.g. `v2`) — this creates a new independent record without altering or deleting the original. + +Deprecation is always **non-destructive**: + +- `get_schema` continues to return the full record with `deprecated = true`. +- `schema_exists` returns `true` for deprecated schemas. +- No on-chain data is deleted. + +Consumers are responsible for checking the `deprecated` flag and refusing to accept new credentials that reference a deprecated schema ID. + +## Error codes + +| Code | Variant | Meaning | +| ---- | ------------------- | ---------------------------------------------------- | +| 1 | `AlreadyInitialized` | `initialize` called more than once | +| 2 | `SchemaNotFound` | Schema ID not in registry | +| 3 | `SchemaAlreadyExists` | Same `(author, name, version)` already registered | +| 4 | `NotInitialized` | Contract not yet initialized | +| 5 | `AlreadyDeprecated` | Schema is already deprecated | +| 6 | `Unauthorized` | Caller is neither admin nor the schema author | + +## Events + +| Event | Emitted by | Fields | +| ------------------ | ------------------- | ----------------------------------------- | +| `Initialized` | `initialize` | `admin: Address` | +| `SchemaRegistered` | `register_schema` | `schema_id: BytesN<32>, author: Address` | +| `SchemaDeprecated` | `deprecate_schema` | `schema_id: BytesN<32>` | + +## Build & test + +```bash +# from repo root +cargo build -p vc-schema-registry-contract +cargo test -p vc-schema-registry-contract + +# WASM +stellar contract build +``` diff --git a/contracts/vc-schema-registry/src/contract.rs b/contracts/vc-schema-registry/src/contract.rs new file mode 100644 index 0000000..55ca524 --- /dev/null +++ b/contracts/vc-schema-registry/src/contract.rs @@ -0,0 +1,179 @@ +//! Contract entry points for vc-schema-registry. + +use crate::error::ContractError; +use crate::events; +use crate::storage::{self, SchemaRecord}; +use soroban_sdk::{ + contract, contractimpl, contractmeta, panic_with_error, + xdr::ToXdr, + Address, Bytes, BytesN, Env, Symbol, +}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +contractmeta!( + key = "Description", + val = "VC Schema Registry: on-chain schema registry for verifiable credentials", +); + +#[contract] +pub struct VcSchemaRegistryContract; + +#[contractimpl] +impl VcSchemaRegistryContract { + + // ----------------------------------------------------------------------- + // Initialization + // ----------------------------------------------------------------------- + + /// One-time initializer. Stores the admin address and emits `Initialized`. + /// Panics with `AlreadyInitialized` if called more than once. + pub fn initialize(e: Env, admin: Address) { + if storage::has_admin(&e) { + panic_with_error!(&e, ContractError::AlreadyInitialized); + } + admin.require_auth(); + storage::write_admin(&e, &admin); + storage::extend_instance_ttl(&e); + events::initialized(&e, &admin); + } + + // ----------------------------------------------------------------------- + // Schema management + // ----------------------------------------------------------------------- + + /// Register a new schema. `author` must authorize this call. + /// + /// The schema ID is derived deterministically as + /// `sha256(xdr(author) || xdr(name) || xdr(version))` and returned to the + /// caller. This ID is stable: the same `(author, name, version)` triple + /// always produces the same ID, on-chain and off-chain alike. + /// + /// Panics with `NotInitialized` if the contract has not been initialized. + /// Panics with `SchemaAlreadyExists` if the triple is already registered. + pub fn register_schema( + e: Env, + author: Address, + name: Symbol, + version: Symbol, + definition: Bytes, + ) -> BytesN<32> { + if !storage::has_admin(&e) { + panic_with_error!(&e, ContractError::NotInitialized); + } + author.require_auth(); + + let schema_id = compute_schema_id(&e, &author, &name, &version); + + if storage::has_schema(&e, &schema_id) { + panic_with_error!(&e, ContractError::SchemaAlreadyExists); + } + + let record = SchemaRecord { + author: author.clone(), + name, + version, + definition, + deprecated: false, + }; + storage::write_schema(&e, &schema_id, &record); + storage::extend_instance_ttl(&e); + events::schema_registered(&e, &schema_id, &author); + + schema_id + } + + /// Deprecate a registered schema. `caller` must be either the contract + /// admin or the author of that specific schema. + /// + /// Deprecation is **non-destructive**: the `SchemaRecord` remains on-chain + /// with `deprecated = true`. Downstream consumers should treat deprecated + /// schemas as invalid for new credential issuance while still being able + /// to verify credentials that reference them. + /// + /// Panics with `NotInitialized` if the contract has not been initialized. + /// Panics with `SchemaNotFound` if `schema_id` does not exist. + /// Panics with `Unauthorized` if `caller` is neither admin nor the schema author. + /// Panics with `AlreadyDeprecated` if the schema is already deprecated. + pub fn deprecate_schema(e: Env, schema_id: BytesN<32>, caller: Address) { + if !storage::has_admin(&e) { + panic_with_error!(&e, ContractError::NotInitialized); + } + caller.require_auth(); + + let mut record = storage::read_schema(&e, &schema_id) + .unwrap_or_else(|| panic_with_error!(&e, ContractError::SchemaNotFound)); + + let admin = storage::read_admin(&e); + if caller != admin && caller != record.author { + panic_with_error!(&e, ContractError::Unauthorized); + } + + if record.deprecated { + panic_with_error!(&e, ContractError::AlreadyDeprecated); + } + + record.deprecated = true; + storage::write_schema(&e, &schema_id, &record); + storage::extend_instance_ttl(&e); + events::schema_deprecated(&e, &schema_id); + } + + // ----------------------------------------------------------------------- + // Read-only queries + // ----------------------------------------------------------------------- + + /// Returns the full `SchemaRecord` for the given ID. + /// Panics with `SchemaNotFound` if the ID is not in the registry. + pub fn get_schema(e: Env, schema_id: BytesN<32>) -> SchemaRecord { + storage::extend_instance_ttl(&e); + storage::read_schema(&e, &schema_id) + .unwrap_or_else(|| panic_with_error!(&e, ContractError::SchemaNotFound)) + } + + /// Returns `true` if a schema with the given ID exists in the registry + /// (regardless of its `deprecated` flag). + pub fn schema_exists(e: Env, schema_id: BytesN<32>) -> bool { + storage::extend_instance_ttl(&e); + storage::has_schema(&e, &schema_id) + } + + /// Computes and returns the schema ID for a given `(author, name, version)` + /// triple without writing to storage. Useful for off-chain pre-computation + /// or UI tools that need to predict the ID before submitting a transaction. + pub fn schema_id(e: Env, author: Address, name: Symbol, version: Symbol) -> BytesN<32> { + compute_schema_id(&e, &author, &name, &version) + } + + /// Returns the current admin address. + /// Panics with `NotInitialized` if the contract has not been initialized. + pub fn admin(e: Env) -> Address { + if !storage::has_admin(&e) { + panic_with_error!(&e, ContractError::NotInitialized); + } + storage::extend_instance_ttl(&e); + storage::read_admin(&e) + } + + /// Returns the contract version string (taken from `Cargo.toml` at compile time). + pub fn version(e: Env) -> soroban_sdk::String { + soroban_sdk::String::from_str(&e, VERSION) + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Computes `sha256(xdr(author) || xdr(name) || xdr(version))`. +/// +/// XDR encoding is used for each component so the preimage is unambiguous and +/// matches what off-chain tooling produces when serializing the same `ScVal` +/// representations of each field. +fn compute_schema_id(e: &Env, author: &Address, name: &Symbol, version: &Symbol) -> BytesN<32> { + let mut preimage = Bytes::new(e); + preimage.append(&author.clone().to_xdr(e)); + preimage.append(&name.clone().to_xdr(e)); + preimage.append(&version.clone().to_xdr(e)); + e.crypto().sha256(&preimage).to_bytes() +} diff --git a/contracts/vc-schema-registry/src/error.rs b/contracts/vc-schema-registry/src/error.rs new file mode 100644 index 0000000..7db0fe8 --- /dev/null +++ b/contracts/vc-schema-registry/src/error.rs @@ -0,0 +1,21 @@ +//! Contract error codes. Exposed as `Error(Contract, #code)` by Soroban. + +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + /// `initialize` has already been called. + AlreadyInitialized = 1, + /// Schema ID not found in the registry. + SchemaNotFound = 2, + /// A schema with the same `(author, name, version)` triple already exists. + SchemaAlreadyExists = 3, + /// Contract has not been initialized yet. + NotInitialized = 4, + /// Schema is already deprecated; cannot deprecate again. + AlreadyDeprecated = 5, + /// Caller is neither the contract admin nor the schema author. + Unauthorized = 6, +} diff --git a/contracts/vc-schema-registry/src/events.rs b/contracts/vc-schema-registry/src/events.rs new file mode 100644 index 0000000..806fbae --- /dev/null +++ b/contracts/vc-schema-registry/src/events.rs @@ -0,0 +1,41 @@ +//! Contract events. Published on key state transitions for on-chain observability. + +use soroban_sdk::{contractevent, Address, BytesN, Env}; + +#[contractevent] +pub struct Initialized { + pub admin: Address, +} + +#[contractevent] +pub struct SchemaRegistered { + pub schema_id: BytesN<32>, + pub author: Address, +} + +#[contractevent] +pub struct SchemaDeprecated { + pub schema_id: BytesN<32>, +} + +pub fn initialized(e: &Env, admin: &Address) { + Initialized { + admin: admin.clone(), + } + .publish(e); +} + +pub fn schema_registered(e: &Env, schema_id: &BytesN<32>, author: &Address) { + SchemaRegistered { + schema_id: schema_id.clone(), + author: author.clone(), + } + .publish(e); +} + +pub fn schema_deprecated(e: &Env, schema_id: &BytesN<32>) { + SchemaDeprecated { + schema_id: schema_id.clone(), + } + .publish(e); +} diff --git a/contracts/vc-schema-registry/src/lib.rs b/contracts/vc-schema-registry/src/lib.rs new file mode 100644 index 0000000..ba59b64 --- /dev/null +++ b/contracts/vc-schema-registry/src/lib.rs @@ -0,0 +1,17 @@ +//! VC Schema Registry Contract +//! +//! Soroban contract for on-chain VC schema governance and discovery. +//! Authors register versioned schema definitions; the contract assigns each +//! schema a deterministic ID derived from `sha256(xdr(author) || xdr(name) || xdr(version))`. +//! Deprecation is non-destructive: deprecated schemas remain readable but are +//! flagged so downstream consumers can reject them. + +#![no_std] + +pub mod contract; +pub mod error; +pub mod events; +pub mod storage; + +#[cfg(test)] +mod test; diff --git a/contracts/vc-schema-registry/src/storage.rs b/contracts/vc-schema-registry/src/storage.rs new file mode 100644 index 0000000..3bd8f19 --- /dev/null +++ b/contracts/vc-schema-registry/src/storage.rs @@ -0,0 +1,81 @@ +//! Storage layout and helpers. +//! Instance storage → admin (global config, low-frequency reads). +//! Persistent storage → per-schema records (long-lived, keyed by BytesN<32>). + +use soroban_sdk::{contracttype, Address, Bytes, BytesN, Env, Symbol}; + +// TTL constants (~5 s ledger close): 518_400 ≈ 30 days, 3_110_400 ≈ 180 days. +const INSTANCE_TTL_THRESHOLD: u32 = 518_400; +const INSTANCE_TTL_EXTEND_TO: u32 = 3_110_400; +const PERSISTENT_TTL_THRESHOLD: u32 = 518_400; +const PERSISTENT_TTL_EXTEND_TO: u32 = 3_110_400; + +/// Storage keys separated by role (explicit role isolation). +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + /// Global admin (singleton, instance storage). + Admin, + /// Schema record (per-schema-id, persistent storage). + Schema(BytesN<32>), +} + +/// On-chain record for a registered VC schema. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SchemaRecord { + /// Address that registered the schema and authorized it. + pub author: Address, + /// Human-readable schema name. + pub name: Symbol, + /// Version string for this schema. + pub version: Symbol, + /// Raw schema definition bytes (e.g. UTF-8 JSON Schema). + pub definition: Bytes, + /// Whether this schema has been deprecated. Non-destructive: record remains on-chain. + pub deprecated: bool, +} + +// --- Admin (instance) --- + +pub fn has_admin(e: &Env) -> bool { + e.storage().instance().has(&DataKey::Admin) +} + +pub fn read_admin(e: &Env) -> Address { + e.storage().instance().get(&DataKey::Admin).unwrap() +} + +pub fn write_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&DataKey::Admin, admin); +} + +// --- Schema records (persistent) --- + +pub fn has_schema(e: &Env, schema_id: &BytesN<32>) -> bool { + e.storage() + .persistent() + .has(&DataKey::Schema(schema_id.clone())) +} + +pub fn read_schema(e: &Env, schema_id: &BytesN<32>) -> Option { + e.storage() + .persistent() + .get(&DataKey::Schema(schema_id.clone())) +} + +pub fn write_schema(e: &Env, schema_id: &BytesN<32>, record: &SchemaRecord) { + let key = DataKey::Schema(schema_id.clone()); + e.storage().persistent().set(&key, record); + e.storage() + .persistent() + .extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); +} + +// --- TTL helpers --- + +pub fn extend_instance_ttl(e: &Env) { + e.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); +} diff --git a/contracts/vc-schema-registry/src/test.rs b/contracts/vc-schema-registry/src/test.rs new file mode 100644 index 0000000..abc3a69 --- /dev/null +++ b/contracts/vc-schema-registry/src/test.rs @@ -0,0 +1,325 @@ +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env, Symbol}; + +use crate::contract::{VcSchemaRegistryContract, VcSchemaRegistryContractClient}; + +fn setup() -> (Env, VcSchemaRegistryContractClient<'static>) { + let e = Env::default(); + e.mock_all_auths(); + let contract_id = e.register(VcSchemaRegistryContract, ()); + let client = VcSchemaRegistryContractClient::new(&e, &contract_id); + (e, client) +} + +fn sample_schema(e: &Env) -> (Address, Symbol, Symbol, Bytes) { + let author = Address::generate(e); + let name = Symbol::new(e, "IdentitySchema"); + let version = Symbol::new(e, "v1"); + let definition = Bytes::from_slice(e, b"{\"type\":\"object\"}"); + (author, name, version, definition) +} + +// --------------------------------------------------------------------------- +// test_initialize_sets_admin +// --------------------------------------------------------------------------- +#[test] +fn test_initialize_sets_admin() { + let (e, client) = setup(); + let admin = Address::generate(&e); + + client.initialize(&admin); + + assert_eq!(client.admin(), admin); +} + +// --------------------------------------------------------------------------- +// test_initialize_only_once +// --------------------------------------------------------------------------- +#[test] +#[should_panic] +fn test_initialize_only_once() { + let (e, client) = setup(); + let admin = Address::generate(&e); + + client.initialize(&admin); + client.initialize(&admin); // must panic with AlreadyInitialized +} + +// --------------------------------------------------------------------------- +// test_register_schema_happy_path +// — register_schema returns a non-zero ID, stores the record with +// deprecated=false, and schema_exists returns true. +// --------------------------------------------------------------------------- +#[test] +fn test_register_schema_happy_path() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let (author, name, version, definition) = sample_schema(&e); + let schema_id = client.register_schema(&author, &name, &version, &definition); + + assert!(client.schema_exists(&schema_id)); + + let record = client.get_schema(&schema_id); + assert_eq!(record.author, author); + assert_eq!(record.name, name); + assert_eq!(record.version, version); + assert_eq!(record.definition, definition); + assert!(!record.deprecated); +} + +// --------------------------------------------------------------------------- +// test_register_schema_id_is_deterministic +// — Calling schema_id() with the same inputs returns the same value as +// the ID returned by register_schema. +// --------------------------------------------------------------------------- +#[test] +fn test_register_schema_id_is_deterministic() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let (author, name, version, definition) = sample_schema(&e); + let registered_id = client.register_schema(&author, &name, &version, &definition); + let computed_id = client.schema_id(&author, &name, &version); + + assert_eq!(registered_id, computed_id); +} + +// --------------------------------------------------------------------------- +// test_register_schema_duplicate_rejected +// — Registering the same (author, name, version) triple twice must panic. +// --------------------------------------------------------------------------- +#[test] +#[should_panic] +fn test_register_schema_duplicate_rejected() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let (author, name, version, definition) = sample_schema(&e); + client.register_schema(&author, &name, &version, &definition); + client.register_schema(&author, &name, &version, &definition); // must panic +} + +// --------------------------------------------------------------------------- +// test_different_versions_are_independent +// — Same author and name but different versions produce distinct IDs and +// can both be registered. +// --------------------------------------------------------------------------- +#[test] +fn test_different_versions_are_independent() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let author = Address::generate(&e); + let name = Symbol::new(&e, "MySchema"); + let def = Bytes::from_slice(&e, b"{}"); + + let id_v1 = client.register_schema(&author, &name, &Symbol::new(&e, "v1"), &def); + let id_v2 = client.register_schema(&author, &name, &Symbol::new(&e, "v2"), &def); + + assert_ne!(id_v1, id_v2); + assert!(client.schema_exists(&id_v1)); + assert!(client.schema_exists(&id_v2)); +} + +// --------------------------------------------------------------------------- +// test_deprecate_schema_by_admin +// — Admin can deprecate any schema; get_schema reflects deprecated=true. +// --------------------------------------------------------------------------- +#[test] +fn test_deprecate_schema_by_admin() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let (author, name, version, definition) = sample_schema(&e); + let schema_id = client.register_schema(&author, &name, &version, &definition); + + client.deprecate_schema(&schema_id, &admin); + + let record = client.get_schema(&schema_id); + assert!(record.deprecated); +} + +// --------------------------------------------------------------------------- +// test_deprecate_schema_by_author +// — The schema author can deprecate their own schema. +// --------------------------------------------------------------------------- +#[test] +fn test_deprecate_schema_by_author() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let (author, name, version, definition) = sample_schema(&e); + let schema_id = client.register_schema(&author, &name, &version, &definition); + + client.deprecate_schema(&schema_id, &author); + + let record = client.get_schema(&schema_id); + assert!(record.deprecated); +} + +// --------------------------------------------------------------------------- +// test_deprecate_schema_unauthorized +// — A random address that is neither admin nor author must be rejected. +// --------------------------------------------------------------------------- +#[test] +#[should_panic] +fn test_deprecate_schema_unauthorized() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let (author, name, version, definition) = sample_schema(&e); + let schema_id = client.register_schema(&author, &name, &version, &definition); + + let stranger = Address::generate(&e); + client.deprecate_schema(&schema_id, &stranger); // must panic with Unauthorized +} + +// --------------------------------------------------------------------------- +// test_deprecate_schema_already_deprecated +// — Calling deprecate_schema a second time must panic with AlreadyDeprecated. +// --------------------------------------------------------------------------- +#[test] +#[should_panic] +fn test_deprecate_schema_already_deprecated() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let (author, name, version, definition) = sample_schema(&e); + let schema_id = client.register_schema(&author, &name, &version, &definition); + + client.deprecate_schema(&schema_id, &admin); + client.deprecate_schema(&schema_id, &admin); // must panic +} + +// --------------------------------------------------------------------------- +// test_deprecate_schema_not_found +// — Deprecating a non-existent schema must panic with SchemaNotFound. +// --------------------------------------------------------------------------- +#[test] +#[should_panic] +fn test_deprecate_schema_not_found() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let fake_id: BytesN<32> = BytesN::from_array(&e, &[0u8; 32]); + client.deprecate_schema(&fake_id, &admin); // must panic +} + +// --------------------------------------------------------------------------- +// test_schema_exists_returns_false_for_unknown +// --------------------------------------------------------------------------- +#[test] +fn test_schema_exists_returns_false_for_unknown() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let unknown: BytesN<32> = BytesN::from_array(&e, &[1u8; 32]); + assert!(!client.schema_exists(&unknown)); +} + +// --------------------------------------------------------------------------- +// test_get_schema_not_found_panics +// --------------------------------------------------------------------------- +#[test] +#[should_panic] +fn test_get_schema_not_found_panics() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let fake: BytesN<32> = BytesN::from_array(&e, &[2u8; 32]); + client.get_schema(&fake); // must panic +} + +// --------------------------------------------------------------------------- +// test_calls_fail_before_initialize +// — register_schema before initialize must panic with NotInitialized. +// --------------------------------------------------------------------------- +#[test] +#[should_panic] +fn test_register_before_initialize_panics() { + let (e, client) = setup(); + let (author, name, version, definition) = sample_schema(&e); + client.register_schema(&author, &name, &version, &definition); // must panic +} + +// --------------------------------------------------------------------------- +// test_non_author_cannot_register_for_another +// — When mocks are cleared, a caller that is not `author` must be rejected +// by the host auth system. +// --------------------------------------------------------------------------- +#[test] +fn test_non_author_cannot_register_for_another() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let author = Address::generate(&e); + let name = Symbol::new(&e, "Schema"); + let version = Symbol::new(&e, "v1"); + let definition = Bytes::from_slice(&e, b"{}"); + + // Clear mocks so no valid auth is provided for the author address. + e.mock_auths(&[]); + let result = client.try_register_schema(&author, &name, &version, &definition); + assert!(result.is_err(), "non-author call must fail auth"); +} + +// --------------------------------------------------------------------------- +// test_schema_id_differs_across_authors +// — Two different authors registering the same name/version get different IDs. +// --------------------------------------------------------------------------- +#[test] +fn test_schema_id_differs_across_authors() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let name = Symbol::new(&e, "Schema"); + let version = Symbol::new(&e, "v1"); + + let author_a = Address::generate(&e); + let author_b = Address::generate(&e); + + let id_a = client.schema_id(&author_a, &name, &version); + let id_b = client.schema_id(&author_b, &name, &version); + + assert_ne!(id_a, id_b); +} + +// --------------------------------------------------------------------------- +// test_deprecation_preserves_record +// — After deprecation get_schema still returns the full record. +// --------------------------------------------------------------------------- +#[test] +fn test_deprecation_preserves_record() { + let (e, client) = setup(); + let admin = Address::generate(&e); + client.initialize(&admin); + + let (author, name, version, definition) = sample_schema(&e); + let schema_id = client.register_schema(&author, &name, &version, &definition); + + client.deprecate_schema(&schema_id, &admin); + + let record = client.get_schema(&schema_id); + assert_eq!(record.author, author); + assert_eq!(record.definition, definition); + assert!(record.deprecated, "deprecated flag must be set"); + // schema_exists returns true even for deprecated schemas + assert!(client.schema_exists(&schema_id)); +} From 9d5ff9ed4af96a751796b1f01dc44310af9f2ee3 Mon Sep 17 00:00:00 2001 From: OlaGreat Date: Wed, 24 Jun 2026 09:55:43 +0100 Subject: [PATCH 2/2] docs(vc-schema-registry): add text language tag to schema_id code fence --- contracts/vc-schema-registry/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/vc-schema-registry/README.md b/contracts/vc-schema-registry/README.md index f12d1c2..da14754 100644 --- a/contracts/vc-schema-registry/README.md +++ b/contracts/vc-schema-registry/README.md @@ -50,7 +50,7 @@ pub struct SchemaRecord { A schema ID is the SHA-256 hash of the XDR-encoded author address, name, and version concatenated in order: -``` +```text schema_id = sha256( xdr(author) || xdr(name) || xdr(version) ) ```