#[cfg(all(feature = "network", target_arch = "wasm32"))]
compile_error!("The 'network' feature is not supported on wasm32 targets");
use affinidi_did_common::Document;
use config::DIDCacheConfig;
use errors::DIDCacheError;
use highway::{HighwayHash, HighwayHasher};
use moka::{Expiry, future::Cache};
#[cfg(feature = "network")]
use networking::{
WSRequest,
network::{NetworkTask, WSCommands},
};
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::{fmt, time::Duration};
#[cfg(feature = "network")]
use tokio::sync::{Mutex, mpsc};
use tracing::debug;
use wasm_bindgen::JsValue;
use wasm_bindgen::prelude::*;
pub mod config;
pub mod errors;
#[cfg(feature = "network")]
pub mod networking;
mod resolver;
pub use affinidi_did_resolver_traits::{
AsyncResolver, MethodName, Resolution, Resolver, ResolverError,
};
pub use resolver::network_resolvers;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[wasm_bindgen]
pub enum DIDMethod {
ETHR,
JWK,
KEY,
PEER,
PKH,
WEB,
WEBVH,
CHEQD,
SCID,
EXAMPLE,
}
impl fmt::Display for DIDMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DIDMethod::ETHR => write!(f, "ethr"),
DIDMethod::JWK => write!(f, "jwk"),
DIDMethod::KEY => write!(f, "key"),
DIDMethod::PEER => write!(f, "peer"),
DIDMethod::PKH => write!(f, "pkh"),
DIDMethod::WEB => write!(f, "web"),
DIDMethod::WEBVH => write!(f, "webvh"),
DIDMethod::CHEQD => write!(f, "cheqd"),
DIDMethod::SCID => write!(f, "scid"),
DIDMethod::EXAMPLE => write!(f, "example"),
}
}
}
impl TryFrom<String> for DIDMethod {
type Error = DIDCacheError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.as_str().try_into()
}
}
impl TryFrom<&str> for DIDMethod {
type Error = DIDCacheError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"ethr" => Ok(DIDMethod::ETHR),
"jwk" => Ok(DIDMethod::JWK),
"key" => Ok(DIDMethod::KEY),
"peer" => Ok(DIDMethod::PEER),
"pkh" => Ok(DIDMethod::PKH),
"web" => Ok(DIDMethod::WEB),
"webvh" => Ok(DIDMethod::WEBVH),
"cheqd" => Ok(DIDMethod::CHEQD),
"scid" => Ok(DIDMethod::SCID),
#[cfg(feature = "did_example")]
"example" => Ok(DIDMethod::EXAMPLE),
_ => Err(DIDCacheError::UnsupportedMethod(value.to_string())),
}
}
}
impl DIDMethod {
pub fn is_mutable(&self) -> bool {
matches!(
self,
DIDMethod::WEB | DIDMethod::WEBVH | DIDMethod::CHEQD | DIDMethod::SCID
)
}
}
#[derive(Debug)]
pub struct ResolveResponse {
pub did: String,
pub method: DIDMethod,
pub did_hash: [u64; 2],
pub doc: Document,
pub cache_hit: bool,
}
struct DIDExpiry {
mutable_ttl: Duration,
}
impl Expiry<[u64; 2], Document> for DIDExpiry {
fn expire_after_create(
&self,
_key: &[u64; 2],
value: &Document,
_created_at: std::time::Instant,
) -> Option<Duration> {
let did_str = value.id.as_str();
let is_mutable = did_str
.split(':')
.nth(1)
.and_then(|m| DIDMethod::try_from(m).ok())
.is_some_and(|m| m.is_mutable());
if is_mutable {
Some(self.mutable_ttl)
} else {
None }
}
}
#[wasm_bindgen(getter_with_clone)]
pub struct DIDCacheClient {
config: DIDCacheConfig,
cache: Cache<[u64; 2], Document>,
#[cfg(feature = "network")]
network_task_tx: Option<mpsc::Sender<WSCommands>>,
#[cfg(feature = "network")]
network_task_rx: Option<Arc<Mutex<mpsc::Receiver<WSCommands>>>>,
#[cfg(feature = "did_example")]
did_example_cache: did_example::DiDExampleCache,
resolvers: Arc<HashMap<MethodName, VecDeque<Box<dyn AsyncResolver>>>>,
}
impl Clone for DIDCacheClient {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
cache: self.cache.clone(),
#[cfg(feature = "network")]
network_task_tx: self.network_task_tx.clone(),
#[cfg(feature = "network")]
network_task_rx: self.network_task_rx.clone(),
#[cfg(feature = "did_example")]
did_example_cache: self.did_example_cache.clone(),
resolvers: self.resolvers.clone(),
}
}
}
impl DIDCacheClient {
fn resolvers_mut(&mut self) -> &mut HashMap<MethodName, VecDeque<Box<dyn AsyncResolver>>> {
Arc::get_mut(&mut self.resolvers)
.expect("Cannot modify resolvers after DIDCacheClient has been cloned")
}
pub fn set_resolver(&mut self, method: MethodName, resolver: Box<dyn AsyncResolver>) {
let map = self.resolvers_mut();
let deque = map.entry(method).or_default();
deque.clear();
deque.push_back(resolver);
}
pub fn prepend_resolver(
&mut self,
method: MethodName,
resolver: Box<dyn AsyncResolver>,
) -> Result<(), DIDCacheError> {
let name = resolver.name().to_string();
let map = self.resolvers_mut();
let deque = map.entry(method.clone()).or_default();
if deque.iter().any(|r| r.name() == name) {
return Err(DIDCacheError::DIDError(format!(
"Resolver '{name}' already registered for method '{method}'"
)));
}
deque.push_front(resolver);
Ok(())
}
pub fn append_resolver(
&mut self,
method: MethodName,
resolver: Box<dyn AsyncResolver>,
) -> Result<(), DIDCacheError> {
let name = resolver.name().to_string();
let map = self.resolvers_mut();
let deque = map.entry(method.clone()).or_default();
if deque.iter().any(|r| r.name() == name) {
return Err(DIDCacheError::DIDError(format!(
"Resolver '{name}' already registered for method '{method}'"
)));
}
deque.push_back(resolver);
Ok(())
}
pub fn clear_resolvers(&mut self, method: &MethodName) {
self.resolvers_mut().remove(method);
}
pub fn remove_resolver(
&mut self,
method: &MethodName,
index: usize,
) -> Option<Box<dyn AsyncResolver>> {
let map = self.resolvers_mut();
let deque = map.get_mut(method)?;
let removed = deque.remove(index);
if deque.is_empty() {
map.remove(method);
}
removed
}
pub fn find_resolver(&self, method: &MethodName, name: &str) -> Option<usize> {
self.resolvers
.get(method)?
.iter()
.position(|r| r.name() == name)
}
pub async fn resolve(&self, did: &str) -> Result<ResolveResponse, DIDCacheError> {
use affinidi_did_common::DID;
if did.len() > self.config.max_did_size_in_bytes {
return Err(DIDCacheError::DIDError(format!(
"The DID size of {0} bytes exceeds the limit of {1}. Please ensure the size is less than {1}.",
did.len(),
self.config.max_did_size_in_bytes
)));
}
let parsed_did: DID = did
.parse()
.map_err(|e| DIDCacheError::DIDError(format!("Failed to parse DID: {e}")))?;
let method_specific_id = parsed_did.method_specific_id();
let key_parts: Vec<&str> = method_specific_id.split('.').collect();
if key_parts.len() > self.config.max_did_parts {
return Err(DIDCacheError::DIDError(format!(
"The total number of keys and/or services must be less than or equal to {}, but {} were found.",
self.config.max_did_parts,
key_parts.len()
)));
}
let method: DIDMethod = parsed_did.method().to_string().as_str().try_into()?;
let hash = DIDCacheClient::hash_did(did);
#[cfg(feature = "did_example")]
if matches!(method, DIDMethod::EXAMPLE)
&& let Some(doc) = self.did_example_cache.get(did)
{
return Ok(ResolveResponse {
did: did.to_string(),
method,
did_hash: hash,
doc: doc.clone(),
cache_hit: true,
});
}
if let Some(doc) = self.cache.get(&hash).await {
debug!("found did ({}) in cache", did);
Ok(ResolveResponse {
did: did.to_string(),
method,
did_hash: hash,
doc,
cache_hit: true,
})
} else {
debug!("did ({}) NOT in cache hash ({:#?})", did, hash);
#[cfg(feature = "network")]
let doc = {
if self.config.service_address.is_some() {
self.network_resolve(did, hash).await?
} else {
self.local_resolve(&parsed_did).await?
}
};
#[cfg(not(feature = "network"))]
let doc = self.local_resolve(&parsed_did).await?;
debug!("adding did ({}) to cache ({:#?})", did, hash);
self.cache.insert(hash, doc.clone()).await;
Ok(ResolveResponse {
did: did.to_string(),
method,
did_hash: hash,
doc,
cache_hit: false,
})
}
}
pub fn get_cache(&self) -> Cache<[u64; 2], Document> {
self.cache.clone()
}
#[cfg(feature = "network")]
pub fn stop(&self) {
if let Some(tx) = self.network_task_tx.as_ref() {
let _ = tx.blocking_send(WSCommands::Exit);
}
}
pub async fn remove(&self, did: &str) -> Option<Document> {
self.cache.remove(&DIDCacheClient::hash_did(did)).await
}
pub async fn add_did_document(&mut self, did: &str, doc: Document) {
let hash = DIDCacheClient::hash_did(did);
debug!("manually adding did ({}) hash({:#?}) to cache", did, hash);
self.cache.insert(hash, doc).await;
}
pub fn hash_did(did: &str) -> [u64; 2] {
HighwayHasher::default().hash128(did.as_bytes())
}
}
#[wasm_bindgen]
impl DIDCacheClient {
pub async fn new(config: DIDCacheConfig) -> Result<DIDCacheClient, DIDCacheError> {
let cache = Cache::builder()
.max_capacity(config.cache_capacity.into())
.expire_after(DIDExpiry {
mutable_ttl: Duration::from_secs(config.cache_ttl.into()),
})
.build();
let mut resolvers: HashMap<MethodName, VecDeque<Box<dyn AsyncResolver>>> = HashMap::new();
resolvers
.entry(MethodName::Key)
.or_default()
.push_back(Box::new(affinidi_did_resolver_traits::KeyResolver));
resolvers
.entry(MethodName::Peer)
.or_default()
.push_back(Box::new(affinidi_did_resolver_traits::PeerResolver));
resolvers
.entry(MethodName::Ethr)
.or_default()
.push_back(Box::new(network_resolvers::EthrResolver));
resolvers
.entry(MethodName::Pkh)
.or_default()
.push_back(Box::new(network_resolvers::PkhResolver));
resolvers
.entry(MethodName::Web)
.or_default()
.push_back(Box::new(network_resolvers::WebResolver));
#[cfg(feature = "did-jwk")]
resolvers
.entry(MethodName::Jwk)
.or_default()
.push_back(Box::new(network_resolvers::JwkResolver));
#[cfg(feature = "did-webvh")]
resolvers
.entry(MethodName::Webvh)
.or_default()
.push_back(Box::new(network_resolvers::WebvhResolver));
#[cfg(feature = "did-cheqd")]
resolvers
.entry(MethodName::Cheqd)
.or_default()
.push_back(Box::new(network_resolvers::CheqdResolver));
#[cfg(feature = "did-scid")]
resolvers
.entry(MethodName::Scid)
.or_default()
.push_back(Box::new(network_resolvers::ScidResolver));
let resolvers = Arc::new(resolvers);
#[cfg(feature = "network")]
let mut client = Self {
config,
cache,
network_task_tx: None,
network_task_rx: None,
#[cfg(feature = "did_example")]
did_example_cache: did_example::DiDExampleCache::new(),
resolvers: resolvers.clone(),
};
#[cfg(not(feature = "network"))]
let client = Self {
config,
cache,
#[cfg(feature = "did_example")]
did_example_cache: did_example::DiDExampleCache::new(),
resolvers,
};
#[cfg(feature = "network")]
{
if client.config.service_address.is_some() {
let (sdk_tx, mut task_rx) = mpsc::channel(32);
let (task_tx, sdk_rx) = mpsc::channel(32);
client.network_task_tx = Some(sdk_tx);
client.network_task_rx = Some(Arc::new(Mutex::new(sdk_rx)));
let _config = client.config.clone();
tokio::spawn(async move {
let _ = NetworkTask::run(_config, &mut task_rx, &task_tx).await;
});
if let Some(arc_rx) = client.network_task_rx.as_ref() {
let mut rx = arc_rx.lock().await;
rx.recv().await.unwrap();
}
}
}
Ok(client)
}
pub async fn wasm_resolve(&self, did: &str) -> Result<JsValue, DIDCacheError> {
let response = self.resolve(did).await?;
match serde_wasm_bindgen::to_value(&response.doc) {
Ok(values) => Ok(values),
Err(err) => Err(DIDCacheError::DIDError(format!(
"Error serializing DID Document: {err}",
))),
}
}
#[cfg(feature = "did_example")]
pub fn add_example_did(&mut self, doc: &str) -> Result<(), DIDCacheError> {
self.did_example_cache
.insert_from_string(doc)
.map_err(|e| DIDCacheError::DIDError(format!("Couldn't parse example DID: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
const DID_KEY: &str = "did:key:z6MkiToqovww7vYtxm1xNM15u9JzqzUFZ1k7s7MazYJUyAxv";
async fn basic_local_client() -> DIDCacheClient {
let config = config::DIDCacheConfigBuilder::default().build();
DIDCacheClient::new(config).await.unwrap()
}
#[tokio::test]
async fn remove_existing_cached_did() {
let client = basic_local_client().await;
let response = client.resolve(DID_KEY).await.unwrap();
let removed_doc = client.remove(DID_KEY).await;
assert_eq!(removed_doc, Some(response.doc));
}
#[tokio::test]
async fn remove_non_existing_cached_did() {
let client = basic_local_client().await;
let removed_doc = client.remove(DID_KEY).await;
assert_eq!(removed_doc, None);
}
#[tokio::test]
async fn resolve_returns_cache_hit_on_second_call() {
let client = basic_local_client().await;
let first = client.resolve(DID_KEY).await.unwrap();
assert!(!first.cache_hit);
let second = client.resolve(DID_KEY).await.unwrap();
assert!(second.cache_hit);
assert_eq!(first.doc, second.doc);
assert_eq!(first.did_hash, second.did_hash);
}
#[tokio::test]
async fn add_did_document_makes_it_retrievable() {
let mut client = basic_local_client().await;
let response = client.resolve(DID_KEY).await.unwrap();
client.remove(DID_KEY).await;
client.add_did_document(DID_KEY, response.doc.clone()).await;
let cached = client.resolve(DID_KEY).await.unwrap();
assert!(cached.cache_hit);
assert_eq!(cached.doc, response.doc);
}
#[tokio::test]
async fn get_cache_returns_shared_cache() {
let client = basic_local_client().await;
client.resolve(DID_KEY).await.unwrap();
let cache = client.get_cache();
let hash = DIDCacheClient::hash_did(DID_KEY);
assert!(cache.get(&hash).await.is_some());
}
#[tokio::test]
async fn clone_shares_cache() {
let client = basic_local_client().await;
let cloned = client.clone();
client.resolve(DID_KEY).await.unwrap();
let from_clone = cloned.resolve(DID_KEY).await.unwrap();
assert!(from_clone.cache_hit);
}
#[tokio::test]
async fn resolve_rejects_did_exceeding_size_limit() {
let config = config::DIDCacheConfigBuilder::default()
.with_max_did_size_in_bytes(20)
.build();
let client = DIDCacheClient::new(config).await.unwrap();
let result = client.resolve(DID_KEY).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("exceeds the limit"), "got: {err}");
}
#[tokio::test]
async fn resolve_rejects_malformed_did() {
let client = basic_local_client().await;
let result = client.resolve("not-a-did").await;
assert!(result.is_err());
}
#[tokio::test]
async fn resolve_rejects_too_many_parts() {
let config = config::DIDCacheConfigBuilder::default()
.with_max_did_parts(1)
.build();
let client = DIDCacheClient::new(config).await.unwrap();
let did = "did:peer:2.Vz6MkiToqovww7vYtxm1xNM15u9JzqzUFZ1k7s7MazYJUyAxv.EzQ3shQLqRUza6AMJFbPuMdvFRFWm1wKviQRnQSC1fScovJN4s";
let result = client.resolve(did).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("keys and/or services"), "got: {err}");
}
#[tokio::test]
async fn resolve_populates_response_fields() {
let client = basic_local_client().await;
let response = client.resolve(DID_KEY).await.unwrap();
assert_eq!(response.did, DID_KEY);
assert_eq!(response.method, DIDMethod::KEY);
assert_eq!(response.did_hash, DIDCacheClient::hash_did(DID_KEY));
assert!(!response.cache_hit);
assert_eq!(response.doc.id.as_str(), DID_KEY);
}
#[test]
fn hash_did_is_deterministic() {
let hash1 = DIDCacheClient::hash_did(DID_KEY);
let hash2 = DIDCacheClient::hash_did(DID_KEY);
assert_eq!(hash1, hash2);
}
#[test]
fn hash_did_differs_for_different_dids() {
let hash1 = DIDCacheClient::hash_did("did:key:abc");
let hash2 = DIDCacheClient::hash_did("did:key:def");
assert_ne!(hash1, hash2);
}
#[test]
fn did_method_display_roundtrips() {
let methods = [
DIDMethod::ETHR,
DIDMethod::JWK,
DIDMethod::KEY,
DIDMethod::PEER,
DIDMethod::PKH,
DIDMethod::WEB,
DIDMethod::WEBVH,
DIDMethod::CHEQD,
DIDMethod::SCID,
];
for method in &methods {
let s = method.to_string();
let back: DIDMethod = s.as_str().try_into().unwrap();
assert_eq!(&back, method);
}
}
#[test]
fn did_method_try_from_is_case_insensitive() {
let result: Result<DIDMethod, _> = "KEY".try_into();
assert_eq!(result.unwrap(), DIDMethod::KEY);
let result: Result<DIDMethod, _> = "Ethr".try_into();
assert_eq!(result.unwrap(), DIDMethod::ETHR);
}
#[test]
fn did_method_try_from_string_works() {
let result: Result<DIDMethod, _> = String::from("peer").try_into();
assert_eq!(result.unwrap(), DIDMethod::PEER);
}
#[test]
fn did_method_try_from_unknown_returns_error() {
let result: Result<DIDMethod, _> = "unknown".try_into();
assert!(result.is_err());
match result.unwrap_err() {
DIDCacheError::UnsupportedMethod(m) => assert_eq!(m, "unknown"),
other => panic!("expected UnsupportedMethod, got: {other:?}"),
}
}
#[test]
fn immutable_methods_are_not_mutable() {
assert!(!DIDMethod::KEY.is_mutable());
assert!(!DIDMethod::PEER.is_mutable());
assert!(!DIDMethod::JWK.is_mutable());
assert!(!DIDMethod::ETHR.is_mutable());
assert!(!DIDMethod::PKH.is_mutable());
assert!(!DIDMethod::EXAMPLE.is_mutable());
}
#[test]
fn mutable_methods_are_mutable() {
assert!(DIDMethod::WEB.is_mutable());
assert!(DIDMethod::WEBVH.is_mutable());
assert!(DIDMethod::CHEQD.is_mutable());
assert!(DIDMethod::SCID.is_mutable());
}
#[tokio::test]
async fn immutable_did_survives_beyond_ttl() {
let config = config::DIDCacheConfigBuilder::default()
.with_cache_ttl(1)
.build();
let client = DIDCacheClient::new(config).await.unwrap();
client.resolve(DID_KEY).await.unwrap();
tokio::time::sleep(Duration::from_secs(2)).await;
client.cache.run_pending_tasks().await;
let result = client.resolve(DID_KEY).await.unwrap();
assert!(
result.cache_hit,
"immutable did:key should survive beyond TTL"
);
}
}