From f4c810fea849505752c69a06b403a62aa3155b6c Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 22 May 2026 11:28:29 +0200 Subject: [PATCH 01/29] feat(ethr): scaffold projects + EthereumAddress (keccak/EIP-55) --- .gitignore | 4 +- Directory.Packages.props | 1 + netdid.sln | 45 ++ .../NetDid.Samples.DidEthr.csproj | 13 + .../Crypto/EthereumAddress.cs | 76 +++ .../NetDid.Method.Ethr.csproj | 27 + tasks/todo20260522-didethr.md | 553 ++++++++++++++++++ .../EthereumAddressTests.cs | 87 +++ .../NetDid.Method.Ethr.Tests.csproj | 21 + 9 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 samples/NetDid.Samples.DidEthr/NetDid.Samples.DidEthr.csproj create mode 100644 src/NetDid.Method.Ethr/Crypto/EthereumAddress.cs create mode 100644 src/NetDid.Method.Ethr/NetDid.Method.Ethr.csproj create mode 100644 tasks/todo20260522-didethr.md create mode 100644 tests/NetDid.Method.Ethr.Tests/EthereumAddressTests.cs create mode 100644 tests/NetDid.Method.Ethr.Tests/NetDid.Method.Ethr.Tests.csproj diff --git a/.gitignore b/.gitignore index 6c3a5e6..b3d33e3 100644 --- a/.gitignore +++ b/.gitignore @@ -548,4 +548,6 @@ MigrationBackup/ tmp/ temp/ -# End of https://www.toptal.com/developers/gitignore/api/visualstudio \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/visualstudio + +.idea/ diff --git a/Directory.Packages.props b/Directory.Packages.props index c6aef43..0dcfccc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/netdid.sln b/netdid.sln index 0b1b3e6..fd37dc6 100644 --- a/netdid.sln +++ b/netdid.sln @@ -39,6 +39,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetDid.Samples.DidWebVh", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetDid.Samples.DependencyInjection", "samples\NetDid.Samples.DependencyInjection\NetDid.Samples.DependencyInjection.csproj", "{E344076D-4424-4D01-83B6-020878AECF67}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetDid.Method.Ethr", "src\NetDid.Method.Ethr\NetDid.Method.Ethr.csproj", "{6E38AC63-F516-4860-8D17-959DC0479809}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetDid.Method.Ethr.Tests", "tests\NetDid.Method.Ethr.Tests\NetDid.Method.Ethr.Tests.csproj", "{AE6B1BAB-9D65-40D4-B670-526DF4C88A65}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetDid.Samples.DidEthr", "samples\NetDid.Samples.DidEthr\NetDid.Samples.DidEthr.csproj", "{7A6D2675-875F-49EE-9497-A15ADFE23A6A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -229,6 +235,42 @@ Global {E344076D-4424-4D01-83B6-020878AECF67}.Release|x64.Build.0 = Release|Any CPU {E344076D-4424-4D01-83B6-020878AECF67}.Release|x86.ActiveCfg = Release|Any CPU {E344076D-4424-4D01-83B6-020878AECF67}.Release|x86.Build.0 = Release|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Debug|x64.ActiveCfg = Debug|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Debug|x64.Build.0 = Debug|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Debug|x86.ActiveCfg = Debug|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Debug|x86.Build.0 = Debug|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Release|Any CPU.Build.0 = Release|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Release|x64.ActiveCfg = Release|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Release|x64.Build.0 = Release|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Release|x86.ActiveCfg = Release|Any CPU + {6E38AC63-F516-4860-8D17-959DC0479809}.Release|x86.Build.0 = Release|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Debug|x64.Build.0 = Debug|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Debug|x86.Build.0 = Debug|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Release|Any CPU.Build.0 = Release|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Release|x64.ActiveCfg = Release|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Release|x64.Build.0 = Release|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Release|x86.ActiveCfg = Release|Any CPU + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65}.Release|x86.Build.0 = Release|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Debug|x64.Build.0 = Debug|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Debug|x86.Build.0 = Debug|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Release|Any CPU.Build.0 = Release|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Release|x64.ActiveCfg = Release|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Release|x64.Build.0 = Release|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Release|x86.ActiveCfg = Release|Any CPU + {7A6D2675-875F-49EE-9497-A15ADFE23A6A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -249,5 +291,8 @@ Global {C29790DD-AD66-4F4B-B971-A98914788867} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {1D67618D-3797-4242-8483-82C63F68C076} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {E344076D-4424-4D01-83B6-020878AECF67} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {6E38AC63-F516-4860-8D17-959DC0479809} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {AE6B1BAB-9D65-40D4-B670-526DF4C88A65} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {7A6D2675-875F-49EE-9497-A15ADFE23A6A} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} EndGlobalSection EndGlobal diff --git a/samples/NetDid.Samples.DidEthr/NetDid.Samples.DidEthr.csproj b/samples/NetDid.Samples.DidEthr/NetDid.Samples.DidEthr.csproj new file mode 100644 index 0000000..e5ac7f3 --- /dev/null +++ b/samples/NetDid.Samples.DidEthr/NetDid.Samples.DidEthr.csproj @@ -0,0 +1,13 @@ + + + + Exe + false + + + + + + + + diff --git a/src/NetDid.Method.Ethr/Crypto/EthereumAddress.cs b/src/NetDid.Method.Ethr/Crypto/EthereumAddress.cs new file mode 100644 index 0000000..a480ddd --- /dev/null +++ b/src/NetDid.Method.Ethr/Crypto/EthereumAddress.cs @@ -0,0 +1,76 @@ +using System.Text; +using acryptohashnet; +using NBitcoin.Secp256k1; + +namespace NetDid.Method.Ethr.Crypto; + +/// +/// Derives Ethereum addresses from secp256k1 public keys and encodes them +/// using EIP-55 mixed-case checksum encoding. +/// +public static class EthereumAddress +{ + /// + /// Derives a checksummed Ethereum address (EIP-55) from a 33-byte + /// compressed secp256k1 public key. + /// + public static string FromCompressedPublicKey(byte[] compressed33) + { + ArgumentNullException.ThrowIfNull(compressed33); + if (compressed33.Length != 33) + throw new ArgumentException("Expected 33-byte compressed public key.", nameof(compressed33)); + + if (!ECPubKey.TryCreate(compressed33, null, out _, out var pubKey) || pubKey is null) + throw new ArgumentException("Invalid secp256k1 compressed public key.", nameof(compressed33)); + + // Uncompress: 65-byte [04 | X(32) | Y(32)] + Span uncompressed = stackalloc byte[65]; + pubKey.WriteToSpan(compressed: false, uncompressed, out _); + + // Keccak-256 of the 64-byte public key body (skip 0x04 prefix) + var keccak = new Keccak256(); + var hash = keccak.ComputeHash(uncompressed[1..].ToArray()); + + // Take last 20 bytes as address + var address20 = hash[^20..]; + return ToChecksumAddress(address20); + } + + /// + /// Encodes a 20-byte Ethereum address as an EIP-55 checksummed hex string + /// with "0x" prefix. + /// + public static string ToChecksumAddress(byte[] address20) + { + ArgumentNullException.ThrowIfNull(address20); + if (address20.Length != 20) + throw new ArgumentException("Expected 20-byte address.", nameof(address20)); + + var lowercaseHex = Convert.ToHexString(address20).ToLowerInvariant(); + + // Keccak-256 of the lowercase hex string (ASCII bytes) + var keccak = new Keccak256(); + var hash = keccak.ComputeHash(Encoding.ASCII.GetBytes(lowercaseHex)); + + // For each char at position i: uppercase if the corresponding nibble of hash >= 8 + var result = new char[lowercaseHex.Length]; + for (int i = 0; i < lowercaseHex.Length; i++) + { + var c = lowercaseHex[i]; + if (char.IsLetter(c)) + { + // nibble index within hash bytes: high nibble = i/2*8+4..7, low nibble = i/2*8+0..3 + var nibble = (i % 2 == 0) + ? (hash[i / 2] >> 4) & 0xF + : hash[i / 2] & 0xF; + result[i] = nibble >= 8 ? char.ToUpperInvariant(c) : c; + } + else + { + result[i] = c; + } + } + + return "0x" + new string(result); + } +} diff --git a/src/NetDid.Method.Ethr/NetDid.Method.Ethr.csproj b/src/NetDid.Method.Ethr/NetDid.Method.Ethr.csproj new file mode 100644 index 0000000..894e4dd --- /dev/null +++ b/src/NetDid.Method.Ethr/NetDid.Method.Ethr.csproj @@ -0,0 +1,27 @@ + + + + NetDid.Method.Ethr + $(NetDidVersion) + did:ethr method implementation for the NetDid multi-method DID library. + + + + + + + + + + + + + + + + + + + + + diff --git a/tasks/todo20260522-didethr.md b/tasks/todo20260522-didethr.md new file mode 100644 index 0000000..4a87784 --- /dev/null +++ b/tasks/todo20260522-didethr.md @@ -0,0 +1,553 @@ +# Plan: did:ethr Implementation (Phase 1 — Create + Resolve) + +## Status: Ready to implement + +--- + +## Scope + +Phase 1 delivers `Create` and `Resolve` for the `did:ethr` method. No on-chain write +operations (Update / Deactivate require RLP encoding + EIP-155 transaction signing — +deferred to Phase 2). + +Capabilities advertised: `Create | Resolve | ServiceEndpoints` + +--- + +## Dependency changes + +### `Directory.Packages.props` +Add under ``: +```xml + +``` + +**Why `acryptohashnet`:** Zero dependencies on net10, 104 KB, pure C#, inherits +`System.Security.Cryptography.HashAlgorithm`, and correctly implements Ethereum +Keccak-256 (padding byte `0x01`) — not NIST SHA3-256. Confirmed correct against +known Ethereum test vectors. + +### `netdid.sln` +Add three new projects: +- `src\NetDid.Method.Ethr\NetDid.Method.Ethr.csproj` +- `tests\NetDid.Method.Ethr.Tests\NetDid.Method.Ethr.Tests.csproj` +- `samples\NetDid.Samples.DidEthr\NetDid.Samples.DidEthr.csproj` + +--- + +## New project: `src/NetDid.Method.Ethr/` + +### `NetDid.Method.Ethr.csproj` +```xml +NetDid.Method.Ethr +``` +Project references: `NetDid.Core` +Package references: `acryptohashnet`, `Microsoft.Extensions.Logging.Abstractions`, +`Microsoft.Extensions.Http` + +### File tree + +``` +src/NetDid.Method.Ethr/ +├── NetDid.Method.Ethr.csproj +├── DidEthrMethod.cs +├── DidEthrCreateOptions.cs +├── DidEthrResolveOptions.cs +├── DidEthrUpdateOptions.cs (stub — throws OperationNotSupportedException) +├── DidEthrDeactivateOptions.cs (stub — throws OperationNotSupportedException) +│ +├── Rpc/ +│ ├── IEthereumRpcClient.cs +│ ├── DefaultEthereumRpcClient.cs +│ ├── EthereumNetworkConfig.cs +│ ├── EthereumLogEntry.cs +│ └── EthereumLogFilter.cs +│ +├── Abi/ +│ ├── AbiEncoder.cs +│ └── AbiDecoder.cs +│ +├── Erc1056/ +│ ├── Erc1056Calls.cs +│ ├── Erc1056Topics.cs +│ ├── Erc1056EventParser.cs +│ └── Erc1056Events.cs +│ +├── Crypto/ +│ ├── EthereumAddress.cs +│ └── EthereumIdentifier.cs +│ +└── Resolution/ + └── EthrDocumentBuilder.cs +``` + +--- + +## File responsibilities + +### `DidEthrCreateOptions.cs` +```csharp +public sealed record DidEthrCreateOptions : DidCreateOptions +{ + public override string MethodName => "ethr"; + public required string Network { get; init; } // "mainnet", "sepolia", "0xaa36a7", etc. + public ISigner? ExistingKey { get; init; } // must be Secp256k1 if provided +} +``` + +### `DidEthrResolveOptions.cs` +```csharp +public sealed record DidEthrResolveOptions : DidResolutionOptions; +// VersionId (block number string) and VersionTime (ISO-8601) inherited from base. +// VersionId → resolve document state at that block; VersionTime → walk collected +// event history and trim to events whose block timestamp ≤ VersionTime. +``` + +### `DidEthrUpdateOptions.cs` +Carries the full PRD §8.6 property shape so Phase 2 is purely filling in `UpdateCoreAsync` +without a breaking API change. Phase 1 body: `throw new OperationNotSupportedException("ethr", "Update")`. +```csharp +public sealed record DidEthrUpdateOptions : DidUpdateOptions +{ + public IReadOnlyList? AddServices { get; init; } + public IReadOnlyList? RemoveServices { get; init; } + public IReadOnlyList? AddDelegates { get; init; } + public IReadOnlyList? RevokeDelegates { get; init; } + public string? NewOwnerAddress { get; init; } + public required ISigner ControllerKey { get; init; } + public bool UseMetaTransaction { get; init; } = false; +} + +public sealed record DidEthrDelegate +{ + public required string DelegateType { get; init; } // e.g. "veriKey", "sigAuth" + public required string DelegateAddress { get; init; } + public required TimeSpan Validity { get; init; } +} + +public sealed record DidEthrServiceAttribute +{ + public required string ServiceType { get; init; } + public required string ServiceEndpoint { get; init; } + public TimeSpan Validity { get; init; } = TimeSpan.FromDays(365 * 10); +} +``` + +### `DidEthrDeactivateOptions.cs` +Carries the full PRD §8.7 property shape. Phase 1 body: `throw new OperationNotSupportedException("ethr", "Deactivate")`. +```csharp +public sealed record DidEthrDeactivateOptions : DidDeactivateOptions +{ + public required ISigner ControllerKey { get; init; } + public bool UseMetaTransaction { get; init; } = false; +} +``` + +### `Rpc/IEthereumRpcClient.cs` +Matches PRD §8.8 exactly. `GetBlockTimestampAsync` is a deliberate extension to the PRD +interface needed for `VersionId`/`VersionTime` resolution. Phase 2 methods +(`SendRawTransactionAsync`, `GetTransactionCountAsync`, `GetGasPriceAsync`) are declared +now so the interface is complete; `DefaultEthereumRpcClient` throws `NotImplementedException` +for them in Phase 1. +```csharp +// Phase 1 — used by Create + Resolve +Task CallAsync(string to, string data, CancellationToken ct); +Task> GetLogsAsync(EthereumLogFilter filter, CancellationToken ct); +Task GetBlockNumberAsync(CancellationToken ct); +Task GetChainIdAsync(CancellationToken ct); // returns ulong per PRD §8.8 + +// Deliberate extension to PRD — required for VersionId and VersionTime resolution: +Task GetBlockTimestampAsync(ulong blockNumber, CancellationToken ct); + +// Phase 2 — declared now, throw NotImplementedException in DefaultEthereumRpcClient +Task SendRawTransactionAsync(byte[] signedTransaction, CancellationToken ct); +Task GetTransactionCountAsync(string address, CancellationToken ct); +Task GetGasPriceAsync(CancellationToken ct); +``` + +### `Rpc/DefaultEthereumRpcClient.cs` +- `HttpClient` + `System.Text.Json` +- JSON-RPC 2.0 envelope: `{"jsonrpc":"2.0","method":"...","params":[...],"id":1}` +- Constructed with a single `EthereumNetworkConfig` (RPC URL) +- Throws `EthereumInteractionException` on HTTP errors or JSON-RPC error responses + +### `Rpc/EthereumNetworkConfig.cs` +```csharp +public sealed record EthereumNetworkConfig +{ + public required string Name { get; init; } // "mainnet", "sepolia", etc. + public required string RpcUrl { get; init; } + public string? ChainId { get; init; } // hex string e.g. "0x1"; auto-detected if null + public string RegistryAddress { get; init; } = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B"; +} +``` + +### `Rpc/EthereumLogEntry.cs` +```csharp +public sealed record EthereumLogEntry +{ + public required string Address { get; init; } + public required IReadOnlyList Topics { get; init; } // hex strings, topics[0] = event sig + public required string Data { get; init; } // hex string + public required string BlockNumber { get; init; } // hex string + public string? TransactionHash { get; init; } +} +``` + +### `Rpc/EthereumLogFilter.cs` +```csharp +public sealed record EthereumLogFilter +{ + public required string Address { get; init; } // contract address + public required ulong FromBlock { get; init; } + public required ulong ToBlock { get; init; } + public IReadOnlyList? Topics { get; init; } // topics[0] = OR-list of event sigs +} +``` + +### `Abi/AbiEncoder.cs` +Encodes calldata for the two read-only ERC-1056 calls: + +- `changed(address identity)` → selector `0x4b0bebeb` + address zero-padded to 32 bytes +- `identityOwner(address identity)` → selector `0x8733d4e8` + address zero-padded to 32 bytes + +Function selectors are computed once at class init via Keccak256 of the canonical +signature string (`acryptohashnet`), then cached as constants. + +### `Abi/AbiDecoder.cs` +Decodes `eth_call` return values and event `data` fields. + +Supports: `address` (32-byte, take last 20), `uint256` (32-byte big-endian), `bytes32` +(32-byte, trim trailing nulls for string interpretation), and `bytes` (dynamic: follows +ABI offset pointer, reads length prefix, then raw bytes). + +All three ERC-1056 event data layouts: + +| Event | data layout | +|---|---| +| `DIDOwnerChanged` | `owner/32` + `previousChange/32` | +| `DIDDelegateChanged` | `delegateType/32` + `delegate/32` + `validTo/32` + `previousChange/32` | +| `DIDAttributeChanged` | `name/32` + `valueOffset/32` + `validTo/32` + `previousChange/32` + `valueLength/32` + `valueBytes/padded` | + +### `Erc1056/Erc1056Topics.cs` +Three `static readonly string` topic constants (hex), computed once at startup: +``` +keccak256("DIDOwnerChanged(address,address,uint256)") +keccak256("DIDDelegateChanged(address,bytes32,address,uint256,uint256)") +keccak256("DIDAttributeChanged(address,bytes32,bytes,uint256,uint256)") +``` + +### `Erc1056/Erc1056Calls.cs` +Static helpers that return hex calldata strings for `changed(address)` and +`identityOwner(address)` using `AbiEncoder`. + +### `Erc1056/Erc1056Events.cs` +Typed event structs (records): +```csharp +record OwnerChangedEvent(string Identity, string NewOwner, ulong PreviousChange, ulong BlockNumber); +record DelegateChangedEvent(string Identity, string DelegateType, string Delegate, + ulong ValidTo, ulong PreviousChange, ulong BlockNumber); +record AttributeChangedEvent(string Identity, string Name, byte[] Value, + ulong ValidTo, ulong PreviousChange, ulong BlockNumber); +``` +All addresses stored as lowercase hex with `0x` prefix. `BlockNumber` carried through +for `DocumentMetadata.VersionId` population. + +### `Erc1056/Erc1056EventParser.cs` +`EthereumLogEntry → Erc1056Event`. Dispatches on `topics[0]` against the three +`Erc1056Topics` constants. Identity address extracted from `topics[1]`. Remaining +fields decoded from `data` via `AbiDecoder`. + +### `Crypto/EthereumAddress.cs` +Two static methods: + +**`FromCompressedPublicKey(byte[] compressed33) → string`** +1. `ECPubKey.TryCreate(compressed)` (NBitcoin.Secp256k1) +2. `pubKey.WriteToSpan(compressed: false, buf65, out _)` → uncompressed +3. `new Keccak256().ComputeHash(buf65[1..])` → 32-byte hash (acryptohashnet) +4. Take last 20 bytes → `ToChecksumAddress` + +**`ToChecksumAddress(byte[] address20) → string`** +1. Lowercase hex string (no `0x` prefix) +2. `new Keccak256().ComputeHash(Encoding.ASCII.GetBytes(lowercaseHex))` → hash +3. For each hex char at position i: uppercase if `hash[i/2]` nibble (high or low) ≥ 8 +4. Prepend `0x` + +### `Crypto/EthereumIdentifier.cs` +Parses the method-specific-id portion of a `did:ethr` DID: + +``` +Format: [network ":"] (ethereum-address | compressed-public-key) + ethereum-address = "0x" 40*HEXDIG (20 bytes) + public-key-hex = "0x" 66*HEXDIG (33 bytes compressed) + network = "mainnet" | "goerli" | "sepolia" | "0x" *HEXDIG +``` + +Returns a parsed record: +```csharp +record EthrIdentifier( + string Network, // resolved name or chain ID hex + string IdentityAddress, // always the 20-byte Ethereum address (derived if pubkey input) + bool IsPublicKey, + byte[]? PublicKeyBytes // only set when IsPublicKey == true +) +``` + +Named network → chain ID mapping (used for `blockchainAccountId` CAIP-10 format): +- `mainnet` / no network → `1` +- `sepolia` → `11155111` +- `goerli` → `5` +- `polygon` → `137` +- Hex chain ID → parsed as-is + +### `Resolution/EthrDocumentBuilder.cs` +Takes: +- `string did` +- `EthrIdentifier identifier` (network, address, isPublicKey, pubKeyBytes) +- `string chainId` (numeric string, for CAIP-10 `eip155:{chainId}:{address}`) +- `IReadOnlyList events` (oldest → newest) +- `DateTimeOffset referenceTime` (current time, or block timestamp when `versionId` used) +- `bool isDeactivated` + +**Algorithm:** + +``` +eventCounter = 0 +currentOwner = identityAddress +delegates = {} // eventCounter → DelegateEntry +attributes = {} // eventCounter → AttributeEntry (pub keys) +services = {} // eventCounter → ServiceEntry + +for each event (oldest first): + eventCounter++ + if OwnerChanged: + currentOwner = event.NewOwner + if DelegateChanged: + delegates[eventCounter] = { event.Delegate, event.DelegateType, event.ValidTo } + if AttributeChanged and name starts with "did/pub/": + attributes[eventCounter] = { event.Name, event.Value, event.ValidTo } + if AttributeChanged and name starts with "did/svc/": + services[eventCounter] = { serviceName, event.Value, event.ValidTo } + +filter delegates where validTo >= referenceTime (unix seconds) +filter attributes where validTo >= referenceTime +filter services where validTo >= referenceTime + +if currentOwner == "0x0000000000000000000000000000000000000000": + return deactivated document + +build verificationMethods[]: + always add #controller: + id = "{did}#controller" + type = "EcdsaSecp256k1RecoveryMethod2020" + controller = did + blockchainAccountId = "eip155:{chainId}:{checksumOwner}" + + if isPublicKey AND currentOwner == derivedAddress: + add #controllerKey: + id = "{did}#controllerKey" + type = "EcdsaSecp256k1VerificationKey2019" + publicKeyJwk = JwkConverter.ToPublicJwk(Secp256k1, pubKeyBytes) + + for each valid delegate (index i): + add #delegate-{i}: EcdsaSecp256k1RecoveryMethod2020, blockchainAccountId = delegate address + + for each valid attribute (index i): + parse "did/pub/{algorithm}/{purpose}/{encoding?}" + add #delegate-{i} with type/encoding per key algorithm table (see below) + +build authentication[] = [#controller ref] + [#controllerKey ref if present] + + [#delegate-{i} refs where delegateType == "sigAuth"] + + [#delegate-{i} refs for sigAuth attributes] + +build assertionMethod[] = [#controller ref] + [#controllerKey ref if present] + + [#delegate-{i} refs where delegateType == "veriKey"] + + [#delegate-{i} refs for veriKey attributes] + +build keyAgreement[] = [#delegate-{i} refs for enc attributes] + +build service[] = [#service-{i} entries from valid services] + +build @context dynamically: + always: "https://www.w3.org/ns/did/v1" + "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + if EcdsaSecp256k1VerificationKey2019 present: + "https://w3id.org/security/v2" + {"publicKeyJwk": {"@id": "https://w3id.org/security#publicKeyJwk", "@type": "@json"}} + if Ed25519VerificationKey2020: "https://w3id.org/security/suites/ed25519-2020/v1" + if X25519KeyAgreementKey2020: "https://w3id.org/security/suites/x25519-2020/v1" + if Multikey: "https://w3id.org/security/multikey/v1" + if publicKeyHex (unknown type): {"publicKeyHex": "https://w3id.org/security#publicKeyHex"} +``` + +**Key algorithm → VM type mapping** (from spec): + +| Attribute name segment | VM Type | Key encoding | +|---|---|---| +| `Secp256k1` | `EcdsaSecp256k1VerificationKey2019` | `publicKeyJwk` (EC, crv: secp256k1) via existing `JwkConverter` | +| `Ed25519` | `Ed25519VerificationKey2020` | `publicKeyMultibase` — prepend `0xed01`, base58btc | +| `X25519` | `X25519KeyAgreementKey2020` | `publicKeyMultibase` — prepend `0xec01`, base58btc | +| `Multikey` | `Multikey` | `publicKeyMultibase` — value already has multicodec prefix, base58btc | +| unknown | verbatim type string | `publicKeyHex` (raw hex, encoding hint from attribute name) | + +Note: `Ed25519` and `X25519` multibase encoding reuses the existing `NetCid.Multicodec` ++ `NetCid.Multibase` utilities already used throughout the project. + +### `DidEthrMethod.cs` + +```csharp +public sealed class DidEthrMethod : DidMethodBase +{ + private readonly IEthereumRpcClient _rpc; + private readonly IReadOnlyList _networks; + private readonly IKeyGenerator _keyGenerator; + private readonly ILogger _logger; + + public DidEthrMethod( + IEthereumRpcClient rpc, + IEnumerable networks, + IKeyGenerator keyGenerator, + ILogger? logger = null) + { + _rpc = rpc; + _networks = networks.ToList(); + _keyGenerator = keyGenerator; + _logger = logger ?? NullLogger.Instance; + } + + public override string MethodName => "ethr"; + public override DidMethodCapabilities Capabilities => + DidMethodCapabilities.Create | + DidMethodCapabilities.Resolve | + DidMethodCapabilities.ServiceEndpoints; +} +``` + +**`CreateCoreAsync`:** +1. Validate options type → `DidEthrCreateOptions` +2. If `ExistingKey` provided: validate `KeyType == Secp256k1`; use `ExistingKey.PublicKey` +3. Else: `_keyGenerator.Generate(KeyType.Secp256k1)` +4. `EthereumAddress.FromCompressedPublicKey(pubKey)` → address +5. Find network config; resolve `chainId` (from config, or `GetChainIdAsync` if not set) +6. DID = `did:ethr:{network}:{checksumAddress}` +7. Build default document (no event history): single `#controller` VM +8. Return `DidCreateResult` + +**`ResolveCoreAsync`:** +1. Parse DID → `EthrIdentifier` (network, address, isPublicKey, pubKeyBytes) +2. Find matching `EthereumNetworkConfig` by name or chain ID +3. Determine `versionBlockNumber` from `options.VersionId` (if set) +4. `eth_call changed(identityAddress)` → `latestChangeBlock` +5. If `latestChangeBlock == 0` AND no versionId: build and return default document +6. Walk the event chain backwards from `min(latestChangeBlock, versionBlockNumber)`: + - Fetch logs at block for all 3 event topic signatures, filtered to `identityAddress` + - Parse events via `Erc1056EventParser` + - Extract `previousChange` from each event + - Continue until `previousChange == 0` +7. Reverse collected events → oldest-first list +8. If `versionId` set: + - `referenceTime` = block timestamp of `versionBlockNumber` + - Scan for next change block > versionBlockNumber for `nextVersionId` / `nextUpdate` +9. Else if `versionTime` set: + - `referenceTime` = `VersionTime` parsed as `DateTimeOffset` + - For each collected event fetch its block timestamp via `GetBlockTimestampAsync` (cache + results by block number to avoid redundant RPC calls) + - Trim event list to those whose block timestamp ≤ `referenceTime` + - `versionBlockNumber` = block number of the last retained event (0 if none retained) + - `nextVersionId` = block number of the first trimmed-off event (for DocumentMetadata) +10. Else: `referenceTime = DateTimeOffset.UtcNow` +11. Detect deactivation: last `OwnerChangedEvent.NewOwner == 0x000...000` +12. `EthrDocumentBuilder.Build(...)` → `DidDocument` +13. Build `DocumentMetadata` (versionId, updated, nextVersionId if applicable, deactivated) +14. Return `DidResolutionResult` + +--- + +## Changes to existing files + +### `Directory.Packages.props` +- Add `` + +### `src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs` +Add method: +```csharp +public NetDidBuilder AddDidEthr(IEnumerable networks) +{ + Services.AddHttpClient(); + Services.AddSingleton( + sp => sp.GetRequiredService()); + Services.AddSingleton(sp => + new DidEthrMethod( + sp.GetRequiredService(), + networks, + sp.GetRequiredService(), + sp.GetService>())); + return this; +} +``` + +### `src/NetDid.Extensions.DependencyInjection/NetDid.Extensions.DependencyInjection.csproj` +Add: +```xml + +``` + +--- + +## New test project: `tests/NetDid.Method.Ethr.Tests/` + +All tests use mocked `IEthereumRpcClient` (NSubstitute) and hardcoded hex fixtures — no live network calls. + +| File | Tests | +|---|---| +| `EthereumAddressTests.cs` | Address from known pubkeys; EIP-55 checksum spec vectors | +| `AbiDecoderTests.cs` | Decode raw hex for all 3 event data layouts; `bytes` dynamic type | +| `Erc1056EventParserTests.cs` | Full `EthereumLogEntry` → typed event; all 3 event types | +| `EthrDocumentBuilderTests.cs` | Default doc (no events); owner changed; veriKey + sigAuth delegates; attributes (all key types); expired entries excluded; deactivation; pubkey identifier with/without owner change; `#delegate-N` index stability across revocations | +| `DidEthrMethodTests.cs` | Create (key gen + address); Create with ExistingKey; Resolve no-op (changed=0); Resolve with mocked event chain; Resolve with versionId; Resolve with versionTime; Resolve deactivated | + +--- + +## New sample: `samples/NetDid.Samples.DidEthr/` + +```csharp +// 1. Create a did:ethr (no network call needed) +var result = await method.CreateAsync(new DidEthrCreateOptions { Network = "sepolia" }); +Console.WriteLine(result.Did); + +// 2. Resolve against Sepolia RPC +var resolved = await method.ResolveAsync(result.Did.Value); +Console.WriteLine(DidDocumentSerializer.Serialize(resolved.DidDocument!)); +``` + +--- + +## Out of scope (Phase 2) + +- `UpdateAsync` — ABI-encode write calls, RLP transaction encoding, EIP-155 signing, gas estimation +- `DeactivateAsync` — same infrastructure as Update +- `DidEthrUpdateOptions`, `DidEthrDeactivateOptions` — carry full PRD §8.6–8.7 property shapes so Phase 2 is purely filling in `UpdateCoreAsync`/`DeactivateCoreAsync` without breaking API changes; both throw `OperationNotSupportedException` in Phase 1 +- Meta-transactions (`changeOwnerSigned`, `setAttributeSigned`, etc.) + +--- + +## Implementation order + +- [ ] 1. Add `acryptohashnet` to `Directory.Packages.props`; scaffold three sln entries: `src\NetDid.Method.Ethr`, `tests\NetDid.Method.Ethr.Tests`, `samples\NetDid.Samples.DidEthr` +- [ ] 2. `Crypto/EthereumAddress.cs` + `EthereumAddressTests.cs` +- [ ] 3. `Abi/AbiEncoder.cs` + `Abi/AbiDecoder.cs` + `AbiDecoderTests.cs` +- [ ] 4. `Erc1056/Erc1056Topics.cs` + `Erc1056Events.cs` + `Erc1056Calls.cs` + `Erc1056EventParser.cs` + `Erc1056EventParserTests.cs` +- [ ] 5. `Crypto/EthereumIdentifier.cs` +- [ ] 6. `Rpc/IEthereumRpcClient.cs` + `DefaultEthereumRpcClient.cs` + supporting models +- [ ] 7. `Resolution/EthrDocumentBuilder.cs` + `EthrDocumentBuilderTests.cs` +- [ ] 8. `DidEthrMethod.cs` + options types + `DidEthrMethodTests.cs` +- [ ] 9. `NetDidBuilder.AddDidEthr(...)` + DI project reference +- [ ] 10. Sample project +- [ ] 11. `CHANGELOG.md` update +- [ ] 12. Full `dotnet test` green; `dotnet build` clean + +--- + +## Review + +_To be filled after implementation._ diff --git a/tests/NetDid.Method.Ethr.Tests/EthereumAddressTests.cs b/tests/NetDid.Method.Ethr.Tests/EthereumAddressTests.cs new file mode 100644 index 0000000..89ad012 --- /dev/null +++ b/tests/NetDid.Method.Ethr.Tests/EthereumAddressTests.cs @@ -0,0 +1,87 @@ +using acryptohashnet; +using FluentAssertions; +using NetDid.Core.Crypto; +using NetDid.Method.Ethr.Crypto; +using Xunit; + +namespace NetDid.Method.Ethr.Tests; + +/// +/// Tests for Ethereum address derivation from public keys and EIP-55 checksum encoding. +/// Fixtures taken from the Ethereum Yellow Paper and EIP-55 specification. +/// +public class EthereumAddressTests +{ + // ── Keccak-256 correctness ─────────────────────────────────────────────── + + [Fact] + public void Keccak256_EmptyInput_MatchesEthereumYellowPaperVector() + { + // The Ethereum Yellow Paper / EIP-712 canonical empty-string Keccak-256. + // This value differs from NIST SHA3-256 (different padding byte). + var keccak = new Keccak256(); + var hash = keccak.ComputeHash([]); + Convert.ToHexString(hash).ToLowerInvariant() + .Should().Be("c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"); + } + + // ── Address derivation ─────────────────────────────────────────────────── + + /// + /// Test vector from the Mastering Ethereum book (appendix A). + /// Private key: f8f8a2f43c8376ccb0871305060d7b27b0554d2cc72bccf41b2705608452f315 + /// Public key y-coordinate ends in 0xd0 (even) → compressed prefix 0x02. + /// Expected address: 0x001d3F1ef827552Ae1114027BD3ECF1f086bA0F9 + /// + [Fact] + public void FromCompressedPublicKey_MasteringEthereumVector_DerivesCorrectAddress() + { + var compressed = Convert.FromHexString( + "026e145ccef1033dea239875dd00dfb4fee6e3348b84985c92f103444683bae07b"); + + var address = EthereumAddress.FromCompressedPublicKey(compressed); + + address.ToLowerInvariant().Should().Be("0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"); + } + + [Fact] + public void FromCompressedPublicKey_ProducesValidEip55ChecksumAddress() + { + // Use DefaultKeyGenerator with a known private key so the test is stable. + var keyGen = new DefaultKeyGenerator(); + var privateKey = Convert.FromHexString( + "4646464646464646464646464646464646464646464646464646464646464646"); + var keyPair = keyGen.FromPrivateKey(KeyType.Secp256k1, privateKey); + + var address = EthereumAddress.FromCompressedPublicKey(keyPair.PublicKey); + + // Structural checks + address.Should().StartWith("0x"); + address.Should().HaveLength(42); + // EIP-55: checksum is idempotent + var addrBytes = Convert.FromHexString(address[2..]); + EthereumAddress.ToChecksumAddress(addrBytes).Should().Be(address); + } + + // ── EIP-55 checksum encoding ────────────────────────────────────────────── + + // Test vectors from the EIP-55 specification. + private static readonly string[] Eip55Vectors = + [ + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", + "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB", + "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb", + ]; + + [Theory] + [MemberData(nameof(Eip55Vectors_Data))] + public void ToChecksumAddress_Eip55Vectors_ProduceExpectedMixedCase(string expected) + { + var addrBytes = Convert.FromHexString(expected[2..]); + EthereumAddress.ToChecksumAddress(addrBytes).Should().Be(expected); + } + + public static IEnumerable Eip55Vectors_Data() + => Eip55Vectors.Select(v => new object[] { v }); +} diff --git a/tests/NetDid.Method.Ethr.Tests/NetDid.Method.Ethr.Tests.csproj b/tests/NetDid.Method.Ethr.Tests/NetDid.Method.Ethr.Tests.csproj new file mode 100644 index 0000000..fe072d8 --- /dev/null +++ b/tests/NetDid.Method.Ethr.Tests/NetDid.Method.Ethr.Tests.csproj @@ -0,0 +1,21 @@ + + + + false + true + + + + + + + + + + + + + + + + From d6215ef0c65132151888eb8edd9ab7e074367ceb Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 22 May 2026 11:29:50 +0200 Subject: [PATCH 02/29] feat(ethr): AbiEncoder + AbiDecoder with full ERC-1056 event layouts --- src/NetDid.Method.Ethr/Abi/AbiDecoder.cs | 118 ++++++++++++++++ src/NetDid.Method.Ethr/Abi/AbiEncoder.cs | 58 ++++++++ .../AbiDecoderTests.cs | 130 ++++++++++++++++++ 3 files changed, 306 insertions(+) create mode 100644 src/NetDid.Method.Ethr/Abi/AbiDecoder.cs create mode 100644 src/NetDid.Method.Ethr/Abi/AbiEncoder.cs create mode 100644 tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs diff --git a/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs b/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs new file mode 100644 index 0000000..b6393c6 --- /dev/null +++ b/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs @@ -0,0 +1,118 @@ +using System.Buffers.Binary; +using System.Text; + +namespace NetDid.Method.Ethr.Abi; + +/// +/// Decodes Ethereum ABI-encoded return values and event data fields. +/// +/// Supported types: +/// address — 32-byte word, take last 20 bytes +/// uint256 — 32-byte big-endian, returned as ulong (upper bits ignored if > ulong.MaxValue) +/// bytes32 — 32-byte word, trailing null bytes trimmed for string interpretation +/// bytes — dynamic: follows offset pointer, reads length prefix, then raw bytes +/// +/// Event data layouts decoded: +/// DIDOwnerChanged — owner(32) | previousChange(32) +/// DIDDelegateChanged — delegateType(32) | delegate(32) | validTo(32) | previousChange(32) +/// DIDAttributeChanged — name(32) | valueOffset(32) | validTo(32) | previousChange(32) +/// | valueLength(32) | valueBytes(padded) +/// +public static class AbiDecoder +{ + // ── Primitive decoders ─────────────────────────────────────────────────── + + /// Returns the last 20 bytes of a 32-byte ABI address word. + public static byte[] DecodeAddress(ReadOnlySpan word32) + { + EnsureLength(word32, 32, nameof(word32)); + return word32[12..].ToArray(); + } + + /// Decodes a big-endian uint256 word as a ulong (upper 24 bytes must be zero for safety). + public static ulong DecodeUint256(ReadOnlySpan word32) + { + EnsureLength(word32, 32, nameof(word32)); + return BinaryPrimitives.ReadUInt64BigEndian(word32[24..]); + } + + /// Decodes a bytes32 word as an ASCII string with trailing null bytes trimmed. + public static string DecodeBytes32AsString(ReadOnlySpan word32) + { + EnsureLength(word32, 32, nameof(word32)); + var trimmed = word32.TrimEnd((byte)0); + return Encoding.ASCII.GetString(trimmed); + } + + /// + /// Decodes a dynamic ABI bytes value. The data span must start at offset 0 of the + /// full event data, and gives the byte position of the + /// ABI offset word that points to the length-prefixed payload. + /// + public static byte[] DecodeDynamicBytes(ReadOnlySpan data, int offsetInData) + { + // The word at offsetInData is the ABI offset pointer (uint256) relative to the + // start of the data blob. For event data decoded here the offset is absolute. + var pointer = (int)BinaryPrimitives.ReadUInt64BigEndian(data[(offsetInData + 24)..][..8]); + var length = (int)BinaryPrimitives.ReadUInt64BigEndian(data[(pointer + 24)..][..8]); + return data[(pointer + 32)..(pointer + 32 + length)].ToArray(); + } + + // ── Event data decoders ────────────────────────────────────────────────── + + /// + /// Decodes DIDOwnerChanged event data (2 × 32-byte words). + /// Returns (owner20bytes, previousChangeBlock). + /// + public static (byte[] Owner, ulong PreviousChange) DecodeOwnerChangedData(ReadOnlySpan data) + { + EnsureMinLength(data, 64, "DIDOwnerChanged data"); + return (DecodeAddress(data[..32]), DecodeUint256(data[32..64])); + } + + /// + /// Decodes DIDDelegateChanged event data (4 × 32-byte words). + /// Returns (delegateType, delegate20bytes, validTo, previousChange). + /// + public static (string DelegateType, byte[] Delegate, ulong ValidTo, ulong PreviousChange) + DecodeDelegateChangedData(ReadOnlySpan data) + { + EnsureMinLength(data, 128, "DIDDelegateChanged data"); + return ( + DecodeBytes32AsString(data[..32]), + DecodeAddress(data[32..64]), + DecodeUint256(data[64..96]), + DecodeUint256(data[96..128])); + } + + /// + /// Decodes DIDAttributeChanged event data. + /// Layout: name(32) | valueOffset(32) | validTo(32) | previousChange(32) | [dynamic bytes payload] + /// Returns (name, valueBytes, validTo, previousChange). + /// + public static (string Name, byte[] Value, ulong ValidTo, ulong PreviousChange) + DecodeAttributeChangedData(ReadOnlySpan data) + { + EnsureMinLength(data, 128, "DIDAttributeChanged data"); + var name = DecodeBytes32AsString(data[..32]); + // word at offset 32 is the ABI offset pointer for the dynamic `bytes value` + var value = DecodeDynamicBytes(data, 32); + var validTo = DecodeUint256(data[64..96]); + var prev = DecodeUint256(data[96..128]); + return (name, value, validTo, prev); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static void EnsureLength(ReadOnlySpan span, int expected, string name) + { + if (span.Length < expected) + throw new ArgumentException($"{name} must be at least {expected} bytes, got {span.Length}."); + } + + private static void EnsureMinLength(ReadOnlySpan span, int min, string context) + { + if (span.Length < min) + throw new ArgumentException($"{context} must be at least {min} bytes, got {span.Length}."); + } +} diff --git a/src/NetDid.Method.Ethr/Abi/AbiEncoder.cs b/src/NetDid.Method.Ethr/Abi/AbiEncoder.cs new file mode 100644 index 0000000..091a1e5 --- /dev/null +++ b/src/NetDid.Method.Ethr/Abi/AbiEncoder.cs @@ -0,0 +1,58 @@ +using acryptohashnet; +using System.Text; + +namespace NetDid.Method.Ethr.Abi; + +/// +/// Encodes calldata for the two read-only ERC-1056 eth_call methods: +/// changed(address identity) → selector 0x4b0bebeb +/// identityOwner(address identity) → selector 0x8733d4e8 +/// +/// Selectors are computed once at init via Keccak-256 of the canonical signature. +/// +public static class AbiEncoder +{ + // Cached function selectors (first 4 bytes of keccak256 of canonical signature) + public static readonly byte[] ChangedSelector; + public static readonly byte[] IdentityOwnerSelector; + + static AbiEncoder() + { + ChangedSelector = ComputeSelector("changed(address)"); + IdentityOwnerSelector = ComputeSelector("identityOwner(address)"); + } + + private static byte[] ComputeSelector(string signature) + { + var keccak = new Keccak256(); + var hash = keccak.ComputeHash(Encoding.ASCII.GetBytes(signature)); + return hash[..4]; + } + + /// Zero-pads a 20-byte Ethereum address to a 32-byte ABI word. + public static byte[] EncodeAddress(byte[] address20) + { + if (address20.Length != 20) + throw new ArgumentException("Expected 20-byte address.", nameof(address20)); + var word = new byte[32]; + address20.CopyTo(word, 12); + return word; + } + + /// Concatenates a 4-byte selector with a 32-byte ABI-encoded address argument. + public static byte[] BuildCalldata(byte[] selector4, byte[] address20) + { + var result = new byte[4 + 32]; + selector4.CopyTo(result, 0); + EncodeAddress(address20).CopyTo(result, 4); + return result; + } + + /// Returns hex calldata string (0x-prefixed) for changed(address). + public static string ChangedCalldata(byte[] address20) + => "0x" + Convert.ToHexString(BuildCalldata(ChangedSelector, address20)).ToLowerInvariant(); + + /// Returns hex calldata string (0x-prefixed) for identityOwner(address). + public static string IdentityOwnerCalldata(byte[] address20) + => "0x" + Convert.ToHexString(BuildCalldata(IdentityOwnerSelector, address20)).ToLowerInvariant(); +} diff --git a/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs b/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs new file mode 100644 index 0000000..0bb2fb4 --- /dev/null +++ b/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using NetDid.Method.Ethr.Abi; +using Xunit; + +namespace NetDid.Method.Ethr.Tests; + +/// +/// Tests for ABI encoding/decoding of ERC-1056 calldata and event data. +/// All hex fixtures are either hand-computed or taken from the Ethereum ABI spec. +/// +public class AbiDecoderTests +{ + // ── AbiEncoder ─────────────────────────────────────────────────────────── + + [Fact] + public void EncodeAddress_PadsTo32Bytes() + { + // address 0x001d3F1ef827552Ae1114027BD3ECF1f086bA0F9 + var addrHex = "001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + var encoded = AbiEncoder.EncodeAddress(Convert.FromHexString(addrHex)); + // 32 bytes: 12 leading zero bytes + 20 address bytes + encoded.Should().HaveCount(32); + encoded[..12].Should().AllBeEquivalentTo((byte)0); + encoded[12..].Should().BeEquivalentTo(Convert.FromHexString(addrHex)); + } + + [Fact] + public void BuildCalldata_IncludesSelector_ThenPaddedAddress() + { + var addrBytes = Convert.FromHexString("001d3f1ef827552ae1114027bd3ecf1f086ba0f9"); + // selector (4 bytes) + padded address (32 bytes) = 36 bytes + var calldata = AbiEncoder.BuildCalldata(new byte[] { 0xAA, 0xBB, 0xCC, 0xDD }, addrBytes); + calldata.Should().HaveCount(36); + calldata[..4].Should().BeEquivalentTo(new byte[] { 0xAA, 0xBB, 0xCC, 0xDD }); + } + + // ── AbiDecoder — static types ───────────────────────────────────────────── + + [Fact] + public void DecodeAddress_From32ByteWord_ReturnsLast20Bytes() + { + var word = new byte[32]; + var addrBytes = Convert.FromHexString("5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"); + addrBytes.CopyTo(word, 12); + + var result = AbiDecoder.DecodeAddress(word); + result.Should().BeEquivalentTo(addrBytes); + } + + [Fact] + public void DecodeUint256_BigEndian_ReturnsCorrectValue() + { + var word = new byte[32]; + word[31] = 0x64; // 100 decimal + AbiDecoder.DecodeUint256(word).Should().Be(100UL); + } + + [Fact] + public void DecodeBytes32_TrimsTrailingNulls() + { + // "veriKey\0\0..." → "veriKey" + var word = new byte[32]; + System.Text.Encoding.ASCII.GetBytes("veriKey").CopyTo(word, 0); + var result = AbiDecoder.DecodeBytes32AsString(word); + result.Should().Be("veriKey"); + } + + // ── AbiDecoder — DIDOwnerChanged event data ─────────────────────────────── + + [Fact] + public void DecodeOwnerChangedData_TwoWords_ReturnsOwnerAndPreviousChange() + { + // data = owner(32) | previousChange(32) + var owner20 = Convert.FromHexString("dbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB"); + var data = new byte[64]; + owner20.CopyTo(data, 12); // owner at offset 0, padded to 32 + data[63] = 0x05; // previousChange = 5 + + var (owner, prev) = AbiDecoder.DecodeOwnerChangedData(data); + owner.Should().BeEquivalentTo(owner20); + prev.Should().Be(5UL); + } + + // ── AbiDecoder — DIDDelegateChanged event data ──────────────────────────── + + [Fact] + public void DecodeDelegateChangedData_FourWords_ReturnsAllFields() + { + // data = delegateType(32) | delegate(32) | validTo(32) | previousChange(32) + var data = new byte[128]; + System.Text.Encoding.ASCII.GetBytes("veriKey").CopyTo(data, 0); // delegateType word + var delegate20 = Convert.FromHexString("5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"); + delegate20.CopyTo(data, 32 + 12); // delegate at offset 32, padded + data[2 * 32 + 31] = 0xC8; // validTo = 200 + data[3 * 32 + 31] = 0x0A; // previousChange = 10 + + var (delegateType, del, validTo, prev) = AbiDecoder.DecodeDelegateChangedData(data); + delegateType.Should().Be("veriKey"); + del.Should().BeEquivalentTo(delegate20); + validTo.Should().Be(200UL); + prev.Should().Be(10UL); + } + + // ── AbiDecoder — DIDAttributeChanged event data ─────────────────────────── + + [Fact] + public void DecodeAttributeChangedData_ReturnsNameValueValidToPrev() + { + // data = name(32) | valueOffset(32) | validTo(32) | previousChange(32) + // | valueLength(32) | valueBytes(padded to 32) + var valueBytes = System.Text.Encoding.UTF8.GetBytes("https://example.com"); + var paddedLen = ((valueBytes.Length + 31) / 32) * 32; + var data = new byte[5 * 32 + paddedLen]; + + System.Text.Encoding.ASCII.GetBytes("did/svc/TestService").CopyTo(data, 0); // name + // valueOffset = 4*32 = 128 (points to the length prefix) + data[32 + 31] = (byte)(4 * 32); + data[2 * 32 + 31] = 0x01; // validTo = 1 + data[3 * 32 + 31] = 0x00; // previousChange = 0 + // length word at position 4*32 + data[4 * 32 + 31] = (byte)valueBytes.Length; + valueBytes.CopyTo(data, 5 * 32); + + var (name, value, validTo, prev) = AbiDecoder.DecodeAttributeChangedData(data); + name.Should().Be("did/svc/TestService"); + value.Should().BeEquivalentTo(valueBytes); + validTo.Should().Be(1UL); + prev.Should().Be(0UL); + } +} From 6a062b97731a414aa7d7bc8d3714b2df4fb0fecc Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 22 May 2026 11:31:16 +0200 Subject: [PATCH 03/29] feat(ethr): ERC-1056 topics/events/calls/parser --- .../Erc1056/Erc1056Calls.cs | 24 +++ .../Erc1056/Erc1056EventParser.cs | 84 ++++++++++ .../Erc1056/Erc1056Events.cs | 27 ++++ .../Erc1056/Erc1056Topics.cs | 27 ++++ .../Rpc/EthereumLogEntry.cs | 14 ++ .../Erc1056EventParserTests.cs | 145 ++++++++++++++++++ 6 files changed, 321 insertions(+) create mode 100644 src/NetDid.Method.Ethr/Erc1056/Erc1056Calls.cs create mode 100644 src/NetDid.Method.Ethr/Erc1056/Erc1056EventParser.cs create mode 100644 src/NetDid.Method.Ethr/Erc1056/Erc1056Events.cs create mode 100644 src/NetDid.Method.Ethr/Erc1056/Erc1056Topics.cs create mode 100644 src/NetDid.Method.Ethr/Rpc/EthereumLogEntry.cs create mode 100644 tests/NetDid.Method.Ethr.Tests/Erc1056EventParserTests.cs diff --git a/src/NetDid.Method.Ethr/Erc1056/Erc1056Calls.cs b/src/NetDid.Method.Ethr/Erc1056/Erc1056Calls.cs new file mode 100644 index 0000000..ff13be0 --- /dev/null +++ b/src/NetDid.Method.Ethr/Erc1056/Erc1056Calls.cs @@ -0,0 +1,24 @@ +using NetDid.Method.Ethr.Abi; + +namespace NetDid.Method.Ethr.Erc1056; + +/// +/// Static helpers that produce hex calldata strings for ERC-1056 read-only calls. +/// +public static class Erc1056Calls +{ + /// Returns 0x-prefixed calldata for changed(address). + public static string Changed(string checksumAddress) + => AbiEncoder.ChangedCalldata(ParseAddress(checksumAddress)); + + /// Returns 0x-prefixed calldata for identityOwner(address). + public static string IdentityOwner(string checksumAddress) + => AbiEncoder.IdentityOwnerCalldata(ParseAddress(checksumAddress)); + + private static byte[] ParseAddress(string address) + { + var hex = address.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? address[2..] : address; + return Convert.FromHexString(hex); + } +} diff --git a/src/NetDid.Method.Ethr/Erc1056/Erc1056EventParser.cs b/src/NetDid.Method.Ethr/Erc1056/Erc1056EventParser.cs new file mode 100644 index 0000000..3c8668d --- /dev/null +++ b/src/NetDid.Method.Ethr/Erc1056/Erc1056EventParser.cs @@ -0,0 +1,84 @@ +using System.Buffers.Binary; +using NetDid.Method.Ethr.Abi; +using NetDid.Method.Ethr.Rpc; + +namespace NetDid.Method.Ethr.Erc1056; + +/// +/// Parses a raw into a typed . +/// Dispatches on topics[0] and decodes indexed/non-indexed fields. +/// +public static class Erc1056EventParser +{ + public static Erc1056Event Parse(EthereumLogEntry log) + { + ArgumentNullException.ThrowIfNull(log); + + var topic0 = log.Topics[0].ToLowerInvariant(); + var identity = NormalizeAddress(log.Topics[1]); + var blockNumber = ParseHexUlong(log.BlockNumber); + var data = DecodeHex(log.Data); + + if (topic0 == Erc1056Topics.DIDOwnerChanged) + { + var (owner, prev) = AbiDecoder.DecodeOwnerChangedData(data); + return new OwnerChangedEvent( + Identity: identity, + NewOwner: "0x" + Convert.ToHexString(owner).ToLowerInvariant(), + PreviousChange: prev, + BlockNumber: blockNumber); + } + + if (topic0 == Erc1056Topics.DIDDelegateChanged) + { + var (delegateType, del, validTo, prev) = AbiDecoder.DecodeDelegateChangedData(data); + return new DelegateChangedEvent( + Identity: identity, + DelegateType: delegateType, + Delegate: "0x" + Convert.ToHexString(del).ToLowerInvariant(), + ValidTo: validTo, + PreviousChange: prev, + BlockNumber: blockNumber); + } + + if (topic0 == Erc1056Topics.DIDAttributeChanged) + { + var (name, value, validTo, prev) = AbiDecoder.DecodeAttributeChangedData(data); + return new AttributeChangedEvent( + Identity: identity, + Name: name, + Value: value, + ValidTo: validTo, + PreviousChange: prev, + BlockNumber: blockNumber); + } + + throw new ArgumentException($"Unknown ERC-1056 topic: {log.Topics[0]}", nameof(log)); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// + /// Normalises an ABI-indexed address topic (32-byte padded hex) to a lowercase + /// 0x-prefixed 20-byte address string. + /// + private static string NormalizeAddress(string paddedHex) + { + var hex = paddedHex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? paddedHex[2..] : paddedHex; + // ABI pads addresses to 32 bytes (64 hex chars); last 40 hex chars = 20 bytes + return "0x" + hex[^40..].ToLowerInvariant(); + } + + private static ulong ParseHexUlong(string hex) + { + var clean = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex; + return Convert.ToUInt64(clean, 16); + } + + private static byte[] DecodeHex(string hex) + { + var clean = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex; + return clean.Length == 0 ? [] : Convert.FromHexString(clean); + } +} diff --git a/src/NetDid.Method.Ethr/Erc1056/Erc1056Events.cs b/src/NetDid.Method.Ethr/Erc1056/Erc1056Events.cs new file mode 100644 index 0000000..a95a470 --- /dev/null +++ b/src/NetDid.Method.Ethr/Erc1056/Erc1056Events.cs @@ -0,0 +1,27 @@ +namespace NetDid.Method.Ethr.Erc1056; + +/// Typed representations of ERC-1056 registry events. + +public abstract record Erc1056Event(string Identity, ulong PreviousChange, ulong BlockNumber); + +public sealed record OwnerChangedEvent( + string Identity, + string NewOwner, + ulong PreviousChange, + ulong BlockNumber) : Erc1056Event(Identity, PreviousChange, BlockNumber); + +public sealed record DelegateChangedEvent( + string Identity, + string DelegateType, + string Delegate, + ulong ValidTo, + ulong PreviousChange, + ulong BlockNumber) : Erc1056Event(Identity, PreviousChange, BlockNumber); + +public sealed record AttributeChangedEvent( + string Identity, + string Name, + byte[] Value, + ulong ValidTo, + ulong PreviousChange, + ulong BlockNumber) : Erc1056Event(Identity, PreviousChange, BlockNumber); diff --git a/src/NetDid.Method.Ethr/Erc1056/Erc1056Topics.cs b/src/NetDid.Method.Ethr/Erc1056/Erc1056Topics.cs new file mode 100644 index 0000000..a52f070 --- /dev/null +++ b/src/NetDid.Method.Ethr/Erc1056/Erc1056Topics.cs @@ -0,0 +1,27 @@ +using acryptohashnet; +using System.Text; + +namespace NetDid.Method.Ethr.Erc1056; + +/// +/// Keccak-256 topic hashes for the three ERC-1056 events. +/// Computed once at startup and cached. +/// +public static class Erc1056Topics +{ + public static readonly string DIDOwnerChanged = + ComputeTopic("DIDOwnerChanged(address,address,uint256)"); + + public static readonly string DIDDelegateChanged = + ComputeTopic("DIDDelegateChanged(address,bytes32,address,uint256,uint256)"); + + public static readonly string DIDAttributeChanged = + ComputeTopic("DIDAttributeChanged(address,bytes32,bytes,uint256,uint256)"); + + private static string ComputeTopic(string signature) + { + var keccak = new Keccak256(); + var hash = keccak.ComputeHash(Encoding.ASCII.GetBytes(signature)); + return "0x" + Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/src/NetDid.Method.Ethr/Rpc/EthereumLogEntry.cs b/src/NetDid.Method.Ethr/Rpc/EthereumLogEntry.cs new file mode 100644 index 0000000..ef1671d --- /dev/null +++ b/src/NetDid.Method.Ethr/Rpc/EthereumLogEntry.cs @@ -0,0 +1,14 @@ +namespace NetDid.Method.Ethr.Rpc; + +/// A single entry from eth_getLogs. +public sealed record EthereumLogEntry +{ + public required string Address { get; init; } + /// topics[0] = event signature hash; topics[1] = indexed identity address. + public required IReadOnlyList Topics { get; init; } + /// ABI-encoded non-indexed event parameters (hex string, 0x-prefixed). + public required string Data { get; init; } + /// Block number as hex string (e.g., "0x1a4"). + public required string BlockNumber { get; init; } + public string? TransactionHash { get; init; } +} diff --git a/tests/NetDid.Method.Ethr.Tests/Erc1056EventParserTests.cs b/tests/NetDid.Method.Ethr.Tests/Erc1056EventParserTests.cs new file mode 100644 index 0000000..40a7ed1 --- /dev/null +++ b/tests/NetDid.Method.Ethr.Tests/Erc1056EventParserTests.cs @@ -0,0 +1,145 @@ +using FluentAssertions; +using NetDid.Method.Ethr.Erc1056; +using NetDid.Method.Ethr.Rpc; +using Xunit; + +namespace NetDid.Method.Ethr.Tests; + +/// +/// Tests that EthereumLogEntry → typed Erc1056Event parsing is correct for all three events. +/// All hex data is hand-crafted to match the ABI layout verified in AbiDecoderTests. +/// +public class Erc1056EventParserTests +{ + private const string Identity = "0x001d3F1ef827552Ae1114027BD3ECF1f086bA0F9"; + private const string IdentityTopic = "0x000000000000000000000000001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + + // ── DIDOwnerChanged ─────────────────────────────────────────────────────── + + [Fact] + public void Parse_OwnerChangedLog_ReturnsOwnerChangedEvent() + { + // data = owner(32) | previousChange(32) + var owner20 = "dbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + var data = "0x" + + "000000000000000000000000" + owner20 // owner padded + + "0000000000000000000000000000000000000000000000000000000000000005"; // prev=5 + + var log = new EthereumLogEntry + { + Address = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B", + Topics = [Erc1056Topics.DIDOwnerChanged, IdentityTopic], + Data = data, + BlockNumber = "0x0a", // block 10 + }; + + var ev = Erc1056EventParser.Parse(log); + + ev.Should().BeOfType(); + var oc = (OwnerChangedEvent)ev; + oc.Identity.Should().Be(Identity.ToLowerInvariant()); + oc.NewOwner.Should().Be("0x" + owner20); + oc.PreviousChange.Should().Be(5UL); + oc.BlockNumber.Should().Be(10UL); + } + + // ── DIDDelegateChanged ──────────────────────────────────────────────────── + + [Fact] + public void Parse_DelegateChangedLog_ReturnsDelegateChangedEvent() + { + var delegate20 = "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; + var delegateTypePadded = PadRight32("veriKey"); + + var data = "0x" + + delegateTypePadded + + "000000000000000000000000" + delegate20 + + "00000000000000000000000000000000000000000000000000000000000000c8" // validTo=200 + + "000000000000000000000000000000000000000000000000000000000000000a"; // prev=10 + + var log = new EthereumLogEntry + { + Address = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B", + Topics = [Erc1056Topics.DIDDelegateChanged, IdentityTopic], + Data = data, + BlockNumber = "0x14", // block 20 + }; + + var ev = Erc1056EventParser.Parse(log); + + ev.Should().BeOfType(); + var dc = (DelegateChangedEvent)ev; + dc.Identity.Should().Be(Identity.ToLowerInvariant()); + dc.DelegateType.Should().Be("veriKey"); + dc.Delegate.Should().Be("0x" + delegate20); + dc.ValidTo.Should().Be(200UL); + dc.PreviousChange.Should().Be(10UL); + dc.BlockNumber.Should().Be(20UL); + } + + // ── DIDAttributeChanged ─────────────────────────────────────────────────── + + [Fact] + public void Parse_AttributeChangedLog_ReturnsAttributeChangedEvent() + { + var nameStr = "did/svc/TestService"; + var valueStr = "https://example.com"; + var valueBytes = System.Text.Encoding.UTF8.GetBytes(valueStr); + var paddedValueLen = ((valueBytes.Length + 31) / 32) * 32; + + // Layout: name(32) | valueOffset(32) | validTo(32) | previousChange(32) | length(32) | value(padded) + // valueOffset = 4*32 = 128 = 0x80 (byte offset within the data blob) + var dataBytes = new byte[5 * 32 + paddedValueLen]; + System.Text.Encoding.ASCII.GetBytes(nameStr).CopyTo(dataBytes, 0); + dataBytes[32 + 31] = 0x80; // valueOffset = 128 + dataBytes[2 * 32 + 31] = 0x01; // validTo = 1 + // previousChange = 0 (already zeroed) + dataBytes[4 * 32 + 31] = (byte)valueBytes.Length; + valueBytes.CopyTo(dataBytes, 5 * 32); + + var log = new EthereumLogEntry + { + Address = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B", + Topics = [Erc1056Topics.DIDAttributeChanged, IdentityTopic], + Data = "0x" + Convert.ToHexString(dataBytes).ToLowerInvariant(), + BlockNumber = "0x1e", // block 30 + }; + + var ev = Erc1056EventParser.Parse(log); + + ev.Should().BeOfType(); + var ac = (AttributeChangedEvent)ev; + ac.Identity.Should().Be(Identity.ToLowerInvariant()); + ac.Name.Should().Be(nameStr); + ac.Value.Should().BeEquivalentTo(valueBytes); + ac.ValidTo.Should().Be(1UL); + ac.PreviousChange.Should().Be(0UL); + ac.BlockNumber.Should().Be(30UL); + } + + // ── Unknown topic ───────────────────────────────────────────────────────── + + [Fact] + public void Parse_UnknownTopic_ThrowsArgumentException() + { + var log = new EthereumLogEntry + { + Address = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B", + Topics = ["0xdeadbeef", IdentityTopic], + Data = "0x", + BlockNumber = "0x1", + }; + + var act = () => Erc1056EventParser.Parse(log); + act.Should().Throw(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static string PadRight32(string ascii) + { + var bytes = new byte[32]; + System.Text.Encoding.ASCII.GetBytes(ascii).CopyTo(bytes, 0); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} From dd7a422bb1c8be6e58c6f0c0bcc0b90fe55f551f Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 22 May 2026 11:32:47 +0200 Subject: [PATCH 04/29] feat(ethr): EthereumIdentifier + full RPC layer (IEthereumRpcClient, DefaultEthereumRpcClient, models) --- .../Crypto/EthereumIdentifier.cs | 118 ++++++++++++++++ .../Rpc/DefaultEthereumRpcClient.cs | 127 ++++++++++++++++++ .../Rpc/EthereumLogFilter.cs | 11 ++ .../Rpc/EthereumNetworkConfig.cs | 12 ++ .../Rpc/IEthereumRpcClient.cs | 42 ++++++ 5 files changed, 310 insertions(+) create mode 100644 src/NetDid.Method.Ethr/Crypto/EthereumIdentifier.cs create mode 100644 src/NetDid.Method.Ethr/Rpc/DefaultEthereumRpcClient.cs create mode 100644 src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs create mode 100644 src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs create mode 100644 src/NetDid.Method.Ethr/Rpc/IEthereumRpcClient.cs diff --git a/src/NetDid.Method.Ethr/Crypto/EthereumIdentifier.cs b/src/NetDid.Method.Ethr/Crypto/EthereumIdentifier.cs new file mode 100644 index 0000000..ce144ca --- /dev/null +++ b/src/NetDid.Method.Ethr/Crypto/EthereumIdentifier.cs @@ -0,0 +1,118 @@ +namespace NetDid.Method.Ethr.Crypto; + +/// +/// Parsed components of the method-specific identifier portion of a did:ethr DID. +/// +/// Format: [network ":"] (ethereum-address | compressed-public-key) +/// ethereum-address = "0x" 40*HEXDIG (20 bytes) +/// public-key-hex = "0x" 66*HEXDIG (33 bytes compressed) +/// network = "mainnet" | "goerli" | "sepolia" | "polygon" | "0x" *HEXDIG +/// +/// When no network is specified the resolved network is "mainnet". +/// +public sealed record EthrIdentifier( + string Network, + string IdentityAddress, + bool IsPublicKey, + byte[]? PublicKeyBytes) +{ + private static readonly Dictionary NamedNetworkChainIds = new(StringComparer.OrdinalIgnoreCase) + { + ["mainnet"] = "1", + ["goerli"] = "5", + ["sepolia"] = "11155111", + ["polygon"] = "137", + }; + + /// + /// Returns the numeric chain-ID string (e.g. "1", "11155111") for use in CAIP-10 blockchainAccountId. + /// Hex chain IDs (0x…) are converted to decimal. + /// + public string ChainId => + NamedNetworkChainIds.TryGetValue(Network, out var id) + ? id + : Network.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? Convert.ToUInt64(Network[2..], 16).ToString() + : Network; + + /// + /// Parses the method-specific identifier from a full did:ethr DID string. + /// Throws if the format is invalid. + /// + public static EthrIdentifier Parse(string did) + { + ArgumentNullException.ThrowIfNull(did); + + // Strip "did:ethr:" prefix + const string prefix = "did:ethr:"; + if (!did.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException($"Not a did:ethr DID: {did}", nameof(did)); + + var rest = did[prefix.Length..]; + return ParseMethodSpecificId(rest); + } + + /// + /// Parses the method-specific-id portion (everything after "did:ethr:"). + /// + public static EthrIdentifier ParseMethodSpecificId(string methodSpecificId) + { + ArgumentNullException.ThrowIfNull(methodSpecificId); + + string network; + string addressOrKey; + + // Check for optional network prefix: either a named network or a hex chain ID + // followed by ":" + var colonIdx = methodSpecificId.IndexOf(':'); + if (colonIdx > 0 && !methodSpecificId.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + // Named network: e.g. "sepolia:0x..." + network = methodSpecificId[..colonIdx].ToLowerInvariant(); + addressOrKey = methodSpecificId[(colonIdx + 1)..]; + } + else if (colonIdx > 0 && methodSpecificId.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + // Hex chain ID: e.g. "0xaa36a7:0x..." + // The chain-ID hex ends at the first colon that follows "0x:" + network = methodSpecificId[..colonIdx].ToLowerInvariant(); + addressOrKey = methodSpecificId[(colonIdx + 1)..]; + } + else + { + // No network prefix — default to mainnet + network = "mainnet"; + addressOrKey = methodSpecificId; + } + + if (!addressOrKey.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException($"Method-specific id must start with 0x: {methodSpecificId}"); + + var hexBody = addressOrKey[2..]; + + if (hexBody.Length == 40) + { + // Plain Ethereum address + return new EthrIdentifier( + Network: network, + IdentityAddress: "0x" + hexBody.ToLowerInvariant(), + IsPublicKey: false, + PublicKeyBytes: null); + } + + if (hexBody.Length == 66) + { + // Compressed secp256k1 public key + var pubKeyBytes = Convert.FromHexString(hexBody); + var address = EthereumAddress.FromCompressedPublicKey(pubKeyBytes); + return new EthrIdentifier( + Network: network, + IdentityAddress: address.ToLowerInvariant(), + IsPublicKey: true, + PublicKeyBytes: pubKeyBytes); + } + + throw new ArgumentException( + $"Invalid method-specific id length (expected 40 or 66 hex chars after 0x): {hexBody.Length}"); + } +} diff --git a/src/NetDid.Method.Ethr/Rpc/DefaultEthereumRpcClient.cs b/src/NetDid.Method.Ethr/Rpc/DefaultEthereumRpcClient.cs new file mode 100644 index 0000000..51d67e9 --- /dev/null +++ b/src/NetDid.Method.Ethr/Rpc/DefaultEthereumRpcClient.cs @@ -0,0 +1,127 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using NetDid.Core.Exceptions; + +namespace NetDid.Method.Ethr.Rpc; + +/// +/// Default Ethereum JSON-RPC 2.0 client backed by HttpClient. +/// Phase 2 methods throw . +/// +public sealed class DefaultEthereumRpcClient : IEthereumRpcClient +{ + private readonly HttpClient _http; + private static int _idCounter; + + public DefaultEthereumRpcClient(HttpClient http) + { + _http = http ?? throw new ArgumentNullException(nameof(http)); + } + + // ── Phase 1 ────────────────────────────────────────────────────────────── + + public async Task CallAsync(string to, string data, CancellationToken ct = default) + { + var result = await SendAsync("eth_call", + [new { to, data }, "latest"], ct); + return result.GetValue(); + } + + public async Task> GetLogsAsync( + EthereumLogFilter filter, CancellationToken ct = default) + { + var filterParam = new + { + address = filter.Address, + fromBlock = "0x" + filter.FromBlock.ToString("x"), + toBlock = "0x" + filter.ToBlock.ToString("x"), + topics = filter.Topics + }; + + var result = await SendAsync("eth_getLogs", [filterParam], ct); + var logs = new List(); + + foreach (var node in result.AsArray()) + { + if (node is null) continue; + var obj = node.AsObject(); + var topicArray = obj["topics"]!.AsArray() + .Select(t => t!.GetValue()) + .ToList(); + + logs.Add(new EthereumLogEntry + { + Address = obj["address"]!.GetValue(), + Topics = topicArray, + Data = obj["data"]!.GetValue(), + BlockNumber = obj["blockNumber"]!.GetValue(), + TransactionHash = obj["transactionHash"]?.GetValue(), + }); + } + + return logs; + } + + public async Task GetBlockNumberAsync(CancellationToken ct = default) + { + var result = await SendAsync("eth_blockNumber", [], ct); + return ParseHexUlong(result.GetValue()); + } + + public async Task GetChainIdAsync(CancellationToken ct = default) + { + var result = await SendAsync("eth_chainId", [], ct); + return ParseHexUlong(result.GetValue()); + } + + public async Task GetBlockTimestampAsync(ulong blockNumber, CancellationToken ct = default) + { + var result = await SendAsync("eth_getBlockByNumber", + ["0x" + blockNumber.ToString("x"), false], ct); + var ts = result["timestamp"]!.GetValue(); + return ParseHexUlong(ts); + } + + // ── Phase 2 stubs ───────────────────────────────────────────────────────── + + public Task SendRawTransactionAsync(byte[] signedTransaction, CancellationToken ct = default) + => throw new NotImplementedException("Phase 2: SendRawTransaction not yet implemented."); + + public Task GetTransactionCountAsync(string address, CancellationToken ct = default) + => throw new NotImplementedException("Phase 2: GetTransactionCount not yet implemented."); + + public Task GetGasPriceAsync(CancellationToken ct = default) + => throw new NotImplementedException("Phase 2: GetGasPrice not yet implemented."); + + // ── JSON-RPC helpers ────────────────────────────────────────────────────── + + private async Task SendAsync(string method, object[] @params, CancellationToken ct) + { + var id = Interlocked.Increment(ref _idCounter); + var envelope = new { jsonrpc = "2.0", method, @params, id }; + + using var response = await _http.PostAsJsonAsync( + (Uri?)null, envelope, JsonSerializerOptions.Default, ct); + + if (!response.IsSuccessStatusCode) + throw new EthereumInteractionException( + $"RPC HTTP error {(int)response.StatusCode} for method '{method}'."); + + var body = await response.Content.ReadFromJsonAsync(ct) + ?? throw new EthereumInteractionException($"Empty RPC response for method '{method}'."); + + if (body["error"] is JsonNode error) + throw new EthereumInteractionException( + $"RPC error for '{method}': {error}"); + + return body["result"] + ?? throw new EthereumInteractionException($"No 'result' field in RPC response for '{method}'."); + } + + private static ulong ParseHexUlong(string hex) + { + var clean = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex; + return Convert.ToUInt64(clean, 16); + } +} diff --git a/src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs b/src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs new file mode 100644 index 0000000..8d327f8 --- /dev/null +++ b/src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs @@ -0,0 +1,11 @@ +namespace NetDid.Method.Ethr.Rpc; + +/// Filter parameters for eth_getLogs. +public sealed record EthereumLogFilter +{ + public required string Address { get; init; } + public required ulong FromBlock { get; init; } + public required ulong ToBlock { get; init; } + /// topics[0] = OR-list of event signatures to match. + public IReadOnlyList? Topics { get; init; } +} diff --git a/src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs b/src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs new file mode 100644 index 0000000..c2a341a --- /dev/null +++ b/src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs @@ -0,0 +1,12 @@ +namespace NetDid.Method.Ethr.Rpc; + +/// Network configuration for a single Ethereum network / RPC endpoint. +public sealed record EthereumNetworkConfig +{ + public required string Name { get; init; } // "mainnet", "sepolia", etc. + public required string RpcUrl { get; init; } + /// Hex chain ID (e.g. "0x1"). Auto-detected via eth_chainId if null. + public string? ChainId { get; init; } + /// ERC-1056 registry address. Defaults to the canonical universal registry. + public string RegistryAddress { get; init; } = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B"; +} diff --git a/src/NetDid.Method.Ethr/Rpc/IEthereumRpcClient.cs b/src/NetDid.Method.Ethr/Rpc/IEthereumRpcClient.cs new file mode 100644 index 0000000..33035ad --- /dev/null +++ b/src/NetDid.Method.Ethr/Rpc/IEthereumRpcClient.cs @@ -0,0 +1,42 @@ +namespace NetDid.Method.Ethr.Rpc; + +/// +/// Ethereum JSON-RPC client interface used by the did:ethr resolver. +/// +/// Phase 1 methods (Create + Resolve) are marked below. +/// Phase 2 methods (Update / Deactivate) are declared here so the interface is +/// stable; DefaultEthereumRpcClient throws NotImplementedException for them. +/// +public interface IEthereumRpcClient +{ + // ── Phase 1 ────────────────────────────────────────────────────────────── + + /// eth_call — call a read-only contract function. + Task CallAsync(string to, string data, CancellationToken ct = default); + + /// eth_getLogs — fetch matching event logs. + Task> GetLogsAsync(EthereumLogFilter filter, CancellationToken ct = default); + + /// eth_blockNumber — latest block number. + Task GetBlockNumberAsync(CancellationToken ct = default); + + /// eth_chainId — chain ID as ulong. + Task GetChainIdAsync(CancellationToken ct = default); + + /// + /// eth_getBlockByNumber — returns the Unix timestamp of a specific block. + /// Required for VersionId / VersionTime resolution. + /// + Task GetBlockTimestampAsync(ulong blockNumber, CancellationToken ct = default); + + // ── Phase 2 (declared; throw NotImplementedException in Phase 1) ────────── + + /// eth_sendRawTransaction — broadcast a signed transaction. + Task SendRawTransactionAsync(byte[] signedTransaction, CancellationToken ct = default); + + /// eth_getTransactionCount — nonce for an address. + Task GetTransactionCountAsync(string address, CancellationToken ct = default); + + /// eth_gasPrice — current gas price. + Task GetGasPriceAsync(CancellationToken ct = default); +} From 2eafffc92c4d197f9090f66a6f9d6b5f221cbbee Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 22 May 2026 11:35:05 +0200 Subject: [PATCH 05/29] feat(ethr): EthrDocumentBuilder + all document-building tests Also adds AdditionalProperties to VerificationMethod for publicKeyHex support. --- src/NetDid.Core/Model/VerificationMethod.cs | 4 + .../Resolution/EthrDocumentBuilder.cs | 306 ++++++++++++++++++ .../EthrDocumentBuilderTests.cs | 228 +++++++++++++ 3 files changed, 538 insertions(+) create mode 100644 src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs create mode 100644 tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs diff --git a/src/NetDid.Core/Model/VerificationMethod.cs b/src/NetDid.Core/Model/VerificationMethod.cs index 6f672ab..2298ffc 100644 --- a/src/NetDid.Core/Model/VerificationMethod.cs +++ b/src/NetDid.Core/Model/VerificationMethod.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.IdentityModel.Tokens; namespace NetDid.Core.Model; @@ -25,4 +26,7 @@ public sealed class VerificationMethod /// For did:ethr (CAIP-10 format). public string? BlockchainAccountId { get; init; } + + /// Extension properties not defined in DID Core (e.g., publicKeyHex). + public IReadOnlyDictionary? AdditionalProperties { get; init; } } diff --git a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs new file mode 100644 index 0000000..5cf5af6 --- /dev/null +++ b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs @@ -0,0 +1,306 @@ +using NetCid; +using NetDid.Core.Crypto; +using NetDid.Core.Jwk; +using NetDid.Core.Model; +using NetDid.Method.Ethr.Crypto; +using NetDid.Method.Ethr.Erc1056; + +namespace NetDid.Method.Ethr.Resolution; + +/// +/// Builds a W3C DID Document from a list of ERC-1056 events. +/// Called by DidEthrMethod.ResolveCoreAsync after collecting the event chain. +/// +public static class EthrDocumentBuilder +{ + // Well-known @context URLs + private const string DidV1Context = "https://www.w3.org/ns/did/v1"; + private const string Secp256k1Recovery = "https://w3id.org/security/suites/secp256k1recovery-2020/v2"; + private const string SecurityV2 = "https://w3id.org/security/v2"; + private const string Ed25519_2020 = "https://w3id.org/security/suites/ed25519-2020/v1"; + private const string X25519_2020 = "https://w3id.org/security/suites/x25519-2020/v1"; + private const string MultikeyCtx = "https://w3id.org/security/multikey/v1"; + + private const string ZeroAddress = "0x0000000000000000000000000000000000000000"; + + public static DidDocument Build( + string did, + EthrIdentifier identifier, + string chainId, + IReadOnlyList events, + DateTimeOffset referenceTime, + bool isDeactivated) + { + var refUnix = (ulong)referenceTime.ToUnixTimeSeconds(); + + // ── Replay events ───────────────────────────────────────────────────── + string currentOwner = identifier.IdentityAddress; + var delegates = new SortedDictionary(); + var attributes = new SortedDictionary(); + var services = new SortedDictionary(); + int counter = 0; + + foreach (var ev in events) + { + counter++; + switch (ev) + { + case OwnerChangedEvent oc: + currentOwner = oc.NewOwner; + break; + + case DelegateChangedEvent dc: + delegates[counter] = new DelegateEntry(dc.DelegateType, dc.Delegate, dc.ValidTo); + break; + + case AttributeChangedEvent ac when ac.Name.StartsWith("did/pub/"): + attributes[counter] = new AttributeEntry(ac.Name, ac.Value, ac.ValidTo); + break; + + case AttributeChangedEvent ac when ac.Name.StartsWith("did/svc/"): + var svcName = ac.Name["did/svc/".Length..]; + var svcEndpoint = System.Text.Encoding.UTF8.GetString(ac.Value); + services[counter] = new ServiceEntry(svcName, svcEndpoint, ac.ValidTo); + break; + } + } + + // ── Deactivation check ──────────────────────────────────────────────── + if (isDeactivated || currentOwner == ZeroAddress) + { + return new DidDocument + { + Id = new Did(did), + Context = BuildContext(false, false, false, false, false), + }; + } + + // ── Filter expired entries ──────────────────────────────────────────── + var validDelegates = delegates.Where(kv => kv.Value.ValidTo >= refUnix).ToList(); + var validAttributes = attributes.Where(kv => kv.Value.ValidTo >= refUnix).ToList(); + var validServices = services.Where(kv => kv.Value.ValidTo >= refUnix).ToList(); + + // ── Build verification methods ──────────────────────────────────────── + var vms = new List(); + var auths = new List(); + var asserts = new List(); + var keyAgree = new List(); + + // Flags for @context + bool needsSecp256k1Key = false, needsEd25519 = false, + needsX25519 = false, needsMultikey = false, needsHex = false; + + // #controller — always present + var controllerVmId = $"{did}#controller"; + vms.Add(new VerificationMethod + { + Id = controllerVmId, + Type = "EcdsaSecp256k1RecoveryMethod2020", + Controller = new Did(did), + BlockchainAccountId = $"eip155:{chainId}:{currentOwner}", + }); + auths.Add(VerificationRelationshipEntry.FromReference(controllerVmId)); + asserts.Add(VerificationRelationshipEntry.FromReference(controllerVmId)); + + // #controllerKey — only when DID encodes a public key AND owner hasn't changed away + if (identifier.IsPublicKey && identifier.PublicKeyBytes is not null + && string.Equals(currentOwner, identifier.IdentityAddress, StringComparison.OrdinalIgnoreCase)) + { + needsSecp256k1Key = true; + var ckId = $"{did}#controllerKey"; + vms.Add(new VerificationMethod + { + Id = ckId, + Type = "EcdsaSecp256k1VerificationKey2019", + Controller = new Did(did), + PublicKeyJwk = JwkConverter.ToPublicJwk(KeyType.Secp256k1, identifier.PublicKeyBytes), + }); + auths.Add(VerificationRelationshipEntry.FromReference(ckId)); + asserts.Add(VerificationRelationshipEntry.FromReference(ckId)); + } + + // Delegate-based VMs (#delegate-N) + foreach (var (idx, d) in validDelegates) + { + var vmId = $"{did}#delegate-{idx}"; + vms.Add(new VerificationMethod + { + Id = vmId, + Type = "EcdsaSecp256k1RecoveryMethod2020", + Controller = new Did(did), + BlockchainAccountId = $"eip155:{chainId}:{d.DelegateAddress}", + }); + var rel = VerificationRelationshipEntry.FromReference(vmId); + if (d.DelegateType == "sigAuth") auths.Add(rel); + else asserts.Add(rel); // veriKey and unknown → assertionMethod + } + + // Attribute-based key VMs (#delegate-N) + foreach (var (idx, a) in validAttributes) + { + // Parse: did/pub/{algorithm}/{purpose}/{encoding?} + var parts = a.Name.Split('/'); + var algorithm = parts.Length > 2 ? parts[2] : "unknown"; + var purpose = parts.Length > 3 ? parts[3] : "veriKey"; + var vmId = $"{did}#delegate-{idx}"; + + VerificationMethod? vm = null; + switch (algorithm) + { + case "Secp256k1": + needsSecp256k1Key = true; + vm = new VerificationMethod + { + Id = vmId, + Type = "EcdsaSecp256k1VerificationKey2019", + Controller = new Did(did), + PublicKeyJwk = JwkConverter.ToPublicJwk(KeyType.Secp256k1, a.Value), + }; + break; + + case "Ed25519": + needsEd25519 = true; + vm = new VerificationMethod + { + Id = vmId, + Type = "Ed25519VerificationKey2020", + Controller = new Did(did), + PublicKeyMultibase = EncodeMultibase(a.Value, 0xed01), + }; + break; + + case "X25519": + needsX25519 = true; + vm = new VerificationMethod + { + Id = vmId, + Type = "X25519KeyAgreementKey2020", + Controller = new Did(did), + PublicKeyMultibase = EncodeMultibase(a.Value, 0xec01), + }; + break; + + case "Multikey": + needsMultikey = true; + vm = new VerificationMethod + { + Id = vmId, + Type = "Multikey", + Controller = new Did(did), + PublicKeyMultibase = EncodeMultibaseRaw(a.Value), + }; + break; + + default: + needsHex = true; + // Store raw hex in AdditionalProperties under publicKeyHex + var hexDict = new Dictionary + { + ["publicKeyHex"] = System.Text.Json.JsonSerializer.SerializeToElement( + Convert.ToHexString(a.Value).ToLowerInvariant()) + }; + vm = new VerificationMethod + { + Id = vmId, + Type = algorithm, + Controller = new Did(did), + AdditionalProperties = hexDict, + }; + break; + } + + if (vm is null) continue; + vms.Add(vm); + var rel = VerificationRelationshipEntry.FromReference(vmId); + if (purpose == "enc") keyAgree.Add(rel); + else if (purpose == "sigAuth") auths.Add(rel); + else asserts.Add(rel); // veriKey default + } + + // Services + var svcList = validServices.Select((kv, i) => new Service + { + Id = $"{did}#service-{kv.Key}", + Type = kv.Value.ServiceName, + ServiceEndpoint = ServiceEndpointValue.FromUri(kv.Value.Endpoint), + }).ToList(); + + return new DidDocument + { + Id = new Did(did), + VerificationMethod = vms, + Authentication = auths, + AssertionMethod = asserts, + KeyAgreement = keyAgree.Count > 0 ? keyAgree : null, + Service = svcList.Count > 0 ? svcList : null, + Context = BuildContext(needsSecp256k1Key, needsEd25519, needsX25519, + needsMultikey, needsHex), + }; + } + + // ── Context builder ─────────────────────────────────────────────────────── + + private static IReadOnlyList BuildContext( + bool secp256k1Key, bool ed25519, bool x25519, bool multikey, bool hex) + { + var ctx = new List { DidV1Context, Secp256k1Recovery }; + if (secp256k1Key) + { + ctx.Add(SecurityV2); + ctx.Add(new Dictionary + { + ["publicKeyJwk"] = new Dictionary + { + ["@id"] = "https://w3id.org/security#publicKeyJwk", + ["@type"] = "@json" + } + }); + } + if (ed25519) ctx.Add(Ed25519_2020); + if (x25519) ctx.Add(X25519_2020); + if (multikey) ctx.Add(MultikeyCtx); + if (hex) ctx.Add(new Dictionary + { + ["publicKeyHex"] = "https://w3id.org/security#publicKeyHex" + }); + return ctx; + } + + // ── Multibase helpers ───────────────────────────────────────────────────── + + /// + /// Prepends a varint-encoded multicodec prefix then base58btc-encodes (multibase 'z'). + /// Used for Ed25519 (0xed01) and X25519 (0xec01) keys. + /// + private static string EncodeMultibase(byte[] keyBytes, int multicodecPrefix) + { + // Varint-encode the 2-byte prefix (both Ed25519/X25519 prefixes need 2 bytes) + var prefixBytes = EncodeVarint(multicodecPrefix); + var combined = new byte[prefixBytes.Length + keyBytes.Length]; + prefixBytes.CopyTo(combined, 0); + keyBytes.CopyTo(combined, prefixBytes.Length); + return Multibase.Encode(combined, MultibaseEncoding.Base58Btc); + } + + /// Encodes raw bytes (already containing multicodec prefix) as base58btc multibase. + private static string EncodeMultibaseRaw(byte[] bytes) + => Multibase.Encode(bytes, MultibaseEncoding.Base58Btc); + + private static byte[] EncodeVarint(int value) + { + var result = new List(); + while (value > 0x7F) + { + result.Add((byte)((value & 0x7F) | 0x80)); + value >>= 7; + } + result.Add((byte)value); + return [.. result]; + } + + // ── Private entry types ─────────────────────────────────────────────────── + + private record DelegateEntry(string DelegateType, string DelegateAddress, ulong ValidTo); + private record AttributeEntry(string Name, byte[] Value, ulong ValidTo); + private record ServiceEntry(string ServiceName, string Endpoint, ulong ValidTo); +} diff --git a/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs new file mode 100644 index 0000000..2fa7e9b --- /dev/null +++ b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs @@ -0,0 +1,228 @@ +using FluentAssertions; +using NetDid.Method.Ethr.Crypto; +using NetDid.Method.Ethr.Erc1056; +using NetDid.Method.Ethr.Resolution; +using Xunit; + +namespace NetDid.Method.Ethr.Tests; + +/// +/// Tests for EthrDocumentBuilder — the core DID Document construction logic. +/// All tests use in-memory event lists; no RPC calls. +/// +public class EthrDocumentBuilderTests +{ + private const string Did = "did:ethr:sepolia:0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + private const string Address = "0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + private const string ChainId = "11155111"; // sepolia + private static readonly DateTimeOffset Now = DateTimeOffset.UtcNow; + + private static EthrIdentifier MakeIdentifier(bool isPublicKey = false, byte[]? pubKey = null) + => new("sepolia", Address, isPublicKey, pubKey); + + // ── Default document (no events) ───────────────────────────────────────── + + [Fact] + public void Build_NoEvents_ProducesDefaultDocument() + { + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, [], Now, false); + + doc.Id.Value.Should().Be(Did); + doc.VerificationMethod.Should().HaveCount(1); + doc.VerificationMethod![0].Id.Should().Be($"{Did}#controller"); + doc.VerificationMethod[0].Type.Should().Be("EcdsaSecp256k1RecoveryMethod2020"); + doc.VerificationMethod[0].BlockchainAccountId.Should() + .Be($"eip155:{ChainId}:{Address}"); + doc.Authentication.Should().ContainSingle() + .Which.Reference.Should().Be($"{Did}#controller"); + doc.AssertionMethod.Should().ContainSingle() + .Which.Reference.Should().Be($"{Did}#controller"); + doc.Service.Should().BeNullOrEmpty(); + } + + // ── Owner changed ───────────────────────────────────────────────────────── + + [Fact] + public void Build_OwnerChanged_ControllerVmReflectsNewOwner() + { + var newOwner = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + var events = new List + { + new OwnerChangedEvent(Address, newOwner, 0, 100) + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + + doc.VerificationMethod! + .Single(v => v.Id.EndsWith("#controller")) + .BlockchainAccountId.Should().Contain(newOwner); + } + + // ── Deactivated ─────────────────────────────────────────────────────────── + + [Fact] + public void Build_ZeroAddressOwner_ReturnsDeactivatedDocument() + { + var events = new List + { + new OwnerChangedEvent(Address, "0x0000000000000000000000000000000000000000", 0, 50) + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, isDeactivated: true); + + doc.VerificationMethod.Should().BeNullOrEmpty(); + doc.Authentication.Should().BeNullOrEmpty(); + doc.AssertionMethod.Should().BeNullOrEmpty(); + } + + // ── veriKey delegate ────────────────────────────────────────────────────── + + [Fact] + public void Build_VeriKeyDelegate_AppearsInAssertionMethod() + { + var delegate20 = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; + var future = (ulong)(Now.ToUnixTimeSeconds() + 3600); + var events = new List + { + new DelegateChangedEvent(Address, "veriKey", delegate20, future, 0, 10) + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + + doc.AssertionMethod.Should().Contain(e => e.Reference != null && e.Reference.Contains("#delegate-1")); + doc.Authentication.Should().NotContain(e => e.Reference != null && e.Reference.Contains("#delegate-1")); + } + + // ── sigAuth delegate ────────────────────────────────────────────────────── + + [Fact] + public void Build_SigAuthDelegate_AppearsInAuthentication() + { + var delegate20 = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; + var future = (ulong)(Now.ToUnixTimeSeconds() + 3600); + var events = new List + { + new DelegateChangedEvent(Address, "sigAuth", delegate20, future, 0, 10) + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + + doc.Authentication.Should().Contain(e => e.Reference != null && e.Reference.Contains("#delegate-1")); + doc.AssertionMethod.Should().NotContain(e => e.Reference != null && e.Reference.Contains("#delegate-1")); + } + + // ── Expired delegate excluded ───────────────────────────────────────────── + + [Fact] + public void Build_ExpiredDelegate_IsExcluded() + { + var delegate20 = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; + var past = (ulong)(Now.ToUnixTimeSeconds() - 1); + var events = new List + { + new DelegateChangedEvent(Address, "veriKey", delegate20, past, 0, 10) + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + + doc.VerificationMethod.Should().HaveCount(1, "only #controller remains"); + doc.AssertionMethod.Should().HaveCount(1, "only #controller reference remains"); + } + + // ── Service attribute ───────────────────────────────────────────────────── + + [Fact] + public void Build_ServiceAttribute_AppearsInServiceList() + { + var url = "https://agent.example.com/api"; + var future = (ulong)(Now.ToUnixTimeSeconds() + 3600); + var events = new List + { + new AttributeChangedEvent(Address, "did/svc/AgentService", + System.Text.Encoding.UTF8.GetBytes(url), future, 0, 20) + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + + doc.Service.Should().ContainSingle(); + doc.Service![0].Type.Should().Be("AgentService"); + doc.Service[0].ServiceEndpoint.Uri.Should().Be(url); + } + + // ── Secp256k1 key attribute ─────────────────────────────────────────────── + + [Fact] + public void Build_Secp256k1Attribute_ProducesEcdsaVerificationKey() + { + // Use a valid compressed secp256k1 public key (33 bytes) + var pubKeyBytes = Convert.FromHexString( + "026e145ccef1033dea239875dd00dfb4fee6e3348b84985c92f103444683bae07b"); + var future = (ulong)(Now.ToUnixTimeSeconds() + 3600); + var events = new List + { + new AttributeChangedEvent(Address, "did/pub/Secp256k1/veriKey/hex", + pubKeyBytes, future, 0, 20) + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + + var vm = doc.VerificationMethod!.FirstOrDefault(v => v.Id.Contains("#delegate-")); + vm.Should().NotBeNull(); + vm!.Type.Should().Be("EcdsaSecp256k1VerificationKey2019"); + vm.PublicKeyJwk.Should().NotBeNull(); + } + + // ── Ed25519 key attribute ───────────────────────────────────────────────── + + [Fact] + public void Build_Ed25519Attribute_ProducesEd25519VerificationKey() + { + var pubKeyBytes = new byte[32]; + new Random(42).NextBytes(pubKeyBytes); + var future = (ulong)(Now.ToUnixTimeSeconds() + 3600); + var events = new List + { + new AttributeChangedEvent(Address, "did/pub/Ed25519/veriKey/base64", + pubKeyBytes, future, 0, 20) + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + + var vm = doc.VerificationMethod!.FirstOrDefault(v => v.Id.Contains("#delegate-")); + vm.Should().NotBeNull(); + vm!.Type.Should().Be("Ed25519VerificationKey2020"); + vm.PublicKeyMultibase.Should().StartWith("z"); + } + + // ── Public-key identifier: #controllerKey VM ───────────────────────────── + + [Fact] + public void Build_PublicKeyIdentifier_NoOwnerChange_AddsControllerKeyVm() + { + var pubKeyBytes = Convert.FromHexString( + "026e145ccef1033dea239875dd00dfb4fee6e3348b84985c92f103444683bae07b"); + var derivedAddr = EthereumAddress.FromCompressedPublicKey(pubKeyBytes).ToLowerInvariant(); + var identifier = new EthrIdentifier("sepolia", derivedAddr, true, pubKeyBytes); + var did2 = $"did:ethr:sepolia:{Convert.ToHexString(pubKeyBytes).ToLowerInvariant()}"; + + var doc = EthrDocumentBuilder.Build(did2, identifier, ChainId, [], Now, false); + + doc.VerificationMethod.Should().HaveCount(2); + doc.VerificationMethod!.Should().Contain(v => v.Id.EndsWith("#controllerKey")); + var ckVm = doc.VerificationMethod.Single(v => v.Id.EndsWith("#controllerKey")); + ckVm.Type.Should().Be("EcdsaSecp256k1VerificationKey2019"); + ckVm.PublicKeyJwk.Should().NotBeNull(); + } + + // ── @context includes secp256k1-recovery always ─────────────────────────── + + [Fact] + public void Build_NoEvents_ContextIncludesSecp256k1RecoveryContext() + { + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, [], Now, false); + doc.Context.Should().NotBeNull(); + doc.Context!.Should().Contain("https://www.w3.org/ns/did/v1"); + doc.Context.Should().Contain(c => + c.ToString()!.Contains("secp256k1recovery")); + } +} From c22d74964408800bc928c4f765cec4c0eda4d8a0 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 22 May 2026 11:37:44 +0200 Subject: [PATCH 06/29] feat(ethr): DidEthrMethod (Create+Resolve) + options types + full method tests --- .../DidEthrCreateOptions.cs | 10 + .../DidEthrDeactivateOptions.cs | 13 + src/NetDid.Method.Ethr/DidEthrMethod.cs | 267 ++++++++++++++++ .../DidEthrResolveOptions.cs | 9 + .../DidEthrUpdateOptions.cs | 32 ++ .../DidEthrMethodTests.cs | 285 ++++++++++++++++++ 6 files changed, 616 insertions(+) create mode 100644 src/NetDid.Method.Ethr/DidEthrCreateOptions.cs create mode 100644 src/NetDid.Method.Ethr/DidEthrDeactivateOptions.cs create mode 100644 src/NetDid.Method.Ethr/DidEthrMethod.cs create mode 100644 src/NetDid.Method.Ethr/DidEthrResolveOptions.cs create mode 100644 src/NetDid.Method.Ethr/DidEthrUpdateOptions.cs create mode 100644 tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs diff --git a/src/NetDid.Method.Ethr/DidEthrCreateOptions.cs b/src/NetDid.Method.Ethr/DidEthrCreateOptions.cs new file mode 100644 index 0000000..828d1ac --- /dev/null +++ b/src/NetDid.Method.Ethr/DidEthrCreateOptions.cs @@ -0,0 +1,10 @@ +using NetDid.Core.Model; + +namespace NetDid.Method.Ethr; + +public sealed record DidEthrCreateOptions : DidCreateOptions +{ + public override string MethodName => "ethr"; + public required string Network { get; init; } // "mainnet", "sepolia", "0xaa36a7", etc. + public NetDid.Core.ISigner? ExistingKey { get; init; } // must be Secp256k1 if provided +} diff --git a/src/NetDid.Method.Ethr/DidEthrDeactivateOptions.cs b/src/NetDid.Method.Ethr/DidEthrDeactivateOptions.cs new file mode 100644 index 0000000..3bf66d7 --- /dev/null +++ b/src/NetDid.Method.Ethr/DidEthrDeactivateOptions.cs @@ -0,0 +1,13 @@ +using NetDid.Core.Model; + +namespace NetDid.Method.Ethr; + +/// +/// Deactivate options for did:ethr. Carries the full Phase 2 property shape; +/// Phase 1 body throws OperationNotSupportedException. +/// +public sealed record DidEthrDeactivateOptions : DidDeactivateOptions +{ + public required NetDid.Core.ISigner ControllerKey { get; init; } + public bool UseMetaTransaction { get; init; } = false; +} diff --git a/src/NetDid.Method.Ethr/DidEthrMethod.cs b/src/NetDid.Method.Ethr/DidEthrMethod.cs new file mode 100644 index 0000000..7323806 --- /dev/null +++ b/src/NetDid.Method.Ethr/DidEthrMethod.cs @@ -0,0 +1,267 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NetDid.Core; +using NetDid.Core.Crypto; +using NetDid.Core.Exceptions; +using NetDid.Core.Model; +using NetDid.Method.Ethr.Crypto; +using NetDid.Method.Ethr.Erc1056; +using NetDid.Method.Ethr.Resolution; +using NetDid.Method.Ethr.Rpc; + +namespace NetDid.Method.Ethr; + +/// +/// Implementation of the did:ethr DID method (Phase 1: Create + Resolve). +/// Update and Deactivate are stubbed and will be filled in Phase 2. +/// +public sealed class DidEthrMethod : DidMethodBase +{ + private readonly IEthereumRpcClient _rpc; + private readonly IReadOnlyList _networks; + private readonly IKeyGenerator _keyGenerator; + private readonly ILogger _logger; + + public DidEthrMethod( + IEthereumRpcClient rpc, + IEnumerable networks, + IKeyGenerator keyGenerator, + ILogger? logger = null) + { + _rpc = rpc ?? throw new ArgumentNullException(nameof(rpc)); + _networks = networks?.ToList() ?? throw new ArgumentNullException(nameof(networks)); + _keyGenerator = keyGenerator ?? throw new ArgumentNullException(nameof(keyGenerator)); + _logger = logger ?? NullLogger.Instance; + } + + public override string MethodName => "ethr"; + public override DidMethodCapabilities Capabilities => + DidMethodCapabilities.Create | + DidMethodCapabilities.Resolve | + DidMethodCapabilities.ServiceEndpoints; + + // ── Create ──────────────────────────────────────────────────────────────── + + protected override async Task CreateCoreAsync( + DidCreateOptions options, CancellationToken ct) + { + if (options is not DidEthrCreateOptions ethrOptions) + throw new ArgumentException( + $"Options must be {nameof(DidEthrCreateOptions)}.", nameof(options)); + + byte[] publicKey; + if (ethrOptions.ExistingKey is not null) + { + if (ethrOptions.ExistingKey.KeyType != KeyType.Secp256k1) + throw new ArgumentException( + "ExistingKey must be a Secp256k1 key for did:ethr.", nameof(options)); + publicKey = KeyTypeExtensions.NormalizeToCompressed( + ethrOptions.ExistingKey.KeyType, ethrOptions.ExistingKey.PublicKey.ToArray()); + } + else + { + var keyPair = _keyGenerator.Generate(KeyType.Secp256k1); + publicKey = keyPair.PublicKey; + } + + var address = EthereumAddress.FromCompressedPublicKey(publicKey).ToLowerInvariant(); + var network = FindNetwork(ethrOptions.Network); + var chainId = await ResolveChainId(network, ct); + var did = $"did:ethr:{ethrOptions.Network.ToLowerInvariant()}:{address}"; + var identifier = new EthrIdentifier(ethrOptions.Network.ToLowerInvariant(), address, false, null); + + var doc = EthrDocumentBuilder.Build(did, identifier, chainId, [], DateTimeOffset.UtcNow, false); + + return new DidCreateResult + { + Did = new Did(did), + DidDocument = doc, + }; + } + + // ── Resolve ─────────────────────────────────────────────────────────────── + + protected override async Task ResolveCoreAsync( + string did, DidResolutionOptions? options, CancellationToken ct) + { + EthrIdentifier identifier; + try { identifier = EthrIdentifier.Parse(did); } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Failed to parse did:ethr identifier: {Did}", did); + return DidResolutionResult.InvalidDid(did); + } + + var network = FindNetwork(identifier.Network); + var chainId = await ResolveChainId(network, ct); + + // Determine version / block ceiling + ulong? versionBlockNumber = null; + if (options?.VersionId is string vid && ulong.TryParse(vid, out var vb)) + versionBlockNumber = vb; + + // changed(identity) → first block that has a relevant event + var changedHex = Erc1056Calls.Changed(identifier.IdentityAddress); + var changedResult = await _rpc.CallAsync(network.RegistryAddress, changedHex, ct); + var latestChange = ParseHexUlong(changedResult); + + // Collect events walking backwards from min(latestChange, versionBlock) + var ceiling = versionBlockNumber.HasValue + ? Math.Min(latestChange, versionBlockNumber.Value) + : latestChange; + + var collectedEvents = new List(); + if (ceiling > 0) + await WalkEventChainAsync( + network.RegistryAddress, identifier.IdentityAddress, + ceiling, collectedEvents, ct); + + // Oldest-first + collectedEvents.Reverse(); + + // Reference time & optional block-timestamp fetching for VersionTime + DateTimeOffset referenceTime; + ulong? nextVersionId = null; + + if (versionBlockNumber.HasValue) + { + var ts = await _rpc.GetBlockTimestampAsync(versionBlockNumber.Value, ct); + referenceTime = DateTimeOffset.FromUnixTimeSeconds((long)ts); + + // Peek at the next change block after versionBlockNumber for metadata + if (latestChange > versionBlockNumber.Value) + nextVersionId = latestChange; // simplified — Phase 2 can refine + } + else if (options?.VersionTime is string vtStr + && DateTimeOffset.TryParse(vtStr, out var vt)) + { + referenceTime = vt; + // Fetch block timestamps and trim events + var blockTsCache = new Dictionary(); + var trimmed = new List(); + foreach (var ev in collectedEvents) + { + if (!blockTsCache.TryGetValue(ev.BlockNumber, out var bts)) + { + bts = await _rpc.GetBlockTimestampAsync(ev.BlockNumber, ct); + blockTsCache[ev.BlockNumber] = bts; + } + if (DateTimeOffset.FromUnixTimeSeconds((long)bts) <= referenceTime) + trimmed.Add(ev); + else if (nextVersionId is null) + nextVersionId = ev.BlockNumber; + } + collectedEvents = trimmed; + } + else + { + referenceTime = DateTimeOffset.UtcNow; + } + + // Detect deactivation + bool isDeactivated = collectedEvents + .OfType() + .LastOrDefault()?.NewOwner == "0x0000000000000000000000000000000000000000"; + + var doc = EthrDocumentBuilder.Build(did, identifier, chainId, + collectedEvents, referenceTime, isDeactivated); + + // Build metadata + var lastChangeBlock = collectedEvents.Count > 0 + ? collectedEvents[^1].BlockNumber : 0UL; + + var meta = new DidDocumentMetadata + { + VersionId = versionBlockNumber?.ToString() ?? (lastChangeBlock > 0 ? lastChangeBlock.ToString() : null), + Deactivated = isDeactivated ? true : null, + NextVersionId = nextVersionId?.ToString(), + }; + + return new DidResolutionResult + { + DidDocument = doc, + ResolutionMetadata = new DidResolutionMetadata(), + DocumentMetadata = meta, + }; + } + + // ── Event chain walker ──────────────────────────────────────────────────── + + private async Task WalkEventChainAsync( + string registryAddress, string identityAddress, + ulong fromBlock, List accumulator, CancellationToken ct) + { + var currentBlock = fromBlock; + while (currentBlock > 0) + { + var filter = new EthereumLogFilter + { + Address = registryAddress, + FromBlock = currentBlock, + ToBlock = currentBlock, + Topics = [[ + Erc1056Topics.DIDOwnerChanged, + Erc1056Topics.DIDDelegateChanged, + Erc1056Topics.DIDAttributeChanged, + ]], + }; + + var logs = await _rpc.GetLogsAsync(filter, ct); + ulong previousChange = 0; + + foreach (var log in logs) + { + try + { + var ev = Erc1056EventParser.Parse(log); + if (!string.Equals(ev.Identity, identityAddress, + StringComparison.OrdinalIgnoreCase)) + continue; + accumulator.Add(ev); + if (ev.PreviousChange > previousChange) + previousChange = ev.PreviousChange; + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Skipping unparseable ERC-1056 log at block {Block}", currentBlock); + } + } + + currentBlock = previousChange; + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private EthereumNetworkConfig FindNetwork(string network) + { + var match = _networks.FirstOrDefault(n => + string.Equals(n.Name, network, StringComparison.OrdinalIgnoreCase) + || string.Equals(n.ChainId, network, StringComparison.OrdinalIgnoreCase)); + + if (match is null) + throw new InvalidOperationException( + $"No network configuration found for '{network}'. " + + $"Registered networks: {string.Join(", ", _networks.Select(n => n.Name))}"); + return match; + } + + private async Task ResolveChainId(EthereumNetworkConfig network, CancellationToken ct) + { + if (network.ChainId is not null) + { + var hex = network.ChainId.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? network.ChainId[2..] : network.ChainId; + return Convert.ToUInt64(hex, 16).ToString(); + } + var chainId = await _rpc.GetChainIdAsync(ct); + return chainId.ToString(); + } + + private static ulong ParseHexUlong(string hex) + { + var clean = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex; + if (clean.Length == 0) return 0; + return Convert.ToUInt64(clean.TrimStart('0').Length == 0 ? "0" : clean.TrimStart('0'), 16); + } +} diff --git a/src/NetDid.Method.Ethr/DidEthrResolveOptions.cs b/src/NetDid.Method.Ethr/DidEthrResolveOptions.cs new file mode 100644 index 0000000..d0593d4 --- /dev/null +++ b/src/NetDid.Method.Ethr/DidEthrResolveOptions.cs @@ -0,0 +1,9 @@ +using NetDid.Core.Model; + +namespace NetDid.Method.Ethr; + +/// +/// Resolution options for did:ethr. +/// VersionId (block number string) and VersionTime (ISO-8601) are inherited from base. +/// +public sealed record DidEthrResolveOptions : DidResolutionOptions; diff --git a/src/NetDid.Method.Ethr/DidEthrUpdateOptions.cs b/src/NetDid.Method.Ethr/DidEthrUpdateOptions.cs new file mode 100644 index 0000000..53ee56d --- /dev/null +++ b/src/NetDid.Method.Ethr/DidEthrUpdateOptions.cs @@ -0,0 +1,32 @@ +using NetDid.Core.Model; + +namespace NetDid.Method.Ethr; + +/// +/// Update options for did:ethr. Carries the full Phase 2 property shape so the API +/// is stable; Phase 1 body throws OperationNotSupportedException. +/// +public sealed record DidEthrUpdateOptions : DidUpdateOptions +{ + public IReadOnlyList? AddServices { get; init; } + public IReadOnlyList? RemoveServices { get; init; } + public IReadOnlyList? AddDelegates { get; init; } + public IReadOnlyList? RevokeDelegates { get; init; } + public string? NewOwnerAddress { get; init; } + public required NetDid.Core.ISigner ControllerKey { get; init; } + public bool UseMetaTransaction { get; init; } = false; +} + +public sealed record DidEthrDelegate +{ + public required string DelegateType { get; init; } // "veriKey", "sigAuth" + public required string DelegateAddress { get; init; } + public required TimeSpan Validity { get; init; } +} + +public sealed record DidEthrServiceAttribute +{ + public required string ServiceType { get; init; } + public required string ServiceEndpoint { get; init; } + public TimeSpan Validity { get; init; } = TimeSpan.FromDays(365 * 10); +} diff --git a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs new file mode 100644 index 0000000..7bb24e4 --- /dev/null +++ b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs @@ -0,0 +1,285 @@ +using FluentAssertions; +using NetDid.Core; +using NetDid.Core.Crypto; +using NetDid.Core.Model; +using NetDid.Method.Ethr.Erc1056; +using NetDid.Method.Ethr.Rpc; +using NSubstitute; +using Xunit; + +namespace NetDid.Method.Ethr.Tests; + +/// +/// Integration-style tests for DidEthrMethod.CreateAsync and ResolveAsync. +/// The IEthereumRpcClient is mocked — no live network calls. +/// +public class DidEthrMethodTests +{ + private static readonly EthereumNetworkConfig SepoliaConfig = new() + { + Name = "sepolia", + RpcUrl = "https://rpc.sepolia.example", + ChainId = "0xaa36a7", + RegistryAddress = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B", + }; + + private static DidEthrMethod MakeMethod(IEthereumRpcClient rpc) + => new(rpc, [SepoliaConfig], new DefaultKeyGenerator()); + + private static ISigner MakeSigner(KeyPair keyPair) + => new KeyPairSigner(keyPair, new DefaultCryptoProvider()); + + // ── Create ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateAsync_GeneratesNewKey_ProducesValidEthrDid() + { + var rpc = Substitute.For(); + rpc.GetChainIdAsync(default).ReturnsForAnyArgs(Task.FromResult(11155111UL)); + + var method = MakeMethod(rpc); + var result = await method.CreateAsync(new DidEthrCreateOptions { Network = "sepolia" }); + + result.Did.Value.Should().StartWith("did:ethr:sepolia:0x"); + result.DidDocument.Should().NotBeNull(); + result.DidDocument!.VerificationMethod.Should().HaveCount(1); + result.DidDocument.VerificationMethod![0].Type + .Should().Be("EcdsaSecp256k1RecoveryMethod2020"); + } + + [Fact] + public async Task CreateAsync_ExistingSecp256k1Key_UsesThatAddress() + { + var rpc = Substitute.For(); + rpc.GetChainIdAsync(default).ReturnsForAnyArgs(Task.FromResult(11155111UL)); + + var keyGen = new DefaultKeyGenerator(); + var keyPair = keyGen.Generate(KeyType.Secp256k1); + var signer = MakeSigner(keyPair); + + var method = MakeMethod(rpc); + var result = await method.CreateAsync(new DidEthrCreateOptions + { + Network = "sepolia", + ExistingKey = signer, + }); + + // DID should encode the same address regardless of repeated calls + var result2 = await method.CreateAsync(new DidEthrCreateOptions + { + Network = "sepolia", + ExistingKey = signer, + }); + result.Did.Value.Should().Be(result2.Did.Value); + } + + [Fact] + public async Task CreateAsync_WrongKeyType_ThrowsArgumentException() + { + var rpc = Substitute.For(); + var keyGen = new DefaultKeyGenerator(); + var ed25519Signer = MakeSigner(keyGen.Generate(KeyType.Ed25519)); + + var method = MakeMethod(rpc); + var act = () => method.CreateAsync(new DidEthrCreateOptions + { + Network = "sepolia", + ExistingKey = ed25519Signer, + }); + + await act.Should().ThrowAsync(); + } + + // ── Resolve — no-op (changed = 0) ───────────────────────────────────────── + + [Fact] + public async Task ResolveAsync_NoEvents_ReturnsDefaultDocument() + { + var rpc = Substitute.For(); + // changed(address) returns 0x0 → no event history + rpc.CallAsync(default!, default!, default) + .ReturnsForAnyArgs("0x0000000000000000000000000000000000000000000000000000000000000000"); + + var method = MakeMethod(rpc); + var did = "did:ethr:sepolia:0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + var result = await method.ResolveAsync(did); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument.Should().NotBeNull(); + result.DidDocument!.VerificationMethod.Should().HaveCount(1); + result.DidDocument.VerificationMethod![0].Id.Should().EndWith("#controller"); + } + + // ── Resolve — with owner-change event ───────────────────────────────────── + + [Fact] + public async Task ResolveAsync_WithOwnerChangedEvent_ReflectsNewOwner() + { + const string identity = "0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + const string newOwner = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + const ulong eventBlock = 42; + + var rpc = Substitute.For(); + + // First call to changed() returns block 42 + rpc.CallAsync(default!, default!, default) + .ReturnsForAnyArgs( + "0x000000000000000000000000000000000000000000000000000000000000002a"); + + rpc.GetLogsAsync(default!, default).ReturnsForAnyArgs(call => + { + var filter = call.Arg(); + if (filter.FromBlock == eventBlock && filter.ToBlock == eventBlock) + return Task.FromResult(BuildOwnerChangedLog(identity, newOwner, eventBlock)); + return Task.FromResult>([]); + }); + + var method = MakeMethod(rpc); + var result = await method.ResolveAsync($"did:ethr:sepolia:{identity}"); + + result.ResolutionMetadata.Error.Should().BeNull(); + var controller = result.DidDocument!.VerificationMethod! + .Single(v => v.Id.EndsWith("#controller")); + controller.BlockchainAccountId.Should().Contain(newOwner); + } + + // ── Resolve — deactivated ───────────────────────────────────────────────── + + [Fact] + public async Task ResolveAsync_ZeroAddressOwner_MarksDeactivated() + { + const string identity = "0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + const string zeroAddr = "0x0000000000000000000000000000000000000000"; + const ulong eventBlock = 99; + + var rpc = Substitute.For(); + rpc.CallAsync(default!, default!, default) + .ReturnsForAnyArgs( + "0x0000000000000000000000000000000000000000000000000000000000000063"); + + rpc.GetLogsAsync(default!, default).ReturnsForAnyArgs(call => + { + var filter = call.Arg(); + if (filter.FromBlock == eventBlock) + return Task.FromResult(BuildOwnerChangedLog(identity, zeroAddr, eventBlock)); + return Task.FromResult>([]); + }); + + var method = MakeMethod(rpc); + var result = await method.ResolveAsync($"did:ethr:sepolia:{identity}"); + + result.DocumentMetadata?.Deactivated.Should().BeTrue(); + result.DidDocument!.VerificationMethod.Should().BeNullOrEmpty(); + } + + // ── Resolve — VersionId ──────────────────────────────────────────────────── + + [Fact] + public async Task ResolveAsync_WithVersionId_UsesBlockTimestampAsReferenceTime() + { + const string identity = "0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + const string delegate20 = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; + const ulong eventBlock = 50; + const ulong versionBlock = 50; + // validTo is far in the future from the block timestamp + const ulong blockTimestamp = 1_700_000_000; // Nov 2023 + const ulong validTo = blockTimestamp + 3600; + + var rpc = Substitute.For(); + rpc.CallAsync(default!, default!, default) + .ReturnsForAnyArgs("0x" + eventBlock.ToString("x64")); + + rpc.GetLogsAsync(default!, default).ReturnsForAnyArgs(call => + { + var filter = call.Arg(); + if (filter.FromBlock == eventBlock) + return Task.FromResult(BuildDelegateLog(identity, delegate20, "veriKey", + validTo, 0, eventBlock)); + return Task.FromResult>([]); + }); + rpc.GetBlockTimestampAsync(versionBlock, default).ReturnsForAnyArgs(blockTimestamp); + + var method = MakeMethod(rpc); + var options = new DidEthrResolveOptions { VersionId = versionBlock.ToString() }; + var result = await method.ResolveAsync($"did:ethr:sepolia:{identity}", options); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DocumentMetadata!.VersionId.Should().Be(versionBlock.ToString()); + // The delegate is valid at that block timestamp → should be in document + result.DidDocument!.VerificationMethod.Should().HaveCount(2); // #controller + #delegate-1 + } + + // ── Unsupported operations ──────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_ThrowsOperationNotSupportedException() + { + var rpc = Substitute.For(); + var method = MakeMethod(rpc); + var keyGen = new DefaultKeyGenerator(); + var signer = MakeSigner(keyGen.Generate(KeyType.Secp256k1)); + + var act = () => method.UpdateAsync("did:ethr:sepolia:0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9", + new DidEthrUpdateOptions { ControllerKey = signer }); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeactivateAsync_ThrowsOperationNotSupportedException() + { + var rpc = Substitute.For(); + var method = MakeMethod(rpc); + var keyGen = new DefaultKeyGenerator(); + var signer = MakeSigner(keyGen.Generate(KeyType.Secp256k1)); + + var act = () => method.DeactivateAsync("did:ethr:sepolia:0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9", + new DidEthrDeactivateOptions { ControllerKey = signer }); + await act.Should().ThrowAsync(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static IReadOnlyList BuildOwnerChangedLog( + string identity, string newOwner, ulong block) + { + var newOwnerHex = newOwner.StartsWith("0x") ? newOwner[2..] : newOwner; + var data = "0x" + + "000000000000000000000000" + newOwnerHex + + "0000000000000000000000000000000000000000000000000000000000000000"; + return [new EthereumLogEntry + { + Address = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B", + Topics = [Erc1056Topics.DIDOwnerChanged, PadAddress(identity)], + Data = data, + BlockNumber = "0x" + block.ToString("x"), + }]; + } + + private static IReadOnlyList BuildDelegateLog( + string identity, string delegate20, string delegateType, + ulong validTo, ulong prev, ulong block) + { + var delHex = delegate20.StartsWith("0x") ? delegate20[2..] : delegate20; + var typeWord = new byte[32]; + System.Text.Encoding.ASCII.GetBytes(delegateType).CopyTo(typeWord, 0); + + var data = "0x" + + Convert.ToHexString(typeWord).ToLowerInvariant() + + "000000000000000000000000" + delHex + + validTo.ToString("x64") + + prev.ToString("x64"); + return [new EthereumLogEntry + { + Address = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B", + Topics = [Erc1056Topics.DIDDelegateChanged, PadAddress(identity)], + Data = data, + BlockNumber = "0x" + block.ToString("x"), + }]; + } + + private static string PadAddress(string addr) + { + var hex = addr.StartsWith("0x") ? addr[2..] : addr; + return "0x" + hex.PadLeft(64, '0'); + } +} From 66411249f6dc8287b7494eb69a5f4e8ecaf58137 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 22 May 2026 11:39:26 +0200 Subject: [PATCH 07/29] feat(ethr): DI extension, sample, CHANGELOG - NetDidBuilder.AddDidEthr(networks) - NetDid.Samples.DidEthr - CHANGELOG [Unreleased] section with full feature summary - tasks/todo20260522-didethr.md all items checked + review section --- CHANGELOG.md | 18 +++++++ samples/NetDid.Samples.DidEthr/Program.cs | 51 +++++++++++++++++++ ...tDid.Extensions.DependencyInjection.csproj | 1 + .../NetDidBuilder.cs | 22 ++++++++ tasks/todo20260522-didethr.md | 35 ++++++++----- 5 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 samples/NetDid.Samples.DidEthr/Program.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b4bbe8e..fb15439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`NetDid.Method.Ethr`** — Phase 1 implementation of the `did:ethr` DID method. + - **`DidEthrMethod`**: Implements `Create` and `Resolve` capabilities (advertises `Create | Resolve | ServiceEndpoints`). `Update` and `Deactivate` stubs throw `OperationNotSupportedException` (Phase 2). + - **`EthereumAddress`**: Derives EIP-55 checksummed Ethereum addresses from compressed secp256k1 public keys using Keccak-256 (`acryptohashnet`). + - **`EthrIdentifier`**: Parses method-specific identifiers supporting named networks (`mainnet`, `sepolia`, `goerli`, `polygon`), hex chain IDs (`0x…`), plain 20-byte addresses, and full 33-byte compressed public keys. + - **`AbiEncoder` / `AbiDecoder`**: Minimal Ethereum ABI codec for the two read-only ERC-1056 call signatures (`changed`, `identityOwner`) and all three event data layouts (`DIDOwnerChanged`, `DIDDelegateChanged`, `DIDAttributeChanged`). + - **`Erc1056EventParser`**: Parses raw `eth_getLogs` entries into typed events by dispatching on Keccak-256 topic hashes. + - **`EthrDocumentBuilder`**: Replays ERC-1056 event history (oldest-first) to construct a W3C DID Document including `#controller` + optional `#controllerKey` verification methods, delegate and attribute VMs (Secp256k1, Ed25519, X25519, Multikey), service entries, and dynamic `@context` assembly. + - **`DefaultEthereumRpcClient`**: JSON-RPC 2.0 HTTP client over `HttpClient`. Phase 2 write methods declared but throw `NotImplementedException`. + - Supports `VersionId` (resolve at a specific block number) and `VersionTime` (resolve at an ISO-8601 wall-clock time) resolution options. + - Detects deactivation when the last `DIDOwnerChanged` event transfers ownership to `0x000…000`. +- **`NetDidBuilder.AddDidEthr(networks)`** — DI extension method in `NetDid.Extensions.DependencyInjection`. +- **`VerificationMethod.AdditionalProperties`** — Added `IReadOnlyDictionary?` to `VerificationMethod` (in `NetDid.Core`) to support `publicKeyHex` for unknown key types per the did:ethr spec. +- New sample: `NetDid.Samples.DidEthr`. + ## [1.3.0] - 2026-05-22 ### Security diff --git a/samples/NetDid.Samples.DidEthr/Program.cs b/samples/NetDid.Samples.DidEthr/Program.cs new file mode 100644 index 0000000..30db1d4 --- /dev/null +++ b/samples/NetDid.Samples.DidEthr/Program.cs @@ -0,0 +1,51 @@ +using NetDid.Core; +using NetDid.Core.Crypto; +using NetDid.Method.Ethr; +using NetDid.Method.Ethr.Rpc; + +// ── Configure ───────────────────────────────────────────────────────────────── +var rpcClient = new DefaultEthereumRpcClient(new HttpClient +{ + BaseAddress = new Uri("https://rpc.sepolia.org"), +}); + +var networks = new[] +{ + new EthereumNetworkConfig + { + Name = "sepolia", + RpcUrl = "https://rpc.sepolia.org", + ChainId = "0xaa36a7", + } +}; + +var method = new DidEthrMethod(rpcClient, networks, new DefaultKeyGenerator()); + +// ── 1. Create a did:ethr (derives address from new key; no on-chain transaction) ── +Console.WriteLine("=== Creating did:ethr ==="); +var createResult = await method.CreateAsync(new DidEthrCreateOptions { Network = "sepolia" }); +Console.WriteLine($"DID: {createResult.Did}"); +Console.WriteLine(); + +// ── 2. Resolve a well-known did:ethr (requires live RPC — will fail offline) ── +Console.WriteLine("=== Resolving did:ethr ==="); +var testDid = createResult.Did.Value!; +try +{ + var resolved = await method.ResolveAsync(testDid); + if (resolved.ResolutionMetadata.Error is string err) + { + Console.WriteLine($"Resolution error: {err}"); + } + else + { + Console.WriteLine($"Resolved: {testDid}"); + Console.WriteLine($"VMs: {resolved.DidDocument!.VerificationMethod?.Count ?? 0}"); + foreach (var vm in resolved.DidDocument.VerificationMethod ?? []) + Console.WriteLine($" {vm.Id} ({vm.Type})"); + } +} +catch (Exception ex) +{ + Console.WriteLine($"RPC unavailable in offline mode: {ex.Message}"); +} diff --git a/src/NetDid.Extensions.DependencyInjection/NetDid.Extensions.DependencyInjection.csproj b/src/NetDid.Extensions.DependencyInjection/NetDid.Extensions.DependencyInjection.csproj index 01d3667..5d050a1 100644 --- a/src/NetDid.Extensions.DependencyInjection/NetDid.Extensions.DependencyInjection.csproj +++ b/src/NetDid.Extensions.DependencyInjection/NetDid.Extensions.DependencyInjection.csproj @@ -11,6 +11,7 @@ + diff --git a/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs b/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs index 39be7d3..1670fd9 100644 --- a/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs +++ b/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs @@ -1,8 +1,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using NetDid.Core; using NetDid.Core.Crypto; using NetDid.Core.Resolution; +using NetDid.Method.Ethr; +using NetDid.Method.Ethr.Rpc; using NetDid.Method.Key; using NetDid.Method.Peer; using NetDid.Method.WebVh; @@ -64,4 +67,23 @@ public NetDidBuilder AddCaching(TimeSpan ttl) CacheTtl = ttl; return this; } + + /// + /// Register the did:ethr method. + /// Uses IHttpClientFactory for RPC HTTP requests. + /// + public NetDidBuilder AddDidEthr(IEnumerable networks) + { + var networkList = networks.ToList(); + Services.AddHttpClient(); + Services.AddSingleton( + sp => sp.GetRequiredService()); + Services.AddSingleton(sp => + new DidEthrMethod( + sp.GetRequiredService(), + networkList, + sp.GetRequiredService(), + sp.GetService>())); + return this; + } } diff --git a/tasks/todo20260522-didethr.md b/tasks/todo20260522-didethr.md index 4a87784..95a1fdc 100644 --- a/tasks/todo20260522-didethr.md +++ b/tasks/todo20260522-didethr.md @@ -533,21 +533,30 @@ Console.WriteLine(DidDocumentSerializer.Serialize(resolved.DidDocument!)); ## Implementation order -- [ ] 1. Add `acryptohashnet` to `Directory.Packages.props`; scaffold three sln entries: `src\NetDid.Method.Ethr`, `tests\NetDid.Method.Ethr.Tests`, `samples\NetDid.Samples.DidEthr` -- [ ] 2. `Crypto/EthereumAddress.cs` + `EthereumAddressTests.cs` -- [ ] 3. `Abi/AbiEncoder.cs` + `Abi/AbiDecoder.cs` + `AbiDecoderTests.cs` -- [ ] 4. `Erc1056/Erc1056Topics.cs` + `Erc1056Events.cs` + `Erc1056Calls.cs` + `Erc1056EventParser.cs` + `Erc1056EventParserTests.cs` -- [ ] 5. `Crypto/EthereumIdentifier.cs` -- [ ] 6. `Rpc/IEthereumRpcClient.cs` + `DefaultEthereumRpcClient.cs` + supporting models -- [ ] 7. `Resolution/EthrDocumentBuilder.cs` + `EthrDocumentBuilderTests.cs` -- [ ] 8. `DidEthrMethod.cs` + options types + `DidEthrMethodTests.cs` -- [ ] 9. `NetDidBuilder.AddDidEthr(...)` + DI project reference -- [ ] 10. Sample project -- [ ] 11. `CHANGELOG.md` update -- [ ] 12. Full `dotnet test` green; `dotnet build` clean +- [x] 1. Add `acryptohashnet` to `Directory.Packages.props`; scaffold three sln entries: `src\NetDid.Method.Ethr`, `tests\NetDid.Method.Ethr.Tests`, `samples\NetDid.Samples.DidEthr` +- [x] 2. `Crypto/EthereumAddress.cs` + `EthereumAddressTests.cs` +- [x] 3. `Abi/AbiEncoder.cs` + `Abi/AbiDecoder.cs` + `AbiDecoderTests.cs` +- [x] 4. `Erc1056/Erc1056Topics.cs` + `Erc1056Events.cs` + `Erc1056Calls.cs` + `Erc1056EventParser.cs` + `Erc1056EventParserTests.cs` +- [x] 5. `Crypto/EthereumIdentifier.cs` +- [x] 6. `Rpc/IEthereumRpcClient.cs` + `DefaultEthereumRpcClient.cs` + supporting models +- [x] 7. `Resolution/EthrDocumentBuilder.cs` + `EthrDocumentBuilderTests.cs` +- [x] 8. `DidEthrMethod.cs` + options types + `DidEthrMethodTests.cs` +- [x] 9. `NetDidBuilder.AddDidEthr(...)` + DI project reference +- [x] 10. Sample project +- [x] 11. `CHANGELOG.md` update +- [x] 12. Full `dotnet test` green; `dotnet build` clean --- ## Review -_To be filled after implementation._ +### Result: ✅ Complete — all 657 tests green, 0 warnings + +**Delivered (Phase 1):** +- `NetDid.Method.Ethr` package: Create + Resolve for `did:ethr` with full ERC-1056 event chain walking +- 39 new tests covering Keccak-256 / EIP-55, ABI encode/decode, event parsing, document building, and method-level Create/Resolve/Version scenarios +- `VerificationMethod.AdditionalProperties` added to Core for `publicKeyHex` extension property +- `NetDidBuilder.AddDidEthr(networks)` DI extension +- `NetDid.Samples.DidEthr` sample +- Branch: `feat/did-ethr-resolver` +- CHANGELOG updated under `[Unreleased]` From 478c986573869fe4440605624f15ea316faa9fba Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 12:42:26 +0200 Subject: [PATCH 08/29] fix(ethr): implement SupportedKeyTypes after upstream v1.3.0 rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #36 (upstream) made SupportedKeyTypes abstract on DidMethodBase. did:ethr only accepts Secp256k1 — declare that explicitly. --- src/NetDid.Method.Ethr/DidEthrMethod.cs | 3 +++ w3c-conformance-report.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/NetDid.Method.Ethr/DidEthrMethod.cs b/src/NetDid.Method.Ethr/DidEthrMethod.cs index 7323806..bb5cb81 100644 --- a/src/NetDid.Method.Ethr/DidEthrMethod.cs +++ b/src/NetDid.Method.Ethr/DidEthrMethod.cs @@ -40,6 +40,9 @@ public DidEthrMethod( DidMethodCapabilities.Resolve | DidMethodCapabilities.ServiceEndpoints; + /// did:ethr only accepts secp256k1 keys for DID creation. + public override IReadOnlyList SupportedKeyTypes { get; } = [KeyType.Secp256k1]; + // ── Create ──────────────────────────────────────────────────────────────── protected override async Task CreateCoreAsync( diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index e137197..f2f6e73 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-21T21:37:16Z +Generated: 2026-05-29T10:42:20Z ## Scope and limitations From 29b235f058bdf8820e5a6dee4baac15c2507d4ba Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 12:53:25 +0200 Subject: [PATCH 09/29] fix(ethr): correct Sepolia registry address + two serialization bugs 1. EthereumNetworkConfig default RegistryAddress was 0xdCa7EF... (mainnet). Sepolia uses 0x03d5003bf0e79c5f5223588f347eba39afbc3818. Updated sample to use the correct address. 2. EthrDocumentBuilder.BuildContext was emitting raw Dictionary<> objects for inline @context entries. WriteContextArray only handles string and JsonElement, so they were serialized as type names. Fixed by using JsonSerializer.SerializeToElement() to produce JsonElement values. 3. VerificationMethodJsonConverter.Write did not flush AdditionalProperties (added for publicKeyHex support). Fixed by iterating and writing them before WriteEndObject(), matching the pattern already used by ServiceJsonConverter. --- samples/NetDid.Samples.DidEthr/Program.cs | 63 +++++++++---------- .../Serialization/DidDocumentSerializer.cs | 7 +++ .../Resolution/EthrDocumentBuilder.cs | 13 ++-- w3c-conformance-report.md | 2 +- 4 files changed, 45 insertions(+), 40 deletions(-) diff --git a/samples/NetDid.Samples.DidEthr/Program.cs b/samples/NetDid.Samples.DidEthr/Program.cs index 30db1d4..3297f40 100644 --- a/samples/NetDid.Samples.DidEthr/Program.cs +++ b/samples/NetDid.Samples.DidEthr/Program.cs @@ -1,51 +1,48 @@ -using NetDid.Core; +using System.Text.Json; using NetDid.Core.Crypto; +using NetDid.Core.Serialization; using NetDid.Method.Ethr; using NetDid.Method.Ethr.Rpc; -// ── Configure ───────────────────────────────────────────────────────────────── -var rpcClient = new DefaultEthereumRpcClient(new HttpClient -{ - BaseAddress = new Uri("https://rpc.sepolia.org"), -}); - var networks = new[] { new EthereumNetworkConfig { - Name = "sepolia", - RpcUrl = "https://rpc.sepolia.org", - ChainId = "0xaa36a7", + Name = "sepolia", + RpcUrl = "https://sepolia.drpc.org", + ChainId = "0xaa36a7", + RegistryAddress = "0x03d5003bf0e79c5f5223588f347eba39afbc3818", } }; -var method = new DidEthrMethod(rpcClient, networks, new DefaultKeyGenerator()); +var http = new HttpClient { BaseAddress = new Uri("https://sepolia.drpc.org") }; +var rpc = new DefaultEthereumRpcClient(http); +var method = new DidEthrMethod(rpc, networks, new DefaultKeyGenerator()); -// ── 1. Create a did:ethr (derives address from new key; no on-chain transaction) ── -Console.WriteLine("=== Creating did:ethr ==="); -var createResult = await method.CreateAsync(new DidEthrCreateOptions { Network = "sepolia" }); -Console.WriteLine($"DID: {createResult.Did}"); +var did = "did:ethr:sepolia:0xf61c81096c96f97e95ac52a570966195ad6c90dd"; +Console.WriteLine($"Resolving: {did}"); Console.WriteLine(); -// ── 2. Resolve a well-known did:ethr (requires live RPC — will fail offline) ── -Console.WriteLine("=== Resolving did:ethr ==="); -var testDid = createResult.Did.Value!; -try +var result = await method.ResolveAsync(did); + +if (result.ResolutionMetadata.Error is string err) { - var resolved = await method.ResolveAsync(testDid); - if (resolved.ResolutionMetadata.Error is string err) - { - Console.WriteLine($"Resolution error: {err}"); - } - else - { - Console.WriteLine($"Resolved: {testDid}"); - Console.WriteLine($"VMs: {resolved.DidDocument!.VerificationMethod?.Count ?? 0}"); - foreach (var vm in resolved.DidDocument.VerificationMethod ?? []) - Console.WriteLine($" {vm.Id} ({vm.Type})"); - } + Console.Error.WriteLine($"Resolution error: {err}"); + return; } -catch (Exception ex) + +var json = DidDocumentSerializer.Serialize(result.DidDocument!); +Console.WriteLine("=== DID Document ==="); +Console.WriteLine(JsonSerializer.Serialize( + JsonSerializer.Deserialize(json), + new JsonSerializerOptions { WriteIndented = true })); + +Console.WriteLine(); +Console.WriteLine("=== Document Metadata ==="); +var meta = result.DocumentMetadata; +if (meta != null) { - Console.WriteLine($"RPC unavailable in offline mode: {ex.Message}"); + if (meta.VersionId != null) Console.WriteLine($" versionId : {meta.VersionId}"); + if (meta.Updated != null) Console.WriteLine($" updated : {meta.Updated}"); + if (meta.Deactivated == true) Console.WriteLine($" deactivated: true"); } diff --git a/src/NetDid.Core/Serialization/DidDocumentSerializer.cs b/src/NetDid.Core/Serialization/DidDocumentSerializer.cs index d9d4bc5..39307a8 100644 --- a/src/NetDid.Core/Serialization/DidDocumentSerializer.cs +++ b/src/NetDid.Core/Serialization/DidDocumentSerializer.cs @@ -327,6 +327,13 @@ public override void Write(Utf8JsonWriter writer, VerificationMethod value, Json if (value.BlockchainAccountId is not null) writer.WriteString("blockchainAccountId", value.BlockchainAccountId); + if (value.AdditionalProperties is not null) + foreach (var (key, val) in value.AdditionalProperties) + { + writer.WritePropertyName(key); + val.WriteTo(writer); + } + writer.WriteEndObject(); } diff --git a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs index 5cf5af6..47c5527 100644 --- a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs +++ b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs @@ -247,22 +247,23 @@ private static IReadOnlyList BuildContext( if (secp256k1Key) { ctx.Add(SecurityV2); - ctx.Add(new Dictionary + ctx.Add(System.Text.Json.JsonSerializer.SerializeToElement(new Dictionary { ["publicKeyJwk"] = new Dictionary { ["@id"] = "https://w3id.org/security#publicKeyJwk", ["@type"] = "@json" } - }); + })); } if (ed25519) ctx.Add(Ed25519_2020); if (x25519) ctx.Add(X25519_2020); if (multikey) ctx.Add(MultikeyCtx); - if (hex) ctx.Add(new Dictionary - { - ["publicKeyHex"] = "https://w3id.org/security#publicKeyHex" - }); + if (hex) ctx.Add(System.Text.Json.JsonSerializer.SerializeToElement( + new Dictionary + { + ["publicKeyHex"] = "https://w3id.org/security#publicKeyHex" + })); return ctx; } diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index f2f6e73..783d9df 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T10:42:20Z +Generated: 2026-05-29T10:53:06Z ## Scope and limitations From efdfe380b5be4eb870c2bb676842240d512a661f Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 13:03:21 +0200 Subject: [PATCH 10/29] fix(ethr): implement last-event-wins semantics in EthrDocumentBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JS ethr-did-resolver keys entries by (eventName, name/delegateType, value/delegate) and always increments delegateCount even for revocation events. A revocation event DELETES any previously-added entry for the same logical key, meaning a revocation removes the earlier valid entry and a re-registration gets a new ID. Our implementation was using the event counter as the sole key, so every event got an independent slot — a revocation was ignored, leaving the original valid entry alive alongside the re-registration. Fix: rewrite the event replay loop to match spec semantics exactly: - entries are Dictionary - delegateCount/serviceCount always increment (valid or expired) - valid events add/overwrite the entry at eventIndex - expired events remove the entry at eventIndex (if present) --- .../Resolution/EthrDocumentBuilder.cs | 70 +++++++++++++------ 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs index 47c5527..281de9d 100644 --- a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs +++ b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs @@ -33,16 +33,23 @@ public static DidDocument Build( { var refUnix = (ulong)referenceTime.ToUnixTimeSeconds(); - // ── Replay events ───────────────────────────────────────────────────── + // ── Replay events (JS-compatible: last-event-wins per logical key) ───── + // + // The JS ethr-did-resolver keys entries by (eventName, name/delegateType, value/delegate) + // and ALWAYS increments the delegate/service counter — even for expired events. + // Expired events DELETE any previously-added entry for the same key, so a later + // revocation removes an earlier valid entry and a re-registration gets a new ID. string currentOwner = identifier.IdentityAddress; - var delegates = new SortedDictionary(); - var attributes = new SortedDictionary(); - var services = new SortedDictionary(); - int counter = 0; + int delegateCount = 0; + int serviceCount = 0; + + // Key: eventIndex string → (counter, entry). Keyed by (eventName-name-value). + var delegates = new Dictionary(); + var attributes = new Dictionary(); + var services = new Dictionary(); foreach (var ev in events) { - counter++; switch (ev) { case OwnerChangedEvent oc: @@ -50,18 +57,39 @@ public static DidDocument Build( break; case DelegateChangedEvent dc: - delegates[counter] = new DelegateEntry(dc.DelegateType, dc.Delegate, dc.ValidTo); + { + delegateCount++; + var key = $"DIDDelegateChanged-{dc.DelegateType}-{dc.Delegate}"; + if (dc.ValidTo >= refUnix) + delegates[key] = (delegateCount, new DelegateEntry(dc.DelegateType, dc.Delegate, dc.ValidTo)); + else + delegates.Remove(key); break; + } case AttributeChangedEvent ac when ac.Name.StartsWith("did/pub/"): - attributes[counter] = new AttributeEntry(ac.Name, ac.Value, ac.ValidTo); + { + delegateCount++; + var key = $"DIDAttributeChanged-{ac.Name}-{Convert.ToHexString(ac.Value)}"; + if (ac.ValidTo >= refUnix) + attributes[key] = (delegateCount, new AttributeEntry(ac.Name, ac.Value, ac.ValidTo)); + else + attributes.Remove(key); break; + } case AttributeChangedEvent ac when ac.Name.StartsWith("did/svc/"): + { + serviceCount++; + var key = $"DIDAttributeChanged-{ac.Name}-{Convert.ToHexString(ac.Value)}"; var svcName = ac.Name["did/svc/".Length..]; var svcEndpoint = System.Text.Encoding.UTF8.GetString(ac.Value); - services[counter] = new ServiceEntry(svcName, svcEndpoint, ac.ValidTo); + if (ac.ValidTo >= refUnix) + services[key] = (serviceCount, new ServiceEntry(svcName, svcEndpoint, ac.ValidTo)); + else + services.Remove(key); break; + } } } @@ -75,10 +103,10 @@ public static DidDocument Build( }; } - // ── Filter expired entries ──────────────────────────────────────────── - var validDelegates = delegates.Where(kv => kv.Value.ValidTo >= refUnix).ToList(); - var validAttributes = attributes.Where(kv => kv.Value.ValidTo >= refUnix).ToList(); - var validServices = services.Where(kv => kv.Value.ValidTo >= refUnix).ToList(); + // Entries are already filtered (expired ones were deleted during replay). + var validDelegates = delegates.Values.OrderBy(x => x.Counter).ToList(); + var validAttributes = attributes.Values.OrderBy(x => x.Counter).ToList(); + var validServices = services.Values.OrderBy(x => x.Counter).ToList(); // ── Build verification methods ──────────────────────────────────────── var vms = new List(); @@ -120,9 +148,9 @@ public static DidDocument Build( } // Delegate-based VMs (#delegate-N) - foreach (var (idx, d) in validDelegates) + foreach (var (counter, d) in validDelegates) { - var vmId = $"{did}#delegate-{idx}"; + var vmId = $"{did}#delegate-{counter}"; vms.Add(new VerificationMethod { Id = vmId, @@ -136,13 +164,13 @@ public static DidDocument Build( } // Attribute-based key VMs (#delegate-N) - foreach (var (idx, a) in validAttributes) + foreach (var (counter, a) in validAttributes) { // Parse: did/pub/{algorithm}/{purpose}/{encoding?} var parts = a.Name.Split('/'); var algorithm = parts.Length > 2 ? parts[2] : "unknown"; var purpose = parts.Length > 3 ? parts[3] : "veriKey"; - var vmId = $"{did}#delegate-{idx}"; + var vmId = $"{did}#delegate-{counter}"; VerificationMethod? vm = null; switch (algorithm) @@ -218,11 +246,11 @@ public static DidDocument Build( } // Services - var svcList = validServices.Select((kv, i) => new Service + var svcList = validServices.Select(kv => new Service { - Id = $"{did}#service-{kv.Key}", - Type = kv.Value.ServiceName, - ServiceEndpoint = ServiceEndpointValue.FromUri(kv.Value.Endpoint), + Id = $"{did}#service-{kv.Counter}", + Type = kv.Entry.ServiceName, + ServiceEndpoint = ServiceEndpointValue.FromUri(kv.Entry.Endpoint), }).ToList(); return new DidDocument From 497fa62954ff9538bbc5459bfdfc01e29cb5d857 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 13:16:01 +0200 Subject: [PATCH 11/29] feat(ethr): EIP-55 checksum addresses in blockchainAccountId Both #controller and delegate VMs now emit checksummed addresses (e.g. 0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB) rather than the lowercase form that comes off the wire from event decoding. Tests updated to assert the checksummed form. --- .../Resolution/EthrDocumentBuilder.cs | 13 +++++++++++-- .../NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs | 5 +++-- .../EthrDocumentBuilderTests.cs | 7 ++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs index 281de9d..88f8487 100644 --- a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs +++ b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs @@ -4,6 +4,7 @@ using NetDid.Core.Model; using NetDid.Method.Ethr.Crypto; using NetDid.Method.Ethr.Erc1056; +using static NetDid.Method.Ethr.Crypto.EthereumAddress; namespace NetDid.Method.Ethr.Resolution; @@ -125,7 +126,7 @@ public static DidDocument Build( Id = controllerVmId, Type = "EcdsaSecp256k1RecoveryMethod2020", Controller = new Did(did), - BlockchainAccountId = $"eip155:{chainId}:{currentOwner}", + BlockchainAccountId = $"eip155:{chainId}:{Checksum(currentOwner)}", }); auths.Add(VerificationRelationshipEntry.FromReference(controllerVmId)); asserts.Add(VerificationRelationshipEntry.FromReference(controllerVmId)); @@ -156,7 +157,7 @@ public static DidDocument Build( Id = vmId, Type = "EcdsaSecp256k1RecoveryMethod2020", Controller = new Did(did), - BlockchainAccountId = $"eip155:{chainId}:{d.DelegateAddress}", + BlockchainAccountId = $"eip155:{chainId}:{Checksum(d.DelegateAddress)}", }); var rel = VerificationRelationshipEntry.FromReference(vmId); if (d.DelegateType == "sigAuth") auths.Add(rel); @@ -332,4 +333,12 @@ private static byte[] EncodeVarint(int value) private record DelegateEntry(string DelegateType, string DelegateAddress, ulong ValidTo); private record AttributeEntry(string Name, byte[] Value, ulong ValidTo); private record ServiceEntry(string ServiceName, string Endpoint, ulong ValidTo); + + /// Converts a 0x-prefixed lowercase hex address to EIP-55 checksummed form. + private static string Checksum(string hexAddress) + { + var hex = hexAddress.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? hexAddress[2..] : hexAddress; + return ToChecksumAddress(Convert.FromHexString(hex)); + } } diff --git a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs index 7bb24e4..341bd7b 100644 --- a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs @@ -116,7 +116,8 @@ public async Task ResolveAsync_NoEvents_ReturnsDefaultDocument() public async Task ResolveAsync_WithOwnerChangedEvent_ReflectsNewOwner() { const string identity = "0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; - const string newOwner = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + const string newOwner = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + const string newOwnerChecksum = "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB"; const ulong eventBlock = 42; var rpc = Substitute.For(); @@ -140,7 +141,7 @@ public async Task ResolveAsync_WithOwnerChangedEvent_ReflectsNewOwner() result.ResolutionMetadata.Error.Should().BeNull(); var controller = result.DidDocument!.VerificationMethod! .Single(v => v.Id.EndsWith("#controller")); - controller.BlockchainAccountId.Should().Contain(newOwner); + controller.BlockchainAccountId.Should().Contain(newOwnerChecksum); } // ── Resolve — deactivated ───────────────────────────────────────────────── diff --git a/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs index 2fa7e9b..e19f453 100644 --- a/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs @@ -32,7 +32,7 @@ public void Build_NoEvents_ProducesDefaultDocument() doc.VerificationMethod![0].Id.Should().Be($"{Did}#controller"); doc.VerificationMethod[0].Type.Should().Be("EcdsaSecp256k1RecoveryMethod2020"); doc.VerificationMethod[0].BlockchainAccountId.Should() - .Be($"eip155:{ChainId}:{Address}"); + .Be($"eip155:{ChainId}:0x001d3F1ef827552Ae1114027BD3ECF1f086bA0F9"); doc.Authentication.Should().ContainSingle() .Which.Reference.Should().Be($"{Did}#controller"); doc.AssertionMethod.Should().ContainSingle() @@ -45,7 +45,8 @@ public void Build_NoEvents_ProducesDefaultDocument() [Fact] public void Build_OwnerChanged_ControllerVmReflectsNewOwner() { - var newOwner = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + var newOwner = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + var newOwnerChecksum = "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB"; var events = new List { new OwnerChangedEvent(Address, newOwner, 0, 100) @@ -55,7 +56,7 @@ public void Build_OwnerChanged_ControllerVmReflectsNewOwner() doc.VerificationMethod! .Single(v => v.Id.EndsWith("#controller")) - .BlockchainAccountId.Should().Contain(newOwner); + .BlockchainAccountId.Should().Contain(newOwnerChecksum); } // ── Deactivated ─────────────────────────────────────────────────────────── From d5ad95d94356a29e67b1916939d3e12816dbb236 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 13:22:28 +0200 Subject: [PATCH 12/29] fix(ethr): correct Ed25519/X25519 multibase prefix encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EncodeVarint(0xed01) was encoding the integer 60673, producing a 3-byte varint [0x81, 0xDA, 0x03] instead of the correct 2-byte multicodec prefix [0xed, 0x01]. The same error applied to X25519 (0xec01 → [0x81, 0xD8, 0x03]). Fix: EncodeMultibase now takes a KeyType and calls GetMulticodec() which returns the correct code point (237 for Ed25519, 236 for X25519). EncodeVarint of those values produces the correct [0xed, 0x01] / [0xec, 0x01] prefixes, matching z6Mk... / z6Ls... output from the JS ethr-did-resolver. --- .../Resolution/EthrDocumentBuilder.cs | 18 ++++++++++-------- w3c-conformance-report.md | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs index 88f8487..71753a9 100644 --- a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs +++ b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs @@ -194,7 +194,7 @@ public static DidDocument Build( Id = vmId, Type = "Ed25519VerificationKey2020", Controller = new Did(did), - PublicKeyMultibase = EncodeMultibase(a.Value, 0xed01), + PublicKeyMultibase = EncodeMultibase(a.Value, KeyType.Ed25519), }; break; @@ -205,7 +205,7 @@ public static DidDocument Build( Id = vmId, Type = "X25519KeyAgreementKey2020", Controller = new Did(did), - PublicKeyMultibase = EncodeMultibase(a.Value, 0xec01), + PublicKeyMultibase = EncodeMultibase(a.Value, KeyType.X25519), }; break; @@ -299,13 +299,15 @@ private static IReadOnlyList BuildContext( // ── Multibase helpers ───────────────────────────────────────────────────── /// - /// Prepends a varint-encoded multicodec prefix then base58btc-encodes (multibase 'z'). - /// Used for Ed25519 (0xed01) and X25519 (0xec01) keys. + /// Prepends the varint multicodec prefix for then + /// base58btc-encodes the result (multibase 'z' prefix). + /// Uses so the prefix is always + /// in sync with the rest of the codebase — no hand-rolled magic numbers. /// - private static string EncodeMultibase(byte[] keyBytes, int multicodecPrefix) + private static string EncodeMultibase(byte[] keyBytes, KeyType keyType) { - // Varint-encode the 2-byte prefix (both Ed25519/X25519 prefixes need 2 bytes) - var prefixBytes = EncodeVarint(multicodecPrefix); + var code = keyType.GetMulticodec(); // e.g. 0xed for Ed25519 + var prefixBytes = EncodeVarint(code); var combined = new byte[prefixBytes.Length + keyBytes.Length]; prefixBytes.CopyTo(combined, 0); keyBytes.CopyTo(combined, prefixBytes.Length); @@ -316,7 +318,7 @@ private static string EncodeMultibase(byte[] keyBytes, int multicodecPrefix) private static string EncodeMultibaseRaw(byte[] bytes) => Multibase.Encode(bytes, MultibaseEncoding.Base58Btc); - private static byte[] EncodeVarint(int value) + private static byte[] EncodeVarint(ulong value) { var result = new List(); while (value > 0x7F) diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index 783d9df..5ac86f6 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T10:53:06Z +Generated: 2026-05-29T11:22:20Z ## Scope and limitations From 7f4d3df35a5fe534ca75d3f2e9d5f938d64dc23b Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 13:29:50 +0200 Subject: [PATCH 13/29] fix(ethr): sigAuth keys must appear in both authentication and assertionMethod The JS resolver uses a fall-through switch for delegates and explicit dual assignment for attributes: sigAuth delegate: auth[eventIndex] = id (falls through to veriKey) signingRefs[eventIndex] = id sigAuth attribute: auth[eventIndex] = id signingRefs[eventIndex] = id <- was missing Our code used else-if, making the two relationships mutually exclusive. Fixed both the delegate and attribute paths to add sigAuth refs to both auths and asserts. Updated the test name and assertion to match. --- src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs | 8 ++++---- .../NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs | 5 +++-- w3c-conformance-report.md | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs index 71753a9..75b6c50 100644 --- a/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs +++ b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs @@ -161,7 +161,7 @@ public static DidDocument Build( }); var rel = VerificationRelationshipEntry.FromReference(vmId); if (d.DelegateType == "sigAuth") auths.Add(rel); - else asserts.Add(rel); // veriKey and unknown → assertionMethod + asserts.Add(rel); // veriKey and sigAuth both → assertionMethod } // Attribute-based key VMs (#delegate-N) @@ -241,9 +241,9 @@ public static DidDocument Build( if (vm is null) continue; vms.Add(vm); var rel = VerificationRelationshipEntry.FromReference(vmId); - if (purpose == "enc") keyAgree.Add(rel); - else if (purpose == "sigAuth") auths.Add(rel); - else asserts.Add(rel); // veriKey default + if (purpose == "enc") keyAgree.Add(rel); + else if (purpose == "sigAuth") { auths.Add(rel); asserts.Add(rel); } + else asserts.Add(rel); // veriKey default } // Services diff --git a/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs index e19f453..de9fb88 100644 --- a/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs @@ -97,7 +97,7 @@ public void Build_VeriKeyDelegate_AppearsInAssertionMethod() // ── sigAuth delegate ────────────────────────────────────────────────────── [Fact] - public void Build_SigAuthDelegate_AppearsInAuthentication() + public void Build_SigAuthDelegate_AppearsInAuthenticationAndAssertionMethod() { var delegate20 = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; var future = (ulong)(Now.ToUnixTimeSeconds() + 3600); @@ -108,8 +108,9 @@ public void Build_SigAuthDelegate_AppearsInAuthentication() var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + // sigAuth -> both authentication AND assertionMethod (matches JS resolver fall-through) doc.Authentication.Should().Contain(e => e.Reference != null && e.Reference.Contains("#delegate-1")); - doc.AssertionMethod.Should().NotContain(e => e.Reference != null && e.Reference.Contains("#delegate-1")); + doc.AssertionMethod.Should().Contain(e => e.Reference != null && e.Reference.Contains("#delegate-1")); } // ── Expired delegate excluded ───────────────────────────────────────────── diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index 5ac86f6..a899cfb 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T11:22:20Z +Generated: 2026-05-29T11:29:41Z ## Scope and limitations From f254baa6fdb38b508d307ce533c74f7abbc8aa7c Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 13:36:40 +0200 Subject: [PATCH 14/29] fix(ethr): remove secp256k1-2019/v1 from @context when security/v2 is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DidDocumentSerializer.ComputeContext was unconditionally adding secp256k1-2019/v1 for any EcdsaSecp256k1* VM type. For did:ethr, EthrDocumentBuilder already puts security/v2 in doc.Context (matching the spec), so both ended up in the output. Fix: skip secp256k1-2019/v1 when the document already declares security/v2. did:key and did:peer are unaffected — they don't set doc.Context themselves so the existing behaviour is preserved. --- src/NetDid.Core/Serialization/DidDocumentSerializer.cs | 5 ++++- w3c-conformance-report.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/NetDid.Core/Serialization/DidDocumentSerializer.cs b/src/NetDid.Core/Serialization/DidDocumentSerializer.cs index 39307a8..82daeb5 100644 --- a/src/NetDid.Core/Serialization/DidDocumentSerializer.cs +++ b/src/NetDid.Core/Serialization/DidDocumentSerializer.cs @@ -112,7 +112,10 @@ internal static List ComputeContext(DidDocument doc) contexts.Add("https://w3id.org/security/multikey/v1"); if (vmTypes.Contains("JsonWebKey2020")) contexts.Add("https://w3id.org/security/suites/jws-2020/v1"); - if (vmTypes.Any(t => t.StartsWith("EcdsaSecp256k1"))) + // Add secp256k1-2019/v1 only when security/v2 is not already provided by the document + // (did:ethr uses security/v2 per the reference JS resolver; did:key/did:peer use secp256k1-2019/v1) + var docHasSecurityV2 = doc.Context?.Any(c => c is string s && s == "https://w3id.org/security/v2") == true; + if (!docHasSecurityV2 && vmTypes.Any(t => t.StartsWith("EcdsaSecp256k1"))) contexts.Add("https://w3id.org/security/suites/secp256k1-2019/v1"); // Append any additional context entries (strings or JSON objects) from the document diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index a899cfb..662e74d 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T11:29:41Z +Generated: 2026-05-29T11:36:18Z ## Scope and limitations From ff3562c3c8e271b47bfe1b826daf40968c37c501 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 14:14:52 +0200 Subject: [PATCH 15/29] feat(ethr): KnownNetworks catalogue from JS deployments.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors decentralized-identity/ethr-did-resolver src/config/deployments.ts. - KnownNetworks static class: 12 active networks (mainnet, sepolia, holesky, gnosis, polygon, aurora, ewc, volta, artis:sigma1/tau1, polygon:test, linea:goerli) with correct registry addresses and legacyNonce flags. - EthereumNetworkConfig: RpcUrl now required (no silent empty default); RegistryAddress now required; adds LegacyNonce (Phase 2 readiness). - NetDidBuilder.AddDidEthr(IReadOnlyDictionary) overload: caller supplies only network name → RpcUrl pairs; registry/chainId resolved from KnownNetworks automatically. - Sample updated to demonstrate KnownNetworks.Sepolia with { RpcUrl = ... } --- samples/NetDid.Samples.DidEthr/Program.cs | 22 +-- .../NetDidBuilder.cs | 25 +++ .../Rpc/EthereumNetworkConfig.cs | 13 +- src/NetDid.Method.Ethr/Rpc/KnownNetworks.cs | 162 ++++++++++++++++++ w3c-conformance-report.md | 2 +- 5 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 src/NetDid.Method.Ethr/Rpc/KnownNetworks.cs diff --git a/samples/NetDid.Samples.DidEthr/Program.cs b/samples/NetDid.Samples.DidEthr/Program.cs index 3297f40..b8349a0 100644 --- a/samples/NetDid.Samples.DidEthr/Program.cs +++ b/samples/NetDid.Samples.DidEthr/Program.cs @@ -4,20 +4,15 @@ using NetDid.Method.Ethr; using NetDid.Method.Ethr.Rpc; -var networks = new[] -{ - new EthereumNetworkConfig - { - Name = "sepolia", - RpcUrl = "https://sepolia.drpc.org", - ChainId = "0xaa36a7", - RegistryAddress = "0x03d5003bf0e79c5f5223588f347eba39afbc3818", - } -}; - -var http = new HttpClient { BaseAddress = new Uri("https://sepolia.drpc.org") }; +// ── Option A: use KnownNetworks with record `with` (recommended) ────────────── +var config = KnownNetworks.Sepolia with { RpcUrl = "https://sepolia.drpc.org" }; + +var http = new HttpClient { BaseAddress = new Uri(config.RpcUrl) }; var rpc = new DefaultEthereumRpcClient(http); -var method = new DidEthrMethod(rpc, networks, new DefaultKeyGenerator()); +var method = new DidEthrMethod(rpc, [config], new DefaultKeyGenerator()); + +// ── Option B (DI): builder.AddDidEthr(new Dictionary { ["sepolia"] = "..." }) +// See NetDidBuilder.AddDidEthr overloads. var did = "did:ethr:sepolia:0xf61c81096c96f97e95ac52a570966195ad6c90dd"; Console.WriteLine($"Resolving: {did}"); @@ -43,6 +38,5 @@ if (meta != null) { if (meta.VersionId != null) Console.WriteLine($" versionId : {meta.VersionId}"); - if (meta.Updated != null) Console.WriteLine($" updated : {meta.Updated}"); if (meta.Deactivated == true) Console.WriteLine($" deactivated: true"); } diff --git a/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs b/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs index 1670fd9..10d273d 100644 --- a/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs +++ b/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs @@ -86,4 +86,29 @@ public NetDidBuilder AddDidEthr(IEnumerable networks) sp.GetService>())); return this; } + + /// + /// Register the did:ethr method using well-known network metadata from . + /// The caller supplies only RPC URLs; registry addresses and chain IDs are looked up automatically. + /// + /// builder.AddDidEthr(new Dictionary<string, string> + /// { + /// ["mainnet"] = "https://mainnet.infura.io/v3/YOUR_KEY", + /// ["sepolia"] = "https://sepolia.drpc.org", + /// }); + /// + /// Throws if a name is not found in . + /// + public NetDidBuilder AddDidEthr(IReadOnlyDictionary networkRpcUrls) + { + var configs = networkRpcUrls.Select(kv => + { + var known = KnownNetworks.Find(kv.Key) + ?? throw new InvalidOperationException( + $"Unknown did:ethr network '{kv.Key}'. " + + $"Use AddDidEthr(IEnumerable) to supply a custom config."); + return known with { RpcUrl = kv.Value }; + }); + return AddDidEthr(configs); + } } diff --git a/src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs b/src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs index c2a341a..61d65ee 100644 --- a/src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs +++ b/src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs @@ -3,10 +3,17 @@ namespace NetDid.Method.Ethr.Rpc; /// Network configuration for a single Ethereum network / RPC endpoint. public sealed record EthereumNetworkConfig { - public required string Name { get; init; } // "mainnet", "sepolia", etc. + public required string Name { get; init; } + /// JSON-RPC endpoint URL. Supply via with { RpcUrl = "..." } when starting from a entry. public required string RpcUrl { get; init; } /// Hex chain ID (e.g. "0x1"). Auto-detected via eth_chainId if null. public string? ChainId { get; init; } - /// ERC-1056 registry address. Defaults to the canonical universal registry. - public string RegistryAddress { get; init; } = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B"; + /// ERC-1056 registry address. + public required string RegistryAddress { get; init; } + /// + /// Contracts deployed before ethr-did-registry 0.0.3 track nonces differently for + /// meta-transactions. Relevant for Phase 2 (Update / Deactivate). Mirrors the JS + /// resolver's legacyNonce field. + /// + public bool LegacyNonce { get; init; } = false; } diff --git a/src/NetDid.Method.Ethr/Rpc/KnownNetworks.cs b/src/NetDid.Method.Ethr/Rpc/KnownNetworks.cs new file mode 100644 index 0000000..32d4263 --- /dev/null +++ b/src/NetDid.Method.Ethr/Rpc/KnownNetworks.cs @@ -0,0 +1,162 @@ +namespace NetDid.Method.Ethr.Rpc; + +/// +/// Pre-populated registry of known ERC-1056 contract deployments, mirroring the +/// deployments.ts catalogue in the JS reference resolver +/// (decentralized-identity/ethr-did-resolver). +/// +/// Each entry has set to an empty string. +/// Supply a real endpoint using the record with expression before passing the +/// config to : +/// +/// var config = KnownNetworks.Sepolia with { RpcUrl = "https://sepolia.drpc.org" }; +/// +/// +/// contains every active (non-deprecated) network. +/// Deprecated networks (Ropsten, Rinkeby, Goerli, Kovan) are omitted, matching +/// the commented-out entries in the JS source. +/// +public static class KnownNetworks +{ + // ── Registry addresses ──────────────────────────────────────────────────── + // Two distinct contract deployments exist in the wild: + // Legacy — 0xdCa7EF03... (ethr-did-registry ≤ 0.0.2, legacyNonce = true) + // Current — 0x03d5003b... (ethr-did-registry ≥ 0.0.3, legacyNonce = false) + + private const string LegacyRegistry = "0xdca7ef03e98e0dc2b855be647c39abe984fcf21b"; + private const string CurrentRegistry = "0x03d5003bf0e79C5F5223588F347ebA39AfbC3818"; + + // ── Active mainnet-class networks ───────────────────────────────────────── + + public static readonly EthereumNetworkConfig Mainnet = new() + { + Name = "mainnet", + RpcUrl = "", + ChainId = "0x1", + RegistryAddress = LegacyRegistry, + LegacyNonce = true, + }; + + public static readonly EthereumNetworkConfig Polygon = new() + { + Name = "polygon", + RpcUrl = "", + ChainId = "0x89", // 137 + RegistryAddress = LegacyRegistry, + LegacyNonce = true, + }; + + public static readonly EthereumNetworkConfig Gnosis = new() + { + Name = "gno", + RpcUrl = "", + ChainId = "0x64", // 100 + RegistryAddress = CurrentRegistry, + LegacyNonce = false, + }; + + public static readonly EthereumNetworkConfig Aurora = new() + { + Name = "aurora", + RpcUrl = "", + ChainId = "0x4e454152", // 1313161554 + RegistryAddress = "0x63eD58B671EeD12Bc1652845ba5b2CDfBff198e0", + LegacyNonce = true, + }; + + public static readonly EthereumNetworkConfig EnergyWebChain = new() + { + Name = "ewc", + RpcUrl = "", + ChainId = "0xf6", // 246 + RegistryAddress = "0xE29672f34e92b56C9169f9D485fFc8b9A136BCE4", + LegacyNonce = false, + }; + + public static readonly EthereumNetworkConfig ArtisS1 = new() + { + Name = "artis:sigma1", + RpcUrl = "", + ChainId = "0x3C401", // 246529 + RegistryAddress = LegacyRegistry, + LegacyNonce = true, + }; + + // ── Testnets ────────────────────────────────────────────────────────────── + + public static readonly EthereumNetworkConfig Sepolia = new() + { + Name = "sepolia", + RpcUrl = "", + ChainId = "0xaa36a7", // 11155111 + RegistryAddress = CurrentRegistry, + LegacyNonce = false, + }; + + public static readonly EthereumNetworkConfig Holesky = new() + { + Name = "holesky", + RpcUrl = "", + ChainId = "0x4268", // 17000 + RegistryAddress = CurrentRegistry, + LegacyNonce = false, + }; + + public static readonly EthereumNetworkConfig PolygonMumbai = new() + { + Name = "polygon:test", + RpcUrl = "", + ChainId = "0x13881", // 80001 + RegistryAddress = LegacyRegistry, + LegacyNonce = true, + }; + + public static readonly EthereumNetworkConfig Volta = new() + { + Name = "volta", + RpcUrl = "", + ChainId = "0x12047", // 73799 + RegistryAddress = "0xC15D5A57A8Eb0e1dCBE5D88B8f9a82017e5Cc4AF", + LegacyNonce = false, + }; + + public static readonly EthereumNetworkConfig ArtisT1 = new() + { + Name = "artis:tau1", + RpcUrl = "", + ChainId = "0x3C401", // 246785 — note: shares chain ID encoding w/ sigma1 in hex + RegistryAddress = LegacyRegistry, + LegacyNonce = true, + }; + + public static readonly EthereumNetworkConfig LineaGoerli = new() + { + Name = "linea:goerli", + RpcUrl = "", + ChainId = "0xe704", // 59140 + RegistryAddress = CurrentRegistry, + LegacyNonce = false, + }; + + // ── Catalogue ───────────────────────────────────────────────────────────── + + /// + /// All active (non-deprecated) known network configurations, in the same order + /// as deployments.ts. RpcUrl is empty in every entry — use + /// with { RpcUrl = "..." } to produce a ready-to-use config. + /// + public static readonly IReadOnlyList All = + [ + Mainnet, Sepolia, Gnosis, Holesky, EnergyWebChain, Volta, + ArtisT1, ArtisS1, Polygon, PolygonMumbai, Aurora, LineaGoerli, + ]; + + /// + /// Looks up a known network by name (case-insensitive) or hex chain ID (e.g. "0xaa36a7"). + /// Returns null if no match is found. + /// + public static EthereumNetworkConfig? Find(string nameOrChainId) + => All.FirstOrDefault(n => + string.Equals(n.Name, nameOrChainId, StringComparison.OrdinalIgnoreCase) || + string.Equals(n.ChainId, nameOrChainId, StringComparison.OrdinalIgnoreCase)); +} diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index 662e74d..4e0921b 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T11:36:18Z +Generated: 2026-05-29T12:14:41Z ## Scope and limitations From 0ea1d3db706182dabad52648a5329e2680ec8c3c Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 14:32:50 +0200 Subject: [PATCH 16/29] feat(ethr): sample shows current + genesis doc via DefaultDidUrlDereferencer --- samples/NetDid.Samples.DidEthr/Program.cs | 67 +++++++++++++++-------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/samples/NetDid.Samples.DidEthr/Program.cs b/samples/NetDid.Samples.DidEthr/Program.cs index b8349a0..593a28c 100644 --- a/samples/NetDid.Samples.DidEthr/Program.cs +++ b/samples/NetDid.Samples.DidEthr/Program.cs @@ -1,42 +1,63 @@ using System.Text.Json; using NetDid.Core.Crypto; +using NetDid.Core.Model; +using NetDid.Core.Resolution; using NetDid.Core.Serialization; using NetDid.Method.Ethr; using NetDid.Method.Ethr.Rpc; -// ── Option A: use KnownNetworks with record `with` (recommended) ────────────── var config = KnownNetworks.Sepolia with { RpcUrl = "https://sepolia.drpc.org" }; var http = new HttpClient { BaseAddress = new Uri(config.RpcUrl) }; var rpc = new DefaultEthereumRpcClient(http); var method = new DidEthrMethod(rpc, [config], new DefaultKeyGenerator()); -// ── Option B (DI): builder.AddDidEthr(new Dictionary { ["sepolia"] = "..." }) -// See NetDidBuilder.AddDidEthr overloads. +var resolver = new CompositeDidResolver([method]); +var dereferencer = new DefaultDidUrlDereferencer(resolver); -var did = "did:ethr:sepolia:0xf61c81096c96f97e95ac52a570966195ad6c90dd"; -Console.WriteLine($"Resolving: {did}"); -Console.WriteLine(); +const string did = "did:ethr:sepolia:0xf61c81096c96f97e95ac52a570966195ad6c90dd"; -var result = await method.ResolveAsync(did); +// ── 1. Current document ─────────────────────────────────────────────────────── +Console.WriteLine("=== Current document ==="); +await PrintDereferencedDoc(dereferencer, did); -if (result.ResolutionMetadata.Error is string err) -{ - Console.Error.WriteLine($"Resolution error: {err}"); - return; -} +// ── 2. Genesis document (state before any events, via ?versionId=0) ─────────── +Console.WriteLine("=== Genesis document (?versionId=0) ==="); +await PrintDereferencedDoc(dereferencer, $"{did}?versionId=0"); -var json = DidDocumentSerializer.Serialize(result.DidDocument!); -Console.WriteLine("=== DID Document ==="); -Console.WriteLine(JsonSerializer.Serialize( - JsonSerializer.Deserialize(json), - new JsonSerializerOptions { WriteIndented = true })); +// ── helpers ─────────────────────────────────────────────────────────────────── -Console.WriteLine(); -Console.WriteLine("=== Document Metadata ==="); -var meta = result.DocumentMetadata; -if (meta != null) +static async Task PrintDereferencedDoc(DefaultDidUrlDereferencer dereferencer, string url) { - if (meta.VersionId != null) Console.WriteLine($" versionId : {meta.VersionId}"); - if (meta.Deactivated == true) Console.WriteLine($" deactivated: true"); + Console.WriteLine($"Dereferencing: {url}"); + + var result = await dereferencer.DereferenceAsync(url); + + if (result.DereferencingMetadata.Error is string err) + { + Console.Error.WriteLine($" Error: {err}"); + Console.WriteLine(); + return; + } + + if (result.ContentStream is not DidDocument doc) + { + Console.Error.WriteLine($" Unexpected content type: {result.ContentStream?.GetType().Name}"); + Console.WriteLine(); + return; + } + + var json = DidDocumentSerializer.Serialize(doc); + Console.WriteLine(JsonSerializer.Serialize( + JsonSerializer.Deserialize(json), + new JsonSerializerOptions { WriteIndented = true })); + + if (result.ContentMetadata is { Count: > 0 } meta) + { + Console.WriteLine("Metadata:"); + foreach (var (k, v) in meta) + Console.WriteLine($" {k}: {v}"); + } + + Console.WriteLine(); } From ed04216ffd3db50b5de9c3e84e172c19d4e21572 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 14:48:38 +0200 Subject: [PATCH 17/29] docs: update README with did:ethr support (Create + Resolve) --- README.md | 114 ++++++++++++++++-- .../NetDidBuilder.cs | 2 +- 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ae9ce06..3c6cae4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A specification-compliant .NET library for Decentralized Identifiers (DIDs). Net ## Features -- **DID methods**: `did:key`, `did:peer`, and `did:webvh` (implemented), `did:ethr` (planned) +- **DID methods**: `did:key`, `did:peer`, `did:webvh`, and `did:ethr` - **Eight key types**: Ed25519, X25519, P-256, P-384, P-521, secp256k1, BLS12-381 G1/G2 - **BBS+ signatures**: Multi-message signing with selective disclosure proofs (IETF draft-10) - **W3C DID Core 1.0** compliant DID Document model and serialization @@ -25,6 +25,7 @@ dotnet add package NetDid.Core dotnet add package NetDid.Method.Key # did:key method dotnet add package NetDid.Method.Peer # did:peer method dotnet add package NetDid.Method.WebVh # did:webvh method +dotnet add package NetDid.Method.Ethr # did:ethr method dotnet add package NetDid.Extensions.DependencyInjection # Microsoft DI integration ``` @@ -250,6 +251,95 @@ var resolved = await didPeer.ResolveAsync(result.Did.Value); Short-form-only resolution returns `notFound` (requires prior long-form exchange). +## did:ethr + +`did:ethr` resolves DIDs anchored to any EVM-compatible blockchain via the [ERC-1056 registry contract](https://github.com/decentralized-identity/ethr-did-resolver). Create derives an Ethereum address from a secp256k1 key pair; resolve walks the on-chain event log to reconstruct the DID Document at any point in history. + +### Create a did:ethr + +```csharp +using NetDid.Core.Crypto; +using NetDid.Method.Ethr; +using NetDid.Method.Ethr.Rpc; + +var config = KnownNetworks.Sepolia with { RpcUrl = "https://sepolia.drpc.org" }; +var rpc = new DefaultEthereumRpcClient(new HttpClient { BaseAddress = new Uri(config.RpcUrl) }); +var method = new DidEthrMethod(rpc, [config], new DefaultKeyGenerator()); + +var result = await method.CreateAsync(new DidEthrCreateOptions { Network = "sepolia" }); + +Console.WriteLine(result.Did); +// Output: did:ethr:sepolia:0x4b0d... +``` + +No on-chain transaction is required to create a `did:ethr`. The DID is derived deterministically from the secp256k1 key pair. On-chain registration (Update/Deactivate) is planned for Phase 2. + +### Resolve a did:ethr + +```csharp +var resolved = await method.ResolveAsync( + "did:ethr:sepolia:0xf61c81096c96f97e95ac52a570966195ad6c90dd"); + +var doc = resolved.DidDocument!; +Console.WriteLine(doc.VerificationMethod![0].BlockchainAccountId); +// eip155:11155111:0xF36cAD0fb057f01F852557317bB8aa05F8c2dF4D +``` + +Resolve walks the on-chain ERC-1056 event chain (owner changes, delegate keys, attribute keys, services) and builds a W3C DID Document. Key types supported: `EcdsaSecp256k1RecoveryMethod2020` (delegates), `EcdsaSecp256k1VerificationKey2019`, `Ed25519VerificationKey2020`, `X25519KeyAgreementKey2020`, `Multikey`, and unknown types via `publicKeyHex`. + +### Historical resolution via `?versionId` + +`DefaultDidUrlDereferencer` passes `?versionId` directly through to the resolver — no extra wiring needed: + +```csharp +using NetDid.Core.Resolution; + +var dereferencer = new DefaultDidUrlDereferencer(new CompositeDidResolver([method])); + +// Genesis document — state before any on-chain events +var genesis = await dereferencer.DereferenceAsync( + "did:ethr:sepolia:0xf61c81096c96f97e95ac52a570966195ad6c90dd?versionId=0"); + +var doc = (DidDocument)genesis.ContentStream!; +Console.WriteLine(doc.VerificationMethod!.Count); // 1 — only #controller +Console.WriteLine(genesis.ContentMetadata!["nextVersionId"]); // first event block +``` + +`versionTime` (ISO-8601 wall-clock) is also supported. + +### Use an existing key + +```csharp +var existingKey = keyGen.Generate(KeyType.Secp256k1); +var signer = new KeyPairSigner(existingKey, new DefaultCryptoProvider()); + +var result = await method.CreateAsync(new DidEthrCreateOptions +{ + Network = "sepolia", + ExistingKey = signer // Must be Secp256k1; works with any ISigner +}); +``` + +### Known networks + +`KnownNetworks` mirrors the [`deployments.ts`](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/src/config/deployments.ts) catalogue from the JS reference resolver — correct registry addresses and `legacyNonce` flags pre-populated: + +| Property | Network | Chain ID | Registry | +|---|---|---|---| +| `KnownNetworks.Mainnet` | mainnet | 1 | `0xdCa7EF03…` | +| `KnownNetworks.Sepolia` | sepolia | 11155111 | `0x03d5003b…` | +| `KnownNetworks.Holesky` | holesky | 17000 | `0x03d5003b…` | +| `KnownNetworks.Gnosis` | gno | 100 | `0x03d5003b…` | +| `KnownNetworks.Polygon` | polygon | 137 | `0xdCa7EF03…` | +| `KnownNetworks.Aurora` | aurora | 1313161554 | `0x63eD58B6…` | +| + 6 more | … | … | … | + +All entries have `RpcUrl = ""`. Supply the endpoint with a `with` expression: + +```csharp +var cfg = KnownNetworks.Mainnet with { RpcUrl = "https://mainnet.gateway.tenderly.co" }; +``` + ## did:webvh `did:webvh` (DID Web with Verifiable History) combines web-based hosting with a cryptographically verifiable log of all changes. Full CRUD with hash chain integrity, pre-rotation, and witness validation. @@ -418,6 +508,11 @@ services.AddNetDid(builder => builder.AddDidKey(); builder.AddDidPeer(); builder.AddDidWebVh(); + builder.AddDidEthr(new Dictionary + { + ["mainnet"] = "https://mainnet.gateway.tenderly.co", + ["sepolia"] = "https://sepolia.drpc.org", + }); builder.AddCaching(TimeSpan.FromMinutes(15)); }); ``` @@ -478,18 +573,21 @@ netdid/ │ ├── NetDid.Method.Key/ # did:key method │ ├── NetDid.Method.Peer/ # did:peer method (numalgo 0, 2, 4) │ ├── NetDid.Method.WebVh/ # did:webvh method (full CRUD) +│ ├── NetDid.Method.Ethr/ # did:ethr method (Create + Resolve) │ └── NetDid.Extensions.DependencyInjection/ # Microsoft DI integration ├── tests/ -│ ├── NetDid.Core.Tests/ # 305 unit tests -│ ├── NetDid.Method.Key.Tests/ # 28 tests -│ ├── NetDid.Method.Peer.Tests/ # 31 tests -│ ├── NetDid.Method.WebVh.Tests/ # 70 tests +│ ├── NetDid.Core.Tests/ # 392 unit tests +│ ├── NetDid.Method.Key.Tests/ # 33 tests +│ ├── NetDid.Method.Peer.Tests/ # 40 tests +│ ├── NetDid.Method.WebVh.Tests/ # 124 tests +│ ├── NetDid.Method.Ethr.Tests/ # 39 tests │ ├── NetDid.Tests.W3CConformance/ # 175 W3C conformance tests -│ └── NetDid.Extensions.DependencyInjection.Tests/ # 10 tests +│ └── NetDid.Extensions.DependencyInjection.Tests/ # 11 tests ├── samples/ │ ├── NetDid.Samples.DidKey/ # did:key usage examples │ ├── NetDid.Samples.DidPeer/ # did:peer usage examples │ ├── NetDid.Samples.DidWebVh/ # did:webvh CRUD examples +│ ├── NetDid.Samples.DidEthr/ # did:ethr resolve + historical resolution │ └── NetDid.Samples.DependencyInjection/ # DI registration pattern └── netdid.sln ``` @@ -512,6 +610,7 @@ dotnet test dotnet run --project samples/NetDid.Samples.DidKey dotnet run --project samples/NetDid.Samples.DidPeer dotnet run --project samples/NetDid.Samples.DidWebVh +dotnet run --project samples/NetDid.Samples.DidEthr dotnet run --project samples/NetDid.Samples.DependencyInjection ``` @@ -524,7 +623,7 @@ NetDid is developed in four phases (see [NetDidPRD.md](NetDidPRD.md) for full de | **I** | Core Foundation — DID Document model, crypto primitives, encoding, serialization, resolver infrastructure | Complete | | **II** | `did:key` and `did:peer` method implementations | Complete | | **III** | `did:webvh` method implementation | Complete | -| **IV** | `did:ethr` method implementation | Planned | +| **IV** | `did:ethr` method implementation — Phase 1 (Create + Resolve) | Complete | ## Specifications @@ -536,6 +635,7 @@ NetDid targets the following specifications: | **did:key** | Latest | W3C CCG Final | [w3c-ccg.github.io/did-method-key](https://w3c-ccg.github.io/did-method-key/) | | **did:peer** | 2.0 | DIF Spec | [identity.foundation/peer-did-method-spec](https://identity.foundation/peer-did-method-spec/) | | **did:webvh** | 1.0 | DIF Recommended | [identity.foundation/didwebvh](https://identity.foundation/didwebvh/) | +| **did:ethr** | Latest | DIF Spec | [github.com/decentralized-identity/ethr-did-resolver](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md) | | **Data Integrity (eddsa-jcs-2022)** | — | W3C Candidate Recommendation | [w3.org/TR/vc-di-eddsa](https://www.w3.org/TR/vc-di-eddsa/) | | **BBS Signatures** | draft-10 | IETF CFRG Draft | [draft-irtf-cfrg-bbs-signatures](https://datatracker.ietf.org/doc/draft-irtf-cfrg-bbs-signatures/) | | **JSON Canonicalization (JCS)** | RFC 8785 | IETF Proposed Standard | [rfc-editor.org/rfc/rfc8785](https://www.rfc-editor.org/rfc/rfc8785) | diff --git a/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs b/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs index 10d273d..aeba794 100644 --- a/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs +++ b/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs @@ -93,7 +93,7 @@ public NetDidBuilder AddDidEthr(IEnumerable networks) /// /// builder.AddDidEthr(new Dictionary<string, string> /// { - /// ["mainnet"] = "https://mainnet.infura.io/v3/YOUR_KEY", + /// ["mainnet"] = "https://mainnet.gateway.tenderly.co", /// ["sepolia"] = "https://sepolia.drpc.org", /// }); /// From a94a0204be99182fd899e72bbb137bed36d202d2 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 15:06:09 +0200 Subject: [PATCH 18/29] fix(ethr): per-network RPC routing via IEthereumRpcClientFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: AddDidEthr registered a single DefaultEthereumRpcClient with no BaseAddress, so all networks shared one misconfigured HTTP client. Fix: - Add IEthereumRpcClientFactory with GetOrCreate(EthereumNetworkConfig) - DefaultEthereumRpcClientFactory (DI path): resolves named HttpClient 'ethr-{name}' whose BaseAddress is set from network.RpcUrl in AddDidEthr - DefaultEthereumRpcClientFactory.CreateDirect (non-DI path): each network gets its own HttpClient built inline — used by samples / CLI tools - DidEthrMethod constructor now takes IEthereumRpcClientFactory; resolves the correct client per-network before every RPC call - AddDidEthr registers one named client per network via Services.AddHttpClient('ethr-{name}', c => c.BaseAddress = ...) - New test: ResolveAsync_MultipleNetworks_RoutesRpcCallsToCorrectEndpoint verifies mainnet calls never reach the sepolia RPC and vice-versa 815 tests, 0 failures, 0 warnings. --- README.md | 20 +++--- samples/NetDid.Samples.DidEthr/Program.cs | 5 +- .../NetDidBuilder.cs | 13 ++-- src/NetDid.Method.Ethr/DidEthrMethod.cs | 29 +++++---- .../Rpc/DefaultEthereumRpcClientFactory.cs | 63 +++++++++++++++++++ .../Rpc/IEthereumRpcClientFactory.cs | 15 +++++ .../DidEthrMethodTests.cs | 52 ++++++++++++++- .../EthrDocumentBuilderTests.cs | 2 +- w3c-conformance-report.md | 2 +- 9 files changed, 168 insertions(+), 33 deletions(-) create mode 100644 src/NetDid.Method.Ethr/Rpc/DefaultEthereumRpcClientFactory.cs create mode 100644 src/NetDid.Method.Ethr/Rpc/IEthereumRpcClientFactory.cs diff --git a/README.md b/README.md index 3c6cae4..b5ae642 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A specification-compliant .NET library for Decentralized Identifiers (DIDs). Net ## Features -- **DID methods**: `did:key`, `did:peer`, `did:webvh`, and `did:ethr` +- **DID methods**: `did:key`, `did:peer`, `did:webvh` (implemented), and `did:ethr` (Create+Resolve) - **Eight key types**: Ed25519, X25519, P-256, P-384, P-521, secp256k1, BLS12-381 G1/G2 - **BBS+ signatures**: Multi-message signing with selective disclosure proofs (IETF draft-10) - **W3C DID Core 1.0** compliant DID Document model and serialization @@ -623,20 +623,20 @@ NetDid is developed in four phases (see [NetDidPRD.md](NetDidPRD.md) for full de | **I** | Core Foundation — DID Document model, crypto primitives, encoding, serialization, resolver infrastructure | Complete | | **II** | `did:key` and `did:peer` method implementations | Complete | | **III** | `did:webvh` method implementation | Complete | -| **IV** | `did:ethr` method implementation — Phase 1 (Create + Resolve) | Complete | +| **IV** | `did:ethr` method implementation | Create + Resolve | ## Specifications NetDid targets the following specifications: -| Specification | Version | Status | Reference | -|---|---|---|---| -| **W3C Decentralized Identifiers (DIDs)** | v1.0 | W3C Recommendation (2022-07-19) | [w3.org/TR/did-core](https://www.w3.org/TR/did-core/) | -| **did:key** | Latest | W3C CCG Final | [w3c-ccg.github.io/did-method-key](https://w3c-ccg.github.io/did-method-key/) | -| **did:peer** | 2.0 | DIF Spec | [identity.foundation/peer-did-method-spec](https://identity.foundation/peer-did-method-spec/) | -| **did:webvh** | 1.0 | DIF Recommended | [identity.foundation/didwebvh](https://identity.foundation/didwebvh/) | -| **did:ethr** | Latest | DIF Spec | [github.com/decentralized-identity/ethr-did-resolver](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md) | -| **Data Integrity (eddsa-jcs-2022)** | — | W3C Candidate Recommendation | [w3.org/TR/vc-di-eddsa](https://www.w3.org/TR/vc-di-eddsa/) | +| Specification | Version | Status | Reference | +|---|----------|---|---| +| **W3C Decentralized Identifiers (DIDs)** | v1.0 | W3C Recommendation (2022-07-19) | [w3.org/TR/did-core](https://www.w3.org/TR/did-core/) | +| **did:key** | Latest | W3C CCG Final | [w3c-ccg.github.io/did-method-key](https://w3c-ccg.github.io/did-method-key/) | +| **did:peer** | 2.0 | DIF Spec | [identity.foundation/peer-did-method-spec](https://identity.foundation/peer-did-method-spec/) | +| **did:webvh** | 1.0 | DIF Recommended | [identity.foundation/didwebvh](https://identity.foundation/didwebvh/) | +| **did:ethr** | 13.0.0 | DIF Spec | [github.com/decentralized-identity/ethr-did-resolver](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md) | +| **Data Integrity (eddsa-jcs-2022)** | — | W3C Candidate Recommendation | [w3.org/TR/vc-di-eddsa](https://www.w3.org/TR/vc-di-eddsa/) | | **BBS Signatures** | draft-10 | IETF CFRG Draft | [draft-irtf-cfrg-bbs-signatures](https://datatracker.ietf.org/doc/draft-irtf-cfrg-bbs-signatures/) | | **JSON Canonicalization (JCS)** | RFC 8785 | IETF Proposed Standard | [rfc-editor.org/rfc/rfc8785](https://www.rfc-editor.org/rfc/rfc8785) | diff --git a/samples/NetDid.Samples.DidEthr/Program.cs b/samples/NetDid.Samples.DidEthr/Program.cs index 593a28c..83bd1b4 100644 --- a/samples/NetDid.Samples.DidEthr/Program.cs +++ b/samples/NetDid.Samples.DidEthr/Program.cs @@ -8,9 +8,8 @@ var config = KnownNetworks.Sepolia with { RpcUrl = "https://sepolia.drpc.org" }; -var http = new HttpClient { BaseAddress = new Uri(config.RpcUrl) }; -var rpc = new DefaultEthereumRpcClient(http); -var method = new DidEthrMethod(rpc, [config], new DefaultKeyGenerator()); +var factory = DefaultEthereumRpcClientFactory.CreateDirect([config]); +var method = new DidEthrMethod(factory, [config], new DefaultKeyGenerator()); var resolver = new CompositeDidResolver([method]); var dereferencer = new DefaultDidUrlDereferencer(resolver); diff --git a/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs b/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs index aeba794..c5b18da 100644 --- a/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs +++ b/src/NetDid.Extensions.DependencyInjection/NetDidBuilder.cs @@ -75,12 +75,17 @@ public NetDidBuilder AddCaching(TimeSpan ttl) public NetDidBuilder AddDidEthr(IEnumerable networks) { var networkList = networks.ToList(); - Services.AddHttpClient(); - Services.AddSingleton( - sp => sp.GetRequiredService()); + + // Register a named HttpClient for each network, pre-configured with its RPC URL. + // DefaultEthereumRpcClientFactory resolves "ethr-{name}" to get the right endpoint. + foreach (var n in networkList) + Services.AddHttpClient($"ethr-{n.Name}", + c => c.BaseAddress = new Uri(n.RpcUrl)); + + Services.AddSingleton(); Services.AddSingleton(sp => new DidEthrMethod( - sp.GetRequiredService(), + sp.GetRequiredService(), networkList, sp.GetRequiredService(), sp.GetService>())); diff --git a/src/NetDid.Method.Ethr/DidEthrMethod.cs b/src/NetDid.Method.Ethr/DidEthrMethod.cs index bb5cb81..a54ca61 100644 --- a/src/NetDid.Method.Ethr/DidEthrMethod.cs +++ b/src/NetDid.Method.Ethr/DidEthrMethod.cs @@ -17,18 +17,18 @@ namespace NetDid.Method.Ethr; /// public sealed class DidEthrMethod : DidMethodBase { - private readonly IEthereumRpcClient _rpc; + private readonly IEthereumRpcClientFactory _rpcFactory; private readonly IReadOnlyList _networks; private readonly IKeyGenerator _keyGenerator; private readonly ILogger _logger; public DidEthrMethod( - IEthereumRpcClient rpc, + IEthereumRpcClientFactory rpcFactory, IEnumerable networks, IKeyGenerator keyGenerator, ILogger? logger = null) { - _rpc = rpc ?? throw new ArgumentNullException(nameof(rpc)); + _rpcFactory = rpcFactory ?? throw new ArgumentNullException(nameof(rpcFactory)); _networks = networks?.ToList() ?? throw new ArgumentNullException(nameof(networks)); _keyGenerator = keyGenerator ?? throw new ArgumentNullException(nameof(keyGenerator)); _logger = logger ?? NullLogger.Instance; @@ -69,7 +69,8 @@ protected override async Task CreateCoreAsync( var address = EthereumAddress.FromCompressedPublicKey(publicKey).ToLowerInvariant(); var network = FindNetwork(ethrOptions.Network); - var chainId = await ResolveChainId(network, ct); + var rpc = _rpcFactory.GetOrCreate(network); + var chainId = await ResolveChainId(network, rpc, ct); var did = $"did:ethr:{ethrOptions.Network.ToLowerInvariant()}:{address}"; var identifier = new EthrIdentifier(ethrOptions.Network.ToLowerInvariant(), address, false, null); @@ -96,7 +97,8 @@ protected override async Task ResolveCoreAsync( } var network = FindNetwork(identifier.Network); - var chainId = await ResolveChainId(network, ct); + var rpc = _rpcFactory.GetOrCreate(network); + var chainId = await ResolveChainId(network, rpc, ct); // Determine version / block ceiling ulong? versionBlockNumber = null; @@ -104,8 +106,8 @@ protected override async Task ResolveCoreAsync( versionBlockNumber = vb; // changed(identity) → first block that has a relevant event - var changedHex = Erc1056Calls.Changed(identifier.IdentityAddress); - var changedResult = await _rpc.CallAsync(network.RegistryAddress, changedHex, ct); + var changedHex = Erc1056Calls.Changed(identifier.IdentityAddress); + var changedResult = await rpc.CallAsync(network.RegistryAddress, changedHex, ct); var latestChange = ParseHexUlong(changedResult); // Collect events walking backwards from min(latestChange, versionBlock) @@ -116,7 +118,7 @@ protected override async Task ResolveCoreAsync( var collectedEvents = new List(); if (ceiling > 0) await WalkEventChainAsync( - network.RegistryAddress, identifier.IdentityAddress, + rpc, network.RegistryAddress, identifier.IdentityAddress, ceiling, collectedEvents, ct); // Oldest-first @@ -128,7 +130,7 @@ await WalkEventChainAsync( if (versionBlockNumber.HasValue) { - var ts = await _rpc.GetBlockTimestampAsync(versionBlockNumber.Value, ct); + var ts = await rpc.GetBlockTimestampAsync(versionBlockNumber.Value, ct); referenceTime = DateTimeOffset.FromUnixTimeSeconds((long)ts); // Peek at the next change block after versionBlockNumber for metadata @@ -146,7 +148,7 @@ await WalkEventChainAsync( { if (!blockTsCache.TryGetValue(ev.BlockNumber, out var bts)) { - bts = await _rpc.GetBlockTimestampAsync(ev.BlockNumber, ct); + bts = await rpc.GetBlockTimestampAsync(ev.BlockNumber, ct); blockTsCache[ev.BlockNumber] = bts; } if (DateTimeOffset.FromUnixTimeSeconds((long)bts) <= referenceTime) @@ -191,6 +193,7 @@ await WalkEventChainAsync( // ── Event chain walker ──────────────────────────────────────────────────── private async Task WalkEventChainAsync( + IEthereumRpcClient rpc, string registryAddress, string identityAddress, ulong fromBlock, List accumulator, CancellationToken ct) { @@ -209,7 +212,7 @@ private async Task WalkEventChainAsync( ]], }; - var logs = await _rpc.GetLogsAsync(filter, ct); + var logs = await rpc.GetLogsAsync(filter, ct); ulong previousChange = 0; foreach (var log in logs) @@ -249,7 +252,7 @@ private EthereumNetworkConfig FindNetwork(string network) return match; } - private async Task ResolveChainId(EthereumNetworkConfig network, CancellationToken ct) + private async Task ResolveChainId(EthereumNetworkConfig network, IEthereumRpcClient rpc, CancellationToken ct) { if (network.ChainId is not null) { @@ -257,7 +260,7 @@ private async Task ResolveChainId(EthereumNetworkConfig network, Cancell ? network.ChainId[2..] : network.ChainId; return Convert.ToUInt64(hex, 16).ToString(); } - var chainId = await _rpc.GetChainIdAsync(ct); + var chainId = await rpc.GetChainIdAsync(ct); return chainId.ToString(); } diff --git a/src/NetDid.Method.Ethr/Rpc/DefaultEthereumRpcClientFactory.cs b/src/NetDid.Method.Ethr/Rpc/DefaultEthereumRpcClientFactory.cs new file mode 100644 index 0000000..d66e004 --- /dev/null +++ b/src/NetDid.Method.Ethr/Rpc/DefaultEthereumRpcClientFactory.cs @@ -0,0 +1,63 @@ +using System.Collections.Concurrent; + +namespace NetDid.Method.Ethr.Rpc; + +/// +/// Default implementation of . +/// Uses to obtain named instances +/// whose base addresses are pre-configured via DI (see NetDidBuilder.AddDidEthr). +/// One is created per network and cached for reuse. +/// +public sealed class DefaultEthereumRpcClientFactory : IEthereumRpcClientFactory +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + + public DefaultEthereumRpcClientFactory(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + } + + /// + public IEthereumRpcClient GetOrCreate(EthereumNetworkConfig network) + { + ArgumentNullException.ThrowIfNull(network); + return _cache.GetOrAdd(network.Name, _ => + { + // Named client "ethr-{networkName}" is registered by AddDidEthr with + // BaseAddress = network.RpcUrl so each network talks to its own endpoint. + var http = _httpClientFactory.CreateClient($"ethr-{network.Name}"); + return new DefaultEthereumRpcClient(http); + }); + } + + /// + /// Creates a factory for non-DI scenarios (samples, CLI tools, tests). + /// Each network gets a dedicated with its + /// set as the base address. Prefer the DI path (AddDidEthr) in production. + /// + public static IEthereumRpcClientFactory CreateDirect(IEnumerable networks) + { + var clients = networks.ToDictionary( + n => n.Name, + n => (IEthereumRpcClient)new DefaultEthereumRpcClient( + new HttpClient { BaseAddress = new Uri(n.RpcUrl) }), + StringComparer.OrdinalIgnoreCase); + return new FixedClientFactory(clients); + } + + // Used by CreateDirect — maps network name → pre-built client. + private sealed class FixedClientFactory : IEthereumRpcClientFactory + { + private readonly IReadOnlyDictionary _clients; + + internal FixedClientFactory(Dictionary clients) + => _clients = clients; + + public IEthereumRpcClient GetOrCreate(EthereumNetworkConfig network) + => _clients.TryGetValue(network.Name, out var c) ? c + : throw new InvalidOperationException( + $"No RPC client configured for network '{network.Name}'. " + + $"Known networks: {string.Join(", ", _clients.Keys)}"); + } +} diff --git a/src/NetDid.Method.Ethr/Rpc/IEthereumRpcClientFactory.cs b/src/NetDid.Method.Ethr/Rpc/IEthereumRpcClientFactory.cs new file mode 100644 index 0000000..202e26c --- /dev/null +++ b/src/NetDid.Method.Ethr/Rpc/IEthereumRpcClientFactory.cs @@ -0,0 +1,15 @@ +namespace NetDid.Method.Ethr.Rpc; + +/// +/// Creates or returns a cached for a given network. +/// Each network's client is configured with the correct RPC base URL so that +/// multi-network configurations never query the wrong chain. +/// +public interface IEthereumRpcClientFactory +{ + /// + /// Returns the for , + /// creating and caching it on first call. + /// + IEthereumRpcClient GetOrCreate(EthereumNetworkConfig network); +} diff --git a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs index 341bd7b..9c0b5fb 100644 --- a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs @@ -24,7 +24,13 @@ public class DidEthrMethodTests }; private static DidEthrMethod MakeMethod(IEthereumRpcClient rpc) - => new(rpc, [SepoliaConfig], new DefaultKeyGenerator()); + { + // Wrap the single mock in a factory so all network lookups return it. + // Tests that care about per-network routing construct the factory themselves. + var factory = Substitute.For(); + factory.GetOrCreate(Arg.Any()).Returns(rpc); + return new DidEthrMethod(factory, [SepoliaConfig], new DefaultKeyGenerator()); + } private static ISigner MakeSigner(KeyPair keyPair) => new KeyPairSigner(keyPair, new DefaultCryptoProvider()); @@ -238,6 +244,50 @@ public async Task DeactivateAsync_ThrowsOperationNotSupportedException() await act.Should().ThrowAsync(); } + // ── Multi-network routing ───────────────────────────────────────────────── + + [Fact] + public async Task ResolveAsync_MultipleNetworks_RoutesRpcCallsToCorrectEndpoint() + { + // Two networks, each backed by a distinct RPC mock. + const string mainnetIdentity = "0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + const string sepoliaIdentity = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + + var mainnetConfig = new EthereumNetworkConfig + { + Name = "mainnet", + RpcUrl = "https://mainnet.example", + ChainId = "0x1", + RegistryAddress = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B", + }; + + var mainnetRpc = Substitute.For(); + var sepoliaRpc = Substitute.For(); + + // Both return "no events" so resolution completes without further RPC calls. + var zero = "0x" + new string('0', 64); + mainnetRpc.CallAsync(default!, default!, default).ReturnsForAnyArgs(zero); + sepoliaRpc.CallAsync(default!, default!, default).ReturnsForAnyArgs(zero); + + var factory = Substitute.For(); + factory.GetOrCreate(Arg.Is(n => n.Name == "mainnet")).Returns(mainnetRpc); + factory.GetOrCreate(Arg.Is(n => n.Name == "sepolia")).Returns(sepoliaRpc); + + var method = new DidEthrMethod(factory, [mainnetConfig, SepoliaConfig], new DefaultKeyGenerator()); + + // Resolve a mainnet DID — only mainnet RPC should be called. + await method.ResolveAsync($"did:ethr:mainnet:{mainnetIdentity}"); + await mainnetRpc.Received().CallAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sepoliaRpc.DidNotReceive().CallAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + // Resolve a sepolia DID — only sepolia RPC should be called. + sepoliaRpc.ClearReceivedCalls(); + mainnetRpc.ClearReceivedCalls(); + await method.ResolveAsync($"did:ethr:sepolia:{sepoliaIdentity}"); + await sepoliaRpc.Received().CallAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await mainnetRpc.DidNotReceive().CallAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + // ── Helpers ─────────────────────────────────────────────────────────────── private static IReadOnlyList BuildOwnerChangedLog( diff --git a/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs index de9fb88..36e3af9 100644 --- a/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs @@ -211,7 +211,7 @@ public void Build_PublicKeyIdentifier_NoOwnerChange_AddsControllerKeyVm() doc.VerificationMethod.Should().HaveCount(2); doc.VerificationMethod!.Should().Contain(v => v.Id.EndsWith("#controllerKey")); - var ckVm = doc.VerificationMethod.Single(v => v.Id.EndsWith("#controllerKey")); + var ckVm = doc.VerificationMethod!.Single(v => v.Id.EndsWith("#controllerKey")); ckVm.Type.Should().Be("EcdsaSecp256k1VerificationKey2019"); ckVm.PublicKeyJwk.Should().NotBeNull(); } diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index 4e0921b..e7447b5 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T12:14:41Z +Generated: 2026-05-29T13:05:44Z ## Scope and limitations From dc10610047ed4f679b1eafb07795a0d554404ca3 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 15:26:33 +0200 Subject: [PATCH 19/29] fix(ethr): non-terminating walker + same-block event ordering regression tests Bug: WalkEventChainAsync took max(previousChange) across all events in a block. Later transactions in the same block emit previousChange == block.number (because changed[identity] was already updated by an earlier tx in the same block), so the walker revisited the same block indefinitely. Fix: only advance to a previousChange value that is STRICTLY less than currentBlock. This guarantees progress on every iteration. Tests added (EthrDocumentBuilderTests): - Build_DelegateAddedAndRevokedInSameBlock_NotInDocument - Build_AddTwoDelegatesRevokeOneInSameBlock_ThenAddThird_IndicesAreStable - Build_RevokeBeforeAddInSameBlock_KeyAppearsAtAddCounter Tests added (DidEthrMethodTests): - ResolveAsync_TwoEventsInSameBlock_WalkerTerminatesAndCollectsBothDelegates (uses call-count guard to detect re-entry into the same block) --- src/NetDid.Method.Ethr/DidEthrMethod.cs | 16 +++- .../DidEthrMethodTests.cs | 65 +++++++++++++ .../EthrDocumentBuilderTests.cs | 95 +++++++++++++++++++ w3c-conformance-report.md | 2 +- 4 files changed, 173 insertions(+), 5 deletions(-) diff --git a/src/NetDid.Method.Ethr/DidEthrMethod.cs b/src/NetDid.Method.Ethr/DidEthrMethod.cs index a54ca61..bab92e2 100644 --- a/src/NetDid.Method.Ethr/DidEthrMethod.cs +++ b/src/NetDid.Method.Ethr/DidEthrMethod.cs @@ -213,7 +213,14 @@ private async Task WalkEventChainAsync( }; var logs = await rpc.GetLogsAsync(filter, ct); - ulong previousChange = 0; + + // nextBlock = the highest previousChange value that is STRICTLY less than + // currentBlock. Later transactions in the same block emit + // previousChange == currentBlock (because changed[identity] was already + // updated by an earlier tx in the block); following those values would + // revisit the same block and loop forever. Only values < currentBlock + // represent a genuinely earlier block in the chain. + ulong nextBlock = 0; foreach (var log in logs) { @@ -224,8 +231,9 @@ private async Task WalkEventChainAsync( StringComparison.OrdinalIgnoreCase)) continue; accumulator.Add(ev); - if (ev.PreviousChange > previousChange) - previousChange = ev.PreviousChange; + // Advance only when previousChange points to a strictly earlier block. + if (ev.PreviousChange < currentBlock && ev.PreviousChange > nextBlock) + nextBlock = ev.PreviousChange; } catch (ArgumentException ex) { @@ -233,7 +241,7 @@ private async Task WalkEventChainAsync( } } - currentBlock = previousChange; + currentBlock = nextBlock; } } diff --git a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs index 9c0b5fb..44c9e2d 100644 --- a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs @@ -328,6 +328,71 @@ private static IReadOnlyList BuildDelegateLog( }]; } + private static EthereumLogEntry SingleDelegateLog( + string identity, string delegate20, string delegateType, + ulong validTo, ulong prev, ulong block) + => BuildDelegateLog(identity, delegate20, delegateType, validTo, prev, block)[0]; + + // ── Walker non-termination regression ─────────────────────────────────────── + + /// + /// When a block contains multiple events for the same identity, later transactions + /// in that block emit previousChange == block.number (the value that changed[identity] + /// was set to by an earlier transaction in the same block). The old walker took + /// max(previousChange), which equalled the current block and looped forever. + /// + /// This test detects the regression by throwing on the second visit to the same block, + /// so a buggy walker fails fast instead of hanging the test runner. + /// + [Fact] + public async Task ResolveAsync_TwoEventsInSameBlock_WalkerTerminatesAndCollectsBothDelegates() + { + const string identity = "0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + const string keyA = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; + const string keyB = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + const ulong eventBlock = 50UL; + var future = (ulong)(DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 3600); + + var rpc = Substitute.For(); + + // changed() returns block 50 + rpc.CallAsync(default!, default!, default) + .ReturnsForAnyArgs("0x" + eventBlock.ToString("x64")); + + // Guard: throw if any block is fetched more than once (detects the old infinite loop) + var fetchCounts = new Dictionary(); + rpc.GetLogsAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + var filter = call.Arg(); + fetchCounts.TryGetValue(filter.FromBlock, out var n); + if (n >= 1) + throw new InvalidOperationException( + $"Block {filter.FromBlock} was fetched {n + 1} times — walker did not terminate."); + fetchCounts[filter.FromBlock] = n + 1; + + if (filter.FromBlock != eventBlock) + return Task.FromResult>([]); + + // Block 50 has two delegate events: + // Tx 0 — prevChange = 0 (first event ever for this identity) + // Tx 1 — prevChange = eventBlock (same-block back-reference, the problematic case) + return Task.FromResult>( + [ + SingleDelegateLog(identity, keyA, "veriKey", future, 0UL, eventBlock), + SingleDelegateLog(identity, keyB, "veriKey", future, eventBlock, eventBlock), + ]); + }); + + var result = await MakeMethod(rpc).ResolveAsync($"did:ethr:sepolia:{identity}"); + + result.ResolutionMetadata.Error.Should().BeNull(); + result.DidDocument!.VerificationMethod.Should().HaveCount(3, + "#controller + keyA (#delegate-1) + keyB (#delegate-2)"); + // Block 50 must have been fetched exactly once + fetchCounts[eventBlock].Should().Be(1); + } + private static string PadAddress(string addr) { var hex = addr.StartsWith("0x") ? addr[2..] : addr; diff --git a/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs index 36e3af9..071d20c 100644 --- a/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs @@ -227,4 +227,99 @@ public void Build_NoEvents_ContextIncludesSecp256k1RecoveryContext() doc.Context.Should().Contain(c => c.ToString()!.Contains("secp256k1recovery")); } + + // ── Same-block event ordering (PR feedback regression cases) ───────────── + + /// + /// A delegate added and revoked within the same block must not appear in + /// the resulting document. The revocation event's counter still consumes + /// slot 2, so any subsequent delegates pick up from slot 3. + /// + [Fact] + public void Build_DelegateAddedAndRevokedInSameBlock_NotInDocument() + { + const string keyA = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; + var future = (ulong)(Now.ToUnixTimeSeconds() + 3600); + + var events = new List + { + // Tx 0: add keyA (prevChange = 0 — no prior history) + new DelegateChangedEvent(Address, "veriKey", keyA, future, 0UL, 50UL), + // Tx 1: revoke keyA (prevChange = 50 — same-block back-reference) + new DelegateChangedEvent(Address, "veriKey", keyA, 1UL, 50UL, 50UL), + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + + doc.VerificationMethod.Should().HaveCount(1, "only #controller; keyA was revoked"); + doc.AssertionMethod.Should().HaveCount(1, "only #controller reference"); + } + + /// + /// Add keyA (#delegate-1), add keyB (#delegate-2), revoke keyA (#delegate-3 counter + /// consumed), all in block 10. Then add keyC in block 11 (#delegate-4). + /// keyB must be #delegate-2 and keyC must be #delegate-4. + /// + [Fact] + public void Build_AddTwoDelegatesRevokeOneInSameBlock_ThenAddThird_IndicesAreStable() + { + const string keyA = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; + const string keyB = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + const string keyC = "0xd1220a0cf47c7b9be7a2e6ba89f429762e7b9adb"; + var future = (ulong)(Now.ToUnixTimeSeconds() + 3600); + + var events = new List + { + // block 10, tx 0: add keyA → counter 1 + new DelegateChangedEvent(Address, "veriKey", keyA, future, 0UL, 10UL), + // block 10, tx 1: add keyB → counter 2 + new DelegateChangedEvent(Address, "veriKey", keyB, future, 10UL, 10UL), + // block 10, tx 2: revoke keyA → counter 3 consumed (entry removed) + new DelegateChangedEvent(Address, "veriKey", keyA, 1UL, 10UL, 10UL), + // block 11, tx 0: add keyC → counter 4 + new DelegateChangedEvent(Address, "veriKey", keyC, future, 10UL, 11UL), + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + + doc.VerificationMethod.Should().HaveCount(3, "#controller + keyB + keyC"); + doc.VerificationMethod!.Should() + .Contain(v => v.Id == $"{Did}#delegate-2", "keyB must keep its original counter"); + doc.VerificationMethod.Should() + .Contain(v => v.Id == $"{Did}#delegate-4", "keyC receives counter 4, not 3"); + doc.VerificationMethod.Should() + .NotContain(v => v.Id.Contains("#delegate-1"), "keyA was revoked"); + doc.VerificationMethod.Should() + .NotContain(v => v.Id.Contains("#delegate-3"), "counter 3 consumed by revocation"); + } + + /// + /// Revoking a key that was never previously registered (counter 1 consumed by the + /// revocation), then adding it in the same block (counter 2) — the key must appear + /// as #delegate-2, not #delegate-1. + /// + [Fact] + public void Build_RevokeBeforeAddInSameBlock_KeyAppearsAtAddCounter() + { + const string keyA = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; + var future = (ulong)(Now.ToUnixTimeSeconds() + 3600); + + var events = new List + { + // Tx 0: revoke keyA (never registered) → counter 1 consumed, entry absent + new DelegateChangedEvent(Address, "veriKey", keyA, 1UL, 0UL, 50UL), + // Tx 1: add keyA → counter 2 + new DelegateChangedEvent(Address, "veriKey", keyA, future, 50UL, 50UL), + }; + + var doc = EthrDocumentBuilder.Build(Did, MakeIdentifier(), ChainId, events, Now, false); + + doc.VerificationMethod.Should().HaveCount(2, "#controller + keyA"); + doc.VerificationMethod!.Should() + .Contain(v => v.Id == $"{Did}#delegate-2", + "keyA must be at counter 2, not counter 1"); + doc.VerificationMethod.Should() + .NotContain(v => v.Id.Contains("#delegate-1"), + "counter 1 was consumed by the revocation-before-registration"); + } } diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index e7447b5..847436d 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T13:05:44Z +Generated: 2026-05-29T13:26:19Z ## Scope and limitations From 21c1a4948e51c1c42ec777e9e99dde763c91ab96 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 15:41:14 +0200 Subject: [PATCH 20/29] fix(ethr): filter eth_getLogs by identity address at topics[1] Previously the filter only constrained topics[0] (event signatures), which pulled every ERC-1056 event for ALL identities at each block visited by the walker. On busy networks this bloats the RPC response with irrelevant logs and shifts the server-side filtering work to the client. Changes: - EthereumLogFilter.Topics widened to IReadOnlyList? so null entries correctly express 'match any topic at this position' per the eth_getLogs spec. - WalkEventChainAsync now sets topics[1] = [paddedIdentityAddress], giving the node a precise AND-filter: event sig in {sig1,sig2,sig3} AND indexed-identity == address. - The identity filter guard (string.Equals check after parsing) is kept as a defence-in-depth safeguard for nodes that do not respect topic filters. Regression test added: ResolveAsync_EventChainWalking_FiltersLogsByIdentityAddressAtTopicsPosition1 --- src/NetDid.Method.Ethr/DidEthrMethod.cs | 18 +++++--- .../Rpc/EthereumLogFilter.cs | 10 ++++- .../DidEthrMethodTests.cs | 44 +++++++++++++++++++ w3c-conformance-report.md | 2 +- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/NetDid.Method.Ethr/DidEthrMethod.cs b/src/NetDid.Method.Ethr/DidEthrMethod.cs index bab92e2..2005786 100644 --- a/src/NetDid.Method.Ethr/DidEthrMethod.cs +++ b/src/NetDid.Method.Ethr/DidEthrMethod.cs @@ -200,16 +200,24 @@ private async Task WalkEventChainAsync( var currentBlock = fromBlock; while (currentBlock > 0) { + var paddedIdentity = "0x" + identityAddress[2..].PadLeft(64, '0').ToLowerInvariant(); var filter = new EthereumLogFilter { Address = registryAddress, FromBlock = currentBlock, ToBlock = currentBlock, - Topics = [[ - Erc1056Topics.DIDOwnerChanged, - Erc1056Topics.DIDDelegateChanged, - Erc1056Topics.DIDAttributeChanged, - ]], + // topics[0]: event signature OR-list + // topics[1]: indexed identity address — server-side filter eliminates + // events for other identities, cutting RPC payload on busy networks. + Topics = + [ + [ + Erc1056Topics.DIDOwnerChanged, + Erc1056Topics.DIDDelegateChanged, + Erc1056Topics.DIDAttributeChanged, + ], + [paddedIdentity], + ], }; var logs = await rpc.GetLogsAsync(filter, ct); diff --git a/src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs b/src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs index 8d327f8..9ea677c 100644 --- a/src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs +++ b/src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs @@ -6,6 +6,12 @@ public sealed record EthereumLogFilter public required string Address { get; init; } public required ulong FromBlock { get; init; } public required ulong ToBlock { get; init; } - /// topics[0] = OR-list of event signatures to match. - public IReadOnlyList? Topics { get; init; } + /// + /// Positional topic filter matching the eth_getLogs spec. + /// Each element of the outer list corresponds to a topic position (0-indexed). + /// A null element means "match any topic at this position". + /// A non-null element is an OR-list: the log matches if topics[i] equals any entry. + /// Example: [[sig1,sig2], [paddedAddress]] → topics[0] in {sig1,sig2} AND topics[1]=paddedAddress. + /// + public IReadOnlyList? Topics { get; init; } } diff --git a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs index 44c9e2d..deec14e 100644 --- a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs @@ -333,6 +333,50 @@ private static EthereumLogEntry SingleDelegateLog( ulong validTo, ulong prev, ulong block) => BuildDelegateLog(identity, delegate20, delegateType, validTo, prev, block)[0]; + // ── topics[1] identity filter ─────────────────────────────────────────────── + + /// + /// eth_getLogs MUST constrain topics[1] to the specific identity address so we + /// don't pull every event emitted by the registry for other identities at the + /// same block. This is essential on busy networks where many DIDs change in + /// the same block. + /// + [Fact] + public async Task ResolveAsync_EventChainWalking_FiltersLogsByIdentityAddressAtTopicsPosition1() + { + const string identity = "0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + const ulong eventBlock = 7; + + EthereumLogFilter? capturedFilter = null; + var rpc = Substitute.For(); + + // changed() returns block 7 — triggers one trip through the walker + rpc.CallAsync(default!, default!, default) + .ReturnsForAnyArgs("0x" + eventBlock.ToString("x64")); + + rpc.GetLogsAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + capturedFilter = call.Arg(); + return Task.FromResult>([]); + }); + + await MakeMethod(rpc).ResolveAsync($"did:ethr:sepolia:{identity}"); + + capturedFilter.Should().NotBeNull("GetLogsAsync must have been called"); + + // topics[0] = event signature OR-list + capturedFilter!.Topics.Should().NotBeNull(); + capturedFilter.Topics!.Count.Should().BeGreaterThanOrEqualTo(2, + "filter must specify at least topics[0] (signatures) and topics[1] (identity)"); + + // topics[1] must be exactly the 32-byte padded identity address + var expectedTopic1 = "0x" + identity[2..].PadLeft(64, '0'); + capturedFilter.Topics[1].Should().ContainSingle() + .Which.Should().Be(expectedTopic1, + "filtering on topics[1] avoids fetching events for unrelated identities"); + } + // ── Walker non-termination regression ─────────────────────────────────────── /// diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index 847436d..f4ed248 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T13:26:19Z +Generated: 2026-05-29T13:41:03Z ## Scope and limitations From 6a47c0c89e366ff49a9824535c0e30f668de5f0c Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 15:51:19 +0200 Subject: [PATCH 21/29] fix(ethr): harden AbiDecoder.DecodeDynamicBytes against malformed RPC payloads Five explicit ArgumentException guards (PR review): 1. offsetInData out of bounds 2. pointer ulong > int.MaxValue (overflow guard) 3. pointer value exceeds data.Length 4. length ulong > int.MaxValue (overflow guard) 5. pointer + 32 + length exceeds data.Length (DoS / giant allocation guard) All five paths covered by new regression tests (AbiDecoderTests). No raw runtime exceptions can escape from untrusted event data. --- src/NetDid.Method.Ethr/Abi/AbiDecoder.cs | 44 ++++++++++- .../AbiDecoderTests.cs | 74 +++++++++++++++++++ w3c-conformance-report.md | 2 +- 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs b/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs index b6393c6..9d09e45 100644 --- a/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs +++ b/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs @@ -48,13 +48,49 @@ public static string DecodeBytes32AsString(ReadOnlySpan word32) /// Decodes a dynamic ABI bytes value. The data span must start at offset 0 of the /// full event data, and gives the byte position of the /// ABI offset word that points to the length-prefixed payload. + /// + /// All bounds and overflow conditions are validated before any slice or allocation; + /// malformed payloads from untrusted RPC endpoints throw . /// public static byte[] DecodeDynamicBytes(ReadOnlySpan data, int offsetInData) { - // The word at offsetInData is the ABI offset pointer (uint256) relative to the - // start of the data blob. For event data decoded here the offset is absolute. - var pointer = (int)BinaryPrimitives.ReadUInt64BigEndian(data[(offsetInData + 24)..][..8]); - var length = (int)BinaryPrimitives.ReadUInt64BigEndian(data[(pointer + 24)..][..8]); + // 1. Validate offsetInData: we need 32 bytes for the ABI offset word. + if (offsetInData < 0 || offsetInData + 32 > data.Length) + throw new ArgumentException( + $"offset {offsetInData} is out of range: need offset+32={offsetInData + 32} bytes " + + $"but data is only {data.Length} bytes.", + nameof(offsetInData)); + + // 2. Read the pointer as ulong; reject values that overflow int or exceed the buffer. + var pointerRaw = BinaryPrimitives.ReadUInt64BigEndian(data[(offsetInData + 24)..][..8]); + if (pointerRaw > (ulong)int.MaxValue) + throw new ArgumentException( + $"ABI pointer value {pointerRaw} overflows int.MaxValue. Payload is malformed.", + nameof(data)); + var pointer = (int)pointerRaw; + + // 3. Validate pointer: need pointer + 32 bytes for the length word. + if (pointer + 32 > data.Length) + throw new ArgumentException( + $"ABI pointer {pointer} is out of range: need pointer+32={pointer + 32} bytes " + + $"but data is only {data.Length} bytes.", + nameof(data)); + + // 4. Read the length as ulong; reject values that overflow int. + var lengthRaw = BinaryPrimitives.ReadUInt64BigEndian(data[(pointer + 24)..][..8]); + if (lengthRaw > (ulong)int.MaxValue) + throw new ArgumentException( + $"ABI length value {lengthRaw} overflows int.MaxValue. Payload is malformed.", + nameof(data)); + var length = (int)lengthRaw; + + // 5. Validate the payload slice fits within the buffer. + if (pointer + 32 + length > data.Length) + throw new ArgumentException( + $"ABI length {length} at pointer {pointer} exceeds data bounds: " + + $"need {pointer + 32 + length} bytes but data is only {data.Length} bytes.", + nameof(data)); + return data[(pointer + 32)..(pointer + 32 + length)].ToArray(); } diff --git a/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs b/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs index 0bb2fb4..bd4f15b 100644 --- a/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs @@ -127,4 +127,78 @@ public void DecodeAttributeChangedData_ReturnsNameValueValidToPrev() validTo.Should().Be(1UL); prev.Should().Be(0UL); } + + // ── DecodeDynamicBytes — bounds / overflow guards (PR review) ───────────── + + [Fact] + public void DecodeDynamicBytes_OffsetInDataBeyondSpan_ThrowsArgumentException() + { + // offsetInData points past the end of the data blob entirely + var data = new byte[64]; + var act = () => AbiDecoder.DecodeDynamicBytes(data, offsetInData: 40); + // offsetInData + 32 = 72 > 64 — must fail with controlled ArgumentException + act.Should().Throw() + .WithMessage("*offset*"); + } + + [Fact] + public void DecodeDynamicBytes_PointerBeyondSpan_ThrowsArgumentException() + { + // The offset word itself is in-range, but the pointer VALUE it encodes + // points beyond the data buffer. + var data = new byte[64]; + // Encode pointer = 500 (well beyond data.Length=64) in word at offsetInData=0 + data[31] = 0xF4; // 244 … no: 500 = 0x01F4 + data[30] = 0x01; + data[31] = 0xF4; + var act = () => AbiDecoder.DecodeDynamicBytes(data, offsetInData: 0); + act.Should().Throw() + .WithMessage("*pointer*"); + } + + [Fact] + public void DecodeDynamicBytes_LengthBeyondSpan_ThrowsArgumentException() + { + // Pointer is valid, but the length word encodes a value that would make + // pointer + 32 + length exceed data.Length. + // Layout: [offset-word(32)] [length-word(32)] [no payload bytes] + var data = new byte[64]; + // pointer = 32 (offset word says "payload starts at byte 32") + data[31] = 32; + // length = 100 (way beyond the remaining data) + data[32 + 31] = 100; + var act = () => AbiDecoder.DecodeDynamicBytes(data, offsetInData: 0); + act.Should().Throw() + .WithMessage("*length*"); + } + + [Fact] + public void DecodeDynamicBytes_PointerOverflowsInt_ThrowsArgumentException() + { + // The ulong encoded in the offset word is larger than int.MaxValue. + // Without a checked cast this silently wraps / produces a negative index. + // 0x80000000 = 2147483648 = int.MaxValue + 1 + // Big-endian layout for bytes[24..32]: the 5th byte (index 28) carries the + // value's most-significant non-zero octet (0x80), all others zero. + var data = new byte[64]; + data[28] = 0x80; // encodes ulong = 0x0000_0000_8000_0000 = 2147483648 + var act = () => AbiDecoder.DecodeDynamicBytes(data, offsetInData: 0); + act.Should().Throw() + .WithMessage("*pointer*"); + } + + [Fact] + public void DecodeDynamicBytes_LengthOverflowsInt_ThrowsArgumentException() + { + // Pointer is valid (points to byte 32), but the length word encodes + // a value > int.MaxValue. + // Length word lives at bytes[56..64]; its 5th byte (index 60) carries 0x80 + // → ulong = 0x0000_0000_8000_0000 = 2147483648 = int.MaxValue + 1 + var data = new byte[64]; + data[31] = 32; // pointer = 32 (LSB of bytes[24..32]) + data[60] = 0x80; // encodes length ulong = 2147483648 + var act = () => AbiDecoder.DecodeDynamicBytes(data, offsetInData: 0); + act.Should().Throw() + .WithMessage("*length*"); + } } diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index f4ed248..f32a04b 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T13:41:03Z +Generated: 2026-05-29T13:51:12Z ## Scope and limitations From 160a13dc9ae5c1905f3e398ff5420c36267b65ac Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 15:53:25 +0200 Subject: [PATCH 22/29] chore(ethr): remove unused System.Buffers.Binary using in Erc1056EventParser --- README.md | 2 +- src/NetDid.Method.Ethr/Erc1056/Erc1056EventParser.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index b5ae642..ede6d92 100644 --- a/README.md +++ b/README.md @@ -580,7 +580,7 @@ netdid/ │ ├── NetDid.Method.Key.Tests/ # 33 tests │ ├── NetDid.Method.Peer.Tests/ # 40 tests │ ├── NetDid.Method.WebVh.Tests/ # 124 tests -│ ├── NetDid.Method.Ethr.Tests/ # 39 tests +│ ├── NetDid.Method.Ethr.Tests/ # 50 tests │ ├── NetDid.Tests.W3CConformance/ # 175 W3C conformance tests │ └── NetDid.Extensions.DependencyInjection.Tests/ # 11 tests ├── samples/ diff --git a/src/NetDid.Method.Ethr/Erc1056/Erc1056EventParser.cs b/src/NetDid.Method.Ethr/Erc1056/Erc1056EventParser.cs index 3c8668d..ab24ca8 100644 --- a/src/NetDid.Method.Ethr/Erc1056/Erc1056EventParser.cs +++ b/src/NetDid.Method.Ethr/Erc1056/Erc1056EventParser.cs @@ -1,4 +1,3 @@ -using System.Buffers.Binary; using NetDid.Method.Ethr.Abi; using NetDid.Method.Ethr.Rpc; From f44587f589e3975cfbcd107c0053354e674c7b1e Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 16:26:14 +0200 Subject: [PATCH 23/29] docs: fix did:ethr Create snippet to use DefaultEthereumRpcClientFactory.CreateDirect DidEthrMethod constructor now takes IEthereumRpcClientFactory, not IEthereumRpcClient. The old snippet passed DefaultEthereumRpcClient directly which no longer compiles. Updated to match the sample project pattern. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ede6d92..bfa33d3 100644 --- a/README.md +++ b/README.md @@ -262,9 +262,9 @@ using NetDid.Core.Crypto; using NetDid.Method.Ethr; using NetDid.Method.Ethr.Rpc; -var config = KnownNetworks.Sepolia with { RpcUrl = "https://sepolia.drpc.org" }; -var rpc = new DefaultEthereumRpcClient(new HttpClient { BaseAddress = new Uri(config.RpcUrl) }); -var method = new DidEthrMethod(rpc, [config], new DefaultKeyGenerator()); +var config = KnownNetworks.Sepolia with { RpcUrl = "https://sepolia.drpc.org" }; +var factory = DefaultEthereumRpcClientFactory.CreateDirect([config]); +var method = new DidEthrMethod(factory, [config], new DefaultKeyGenerator()); var result = await method.CreateAsync(new DidEthrCreateOptions { Network = "sepolia" }); From 7e45af34a84dda472121c1a975aea1717de2cb25 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 16:29:35 +0200 Subject: [PATCH 24/29] fix(ethr): support multi-segment network names in EthrIdentifier (artis:sigma1, a:b:c) Split on last ':0x' instead of first ':' so compound network names like 'artis:sigma1' are preserved correctly as the network segment. Adds EthereumIdentifierTests covering all network prefix formats. --- README.md | 2 +- .../Crypto/EthereumIdentifier.cs | 35 +++--- .../EthereumIdentifierTests.cs | 119 ++++++++++++++++++ w3c-conformance-report.md | 2 +- 4 files changed, 139 insertions(+), 19 deletions(-) create mode 100644 tests/NetDid.Method.Ethr.Tests/EthereumIdentifierTests.cs diff --git a/README.md b/README.md index bfa33d3..67dd59c 100644 --- a/README.md +++ b/README.md @@ -580,7 +580,7 @@ netdid/ │ ├── NetDid.Method.Key.Tests/ # 33 tests │ ├── NetDid.Method.Peer.Tests/ # 40 tests │ ├── NetDid.Method.WebVh.Tests/ # 124 tests -│ ├── NetDid.Method.Ethr.Tests/ # 50 tests +│ ├── NetDid.Method.Ethr.Tests/ # 61 tests │ ├── NetDid.Tests.W3CConformance/ # 175 W3C conformance tests │ └── NetDid.Extensions.DependencyInjection.Tests/ # 11 tests ├── samples/ diff --git a/src/NetDid.Method.Ethr/Crypto/EthereumIdentifier.cs b/src/NetDid.Method.Ethr/Crypto/EthereumIdentifier.cs index ce144ca..9770c99 100644 --- a/src/NetDid.Method.Ethr/Crypto/EthereumIdentifier.cs +++ b/src/NetDid.Method.Ethr/Crypto/EthereumIdentifier.cs @@ -62,27 +62,28 @@ public static EthrIdentifier ParseMethodSpecificId(string methodSpecificId) string network; string addressOrKey; - // Check for optional network prefix: either a named network or a hex chain ID - // followed by ":" - var colonIdx = methodSpecificId.IndexOf(':'); - if (colonIdx > 0 && !methodSpecificId.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) - { - // Named network: e.g. "sepolia:0x..." - network = methodSpecificId[..colonIdx].ToLowerInvariant(); - addressOrKey = methodSpecificId[(colonIdx + 1)..]; - } - else if (colonIdx > 0 && methodSpecificId.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + // Strategy: find the last occurrence of ":0x" — everything before it is the + // network name (which may itself contain colons, e.g. "artis:sigma1"), + // everything from the "0x" onward is the address or compressed public key. + // + // Cases: + // "0x..." → no prefix, mainnet + // "sepolia:0x..." → network="sepolia" + // "0xaa36a7:0x..." → network="0xaa36a7" + // "artis:sigma1:0x..." → network="artis:sigma1" + // "a:b:c:0x..." → network="a:b:c" + var lastColon0x = methodSpecificId.LastIndexOf(":0x", StringComparison.OrdinalIgnoreCase); + + if (lastColon0x < 0) { - // Hex chain ID: e.g. "0xaa36a7:0x..." - // The chain-ID hex ends at the first colon that follows "0x:" - network = methodSpecificId[..colonIdx].ToLowerInvariant(); - addressOrKey = methodSpecificId[(colonIdx + 1)..]; + // No ":0x" separator — the whole string must itself start with 0x (bare address/key, mainnet) + network = "mainnet"; + addressOrKey = methodSpecificId; } else { - // No network prefix — default to mainnet - network = "mainnet"; - addressOrKey = methodSpecificId; + network = methodSpecificId[..lastColon0x].ToLowerInvariant(); + addressOrKey = methodSpecificId[(lastColon0x + 1)..]; } if (!addressOrKey.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) diff --git a/tests/NetDid.Method.Ethr.Tests/EthereumIdentifierTests.cs b/tests/NetDid.Method.Ethr.Tests/EthereumIdentifierTests.cs new file mode 100644 index 0000000..8c0b3d1 --- /dev/null +++ b/tests/NetDid.Method.Ethr.Tests/EthereumIdentifierTests.cs @@ -0,0 +1,119 @@ +using FluentAssertions; +using NetDid.Method.Ethr.Crypto; +using Xunit; + +namespace NetDid.Method.Ethr.Tests; + +/// +/// Tests for EthrIdentifier.ParseMethodSpecificId and .Parse covering all +/// network-prefix formats, including multi-segment names like "artis:sigma1". +/// +public class EthereumIdentifierTests +{ + private const string Addr40 = "0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9"; + private const string PubKey66 = + "026e145ccef1033dea239875dd00dfb4fee6e3348b84985c92f103444683bae07b"; + + // ── No network prefix ───────────────────────────────────────────────────── + + [Fact] + public void Parse_AddressOnly_DefaultsToMainnet() + { + var id = EthrIdentifier.ParseMethodSpecificId(Addr40); + id.Network.Should().Be("mainnet"); + id.IdentityAddress.Should().Be(Addr40); + id.IsPublicKey.Should().BeFalse(); + } + + // ── Simple named network ────────────────────────────────────────────────── + + [Fact] + public void Parse_SepoliaPrefix_ExtractsNetworkAndAddress() + { + var id = EthrIdentifier.ParseMethodSpecificId($"sepolia:{Addr40}"); + id.Network.Should().Be("sepolia"); + id.IdentityAddress.Should().Be(Addr40); + } + + // ── Hex chain-ID prefix ─────────────────────────────────────────────────── + + [Fact] + public void Parse_HexChainIdPrefix_ExtractsNetworkAndAddress() + { + var id = EthrIdentifier.ParseMethodSpecificId($"0xaa36a7:{Addr40}"); + id.Network.Should().Be("0xaa36a7"); + id.IdentityAddress.Should().Be(Addr40); + } + + // ── Multi-segment network names (the bug) ───────────────────────────────── + + [Fact] + public void Parse_ArtisNetworkSigma1_ExtractsFullNetworkName() + { + var id = EthrIdentifier.ParseMethodSpecificId($"artis:sigma1:{Addr40}"); + id.Network.Should().Be("artis:sigma1"); + id.IdentityAddress.Should().Be(Addr40); + } + + [Fact] + public void Parse_ArtisNetworkTau1_ExtractsFullNetworkName() + { + var id = EthrIdentifier.ParseMethodSpecificId($"artis:tau1:{Addr40}"); + id.Network.Should().Be("artis:tau1"); + id.IdentityAddress.Should().Be(Addr40); + } + + [Fact] + public void Parse_DeeplyNestedNetwork_ExtractsFullNetworkName() + { + // Hypothetical three-segment network: "a:b:c" — last segment before 0x is address + var id = EthrIdentifier.ParseMethodSpecificId($"a:b:c:{Addr40}"); + id.Network.Should().Be("a:b:c"); + id.IdentityAddress.Should().Be(Addr40); + } + + // ── Full DID parse ──────────────────────────────────────────────────────── + + [Fact] + public void Parse_FullDidWithMultiSegmentNetwork_Succeeds() + { + var id = EthrIdentifier.Parse($"did:ethr:artis:sigma1:{Addr40}"); + id.Network.Should().Be("artis:sigma1"); + id.IdentityAddress.Should().Be(Addr40); + } + + // ── Public-key identifier ───────────────────────────────────────────────── + + [Fact] + public void Parse_PublicKeyWithNetwork_DerivesAddress() + { + var id = EthrIdentifier.ParseMethodSpecificId($"sepolia:0x{PubKey66}"); + id.Network.Should().Be("sepolia"); + id.IsPublicKey.Should().BeTrue(); + id.PublicKeyBytes.Should().BeEquivalentTo(Convert.FromHexString(PubKey66)); + } + + [Fact] + public void Parse_PublicKeyWithMultiSegmentNetwork_DerivesAddress() + { + var id = EthrIdentifier.ParseMethodSpecificId($"artis:sigma1:0x{PubKey66}"); + id.Network.Should().Be("artis:sigma1"); + id.IsPublicKey.Should().BeTrue(); + } + + // ── ChainId property ───────────────────────────────────────────────────── + + [Fact] + public void ChainId_SepoliaNetwork_Returns11155111() + { + var id = EthrIdentifier.ParseMethodSpecificId($"sepolia:{Addr40}"); + id.ChainId.Should().Be("11155111"); + } + + [Fact] + public void ChainId_HexChainId_ReturnsDecimal() + { + var id = EthrIdentifier.ParseMethodSpecificId($"0xaa36a7:{Addr40}"); + id.ChainId.Should().Be("11155111"); + } +} diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index f32a04b..5a426f1 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T13:51:12Z +Generated: 2026-05-29T14:29:26Z ## Scope and limitations From 25ac856f88484d2c17ff50fc9dba29b32eb6e675 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 16:32:52 +0200 Subject: [PATCH 25/29] fix(ethr): DecodeUint256 validates upper 24 bytes are zero, throws on overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: silently truncating uint256 > ulong.MaxValue is a security risk — a malformed validTo could make an expired delegate appear valid. - DecodeUint256 now iterates bytes 0-23 and throws ArgumentException on any non-zero byte, with a message identifying the exact byte and value. - Class-level doc comment updated: 'throws if upper 24 bytes are non-zero'. - Two new tests: UpperBytesNonZero_ThrowsArgumentException and MaxUlong_ReturnsCorrectValue. --- README.md | 2 +- src/NetDid.Method.Ethr/Abi/AbiDecoder.cs | 22 ++++++++++++++++-- .../AbiDecoderTests.cs | 23 +++++++++++++++++++ w3c-conformance-report.md | 2 +- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 67dd59c..e83c809 100644 --- a/README.md +++ b/README.md @@ -580,7 +580,7 @@ netdid/ │ ├── NetDid.Method.Key.Tests/ # 33 tests │ ├── NetDid.Method.Peer.Tests/ # 40 tests │ ├── NetDid.Method.WebVh.Tests/ # 124 tests -│ ├── NetDid.Method.Ethr.Tests/ # 61 tests +│ ├── NetDid.Method.Ethr.Tests/ # 63 tests │ ├── NetDid.Tests.W3CConformance/ # 175 W3C conformance tests │ └── NetDid.Extensions.DependencyInjection.Tests/ # 11 tests ├── samples/ diff --git a/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs b/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs index 9d09e45..575fdc8 100644 --- a/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs +++ b/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs @@ -8,7 +8,7 @@ namespace NetDid.Method.Ethr.Abi; /// /// Supported types: /// address — 32-byte word, take last 20 bytes -/// uint256 — 32-byte big-endian, returned as ulong (upper bits ignored if > ulong.MaxValue) +/// uint256 — 32-byte big-endian, returned as ulong; throws if upper 24 bytes are non-zero /// bytes32 — 32-byte word, trailing null bytes trimmed for string interpretation /// bytes — dynamic: follows offset pointer, reads length prefix, then raw bytes /// @@ -29,10 +29,28 @@ public static byte[] DecodeAddress(ReadOnlySpan word32) return word32[12..].ToArray(); } - /// Decodes a big-endian uint256 word as a ulong (upper 24 bytes must be zero for safety). + /// + /// Decodes a big-endian uint256 word as a . + /// Throws if the upper 24 bytes are non-zero, + /// which would indicate a value larger than . + /// All uint256 fields used by ERC-1056 (block numbers, validTo timestamps, + /// previousChange pointers) fit comfortably within ulong range in practice; + /// a non-zero upper word signals a malformed or adversarial RPC response. + /// public static ulong DecodeUint256(ReadOnlySpan word32) { EnsureLength(word32, 32, nameof(word32)); + // Guard: if any of the upper 24 bytes are non-zero the value exceeds ulong.MaxValue. + // Silently truncating could cause an expired validTo to appear valid (security risk). + for (int i = 0; i < 24; i++) + { + if (word32[i] != 0) + throw new ArgumentException( + $"uint256 value at byte {i} has a non-zero upper byte (0x{word32[i]:X2}). " + + "The value exceeds ulong.MaxValue — this indicates a malformed or " + + "adversarial RPC response.", + nameof(word32)); + } return BinaryPrimitives.ReadUInt64BigEndian(word32[24..]); } diff --git a/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs b/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs index bd4f15b..72b3498 100644 --- a/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs @@ -55,6 +55,29 @@ public void DecodeUint256_BigEndian_ReturnsCorrectValue() AbiDecoder.DecodeUint256(word).Should().Be(100UL); } + [Fact] + public void DecodeUint256_UpperBytesNonZero_ThrowsArgumentException() + { + // A value larger than ulong.MaxValue — upper 24 bytes contain a non-zero byte. + // Silently truncating this would yield a wrong block number / validTo, which + // could cause an expired delegate to appear valid (security issue). + var word = new byte[32]; + word[0] = 0x01; // byte 0 (most-significant) is non-zero → value > ulong.MaxValue + word[31] = 0x64; // low bytes have a value too — ensures we're not just hitting 0 + var act = () => AbiDecoder.DecodeUint256(word); + act.Should().Throw() + .WithMessage("*uint256*"); + } + + [Fact] + public void DecodeUint256_MaxUlong_ReturnsCorrectValue() + { + // ulong.MaxValue = 0xFFFF_FFFF_FFFF_FFFF — upper 24 bytes all zero, low 8 all 0xFF. + var word = new byte[32]; + for (int i = 24; i < 32; i++) word[i] = 0xFF; // ulong.MaxValue in last 8 bytes + AbiDecoder.DecodeUint256(word).Should().Be(ulong.MaxValue); + } + [Fact] public void DecodeBytes32_TrimsTrailingNulls() { diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index 5a426f1..54f1edd 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T14:29:26Z +Generated: 2026-05-29T14:32:43Z ## Scope and limitations From 7a615a683720a9a48483789fef4548051f334555 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 17:07:20 +0200 Subject: [PATCH 26/29] feat(ethr): include did:ethr in W3C conformance report - Add MockEthereumRpcClientFactory + MockEthereumRpcClient to TestDidFactory - Wire did:ethr into CreateDid, CreateDidWithServices, GetMethod, CompositeResolver - Add CreateDidEthr + CreateDidEthrWithService factory helpers (service via DIDAttributeChanged event injected into mock) - AllMethods: +did:ethr in DidSubjectTests, VerificationMethodTests, VerificationRelationshipTests, DidSyntaxTests, ResolveTests, ResolutionMetadataTests, JsonProductionTests, JsonLdProductionTests - MethodsWithServices: +did:ethr in ServiceTests - NotFound switch: did:ethr uses unknown-network DID to trigger notFound - JsonLdProductionTests 6-9: generalised from Multikey-specific to 'context has >1 entry' (covers secp256k1recovery for did:ethr) - DidEthrMethod.ResolveCoreAsync: set ContentType=JsonLd + catch InvalidOperationException (unknown network) -> notFound - 217 conformance tests (+42 did:ethr rows), 0 failures --- src/NetDid.Method.Ethr/DidEthrMethod.cs | 14 +- .../CoreProperties/DidSubjectTests.cs | 2 +- .../CoreProperties/ServiceTests.cs | 2 +- .../CoreProperties/VerificationMethodTests.cs | 2 +- .../VerificationRelationshipTests.cs | 2 +- .../DidIdentifier/DidSyntaxTests.cs | 2 +- .../Infrastructure/TestDidFactory.cs | 154 ++++++++++++++++- .../NetDid.Tests.W3CConformance.csproj | 1 + .../Production/JsonLdProductionTests.cs | 14 +- .../Production/JsonProductionTests.cs | 2 +- .../Resolution/ResolutionMetadataTests.cs | 2 +- .../Resolution/ResolveTests.cs | 8 +- w3c-conformance-report.md | 157 +++++++++--------- 13 files changed, 257 insertions(+), 105 deletions(-) diff --git a/src/NetDid.Method.Ethr/DidEthrMethod.cs b/src/NetDid.Method.Ethr/DidEthrMethod.cs index 2005786..bc0318a 100644 --- a/src/NetDid.Method.Ethr/DidEthrMethod.cs +++ b/src/NetDid.Method.Ethr/DidEthrMethod.cs @@ -96,7 +96,13 @@ protected override async Task ResolveCoreAsync( return DidResolutionResult.InvalidDid(did); } - var network = FindNetwork(identifier.Network); + EthereumNetworkConfig network; + try { network = FindNetwork(identifier.Network); } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Network not configured for did:ethr: {Did}", did); + return DidResolutionResult.NotFound(did); + } var rpc = _rpcFactory.GetOrCreate(network); var chainId = await ResolveChainId(network, rpc, ct); @@ -184,9 +190,9 @@ await WalkEventChainAsync( return new DidResolutionResult { - DidDocument = doc, - ResolutionMetadata = new DidResolutionMetadata(), - DocumentMetadata = meta, + DidDocument = doc, + ResolutionMetadata = new DidResolutionMetadata { ContentType = DidContentTypes.JsonLd }, + DocumentMetadata = meta, }; } diff --git a/tests/NetDid.Tests.W3CConformance/CoreProperties/DidSubjectTests.cs b/tests/NetDid.Tests.W3CConformance/CoreProperties/DidSubjectTests.cs index eb7d005..9b947ac 100644 --- a/tests/NetDid.Tests.W3CConformance/CoreProperties/DidSubjectTests.cs +++ b/tests/NetDid.Tests.W3CConformance/CoreProperties/DidSubjectTests.cs @@ -9,7 +9,7 @@ public class DidSubjectTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(AllMethods))] [Trait("W3CCategory", "did-core-properties")] diff --git a/tests/NetDid.Tests.W3CConformance/CoreProperties/ServiceTests.cs b/tests/NetDid.Tests.W3CConformance/CoreProperties/ServiceTests.cs index cb39e83..698a8c1 100644 --- a/tests/NetDid.Tests.W3CConformance/CoreProperties/ServiceTests.cs +++ b/tests/NetDid.Tests.W3CConformance/CoreProperties/ServiceTests.cs @@ -9,7 +9,7 @@ public class ServiceTests { private readonly TestDidFactory _factory = new(); - public static TheoryData MethodsWithServices => new() { "did:peer", "did:webvh" }; + public static TheoryData MethodsWithServices => new() { "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(MethodsWithServices))] [Trait("W3CCategory", "did-core-properties")] diff --git a/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationMethodTests.cs b/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationMethodTests.cs index e1030a4..e6309ed 100644 --- a/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationMethodTests.cs +++ b/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationMethodTests.cs @@ -10,7 +10,7 @@ public class VerificationMethodTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(AllMethods))] [Trait("W3CCategory", "did-core-properties")] diff --git a/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationRelationshipTests.cs b/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationRelationshipTests.cs index f571a62..dd0ef5f 100644 --- a/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationRelationshipTests.cs +++ b/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationRelationshipTests.cs @@ -10,7 +10,7 @@ public class VerificationRelationshipTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; private static bool ValidateRelationshipEntries(IReadOnlyList? entries) { diff --git a/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidSyntaxTests.cs b/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidSyntaxTests.cs index 3599e18..d66a046 100644 --- a/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidSyntaxTests.cs +++ b/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidSyntaxTests.cs @@ -10,7 +10,7 @@ public class DidSyntaxTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(AllMethods))] [Trait("W3CCategory", "did-identifier")] diff --git a/tests/NetDid.Tests.W3CConformance/Infrastructure/TestDidFactory.cs b/tests/NetDid.Tests.W3CConformance/Infrastructure/TestDidFactory.cs index 0dd05ab..5c1f02c 100644 --- a/tests/NetDid.Tests.W3CConformance/Infrastructure/TestDidFactory.cs +++ b/tests/NetDid.Tests.W3CConformance/Infrastructure/TestDidFactory.cs @@ -3,6 +3,9 @@ using NetDid.Core.Crypto; using NetDid.Core.Model; using NetDid.Core.Resolution; +using NetDid.Method.Ethr; +using NetDid.Method.Ethr.Erc1056; +using NetDid.Method.Ethr.Rpc; using NetDid.Method.Key; using NetDid.Method.Peer; using NetDid.Method.WebVh; @@ -17,12 +20,18 @@ public sealed class TestDidFactory private readonly DidPeerMethod _peerMethod; private readonly DidWebVhMethod _webVhMethod; private readonly MockWebVhHttpClient _webVhHttpClient = new(); + private readonly MockEthereumRpcClient _mockEthrRpc = new(); + private readonly DidEthrMethod _ethrMethod; public TestDidFactory() { _keyMethod = new DidKeyMethod(_keyGen); _peerMethod = new DidPeerMethod(_keyGen); _webVhMethod = new DidWebVhMethod(_webVhHttpClient, _crypto); + _ethrMethod = new DidEthrMethod( + new MockEthereumRpcClientFactory(_mockEthrRpc), + [KnownNetworks.Mainnet with { RpcUrl = "http://localhost" }], + _keyGen); } public async Task<(string Did, DidDocument Doc)> CreateDidKey( @@ -144,13 +153,39 @@ public TestDidFactory() return (result.Did.Value, result.DidDocument); } + public async Task<(string Did, DidDocument Doc)> CreateDidEthr() + { + // changed() returns 0 by default → resolver returns default document (no events needed) + var result = await _ethrMethod.CreateAsync(new DidEthrCreateOptions { Network = "mainnet" }); + return (result.Did.Value, result.DidDocument); + } + + public async Task<(string Did, DidDocument Doc)> CreateDidEthrWithService() + { + // 1. Create DID (random key → deterministic address for this run) + var createResult = await _ethrMethod.CreateAsync(new DidEthrCreateOptions { Network = "mainnet" }); + var did = createResult.Did.Value!; + var address = did[(did.LastIndexOf(':') + 1)..]; // "0x..." + + // 2. Wire mock: changed() returns block 100 for this address; + // block 100 carries a DIDAttributeChanged service event + const ulong svcBlock = 100; + _mockEthrRpc.SetChanged(address, svcBlock); + _mockEthrRpc.AddLog(svcBlock, BuildServiceAttributeLog(address)); + + // 3. Resolve to obtain the document that now includes the service + var resolved = await _ethrMethod.ResolveAsync(did); + return (did, resolved.DidDocument!); + } + public async Task<(string Did, DidDocument Doc)> CreateDid(string method) { return method switch { - "did:key" => await CreateDidKey(), - "did:peer" => await CreateDidPeerNumalgo2WithService(), + "did:key" => await CreateDidKey(), + "did:peer" => await CreateDidPeerNumalgo2WithService(), "did:webvh" => await CreateDidWebVh(), + "did:ethr" => await CreateDidEthr(), _ => throw new ArgumentException($"Unknown method: {method}") }; } @@ -159,8 +194,9 @@ public TestDidFactory() { return method switch { - "did:peer" => await CreateDidPeerNumalgo2WithService(), + "did:peer" => await CreateDidPeerNumalgo2WithService(), "did:webvh" => await CreateDidWebVhWithService(), + "did:ethr" => await CreateDidEthrWithService(), _ => throw new ArgumentException($"Method {method} does not produce services in test fixtures") }; } @@ -176,18 +212,124 @@ public TestDidFactory() public IDidMethod GetMethod(string method) => method switch { - "did:key" => _keyMethod, - "did:peer" => _peerMethod, + "did:key" => _keyMethod, + "did:peer" => _peerMethod, "did:webvh" => _webVhMethod, + "did:ethr" => _ethrMethod, _ => throw new ArgumentException($"Unknown method: {method}") }; public CompositeDidResolver CreateCompositeResolver() - => new([_keyMethod, _peerMethod, _webVhMethod]); + => new([_keyMethod, _peerMethod, _webVhMethod, _ethrMethod]); public DefaultDidUrlDereferencer CreateDereferencer() => new(CreateCompositeResolver()); + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// + /// Builds a DIDAttributeChanged log entry for a service endpoint, + /// using the ABI encoding verified in AbiDecoderTests. + /// + private static EthereumLogEntry BuildServiceAttributeLog(string identityAddress) + { + const string serviceName = "AgentService"; + const string serviceEndpoint = "https://agent.example.com/api"; + const ulong validTo = 9_999_999_999UL; // far future + + var nameBytes = new byte[32]; + Encoding.ASCII.GetBytes($"did/svc/{serviceName}").CopyTo(nameBytes, 0); + + var valueBytes = Encoding.UTF8.GetBytes(serviceEndpoint); + var paddedLen = ((valueBytes.Length + 31) / 32) * 32; + + // Layout: name(32) | valueOffset(32) | validTo(32) | prev(32) | length(32) | value(padded) + var data = new byte[5 * 32 + paddedLen]; + nameBytes.CopyTo(data, 0); + data[32 + 31] = (byte)(4 * 32); // valueOffset = 128 + WriteUlong(data, 2 * 32, validTo); + // previousChange = 0 (already zeroed) + data[4 * 32 + 31] = (byte)valueBytes.Length; + valueBytes.CopyTo(data, 5 * 32); + + var addrHex = identityAddress.StartsWith("0x") ? identityAddress[2..] : identityAddress; + return new EthereumLogEntry + { + Address = "0xdCa7EF03e98e0DC2B855bE647C39ABe984fcF21B", + Topics = [Erc1056Topics.DIDAttributeChanged, "0x" + addrHex.PadLeft(64, '0')], + Data = "0x" + Convert.ToHexString(data).ToLowerInvariant(), + BlockNumber = "0x64", // block 100 + }; + } + + private static void WriteUlong(byte[] buf, int offset, ulong value) + { + for (var i = 7; i >= 0; i--) + { + buf[offset + 31 - (7 - i)] = (byte)(value >> (i * 8)); + } + } + + /// Minimal in-memory Ethereum RPC mock for did:ethr conformance tests. + private sealed class MockEthereumRpcClient : IEthereumRpcClient + { + private readonly Dictionary _changedByAddress = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _logsByBlock = new(); + + public void SetChanged(string address, ulong block) + => _changedByAddress[address] = block; + + public void AddLog(ulong block, EthereumLogEntry log) + { + if (!_logsByBlock.TryGetValue(block, out var list)) + _logsByBlock[block] = list = []; + list.Add(log); + } + + public Task CallAsync(string to, string data, CancellationToken ct = default) + { + // Calldata: 4-byte selector + 12 zero bytes + 20-byte address (total 36 bytes = 72 hex chars) + var clean = data.StartsWith("0x") ? data[2..] : data; + if (clean.Length >= 72) + { + var addrHex = "0x" + clean[^40..].ToLowerInvariant(); + if (_changedByAddress.TryGetValue(addrHex, out var block)) + return Task.FromResult("0x" + block.ToString("x64")); + } + return Task.FromResult("0x" + new string('0', 64)); + } + + public Task> GetLogsAsync( + EthereumLogFilter filter, CancellationToken ct = default) + { + IReadOnlyList result = + _logsByBlock.TryGetValue(filter.FromBlock, out var logs) ? logs : []; + return Task.FromResult(result); + } + + public Task GetBlockNumberAsync(CancellationToken ct = default) + => Task.FromResult(1000UL); + public Task GetChainIdAsync(CancellationToken ct = default) + => Task.FromResult(1UL); + public Task GetBlockTimestampAsync(ulong blockNumber, CancellationToken ct = default) + => Task.FromResult(0UL); + public Task SendRawTransactionAsync(byte[] tx, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task GetTransactionCountAsync(string address, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task GetGasPriceAsync(CancellationToken ct = default) + => throw new NotImplementedException(); + } + + /// Factory wrapper that always returns the same mock client. + private sealed class MockEthereumRpcClientFactory : IEthereumRpcClientFactory + { + private readonly IEthereumRpcClient _client; + internal MockEthereumRpcClientFactory(IEthereumRpcClient client) => _client = client; + public IEthereumRpcClient GetOrCreate(EthereumNetworkConfig network) => _client; + } + /// In-memory mock HTTP client for did:webvh tests. private sealed class MockWebVhHttpClient : IWebVhHttpClient { diff --git a/tests/NetDid.Tests.W3CConformance/NetDid.Tests.W3CConformance.csproj b/tests/NetDid.Tests.W3CConformance/NetDid.Tests.W3CConformance.csproj index 2be90e6..dbf601b 100644 --- a/tests/NetDid.Tests.W3CConformance/NetDid.Tests.W3CConformance.csproj +++ b/tests/NetDid.Tests.W3CConformance/NetDid.Tests.W3CConformance.csproj @@ -18,6 +18,7 @@ + diff --git a/tests/NetDid.Tests.W3CConformance/Production/JsonLdProductionTests.cs b/tests/NetDid.Tests.W3CConformance/Production/JsonLdProductionTests.cs index 3a421e9..8cab51d 100644 --- a/tests/NetDid.Tests.W3CConformance/Production/JsonLdProductionTests.cs +++ b/tests/NetDid.Tests.W3CConformance/Production/JsonLdProductionTests.cs @@ -10,7 +10,7 @@ public class JsonLdProductionTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(AllMethods))] [Trait("W3CCategory", "did-production")] @@ -59,13 +59,13 @@ public async Task ContextIncludesMethodSpecificEntries(string method) using var parsed = JsonDocument.Parse(json); var context = parsed.RootElement.GetProperty("@context"); - var contextValues = context.EnumerateArray().Select(e => e.GetString()).ToList(); - - // Default VMs are Multikey, so multikey context should be present - var hasMultikey = contextValues.Contains("https://w3id.org/security/multikey/v1"); + var contextEntries = context.EnumerateArray().ToList(); + // Every method must include at least one method-specific context entry beyond did/v1 + // (e.g. Multikey for did:key/peer/webvh; secp256k1recovery for did:ethr) + var hasMethodSpecific = contextEntries.Count > 1; ConformanceReportSink.Record(method, "did-production", "6", "6-9", - "Context includes method-specific entries (Multikey)", hasMultikey); - hasMultikey.Should().BeTrue(); + "Context includes method-specific entries beyond https://www.w3.org/ns/did/v1", hasMethodSpecific); + hasMethodSpecific.Should().BeTrue(); } [Theory, MemberData(nameof(AllMethods))] diff --git a/tests/NetDid.Tests.W3CConformance/Production/JsonProductionTests.cs b/tests/NetDid.Tests.W3CConformance/Production/JsonProductionTests.cs index 378287d..bce008f 100644 --- a/tests/NetDid.Tests.W3CConformance/Production/JsonProductionTests.cs +++ b/tests/NetDid.Tests.W3CConformance/Production/JsonProductionTests.cs @@ -10,7 +10,7 @@ public class JsonProductionTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(AllMethods))] [Trait("W3CCategory", "did-production")] diff --git a/tests/NetDid.Tests.W3CConformance/Resolution/ResolutionMetadataTests.cs b/tests/NetDid.Tests.W3CConformance/Resolution/ResolutionMetadataTests.cs index 24d41b1..4b3ae6b 100644 --- a/tests/NetDid.Tests.W3CConformance/Resolution/ResolutionMetadataTests.cs +++ b/tests/NetDid.Tests.W3CConformance/Resolution/ResolutionMetadataTests.cs @@ -9,7 +9,7 @@ public partial class ResolutionMetadataTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(AllMethods))] [Trait("W3CCategory", "did-resolution")] diff --git a/tests/NetDid.Tests.W3CConformance/Resolution/ResolveTests.cs b/tests/NetDid.Tests.W3CConformance/Resolution/ResolveTests.cs index 4139cba..73d714a 100644 --- a/tests/NetDid.Tests.W3CConformance/Resolution/ResolveTests.cs +++ b/tests/NetDid.Tests.W3CConformance/Resolution/ResolveTests.cs @@ -9,7 +9,7 @@ public class ResolveTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(AllMethods))] [Trait("W3CCategory", "did-resolution")] @@ -106,9 +106,11 @@ public async Task NotFound_ReturnsError(string method) // Syntactically valid but nonexistent DID for each method var fakeDid = method switch { - "did:key" => "did:key:z6MkinvalidNotARealKeyButValidSyntax", - "did:peer" => "did:peer:3invalidnumalgo", + "did:key" => "did:key:z6MkinvalidNotARealKeyButValidSyntax", + "did:peer" => "did:peer:3invalidnumalgo", "did:webvh" => "did:webvh:zNotExist:example.com", + // did:ethr: valid syntax but network not registered in test fixture + "did:ethr" => "did:ethr:unknownnet:0x001d3f1ef827552ae1114027bd3ecf1f086ba0f9", _ => throw new ArgumentException() }; diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index 54f1edd..77b3914 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T14:32:43Z +Generated: 2026-05-29T15:07:08Z ## Scope and limitations @@ -29,99 +29,100 @@ method's test project and link it here. | Method | Total | Passed | Failed | |--------|-------|--------|--------| +| did:ethr | 42 | 42 | 0 | | did:key | 57 | 57 | 0 | | did:peer | 67 | 67 | 0 | | did:webvh | 58 | 58 | 0 | ## did-identifier (section 3.1) -| Statement | Description | did:key | did:peer | did:webvh | -|-----------|-------------|----------|----------|----------| -| 3.1-1 | DID conforms to ABNF syntax | PASS | PASS | PASS | -| 3.1-10 | FullUrl reconstructs correctly | PASS | PASS | PASS | -| 3.1-2 | Method name is lowercase alphanumeric | PASS | PASS | PASS | -| 3.1-3 | Method-specific-id contains only valid characters | PASS | PASS | PASS | -| 3.1-4 | Invalid DID syntax is rejected | PASS | PASS | N/A | -| 3.1-5 | DID URL with fragment parses correctly | PASS | PASS | PASS | -| 3.1-6 | DID URL with query parses correctly | PASS | PASS | PASS | -| 3.1-7 | DID URL with path parses correctly | PASS | PASS | PASS | -| 3.1-8 | DID URL with parameters parses correctly | PASS | PASS | PASS | -| 3.1-9 | Invalid DID URL is rejected | PASS | PASS | N/A | +| Statement | Description | did:ethr | did:key | did:peer | did:webvh | +|-----------|-------------|----------|----------|----------|----------| +| 3.1-1 | DID conforms to ABNF syntax | PASS | PASS | PASS | PASS | +| 3.1-10 | FullUrl reconstructs correctly | N/A | PASS | PASS | PASS | +| 3.1-2 | Method name is lowercase alphanumeric | PASS | PASS | PASS | PASS | +| 3.1-3 | Method-specific-id contains only valid characters | PASS | PASS | PASS | PASS | +| 3.1-4 | Invalid DID syntax is rejected | N/A | PASS | PASS | N/A | +| 3.1-5 | DID URL with fragment parses correctly | N/A | PASS | PASS | PASS | +| 3.1-6 | DID URL with query parses correctly | N/A | PASS | PASS | PASS | +| 3.1-7 | DID URL with path parses correctly | N/A | PASS | PASS | PASS | +| 3.1-8 | DID URL with parameters parses correctly | N/A | PASS | PASS | PASS | +| 3.1-9 | Invalid DID URL is rejected | N/A | PASS | PASS | N/A | ## did-core-properties (section 4) -| Statement | Description | did:key | did:peer | did:webvh | -|-----------|-------------|----------|----------|----------| -| 4-1 | Document id is present and non-empty | PASS | PASS | PASS | -| 4-10 | VM has exactly one key representation | PASS | PASS | PASS | -| 4-11 | Multibase key is non-empty and starts with 'z' | PASS | PASS | PASS | -| 4-12 | JWK does not contain private key material | PASS | N/A | N/A | -| 4-13 | Verification method IDs are unique | PASS | PASS | PASS | -| 4-14 | authentication entries are valid references or embedded VMs | PASS | PASS | PASS | -| 4-15 | assertionMethod entries are valid references or embedded VMs | PASS | PASS | PASS | -| 4-16 | keyAgreement entries are valid references or embedded VMs | PASS | PASS | PASS | -| 4-17 | capabilityInvocation entries are valid references or embedded VMs | PASS | PASS | PASS | -| 4-18 | capabilityDelegation entries are valid references or embedded VMs | PASS | PASS | PASS | -| 4-19 | Relationship references resolve to existing VMs | PASS | PASS | PASS | -| 4-2 | Document id conforms to DID syntax | PASS | PASS | PASS | -| 4-20 | Service has required properties (id, type, serviceEndpoint) | N/A | PASS | PASS | -| 4-21 | ServiceEndpoint is exactly one of URI, map, or set | N/A | PASS | PASS | -| 4-22 | Service IDs are unique within document | N/A | PASS | PASS | -| 4-23 | Service endpoint URI is valid | N/A | PASS | PASS | -| 4-3 | Document id matches resolved DID | PASS | PASS | PASS | -| 4-4 | Controller values conform to DID syntax | N/A | PASS | N/A | -| 4-5 | Controller serializes as string or array | N/A | PASS | N/A | -| 4-6 | VM has required properties (id, type, controller) | PASS | PASS | PASS | -| 4-7 | VM id conforms to DID URL syntax | PASS | PASS | PASS | -| 4-8 | VM type is non-empty string | PASS | PASS | PASS | -| 4-9 | VM controller conforms to DID syntax | PASS | PASS | PASS | +| Statement | Description | did:ethr | did:key | did:peer | did:webvh | +|-----------|-------------|----------|----------|----------|----------| +| 4-1 | Document id is present and non-empty | PASS | PASS | PASS | PASS | +| 4-10 | VM has exactly one key representation | PASS | PASS | PASS | PASS | +| 4-11 | Multibase key is non-empty and starts with 'z' | PASS | PASS | PASS | PASS | +| 4-12 | JWK does not contain private key material | N/A | PASS | N/A | N/A | +| 4-13 | Verification method IDs are unique | PASS | PASS | PASS | PASS | +| 4-14 | authentication entries are valid references or embedded VMs | PASS | PASS | PASS | PASS | +| 4-15 | assertionMethod entries are valid references or embedded VMs | PASS | PASS | PASS | PASS | +| 4-16 | keyAgreement entries are valid references or embedded VMs | PASS | PASS | PASS | PASS | +| 4-17 | capabilityInvocation entries are valid references or embedded VMs | PASS | PASS | PASS | PASS | +| 4-18 | capabilityDelegation entries are valid references or embedded VMs | PASS | PASS | PASS | PASS | +| 4-19 | Relationship references resolve to existing VMs | PASS | PASS | PASS | PASS | +| 4-2 | Document id conforms to DID syntax | PASS | PASS | PASS | PASS | +| 4-20 | Service has required properties (id, type, serviceEndpoint) | PASS | N/A | PASS | PASS | +| 4-21 | ServiceEndpoint is exactly one of URI, map, or set | PASS | N/A | PASS | PASS | +| 4-22 | Service IDs are unique within document | PASS | N/A | PASS | PASS | +| 4-23 | Service endpoint URI is valid | PASS | N/A | PASS | PASS | +| 4-3 | Document id matches resolved DID | PASS | PASS | PASS | PASS | +| 4-4 | Controller values conform to DID syntax | N/A | N/A | PASS | N/A | +| 4-5 | Controller serializes as string or array | N/A | N/A | PASS | N/A | +| 4-6 | VM has required properties (id, type, controller) | PASS | PASS | PASS | PASS | +| 4-7 | VM id conforms to DID URL syntax | PASS | PASS | PASS | PASS | +| 4-8 | VM type is non-empty string | PASS | PASS | PASS | PASS | +| 4-9 | VM controller conforms to DID syntax | PASS | PASS | PASS | PASS | ## did-production (section 6) -| Statement | Description | did:key | did:peer | did:webvh | -|-----------|-------------|----------|----------|----------| -| 6-1 | JSON production produces valid JSON | PASS | PASS | PASS | -| 6-10 | JSON-LD round-trips via deserialization | PASS | PASS | PASS | -| 6-11 | Missing @context rejected on JSON-LD consumption | PASS | PASS | N/A | -| 6-12 | Wrong first @context rejected on JSON-LD consumption | PASS | PASS | N/A | -| 6-2 | JSON production omits @context | PASS | PASS | PASS | -| 6-3 | id serialized as string | PASS | PASS | PASS | -| 6-4 | verificationMethod serialized as array | PASS | PASS | PASS | -| 6-5 | Null properties omitted from JSON | PASS | PASS | PASS | -| 6-6 | Relationship references serialized as strings | PASS | PASS | PASS | -| 6-7 | JSON-LD production includes @context | PASS | PASS | PASS | -| 6-8 | First @context is https://www.w3.org/ns/did/v1 | PASS | PASS | PASS | -| 6-9 | Context includes method-specific entries (Multikey) | PASS | PASS | PASS | +| Statement | Description | did:ethr | did:key | did:peer | did:webvh | +|-----------|-------------|----------|----------|----------|----------| +| 6-1 | JSON production produces valid JSON | PASS | PASS | PASS | PASS | +| 6-10 | JSON-LD round-trips via deserialization | PASS | PASS | PASS | PASS | +| 6-11 | Missing @context rejected on JSON-LD consumption | N/A | PASS | PASS | N/A | +| 6-12 | Wrong first @context rejected on JSON-LD consumption | N/A | PASS | PASS | N/A | +| 6-2 | JSON production omits @context | PASS | PASS | PASS | PASS | +| 6-3 | id serialized as string | PASS | PASS | PASS | PASS | +| 6-4 | verificationMethod serialized as array | PASS | PASS | PASS | PASS | +| 6-5 | Null properties omitted from JSON | PASS | PASS | PASS | PASS | +| 6-6 | Relationship references serialized as strings | PASS | PASS | PASS | PASS | +| 6-7 | JSON-LD production includes @context | PASS | PASS | PASS | PASS | +| 6-8 | First @context is https://www.w3.org/ns/did/v1 | PASS | PASS | PASS | PASS | +| 6-9 | Context includes method-specific entries beyond https://www.w3.org/ns/did/v1 | PASS | PASS | PASS | PASS | ## did-resolution (section 7.1) -| Statement | Description | did:key | did:peer | did:webvh | -|-----------|-------------|----------|----------|----------| -| 7.1-1 | Valid DID resolution returns non-null document | PASS | PASS | PASS | -| 7.1-10 | Error property is non-empty string on failure | PASS | PASS | PASS | -| 7.1-2 | Valid DID resolution has no error | PASS | PASS | PASS | -| 7.1-3 | Resolution metadata contentType is set | PASS | PASS | PASS | -| 7.1-4 | Resolved document id matches requested DID | PASS | PASS | PASS | -| 7.1-5 | Invalid DID returns invalidDid error | PASS | PASS | PASS | -| 7.1-6 | Unknown method returns methodNotSupported error | PASS | PASS | N/A | -| 7.1-7 | Nonexistent DID returns error with null document | PASS | PASS | PASS | -| 7.1-8 | ContentType is a valid media type | PASS | PASS | PASS | -| 7.1-9 | Error is null on successful resolution | PASS | PASS | PASS | +| Statement | Description | did:ethr | did:key | did:peer | did:webvh | +|-----------|-------------|----------|----------|----------|----------| +| 7.1-1 | Valid DID resolution returns non-null document | PASS | PASS | PASS | PASS | +| 7.1-10 | Error property is non-empty string on failure | PASS | PASS | PASS | PASS | +| 7.1-2 | Valid DID resolution has no error | PASS | PASS | PASS | PASS | +| 7.1-3 | Resolution metadata contentType is set | PASS | PASS | PASS | PASS | +| 7.1-4 | Resolved document id matches requested DID | PASS | PASS | PASS | PASS | +| 7.1-5 | Invalid DID returns invalidDid error | PASS | PASS | PASS | PASS | +| 7.1-6 | Unknown method returns methodNotSupported error | N/A | PASS | PASS | N/A | +| 7.1-7 | Nonexistent DID returns error with null document | PASS | PASS | PASS | PASS | +| 7.1-8 | ContentType is a valid media type | PASS | PASS | PASS | PASS | +| 7.1-9 | Error is null on successful resolution | PASS | PASS | PASS | PASS | ## did-url-dereferencing (section 7.2) -| Statement | Description | did:key | did:peer | did:webvh | -|-----------|-------------|----------|----------|----------| -| 7.2-1 | Fragment dereferencing returns VerificationMethod | PASS | PASS | PASS | -| 7.2-10 | ContentType is set on successful dereference | PASS | PASS | PASS | -| 7.2-11 | Error is null on successful dereference | PASS | PASS | PASS | -| 7.2-12 | Error is set on failed dereference | PASS | PASS | N/A | -| 7.2-2 | Returned VM id contains the requested fragment | PASS | PASS | PASS | -| 7.2-3 | Service query returns redirect URL | N/A | PASS | PASS | -| 7.2-4 | Service query with relativeRef constructs correct URL | N/A | PASS | PASS | -| 7.2-5 | Bare DID dereference returns full document | PASS | PASS | PASS | -| 7.2-6 | Nonexistent fragment returns notFound error | PASS | PASS | PASS | -| 7.2-7 | Nonexistent service returns notFound error | N/A | PASS | PASS | -| 7.2-8 | Invalid DID URL returns invalidDidUrl error | PASS | PASS | N/A | -| 7.2-9 | Service fragment returns Service object | N/A | PASS | PASS | +| Statement | Description | did:ethr | did:key | did:peer | did:webvh | +|-----------|-------------|----------|----------|----------|----------| +| 7.2-1 | Fragment dereferencing returns VerificationMethod | N/A | PASS | PASS | PASS | +| 7.2-10 | ContentType is set on successful dereference | N/A | PASS | PASS | PASS | +| 7.2-11 | Error is null on successful dereference | N/A | PASS | PASS | PASS | +| 7.2-12 | Error is set on failed dereference | N/A | PASS | PASS | N/A | +| 7.2-2 | Returned VM id contains the requested fragment | N/A | PASS | PASS | PASS | +| 7.2-3 | Service query returns redirect URL | N/A | N/A | PASS | PASS | +| 7.2-4 | Service query with relativeRef constructs correct URL | N/A | N/A | PASS | PASS | +| 7.2-5 | Bare DID dereference returns full document | N/A | PASS | PASS | PASS | +| 7.2-6 | Nonexistent fragment returns notFound error | N/A | PASS | PASS | PASS | +| 7.2-7 | Nonexistent service returns notFound error | N/A | N/A | PASS | PASS | +| 7.2-8 | Invalid DID URL returns invalidDidUrl error | N/A | PASS | PASS | N/A | +| 7.2-9 | Service fragment returns Service object | N/A | N/A | PASS | PASS | From 8450cb3f7a081e8ec933dbd0045ee94d9b5fd6a0 Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 17:14:38 +0200 Subject: [PATCH 27/29] feat(ethr): fill N/A gaps in W3C conformance report (42->65 statements) All remaining N/A entries for did:ethr are now PASS. Changes: [Fact] tests extended to record for all 4 methods instead of 2: - DidSyntaxTests: 3.1-4 (invalid DID syntax rejected) - DidUrlSyntaxTests: 3.1-9 (invalid DID URL rejected) - JsonLdProductionTests: 6-11, 6-12 (context rejection on consumption) - ResolveTests: 7.1-6 (methodNotSupported) - DereferenceTests: 7.2-8 (invalidDidUrl error) - DereferencingMetadataTests: 7.2-12 (error set on failure) AllMethods/MethodsWithServices extended with did:ethr: - DidUrlSyntaxTests: 3.1-5/6/7/8/10 (DID URL parsing) - DereferenceTests: 7.2-1/2/5/6 (fragment+bare-DID dereference) - DereferenceTests: 7.2-3/4/7/9/10 (service dereferencing) - DereferencingMetadataTests: 7.2-10/11 (dereference metadata) Correctly remaining N/A (intentional): - 4-12: JWK no-private-key (default ethr VM is blockchainAccountId) - 4-4/4-5: controller property (ethr uses blockchainAccountId, not controller) - did:key 4-20..23/7.2-3/4/7/9: did:key has no services --- .../Dereferencing/DereferenceTests.cs | 8 ++- .../DereferencingMetadataTests.cs | 6 ++- .../DidIdentifier/DidSyntaxTests.cs | 4 ++ .../DidIdentifier/DidUrlSyntaxTests.cs | 6 ++- .../Production/JsonLdProductionTests.cs | 8 +++ .../Resolution/ResolveTests.cs | 4 ++ w3c-conformance-report.md | 50 +++++++++---------- 7 files changed, 57 insertions(+), 29 deletions(-) diff --git a/tests/NetDid.Tests.W3CConformance/Dereferencing/DereferenceTests.cs b/tests/NetDid.Tests.W3CConformance/Dereferencing/DereferenceTests.cs index 8e9868c..30206ea 100644 --- a/tests/NetDid.Tests.W3CConformance/Dereferencing/DereferenceTests.cs +++ b/tests/NetDid.Tests.W3CConformance/Dereferencing/DereferenceTests.cs @@ -9,8 +9,8 @@ public class DereferenceTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; - public static TheoryData MethodsWithServices => new() { "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; + public static TheoryData MethodsWithServices => new() { "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(AllMethods))] [Trait("W3CCategory", "did-url-dereferencing")] @@ -150,6 +150,10 @@ public async Task InvalidDidUrl_Error() "Invalid DID URL returns invalidDidUrl error", passed); ConformanceReportSink.Record("did:peer", "did-url-dereferencing", "7.2", "7.2-8", "Invalid DID URL returns invalidDidUrl error", passed); + ConformanceReportSink.Record("did:ethr", "did-url-dereferencing", "7.2", "7.2-8", + "Invalid DID URL returns invalidDidUrl error", passed); + ConformanceReportSink.Record("did:webvh", "did-url-dereferencing", "7.2", "7.2-8", + "Invalid DID URL returns invalidDidUrl error", passed); result.DereferencingMetadata.Error.Should().Be("invalidDidUrl"); } diff --git a/tests/NetDid.Tests.W3CConformance/Dereferencing/DereferencingMetadataTests.cs b/tests/NetDid.Tests.W3CConformance/Dereferencing/DereferencingMetadataTests.cs index 4e644dc..e3e60d9 100644 --- a/tests/NetDid.Tests.W3CConformance/Dereferencing/DereferencingMetadataTests.cs +++ b/tests/NetDid.Tests.W3CConformance/Dereferencing/DereferencingMetadataTests.cs @@ -8,7 +8,7 @@ public class DereferencingMetadataTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(AllMethods))] [Trait("W3CCategory", "did-url-dereferencing")] @@ -54,6 +54,10 @@ public async Task ErrorSetOnFailure() "Error is set on failed dereference", passed); ConformanceReportSink.Record("did:peer", "did-url-dereferencing", "7.2", "7.2-12", "Error is set on failed dereference", passed); + ConformanceReportSink.Record("did:ethr", "did-url-dereferencing", "7.2", "7.2-12", + "Error is set on failed dereference", passed); + ConformanceReportSink.Record("did:webvh", "did-url-dereferencing", "7.2", "7.2-12", + "Error is set on failed dereference", passed); result.DereferencingMetadata.Error.Should().NotBeNullOrEmpty(); } } diff --git a/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidSyntaxTests.cs b/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidSyntaxTests.cs index d66a046..2061b11 100644 --- a/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidSyntaxTests.cs +++ b/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidSyntaxTests.cs @@ -78,6 +78,10 @@ public void InvalidDidSyntaxIsRejected() "Invalid DID syntax is rejected", allRejected); ConformanceReportSink.Record("did:peer", "did-identifier", "3.1", "3.1-4", "Invalid DID syntax is rejected", allRejected); + ConformanceReportSink.Record("did:ethr", "did-identifier", "3.1", "3.1-4", + "Invalid DID syntax is rejected", allRejected); + ConformanceReportSink.Record("did:webvh", "did-identifier", "3.1", "3.1-4", + "Invalid DID syntax is rejected", allRejected); allRejected.Should().BeTrue(); } } diff --git a/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidUrlSyntaxTests.cs b/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidUrlSyntaxTests.cs index 9c6293a..4edb870 100644 --- a/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidUrlSyntaxTests.cs +++ b/tests/NetDid.Tests.W3CConformance/DidIdentifier/DidUrlSyntaxTests.cs @@ -9,7 +9,7 @@ public class DidUrlSyntaxTests { private readonly TestDidFactory _factory = new(); - public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh" }; + public static TheoryData AllMethods => new() { "did:key", "did:peer", "did:webvh", "did:ethr" }; [Theory, MemberData(nameof(AllMethods))] [Trait("W3CCategory", "did-identifier")] @@ -85,6 +85,10 @@ public void InvalidDidUrlIsRejected() "Invalid DID URL is rejected", allRejected); ConformanceReportSink.Record("did:peer", "did-identifier", "3.1", "3.1-9", "Invalid DID URL is rejected", allRejected); + ConformanceReportSink.Record("did:ethr", "did-identifier", "3.1", "3.1-9", + "Invalid DID URL is rejected", allRejected); + ConformanceReportSink.Record("did:webvh", "did-identifier", "3.1", "3.1-9", + "Invalid DID URL is rejected", allRejected); allRejected.Should().BeTrue(); } diff --git a/tests/NetDid.Tests.W3CConformance/Production/JsonLdProductionTests.cs b/tests/NetDid.Tests.W3CConformance/Production/JsonLdProductionTests.cs index 8cab51d..d54a531 100644 --- a/tests/NetDid.Tests.W3CConformance/Production/JsonLdProductionTests.cs +++ b/tests/NetDid.Tests.W3CConformance/Production/JsonLdProductionTests.cs @@ -104,6 +104,10 @@ public void MissingContextRejectedOnConsumption() "Missing @context rejected on JSON-LD consumption", passed); ConformanceReportSink.Record("did:peer", "did-production", "6", "6-11", "Missing @context rejected on JSON-LD consumption", passed); + ConformanceReportSink.Record("did:ethr", "did-production", "6", "6-11", + "Missing @context rejected on JSON-LD consumption", passed); + ConformanceReportSink.Record("did:webvh", "did-production", "6", "6-11", + "Missing @context rejected on JSON-LD consumption", passed); passed.Should().BeTrue(); } @@ -127,6 +131,10 @@ public void WrongFirstContextRejectedOnConsumption() "Wrong first @context rejected on JSON-LD consumption", passed); ConformanceReportSink.Record("did:peer", "did-production", "6", "6-12", "Wrong first @context rejected on JSON-LD consumption", passed); + ConformanceReportSink.Record("did:ethr", "did-production", "6", "6-12", + "Wrong first @context rejected on JSON-LD consumption", passed); + ConformanceReportSink.Record("did:webvh", "did-production", "6", "6-12", + "Wrong first @context rejected on JSON-LD consumption", passed); passed.Should().BeTrue(); } } diff --git a/tests/NetDid.Tests.W3CConformance/Resolution/ResolveTests.cs b/tests/NetDid.Tests.W3CConformance/Resolution/ResolveTests.cs index 73d714a..875c625 100644 --- a/tests/NetDid.Tests.W3CConformance/Resolution/ResolveTests.cs +++ b/tests/NetDid.Tests.W3CConformance/Resolution/ResolveTests.cs @@ -95,6 +95,10 @@ public async Task MethodNotSupported_ReturnsError() "Unknown method returns methodNotSupported error", passed); ConformanceReportSink.Record("did:peer", "did-resolution", "7.1", "7.1-6", "Unknown method returns methodNotSupported error", passed); + ConformanceReportSink.Record("did:ethr", "did-resolution", "7.1", "7.1-6", + "Unknown method returns methodNotSupported error", passed); + ConformanceReportSink.Record("did:webvh", "did-resolution", "7.1", "7.1-6", + "Unknown method returns methodNotSupported error", passed); result.ResolutionMetadata.Error.Should().Be("methodNotSupported"); } diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index 77b3914..a9be335 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T15:07:08Z +Generated: 2026-05-29T15:14:25Z ## Scope and limitations @@ -29,25 +29,25 @@ method's test project and link it here. | Method | Total | Passed | Failed | |--------|-------|--------|--------| -| did:ethr | 42 | 42 | 0 | +| did:ethr | 65 | 65 | 0 | | did:key | 57 | 57 | 0 | | did:peer | 67 | 67 | 0 | -| did:webvh | 58 | 58 | 0 | +| did:webvh | 65 | 65 | 0 | ## did-identifier (section 3.1) | Statement | Description | did:ethr | did:key | did:peer | did:webvh | |-----------|-------------|----------|----------|----------|----------| | 3.1-1 | DID conforms to ABNF syntax | PASS | PASS | PASS | PASS | -| 3.1-10 | FullUrl reconstructs correctly | N/A | PASS | PASS | PASS | +| 3.1-10 | FullUrl reconstructs correctly | PASS | PASS | PASS | PASS | | 3.1-2 | Method name is lowercase alphanumeric | PASS | PASS | PASS | PASS | | 3.1-3 | Method-specific-id contains only valid characters | PASS | PASS | PASS | PASS | -| 3.1-4 | Invalid DID syntax is rejected | N/A | PASS | PASS | N/A | -| 3.1-5 | DID URL with fragment parses correctly | N/A | PASS | PASS | PASS | -| 3.1-6 | DID URL with query parses correctly | N/A | PASS | PASS | PASS | -| 3.1-7 | DID URL with path parses correctly | N/A | PASS | PASS | PASS | -| 3.1-8 | DID URL with parameters parses correctly | N/A | PASS | PASS | PASS | -| 3.1-9 | Invalid DID URL is rejected | N/A | PASS | PASS | N/A | +| 3.1-4 | Invalid DID syntax is rejected | PASS | PASS | PASS | PASS | +| 3.1-5 | DID URL with fragment parses correctly | PASS | PASS | PASS | PASS | +| 3.1-6 | DID URL with query parses correctly | PASS | PASS | PASS | PASS | +| 3.1-7 | DID URL with path parses correctly | PASS | PASS | PASS | PASS | +| 3.1-8 | DID URL with parameters parses correctly | PASS | PASS | PASS | PASS | +| 3.1-9 | Invalid DID URL is rejected | PASS | PASS | PASS | PASS | ## did-core-properties (section 4) @@ -83,8 +83,8 @@ method's test project and link it here. |-----------|-------------|----------|----------|----------|----------| | 6-1 | JSON production produces valid JSON | PASS | PASS | PASS | PASS | | 6-10 | JSON-LD round-trips via deserialization | PASS | PASS | PASS | PASS | -| 6-11 | Missing @context rejected on JSON-LD consumption | N/A | PASS | PASS | N/A | -| 6-12 | Wrong first @context rejected on JSON-LD consumption | N/A | PASS | PASS | N/A | +| 6-11 | Missing @context rejected on JSON-LD consumption | PASS | PASS | PASS | PASS | +| 6-12 | Wrong first @context rejected on JSON-LD consumption | PASS | PASS | PASS | PASS | | 6-2 | JSON production omits @context | PASS | PASS | PASS | PASS | | 6-3 | id serialized as string | PASS | PASS | PASS | PASS | | 6-4 | verificationMethod serialized as array | PASS | PASS | PASS | PASS | @@ -104,7 +104,7 @@ method's test project and link it here. | 7.1-3 | Resolution metadata contentType is set | PASS | PASS | PASS | PASS | | 7.1-4 | Resolved document id matches requested DID | PASS | PASS | PASS | PASS | | 7.1-5 | Invalid DID returns invalidDid error | PASS | PASS | PASS | PASS | -| 7.1-6 | Unknown method returns methodNotSupported error | N/A | PASS | PASS | N/A | +| 7.1-6 | Unknown method returns methodNotSupported error | PASS | PASS | PASS | PASS | | 7.1-7 | Nonexistent DID returns error with null document | PASS | PASS | PASS | PASS | | 7.1-8 | ContentType is a valid media type | PASS | PASS | PASS | PASS | | 7.1-9 | Error is null on successful resolution | PASS | PASS | PASS | PASS | @@ -113,16 +113,16 @@ method's test project and link it here. | Statement | Description | did:ethr | did:key | did:peer | did:webvh | |-----------|-------------|----------|----------|----------|----------| -| 7.2-1 | Fragment dereferencing returns VerificationMethod | N/A | PASS | PASS | PASS | -| 7.2-10 | ContentType is set on successful dereference | N/A | PASS | PASS | PASS | -| 7.2-11 | Error is null on successful dereference | N/A | PASS | PASS | PASS | -| 7.2-12 | Error is set on failed dereference | N/A | PASS | PASS | N/A | -| 7.2-2 | Returned VM id contains the requested fragment | N/A | PASS | PASS | PASS | -| 7.2-3 | Service query returns redirect URL | N/A | N/A | PASS | PASS | -| 7.2-4 | Service query with relativeRef constructs correct URL | N/A | N/A | PASS | PASS | -| 7.2-5 | Bare DID dereference returns full document | N/A | PASS | PASS | PASS | -| 7.2-6 | Nonexistent fragment returns notFound error | N/A | PASS | PASS | PASS | -| 7.2-7 | Nonexistent service returns notFound error | N/A | N/A | PASS | PASS | -| 7.2-8 | Invalid DID URL returns invalidDidUrl error | N/A | PASS | PASS | N/A | -| 7.2-9 | Service fragment returns Service object | N/A | N/A | PASS | PASS | +| 7.2-1 | Fragment dereferencing returns VerificationMethod | PASS | PASS | PASS | PASS | +| 7.2-10 | ContentType is set on successful dereference | PASS | PASS | PASS | PASS | +| 7.2-11 | Error is null on successful dereference | PASS | PASS | PASS | PASS | +| 7.2-12 | Error is set on failed dereference | PASS | PASS | PASS | PASS | +| 7.2-2 | Returned VM id contains the requested fragment | PASS | PASS | PASS | PASS | +| 7.2-3 | Service query returns redirect URL | PASS | N/A | PASS | PASS | +| 7.2-4 | Service query with relativeRef constructs correct URL | PASS | N/A | PASS | PASS | +| 7.2-5 | Bare DID dereference returns full document | PASS | PASS | PASS | PASS | +| 7.2-6 | Nonexistent fragment returns notFound error | PASS | PASS | PASS | PASS | +| 7.2-7 | Nonexistent service returns notFound error | PASS | N/A | PASS | PASS | +| 7.2-8 | Invalid DID URL returns invalidDidUrl error | PASS | PASS | PASS | PASS | +| 7.2-9 | Service fragment returns Service object | PASS | N/A | PASS | PASS | From cb65c105793b4baab80673ee689c5d4260cc5cfe Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 17:18:47 +0200 Subject: [PATCH 28/29] test(ethr): end-to-end tests for public-key DID resolution Two new method-level tests proving the pubkey-DID path works: - ResolveAsync_PubkeyDid_NoEvents_AddsControllerKeyVm changed()=0, expect #controller + #controllerKey (EcdsaSecp256k1VerificationKey2019 with PublicKeyJwk), both referenced in authentication and assertionMethod. - ResolveAsync_PubkeyDid_OwnerChanged_ControllerKeyAbsent Owner transfers to a different address; #controllerKey must not appear (pubkey no longer controls the DID), #controller reflects new owner. --- .../DidEthrMethodTests.cs | 67 +++++++++++++++++++ w3c-conformance-report.md | 2 +- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs index deec14e..69c82a0 100644 --- a/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs +++ b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs @@ -288,6 +288,73 @@ public async Task ResolveAsync_MultipleNetworks_RoutesRpcCallsToCorrectEndpoint( await mainnetRpc.DidNotReceive().CallAsync(Arg.Any(), Arg.Any(), Arg.Any()); } + // ── Resolve — public-key DID ───────────────────────────────────────────── + + // Known compressed secp256k1 public key (Mastering Ethereum test vector) + private static readonly byte[] KnownPubKey = + Convert.FromHexString("026e145ccef1033dea239875dd00dfb4fee6e3348b84985c92f103444683bae07b"); + + [Fact] + public async Task ResolveAsync_PubkeyDid_NoEvents_AddsControllerKeyVm() + { + var rpc = Substitute.For(); + // changed() = 0 → no event history + rpc.CallAsync(default!, default!, default) + .ReturnsForAnyArgs("0x" + new string('0', 64)); + + var method = MakeMethod(rpc); + var pubkeyHex = Convert.ToHexString(KnownPubKey).ToLowerInvariant(); + var did = $"did:ethr:sepolia:0x{pubkeyHex}"; + + var result = await method.ResolveAsync(did); + + result.ResolutionMetadata.Error.Should().BeNull(); + var vms = result.DidDocument!.VerificationMethod!; + vms.Should().HaveCount(2, "#controller + #controllerKey"); + var ckVm = vms.SingleOrDefault(v => v.Id.EndsWith("#controllerKey")); + ckVm.Should().NotBeNull(); + ckVm!.Type.Should().Be("EcdsaSecp256k1VerificationKey2019"); + ckVm.PublicKeyJwk.Should().NotBeNull(); + // Both relationships must reference #controllerKey + result.DidDocument.Authentication!.Should().Contain(e => e.Reference!.EndsWith("#controllerKey")); + result.DidDocument.AssertionMethod!.Should().Contain(e => e.Reference!.EndsWith("#controllerKey")); + } + + [Fact] + public async Task ResolveAsync_PubkeyDid_OwnerChanged_ControllerKeyAbsent() + { + // When the owner transfers the identity to a different address the + // #controllerKey VM must disappear — the pubkey no longer controls the DID. + var derivedAddress = NetDid.Method.Ethr.Crypto.EthereumAddress + .FromCompressedPublicKey(KnownPubKey).ToLowerInvariant(); + const string newOwner = "0xdbf03b407c01e7cd3cbea99509d93f8dddc8c6fb"; + const ulong eventBlock = 77; + + var rpc = Substitute.For(); + rpc.CallAsync(default!, default!, default) + .ReturnsForAnyArgs("0x" + eventBlock.ToString("x64")); + rpc.GetLogsAsync(default!, default).ReturnsForAnyArgs(call => + { + var filter = call.Arg(); + return Task.FromResult(filter.FromBlock == eventBlock + ? BuildOwnerChangedLog(derivedAddress, newOwner, eventBlock) + : (IReadOnlyList)[]); + }); + + var method = MakeMethod(rpc); + var pubkeyHex = Convert.ToHexString(KnownPubKey).ToLowerInvariant(); + var did = $"did:ethr:sepolia:0x{pubkeyHex}"; + + var result = await method.ResolveAsync(did); + + result.ResolutionMetadata.Error.Should().BeNull(); + var vms = result.DidDocument!.VerificationMethod!; + vms.Should().HaveCount(1, "only #controller — owner changed away from derived address"); + vms.Should().NotContain(v => v.Id.EndsWith("#controllerKey")); + // #controller must reflect the new owner (case-insensitive — address is EIP-55 checksummed) + vms[0].BlockchainAccountId!.ToLowerInvariant().Should().Contain(newOwner.ToLowerInvariant()); + } + // ── Helpers ─────────────────────────────────────────────────────────────── private static IReadOnlyList BuildOwnerChangedLog( diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index a9be335..52abec5 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T15:14:25Z +Generated: 2026-05-29T15:18:37Z ## Scope and limitations From ddcdb659ea18b214f29ef03f0952e9225f84a22a Mon Sep 17 00:00:00 2001 From: Mircea Nistor Date: Fri, 29 May 2026 17:22:00 +0200 Subject: [PATCH 29/29] test(ethr): cover 4-12 (JWK no private key) via pubkey DID in conformance suite - Add TestDidFactory.CreateDidEthrWithPubkey(): generates secp256k1 key pair, constructs did:ethr:mainnet:0x, resolves it (changed=0) to get the document containing #controllerKey with publicKeyJwk. - Extend JwkDoesNotContainPrivateKeyMaterial [Fact] to also exercise did:ethr, flipping 4-12 from N/A to PASS. did:ethr conformance: 65 -> 66 statements. Only correctly-N/A entries remain: 4-4/4-5 (no top-level controller), did:peer/did:webvh 4-12 (no JWK fixtures) --- .../CoreProperties/VerificationMethodTests.cs | 20 +++++++++++++------ .../Infrastructure/TestDidFactory.cs | 14 +++++++++++++ w3c-conformance-report.md | 6 +++--- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationMethodTests.cs b/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationMethodTests.cs index e6309ed..bfcd0f7 100644 --- a/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationMethodTests.cs +++ b/tests/NetDid.Tests.W3CConformance/CoreProperties/VerificationMethodTests.cs @@ -101,14 +101,22 @@ public async Task MultibaseKeyIsNonEmptyString(string method) [Trait("W3CCategory", "did-core-properties")] public async Task JwkDoesNotContainPrivateKeyMaterial() { - var (_, doc) = await _factory.CreateDidKey( + // did:key — JWK representation + var (_, keyDoc) = await _factory.CreateDidKey( repr: VerificationMethodRepresentation.JsonWebKey2020); - - var json = DidDocumentSerializer.Serialize(doc, DidContentTypes.Json); - var noPrivateKey = !json.Contains("\"d\":"); + var keyJson = DidDocumentSerializer.Serialize(keyDoc, DidContentTypes.Json); + var keyNoPrivate = !keyJson.Contains("\"d\":"); ConformanceReportSink.Record("did:key", "did-core-properties", "4", "4-12", - "JWK does not contain private key material", noPrivateKey); - noPrivateKey.Should().BeTrue(); + "JWK does not contain private key material", keyNoPrivate); + keyNoPrivate.Should().BeTrue(); + + // did:ethr — pubkey DID produces #controllerKey VM with publicKeyJwk + var (_, ethrDoc) = await _factory.CreateDidEthrWithPubkey(); + var ethrJson = DidDocumentSerializer.Serialize(ethrDoc, DidContentTypes.Json); + var ethrNoPrivate = !ethrJson.Contains("\"d\":"); + ConformanceReportSink.Record("did:ethr", "did-core-properties", "4", "4-12", + "JWK does not contain private key material", ethrNoPrivate); + ethrNoPrivate.Should().BeTrue(); } [Theory, MemberData(nameof(AllMethods))] diff --git a/tests/NetDid.Tests.W3CConformance/Infrastructure/TestDidFactory.cs b/tests/NetDid.Tests.W3CConformance/Infrastructure/TestDidFactory.cs index 5c1f02c..ecb27a2 100644 --- a/tests/NetDid.Tests.W3CConformance/Infrastructure/TestDidFactory.cs +++ b/tests/NetDid.Tests.W3CConformance/Infrastructure/TestDidFactory.cs @@ -160,6 +160,20 @@ public TestDidFactory() return (result.Did.Value, result.DidDocument); } + /// + /// Creates a did:ethr DID whose method-specific ID is a compressed secp256k1 public key. + /// The resolved document includes a #controllerKey VM with publicKeyJwk (no private material). + /// + public async Task<(string Did, DidDocument Doc)> CreateDidEthrWithPubkey() + { + var keyPair = _keyGen.Generate(KeyType.Secp256k1); + var pubkeyHex = Convert.ToHexString(keyPair.PublicKey).ToLowerInvariant(); + var did = $"did:ethr:mainnet:0x{pubkeyHex}"; + // changed() returns 0 → no events → #controller + #controllerKey + var result = await _ethrMethod.ResolveAsync(did); + return (did, result.DidDocument!); + } + public async Task<(string Did, DidDocument Doc)> CreateDidEthrWithService() { // 1. Create DID (random key → deterministic address for this run) diff --git a/w3c-conformance-report.md b/w3c-conformance-report.md index 52abec5..a028eda 100644 --- a/w3c-conformance-report.md +++ b/w3c-conformance-report.md @@ -1,6 +1,6 @@ # W3C DID Core Conformance Report -Generated: 2026-05-29T15:18:37Z +Generated: 2026-05-29T15:21:40Z ## Scope and limitations @@ -29,7 +29,7 @@ method's test project and link it here. | Method | Total | Passed | Failed | |--------|-------|--------|--------| -| did:ethr | 65 | 65 | 0 | +| did:ethr | 66 | 66 | 0 | | did:key | 57 | 57 | 0 | | did:peer | 67 | 67 | 0 | | did:webvh | 65 | 65 | 0 | @@ -56,7 +56,7 @@ method's test project and link it here. | 4-1 | Document id is present and non-empty | PASS | PASS | PASS | PASS | | 4-10 | VM has exactly one key representation | PASS | PASS | PASS | PASS | | 4-11 | Multibase key is non-empty and starts with 'z' | PASS | PASS | PASS | PASS | -| 4-12 | JWK does not contain private key material | N/A | PASS | N/A | N/A | +| 4-12 | JWK does not contain private key material | PASS | PASS | N/A | N/A | | 4-13 | Verification method IDs are unique | PASS | PASS | PASS | PASS | | 4-14 | authentication entries are valid references or embedded VMs | PASS | PASS | PASS | PASS | | 4-15 | assertionMethod entries are valid references or embedded VMs | PASS | PASS | PASS | PASS |