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/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/core/src/document.rs b/crates/dids/core/src/document.rs index c756d8670..110408d55 100644 --- a/crates/dids/core/src/document.rs +++ b/crates/dids/core/src/document.rs @@ -170,10 +170,19 @@ 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, + #[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/Cargo.toml b/crates/dids/methods/ethr/Cargo.toml index 2f19f5b0c..5fe18c6a8 100644 --- a/crates/dids/methods/ethr/Cargo.toml +++ b/crates/dids/methods/ethr/Cargo.toml @@ -15,11 +15,17 @@ 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"] } +ssi-multicodec.workspace = true +multibase.workspace = true +chrono.workspace = true +indexmap.workspace = true [dev-dependencies] tokio = { version = "1.0", features = ["macros"] } diff --git a/crates/dids/methods/ethr/src/abi.rs b/crates/dids/methods/ethr/src/abi.rs new file mode 100644 index 000000000..72c74565f --- /dev/null +++ b/crates/dids/methods/ethr/src/abi.rs @@ -0,0 +1,65 @@ +use chrono::{DateTime, Utc}; +use ssi_crypto::hashes::keccak; + +// --- 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. +/// 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 Err("uint256 data too short"); + } + if data[..24].iter().any(|&b| b != 0) { + return Err("uint256 overflows u64"); + } + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&data[24..32]); + Ok(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 { + 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()) +} + +pub(crate) use keccak::keccak256; diff --git a/crates/dids/methods/ethr/src/events.rs b/crates/dids/methods/ethr/src/events.rs new file mode 100644 index 000000000..492befb3b --- /dev/null +++ b/crates/dids/methods/ethr/src/events.rs @@ -0,0 +1,602 @@ +use crate::abi::{abi_encode_address, decode_uint256, keccak256}; +use crate::provider::{EthProvider, Log, LogFilter}; + +// --- ERC-1056 event topic hashes --- + +pub(crate) fn topic_owner_changed() -> [u8; 32] { + 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] { + 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] { + 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 --- + +/// 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]).ok()?; + 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]).ok()?; + let previous_change = decode_uint256(&log.data[96..128]).ok()?; + 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]).ok()?; + let previous_change = decode_uint256(&log.data[96..128]).ok()?; + let value_len = decode_uint256(&log.data[128..160]).ok()? as usize; + if log.data.len() < 160 + value_len { + return None; + } + let value = log.data[160..160 + value_len].to_vec(); + 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<(u64, u64, Erc1056Event)> = 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, 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. + if prev < current_block { + next_block = next_block.max(prev); + } + } + } + + current_block = next_block; + } + + // 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)] +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 chain_id(&self) -> Result { + Ok(1) + } + + 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, + log_index: log.log_index, + }); + } + } + } + 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, + log_index: 0, + } + } + + 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, + log_index: 0, + } + } + + 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, + log_index: 0, + } + } + + #[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 (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]; + 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 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 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); + + 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, _))); + // 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:?}"), + } + } + + #[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"); + } +} diff --git a/crates/dids/methods/ethr/src/json_ld_context.rs b/crates/dids/methods/ethr/src/json_ld_context.rs index be536de29..6d3541a66 100644 --- a/crates/dids/methods/ethr/src/json_ld_context.rs +++ b/crates/dids/methods/ethr/src/json_ld_context.rs @@ -16,7 +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_jwk: bool, + public_key_multibase: bool, } impl JsonLdContext { @@ -28,27 +32,28 @@ 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 { + "publicKeyJwk" => self.public_key_jwk = true, + "publicKeyMultibase" => self.public_key_multibase = true, + _ => {} + } + } + 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; @@ -56,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 { @@ -70,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( @@ -80,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 { @@ -96,6 +115,18 @@ impl JsonLdContext { ); } + if self.public_key_multibase { + def.bindings.insert( + "publicKeyMultibase".into(), + TermDefinition::Simple( + iri!("https://w3id.org/security#publicKeyMultibase") + .to_owned() + .into(), + ) + .into(), + ); + } + if blockchain_account_id { def.bindings.insert( "blockchainAccountId".into(), diff --git a/crates/dids/methods/ethr/src/lib.rs b/crates/dids/methods/ethr/src/lib.rs index 62f9a4f7f..ab6858a10 100644 --- a/crates/dids/methods/ethr/src/lib.rs +++ b/crates/dids/methods/ethr/src/lib.rs @@ -1,374 +1,15 @@ -use iref::Iri; -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, DIDURL, -}; -use static_iref::iri; -use std::str::FromStr; - +mod abi; +mod events; mod json_ld_context; -use json_ld_context::JsonLdContext; -use ssi_jwk::JWK; - -/// did:ethr DID Method -/// -/// [Specification](https://github.com/decentralized-identity/ethr-did-resolver/) -pub struct DIDEthr; - -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 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(), - ), - }, - )); - - Ok(resolution::Output::new( - represented.to_bytes(), - document::Metadata::default(), - resolution::Metadata::from_content_type(Some(content_type.to_string())), - )) - } -} - -struct DecodedMethodSpecificId { - network_chain: NetworkChain, - address_or_public_key: String, -} - -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()?, - address_or_public_key, - }) - } -} - -#[derive(Debug, thiserror::Error)] -#[error("invalid network `{0}`")] -struct InvalidNetwork(String); - -enum NetworkChain { - Mainnet, - Morden, - Ropsten, - Rinkeby, - Georli, - Kovan, - Other(u64), -} - -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::Other(i) => *i, - } - } -} - -impl FromStr for NetworkChain { - type Err = InvalidNetwork; - - 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), - 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: String, -) -> Result { - 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 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_()); +mod network; +mod provider; +mod resolver; +mod vm; - 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, - }; - - let key_vm = VerificationMethod::EcdsaSecp256k1VerificationKey2019 { - id: DIDURLBuf::from_string(format!("{did}#controllerKey")).unwrap(), - controller: did.to_owned(), - public_key_jwk: pk_jwk, - }; - - json_ld_context.add_verification_method_type(vm.type_()); - json_ld_context.add_verification_method_type(key_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()]; - - 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, - } - } -} - -pub enum VerificationMethodType { - EcdsaSecp256k1VerificationKey2019, - EcdsaSecp256k1RecoveryMethod2020, - Eip712Method2021, -} - -impl VerificationMethodType { - pub fn name(&self) -> &'static str { - match self { - Self::EcdsaSecp256k1VerificationKey2019 => "EcdsaSecp256k1VerificationKey2019", - Self::EcdsaSecp256k1RecoveryMethod2020 => "EcdsaSecp256k1RecoveryMethod2020", - 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::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(), - }, - } - } -} +pub use network::NetworkChain; +pub use provider::{BlockRef, EthProvider, Log, LogFilter, NetworkConfig}; +pub use resolver::DIDEthr; +pub use vm::{VerificationMethod, VerificationMethodType}; #[cfg(test)] mod tests { @@ -408,7 +49,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 +93,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 +119,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 +238,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" )) @@ -607,4 +250,190 @@ mod tests { .unwrap() .is_ok()) } + + #[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()), + ..Default::default() + }; + 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()), + ..Default::default() + }; + 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"); + } + + // ── 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()); + } + + // ── 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 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" + ); + } } diff --git a/crates/dids/methods/ethr/src/network.rs b/crates/dids/methods/ethr/src/network.rs new file mode 100644 index 000000000..9a3b7624e --- /dev/null +++ b/crates/dids/methods/ethr/src/network.rs @@ -0,0 +1,109 @@ +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) -> &str { + &self.network_name + } + + /// Extract the Ethereum address hex string (with 0x prefix). + /// For public-key DIDs, derives the address from the public key. + /// 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 { + Some(self.address_or_public_key.clone()) + } else { + // Public key DID — derive the address + let pk_hex = &self.address_or_public_key; + 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() + } + } +} + +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..661721e48 --- /dev/null +++ b/crates/dids/methods/ethr/src/provider.rs @@ -0,0 +1,63 @@ +/// 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, + /// 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. +/// 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_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, + 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 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..e6806f533 --- /dev/null +++ b/crates/dids/methods/ethr/src/resolver.rs @@ -0,0 +1,2857 @@ +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::sync::OnceLock; +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, KeyPurpose, 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>, + chain_id_cache: HashMap>, +} + +impl

Default for DIDEthr

{ + fn default() -> Self { + Self { + networks: HashMap::new(), + chain_id_cache: 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); + self.chain_id_cache.insert(name.to_owned(), OnceLock::new()); + } +} + +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()))?; + + // 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) { + // 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, BlockRef::Latest) + .await + .map_err(|e| Error::Internal(e.to_string()))?; + 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 + 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 { + all_events.into_iter().partition(|(b, _)| *b <= tb) + } 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 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) + .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) + } +} + +/// 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 +/// 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 purpose = if dt == b"veriKey" { + KeyPurpose::VeriKey + } else if dt == b"sigAuth" { + KeyPurpose::SigAuth + } else { + 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 }, + purpose, + }); + } 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_str = 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 = encode_multibase_multicodec( + ssi_multicodec::ED25519_PUB, value, + ); + Some(PendingVmPayload::AttributeKey { + vm_type: VerificationMethodType::Ed25519VerificationKey2020, + prop_name: "publicKeyMultibase", + prop_value: serde_json::Value::String(multibase), + }) + } + "X25519" => { + let multibase = encode_multibase_multicodec( + ssi_multicodec::X25519_PUB, value, + ); + Some(PendingVmPayload::AttributeKey { + vm_type: VerificationMethodType::X25519KeyAgreementKey2020, + prop_name: "publicKeyMultibase", + prop_value: serde_json::Value::String(multibase), + }) + } + _ => None, + }; + + if let Some(payload) = pending { + let purpose = match purpose_str { + "sigAuth" => KeyPurpose::SigAuth, + "enc" => KeyPurpose::Enc, + _ => KeyPurpose::VeriKey, + }; + vms.insert(key, PendingVm { + counter: delegate_counter, + payload, + purpose, + }); + } + } 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.purpose == KeyPurpose::SigAuth { + 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); + + // 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()); + } + KeyPurpose::SigAuth => { + doc.verification_relationships + .assertion_method + .push(vm_id_url.clone().into()); + doc.verification_relationships + .authentication + .push(vm_id_url.into()); + } + KeyPurpose::VeriKey => { + 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 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.clone().into(), eip712_vm_id.clone().into()]; + doc.verification_relationships.authentication = + vec![vm_id.into(), eip712_vm_id.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 chain_id(&self) -> Result { + Ok(1) + } + + 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, + log_index: log.log_index, + }); + } + } + } + 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, + log_index: 0, + } + } + + /// 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, + log_index: 0, + } + } + + /// 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, + log_index: 0, + } + } + + /// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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"); + + // 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 { + 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 = encode_multibase_multicodec(ssi_multicodec::X25519_PUB, &x_key); + 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_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 { + 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 + // 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 { + 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")); + } +} diff --git a/crates/dids/methods/ethr/src/vm.rs b/crates/dids/methods/ethr/src/vm.rs new file mode 100644 index 000000000..35521d14f --- /dev/null +++ b/crates/dids/methods/ethr/src/vm.rs @@ -0,0 +1,181 @@ +use iref::Iri; +use ssi_caips::caip10::BlockchainAccountId; +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, + pub(crate) purpose: KeyPurpose, +} + +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(), + }, + } + } +} 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" ] } 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..cc068728f --- /dev/null +++ b/examples/resolve_with_provider.rs @@ -0,0 +1,275 @@ +//! 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 -- [DID] [RPC_URL] [REGISTRY] + +use did_ethr::{BlockRef, DIDEthr, EthProvider, Log, LogFilter, NetworkConfig}; +use serde::{de::DeserializeOwned, Serialize}; +use ssi_dids_core::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 mut resp: serde_json::Value = self + .client + .post(&self.url) + .json(&body) + .send() + .await + .map_err(|e| ProviderError(e.to_string()))? + .json() + .await + .map_err(|e| ProviderError(e.to_string()))?; + if let Some(err) = resp.get("error") { + return Err(ProviderError(err.to_string())); + } + serde_json::from_value(resp["result"].take()).map_err(|e| ProviderError(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 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], + 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?; + 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?; + + raw.into_iter() + .map(|entry| { + let topics = entry["topics"] + .as_array() + .ok_or_else(|| ProviderError("missing topics".into()))? + .iter() + .map(|t| { + 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().ok_or_else(|| ProviderError("missing data".into()))?, + ) + .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() + .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()))?; + + 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() + } + + 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?; + 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 ────────────────────────────────────────────────────────────────────── +// +// Usage: resolve_with_provider [DID] [RPC_URL] [REGISTRY] +// +// Defaults: +// DID = did:ethr:0x3ec96eb0ca7e28bdda8345dba863ff62d3a0f603 +// 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"; + +/// 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, + } +} + +/// 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)?; + + let mut resolver = DIDEthr::new(); + resolver.add_network( + network, + NetworkConfig { + registry, + provider: HttpProvider { + client: reqwest::Client::new(), + 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) + .await + .map_err(|e| format!("resolution failed: {e}"))?; + serde_json::to_value(&output.document).map_err(|e| e.to_string()) +} + +#[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); + 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!("Network: {network}"); + eprintln!("Registry: {registry_hex}"); + + 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}"); + std::process::exit(1); + } + } +}