diff --git a/README.md b/README.md index df3c788..e0febc0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,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/README.md b/contracts/vc-schema-registry/README.md new file mode 100644 index 0000000..da14754 --- /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: + +```text +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 index f0b31b3..55ca524 100644 --- a/contracts/vc-schema-registry/src/contract.rs +++ b/contracts/vc-schema-registry/src/contract.rs @@ -3,16 +3,17 @@ use crate::error::ContractError; use crate::events; use crate::storage::{self, SchemaRecord}; -use soroban_sdk::{contract, contractimpl, contractmeta, panic_with_error, Address, Bytes, Env}; +use soroban_sdk::{ + contract, contractimpl, contractmeta, panic_with_error, + xdr::ToXdr, + Address, Bytes, BytesN, Env, Symbol, +}; const VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Maximum allowed byte length for the `uri` field. -const MAX_URI_BYTES: u32 = 256; - contractmeta!( key = "Description", - val = "VC Schema Registry: on-chain registration and governance of VC schemas", + val = "VC Schema Registry: on-chain schema registry for verifiable credentials", ); #[contract] @@ -25,7 +26,8 @@ impl VcSchemaRegistryContract { // Initialization // ----------------------------------------------------------------------- - /// One-time initializer. Stores the admin address. Panics if already called. + /// 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); @@ -40,71 +42,111 @@ impl VcSchemaRegistryContract { // Schema management // ----------------------------------------------------------------------- - /// Register a new schema. Caller must be the declared `author`. - /// Fails if `id` is already registered, or `uri` exceeds [`MAX_URI_BYTES`]. - pub fn register_schema(e: Env, id: Bytes, author: Address, uri: Bytes) { + /// 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(); - if storage::has_schema(&e, &id) { + + let schema_id = compute_schema_id(&e, &author, &name, &version); + + if storage::has_schema(&e, &schema_id) { panic_with_error!(&e, ContractError::SchemaAlreadyExists); } - validate_uri(&e, &uri); + let record = SchemaRecord { author: author.clone(), - uri, + name, + version, + definition, deprecated: false, }; - storage::write_schema(&e, &id, &record); + storage::write_schema(&e, &schema_id, &record); storage::extend_instance_ttl(&e); - events::schema_registered(&e, &id, &author); - } + events::schema_registered(&e, &schema_id, &author); - /// Mark a schema as deprecated. Callable by the contract admin or the - /// schema's original author; all other callers fail authorization. - pub fn deprecate_schema(e: Env, id: Bytes, caller: Address) { - caller.require_auth(); - let mut record = storage::read_schema(&e, &id) - .unwrap_or_else(|| panic_with_error!(&e, ContractError::SchemaNotFound)); - require_admin_or_author(&e, &caller, &record); - record.deprecated = true; - storage::write_schema(&e, &id, &record); - storage::extend_instance_ttl(&e); - events::schema_deprecated(&e, &id); + schema_id } - /// Update the URI for an existing schema. Only the original author may - /// call this. Fails if `uri` exceeds [`MAX_URI_BYTES`]. - pub fn update_schema_uri(e: Env, id: Bytes, caller: Address, uri: Bytes) { + /// 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, &id) + + let mut record = storage::read_schema(&e, &schema_id) .unwrap_or_else(|| panic_with_error!(&e, ContractError::SchemaNotFound)); - if record.author != caller { - panic_with_error!(&e, ContractError::NotAuthorized); + + let admin = storage::read_admin(&e); + if caller != admin && caller != record.author { + panic_with_error!(&e, ContractError::Unauthorized); } - validate_uri(&e, &uri); - record.uri = uri; - storage::write_schema(&e, &id, &record); + + 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_uri_updated(&e, &id); + events::schema_deprecated(&e, &schema_id); } // ----------------------------------------------------------------------- // Read-only queries // ----------------------------------------------------------------------- - /// Returns the full record for a schema, or panics with SchemaNotFound. - pub fn get_schema(e: Env, id: Bytes) -> SchemaRecord { + /// 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, &id) + storage::read_schema(&e, &schema_id) .unwrap_or_else(|| panic_with_error!(&e, ContractError::SchemaNotFound)) } - /// Returns true if a schema with `id` is registered. - pub fn schema_exists(e: Env, id: Bytes) -> bool { + /// 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, &id) + storage::has_schema(&e, &schema_id) } - /// Returns the current admin address. Panics with NotInitialized if not set. + /// 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); @@ -113,7 +155,7 @@ impl VcSchemaRegistryContract { storage::read_admin(&e) } - /// Returns the contract version string. + /// 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) } @@ -123,19 +165,15 @@ impl VcSchemaRegistryContract { // Internal helpers // --------------------------------------------------------------------------- -/// Panics with `NotAuthorized` unless `caller` is the stored admin or the -/// schema's author. Tolerates an uninitialized admin (author-only check). -fn require_admin_or_author(e: &Env, caller: &Address, record: &SchemaRecord) { - let is_author = record.author == *caller; - let is_admin = storage::has_admin(e) && storage::read_admin(e) == *caller; - if !is_author && !is_admin { - panic_with_error!(e, ContractError::NotAuthorized); - } -} - -/// Validates `uri` size. Panics with `InvalidUri` if it exceeds [`MAX_URI_BYTES`]. -fn validate_uri(e: &Env, uri: &Bytes) { - if uri.len() > MAX_URI_BYTES { - panic_with_error!(e, ContractError::InvalidUri); - } +/// 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 index cafa9a8..7db0fe8 100644 --- a/contracts/vc-schema-registry/src/error.rs +++ b/contracts/vc-schema-registry/src/error.rs @@ -6,16 +6,16 @@ use soroban_sdk::contracterror; #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] pub enum ContractError { - /// initialize() has already been called. + /// `initialize` has already been called. AlreadyInitialized = 1, - /// Schema id not found in the registry. + /// Schema ID not found in the registry. SchemaNotFound = 2, - /// Schema id already registered. + /// A schema with the same `(author, name, version)` triple already exists. SchemaAlreadyExists = 3, /// Contract has not been initialized yet. NotInitialized = 4, - /// `uri` exceeds the maximum allowed size. - InvalidUri = 5, - /// Caller is neither the admin nor the schema's author. - NotAuthorized = 6, + /// 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 index cd41976..806fbae 100644 --- a/contracts/vc-schema-registry/src/events.rs +++ b/contracts/vc-schema-registry/src/events.rs @@ -1,6 +1,6 @@ //! Contract events. Published on key state transitions for on-chain observability. -use soroban_sdk::{contractevent, Address, Bytes, Env}; +use soroban_sdk::{contractevent, Address, BytesN, Env}; #[contractevent] pub struct Initialized { @@ -9,18 +9,13 @@ pub struct Initialized { #[contractevent] pub struct SchemaRegistered { - pub id: Bytes, + pub schema_id: BytesN<32>, pub author: Address, } #[contractevent] pub struct SchemaDeprecated { - pub id: Bytes, -} - -#[contractevent] -pub struct SchemaUriUpdated { - pub id: Bytes, + pub schema_id: BytesN<32>, } pub fn initialized(e: &Env, admin: &Address) { @@ -30,18 +25,17 @@ pub fn initialized(e: &Env, admin: &Address) { .publish(e); } -pub fn schema_registered(e: &Env, id: &Bytes, author: &Address) { +pub fn schema_registered(e: &Env, schema_id: &BytesN<32>, author: &Address) { SchemaRegistered { - id: id.clone(), + schema_id: schema_id.clone(), author: author.clone(), } .publish(e); } -pub fn schema_deprecated(e: &Env, id: &Bytes) { - SchemaDeprecated { id: id.clone() }.publish(e); -} - -pub fn schema_uri_updated(e: &Env, id: &Bytes) { - SchemaUriUpdated { id: id.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 index c2b706e..ba59b64 100644 --- a/contracts/vc-schema-registry/src/lib.rs +++ b/contracts/vc-schema-registry/src/lib.rs @@ -1,8 +1,10 @@ //! VC Schema Registry Contract //! -//! Soroban contract for on-chain registration and governance of verifiable -//! credential schemas. Authors register schemas by id with a URI pointing to -//! the schema document; admins or the original author may deprecate a schema. +//! 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] diff --git a/contracts/vc-schema-registry/src/storage.rs b/contracts/vc-schema-registry/src/storage.rs index 4547276..3bd8f19 100644 --- a/contracts/vc-schema-registry/src/storage.rs +++ b/contracts/vc-schema-registry/src/storage.rs @@ -1,8 +1,8 @@ //! Storage layout and helpers. //! Instance storage → admin (global config, low-frequency reads). -//! Persistent storage → per-schema records (long-lived, keyed by schema id). +//! Persistent storage → per-schema records (long-lived, keyed by BytesN<32>). -use soroban_sdk::{contracttype, Address, Bytes, Env}; +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; @@ -14,22 +14,25 @@ const PERSISTENT_TTL_EXTEND_TO: u32 = 3_110_400; #[derive(Clone)] #[contracttype] pub enum DataKey { - /// Global admin (singleton, instance storage) + /// Global admin (singleton, instance storage). Admin, - - /// Schema registry (per-id persistent storage) - Schema(Bytes), + /// 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 may update/deprecate it). + /// Address that registered the schema and authorized it. pub author: Address, - /// URI pointing to the schema document. - pub uri: Bytes, - /// Whether this schema has been deprecated. + /// 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, } @@ -49,16 +52,20 @@ pub fn write_admin(e: &Env, admin: &Address) { // --- Schema records (persistent) --- -pub fn has_schema(e: &Env, id: &Bytes) -> bool { - e.storage().persistent().has(&DataKey::Schema(id.clone())) +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, id: &Bytes) -> Option { - e.storage().persistent().get(&DataKey::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, id: &Bytes, record: &SchemaRecord) { - let key = DataKey::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() diff --git a/contracts/vc-schema-registry/src/test.rs b/contracts/vc-schema-registry/src/test.rs index 60a469a..abc3a69 100644 --- a/contracts/vc-schema-registry/src/test.rs +++ b/contracts/vc-schema-registry/src/test.rs @@ -2,27 +2,10 @@ extern crate std; -use soroban_sdk::{ - testutils::{Address as _, Events as _}, - Address, Bytes, Env, Symbol, TryFromVal, -}; +use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env, Symbol}; use crate::contract::{VcSchemaRegistryContract, VcSchemaRegistryContractClient}; -/// Returns true if the most recent contract call published an event whose -/// first topic is `topic`. `e.events().all()` only reflects the latest -/// top-level invocation, not a cumulative log across calls. -fn last_call_emitted(e: &Env, topic: &str) -> bool { - let expected = Symbol::new(e, topic); - e.events().all().iter().any(|(_, topics, _)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(e, &t).ok()) - .map(|s| s == expected) - .unwrap_or(false) - }) -} - fn setup() -> (Env, VcSchemaRegistryContractClient<'static>) { let e = Env::default(); e.mock_all_auths(); @@ -31,6 +14,14 @@ fn setup() -> (Env, VcSchemaRegistryContractClient<'static>) { (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 // --------------------------------------------------------------------------- @@ -59,230 +50,185 @@ fn test_initialize_only_once() { // --------------------------------------------------------------------------- // test_register_schema_happy_path -// — Caller is the declared author; record is stored and queryable. +// — 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); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/schema-1.json"); - client.initialize(&admin); - client.register_schema(&id, &author, &uri); - assert!(client.schema_exists(&id)); + 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(&id); + let record = client.get_schema(&schema_id); assert_eq!(record.author, author); - assert_eq!(record.uri, uri); + assert_eq!(record.name, name); + assert_eq!(record.version, version); + assert_eq!(record.definition, definition); assert!(!record.deprecated); } // --------------------------------------------------------------------------- -// test_register_schema_duplicate_rejected +// 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] -#[should_panic] -fn test_register_schema_duplicate_rejected() { +fn test_register_schema_id_is_deterministic() { let (e, client) = setup(); let admin = Address::generate(&e); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/schema-1.json"); - client.initialize(&admin); - client.register_schema(&id, &author, &uri); - client.register_schema(&id, &author, &uri); // must panic with SchemaAlreadyExists -} -// --------------------------------------------------------------------------- -// test_register_schema_uri_too_long_rejected -// --------------------------------------------------------------------------- -#[test] -#[should_panic] -fn test_register_schema_uri_too_long_rejected() { - let (e, client) = setup(); - let admin = Address::generate(&e); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let long_uri = Bytes::from_slice(&e, &[b'x'; 257]); + 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); - client.initialize(&admin); - client.register_schema(&id, &author, &long_uri); // must panic with InvalidUri + assert_eq!(registered_id, computed_id); } // --------------------------------------------------------------------------- -// test_register_schema_uri_boundary_ok -// — A 256-byte uri (the max) must be accepted. +// test_register_schema_duplicate_rejected +// — Registering the same (author, name, version) triple twice must panic. // --------------------------------------------------------------------------- #[test] -fn test_register_schema_uri_boundary_ok() { +#[should_panic] +fn test_register_schema_duplicate_rejected() { let (e, client) = setup(); let admin = Address::generate(&e); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let max_uri = Bytes::from_slice(&e, &[b'x'; 256]); - client.initialize(&admin); - client.register_schema(&id, &author, &max_uri); - assert!(client.schema_exists(&id)); + 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_register_schema_requires_author_auth -// — A caller other than the declared author must be rejected. +// test_different_versions_are_independent +// — Same author and name but different versions produce distinct IDs and +// can both be registered. // --------------------------------------------------------------------------- #[test] -fn test_register_schema_requires_author_auth() { +fn test_different_versions_are_independent() { let (e, client) = setup(); let admin = Address::generate(&e); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/schema-1.json"); - client.initialize(&admin); - // Clear mocks so no valid auth is provided for register_schema. - e.mock_auths(&[]); - let result = client.try_register_schema(&id, &author, &uri); + 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!(result.is_err(), "register_schema without author auth must fail"); + 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); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/schema-1.json"); - client.initialize(&admin); - client.register_schema(&id, &author, &uri); - client.deprecate_schema(&id, &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(&id); + 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); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/schema-1.json"); - client.initialize(&admin); - client.register_schema(&id, &author, &uri); - client.deprecate_schema(&id, &author); + 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(&id); + let record = client.get_schema(&schema_id); assert!(record.deprecated); } // --------------------------------------------------------------------------- -// test_deprecate_schema_non_admin_non_author_fails +// test_deprecate_schema_unauthorized +// — A random address that is neither admin nor author must be rejected. // --------------------------------------------------------------------------- #[test] #[should_panic] -fn test_deprecate_schema_non_admin_non_author_fails() { +fn test_deprecate_schema_unauthorized() { let (e, client) = setup(); let admin = Address::generate(&e); - let author = Address::generate(&e); - let stranger = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/schema-1.json"); - client.initialize(&admin); - client.register_schema(&id, &author, &uri); - client.deprecate_schema(&id, &stranger); // must panic with NotAuthorized -} + let (author, name, version, definition) = sample_schema(&e); + let schema_id = client.register_schema(&author, &name, &version, &definition); -// --------------------------------------------------------------------------- -// test_deprecate_schema_not_found -// --------------------------------------------------------------------------- -#[test] -#[should_panic] -fn test_deprecate_schema_not_found() { - let (e, client) = setup(); - let admin = Address::generate(&e); - let id = Bytes::from_slice(&e, b"missing"); - - client.initialize(&admin); - client.deprecate_schema(&id, &admin); // must panic with SchemaNotFound + let stranger = Address::generate(&e); + client.deprecate_schema(&schema_id, &stranger); // must panic with Unauthorized } // --------------------------------------------------------------------------- -// test_update_schema_uri_by_author +// test_deprecate_schema_already_deprecated +// — Calling deprecate_schema a second time must panic with AlreadyDeprecated. // --------------------------------------------------------------------------- #[test] -fn test_update_schema_uri_by_author() { +#[should_panic] +fn test_deprecate_schema_already_deprecated() { let (e, client) = setup(); let admin = Address::generate(&e); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri_v1 = Bytes::from_slice(&e, b"https://example.com/v1.json"); - let uri_v2 = Bytes::from_slice(&e, b"https://example.com/v2.json"); - client.initialize(&admin); - client.register_schema(&id, &author, &uri_v1); - client.update_schema_uri(&id, &author, &uri_v2); + let (author, name, version, definition) = sample_schema(&e); + let schema_id = client.register_schema(&author, &name, &version, &definition); - let record = client.get_schema(&id); - assert_eq!(record.uri, uri_v2); + client.deprecate_schema(&schema_id, &admin); + client.deprecate_schema(&schema_id, &admin); // must panic } // --------------------------------------------------------------------------- -// test_update_schema_uri_non_author_fails -// — Even the admin cannot update the uri; only the author may. +// test_deprecate_schema_not_found +// — Deprecating a non-existent schema must panic with SchemaNotFound. // --------------------------------------------------------------------------- #[test] #[should_panic] -fn test_update_schema_uri_non_author_fails() { +fn test_deprecate_schema_not_found() { let (e, client) = setup(); let admin = Address::generate(&e); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/v1.json"); - let new_uri = Bytes::from_slice(&e, b"https://example.com/v2.json"); - client.initialize(&admin); - client.register_schema(&id, &author, &uri); - client.update_schema_uri(&id, &admin, &new_uri); // must panic with NotAuthorized + let fake_id: BytesN<32> = BytesN::from_array(&e, &[0u8; 32]); + client.deprecate_schema(&fake_id, &admin); // must panic } // --------------------------------------------------------------------------- -// test_update_schema_uri_too_long_rejected +// test_schema_exists_returns_false_for_unknown // --------------------------------------------------------------------------- #[test] -#[should_panic] -fn test_update_schema_uri_too_long_rejected() { +fn test_schema_exists_returns_false_for_unknown() { let (e, client) = setup(); let admin = Address::generate(&e); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/v1.json"); - let long_uri = Bytes::from_slice(&e, &[b'x'; 257]); - client.initialize(&admin); - client.register_schema(&id, &author, &uri); - client.update_schema_uri(&id, &author, &long_uri); // must panic with InvalidUri + let unknown: BytesN<32> = BytesN::from_array(&e, &[1u8; 32]); + assert!(!client.schema_exists(&unknown)); } // --------------------------------------------------------------------------- @@ -293,126 +239,87 @@ fn test_update_schema_uri_too_long_rejected() { fn test_get_schema_not_found_panics() { let (e, client) = setup(); let admin = Address::generate(&e); - let id = Bytes::from_slice(&e, b"missing"); - client.initialize(&admin); - client.get_schema(&id); // must panic with SchemaNotFound -} -// --------------------------------------------------------------------------- -// test_schema_exists_reflects_lifecycle -// — schema_exists is true once registered, and stays true after -// deprecation (deprecated schemas remain queryable, not deleted). -// --------------------------------------------------------------------------- -#[test] -fn test_schema_exists_reflects_lifecycle() { - let (e, client) = setup(); - let admin = Address::generate(&e); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/schema-1.json"); - let unknown = Bytes::from_slice(&e, b"unknown"); - - client.initialize(&admin); - assert!(!client.schema_exists(&id)); - - client.register_schema(&id, &author, &uri); - assert!(client.schema_exists(&id)); - - client.deprecate_schema(&id, &admin); - assert!(client.schema_exists(&id)); - assert!(client.get_schema(&id).deprecated); - - assert!(!client.schema_exists(&unknown)); + let fake: BytesN<32> = BytesN::from_array(&e, &[2u8; 32]); + client.get_schema(&fake); // must panic } // --------------------------------------------------------------------------- -// test_admin_not_initialized_panics +// test_calls_fail_before_initialize +// — register_schema before initialize must panic with NotInitialized. // --------------------------------------------------------------------------- #[test] #[should_panic] -fn test_admin_not_initialized_panics() { - let (_e, client) = setup(); - client.admin(); // must panic with NotInitialized -} - -// --------------------------------------------------------------------------- -// test_version_returns_value -// --------------------------------------------------------------------------- -#[test] -fn test_version_returns_value() { - let (_e, client) = setup(); - let version = client.version(); - assert!(!version.is_empty(), "version() must return a non-empty string"); +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 } // --------------------------------------------------------------------------- -// Event assertions +// 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_initialize_emits_event() { +fn test_non_author_cannot_register_for_another() { let (e, client) = setup(); let admin = Address::generate(&e); - client.initialize(&admin); - assert!(last_call_emitted(&e, "initialized"), "initialize() must emit an event"); -} - -#[test] -fn test_register_schema_emits_event() { - let (e, client) = setup(); - let admin = Address::generate(&e); let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/schema-1.json"); - - client.initialize(&admin); - - client.register_schema(&id, &author, &uri); + let name = Symbol::new(&e, "Schema"); + let version = Symbol::new(&e, "v1"); + let definition = Bytes::from_slice(&e, b"{}"); - assert!( - last_call_emitted(&e, "schema_registered"), - "register_schema() must emit an event" - ); + // 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_deprecate_schema_emits_event() { +fn test_schema_id_differs_across_authors() { let (e, client) = setup(); let admin = Address::generate(&e); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/schema-1.json"); - client.initialize(&admin); - client.register_schema(&id, &author, &uri); - client.deprecate_schema(&id, &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!( - last_call_emitted(&e, "schema_deprecated"), - "deprecate_schema() must emit an event" - ); + assert_ne!(id_a, id_b); } +// --------------------------------------------------------------------------- +// test_deprecation_preserves_record +// — After deprecation get_schema still returns the full record. +// --------------------------------------------------------------------------- #[test] -fn test_update_schema_uri_emits_event() { +fn test_deprecation_preserves_record() { let (e, client) = setup(); let admin = Address::generate(&e); - let author = Address::generate(&e); - let id = Bytes::from_slice(&e, b"schema-1"); - let uri = Bytes::from_slice(&e, b"https://example.com/v1.json"); - let new_uri = Bytes::from_slice(&e, b"https://example.com/v2.json"); - client.initialize(&admin); - client.register_schema(&id, &author, &uri); - client.update_schema_uri(&id, &author, &new_uri); + let (author, name, version, definition) = sample_schema(&e); + let schema_id = client.register_schema(&author, &name, &version, &definition); + + client.deprecate_schema(&schema_id, &admin); - assert!( - last_call_emitted(&e, "schema_uri_updated"), - "update_schema_uri() must emit an event" - ); + 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)); }