From 835f2149c473c56229bd3d914d011d4bc2a8a33b Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:11:13 +0100 Subject: [PATCH 01/36] chore: make DIDEthr generic w/ EthProvider trait, ABI helpers, NetworkConfig; all existing tests pass --- crates/dids/methods/ethr/src/lib.rs | 322 ++++++++++++++++++++++++---- 1 file changed, 277 insertions(+), 45 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 62f9a4f7f..6227eef43 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -11,16 +11,146 @@ use ssi_dids_core::{ DIDBuf, DIDMethod, DIDURLBuf, Document, DIDURL, }; use static_iref::iri; +use std::collections::HashMap; use std::str::FromStr; mod json_ld_context; use json_ld_context::JsonLdContext; use ssi_jwk::JWK; +// --- Ethereum provider types --- + +/// Block reference for eth_call +pub enum BlockRef { + Latest, + Number(u64), +} + +/// Log filter for eth_getLogs +pub struct LogFilter { + pub address: [u8; 20], + pub topics: Vec<[u8; 32]>, + pub from_block: u64, + pub to_block: u64, +} + +/// Ethereum event log +pub struct Log { + pub address: [u8; 20], + pub topics: Vec<[u8; 32]>, + pub data: Vec, + pub block_number: u64, +} + +/// Minimal async trait for Ethereum JSON-RPC interaction. +/// Users implement this with their preferred client (ethers-rs, alloy, etc.) +pub trait EthProvider: Send + Sync { + type Error: std::error::Error + Send + Sync + 'static; + + /// eth_call — execute a read-only contract call + fn call( + &self, + to: [u8; 20], + data: Vec, + block: BlockRef, + ) -> impl std::future::Future, Self::Error>> + Send; + + /// eth_getLogs — query event logs + fn get_logs( + &self, + filter: LogFilter, + ) -> impl std::future::Future, Self::Error>> + Send; + + /// Get block timestamp (seconds since epoch) + fn block_timestamp( + &self, + block: u64, + ) -> impl std::future::Future> + Send; +} + +/// Per-network Ethereum configuration +pub struct NetworkConfig

{ + pub chain_id: u64, + pub registry: [u8; 20], + pub provider: P, +} + +// --- ERC-1056 ABI selectors --- + +/// `changed(address)` — selector 0xf96d0f9f +const CHANGED_SELECTOR: [u8; 4] = [0xf9, 0x6d, 0x0f, 0x9f]; + +/// `identityOwner(address)` — selector 0x8733d4e8 +const IDENTITY_OWNER_SELECTOR: [u8; 4] = [0x87, 0x33, 0xd4, 0xe8]; + +/// Encode a 20-byte address as a 32-byte ABI-padded word +fn abi_encode_address(addr: &[u8; 20]) -> [u8; 32] { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(addr); + word +} + +/// Build calldata: 4-byte selector + 32-byte padded address +fn encode_call(selector: [u8; 4], addr: &[u8; 20]) -> Vec { + let mut data = Vec::with_capacity(36); + data.extend_from_slice(&selector); + data.extend_from_slice(&abi_encode_address(addr)); + data +} + +/// Decode a 32-byte uint256 return value +fn decode_uint256(data: &[u8]) -> u64 { + if data.len() < 32 { + return 0; + } + // Read last 8 bytes as u64 (ERC-1056 changed() returns small block numbers) + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&data[24..32]); + u64::from_be_bytes(bytes) +} + +/// Decode a 32-byte ABI-encoded address return value +fn decode_address(data: &[u8]) -> [u8; 20] { + if data.len() < 32 { + return [0u8; 20]; + } + let mut addr = [0u8; 20]; + addr.copy_from_slice(&data[12..32]); + addr +} + +// --- DIDEthr --- + /// did:ethr DID Method /// /// [Specification](https://github.com/decentralized-identity/ethr-did-resolver/) -pub struct DIDEthr; +/// +/// Generic over `P`: when `P = ()` (the default), only offline resolution is +/// available. When `P` implements [`EthProvider`], on-chain resolution is used +/// for networks that have a configured provider. +pub struct DIDEthr

{ + networks: HashMap>, +} + +impl

Default for DIDEthr

{ + fn default() -> Self { + Self { + networks: HashMap::new(), + } + } +} + +impl

DIDEthr

{ + /// Create a new `DIDEthr` resolver with no networks configured. + pub fn new() -> Self { + Self::default() + } + + /// Add a named network configuration. + pub fn add_network(&mut self, name: &str, config: NetworkConfig

) { + self.networks.insert(name.to_owned(), config); + } +} impl DIDEthr { pub fn generate(jwk: &JWK) -> Result { @@ -29,11 +159,11 @@ impl DIDEthr { } } -impl DIDMethod for DIDEthr { +impl DIDMethod for DIDEthr

{ const DID_METHOD_NAME: &'static str = "ethr"; } -impl DIDMethodResolver for DIDEthr { +impl DIDMethodResolver for DIDEthr

{ async fn resolve_method_representation<'a>( &'a self, method_specific_id: &'a str, @@ -42,50 +172,135 @@ impl DIDMethodResolver for DIDEthr { let decoded_id = DecodedMethodSpecificId::from_str(method_specific_id) .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; - let mut json_ld_context = JsonLdContext::default(); + let network_name = decoded_id.network_name(); + + // Check if we have a provider for this network + let use_onchain = if let Some(config) = self.networks.get(&network_name) { + // Parse address from the identifier + let addr_hex = decoded_id.account_address_hex(); + if let Some(addr) = parse_address_bytes(&addr_hex) { + // Call changed(addr) to see if there are on-chain modifications + let calldata = encode_call(CHANGED_SELECTOR, &addr); + let result = config + .provider + .call(config.registry, calldata, BlockRef::Latest) + .await + .map_err(|e| Error::Internal(e.to_string()))?; + let changed_block = decode_uint256(&result); + changed_block > 0 + } else { + false + } + } else { + false + }; + + if use_onchain { + // For now, on-chain path with changed > 0 still resolves offline. + // Phase 2+ will implement full on-chain resolution. + resolve_offline(method_specific_id, &decoded_id, options) + } else { + resolve_offline(method_specific_id, &decoded_id, options) + } + } +} - let doc = match decoded_id.address_or_public_key.len() { - 42 => resolve_address( - &mut json_ld_context, - method_specific_id, - decoded_id.network_chain, - decoded_id.address_or_public_key, - ), - 68 => resolve_public_key( - &mut json_ld_context, - method_specific_id, - decoded_id.network_chain, - &decoded_id.address_or_public_key, +/// DIDMethodResolver impl for DIDEthr<()> — offline-only resolution +impl DIDMethodResolver for DIDEthr<()> { + async fn resolve_method_representation<'a>( + &'a self, + method_specific_id: &'a str, + options: resolution::Options, + ) -> Result>, Error> { + let decoded_id = DecodedMethodSpecificId::from_str(method_specific_id) + .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; + resolve_offline(method_specific_id, &decoded_id, options) + } +} + +/// Resolve a DID using the offline (genesis document) path +fn resolve_offline( + method_specific_id: &str, + decoded_id: &DecodedMethodSpecificId, + options: resolution::Options, +) -> Result>, Error> { + let mut json_ld_context = JsonLdContext::default(); + + let doc = match decoded_id.address_or_public_key.len() { + 42 => resolve_address( + &mut json_ld_context, + method_specific_id, + &decoded_id.network_chain, + &decoded_id.address_or_public_key, + ), + 68 => resolve_public_key( + &mut json_ld_context, + method_specific_id, + &decoded_id.network_chain, + &decoded_id.address_or_public_key, + ), + _ => Err(Error::InvalidMethodSpecificId( + method_specific_id.to_owned(), + )), + }?; + + let content_type = options.accept.unwrap_or(MediaType::JsonLd); + let represented = doc.into_representation(representation::Options::from_media_type( + content_type, + move || representation::json_ld::Options { + context: representation::json_ld::Context::array( + representation::json_ld::DIDContext::V1, + json_ld_context.into_entries(), ), - _ => Err(Error::InvalidMethodSpecificId( - method_specific_id.to_owned(), - )), - }?; - - let content_type = options.accept.unwrap_or(MediaType::JsonLd); - let represented = doc.into_representation(representation::Options::from_media_type( - content_type, - move || representation::json_ld::Options { - context: representation::json_ld::Context::array( - representation::json_ld::DIDContext::V1, - json_ld_context.into_entries(), - ), - }, - )); + }, + )); - Ok(resolution::Output::new( - represented.to_bytes(), - document::Metadata::default(), - resolution::Metadata::from_content_type(Some(content_type.to_string())), - )) - } + Ok(resolution::Output::new( + represented.to_bytes(), + document::Metadata::default(), + resolution::Metadata::from_content_type(Some(content_type.to_string())), + )) } struct DecodedMethodSpecificId { + network_name: String, network_chain: NetworkChain, address_or_public_key: String, } +impl DecodedMethodSpecificId { + /// Return the network name used for provider lookup + fn network_name(&self) -> String { + self.network_name.clone() + } + + /// Extract the Ethereum address hex string (with 0x prefix). + /// For public-key DIDs, derives the address from the public key. + fn account_address_hex(&self) -> String { + if self.address_or_public_key.len() == 42 { + self.address_or_public_key.clone() + } else { + // Public key DID — derive the address + let pk_hex = &self.address_or_public_key; + if !pk_hex.starts_with("0x") { + return String::new(); + } + let pk_bytes = match hex::decode(&pk_hex[2..]) { + Ok(b) => b, + Err(_) => return String::new(), + }; + let pk_jwk = match ssi_jwk::secp256k1_parse(&pk_bytes) { + Ok(j) => j, + Err(_) => return String::new(), + }; + match ssi_jwk::eip155::hash_public_key_eip55(&pk_jwk) { + Ok(addr) => addr, + Err(_) => String::new(), + } + } + } +} + impl FromStr for DecodedMethodSpecificId { type Err = InvalidNetwork; @@ -100,11 +315,26 @@ impl FromStr for DecodedMethodSpecificId { Ok(DecodedMethodSpecificId { network_chain: network_name.parse()?, + network_name, address_or_public_key, }) } } +/// Parse a hex address string (with 0x prefix) into 20 bytes +fn parse_address_bytes(addr_hex: &str) -> Option<[u8; 20]> { + if !addr_hex.starts_with("0x") || addr_hex.len() != 42 { + return None; + } + let bytes = hex::decode(&addr_hex[2..]).ok()?; + if bytes.len() != 20 { + return None; + } + let mut addr = [0u8; 20]; + addr.copy_from_slice(&bytes); + Some(addr) +} + #[derive(Debug, thiserror::Error)] #[error("invalid network `{0}`")] struct InvalidNetwork(String); @@ -158,11 +388,11 @@ impl FromStr for NetworkChain { fn resolve_address( json_ld_context: &mut JsonLdContext, method_specific_id: &str, - network_chain: NetworkChain, - account_address: String, + network_chain: &NetworkChain, + account_address: &str, ) -> Result { let blockchain_account_id = BlockchainAccountId { - account_address, + account_address: account_address.to_owned(), chain_id: ChainId { namespace: "eip155".to_string(), reference: network_chain.id().to_string(), @@ -200,7 +430,7 @@ fn resolve_address( fn resolve_public_key( json_ld_context: &mut JsonLdContext, method_specific_id: &str, - network_chain: NetworkChain, + network_chain: &NetworkChain, public_key_hex: &str, ) -> Result { if !public_key_hex.starts_with("0x") { @@ -408,7 +638,8 @@ mod tests { #[tokio::test] async fn resolve_did_ethr_addr() { // https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md#create-register - let doc = DIDEthr + let resolver = DIDEthr::<()>::default(); + let doc = resolver .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) .await .unwrap() @@ -451,7 +682,8 @@ mod tests { #[tokio::test] async fn resolve_did_ethr_pk() { - let doc = DIDEthr + let resolver = DIDEthr::<()>::default(); + let doc = resolver .resolve(did!( "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" )) @@ -476,7 +708,7 @@ mod tests { } async fn credential_prove_verify_did_ethr2(eip712: bool) { - let didethr = DIDEthr.into_vm_resolver(); + let didethr = DIDEthr::<()>::default().into_vm_resolver(); let verifier = VerificationParameters::from_resolver(&didethr); let key: JWK = serde_json::from_value(json!({ "alg": "ES256K-R", @@ -595,7 +827,7 @@ mod tests { #[tokio::test] async fn credential_verify_eip712vm() { - let didethr = DIDEthr.into_vm_resolver(); + let didethr = DIDEthr::<()>::default().into_vm_resolver(); let vc = ssi_claims::vc::v1::data_integrity::any_credential_from_json_str(include_str!( "../tests/vc.jsonld" )) From 7650eb9e41866ccbea4683b120c6f8e7a2c67c7e Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:12:58 +0100 Subject: [PATCH 02/36] chore: add on-chain identityOwner check add mock provider tests for changed=0 and identityOwner=same; --- crates/dids/methods/ethr/src/lib.rs | 201 +++++++++++++++++++++++++--- 1 file changed, 185 insertions(+), 16 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 6227eef43..976b93c4e 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -175,33 +175,42 @@ impl DIDMethodResolver for DIDEthr

{ let network_name = decoded_id.network_name(); // Check if we have a provider for this network - let use_onchain = if let Some(config) = self.networks.get(&network_name) { - // Parse address from the identifier + if let Some(config) = self.networks.get(&network_name) { let addr_hex = decoded_id.account_address_hex(); if let Some(addr) = parse_address_bytes(&addr_hex) { // Call changed(addr) to see if there are on-chain modifications let calldata = encode_call(CHANGED_SELECTOR, &addr); let result = config .provider - .call(config.registry, calldata, BlockRef::Latest) + .call(config.registry, calldata.clone(), BlockRef::Latest) .await .map_err(|e| Error::Internal(e.to_string()))?; let changed_block = decode_uint256(&result); - changed_block > 0 - } else { - false - } - } else { - false - }; - if use_onchain { - // For now, on-chain path with changed > 0 still resolves offline. - // Phase 2+ will implement full on-chain resolution. - resolve_offline(method_specific_id, &decoded_id, options) - } else { - resolve_offline(method_specific_id, &decoded_id, options) + if changed_block > 0 { + // Check identityOwner(addr) + let owner_calldata = encode_call(IDENTITY_OWNER_SELECTOR, &addr); + let owner_result = config + .provider + .call(config.registry, owner_calldata, BlockRef::Latest) + .await + .map_err(|e| Error::Internal(e.to_string()))?; + let owner = decode_address(&owner_result); + + if owner == addr { + // Owner unchanged — use offline (genesis) document + return resolve_offline(method_specific_id, &decoded_id, options); + } + + // Phase 2+ will handle different-owner resolution here. + // For now, fall back to offline. + return resolve_offline(method_specific_id, &decoded_id, options); + } + } } + + // No provider or changed=0 — offline resolution + resolve_offline(method_specific_id, &decoded_id, options) } } @@ -839,4 +848,164 @@ mod tests { .unwrap() .is_ok()) } + + // --- Mock provider for on-chain resolution tests --- + + #[derive(Debug)] + struct MockProviderError(String); + impl std::fmt::Display for MockProviderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "MockProviderError: {}", self.0) + } + } + impl std::error::Error for MockProviderError {} + + /// Mock provider that returns configurable responses for changed() and identityOwner() + struct MockProvider { + /// Block number to return for changed(addr) calls + changed_block: u64, + /// Address to return for identityOwner(addr) calls (None = return the queried address) + identity_owner: Option<[u8; 20]>, + } + + impl MockProvider { + fn new_unchanged() -> Self { + Self { + changed_block: 0, + identity_owner: None, + } + } + + fn new_same_owner() -> Self { + Self { + changed_block: 1, // has changes + identity_owner: None, // but owner is the same + } + } + } + + impl EthProvider for MockProvider { + type Error = MockProviderError; + + async fn call( + &self, + _to: [u8; 20], + data: Vec, + _block: BlockRef, + ) -> Result, Self::Error> { + if data.len() < 4 { + return Err(MockProviderError("calldata too short".into())); + } + let selector: [u8; 4] = data[..4].try_into().unwrap(); + match selector { + CHANGED_SELECTOR => { + // Return changed_block as uint256 + let mut result = vec![0u8; 32]; + result[24..32].copy_from_slice(&self.changed_block.to_be_bytes()); + Ok(result) + } + IDENTITY_OWNER_SELECTOR => { + // Return identity_owner or echo back the queried address + let mut result = vec![0u8; 32]; + if let Some(owner) = self.identity_owner { + result[12..32].copy_from_slice(&owner); + } else if data.len() >= 36 { + // Echo back the queried address (last 20 bytes of the 32-byte arg) + result[12..32].copy_from_slice(&data[16..36]); + } + Ok(result) + } + _ => Err(MockProviderError(format!( + "unknown selector: {:?}", + selector + ))), + } + } + + async fn get_logs(&self, _filter: LogFilter) -> Result, Self::Error> { + Ok(vec![]) + } + + async fn block_timestamp(&self, _block: u64) -> Result { + Ok(0) + } + } + + #[tokio::test] + async fn resolve_with_mock_provider_changed_zero() { + // A mock provider where changed(addr) returns 0 should produce + // the same document as offline resolution. + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider::new_unchanged(), + }, + ); + + let doc_onchain = resolver + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + let doc_offline = DIDEthr::<()>::default() + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + assert_eq!( + serde_json::to_value(&doc_onchain).unwrap(), + serde_json::to_value(&doc_offline).unwrap(), + "mock provider with changed=0 should produce same doc as offline" + ); + } + + #[tokio::test] + async fn resolve_with_mock_provider_identity_owner_same() { + // A mock provider where identityOwner(addr) returns the same address + // should produce the same document as offline resolution. + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider::new_same_owner(), + }, + ); + + let doc_onchain = resolver + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + let doc_offline = DIDEthr::<()>::default() + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + assert_eq!( + serde_json::to_value(&doc_onchain).unwrap(), + serde_json::to_value(&doc_offline).unwrap(), + "mock provider with identityOwner=same should produce same doc as offline" + ); + } } From 83fee37925978f23a69b2231afb8a09343615b96 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:17:43 +0100 Subject: [PATCH 03/36] feat: resolve address-based DID with changed owner --- crates/dids/methods/ethr/Cargo.toml | 1 + crates/dids/methods/ethr/src/lib.rs | 114 +++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/crates/dids/methods/ethr/Cargo.toml b/crates/dids/methods/ethr/Cargo.toml index 2f19f5b0c..1f3bb363f 100644 --- a/crates/dids/methods/ethr/Cargo.toml +++ b/crates/dids/methods/ethr/Cargo.toml @@ -20,6 +20,7 @@ static-iref.workspace = true thiserror.workspace = true hex.workspace = true serde_json.workspace = true +ssi-crypto = { workspace = true, features = ["keccak"] } [dev-dependencies] tokio = { version = "1.0", features = ["macros"] } diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 976b93c4e..b85cf27a9 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -1,6 +1,7 @@ use iref::Iri; use ssi_caips::caip10::BlockchainAccountId; use ssi_caips::caip2::ChainId; +use ssi_crypto::hashes::keccak; use ssi_dids_core::{ document::{ self, @@ -119,6 +120,12 @@ fn decode_address(data: &[u8]) -> [u8; 20] { addr } +/// Convert raw 20 bytes to an EIP-55 checksummed hex address string +fn format_address_eip55(addr: &[u8; 20]) -> String { + let lowercase = format!("0x{}", hex::encode(addr)); + keccak::eip55_checksum_addr(&lowercase).unwrap_or(lowercase) +} + // --- DIDEthr --- /// did:ethr DID Method @@ -202,9 +209,16 @@ impl DIDMethodResolver for DIDEthr

{ return resolve_offline(method_specific_id, &decoded_id, options); } - // Phase 2+ will handle different-owner resolution here. - // For now, fall back to offline. - return resolve_offline(method_specific_id, &decoded_id, options); + // Owner changed — build document with the new owner's address + let owner_address = format_address_eip55(&owner); + let is_public_key_did = decoded_id.address_or_public_key.len() == 68; + return resolve_with_owner( + method_specific_id, + &decoded_id.network_chain, + &owner_address, + is_public_key_did, + options, + ); } } } @@ -253,6 +267,40 @@ fn resolve_offline( )), }?; + serialize_document(doc, json_ld_context, options) +} + +/// Resolve a DID when the on-chain owner differs from the identity address. +/// +/// For address-based DIDs: `#controller` and `Eip712Method2021` use the owner's address. +/// For public-key DIDs: `#controllerKey` is omitted (the key no longer represents the owner). +fn resolve_with_owner( + method_specific_id: &str, + network_chain: &NetworkChain, + owner_address: &str, + _is_public_key_did: bool, + options: resolution::Options, +) -> Result>, Error> { + let mut json_ld_context = JsonLdContext::default(); + + // Both address-based and public-key DIDs with a changed owner produce + // the same document shape: #controller + Eip712Method2021, using the + // owner's address. For public-key DIDs, #controllerKey is omitted. + let doc = resolve_address( + &mut json_ld_context, + method_specific_id, + network_chain, + owner_address, + )?; + + serialize_document(doc, json_ld_context, options) +} + +fn serialize_document( + doc: Document, + json_ld_context: JsonLdContext, + options: resolution::Options, +) -> Result>, Error> { let content_type = options.accept.unwrap_or(MediaType::JsonLd); let represented = doc.into_representation(representation::Options::from_media_type( content_type, @@ -970,6 +1018,66 @@ mod tests { ); } + #[tokio::test] + async fn resolve_with_mock_provider_owner_changed_address_did() { + // When identityOwner(addr) returns a different address, the #controller + // and Eip712Method2021 VMs should use the new owner's address in + // blockchainAccountId. + let new_owner: [u8; 20] = [0x11; 20]; + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider { + changed_block: 1, + identity_owner: Some(new_owner), + }, + }, + ); + + let doc = resolver + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + + // The DID id should still use the original address + assert_eq!( + doc_value["id"], + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + ); + + // #controller VM should use the new owner's address + let vms = doc_value["verificationMethod"].as_array().unwrap(); + let controller_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#controller") + }).expect("should have #controller VM"); + assert_eq!( + controller_vm["blockchainAccountId"], + "eip155:1:0x1111111111111111111111111111111111111111" + ); + + // Eip712Method2021 should also use the new owner's address + let eip712_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#Eip712Method2021") + }).expect("should have #Eip712Method2021 VM"); + assert_eq!( + eip712_vm["blockchainAccountId"], + "eip155:1:0x1111111111111111111111111111111111111111" + ); + + // Should only have 2 VMs (no controllerKey for address-based DID) + assert_eq!(vms.len(), 2); + } + #[tokio::test] async fn resolve_with_mock_provider_identity_owner_same() { // A mock provider where identityOwner(addr) returns the same address From 8ff2eeb5f8568afdc321fe5e2c22f97094e3c14e Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:18:29 +0100 Subject: [PATCH 04/36] feat: owner changed on pubkey DID omits #controllerKey --- crates/dids/methods/ethr/src/lib.rs | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index b85cf27a9..1b7fd27c7 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -1078,6 +1078,71 @@ mod tests { assert_eq!(vms.len(), 2); } + #[tokio::test] + async fn resolve_with_mock_provider_owner_changed_pubkey_did() { + // When a public-key DID has a changed owner, #controllerKey must be + // omitted (the pubkey no longer represents the current owner). + // Only #controller and Eip712Method2021 remain. + let new_owner: [u8; 20] = [0x22; 20]; + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider { + changed_block: 1, + identity_owner: Some(new_owner), + }, + }, + ); + + let did_str = "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479"; + let doc = resolver + .resolve(did!( + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + + // DID id still uses the original public key + assert_eq!(doc_value["id"], did_str); + + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have exactly 2 VMs: #controller and #Eip712Method2021 + assert_eq!(vms.len(), 2, "should have 2 VMs, not 3 (no #controllerKey)"); + + // No #controllerKey + assert!( + vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#controllerKey")), + "#controllerKey should be omitted when owner has changed" + ); + + // #controller uses new owner's blockchainAccountId + let controller_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#controller") + }).expect("should have #controller VM"); + assert_eq!( + controller_vm["blockchainAccountId"], + "eip155:1:0x2222222222222222222222222222222222222222" + ); + + // Eip712Method2021 uses new owner's blockchainAccountId + let eip712_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#Eip712Method2021") + }).expect("should have #Eip712Method2021 VM"); + assert_eq!( + eip712_vm["blockchainAccountId"], + "eip155:1:0x2222222222222222222222222222222222222222" + ); + } + #[tokio::test] async fn resolve_with_mock_provider_identity_owner_same() { // A mock provider where identityOwner(addr) returns the same address From f7e6684dc2814c0dfe41e295f98255a8f98a92b3 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:19:05 +0100 Subject: [PATCH 05/36] test: owner unchanged pubkey DID retains #controllerKey --- crates/dids/methods/ethr/src/lib.rs | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 1b7fd27c7..68eebd659 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -1181,4 +1181,46 @@ mod tests { "mock provider with identityOwner=same should produce same doc as offline" ); } + + #[tokio::test] + async fn resolve_with_mock_provider_owner_same_pubkey_did_retains_controller_key() { + // When a public-key DID's owner hasn't changed, #controllerKey + // must be retained in the document. + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider::new_same_owner(), + }, + ); + + let doc = resolver + .resolve(did!( + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have 3 VMs: #controller, #controllerKey, Eip712Method2021 + // (Note: Eip712Method2021 is only on the controller, not the key, + // so the exact set depends on the offline resolve_public_key behavior) + assert!( + vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#controllerKey")), + "#controllerKey should be retained when owner is unchanged" + ); + + // #controllerKey should have publicKeyJwk + let key_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#controllerKey") + }).unwrap(); + assert!(key_vm.get("publicKeyJwk").is_some(), "#controllerKey should have publicKeyJwk"); + } } From 330e7548e53a991e9182ccfd6439714df6335774 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:19:38 +0100 Subject: [PATCH 06/36] test: multiple owner changes uses identityOwner() result --- crates/dids/methods/ethr/src/lib.rs | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 68eebd659..d21cd8b7b 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -1223,4 +1223,60 @@ mod tests { }).unwrap(); assert!(key_vm.get("publicKeyJwk").is_some(), "#controllerKey should have publicKeyJwk"); } + + #[tokio::test] + async fn resolve_with_mock_provider_multiple_owner_changes() { + // Simulate multiple ownership transfers: identityOwner() returns the + // final owner. The document should use that address regardless of + // how many transfers occurred. + let final_owner: [u8; 20] = [ + 0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, + 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0x01, + ]; + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider { + changed_block: 5, // multiple blocks of changes + identity_owner: Some(final_owner), + }, + }, + ); + + let doc = resolver + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Compute expected checksummed address + let expected_addr = format_address_eip55(&final_owner); + let expected_account_id = format!("eip155:1:{expected_addr}"); + + let controller_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#controller") + }).expect("should have #controller VM"); + assert_eq!( + controller_vm["blockchainAccountId"].as_str().unwrap(), + expected_account_id, + ); + + let eip712_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#Eip712Method2021") + }).expect("should have #Eip712Method2021 VM"); + assert_eq!( + eip712_vm["blockchainAccountId"].as_str().unwrap(), + expected_account_id, + ); + } } From 43f7038a21ea40bdd655aa0e49f76e6e1e508048 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:20:05 +0100 Subject: [PATCH 07/36] refactor: remove unused is_public_key_did param from resolve_with_owner --- crates/dids/methods/ethr/src/lib.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index d21cd8b7b..0a88fee8e 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -211,12 +211,10 @@ impl DIDMethodResolver for DIDEthr

{ // Owner changed — build document with the new owner's address let owner_address = format_address_eip55(&owner); - let is_public_key_did = decoded_id.address_or_public_key.len() == 68; return resolve_with_owner( method_specific_id, &decoded_id.network_chain, &owner_address, - is_public_key_did, options, ); } @@ -272,20 +270,18 @@ fn resolve_offline( /// Resolve a DID when the on-chain owner differs from the identity address. /// -/// For address-based DIDs: `#controller` and `Eip712Method2021` use the owner's address. -/// For public-key DIDs: `#controllerKey` is omitted (the key no longer represents the owner). +/// Both address-based and public-key DIDs with a changed owner produce the +/// same document shape: `#controller` + `Eip712Method2021`, using the owner's +/// address. For public-key DIDs this means `#controllerKey` is implicitly +/// omitted since the public key no longer represents the current owner. fn resolve_with_owner( method_specific_id: &str, network_chain: &NetworkChain, owner_address: &str, - _is_public_key_did: bool, options: resolution::Options, ) -> Result>, Error> { let mut json_ld_context = JsonLdContext::default(); - // Both address-based and public-key DIDs with a changed owner produce - // the same document shape: #controller + Eip712Method2021, using the - // owner's address. For public-key DIDs, #controllerKey is omitted. let doc = resolve_address( &mut json_ld_context, method_specific_id, From 5c295e88ecb36ae1dad7830a0765395282830c29 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:28:33 +0100 Subject: [PATCH 08/36] feat: event history traversal via linked-list log walk Erc1056Event enum, keccak256 topic hashes, collect_events(), parse_erc1056_event(), enhanced LogFilter (topic0/topic1), MockProvider with logs support. 4 new tests. --- crates/crypto/src/hashes/keccak.rs | 5 + crates/dids/methods/ethr/src/lib.rs | 473 +++++++++++++++++++++++++++- 2 files changed, 474 insertions(+), 4 deletions(-) diff --git a/crates/crypto/src/hashes/keccak.rs b/crates/crypto/src/hashes/keccak.rs index 745d3c6f3..bcd902bba 100644 --- a/crates/crypto/src/hashes/keccak.rs +++ b/crates/crypto/src/hashes/keccak.rs @@ -1,5 +1,10 @@ use keccak_hash::keccak; +/// Compute the keccak256 hash of arbitrary bytes. +pub fn keccak256(data: &[u8]) -> [u8; 32] { + keccak(data).to_fixed_bytes() +} + pub fn bytes_to_lowerhex(bytes: &[u8]) -> String { use std::fmt::Write; bytes.iter().fold("0x".to_owned(), |mut s, byte| { diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 0a88fee8e..46793d50a 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -28,9 +28,13 @@ pub enum BlockRef { } /// Log filter for eth_getLogs +/// +/// `topic0` filters by event signature hash(es) — multiple values are OR'd. +/// `topic1` filters by the first indexed parameter (e.g. identity address). pub struct LogFilter { pub address: [u8; 20], - pub topics: Vec<[u8; 32]>, + pub topic0: Vec<[u8; 32]>, + pub topic1: Option<[u8; 32]>, pub from_block: u64, pub to_block: u64, } @@ -126,6 +130,191 @@ fn format_address_eip55(addr: &[u8; 20]) -> String { keccak::eip55_checksum_addr(&lowercase).unwrap_or(lowercase) } +// --- ERC-1056 event topic hashes --- + +/// Compute keccak256 hash of an event signature string +fn keccak256(data: &[u8]) -> [u8; 32] { + keccak::keccak256(data) +} + +/// Lazily compute event topic hashes from their Solidity signatures +fn topic_owner_changed() -> [u8; 32] { + keccak256(b"DIDOwnerChanged(address,address,uint256)") +} + +fn topic_delegate_changed() -> [u8; 32] { + keccak256(b"DIDDelegateChanged(address,bytes32,address,uint256,uint256)") +} + +fn topic_attribute_changed() -> [u8; 32] { + keccak256(b"DIDAttributeChanged(address,bytes32,bytes,uint256,uint256)") +} + +// --- ERC-1056 event types --- + +/// Parsed ERC-1056 events from the DIDRegistry contract +#[derive(Debug, Clone, PartialEq)] +pub enum Erc1056Event { + OwnerChanged { + identity: [u8; 20], + owner: [u8; 20], + previous_change: u64, + }, + DelegateChanged { + identity: [u8; 20], + delegate_type: [u8; 32], + delegate: [u8; 20], + valid_to: u64, + previous_change: u64, + }, + AttributeChanged { + identity: [u8; 20], + name: [u8; 32], + value: Vec, + valid_to: u64, + previous_change: u64, + }, +} + +impl Erc1056Event { + fn previous_change(&self) -> u64 { + match self { + Self::OwnerChanged { previous_change, .. } => *previous_change, + Self::DelegateChanged { previous_change, .. } => *previous_change, + Self::AttributeChanged { previous_change, .. } => *previous_change, + } + } +} + +/// Parse an Ethereum log into an Erc1056Event. +/// +/// Returns `None` if the log doesn't match any known ERC-1056 event or has +/// insufficient data. +fn parse_erc1056_event(log: &Log) -> Option { + if log.topics.is_empty() { + return None; + } + let topic0 = log.topics[0]; + + // Extract identity from topic[1] (indexed parameter, last 20 bytes of 32) + if log.topics.len() < 2 { + return None; + } + let mut identity = [0u8; 20]; + identity.copy_from_slice(&log.topics[1][12..32]); + + if topic0 == topic_owner_changed() { + // data: owner(32) + previousChange(32) = 64 bytes + if log.data.len() < 64 { + return None; + } + let mut owner = [0u8; 20]; + owner.copy_from_slice(&log.data[12..32]); + let previous_change = decode_uint256(&log.data[32..64]); + Some(Erc1056Event::OwnerChanged { + identity, + owner, + previous_change, + }) + } else if topic0 == topic_delegate_changed() { + // data: delegateType(32) + delegate(32) + validTo(32) + previousChange(32) = 128 + if log.data.len() < 128 { + return None; + } + let mut delegate_type = [0u8; 32]; + delegate_type.copy_from_slice(&log.data[0..32]); + let mut delegate = [0u8; 20]; + delegate.copy_from_slice(&log.data[44..64]); + let valid_to = decode_uint256(&log.data[64..96]); + let previous_change = decode_uint256(&log.data[96..128]); + Some(Erc1056Event::DelegateChanged { + identity, + delegate_type, + delegate, + valid_to, + previous_change, + }) + } else if topic0 == topic_attribute_changed() { + // data: name(32) + offset(32) + validTo(32) + previousChange(32) + valueLen(32) + value... + if log.data.len() < 160 { + return None; + } + let mut name = [0u8; 32]; + name.copy_from_slice(&log.data[0..32]); + let valid_to = decode_uint256(&log.data[64..96]); + let previous_change = decode_uint256(&log.data[96..128]); + let value_len = decode_uint256(&log.data[128..160]) as usize; + let value = if log.data.len() >= 160 + value_len { + log.data[160..160 + value_len].to_vec() + } else { + Vec::new() + }; + Some(Erc1056Event::AttributeChanged { + identity, + name, + value, + valid_to, + previous_change, + }) + } else { + None + } +} + +/// Walk the ERC-1056 linked-list event log and return events in chronological order. +/// +/// Starting from `changed_block`, fetches logs at each block and follows the +/// `previousChange` pointer backwards. The result is reversed to yield +/// chronological order. +async fn collect_events( + provider: &P, + registry: [u8; 20], + identity: &[u8; 20], + changed_block: u64, +) -> Result, String> { + if changed_block == 0 { + return Ok(Vec::new()); + } + + let identity_topic = abi_encode_address(identity); + let topic0s = vec![ + topic_owner_changed(), + topic_delegate_changed(), + topic_attribute_changed(), + ]; + + let mut events = Vec::new(); + let mut current_block = changed_block; + + while current_block > 0 { + let filter = LogFilter { + address: registry, + topic0: topic0s.clone(), + topic1: Some(identity_topic), + from_block: current_block, + to_block: current_block, + }; + + let logs = provider + .get_logs(filter) + .await + .map_err(|e| e.to_string())?; + + let mut next_block = 0u64; + for log in &logs { + if let Some(event) = parse_erc1056_event(log) { + next_block = event.previous_change(); + events.push(event); + } + } + + current_block = next_block; + } + + events.reverse(); // chronological order + Ok(events) +} + // --- DIDEthr --- /// did:ethr DID Method @@ -904,12 +1093,14 @@ mod tests { } impl std::error::Error for MockProviderError {} - /// Mock provider that returns configurable responses for changed() and identityOwner() + /// Mock provider that returns configurable responses for changed(), identityOwner(), and get_logs() struct MockProvider { /// Block number to return for changed(addr) calls changed_block: u64, /// Address to return for identityOwner(addr) calls (None = return the queried address) identity_owner: Option<[u8; 20]>, + /// Logs to return for get_logs calls, keyed by block number + logs: HashMap>, } impl MockProvider { @@ -917,6 +1108,7 @@ mod tests { Self { changed_block: 0, identity_owner: None, + logs: HashMap::new(), } } @@ -924,6 +1116,7 @@ mod tests { Self { changed_block: 1, // has changes identity_owner: None, // but owner is the same + logs: HashMap::new(), } } } @@ -966,8 +1159,34 @@ mod tests { } } - async fn get_logs(&self, _filter: LogFilter) -> Result, Self::Error> { - Ok(vec![]) + async fn get_logs(&self, filter: LogFilter) -> Result, Self::Error> { + // Return logs for the requested block range, filtering by topic0 and topic1 + let mut result = Vec::new(); + for block in filter.from_block..=filter.to_block { + if let Some(block_logs) = self.logs.get(&block) { + for log in block_logs { + // Filter by topic0 if specified + if !filter.topic0.is_empty() && !log.topics.is_empty() { + if !filter.topic0.contains(&log.topics[0]) { + continue; + } + } + // Filter by topic1 if specified + if let Some(t1) = filter.topic1 { + if log.topics.len() < 2 || log.topics[1] != t1 { + continue; + } + } + result.push(Log { + address: log.address, + topics: log.topics.clone(), + data: log.data.clone(), + block_number: log.block_number, + }); + } + } + } + Ok(result) } async fn block_timestamp(&self, _block: u64) -> Result { @@ -975,6 +1194,249 @@ mod tests { } } + const TEST_REGISTRY: [u8; 20] = [ + 0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b, + ]; + + /// Build a DIDOwnerChanged log entry for testing + fn make_owner_changed_log( + block: u64, + identity: &[u8; 20], + new_owner: &[u8; 20], + previous_change: u64, + ) -> Log { + let identity_topic = abi_encode_address(identity); + let mut data = vec![0u8; 64]; + // data[0:32] = owner (address, zero-padded to 32 bytes) + data[12..32].copy_from_slice(new_owner); + // data[32:64] = previousChange (uint256) + data[56..64].copy_from_slice(&previous_change.to_be_bytes()); + + Log { + address: TEST_REGISTRY, + topics: vec![topic_owner_changed(), identity_topic], + data, + block_number: block, + } + } + + /// Build a DIDDelegateChanged log entry for testing + fn make_delegate_changed_log( + block: u64, + identity: &[u8; 20], + delegate_type: &[u8; 32], + delegate: &[u8; 20], + valid_to: u64, + previous_change: u64, + ) -> Log { + let identity_topic = abi_encode_address(identity); + let mut data = vec![0u8; 128]; + // data[0:32] = delegateType + data[0..32].copy_from_slice(delegate_type); + // data[32:64] = delegate (address, zero-padded) + data[44..64].copy_from_slice(delegate); + // data[64:96] = validTo + data[88..96].copy_from_slice(&valid_to.to_be_bytes()); + // data[96:128] = previousChange + data[120..128].copy_from_slice(&previous_change.to_be_bytes()); + + Log { + address: TEST_REGISTRY, + topics: vec![topic_delegate_changed(), identity_topic], + data, + block_number: block, + } + } + + /// Build a DIDAttributeChanged log entry for testing + fn make_attribute_changed_log( + block: u64, + identity: &[u8; 20], + name: &[u8; 32], + value: &[u8], + valid_to: u64, + previous_change: u64, + ) -> Log { + let identity_topic = abi_encode_address(identity); + // data layout: name(32) + offset(32) + validTo(32) + previousChange(32) + valueLen(32) + value(padded) + let padded_value_len = ((value.len() + 31) / 32) * 32; + let total_len = 160 + padded_value_len; + let mut data = vec![0u8; total_len]; + // data[0:32] = name + data[0..32].copy_from_slice(name); + // data[32:64] = offset to value (always 0xa0 = 160) + data[56..64].copy_from_slice(&160u64.to_be_bytes()); + // data[64:96] = validTo + data[88..96].copy_from_slice(&valid_to.to_be_bytes()); + // data[96:128] = previousChange + data[120..128].copy_from_slice(&previous_change.to_be_bytes()); + // data[128:160] = value length + data[152..160].copy_from_slice(&(value.len() as u64).to_be_bytes()); + // data[160..] = value bytes + data[160..160 + value.len()].copy_from_slice(value); + + Log { + address: TEST_REGISTRY, + topics: vec![topic_attribute_changed(), identity_topic], + data, + block_number: block, + } + } + + #[tokio::test] + async fn collect_events_changed_zero_returns_empty() { + let identity: [u8; 20] = [0xAA; 20]; + let provider = MockProvider::new_unchanged(); + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 0) + .await + .unwrap(); + + assert!(events.is_empty(), "changed=0 should yield no events"); + } + + #[tokio::test] + async fn collect_events_single_block_one_event() { + let identity: [u8; 20] = [0xBB; 20]; + let new_owner: [u8; 20] = [0xCC; 20]; + + let log = make_owner_changed_log(100, &identity, &new_owner, 0); + + let provider = MockProvider { + changed_block: 100, + identity_owner: Some(new_owner), + logs: HashMap::from([(100, vec![log])]), + }; + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 100) + .await + .unwrap(); + + assert_eq!(events.len(), 1); + match &events[0] { + Erc1056Event::OwnerChanged { identity: id, owner, previous_change } => { + assert_eq!(id, &[0xBB; 20]); + assert_eq!(owner, &[0xCC; 20]); + assert_eq!(*previous_change, 0); + } + _ => panic!("expected OwnerChanged event"), + } + } + + #[tokio::test] + async fn collect_events_linked_list_walk_chronological_order() { + // Block 200 has an owner change with previousChange=100 + // Block 100 has an owner change with previousChange=0 + // Expected: events returned in chronological order [block100, block200] + let identity: [u8; 20] = [0xDD; 20]; + let owner_a: [u8; 20] = [0x11; 20]; + let owner_b: [u8; 20] = [0x22; 20]; + + let log_at_100 = make_owner_changed_log(100, &identity, &owner_a, 0); + let log_at_200 = make_owner_changed_log(200, &identity, &owner_b, 100); + + let provider = MockProvider { + changed_block: 200, + identity_owner: Some(owner_b), + logs: HashMap::from([ + (100, vec![log_at_100]), + (200, vec![log_at_200]), + ]), + }; + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 200) + .await + .unwrap(); + + assert_eq!(events.len(), 2); + + // First event (chronologically) should be from block 100 + match &events[0] { + Erc1056Event::OwnerChanged { owner, previous_change, .. } => { + assert_eq!(owner, &owner_a); + assert_eq!(*previous_change, 0); + } + _ => panic!("expected OwnerChanged event at index 0"), + } + + // Second event should be from block 200 + match &events[1] { + Erc1056Event::OwnerChanged { owner, previous_change, .. } => { + assert_eq!(owner, &owner_b); + assert_eq!(*previous_change, 100); + } + _ => panic!("expected OwnerChanged event at index 1"), + } + } + + #[tokio::test] + async fn collect_events_multiple_event_types_across_blocks() { + // Block 300: attribute change (previousChange=200) + // Block 200: delegate change (previousChange=100) + // Block 100: owner change (previousChange=0) + let identity: [u8; 20] = [0xEE; 20]; + let new_owner: [u8; 20] = [0x11; 20]; + let delegate: [u8; 20] = [0x22; 20]; + + let mut delegate_type = [0u8; 32]; + delegate_type[..7].copy_from_slice(b"veriKey"); + + let mut attr_name = [0u8; 32]; + attr_name[..29].copy_from_slice(b"did/pub/Secp256k1/veriKey/hex"); + + let log_100 = make_owner_changed_log(100, &identity, &new_owner, 0); + let log_200 = make_delegate_changed_log( + 200, &identity, &delegate_type, &delegate, u64::MAX, 100, + ); + let log_300 = make_attribute_changed_log( + 300, &identity, &attr_name, b"\x04abc", u64::MAX, 200, + ); + + let provider = MockProvider { + changed_block: 300, + identity_owner: Some(new_owner), + logs: HashMap::from([ + (100, vec![log_100]), + (200, vec![log_200]), + (300, vec![log_300]), + ]), + }; + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 300) + .await + .unwrap(); + + assert_eq!(events.len(), 3); + + // Chronological: block 100 first + assert!(matches!(&events[0], Erc1056Event::OwnerChanged { .. })); + assert!(matches!(&events[1], Erc1056Event::DelegateChanged { .. })); + assert!(matches!(&events[2], Erc1056Event::AttributeChanged { .. })); + + // Verify delegate event details + match &events[1] { + Erc1056Event::DelegateChanged { delegate: d, valid_to, previous_change, .. } => { + assert_eq!(d, &delegate); + assert_eq!(*valid_to, u64::MAX); + assert_eq!(*previous_change, 100); + } + _ => unreachable!(), + } + + // Verify attribute event details + match &events[2] { + Erc1056Event::AttributeChanged { name, value, valid_to, previous_change, .. } => { + assert_eq!(name, &attr_name); + assert_eq!(value, b"\x04abc"); + assert_eq!(*valid_to, u64::MAX); + assert_eq!(*previous_change, 200); + } + _ => unreachable!(), + } + } + #[tokio::test] async fn resolve_with_mock_provider_changed_zero() { // A mock provider where changed(addr) returns 0 should produce @@ -1031,6 +1493,7 @@ mod tests { provider: MockProvider { changed_block: 1, identity_owner: Some(new_owner), + logs: HashMap::new(), }, }, ); @@ -1091,6 +1554,7 @@ mod tests { provider: MockProvider { changed_block: 1, identity_owner: Some(new_owner), + logs: HashMap::new(), }, }, ); @@ -1240,6 +1704,7 @@ mod tests { provider: MockProvider { changed_block: 5, // multiple blocks of changes identity_owner: Some(final_owner), + logs: HashMap::new(), }, }, ); From 03ce6fcd4a98453d503c5bc3f82a8a4a5a486b33 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:29:27 +0100 Subject: [PATCH 09/36] feat: integrate collect_events into resolver, call alongside identityOwner() --- crates/dids/methods/ethr/src/lib.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 46793d50a..26871f8fe 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -384,7 +384,17 @@ impl DIDMethodResolver for DIDEthr

{ let changed_block = decode_uint256(&result); if changed_block > 0 { - // Check identityOwner(addr) + // Collect all events via linked-list walk + let _events = collect_events( + &config.provider, + config.registry, + &addr, + changed_block, + ) + .await + .map_err(Error::Internal)?; + + // Check identityOwner(addr) for current owner let owner_calldata = encode_call(IDENTITY_OWNER_SELECTOR, &addr); let owner_result = config .provider @@ -395,10 +405,12 @@ impl DIDMethodResolver for DIDEthr

{ if owner == addr { // Owner unchanged — use offline (genesis) document + // TODO: Phase 4-5 will process _events for delegates/attributes return resolve_offline(method_specific_id, &decoded_id, options); } // Owner changed — build document with the new owner's address + // TODO: Phase 4-5 will process _events for delegates/attributes let owner_address = format_address_eip55(&owner); return resolve_with_owner( method_specific_id, From 7d4a852fddb6fdd7d78f0ec1ca37e2be5a51cfa2 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:36:44 +0100 Subject: [PATCH 10/36] feat: veriKey delegate support with apply_events refactor --- crates/dids/methods/ethr/src/lib.rs | 276 ++++++++++++++++++++++++---- 1 file changed, 238 insertions(+), 38 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 26871f8fe..ba84e3746 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -385,7 +385,7 @@ impl DIDMethodResolver for DIDEthr

{ if changed_block > 0 { // Collect all events via linked-list walk - let _events = collect_events( + let events = collect_events( &config.provider, config.registry, &addr, @@ -403,21 +403,58 @@ impl DIDMethodResolver for DIDEthr

{ .map_err(|e| Error::Internal(e.to_string()))?; let owner = decode_address(&owner_result); - if owner == addr { - // Owner unchanged — use offline (genesis) document - // TODO: Phase 4-5 will process _events for delegates/attributes - return resolve_offline(method_specific_id, &decoded_id, options); - } - - // Owner changed — build document with the new owner's address - // TODO: Phase 4-5 will process _events for delegates/attributes - let owner_address = format_address_eip55(&owner); - return resolve_with_owner( - method_specific_id, + // Build base document from owner + let mut json_ld_context = JsonLdContext::default(); + let (mut doc, _account_address) = if owner == addr { + // Owner unchanged — build from the DID's own identity + let doc = match decoded_id.address_or_public_key.len() { + 42 => resolve_address( + &mut json_ld_context, + method_specific_id, + &decoded_id.network_chain, + &decoded_id.address_or_public_key, + ), + 68 => resolve_public_key( + &mut json_ld_context, + method_specific_id, + &decoded_id.network_chain, + &decoded_id.address_or_public_key, + ), + _ => Err(Error::InvalidMethodSpecificId( + method_specific_id.to_owned(), + )), + }?; + (doc, decoded_id.address_or_public_key.clone()) + } else { + // Owner changed — build with the new owner's address + let owner_address = format_address_eip55(&owner); + let doc = resolve_address( + &mut json_ld_context, + method_specific_id, + &decoded_id.network_chain, + &owner_address, + )?; + (doc, owner_address) + }; + + // Apply delegate/attribute events + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")) + .unwrap(); + apply_events( + &mut doc, + &events, + &did, &decoded_id.network_chain, - &owner_address, - options, + &mut json_ld_context, + now, ); + + return serialize_document(doc, json_ld_context, options); } } } @@ -440,6 +477,117 @@ impl DIDMethodResolver for DIDEthr<()> { } } +/// Decode the delegate_type bytes32 field by trimming trailing zeros +fn decode_delegate_type(delegate_type: &[u8; 32]) -> &[u8] { + let end = delegate_type + .iter() + .rposition(|&b| b != 0) + .map(|i| i + 1) + .unwrap_or(0); + &delegate_type[..end] +} + +/// Process ERC-1056 events and add delegate verification methods to the document. +/// +/// `now` is the current timestamp (seconds since epoch) used for expiry checks. +/// The delegate counter increments for every DelegateChanged event regardless of +/// validity, ensuring stable `#delegate-N` IDs. +fn apply_events( + doc: &mut Document, + events: &[Erc1056Event], + did: &DIDBuf, + network_chain: &NetworkChain, + json_ld_context: &mut JsonLdContext, + now: u64, +) { + let mut delegate_counter = 0u64; + + for event in events { + match event { + Erc1056Event::DelegateChanged { + delegate_type, + delegate, + valid_to, + .. + } => { + delegate_counter += 1; + let dt = decode_delegate_type(delegate_type); + + let is_veri_key = dt == b"veriKey"; + let is_sig_auth = dt == b"sigAuth"; + + if !is_veri_key && !is_sig_auth { + continue; + } + + // Skip expired/revoked delegates + if *valid_to < now { + continue; + } + + let delegate_addr = format_address_eip55(delegate); + let blockchain_account_id = BlockchainAccountId { + account_address: delegate_addr, + chain_id: ChainId { + namespace: "eip155".to_string(), + reference: network_chain.id().to_string(), + }, + }; + + let vm_id = format!("{did}#delegate-{delegate_counter}"); + let eip712_id = format!("{did}#delegate-{delegate_counter}-Eip712Method2021"); + + let vm = VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { + id: DIDURLBuf::from_string(vm_id).unwrap(), + controller: did.clone(), + blockchain_account_id: blockchain_account_id.clone(), + }; + + let eip712_vm = VerificationMethod::Eip712Method2021 { + id: DIDURLBuf::from_string(eip712_id).unwrap(), + controller: did.clone(), + blockchain_account_id, + }; + + json_ld_context.add_verification_method_type(vm.type_()); + json_ld_context.add_verification_method_type(eip712_vm.type_()); + + let vm_ref = vm.id().to_owned().into(); + let eip712_ref = eip712_vm.id().to_owned().into(); + + doc.verification_method.push(vm.into()); + doc.verification_method.push(eip712_vm.into()); + + doc.verification_relationships + .assertion_method + .push(vm_ref); + doc.verification_relationships + .assertion_method + .push(eip712_ref); + + if is_sig_auth { + let vm_ref2 = doc.verification_method[doc.verification_method.len() - 2] + .id + .to_owned() + .into(); + let eip712_ref2 = doc.verification_method[doc.verification_method.len() - 1] + .id + .to_owned() + .into(); + doc.verification_relationships + .authentication + .push(vm_ref2); + doc.verification_relationships + .authentication + .push(eip712_ref2); + } + } + // OwnerChanged and AttributeChanged are handled elsewhere (Phase 2 / Phase 5) + _ => {} + } + } +} + /// Resolve a DID using the offline (genesis document) path fn resolve_offline( method_specific_id: &str, @@ -469,30 +617,6 @@ fn resolve_offline( serialize_document(doc, json_ld_context, options) } -/// Resolve a DID when the on-chain owner differs from the identity address. -/// -/// Both address-based and public-key DIDs with a changed owner produce the -/// same document shape: `#controller` + `Eip712Method2021`, using the owner's -/// address. For public-key DIDs this means `#controllerKey` is implicitly -/// omitted since the public key no longer represents the current owner. -fn resolve_with_owner( - method_specific_id: &str, - network_chain: &NetworkChain, - owner_address: &str, - options: resolution::Options, -) -> Result>, Error> { - let mut json_ld_context = JsonLdContext::default(); - - let doc = resolve_address( - &mut json_ld_context, - method_specific_id, - network_chain, - owner_address, - )?; - - serialize_document(doc, json_ld_context, options) -} - fn serialize_document( doc: Document, json_ld_context: JsonLdContext, @@ -1696,6 +1820,82 @@ mod tests { assert!(key_vm.get("publicKeyJwk").is_some(), "#controllerKey should have publicKeyJwk"); } + /// Helper: encode a delegate type string as bytes32 (right-padded with zeros) + fn encode_delegate_type(s: &str) -> [u8; 32] { + let mut b = [0u8; 32]; + let bytes = s.as_bytes(); + b[..bytes.len().min(32)].copy_from_slice(&bytes[..bytes.len().min(32)]); + b + } + + #[tokio::test] + async fn resolve_verikey_delegate_adds_vm() { + // A DIDDelegateChanged event with delegate_type="veriKey" and valid_to=MAX + // should add EcdsaSecp256k1RecoveryMethod2020 + Eip712Method2021 VMs + // with #delegate-1 and #delegate-1-Eip712Method2021 IDs. + // The delegate VM is referenced in assertionMethod but NOT authentication. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, // same as identity + logs: HashMap::from([(100, vec![log])]), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); + + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have 4 VMs: #controller, #Eip712Method2021, #delegate-1, #delegate-1-Eip712Method2021 + assert_eq!(vms.len(), 4, "expected 4 VMs, got {}", vms.len()); + + // Check #delegate-1 VM + let delegate_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM"); + assert_eq!(delegate_vm["type"], "EcdsaSecp256k1RecoveryMethod2020"); + let delegate_addr = format_address_eip55(&delegate); + let expected_account_id = format!("eip155:1:{}", delegate_addr); + assert_eq!(delegate_vm["blockchainAccountId"], expected_account_id); + + // Check #delegate-1-Eip712Method2021 VM + let delegate_eip712 = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1-Eip712Method2021") + }).expect("should have #delegate-1-Eip712Method2021 VM"); + assert_eq!(delegate_eip712["type"], "Eip712Method2021"); + assert_eq!(delegate_eip712["blockchainAccountId"], expected_account_id); + + // #delegate-1 should be in assertionMethod but NOT authentication + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + let auth = doc_value["authentication"].as_array().unwrap(); + + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); + assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); + } + #[tokio::test] async fn resolve_with_mock_provider_multiple_owner_changes() { // Simulate multiple ownership transfers: identityOwner() returns the From 35375da33605a56f026c5bfd5b99cfd03531e3d4 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:37:48 +0100 Subject: [PATCH 11/36] test: sigAuth, expired, revoked, multiple delegates, owner+delegate integration tests --- crates/dids/methods/ethr/src/lib.rs | 266 ++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index ba84e3746..e0025d792 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -1896,6 +1896,272 @@ mod tests { assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); } + #[tokio::test] + async fn resolve_sigauth_delegate_also_in_authentication() { + // A DIDDelegateChanged with delegate_type="sigAuth" should add VMs + // referenced in BOTH assertionMethod AND authentication. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xBB; 20]; + let delegate_type = encode_delegate_type("sigAuth"); + + let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + + let vms = doc_value["verificationMethod"].as_array().unwrap(); + assert_eq!(vms.len(), 4); + + // #delegate-1 should be in BOTH assertionMethod AND authentication + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + let auth = doc_value["authentication"].as_array().unwrap(); + + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); + assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); + } + + #[tokio::test] + async fn resolve_expired_delegate_not_included() { + // A delegate with valid_to < now is NOT included in the document. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xCC; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + // valid_to = 1000 (well in the past) + let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, 1000, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should only have the 2 base VMs (no delegate VMs) + assert_eq!(vms.len(), 2, "expired delegate should not be in document"); + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().contains("delegate"))); + } + + #[tokio::test] + async fn resolve_revoked_delegate_skipped_but_counter_increments() { + // A revoked delegate (valid_to=0) is not included, but the counter + // still increments. So a subsequent valid delegate gets #delegate-2. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0xDD; 20]; + let delegate_b: [u8; 20] = [0xEE; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + // First delegate: revoked (valid_to=0) + let log_a = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, 0, 0); + // Second delegate: valid + let log_b = make_delegate_changed_log(200, &identity, &delegate_type, &delegate_b, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([ + (100, vec![log_a]), + (200, vec![log_b]), + ]), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have 4 VMs: 2 base + 2 delegate (only delegate_b) + assert_eq!(vms.len(), 4); + + // No #delegate-1 (revoked) + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-1"))); + + // Has #delegate-2 (counter still incremented past revoked) + let delegate_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-2") + }).expect("should have #delegate-2 VM"); + + let delegate_addr = format_address_eip55(&delegate_b); + assert_eq!(delegate_vm["blockchainAccountId"], format!("eip155:1:{delegate_addr}")); + } + + #[tokio::test] + async fn resolve_multiple_valid_delegates_sequential_ids() { + // Multiple valid delegates produce sequential #delegate-1, #delegate-2, etc. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0x11; 20]; + let delegate_b: [u8; 20] = [0x22; 20]; + let veri_key = encode_delegate_type("veriKey"); + let sig_auth = encode_delegate_type("sigAuth"); + + let log_a = make_delegate_changed_log(100, &identity, &veri_key, &delegate_a, u64::MAX, 0); + let log_b = make_delegate_changed_log(200, &identity, &sig_auth, &delegate_b, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([ + (100, vec![log_a]), + (200, vec![log_b]), + ]), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 2 for delegate_a + 2 for delegate_b = 6 + assert_eq!(vms.len(), 6); + + // #delegate-1 is veriKey (assertionMethod only, NOT authentication) + assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1"))); + assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1-Eip712Method2021"))); + + // #delegate-2 is sigAuth (both assertionMethod AND authentication) + assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2"))); + assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2-Eip712Method2021"))); + + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + let auth = doc_value["authentication"].as_array().unwrap(); + + // delegate-1 should NOT be in auth (veriKey) + assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + + // delegate-2 SHOULD be in auth (sigAuth) + assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-2"))); + assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-2-Eip712Method2021"))); + } + + #[tokio::test] + async fn resolve_delegates_with_owner_change_integration() { + // When the owner has changed AND there are delegates, both the + // owner-derived controller VM and the delegate VMs appear. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let new_owner: [u8; 20] = [0xFF; 20]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + let log_owner = make_owner_changed_log(100, &identity, &new_owner, 0); + let log_delegate = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: Some(new_owner), + logs: HashMap::from([ + (100, vec![log_owner]), + (200, vec![log_delegate]), + ]), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base (with new owner) + 2 delegate = 4 + assert_eq!(vms.len(), 4); + + // #controller uses the new owner's address + let controller_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#controller") + }).unwrap(); + let owner_addr = format_address_eip55(&new_owner); + assert_eq!( + controller_vm["blockchainAccountId"], + format!("eip155:1:{owner_addr}") + ); + + // #delegate-1 uses the delegate's address + let delegate_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).unwrap(); + let delegate_addr = format_address_eip55(&delegate); + assert_eq!( + delegate_vm["blockchainAccountId"], + format!("eip155:1:{delegate_addr}") + ); + } + #[tokio::test] async fn resolve_with_mock_provider_multiple_owner_changes() { // Simulate multiple ownership transfers: identityOwner() returns the From 9033f028070f62bf337f66912ac0d58d7a43934e Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:38:11 +0100 Subject: [PATCH 12/36] refactor: simplify delegate VM reference construction --- crates/dids/methods/ethr/src/lib.rs | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index e0025d792..f43f9940f 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -537,14 +537,17 @@ fn apply_events( let vm_id = format!("{did}#delegate-{delegate_counter}"); let eip712_id = format!("{did}#delegate-{delegate_counter}-Eip712Method2021"); + let vm_id_url = DIDURLBuf::from_string(vm_id).unwrap(); + let eip712_id_url = DIDURLBuf::from_string(eip712_id).unwrap(); + let vm = VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { - id: DIDURLBuf::from_string(vm_id).unwrap(), + id: vm_id_url.clone(), controller: did.clone(), blockchain_account_id: blockchain_account_id.clone(), }; let eip712_vm = VerificationMethod::Eip712Method2021 { - id: DIDURLBuf::from_string(eip712_id).unwrap(), + id: eip712_id_url.clone(), controller: did.clone(), blockchain_account_id, }; @@ -552,34 +555,23 @@ fn apply_events( json_ld_context.add_verification_method_type(vm.type_()); json_ld_context.add_verification_method_type(eip712_vm.type_()); - let vm_ref = vm.id().to_owned().into(); - let eip712_ref = eip712_vm.id().to_owned().into(); - doc.verification_method.push(vm.into()); doc.verification_method.push(eip712_vm.into()); doc.verification_relationships .assertion_method - .push(vm_ref); + .push(vm_id_url.clone().into()); doc.verification_relationships .assertion_method - .push(eip712_ref); + .push(eip712_id_url.clone().into()); if is_sig_auth { - let vm_ref2 = doc.verification_method[doc.verification_method.len() - 2] - .id - .to_owned() - .into(); - let eip712_ref2 = doc.verification_method[doc.verification_method.len() - 1] - .id - .to_owned() - .into(); doc.verification_relationships .authentication - .push(vm_ref2); + .push(vm_id_url.into()); doc.verification_relationships .authentication - .push(eip712_ref2); + .push(eip712_id_url.into()); } } // OwnerChanged and AttributeChanged are handled elsewhere (Phase 2 / Phase 5) From f4f3614a898ac579a9be2c7a25b20184152b10c6 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:42:31 +0100 Subject: [PATCH 13/36] feat: secp256k1 veriKey encoding attribute support in apply_events --- crates/dids/methods/ethr/Cargo.toml | 2 + .../dids/methods/ethr/src/json_ld_context.rs | 62 ++++++ crates/dids/methods/ethr/src/lib.rs | 196 +++++++++++++++++- 3 files changed, 259 insertions(+), 1 deletion(-) diff --git a/crates/dids/methods/ethr/Cargo.toml b/crates/dids/methods/ethr/Cargo.toml index 1f3bb363f..362fc161f 100644 --- a/crates/dids/methods/ethr/Cargo.toml +++ b/crates/dids/methods/ethr/Cargo.toml @@ -15,12 +15,14 @@ documentation = "https://docs.rs/did-ethr/" ssi-dids-core.workspace = true ssi-caips = { workspace = true, features = ["eip"] } ssi-jwk.workspace = true +ssi-core.workspace = true iref.workspace = true static-iref.workspace = true thiserror.workspace = true hex.workspace = true serde_json.workspace = true ssi-crypto = { workspace = true, features = ["keccak"] } +base64.workspace = true [dev-dependencies] tokio = { version = "1.0", features = ["macros"] } diff --git a/crates/dids/methods/ethr/src/json_ld_context.rs b/crates/dids/methods/ethr/src/json_ld_context.rs index be536de29..386ba5b87 100644 --- a/crates/dids/methods/ethr/src/json_ld_context.rs +++ b/crates/dids/methods/ethr/src/json_ld_context.rs @@ -17,6 +17,10 @@ pub struct JsonLdContext { ecdsa_secp256k1_verification_key_2019: bool, ecdsa_secp256k1_recovery_method_2020: bool, eip712_method_2021: bool, + public_key_hex: bool, + public_key_base64: bool, + public_key_base58: bool, + public_key_pem: bool, } impl JsonLdContext { @@ -32,6 +36,16 @@ impl JsonLdContext { } } + pub fn add_property(&mut self, prop: &str) { + match prop { + "publicKeyHex" => self.public_key_hex = true, + "publicKeyBase64" => self.public_key_base64 = true, + "publicKeyBase58" => self.public_key_base58 = true, + "publicKeyPem" => self.public_key_pem = true, + _ => {} + } + } + pub fn into_entries(self) -> Vec { let mut def = Definition::new(); @@ -108,6 +122,54 @@ impl JsonLdContext { ); } + if self.public_key_hex { + def.bindings.insert( + "publicKeyHex".into(), + TermDefinition::Simple( + iri!("https://w3id.org/security#publicKeyHex") + .to_owned() + .into(), + ) + .into(), + ); + } + + if self.public_key_base64 { + def.bindings.insert( + "publicKeyBase64".into(), + TermDefinition::Simple( + iri!("https://w3id.org/security#publicKeyBase64") + .to_owned() + .into(), + ) + .into(), + ); + } + + if self.public_key_base58 { + def.bindings.insert( + "publicKeyBase58".into(), + TermDefinition::Simple( + iri!("https://w3id.org/security#publicKeyBase58") + .to_owned() + .into(), + ) + .into(), + ); + } + + if self.public_key_pem { + def.bindings.insert( + "publicKeyPem".into(), + TermDefinition::Simple( + iri!("https://w3id.org/security#publicKeyPem") + .to_owned() + .into(), + ) + .into(), + ); + } + vec![representation::json_ld::ContextEntry::Definition(def)] } } diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index f43f9940f..9f2d960fa 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -15,6 +15,8 @@ use static_iref::iri; use std::collections::HashMap; use std::str::FromStr; +use base64::Engine as _; + mod json_ld_context; use json_ld_context::JsonLdContext; use ssi_jwk::JWK; @@ -501,6 +503,7 @@ fn apply_events( now: u64, ) { let mut delegate_counter = 0u64; + let mut service_counter = 0u64; for event in events { match event { @@ -574,7 +577,142 @@ fn apply_events( .push(eip712_id_url.into()); } } - // OwnerChanged and AttributeChanged are handled elsewhere (Phase 2 / Phase 5) + Erc1056Event::AttributeChanged { + name, + value, + valid_to, + .. + } => { + let attr_name = decode_delegate_type(name); // trims trailing zeros + let attr_str = match std::str::from_utf8(attr_name) { + Ok(s) => s, + Err(_) => continue, + }; + let parts: Vec<&str> = attr_str.split('/').collect(); + + if parts.len() >= 3 && parts[0] == "did" && parts[1] == "pub" { + // did/pub/// + delegate_counter += 1; + + if *valid_to < now { + continue; + } + + let algo = parts.get(2).copied().unwrap_or(""); + let purpose = parts.get(3).copied().unwrap_or(""); + let encoding = parts.get(4).copied().unwrap_or("hex"); + + let vm_type = match algo { + "Secp256k1" => "EcdsaSecp256k1VerificationKey2019", + "Ed25519" => "Ed25519VerificationKey2018", + "X25519" => "X25519KeyAgreementKey2019", + _ => continue, + }; + + let prop_name = match encoding { + "hex" | "" => "publicKeyHex", + "base64" => "publicKeyBase64", + "base58" => "publicKeyBase58", + "pem" => "publicKeyPem", + _ => continue, + }; + + let prop_value = match encoding { + "hex" | "" => hex::encode(value), + "base64" => base64::engine::general_purpose::STANDARD.encode(value), + _ => String::from_utf8_lossy(value).into_owned(), + }; + + let vm_id = format!("{did}#delegate-{delegate_counter}"); + let vm_id_url = DIDURLBuf::from_string(vm_id).unwrap(); + + let vm = DIDVerificationMethod { + id: vm_id_url.clone(), + type_: vm_type.to_owned(), + controller: did.clone(), + properties: [( + prop_name.into(), + serde_json::Value::String(prop_value), + )] + .into_iter() + .collect(), + }; + + // Add context entries + match algo { + "Secp256k1" => json_ld_context.add_verification_method_type( + VerificationMethodType::EcdsaSecp256k1VerificationKey2019, + ), + _ => {} // Ed25519/X25519 context handled in Phase 10+ + } + json_ld_context.add_property(prop_name); + + doc.verification_method.push(vm); + + let is_veri_key = purpose == "veriKey"; + let is_sig_auth = purpose == "sigAuth"; + let is_enc = purpose == "enc"; + + if is_veri_key || is_sig_auth { + doc.verification_relationships + .assertion_method + .push(vm_id_url.clone().into()); + } + + if is_sig_auth { + doc.verification_relationships + .authentication + .push(vm_id_url.clone().into()); + } + + if is_enc { + doc.verification_relationships + .key_agreement + .push(vm_id_url.into()); + } + } else if parts.len() >= 3 && parts[0] == "did" && parts[1] == "svc" { + // did/svc/ + service_counter += 1; + + if *valid_to < now { + continue; + } + + let service_type = parts[2..].join("/"); + let service_id = format!("{did}#service-{service_counter}"); + + let endpoint_str = String::from_utf8_lossy(value); + let endpoint = if let Ok(json_val) = serde_json::from_str::(&endpoint_str) { + if json_val.is_object() || json_val.is_array() { + document::service::Endpoint::Map(json_val) + } else { + match iref::UriBuf::new(endpoint_str.as_bytes().to_vec()) { + Ok(uri) => document::service::Endpoint::Uri(uri), + Err(e) => document::service::Endpoint::Map( + serde_json::Value::String(String::from_utf8_lossy(&e.0).into_owned()), + ), + } + } + } else { + match iref::UriBuf::new(endpoint_str.as_bytes().to_vec()) { + Ok(uri) => document::service::Endpoint::Uri(uri), + Err(e) => document::service::Endpoint::Map( + serde_json::Value::String(String::from_utf8_lossy(&e.0).into_owned()), + ), + } + }; + + let service = document::Service { + id: iref::UriBuf::new(service_id.into_bytes()).unwrap(), + type_: ssi_core::one_or_many::OneOrMany::One(service_type), + service_endpoint: Some(ssi_core::one_or_many::OneOrMany::One(endpoint)), + property_set: std::collections::BTreeMap::new(), + }; + + doc.service.push(service); + } + } + // OwnerChanged handled by identityOwner() (Phase 2) _ => {} } } @@ -2154,6 +2292,62 @@ mod tests { ); } + /// Helper: encode an attribute name string as bytes32 (right-padded with zeros) + fn encode_attr_name(s: &str) -> [u8; 32] { + let mut b = [0u8; 32]; + let bytes = s.as_bytes(); + b[..bytes.len().min(32)].copy_from_slice(&bytes[..bytes.len().min(32)]); + b + } + + #[tokio::test] + async fn resolve_secp256k1_verikey_hex_attribute() { + // did/pub/Secp256k1/veriKey/hex attribute adds EcdsaSecp256k1VerificationKey2019 + // with publicKeyHex to verificationMethod + assertionMethod, using #delegate-N ID + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); + let pub_key_value: Vec = vec![0x04, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67]; + + let log = make_attribute_changed_log(100, &identity, &attr_name, &pub_key_value, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 1 attribute key = 3 + assert_eq!(vms.len(), 3); + + let attr_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM from attribute"); + assert_eq!(attr_vm["type"], "EcdsaSecp256k1VerificationKey2019"); + assert_eq!(attr_vm["publicKeyHex"], hex::encode(&pub_key_value)); + assert_eq!(attr_vm["controller"], "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"); + + // Should be in assertionMethod but NOT authentication + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + let auth = doc_value["authentication"].as_array().unwrap(); + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + } + #[tokio::test] async fn resolve_with_mock_provider_multiple_owner_changes() { // Simulate multiple ownership transfers: identityOwner() returns the From 2e1e2651c45cf93af2bbe48bd028f7283da5333c Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:44:13 +0100 Subject: [PATCH 14/36] =?UTF-8?q?test:=20attribute=20support=20=E2=80=94?= =?UTF-8?q?=20sigAuth,=20services=20(URL+JSON),=20expiry,=20shared=20count?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/dids/methods/ethr/src/lib.rs | 239 ++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 9f2d960fa..ce625bd9e 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -2229,6 +2229,245 @@ mod tests { assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-2-Eip712Method2021"))); } + #[tokio::test] + async fn resolve_delegate_and_attribute_key_share_counter() { + // Delegate events and attribute pub key events share the same delegate + // counter. A delegate at block 100 gets #delegate-1, an attribute key + // at block 200 gets #delegate-2. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); + let pub_key: Vec = vec![0x04, 0xab, 0xcd]; + + let log_delegate = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + let log_attr = make_attribute_changed_log(200, &identity, &attr_name, &pub_key, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([(100, vec![log_delegate]), (200, vec![log_attr])]), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 2 delegate (RecoveryMethod + Eip712) + 1 attribute key = 5 + assert_eq!(vms.len(), 5); + + // #delegate-1 is the delegate event (EcdsaSecp256k1RecoveryMethod2020) + let d1 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1")).unwrap(); + assert_eq!(d1["type"], "EcdsaSecp256k1RecoveryMethod2020"); + + // #delegate-2 is the attribute key (EcdsaSecp256k1VerificationKey2019) + let d2 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2")).unwrap(); + assert_eq!(d2["type"], "EcdsaSecp256k1VerificationKey2019"); + assert_eq!(d2["publicKeyHex"], hex::encode(&pub_key)); + } + + #[tokio::test] + async fn resolve_multiple_services_sequential_ids() { + // Multiple did/svc attributes produce #service-1, #service-2, etc. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_hub = encode_attr_name("did/svc/HubService"); + let attr_msg = encode_attr_name("did/svc/MessagingService"); + + let log_a = make_attribute_changed_log(100, &identity, &attr_hub, b"https://hub.example.com", u64::MAX, 0); + let log_b = make_attribute_changed_log(200, &identity, &attr_msg, b"https://msg.example.com", u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([(100, vec![log_a]), (200, vec![log_b])]), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let services = doc_value["service"].as_array().unwrap(); + assert_eq!(services.len(), 2); + + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + assert_eq!(services[0]["id"], format!("{did_prefix}#service-1")); + assert_eq!(services[0]["type"], "HubService"); + assert_eq!(services[1]["id"], format!("{did_prefix}#service-2")); + assert_eq!(services[1]["type"], "MessagingService"); + } + + #[tokio::test] + async fn resolve_expired_attribute_excluded_counter_increments() { + // An expired attribute (valid_to < now) is excluded but the delegate + // counter still increments, so the next valid entry gets #delegate-2. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); + let pub_key_a: Vec = vec![0x04, 0xaa]; + let pub_key_b: Vec = vec![0x04, 0xbb]; + + // First key: expired (valid_to = 1000, well in the past) + let log_a = make_attribute_changed_log(100, &identity, &attr_name, &pub_key_a, 1000, 0); + // Second key: valid + let log_b = make_attribute_changed_log(200, &identity, &attr_name, &pub_key_b, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([(100, vec![log_a]), (200, vec![log_b])]), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 1 valid attribute key = 3 (expired one excluded) + assert_eq!(vms.len(), 3); + + // No #delegate-1 (expired) + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-1"))); + + // Has #delegate-2 (counter incremented past expired) + let vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-2") + }).expect("should have #delegate-2 VM"); + assert_eq!(vm["publicKeyHex"], hex::encode(&pub_key_b)); + } + + #[tokio::test] + async fn resolve_service_endpoint_json() { + // did/svc/MessagingService with JSON object value → structured serviceEndpoint + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/svc/MessagingService"); + let endpoint = br#"{"uri":"https://msg.example.com","accept":["didcomm/v2"]}"#; + + let log = make_attribute_changed_log(100, &identity, &attr_name, endpoint, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let services = doc_value["service"].as_array().unwrap(); + assert_eq!(services.len(), 1); + + let svc = &services[0]; + assert_eq!(svc["type"], "MessagingService"); + // JSON endpoint should be a parsed object, not a string + assert!(svc["serviceEndpoint"].is_object()); + assert_eq!(svc["serviceEndpoint"]["uri"], "https://msg.example.com"); + assert_eq!(svc["serviceEndpoint"]["accept"], json!(["didcomm/v2"])); + } + + #[tokio::test] + async fn resolve_service_endpoint_url() { + // did/svc/HubService with URL string → service entry + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/svc/HubService"); + let endpoint = b"https://hubs.uport.me"; + + let log = make_attribute_changed_log(100, &identity, &attr_name, endpoint, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); + let services = doc_value["service"].as_array().unwrap(); + assert_eq!(services.len(), 1); + + let svc = &services[0]; + assert_eq!(svc["id"], "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#service-1"); + assert_eq!(svc["type"], "HubService"); + assert_eq!(svc["serviceEndpoint"], "https://hubs.uport.me"); + } + + #[tokio::test] + async fn resolve_secp256k1_sigauth_hex_attribute_in_authentication() { + // did/pub/Secp256k1/sigAuth/hex attribute adds VM referenced in + // verificationMethod + assertionMethod + authentication + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Secp256k1/sigAuth/hex"); + let pub_key_value: Vec = vec![0x04, 0xde, 0xad, 0xbe, 0xef]; + + let log = make_attribute_changed_log(100, &identity, &attr_name, &pub_key_value, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + let auth = doc_value["authentication"].as_array().unwrap(); + + // sigAuth should be in BOTH assertionMethod AND authentication + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + } + #[tokio::test] async fn resolve_delegates_with_owner_change_integration() { // When the owner has changed AND there are delegates, both the From 1dfb4f391e843e681df37d2a075a88e664f1e6af Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:48:05 +0100 Subject: [PATCH 15/36] feat: deactivation on null owner address --- crates/dids/methods/ethr/src/lib.rs | 126 +++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 3 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index ce625bd9e..43433778a 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -405,6 +405,16 @@ impl DIDMethodResolver for DIDEthr

{ .map_err(|e| Error::Internal(e.to_string()))?; let owner = decode_address(&owner_result); + // Check for deactivation (owner = null address) + const NULL_ADDRESS: [u8; 20] = [0u8; 20]; + if owner == NULL_ADDRESS { + let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")).unwrap(); + let doc = Document::new(did); + let json_ld_context = JsonLdContext::default(); + let doc_metadata = document::Metadata { deactivated: Some(true) }; + return serialize_document(doc, json_ld_context, options, doc_metadata); + } + // Build base document from owner let mut json_ld_context = JsonLdContext::default(); let (mut doc, _account_address) = if owner == addr { @@ -456,7 +466,7 @@ impl DIDMethodResolver for DIDEthr

{ now, ); - return serialize_document(doc, json_ld_context, options); + return serialize_document(doc, json_ld_context, options, document::Metadata::default()); } } } @@ -744,13 +754,14 @@ fn resolve_offline( )), }?; - serialize_document(doc, json_ld_context, options) + serialize_document(doc, json_ld_context, options, document::Metadata::default()) } fn serialize_document( doc: Document, json_ld_context: JsonLdContext, options: resolution::Options, + doc_metadata: document::Metadata, ) -> Result>, Error> { let content_type = options.accept.unwrap_or(MediaType::JsonLd); let represented = doc.into_representation(representation::Options::from_media_type( @@ -765,7 +776,7 @@ fn serialize_document( Ok(resolution::Output::new( represented.to_bytes(), - document::Metadata::default(), + doc_metadata, resolution::Metadata::from_content_type(Some(content_type.to_string())), )) } @@ -2643,4 +2654,113 @@ mod tests { expected_account_id, ); } + + #[tokio::test] + async fn resolve_deactivated_null_owner_bare_doc() { + // When identityOwner() returns the null address (0x000...0), + // the DID is deactivated: bare doc with only `id`, empty VMs, + // and document_metadata.deactivated = true. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let null_owner: [u8; 20] = [0u8; 20]; + + let log = make_owner_changed_log(100, &identity, &null_owner, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: Some(null_owner), + logs: HashMap::from([(100, vec![log])]), + }, + }); + + let output = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + + // Document metadata must have deactivated = true + assert_eq!(output.document_metadata.deactivated, Some(true)); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + + // ID preserved + assert_eq!(doc_value["id"], "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"); + + // Empty verificationMethod, authentication, assertionMethod + let vms = doc_value.get("verificationMethod"); + assert!(vms.is_none() || vms.unwrap().as_array().map_or(true, |a| a.is_empty())); + let auth = doc_value.get("authentication"); + assert!(auth.is_none() || auth.unwrap().as_array().map_or(true, |a| a.is_empty())); + let assertion = doc_value.get("assertionMethod"); + assert!(assertion.is_none() || assertion.unwrap().as_array().map_or(true, |a| a.is_empty())); + } + + #[tokio::test] + async fn resolve_deactivated_ignores_events() { + // Even with delegate/attribute events, deactivation discards them all. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let null_owner: [u8; 20] = [0u8; 20]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + let attr_name = encode_attr_name("did/svc/HubService"); + + let log_owner = make_owner_changed_log(100, &identity, &null_owner, 0); + let log_delegate = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, u64::MAX, 100); + let log_attr = make_attribute_changed_log(300, &identity, &attr_name, b"https://hub.example.com", u64::MAX, 200); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 300, + identity_owner: Some(null_owner), + logs: HashMap::from([ + (100, vec![log_owner]), + (200, vec![log_delegate]), + (300, vec![log_attr]), + ]), + }, + }); + + let output = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + + assert_eq!(output.document_metadata.deactivated, Some(true)); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + + // No VMs, no services — all events discarded + let vms = doc_value.get("verificationMethod"); + assert!(vms.is_none() || vms.unwrap().as_array().map_or(true, |a| a.is_empty())); + let services = doc_value.get("service"); + assert!(services.is_none() || services.unwrap().as_array().map_or(true, |a| a.is_empty())); + } + + #[tokio::test] + async fn resolve_non_null_owner_not_deactivated() { + // When the owner is non-null, deactivated should be None (not set). + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider::new_same_owner(), + }); + + let output = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + + // deactivated should be None (default) + assert!(output.document_metadata.deactivated.is_none() + || output.document_metadata.deactivated == Some(false)); + } } From cba9138c2e2c10b062d13b144737cc77bf0d8f02 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:54:36 +0100 Subject: [PATCH 16/36] feat: add version_id/updated to Metadata, block_timestamps to MockProvider --- crates/dids/core/src/document.rs | 7 ++++++- crates/dids/methods/ethr/src/lib.rs | 31 ++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/dids/core/src/document.rs b/crates/dids/core/src/document.rs index c756d8670..0b336929b 100644 --- a/crates/dids/core/src/document.rs +++ b/crates/dids/core/src/document.rs @@ -170,10 +170,15 @@ pub enum InvalidData { } /// Document metadata. -#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Metadata { + #[serde(skip_serializing_if = "Option::is_none")] pub deactivated: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated: Option, } #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)] diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 43433778a..390fb9656 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -411,7 +411,7 @@ impl DIDMethodResolver for DIDEthr

{ let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")).unwrap(); let doc = Document::new(did); let json_ld_context = JsonLdContext::default(); - let doc_metadata = document::Metadata { deactivated: Some(true) }; + let doc_metadata = document::Metadata { deactivated: Some(true), ..Default::default() }; return serialize_document(doc, json_ld_context, options, doc_metadata); } @@ -1378,6 +1378,8 @@ mod tests { identity_owner: Option<[u8; 20]>, /// Logs to return for get_logs calls, keyed by block number logs: HashMap>, + /// Block timestamps to return for block_timestamp calls + block_timestamps: HashMap, } impl MockProvider { @@ -1386,6 +1388,7 @@ mod tests { changed_block: 0, identity_owner: None, logs: HashMap::new(), + block_timestamps: HashMap::new(), } } @@ -1394,6 +1397,7 @@ mod tests { changed_block: 1, // has changes identity_owner: None, // but owner is the same logs: HashMap::new(), + block_timestamps: HashMap::new(), } } } @@ -1466,8 +1470,8 @@ mod tests { Ok(result) } - async fn block_timestamp(&self, _block: u64) -> Result { - Ok(0) + async fn block_timestamp(&self, block: u64) -> Result { + Ok(self.block_timestamps.get(&block).copied().unwrap_or(0)) } } @@ -1585,6 +1589,7 @@ mod tests { changed_block: 100, identity_owner: Some(new_owner), logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), }; let events = collect_events(&provider, TEST_REGISTRY, &identity, 100) @@ -1621,6 +1626,7 @@ mod tests { (100, vec![log_at_100]), (200, vec![log_at_200]), ]), + block_timestamps: HashMap::new(), }; let events = collect_events(&provider, TEST_REGISTRY, &identity, 200) @@ -1679,6 +1685,7 @@ mod tests { (200, vec![log_200]), (300, vec![log_300]), ]), + block_timestamps: HashMap::new(), }; let events = collect_events(&provider, TEST_REGISTRY, &identity, 300) @@ -1771,6 +1778,7 @@ mod tests { changed_block: 1, identity_owner: Some(new_owner), logs: HashMap::new(), + block_timestamps: HashMap::new(), }, }, ); @@ -1832,6 +1840,7 @@ mod tests { changed_block: 1, identity_owner: Some(new_owner), logs: HashMap::new(), + block_timestamps: HashMap::new(), }, }, ); @@ -1992,6 +2001,7 @@ mod tests { changed_block: 100, identity_owner: None, // same as identity logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), }, }, ); @@ -2058,6 +2068,7 @@ mod tests { changed_block: 100, identity_owner: None, logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), }, }, ); @@ -2105,6 +2116,7 @@ mod tests { changed_block: 100, identity_owner: None, logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), }, }, ); @@ -2151,6 +2163,7 @@ mod tests { (100, vec![log_a]), (200, vec![log_b]), ]), + block_timestamps: HashMap::new(), }, }, ); @@ -2205,6 +2218,7 @@ mod tests { (100, vec![log_a]), (200, vec![log_b]), ]), + block_timestamps: HashMap::new(), }, }, ); @@ -2263,6 +2277,7 @@ mod tests { changed_block: 200, identity_owner: None, logs: HashMap::from([(100, vec![log_delegate]), (200, vec![log_attr])]), + block_timestamps: HashMap::new(), }, }); @@ -2305,6 +2320,7 @@ mod tests { changed_block: 200, identity_owner: None, logs: HashMap::from([(100, vec![log_a]), (200, vec![log_b])]), + block_timestamps: HashMap::new(), }, }); @@ -2346,6 +2362,7 @@ mod tests { changed_block: 200, identity_owner: None, logs: HashMap::from([(100, vec![log_a]), (200, vec![log_b])]), + block_timestamps: HashMap::new(), }, }); @@ -2387,6 +2404,7 @@ mod tests { changed_block: 100, identity_owner: None, logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), }, }); @@ -2424,6 +2442,7 @@ mod tests { changed_block: 100, identity_owner: None, logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), }, }); @@ -2461,6 +2480,7 @@ mod tests { changed_block: 100, identity_owner: None, logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), }, }); @@ -2505,6 +2525,7 @@ mod tests { (100, vec![log_owner]), (200, vec![log_delegate]), ]), + block_timestamps: HashMap::new(), }, }, ); @@ -2569,6 +2590,7 @@ mod tests { changed_block: 100, identity_owner: None, logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), }, }); @@ -2619,6 +2641,7 @@ mod tests { changed_block: 5, // multiple blocks of changes identity_owner: Some(final_owner), logs: HashMap::new(), + block_timestamps: HashMap::new(), }, }, ); @@ -2674,6 +2697,7 @@ mod tests { changed_block: 100, identity_owner: Some(null_owner), logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), }, }); @@ -2725,6 +2749,7 @@ mod tests { (200, vec![log_delegate]), (300, vec![log_attr]), ]), + block_timestamps: HashMap::new(), }, }); From 68ee02d9508bfe4195ad726baf400eaced9fda3f Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 17:58:28 +0100 Subject: [PATCH 17/36] =?UTF-8?q?test:=20complete=20metadata=20tests=20?= =?UTF-8?q?=E2=80=94=20versionId/updated=20on=20deactivation,=20JSON=20ser?= =?UTF-8?q?ialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/dids/methods/ethr/Cargo.toml | 1 + crates/dids/methods/ethr/src/lib.rs | 115 +++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/crates/dids/methods/ethr/Cargo.toml b/crates/dids/methods/ethr/Cargo.toml index 362fc161f..11d0925e9 100644 --- a/crates/dids/methods/ethr/Cargo.toml +++ b/crates/dids/methods/ethr/Cargo.toml @@ -23,6 +23,7 @@ hex.workspace = true serde_json.workspace = true ssi-crypto = { workspace = true, features = ["keccak"] } base64.workspace = true +chrono.workspace = true [dev-dependencies] tokio = { version = "1.0", features = ["macros"] } diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 390fb9656..83e6b9e98 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -1,4 +1,5 @@ use iref::Iri; +use chrono::{DateTime, Utc}; use ssi_caips::caip10::BlockchainAccountId; use ssi_caips::caip2::ChainId; use ssi_crypto::hashes::keccak; @@ -132,6 +133,13 @@ fn format_address_eip55(addr: &[u8; 20]) -> String { keccak::eip55_checksum_addr(&lowercase).unwrap_or(lowercase) } +/// Format a Unix timestamp (seconds since epoch) as ISO 8601 UTC string +fn format_timestamp_iso8601(unix_secs: u64) -> String { + DateTime::::from_timestamp(unix_secs as i64, 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) +} + // --- ERC-1056 event topic hashes --- /// Compute keccak256 hash of an event signature string @@ -386,6 +394,18 @@ impl DIDMethodResolver for DIDEthr

{ let changed_block = decode_uint256(&result); if changed_block > 0 { + // Build document metadata (versionId + updated) + let block_ts = config + .provider + .block_timestamp(changed_block) + .await + .map_err(|e| Error::Internal(e.to_string()))?; + let mut doc_metadata = document::Metadata { + version_id: Some(changed_block.to_string()), + updated: Some(format_timestamp_iso8601(block_ts)), + ..Default::default() + }; + // Collect all events via linked-list walk let events = collect_events( &config.provider, @@ -411,7 +431,7 @@ impl DIDMethodResolver for DIDEthr

{ let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")).unwrap(); let doc = Document::new(did); let json_ld_context = JsonLdContext::default(); - let doc_metadata = document::Metadata { deactivated: Some(true), ..Default::default() }; + doc_metadata.deactivated = Some(true); return serialize_document(doc, json_ld_context, options, doc_metadata); } @@ -466,7 +486,7 @@ impl DIDMethodResolver for DIDEthr

{ now, ); - return serialize_document(doc, json_ld_context, options, document::Metadata::default()); + return serialize_document(doc, json_ld_context, options, doc_metadata); } } } @@ -2697,7 +2717,7 @@ mod tests { changed_block: 100, identity_owner: Some(null_owner), logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), + block_timestamps: HashMap::from([(100, 1705312200)]), }, }); @@ -2708,6 +2728,9 @@ mod tests { // Document metadata must have deactivated = true assert_eq!(output.document_metadata.deactivated, Some(true)); + // Deactivation is an on-chain change, so versionId/updated must be set + assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); + assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); let doc_value = serde_json::to_value(&output.document).unwrap(); @@ -2788,4 +2811,90 @@ mod tests { assert!(output.document_metadata.deactivated.is_none() || output.document_metadata.deactivated == Some(false)); } + + #[tokio::test] + async fn metadata_no_changes_no_version_id_or_updated() { + // DID with no on-chain changes (changed=0) → no versionId/updated metadata + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider::new_unchanged(), + }); + + let output = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + + assert!(output.document_metadata.version_id.is_none()); + assert!(output.document_metadata.updated.is_none()); + } + + #[tokio::test] + async fn metadata_with_changes_has_version_id_and_updated() { + // DID with on-chain changes (changed_block=100) → versionId = "100", + // updated = ISO 8601 timestamp of block 100 + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + + // Block 100 has timestamp 1705312200 = 2024-01-15T09:50:00Z + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::from([(100, 1705312200)]), + }, + }); + + let output = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + + assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); + assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); + } + + #[tokio::test] + async fn metadata_serializes_correctly_in_json() { + // Test 7.3: versionId/updated appear when present, omitted when None + use ssi_dids_core::document::Metadata; + + // Metadata with all fields set + let meta_full = Metadata { + deactivated: Some(true), + version_id: Some("42".to_string()), + updated: Some("2024-06-01T12:00:00Z".to_string()), + }; + let json = serde_json::to_value(&meta_full).unwrap(); + assert_eq!(json["deactivated"], true); + assert_eq!(json["versionId"], "42"); + assert_eq!(json["updated"], "2024-06-01T12:00:00Z"); + + // Metadata with no fields set — all should be omitted + let meta_empty = Metadata::default(); + let json = serde_json::to_value(&meta_empty).unwrap(); + assert!(json.get("deactivated").is_none()); + assert!(json.get("versionId").is_none()); + assert!(json.get("updated").is_none()); + + // Metadata with only versionId/updated (no deactivated) + let meta_partial = Metadata { + deactivated: None, + version_id: Some("100".to_string()), + updated: Some("2024-01-15T09:50:00Z".to_string()), + }; + let json = serde_json::to_value(&meta_partial).unwrap(); + assert!(json.get("deactivated").is_none()); + assert_eq!(json["versionId"], "100"); + assert_eq!(json["updated"], "2024-01-15T09:50:00Z"); + } } From 39b71146b16cb268bd25f7803360050143b56833 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 18:03:23 +0100 Subject: [PATCH 18/36] feat: add next_version_id/next_update to Metadata, collect_events returns (block, event) tuples --- crates/dids/core/src/document.rs | 4 ++++ crates/dids/methods/ethr/src/lib.rs | 26 ++++++++++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/dids/core/src/document.rs b/crates/dids/core/src/document.rs index 0b336929b..110408d55 100644 --- a/crates/dids/core/src/document.rs +++ b/crates/dids/core/src/document.rs @@ -179,6 +179,10 @@ pub struct Metadata { pub version_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub updated: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_version_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_update: Option, } #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)] diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 83e6b9e98..05a28aabb 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -281,7 +281,7 @@ async fn collect_events( registry: [u8; 20], identity: &[u8; 20], changed_block: u64, -) -> Result, String> { +) -> Result, String> { if changed_block == 0 { return Ok(Vec::new()); } @@ -314,7 +314,7 @@ async fn collect_events( for log in &logs { if let Some(event) = parse_erc1056_event(log) { next_block = event.previous_change(); - events.push(event); + events.push((log.block_number, event)); } } @@ -526,7 +526,7 @@ fn decode_delegate_type(delegate_type: &[u8; 32]) -> &[u8] { /// validity, ensuring stable `#delegate-N` IDs. fn apply_events( doc: &mut Document, - events: &[Erc1056Event], + events: &[(u64, Erc1056Event)], did: &DIDBuf, network_chain: &NetworkChain, json_ld_context: &mut JsonLdContext, @@ -535,7 +535,7 @@ fn apply_events( let mut delegate_counter = 0u64; let mut service_counter = 0u64; - for event in events { + for (_block, event) in events { match event { Erc1056Event::DelegateChanged { delegate_type, @@ -1618,7 +1618,7 @@ mod tests { assert_eq!(events.len(), 1); match &events[0] { - Erc1056Event::OwnerChanged { identity: id, owner, previous_change } => { + (_, Erc1056Event::OwnerChanged { identity: id, owner, previous_change }) => { assert_eq!(id, &[0xBB; 20]); assert_eq!(owner, &[0xCC; 20]); assert_eq!(*previous_change, 0); @@ -1657,7 +1657,7 @@ mod tests { // First event (chronologically) should be from block 100 match &events[0] { - Erc1056Event::OwnerChanged { owner, previous_change, .. } => { + (_, Erc1056Event::OwnerChanged { owner, previous_change, .. }) => { assert_eq!(owner, &owner_a); assert_eq!(*previous_change, 0); } @@ -1666,7 +1666,7 @@ mod tests { // Second event should be from block 200 match &events[1] { - Erc1056Event::OwnerChanged { owner, previous_change, .. } => { + (_, Erc1056Event::OwnerChanged { owner, previous_change, .. }) => { assert_eq!(owner, &owner_b); assert_eq!(*previous_change, 100); } @@ -1715,13 +1715,13 @@ mod tests { assert_eq!(events.len(), 3); // Chronological: block 100 first - assert!(matches!(&events[0], Erc1056Event::OwnerChanged { .. })); - assert!(matches!(&events[1], Erc1056Event::DelegateChanged { .. })); - assert!(matches!(&events[2], Erc1056Event::AttributeChanged { .. })); + assert!(matches!(&events[0], (_, Erc1056Event::OwnerChanged { .. }))); + assert!(matches!(&events[1], (_, Erc1056Event::DelegateChanged { .. }))); + assert!(matches!(&events[2], (_, Erc1056Event::AttributeChanged { .. }))); // Verify delegate event details match &events[1] { - Erc1056Event::DelegateChanged { delegate: d, valid_to, previous_change, .. } => { + (_, Erc1056Event::DelegateChanged { delegate: d, valid_to, previous_change, .. }) => { assert_eq!(d, &delegate); assert_eq!(*valid_to, u64::MAX); assert_eq!(*previous_change, 100); @@ -1731,7 +1731,7 @@ mod tests { // Verify attribute event details match &events[2] { - Erc1056Event::AttributeChanged { name, value, valid_to, previous_change, .. } => { + (_, Erc1056Event::AttributeChanged { name, value, valid_to, previous_change, .. }) => { assert_eq!(name, &attr_name); assert_eq!(value, b"\x04abc"); assert_eq!(*valid_to, u64::MAX); @@ -2873,6 +2873,7 @@ mod tests { deactivated: Some(true), version_id: Some("42".to_string()), updated: Some("2024-06-01T12:00:00Z".to_string()), + ..Default::default() }; let json = serde_json::to_value(&meta_full).unwrap(); assert_eq!(json["deactivated"], true); @@ -2891,6 +2892,7 @@ mod tests { deactivated: None, version_id: Some("100".to_string()), updated: Some("2024-01-15T09:50:00Z".to_string()), + ..Default::default() }; let json = serde_json::to_value(&meta_partial).unwrap(); assert!(json.get("deactivated").is_none()); From 1aa69dca7f7cbf8d502c5ef6c4021073d6890848 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 18:04:48 +0100 Subject: [PATCH 19/36] test: MockProvider: support per-block identityOwner for historical resolution --- crates/dids/methods/ethr/src/lib.rs | 48 +++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 05a28aabb..44f6ba7dd 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -1396,6 +1396,10 @@ mod tests { changed_block: u64, /// Address to return for identityOwner(addr) calls (None = return the queried address) identity_owner: Option<[u8; 20]>, + /// Per-block identity owners for historical resolution (block -> owner) + /// When identityOwner is called at BlockRef::Number(n), returns the owner for + /// the highest block <= n, falling back to identity_owner + identity_owner_at_block: HashMap, /// Logs to return for get_logs calls, keyed by block number logs: HashMap>, /// Block timestamps to return for block_timestamp calls @@ -1407,6 +1411,7 @@ mod tests { Self { changed_block: 0, identity_owner: None, + identity_owner_at_block: HashMap::new(), logs: HashMap::new(), block_timestamps: HashMap::new(), } @@ -1416,6 +1421,7 @@ mod tests { Self { changed_block: 1, // has changes identity_owner: None, // but owner is the same + identity_owner_at_block: HashMap::new(), logs: HashMap::new(), block_timestamps: HashMap::new(), } @@ -1429,7 +1435,7 @@ mod tests { &self, _to: [u8; 20], data: Vec, - _block: BlockRef, + block: BlockRef, ) -> Result, Self::Error> { if data.len() < 4 { return Err(MockProviderError("calldata too short".into())); @@ -1443,12 +1449,26 @@ mod tests { Ok(result) } IDENTITY_OWNER_SELECTOR => { - // Return identity_owner or echo back the queried address let mut result = vec![0u8; 32]; + // For block-specific queries, check identity_owner_at_block first + if let BlockRef::Number(n) = block { + if !self.identity_owner_at_block.is_empty() { + // Find the owner at or before block n + let owner = self.identity_owner_at_block + .iter() + .filter(|(&b, _)| b <= n) + .max_by_key(|(&b, _)| b) + .map(|(_, o)| *o); + if let Some(o) = owner { + result[12..32].copy_from_slice(&o); + return Ok(result); + } + } + } + // Fallback to identity_owner or echo back the queried address if let Some(owner) = self.identity_owner { result[12..32].copy_from_slice(&owner); } else if data.len() >= 36 { - // Echo back the queried address (last 20 bytes of the 32-byte arg) result[12..32].copy_from_slice(&data[16..36]); } Ok(result) @@ -1610,6 +1630,7 @@ mod tests { identity_owner: Some(new_owner), logs: HashMap::from([(100, vec![log])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }; let events = collect_events(&provider, TEST_REGISTRY, &identity, 100) @@ -1647,6 +1668,7 @@ mod tests { (200, vec![log_at_200]), ]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }; let events = collect_events(&provider, TEST_REGISTRY, &identity, 200) @@ -1706,6 +1728,7 @@ mod tests { (300, vec![log_300]), ]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }; let events = collect_events(&provider, TEST_REGISTRY, &identity, 300) @@ -1799,6 +1822,7 @@ mod tests { identity_owner: Some(new_owner), logs: HashMap::new(), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }, ); @@ -1861,6 +1885,7 @@ mod tests { identity_owner: Some(new_owner), logs: HashMap::new(), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }, ); @@ -2022,6 +2047,7 @@ mod tests { identity_owner: None, // same as identity logs: HashMap::from([(100, vec![log])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }, ); @@ -2089,6 +2115,7 @@ mod tests { identity_owner: None, logs: HashMap::from([(100, vec![log])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }, ); @@ -2137,6 +2164,7 @@ mod tests { identity_owner: None, logs: HashMap::from([(100, vec![log])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }, ); @@ -2184,6 +2212,7 @@ mod tests { (200, vec![log_b]), ]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }, ); @@ -2239,6 +2268,7 @@ mod tests { (200, vec![log_b]), ]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }, ); @@ -2298,6 +2328,7 @@ mod tests { identity_owner: None, logs: HashMap::from([(100, vec![log_delegate]), (200, vec![log_attr])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }); @@ -2341,6 +2372,7 @@ mod tests { identity_owner: None, logs: HashMap::from([(100, vec![log_a]), (200, vec![log_b])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }); @@ -2383,6 +2415,7 @@ mod tests { identity_owner: None, logs: HashMap::from([(100, vec![log_a]), (200, vec![log_b])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }); @@ -2425,6 +2458,7 @@ mod tests { identity_owner: None, logs: HashMap::from([(100, vec![log])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }); @@ -2463,6 +2497,7 @@ mod tests { identity_owner: None, logs: HashMap::from([(100, vec![log])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }); @@ -2501,6 +2536,7 @@ mod tests { identity_owner: None, logs: HashMap::from([(100, vec![log])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }); @@ -2546,6 +2582,7 @@ mod tests { (200, vec![log_delegate]), ]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }, ); @@ -2611,6 +2648,7 @@ mod tests { identity_owner: None, logs: HashMap::from([(100, vec![log])]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }); @@ -2662,6 +2700,7 @@ mod tests { identity_owner: Some(final_owner), logs: HashMap::new(), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }, ); @@ -2718,6 +2757,7 @@ mod tests { identity_owner: Some(null_owner), logs: HashMap::from([(100, vec![log])]), block_timestamps: HashMap::from([(100, 1705312200)]), + identity_owner_at_block: HashMap::new(), }, }); @@ -2773,6 +2813,7 @@ mod tests { (300, vec![log_attr]), ]), block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), }, }); @@ -2851,6 +2892,7 @@ mod tests { identity_owner: None, logs: HashMap::from([(100, vec![log])]), block_timestamps: HashMap::from([(100, 1705312200)]), + identity_owner_at_block: HashMap::new(), }, }); From 4c52b0ccf19f873bab4bde3c6899d103c40d9165 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 18:08:47 +0100 Subject: [PATCH 20/36] feat: historical resolution (?versionId=N) filter events by target block, use block timestamp for validTo, populate nextVersionId/nextUpdate, genesis fallback --- crates/dids/methods/ethr/src/lib.rs | 319 ++++++++++++++++++++++++++-- 1 file changed, 302 insertions(+), 17 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 44f6ba7dd..535bd7c13 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -384,6 +384,13 @@ impl DIDMethodResolver for DIDEthr

{ if let Some(config) = self.networks.get(&network_name) { let addr_hex = decoded_id.account_address_hex(); if let Some(addr) = parse_address_bytes(&addr_hex) { + // Parse historical resolution target block from ?versionId=N + let target_block: Option = options + .parameters + .version_id + .as_deref() + .and_then(|v| v.parse::().ok()); + // Call changed(addr) to see if there are on-chain modifications let calldata = encode_call(CHANGED_SELECTOR, &addr); let result = config @@ -394,33 +401,71 @@ impl DIDMethodResolver for DIDEthr

{ let changed_block = decode_uint256(&result); if changed_block > 0 { + // Collect all events via linked-list walk + let all_events = collect_events( + &config.provider, + config.registry, + &addr, + changed_block, + ) + .await + .map_err(Error::Internal)?; + + // Partition events for historical resolution + let (events, events_after) = if let Some(tb) = target_block { + let before: Vec<_> = all_events.iter().filter(|(b, _)| *b <= tb).cloned().collect(); + let after: Vec<_> = all_events.iter().filter(|(b, _)| *b > tb).cloned().collect(); + (before, after) + } else { + (all_events, Vec::new()) + }; + + // For historical resolution at a block before any changes, + // return the genesis (default) document + if target_block.is_some() && events.is_empty() { + return resolve_offline(method_specific_id, &decoded_id, options); + } + // Build document metadata (versionId + updated) + let meta_block = if let Some(tb) = target_block { + // Latest event block at or before target + events.iter().map(|(b, _)| *b).max().unwrap_or(tb) + } else { + changed_block + }; let block_ts = config .provider - .block_timestamp(changed_block) + .block_timestamp(meta_block) .await .map_err(|e| Error::Internal(e.to_string()))?; let mut doc_metadata = document::Metadata { - version_id: Some(changed_block.to_string()), + version_id: Some(meta_block.to_string()), updated: Some(format_timestamp_iso8601(block_ts)), ..Default::default() }; - // Collect all events via linked-list walk - let events = collect_events( - &config.provider, - config.registry, - &addr, - changed_block, - ) - .await - .map_err(Error::Internal)?; + // Populate nextVersionId/nextUpdate from first event after target + if target_block.is_some() { + if let Some((next_block, _)) = events_after.first() { + let next_ts = config + .provider + .block_timestamp(*next_block) + .await + .map_err(|e| Error::Internal(e.to_string()))?; + doc_metadata.next_version_id = Some(next_block.to_string()); + doc_metadata.next_update = Some(format_timestamp_iso8601(next_ts)); + } + } - // Check identityOwner(addr) for current owner + // Check identityOwner(addr) — at target block for historical, Latest otherwise + let owner_block = match target_block { + Some(tb) => BlockRef::Number(tb), + None => BlockRef::Latest, + }; let owner_calldata = encode_call(IDENTITY_OWNER_SELECTOR, &addr); let owner_result = config .provider - .call(config.registry, owner_calldata, BlockRef::Latest) + .call(config.registry, owner_calldata, owner_block) .await .map_err(|e| Error::Internal(e.to_string()))?; let owner = decode_address(&owner_result); @@ -470,10 +515,15 @@ impl DIDMethodResolver for DIDEthr

{ }; // Apply delegate/attribute events - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); + // For historical resolution, use target block's timestamp as "now" + let now = if let Some(_) = target_block { + block_ts + } else { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + }; let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")) .unwrap(); @@ -2941,4 +2991,239 @@ mod tests { assert_eq!(json["versionId"], "100"); assert_eq!(json["updated"], "2024-01-15T09:50:00Z"); } + + // --- Phase 8: Historical Resolution (?versionId=N) tests --- + + #[tokio::test] + async fn historical_version_id_skips_events_after_target_block() { + // Events at blocks 100 and 200. Resolving with versionId=100 + // should only include events from block 100. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0xAA; 20]; + let delegate_b: [u8; 20] = [0xBB; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, u64::MAX, 0); + let log_200 = make_delegate_changed_log(200, &identity, &delegate_type, &delegate_b, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + identity_owner_at_block: HashMap::new(), + logs: HashMap::from([ + (100, vec![log_100]), + (200, vec![log_200]), + ]), + block_timestamps: HashMap::from([ + (100, 1705312200), // 2024-01-15T09:50:00Z + (200, 1705398600), // 2024-01-16T09:50:00Z + ]), + }, + }); + + let options = resolution::Options { + parameters: resolution::Parameters { + version_id: Some("100".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let output = resolver + .resolve_with( + did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), + options, + ) + .await + .unwrap(); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have 4 VMs: 2 base + 2 delegate (only delegate_a from block 100) + assert_eq!(vms.len(), 4, "only events at/before block 100 should be applied"); + + // delegate_a (#delegate-1) present + let d1 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1")); + assert!(d1.is_some(), "delegate from block 100 should be present"); + + // delegate_b (#delegate-2) NOT present + let d2 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2")); + assert!(d2.is_none(), "delegate from block 200 should NOT be present"); + + // Metadata: versionId should be "100" (latest event at or before target) + assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); + assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); + } + + #[tokio::test] + async fn historical_valid_to_uses_target_block_timestamp() { + // Delegate valid_to = 1705315800 (block 100 timestamp + 1 hour). + // Block 100 timestamp = 1705312200. At block 100 the delegate is still valid. + // At wall-clock time (far future) it would be expired. + // ?versionId=100 should include the delegate. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + let valid_to = 1705315800u64; // 1 hour after block 100 timestamp + + let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, valid_to, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + identity_owner_at_block: HashMap::new(), + logs: HashMap::from([ + (100, vec![log_100]), + ]), + block_timestamps: HashMap::from([ + (100, 1705312200), // 2024-01-15T09:50:00Z + ]), + }, + }); + + let options = resolution::Options { + parameters: resolution::Parameters { + version_id: Some("100".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let output = resolver + .resolve_with( + did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), + options, + ) + .await + .unwrap(); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 2 delegate VMs (delegate is valid at block 100's timestamp) + assert_eq!(vms.len(), 4, "delegate valid at block timestamp should be included"); + + let d1 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1")); + assert!(d1.is_some(), "delegate still valid at block 100 timestamp should be present"); + } + + #[tokio::test] + async fn historical_next_version_id_and_next_update() { + // Events at blocks 100 and 200. Resolving at versionId=100 should set + // nextVersionId=200 and nextUpdate to block 200's timestamp. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0xAA; 20]; + let delegate_b: [u8; 20] = [0xBB; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, u64::MAX, 0); + let log_200 = make_delegate_changed_log(200, &identity, &delegate_type, &delegate_b, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + identity_owner_at_block: HashMap::new(), + logs: HashMap::from([ + (100, vec![log_100]), + (200, vec![log_200]), + ]), + block_timestamps: HashMap::from([ + (100, 1705312200), // 2024-01-15T09:50:00Z + (200, 1705398600), // 2024-01-16T09:50:00Z + ]), + }, + }); + + let options = resolution::Options { + parameters: resolution::Parameters { + version_id: Some("100".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let output = resolver + .resolve_with( + did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), + options, + ) + .await + .unwrap(); + + assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); + assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); + assert_eq!(output.document_metadata.next_version_id.as_deref(), Some("200")); + assert_eq!(output.document_metadata.next_update.as_deref(), Some("2024-01-16T09:50:00Z")); + } + + #[tokio::test] + async fn historical_before_any_changes_returns_genesis() { + // Events only at block 100. Resolving at versionId=50 (before any changes) + // should return the default genesis document (no delegates/attributes). + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + identity_owner_at_block: HashMap::new(), + logs: HashMap::from([ + (100, vec![log_100]), + ]), + block_timestamps: HashMap::from([ + (100, 1705312200), + ]), + }, + }); + + let options = resolution::Options { + parameters: resolution::Parameters { + version_id: Some("50".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let output = resolver + .resolve_with( + did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), + options, + ) + .await + .unwrap(); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Genesis document: only 2 base VMs, no delegates + assert_eq!(vms.len(), 2, "genesis doc should have only base VMs"); + + // No metadata versionId/updated (offline genesis) + assert!(output.document_metadata.version_id.is_none()); + assert!(output.document_metadata.updated.is_none()); + } } From eda3460939ce00b75c8a5ded95a60b122a9487c7 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 18:12:56 +0100 Subject: [PATCH 21/36] refactor: clean up NetworkChain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove deprecated testnets, add sepolia, fix Georli→Goerli typo, backward-compat aliases for deprecated names --- crates/dids/methods/ethr/src/lib.rs | 98 ++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 15 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 535bd7c13..2a6bef5ae 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -930,11 +930,8 @@ struct InvalidNetwork(String); enum NetworkChain { Mainnet, - Morden, - Ropsten, - Rinkeby, - Georli, - Kovan, + Goerli, + Sepolia, Other(u64), } @@ -942,11 +939,8 @@ impl NetworkChain { pub fn id(&self) -> u64 { match self { Self::Mainnet => 1, - Self::Morden => 2, - Self::Ropsten => 3, - Self::Rinkeby => 4, - Self::Georli => 5, - Self::Kovan => 42, + Self::Goerli => 5, + Self::Sepolia => 11155111, Self::Other(i) => *i, } } @@ -958,11 +952,13 @@ impl FromStr for NetworkChain { fn from_str(network_name: &str) -> Result { match network_name { "mainnet" => Ok(Self::Mainnet), - "morden" => Ok(Self::Morden), - "ropsten" => Ok(Self::Ropsten), - "rinkeby" => Ok(Self::Rinkeby), - "goerli" => Ok(Self::Georli), - "kovan" => Ok(Self::Kovan), + "goerli" => Ok(Self::Goerli), + "sepolia" => Ok(Self::Sepolia), + // Deprecated testnets — still parse for backward compatibility + "morden" => Ok(Self::Other(2)), + "ropsten" => Ok(Self::Other(3)), + "rinkeby" => Ok(Self::Other(4)), + "kovan" => Ok(Self::Other(42)), network_chain_id if network_chain_id.starts_with("0x") => { match u64::from_str_radix(&network_chain_id[2..], 16) { Ok(chain_id) => Ok(Self::Other(chain_id)), @@ -3226,4 +3222,76 @@ mod tests { assert!(output.document_metadata.version_id.is_none()); assert!(output.document_metadata.updated.is_none()); } + + // ── Phase 9: Network Configuration Cleanup ── + + #[tokio::test] + async fn sepolia_network_parses_with_correct_chain_id() { + let resolver = DIDEthr::<()>::default(); + let output = resolver + .resolve(did!("did:ethr:sepolia:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + let doc_value = serde_json::to_value(&output.document).unwrap(); + // blockchainAccountId should use eip155:11155111 + let vm = &doc_value["verificationMethod"][0]; + assert_eq!( + vm["blockchainAccountId"], + "eip155:11155111:0xb9c5714089478a327f09197987f16f9e5d936e8a" + ); + } + + #[tokio::test] + async fn goerli_network_still_works() { + let resolver = DIDEthr::<()>::default(); + let output = resolver + .resolve(did!("did:ethr:goerli:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + let doc_value = serde_json::to_value(&output.document).unwrap(); + let vm = &doc_value["verificationMethod"][0]; + assert_eq!( + vm["blockchainAccountId"], + "eip155:5:0xb9c5714089478a327f09197987f16f9e5d936e8a" + ); + } + + #[tokio::test] + async fn deprecated_network_ropsten_still_parses() { + let resolver = DIDEthr::<()>::default(); + let output = resolver + .resolve(did!("did:ethr:ropsten:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + let doc_value = serde_json::to_value(&output.document).unwrap(); + let vm = &doc_value["verificationMethod"][0]; + assert_eq!( + vm["blockchainAccountId"], + "eip155:3:0xb9c5714089478a327f09197987f16f9e5d936e8a" + ); + } + + #[tokio::test] + async fn hex_chain_id_works() { + let resolver = DIDEthr::<()>::default(); + let output = resolver + .resolve(did!("did:ethr:0x5:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + let doc_value = serde_json::to_value(&output.document).unwrap(); + let vm = &doc_value["verificationMethod"][0]; + assert_eq!( + vm["blockchainAccountId"], + "eip155:5:0xb9c5714089478a327f09197987f16f9e5d936e8a" + ); + } + + #[tokio::test] + async fn unknown_network_name_returns_error() { + let resolver = DIDEthr::<()>::default(); + let result = resolver + .resolve(did!("did:ethr:fakenet:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await; + assert!(result.is_err()); + } } From fec8d366632c470859b71dd4dc1f3968bc3df109 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 9 Mar 2026 18:45:51 +0100 Subject: [PATCH 22/36] chore: add Eip712Method2021 to public-key DID documents --- crates/dids/methods/ethr/src/lib.rs | 128 ++++++++++++++++++- crates/dids/methods/ethr/tests/did-pk.jsonld | 13 +- 2 files changed, 133 insertions(+), 8 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 2a6bef5ae..0d2bb3a87 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -1046,7 +1046,7 @@ fn resolve_public_key( let vm = VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { id: DIDURLBuf::from_string(format!("{did}#controller")).unwrap(), controller: did.to_owned(), - blockchain_account_id, + blockchain_account_id: blockchain_account_id.clone(), }; let key_vm = VerificationMethod::EcdsaSecp256k1VerificationKey2019 { @@ -1055,15 +1055,28 @@ fn resolve_public_key( public_key_jwk: pk_jwk, }; + let eip712_vm = VerificationMethod::Eip712Method2021 { + id: DIDURLBuf::from_string(format!("{did}#Eip712Method2021")).unwrap(), + controller: did.to_owned(), + blockchain_account_id, + }; + json_ld_context.add_verification_method_type(vm.type_()); json_ld_context.add_verification_method_type(key_vm.type_()); + json_ld_context.add_verification_method_type(eip712_vm.type_()); let mut doc = Document::new(did); - doc.verification_relationships.assertion_method = - vec![vm.id().to_owned().into(), key_vm.id().to_owned().into()]; - doc.verification_relationships.authentication = - vec![vm.id().to_owned().into(), key_vm.id().to_owned().into()]; - doc.verification_method = vec![vm.into(), key_vm.into()]; + doc.verification_relationships.assertion_method = vec![ + vm.id().to_owned().into(), + key_vm.id().to_owned().into(), + eip712_vm.id().to_owned().into(), + ]; + doc.verification_relationships.authentication = vec![ + vm.id().to_owned().into(), + key_vm.id().to_owned().into(), + eip712_vm.id().to_owned().into(), + ]; + doc.verification_method = vec![vm.into(), key_vm.into(), eip712_vm.into()]; Ok(doc) } @@ -3294,4 +3307,107 @@ mod tests { .await; assert!(result.is_err()); } + + // ── Phase 11: Eip712Method2021 for public-key DIDs ── + + #[tokio::test] + async fn pubkey_did_genesis_includes_eip712method2021() { + // Public-key DID genesis doc should include Eip712Method2021 VM + // paired with #controller (same blockchainAccountId). + let resolver = DIDEthr::<()>::default(); + let doc = resolver + .resolve(did!( + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have 3 VMs: #controller, #controllerKey, #Eip712Method2021 + assert_eq!(vms.len(), 3, "public-key DID should have 3 VMs"); + + // Find the Eip712Method2021 VM + let eip712_vm = vms.iter() + .find(|vm| vm["type"].as_str() == Some("Eip712Method2021")) + .expect("should have Eip712Method2021 VM"); + + assert!( + eip712_vm["id"].as_str().unwrap().ends_with("#Eip712Method2021"), + "Eip712Method2021 VM should have #Eip712Method2021 fragment" + ); + + // Same blockchainAccountId as #controller + let controller_vm = vms.iter() + .find(|vm| vm["id"].as_str().unwrap().ends_with("#controller")) + .unwrap(); + assert_eq!( + eip712_vm["blockchainAccountId"], + controller_vm["blockchainAccountId"], + "Eip712Method2021 should share blockchainAccountId with #controller" + ); + + // Referenced in assertionMethod and authentication + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + let auth = doc_value["authentication"].as_array().unwrap(); + let eip712_id = eip712_vm["id"].as_str().unwrap(); + assert!( + assertion.iter().any(|r| r.as_str() == Some(eip712_id)), + "Eip712Method2021 should be in assertionMethod" + ); + assert!( + auth.iter().any(|r| r.as_str() == Some(eip712_id)), + "Eip712Method2021 should be in authentication" + ); + } + + #[tokio::test] + async fn pubkey_did_with_provider_unchanged_includes_eip712method2021() { + // Public-key DID with MockProvider (changed_block=0) should also + // include Eip712Method2021, matching offline resolution. + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider::new_unchanged(), + }, + ); + + let doc = resolver + .resolve(did!( + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + assert_eq!(vms.len(), 3, "public-key DID with unchanged provider should have 3 VMs"); + + assert!( + vms.iter().any(|vm| vm["type"].as_str() == Some("Eip712Method2021")), + "should include Eip712Method2021" + ); + + // Should match offline resolution exactly + let doc_offline = DIDEthr::<()>::default() + .resolve(did!( + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" + )) + .await + .unwrap() + .document; + + assert_eq!( + serde_json::to_value(&doc).unwrap(), + serde_json::to_value(&doc_offline).unwrap(), + "unchanged provider should match offline for public-key DID" + ); + } } diff --git a/crates/dids/methods/ethr/tests/did-pk.jsonld b/crates/dids/methods/ethr/tests/did-pk.jsonld index 604cb7d70..a579c2d53 100644 --- a/crates/dids/methods/ethr/tests/did-pk.jsonld +++ b/crates/dids/methods/ethr/tests/did-pk.jsonld @@ -4,6 +4,7 @@ { "EcdsaSecp256k1RecoveryMethod2020": "https://identity.foundation/EcdsaSecp256k1RecoverySignature2020#EcdsaSecp256k1RecoveryMethod2020", "EcdsaSecp256k1VerificationKey2019": "https://w3id.org/security#EcdsaSecp256k1VerificationKey2019", + "Eip712Method2021": "https://w3id.org/security#Eip712Method2021", "blockchainAccountId": "https://w3id.org/security#blockchainAccountId", "publicKeyJwk": { "@id": "https://w3id.org/security#publicKeyJwk", @@ -29,14 +30,22 @@ "x": "_dV63sPUOOojf-RrM-4eAW7aa1hcPifqZmhsLqU1hHk", "y": "Rjk_gUUlLupor-Z-KHs-2bMWhbpsOwAGCnO5sSQtaPc" } + }, + { + "id": "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479#Eip712Method2021", + "type": "Eip712Method2021", + "controller": "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479", + "blockchainAccountId": "eip155:1:0xF3beAC30C498D9E26865F34fCAa57dBB935b0D74" } ], "authentication": [ "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479#controller", - "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479#controllerKey" + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479#controllerKey", + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479#Eip712Method2021" ], "assertionMethod": [ "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479#controller", - "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479#controllerKey" + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479#controllerKey", + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479#Eip712Method2021" ] } From c0a18f586dad2d97424940e54118209e9a947a94 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 10 Mar 2026 11:26:45 +0100 Subject: [PATCH 23/36] feat: update DID document event processing for spec compliance - Switch attribute key encoding to canonical property per VM type (e.g. secp256k1 keys use publicKeyJwk, Ed25519/X25519 use publicKeyMultibase) - Add support for Ed25519VerificationKey2020 and X25519KeyAgreementKey2020 attribute keys - Revise JsonLdContext to bind only relevant properties and VM types - Use content-keyed maps for event deduplication and revocation handling - Ensure revoked delegates/services are removed from the DID document - Update tests for new encoding, revocation, and context bindings - Add bs58 and indexmap workspace dependencies --- crates/dids/methods/ethr/Cargo.toml | 3 +- .../dids/methods/ethr/src/json_ld_context.rs | 101 +-- crates/dids/methods/ethr/src/lib.rs | 785 ++++++++++++++---- 3 files changed, 666 insertions(+), 223 deletions(-) diff --git a/crates/dids/methods/ethr/Cargo.toml b/crates/dids/methods/ethr/Cargo.toml index 11d0925e9..a4f13a045 100644 --- a/crates/dids/methods/ethr/Cargo.toml +++ b/crates/dids/methods/ethr/Cargo.toml @@ -22,8 +22,9 @@ thiserror.workspace = true hex.workspace = true serde_json.workspace = true ssi-crypto = { workspace = true, features = ["keccak"] } -base64.workspace = true +bs58.workspace = true chrono.workspace = true +indexmap.workspace = true [dev-dependencies] tokio = { version = "1.0", features = ["macros"] } diff --git a/crates/dids/methods/ethr/src/json_ld_context.rs b/crates/dids/methods/ethr/src/json_ld_context.rs index 386ba5b87..6d3541a66 100644 --- a/crates/dids/methods/ethr/src/json_ld_context.rs +++ b/crates/dids/methods/ethr/src/json_ld_context.rs @@ -16,11 +16,11 @@ use crate::VerificationMethodType; pub struct JsonLdContext { ecdsa_secp256k1_verification_key_2019: bool, ecdsa_secp256k1_recovery_method_2020: bool, + ed25519_verification_key_2020: bool, + x25519_key_agreement_key_2020: bool, eip712_method_2021: bool, - public_key_hex: bool, - public_key_base64: bool, - public_key_base58: bool, - public_key_pem: bool, + public_key_jwk: bool, + public_key_multibase: bool, } impl JsonLdContext { @@ -32,16 +32,20 @@ impl JsonLdContext { VerificationMethodType::EcdsaSecp256k1RecoveryMethod2020 => { self.ecdsa_secp256k1_recovery_method_2020 = true } + VerificationMethodType::Ed25519VerificationKey2020 => { + self.ed25519_verification_key_2020 = true + } + VerificationMethodType::X25519KeyAgreementKey2020 => { + self.x25519_key_agreement_key_2020 = true + } VerificationMethodType::Eip712Method2021 => self.eip712_method_2021 = true, } } pub fn add_property(&mut self, prop: &str) { match prop { - "publicKeyHex" => self.public_key_hex = true, - "publicKeyBase64" => self.public_key_base64 = true, - "publicKeyBase58" => self.public_key_base58 = true, - "publicKeyPem" => self.public_key_pem = true, + "publicKeyJwk" => self.public_key_jwk = true, + "publicKeyMultibase" => self.public_key_multibase = true, _ => {} } } @@ -49,20 +53,7 @@ impl JsonLdContext { pub fn into_entries(self) -> Vec { let mut def = Definition::new(); - let mut public_key_jwk = false; let mut blockchain_account_id = false; - // let mut public_key_base_58 = false; - // let mut public_key_multibase = false; - - // if self.ed25519_verification_key_2018 { - // let ty = VerificationMethodType::Ed25519VerificationKey2018; - // def.bindings.insert( - // ty.name().into(), - // TermDefinition::Simple(ty.iri().to_owned().into()).into(), - // ); - - // public_key_base_58 = true; - // } if self.ecdsa_secp256k1_verification_key_2019 { let ty = VerificationMethodType::EcdsaSecp256k1VerificationKey2019; @@ -70,8 +61,6 @@ impl JsonLdContext { ty.name().into(), TermDefinition::Simple(ty.iri().to_owned().into()).into(), ); - - public_key_jwk = true; } if self.ecdsa_secp256k1_recovery_method_2020 { @@ -84,6 +73,22 @@ impl JsonLdContext { blockchain_account_id = true; } + if self.ed25519_verification_key_2020 { + let ty = VerificationMethodType::Ed25519VerificationKey2020; + def.bindings.insert( + ty.name().into(), + TermDefinition::Simple(ty.iri().to_owned().into()).into(), + ); + } + + if self.x25519_key_agreement_key_2020 { + let ty = VerificationMethodType::X25519KeyAgreementKey2020; + def.bindings.insert( + ty.name().into(), + TermDefinition::Simple(ty.iri().to_owned().into()).into(), + ); + } + if self.eip712_method_2021 { let ty = VerificationMethodType::Eip712Method2021; def.bindings.insert( @@ -94,7 +99,7 @@ impl JsonLdContext { blockchain_account_id = true; } - if public_key_jwk { + if self.public_key_jwk { def.bindings.insert( "publicKeyJwk".into(), TermDefinition::Expanded(Box::new(Expanded { @@ -110,35 +115,11 @@ impl JsonLdContext { ); } - if blockchain_account_id { - def.bindings.insert( - "blockchainAccountId".into(), - TermDefinition::Simple( - iri!("https://w3id.org/security#blockchainAccountId") - .to_owned() - .into(), - ) - .into(), - ); - } - - if self.public_key_hex { - def.bindings.insert( - "publicKeyHex".into(), - TermDefinition::Simple( - iri!("https://w3id.org/security#publicKeyHex") - .to_owned() - .into(), - ) - .into(), - ); - } - - if self.public_key_base64 { + if self.public_key_multibase { def.bindings.insert( - "publicKeyBase64".into(), + "publicKeyMultibase".into(), TermDefinition::Simple( - iri!("https://w3id.org/security#publicKeyBase64") + iri!("https://w3id.org/security#publicKeyMultibase") .to_owned() .into(), ) @@ -146,23 +127,11 @@ impl JsonLdContext { ); } - if self.public_key_base58 { - def.bindings.insert( - "publicKeyBase58".into(), - TermDefinition::Simple( - iri!("https://w3id.org/security#publicKeyBase58") - .to_owned() - .into(), - ) - .into(), - ); - } - - if self.public_key_pem { + if blockchain_account_id { def.bindings.insert( - "publicKeyPem".into(), + "blockchainAccountId".into(), TermDefinition::Simple( - iri!("https://w3id.org/security#publicKeyPem") + iri!("https://w3id.org/security#blockchainAccountId") .to_owned() .into(), ) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 0d2bb3a87..ce1c8bbfb 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -16,7 +16,7 @@ use static_iref::iri; use std::collections::HashMap; use std::str::FromStr; -use base64::Engine as _; +use indexmap::IndexMap; mod json_ld_context; use json_ld_context::JsonLdContext; @@ -571,9 +571,15 @@ fn decode_delegate_type(delegate_type: &[u8; 32]) -> &[u8] { /// Process ERC-1056 events and add delegate verification methods to the document. /// +/// Uses a map-accumulation model: each delegate/attribute event is keyed by its +/// content (delegate_type+delegate or name+value). When a valid event arrives the +/// entry is inserted; when a revoked/expired event arrives the entry is removed. +/// This correctly handles the case where a previously-valid key is later revoked. +/// /// `now` is the current timestamp (seconds since epoch) used for expiry checks. -/// The delegate counter increments for every DelegateChanged event regardless of -/// validity, ensuring stable `#delegate-N` IDs. +/// The delegate counter increments for every recognised DelegateChanged / +/// AttributeChanged-pub event regardless of validity, ensuring stable `#delegate-N` +/// IDs. Likewise for service_counter / `#service-N`. fn apply_events( doc: &mut Document, events: &[(u64, Erc1056Event)], @@ -585,6 +591,12 @@ fn apply_events( let mut delegate_counter = 0u64; let mut service_counter = 0u64; + // Content-keyed maps for deduplication and revocation support. + // Key = delegate_type[32] ++ delegate[20] for delegates, + // name[32] ++ value[..] for attribute keys / services. + let mut vms: IndexMap, PendingVm> = IndexMap::new(); + let mut svcs: IndexMap, PendingService> = IndexMap::new(); + for (_block, event) in events { match event { Erc1056Event::DelegateChanged { @@ -593,7 +605,6 @@ fn apply_events( valid_to, .. } => { - delegate_counter += 1; let dt = decode_delegate_type(delegate_type); let is_veri_key = dt == b"veriKey"; @@ -603,22 +614,149 @@ fn apply_events( continue; } - // Skip expired/revoked delegates - if *valid_to < now { - continue; - } + delegate_counter += 1; - let delegate_addr = format_address_eip55(delegate); - let blockchain_account_id = BlockchainAccountId { - account_address: delegate_addr, - chain_id: ChainId { - namespace: "eip155".to_string(), - reference: network_chain.id().to_string(), - }, + // Content key: delegate_type[32] ++ delegate[20] + let mut key = Vec::with_capacity(52); + key.extend_from_slice(delegate_type); + key.extend_from_slice(delegate); + + if *valid_to >= now { + let delegate_addr = format_address_eip55(delegate); + let blockchain_account_id = BlockchainAccountId { + account_address: delegate_addr, + chain_id: ChainId { + namespace: "eip155".to_string(), + reference: network_chain.id().to_string(), + }, + }; + + vms.insert(key, PendingVm { + counter: delegate_counter, + payload: PendingVmPayload::Delegate { blockchain_account_id }, + is_sig_auth, + }); + } else { + vms.shift_remove(&key); + } + } + Erc1056Event::AttributeChanged { + name, + value, + valid_to, + .. + } => { + let attr_name = decode_delegate_type(name); // trims trailing zeros + let attr_str = match std::str::from_utf8(attr_name) { + Ok(s) => s, + Err(_) => continue, }; + let parts: Vec<&str> = attr_str.split('/').collect(); + + if parts.len() >= 3 && parts[0] == "did" && parts[1] == "pub" { + // did/pub/// + delegate_counter += 1; - let vm_id = format!("{did}#delegate-{delegate_counter}"); - let eip712_id = format!("{did}#delegate-{delegate_counter}-Eip712Method2021"); + let algo = parts.get(2).copied().unwrap_or(""); + let purpose = parts.get(3).copied().unwrap_or(""); + + // Content key: name[32] ++ value[..] + let mut key = Vec::with_capacity(32 + value.len()); + key.extend_from_slice(name); + key.extend_from_slice(value); + + if *valid_to >= now { + // Determine VM type and build the property value. + // Encoding hint from the attribute name is ignored; we + // always use the canonical property for each VM type. + let pending = match algo { + "Secp256k1" => { + match ssi_jwk::secp256k1_parse(value) { + Ok(jwk) => Some(PendingVmPayload::AttributeKey { + vm_type: VerificationMethodType::EcdsaSecp256k1VerificationKey2019, + prop_name: "publicKeyJwk", + prop_value: serde_json::to_value(&jwk).unwrap(), + }), + Err(_) => None, + } + } + "Ed25519" => { + let multibase = format!("z{}", bs58::encode(value).into_string()); + Some(PendingVmPayload::AttributeKey { + vm_type: VerificationMethodType::Ed25519VerificationKey2020, + prop_name: "publicKeyMultibase", + prop_value: serde_json::Value::String(multibase), + }) + } + "X25519" => { + let multibase = format!("z{}", bs58::encode(value).into_string()); + Some(PendingVmPayload::AttributeKey { + vm_type: VerificationMethodType::X25519KeyAgreementKey2020, + prop_name: "publicKeyMultibase", + prop_value: serde_json::Value::String(multibase), + }) + } + _ => None, + }; + + if let Some(payload) = pending { + let is_enc = purpose == "enc"; + let is_sig_auth = purpose == "sigAuth"; + vms.insert(key, PendingVm { + counter: delegate_counter, + payload, + is_sig_auth: is_sig_auth || is_enc, + }); + } + } else { + vms.shift_remove(&key); + } + } else if parts.len() >= 3 && parts[0] == "did" && parts[1] == "svc" { + // did/svc/ + service_counter += 1; + + // Content key: name[32] ++ value[..] + let mut key = Vec::with_capacity(32 + value.len()); + key.extend_from_slice(name); + key.extend_from_slice(value); + + if *valid_to >= now { + let service_type = parts[2..].join("/"); + let endpoint_str = String::from_utf8_lossy(value); + let endpoint = if let Ok(json_val) = serde_json::from_str::(&endpoint_str) { + if json_val.is_object() || json_val.is_array() { + document::service::Endpoint::Map(json_val) + } else { + parse_uri_endpoint(&endpoint_str) + } + } else { + parse_uri_endpoint(&endpoint_str) + }; + + svcs.insert(key, PendingService { + counter: service_counter, + service_type, + endpoint, + }); + } else { + svcs.shift_remove(&key); + } + } + } + // OwnerChanged handled by identityOwner() call + _ => {} + } + } + + // Materialise VMs from the map, sorted by counter for stable ordering. + let mut vm_entries: Vec<_> = vms.into_values().collect(); + vm_entries.sort_by_key(|v| v.counter); + + for vm_entry in vm_entries { + match vm_entry.payload { + PendingVmPayload::Delegate { blockchain_account_id } => { + let vm_id = format!("{did}#delegate-{}", vm_entry.counter); + let eip712_id = format!("{did}#delegate-{}-Eip712Method2021", vm_entry.counter); let vm_id_url = DIDURLBuf::from_string(vm_id).unwrap(); let eip712_id_url = DIDURLBuf::from_string(eip712_id).unwrap(); @@ -648,7 +786,7 @@ fn apply_events( .assertion_method .push(eip712_id_url.clone().into()); - if is_sig_auth { + if vm_entry.is_sig_auth { doc.verification_relationships .authentication .push(vm_id_url.into()); @@ -657,145 +795,109 @@ fn apply_events( .push(eip712_id_url.into()); } } - Erc1056Event::AttributeChanged { - name, - value, - valid_to, - .. - } => { - let attr_name = decode_delegate_type(name); // trims trailing zeros - let attr_str = match std::str::from_utf8(attr_name) { - Ok(s) => s, - Err(_) => continue, - }; - let parts: Vec<&str> = attr_str.split('/').collect(); - - if parts.len() >= 3 && parts[0] == "did" && parts[1] == "pub" { - // did/pub/// - delegate_counter += 1; - - if *valid_to < now { - continue; - } - - let algo = parts.get(2).copied().unwrap_or(""); - let purpose = parts.get(3).copied().unwrap_or(""); - let encoding = parts.get(4).copied().unwrap_or("hex"); - - let vm_type = match algo { - "Secp256k1" => "EcdsaSecp256k1VerificationKey2019", - "Ed25519" => "Ed25519VerificationKey2018", - "X25519" => "X25519KeyAgreementKey2019", - _ => continue, - }; - - let prop_name = match encoding { - "hex" | "" => "publicKeyHex", - "base64" => "publicKeyBase64", - "base58" => "publicKeyBase58", - "pem" => "publicKeyPem", - _ => continue, - }; - - let prop_value = match encoding { - "hex" | "" => hex::encode(value), - "base64" => base64::engine::general_purpose::STANDARD.encode(value), - _ => String::from_utf8_lossy(value).into_owned(), - }; + PendingVmPayload::AttributeKey { vm_type, prop_name, prop_value } => { + let vm_id = format!("{did}#delegate-{}", vm_entry.counter); + let vm_id_url = DIDURLBuf::from_string(vm_id).unwrap(); - let vm_id = format!("{did}#delegate-{delegate_counter}"); - let vm_id_url = DIDURLBuf::from_string(vm_id).unwrap(); - - let vm = DIDVerificationMethod { - id: vm_id_url.clone(), - type_: vm_type.to_owned(), - controller: did.clone(), - properties: [( - prop_name.into(), - serde_json::Value::String(prop_value), - )] + let vm = DIDVerificationMethod { + id: vm_id_url.clone(), + type_: vm_type.name().to_owned(), + controller: did.clone(), + properties: [(prop_name.into(), prop_value)] .into_iter() .collect(), - }; + }; - // Add context entries - match algo { - "Secp256k1" => json_ld_context.add_verification_method_type( - VerificationMethodType::EcdsaSecp256k1VerificationKey2019, - ), - _ => {} // Ed25519/X25519 context handled in Phase 10+ + json_ld_context.add_verification_method_type(vm_type); + json_ld_context.add_property(prop_name); + + doc.verification_method.push(vm); + + // Determine purpose from the attribute name embedded in is_sig_auth. + // For attribute keys, is_sig_auth encodes whether the key goes into + // authentication/keyAgreement (true for sigAuth and enc purposes). + // + // We need to re-derive the purpose from the original attribute, but + // since we only store is_sig_auth, we use a simpler approach: + // - X25519 keys → keyAgreement + // - is_sig_auth && not X25519 → assertionMethod + authentication + // - else → assertionMethod only + match vm_type { + VerificationMethodType::X25519KeyAgreementKey2020 => { + doc.verification_relationships + .key_agreement + .push(vm_id_url.into()); } - json_ld_context.add_property(prop_name); - - doc.verification_method.push(vm); - - let is_veri_key = purpose == "veriKey"; - let is_sig_auth = purpose == "sigAuth"; - let is_enc = purpose == "enc"; - - if is_veri_key || is_sig_auth { + _ if vm_entry.is_sig_auth => { doc.verification_relationships .assertion_method .push(vm_id_url.clone().into()); - } - - if is_sig_auth { doc.verification_relationships .authentication - .push(vm_id_url.clone().into()); + .push(vm_id_url.into()); } - - if is_enc { + _ => { doc.verification_relationships - .key_agreement + .assertion_method .push(vm_id_url.into()); } - } else if parts.len() >= 3 && parts[0] == "did" && parts[1] == "svc" { - // did/svc/ - service_counter += 1; + } + } + } + } - if *valid_to < now { - continue; - } + // Materialise services from the map, sorted by counter. + let mut svc_entries: Vec<_> = svcs.into_values().collect(); + svc_entries.sort_by_key(|s| s.counter); - let service_type = parts[2..].join("/"); - let service_id = format!("{did}#service-{service_counter}"); + for svc_entry in svc_entries { + let service_id = format!("{did}#service-{}", svc_entry.counter); + let service = document::Service { + id: iref::UriBuf::new(service_id.into_bytes()).unwrap(), + type_: ssi_core::one_or_many::OneOrMany::One(svc_entry.service_type), + service_endpoint: Some(ssi_core::one_or_many::OneOrMany::One(svc_entry.endpoint)), + property_set: std::collections::BTreeMap::new(), + }; + doc.service.push(service); + } +} - let endpoint_str = String::from_utf8_lossy(value); - let endpoint = if let Ok(json_val) = serde_json::from_str::(&endpoint_str) { - if json_val.is_object() || json_val.is_array() { - document::service::Endpoint::Map(json_val) - } else { - match iref::UriBuf::new(endpoint_str.as_bytes().to_vec()) { - Ok(uri) => document::service::Endpoint::Uri(uri), - Err(e) => document::service::Endpoint::Map( - serde_json::Value::String(String::from_utf8_lossy(&e.0).into_owned()), - ), - } - } - } else { - match iref::UriBuf::new(endpoint_str.as_bytes().to_vec()) { - Ok(uri) => document::service::Endpoint::Uri(uri), - Err(e) => document::service::Endpoint::Map( - serde_json::Value::String(String::from_utf8_lossy(&e.0).into_owned()), - ), - } - }; +/// Helper to parse a string as a URI endpoint, falling back to a string-valued Map. +fn parse_uri_endpoint(s: &str) -> document::service::Endpoint { + match iref::UriBuf::new(s.as_bytes().to_vec()) { + Ok(uri) => document::service::Endpoint::Uri(uri), + Err(e) => document::service::Endpoint::Map( + serde_json::Value::String(String::from_utf8_lossy(&e.0).into_owned()), + ), + } +} - let service = document::Service { - id: iref::UriBuf::new(service_id.into_bytes()).unwrap(), - type_: ssi_core::one_or_many::OneOrMany::One(service_type), - service_endpoint: Some(ssi_core::one_or_many::OneOrMany::One(endpoint)), - property_set: std::collections::BTreeMap::new(), - }; +/// Intermediate representation for a verification method accumulated during +/// event processing, before materialisation into the DID document. +struct PendingVm { + counter: u64, + payload: PendingVmPayload, + /// For delegates: true if sigAuth. For attribute keys: true if sigAuth or enc purpose. + is_sig_auth: bool, +} - doc.service.push(service); - } - } - // OwnerChanged handled by identityOwner() (Phase 2) - _ => {} - } - } +enum PendingVmPayload { + Delegate { + blockchain_account_id: BlockchainAccountId, + }, + AttributeKey { + vm_type: VerificationMethodType, + prop_name: &'static str, + prop_value: serde_json::Value, + }, +} + +/// Intermediate representation for a service endpoint accumulated during +/// event processing. +struct PendingService { + counter: u64, + service_type: String, + endpoint: document::service::Endpoint, } /// Resolve a DID using the offline (genesis document) path @@ -1064,6 +1166,7 @@ fn resolve_public_key( json_ld_context.add_verification_method_type(vm.type_()); json_ld_context.add_verification_method_type(key_vm.type_()); json_ld_context.add_verification_method_type(eip712_vm.type_()); + json_ld_context.add_property("publicKeyJwk"); let mut doc = Document::new(did); doc.verification_relationships.assertion_method = vec![ @@ -1122,9 +1225,12 @@ impl VerificationMethod { } } +#[derive(Clone, Copy)] pub enum VerificationMethodType { EcdsaSecp256k1VerificationKey2019, EcdsaSecp256k1RecoveryMethod2020, + Ed25519VerificationKey2020, + X25519KeyAgreementKey2020, Eip712Method2021, } @@ -1133,6 +1239,8 @@ impl VerificationMethodType { match self { Self::EcdsaSecp256k1VerificationKey2019 => "EcdsaSecp256k1VerificationKey2019", Self::EcdsaSecp256k1RecoveryMethod2020 => "EcdsaSecp256k1RecoveryMethod2020", + Self::Ed25519VerificationKey2020 => "Ed25519VerificationKey2020", + Self::X25519KeyAgreementKey2020 => "X25519KeyAgreementKey2020", Self::Eip712Method2021 => "Eip712Method2021", } } @@ -1141,7 +1249,9 @@ impl VerificationMethodType { match self { Self::EcdsaSecp256k1VerificationKey2019 => iri!("https://w3id.org/security#EcdsaSecp256k1VerificationKey2019"), Self::EcdsaSecp256k1RecoveryMethod2020 => iri!("https://identity.foundation/EcdsaSecp256k1RecoverySignature2020#EcdsaSecp256k1RecoveryMethod2020"), - Self::Eip712Method2021 => iri!("https://w3id.org/security#Eip712Method2021") + Self::Ed25519VerificationKey2020 => iri!("https://w3id.org/security#Ed25519VerificationKey2020"), + Self::X25519KeyAgreementKey2020 => iri!("https://w3id.org/security#X25519KeyAgreementKey2020"), + Self::Eip712Method2021 => iri!("https://w3id.org/security#Eip712Method2021"), } } } @@ -2373,10 +2483,9 @@ mod tests { let delegate: [u8; 20] = [0xAA; 20]; let delegate_type = encode_delegate_type("veriKey"); let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); - let pub_key: Vec = vec![0x04, 0xab, 0xcd]; let log_delegate = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); - let log_attr = make_attribute_changed_log(200, &identity, &attr_name, &pub_key, u64::MAX, 100); + let log_attr = make_attribute_changed_log(200, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 100); let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { @@ -2405,10 +2514,12 @@ mod tests { let d1 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1")).unwrap(); assert_eq!(d1["type"], "EcdsaSecp256k1RecoveryMethod2020"); - // #delegate-2 is the attribute key (EcdsaSecp256k1VerificationKey2019) + // #delegate-2 is the attribute key (EcdsaSecp256k1VerificationKey2019 with publicKeyJwk) let d2 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2")).unwrap(); assert_eq!(d2["type"], "EcdsaSecp256k1VerificationKey2019"); - assert_eq!(d2["publicKeyHex"], hex::encode(&pub_key)); + assert!(d2["publicKeyJwk"].is_object(), "attribute key should have publicKeyJwk"); + assert_eq!(d2["publicKeyJwk"]["kty"], "EC"); + assert_eq!(d2["publicKeyJwk"]["crv"], "secp256k1"); } #[tokio::test] @@ -2457,13 +2568,11 @@ mod tests { let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); - let pub_key_a: Vec = vec![0x04, 0xaa]; - let pub_key_b: Vec = vec![0x04, 0xbb]; // First key: expired (valid_to = 1000, well in the past) - let log_a = make_attribute_changed_log(100, &identity, &attr_name, &pub_key_a, 1000, 0); - // Second key: valid - let log_b = make_attribute_changed_log(200, &identity, &attr_name, &pub_key_b, u64::MAX, 100); + let log_a = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, 1000, 0); + // Second key: valid (different key bytes so they have different content keys) + let log_b = make_attribute_changed_log(200, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED_2, u64::MAX, 100); let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { @@ -2495,7 +2604,7 @@ mod tests { let vm = vms.iter().find(|vm| { vm["id"].as_str().unwrap().ends_with("#delegate-2") }).expect("should have #delegate-2 VM"); - assert_eq!(vm["publicKeyHex"], hex::encode(&pub_key_b)); + assert!(vm["publicKeyJwk"].is_object(), "should have publicKeyJwk as object"); } #[tokio::test] @@ -2578,13 +2687,13 @@ mod tests { #[tokio::test] async fn resolve_secp256k1_sigauth_hex_attribute_in_authentication() { // did/pub/Secp256k1/sigAuth/hex attribute adds VM referenced in - // verificationMethod + assertionMethod + authentication + // verificationMethod + assertionMethod + authentication. + // Uses a real compressed secp256k1 key so secp256k1_parse succeeds. let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; let attr_name = encode_attr_name("did/pub/Secp256k1/sigAuth/hex"); - let pub_key_value: Vec = vec![0x04, 0xde, 0xad, 0xbe, 0xef]; - let log = make_attribute_changed_log(100, &identity, &attr_name, &pub_key_value, u64::MAX, 0); + let log = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 0); let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { @@ -2606,6 +2715,16 @@ mod tests { let doc_value = serde_json::to_value(&doc).unwrap(); let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + let vms = doc_value["verificationMethod"].as_array().unwrap(); + // 2 base + 1 attribute key = 3 + assert_eq!(vms.len(), 3); + + let attr_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM"); + assert_eq!(attr_vm["type"], "EcdsaSecp256k1VerificationKey2019"); + assert!(attr_vm["publicKeyJwk"].is_object(), "should have publicKeyJwk"); + let assertion = doc_value["assertionMethod"].as_array().unwrap(); let auth = doc_value["authentication"].as_array().unwrap(); @@ -2687,16 +2806,30 @@ mod tests { b } + /// A valid compressed secp256k1 public key (33 bytes) for use in tests. + const TEST_SECP256K1_COMPRESSED: [u8; 33] = [ + 0x03, 0xfd, 0xd5, 0x7a, 0xde, 0xc3, 0xd4, 0x38, 0xea, 0x23, 0x7f, + 0xe4, 0x6b, 0x33, 0xee, 0x1e, 0x01, 0x6e, 0xda, 0x6b, 0x58, 0x5c, + 0x3e, 0x27, 0xea, 0x66, 0x68, 0x6c, 0x2e, 0xa5, 0x35, 0x84, 0x79, + ]; + + /// A second valid compressed secp256k1 public key (different from the first). + const TEST_SECP256K1_COMPRESSED_2: [u8; 33] = [ + 0x02, 0xb9, 0x7c, 0x30, 0xde, 0x76, 0x7f, 0x08, 0x4c, 0xe3, 0x08, + 0x09, 0x68, 0xd8, 0x53, 0xd0, 0x3c, 0x3a, 0x28, 0x86, 0x53, 0xf8, + 0x12, 0x64, 0xa0, 0x90, 0xcd, 0x20, 0x3a, 0x12, 0xe5, 0x60, 0x40, + ]; + #[tokio::test] async fn resolve_secp256k1_verikey_hex_attribute() { // did/pub/Secp256k1/veriKey/hex attribute adds EcdsaSecp256k1VerificationKey2019 - // with publicKeyHex to verificationMethod + assertionMethod, using #delegate-N ID + // with publicKeyJwk to verificationMethod + assertionMethod, using #delegate-N ID. + // Encoding hint ("hex") is ignored; we always convert to JWK. let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); - let pub_key_value: Vec = vec![0x04, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67]; - let log = make_attribute_changed_log(100, &identity, &attr_name, &pub_key_value, u64::MAX, 0); + let log = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 0); let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { @@ -2726,7 +2859,11 @@ mod tests { vm["id"].as_str().unwrap().ends_with("#delegate-1") }).expect("should have #delegate-1 VM from attribute"); assert_eq!(attr_vm["type"], "EcdsaSecp256k1VerificationKey2019"); - assert_eq!(attr_vm["publicKeyHex"], hex::encode(&pub_key_value)); + // Must have publicKeyJwk (a JSON object), NOT publicKeyHex + assert!(attr_vm["publicKeyJwk"].is_object(), "should have publicKeyJwk as object"); + assert_eq!(attr_vm["publicKeyJwk"]["kty"], "EC"); + assert_eq!(attr_vm["publicKeyJwk"]["crv"], "secp256k1"); + assert!(attr_vm.get("publicKeyHex").is_none(), "should NOT have publicKeyHex"); assert_eq!(attr_vm["controller"], "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"); // Should be in assertionMethod but NOT authentication @@ -2735,6 +2872,11 @@ mod tests { let auth = doc_value["authentication"].as_array().unwrap(); assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + + // Context should include publicKeyJwk binding + let context = doc_value["@context"].as_array().unwrap(); + let ctx_obj = context.iter().find(|c| c.is_object()).unwrap(); + assert!(ctx_obj.get("publicKeyJwk").is_some(), "context should include publicKeyJwk"); } #[tokio::test] @@ -3410,4 +3552,335 @@ mod tests { "unchanged provider should match offline for public-key DID" ); } + + // ── Revocation tests ── + + #[tokio::test] + async fn resolve_previously_valid_delegate_then_revoked() { + // A delegate is added valid at block 100, then revoked at block 200. + // The revoked delegate must NOT appear in the final document. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + // Block 100: delegate added, valid_to = far future + let log_add = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + // Block 200: same delegate revoked (valid_to = 0) + let log_revoke = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, 0, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([ + (100, vec![log_add]), + (200, vec![log_revoke]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Only 2 base VMs — delegate was revoked + assert_eq!(vms.len(), 2, "revoked delegate should not appear in document"); + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().contains("delegate")), + "no delegate VMs should be present"); + } + + #[tokio::test] + async fn resolve_previously_valid_service_then_revoked() { + // A service is added valid at block 100, then revoked at block 200. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/svc/HubService"); + let endpoint = b"https://hub.example.com"; + + // Block 100: service added + let log_add = make_attribute_changed_log(100, &identity, &attr_name, endpoint, u64::MAX, 0); + // Block 200: same service revoked (valid_to = 0) + let log_revoke = make_attribute_changed_log(200, &identity, &attr_name, endpoint, 0, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([ + (100, vec![log_add]), + (200, vec![log_revoke]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let services = doc_value.get("service"); + + // No services — the service was revoked + assert!( + services.is_none() || services.unwrap().as_array().map_or(true, |a| a.is_empty()), + "revoked service should not appear in document" + ); + } + + #[tokio::test] + async fn resolve_revoked_then_readded_gets_new_id() { + // A delegate is added, revoked, then re-added. The re-added delegate + // should get a higher counter ID (#delegate-3, not #delegate-1). + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + // Block 100: add (counter=1) + let log1 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + // Block 200: revoke (counter=2) + let log2 = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, 0, 100); + // Block 300: re-add (counter=3) + let log3 = make_delegate_changed_log(300, &identity, &delegate_type, &delegate, u64::MAX, 200); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 300, + identity_owner: None, + logs: HashMap::from([ + (100, vec![log1]), + (200, vec![log2]), + (300, vec![log3]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 2 delegate (RecoveryMethod + Eip712) = 4 + assert_eq!(vms.len(), 4); + + // Should NOT have #delegate-1 or #delegate-2 + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-1"))); + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-2"))); + + // Should have #delegate-3 (the re-added entry) + let d3 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-3")) + .expect("should have #delegate-3 VM"); + assert_eq!(d3["type"], "EcdsaSecp256k1RecoveryMethod2020"); + } + + #[tokio::test] + async fn resolve_secp256k1_attr_key_uses_jwk() { + // Secp256k1 attribute key (any encoding hint) must produce + // EcdsaSecp256k1VerificationKey2019 with publicKeyJwk (a JSON object). + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/base64"); + + let log = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + let attr_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM"); + assert_eq!(attr_vm["type"], "EcdsaSecp256k1VerificationKey2019"); + assert!(attr_vm["publicKeyJwk"].is_object(), "should use publicKeyJwk"); + assert_eq!(attr_vm["publicKeyJwk"]["kty"], "EC"); + assert_eq!(attr_vm["publicKeyJwk"]["crv"], "secp256k1"); + } + + #[tokio::test] + async fn resolve_ed25519_attr_key() { + // Ed25519 attribute key → Ed25519VerificationKey2020 + publicKeyMultibase + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Ed25519/veriKey/base64"); + // 32-byte Ed25519 public key + let ed_key: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let log = make_attribute_changed_log(100, &identity, &attr_name, &ed_key, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 1 Ed25519 = 3 + assert_eq!(vms.len(), 3); + + let attr_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM"); + assert_eq!(attr_vm["type"], "Ed25519VerificationKey2020"); + let expected_multibase = format!("z{}", bs58::encode(&ed_key).into_string()); + assert_eq!(attr_vm["publicKeyMultibase"], expected_multibase); + assert!(attr_vm.get("publicKeyJwk").is_none(), "should NOT have publicKeyJwk"); + + // Should be in assertionMethod (veriKey purpose) + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + + // Context should include Ed25519VerificationKey2020 and publicKeyMultibase + let context = doc_value["@context"].as_array().unwrap(); + let ctx_obj = context.iter().find(|c| c.is_object()).unwrap(); + assert!(ctx_obj.get("Ed25519VerificationKey2020").is_some(), + "context should include Ed25519VerificationKey2020"); + assert!(ctx_obj.get("publicKeyMultibase").is_some(), + "context should include publicKeyMultibase"); + } + + #[tokio::test] + async fn resolve_x25519_attr_key() { + // X25519 attribute key → X25519KeyAgreementKey2020 + publicKeyMultibase + keyAgreement + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/X25519/enc/base64"); + // 32-byte X25519 public key + let x_key: [u8; 32] = [ + 0xAA, 0xBB, 0xCC, 0xDD, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, + 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, + 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + ]; + + let log = make_attribute_changed_log(100, &identity, &attr_name, &x_key, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 1 X25519 = 3 + assert_eq!(vms.len(), 3); + + let attr_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM"); + assert_eq!(attr_vm["type"], "X25519KeyAgreementKey2020"); + let expected_multibase = format!("z{}", bs58::encode(&x_key).into_string()); + assert_eq!(attr_vm["publicKeyMultibase"], expected_multibase); + + // X25519 enc purpose → keyAgreement (not assertionMethod or authentication) + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + let key_agreement = doc_value["keyAgreement"].as_array() + .expect("should have keyAgreement array"); + assert!(key_agreement.iter().any(|v| v == &format!("{did_prefix}#delegate-1")), + "X25519 should be in keyAgreement"); + + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + assert!(!assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1")), + "X25519 should NOT be in assertionMethod"); + + // Context + let context = doc_value["@context"].as_array().unwrap(); + let ctx_obj = context.iter().find(|c| c.is_object()).unwrap(); + assert!(ctx_obj.get("X25519KeyAgreementKey2020").is_some(), + "context should include X25519KeyAgreementKey2020"); + assert!(ctx_obj.get("publicKeyMultibase").is_some(), + "context should include publicKeyMultibase"); + } + + #[tokio::test] + async fn resolve_address_did_has_no_public_key_jwk_in_context() { + // An address-only DID (no public key, no attribute keys) should NOT + // have publicKeyJwk in the @context — it was a bug where any + // EcdsaSecp256k1VerificationKey2019 VM triggered publicKeyJwk context. + let resolver = DIDEthr::<()>::default(); + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let context = doc_value["@context"].as_array().unwrap(); + let ctx_obj = context.iter().find(|c| c.is_object()).unwrap(); + + assert!( + ctx_obj.get("publicKeyJwk").is_none(), + "address-only DID should NOT have publicKeyJwk in context" + ); + } } From 8ca6b9e0f81c0e51128e0dc6b5b194d970794443 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 10 Mar 2026 12:00:40 +0100 Subject: [PATCH 24/36] fix: collect_events cycle guard for same-block previousChange Add visited set to break cycles when multiple registry calls in the same block cause previousChange == current_block. Follow only strictly retreating pointers (prev < current_block), taking max to avoid skipping intermediate blocks. Add test: collect_events_same_block_events_all_collected --- crates/dids/methods/ethr/src/lib.rs | 68 ++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index ce1c8bbfb..5428d0c49 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -295,8 +295,13 @@ async fn collect_events( let mut events = Vec::new(); let mut current_block = changed_block; + let mut visited = std::collections::HashSet::new(); while current_block > 0 { + if !visited.insert(current_block) { + break; // cycle guard + } + let filter = LogFilter { address: registry, topic0: topic0s.clone(), @@ -313,8 +318,13 @@ async fn collect_events( let mut next_block = 0u64; for log in &logs { if let Some(event) = parse_erc1056_event(log) { - next_block = event.previous_change(); - events.push((log.block_number, event)); + events.push((log.block_number, event.clone())); + let prev = event.previous_change(); + // Only follow pointers that strictly retreat; ignore same-block + // self-references (prev == current_block) which cause cycles. + if prev < current_block { + next_block = next_block.max(prev); + } } } @@ -1933,6 +1943,60 @@ mod tests { } } + #[tokio::test] + async fn collect_events_same_block_events_all_collected() { + // Simulate the same-block cycle bug: + // Block 100 has two events: + // - first event: previousChange=50 (normal retreat) + // - second event: previousChange=100 (self-reference due to changed[identity] + // already updated to current_block in the same block) + // Both events should be collected; next block is 50; loop terminates. + let identity: [u8; 20] = [0xFF; 20]; + let new_owner: [u8; 20] = [0x11; 20]; + + let mut attr_name = [0u8; 32]; + attr_name[..29].copy_from_slice(b"did/pub/Secp256k1/veriKey/hex"); + + // First event in block 100: previousChange=50 (normal) + let log_100_first = make_owner_changed_log(100, &identity, &new_owner, 50); + // Second event in block 100: previousChange=100 (self-reference / cycle) + let log_100_second = make_attribute_changed_log( + 100, &identity, &attr_name, b"\x04abc", u64::MAX, 100, + ); + // Event at block 50 (to ensure walk continues correctly) + let owner_at_50: [u8; 20] = [0x22; 20]; + let log_50 = make_owner_changed_log(50, &identity, &owner_at_50, 0); + + let provider = MockProvider { + changed_block: 100, + identity_owner: Some(new_owner), + logs: HashMap::from([ + (100, vec![log_100_first, log_100_second]), + (50, vec![log_50]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }; + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 100) + .await + .unwrap(); + + // Both block-100 events plus the block-50 event should all be collected + assert_eq!(events.len(), 3, "expected 3 events: one at block 50 and two at block 100"); + + // Chronological by block: block 50 first, then block 100 events + assert!(matches!(&events[0], (50, _))); + // Both events from block 100 are present (order within same block is reversed by + // the final events.reverse()) + let block_100_events: Vec<_> = events.iter().filter(|(b, _)| *b == 100).collect(); + assert_eq!(block_100_events.len(), 2); + let has_owner = block_100_events.iter().any(|(_, e)| matches!(e, Erc1056Event::OwnerChanged { .. })); + let has_attr = block_100_events.iter().any(|(_, e)| matches!(e, Erc1056Event::AttributeChanged { .. })); + assert!(has_owner, "expected an OwnerChanged event at block 100"); + assert!(has_attr, "expected an AttributeChanged event at block 100"); + } + #[tokio::test] async fn resolve_with_mock_provider_changed_zero() { // A mock provider where changed(addr) returns 0 should produce From 3827683d2fc9768a3877655b9086d8e953f15aca Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 10 Mar 2026 12:43:12 +0100 Subject: [PATCH 25/36] refactor: split lib.rs into focused modules - provider.rs: BlockRef, LogFilter, Log, EthProvider, NetworkConfig - abi.rs: ABI selectors, encode/decode helpers, formatting utils - events.rs: Erc1056Event, parse/collect logic + collect_events tests - network.rs: NetworkChain, InvalidNetwork, DecodedMethodSpecificId, parse_address_bytes - vm.rs: VerificationMethod, VerificationMethodType, Pending* structs, decode_delegate_type - resolver.rs: DIDEthr, apply_events, resolve_*, serialize_document + MockProvider tests - lib.rs: module declarations + public re-exports + offline-only tests --- crates/dids/methods/ethr/src/abi.rs | 64 + crates/dids/methods/ethr/src/events.rs | 513 +++ crates/dids/methods/ethr/src/lib.rs | 3911 ++-------------------- crates/dids/methods/ethr/src/network.rs | 120 + crates/dids/methods/ethr/src/provider.rs | 58 + crates/dids/methods/ethr/src/resolver.rs | 2730 +++++++++++++++ crates/dids/methods/ethr/src/vm.rs | 167 + 7 files changed, 3852 insertions(+), 3711 deletions(-) create mode 100644 crates/dids/methods/ethr/src/abi.rs create mode 100644 crates/dids/methods/ethr/src/events.rs create mode 100644 crates/dids/methods/ethr/src/network.rs create mode 100644 crates/dids/methods/ethr/src/provider.rs create mode 100644 crates/dids/methods/ethr/src/resolver.rs create mode 100644 crates/dids/methods/ethr/src/vm.rs diff --git a/crates/dids/methods/ethr/src/abi.rs b/crates/dids/methods/ethr/src/abi.rs new file mode 100644 index 000000000..09433ee8a --- /dev/null +++ b/crates/dids/methods/ethr/src/abi.rs @@ -0,0 +1,64 @@ +use ssi_crypto::hashes::keccak; +use chrono::{DateTime, Utc}; + +// --- ERC-1056 ABI selectors --- + +/// `changed(address)` — selector 0xf96d0f9f +pub(crate) const CHANGED_SELECTOR: [u8; 4] = [0xf9, 0x6d, 0x0f, 0x9f]; + +/// `identityOwner(address)` — selector 0x8733d4e8 +pub(crate) const IDENTITY_OWNER_SELECTOR: [u8; 4] = [0x87, 0x33, 0xd4, 0xe8]; + +/// Encode a 20-byte address as a 32-byte ABI-padded word +pub(crate) fn abi_encode_address(addr: &[u8; 20]) -> [u8; 32] { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(addr); + word +} + +/// Build calldata: 4-byte selector + 32-byte padded address +pub(crate) fn encode_call(selector: [u8; 4], addr: &[u8; 20]) -> Vec { + let mut data = Vec::with_capacity(36); + data.extend_from_slice(&selector); + data.extend_from_slice(&abi_encode_address(addr)); + data +} + +/// Decode a 32-byte uint256 return value +pub(crate) fn decode_uint256(data: &[u8]) -> u64 { + if data.len() < 32 { + return 0; + } + // Read last 8 bytes as u64 (ERC-1056 changed() returns small block numbers) + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&data[24..32]); + u64::from_be_bytes(bytes) +} + +/// Decode a 32-byte ABI-encoded address return value +pub(crate) fn decode_address(data: &[u8]) -> [u8; 20] { + if data.len() < 32 { + return [0u8; 20]; + } + let mut addr = [0u8; 20]; + addr.copy_from_slice(&data[12..32]); + addr +} + +/// Convert raw 20 bytes to an EIP-55 checksummed hex address string +pub(crate) fn format_address_eip55(addr: &[u8; 20]) -> String { + let lowercase = format!("0x{}", hex::encode(addr)); + keccak::eip55_checksum_addr(&lowercase).unwrap_or(lowercase) +} + +/// Format a Unix timestamp (seconds since epoch) as ISO 8601 UTC string +pub(crate) fn format_timestamp_iso8601(unix_secs: u64) -> String { + DateTime::::from_timestamp(unix_secs as i64, 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) +} + +/// Compute keccak256 hash of an event signature string +pub(crate) fn keccak256(data: &[u8]) -> [u8; 32] { + keccak::keccak256(data) +} diff --git a/crates/dids/methods/ethr/src/events.rs b/crates/dids/methods/ethr/src/events.rs new file mode 100644 index 000000000..8da7b6c5a --- /dev/null +++ b/crates/dids/methods/ethr/src/events.rs @@ -0,0 +1,513 @@ +use crate::abi::{abi_encode_address, decode_uint256, keccak256}; +use crate::provider::{EthProvider, Log, LogFilter}; + +// --- ERC-1056 event topic hashes --- + +/// Lazily compute event topic hashes from their Solidity signatures +pub(crate) fn topic_owner_changed() -> [u8; 32] { + keccak256(b"DIDOwnerChanged(address,address,uint256)") +} + +pub(crate) fn topic_delegate_changed() -> [u8; 32] { + keccak256(b"DIDDelegateChanged(address,bytes32,address,uint256,uint256)") +} + +pub(crate) fn topic_attribute_changed() -> [u8; 32] { + keccak256(b"DIDAttributeChanged(address,bytes32,bytes,uint256,uint256)") +} + +// --- ERC-1056 event types --- + +/// Parsed ERC-1056 events from the DIDRegistry contract +#[derive(Debug, Clone, PartialEq)] +pub enum Erc1056Event { + OwnerChanged { + identity: [u8; 20], + owner: [u8; 20], + previous_change: u64, + }, + DelegateChanged { + identity: [u8; 20], + delegate_type: [u8; 32], + delegate: [u8; 20], + valid_to: u64, + previous_change: u64, + }, + AttributeChanged { + identity: [u8; 20], + name: [u8; 32], + value: Vec, + valid_to: u64, + previous_change: u64, + }, +} + +impl Erc1056Event { + pub(crate) fn previous_change(&self) -> u64 { + match self { + Self::OwnerChanged { previous_change, .. } => *previous_change, + Self::DelegateChanged { previous_change, .. } => *previous_change, + Self::AttributeChanged { previous_change, .. } => *previous_change, + } + } +} + +/// Parse an Ethereum log into an Erc1056Event. +/// +/// Returns `None` if the log doesn't match any known ERC-1056 event or has +/// insufficient data. +pub(crate) fn parse_erc1056_event(log: &Log) -> Option { + if log.topics.is_empty() { + return None; + } + let topic0 = log.topics[0]; + + // Extract identity from topic[1] (indexed parameter, last 20 bytes of 32) + if log.topics.len() < 2 { + return None; + } + let mut identity = [0u8; 20]; + identity.copy_from_slice(&log.topics[1][12..32]); + + if topic0 == topic_owner_changed() { + // data: owner(32) + previousChange(32) = 64 bytes + if log.data.len() < 64 { + return None; + } + let mut owner = [0u8; 20]; + owner.copy_from_slice(&log.data[12..32]); + let previous_change = decode_uint256(&log.data[32..64]); + Some(Erc1056Event::OwnerChanged { + identity, + owner, + previous_change, + }) + } else if topic0 == topic_delegate_changed() { + // data: delegateType(32) + delegate(32) + validTo(32) + previousChange(32) = 128 + if log.data.len() < 128 { + return None; + } + let mut delegate_type = [0u8; 32]; + delegate_type.copy_from_slice(&log.data[0..32]); + let mut delegate = [0u8; 20]; + delegate.copy_from_slice(&log.data[44..64]); + let valid_to = decode_uint256(&log.data[64..96]); + let previous_change = decode_uint256(&log.data[96..128]); + Some(Erc1056Event::DelegateChanged { + identity, + delegate_type, + delegate, + valid_to, + previous_change, + }) + } else if topic0 == topic_attribute_changed() { + // data: name(32) + offset(32) + validTo(32) + previousChange(32) + valueLen(32) + value... + if log.data.len() < 160 { + return None; + } + let mut name = [0u8; 32]; + name.copy_from_slice(&log.data[0..32]); + let valid_to = decode_uint256(&log.data[64..96]); + let previous_change = decode_uint256(&log.data[96..128]); + let value_len = decode_uint256(&log.data[128..160]) as usize; + let value = if log.data.len() >= 160 + value_len { + log.data[160..160 + value_len].to_vec() + } else { + Vec::new() + }; + Some(Erc1056Event::AttributeChanged { + identity, + name, + value, + valid_to, + previous_change, + }) + } else { + None + } +} + +/// Walk the ERC-1056 linked-list event log and return events in chronological order. +/// +/// Starting from `changed_block`, fetches logs at each block and follows the +/// `previousChange` pointer backwards. The result is reversed to yield +/// chronological order. +pub(crate) async fn collect_events( + provider: &P, + registry: [u8; 20], + identity: &[u8; 20], + changed_block: u64, +) -> Result, String> { + if changed_block == 0 { + return Ok(Vec::new()); + } + + let identity_topic = abi_encode_address(identity); + let topic0s = vec![ + topic_owner_changed(), + topic_delegate_changed(), + topic_attribute_changed(), + ]; + + let mut events = Vec::new(); + let mut current_block = changed_block; + let mut visited = std::collections::HashSet::new(); + + while current_block > 0 { + if !visited.insert(current_block) { + break; // cycle guard + } + + let filter = LogFilter { + address: registry, + topic0: topic0s.clone(), + topic1: Some(identity_topic), + from_block: current_block, + to_block: current_block, + }; + + let logs = provider + .get_logs(filter) + .await + .map_err(|e| e.to_string())?; + + let mut next_block = 0u64; + for log in &logs { + if let Some(event) = parse_erc1056_event(log) { + events.push((log.block_number, event.clone())); + let prev = event.previous_change(); + // Only follow pointers that strictly retreat; ignore same-block + // self-references (prev == current_block) which cause cycles. + if prev < current_block { + next_block = next_block.max(prev); + } + } + } + + current_block = next_block; + } + + events.reverse(); // chronological order + Ok(events) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use crate::provider::Log; + + const TEST_REGISTRY: [u8; 20] = [ + 0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b, + ]; + + #[derive(Debug)] + struct MockProviderError(String); + impl std::fmt::Display for MockProviderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "MockProviderError: {}", self.0) + } + } + impl std::error::Error for MockProviderError {} + + struct MockProvider { + logs: HashMap>, + } + + impl EthProvider for MockProvider { + type Error = MockProviderError; + + async fn call( + &self, + _to: [u8; 20], + _data: Vec, + _block: crate::provider::BlockRef, + ) -> Result, Self::Error> { + Ok(vec![0u8; 32]) + } + + async fn get_logs(&self, filter: LogFilter) -> Result, Self::Error> { + let mut result = Vec::new(); + for block in filter.from_block..=filter.to_block { + if let Some(block_logs) = self.logs.get(&block) { + for log in block_logs { + if !filter.topic0.is_empty() && !log.topics.is_empty() { + if !filter.topic0.contains(&log.topics[0]) { + continue; + } + } + if let Some(t1) = filter.topic1 { + if log.topics.len() < 2 || log.topics[1] != t1 { + continue; + } + } + result.push(Log { + address: log.address, + topics: log.topics.clone(), + data: log.data.clone(), + block_number: log.block_number, + }); + } + } + } + Ok(result) + } + + async fn block_timestamp(&self, _block: u64) -> Result { + Ok(0) + } + } + + fn make_owner_changed_log( + block: u64, + identity: &[u8; 20], + new_owner: &[u8; 20], + previous_change: u64, + ) -> Log { + let identity_topic = abi_encode_address(identity); + let mut data = vec![0u8; 64]; + data[12..32].copy_from_slice(new_owner); + data[56..64].copy_from_slice(&previous_change.to_be_bytes()); + Log { + address: TEST_REGISTRY, + topics: vec![topic_owner_changed(), identity_topic], + data, + block_number: block, + } + } + + fn make_attribute_changed_log( + block: u64, + identity: &[u8; 20], + name: &[u8; 32], + value: &[u8], + valid_to: u64, + previous_change: u64, + ) -> Log { + let identity_topic = abi_encode_address(identity); + let padded_value_len = ((value.len() + 31) / 32) * 32; + let total_len = 160 + padded_value_len; + let mut data = vec![0u8; total_len]; + data[0..32].copy_from_slice(name); + data[56..64].copy_from_slice(&160u64.to_be_bytes()); + data[88..96].copy_from_slice(&valid_to.to_be_bytes()); + data[120..128].copy_from_slice(&previous_change.to_be_bytes()); + data[152..160].copy_from_slice(&(value.len() as u64).to_be_bytes()); + data[160..160 + value.len()].copy_from_slice(value); + Log { + address: TEST_REGISTRY, + topics: vec![topic_attribute_changed(), identity_topic], + data, + block_number: block, + } + } + + fn make_delegate_changed_log( + block: u64, + identity: &[u8; 20], + delegate_type: &[u8; 32], + delegate: &[u8; 20], + valid_to: u64, + previous_change: u64, + ) -> Log { + let identity_topic = abi_encode_address(identity); + let mut data = vec![0u8; 128]; + data[0..32].copy_from_slice(delegate_type); + data[44..64].copy_from_slice(delegate); + data[88..96].copy_from_slice(&valid_to.to_be_bytes()); + data[120..128].copy_from_slice(&previous_change.to_be_bytes()); + Log { + address: TEST_REGISTRY, + topics: vec![topic_delegate_changed(), identity_topic], + data, + block_number: block, + } + } + + #[tokio::test] + async fn collect_events_changed_zero_returns_empty() { + let identity: [u8; 20] = [0xAA; 20]; + let provider = MockProvider { logs: HashMap::new() }; + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 0) + .await + .unwrap(); + + assert!(events.is_empty(), "changed=0 should yield no events"); + } + + #[tokio::test] + async fn collect_events_single_block_one_event() { + let identity: [u8; 20] = [0xBB; 20]; + let new_owner: [u8; 20] = [0xCC; 20]; + + let log = make_owner_changed_log(100, &identity, &new_owner, 0); + + let provider = MockProvider { + logs: HashMap::from([(100, vec![log])]), + }; + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 100) + .await + .unwrap(); + + assert_eq!(events.len(), 1); + match &events[0] { + (_, Erc1056Event::OwnerChanged { identity: id, owner, previous_change }) => { + assert_eq!(id, &[0xBB; 20]); + assert_eq!(owner, &[0xCC; 20]); + assert_eq!(*previous_change, 0); + } + _ => panic!("expected OwnerChanged event"), + } + } + + #[tokio::test] + async fn collect_events_linked_list_walk_chronological_order() { + let identity: [u8; 20] = [0xDD; 20]; + let owner_a: [u8; 20] = [0x11; 20]; + let owner_b: [u8; 20] = [0x22; 20]; + + let log_at_100 = make_owner_changed_log(100, &identity, &owner_a, 0); + let log_at_200 = make_owner_changed_log(200, &identity, &owner_b, 100); + + let provider = MockProvider { + logs: HashMap::from([ + (100, vec![log_at_100]), + (200, vec![log_at_200]), + ]), + }; + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 200) + .await + .unwrap(); + + assert_eq!(events.len(), 2); + + match &events[0] { + (_, Erc1056Event::OwnerChanged { owner, previous_change, .. }) => { + assert_eq!(owner, &owner_a); + assert_eq!(*previous_change, 0); + } + _ => panic!("expected OwnerChanged event at index 0"), + } + + match &events[1] { + (_, Erc1056Event::OwnerChanged { owner, previous_change, .. }) => { + assert_eq!(owner, &owner_b); + assert_eq!(*previous_change, 100); + } + _ => panic!("expected OwnerChanged event at index 1"), + } + } + + #[tokio::test] + async fn collect_events_multiple_event_types_across_blocks() { + let identity: [u8; 20] = [0xEE; 20]; + let new_owner: [u8; 20] = [0x11; 20]; + let delegate: [u8; 20] = [0x22; 20]; + + let mut delegate_type = [0u8; 32]; + delegate_type[..7].copy_from_slice(b"veriKey"); + + let mut attr_name = [0u8; 32]; + attr_name[..29].copy_from_slice(b"did/pub/Secp256k1/veriKey/hex"); + + let log_100 = make_owner_changed_log(100, &identity, &new_owner, 0); + let log_200 = make_delegate_changed_log( + 200, &identity, &delegate_type, &delegate, u64::MAX, 100, + ); + let log_300 = make_attribute_changed_log( + 300, &identity, &attr_name, b"\x04abc", u64::MAX, 200, + ); + + let provider = MockProvider { + logs: HashMap::from([ + (100, vec![log_100]), + (200, vec![log_200]), + (300, vec![log_300]), + ]), + }; + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 300) + .await + .unwrap(); + + assert_eq!(events.len(), 3); + + assert!(matches!(&events[0], (_, Erc1056Event::OwnerChanged { .. }))); + assert!(matches!(&events[1], (_, Erc1056Event::DelegateChanged { .. }))); + assert!(matches!(&events[2], (_, Erc1056Event::AttributeChanged { .. }))); + + match &events[1] { + (_, Erc1056Event::DelegateChanged { delegate: d, valid_to, previous_change, .. }) => { + assert_eq!(d, &delegate); + assert_eq!(*valid_to, u64::MAX); + assert_eq!(*previous_change, 100); + } + _ => unreachable!(), + } + + match &events[2] { + (_, Erc1056Event::AttributeChanged { name, value, valid_to, previous_change, .. }) => { + assert_eq!(name, &attr_name); + assert_eq!(value, b"\x04abc"); + assert_eq!(*valid_to, u64::MAX); + assert_eq!(*previous_change, 200); + } + _ => unreachable!(), + } + } + + #[tokio::test] + async fn collect_events_same_block_events_all_collected() { + // Simulate the same-block cycle bug: + // Block 100 has two events: + // - first event: previousChange=50 (normal retreat) + // - second event: previousChange=100 (self-reference due to changed[identity] + // already updated to current_block in the same block) + // Both events should be collected; next block is 50; loop terminates. + let identity: [u8; 20] = [0xFF; 20]; + let new_owner: [u8; 20] = [0x11; 20]; + + let mut attr_name = [0u8; 32]; + attr_name[..29].copy_from_slice(b"did/pub/Secp256k1/veriKey/hex"); + + // First event in block 100: previousChange=50 (normal) + let log_100_first = make_owner_changed_log(100, &identity, &new_owner, 50); + // Second event in block 100: previousChange=100 (self-reference / cycle) + let log_100_second = make_attribute_changed_log( + 100, &identity, &attr_name, b"\x04abc", u64::MAX, 100, + ); + // Event at block 50 (to ensure walk continues correctly) + let owner_at_50: [u8; 20] = [0x22; 20]; + let log_50 = make_owner_changed_log(50, &identity, &owner_at_50, 0); + + let provider = MockProvider { + logs: HashMap::from([ + (100, vec![log_100_first, log_100_second]), + (50, vec![log_50]), + ]), + }; + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 100) + .await + .unwrap(); + + // Both block-100 events plus the block-50 event should all be collected + assert_eq!(events.len(), 3, "expected 3 events: one at block 50 and two at block 100"); + + // Chronological by block: block 50 first, then block 100 events + assert!(matches!(&events[0], (50, _))); + // Both events from block 100 are present (order within same block is reversed by + // the final events.reverse()) + let block_100_events: Vec<_> = events.iter().filter(|(b, _)| *b == 100).collect(); + assert_eq!(block_100_events.len(), 2); + let has_owner = block_100_events.iter().any(|(_, e)| matches!(e, Erc1056Event::OwnerChanged { .. })); + let has_attr = block_100_events.iter().any(|(_, e)| matches!(e, Erc1056Event::AttributeChanged { .. })); + assert!(has_owner, "expected an OwnerChanged event at block 100"); + assert!(has_attr, "expected an AttributeChanged event at block 100"); + } +} diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 5428d0c49..ab6858a10 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -1,3173 +1,254 @@ -use iref::Iri; -use chrono::{DateTime, Utc}; -use ssi_caips::caip10::BlockchainAccountId; -use ssi_caips::caip2::ChainId; -use ssi_crypto::hashes::keccak; -use ssi_dids_core::{ - document::{ - self, - representation::{self, MediaType}, - DIDVerificationMethod, - }, - resolution::{self, DIDMethodResolver, Error, Output}, - DIDBuf, DIDMethod, DIDURLBuf, Document, DIDURL, -}; -use static_iref::iri; -use std::collections::HashMap; -use std::str::FromStr; - -use indexmap::IndexMap; - -mod json_ld_context; -use json_ld_context::JsonLdContext; -use ssi_jwk::JWK; - -// --- Ethereum provider types --- - -/// Block reference for eth_call -pub enum BlockRef { - Latest, - Number(u64), -} - -/// Log filter for eth_getLogs -/// -/// `topic0` filters by event signature hash(es) — multiple values are OR'd. -/// `topic1` filters by the first indexed parameter (e.g. identity address). -pub struct LogFilter { - pub address: [u8; 20], - pub topic0: Vec<[u8; 32]>, - pub topic1: Option<[u8; 32]>, - pub from_block: u64, - pub to_block: u64, -} - -/// Ethereum event log -pub struct Log { - pub address: [u8; 20], - pub topics: Vec<[u8; 32]>, - pub data: Vec, - pub block_number: u64, -} - -/// Minimal async trait for Ethereum JSON-RPC interaction. -/// Users implement this with their preferred client (ethers-rs, alloy, etc.) -pub trait EthProvider: Send + Sync { - type Error: std::error::Error + Send + Sync + 'static; - - /// eth_call — execute a read-only contract call - fn call( - &self, - to: [u8; 20], - data: Vec, - block: BlockRef, - ) -> impl std::future::Future, Self::Error>> + Send; - - /// eth_getLogs — query event logs - fn get_logs( - &self, - filter: LogFilter, - ) -> impl std::future::Future, Self::Error>> + Send; - - /// Get block timestamp (seconds since epoch) - fn block_timestamp( - &self, - block: u64, - ) -> impl std::future::Future> + Send; -} - -/// Per-network Ethereum configuration -pub struct NetworkConfig

{ - pub chain_id: u64, - pub registry: [u8; 20], - pub provider: P, -} - -// --- ERC-1056 ABI selectors --- - -/// `changed(address)` — selector 0xf96d0f9f -const CHANGED_SELECTOR: [u8; 4] = [0xf9, 0x6d, 0x0f, 0x9f]; - -/// `identityOwner(address)` — selector 0x8733d4e8 -const IDENTITY_OWNER_SELECTOR: [u8; 4] = [0x87, 0x33, 0xd4, 0xe8]; - -/// Encode a 20-byte address as a 32-byte ABI-padded word -fn abi_encode_address(addr: &[u8; 20]) -> [u8; 32] { - let mut word = [0u8; 32]; - word[12..].copy_from_slice(addr); - word -} - -/// Build calldata: 4-byte selector + 32-byte padded address -fn encode_call(selector: [u8; 4], addr: &[u8; 20]) -> Vec { - let mut data = Vec::with_capacity(36); - data.extend_from_slice(&selector); - data.extend_from_slice(&abi_encode_address(addr)); - data -} - -/// Decode a 32-byte uint256 return value -fn decode_uint256(data: &[u8]) -> u64 { - if data.len() < 32 { - return 0; - } - // Read last 8 bytes as u64 (ERC-1056 changed() returns small block numbers) - let mut bytes = [0u8; 8]; - bytes.copy_from_slice(&data[24..32]); - u64::from_be_bytes(bytes) -} - -/// Decode a 32-byte ABI-encoded address return value -fn decode_address(data: &[u8]) -> [u8; 20] { - if data.len() < 32 { - return [0u8; 20]; - } - let mut addr = [0u8; 20]; - addr.copy_from_slice(&data[12..32]); - addr -} - -/// Convert raw 20 bytes to an EIP-55 checksummed hex address string -fn format_address_eip55(addr: &[u8; 20]) -> String { - let lowercase = format!("0x{}", hex::encode(addr)); - keccak::eip55_checksum_addr(&lowercase).unwrap_or(lowercase) -} - -/// Format a Unix timestamp (seconds since epoch) as ISO 8601 UTC string -fn format_timestamp_iso8601(unix_secs: u64) -> String { - DateTime::::from_timestamp(unix_secs as i64, 0) - .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) - .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) -} - -// --- ERC-1056 event topic hashes --- - -/// Compute keccak256 hash of an event signature string -fn keccak256(data: &[u8]) -> [u8; 32] { - keccak::keccak256(data) -} - -/// Lazily compute event topic hashes from their Solidity signatures -fn topic_owner_changed() -> [u8; 32] { - keccak256(b"DIDOwnerChanged(address,address,uint256)") -} - -fn topic_delegate_changed() -> [u8; 32] { - keccak256(b"DIDDelegateChanged(address,bytes32,address,uint256,uint256)") -} - -fn topic_attribute_changed() -> [u8; 32] { - keccak256(b"DIDAttributeChanged(address,bytes32,bytes,uint256,uint256)") -} - -// --- ERC-1056 event types --- - -/// Parsed ERC-1056 events from the DIDRegistry contract -#[derive(Debug, Clone, PartialEq)] -pub enum Erc1056Event { - OwnerChanged { - identity: [u8; 20], - owner: [u8; 20], - previous_change: u64, - }, - DelegateChanged { - identity: [u8; 20], - delegate_type: [u8; 32], - delegate: [u8; 20], - valid_to: u64, - previous_change: u64, - }, - AttributeChanged { - identity: [u8; 20], - name: [u8; 32], - value: Vec, - valid_to: u64, - previous_change: u64, - }, -} - -impl Erc1056Event { - fn previous_change(&self) -> u64 { - match self { - Self::OwnerChanged { previous_change, .. } => *previous_change, - Self::DelegateChanged { previous_change, .. } => *previous_change, - Self::AttributeChanged { previous_change, .. } => *previous_change, - } - } -} - -/// Parse an Ethereum log into an Erc1056Event. -/// -/// Returns `None` if the log doesn't match any known ERC-1056 event or has -/// insufficient data. -fn parse_erc1056_event(log: &Log) -> Option { - if log.topics.is_empty() { - return None; - } - let topic0 = log.topics[0]; - - // Extract identity from topic[1] (indexed parameter, last 20 bytes of 32) - if log.topics.len() < 2 { - return None; - } - let mut identity = [0u8; 20]; - identity.copy_from_slice(&log.topics[1][12..32]); - - if topic0 == topic_owner_changed() { - // data: owner(32) + previousChange(32) = 64 bytes - if log.data.len() < 64 { - return None; - } - let mut owner = [0u8; 20]; - owner.copy_from_slice(&log.data[12..32]); - let previous_change = decode_uint256(&log.data[32..64]); - Some(Erc1056Event::OwnerChanged { - identity, - owner, - previous_change, - }) - } else if topic0 == topic_delegate_changed() { - // data: delegateType(32) + delegate(32) + validTo(32) + previousChange(32) = 128 - if log.data.len() < 128 { - return None; - } - let mut delegate_type = [0u8; 32]; - delegate_type.copy_from_slice(&log.data[0..32]); - let mut delegate = [0u8; 20]; - delegate.copy_from_slice(&log.data[44..64]); - let valid_to = decode_uint256(&log.data[64..96]); - let previous_change = decode_uint256(&log.data[96..128]); - Some(Erc1056Event::DelegateChanged { - identity, - delegate_type, - delegate, - valid_to, - previous_change, - }) - } else if topic0 == topic_attribute_changed() { - // data: name(32) + offset(32) + validTo(32) + previousChange(32) + valueLen(32) + value... - if log.data.len() < 160 { - return None; - } - let mut name = [0u8; 32]; - name.copy_from_slice(&log.data[0..32]); - let valid_to = decode_uint256(&log.data[64..96]); - let previous_change = decode_uint256(&log.data[96..128]); - let value_len = decode_uint256(&log.data[128..160]) as usize; - let value = if log.data.len() >= 160 + value_len { - log.data[160..160 + value_len].to_vec() - } else { - Vec::new() - }; - Some(Erc1056Event::AttributeChanged { - identity, - name, - value, - valid_to, - previous_change, - }) - } else { - None - } -} - -/// Walk the ERC-1056 linked-list event log and return events in chronological order. -/// -/// Starting from `changed_block`, fetches logs at each block and follows the -/// `previousChange` pointer backwards. The result is reversed to yield -/// chronological order. -async fn collect_events( - provider: &P, - registry: [u8; 20], - identity: &[u8; 20], - changed_block: u64, -) -> Result, String> { - if changed_block == 0 { - return Ok(Vec::new()); - } - - let identity_topic = abi_encode_address(identity); - let topic0s = vec![ - topic_owner_changed(), - topic_delegate_changed(), - topic_attribute_changed(), - ]; - - let mut events = Vec::new(); - let mut current_block = changed_block; - let mut visited = std::collections::HashSet::new(); - - while current_block > 0 { - if !visited.insert(current_block) { - break; // cycle guard - } - - let filter = LogFilter { - address: registry, - topic0: topic0s.clone(), - topic1: Some(identity_topic), - from_block: current_block, - to_block: current_block, - }; - - let logs = provider - .get_logs(filter) - .await - .map_err(|e| e.to_string())?; - - let mut next_block = 0u64; - for log in &logs { - if let Some(event) = parse_erc1056_event(log) { - events.push((log.block_number, event.clone())); - let prev = event.previous_change(); - // Only follow pointers that strictly retreat; ignore same-block - // self-references (prev == current_block) which cause cycles. - if prev < current_block { - next_block = next_block.max(prev); - } - } - } - - current_block = next_block; - } - - events.reverse(); // chronological order - Ok(events) -} - -// --- DIDEthr --- - -/// did:ethr DID Method -/// -/// [Specification](https://github.com/decentralized-identity/ethr-did-resolver/) -/// -/// Generic over `P`: when `P = ()` (the default), only offline resolution is -/// available. When `P` implements [`EthProvider`], on-chain resolution is used -/// for networks that have a configured provider. -pub struct DIDEthr

{ - networks: HashMap>, -} - -impl

Default for DIDEthr

{ - fn default() -> Self { - Self { - networks: HashMap::new(), - } - } -} - -impl

DIDEthr

{ - /// Create a new `DIDEthr` resolver with no networks configured. - pub fn new() -> Self { - Self::default() - } - - /// Add a named network configuration. - pub fn add_network(&mut self, name: &str, config: NetworkConfig

) { - self.networks.insert(name.to_owned(), config); - } -} - -impl DIDEthr { - pub fn generate(jwk: &JWK) -> Result { - let hash = ssi_jwk::eip155::hash_public_key(jwk)?; - Ok(DIDBuf::from_string(format!("did:ethr:{}", hash)).unwrap()) - } -} - -impl DIDMethod for DIDEthr

{ - const DID_METHOD_NAME: &'static str = "ethr"; -} - -impl DIDMethodResolver for DIDEthr

{ - async fn resolve_method_representation<'a>( - &'a self, - method_specific_id: &'a str, - options: resolution::Options, - ) -> Result>, Error> { - let decoded_id = DecodedMethodSpecificId::from_str(method_specific_id) - .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; - - let network_name = decoded_id.network_name(); - - // Check if we have a provider for this network - if let Some(config) = self.networks.get(&network_name) { - let addr_hex = decoded_id.account_address_hex(); - if let Some(addr) = parse_address_bytes(&addr_hex) { - // Parse historical resolution target block from ?versionId=N - let target_block: Option = options - .parameters - .version_id - .as_deref() - .and_then(|v| v.parse::().ok()); - - // Call changed(addr) to see if there are on-chain modifications - let calldata = encode_call(CHANGED_SELECTOR, &addr); - let result = config - .provider - .call(config.registry, calldata.clone(), BlockRef::Latest) - .await - .map_err(|e| Error::Internal(e.to_string()))?; - let changed_block = decode_uint256(&result); - - if changed_block > 0 { - // Collect all events via linked-list walk - let all_events = collect_events( - &config.provider, - config.registry, - &addr, - changed_block, - ) - .await - .map_err(Error::Internal)?; - - // Partition events for historical resolution - let (events, events_after) = if let Some(tb) = target_block { - let before: Vec<_> = all_events.iter().filter(|(b, _)| *b <= tb).cloned().collect(); - let after: Vec<_> = all_events.iter().filter(|(b, _)| *b > tb).cloned().collect(); - (before, after) - } else { - (all_events, Vec::new()) - }; - - // For historical resolution at a block before any changes, - // return the genesis (default) document - if target_block.is_some() && events.is_empty() { - return resolve_offline(method_specific_id, &decoded_id, options); - } - - // Build document metadata (versionId + updated) - let meta_block = if let Some(tb) = target_block { - // Latest event block at or before target - events.iter().map(|(b, _)| *b).max().unwrap_or(tb) - } else { - changed_block - }; - let block_ts = config - .provider - .block_timestamp(meta_block) - .await - .map_err(|e| Error::Internal(e.to_string()))?; - let mut doc_metadata = document::Metadata { - version_id: Some(meta_block.to_string()), - updated: Some(format_timestamp_iso8601(block_ts)), - ..Default::default() - }; - - // Populate nextVersionId/nextUpdate from first event after target - if target_block.is_some() { - if let Some((next_block, _)) = events_after.first() { - let next_ts = config - .provider - .block_timestamp(*next_block) - .await - .map_err(|e| Error::Internal(e.to_string()))?; - doc_metadata.next_version_id = Some(next_block.to_string()); - doc_metadata.next_update = Some(format_timestamp_iso8601(next_ts)); - } - } - - // Check identityOwner(addr) — at target block for historical, Latest otherwise - let owner_block = match target_block { - Some(tb) => BlockRef::Number(tb), - None => BlockRef::Latest, - }; - let owner_calldata = encode_call(IDENTITY_OWNER_SELECTOR, &addr); - let owner_result = config - .provider - .call(config.registry, owner_calldata, owner_block) - .await - .map_err(|e| Error::Internal(e.to_string()))?; - let owner = decode_address(&owner_result); - - // Check for deactivation (owner = null address) - const NULL_ADDRESS: [u8; 20] = [0u8; 20]; - if owner == NULL_ADDRESS { - let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")).unwrap(); - let doc = Document::new(did); - let json_ld_context = JsonLdContext::default(); - doc_metadata.deactivated = Some(true); - return serialize_document(doc, json_ld_context, options, doc_metadata); - } - - // Build base document from owner - let mut json_ld_context = JsonLdContext::default(); - let (mut doc, _account_address) = if owner == addr { - // Owner unchanged — build from the DID's own identity - let doc = match decoded_id.address_or_public_key.len() { - 42 => resolve_address( - &mut json_ld_context, - method_specific_id, - &decoded_id.network_chain, - &decoded_id.address_or_public_key, - ), - 68 => resolve_public_key( - &mut json_ld_context, - method_specific_id, - &decoded_id.network_chain, - &decoded_id.address_or_public_key, - ), - _ => Err(Error::InvalidMethodSpecificId( - method_specific_id.to_owned(), - )), - }?; - (doc, decoded_id.address_or_public_key.clone()) - } else { - // Owner changed — build with the new owner's address - let owner_address = format_address_eip55(&owner); - let doc = resolve_address( - &mut json_ld_context, - method_specific_id, - &decoded_id.network_chain, - &owner_address, - )?; - (doc, owner_address) - }; - - // Apply delegate/attribute events - // For historical resolution, use target block's timestamp as "now" - let now = if let Some(_) = target_block { - block_ts - } else { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - }; - - let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")) - .unwrap(); - apply_events( - &mut doc, - &events, - &did, - &decoded_id.network_chain, - &mut json_ld_context, - now, - ); - - return serialize_document(doc, json_ld_context, options, doc_metadata); - } - } - } - - // No provider or changed=0 — offline resolution - resolve_offline(method_specific_id, &decoded_id, options) - } -} - -/// DIDMethodResolver impl for DIDEthr<()> — offline-only resolution -impl DIDMethodResolver for DIDEthr<()> { - async fn resolve_method_representation<'a>( - &'a self, - method_specific_id: &'a str, - options: resolution::Options, - ) -> Result>, Error> { - let decoded_id = DecodedMethodSpecificId::from_str(method_specific_id) - .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; - resolve_offline(method_specific_id, &decoded_id, options) - } -} - -/// Decode the delegate_type bytes32 field by trimming trailing zeros -fn decode_delegate_type(delegate_type: &[u8; 32]) -> &[u8] { - let end = delegate_type - .iter() - .rposition(|&b| b != 0) - .map(|i| i + 1) - .unwrap_or(0); - &delegate_type[..end] -} - -/// Process ERC-1056 events and add delegate verification methods to the document. -/// -/// Uses a map-accumulation model: each delegate/attribute event is keyed by its -/// content (delegate_type+delegate or name+value). When a valid event arrives the -/// entry is inserted; when a revoked/expired event arrives the entry is removed. -/// This correctly handles the case where a previously-valid key is later revoked. -/// -/// `now` is the current timestamp (seconds since epoch) used for expiry checks. -/// The delegate counter increments for every recognised DelegateChanged / -/// AttributeChanged-pub event regardless of validity, ensuring stable `#delegate-N` -/// IDs. Likewise for service_counter / `#service-N`. -fn apply_events( - doc: &mut Document, - events: &[(u64, Erc1056Event)], - did: &DIDBuf, - network_chain: &NetworkChain, - json_ld_context: &mut JsonLdContext, - now: u64, -) { - let mut delegate_counter = 0u64; - let mut service_counter = 0u64; - - // Content-keyed maps for deduplication and revocation support. - // Key = delegate_type[32] ++ delegate[20] for delegates, - // name[32] ++ value[..] for attribute keys / services. - let mut vms: IndexMap, PendingVm> = IndexMap::new(); - let mut svcs: IndexMap, PendingService> = IndexMap::new(); - - for (_block, event) in events { - match event { - Erc1056Event::DelegateChanged { - delegate_type, - delegate, - valid_to, - .. - } => { - let dt = decode_delegate_type(delegate_type); - - let is_veri_key = dt == b"veriKey"; - let is_sig_auth = dt == b"sigAuth"; - - if !is_veri_key && !is_sig_auth { - continue; - } - - delegate_counter += 1; - - // Content key: delegate_type[32] ++ delegate[20] - let mut key = Vec::with_capacity(52); - key.extend_from_slice(delegate_type); - key.extend_from_slice(delegate); - - if *valid_to >= now { - let delegate_addr = format_address_eip55(delegate); - let blockchain_account_id = BlockchainAccountId { - account_address: delegate_addr, - chain_id: ChainId { - namespace: "eip155".to_string(), - reference: network_chain.id().to_string(), - }, - }; - - vms.insert(key, PendingVm { - counter: delegate_counter, - payload: PendingVmPayload::Delegate { blockchain_account_id }, - is_sig_auth, - }); - } else { - vms.shift_remove(&key); - } - } - Erc1056Event::AttributeChanged { - name, - value, - valid_to, - .. - } => { - let attr_name = decode_delegate_type(name); // trims trailing zeros - let attr_str = match std::str::from_utf8(attr_name) { - Ok(s) => s, - Err(_) => continue, - }; - let parts: Vec<&str> = attr_str.split('/').collect(); - - if parts.len() >= 3 && parts[0] == "did" && parts[1] == "pub" { - // did/pub/// - delegate_counter += 1; - - let algo = parts.get(2).copied().unwrap_or(""); - let purpose = parts.get(3).copied().unwrap_or(""); - - // Content key: name[32] ++ value[..] - let mut key = Vec::with_capacity(32 + value.len()); - key.extend_from_slice(name); - key.extend_from_slice(value); - - if *valid_to >= now { - // Determine VM type and build the property value. - // Encoding hint from the attribute name is ignored; we - // always use the canonical property for each VM type. - let pending = match algo { - "Secp256k1" => { - match ssi_jwk::secp256k1_parse(value) { - Ok(jwk) => Some(PendingVmPayload::AttributeKey { - vm_type: VerificationMethodType::EcdsaSecp256k1VerificationKey2019, - prop_name: "publicKeyJwk", - prop_value: serde_json::to_value(&jwk).unwrap(), - }), - Err(_) => None, - } - } - "Ed25519" => { - let multibase = format!("z{}", bs58::encode(value).into_string()); - Some(PendingVmPayload::AttributeKey { - vm_type: VerificationMethodType::Ed25519VerificationKey2020, - prop_name: "publicKeyMultibase", - prop_value: serde_json::Value::String(multibase), - }) - } - "X25519" => { - let multibase = format!("z{}", bs58::encode(value).into_string()); - Some(PendingVmPayload::AttributeKey { - vm_type: VerificationMethodType::X25519KeyAgreementKey2020, - prop_name: "publicKeyMultibase", - prop_value: serde_json::Value::String(multibase), - }) - } - _ => None, - }; - - if let Some(payload) = pending { - let is_enc = purpose == "enc"; - let is_sig_auth = purpose == "sigAuth"; - vms.insert(key, PendingVm { - counter: delegate_counter, - payload, - is_sig_auth: is_sig_auth || is_enc, - }); - } - } else { - vms.shift_remove(&key); - } - } else if parts.len() >= 3 && parts[0] == "did" && parts[1] == "svc" { - // did/svc/ - service_counter += 1; - - // Content key: name[32] ++ value[..] - let mut key = Vec::with_capacity(32 + value.len()); - key.extend_from_slice(name); - key.extend_from_slice(value); - - if *valid_to >= now { - let service_type = parts[2..].join("/"); - let endpoint_str = String::from_utf8_lossy(value); - let endpoint = if let Ok(json_val) = serde_json::from_str::(&endpoint_str) { - if json_val.is_object() || json_val.is_array() { - document::service::Endpoint::Map(json_val) - } else { - parse_uri_endpoint(&endpoint_str) - } - } else { - parse_uri_endpoint(&endpoint_str) - }; - - svcs.insert(key, PendingService { - counter: service_counter, - service_type, - endpoint, - }); - } else { - svcs.shift_remove(&key); - } - } - } - // OwnerChanged handled by identityOwner() call - _ => {} - } - } - - // Materialise VMs from the map, sorted by counter for stable ordering. - let mut vm_entries: Vec<_> = vms.into_values().collect(); - vm_entries.sort_by_key(|v| v.counter); - - for vm_entry in vm_entries { - match vm_entry.payload { - PendingVmPayload::Delegate { blockchain_account_id } => { - let vm_id = format!("{did}#delegate-{}", vm_entry.counter); - let eip712_id = format!("{did}#delegate-{}-Eip712Method2021", vm_entry.counter); - - let vm_id_url = DIDURLBuf::from_string(vm_id).unwrap(); - let eip712_id_url = DIDURLBuf::from_string(eip712_id).unwrap(); - - let vm = VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { - id: vm_id_url.clone(), - controller: did.clone(), - blockchain_account_id: blockchain_account_id.clone(), - }; - - let eip712_vm = VerificationMethod::Eip712Method2021 { - id: eip712_id_url.clone(), - controller: did.clone(), - blockchain_account_id, - }; - - json_ld_context.add_verification_method_type(vm.type_()); - json_ld_context.add_verification_method_type(eip712_vm.type_()); - - doc.verification_method.push(vm.into()); - doc.verification_method.push(eip712_vm.into()); - - doc.verification_relationships - .assertion_method - .push(vm_id_url.clone().into()); - doc.verification_relationships - .assertion_method - .push(eip712_id_url.clone().into()); - - if vm_entry.is_sig_auth { - doc.verification_relationships - .authentication - .push(vm_id_url.into()); - doc.verification_relationships - .authentication - .push(eip712_id_url.into()); - } - } - PendingVmPayload::AttributeKey { vm_type, prop_name, prop_value } => { - let vm_id = format!("{did}#delegate-{}", vm_entry.counter); - let vm_id_url = DIDURLBuf::from_string(vm_id).unwrap(); - - let vm = DIDVerificationMethod { - id: vm_id_url.clone(), - type_: vm_type.name().to_owned(), - controller: did.clone(), - properties: [(prop_name.into(), prop_value)] - .into_iter() - .collect(), - }; - - json_ld_context.add_verification_method_type(vm_type); - json_ld_context.add_property(prop_name); - - doc.verification_method.push(vm); - - // Determine purpose from the attribute name embedded in is_sig_auth. - // For attribute keys, is_sig_auth encodes whether the key goes into - // authentication/keyAgreement (true for sigAuth and enc purposes). - // - // We need to re-derive the purpose from the original attribute, but - // since we only store is_sig_auth, we use a simpler approach: - // - X25519 keys → keyAgreement - // - is_sig_auth && not X25519 → assertionMethod + authentication - // - else → assertionMethod only - match vm_type { - VerificationMethodType::X25519KeyAgreementKey2020 => { - doc.verification_relationships - .key_agreement - .push(vm_id_url.into()); - } - _ if vm_entry.is_sig_auth => { - doc.verification_relationships - .assertion_method - .push(vm_id_url.clone().into()); - doc.verification_relationships - .authentication - .push(vm_id_url.into()); - } - _ => { - doc.verification_relationships - .assertion_method - .push(vm_id_url.into()); - } - } - } - } - } - - // Materialise services from the map, sorted by counter. - let mut svc_entries: Vec<_> = svcs.into_values().collect(); - svc_entries.sort_by_key(|s| s.counter); - - for svc_entry in svc_entries { - let service_id = format!("{did}#service-{}", svc_entry.counter); - let service = document::Service { - id: iref::UriBuf::new(service_id.into_bytes()).unwrap(), - type_: ssi_core::one_or_many::OneOrMany::One(svc_entry.service_type), - service_endpoint: Some(ssi_core::one_or_many::OneOrMany::One(svc_entry.endpoint)), - property_set: std::collections::BTreeMap::new(), - }; - doc.service.push(service); - } -} - -/// Helper to parse a string as a URI endpoint, falling back to a string-valued Map. -fn parse_uri_endpoint(s: &str) -> document::service::Endpoint { - match iref::UriBuf::new(s.as_bytes().to_vec()) { - Ok(uri) => document::service::Endpoint::Uri(uri), - Err(e) => document::service::Endpoint::Map( - serde_json::Value::String(String::from_utf8_lossy(&e.0).into_owned()), - ), - } -} - -/// Intermediate representation for a verification method accumulated during -/// event processing, before materialisation into the DID document. -struct PendingVm { - counter: u64, - payload: PendingVmPayload, - /// For delegates: true if sigAuth. For attribute keys: true if sigAuth or enc purpose. - is_sig_auth: bool, -} - -enum PendingVmPayload { - Delegate { - blockchain_account_id: BlockchainAccountId, - }, - AttributeKey { - vm_type: VerificationMethodType, - prop_name: &'static str, - prop_value: serde_json::Value, - }, -} - -/// Intermediate representation for a service endpoint accumulated during -/// event processing. -struct PendingService { - counter: u64, - service_type: String, - endpoint: document::service::Endpoint, -} - -/// Resolve a DID using the offline (genesis document) path -fn resolve_offline( - method_specific_id: &str, - decoded_id: &DecodedMethodSpecificId, - options: resolution::Options, -) -> Result>, Error> { - let mut json_ld_context = JsonLdContext::default(); - - let doc = match decoded_id.address_or_public_key.len() { - 42 => resolve_address( - &mut json_ld_context, - method_specific_id, - &decoded_id.network_chain, - &decoded_id.address_or_public_key, - ), - 68 => resolve_public_key( - &mut json_ld_context, - method_specific_id, - &decoded_id.network_chain, - &decoded_id.address_or_public_key, - ), - _ => Err(Error::InvalidMethodSpecificId( - method_specific_id.to_owned(), - )), - }?; - - serialize_document(doc, json_ld_context, options, document::Metadata::default()) -} - -fn serialize_document( - doc: Document, - json_ld_context: JsonLdContext, - options: resolution::Options, - doc_metadata: document::Metadata, -) -> Result>, Error> { - let content_type = options.accept.unwrap_or(MediaType::JsonLd); - let represented = doc.into_representation(representation::Options::from_media_type( - content_type, - move || representation::json_ld::Options { - context: representation::json_ld::Context::array( - representation::json_ld::DIDContext::V1, - json_ld_context.into_entries(), - ), - }, - )); - - Ok(resolution::Output::new( - represented.to_bytes(), - doc_metadata, - resolution::Metadata::from_content_type(Some(content_type.to_string())), - )) -} - -struct DecodedMethodSpecificId { - network_name: String, - network_chain: NetworkChain, - address_or_public_key: String, -} - -impl DecodedMethodSpecificId { - /// Return the network name used for provider lookup - fn network_name(&self) -> String { - self.network_name.clone() - } - - /// Extract the Ethereum address hex string (with 0x prefix). - /// For public-key DIDs, derives the address from the public key. - fn account_address_hex(&self) -> String { - if self.address_or_public_key.len() == 42 { - self.address_or_public_key.clone() - } else { - // Public key DID — derive the address - let pk_hex = &self.address_or_public_key; - if !pk_hex.starts_with("0x") { - return String::new(); - } - let pk_bytes = match hex::decode(&pk_hex[2..]) { - Ok(b) => b, - Err(_) => return String::new(), - }; - let pk_jwk = match ssi_jwk::secp256k1_parse(&pk_bytes) { - Ok(j) => j, - Err(_) => return String::new(), - }; - match ssi_jwk::eip155::hash_public_key_eip55(&pk_jwk) { - Ok(addr) => addr, - Err(_) => String::new(), - } - } - } -} - -impl FromStr for DecodedMethodSpecificId { - type Err = InvalidNetwork; - - fn from_str(method_specific_id: &str) -> Result { - // https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md#method-specific-identifier - let (network_name, address_or_public_key) = match method_specific_id.split_once(':') { - None => ("mainnet".to_string(), method_specific_id.to_string()), - Some((network, address_or_public_key)) => { - (network.to_string(), address_or_public_key.to_string()) - } - }; - - Ok(DecodedMethodSpecificId { - network_chain: network_name.parse()?, - network_name, - address_or_public_key, - }) - } -} - -/// Parse a hex address string (with 0x prefix) into 20 bytes -fn parse_address_bytes(addr_hex: &str) -> Option<[u8; 20]> { - if !addr_hex.starts_with("0x") || addr_hex.len() != 42 { - return None; - } - let bytes = hex::decode(&addr_hex[2..]).ok()?; - if bytes.len() != 20 { - return None; - } - let mut addr = [0u8; 20]; - addr.copy_from_slice(&bytes); - Some(addr) -} - -#[derive(Debug, thiserror::Error)] -#[error("invalid network `{0}`")] -struct InvalidNetwork(String); - -enum NetworkChain { - Mainnet, - Goerli, - Sepolia, - Other(u64), -} - -impl NetworkChain { - pub fn id(&self) -> u64 { - match self { - Self::Mainnet => 1, - Self::Goerli => 5, - Self::Sepolia => 11155111, - Self::Other(i) => *i, - } - } -} - -impl FromStr for NetworkChain { - type Err = InvalidNetwork; - - fn from_str(network_name: &str) -> Result { - match network_name { - "mainnet" => Ok(Self::Mainnet), - "goerli" => Ok(Self::Goerli), - "sepolia" => Ok(Self::Sepolia), - // Deprecated testnets — still parse for backward compatibility - "morden" => Ok(Self::Other(2)), - "ropsten" => Ok(Self::Other(3)), - "rinkeby" => Ok(Self::Other(4)), - "kovan" => Ok(Self::Other(42)), - network_chain_id if network_chain_id.starts_with("0x") => { - match u64::from_str_radix(&network_chain_id[2..], 16) { - Ok(chain_id) => Ok(Self::Other(chain_id)), - Err(_) => Err(InvalidNetwork(network_name.to_owned())), - } - } - _ => Err(InvalidNetwork(network_name.to_owned())), - } - } -} - -fn resolve_address( - json_ld_context: &mut JsonLdContext, - method_specific_id: &str, - network_chain: &NetworkChain, - account_address: &str, -) -> Result { - let blockchain_account_id = BlockchainAccountId { - account_address: account_address.to_owned(), - chain_id: ChainId { - namespace: "eip155".to_string(), - reference: network_chain.id().to_string(), - }, - }; - - let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")).unwrap(); - - let vm = VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { - id: DIDURLBuf::from_string(format!("{did}#controller")).unwrap(), - controller: did.to_owned(), - blockchain_account_id: blockchain_account_id.clone(), - }; - - let eip712_vm = VerificationMethod::Eip712Method2021 { - id: DIDURLBuf::from_string(format!("{did}#Eip712Method2021")).unwrap(), - controller: did.to_owned(), - blockchain_account_id, - }; - - json_ld_context.add_verification_method_type(vm.type_()); - json_ld_context.add_verification_method_type(eip712_vm.type_()); - - let mut doc = Document::new(did); - doc.verification_relationships.assertion_method = - vec![vm.id().to_owned().into(), eip712_vm.id().to_owned().into()]; - doc.verification_relationships.authentication = - vec![vm.id().to_owned().into(), eip712_vm.id().to_owned().into()]; - doc.verification_method = vec![vm.into(), eip712_vm.into()]; - - Ok(doc) -} - -/// Resolve an Ethr DID that uses a public key hex string instead of an account address -fn resolve_public_key( - json_ld_context: &mut JsonLdContext, - method_specific_id: &str, - network_chain: &NetworkChain, - public_key_hex: &str, -) -> Result { - if !public_key_hex.starts_with("0x") { - return Err(Error::InvalidMethodSpecificId( - method_specific_id.to_owned(), - )); - } - - let pk_bytes = hex::decode(&public_key_hex[2..]) - .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; - - let pk_jwk = ssi_jwk::secp256k1_parse(&pk_bytes) - .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; - - let account_address = ssi_jwk::eip155::hash_public_key_eip55(&pk_jwk) - .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; - - let blockchain_account_id = BlockchainAccountId { - account_address, - chain_id: ChainId { - namespace: "eip155".to_string(), - reference: network_chain.id().to_string(), - }, - }; - - let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")).unwrap(); - - let vm = VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { - id: DIDURLBuf::from_string(format!("{did}#controller")).unwrap(), - controller: did.to_owned(), - blockchain_account_id: blockchain_account_id.clone(), - }; - - let key_vm = VerificationMethod::EcdsaSecp256k1VerificationKey2019 { - id: DIDURLBuf::from_string(format!("{did}#controllerKey")).unwrap(), - controller: did.to_owned(), - public_key_jwk: pk_jwk, - }; - - let eip712_vm = VerificationMethod::Eip712Method2021 { - id: DIDURLBuf::from_string(format!("{did}#Eip712Method2021")).unwrap(), - controller: did.to_owned(), - blockchain_account_id, - }; - - json_ld_context.add_verification_method_type(vm.type_()); - json_ld_context.add_verification_method_type(key_vm.type_()); - json_ld_context.add_verification_method_type(eip712_vm.type_()); - json_ld_context.add_property("publicKeyJwk"); - - let mut doc = Document::new(did); - doc.verification_relationships.assertion_method = vec![ - vm.id().to_owned().into(), - key_vm.id().to_owned().into(), - eip712_vm.id().to_owned().into(), - ]; - doc.verification_relationships.authentication = vec![ - vm.id().to_owned().into(), - key_vm.id().to_owned().into(), - eip712_vm.id().to_owned().into(), - ]; - doc.verification_method = vec![vm.into(), key_vm.into(), eip712_vm.into()]; - - Ok(doc) -} - -#[allow(clippy::large_enum_variant)] -pub enum VerificationMethod { - EcdsaSecp256k1VerificationKey2019 { - id: DIDURLBuf, - controller: DIDBuf, - public_key_jwk: JWK, - }, - EcdsaSecp256k1RecoveryMethod2020 { - id: DIDURLBuf, - controller: DIDBuf, - blockchain_account_id: BlockchainAccountId, - }, - Eip712Method2021 { - id: DIDURLBuf, - controller: DIDBuf, - blockchain_account_id: BlockchainAccountId, - }, -} - -impl VerificationMethod { - pub fn id(&self) -> &DIDURL { - match self { - Self::EcdsaSecp256k1VerificationKey2019 { id, .. } => id, - Self::EcdsaSecp256k1RecoveryMethod2020 { id, .. } => id, - Self::Eip712Method2021 { id, .. } => id, - } - } - - pub fn type_(&self) -> VerificationMethodType { - match self { - Self::EcdsaSecp256k1VerificationKey2019 { .. } => { - VerificationMethodType::EcdsaSecp256k1VerificationKey2019 - } - Self::EcdsaSecp256k1RecoveryMethod2020 { .. } => { - VerificationMethodType::EcdsaSecp256k1RecoveryMethod2020 - } - Self::Eip712Method2021 { .. } => VerificationMethodType::Eip712Method2021, - } - } -} - -#[derive(Clone, Copy)] -pub enum VerificationMethodType { - EcdsaSecp256k1VerificationKey2019, - EcdsaSecp256k1RecoveryMethod2020, - Ed25519VerificationKey2020, - X25519KeyAgreementKey2020, - Eip712Method2021, -} - -impl VerificationMethodType { - pub fn name(&self) -> &'static str { - match self { - Self::EcdsaSecp256k1VerificationKey2019 => "EcdsaSecp256k1VerificationKey2019", - Self::EcdsaSecp256k1RecoveryMethod2020 => "EcdsaSecp256k1RecoveryMethod2020", - Self::Ed25519VerificationKey2020 => "Ed25519VerificationKey2020", - Self::X25519KeyAgreementKey2020 => "X25519KeyAgreementKey2020", - Self::Eip712Method2021 => "Eip712Method2021", - } - } - - pub fn iri(&self) -> &'static Iri { - match self { - Self::EcdsaSecp256k1VerificationKey2019 => iri!("https://w3id.org/security#EcdsaSecp256k1VerificationKey2019"), - Self::EcdsaSecp256k1RecoveryMethod2020 => iri!("https://identity.foundation/EcdsaSecp256k1RecoverySignature2020#EcdsaSecp256k1RecoveryMethod2020"), - Self::Ed25519VerificationKey2020 => iri!("https://w3id.org/security#Ed25519VerificationKey2020"), - Self::X25519KeyAgreementKey2020 => iri!("https://w3id.org/security#X25519KeyAgreementKey2020"), - Self::Eip712Method2021 => iri!("https://w3id.org/security#Eip712Method2021"), - } - } -} - -impl From for DIDVerificationMethod { - fn from(value: VerificationMethod) -> Self { - match value { - VerificationMethod::EcdsaSecp256k1VerificationKey2019 { - id, - controller, - public_key_jwk, - } => Self { - id, - type_: "EcdsaSecp256k1VerificationKey2019".to_owned(), - controller, - properties: [( - "publicKeyJwk".into(), - serde_json::to_value(&public_key_jwk).unwrap(), - )] - .into_iter() - .collect(), - }, - VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { - id, - controller, - blockchain_account_id, - } => Self { - id, - type_: "EcdsaSecp256k1RecoveryMethod2020".to_owned(), - controller, - properties: [( - "blockchainAccountId".into(), - blockchain_account_id.to_string().into(), - )] - .into_iter() - .collect(), - }, - VerificationMethod::Eip712Method2021 { - id, - controller, - blockchain_account_id, - } => Self { - id, - type_: "Eip712Method2021".to_owned(), - controller, - properties: [( - "blockchainAccountId".into(), - blockchain_account_id.to_string().into(), - )] - .into_iter() - .collect(), - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use iref::IriBuf; - use serde_json::json; - use ssi_claims::{ - data_integrity::{ - signing::AlterSignature, AnyInputSuiteOptions, AnySuite, CryptographicSuite, - ProofOptions, - }, - vc::{ - syntax::NonEmptyVec, - v1::{JsonCredential, JsonPresentation}, - }, - VerificationParameters, - }; - use ssi_dids_core::{did, DIDResolver}; - use ssi_jwk::JWK; - use ssi_verification_methods_core::{ProofPurpose, ReferenceOrOwned, SingleSecretSigner}; - use static_iref::uri; - - #[test] - fn jwk_to_did_ethr() { - let jwk: JWK = serde_json::from_value(json!({ - "alg": "ES256K-R", - "kty": "EC", - "crv": "secp256k1", - "x": "yclqMZ0MtyVkKm1eBh2AyaUtsqT0l5RJM3g4SzRT96A", - "y": "yQzUwKnftWCJPGs-faGaHiYi1sxA6fGJVw2Px_LCNe8", - })) - .unwrap(); - let did = DIDEthr::generate(&jwk).unwrap(); - assert_eq!(did, "did:ethr:0x2fbf1be19d90a29aea9363f4ef0b6bf1c4ff0758"); - } - - #[tokio::test] - async fn resolve_did_ethr_addr() { - // https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md#create-register - let resolver = DIDEthr::<()>::default(); - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await - .unwrap() - .document; - eprintln!("{}", serde_json::to_string_pretty(&doc).unwrap()); - assert_eq!( - serde_json::to_value(doc).unwrap(), - json!({ - "@context": [ - "https://www.w3.org/ns/did/v1", - { - "blockchainAccountId": "https://w3id.org/security#blockchainAccountId", - "EcdsaSecp256k1RecoveryMethod2020": "https://identity.foundation/EcdsaSecp256k1RecoverySignature2020#EcdsaSecp256k1RecoveryMethod2020", - "Eip712Method2021": "https://w3id.org/security#Eip712Method2021" - } - ], - "id": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a", - "verificationMethod": [{ - "id": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#controller", - "type": "EcdsaSecp256k1RecoveryMethod2020", - "controller": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a", - "blockchainAccountId": "eip155:1:0xb9c5714089478a327f09197987f16f9e5d936e8a" - }, { - "id": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#Eip712Method2021", - "type": "Eip712Method2021", - "controller": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a", - "blockchainAccountId": "eip155:1:0xb9c5714089478a327f09197987f16f9e5d936e8a" - }], - "authentication": [ - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#controller", - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#Eip712Method2021" - ], - "assertionMethod": [ - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#controller", - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#Eip712Method2021" - ] - }) - ); - } - - #[tokio::test] - async fn resolve_did_ethr_pk() { - let resolver = DIDEthr::<()>::default(); - let doc = resolver - .resolve(did!( - "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" - )) - .await - .unwrap() - .document; - eprintln!("{}", serde_json::to_string_pretty(&doc).unwrap()); - let doc_expected: serde_json::Value = - serde_json::from_str(include_str!("../tests/did-pk.jsonld")).unwrap(); - assert_eq!( - serde_json::to_value(doc).unwrap(), - serde_json::to_value(doc_expected).unwrap() - ); - } - - #[tokio::test] - async fn credential_prove_verify_did_ethr() { - eprintln!("with EcdsaSecp256k1RecoveryMethod2020..."); - credential_prove_verify_did_ethr2(false).await; - eprintln!("with Eip712Method2021..."); - credential_prove_verify_did_ethr2(true).await; - } - - async fn credential_prove_verify_did_ethr2(eip712: bool) { - let didethr = DIDEthr::<()>::default().into_vm_resolver(); - let verifier = VerificationParameters::from_resolver(&didethr); - let key: JWK = serde_json::from_value(json!({ - "alg": "ES256K-R", - "kty": "EC", - "crv": "secp256k1", - "x": "yclqMZ0MtyVkKm1eBh2AyaUtsqT0l5RJM3g4SzRT96A", - "y": "yQzUwKnftWCJPGs-faGaHiYi1sxA6fGJVw2Px_LCNe8", - "d": "meTmccmR_6ZsOa2YuTTkKkJ4ZPYsKdAH1Wx_RRf2j_E" - })) - .unwrap(); - - let did = DIDEthr::generate(&key).unwrap(); - eprintln!("did: {}", did); - - let cred = JsonCredential::new( - None, - did.clone().into_uri().into(), - "2021-02-18T20:23:13Z".parse().unwrap(), - NonEmptyVec::new(json_syntax::json!({ - "id": "did:example:foo" - })), - ); - - let verification_method = if eip712 { - ReferenceOrOwned::Reference(IriBuf::new(format!("{did}#Eip712Method2021")).unwrap()) - } else { - ReferenceOrOwned::Reference(IriBuf::new(format!("{did}#controller")).unwrap()) - }; - - let suite = AnySuite::pick(&key, Some(&verification_method)).unwrap(); - let issue_options = ProofOptions::new( - "2021-02-18T20:23:13Z".parse().unwrap(), - verification_method, - ProofPurpose::Assertion, - AnyInputSuiteOptions::default(), - ); - - eprintln!("vm {:?}", issue_options.verification_method); - let signer = SingleSecretSigner::new(key).into_local(); - let vc = suite - .sign(cred.clone(), &didethr, &signer, issue_options.clone()) - .await - .unwrap(); - println!( - "proof: {}", - serde_json::to_string_pretty(&vc.proofs).unwrap() - ); - if eip712 { - assert_eq!(vc.proofs.first().unwrap().signature.as_ref(), "0xd3f4a049551fd25c7fb0789c7303be63265e8ade2630747de3807710382bbb7a25b0407e9f858a771782c35b4f487f4337341e9a4375a073730bda643895964e1b") - } else { - assert_eq!(vc.proofs.first().unwrap().signature.as_ref(), "eyJhbGciOiJFUzI1NkstUiIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..nwNfIHhCQlI-j58zgqwJgX2irGJNP8hqLis-xS16hMwzs3OuvjqzZIHlwvdzDMPopUA_Oq7M7Iql2LNe0B22oQE"); - } - assert!(vc.verify(&verifier).await.unwrap().is_ok()); - - // test that issuer property is used for verification - let mut vc_bad_issuer = vc.clone(); - vc_bad_issuer.issuer = uri!("did:pkh:example:bad").to_owned().into(); - - // It should fail. - assert!(vc_bad_issuer.verify(&verifier).await.unwrap().is_err()); - - // Check that proof JWK must match proof verificationMethod - let wrong_key = JWK::generate_secp256k1(); - let wrong_signer = SingleSecretSigner::new(wrong_key.clone()).into_local(); - let vc_wrong_key = suite - .sign( - cred, - &didethr, - &wrong_signer, - ProofOptions { - options: AnyInputSuiteOptions::default() - .with_public_key(wrong_key.to_public()) - .unwrap(), - ..issue_options - }, - ) - .await - .unwrap(); - assert!(vc_wrong_key.verify(&verifier).await.unwrap().is_err()); - - // Make it into a VP - let presentation = JsonPresentation::new( - Some(uri!("http://example.org/presentations/3731").to_owned()), - None, - vec![vc], - ); - - let vp_issue_options = ProofOptions::new( - "2021-02-18T20:23:13Z".parse().unwrap(), - IriBuf::new(format!("{did}#controller")).unwrap().into(), - ProofPurpose::Authentication, - AnyInputSuiteOptions::default(), - ); - - let vp = suite - .sign(presentation, &didethr, &signer, vp_issue_options) - .await - .unwrap(); - - println!("VP: {}", serde_json::to_string_pretty(&vp).unwrap()); - assert!(vp.verify(&verifier).await.unwrap().is_ok()); - - // Mess with proof signature to make verify fail. - let mut vp_fuzzed = vp.clone(); - vp_fuzzed.proofs.first_mut().unwrap().signature.alter(); - let vp_fuzzed_result = vp_fuzzed.verify(&verifier).await; - assert!(vp_fuzzed_result.is_err() || vp_fuzzed_result.is_ok_and(|v| v.is_err())); - - // test that holder is verified - let mut vp_bad_holder = vp; - vp_bad_holder.holder = Some(uri!("did:pkh:example:bad").to_owned()); - - // It should fail. - assert!(vp_bad_holder.verify(&verifier).await.unwrap().is_err()); - } - - #[tokio::test] - async fn credential_verify_eip712vm() { - let didethr = DIDEthr::<()>::default().into_vm_resolver(); - let vc = ssi_claims::vc::v1::data_integrity::any_credential_from_json_str(include_str!( - "../tests/vc.jsonld" - )) - .unwrap(); - // eprintln!("vc {:?}", vc); - assert!(vc - .verify(VerificationParameters::from_resolver(didethr)) - .await - .unwrap() - .is_ok()) - } - - // --- Mock provider for on-chain resolution tests --- - - #[derive(Debug)] - struct MockProviderError(String); - impl std::fmt::Display for MockProviderError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "MockProviderError: {}", self.0) - } - } - impl std::error::Error for MockProviderError {} - - /// Mock provider that returns configurable responses for changed(), identityOwner(), and get_logs() - struct MockProvider { - /// Block number to return for changed(addr) calls - changed_block: u64, - /// Address to return for identityOwner(addr) calls (None = return the queried address) - identity_owner: Option<[u8; 20]>, - /// Per-block identity owners for historical resolution (block -> owner) - /// When identityOwner is called at BlockRef::Number(n), returns the owner for - /// the highest block <= n, falling back to identity_owner - identity_owner_at_block: HashMap, - /// Logs to return for get_logs calls, keyed by block number - logs: HashMap>, - /// Block timestamps to return for block_timestamp calls - block_timestamps: HashMap, - } - - impl MockProvider { - fn new_unchanged() -> Self { - Self { - changed_block: 0, - identity_owner: None, - identity_owner_at_block: HashMap::new(), - logs: HashMap::new(), - block_timestamps: HashMap::new(), - } - } - - fn new_same_owner() -> Self { - Self { - changed_block: 1, // has changes - identity_owner: None, // but owner is the same - identity_owner_at_block: HashMap::new(), - logs: HashMap::new(), - block_timestamps: HashMap::new(), - } - } - } - - impl EthProvider for MockProvider { - type Error = MockProviderError; - - async fn call( - &self, - _to: [u8; 20], - data: Vec, - block: BlockRef, - ) -> Result, Self::Error> { - if data.len() < 4 { - return Err(MockProviderError("calldata too short".into())); - } - let selector: [u8; 4] = data[..4].try_into().unwrap(); - match selector { - CHANGED_SELECTOR => { - // Return changed_block as uint256 - let mut result = vec![0u8; 32]; - result[24..32].copy_from_slice(&self.changed_block.to_be_bytes()); - Ok(result) - } - IDENTITY_OWNER_SELECTOR => { - let mut result = vec![0u8; 32]; - // For block-specific queries, check identity_owner_at_block first - if let BlockRef::Number(n) = block { - if !self.identity_owner_at_block.is_empty() { - // Find the owner at or before block n - let owner = self.identity_owner_at_block - .iter() - .filter(|(&b, _)| b <= n) - .max_by_key(|(&b, _)| b) - .map(|(_, o)| *o); - if let Some(o) = owner { - result[12..32].copy_from_slice(&o); - return Ok(result); - } - } - } - // Fallback to identity_owner or echo back the queried address - if let Some(owner) = self.identity_owner { - result[12..32].copy_from_slice(&owner); - } else if data.len() >= 36 { - result[12..32].copy_from_slice(&data[16..36]); - } - Ok(result) - } - _ => Err(MockProviderError(format!( - "unknown selector: {:?}", - selector - ))), - } - } - - async fn get_logs(&self, filter: LogFilter) -> Result, Self::Error> { - // Return logs for the requested block range, filtering by topic0 and topic1 - let mut result = Vec::new(); - for block in filter.from_block..=filter.to_block { - if let Some(block_logs) = self.logs.get(&block) { - for log in block_logs { - // Filter by topic0 if specified - if !filter.topic0.is_empty() && !log.topics.is_empty() { - if !filter.topic0.contains(&log.topics[0]) { - continue; - } - } - // Filter by topic1 if specified - if let Some(t1) = filter.topic1 { - if log.topics.len() < 2 || log.topics[1] != t1 { - continue; - } - } - result.push(Log { - address: log.address, - topics: log.topics.clone(), - data: log.data.clone(), - block_number: log.block_number, - }); - } - } - } - Ok(result) - } - - async fn block_timestamp(&self, block: u64) -> Result { - Ok(self.block_timestamps.get(&block).copied().unwrap_or(0)) - } - } - - const TEST_REGISTRY: [u8; 20] = [ - 0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, - 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, - 0x84, 0xfc, 0xf2, 0x1b, - ]; - - /// Build a DIDOwnerChanged log entry for testing - fn make_owner_changed_log( - block: u64, - identity: &[u8; 20], - new_owner: &[u8; 20], - previous_change: u64, - ) -> Log { - let identity_topic = abi_encode_address(identity); - let mut data = vec![0u8; 64]; - // data[0:32] = owner (address, zero-padded to 32 bytes) - data[12..32].copy_from_slice(new_owner); - // data[32:64] = previousChange (uint256) - data[56..64].copy_from_slice(&previous_change.to_be_bytes()); - - Log { - address: TEST_REGISTRY, - topics: vec![topic_owner_changed(), identity_topic], - data, - block_number: block, - } - } - - /// Build a DIDDelegateChanged log entry for testing - fn make_delegate_changed_log( - block: u64, - identity: &[u8; 20], - delegate_type: &[u8; 32], - delegate: &[u8; 20], - valid_to: u64, - previous_change: u64, - ) -> Log { - let identity_topic = abi_encode_address(identity); - let mut data = vec![0u8; 128]; - // data[0:32] = delegateType - data[0..32].copy_from_slice(delegate_type); - // data[32:64] = delegate (address, zero-padded) - data[44..64].copy_from_slice(delegate); - // data[64:96] = validTo - data[88..96].copy_from_slice(&valid_to.to_be_bytes()); - // data[96:128] = previousChange - data[120..128].copy_from_slice(&previous_change.to_be_bytes()); - - Log { - address: TEST_REGISTRY, - topics: vec![topic_delegate_changed(), identity_topic], - data, - block_number: block, - } - } - - /// Build a DIDAttributeChanged log entry for testing - fn make_attribute_changed_log( - block: u64, - identity: &[u8; 20], - name: &[u8; 32], - value: &[u8], - valid_to: u64, - previous_change: u64, - ) -> Log { - let identity_topic = abi_encode_address(identity); - // data layout: name(32) + offset(32) + validTo(32) + previousChange(32) + valueLen(32) + value(padded) - let padded_value_len = ((value.len() + 31) / 32) * 32; - let total_len = 160 + padded_value_len; - let mut data = vec![0u8; total_len]; - // data[0:32] = name - data[0..32].copy_from_slice(name); - // data[32:64] = offset to value (always 0xa0 = 160) - data[56..64].copy_from_slice(&160u64.to_be_bytes()); - // data[64:96] = validTo - data[88..96].copy_from_slice(&valid_to.to_be_bytes()); - // data[96:128] = previousChange - data[120..128].copy_from_slice(&previous_change.to_be_bytes()); - // data[128:160] = value length - data[152..160].copy_from_slice(&(value.len() as u64).to_be_bytes()); - // data[160..] = value bytes - data[160..160 + value.len()].copy_from_slice(value); - - Log { - address: TEST_REGISTRY, - topics: vec![topic_attribute_changed(), identity_topic], - data, - block_number: block, - } - } - - #[tokio::test] - async fn collect_events_changed_zero_returns_empty() { - let identity: [u8; 20] = [0xAA; 20]; - let provider = MockProvider::new_unchanged(); - - let events = collect_events(&provider, TEST_REGISTRY, &identity, 0) - .await - .unwrap(); - - assert!(events.is_empty(), "changed=0 should yield no events"); - } - - #[tokio::test] - async fn collect_events_single_block_one_event() { - let identity: [u8; 20] = [0xBB; 20]; - let new_owner: [u8; 20] = [0xCC; 20]; - - let log = make_owner_changed_log(100, &identity, &new_owner, 0); - - let provider = MockProvider { - changed_block: 100, - identity_owner: Some(new_owner), - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }; - - let events = collect_events(&provider, TEST_REGISTRY, &identity, 100) - .await - .unwrap(); - - assert_eq!(events.len(), 1); - match &events[0] { - (_, Erc1056Event::OwnerChanged { identity: id, owner, previous_change }) => { - assert_eq!(id, &[0xBB; 20]); - assert_eq!(owner, &[0xCC; 20]); - assert_eq!(*previous_change, 0); - } - _ => panic!("expected OwnerChanged event"), - } - } - - #[tokio::test] - async fn collect_events_linked_list_walk_chronological_order() { - // Block 200 has an owner change with previousChange=100 - // Block 100 has an owner change with previousChange=0 - // Expected: events returned in chronological order [block100, block200] - let identity: [u8; 20] = [0xDD; 20]; - let owner_a: [u8; 20] = [0x11; 20]; - let owner_b: [u8; 20] = [0x22; 20]; - - let log_at_100 = make_owner_changed_log(100, &identity, &owner_a, 0); - let log_at_200 = make_owner_changed_log(200, &identity, &owner_b, 100); - - let provider = MockProvider { - changed_block: 200, - identity_owner: Some(owner_b), - logs: HashMap::from([ - (100, vec![log_at_100]), - (200, vec![log_at_200]), - ]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }; - - let events = collect_events(&provider, TEST_REGISTRY, &identity, 200) - .await - .unwrap(); - - assert_eq!(events.len(), 2); - - // First event (chronologically) should be from block 100 - match &events[0] { - (_, Erc1056Event::OwnerChanged { owner, previous_change, .. }) => { - assert_eq!(owner, &owner_a); - assert_eq!(*previous_change, 0); - } - _ => panic!("expected OwnerChanged event at index 0"), - } - - // Second event should be from block 200 - match &events[1] { - (_, Erc1056Event::OwnerChanged { owner, previous_change, .. }) => { - assert_eq!(owner, &owner_b); - assert_eq!(*previous_change, 100); - } - _ => panic!("expected OwnerChanged event at index 1"), - } - } - - #[tokio::test] - async fn collect_events_multiple_event_types_across_blocks() { - // Block 300: attribute change (previousChange=200) - // Block 200: delegate change (previousChange=100) - // Block 100: owner change (previousChange=0) - let identity: [u8; 20] = [0xEE; 20]; - let new_owner: [u8; 20] = [0x11; 20]; - let delegate: [u8; 20] = [0x22; 20]; - - let mut delegate_type = [0u8; 32]; - delegate_type[..7].copy_from_slice(b"veriKey"); - - let mut attr_name = [0u8; 32]; - attr_name[..29].copy_from_slice(b"did/pub/Secp256k1/veriKey/hex"); - - let log_100 = make_owner_changed_log(100, &identity, &new_owner, 0); - let log_200 = make_delegate_changed_log( - 200, &identity, &delegate_type, &delegate, u64::MAX, 100, - ); - let log_300 = make_attribute_changed_log( - 300, &identity, &attr_name, b"\x04abc", u64::MAX, 200, - ); - - let provider = MockProvider { - changed_block: 300, - identity_owner: Some(new_owner), - logs: HashMap::from([ - (100, vec![log_100]), - (200, vec![log_200]), - (300, vec![log_300]), - ]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }; - - let events = collect_events(&provider, TEST_REGISTRY, &identity, 300) - .await - .unwrap(); - - assert_eq!(events.len(), 3); - - // Chronological: block 100 first - assert!(matches!(&events[0], (_, Erc1056Event::OwnerChanged { .. }))); - assert!(matches!(&events[1], (_, Erc1056Event::DelegateChanged { .. }))); - assert!(matches!(&events[2], (_, Erc1056Event::AttributeChanged { .. }))); - - // Verify delegate event details - match &events[1] { - (_, Erc1056Event::DelegateChanged { delegate: d, valid_to, previous_change, .. }) => { - assert_eq!(d, &delegate); - assert_eq!(*valid_to, u64::MAX); - assert_eq!(*previous_change, 100); - } - _ => unreachable!(), - } - - // Verify attribute event details - match &events[2] { - (_, Erc1056Event::AttributeChanged { name, value, valid_to, previous_change, .. }) => { - assert_eq!(name, &attr_name); - assert_eq!(value, b"\x04abc"); - assert_eq!(*valid_to, u64::MAX); - assert_eq!(*previous_change, 200); - } - _ => unreachable!(), - } - } - - #[tokio::test] - async fn collect_events_same_block_events_all_collected() { - // Simulate the same-block cycle bug: - // Block 100 has two events: - // - first event: previousChange=50 (normal retreat) - // - second event: previousChange=100 (self-reference due to changed[identity] - // already updated to current_block in the same block) - // Both events should be collected; next block is 50; loop terminates. - let identity: [u8; 20] = [0xFF; 20]; - let new_owner: [u8; 20] = [0x11; 20]; - - let mut attr_name = [0u8; 32]; - attr_name[..29].copy_from_slice(b"did/pub/Secp256k1/veriKey/hex"); - - // First event in block 100: previousChange=50 (normal) - let log_100_first = make_owner_changed_log(100, &identity, &new_owner, 50); - // Second event in block 100: previousChange=100 (self-reference / cycle) - let log_100_second = make_attribute_changed_log( - 100, &identity, &attr_name, b"\x04abc", u64::MAX, 100, - ); - // Event at block 50 (to ensure walk continues correctly) - let owner_at_50: [u8; 20] = [0x22; 20]; - let log_50 = make_owner_changed_log(50, &identity, &owner_at_50, 0); - - let provider = MockProvider { - changed_block: 100, - identity_owner: Some(new_owner), - logs: HashMap::from([ - (100, vec![log_100_first, log_100_second]), - (50, vec![log_50]), - ]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }; - - let events = collect_events(&provider, TEST_REGISTRY, &identity, 100) - .await - .unwrap(); - - // Both block-100 events plus the block-50 event should all be collected - assert_eq!(events.len(), 3, "expected 3 events: one at block 50 and two at block 100"); - - // Chronological by block: block 50 first, then block 100 events - assert!(matches!(&events[0], (50, _))); - // Both events from block 100 are present (order within same block is reversed by - // the final events.reverse()) - let block_100_events: Vec<_> = events.iter().filter(|(b, _)| *b == 100).collect(); - assert_eq!(block_100_events.len(), 2); - let has_owner = block_100_events.iter().any(|(_, e)| matches!(e, Erc1056Event::OwnerChanged { .. })); - let has_attr = block_100_events.iter().any(|(_, e)| matches!(e, Erc1056Event::AttributeChanged { .. })); - assert!(has_owner, "expected an OwnerChanged event at block 100"); - assert!(has_attr, "expected an AttributeChanged event at block 100"); - } - - #[tokio::test] - async fn resolve_with_mock_provider_changed_zero() { - // A mock provider where changed(addr) returns 0 should produce - // the same document as offline resolution. - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, - 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, - 0x84, 0xfc, 0xf2, 0x1b], - provider: MockProvider::new_unchanged(), - }, - ); - - let doc_onchain = resolver - .resolve(did!( - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" - )) - .await - .unwrap() - .document; - - let doc_offline = DIDEthr::<()>::default() - .resolve(did!( - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" - )) - .await - .unwrap() - .document; - - assert_eq!( - serde_json::to_value(&doc_onchain).unwrap(), - serde_json::to_value(&doc_offline).unwrap(), - "mock provider with changed=0 should produce same doc as offline" - ); - } - - #[tokio::test] - async fn resolve_with_mock_provider_owner_changed_address_did() { - // When identityOwner(addr) returns a different address, the #controller - // and Eip712Method2021 VMs should use the new owner's address in - // blockchainAccountId. - let new_owner: [u8; 20] = [0x11; 20]; - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, - 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, - 0x84, 0xfc, 0xf2, 0x1b], - provider: MockProvider { - changed_block: 1, - identity_owner: Some(new_owner), - logs: HashMap::new(), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }, - ); - - let doc = resolver - .resolve(did!( - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" - )) - .await - .unwrap() - .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - - // The DID id should still use the original address - assert_eq!( - doc_value["id"], - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" - ); - - // #controller VM should use the new owner's address - let vms = doc_value["verificationMethod"].as_array().unwrap(); - let controller_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#controller") - }).expect("should have #controller VM"); - assert_eq!( - controller_vm["blockchainAccountId"], - "eip155:1:0x1111111111111111111111111111111111111111" - ); - - // Eip712Method2021 should also use the new owner's address - let eip712_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#Eip712Method2021") - }).expect("should have #Eip712Method2021 VM"); - assert_eq!( - eip712_vm["blockchainAccountId"], - "eip155:1:0x1111111111111111111111111111111111111111" - ); - - // Should only have 2 VMs (no controllerKey for address-based DID) - assert_eq!(vms.len(), 2); - } - - #[tokio::test] - async fn resolve_with_mock_provider_owner_changed_pubkey_did() { - // When a public-key DID has a changed owner, #controllerKey must be - // omitted (the pubkey no longer represents the current owner). - // Only #controller and Eip712Method2021 remain. - let new_owner: [u8; 20] = [0x22; 20]; - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, - 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, - 0x84, 0xfc, 0xf2, 0x1b], - provider: MockProvider { - changed_block: 1, - identity_owner: Some(new_owner), - logs: HashMap::new(), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }, - ); - - let did_str = "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479"; - let doc = resolver - .resolve(did!( - "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" - )) - .await - .unwrap() - .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - - // DID id still uses the original public key - assert_eq!(doc_value["id"], did_str); - - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // Should have exactly 2 VMs: #controller and #Eip712Method2021 - assert_eq!(vms.len(), 2, "should have 2 VMs, not 3 (no #controllerKey)"); - - // No #controllerKey - assert!( - vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#controllerKey")), - "#controllerKey should be omitted when owner has changed" - ); - - // #controller uses new owner's blockchainAccountId - let controller_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#controller") - }).expect("should have #controller VM"); - assert_eq!( - controller_vm["blockchainAccountId"], - "eip155:1:0x2222222222222222222222222222222222222222" - ); - - // Eip712Method2021 uses new owner's blockchainAccountId - let eip712_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#Eip712Method2021") - }).expect("should have #Eip712Method2021 VM"); - assert_eq!( - eip712_vm["blockchainAccountId"], - "eip155:1:0x2222222222222222222222222222222222222222" - ); - } - - #[tokio::test] - async fn resolve_with_mock_provider_identity_owner_same() { - // A mock provider where identityOwner(addr) returns the same address - // should produce the same document as offline resolution. - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, - 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, - 0x84, 0xfc, 0xf2, 0x1b], - provider: MockProvider::new_same_owner(), - }, - ); - - let doc_onchain = resolver - .resolve(did!( - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" - )) - .await - .unwrap() - .document; - - let doc_offline = DIDEthr::<()>::default() - .resolve(did!( - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" - )) - .await - .unwrap() - .document; - - assert_eq!( - serde_json::to_value(&doc_onchain).unwrap(), - serde_json::to_value(&doc_offline).unwrap(), - "mock provider with identityOwner=same should produce same doc as offline" - ); - } - - #[tokio::test] - async fn resolve_with_mock_provider_owner_same_pubkey_did_retains_controller_key() { - // When a public-key DID's owner hasn't changed, #controllerKey - // must be retained in the document. - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, - 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, - 0x84, 0xfc, 0xf2, 0x1b], - provider: MockProvider::new_same_owner(), - }, - ); - - let doc = resolver - .resolve(did!( - "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" - )) - .await - .unwrap() - .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // Should have 3 VMs: #controller, #controllerKey, Eip712Method2021 - // (Note: Eip712Method2021 is only on the controller, not the key, - // so the exact set depends on the offline resolve_public_key behavior) - assert!( - vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#controllerKey")), - "#controllerKey should be retained when owner is unchanged" - ); - - // #controllerKey should have publicKeyJwk - let key_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#controllerKey") - }).unwrap(); - assert!(key_vm.get("publicKeyJwk").is_some(), "#controllerKey should have publicKeyJwk"); - } - - /// Helper: encode a delegate type string as bytes32 (right-padded with zeros) - fn encode_delegate_type(s: &str) -> [u8; 32] { - let mut b = [0u8; 32]; - let bytes = s.as_bytes(); - b[..bytes.len().min(32)].copy_from_slice(&bytes[..bytes.len().min(32)]); - b - } - - #[tokio::test] - async fn resolve_verikey_delegate_adds_vm() { - // A DIDDelegateChanged event with delegate_type="veriKey" and valid_to=MAX - // should add EcdsaSecp256k1RecoveryMethod2020 + Eip712Method2021 VMs - // with #delegate-1 and #delegate-1-Eip712Method2021 IDs. - // The delegate VM is referenced in assertionMethod but NOT authentication. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate: [u8; 20] = [0xAA; 20]; - let delegate_type = encode_delegate_type("veriKey"); - - let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, // same as identity - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }, - ); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await - .unwrap() - .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); - - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // Should have 4 VMs: #controller, #Eip712Method2021, #delegate-1, #delegate-1-Eip712Method2021 - assert_eq!(vms.len(), 4, "expected 4 VMs, got {}", vms.len()); - - // Check #delegate-1 VM - let delegate_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#delegate-1") - }).expect("should have #delegate-1 VM"); - assert_eq!(delegate_vm["type"], "EcdsaSecp256k1RecoveryMethod2020"); - let delegate_addr = format_address_eip55(&delegate); - let expected_account_id = format!("eip155:1:{}", delegate_addr); - assert_eq!(delegate_vm["blockchainAccountId"], expected_account_id); - - // Check #delegate-1-Eip712Method2021 VM - let delegate_eip712 = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#delegate-1-Eip712Method2021") - }).expect("should have #delegate-1-Eip712Method2021 VM"); - assert_eq!(delegate_eip712["type"], "Eip712Method2021"); - assert_eq!(delegate_eip712["blockchainAccountId"], expected_account_id); - - // #delegate-1 should be in assertionMethod but NOT authentication - let assertion = doc_value["assertionMethod"].as_array().unwrap(); - let auth = doc_value["authentication"].as_array().unwrap(); - - let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; - assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); - assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); - assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); - assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); - } - - #[tokio::test] - async fn resolve_sigauth_delegate_also_in_authentication() { - // A DIDDelegateChanged with delegate_type="sigAuth" should add VMs - // referenced in BOTH assertionMethod AND authentication. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate: [u8; 20] = [0xBB; 20]; - let delegate_type = encode_delegate_type("sigAuth"); - - let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }, - ); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await - .unwrap() - .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; - - let vms = doc_value["verificationMethod"].as_array().unwrap(); - assert_eq!(vms.len(), 4); - - // #delegate-1 should be in BOTH assertionMethod AND authentication - let assertion = doc_value["assertionMethod"].as_array().unwrap(); - let auth = doc_value["authentication"].as_array().unwrap(); - - assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); - assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); - assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); - assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); - } - - #[tokio::test] - async fn resolve_expired_delegate_not_included() { - // A delegate with valid_to < now is NOT included in the document. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate: [u8; 20] = [0xCC; 20]; - let delegate_type = encode_delegate_type("veriKey"); - - // valid_to = 1000 (well in the past) - let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, 1000, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }, - ); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await - .unwrap() - .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // Should only have the 2 base VMs (no delegate VMs) - assert_eq!(vms.len(), 2, "expired delegate should not be in document"); - assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().contains("delegate"))); - } - - #[tokio::test] - async fn resolve_revoked_delegate_skipped_but_counter_increments() { - // A revoked delegate (valid_to=0) is not included, but the counter - // still increments. So a subsequent valid delegate gets #delegate-2. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate_a: [u8; 20] = [0xDD; 20]; - let delegate_b: [u8; 20] = [0xEE; 20]; - let delegate_type = encode_delegate_type("veriKey"); - - // First delegate: revoked (valid_to=0) - let log_a = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, 0, 0); - // Second delegate: valid - let log_b = make_delegate_changed_log(200, &identity, &delegate_type, &delegate_b, u64::MAX, 100); - - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 200, - identity_owner: None, - logs: HashMap::from([ - (100, vec![log_a]), - (200, vec![log_b]), - ]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }, - ); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await - .unwrap() - .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // Should have 4 VMs: 2 base + 2 delegate (only delegate_b) - assert_eq!(vms.len(), 4); - - // No #delegate-1 (revoked) - assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-1"))); - - // Has #delegate-2 (counter still incremented past revoked) - let delegate_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#delegate-2") - }).expect("should have #delegate-2 VM"); - - let delegate_addr = format_address_eip55(&delegate_b); - assert_eq!(delegate_vm["blockchainAccountId"], format!("eip155:1:{delegate_addr}")); - } - - #[tokio::test] - async fn resolve_multiple_valid_delegates_sequential_ids() { - // Multiple valid delegates produce sequential #delegate-1, #delegate-2, etc. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate_a: [u8; 20] = [0x11; 20]; - let delegate_b: [u8; 20] = [0x22; 20]; - let veri_key = encode_delegate_type("veriKey"); - let sig_auth = encode_delegate_type("sigAuth"); - - let log_a = make_delegate_changed_log(100, &identity, &veri_key, &delegate_a, u64::MAX, 0); - let log_b = make_delegate_changed_log(200, &identity, &sig_auth, &delegate_b, u64::MAX, 100); - - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 200, - identity_owner: None, - logs: HashMap::from([ - (100, vec![log_a]), - (200, vec![log_b]), - ]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }, - ); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await - .unwrap() - .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // 2 base + 2 for delegate_a + 2 for delegate_b = 6 - assert_eq!(vms.len(), 6); - - // #delegate-1 is veriKey (assertionMethod only, NOT authentication) - assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1"))); - assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1-Eip712Method2021"))); - - // #delegate-2 is sigAuth (both assertionMethod AND authentication) - assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2"))); - assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2-Eip712Method2021"))); - - let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; - let auth = doc_value["authentication"].as_array().unwrap(); - - // delegate-1 should NOT be in auth (veriKey) - assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); - - // delegate-2 SHOULD be in auth (sigAuth) - assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-2"))); - assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-2-Eip712Method2021"))); - } - - #[tokio::test] - async fn resolve_delegate_and_attribute_key_share_counter() { - // Delegate events and attribute pub key events share the same delegate - // counter. A delegate at block 100 gets #delegate-1, an attribute key - // at block 200 gets #delegate-2. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate: [u8; 20] = [0xAA; 20]; - let delegate_type = encode_delegate_type("veriKey"); - let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); - - let log_delegate = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); - let log_attr = make_attribute_changed_log(200, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 100); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 200, - identity_owner: None, - logs: HashMap::from([(100, vec![log_delegate]), (200, vec![log_attr])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // 2 base + 2 delegate (RecoveryMethod + Eip712) + 1 attribute key = 5 - assert_eq!(vms.len(), 5); - - // #delegate-1 is the delegate event (EcdsaSecp256k1RecoveryMethod2020) - let d1 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1")).unwrap(); - assert_eq!(d1["type"], "EcdsaSecp256k1RecoveryMethod2020"); - - // #delegate-2 is the attribute key (EcdsaSecp256k1VerificationKey2019 with publicKeyJwk) - let d2 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2")).unwrap(); - assert_eq!(d2["type"], "EcdsaSecp256k1VerificationKey2019"); - assert!(d2["publicKeyJwk"].is_object(), "attribute key should have publicKeyJwk"); - assert_eq!(d2["publicKeyJwk"]["kty"], "EC"); - assert_eq!(d2["publicKeyJwk"]["crv"], "secp256k1"); - } - - #[tokio::test] - async fn resolve_multiple_services_sequential_ids() { - // Multiple did/svc attributes produce #service-1, #service-2, etc. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let attr_hub = encode_attr_name("did/svc/HubService"); - let attr_msg = encode_attr_name("did/svc/MessagingService"); - - let log_a = make_attribute_changed_log(100, &identity, &attr_hub, b"https://hub.example.com", u64::MAX, 0); - let log_b = make_attribute_changed_log(200, &identity, &attr_msg, b"https://msg.example.com", u64::MAX, 100); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 200, - identity_owner: None, - logs: HashMap::from([(100, vec![log_a]), (200, vec![log_b])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let services = doc_value["service"].as_array().unwrap(); - assert_eq!(services.len(), 2); - - let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; - assert_eq!(services[0]["id"], format!("{did_prefix}#service-1")); - assert_eq!(services[0]["type"], "HubService"); - assert_eq!(services[1]["id"], format!("{did_prefix}#service-2")); - assert_eq!(services[1]["type"], "MessagingService"); - } - - #[tokio::test] - async fn resolve_expired_attribute_excluded_counter_increments() { - // An expired attribute (valid_to < now) is excluded but the delegate - // counter still increments, so the next valid entry gets #delegate-2. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); - - // First key: expired (valid_to = 1000, well in the past) - let log_a = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, 1000, 0); - // Second key: valid (different key bytes so they have different content keys) - let log_b = make_attribute_changed_log(200, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED_2, u64::MAX, 100); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 200, - identity_owner: None, - logs: HashMap::from([(100, vec![log_a]), (200, vec![log_b])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // 2 base + 1 valid attribute key = 3 (expired one excluded) - assert_eq!(vms.len(), 3); - - // No #delegate-1 (expired) - assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-1"))); - - // Has #delegate-2 (counter incremented past expired) - let vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#delegate-2") - }).expect("should have #delegate-2 VM"); - assert!(vm["publicKeyJwk"].is_object(), "should have publicKeyJwk as object"); - } - - #[tokio::test] - async fn resolve_service_endpoint_json() { - // did/svc/MessagingService with JSON object value → structured serviceEndpoint - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let attr_name = encode_attr_name("did/svc/MessagingService"); - let endpoint = br#"{"uri":"https://msg.example.com","accept":["didcomm/v2"]}"#; - - let log = make_attribute_changed_log(100, &identity, &attr_name, endpoint, u64::MAX, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let services = doc_value["service"].as_array().unwrap(); - assert_eq!(services.len(), 1); - - let svc = &services[0]; - assert_eq!(svc["type"], "MessagingService"); - // JSON endpoint should be a parsed object, not a string - assert!(svc["serviceEndpoint"].is_object()); - assert_eq!(svc["serviceEndpoint"]["uri"], "https://msg.example.com"); - assert_eq!(svc["serviceEndpoint"]["accept"], json!(["didcomm/v2"])); - } - - #[tokio::test] - async fn resolve_service_endpoint_url() { - // did/svc/HubService with URL string → service entry - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let attr_name = encode_attr_name("did/svc/HubService"); - let endpoint = b"https://hubs.uport.me"; - - let log = make_attribute_changed_log(100, &identity, &attr_name, endpoint, u64::MAX, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); - let services = doc_value["service"].as_array().unwrap(); - assert_eq!(services.len(), 1); - - let svc = &services[0]; - assert_eq!(svc["id"], "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#service-1"); - assert_eq!(svc["type"], "HubService"); - assert_eq!(svc["serviceEndpoint"], "https://hubs.uport.me"); - } - - #[tokio::test] - async fn resolve_secp256k1_sigauth_hex_attribute_in_authentication() { - // did/pub/Secp256k1/sigAuth/hex attribute adds VM referenced in - // verificationMethod + assertionMethod + authentication. - // Uses a real compressed secp256k1 key so secp256k1_parse succeeds. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let attr_name = encode_attr_name("did/pub/Secp256k1/sigAuth/hex"); - - let log = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; - - let vms = doc_value["verificationMethod"].as_array().unwrap(); - // 2 base + 1 attribute key = 3 - assert_eq!(vms.len(), 3); +mod abi; +mod events; +mod json_ld_context; +mod network; +mod provider; +mod resolver; +mod vm; - let attr_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#delegate-1") - }).expect("should have #delegate-1 VM"); - assert_eq!(attr_vm["type"], "EcdsaSecp256k1VerificationKey2019"); - assert!(attr_vm["publicKeyJwk"].is_object(), "should have publicKeyJwk"); +pub use network::NetworkChain; +pub use provider::{BlockRef, EthProvider, Log, LogFilter, NetworkConfig}; +pub use resolver::DIDEthr; +pub use vm::{VerificationMethod, VerificationMethodType}; - let assertion = doc_value["assertionMethod"].as_array().unwrap(); - let auth = doc_value["authentication"].as_array().unwrap(); +#[cfg(test)] +mod tests { + use super::*; + use iref::IriBuf; + use serde_json::json; + use ssi_claims::{ + data_integrity::{ + signing::AlterSignature, AnyInputSuiteOptions, AnySuite, CryptographicSuite, + ProofOptions, + }, + vc::{ + syntax::NonEmptyVec, + v1::{JsonCredential, JsonPresentation}, + }, + VerificationParameters, + }; + use ssi_dids_core::{did, DIDResolver}; + use ssi_jwk::JWK; + use ssi_verification_methods_core::{ProofPurpose, ReferenceOrOwned, SingleSecretSigner}; + use static_iref::uri; - // sigAuth should be in BOTH assertionMethod AND authentication - assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); - assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + #[test] + fn jwk_to_did_ethr() { + let jwk: JWK = serde_json::from_value(json!({ + "alg": "ES256K-R", + "kty": "EC", + "crv": "secp256k1", + "x": "yclqMZ0MtyVkKm1eBh2AyaUtsqT0l5RJM3g4SzRT96A", + "y": "yQzUwKnftWCJPGs-faGaHiYi1sxA6fGJVw2Px_LCNe8", + })) + .unwrap(); + let did = DIDEthr::generate(&jwk).unwrap(); + assert_eq!(did, "did:ethr:0x2fbf1be19d90a29aea9363f4ef0b6bf1c4ff0758"); } #[tokio::test] - async fn resolve_delegates_with_owner_change_integration() { - // When the owner has changed AND there are delegates, both the - // owner-derived controller VM and the delegate VMs appear. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let new_owner: [u8; 20] = [0xFF; 20]; - let delegate: [u8; 20] = [0xAA; 20]; - let delegate_type = encode_delegate_type("veriKey"); - - let log_owner = make_owner_changed_log(100, &identity, &new_owner, 0); - let log_delegate = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, u64::MAX, 100); - - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 200, - identity_owner: Some(new_owner), - logs: HashMap::from([ - (100, vec![log_owner]), - (200, vec![log_delegate]), - ]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }, - ); - + async fn resolve_did_ethr_addr() { + // https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md#create-register + let resolver = DIDEthr::<()>::default(); let doc = resolver .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) .await .unwrap() .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // 2 base (with new owner) + 2 delegate = 4 - assert_eq!(vms.len(), 4); - - // #controller uses the new owner's address - let controller_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#controller") - }).unwrap(); - let owner_addr = format_address_eip55(&new_owner); - assert_eq!( - controller_vm["blockchainAccountId"], - format!("eip155:1:{owner_addr}") - ); - - // #delegate-1 uses the delegate's address - let delegate_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#delegate-1") - }).unwrap(); - let delegate_addr = format_address_eip55(&delegate); + eprintln!("{}", serde_json::to_string_pretty(&doc).unwrap()); assert_eq!( - delegate_vm["blockchainAccountId"], - format!("eip155:1:{delegate_addr}") + serde_json::to_value(doc).unwrap(), + json!({ + "@context": [ + "https://www.w3.org/ns/did/v1", + { + "blockchainAccountId": "https://w3id.org/security#blockchainAccountId", + "EcdsaSecp256k1RecoveryMethod2020": "https://identity.foundation/EcdsaSecp256k1RecoverySignature2020#EcdsaSecp256k1RecoveryMethod2020", + "Eip712Method2021": "https://w3id.org/security#Eip712Method2021" + } + ], + "id": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a", + "verificationMethod": [{ + "id": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#controller", + "type": "EcdsaSecp256k1RecoveryMethod2020", + "controller": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a", + "blockchainAccountId": "eip155:1:0xb9c5714089478a327f09197987f16f9e5d936e8a" + }, { + "id": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#Eip712Method2021", + "type": "Eip712Method2021", + "controller": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a", + "blockchainAccountId": "eip155:1:0xb9c5714089478a327f09197987f16f9e5d936e8a" + }], + "authentication": [ + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#controller", + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#Eip712Method2021" + ], + "assertionMethod": [ + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#controller", + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#Eip712Method2021" + ] + }) ); } - /// Helper: encode an attribute name string as bytes32 (right-padded with zeros) - fn encode_attr_name(s: &str) -> [u8; 32] { - let mut b = [0u8; 32]; - let bytes = s.as_bytes(); - b[..bytes.len().min(32)].copy_from_slice(&bytes[..bytes.len().min(32)]); - b - } - - /// A valid compressed secp256k1 public key (33 bytes) for use in tests. - const TEST_SECP256K1_COMPRESSED: [u8; 33] = [ - 0x03, 0xfd, 0xd5, 0x7a, 0xde, 0xc3, 0xd4, 0x38, 0xea, 0x23, 0x7f, - 0xe4, 0x6b, 0x33, 0xee, 0x1e, 0x01, 0x6e, 0xda, 0x6b, 0x58, 0x5c, - 0x3e, 0x27, 0xea, 0x66, 0x68, 0x6c, 0x2e, 0xa5, 0x35, 0x84, 0x79, - ]; - - /// A second valid compressed secp256k1 public key (different from the first). - const TEST_SECP256K1_COMPRESSED_2: [u8; 33] = [ - 0x02, 0xb9, 0x7c, 0x30, 0xde, 0x76, 0x7f, 0x08, 0x4c, 0xe3, 0x08, - 0x09, 0x68, 0xd8, 0x53, 0xd0, 0x3c, 0x3a, 0x28, 0x86, 0x53, 0xf8, - 0x12, 0x64, 0xa0, 0x90, 0xcd, 0x20, 0x3a, 0x12, 0xe5, 0x60, 0x40, - ]; - - #[tokio::test] - async fn resolve_secp256k1_verikey_hex_attribute() { - // did/pub/Secp256k1/veriKey/hex attribute adds EcdsaSecp256k1VerificationKey2019 - // with publicKeyJwk to verificationMethod + assertionMethod, using #delegate-N ID. - // Encoding hint ("hex") is ignored; we always convert to JWK. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); - - let log = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // 2 base + 1 attribute key = 3 - assert_eq!(vms.len(), 3); - - let attr_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#delegate-1") - }).expect("should have #delegate-1 VM from attribute"); - assert_eq!(attr_vm["type"], "EcdsaSecp256k1VerificationKey2019"); - // Must have publicKeyJwk (a JSON object), NOT publicKeyHex - assert!(attr_vm["publicKeyJwk"].is_object(), "should have publicKeyJwk as object"); - assert_eq!(attr_vm["publicKeyJwk"]["kty"], "EC"); - assert_eq!(attr_vm["publicKeyJwk"]["crv"], "secp256k1"); - assert!(attr_vm.get("publicKeyHex").is_none(), "should NOT have publicKeyHex"); - assert_eq!(attr_vm["controller"], "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"); - - // Should be in assertionMethod but NOT authentication - let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; - let assertion = doc_value["assertionMethod"].as_array().unwrap(); - let auth = doc_value["authentication"].as_array().unwrap(); - assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); - assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); - - // Context should include publicKeyJwk binding - let context = doc_value["@context"].as_array().unwrap(); - let ctx_obj = context.iter().find(|c| c.is_object()).unwrap(); - assert!(ctx_obj.get("publicKeyJwk").is_some(), "context should include publicKeyJwk"); - } - #[tokio::test] - async fn resolve_with_mock_provider_multiple_owner_changes() { - // Simulate multiple ownership transfers: identityOwner() returns the - // final owner. The document should use that address regardless of - // how many transfers occurred. - let final_owner: [u8; 20] = [ - 0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, - 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0x01, - ]; - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, - 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, - 0x84, 0xfc, 0xf2, 0x1b], - provider: MockProvider { - changed_block: 5, // multiple blocks of changes - identity_owner: Some(final_owner), - logs: HashMap::new(), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }, - ); - + async fn resolve_did_ethr_pk() { + let resolver = DIDEthr::<()>::default(); let doc = resolver .resolve(did!( - "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" )) .await .unwrap() .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // Compute expected checksummed address - let expected_addr = format_address_eip55(&final_owner); - let expected_account_id = format!("eip155:1:{expected_addr}"); - - let controller_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#controller") - }).expect("should have #controller VM"); - assert_eq!( - controller_vm["blockchainAccountId"].as_str().unwrap(), - expected_account_id, - ); - - let eip712_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#Eip712Method2021") - }).expect("should have #Eip712Method2021 VM"); + eprintln!("{}", serde_json::to_string_pretty(&doc).unwrap()); + let doc_expected: serde_json::Value = + serde_json::from_str(include_str!("../tests/did-pk.jsonld")).unwrap(); assert_eq!( - eip712_vm["blockchainAccountId"].as_str().unwrap(), - expected_account_id, + serde_json::to_value(doc).unwrap(), + serde_json::to_value(doc_expected).unwrap() ); } #[tokio::test] - async fn resolve_deactivated_null_owner_bare_doc() { - // When identityOwner() returns the null address (0x000...0), - // the DID is deactivated: bare doc with only `id`, empty VMs, - // and document_metadata.deactivated = true. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let null_owner: [u8; 20] = [0u8; 20]; - - let log = make_owner_changed_log(100, &identity, &null_owner, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: Some(null_owner), - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::from([(100, 1705312200)]), - identity_owner_at_block: HashMap::new(), - }, - }); + async fn credential_prove_verify_did_ethr() { + eprintln!("with EcdsaSecp256k1RecoveryMethod2020..."); + credential_prove_verify_did_ethr2(false).await; + eprintln!("with Eip712Method2021..."); + credential_prove_verify_did_ethr2(true).await; + } - let output = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await - .unwrap(); + async fn credential_prove_verify_did_ethr2(eip712: bool) { + let didethr = DIDEthr::<()>::default().into_vm_resolver(); + let verifier = VerificationParameters::from_resolver(&didethr); + let key: JWK = serde_json::from_value(json!({ + "alg": "ES256K-R", + "kty": "EC", + "crv": "secp256k1", + "x": "yclqMZ0MtyVkKm1eBh2AyaUtsqT0l5RJM3g4SzRT96A", + "y": "yQzUwKnftWCJPGs-faGaHiYi1sxA6fGJVw2Px_LCNe8", + "d": "meTmccmR_6ZsOa2YuTTkKkJ4ZPYsKdAH1Wx_RRf2j_E" + })) + .unwrap(); - // Document metadata must have deactivated = true - assert_eq!(output.document_metadata.deactivated, Some(true)); - // Deactivation is an on-chain change, so versionId/updated must be set - assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); - assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); + let did = DIDEthr::generate(&key).unwrap(); + eprintln!("did: {}", did); - let doc_value = serde_json::to_value(&output.document).unwrap(); + let cred = JsonCredential::new( + None, + did.clone().into_uri().into(), + "2021-02-18T20:23:13Z".parse().unwrap(), + NonEmptyVec::new(json_syntax::json!({ + "id": "did:example:foo" + })), + ); - // ID preserved - assert_eq!(doc_value["id"], "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"); + let verification_method = if eip712 { + ReferenceOrOwned::Reference(IriBuf::new(format!("{did}#Eip712Method2021")).unwrap()) + } else { + ReferenceOrOwned::Reference(IriBuf::new(format!("{did}#controller")).unwrap()) + }; - // Empty verificationMethod, authentication, assertionMethod - let vms = doc_value.get("verificationMethod"); - assert!(vms.is_none() || vms.unwrap().as_array().map_or(true, |a| a.is_empty())); - let auth = doc_value.get("authentication"); - assert!(auth.is_none() || auth.unwrap().as_array().map_or(true, |a| a.is_empty())); - let assertion = doc_value.get("assertionMethod"); - assert!(assertion.is_none() || assertion.unwrap().as_array().map_or(true, |a| a.is_empty())); - } + let suite = AnySuite::pick(&key, Some(&verification_method)).unwrap(); + let issue_options = ProofOptions::new( + "2021-02-18T20:23:13Z".parse().unwrap(), + verification_method, + ProofPurpose::Assertion, + AnyInputSuiteOptions::default(), + ); - #[tokio::test] - async fn resolve_deactivated_ignores_events() { - // Even with delegate/attribute events, deactivation discards them all. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let null_owner: [u8; 20] = [0u8; 20]; - let delegate: [u8; 20] = [0xAA; 20]; - let delegate_type = encode_delegate_type("veriKey"); - let attr_name = encode_attr_name("did/svc/HubService"); + eprintln!("vm {:?}", issue_options.verification_method); + let signer = SingleSecretSigner::new(key).into_local(); + let vc = suite + .sign(cred.clone(), &didethr, &signer, issue_options.clone()) + .await + .unwrap(); + println!( + "proof: {}", + serde_json::to_string_pretty(&vc.proofs).unwrap() + ); + if eip712 { + assert_eq!(vc.proofs.first().unwrap().signature.as_ref(), "0xd3f4a049551fd25c7fb0789c7303be63265e8ade2630747de3807710382bbb7a25b0407e9f858a771782c35b4f487f4337341e9a4375a073730bda643895964e1b") + } else { + assert_eq!(vc.proofs.first().unwrap().signature.as_ref(), "eyJhbGciOiJFUzI1NkstUiIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..nwNfIHhCQlI-j58zgqwJgX2irGJNP8hqLis-xS16hMwzs3OuvjqzZIHlwvdzDMPopUA_Oq7M7Iql2LNe0B22oQE"); + } + assert!(vc.verify(&verifier).await.unwrap().is_ok()); - let log_owner = make_owner_changed_log(100, &identity, &null_owner, 0); - let log_delegate = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, u64::MAX, 100); - let log_attr = make_attribute_changed_log(300, &identity, &attr_name, b"https://hub.example.com", u64::MAX, 200); + // test that issuer property is used for verification + let mut vc_bad_issuer = vc.clone(); + vc_bad_issuer.issuer = uri!("did:pkh:example:bad").to_owned().into(); - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 300, - identity_owner: Some(null_owner), - logs: HashMap::from([ - (100, vec![log_owner]), - (200, vec![log_delegate]), - (300, vec![log_attr]), - ]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); + // It should fail. + assert!(vc_bad_issuer.verify(&verifier).await.unwrap().is_err()); - let output = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + // Check that proof JWK must match proof verificationMethod + let wrong_key = JWK::generate_secp256k1(); + let wrong_signer = SingleSecretSigner::new(wrong_key.clone()).into_local(); + let vc_wrong_key = suite + .sign( + cred, + &didethr, + &wrong_signer, + ProofOptions { + options: AnyInputSuiteOptions::default() + .with_public_key(wrong_key.to_public()) + .unwrap(), + ..issue_options + }, + ) .await .unwrap(); + assert!(vc_wrong_key.verify(&verifier).await.unwrap().is_err()); - assert_eq!(output.document_metadata.deactivated, Some(true)); - - let doc_value = serde_json::to_value(&output.document).unwrap(); - - // No VMs, no services — all events discarded - let vms = doc_value.get("verificationMethod"); - assert!(vms.is_none() || vms.unwrap().as_array().map_or(true, |a| a.is_empty())); - let services = doc_value.get("service"); - assert!(services.is_none() || services.unwrap().as_array().map_or(true, |a| a.is_empty())); - } + // Make it into a VP + let presentation = JsonPresentation::new( + Some(uri!("http://example.org/presentations/3731").to_owned()), + None, + vec![vc], + ); - #[tokio::test] - async fn resolve_non_null_owner_not_deactivated() { - // When the owner is non-null, deactivated should be None (not set). - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider::new_same_owner(), - }); + let vp_issue_options = ProofOptions::new( + "2021-02-18T20:23:13Z".parse().unwrap(), + IriBuf::new(format!("{did}#controller")).unwrap().into(), + ProofPurpose::Authentication, + AnyInputSuiteOptions::default(), + ); - let output = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + let vp = suite + .sign(presentation, &didethr, &signer, vp_issue_options) .await .unwrap(); - // deactivated should be None (default) - assert!(output.document_metadata.deactivated.is_none() - || output.document_metadata.deactivated == Some(false)); - } + println!("VP: {}", serde_json::to_string_pretty(&vp).unwrap()); + assert!(vp.verify(&verifier).await.unwrap().is_ok()); - #[tokio::test] - async fn metadata_no_changes_no_version_id_or_updated() { - // DID with no on-chain changes (changed=0) → no versionId/updated metadata - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider::new_unchanged(), - }); + // Mess with proof signature to make verify fail. + let mut vp_fuzzed = vp.clone(); + vp_fuzzed.proofs.first_mut().unwrap().signature.alter(); + let vp_fuzzed_result = vp_fuzzed.verify(&verifier).await; + assert!(vp_fuzzed_result.is_err() || vp_fuzzed_result.is_ok_and(|v| v.is_err())); - let output = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await - .unwrap(); + // test that holder is verified + let mut vp_bad_holder = vp; + vp_bad_holder.holder = Some(uri!("did:pkh:example:bad").to_owned()); - assert!(output.document_metadata.version_id.is_none()); - assert!(output.document_metadata.updated.is_none()); + // It should fail. + assert!(vp_bad_holder.verify(&verifier).await.unwrap().is_err()); } #[tokio::test] - async fn metadata_with_changes_has_version_id_and_updated() { - // DID with on-chain changes (changed_block=100) → versionId = "100", - // updated = ISO 8601 timestamp of block 100 - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate: [u8; 20] = [0xAA; 20]; - let delegate_type = encode_delegate_type("veriKey"); - let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); - - // Block 100 has timestamp 1705312200 = 2024-01-15T09:50:00Z - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::from([(100, 1705312200)]), - identity_owner_at_block: HashMap::new(), - }, - }); - - let output = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + async fn credential_verify_eip712vm() { + let didethr = DIDEthr::<()>::default().into_vm_resolver(); + let vc = ssi_claims::vc::v1::data_integrity::any_credential_from_json_str(include_str!( + "../tests/vc.jsonld" + )) + .unwrap(); + // eprintln!("vc {:?}", vc); + assert!(vc + .verify(VerificationParameters::from_resolver(didethr)) .await - .unwrap(); - - assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); - assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); + .unwrap() + .is_ok()) } #[tokio::test] @@ -3207,241 +288,6 @@ mod tests { assert_eq!(json["updated"], "2024-01-15T09:50:00Z"); } - // --- Phase 8: Historical Resolution (?versionId=N) tests --- - - #[tokio::test] - async fn historical_version_id_skips_events_after_target_block() { - // Events at blocks 100 and 200. Resolving with versionId=100 - // should only include events from block 100. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate_a: [u8; 20] = [0xAA; 20]; - let delegate_b: [u8; 20] = [0xBB; 20]; - let delegate_type = encode_delegate_type("veriKey"); - - let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, u64::MAX, 0); - let log_200 = make_delegate_changed_log(200, &identity, &delegate_type, &delegate_b, u64::MAX, 100); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 200, - identity_owner: None, - identity_owner_at_block: HashMap::new(), - logs: HashMap::from([ - (100, vec![log_100]), - (200, vec![log_200]), - ]), - block_timestamps: HashMap::from([ - (100, 1705312200), // 2024-01-15T09:50:00Z - (200, 1705398600), // 2024-01-16T09:50:00Z - ]), - }, - }); - - let options = resolution::Options { - parameters: resolution::Parameters { - version_id: Some("100".to_string()), - ..Default::default() - }, - ..Default::default() - }; - - let output = resolver - .resolve_with( - did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), - options, - ) - .await - .unwrap(); - - let doc_value = serde_json::to_value(&output.document).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // Should have 4 VMs: 2 base + 2 delegate (only delegate_a from block 100) - assert_eq!(vms.len(), 4, "only events at/before block 100 should be applied"); - - // delegate_a (#delegate-1) present - let d1 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1")); - assert!(d1.is_some(), "delegate from block 100 should be present"); - - // delegate_b (#delegate-2) NOT present - let d2 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2")); - assert!(d2.is_none(), "delegate from block 200 should NOT be present"); - - // Metadata: versionId should be "100" (latest event at or before target) - assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); - assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); - } - - #[tokio::test] - async fn historical_valid_to_uses_target_block_timestamp() { - // Delegate valid_to = 1705315800 (block 100 timestamp + 1 hour). - // Block 100 timestamp = 1705312200. At block 100 the delegate is still valid. - // At wall-clock time (far future) it would be expired. - // ?versionId=100 should include the delegate. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate_a: [u8; 20] = [0xAA; 20]; - let delegate_type = encode_delegate_type("veriKey"); - let valid_to = 1705315800u64; // 1 hour after block 100 timestamp - - let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, valid_to, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - identity_owner_at_block: HashMap::new(), - logs: HashMap::from([ - (100, vec![log_100]), - ]), - block_timestamps: HashMap::from([ - (100, 1705312200), // 2024-01-15T09:50:00Z - ]), - }, - }); - - let options = resolution::Options { - parameters: resolution::Parameters { - version_id: Some("100".to_string()), - ..Default::default() - }, - ..Default::default() - }; - - let output = resolver - .resolve_with( - did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), - options, - ) - .await - .unwrap(); - - let doc_value = serde_json::to_value(&output.document).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // 2 base + 2 delegate VMs (delegate is valid at block 100's timestamp) - assert_eq!(vms.len(), 4, "delegate valid at block timestamp should be included"); - - let d1 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1")); - assert!(d1.is_some(), "delegate still valid at block 100 timestamp should be present"); - } - - #[tokio::test] - async fn historical_next_version_id_and_next_update() { - // Events at blocks 100 and 200. Resolving at versionId=100 should set - // nextVersionId=200 and nextUpdate to block 200's timestamp. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate_a: [u8; 20] = [0xAA; 20]; - let delegate_b: [u8; 20] = [0xBB; 20]; - let delegate_type = encode_delegate_type("veriKey"); - - let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, u64::MAX, 0); - let log_200 = make_delegate_changed_log(200, &identity, &delegate_type, &delegate_b, u64::MAX, 100); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 200, - identity_owner: None, - identity_owner_at_block: HashMap::new(), - logs: HashMap::from([ - (100, vec![log_100]), - (200, vec![log_200]), - ]), - block_timestamps: HashMap::from([ - (100, 1705312200), // 2024-01-15T09:50:00Z - (200, 1705398600), // 2024-01-16T09:50:00Z - ]), - }, - }); - - let options = resolution::Options { - parameters: resolution::Parameters { - version_id: Some("100".to_string()), - ..Default::default() - }, - ..Default::default() - }; - - let output = resolver - .resolve_with( - did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), - options, - ) - .await - .unwrap(); - - assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); - assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); - assert_eq!(output.document_metadata.next_version_id.as_deref(), Some("200")); - assert_eq!(output.document_metadata.next_update.as_deref(), Some("2024-01-16T09:50:00Z")); - } - - #[tokio::test] - async fn historical_before_any_changes_returns_genesis() { - // Events only at block 100. Resolving at versionId=50 (before any changes) - // should return the default genesis document (no delegates/attributes). - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate_a: [u8; 20] = [0xAA; 20]; - let delegate_type = encode_delegate_type("veriKey"); - - let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, u64::MAX, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - identity_owner_at_block: HashMap::new(), - logs: HashMap::from([ - (100, vec![log_100]), - ]), - block_timestamps: HashMap::from([ - (100, 1705312200), - ]), - }, - }); - - let options = resolution::Options { - parameters: resolution::Parameters { - version_id: Some("50".to_string()), - ..Default::default() - }, - ..Default::default() - }; - - let output = resolver - .resolve_with( - did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), - options, - ) - .await - .unwrap(); - - let doc_value = serde_json::to_value(&output.document).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // Genesis document: only 2 base VMs, no delegates - assert_eq!(vms.len(), 2, "genesis doc should have only base VMs"); - - // No metadata versionId/updated (offline genesis) - assert!(output.document_metadata.version_id.is_none()); - assert!(output.document_metadata.updated.is_none()); - } - // ── Phase 9: Network Configuration Cleanup ── #[tokio::test] @@ -3569,363 +415,6 @@ mod tests { ); } - #[tokio::test] - async fn pubkey_did_with_provider_unchanged_includes_eip712method2021() { - // Public-key DID with MockProvider (changed_block=0) should also - // include Eip712Method2021, matching offline resolution. - let mut resolver = DIDEthr::new(); - resolver.add_network( - "mainnet", - NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider::new_unchanged(), - }, - ); - - let doc = resolver - .resolve(did!( - "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" - )) - .await - .unwrap() - .document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - assert_eq!(vms.len(), 3, "public-key DID with unchanged provider should have 3 VMs"); - - assert!( - vms.iter().any(|vm| vm["type"].as_str() == Some("Eip712Method2021")), - "should include Eip712Method2021" - ); - - // Should match offline resolution exactly - let doc_offline = DIDEthr::<()>::default() - .resolve(did!( - "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" - )) - .await - .unwrap() - .document; - - assert_eq!( - serde_json::to_value(&doc).unwrap(), - serde_json::to_value(&doc_offline).unwrap(), - "unchanged provider should match offline for public-key DID" - ); - } - - // ── Revocation tests ── - - #[tokio::test] - async fn resolve_previously_valid_delegate_then_revoked() { - // A delegate is added valid at block 100, then revoked at block 200. - // The revoked delegate must NOT appear in the final document. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate: [u8; 20] = [0xAA; 20]; - let delegate_type = encode_delegate_type("veriKey"); - - // Block 100: delegate added, valid_to = far future - let log_add = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); - // Block 200: same delegate revoked (valid_to = 0) - let log_revoke = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, 0, 100); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 200, - identity_owner: None, - logs: HashMap::from([ - (100, vec![log_add]), - (200, vec![log_revoke]), - ]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // Only 2 base VMs — delegate was revoked - assert_eq!(vms.len(), 2, "revoked delegate should not appear in document"); - assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().contains("delegate")), - "no delegate VMs should be present"); - } - - #[tokio::test] - async fn resolve_previously_valid_service_then_revoked() { - // A service is added valid at block 100, then revoked at block 200. - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let attr_name = encode_attr_name("did/svc/HubService"); - let endpoint = b"https://hub.example.com"; - - // Block 100: service added - let log_add = make_attribute_changed_log(100, &identity, &attr_name, endpoint, u64::MAX, 0); - // Block 200: same service revoked (valid_to = 0) - let log_revoke = make_attribute_changed_log(200, &identity, &attr_name, endpoint, 0, 100); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 200, - identity_owner: None, - logs: HashMap::from([ - (100, vec![log_add]), - (200, vec![log_revoke]), - ]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let services = doc_value.get("service"); - - // No services — the service was revoked - assert!( - services.is_none() || services.unwrap().as_array().map_or(true, |a| a.is_empty()), - "revoked service should not appear in document" - ); - } - - #[tokio::test] - async fn resolve_revoked_then_readded_gets_new_id() { - // A delegate is added, revoked, then re-added. The re-added delegate - // should get a higher counter ID (#delegate-3, not #delegate-1). - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let delegate: [u8; 20] = [0xAA; 20]; - let delegate_type = encode_delegate_type("veriKey"); - - // Block 100: add (counter=1) - let log1 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); - // Block 200: revoke (counter=2) - let log2 = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, 0, 100); - // Block 300: re-add (counter=3) - let log3 = make_delegate_changed_log(300, &identity, &delegate_type, &delegate, u64::MAX, 200); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 300, - identity_owner: None, - logs: HashMap::from([ - (100, vec![log1]), - (200, vec![log2]), - (300, vec![log3]), - ]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // 2 base + 2 delegate (RecoveryMethod + Eip712) = 4 - assert_eq!(vms.len(), 4); - - // Should NOT have #delegate-1 or #delegate-2 - assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-1"))); - assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-2"))); - - // Should have #delegate-3 (the re-added entry) - let d3 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-3")) - .expect("should have #delegate-3 VM"); - assert_eq!(d3["type"], "EcdsaSecp256k1RecoveryMethod2020"); - } - - #[tokio::test] - async fn resolve_secp256k1_attr_key_uses_jwk() { - // Secp256k1 attribute key (any encoding hint) must produce - // EcdsaSecp256k1VerificationKey2019 with publicKeyJwk (a JSON object). - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/base64"); - - let log = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - let attr_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#delegate-1") - }).expect("should have #delegate-1 VM"); - assert_eq!(attr_vm["type"], "EcdsaSecp256k1VerificationKey2019"); - assert!(attr_vm["publicKeyJwk"].is_object(), "should use publicKeyJwk"); - assert_eq!(attr_vm["publicKeyJwk"]["kty"], "EC"); - assert_eq!(attr_vm["publicKeyJwk"]["crv"], "secp256k1"); - } - - #[tokio::test] - async fn resolve_ed25519_attr_key() { - // Ed25519 attribute key → Ed25519VerificationKey2020 + publicKeyMultibase - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let attr_name = encode_attr_name("did/pub/Ed25519/veriKey/base64"); - // 32-byte Ed25519 public key - let ed_key: [u8; 32] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, - 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, - 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, - 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, - ]; - - let log = make_attribute_changed_log(100, &identity, &attr_name, &ed_key, u64::MAX, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // 2 base + 1 Ed25519 = 3 - assert_eq!(vms.len(), 3); - - let attr_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#delegate-1") - }).expect("should have #delegate-1 VM"); - assert_eq!(attr_vm["type"], "Ed25519VerificationKey2020"); - let expected_multibase = format!("z{}", bs58::encode(&ed_key).into_string()); - assert_eq!(attr_vm["publicKeyMultibase"], expected_multibase); - assert!(attr_vm.get("publicKeyJwk").is_none(), "should NOT have publicKeyJwk"); - - // Should be in assertionMethod (veriKey purpose) - let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; - let assertion = doc_value["assertionMethod"].as_array().unwrap(); - assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); - - // Context should include Ed25519VerificationKey2020 and publicKeyMultibase - let context = doc_value["@context"].as_array().unwrap(); - let ctx_obj = context.iter().find(|c| c.is_object()).unwrap(); - assert!(ctx_obj.get("Ed25519VerificationKey2020").is_some(), - "context should include Ed25519VerificationKey2020"); - assert!(ctx_obj.get("publicKeyMultibase").is_some(), - "context should include publicKeyMultibase"); - } - - #[tokio::test] - async fn resolve_x25519_attr_key() { - // X25519 attribute key → X25519KeyAgreementKey2020 + publicKeyMultibase + keyAgreement - let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, - 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; - let attr_name = encode_attr_name("did/pub/X25519/enc/base64"); - // 32-byte X25519 public key - let x_key: [u8; 32] = [ - 0xAA, 0xBB, 0xCC, 0xDD, 0x01, 0x02, 0x03, 0x04, - 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, - 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, - 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, - ]; - - let log = make_attribute_changed_log(100, &identity, &attr_name, &x_key, u64::MAX, 0); - - let mut resolver = DIDEthr::new(); - resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, - registry: TEST_REGISTRY, - provider: MockProvider { - changed_block: 100, - identity_owner: None, - logs: HashMap::from([(100, vec![log])]), - block_timestamps: HashMap::new(), - identity_owner_at_block: HashMap::new(), - }, - }); - - let doc = resolver - .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) - .await.unwrap().document; - - let doc_value = serde_json::to_value(&doc).unwrap(); - eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); - let vms = doc_value["verificationMethod"].as_array().unwrap(); - - // 2 base + 1 X25519 = 3 - assert_eq!(vms.len(), 3); - - let attr_vm = vms.iter().find(|vm| { - vm["id"].as_str().unwrap().ends_with("#delegate-1") - }).expect("should have #delegate-1 VM"); - assert_eq!(attr_vm["type"], "X25519KeyAgreementKey2020"); - let expected_multibase = format!("z{}", bs58::encode(&x_key).into_string()); - assert_eq!(attr_vm["publicKeyMultibase"], expected_multibase); - - // X25519 enc purpose → keyAgreement (not assertionMethod or authentication) - let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; - let key_agreement = doc_value["keyAgreement"].as_array() - .expect("should have keyAgreement array"); - assert!(key_agreement.iter().any(|v| v == &format!("{did_prefix}#delegate-1")), - "X25519 should be in keyAgreement"); - - let assertion = doc_value["assertionMethod"].as_array().unwrap(); - assert!(!assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1")), - "X25519 should NOT be in assertionMethod"); - - // Context - let context = doc_value["@context"].as_array().unwrap(); - let ctx_obj = context.iter().find(|c| c.is_object()).unwrap(); - assert!(ctx_obj.get("X25519KeyAgreementKey2020").is_some(), - "context should include X25519KeyAgreementKey2020"); - assert!(ctx_obj.get("publicKeyMultibase").is_some(), - "context should include publicKeyMultibase"); - } - #[tokio::test] async fn resolve_address_did_has_no_public_key_jwk_in_context() { // An address-only DID (no public key, no attribute keys) should NOT diff --git a/crates/dids/methods/ethr/src/network.rs b/crates/dids/methods/ethr/src/network.rs new file mode 100644 index 000000000..47951349b --- /dev/null +++ b/crates/dids/methods/ethr/src/network.rs @@ -0,0 +1,120 @@ +use std::str::FromStr; + +#[derive(Debug, thiserror::Error)] +#[error("invalid network `{0}`")] +pub struct InvalidNetwork(String); + +pub enum NetworkChain { + Mainnet, + Goerli, + Sepolia, + Other(u64), +} + +impl NetworkChain { + pub fn id(&self) -> u64 { + match self { + Self::Mainnet => 1, + Self::Goerli => 5, + Self::Sepolia => 11155111, + Self::Other(i) => *i, + } + } +} + +impl FromStr for NetworkChain { + type Err = InvalidNetwork; + + fn from_str(network_name: &str) -> Result { + match network_name { + "mainnet" => Ok(Self::Mainnet), + "goerli" => Ok(Self::Goerli), + "sepolia" => Ok(Self::Sepolia), + // Deprecated testnets — still parse for backward compatibility + "morden" => Ok(Self::Other(2)), + "ropsten" => Ok(Self::Other(3)), + "rinkeby" => Ok(Self::Other(4)), + "kovan" => Ok(Self::Other(42)), + network_chain_id if network_chain_id.starts_with("0x") => { + match u64::from_str_radix(&network_chain_id[2..], 16) { + Ok(chain_id) => Ok(Self::Other(chain_id)), + Err(_) => Err(InvalidNetwork(network_name.to_owned())), + } + } + _ => Err(InvalidNetwork(network_name.to_owned())), + } + } +} + +pub(crate) struct DecodedMethodSpecificId { + pub(crate) network_name: String, + pub(crate) network_chain: NetworkChain, + pub(crate) address_or_public_key: String, +} + +impl DecodedMethodSpecificId { + /// Return the network name used for provider lookup + pub(crate) fn network_name(&self) -> String { + self.network_name.clone() + } + + /// Extract the Ethereum address hex string (with 0x prefix). + /// For public-key DIDs, derives the address from the public key. + pub(crate) fn account_address_hex(&self) -> String { + if self.address_or_public_key.len() == 42 { + self.address_or_public_key.clone() + } else { + // Public key DID — derive the address + let pk_hex = &self.address_or_public_key; + if !pk_hex.starts_with("0x") { + return String::new(); + } + let pk_bytes = match hex::decode(&pk_hex[2..]) { + Ok(b) => b, + Err(_) => return String::new(), + }; + let pk_jwk = match ssi_jwk::secp256k1_parse(&pk_bytes) { + Ok(j) => j, + Err(_) => return String::new(), + }; + match ssi_jwk::eip155::hash_public_key_eip55(&pk_jwk) { + Ok(addr) => addr, + Err(_) => String::new(), + } + } + } +} + +impl FromStr for DecodedMethodSpecificId { + type Err = InvalidNetwork; + + fn from_str(method_specific_id: &str) -> Result { + // https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md#method-specific-identifier + let (network_name, address_or_public_key) = match method_specific_id.split_once(':') { + None => ("mainnet".to_string(), method_specific_id.to_string()), + Some((network, address_or_public_key)) => { + (network.to_string(), address_or_public_key.to_string()) + } + }; + + Ok(DecodedMethodSpecificId { + network_chain: network_name.parse()?, + network_name, + address_or_public_key, + }) + } +} + +/// Parse a hex address string (with 0x prefix) into 20 bytes +pub(crate) fn parse_address_bytes(addr_hex: &str) -> Option<[u8; 20]> { + if !addr_hex.starts_with("0x") || addr_hex.len() != 42 { + return None; + } + let bytes = hex::decode(&addr_hex[2..]).ok()?; + if bytes.len() != 20 { + return None; + } + let mut addr = [0u8; 20]; + addr.copy_from_slice(&bytes); + Some(addr) +} diff --git a/crates/dids/methods/ethr/src/provider.rs b/crates/dids/methods/ethr/src/provider.rs new file mode 100644 index 000000000..eab26b685 --- /dev/null +++ b/crates/dids/methods/ethr/src/provider.rs @@ -0,0 +1,58 @@ +/// Block reference for eth_call +pub enum BlockRef { + Latest, + Number(u64), +} + +/// Log filter for eth_getLogs +/// +/// `topic0` filters by event signature hash(es) — multiple values are OR'd. +/// `topic1` filters by the first indexed parameter (e.g. identity address). +pub struct LogFilter { + pub address: [u8; 20], + pub topic0: Vec<[u8; 32]>, + pub topic1: Option<[u8; 32]>, + pub from_block: u64, + pub to_block: u64, +} + +/// Ethereum event log +pub struct Log { + pub address: [u8; 20], + pub topics: Vec<[u8; 32]>, + pub data: Vec, + pub block_number: u64, +} + +/// Minimal async trait for Ethereum JSON-RPC interaction. +/// Users implement this with their preferred client (ethers-rs, alloy, etc.) +pub trait EthProvider: Send + Sync { + type Error: std::error::Error + Send + Sync + 'static; + + /// eth_call — execute a read-only contract call + fn call( + &self, + to: [u8; 20], + data: Vec, + block: BlockRef, + ) -> impl std::future::Future, Self::Error>> + Send; + + /// eth_getLogs — query event logs + fn get_logs( + &self, + filter: LogFilter, + ) -> impl std::future::Future, Self::Error>> + Send; + + /// Get block timestamp (seconds since epoch) + fn block_timestamp( + &self, + block: u64, + ) -> impl std::future::Future> + Send; +} + +/// Per-network Ethereum configuration +pub struct NetworkConfig

{ + pub chain_id: u64, + pub registry: [u8; 20], + pub provider: P, +} diff --git a/crates/dids/methods/ethr/src/resolver.rs b/crates/dids/methods/ethr/src/resolver.rs new file mode 100644 index 000000000..91818cf3f --- /dev/null +++ b/crates/dids/methods/ethr/src/resolver.rs @@ -0,0 +1,2730 @@ +use indexmap::IndexMap; +use ssi_caips::caip10::BlockchainAccountId; +use ssi_caips::caip2::ChainId; +use ssi_dids_core::{ + document::{ + self, + representation::{self, MediaType}, + DIDVerificationMethod, + }, + resolution::{self, DIDMethodResolver, Error, Output}, + DIDBuf, DIDMethod, DIDURLBuf, Document, +}; +use std::collections::HashMap; +use std::str::FromStr; + +use crate::abi::{ + decode_address, decode_uint256, encode_call, format_address_eip55, format_timestamp_iso8601, + CHANGED_SELECTOR, IDENTITY_OWNER_SELECTOR, +}; +use crate::events::{collect_events, Erc1056Event}; +use crate::json_ld_context::JsonLdContext; +use crate::network::{DecodedMethodSpecificId, NetworkChain}; +use crate::provider::{BlockRef, EthProvider, NetworkConfig}; +use crate::vm::{ + decode_delegate_type, PendingService, PendingVm, PendingVmPayload, VerificationMethod, + VerificationMethodType, +}; + +// --- DIDEthr --- + +/// did:ethr DID Method +/// +/// [Specification](https://github.com/decentralized-identity/ethr-did-resolver/) +/// +/// Generic over `P`: when `P = ()` (the default), only offline resolution is +/// available. When `P` implements [`EthProvider`], on-chain resolution is used +/// for networks that have a configured provider. +pub struct DIDEthr

{ + networks: HashMap>, +} + +impl

Default for DIDEthr

{ + fn default() -> Self { + Self { + networks: HashMap::new(), + } + } +} + +impl

DIDEthr

{ + /// Create a new `DIDEthr` resolver with no networks configured. + pub fn new() -> Self { + Self::default() + } + + /// Add a named network configuration. + pub fn add_network(&mut self, name: &str, config: NetworkConfig

) { + self.networks.insert(name.to_owned(), config); + } +} + +impl DIDEthr { + pub fn generate(jwk: &ssi_jwk::JWK) -> Result { + let hash = ssi_jwk::eip155::hash_public_key(jwk)?; + Ok(DIDBuf::from_string(format!("did:ethr:{}", hash)).unwrap()) + } +} + +impl DIDMethod for DIDEthr

{ + const DID_METHOD_NAME: &'static str = "ethr"; +} + +impl DIDMethodResolver for DIDEthr

{ + async fn resolve_method_representation<'a>( + &'a self, + method_specific_id: &'a str, + options: resolution::Options, + ) -> Result>, Error> { + let decoded_id = DecodedMethodSpecificId::from_str(method_specific_id) + .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; + + let network_name = decoded_id.network_name(); + + // Check if we have a provider for this network + if let Some(config) = self.networks.get(&network_name) { + let addr_hex = decoded_id.account_address_hex(); + if let Some(addr) = crate::network::parse_address_bytes(&addr_hex) { + // Parse historical resolution target block from ?versionId=N + let target_block: Option = options + .parameters + .version_id + .as_deref() + .and_then(|v| v.parse::().ok()); + + // Call changed(addr) to see if there are on-chain modifications + let calldata = encode_call(CHANGED_SELECTOR, &addr); + let result = config + .provider + .call(config.registry, calldata.clone(), BlockRef::Latest) + .await + .map_err(|e| Error::Internal(e.to_string()))?; + let changed_block = decode_uint256(&result); + + if changed_block > 0 { + // Collect all events via linked-list walk + let all_events = collect_events( + &config.provider, + config.registry, + &addr, + changed_block, + ) + .await + .map_err(Error::Internal)?; + + // Partition events for historical resolution + let (events, events_after) = if let Some(tb) = target_block { + let before: Vec<_> = all_events.iter().filter(|(b, _)| *b <= tb).cloned().collect(); + let after: Vec<_> = all_events.iter().filter(|(b, _)| *b > tb).cloned().collect(); + (before, after) + } else { + (all_events, Vec::new()) + }; + + // For historical resolution at a block before any changes, + // return the genesis (default) document + if target_block.is_some() && events.is_empty() { + return resolve_offline(method_specific_id, &decoded_id, options); + } + + // Build document metadata (versionId + updated) + let meta_block = if let Some(tb) = target_block { + // Latest event block at or before target + events.iter().map(|(b, _)| *b).max().unwrap_or(tb) + } else { + changed_block + }; + let block_ts = config + .provider + .block_timestamp(meta_block) + .await + .map_err(|e| Error::Internal(e.to_string()))?; + let mut doc_metadata = document::Metadata { + version_id: Some(meta_block.to_string()), + updated: Some(format_timestamp_iso8601(block_ts)), + ..Default::default() + }; + + // Populate nextVersionId/nextUpdate from first event after target + if target_block.is_some() { + if let Some((next_block, _)) = events_after.first() { + let next_ts = config + .provider + .block_timestamp(*next_block) + .await + .map_err(|e| Error::Internal(e.to_string()))?; + doc_metadata.next_version_id = Some(next_block.to_string()); + doc_metadata.next_update = Some(format_timestamp_iso8601(next_ts)); + } + } + + // Check identityOwner(addr) — at target block for historical, Latest otherwise + let owner_block = match target_block { + Some(tb) => BlockRef::Number(tb), + None => BlockRef::Latest, + }; + let owner_calldata = encode_call(IDENTITY_OWNER_SELECTOR, &addr); + let owner_result = config + .provider + .call(config.registry, owner_calldata, owner_block) + .await + .map_err(|e| Error::Internal(e.to_string()))?; + let owner = decode_address(&owner_result); + + // Check for deactivation (owner = null address) + const NULL_ADDRESS: [u8; 20] = [0u8; 20]; + if owner == NULL_ADDRESS { + let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")).unwrap(); + let doc = Document::new(did); + let json_ld_context = JsonLdContext::default(); + doc_metadata.deactivated = Some(true); + return serialize_document(doc, json_ld_context, options, doc_metadata); + } + + // Build base document from owner + let mut json_ld_context = JsonLdContext::default(); + let (mut doc, _account_address) = if owner == addr { + // Owner unchanged — build from the DID's own identity + let doc = match decoded_id.address_or_public_key.len() { + 42 => resolve_address( + &mut json_ld_context, + method_specific_id, + &decoded_id.network_chain, + &decoded_id.address_or_public_key, + ), + 68 => resolve_public_key( + &mut json_ld_context, + method_specific_id, + &decoded_id.network_chain, + &decoded_id.address_or_public_key, + ), + _ => Err(Error::InvalidMethodSpecificId( + method_specific_id.to_owned(), + )), + }?; + (doc, decoded_id.address_or_public_key.clone()) + } else { + // Owner changed — build with the new owner's address + let owner_address = format_address_eip55(&owner); + let doc = resolve_address( + &mut json_ld_context, + method_specific_id, + &decoded_id.network_chain, + &owner_address, + )?; + (doc, owner_address) + }; + + // Apply delegate/attribute events + // For historical resolution, use target block's timestamp as "now" + let now = if let Some(_) = target_block { + block_ts + } else { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + }; + + let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")) + .unwrap(); + apply_events( + &mut doc, + &events, + &did, + &decoded_id.network_chain, + &mut json_ld_context, + now, + ); + + return serialize_document(doc, json_ld_context, options, doc_metadata); + } + } + } + + // No provider or changed=0 — offline resolution + resolve_offline(method_specific_id, &decoded_id, options) + } +} + +/// DIDMethodResolver impl for DIDEthr<()> — offline-only resolution +impl DIDMethodResolver for DIDEthr<()> { + async fn resolve_method_representation<'a>( + &'a self, + method_specific_id: &'a str, + options: resolution::Options, + ) -> Result>, Error> { + let decoded_id = DecodedMethodSpecificId::from_str(method_specific_id) + .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; + resolve_offline(method_specific_id, &decoded_id, options) + } +} + +/// Process ERC-1056 events and add delegate verification methods to the document. +/// +/// Uses a map-accumulation model: each delegate/attribute event is keyed by its +/// content (delegate_type+delegate or name+value). When a valid event arrives the +/// entry is inserted; when a revoked/expired event arrives the entry is removed. +/// This correctly handles the case where a previously-valid key is later revoked. +/// +/// `now` is the current timestamp (seconds since epoch) used for expiry checks. +/// The delegate counter increments for every recognised DelegateChanged / +/// AttributeChanged-pub event regardless of validity, ensuring stable `#delegate-N` +/// IDs. Likewise for service_counter / `#service-N`. +pub(crate) fn apply_events( + doc: &mut Document, + events: &[(u64, Erc1056Event)], + did: &DIDBuf, + network_chain: &NetworkChain, + json_ld_context: &mut JsonLdContext, + now: u64, +) { + let mut delegate_counter = 0u64; + let mut service_counter = 0u64; + + // Content-keyed maps for deduplication and revocation support. + // Key = delegate_type[32] ++ delegate[20] for delegates, + // name[32] ++ value[..] for attribute keys / services. + let mut vms: IndexMap, PendingVm> = IndexMap::new(); + let mut svcs: IndexMap, PendingService> = IndexMap::new(); + + for (_block, event) in events { + match event { + Erc1056Event::DelegateChanged { + delegate_type, + delegate, + valid_to, + .. + } => { + let dt = decode_delegate_type(delegate_type); + + let is_veri_key = dt == b"veriKey"; + let is_sig_auth = dt == b"sigAuth"; + + if !is_veri_key && !is_sig_auth { + continue; + } + + delegate_counter += 1; + + // Content key: delegate_type[32] ++ delegate[20] + let mut key = Vec::with_capacity(52); + key.extend_from_slice(delegate_type); + key.extend_from_slice(delegate); + + if *valid_to >= now { + let delegate_addr = format_address_eip55(delegate); + let blockchain_account_id = BlockchainAccountId { + account_address: delegate_addr, + chain_id: ChainId { + namespace: "eip155".to_string(), + reference: network_chain.id().to_string(), + }, + }; + + vms.insert(key, PendingVm { + counter: delegate_counter, + payload: PendingVmPayload::Delegate { blockchain_account_id }, + is_sig_auth, + }); + } else { + vms.shift_remove(&key); + } + } + Erc1056Event::AttributeChanged { + name, + value, + valid_to, + .. + } => { + let attr_name = decode_delegate_type(name); // trims trailing zeros + let attr_str = match std::str::from_utf8(attr_name) { + Ok(s) => s, + Err(_) => continue, + }; + let parts: Vec<&str> = attr_str.split('/').collect(); + + if parts.len() >= 3 && parts[0] == "did" && parts[1] == "pub" { + // did/pub/// + delegate_counter += 1; + + let algo = parts.get(2).copied().unwrap_or(""); + let purpose = parts.get(3).copied().unwrap_or(""); + + // Content key: name[32] ++ value[..] + let mut key = Vec::with_capacity(32 + value.len()); + key.extend_from_slice(name); + key.extend_from_slice(value); + + if *valid_to >= now { + // Determine VM type and build the property value. + // Encoding hint from the attribute name is ignored; we + // always use the canonical property for each VM type. + let pending = match algo { + "Secp256k1" => { + match ssi_jwk::secp256k1_parse(value) { + Ok(jwk) => Some(PendingVmPayload::AttributeKey { + vm_type: VerificationMethodType::EcdsaSecp256k1VerificationKey2019, + prop_name: "publicKeyJwk", + prop_value: serde_json::to_value(&jwk).unwrap(), + }), + Err(_) => None, + } + } + "Ed25519" => { + let multibase = format!("z{}", bs58::encode(value).into_string()); + Some(PendingVmPayload::AttributeKey { + vm_type: VerificationMethodType::Ed25519VerificationKey2020, + prop_name: "publicKeyMultibase", + prop_value: serde_json::Value::String(multibase), + }) + } + "X25519" => { + let multibase = format!("z{}", bs58::encode(value).into_string()); + Some(PendingVmPayload::AttributeKey { + vm_type: VerificationMethodType::X25519KeyAgreementKey2020, + prop_name: "publicKeyMultibase", + prop_value: serde_json::Value::String(multibase), + }) + } + _ => None, + }; + + if let Some(payload) = pending { + let is_enc = purpose == "enc"; + let is_sig_auth = purpose == "sigAuth"; + vms.insert(key, PendingVm { + counter: delegate_counter, + payload, + is_sig_auth: is_sig_auth || is_enc, + }); + } + } else { + vms.shift_remove(&key); + } + } else if parts.len() >= 3 && parts[0] == "did" && parts[1] == "svc" { + // did/svc/ + service_counter += 1; + + // Content key: name[32] ++ value[..] + let mut key = Vec::with_capacity(32 + value.len()); + key.extend_from_slice(name); + key.extend_from_slice(value); + + if *valid_to >= now { + let service_type = parts[2..].join("/"); + let endpoint_str = String::from_utf8_lossy(value); + let endpoint = if let Ok(json_val) = serde_json::from_str::(&endpoint_str) { + if json_val.is_object() || json_val.is_array() { + document::service::Endpoint::Map(json_val) + } else { + parse_uri_endpoint(&endpoint_str) + } + } else { + parse_uri_endpoint(&endpoint_str) + }; + + svcs.insert(key, PendingService { + counter: service_counter, + service_type, + endpoint, + }); + } else { + svcs.shift_remove(&key); + } + } + } + // OwnerChanged handled by identityOwner() call + _ => {} + } + } + + // Materialise VMs from the map, sorted by counter for stable ordering. + let mut vm_entries: Vec<_> = vms.into_values().collect(); + vm_entries.sort_by_key(|v| v.counter); + + for vm_entry in vm_entries { + match vm_entry.payload { + PendingVmPayload::Delegate { blockchain_account_id } => { + let vm_id = format!("{did}#delegate-{}", vm_entry.counter); + let eip712_id = format!("{did}#delegate-{}-Eip712Method2021", vm_entry.counter); + + let vm_id_url = DIDURLBuf::from_string(vm_id).unwrap(); + let eip712_id_url = DIDURLBuf::from_string(eip712_id).unwrap(); + + let vm = VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { + id: vm_id_url.clone(), + controller: did.clone(), + blockchain_account_id: blockchain_account_id.clone(), + }; + + let eip712_vm = VerificationMethod::Eip712Method2021 { + id: eip712_id_url.clone(), + controller: did.clone(), + blockchain_account_id, + }; + + json_ld_context.add_verification_method_type(vm.type_()); + json_ld_context.add_verification_method_type(eip712_vm.type_()); + + doc.verification_method.push(vm.into()); + doc.verification_method.push(eip712_vm.into()); + + doc.verification_relationships + .assertion_method + .push(vm_id_url.clone().into()); + doc.verification_relationships + .assertion_method + .push(eip712_id_url.clone().into()); + + if vm_entry.is_sig_auth { + doc.verification_relationships + .authentication + .push(vm_id_url.into()); + doc.verification_relationships + .authentication + .push(eip712_id_url.into()); + } + } + PendingVmPayload::AttributeKey { vm_type, prop_name, prop_value } => { + let vm_id = format!("{did}#delegate-{}", vm_entry.counter); + let vm_id_url = DIDURLBuf::from_string(vm_id).unwrap(); + + let vm = DIDVerificationMethod { + id: vm_id_url.clone(), + type_: vm_type.name().to_owned(), + controller: did.clone(), + properties: [(prop_name.into(), prop_value)] + .into_iter() + .collect(), + }; + + json_ld_context.add_verification_method_type(vm_type); + json_ld_context.add_property(prop_name); + + doc.verification_method.push(vm); + + // Determine purpose from the attribute name embedded in is_sig_auth. + // For attribute keys, is_sig_auth encodes whether the key goes into + // authentication/keyAgreement (true for sigAuth and enc purposes). + // + // We need to re-derive the purpose from the original attribute, but + // since we only store is_sig_auth, we use a simpler approach: + // - X25519 keys → keyAgreement + // - is_sig_auth && not X25519 → assertionMethod + authentication + // - else → assertionMethod only + match vm_type { + VerificationMethodType::X25519KeyAgreementKey2020 => { + doc.verification_relationships + .key_agreement + .push(vm_id_url.into()); + } + _ if vm_entry.is_sig_auth => { + doc.verification_relationships + .assertion_method + .push(vm_id_url.clone().into()); + doc.verification_relationships + .authentication + .push(vm_id_url.into()); + } + _ => { + doc.verification_relationships + .assertion_method + .push(vm_id_url.into()); + } + } + } + } + } + + // Materialise services from the map, sorted by counter. + let mut svc_entries: Vec<_> = svcs.into_values().collect(); + svc_entries.sort_by_key(|s| s.counter); + + for svc_entry in svc_entries { + let service_id = format!("{did}#service-{}", svc_entry.counter); + let service = document::Service { + id: iref::UriBuf::new(service_id.into_bytes()).unwrap(), + type_: ssi_core::one_or_many::OneOrMany::One(svc_entry.service_type), + service_endpoint: Some(ssi_core::one_or_many::OneOrMany::One(svc_entry.endpoint)), + property_set: std::collections::BTreeMap::new(), + }; + doc.service.push(service); + } +} + +/// Helper to parse a string as a URI endpoint, falling back to a string-valued Map. +pub(crate) fn parse_uri_endpoint(s: &str) -> document::service::Endpoint { + match iref::UriBuf::new(s.as_bytes().to_vec()) { + Ok(uri) => document::service::Endpoint::Uri(uri), + Err(e) => document::service::Endpoint::Map( + serde_json::Value::String(String::from_utf8_lossy(&e.0).into_owned()), + ), + } +} + +/// Resolve a DID using the offline (genesis document) path +pub(crate) fn resolve_offline( + method_specific_id: &str, + decoded_id: &DecodedMethodSpecificId, + options: resolution::Options, +) -> Result>, Error> { + let mut json_ld_context = JsonLdContext::default(); + + let doc = match decoded_id.address_or_public_key.len() { + 42 => resolve_address( + &mut json_ld_context, + method_specific_id, + &decoded_id.network_chain, + &decoded_id.address_or_public_key, + ), + 68 => resolve_public_key( + &mut json_ld_context, + method_specific_id, + &decoded_id.network_chain, + &decoded_id.address_or_public_key, + ), + _ => Err(Error::InvalidMethodSpecificId( + method_specific_id.to_owned(), + )), + }?; + + serialize_document(doc, json_ld_context, options, document::Metadata::default()) +} + +pub(crate) fn serialize_document( + doc: Document, + json_ld_context: JsonLdContext, + options: resolution::Options, + doc_metadata: document::Metadata, +) -> Result>, Error> { + let content_type = options.accept.unwrap_or(MediaType::JsonLd); + let represented = doc.into_representation(representation::Options::from_media_type( + content_type, + move || representation::json_ld::Options { + context: representation::json_ld::Context::array( + representation::json_ld::DIDContext::V1, + json_ld_context.into_entries(), + ), + }, + )); + + Ok(resolution::Output::new( + represented.to_bytes(), + doc_metadata, + resolution::Metadata::from_content_type(Some(content_type.to_string())), + )) +} + +pub(crate) fn resolve_address( + json_ld_context: &mut JsonLdContext, + method_specific_id: &str, + network_chain: &NetworkChain, + account_address: &str, +) -> Result { + let blockchain_account_id = BlockchainAccountId { + account_address: account_address.to_owned(), + chain_id: ChainId { + namespace: "eip155".to_string(), + reference: network_chain.id().to_string(), + }, + }; + + let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")).unwrap(); + + let vm = VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { + id: DIDURLBuf::from_string(format!("{did}#controller")).unwrap(), + controller: did.to_owned(), + blockchain_account_id: blockchain_account_id.clone(), + }; + + let eip712_vm = VerificationMethod::Eip712Method2021 { + id: DIDURLBuf::from_string(format!("{did}#Eip712Method2021")).unwrap(), + controller: did.to_owned(), + blockchain_account_id, + }; + + json_ld_context.add_verification_method_type(vm.type_()); + json_ld_context.add_verification_method_type(eip712_vm.type_()); + + let mut doc = Document::new(did); + doc.verification_relationships.assertion_method = + vec![vm.id().to_owned().into(), eip712_vm.id().to_owned().into()]; + doc.verification_relationships.authentication = + vec![vm.id().to_owned().into(), eip712_vm.id().to_owned().into()]; + doc.verification_method = vec![vm.into(), eip712_vm.into()]; + + Ok(doc) +} + +/// Resolve an Ethr DID that uses a public key hex string instead of an account address +pub(crate) fn resolve_public_key( + json_ld_context: &mut JsonLdContext, + method_specific_id: &str, + network_chain: &NetworkChain, + public_key_hex: &str, +) -> Result { + if !public_key_hex.starts_with("0x") { + return Err(Error::InvalidMethodSpecificId( + method_specific_id.to_owned(), + )); + } + + let pk_bytes = hex::decode(&public_key_hex[2..]) + .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; + + let pk_jwk = ssi_jwk::secp256k1_parse(&pk_bytes) + .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; + + let account_address = ssi_jwk::eip155::hash_public_key_eip55(&pk_jwk) + .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; + + let blockchain_account_id = BlockchainAccountId { + account_address, + chain_id: ChainId { + namespace: "eip155".to_string(), + reference: network_chain.id().to_string(), + }, + }; + + let did = DIDBuf::from_string(format!("did:ethr:{method_specific_id}")).unwrap(); + + let vm = VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { + id: DIDURLBuf::from_string(format!("{did}#controller")).unwrap(), + controller: did.to_owned(), + blockchain_account_id: blockchain_account_id.clone(), + }; + + let key_vm = VerificationMethod::EcdsaSecp256k1VerificationKey2019 { + id: DIDURLBuf::from_string(format!("{did}#controllerKey")).unwrap(), + controller: did.to_owned(), + public_key_jwk: pk_jwk, + }; + + let eip712_vm = VerificationMethod::Eip712Method2021 { + id: DIDURLBuf::from_string(format!("{did}#Eip712Method2021")).unwrap(), + controller: did.to_owned(), + blockchain_account_id, + }; + + json_ld_context.add_verification_method_type(vm.type_()); + json_ld_context.add_verification_method_type(key_vm.type_()); + json_ld_context.add_verification_method_type(eip712_vm.type_()); + json_ld_context.add_property("publicKeyJwk"); + + let mut doc = Document::new(did); + doc.verification_relationships.assertion_method = vec![ + vm.id().to_owned().into(), + key_vm.id().to_owned().into(), + eip712_vm.id().to_owned().into(), + ]; + doc.verification_relationships.authentication = vec![ + vm.id().to_owned().into(), + key_vm.id().to_owned().into(), + eip712_vm.id().to_owned().into(), + ]; + doc.verification_method = vec![vm.into(), key_vm.into(), eip712_vm.into()]; + + Ok(doc) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::abi::{abi_encode_address, CHANGED_SELECTOR, IDENTITY_OWNER_SELECTOR}; + use crate::events::{topic_owner_changed, topic_delegate_changed, topic_attribute_changed}; + use crate::provider::{BlockRef, EthProvider, Log, LogFilter, NetworkConfig}; + use ssi_dids_core::{did, DIDResolver}; + + // --- Mock provider for on-chain resolution tests --- + + #[derive(Debug)] + struct MockProviderError(String); + impl std::fmt::Display for MockProviderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "MockProviderError: {}", self.0) + } + } + impl std::error::Error for MockProviderError {} + + /// Mock provider that returns configurable responses for changed(), identityOwner(), and get_logs() + struct MockProvider { + /// Block number to return for changed(addr) calls + changed_block: u64, + /// Address to return for identityOwner(addr) calls (None = return the queried address) + identity_owner: Option<[u8; 20]>, + /// Per-block identity owners for historical resolution (block -> owner) + /// When identityOwner is called at BlockRef::Number(n), returns the owner for + /// the highest block <= n, falling back to identity_owner + identity_owner_at_block: HashMap, + /// Logs to return for get_logs calls, keyed by block number + logs: HashMap>, + /// Block timestamps to return for block_timestamp calls + block_timestamps: HashMap, + } + + impl MockProvider { + fn new_unchanged() -> Self { + Self { + changed_block: 0, + identity_owner: None, + identity_owner_at_block: HashMap::new(), + logs: HashMap::new(), + block_timestamps: HashMap::new(), + } + } + + fn new_same_owner() -> Self { + Self { + changed_block: 1, // has changes + identity_owner: None, // but owner is the same + identity_owner_at_block: HashMap::new(), + logs: HashMap::new(), + block_timestamps: HashMap::new(), + } + } + } + + impl EthProvider for MockProvider { + type Error = MockProviderError; + + async fn call( + &self, + _to: [u8; 20], + data: Vec, + block: BlockRef, + ) -> Result, Self::Error> { + if data.len() < 4 { + return Err(MockProviderError("calldata too short".into())); + } + let selector: [u8; 4] = data[..4].try_into().unwrap(); + match selector { + CHANGED_SELECTOR => { + // Return changed_block as uint256 + let mut result = vec![0u8; 32]; + result[24..32].copy_from_slice(&self.changed_block.to_be_bytes()); + Ok(result) + } + IDENTITY_OWNER_SELECTOR => { + let mut result = vec![0u8; 32]; + // For block-specific queries, check identity_owner_at_block first + if let BlockRef::Number(n) = block { + if !self.identity_owner_at_block.is_empty() { + // Find the owner at or before block n + let owner = self.identity_owner_at_block + .iter() + .filter(|(&b, _)| b <= n) + .max_by_key(|(&b, _)| b) + .map(|(_, o)| *o); + if let Some(o) = owner { + result[12..32].copy_from_slice(&o); + return Ok(result); + } + } + } + // Fallback to identity_owner or echo back the queried address + if let Some(owner) = self.identity_owner { + result[12..32].copy_from_slice(&owner); + } else if data.len() >= 36 { + result[12..32].copy_from_slice(&data[16..36]); + } + Ok(result) + } + _ => Err(MockProviderError(format!( + "unknown selector: {:?}", + selector + ))), + } + } + + async fn get_logs(&self, filter: LogFilter) -> Result, Self::Error> { + // Return logs for the requested block range, filtering by topic0 and topic1 + let mut result = Vec::new(); + for block in filter.from_block..=filter.to_block { + if let Some(block_logs) = self.logs.get(&block) { + for log in block_logs { + // Filter by topic0 if specified + if !filter.topic0.is_empty() && !log.topics.is_empty() { + if !filter.topic0.contains(&log.topics[0]) { + continue; + } + } + // Filter by topic1 if specified + if let Some(t1) = filter.topic1 { + if log.topics.len() < 2 || log.topics[1] != t1 { + continue; + } + } + result.push(Log { + address: log.address, + topics: log.topics.clone(), + data: log.data.clone(), + block_number: log.block_number, + }); + } + } + } + Ok(result) + } + + async fn block_timestamp(&self, block: u64) -> Result { + Ok(self.block_timestamps.get(&block).copied().unwrap_or(0)) + } + } + + const TEST_REGISTRY: [u8; 20] = [ + 0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b, + ]; + + /// Build a DIDOwnerChanged log entry for testing + fn make_owner_changed_log( + block: u64, + identity: &[u8; 20], + new_owner: &[u8; 20], + previous_change: u64, + ) -> Log { + let identity_topic = abi_encode_address(identity); + let mut data = vec![0u8; 64]; + // data[0:32] = owner (address, zero-padded to 32 bytes) + data[12..32].copy_from_slice(new_owner); + // data[32:64] = previousChange (uint256) + data[56..64].copy_from_slice(&previous_change.to_be_bytes()); + + Log { + address: TEST_REGISTRY, + topics: vec![topic_owner_changed(), identity_topic], + data, + block_number: block, + } + } + + /// Build a DIDDelegateChanged log entry for testing + fn make_delegate_changed_log( + block: u64, + identity: &[u8; 20], + delegate_type: &[u8; 32], + delegate: &[u8; 20], + valid_to: u64, + previous_change: u64, + ) -> Log { + let identity_topic = abi_encode_address(identity); + let mut data = vec![0u8; 128]; + // data[0:32] = delegateType + data[0..32].copy_from_slice(delegate_type); + // data[32:64] = delegate (address, zero-padded) + data[44..64].copy_from_slice(delegate); + // data[64:96] = validTo + data[88..96].copy_from_slice(&valid_to.to_be_bytes()); + // data[96:128] = previousChange + data[120..128].copy_from_slice(&previous_change.to_be_bytes()); + + Log { + address: TEST_REGISTRY, + topics: vec![topic_delegate_changed(), identity_topic], + data, + block_number: block, + } + } + + /// Build a DIDAttributeChanged log entry for testing + fn make_attribute_changed_log( + block: u64, + identity: &[u8; 20], + name: &[u8; 32], + value: &[u8], + valid_to: u64, + previous_change: u64, + ) -> Log { + let identity_topic = abi_encode_address(identity); + // data layout: name(32) + offset(32) + validTo(32) + previousChange(32) + valueLen(32) + value(padded) + let padded_value_len = ((value.len() + 31) / 32) * 32; + let total_len = 160 + padded_value_len; + let mut data = vec![0u8; total_len]; + // data[0:32] = name + data[0..32].copy_from_slice(name); + // data[32:64] = offset to value (always 0xa0 = 160) + data[56..64].copy_from_slice(&160u64.to_be_bytes()); + // data[64:96] = validTo + data[88..96].copy_from_slice(&valid_to.to_be_bytes()); + // data[96:128] = previousChange + data[120..128].copy_from_slice(&previous_change.to_be_bytes()); + // data[128:160] = value length + data[152..160].copy_from_slice(&(value.len() as u64).to_be_bytes()); + // data[160..] = value bytes + data[160..160 + value.len()].copy_from_slice(value); + + Log { + address: TEST_REGISTRY, + topics: vec![topic_attribute_changed(), identity_topic], + data, + block_number: block, + } + } + + /// Helper: encode a delegate type string as bytes32 (right-padded with zeros) + fn encode_delegate_type(s: &str) -> [u8; 32] { + let mut b = [0u8; 32]; + let bytes = s.as_bytes(); + b[..bytes.len().min(32)].copy_from_slice(&bytes[..bytes.len().min(32)]); + b + } + + /// Helper: encode an attribute name string as bytes32 (right-padded with zeros) + fn encode_attr_name(s: &str) -> [u8; 32] { + let mut b = [0u8; 32]; + let bytes = s.as_bytes(); + b[..bytes.len().min(32)].copy_from_slice(&bytes[..bytes.len().min(32)]); + b + } + + /// A valid compressed secp256k1 public key (33 bytes) for use in tests. + const TEST_SECP256K1_COMPRESSED: [u8; 33] = [ + 0x03, 0xfd, 0xd5, 0x7a, 0xde, 0xc3, 0xd4, 0x38, 0xea, 0x23, 0x7f, + 0xe4, 0x6b, 0x33, 0xee, 0x1e, 0x01, 0x6e, 0xda, 0x6b, 0x58, 0x5c, + 0x3e, 0x27, 0xea, 0x66, 0x68, 0x6c, 0x2e, 0xa5, 0x35, 0x84, 0x79, + ]; + + /// A second valid compressed secp256k1 public key (different from the first). + const TEST_SECP256K1_COMPRESSED_2: [u8; 33] = [ + 0x02, 0xb9, 0x7c, 0x30, 0xde, 0x76, 0x7f, 0x08, 0x4c, 0xe3, 0x08, + 0x09, 0x68, 0xd8, 0x53, 0xd0, 0x3c, 0x3a, 0x28, 0x86, 0x53, 0xf8, + 0x12, 0x64, 0xa0, 0x90, 0xcd, 0x20, 0x3a, 0x12, 0xe5, 0x60, 0x40, + ]; + + #[tokio::test] + async fn resolve_with_mock_provider_changed_zero() { + // A mock provider where changed(addr) returns 0 should produce + // the same document as offline resolution. + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider::new_unchanged(), + }, + ); + + let doc_onchain = resolver + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + let doc_offline = DIDEthr::<()>::default() + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + assert_eq!( + serde_json::to_value(&doc_onchain).unwrap(), + serde_json::to_value(&doc_offline).unwrap(), + "mock provider with changed=0 should produce same doc as offline" + ); + } + + #[tokio::test] + async fn resolve_with_mock_provider_owner_changed_address_did() { + // When identityOwner(addr) returns a different address, the #controller + // and Eip712Method2021 VMs should use the new owner's address in + // blockchainAccountId. + let new_owner: [u8; 20] = [0x11; 20]; + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider { + changed_block: 1, + identity_owner: Some(new_owner), + logs: HashMap::new(), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }, + ); + + let doc = resolver + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + + // The DID id should still use the original address + assert_eq!( + doc_value["id"], + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + ); + + // #controller VM should use the new owner's address + let vms = doc_value["verificationMethod"].as_array().unwrap(); + let controller_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#controller") + }).expect("should have #controller VM"); + assert_eq!( + controller_vm["blockchainAccountId"], + "eip155:1:0x1111111111111111111111111111111111111111" + ); + + // Eip712Method2021 should also use the new owner's address + let eip712_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#Eip712Method2021") + }).expect("should have #Eip712Method2021 VM"); + assert_eq!( + eip712_vm["blockchainAccountId"], + "eip155:1:0x1111111111111111111111111111111111111111" + ); + + // Should only have 2 VMs (no controllerKey for address-based DID) + assert_eq!(vms.len(), 2); + } + + #[tokio::test] + async fn resolve_with_mock_provider_owner_changed_pubkey_did() { + // When a public-key DID has a changed owner, #controllerKey must be + // omitted (the pubkey no longer represents the current owner). + // Only #controller and Eip712Method2021 remain. + let new_owner: [u8; 20] = [0x22; 20]; + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider { + changed_block: 1, + identity_owner: Some(new_owner), + logs: HashMap::new(), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }, + ); + + let did_str = "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479"; + let doc = resolver + .resolve(did!( + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + + // DID id still uses the original public key + assert_eq!(doc_value["id"], did_str); + + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have exactly 2 VMs: #controller and #Eip712Method2021 + assert_eq!(vms.len(), 2, "should have 2 VMs, not 3 (no #controllerKey)"); + + // No #controllerKey + assert!( + vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#controllerKey")), + "#controllerKey should be omitted when owner has changed" + ); + + // #controller uses new owner's blockchainAccountId + let controller_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#controller") + }).expect("should have #controller VM"); + assert_eq!( + controller_vm["blockchainAccountId"], + "eip155:1:0x2222222222222222222222222222222222222222" + ); + + // Eip712Method2021 uses new owner's blockchainAccountId + let eip712_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#Eip712Method2021") + }).expect("should have #Eip712Method2021 VM"); + assert_eq!( + eip712_vm["blockchainAccountId"], + "eip155:1:0x2222222222222222222222222222222222222222" + ); + } + + #[tokio::test] + async fn resolve_with_mock_provider_identity_owner_same() { + // A mock provider where identityOwner(addr) returns the same address + // should produce the same document as offline resolution. + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider::new_same_owner(), + }, + ); + + let doc_onchain = resolver + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + let doc_offline = DIDEthr::<()>::default() + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + assert_eq!( + serde_json::to_value(&doc_onchain).unwrap(), + serde_json::to_value(&doc_offline).unwrap(), + "mock provider with identityOwner=same should produce same doc as offline" + ); + } + + #[tokio::test] + async fn resolve_with_mock_provider_owner_same_pubkey_did_retains_controller_key() { + // When a public-key DID's owner hasn't changed, #controllerKey + // must be retained in the document. + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider::new_same_owner(), + }, + ); + + let doc = resolver + .resolve(did!( + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have 3 VMs: #controller, #controllerKey, Eip712Method2021 + // (Note: Eip712Method2021 is only on the controller, not the key, + // so the exact set depends on the offline resolve_public_key behavior) + assert!( + vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#controllerKey")), + "#controllerKey should be retained when owner is unchanged" + ); + + // #controllerKey should have publicKeyJwk + let key_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#controllerKey") + }).unwrap(); + assert!(key_vm.get("publicKeyJwk").is_some(), "#controllerKey should have publicKeyJwk"); + } + + #[tokio::test] + async fn resolve_verikey_delegate_adds_vm() { + // A DIDDelegateChanged event with delegate_type="veriKey" and valid_to=MAX + // should add EcdsaSecp256k1RecoveryMethod2020 + Eip712Method2021 VMs + // with #delegate-1 and #delegate-1-Eip712Method2021 IDs. + // The delegate VM is referenced in assertionMethod but NOT authentication. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, // same as identity + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); + + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have 4 VMs: #controller, #Eip712Method2021, #delegate-1, #delegate-1-Eip712Method2021 + assert_eq!(vms.len(), 4, "expected 4 VMs, got {}", vms.len()); + + // Check #delegate-1 VM + let delegate_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM"); + assert_eq!(delegate_vm["type"], "EcdsaSecp256k1RecoveryMethod2020"); + let delegate_addr = format_address_eip55(&delegate); + let expected_account_id = format!("eip155:1:{}", delegate_addr); + assert_eq!(delegate_vm["blockchainAccountId"], expected_account_id); + + // Check #delegate-1-Eip712Method2021 VM + let delegate_eip712 = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1-Eip712Method2021") + }).expect("should have #delegate-1-Eip712Method2021 VM"); + assert_eq!(delegate_eip712["type"], "Eip712Method2021"); + assert_eq!(delegate_eip712["blockchainAccountId"], expected_account_id); + + // #delegate-1 should be in assertionMethod but NOT authentication + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + let auth = doc_value["authentication"].as_array().unwrap(); + + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); + assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); + } + + #[tokio::test] + async fn resolve_sigauth_delegate_also_in_authentication() { + // A DIDDelegateChanged with delegate_type="sigAuth" should add VMs + // referenced in BOTH assertionMethod AND authentication. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xBB; 20]; + let delegate_type = encode_delegate_type("sigAuth"); + + let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + + let vms = doc_value["verificationMethod"].as_array().unwrap(); + assert_eq!(vms.len(), 4); + + // #delegate-1 should be in BOTH assertionMethod AND authentication + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + let auth = doc_value["authentication"].as_array().unwrap(); + + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); + assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1-Eip712Method2021"))); + } + + #[tokio::test] + async fn resolve_expired_delegate_not_included() { + // A delegate with valid_to < now is NOT included in the document. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xCC; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + // valid_to = 1000 (well in the past) + let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, 1000, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should only have the 2 base VMs (no delegate VMs) + assert_eq!(vms.len(), 2, "expired delegate should not be in document"); + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().contains("delegate"))); + } + + #[tokio::test] + async fn resolve_revoked_delegate_skipped_but_counter_increments() { + // A revoked delegate (valid_to=0) is not included, but the counter + // still increments. So a subsequent valid delegate gets #delegate-2. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0xDD; 20]; + let delegate_b: [u8; 20] = [0xEE; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + // First delegate: revoked (valid_to=0) + let log_a = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, 0, 0); + // Second delegate: valid + let log_b = make_delegate_changed_log(200, &identity, &delegate_type, &delegate_b, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([ + (100, vec![log_a]), + (200, vec![log_b]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have 4 VMs: 2 base + 2 delegate (only delegate_b) + assert_eq!(vms.len(), 4); + + // No #delegate-1 (revoked) + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-1"))); + + // Has #delegate-2 (counter still incremented past revoked) + let delegate_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-2") + }).expect("should have #delegate-2 VM"); + + let delegate_addr = format_address_eip55(&delegate_b); + assert_eq!(delegate_vm["blockchainAccountId"], format!("eip155:1:{delegate_addr}")); + } + + #[tokio::test] + async fn resolve_multiple_valid_delegates_sequential_ids() { + // Multiple valid delegates produce sequential #delegate-1, #delegate-2, etc. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0x11; 20]; + let delegate_b: [u8; 20] = [0x22; 20]; + let veri_key = encode_delegate_type("veriKey"); + let sig_auth = encode_delegate_type("sigAuth"); + + let log_a = make_delegate_changed_log(100, &identity, &veri_key, &delegate_a, u64::MAX, 0); + let log_b = make_delegate_changed_log(200, &identity, &sig_auth, &delegate_b, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([ + (100, vec![log_a]), + (200, vec![log_b]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 2 for delegate_a + 2 for delegate_b = 6 + assert_eq!(vms.len(), 6); + + // #delegate-1 is veriKey (assertionMethod only, NOT authentication) + assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1"))); + assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1-Eip712Method2021"))); + + // #delegate-2 is sigAuth (both assertionMethod AND authentication) + assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2"))); + assert!(vms.iter().any(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2-Eip712Method2021"))); + + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + let auth = doc_value["authentication"].as_array().unwrap(); + + // delegate-1 should NOT be in auth (veriKey) + assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + + // delegate-2 SHOULD be in auth (sigAuth) + assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-2"))); + assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-2-Eip712Method2021"))); + } + + #[tokio::test] + async fn resolve_delegate_and_attribute_key_share_counter() { + // Delegate events and attribute pub key events share the same delegate + // counter. A delegate at block 100 gets #delegate-1, an attribute key + // at block 200 gets #delegate-2. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); + + let log_delegate = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + let log_attr = make_attribute_changed_log(200, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([(100, vec![log_delegate]), (200, vec![log_attr])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 2 delegate (RecoveryMethod + Eip712) + 1 attribute key = 5 + assert_eq!(vms.len(), 5); + + // #delegate-1 is the delegate event (EcdsaSecp256k1RecoveryMethod2020) + let d1 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1")).unwrap(); + assert_eq!(d1["type"], "EcdsaSecp256k1RecoveryMethod2020"); + + // #delegate-2 is the attribute key (EcdsaSecp256k1VerificationKey2019 with publicKeyJwk) + let d2 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2")).unwrap(); + assert_eq!(d2["type"], "EcdsaSecp256k1VerificationKey2019"); + assert!(d2["publicKeyJwk"].is_object(), "attribute key should have publicKeyJwk"); + assert_eq!(d2["publicKeyJwk"]["kty"], "EC"); + assert_eq!(d2["publicKeyJwk"]["crv"], "secp256k1"); + } + + #[tokio::test] + async fn resolve_multiple_services_sequential_ids() { + // Multiple did/svc attributes produce #service-1, #service-2, etc. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_hub = encode_attr_name("did/svc/HubService"); + let attr_msg = encode_attr_name("did/svc/MessagingService"); + + let log_a = make_attribute_changed_log(100, &identity, &attr_hub, b"https://hub.example.com", u64::MAX, 0); + let log_b = make_attribute_changed_log(200, &identity, &attr_msg, b"https://msg.example.com", u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([(100, vec![log_a]), (200, vec![log_b])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let services = doc_value["service"].as_array().unwrap(); + assert_eq!(services.len(), 2); + + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + assert_eq!(services[0]["id"], format!("{did_prefix}#service-1")); + assert_eq!(services[0]["type"], "HubService"); + assert_eq!(services[1]["id"], format!("{did_prefix}#service-2")); + assert_eq!(services[1]["type"], "MessagingService"); + } + + #[tokio::test] + async fn resolve_expired_attribute_excluded_counter_increments() { + // An expired attribute (valid_to < now) is excluded but the delegate + // counter still increments, so the next valid entry gets #delegate-2. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); + + // First key: expired (valid_to = 1000, well in the past) + let log_a = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, 1000, 0); + // Second key: valid (different key bytes so they have different content keys) + let log_b = make_attribute_changed_log(200, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED_2, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([(100, vec![log_a]), (200, vec![log_b])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 1 valid attribute key = 3 (expired one excluded) + assert_eq!(vms.len(), 3); + + // No #delegate-1 (expired) + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-1"))); + + // Has #delegate-2 (counter incremented past expired) + let vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-2") + }).expect("should have #delegate-2 VM"); + assert!(vm["publicKeyJwk"].is_object(), "should have publicKeyJwk as object"); + } + + #[tokio::test] + async fn resolve_service_endpoint_json() { + // did/svc/MessagingService with JSON object value → structured serviceEndpoint + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/svc/MessagingService"); + let endpoint = br#"{"uri":"https://msg.example.com","accept":["didcomm/v2"]}"#; + + let log = make_attribute_changed_log(100, &identity, &attr_name, endpoint, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let services = doc_value["service"].as_array().unwrap(); + assert_eq!(services.len(), 1); + + let svc = &services[0]; + assert_eq!(svc["type"], "MessagingService"); + // JSON endpoint should be a parsed object, not a string + assert!(svc["serviceEndpoint"].is_object()); + assert_eq!(svc["serviceEndpoint"]["uri"], "https://msg.example.com"); + assert_eq!(svc["serviceEndpoint"]["accept"], serde_json::json!(["didcomm/v2"])); + } + + #[tokio::test] + async fn resolve_service_endpoint_url() { + // did/svc/HubService with URL string → service entry + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/svc/HubService"); + let endpoint = b"https://hubs.uport.me"; + + let log = make_attribute_changed_log(100, &identity, &attr_name, endpoint, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); + let services = doc_value["service"].as_array().unwrap(); + assert_eq!(services.len(), 1); + + let svc = &services[0]; + assert_eq!(svc["id"], "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#service-1"); + assert_eq!(svc["type"], "HubService"); + assert_eq!(svc["serviceEndpoint"], "https://hubs.uport.me"); + } + + #[tokio::test] + async fn resolve_secp256k1_sigauth_hex_attribute_in_authentication() { + // did/pub/Secp256k1/sigAuth/hex attribute adds VM referenced in + // verificationMethod + assertionMethod + authentication. + // Uses a real compressed secp256k1 key so secp256k1_parse succeeds. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Secp256k1/sigAuth/hex"); + + let log = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + + let vms = doc_value["verificationMethod"].as_array().unwrap(); + // 2 base + 1 attribute key = 3 + assert_eq!(vms.len(), 3); + + let attr_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM"); + assert_eq!(attr_vm["type"], "EcdsaSecp256k1VerificationKey2019"); + assert!(attr_vm["publicKeyJwk"].is_object(), "should have publicKeyJwk"); + + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + let auth = doc_value["authentication"].as_array().unwrap(); + + // sigAuth should be in BOTH assertionMethod AND authentication + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + } + + #[tokio::test] + async fn resolve_delegates_with_owner_change_integration() { + // When the owner has changed AND there are delegates, both the + // owner-derived controller VM and the delegate VMs appear. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let new_owner: [u8; 20] = [0xFF; 20]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + let log_owner = make_owner_changed_log(100, &identity, &new_owner, 0); + let log_delegate = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: Some(new_owner), + logs: HashMap::from([ + (100, vec![log_owner]), + (200, vec![log_delegate]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }, + ); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base (with new owner) + 2 delegate = 4 + assert_eq!(vms.len(), 4); + + // #controller uses the new owner's address + let controller_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#controller") + }).unwrap(); + let owner_addr = format_address_eip55(&new_owner); + assert_eq!( + controller_vm["blockchainAccountId"], + format!("eip155:1:{owner_addr}") + ); + + // #delegate-1 uses the delegate's address + let delegate_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).unwrap(); + let delegate_addr = format_address_eip55(&delegate); + assert_eq!( + delegate_vm["blockchainAccountId"], + format!("eip155:1:{delegate_addr}") + ); + } + + #[tokio::test] + async fn resolve_with_mock_provider_multiple_owner_changes() { + // Simulate multiple ownership transfers: identityOwner() returns the + // final owner. The document should use that address regardless of + // how many transfers occurred. + let final_owner: [u8; 20] = [ + 0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, + 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0x01, + ]; + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b], + provider: MockProvider { + changed_block: 5, // multiple blocks of changes + identity_owner: Some(final_owner), + logs: HashMap::new(), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }, + ); + + let doc = resolver + .resolve(did!( + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Compute expected checksummed address + let expected_addr = format_address_eip55(&final_owner); + let expected_account_id = format!("eip155:1:{expected_addr}"); + + let controller_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#controller") + }).expect("should have #controller VM"); + assert_eq!( + controller_vm["blockchainAccountId"].as_str().unwrap(), + expected_account_id, + ); + + let eip712_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#Eip712Method2021") + }).expect("should have #Eip712Method2021 VM"); + assert_eq!( + eip712_vm["blockchainAccountId"].as_str().unwrap(), + expected_account_id, + ); + } + + #[tokio::test] + async fn resolve_deactivated_null_owner_bare_doc() { + // When identityOwner() returns the null address (0x000...0), + // the DID is deactivated: bare doc with only `id`, empty VMs, + // and document_metadata.deactivated = true. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let null_owner: [u8; 20] = [0u8; 20]; + + let log = make_owner_changed_log(100, &identity, &null_owner, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: Some(null_owner), + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::from([(100, 1705312200)]), + identity_owner_at_block: HashMap::new(), + }, + }); + + let output = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + + // Document metadata must have deactivated = true + assert_eq!(output.document_metadata.deactivated, Some(true)); + // Deactivation is an on-chain change, so versionId/updated must be set + assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); + assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + + // ID preserved + assert_eq!(doc_value["id"], "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"); + + // Empty verificationMethod, authentication, assertionMethod + let vms = doc_value.get("verificationMethod"); + assert!(vms.is_none() || vms.unwrap().as_array().map_or(true, |a| a.is_empty())); + let auth = doc_value.get("authentication"); + assert!(auth.is_none() || auth.unwrap().as_array().map_or(true, |a| a.is_empty())); + let assertion = doc_value.get("assertionMethod"); + assert!(assertion.is_none() || assertion.unwrap().as_array().map_or(true, |a| a.is_empty())); + } + + #[tokio::test] + async fn resolve_deactivated_ignores_events() { + // Even with delegate/attribute events, deactivation discards them all. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let null_owner: [u8; 20] = [0u8; 20]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + let attr_name = encode_attr_name("did/svc/HubService"); + + let log_owner = make_owner_changed_log(100, &identity, &null_owner, 0); + let log_delegate = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, u64::MAX, 100); + let log_attr = make_attribute_changed_log(300, &identity, &attr_name, b"https://hub.example.com", u64::MAX, 200); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 300, + identity_owner: Some(null_owner), + logs: HashMap::from([ + (100, vec![log_owner]), + (200, vec![log_delegate]), + (300, vec![log_attr]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let output = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + + assert_eq!(output.document_metadata.deactivated, Some(true)); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + + // No VMs, no services — all events discarded + let vms = doc_value.get("verificationMethod"); + assert!(vms.is_none() || vms.unwrap().as_array().map_or(true, |a| a.is_empty())); + let services = doc_value.get("service"); + assert!(services.is_none() || services.unwrap().as_array().map_or(true, |a| a.is_empty())); + } + + #[tokio::test] + async fn resolve_non_null_owner_not_deactivated() { + // When the owner is non-null, deactivated should be None (not set). + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider::new_same_owner(), + }); + + let output = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + + // deactivated should be None (default) + assert!(output.document_metadata.deactivated.is_none() + || output.document_metadata.deactivated == Some(false)); + } + + #[tokio::test] + async fn metadata_no_changes_no_version_id_or_updated() { + // DID with no on-chain changes (changed=0) → no versionId/updated metadata + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider::new_unchanged(), + }); + + let output = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + + assert!(output.document_metadata.version_id.is_none()); + assert!(output.document_metadata.updated.is_none()); + } + + #[tokio::test] + async fn metadata_with_changes_has_version_id_and_updated() { + // DID with on-chain changes (changed_block=100) → versionId = "100", + // updated = ISO 8601 timestamp of block 100 + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + + // Block 100 has timestamp 1705312200 = 2024-01-15T09:50:00Z + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::from([(100, 1705312200)]), + identity_owner_at_block: HashMap::new(), + }, + }); + + let output = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await + .unwrap(); + + assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); + assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); + } + + // --- Phase 8: Historical Resolution (?versionId=N) tests --- + + #[tokio::test] + async fn historical_version_id_skips_events_after_target_block() { + // Events at blocks 100 and 200. Resolving with versionId=100 + // should only include events from block 100. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0xAA; 20]; + let delegate_b: [u8; 20] = [0xBB; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, u64::MAX, 0); + let log_200 = make_delegate_changed_log(200, &identity, &delegate_type, &delegate_b, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + identity_owner_at_block: HashMap::new(), + logs: HashMap::from([ + (100, vec![log_100]), + (200, vec![log_200]), + ]), + block_timestamps: HashMap::from([ + (100, 1705312200), // 2024-01-15T09:50:00Z + (200, 1705398600), // 2024-01-16T09:50:00Z + ]), + }, + }); + + let options = resolution::Options { + parameters: resolution::Parameters { + version_id: Some("100".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let output = resolver + .resolve_with( + did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), + options, + ) + .await + .unwrap(); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Should have 4 VMs: 2 base + 2 delegate (only delegate_a from block 100) + assert_eq!(vms.len(), 4, "only events at/before block 100 should be applied"); + + // delegate_a (#delegate-1) present + let d1 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1")); + assert!(d1.is_some(), "delegate from block 100 should be present"); + + // delegate_b (#delegate-2) NOT present + let d2 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-2")); + assert!(d2.is_none(), "delegate from block 200 should NOT be present"); + + // Metadata: versionId should be "100" (latest event at or before target) + assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); + assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); + } + + #[tokio::test] + async fn historical_valid_to_uses_target_block_timestamp() { + // Delegate valid_to = 1705315800 (block 100 timestamp + 1 hour). + // Block 100 timestamp = 1705312200. At block 100 the delegate is still valid. + // At wall-clock time (far future) it would be expired. + // ?versionId=100 should include the delegate. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + let valid_to = 1705315800u64; // 1 hour after block 100 timestamp + + let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, valid_to, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + identity_owner_at_block: HashMap::new(), + logs: HashMap::from([ + (100, vec![log_100]), + ]), + block_timestamps: HashMap::from([ + (100, 1705312200), // 2024-01-15T09:50:00Z + ]), + }, + }); + + let options = resolution::Options { + parameters: resolution::Parameters { + version_id: Some("100".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let output = resolver + .resolve_with( + did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), + options, + ) + .await + .unwrap(); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 2 delegate VMs (delegate is valid at block 100's timestamp) + assert_eq!(vms.len(), 4, "delegate valid at block timestamp should be included"); + + let d1 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-1")); + assert!(d1.is_some(), "delegate still valid at block 100 timestamp should be present"); + } + + #[tokio::test] + async fn historical_next_version_id_and_next_update() { + // Events at blocks 100 and 200. Resolving at versionId=100 should set + // nextVersionId=200 and nextUpdate to block 200's timestamp. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0xAA; 20]; + let delegate_b: [u8; 20] = [0xBB; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, u64::MAX, 0); + let log_200 = make_delegate_changed_log(200, &identity, &delegate_type, &delegate_b, u64::MAX, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + identity_owner_at_block: HashMap::new(), + logs: HashMap::from([ + (100, vec![log_100]), + (200, vec![log_200]), + ]), + block_timestamps: HashMap::from([ + (100, 1705312200), // 2024-01-15T09:50:00Z + (200, 1705398600), // 2024-01-16T09:50:00Z + ]), + }, + }); + + let options = resolution::Options { + parameters: resolution::Parameters { + version_id: Some("100".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let output = resolver + .resolve_with( + did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), + options, + ) + .await + .unwrap(); + + assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); + assert_eq!(output.document_metadata.updated.as_deref(), Some("2024-01-15T09:50:00Z")); + assert_eq!(output.document_metadata.next_version_id.as_deref(), Some("200")); + assert_eq!(output.document_metadata.next_update.as_deref(), Some("2024-01-16T09:50:00Z")); + } + + #[tokio::test] + async fn historical_before_any_changes_returns_genesis() { + // Events only at block 100. Resolving at versionId=50 (before any changes) + // should return the default genesis document (no delegates/attributes). + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate_a: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + let log_100 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate_a, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + identity_owner_at_block: HashMap::new(), + logs: HashMap::from([ + (100, vec![log_100]), + ]), + block_timestamps: HashMap::from([ + (100, 1705312200), + ]), + }, + }); + + let options = resolution::Options { + parameters: resolution::Parameters { + version_id: Some("50".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let output = resolver + .resolve_with( + did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), + options, + ) + .await + .unwrap(); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Genesis document: only 2 base VMs, no delegates + assert_eq!(vms.len(), 2, "genesis doc should have only base VMs"); + + // No metadata versionId/updated (offline genesis) + assert!(output.document_metadata.version_id.is_none()); + assert!(output.document_metadata.updated.is_none()); + } + + #[tokio::test] + async fn pubkey_did_with_provider_unchanged_includes_eip712method2021() { + // Public-key DID with MockProvider (changed_block=0) should also + // include Eip712Method2021, matching offline resolution. + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider::new_unchanged(), + }, + ); + + let doc = resolver + .resolve(did!( + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" + )) + .await + .unwrap() + .document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + assert_eq!(vms.len(), 3, "public-key DID with unchanged provider should have 3 VMs"); + + assert!( + vms.iter().any(|vm| vm["type"].as_str() == Some("Eip712Method2021")), + "should include Eip712Method2021" + ); + + // Should match offline resolution exactly + let doc_offline = DIDEthr::<()>::default() + .resolve(did!( + "did:ethr:0x03fdd57adec3d438ea237fe46b33ee1e016eda6b585c3e27ea66686c2ea5358479" + )) + .await + .unwrap() + .document; + + assert_eq!( + serde_json::to_value(&doc).unwrap(), + serde_json::to_value(&doc_offline).unwrap(), + "unchanged provider should match offline for public-key DID" + ); + } + + // ── Revocation tests ── + + #[tokio::test] + async fn resolve_previously_valid_delegate_then_revoked() { + // A delegate is added valid at block 100, then revoked at block 200. + // The revoked delegate must NOT appear in the final document. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + // Block 100: delegate added, valid_to = far future + let log_add = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + // Block 200: same delegate revoked (valid_to = 0) + let log_revoke = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, 0, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([ + (100, vec![log_add]), + (200, vec![log_revoke]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Only 2 base VMs — delegate was revoked + assert_eq!(vms.len(), 2, "revoked delegate should not appear in document"); + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().contains("delegate")), + "no delegate VMs should be present"); + } + + #[tokio::test] + async fn resolve_previously_valid_service_then_revoked() { + // A service is added valid at block 100, then revoked at block 200. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/svc/HubService"); + let endpoint = b"https://hub.example.com"; + + // Block 100: service added + let log_add = make_attribute_changed_log(100, &identity, &attr_name, endpoint, u64::MAX, 0); + // Block 200: same service revoked (valid_to = 0) + let log_revoke = make_attribute_changed_log(200, &identity, &attr_name, endpoint, 0, 100); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 200, + identity_owner: None, + logs: HashMap::from([ + (100, vec![log_add]), + (200, vec![log_revoke]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let services = doc_value.get("service"); + + // No services — the service was revoked + assert!( + services.is_none() || services.unwrap().as_array().map_or(true, |a| a.is_empty()), + "revoked service should not appear in document" + ); + } + + #[tokio::test] + async fn resolve_revoked_then_readded_gets_new_id() { + // A delegate is added, revoked, then re-added. The re-added delegate + // should get a higher counter ID (#delegate-3, not #delegate-1). + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + // Block 100: add (counter=1) + let log1 = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, u64::MAX, 0); + // Block 200: revoke (counter=2) + let log2 = make_delegate_changed_log(200, &identity, &delegate_type, &delegate, 0, 100); + // Block 300: re-add (counter=3) + let log3 = make_delegate_changed_log(300, &identity, &delegate_type, &delegate, u64::MAX, 200); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 300, + identity_owner: None, + logs: HashMap::from([ + (100, vec![log1]), + (200, vec![log2]), + (300, vec![log3]), + ]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 2 delegate (RecoveryMethod + Eip712) = 4 + assert_eq!(vms.len(), 4); + + // Should NOT have #delegate-1 or #delegate-2 + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-1"))); + assert!(vms.iter().all(|vm| !vm["id"].as_str().unwrap().ends_with("#delegate-2"))); + + // Should have #delegate-3 (the re-added entry) + let d3 = vms.iter().find(|vm| vm["id"].as_str().unwrap().ends_with("#delegate-3")) + .expect("should have #delegate-3 VM"); + assert_eq!(d3["type"], "EcdsaSecp256k1RecoveryMethod2020"); + } + + #[tokio::test] + async fn resolve_secp256k1_verikey_hex_attribute() { + // did/pub/Secp256k1/veriKey/hex attribute adds EcdsaSecp256k1VerificationKey2019 + // with publicKeyJwk to verificationMethod + assertionMethod, using #delegate-N ID. + // Encoding hint ("hex") is ignored; we always convert to JWK. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/hex"); + + let log = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 1 attribute key = 3 + assert_eq!(vms.len(), 3); + + let attr_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM from attribute"); + assert_eq!(attr_vm["type"], "EcdsaSecp256k1VerificationKey2019"); + // Must have publicKeyJwk (a JSON object), NOT publicKeyHex + assert!(attr_vm["publicKeyJwk"].is_object(), "should have publicKeyJwk as object"); + assert_eq!(attr_vm["publicKeyJwk"]["kty"], "EC"); + assert_eq!(attr_vm["publicKeyJwk"]["crv"], "secp256k1"); + assert!(attr_vm.get("publicKeyHex").is_none(), "should NOT have publicKeyHex"); + assert_eq!(attr_vm["controller"], "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"); + + // Should be in assertionMethod but NOT authentication + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + let auth = doc_value["authentication"].as_array().unwrap(); + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + + // Context should include publicKeyJwk binding + let context = doc_value["@context"].as_array().unwrap(); + let ctx_obj = context.iter().find(|c| c.is_object()).unwrap(); + assert!(ctx_obj.get("publicKeyJwk").is_some(), "context should include publicKeyJwk"); + } + + #[tokio::test] + async fn resolve_secp256k1_attr_key_uses_jwk() { + // Secp256k1 attribute key (any encoding hint) must produce + // EcdsaSecp256k1VerificationKey2019 with publicKeyJwk (a JSON object). + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Secp256k1/veriKey/base64"); + + let log = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + let attr_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM"); + assert_eq!(attr_vm["type"], "EcdsaSecp256k1VerificationKey2019"); + assert!(attr_vm["publicKeyJwk"].is_object(), "should use publicKeyJwk"); + assert_eq!(attr_vm["publicKeyJwk"]["kty"], "EC"); + assert_eq!(attr_vm["publicKeyJwk"]["crv"], "secp256k1"); + } + + #[tokio::test] + async fn resolve_ed25519_attr_key() { + // Ed25519 attribute key → Ed25519VerificationKey2020 + publicKeyMultibase + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Ed25519/veriKey/base64"); + // 32-byte Ed25519 public key + let ed_key: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + ]; + + let log = make_attribute_changed_log(100, &identity, &attr_name, &ed_key, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 1 Ed25519 = 3 + assert_eq!(vms.len(), 3); + + let attr_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM"); + assert_eq!(attr_vm["type"], "Ed25519VerificationKey2020"); + let expected_multibase = format!("z{}", bs58::encode(&ed_key).into_string()); + assert_eq!(attr_vm["publicKeyMultibase"], expected_multibase); + assert!(attr_vm.get("publicKeyJwk").is_none(), "should NOT have publicKeyJwk"); + + // Should be in assertionMethod (veriKey purpose) + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + assert!(assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1"))); + + // Context should include Ed25519VerificationKey2020 and publicKeyMultibase + let context = doc_value["@context"].as_array().unwrap(); + let ctx_obj = context.iter().find(|c| c.is_object()).unwrap(); + assert!(ctx_obj.get("Ed25519VerificationKey2020").is_some(), + "context should include Ed25519VerificationKey2020"); + assert!(ctx_obj.get("publicKeyMultibase").is_some(), + "context should include publicKeyMultibase"); + } + + #[tokio::test] + async fn resolve_x25519_attr_key() { + // X25519 attribute key → X25519KeyAgreementKey2020 + publicKeyMultibase + keyAgreement + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/X25519/enc/base64"); + // 32-byte X25519 public key + let x_key: [u8; 32] = [ + 0xAA, 0xBB, 0xCC, 0xDD, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, + 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, + 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + ]; + + let log = make_attribute_changed_log(100, &identity, &attr_name, &x_key, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + eprintln!("{}", serde_json::to_string_pretty(&doc_value).unwrap()); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // 2 base + 1 X25519 = 3 + assert_eq!(vms.len(), 3); + + let attr_vm = vms.iter().find(|vm| { + vm["id"].as_str().unwrap().ends_with("#delegate-1") + }).expect("should have #delegate-1 VM"); + assert_eq!(attr_vm["type"], "X25519KeyAgreementKey2020"); + let expected_multibase = format!("z{}", bs58::encode(&x_key).into_string()); + assert_eq!(attr_vm["publicKeyMultibase"], expected_multibase); + + // X25519 enc purpose → keyAgreement (not assertionMethod or authentication) + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + let key_agreement = doc_value["keyAgreement"].as_array() + .expect("should have keyAgreement array"); + assert!(key_agreement.iter().any(|v| v == &format!("{did_prefix}#delegate-1")), + "X25519 should be in keyAgreement"); + + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + assert!(!assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1")), + "X25519 should NOT be in assertionMethod"); + + // Context + let context = doc_value["@context"].as_array().unwrap(); + let ctx_obj = context.iter().find(|c| c.is_object()).unwrap(); + assert!(ctx_obj.get("X25519KeyAgreementKey2020").is_some(), + "context should include X25519KeyAgreementKey2020"); + assert!(ctx_obj.get("publicKeyMultibase").is_some(), + "context should include publicKeyMultibase"); + } +} diff --git a/crates/dids/methods/ethr/src/vm.rs b/crates/dids/methods/ethr/src/vm.rs new file mode 100644 index 000000000..90f89370a --- /dev/null +++ b/crates/dids/methods/ethr/src/vm.rs @@ -0,0 +1,167 @@ +use iref::Iri; +use ssi_caips::caip10::BlockchainAccountId; +use ssi_dids_core::{document::{self, DIDVerificationMethod}, DIDURLBuf, DIDBuf}; +use ssi_jwk::JWK; +use static_iref::iri; + +/// Intermediate representation for a verification method accumulated during +/// event processing, before materialisation into the DID document. +pub(crate) struct PendingVm { + pub(crate) counter: u64, + pub(crate) payload: PendingVmPayload, + /// For delegates: true if sigAuth. For attribute keys: true if sigAuth or enc purpose. + pub(crate) is_sig_auth: bool, +} + +pub(crate) enum PendingVmPayload { + Delegate { + blockchain_account_id: BlockchainAccountId, + }, + AttributeKey { + vm_type: VerificationMethodType, + prop_name: &'static str, + prop_value: serde_json::Value, + }, +} + +/// Intermediate representation for a service endpoint accumulated during +/// event processing. +pub(crate) struct PendingService { + pub(crate) counter: u64, + pub(crate) service_type: String, + pub(crate) endpoint: document::service::Endpoint, +} + +/// Decode the delegate_type bytes32 field by trimming trailing zeros +pub(crate) fn decode_delegate_type(delegate_type: &[u8; 32]) -> &[u8] { + let end = delegate_type + .iter() + .rposition(|&b| b != 0) + .map(|i| i + 1) + .unwrap_or(0); + &delegate_type[..end] +} + +#[allow(clippy::large_enum_variant)] +pub enum VerificationMethod { + EcdsaSecp256k1VerificationKey2019 { + id: DIDURLBuf, + controller: DIDBuf, + public_key_jwk: JWK, + }, + EcdsaSecp256k1RecoveryMethod2020 { + id: DIDURLBuf, + controller: DIDBuf, + blockchain_account_id: BlockchainAccountId, + }, + Eip712Method2021 { + id: DIDURLBuf, + controller: DIDBuf, + blockchain_account_id: BlockchainAccountId, + }, +} + +impl VerificationMethod { + pub fn id(&self) -> &ssi_dids_core::DIDURL { + match self { + Self::EcdsaSecp256k1VerificationKey2019 { id, .. } => id, + Self::EcdsaSecp256k1RecoveryMethod2020 { id, .. } => id, + Self::Eip712Method2021 { id, .. } => id, + } + } + + pub fn type_(&self) -> VerificationMethodType { + match self { + Self::EcdsaSecp256k1VerificationKey2019 { .. } => { + VerificationMethodType::EcdsaSecp256k1VerificationKey2019 + } + Self::EcdsaSecp256k1RecoveryMethod2020 { .. } => { + VerificationMethodType::EcdsaSecp256k1RecoveryMethod2020 + } + Self::Eip712Method2021 { .. } => VerificationMethodType::Eip712Method2021, + } + } +} + +#[derive(Clone, Copy)] +pub enum VerificationMethodType { + EcdsaSecp256k1VerificationKey2019, + EcdsaSecp256k1RecoveryMethod2020, + Ed25519VerificationKey2020, + X25519KeyAgreementKey2020, + Eip712Method2021, +} + +impl VerificationMethodType { + pub fn name(&self) -> &'static str { + match self { + Self::EcdsaSecp256k1VerificationKey2019 => "EcdsaSecp256k1VerificationKey2019", + Self::EcdsaSecp256k1RecoveryMethod2020 => "EcdsaSecp256k1RecoveryMethod2020", + Self::Ed25519VerificationKey2020 => "Ed25519VerificationKey2020", + Self::X25519KeyAgreementKey2020 => "X25519KeyAgreementKey2020", + Self::Eip712Method2021 => "Eip712Method2021", + } + } + + pub fn iri(&self) -> &'static Iri { + match self { + Self::EcdsaSecp256k1VerificationKey2019 => iri!("https://w3id.org/security#EcdsaSecp256k1VerificationKey2019"), + Self::EcdsaSecp256k1RecoveryMethod2020 => iri!("https://identity.foundation/EcdsaSecp256k1RecoverySignature2020#EcdsaSecp256k1RecoveryMethod2020"), + Self::Ed25519VerificationKey2020 => iri!("https://w3id.org/security#Ed25519VerificationKey2020"), + Self::X25519KeyAgreementKey2020 => iri!("https://w3id.org/security#X25519KeyAgreementKey2020"), + Self::Eip712Method2021 => iri!("https://w3id.org/security#Eip712Method2021"), + } + } +} + +impl From for DIDVerificationMethod { + fn from(value: VerificationMethod) -> Self { + match value { + VerificationMethod::EcdsaSecp256k1VerificationKey2019 { + id, + controller, + public_key_jwk, + } => Self { + id, + type_: "EcdsaSecp256k1VerificationKey2019".to_owned(), + controller, + properties: [( + "publicKeyJwk".into(), + serde_json::to_value(&public_key_jwk).unwrap(), + )] + .into_iter() + .collect(), + }, + VerificationMethod::EcdsaSecp256k1RecoveryMethod2020 { + id, + controller, + blockchain_account_id, + } => Self { + id, + type_: "EcdsaSecp256k1RecoveryMethod2020".to_owned(), + controller, + properties: [( + "blockchainAccountId".into(), + blockchain_account_id.to_string().into(), + )] + .into_iter() + .collect(), + }, + VerificationMethod::Eip712Method2021 { + id, + controller, + blockchain_account_id, + } => Self { + id, + type_: "Eip712Method2021".to_owned(), + controller, + properties: [( + "blockchainAccountId".into(), + blockchain_account_id.to_string().into(), + )] + .into_iter() + .collect(), + }, + } + } +} From bd8f910943a145feedf2a9e79b65bd4abfce96b6 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 10 Mar 2026 12:52:38 +0100 Subject: [PATCH 26/36] refactor: simplify abi/network/resolver - re-export keccak::keccak256 instead of wrapping - network_name() returns &str to avoid clone - remove calldata.clone() (moved by value) - partition all_events in one pass via Iterator::partition --- crates/dids/methods/ethr/src/abi.rs | 5 +---- crates/dids/methods/ethr/src/network.rs | 4 ++-- crates/dids/methods/ethr/src/resolver.rs | 10 +++------- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/crates/dids/methods/ethr/src/abi.rs b/crates/dids/methods/ethr/src/abi.rs index 09433ee8a..66eb24d69 100644 --- a/crates/dids/methods/ethr/src/abi.rs +++ b/crates/dids/methods/ethr/src/abi.rs @@ -58,7 +58,4 @@ pub(crate) fn format_timestamp_iso8601(unix_secs: u64) -> String { .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) } -/// Compute keccak256 hash of an event signature string -pub(crate) fn keccak256(data: &[u8]) -> [u8; 32] { - keccak::keccak256(data) -} +pub(crate) use keccak::keccak256; diff --git a/crates/dids/methods/ethr/src/network.rs b/crates/dids/methods/ethr/src/network.rs index 47951349b..48fd539bd 100644 --- a/crates/dids/methods/ethr/src/network.rs +++ b/crates/dids/methods/ethr/src/network.rs @@ -54,8 +54,8 @@ pub(crate) struct DecodedMethodSpecificId { impl DecodedMethodSpecificId { /// Return the network name used for provider lookup - pub(crate) fn network_name(&self) -> String { - self.network_name.clone() + pub(crate) fn network_name(&self) -> &str { + &self.network_name } /// Extract the Ethereum address hex string (with 0x prefix). diff --git a/crates/dids/methods/ethr/src/resolver.rs b/crates/dids/methods/ethr/src/resolver.rs index 91818cf3f..90e7d2dcd 100644 --- a/crates/dids/methods/ethr/src/resolver.rs +++ b/crates/dids/methods/ethr/src/resolver.rs @@ -79,10 +79,8 @@ impl DIDMethodResolver for DIDEthr

{ let decoded_id = DecodedMethodSpecificId::from_str(method_specific_id) .map_err(|_| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; - let network_name = decoded_id.network_name(); - // Check if we have a provider for this network - if let Some(config) = self.networks.get(&network_name) { + if let Some(config) = self.networks.get(decoded_id.network_name()) { let addr_hex = decoded_id.account_address_hex(); if let Some(addr) = crate::network::parse_address_bytes(&addr_hex) { // Parse historical resolution target block from ?versionId=N @@ -96,7 +94,7 @@ impl DIDMethodResolver for DIDEthr

{ let calldata = encode_call(CHANGED_SELECTOR, &addr); let result = config .provider - .call(config.registry, calldata.clone(), BlockRef::Latest) + .call(config.registry, calldata, BlockRef::Latest) .await .map_err(|e| Error::Internal(e.to_string()))?; let changed_block = decode_uint256(&result); @@ -114,9 +112,7 @@ impl DIDMethodResolver for DIDEthr

{ // Partition events for historical resolution let (events, events_after) = if let Some(tb) = target_block { - let before: Vec<_> = all_events.iter().filter(|(b, _)| *b <= tb).cloned().collect(); - let after: Vec<_> = all_events.iter().filter(|(b, _)| *b > tb).cloned().collect(); - (before, after) + all_events.into_iter().partition(|(b, _)| *b <= tb) } else { (all_events, Vec::new()) }; From f70d3350bf159633ad2393a4530a31a6f57543f7 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Tue, 10 Mar 2026 12:54:08 +0100 Subject: [PATCH 27/36] perf: cache topic hashes; reduce vm.id() clones --- crates/dids/methods/ethr/src/events.rs | 10 ++++++---- crates/dids/methods/ethr/src/resolver.rs | 7 +++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/dids/methods/ethr/src/events.rs b/crates/dids/methods/ethr/src/events.rs index 8da7b6c5a..6f0b551ef 100644 --- a/crates/dids/methods/ethr/src/events.rs +++ b/crates/dids/methods/ethr/src/events.rs @@ -3,17 +3,19 @@ use crate::provider::{EthProvider, Log, LogFilter}; // --- ERC-1056 event topic hashes --- -/// Lazily compute event topic hashes from their Solidity signatures pub(crate) fn topic_owner_changed() -> [u8; 32] { - keccak256(b"DIDOwnerChanged(address,address,uint256)") + static HASH: std::sync::OnceLock<[u8; 32]> = std::sync::OnceLock::new(); + *HASH.get_or_init(|| keccak256(b"DIDOwnerChanged(address,address,uint256)")) } pub(crate) fn topic_delegate_changed() -> [u8; 32] { - keccak256(b"DIDDelegateChanged(address,bytes32,address,uint256,uint256)") + static HASH: std::sync::OnceLock<[u8; 32]> = std::sync::OnceLock::new(); + *HASH.get_or_init(|| keccak256(b"DIDDelegateChanged(address,bytes32,address,uint256,uint256)")) } pub(crate) fn topic_attribute_changed() -> [u8; 32] { - keccak256(b"DIDAttributeChanged(address,bytes32,bytes,uint256,uint256)") + static HASH: std::sync::OnceLock<[u8; 32]> = std::sync::OnceLock::new(); + *HASH.get_or_init(|| keccak256(b"DIDAttributeChanged(address,bytes32,bytes,uint256,uint256)")) } // --- ERC-1056 event types --- diff --git a/crates/dids/methods/ethr/src/resolver.rs b/crates/dids/methods/ethr/src/resolver.rs index 90e7d2dcd..5f5c6f1ab 100644 --- a/crates/dids/methods/ethr/src/resolver.rs +++ b/crates/dids/methods/ethr/src/resolver.rs @@ -643,11 +643,14 @@ pub(crate) fn resolve_address( json_ld_context.add_verification_method_type(vm.type_()); json_ld_context.add_verification_method_type(eip712_vm.type_()); + let vm_id = vm.id().to_owned(); + let eip712_vm_id = eip712_vm.id().to_owned(); + let mut doc = Document::new(did); doc.verification_relationships.assertion_method = - vec![vm.id().to_owned().into(), eip712_vm.id().to_owned().into()]; + vec![vm_id.clone().into(), eip712_vm_id.clone().into()]; doc.verification_relationships.authentication = - vec![vm.id().to_owned().into(), eip712_vm.id().to_owned().into()]; + vec![vm_id.into(), eip712_vm_id.into()]; doc.verification_method = vec![vm.into(), eip712_vm.into()]; Ok(doc) From 8335667a0d2b9f5c9f8d92034448b8dc91a23eeb Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 13 Mar 2026 14:15:13 +0100 Subject: [PATCH 28/36] feat: add resolve_with_provider example for did:ethr resolution via HTTP JSON-RPC - Implements EthProvider over raw HTTP using reqwest - Adds example to resolve did:ethr DIDs against real Ethereum endpoints - Updates dependencies in Cargo.toml for example support - Fixes DIDEthr instantiation for generic parameter compliance --- Cargo.toml | 5 + crates/dids/src/lib.rs | 2 +- examples/resolve_with_provider.rs | 198 ++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 examples/resolve_with_provider.rs diff --git a/Cargo.toml b/Cargo.toml index 621c32090..444346db9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -255,6 +255,11 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true json-syntax.workspace = true ssi-dids = { workspace = true, features = ["example"] } +did-ethr.workspace = true +ssi-dids-core.workspace = true +hex.workspace = true +reqwest = { version = "0.12", features = ["json"] } +tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } [package.metadata.docs.rs] all-features = true diff --git a/crates/dids/src/lib.rs b/crates/dids/src/lib.rs index 4a2a6a063..033c061ad 100644 --- a/crates/dids/src/lib.rs +++ b/crates/dids/src/lib.rs @@ -307,7 +307,7 @@ impl DIDResolver for AnyDidMethod { ) -> Result>, resolution::Error> { match did.method_name() { "ethr" => { - ethr::DIDEthr + ethr::DIDEthr::<()>::default() .resolve_method_representation(did.method_specific_id(), options) .await } diff --git a/examples/resolve_with_provider.rs b/examples/resolve_with_provider.rs new file mode 100644 index 000000000..f639486c7 --- /dev/null +++ b/examples/resolve_with_provider.rs @@ -0,0 +1,198 @@ +//! Resolve a did:ethr DID using a real Ethereum JSON-RPC endpoint. +//! +//! Implements `EthProvider` over raw HTTP JSON-RPC (works with any endpoint), +//! walks the ERC-1056 event chain, and prints the resolved DID document. +//! +//! Run with: +//! cargo run --example resolve_with_provider + +use did_ethr::{BlockRef, DIDEthr, EthProvider, Log, LogFilter, NetworkConfig}; +use serde::{de::DeserializeOwned, Serialize}; +use ssi_dids_core::{did, DIDResolver}; + +// ── Hex helpers ────────────────────────────────────────────────────────────── + +fn hex_encode(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} + +fn hex_decode(s: &str) -> Result, String> { + let s = s.strip_prefix("0x").unwrap_or(s); + hex::decode(s).map_err(|e| e.to_string()) +} + +// ── HttpProvider ───────────────────────────────────────────────────────────── + +struct HttpProvider { + client: reqwest::Client, + url: String, +} + +impl HttpProvider { + async fn rpc( + &self, + method: &str, + params: P, + ) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + }); + let resp: serde_json::Value = self + .client + .post(&self.url) + .json(&body) + .send() + .await + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + if let Some(err) = resp.get("error") { + return Err(err.to_string()); + } + serde_json::from_value(resp["result"].clone()).map_err(|e| e.to_string()) + } +} + +#[derive(Debug)] +struct ProviderError(String); + +impl std::fmt::Display for ProviderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::error::Error for ProviderError {} + +impl EthProvider for HttpProvider { + type Error = ProviderError; + + async fn call( + &self, + to: [u8; 20], + data: Vec, + block: BlockRef, + ) -> Result, Self::Error> { + let block_param = match block { + BlockRef::Latest => "latest".to_owned(), + BlockRef::Number(n) => format!("0x{n:x}"), + }; + let result: String = self + .rpc( + "eth_call", + serde_json::json!([ + {"to": hex_encode(&to), "data": hex_encode(&data)}, + block_param, + ]), + ) + .await + .map_err(ProviderError)?; + hex_decode(&result).map_err(ProviderError) + } + + async fn get_logs(&self, filter: LogFilter) -> Result, Self::Error> { + // topic0: multiple hashes → inner array (OR semantics) + let topic0: Vec = filter.topic0.iter().map(|t| hex_encode(t)).collect(); + // topic1: optional single hash + let topic1 = filter + .topic1 + .as_ref() + .map(|t| serde_json::Value::String(hex_encode(t))) + .unwrap_or(serde_json::Value::Null); + + let raw: Vec = self + .rpc( + "eth_getLogs", + serde_json::json!([{ + "address": hex_encode(&filter.address), + "topics": [topic0, topic1], + "fromBlock": format!("0x{:x}", filter.from_block), + "toBlock": format!("0x{:x}", filter.to_block), + }]), + ) + .await + .map_err(ProviderError)?; + + raw.into_iter() + .map(|entry| { + let topics = entry["topics"] + .as_array() + .ok_or_else(|| ProviderError("missing topics".into()))? + .iter() + .map(|t| { + let bytes = hex_decode(t.as_str().unwrap_or("")) + .map_err(ProviderError)?; + bytes + .try_into() + .map_err(|_| ProviderError("topic not 32 bytes".into())) + }) + .collect::, _>>()?; + + let data = hex_decode(entry["data"].as_str().unwrap_or("0x")) + .map_err(ProviderError)?; + + let addr_bytes = hex_decode(entry["address"].as_str().unwrap_or("0x")) + .map_err(ProviderError)?; + let address: [u8; 20] = addr_bytes + .try_into() + .map_err(|_| ProviderError("address not 20 bytes".into()))?; + + let bn_str = entry["blockNumber"].as_str().unwrap_or("0x0"); + let block_number = u64::from_str_radix(bn_str.trim_start_matches("0x"), 16) + .map_err(|e| ProviderError(e.to_string()))?; + + Ok(Log { address, topics, data, block_number }) + }) + .collect() + } + + async fn block_timestamp(&self, block: u64) -> Result { + let result: serde_json::Value = self + .rpc( + "eth_getBlockByNumber", + serde_json::json!([format!("0x{block:x}"), false]), + ) + .await + .map_err(ProviderError)?; + let ts_str = result["timestamp"] + .as_str() + .ok_or_else(|| ProviderError("missing timestamp".into()))?; + u64::from_str_radix(ts_str.trim_start_matches("0x"), 16) + .map_err(|e| ProviderError(e.to_string())) + } +} + +// ── main ────────────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() { + const MAINNET_REGISTRY: [u8; 20] = [ + 0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b, + ]; + + let mut resolver = DIDEthr::new(); + resolver.add_network( + "mainnet", + NetworkConfig { + chain_id: 1, + registry: MAINNET_REGISTRY, + provider: HttpProvider { + client: reqwest::Client::new(), + url: "https://mainnet.gateway.tenderly.co".to_owned(), + }, + }, + ); + + let output = resolver + .resolve(did!("did:ethr:0xee9bddd4cdd24174f91949293f415bfad57cfa22")) + .await + .expect("resolution failed"); + + println!("{}", serde_json::to_string_pretty(&output.document).unwrap()); +} From b02b526c00b847e33863cab0a65a0d1f6e1fe65d Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 13 Mar 2026 14:29:13 +0100 Subject: [PATCH 29/36] feat: improve resolve_with_provider CLI usability and error handling - Accept DID and RPC URL as command-line arguments with sensible defaults - Enhance error messages for missing or malformed JSON fields - Refactor main logic into resolve_did for clarity and testability - Print input parameters and handle resolution errors gracefully --- examples/resolve_with_provider.rs | 100 +++++++++++++++++++----------- 1 file changed, 64 insertions(+), 36 deletions(-) diff --git a/examples/resolve_with_provider.rs b/examples/resolve_with_provider.rs index f639486c7..279b6f297 100644 --- a/examples/resolve_with_provider.rs +++ b/examples/resolve_with_provider.rs @@ -8,7 +8,7 @@ use did_ethr::{BlockRef, DIDEthr, EthProvider, Log, LogFilter, NetworkConfig}; use serde::{de::DeserializeOwned, Serialize}; -use ssi_dids_core::{did, DIDResolver}; +use ssi_dids_core::DIDResolver; // ── Hex helpers ────────────────────────────────────────────────────────────── @@ -33,27 +33,27 @@ impl HttpProvider { &self, method: &str, params: P, - ) -> Result { + ) -> Result { let body = serde_json::json!({ "jsonrpc": "2.0", "method": method, "params": params, "id": 1, }); - let resp: serde_json::Value = self + let mut resp: serde_json::Value = self .client .post(&self.url) .json(&body) .send() .await - .map_err(|e| e.to_string())? + .map_err(|e| ProviderError(e.to_string()))? .json() .await - .map_err(|e| e.to_string())?; + .map_err(|e| ProviderError(e.to_string()))?; if let Some(err) = resp.get("error") { - return Err(err.to_string()); + return Err(ProviderError(err.to_string())); } - serde_json::from_value(resp["result"].clone()).map_err(|e| e.to_string()) + serde_json::from_value(resp["result"].take()).map_err(|e| ProviderError(e.to_string())) } } @@ -89,8 +89,7 @@ impl EthProvider for HttpProvider { block_param, ]), ) - .await - .map_err(ProviderError)?; + .await?; hex_decode(&result).map_err(ProviderError) } @@ -114,8 +113,7 @@ impl EthProvider for HttpProvider { "toBlock": format!("0x{:x}", filter.to_block), }]), ) - .await - .map_err(ProviderError)?; + .await?; raw.into_iter() .map(|entry| { @@ -124,24 +122,28 @@ impl EthProvider for HttpProvider { .ok_or_else(|| ProviderError("missing topics".into()))? .iter() .map(|t| { - let bytes = hex_decode(t.as_str().unwrap_or("")) - .map_err(ProviderError)?; - bytes - .try_into() - .map_err(|_| ProviderError("topic not 32 bytes".into())) + let s = t.as_str().ok_or_else(|| ProviderError("topic not a string".into()))?; + let bytes = hex_decode(s).map_err(ProviderError)?; + bytes.try_into().map_err(|_| ProviderError("topic not 32 bytes".into())) }) .collect::, _>>()?; - let data = hex_decode(entry["data"].as_str().unwrap_or("0x")) - .map_err(ProviderError)?; + let data = hex_decode( + entry["data"].as_str().ok_or_else(|| ProviderError("missing data".into()))?, + ) + .map_err(ProviderError)?; - let addr_bytes = hex_decode(entry["address"].as_str().unwrap_or("0x")) - .map_err(ProviderError)?; + let addr_bytes = hex_decode( + entry["address"].as_str().ok_or_else(|| ProviderError("missing address".into()))?, + ) + .map_err(ProviderError)?; let address: [u8; 20] = addr_bytes .try_into() .map_err(|_| ProviderError("address not 20 bytes".into()))?; - let bn_str = entry["blockNumber"].as_str().unwrap_or("0x0"); + let bn_str = entry["blockNumber"] + .as_str() + .ok_or_else(|| ProviderError("missing blockNumber".into()))?; let block_number = u64::from_str_radix(bn_str.trim_start_matches("0x"), 16) .map_err(|e| ProviderError(e.to_string()))?; @@ -156,8 +158,7 @@ impl EthProvider for HttpProvider { "eth_getBlockByNumber", serde_json::json!([format!("0x{block:x}"), false]), ) - .await - .map_err(ProviderError)?; + .await?; let ts_str = result["timestamp"] .as_str() .ok_or_else(|| ProviderError("missing timestamp".into()))?; @@ -167,15 +168,23 @@ impl EthProvider for HttpProvider { } // ── main ────────────────────────────────────────────────────────────────────── - -#[tokio::main] -async fn main() { - const MAINNET_REGISTRY: [u8; 20] = [ - 0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, - 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, - 0x84, 0xfc, 0xf2, 0x1b, - ]; - +// +// Usage: resolve_with_provider [DID] [RPC_URL] +// +// Defaults: +// DID = did:ethr:0x3ec96eb0ca7e28bdda8345dba863ff62d3a0f603 +// RPC_URL = https://mainnet.gateway.tenderly.co + +const DEFAULT_DID: &str = "did:ethr:0x3ec96eb0ca7e28bdda8345dba863ff62d3a0f603"; +const DEFAULT_RPC: &str = "https://mainnet.gateway.tenderly.co"; + +const MAINNET_REGISTRY: [u8; 20] = [ + 0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, + 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, + 0x84, 0xfc, 0xf2, 0x1b, +]; + +async fn resolve_did(did_str: &str, rpc_url: &str) -> Result { let mut resolver = DIDEthr::new(); resolver.add_network( "mainnet", @@ -184,15 +193,34 @@ async fn main() { registry: MAINNET_REGISTRY, provider: HttpProvider { client: reqwest::Client::new(), - url: "https://mainnet.gateway.tenderly.co".to_owned(), + url: rpc_url.to_owned(), }, }, ); + let did = ssi_dids_core::DIDBuf::from_string(did_str.to_owned()) + .map_err(|e| format!("invalid DID: {e}"))?; let output = resolver - .resolve(did!("did:ethr:0xee9bddd4cdd24174f91949293f415bfad57cfa22")) + .resolve(&did) .await - .expect("resolution failed"); + .map_err(|e| format!("resolution failed: {e}"))?; + serde_json::to_value(&output.document).map_err(|e| e.to_string()) +} - println!("{}", serde_json::to_string_pretty(&output.document).unwrap()); +#[tokio::main] +async fn main() { + let args: Vec = std::env::args().collect(); + let did_str = args.get(1).map(String::as_str).unwrap_or(DEFAULT_DID); + let rpc_url = args.get(2).map(String::as_str).unwrap_or(DEFAULT_RPC); + + eprintln!("DID: {did_str}"); + eprintln!("RPC: {rpc_url}"); + + match resolve_did(did_str, rpc_url).await { + Ok(doc) => println!("{}", serde_json::to_string_pretty(&doc).unwrap()), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } } From fec12ab939d686b25ab68b372315ba0053a0201b Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 13 Mar 2026 17:01:29 +0100 Subject: [PATCH 30/36] feat: support registry address selection for did:ethr resolution; add network inference and defaults --- examples/resolve_with_provider.rs | 77 ++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/examples/resolve_with_provider.rs b/examples/resolve_with_provider.rs index 279b6f297..cbd031ac0 100644 --- a/examples/resolve_with_provider.rs +++ b/examples/resolve_with_provider.rs @@ -4,7 +4,7 @@ //! walks the ERC-1056 event chain, and prints the resolved DID document. //! //! Run with: -//! cargo run --example resolve_with_provider +//! cargo run --example resolve_with_provider -- [DID] [RPC_URL] [REGISTRY] use did_ethr::{BlockRef, DIDEthr, EthProvider, Log, LogFilter, NetworkConfig}; use serde::{de::DeserializeOwned, Serialize}; @@ -169,28 +169,64 @@ impl EthProvider for HttpProvider { // ── main ────────────────────────────────────────────────────────────────────── // -// Usage: resolve_with_provider [DID] [RPC_URL] +// Usage: resolve_with_provider [DID] [RPC_URL] [REGISTRY] // // Defaults: -// DID = did:ethr:0x3ec96eb0ca7e28bdda8345dba863ff62d3a0f603 -// RPC_URL = https://mainnet.gateway.tenderly.co +// DID = did:ethr:0x3ec96eb0ca7e28bdda8345dba863ff62d3a0f603 +// RPC_URL = https://mainnet.gateway.tenderly.co +// REGISTRY = 0xdca7ef03e98e0dc2b855be647c39abe984fcf21b (mainnet default) const DEFAULT_DID: &str = "did:ethr:0x3ec96eb0ca7e28bdda8345dba863ff62d3a0f603"; const DEFAULT_RPC: &str = "https://mainnet.gateway.tenderly.co"; +const DEFAULT_REGISTRY: &str = "0xdca7ef03e98e0dc2b855be647c39abe984fcf21b"; -const MAINNET_REGISTRY: [u8; 20] = [ - 0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, - 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, - 0x84, 0xfc, 0xf2, 0x1b, -]; +/// Well-known ERC-1056 registries per network name. +fn default_registry(network: &str) -> Option<&'static str> { + match network { + "mainnet" => Some("0xdca7ef03e98e0dc2b855be647c39abe984fcf21b"), + "sepolia" => Some("0x03d5003bf0e79C5F5223588F347ebA39AfbC3818"), + _ => None, + } +} + +/// Well-known chain IDs per network name. +fn chain_id_for(network: &str) -> u64 { + match network { + "mainnet" => 1, + "sepolia" => 11155111, + _ => 1, + } +} + +/// Extract the network name from a did:ethr DID string. +/// Returns "mainnet" if no network segment is present. +fn network_from_did(did_str: &str) -> &str { + // did:ethr::

or did:ethr:
+ let rest = did_str + .strip_prefix("did:ethr:") + .unwrap_or(did_str); + match rest.split_once(':') { + Some((network, _)) if !network.starts_with("0x") => network, + _ => "mainnet", + } +} + +fn parse_registry(hex_str: &str) -> Result<[u8; 20], String> { + let hex_str = hex_str.strip_prefix("0x").unwrap_or(hex_str); + let bytes = hex::decode(hex_str).map_err(|e| format!("bad registry hex: {e}"))?; + bytes.try_into().map_err(|_| "registry must be 20 bytes".into()) +} + +async fn resolve_did(did_str: &str, rpc_url: &str, registry_hex: &str) -> Result { + let network = network_from_did(did_str); + let registry = parse_registry(registry_hex)?; -async fn resolve_did(did_str: &str, rpc_url: &str) -> Result { let mut resolver = DIDEthr::new(); resolver.add_network( - "mainnet", + network, NetworkConfig { - chain_id: 1, - registry: MAINNET_REGISTRY, + chain_id: chain_id_for(network), + registry, provider: HttpProvider { client: reqwest::Client::new(), url: rpc_url.to_owned(), @@ -212,11 +248,20 @@ async fn main() { let args: Vec = std::env::args().collect(); let did_str = args.get(1).map(String::as_str).unwrap_or(DEFAULT_DID); let rpc_url = args.get(2).map(String::as_str).unwrap_or(DEFAULT_RPC); + let registry_arg = args.get(3).map(String::as_str); + + // Resolve registry: CLI arg > well-known default for network > global default + let network = network_from_did(did_str); + let registry_hex = registry_arg + .or_else(|| default_registry(network)) + .unwrap_or(DEFAULT_REGISTRY); - eprintln!("DID: {did_str}"); - eprintln!("RPC: {rpc_url}"); + eprintln!("DID: {did_str}"); + eprintln!("RPC: {rpc_url}"); + eprintln!("Network: {network}"); + eprintln!("Registry: {registry_hex}"); - match resolve_did(did_str, rpc_url).await { + match resolve_did(did_str, rpc_url, registry_hex).await { Ok(doc) => println!("{}", serde_json::to_string_pretty(&doc).unwrap()), Err(e) => { eprintln!("error: {e}"); From 61e544d90decfae5a764d9e482b0fabbd05a3d26 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 23 Mar 2026 12:03:14 +0100 Subject: [PATCH 31/36] fix: ensure historical resolution uses target block timestamp for expiry; improve uint256 decoding and multibase encoding - Fix delegate/attribute expiry logic to use the target block's timestamp, not meta_block's, for historical resolution - Make decode_uint256 return Result and handle overflows/short data - Update event decoding to propagate uint256 decode errors - Refactor account_address_hex to return Option and handle malformed keys - Add encode_multibase_multicodec for correct Ed25519/X25519 multibase encoding per spec - Add tests for historical expiry and multibase encoding - Update dependencies for ssi-multicodec and multibase --- crates/dids/methods/ethr/Cargo.toml | 2 + crates/dids/methods/ethr/src/abi.rs | 18 ++-- crates/dids/methods/ethr/src/events.rs | 12 +-- crates/dids/methods/ethr/src/network.rs | 23 ++--- crates/dids/methods/ethr/src/resolver.rs | 110 +++++++++++++++++++++-- 5 files changed, 126 insertions(+), 39 deletions(-) diff --git a/crates/dids/methods/ethr/Cargo.toml b/crates/dids/methods/ethr/Cargo.toml index a4f13a045..050363429 100644 --- a/crates/dids/methods/ethr/Cargo.toml +++ b/crates/dids/methods/ethr/Cargo.toml @@ -23,6 +23,8 @@ hex.workspace = true serde_json.workspace = true ssi-crypto = { workspace = true, features = ["keccak"] } bs58.workspace = true +ssi-multicodec.workspace = true +multibase.workspace = true chrono.workspace = true indexmap.workspace = true diff --git a/crates/dids/methods/ethr/src/abi.rs b/crates/dids/methods/ethr/src/abi.rs index 66eb24d69..72c74565f 100644 --- a/crates/dids/methods/ethr/src/abi.rs +++ b/crates/dids/methods/ethr/src/abi.rs @@ -1,5 +1,5 @@ -use ssi_crypto::hashes::keccak; use chrono::{DateTime, Utc}; +use ssi_crypto::hashes::keccak; // --- ERC-1056 ABI selectors --- @@ -24,15 +24,18 @@ pub(crate) fn encode_call(selector: [u8; 4], addr: &[u8; 20]) -> Vec { data } -/// Decode a 32-byte uint256 return value -pub(crate) fn decode_uint256(data: &[u8]) -> u64 { +/// Decode a 32-byte uint256 return value. +/// Returns an error if the value overflows u64 (high 24 bytes non-zero). +pub(crate) fn decode_uint256(data: &[u8]) -> Result { if data.len() < 32 { - return 0; + return Err("uint256 data too short"); + } + if data[..24].iter().any(|&b| b != 0) { + return Err("uint256 overflows u64"); } - // Read last 8 bytes as u64 (ERC-1056 changed() returns small block numbers) let mut bytes = [0u8; 8]; bytes.copy_from_slice(&data[24..32]); - u64::from_be_bytes(bytes) + Ok(u64::from_be_bytes(bytes)) } /// Decode a 32-byte ABI-encoded address return value @@ -53,7 +56,8 @@ pub(crate) fn format_address_eip55(addr: &[u8; 20]) -> String { /// Format a Unix timestamp (seconds since epoch) as ISO 8601 UTC string pub(crate) fn format_timestamp_iso8601(unix_secs: u64) -> String { - DateTime::::from_timestamp(unix_secs as i64, 0) + let secs = i64::try_from(unix_secs).ok(); + secs.and_then(|s| DateTime::::from_timestamp(s, 0)) .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()) } diff --git a/crates/dids/methods/ethr/src/events.rs b/crates/dids/methods/ethr/src/events.rs index 6f0b551ef..14ecc6700 100644 --- a/crates/dids/methods/ethr/src/events.rs +++ b/crates/dids/methods/ethr/src/events.rs @@ -78,7 +78,7 @@ pub(crate) fn parse_erc1056_event(log: &Log) -> Option { } let mut owner = [0u8; 20]; owner.copy_from_slice(&log.data[12..32]); - let previous_change = decode_uint256(&log.data[32..64]); + let previous_change = decode_uint256(&log.data[32..64]).ok()?; Some(Erc1056Event::OwnerChanged { identity, owner, @@ -93,8 +93,8 @@ pub(crate) fn parse_erc1056_event(log: &Log) -> Option { delegate_type.copy_from_slice(&log.data[0..32]); let mut delegate = [0u8; 20]; delegate.copy_from_slice(&log.data[44..64]); - let valid_to = decode_uint256(&log.data[64..96]); - let previous_change = decode_uint256(&log.data[96..128]); + let valid_to = decode_uint256(&log.data[64..96]).ok()?; + let previous_change = decode_uint256(&log.data[96..128]).ok()?; Some(Erc1056Event::DelegateChanged { identity, delegate_type, @@ -109,9 +109,9 @@ pub(crate) fn parse_erc1056_event(log: &Log) -> Option { } let mut name = [0u8; 32]; name.copy_from_slice(&log.data[0..32]); - let valid_to = decode_uint256(&log.data[64..96]); - let previous_change = decode_uint256(&log.data[96..128]); - let value_len = decode_uint256(&log.data[128..160]) as usize; + let valid_to = decode_uint256(&log.data[64..96]).ok()?; + let previous_change = decode_uint256(&log.data[96..128]).ok()?; + let value_len = decode_uint256(&log.data[128..160]).ok()? as usize; let value = if log.data.len() >= 160 + value_len { log.data[160..160 + value_len].to_vec() } else { diff --git a/crates/dids/methods/ethr/src/network.rs b/crates/dids/methods/ethr/src/network.rs index 48fd539bd..9a3b7624e 100644 --- a/crates/dids/methods/ethr/src/network.rs +++ b/crates/dids/methods/ethr/src/network.rs @@ -60,27 +60,16 @@ impl DecodedMethodSpecificId { /// Extract the Ethereum address hex string (with 0x prefix). /// For public-key DIDs, derives the address from the public key. - pub(crate) fn account_address_hex(&self) -> String { + /// Returns `None` if the public key is malformed or address derivation fails. + pub(crate) fn account_address_hex(&self) -> Option { if self.address_or_public_key.len() == 42 { - self.address_or_public_key.clone() + Some(self.address_or_public_key.clone()) } else { // Public key DID — derive the address let pk_hex = &self.address_or_public_key; - if !pk_hex.starts_with("0x") { - return String::new(); - } - let pk_bytes = match hex::decode(&pk_hex[2..]) { - Ok(b) => b, - Err(_) => return String::new(), - }; - let pk_jwk = match ssi_jwk::secp256k1_parse(&pk_bytes) { - Ok(j) => j, - Err(_) => return String::new(), - }; - match ssi_jwk::eip155::hash_public_key_eip55(&pk_jwk) { - Ok(addr) => addr, - Err(_) => String::new(), - } + let pk_bytes = hex::decode(pk_hex.strip_prefix("0x")?).ok()?; + let pk_jwk = ssi_jwk::secp256k1_parse(&pk_bytes).ok()?; + ssi_jwk::eip155::hash_public_key_eip55(&pk_jwk).ok() } } } diff --git a/crates/dids/methods/ethr/src/resolver.rs b/crates/dids/methods/ethr/src/resolver.rs index 5f5c6f1ab..e180d3106 100644 --- a/crates/dids/methods/ethr/src/resolver.rs +++ b/crates/dids/methods/ethr/src/resolver.rs @@ -81,7 +81,8 @@ impl DIDMethodResolver for DIDEthr

{ // Check if we have a provider for this network if let Some(config) = self.networks.get(decoded_id.network_name()) { - let addr_hex = decoded_id.account_address_hex(); + let addr_hex = decoded_id.account_address_hex() + .ok_or_else(|| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; if let Some(addr) = crate::network::parse_address_bytes(&addr_hex) { // Parse historical resolution target block from ?versionId=N let target_block: Option = options @@ -97,7 +98,8 @@ impl DIDMethodResolver for DIDEthr

{ .call(config.registry, calldata, BlockRef::Latest) .await .map_err(|e| Error::Internal(e.to_string()))?; - let changed_block = decode_uint256(&result); + let changed_block = decode_uint256(&result) + .map_err(|e| Error::Internal(e.to_string()))?; if changed_block > 0 { // Collect all events via linked-list walk @@ -212,9 +214,18 @@ impl DIDMethodResolver for DIDEthr

{ }; // Apply delegate/attribute events - // For historical resolution, use target block's timestamp as "now" - let now = if let Some(_) = target_block { - block_ts + // For historical resolution, use the actual target block's + // timestamp as "now" (not meta_block's, which may be earlier) + let now = if let Some(tb) = target_block { + if meta_block == tb { + block_ts + } else { + config + .provider + .block_timestamp(tb) + .await + .map_err(|e| Error::Internal(e.to_string()))? + } } else { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -256,6 +267,14 @@ impl DIDMethodResolver for DIDEthr<()> { } } +/// Encode raw key bytes as multibase(base58btc) with a multicodec varint prefix. +/// Matches the encoding expected by Ed25519VerificationKey2020 and +/// X25519KeyAgreementKey2020 verification method types per W3C spec. +fn encode_multibase_multicodec(codec: u64, key_bytes: &[u8]) -> String { + let encoded = ssi_multicodec::MultiEncodedBuf::encode_bytes(codec, key_bytes); + multibase::encode(multibase::Base::Base58Btc, encoded.as_bytes()) +} + /// Process ERC-1056 events and add delegate verification methods to the document. /// /// Uses a map-accumulation model: each delegate/attribute event is keyed by its @@ -368,7 +387,9 @@ pub(crate) fn apply_events( } } "Ed25519" => { - let multibase = format!("z{}", bs58::encode(value).into_string()); + let multibase = encode_multibase_multicodec( + ssi_multicodec::ED25519_PUB, value, + ); Some(PendingVmPayload::AttributeKey { vm_type: VerificationMethodType::Ed25519VerificationKey2020, prop_name: "publicKeyMultibase", @@ -376,7 +397,9 @@ pub(crate) fn apply_events( }) } "X25519" => { - let multibase = format!("z{}", bs58::encode(value).into_string()); + let multibase = encode_multibase_multicodec( + ssi_multicodec::X25519_PUB, value, + ); Some(PendingVmPayload::AttributeKey { vm_type: VerificationMethodType::X25519KeyAgreementKey2020, prop_name: "publicKeyMultibase", @@ -2642,7 +2665,7 @@ mod tests { vm["id"].as_str().unwrap().ends_with("#delegate-1") }).expect("should have #delegate-1 VM"); assert_eq!(attr_vm["type"], "Ed25519VerificationKey2020"); - let expected_multibase = format!("z{}", bs58::encode(&ed_key).into_string()); + let expected_multibase = encode_multibase_multicodec(ssi_multicodec::ED25519_PUB, &ed_key); assert_eq!(attr_vm["publicKeyMultibase"], expected_multibase); assert!(attr_vm.get("publicKeyJwk").is_none(), "should NOT have publicKeyJwk"); @@ -2704,7 +2727,7 @@ mod tests { vm["id"].as_str().unwrap().ends_with("#delegate-1") }).expect("should have #delegate-1 VM"); assert_eq!(attr_vm["type"], "X25519KeyAgreementKey2020"); - let expected_multibase = format!("z{}", bs58::encode(&x_key).into_string()); + let expected_multibase = encode_multibase_multicodec(ssi_multicodec::X25519_PUB, &x_key); assert_eq!(attr_vm["publicKeyMultibase"], expected_multibase); // X25519 enc purpose → keyAgreement (not assertionMethod or authentication) @@ -2726,4 +2749,73 @@ mod tests { assert!(ctx_obj.get("publicKeyMultibase").is_some(), "context should include publicKeyMultibase"); } + + #[tokio::test] + async fn historical_expiry_uses_target_block_timestamp_not_meta_block() { + // Bug regression: when target_block > meta_block, `now` must be the + // target block's timestamp, not meta_block's. A delegate whose + // valid_to falls between the two timestamps must be expired. + // + // Setup: + // Block 100 (timestamp 1000): delegate added, valid_to = 1500 + // Block 150 (timestamp 2000): no events, but this is the target + // + // meta_block = 100 (latest event at or before 150) + // Before fix: now = 1000 → 1500 >= 1000 → delegate included (WRONG) + // After fix: now = 2000 → 1500 < 2000 → delegate excluded (CORRECT) + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let delegate: [u8; 20] = [0xAA; 20]; + let delegate_type = encode_delegate_type("veriKey"); + + // Delegate valid_to = 1500, between block 100 ts (1000) and block 150 ts (2000) + let log = make_delegate_changed_log(100, &identity, &delegate_type, &delegate, 1500, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + identity_owner_at_block: HashMap::new(), + logs: HashMap::from([ + (100, vec![log]), + ]), + block_timestamps: HashMap::from([ + (100, 1000), // meta_block timestamp + (150, 2000), // target block timestamp + ]), + }, + }); + + let options = resolution::Options { + parameters: resolution::Parameters { + version_id: Some("150".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let output = resolver + .resolve_with( + did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"), + options, + ) + .await + .unwrap(); + + let doc_value = serde_json::to_value(&output.document).unwrap(); + let vms = doc_value["verificationMethod"].as_array().unwrap(); + + // Delegate valid_to (1500) < target block timestamp (2000) → expired + assert_eq!(vms.len(), 2, "delegate expired at target block should NOT be included"); + assert!( + vms.iter().all(|vm| !vm["id"].as_str().unwrap().contains("delegate")), + "no delegate VMs should be present — delegate expired before target block" + ); + + // Metadata should still use meta_block (100) for versionId/updated + assert_eq!(output.document_metadata.version_id.as_deref(), Some("100")); + } } From 64ccd32f792157ec9ef07767d2203cccf1429a45 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 23 Mar 2026 13:12:55 +0100 Subject: [PATCH 32/36] chore: remove unused bs58 dependency; add comment clarifying DEFAULT_DID in resolve_with_provider example --- crates/dids/methods/ethr/Cargo.toml | 1 - examples/resolve_with_provider.rs | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dids/methods/ethr/Cargo.toml b/crates/dids/methods/ethr/Cargo.toml index 050363429..5fe18c6a8 100644 --- a/crates/dids/methods/ethr/Cargo.toml +++ b/crates/dids/methods/ethr/Cargo.toml @@ -22,7 +22,6 @@ thiserror.workspace = true hex.workspace = true serde_json.workspace = true ssi-crypto = { workspace = true, features = ["keccak"] } -bs58.workspace = true ssi-multicodec.workspace = true multibase.workspace = true chrono.workspace = true diff --git a/examples/resolve_with_provider.rs b/examples/resolve_with_provider.rs index cbd031ac0..66e36c63d 100644 --- a/examples/resolve_with_provider.rs +++ b/examples/resolve_with_provider.rs @@ -176,6 +176,7 @@ impl EthProvider for HttpProvider { // RPC_URL = https://mainnet.gateway.tenderly.co // REGISTRY = 0xdca7ef03e98e0dc2b855be647c39abe984fcf21b (mainnet default) +// a DID that had an owner change. The blockchainAccountId of the #controller entry should be different than the address of the DID const DEFAULT_DID: &str = "did:ethr:0x3ec96eb0ca7e28bdda8345dba863ff62d3a0f603"; const DEFAULT_RPC: &str = "https://mainnet.gateway.tenderly.co"; const DEFAULT_REGISTRY: &str = "0xdca7ef03e98e0dc2b855be647c39abe984fcf21b"; From 5caf4db26e442402facd8e72cd97788270abc79e Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 23 Mar 2026 13:17:55 +0100 Subject: [PATCH 33/36] fix: preserve intra-block event order by tracking log_index in event collection - Add log_index to Log struct and propagate throughout event collection - Sort events by (block_number, log_index) to maintain correct intra-block ordering - Update tests to verify intra-block order is preserved --- crates/dids/methods/ethr/src/events.rs | 90 +++++++++++++++++++----- crates/dids/methods/ethr/src/provider.rs | 3 + crates/dids/methods/ethr/src/resolver.rs | 4 ++ examples/resolve_with_provider.rs | 7 +- 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/crates/dids/methods/ethr/src/events.rs b/crates/dids/methods/ethr/src/events.rs index 14ecc6700..c09ba28cc 100644 --- a/crates/dids/methods/ethr/src/events.rs +++ b/crates/dids/methods/ethr/src/events.rs @@ -151,7 +151,7 @@ pub(crate) async fn collect_events( topic_attribute_changed(), ]; - let mut events = Vec::new(); + let mut events: Vec<(u64, u64, Erc1056Event)> = Vec::new(); let mut current_block = changed_block; let mut visited = std::collections::HashSet::new(); @@ -176,7 +176,7 @@ pub(crate) async fn collect_events( let mut next_block = 0u64; for log in &logs { if let Some(event) = parse_erc1056_event(log) { - events.push((log.block_number, event.clone())); + events.push((log.block_number, log.log_index, event.clone())); let prev = event.previous_change(); // Only follow pointers that strictly retreat; ignore same-block // self-references (prev == current_block) which cause cycles. @@ -189,8 +189,11 @@ pub(crate) async fn collect_events( current_block = next_block; } - events.reverse(); // chronological order - Ok(events) + // Sort into chronological order by (block_number, log_index). + // This preserves intra-block ordering — critical when multiple events + // in the same block have order-dependent semantics (e.g. add then revoke). + events.sort_by_key(|(block, log_idx, _)| (*block, *log_idx)); + Ok(events.into_iter().map(|(block, _, event)| (block, event)).collect()) } #[cfg(test)] @@ -250,6 +253,7 @@ mod tests { topics: log.topics.clone(), data: log.data.clone(), block_number: log.block_number, + log_index: log.log_index, }); } } @@ -277,6 +281,7 @@ mod tests { topics: vec![topic_owner_changed(), identity_topic], data, block_number: block, + log_index: 0, } } @@ -303,6 +308,7 @@ mod tests { topics: vec![topic_attribute_changed(), identity_topic], data, block_number: block, + log_index: 0, } } @@ -325,6 +331,7 @@ mod tests { topics: vec![topic_delegate_changed(), identity_topic], data, block_number: block, + log_index: 0, } } @@ -467,8 +474,8 @@ mod tests { async fn collect_events_same_block_events_all_collected() { // Simulate the same-block cycle bug: // Block 100 has two events: - // - first event: previousChange=50 (normal retreat) - // - second event: previousChange=100 (self-reference due to changed[identity] + // - first event (log_index=0): previousChange=50 (normal retreat) + // - second event (log_index=1): previousChange=100 (self-reference due to changed[identity] // already updated to current_block in the same block) // Both events should be collected; next block is 50; loop terminates. let identity: [u8; 20] = [0xFF; 20]; @@ -478,11 +485,13 @@ mod tests { attr_name[..29].copy_from_slice(b"did/pub/Secp256k1/veriKey/hex"); // First event in block 100: previousChange=50 (normal) - let log_100_first = make_owner_changed_log(100, &identity, &new_owner, 50); + let mut log_100_first = make_owner_changed_log(100, &identity, &new_owner, 50); + log_100_first.log_index = 0; // Second event in block 100: previousChange=100 (self-reference / cycle) - let log_100_second = make_attribute_changed_log( + let mut log_100_second = make_attribute_changed_log( 100, &identity, &attr_name, b"\x04abc", u64::MAX, 100, ); + log_100_second.log_index = 1; // Event at block 50 (to ensure walk continues correctly) let owner_at_50: [u8; 20] = [0x22; 20]; let log_50 = make_owner_changed_log(50, &identity, &owner_at_50, 0); @@ -503,13 +512,62 @@ mod tests { // Chronological by block: block 50 first, then block 100 events assert!(matches!(&events[0], (50, _))); - // Both events from block 100 are present (order within same block is reversed by - // the final events.reverse()) - let block_100_events: Vec<_> = events.iter().filter(|(b, _)| *b == 100).collect(); - assert_eq!(block_100_events.len(), 2); - let has_owner = block_100_events.iter().any(|(_, e)| matches!(e, Erc1056Event::OwnerChanged { .. })); - let has_attr = block_100_events.iter().any(|(_, e)| matches!(e, Erc1056Event::AttributeChanged { .. })); - assert!(has_owner, "expected an OwnerChanged event at block 100"); - assert!(has_attr, "expected an AttributeChanged event at block 100"); + // Intra-block order preserved: OwnerChanged (log_index=0) before AttributeChanged (log_index=1) + assert!(matches!(&events[1], (100, Erc1056Event::OwnerChanged { .. })), + "expected OwnerChanged at index 1 (log_index=0)"); + assert!(matches!(&events[2], (100, Erc1056Event::AttributeChanged { .. })), + "expected AttributeChanged at index 2 (log_index=1)"); + } + + #[tokio::test] + async fn collect_events_preserves_intra_block_order() { + // Two events in the same block with distinct log_index values. + // After collect_events, they must appear in log_index order (not reversed). + let identity: [u8; 20] = [0xAA; 20]; + let delegate: [u8; 20] = [0xBB; 20]; + + let mut delegate_type = [0u8; 32]; + delegate_type[..7].copy_from_slice(b"veriKey"); + + let mut attr_name = [0u8; 32]; + attr_name[..29].copy_from_slice(b"did/pub/Secp256k1/veriKey/hex"); + + // log_index=0: add delegate + let mut log_add = make_delegate_changed_log( + 100, &identity, &delegate_type, &delegate, u64::MAX, 0, + ); + log_add.log_index = 0; + + // log_index=1: revoke delegate (valid_to=0) + let mut log_revoke = make_delegate_changed_log( + 100, &identity, &delegate_type, &delegate, 0, 100, + ); + log_revoke.log_index = 1; + + let provider = MockProvider { + logs: HashMap::from([(100, vec![log_add, log_revoke])]), + }; + + let events = collect_events(&provider, TEST_REGISTRY, &identity, 100) + .await + .unwrap(); + + assert_eq!(events.len(), 2); + + // First event: add (valid_to = MAX) + match &events[0] { + (100, Erc1056Event::DelegateChanged { valid_to, .. }) => { + assert_eq!(*valid_to, u64::MAX, "first event should be the add (valid_to=MAX)"); + } + other => panic!("expected DelegateChanged add at index 0, got {other:?}"), + } + + // Second event: revoke (valid_to = 0) + match &events[1] { + (100, Erc1056Event::DelegateChanged { valid_to, .. }) => { + assert_eq!(*valid_to, 0, "second event should be the revoke (valid_to=0)"); + } + other => panic!("expected DelegateChanged revoke at index 1, got {other:?}"), + } } } diff --git a/crates/dids/methods/ethr/src/provider.rs b/crates/dids/methods/ethr/src/provider.rs index eab26b685..6f497644b 100644 --- a/crates/dids/methods/ethr/src/provider.rs +++ b/crates/dids/methods/ethr/src/provider.rs @@ -22,6 +22,9 @@ pub struct Log { pub topics: Vec<[u8; 32]>, pub data: Vec, pub block_number: u64, + /// Position of the log within its block (from eth_getLogs `logIndex`). + /// Used to preserve intra-block event ordering. + pub log_index: u64, } /// Minimal async trait for Ethereum JSON-RPC interaction. diff --git a/crates/dids/methods/ethr/src/resolver.rs b/crates/dids/methods/ethr/src/resolver.rs index e180d3106..e7b481b05 100644 --- a/crates/dids/methods/ethr/src/resolver.rs +++ b/crates/dids/methods/ethr/src/resolver.rs @@ -882,6 +882,7 @@ mod tests { topics: log.topics.clone(), data: log.data.clone(), block_number: log.block_number, + log_index: log.log_index, }); } } @@ -919,6 +920,7 @@ mod tests { topics: vec![topic_owner_changed(), identity_topic], data, block_number: block, + log_index: 0, } } @@ -947,6 +949,7 @@ mod tests { topics: vec![topic_delegate_changed(), identity_topic], data, block_number: block, + log_index: 0, } } @@ -982,6 +985,7 @@ mod tests { topics: vec![topic_attribute_changed(), identity_topic], data, block_number: block, + log_index: 0, } } diff --git a/examples/resolve_with_provider.rs b/examples/resolve_with_provider.rs index 66e36c63d..0a46dd9e8 100644 --- a/examples/resolve_with_provider.rs +++ b/examples/resolve_with_provider.rs @@ -147,7 +147,12 @@ impl EthProvider for HttpProvider { let block_number = u64::from_str_radix(bn_str.trim_start_matches("0x"), 16) .map_err(|e| ProviderError(e.to_string()))?; - Ok(Log { address, topics, data, block_number }) + let log_index = entry["logIndex"] + .as_str() + .and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()) + .unwrap_or(0); + + Ok(Log { address, topics, data, block_number, log_index }) }) .collect() } From 879e9d6571ecc5d8d11be9a25b3e9417f6401d75 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 23 Mar 2026 13:35:54 +0100 Subject: [PATCH 34/36] fix: better handling of keyPurpose for `enc` keys --- crates/dids/methods/ethr/src/resolver.rs | 95 +++++++++++++++++------- crates/dids/methods/ethr/src/vm.rs | 20 ++++- 2 files changed, 86 insertions(+), 29 deletions(-) diff --git a/crates/dids/methods/ethr/src/resolver.rs b/crates/dids/methods/ethr/src/resolver.rs index e7b481b05..4c9cb7a05 100644 --- a/crates/dids/methods/ethr/src/resolver.rs +++ b/crates/dids/methods/ethr/src/resolver.rs @@ -22,8 +22,8 @@ use crate::json_ld_context::JsonLdContext; use crate::network::{DecodedMethodSpecificId, NetworkChain}; use crate::provider::{BlockRef, EthProvider, NetworkConfig}; use crate::vm::{ - decode_delegate_type, PendingService, PendingVm, PendingVmPayload, VerificationMethod, - VerificationMethodType, + decode_delegate_type, KeyPurpose, PendingService, PendingVm, PendingVmPayload, + VerificationMethod, VerificationMethodType, }; // --- DIDEthr --- @@ -313,12 +313,13 @@ pub(crate) fn apply_events( } => { let dt = decode_delegate_type(delegate_type); - let is_veri_key = dt == b"veriKey"; - let is_sig_auth = dt == b"sigAuth"; - - if !is_veri_key && !is_sig_auth { + let purpose = if dt == b"veriKey" { + KeyPurpose::VeriKey + } else if dt == b"sigAuth" { + KeyPurpose::SigAuth + } else { continue; - } + }; delegate_counter += 1; @@ -340,7 +341,7 @@ pub(crate) fn apply_events( vms.insert(key, PendingVm { counter: delegate_counter, payload: PendingVmPayload::Delegate { blockchain_account_id }, - is_sig_auth, + purpose, }); } else { vms.shift_remove(&key); @@ -364,7 +365,7 @@ pub(crate) fn apply_events( delegate_counter += 1; let algo = parts.get(2).copied().unwrap_or(""); - let purpose = parts.get(3).copied().unwrap_or(""); + let purpose_str = parts.get(3).copied().unwrap_or(""); // Content key: name[32] ++ value[..] let mut key = Vec::with_capacity(32 + value.len()); @@ -410,12 +411,15 @@ pub(crate) fn apply_events( }; if let Some(payload) = pending { - let is_enc = purpose == "enc"; - let is_sig_auth = purpose == "sigAuth"; + let purpose = match purpose_str { + "sigAuth" => KeyPurpose::SigAuth, + "enc" => KeyPurpose::Enc, + _ => KeyPurpose::VeriKey, + }; vms.insert(key, PendingVm { counter: delegate_counter, payload, - is_sig_auth: is_sig_auth || is_enc, + purpose, }); } } else { @@ -496,7 +500,7 @@ pub(crate) fn apply_events( .assertion_method .push(eip712_id_url.clone().into()); - if vm_entry.is_sig_auth { + if vm_entry.purpose == KeyPurpose::SigAuth { doc.verification_relationships .authentication .push(vm_id_url.into()); @@ -523,22 +527,15 @@ pub(crate) fn apply_events( doc.verification_method.push(vm); - // Determine purpose from the attribute name embedded in is_sig_auth. - // For attribute keys, is_sig_auth encodes whether the key goes into - // authentication/keyAgreement (true for sigAuth and enc purposes). - // - // We need to re-derive the purpose from the original attribute, but - // since we only store is_sig_auth, we use a simpler approach: - // - X25519 keys → keyAgreement - // - is_sig_auth && not X25519 → assertionMethod + authentication - // - else → assertionMethod only - match vm_type { - VerificationMethodType::X25519KeyAgreementKey2020 => { + // Route to the correct verification relationship based on + // the explicit purpose from the attribute name. + match vm_entry.purpose { + KeyPurpose::Enc => { doc.verification_relationships .key_agreement .push(vm_id_url.into()); } - _ if vm_entry.is_sig_auth => { + KeyPurpose::SigAuth => { doc.verification_relationships .assertion_method .push(vm_id_url.clone().into()); @@ -546,7 +543,7 @@ pub(crate) fn apply_events( .authentication .push(vm_id_url.into()); } - _ => { + KeyPurpose::VeriKey => { doc.verification_relationships .assertion_method .push(vm_id_url.into()); @@ -2754,6 +2751,52 @@ mod tests { "context should include publicKeyMultibase"); } + #[tokio::test] + async fn resolve_secp256k1_enc_attribute_goes_to_key_agreement() { + // did/pub/Secp256k1/enc/hex must route to keyAgreement, NOT + // authentication/assertionMethod. This was previously broken because + // `enc` purpose was folded into `is_sig_auth`. + let identity: [u8; 20] = [0xb9, 0xc5, 0x71, 0x40, 0x89, 0x47, 0x8a, 0x32, 0x7f, 0x09, + 0x19, 0x79, 0x87, 0xf1, 0x6f, 0x9e, 0x5d, 0x93, 0x6e, 0x8a]; + let attr_name = encode_attr_name("did/pub/Secp256k1/enc/hex"); + + let log = make_attribute_changed_log(100, &identity, &attr_name, &TEST_SECP256K1_COMPRESSED, u64::MAX, 0); + + let mut resolver = DIDEthr::new(); + resolver.add_network("mainnet", NetworkConfig { + chain_id: 1, + registry: TEST_REGISTRY, + provider: MockProvider { + changed_block: 100, + identity_owner: None, + logs: HashMap::from([(100, vec![log])]), + block_timestamps: HashMap::new(), + identity_owner_at_block: HashMap::new(), + }, + }); + + let doc = resolver + .resolve(did!("did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a")) + .await.unwrap().document; + + let doc_value = serde_json::to_value(&doc).unwrap(); + let did_prefix = "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a"; + + // Should be in keyAgreement + let key_agreement = doc_value["keyAgreement"].as_array() + .expect("should have keyAgreement array"); + assert!(key_agreement.iter().any(|v| v == &format!("{did_prefix}#delegate-1")), + "Secp256k1/enc should be in keyAgreement"); + + // Should NOT be in assertionMethod or authentication + let assertion = doc_value["assertionMethod"].as_array().unwrap(); + assert!(!assertion.iter().any(|v| v == &format!("{did_prefix}#delegate-1")), + "Secp256k1/enc should NOT be in assertionMethod"); + let auth = doc_value["authentication"].as_array().unwrap(); + assert!(!auth.iter().any(|v| v == &format!("{did_prefix}#delegate-1")), + "Secp256k1/enc should NOT be in authentication"); + } + #[tokio::test] async fn historical_expiry_uses_target_block_timestamp_not_meta_block() { // Bug regression: when target_block > meta_block, `now` must be the diff --git a/crates/dids/methods/ethr/src/vm.rs b/crates/dids/methods/ethr/src/vm.rs index 90f89370a..35521d14f 100644 --- a/crates/dids/methods/ethr/src/vm.rs +++ b/crates/dids/methods/ethr/src/vm.rs @@ -1,16 +1,30 @@ use iref::Iri; use ssi_caips::caip10::BlockchainAccountId; -use ssi_dids_core::{document::{self, DIDVerificationMethod}, DIDURLBuf, DIDBuf}; +use ssi_dids_core::{ + document::{self, DIDVerificationMethod}, + DIDBuf, DIDURLBuf, +}; use ssi_jwk::JWK; use static_iref::iri; +/// The purpose of a verification method, derived from the delegate type or +/// attribute name purpose field. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum KeyPurpose { + /// `veriKey` — assertionMethod only. + VeriKey, + /// `sigAuth` — assertionMethod + authentication. + SigAuth, + /// `enc` — keyAgreement. + Enc, +} + /// Intermediate representation for a verification method accumulated during /// event processing, before materialisation into the DID document. pub(crate) struct PendingVm { pub(crate) counter: u64, pub(crate) payload: PendingVmPayload, - /// For delegates: true if sigAuth. For attribute keys: true if sigAuth or enc purpose. - pub(crate) is_sig_auth: bool, + pub(crate) purpose: KeyPurpose, } pub(crate) enum PendingVmPayload { From 3143b7e11acae5653be8fccc627dd9cbf31aa136 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 23 Mar 2026 14:19:40 +0100 Subject: [PATCH 35/36] fix: return None for AttributeChanged events with truncated value; add test for incomplete value data --- crates/dids/methods/ethr/src/events.rs | 35 ++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/dids/methods/ethr/src/events.rs b/crates/dids/methods/ethr/src/events.rs index c09ba28cc..0efb957db 100644 --- a/crates/dids/methods/ethr/src/events.rs +++ b/crates/dids/methods/ethr/src/events.rs @@ -112,11 +112,10 @@ pub(crate) fn parse_erc1056_event(log: &Log) -> Option { let valid_to = decode_uint256(&log.data[64..96]).ok()?; let previous_change = decode_uint256(&log.data[96..128]).ok()?; let value_len = decode_uint256(&log.data[128..160]).ok()? as usize; - let value = if log.data.len() >= 160 + value_len { - log.data[160..160 + value_len].to_vec() - } else { - Vec::new() - }; + if log.data.len() < 160 + value_len { + return None; + } + let value = log.data[160..160 + value_len].to_vec(); Some(Erc1056Event::AttributeChanged { identity, name, @@ -570,4 +569,30 @@ mod tests { other => panic!("expected DelegateChanged revoke at index 1, got {other:?}"), } } + + #[test] + fn parse_attribute_changed_truncated_value_returns_none() { + let identity: [u8; 20] = [0xAA; 20]; + let identity_topic = abi_encode_address(&identity); + let mut name = [0u8; 32]; + name[..29].copy_from_slice(b"did/pub/Secp256k1/veriKey/hex"); + + // Build data with value_len=33 but only 10 bytes of actual value + let mut data = vec![0u8; 160 + 10]; + data[0..32].copy_from_slice(&name); + data[56..64].copy_from_slice(&160u64.to_be_bytes()); // offset + data[88..96].copy_from_slice(&u64::MAX.to_be_bytes()); // valid_to + data[120..128].copy_from_slice(&0u64.to_be_bytes()); // previous_change + data[152..160].copy_from_slice(&33u64.to_be_bytes()); // value_len (exceeds available) + + let log = Log { + address: TEST_REGISTRY, + topics: vec![topic_attribute_changed(), identity_topic], + data, + block_number: 1, + log_index: 0, + }; + + assert_eq!(parse_erc1056_event(&log), None, "truncated value should return None"); + } } From 1af150caef57cfbe7ec1c733fb54050669415def Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Mon, 23 Mar 2026 15:06:33 +0100 Subject: [PATCH 36/36] feat: validate provider chain ID against DID network; add chain_id to EthProvider trait and cache per network --- crates/dids/methods/ethr/src/events.rs | 4 ++ crates/dids/methods/ethr/src/provider.rs | 4 +- crates/dids/methods/ethr/src/resolver.rs | 63 ++++++++++-------------- examples/resolve_with_provider.rs | 18 +++---- 4 files changed, 41 insertions(+), 48 deletions(-) diff --git a/crates/dids/methods/ethr/src/events.rs b/crates/dids/methods/ethr/src/events.rs index 0efb957db..492befb3b 100644 --- a/crates/dids/methods/ethr/src/events.rs +++ b/crates/dids/methods/ethr/src/events.rs @@ -223,6 +223,10 @@ mod tests { impl EthProvider for MockProvider { type Error = MockProviderError; + async fn chain_id(&self) -> Result { + Ok(1) + } + async fn call( &self, _to: [u8; 20], diff --git a/crates/dids/methods/ethr/src/provider.rs b/crates/dids/methods/ethr/src/provider.rs index 6f497644b..661721e48 100644 --- a/crates/dids/methods/ethr/src/provider.rs +++ b/crates/dids/methods/ethr/src/provider.rs @@ -32,6 +32,9 @@ pub struct Log { pub trait EthProvider: Send + Sync { type Error: std::error::Error + Send + Sync + 'static; + /// eth_chainId — return the connected chain's numeric ID + fn chain_id(&self) -> impl std::future::Future> + Send; + /// eth_call — execute a read-only contract call fn call( &self, @@ -55,7 +58,6 @@ pub trait EthProvider: Send + Sync { /// Per-network Ethereum configuration pub struct NetworkConfig

{ - pub chain_id: u64, pub registry: [u8; 20], pub provider: P, } diff --git a/crates/dids/methods/ethr/src/resolver.rs b/crates/dids/methods/ethr/src/resolver.rs index 4c9cb7a05..e6806f533 100644 --- a/crates/dids/methods/ethr/src/resolver.rs +++ b/crates/dids/methods/ethr/src/resolver.rs @@ -11,6 +11,7 @@ use ssi_dids_core::{ DIDBuf, DIDMethod, DIDURLBuf, Document, }; use std::collections::HashMap; +use std::sync::OnceLock; use std::str::FromStr; use crate::abi::{ @@ -37,12 +38,14 @@ use crate::vm::{ /// for networks that have a configured provider. pub struct DIDEthr

{ networks: HashMap>, + chain_id_cache: HashMap>, } impl

Default for DIDEthr

{ fn default() -> Self { Self { networks: HashMap::new(), + chain_id_cache: HashMap::new(), } } } @@ -56,6 +59,7 @@ impl

DIDEthr

{ /// Add a named network configuration. pub fn add_network(&mut self, name: &str, config: NetworkConfig

) { self.networks.insert(name.to_owned(), config); + self.chain_id_cache.insert(name.to_owned(), OnceLock::new()); } } @@ -81,6 +85,24 @@ impl DIDMethodResolver for DIDEthr

{ // Check if we have a provider for this network if let Some(config) = self.networks.get(decoded_id.network_name()) { + // Validate that the provider's chain matches the DID's expected chain + let expected_chain = decoded_id.network_chain.id(); + let cache = self.chain_id_cache.get(decoded_id.network_name()).unwrap(); + let actual_chain = match cache.get() { + Some(&id) => id, + None => { + let id = config.provider.chain_id().await + .map_err(|e| Error::Internal(e.to_string()))?; + let _ = cache.set(id); + id + } + }; + if actual_chain != expected_chain { + return Err(Error::Internal(format!( + "chain ID mismatch: provider reports {actual_chain}, DID expects {expected_chain}" + ))); + } + let addr_hex = decoded_id.account_address_hex() .ok_or_else(|| Error::InvalidMethodSpecificId(method_specific_id.to_owned()))?; if let Some(addr) = crate::network::parse_address_bytes(&addr_hex) { @@ -807,6 +829,10 @@ mod tests { impl EthProvider for MockProvider { type Error = MockProviderError; + async fn chain_id(&self) -> Result { + Ok(1) + } + async fn call( &self, _to: [u8; 20], @@ -1024,7 +1050,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, 0x84, 0xfc, 0xf2, 0x1b], @@ -1065,7 +1090,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, 0x84, 0xfc, 0xf2, 0x1b], @@ -1128,7 +1152,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, 0x84, 0xfc, 0xf2, 0x1b], @@ -1194,7 +1217,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, 0x84, 0xfc, 0xf2, 0x1b], @@ -1233,7 +1255,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, 0x84, 0xfc, 0xf2, 0x1b], @@ -1284,7 +1305,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -1352,7 +1372,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -1401,7 +1420,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -1446,7 +1464,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 200, @@ -1502,7 +1519,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 200, @@ -1564,7 +1580,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 200, @@ -1610,7 +1625,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 200, @@ -1651,7 +1665,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 200, @@ -1694,7 +1707,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -1733,7 +1745,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -1772,7 +1783,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -1825,7 +1835,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 200, @@ -1886,7 +1895,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: [0xdc, 0xa7, 0xef, 0x03, 0xe9, 0x8e, 0x0d, 0xc2, 0xb8, 0x55, 0xbe, 0x64, 0x7c, 0x39, 0xab, 0xe9, 0x84, 0xfc, 0xf2, 0x1b], @@ -1945,7 +1953,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -1997,7 +2004,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 300, @@ -2033,7 +2039,6 @@ mod tests { // When the owner is non-null, deactivated should be None (not set). let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider::new_same_owner(), }); @@ -2053,7 +2058,6 @@ mod tests { // DID with no on-chain changes (changed=0) → no versionId/updated metadata let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider::new_unchanged(), }); @@ -2080,7 +2084,6 @@ mod tests { // Block 100 has timestamp 1705312200 = 2024-01-15T09:50:00Z let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -2117,7 +2120,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 200, @@ -2185,7 +2187,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -2241,7 +2242,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 200, @@ -2293,7 +2293,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -2343,7 +2342,6 @@ mod tests { resolver.add_network( "mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider::new_unchanged(), }, @@ -2401,7 +2399,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 200, @@ -2443,7 +2440,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 200, @@ -2489,7 +2485,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 300, @@ -2537,7 +2532,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -2595,7 +2589,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -2640,7 +2633,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -2702,7 +2694,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -2764,7 +2755,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, @@ -2820,7 +2810,6 @@ mod tests { let mut resolver = DIDEthr::new(); resolver.add_network("mainnet", NetworkConfig { - chain_id: 1, registry: TEST_REGISTRY, provider: MockProvider { changed_block: 100, diff --git a/examples/resolve_with_provider.rs b/examples/resolve_with_provider.rs index 0a46dd9e8..cc068728f 100644 --- a/examples/resolve_with_provider.rs +++ b/examples/resolve_with_provider.rs @@ -71,6 +71,14 @@ impl std::error::Error for ProviderError {} impl EthProvider for HttpProvider { type Error = ProviderError; + async fn chain_id(&self) -> Result { + let result: String = self + .rpc("eth_chainId", serde_json::json!([])) + .await?; + u64::from_str_radix(result.trim_start_matches("0x"), 16) + .map_err(|e| ProviderError(e.to_string())) + } + async fn call( &self, to: [u8; 20], @@ -195,15 +203,6 @@ fn default_registry(network: &str) -> Option<&'static str> { } } -/// Well-known chain IDs per network name. -fn chain_id_for(network: &str) -> u64 { - match network { - "mainnet" => 1, - "sepolia" => 11155111, - _ => 1, - } -} - /// Extract the network name from a did:ethr DID string. /// Returns "mainnet" if no network segment is present. fn network_from_did(did_str: &str) -> &str { @@ -231,7 +230,6 @@ async fn resolve_did(did_str: &str, rpc_url: &str, registry_hex: &str) -> Result resolver.add_network( network, NetworkConfig { - chain_id: chain_id_for(network), registry, provider: HttpProvider { client: reqwest::Client::new(),