import { BlockTag, encodeBase58, encodeBase64, toUtf8String } from 'ethers' import { ConfigurationOptions, ConfiguredNetworks, configureResolverWithNetworks } from './configuration.js' import type { ContextEntry, DIDDocument, DIDResolutionOptions, DIDResolutionResult, DIDResolver, ParsedDID, Resolvable, Service, VerificationMethod, } from 'did-resolver' import { algoToVMType, compressedSecp256k1ToJwk, DIDAttributeChanged, DIDDelegateChanged, DIDOwnerChanged, ERC1056Event, Errors, eventNames, ExtendedVerificationMethod, identifierMatcher, interpretIdentifier, multicodecPrefixes, nullAddress, strip0x, toMultibase, VMTypes, } from './helpers.js' import { logDecoder } from './logParser.js' /** * Builds the JSON-LD @context array for a DID document based on the verification method types * and key encoding properties actually present in the document. * * - Always includes the base DID v1 and secp256k1recovery-2020/v2 contexts. * - Adds suite-specific contexts only for key types present in the document. * - Appends an inline term definition object for any terms not covered by the above contexts * (publicKeyHex, publicKeyJwk). */ function buildLdContext(didDocument: DIDDocument): ContextEntry[] { const contexts: ContextEntry[] = [ 'https://www.w3.org/ns/did/v1', // defines EcdsaSecp256k1RecoveryMethod2020, blockchainAccountId 'https://w3id.org/security/suites/secp256k1recovery-2020/v2', ] const allVMs: VerificationMethod[] = didDocument.verificationMethod ?? [] const types = new Set(allVMs.map((vm) => vm.type)) const hasPublicKeyHex = allVMs.some((vm) => 'publicKeyHex' in vm) const hasPublicKeyJwk = allVMs.some((vm) => 'publicKeyJwk' in vm) const hasPublicKeyBase58 = allVMs.some((vm) => 'publicKeyBase58' in vm) const hasPublicKeyBase64 = allVMs.some((vm) => 'publicKeyBase64' in vm) // security/v2 defines EcdsaSecp256k1VerificationKey2019 & publicKeyBase58 if (types.has(VMTypes.EcdsaSecp256k1VerificationKey2019) || hasPublicKeyBase58) { contexts.push('https://w3id.org/security/v2') } if (types.has(VMTypes.Ed25519VerificationKey2020)) { contexts.push('https://w3id.org/security/suites/ed25519-2020/v1') } if (types.has(VMTypes.X25519KeyAgreementKey2020)) { contexts.push('https://w3id.org/security/suites/x25519-2020/v1') } if (types.has(VMTypes.Multikey)) { contexts.push('https://w3id.org/security/multikey/v1') } // Inline term definitions for properties not defined by any of the above contexts. const securityV2Included = contexts.includes('https://w3id.org/security/v2') const inline: Record = {} if (hasPublicKeyHex) { inline['publicKeyHex'] = 'https://w3id.org/security#publicKeyHex' } if (hasPublicKeyJwk) { inline['publicKeyJwk'] = { '@id': 'https://w3id.org/security#publicKeyJwk', '@type': '@json' } } // publicKeyBase58 is defined by security/v2; only add inline if that context is absent. if (hasPublicKeyBase58 && !securityV2Included) { inline['publicKeyBase58'] = 'https://w3id.org/security#publicKeyBase58' } // publicKeyBase64 is not defined by any suite context — always inline when present. if (hasPublicKeyBase64) { inline['publicKeyBase64'] = 'https://w3id.org/security#publicKeyBase64' } if (Object.keys(inline).length > 0) { contexts.push(inline) } return contexts } export function getResolver(options: ConfigurationOptions): Record { return new EthrDidResolver(options).build() } export class EthrDidResolver { private contracts: ConfiguredNetworks constructor(options: ConfigurationOptions) { this.contracts = configureResolverWithNetworks(options) } /** * Returns the block number with the previous change to a particular address (DID) * * @param address - the address (DID) to check for changes * @param networkId - the EVM network to check * @param blockTag - the block tag to use for the query (default: 'latest') */ async previousChange(address: string, networkId: string, blockTag?: BlockTag): Promise { return await this.contracts[networkId].changed(address, { blockTag }) } async getBlockMetadata(blockHeight: number, networkId: string): Promise<{ height: string; isoDate: string }> { const networkContract = this.contracts[networkId] if (!networkContract) throw new Error(`No contract configured for network ${networkId}`) if (!networkContract.runner) throw new Error(`No runner configured for contract with network ${networkId}`) if (!networkContract.runner.provider) throw new Error(`No provider configured for runner in contract with network ${networkId}`) const block = await networkContract.runner.provider.getBlock(blockHeight) if (!block) throw new Error(`Block at height ${blockHeight} not found`) return { height: block.number.toString(), isoDate: new Date(block.timestamp * 1000).toISOString().replace('.000', ''), } } async changeLog( identity: string, networkId: string, blockTag: BlockTag = 'latest' ): Promise<{ address: string; history: ERC1056Event[]; controllerKey?: string; chainId: bigint }> { const contract = this.contracts[networkId] if (!contract) throw new Error(`No contract configured for network ${networkId}`) if (!contract.runner) throw new Error(`No runner configured for contract with network ${networkId}`) if (!contract.runner.provider) throw new Error(`No provider configured for runner in contract with network ${networkId}`) const provider = contract.runner.provider const hexChainId = networkId.startsWith('0x') ? networkId : undefined //TODO: this can be used to check if the configuration is ok const chainId = hexChainId ? BigInt(hexChainId) : (await provider.getNetwork()).chainId const history: ERC1056Event[] = [] const { address, publicKey } = interpretIdentifier(identity) const controllerKey = publicKey let previousChange: bigint | null = await this.previousChange(address, networkId, blockTag) while (previousChange) { const blockNumber = previousChange const logs = await provider.getLogs({ address: await contract.getAddress(), // networks[networkId].registryAddress, // eslint-disable-next-line @typescript-eslint/no-explicit-any topics: [null as any, `0x000000000000000000000000${address.slice(2)}`], fromBlock: previousChange, toBlock: previousChange, }) if (logs.length === 0) { throw new Error( `No logs found for block ${blockNumber} but previousChange points here. ` + `The RPC node may not have historical log data. Use an archive node for complete DID resolution.` ) } const { events, previousChange: pc } = logDecoder(contract, logs, blockNumber) previousChange = pc || null events.reverse() for (const event of events) { history.unshift(event) } } return { address, history, controllerKey, chainId } } wrapDidDocument( did: string, address: string, controllerKey: string | undefined, history: ERC1056Event[], chainId: bigint, blockHeight: string | number, now: bigint ): { didDocument: DIDDocument; deactivated: boolean; versionId: number; nextVersionId: number } { const baseDIDDocument: DIDDocument = { id: did, verificationMethod: [], authentication: [], assertionMethod: [], } let controller = address const authentication = [`${did}#controller`] const assertionMethod = [`${did}#controller`] let versionId = 0 let nextVersionId = Number.POSITIVE_INFINITY let deactivated = false let delegateCount = 0 let serviceCount = 0 let endpoint = '' const auth: Record = {} const keyAgreementRefs: Record = {} const signingRefs: Record = {} const pks: Record = {} const services: Record = {} if (typeof blockHeight === 'string') { // latest blockHeight = -1 } for (const event of history) { if (blockHeight !== -1 && event.blockNumber > blockHeight) { if (nextVersionId > event.blockNumber) { nextVersionId = event.blockNumber } continue } else { if (versionId < event.blockNumber) { versionId = event.blockNumber } } const validTo = event.validTo || BigInt(0) const eventIndex = `${event._eventName}-${ (event as DIDDelegateChanged).delegateType || (event as DIDAttributeChanged).name }-${(event as DIDDelegateChanged).delegate || (event as DIDAttributeChanged).value}` if (validTo && validTo >= now) { if (event._eventName === eventNames.DIDDelegateChanged) { const currentEvent = event as DIDDelegateChanged delegateCount++ const delegateType = currentEvent.delegateType //conversion from bytes32 is done in logParser // noinspection FallThroughInSwitchStatementJS switch (delegateType) { case 'sigAuth': auth[eventIndex] = `${did}#delegate-${delegateCount}` signingRefs[eventIndex] = `${did}#delegate-${delegateCount}` case 'veriKey': pks[eventIndex] = { id: `${did}#delegate-${delegateCount}`, type: VMTypes.EcdsaSecp256k1RecoveryMethod2020, controller: did, blockchainAccountId: `eip155:${chainId}:${currentEvent.delegate}`, } signingRefs[eventIndex] = `${did}#delegate-${delegateCount}` break } } else if (event._eventName === eventNames.DIDAttributeChanged) { const currentEvent = event as DIDAttributeChanged const name = currentEvent.name //conversion from bytes32 is done in logParser const match = name.match(/^did\/(pub|svc)\/(\w+)(\/(\w+))?(\/(\w+))?$/) if (match) { const section = match[1] const algorithm = match[2] const encoding = match[6] switch (section) { case 'pub': { delegateCount++ // Primary lookup: algorithm token → canonical VM type. // Unknown/future key types pass through as-is. const vmType = algoToVMType[algorithm] ?? algorithm const pk: ExtendedVerificationMethod = { id: `${did}#delegate-${delegateCount}`, type: vmType, controller: did, } switch (pk.type) { case VMTypes.EcdsaSecp256k1VerificationKey2019: // Spec mandates publicKeyJwk for Secp256k1 attribute keys regardless of encoding hint. pk.publicKeyJwk = compressedSecp256k1ToJwk(currentEvent.value) break case VMTypes.Ed25519VerificationKey2020: case VMTypes.X25519KeyAgreementKey2020: // Always produce publicKeyMultibase regardless of encoding hint, to match spec. pk.publicKeyMultibase = toMultibase(currentEvent.value, multicodecPrefixes[pk.type]) break case VMTypes.Multikey: // On-chain value already includes the multicodec prefix; just base58btc-encode. pk.publicKeyMultibase = toMultibase(currentEvent.value) break default: // Unknown key types: honor the encoding hint for legacy compat. switch (encoding) { case null: case undefined: case 'hex': pk.publicKeyHex = strip0x(currentEvent.value) break case 'base64': pk.publicKeyBase64 = encodeBase64(currentEvent.value) break case 'base58': pk.publicKeyBase58 = encodeBase58(currentEvent.value) break default: pk.value = strip0x(currentEvent.value) } } pks[eventIndex] = pk if (match[4] === 'sigAuth') { auth[eventIndex] = pk.id signingRefs[eventIndex] = pk.id } else if (match[4] === 'enc') { keyAgreementRefs[eventIndex] = pk.id } else { signingRefs[eventIndex] = pk.id } break } case 'svc': { serviceCount++ let encodedService: string | null = null try { encodedService = toUtf8String(currentEvent.value) } catch { // value is not valid UTF-8; skip this service — non-DID use of registry } if (encodedService !== null) { try { endpoint = JSON.parse(encodedService) } catch { endpoint = encodedService } services[eventIndex] = { id: `${did}#service-${serviceCount}`, type: algorithm, serviceEndpoint: endpoint, } } break } } } } } else if (event._eventName === eventNames.DIDOwnerChanged) { const currentEvent = event as DIDOwnerChanged controller = currentEvent.owner if (currentEvent.owner === nullAddress) { deactivated = true break } } else { if ( event._eventName === eventNames.DIDDelegateChanged || (event._eventName === eventNames.DIDAttributeChanged && (event as DIDAttributeChanged).name.match(/^did\/pub\//)) ) { delegateCount++ } else if ( event._eventName === eventNames.DIDAttributeChanged && (event as DIDAttributeChanged).name.match(/^did\/svc\//) ) { serviceCount++ } delete auth[eventIndex] delete signingRefs[eventIndex] delete pks[eventIndex] delete services[eventIndex] } } const publicKeys: VerificationMethod[] = [ { id: `${did}#controller`, type: VMTypes.EcdsaSecp256k1RecoveryMethod2020, controller: did, blockchainAccountId: `eip155:${chainId}:${controller}`, }, ] if (controllerKey && controller == address) { publicKeys.push({ id: `${did}#controllerKey`, type: VMTypes.EcdsaSecp256k1VerificationKey2019, controller: did, publicKeyJwk: compressedSecp256k1ToJwk(controllerKey), }) authentication.push(`${did}#controllerKey`) assertionMethod.push(`${did}#controllerKey`) } const didDocument: DIDDocument = { ...baseDIDDocument, verificationMethod: publicKeys.concat(Object.values(pks)), authentication: authentication.concat(Object.values(auth)), assertionMethod: assertionMethod.concat(Object.values(signingRefs)), } if (Object.values(services).length > 0) { didDocument.service = Object.values(services) } if (Object.values(keyAgreementRefs).length > 0) { didDocument.keyAgreement = Object.values(keyAgreementRefs) } return deactivated ? { didDocument: baseDIDDocument, deactivated, versionId, nextVersionId, } : { didDocument, deactivated, versionId, nextVersionId } } async resolve( did: string, parsed: ParsedDID, _unused: Resolvable, options: DIDResolutionOptions ): Promise { let wantLdContext = false if (options.accept === 'application/did+json') { wantLdContext = false } else if (options.accept === 'application/did+ld+json' || typeof options.accept !== 'string') { wantLdContext = true } else { return { didResolutionMetadata: { error: Errors.unsupportedFormat, message: `The DID resolver does not support the requested 'accept' format: ${options.accept}`, }, didDocumentMetadata: {}, didDocument: null, } } const fullId = parsed.id.match(identifierMatcher) if (!fullId) { return { didResolutionMetadata: { error: Errors.invalidDid, message: `Not a valid did:ethr: ${parsed.id}`, }, didDocumentMetadata: {}, didDocument: null, } } const id = fullId[2] const networkId = !fullId[1] ? 'mainnet' : fullId[1].slice(0, -1) let blockTag: string | number = options.blockTag || 'latest' if (typeof parsed.query === 'string') { const qParams = new URLSearchParams(parsed.query) blockTag = qParams.get('versionId') ?? blockTag const parsedBlockTag = Number.parseInt(blockTag as string) if (!Number.isNaN(parsedBlockTag)) { blockTag = parsedBlockTag } else { blockTag = 'latest' } } if (!this.contracts[networkId]) { return { didResolutionMetadata: { error: Errors.unknownNetwork, message: `The DID resolver does not have a configuration for network: ${networkId}`, }, didDocumentMetadata: {}, didDocument: null, } } let now = BigInt(Math.floor(new Date().getTime() / 1000)) try { if (typeof blockTag === 'number') { const block = await this.getBlockMetadata(blockTag, networkId) now = BigInt(Date.parse(block.isoDate) / 1000) } const { address, history, controllerKey, chainId } = await this.changeLog(id, networkId, 'latest') const { didDocument, deactivated, versionId, nextVersionId } = this.wrapDidDocument( did, address, controllerKey, history, chainId, blockTag, now ) const status = deactivated ? { deactivated: true } : {} let versionMeta = {} let versionMetaNext = {} if (versionId !== 0) { const block = await this.getBlockMetadata(versionId, networkId) versionMeta = { versionId: block.height, updated: block.isoDate, } } if (nextVersionId !== Number.POSITIVE_INFINITY) { const block = await this.getBlockMetadata(nextVersionId, networkId) versionMetaNext = { nextVersionId: block.height, nextUpdate: block.isoDate, } } return { didDocumentMetadata: { ...status, ...versionMeta, ...versionMetaNext }, didResolutionMetadata: { contentType: options.accept ?? 'application/did+ld+json' }, didDocument: { ...didDocument, ...(wantLdContext ? { '@context': buildLdContext(didDocument) } : {}), }, } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { const message: string = e?.message ?? e?.toString() ?? 'unknown error' const isHistoricalQuery = typeof blockTag === 'number' // Errors that indicate the node lacks historical state (non-archive node) const isArchiveError = message.includes('missing trie node') || message.includes('header not found') || message.includes('missing revert data') || message.includes('pruned history unavailable') || message.includes('beyond current head block') || message.includes('historical state not available') // Pure connectivity/timeout failures — the endpoint is not reachable at all const isConnectivityError = message.includes('could not detect network') || message.includes('timeout') || e?.code === 'NETWORK_ERROR' || e?.code === 'TIMEOUT' // Server responded but with an error — may indicate missing historical data on non-archive nodes const isServerError = message.includes('missing response') || message.includes('SERVER_ERROR') || e?.code === 'SERVER_ERROR' const isRpcError = isConnectivityError || isServerError let hint = '' if (isArchiveError || (isHistoricalQuery && isServerError)) { hint = ' The RPC node does not have the requested historical state. Use an archive node to resolve historical DID versions (versionId queries).' } else if (isRpcError) { hint = ' Ensure the RPC endpoint is reachable.' } return { didResolutionMetadata: { error: Errors.notFound, message: `${message}${hint}`, }, didDocumentMetadata: {}, didDocument: null, } } } build(): Record { return { ethr: this.resolve.bind(this) } } }