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/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/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/README.md b/README.md index ae9ce06..e83c809 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` (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 @@ -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 factory = DefaultEthereumRpcClientFactory.CreateDirect([config]); +var method = new DidEthrMethod(factory, [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/ # 63 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,19 +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 | Planned | +| **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/) | -| **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/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/samples/NetDid.Samples.DidEthr/Program.cs b/samples/NetDid.Samples.DidEthr/Program.cs new file mode 100644 index 0000000..83bd1b4 --- /dev/null +++ b/samples/NetDid.Samples.DidEthr/Program.cs @@ -0,0 +1,62 @@ +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; + +var config = KnownNetworks.Sepolia with { RpcUrl = "https://sepolia.drpc.org" }; + +var factory = DefaultEthereumRpcClientFactory.CreateDirect([config]); +var method = new DidEthrMethod(factory, [config], new DefaultKeyGenerator()); + +var resolver = new CompositeDidResolver([method]); +var dereferencer = new DefaultDidUrlDereferencer(resolver); + +const string did = "did:ethr:sepolia:0xf61c81096c96f97e95ac52a570966195ad6c90dd"; + +// ── 1. Current document ─────────────────────────────────────────────────────── +Console.WriteLine("=== Current document ==="); +await PrintDereferencedDoc(dereferencer, did); + +// ── 2. Genesis document (state before any events, via ?versionId=0) ─────────── +Console.WriteLine("=== Genesis document (?versionId=0) ==="); +await PrintDereferencedDoc(dereferencer, $"{did}?versionId=0"); + +// ── helpers ─────────────────────────────────────────────────────────────────── + +static async Task PrintDereferencedDoc(DefaultDidUrlDereferencer dereferencer, string url) +{ + 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(); +} 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.Core/Serialization/DidDocumentSerializer.cs b/src/NetDid.Core/Serialization/DidDocumentSerializer.cs index d9d4bc5..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 @@ -327,6 +330,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.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..c5b18da 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,53 @@ 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(); + + // 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(), + networkList, + sp.GetRequiredService(), + 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.gateway.tenderly.co", + /// ["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/Abi/AbiDecoder.cs b/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs new file mode 100644 index 0000000..575fdc8 --- /dev/null +++ b/src/NetDid.Method.Ethr/Abi/AbiDecoder.cs @@ -0,0 +1,172 @@ +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; 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 +/// +/// 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 . + /// 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..]); + } + + /// 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. + /// + /// 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) + { + // 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(); + } + + // ── 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/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/Crypto/EthereumIdentifier.cs b/src/NetDid.Method.Ethr/Crypto/EthereumIdentifier.cs new file mode 100644 index 0000000..9770c99 --- /dev/null +++ b/src/NetDid.Method.Ethr/Crypto/EthereumIdentifier.cs @@ -0,0 +1,119 @@ +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; + + // 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) + { + // No ":0x" separator — the whole string must itself start with 0x (bare address/key, mainnet) + network = "mainnet"; + addressOrKey = methodSpecificId; + } + else + { + network = methodSpecificId[..lastColon0x].ToLowerInvariant(); + addressOrKey = methodSpecificId[(lastColon0x + 1)..]; + } + + 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/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..bc0318a --- /dev/null +++ b/src/NetDid.Method.Ethr/DidEthrMethod.cs @@ -0,0 +1,295 @@ +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 IEthereumRpcClientFactory _rpcFactory; + private readonly IReadOnlyList _networks; + private readonly IKeyGenerator _keyGenerator; + private readonly ILogger _logger; + + public DidEthrMethod( + IEthereumRpcClientFactory rpcFactory, + IEnumerable networks, + IKeyGenerator keyGenerator, + ILogger? logger = null) + { + _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; + } + + public override string MethodName => "ethr"; + public override DidMethodCapabilities Capabilities => + DidMethodCapabilities.Create | + 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( + 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 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); + + 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); + } + + 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); + + // 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( + rpc, 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 { ContentType = DidContentTypes.JsonLd }, + DocumentMetadata = meta, + }; + } + + // ── Event chain walker ──────────────────────────────────────────────────── + + private async Task WalkEventChainAsync( + IEthereumRpcClient rpc, + string registryAddress, string identityAddress, + ulong fromBlock, List accumulator, CancellationToken ct) + { + 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[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); + + // 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) + { + try + { + var ev = Erc1056EventParser.Parse(log); + if (!string.Equals(ev.Identity, identityAddress, + StringComparison.OrdinalIgnoreCase)) + continue; + accumulator.Add(ev); + // Advance only when previousChange points to a strictly earlier block. + if (ev.PreviousChange < currentBlock && ev.PreviousChange > nextBlock) + nextBlock = ev.PreviousChange; + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Skipping unparseable ERC-1056 log at block {Block}", currentBlock); + } + } + + currentBlock = nextBlock; + } + } + + // ── 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, IEthereumRpcClient rpc, 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/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..ab24ca8 --- /dev/null +++ b/src/NetDid.Method.Ethr/Erc1056/Erc1056EventParser.cs @@ -0,0 +1,83 @@ +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/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/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs new file mode 100644 index 0000000..75b6c50 --- /dev/null +++ b/src/NetDid.Method.Ethr/Resolution/EthrDocumentBuilder.cs @@ -0,0 +1,346 @@ +using NetCid; +using NetDid.Core.Crypto; +using NetDid.Core.Jwk; +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; + +/// +/// 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 (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; + 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) + { + switch (ev) + { + case OwnerChangedEvent oc: + currentOwner = oc.NewOwner; + break; + + case DelegateChangedEvent dc: + { + 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/"): + { + 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); + if (ac.ValidTo >= refUnix) + services[key] = (serviceCount, new ServiceEntry(svcName, svcEndpoint, ac.ValidTo)); + else + services.Remove(key); + break; + } + } + } + + // ── Deactivation check ──────────────────────────────────────────────── + if (isDeactivated || currentOwner == ZeroAddress) + { + return new DidDocument + { + Id = new Did(did), + Context = BuildContext(false, false, false, false, false), + }; + } + + // 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(); + 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}:{Checksum(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 (counter, d) in validDelegates) + { + var vmId = $"{did}#delegate-{counter}"; + vms.Add(new VerificationMethod + { + Id = vmId, + Type = "EcdsaSecp256k1RecoveryMethod2020", + Controller = new Did(did), + BlockchainAccountId = $"eip155:{chainId}:{Checksum(d.DelegateAddress)}", + }); + var rel = VerificationRelationshipEntry.FromReference(vmId); + if (d.DelegateType == "sigAuth") auths.Add(rel); + asserts.Add(rel); // veriKey and sigAuth both → assertionMethod + } + + // Attribute-based key VMs (#delegate-N) + 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-{counter}"; + + 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, KeyType.Ed25519), + }; + break; + + case "X25519": + needsX25519 = true; + vm = new VerificationMethod + { + Id = vmId, + Type = "X25519KeyAgreementKey2020", + Controller = new Did(did), + PublicKeyMultibase = EncodeMultibase(a.Value, KeyType.X25519), + }; + 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); asserts.Add(rel); } + else asserts.Add(rel); // veriKey default + } + + // Services + var svcList = validServices.Select(kv => new Service + { + Id = $"{did}#service-{kv.Counter}", + Type = kv.Entry.ServiceName, + ServiceEndpoint = ServiceEndpointValue.FromUri(kv.Entry.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(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(System.Text.Json.JsonSerializer.SerializeToElement( + new Dictionary + { + ["publicKeyHex"] = "https://w3id.org/security#publicKeyHex" + })); + return ctx; + } + + // ── Multibase helpers ───────────────────────────────────────────────────── + + /// + /// 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, KeyType keyType) + { + 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); + 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(ulong 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); + + /// 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/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/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/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/src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs b/src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs new file mode 100644 index 0000000..9ea677c --- /dev/null +++ b/src/NetDid.Method.Ethr/Rpc/EthereumLogFilter.cs @@ -0,0 +1,17 @@ +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; } + /// + /// 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/src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs b/src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs new file mode 100644 index 0000000..61d65ee --- /dev/null +++ b/src/NetDid.Method.Ethr/Rpc/EthereumNetworkConfig.cs @@ -0,0 +1,19 @@ +namespace NetDid.Method.Ethr.Rpc; + +/// Network configuration for a single Ethereum network / RPC endpoint. +public sealed record EthereumNetworkConfig +{ + 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. + 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/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); +} 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/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/tasks/todo20260522-didethr.md b/tasks/todo20260522-didethr.md new file mode 100644 index 0000000..95a1fdc --- /dev/null +++ b/tasks/todo20260522-didethr.md @@ -0,0 +1,562 @@ +# 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 + +- [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 + +### 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]` diff --git a/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs b/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs new file mode 100644 index 0000000..72b3498 --- /dev/null +++ b/tests/NetDid.Method.Ethr.Tests/AbiDecoderTests.cs @@ -0,0 +1,227 @@ +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 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() + { + // "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); + } + + // ── 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/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs new file mode 100644 index 0000000..69c82a0 --- /dev/null +++ b/tests/NetDid.Method.Ethr.Tests/DidEthrMethodTests.cs @@ -0,0 +1,512 @@ +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) + { + // 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()); + + // ── 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 string newOwnerChecksum = "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(newOwnerChecksum); + } + + // ── 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(); + } + + // ── 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()); + } + + // ── 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( + 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 EthereumLogEntry SingleDelegateLog( + string identity, string delegate20, string delegateType, + 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 ─────────────────────────────────────── + + /// + /// 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; + return "0x" + hex.PadLeft(64, '0'); + } +} 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(); + } +} 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/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/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs new file mode 100644 index 0000000..071d20c --- /dev/null +++ b/tests/NetDid.Method.Ethr.Tests/EthrDocumentBuilderTests.cs @@ -0,0 +1,325 @@ +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}:0x001d3F1ef827552Ae1114027BD3ECF1f086bA0F9"); + 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 newOwnerChecksum = "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(newOwnerChecksum); + } + + // ── 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_AppearsInAuthenticationAndAssertionMethod() + { + 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); + + // 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().Contain(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")); + } + + // ── 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/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 + + + + + + + + + + + + + + + + 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..bfcd0f7 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")] @@ -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/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/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 3599e18..2061b11 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")] @@ -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/Infrastructure/TestDidFactory.cs b/tests/NetDid.Tests.W3CConformance/Infrastructure/TestDidFactory.cs index 0dd05ab..ecb27a2 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,53 @@ 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); + } + + /// + /// 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) + 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 +208,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 +226,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..d54a531 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))] @@ -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/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..875c625 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")] @@ -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"); } @@ -106,9 +110,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 e137197..a028eda 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-29T15:21:40Z ## Scope and limitations @@ -29,99 +29,100 @@ method's test project and link it here. | Method | Total | Passed | Failed | |--------|-------|--------|--------| +| did:ethr | 66 | 66 | 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: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 | 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 | 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) -| 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 | 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 | +| 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 | 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 | +| 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 | 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 | ## 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 | 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 |