Skip to content
Open
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 contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ members = [
"access_whitelist",
"zk_event_filter",
"parameter_registry",

# Issue #292 — multi-signature wrapper around high-value event emission.
"multisig_event_registry",
# Issue #293 — gas-optimized batched event emission utility.
"event_batch",
# Issue #295 — W3C-compatible DID registry/resolver.
"did_resolver",
]

[profile.release]
Expand Down
13 changes: 13 additions & 0 deletions contracts/did_resolver/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "event-horizon-did-resolver"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
soroban-sdk = "23.4.0"

[dev-dependencies]
soroban-sdk = { version = "23.4.0", features = ["testutils"] }
159 changes: 159 additions & 0 deletions contracts/did_resolver/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#![no_std]
#![allow(deprecated)]
//! Decentralized Identity (DID) resolver on Soroban.
//!
//! See issue #295. Implements a minimal W3C-DID-Core-compatible
//! registry. DID documents are stored as opaque bytes (intended to be
//! a canonical-JSON encoding) keyed by DID string. Each DID has a
//! single on-chain controller address that may update or revoke it.
//!
//! ## W3C alignment
//! - `did:soroban:<method-specific-id>` strings are the registry keys.
//! The method-specific id is the part after `did:soroban:` — the
//! registry does not parse it, just stores the full string.
//! - `register` corresponds to the W3C "create" CRUD verb.
//! - `update` / `revoke` cover "update" and "deactivate".
//! - `resolve` returns the current `DidDocument`; revoked DIDs
//! resolve as `Revoked` so consumers can distinguish from "never
//! existed".
//! - Every state transition emits an event (`did_reg`, `did_upd`,
//! `did_rev`, `did_attest`) so off-chain indexers can mirror the
//! registry without scanning storage.

use soroban_sdk::{
contract, contractimpl, contracttype, symbol_short, Address, Bytes, Env, String,
};

#[contracttype]
#[derive(Clone)]
pub enum DataKey {
Document(String),
}

#[contracttype]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u32)]
pub enum DidStatus {
Active = 0,
Revoked = 1,
}

#[contracttype]
#[derive(Clone)]
pub struct DidDocument {
pub did: String,
pub controller: Address,
/// W3C DID Document as canonical JSON or CBOR bytes. The registry
/// does not parse this — that's the consumer's responsibility.
pub document: Bytes,
pub status: DidStatus,
pub created_at_ledger: u32,
pub updated_at_ledger: u32,
/// Monotonic version counter; bumps on every successful `update`.
pub version: u32,
}

#[contract]
pub struct DidResolver;

#[contractimpl]
impl DidResolver {
/// Register a new DID under `controller`. Panics if `did` already
/// exists (active or revoked) — DIDs are non-recyclable.
pub fn register(env: Env, controller: Address, did: String, document: Bytes) {
controller.require_auth();
if did.len() == 0 {
panic!("did empty");
}
if env.storage().persistent().has(&DataKey::Document(did.clone())) {
panic!("did already registered");
}
let now = env.ledger().sequence();
let doc = DidDocument {
did: did.clone(),
controller: controller.clone(),
document,
status: DidStatus::Active,
created_at_ledger: now,
updated_at_ledger: now,
version: 1,
};
env.storage()
.persistent()
.set(&DataKey::Document(did.clone()), &doc);

env.events()
.publish((symbol_short!("did_reg"),), (did.clone(), controller.clone()));
env.events()
.publish((symbol_short!("did_attst"),), (did, controller, 1u32));
}

/// Replace the DID document. Only the recorded controller can call.
/// Bumps `version` and `updated_at_ledger`. Cannot update a revoked DID.
pub fn update(env: Env, controller: Address, did: String, new_document: Bytes) {
controller.require_auth();
let mut doc: DidDocument = env
.storage()
.persistent()
.get(&DataKey::Document(did.clone()))
.expect("did not found");
if doc.controller != controller {
panic!("not the controller");
}
if doc.status != DidStatus::Active {
panic!("did is revoked");
}
doc.document = new_document;
doc.updated_at_ledger = env.ledger().sequence();
doc.version += 1;
let new_version = doc.version;
env.storage()
.persistent()
.set(&DataKey::Document(did.clone()), &doc);

env.events()
.publish((symbol_short!("did_upd"),), (did.clone(), new_version));
env.events()
.publish((symbol_short!("did_attst"),), (did, controller, new_version));
}

/// Mark a DID as revoked. Subsequent `update`s panic; `resolve`
/// still returns the document but with `status = Revoked`.
pub fn revoke(env: Env, controller: Address, did: String) {
controller.require_auth();
let mut doc: DidDocument = env
.storage()
.persistent()
.get(&DataKey::Document(did.clone()))
.expect("did not found");
if doc.controller != controller {
panic!("not the controller");
}
if doc.status == DidStatus::Revoked {
panic!("already revoked");
}
doc.status = DidStatus::Revoked;
doc.updated_at_ledger = env.ledger().sequence();
env.storage()
.persistent()
.set(&DataKey::Document(did.clone()), &doc);

env.events()
.publish((symbol_short!("did_rev"),), (did, controller));
}

/// Resolve a DID. Returns `None` if never registered. Revoked DIDs
/// resolve to `Some(doc)` with `status = Revoked` so consumers can
/// distinguish from "never existed".
pub fn resolve(env: Env, did: String) -> Option<DidDocument> {
env.storage().persistent().get(&DataKey::Document(did))
}

/// Lightweight existence + status check that avoids returning the
/// full document.
pub fn status(env: Env, did: String) -> Option<DidStatus> {
Self::resolve(env, did).map(|d| d.status)
}
}

mod test;
132 changes: 132 additions & 0 deletions contracts/did_resolver/src/test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#![cfg(test)]
use super::*;
use soroban_sdk::testutils::{Address as _, Events};
use soroban_sdk::{symbol_short, Address, Bytes, Env, IntoVal, String};

fn setup() -> (Env, Address, DidResolverClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(DidResolver, ());
let client = DidResolverClient::new(&env, &contract_id);
let controller = Address::generate(&env);
(env, controller, client)
}

fn has_event(env: &Env, name: &str) -> bool {
let expected = soroban_sdk::Symbol::new(env, name);
for (_, topics, _) in env.events().all().iter() {
let t: soroban_sdk::Symbol = topics.get(0).unwrap().into_val(env);
if t == expected {
return true;
}
}
false
}

#[test]
fn register_then_resolve() {
let (env, controller, client) = setup();
let did = String::from_str(&env, "did:soroban:alice");
let doc = Bytes::from_slice(&env, b"{\"id\":\"did:soroban:alice\"}");
client.register(&controller, &did, &doc);
// `env.events().all()` is snapshotted per invocation, so assert on
// emitted events before making the read-only `resolve` call.
assert!(has_event(&env, "did_reg"));
assert!(has_event(&env, "did_attst"));

let resolved = client.resolve(&did).unwrap();
assert_eq!(resolved.controller, controller);
assert_eq!(resolved.status, DidStatus::Active);
assert_eq!(resolved.version, 1);
}

#[test]
fn resolve_unknown_did_returns_none() {
let (env, _c, client) = setup();
assert!(client.resolve(&String::from_str(&env, "did:soroban:nope")).is_none());
}

#[test]
#[should_panic(expected = "did already registered")]
fn cannot_register_twice() {
let (env, controller, client) = setup();
let did = String::from_str(&env, "did:soroban:bob");
let doc = Bytes::from_slice(&env, b"a");
client.register(&controller, &did, &doc);
client.register(&controller, &did, &doc);
}

#[test]
fn update_bumps_version_and_emits_identity_verification() {
let (env, controller, client) = setup();
let did = String::from_str(&env, "did:soroban:carol");
client.register(&controller, &did, &Bytes::from_slice(&env, b"v1"));

client.update(&controller, &did, &Bytes::from_slice(&env, b"v2"));
// Check events immediately after `update` before any read-only call
// wipes the per-invocation event buffer.
assert!(has_event(&env, "did_upd"));
// did_attst is the "event-based identity verification update" hook
// called out in the issue's technical requirements.
assert!(has_event(&env, "did_attst"));

let doc = client.resolve(&did).unwrap();
assert_eq!(doc.version, 2);
}

#[test]
#[should_panic(expected = "not the controller")]
fn non_controller_cannot_update() {
let (env, controller, client) = setup();
let did = String::from_str(&env, "did:soroban:dave");
client.register(&controller, &did, &Bytes::from_slice(&env, b"v1"));
let stranger = Address::generate(&env);
client.update(&stranger, &did, &Bytes::from_slice(&env, b"v2"));
}

#[test]
fn revoke_marks_revoked_but_still_resolves() {
let (env, controller, client) = setup();
let did = String::from_str(&env, "did:soroban:eve");
client.register(&controller, &did, &Bytes::from_slice(&env, b"v1"));
client.revoke(&controller, &did);
let doc = client.resolve(&did).unwrap();
assert_eq!(doc.status, DidStatus::Revoked);
assert_eq!(client.status(&did), Some(DidStatus::Revoked));
}

#[test]
#[should_panic(expected = "did is revoked")]
fn cannot_update_after_revoke() {
let (env, controller, client) = setup();
let did = String::from_str(&env, "did:soroban:frank");
client.register(&controller, &did, &Bytes::from_slice(&env, b"v1"));
client.revoke(&controller, &did);
client.update(&controller, &did, &Bytes::from_slice(&env, b"v2"));
}

#[test]
#[should_panic(expected = "did empty")]
fn empty_did_rejected() {
let (env, controller, client) = setup();
client.register(&controller, &String::from_str(&env, ""), &Bytes::from_slice(&env, b"x"));
}

#[test]
fn issue_295_emits_identity_verification_on_update() {
// Direct test for the "Allow event-based identity verification updates"
// bullet — every update emits a did_attst event with did + controller +
// version, which a verifier can subscribe to. Soroban testutils snapshot
// `events().all()` per invocation, so we assert once per call.
let (env, controller, client) = setup();
let did = String::from_str(&env, "did:soroban:verify");

client.register(&controller, &did, &Bytes::from_slice(&env, b"v1"));
assert!(has_event(&env, "did_attst"));

client.update(&controller, &did, &Bytes::from_slice(&env, b"v2"));
assert!(has_event(&env, "did_attst"));

client.update(&controller, &did, &Bytes::from_slice(&env, b"v3"));
assert!(has_event(&env, "did_attst"));
}
Loading