Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions contracts/vc-schema-registry/README.md
Original file line number Diff line number Diff line change
@@ -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
```
156 changes: 97 additions & 59 deletions contracts/vc-schema-registry/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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)
}
Expand All @@ -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()
}
14 changes: 7 additions & 7 deletions contracts/vc-schema-registry/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Loading