import { getNetwork } from '@did-btcr2/bitcoin'; import { canonicalHash, canonicalHashBytes, canonicalize, DateUtils, encode as encodeHash, decode as decodeHash, INTERNAL_ERROR, INVALID_DID, INVALID_DID_DOCUMENT, INVALID_DID_UPDATE, INVALID_OPTIONS, INVALID_SIGNAL_DATA, JSONPatch, LATE_PUBLISHING, MISSING_UPDATE_DATA, NOT_FOUND, ResolveError } from '@did-btcr2/common'; import type { HashBytes } from '@did-btcr2/common'; import type { Btcr2DataIntegrityProof, SignedBTCR2Update } from './btcr2-update.js'; import { BTCR2_UPDATE_CONTEXT, isBtcr2UpdateContext } from './btcr2-update.js'; import { BIP340Cryptosuite, BIP340DataIntegrityProof, SchnorrMultikey } from '@did-btcr2/cryptosuite'; import { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair'; import { Appendix } from '../utils/appendix.js'; import { DidDocument, ID_PLACEHOLDER_VALUE } from '../utils/did-document.js'; import { isDateTimeStamp } from '../utils/did-core-conformance.js'; import { hasNonZeroPadBits, smtProofRootHex } from '../utils/base64url-hash.js'; import { errorCause } from '../utils/error-cause.js'; import { signingMethodProblem } from '../utils/signing-method.js'; import { BeaconFactory } from './beacon/factory.js'; import type { BeaconService, BeaconSignal, BlockMetadata } from './beacon/interfaces.js'; import { BeaconUtils } from './beacon/utils.js'; import type { DidComponents} from './identifier.js'; import { Identifier } from './identifier.js'; import type { SMTProof } from './interfaces.js'; import type { CASAnnouncement, Sidecar, SidecarData } from './types.js'; import { equalBytes } from '@noble/curves/utils.js'; /** * Default minimum number of Bitcoin block confirmations a Beacon Signal * transaction must have before resolution processes it. The specification * mandates `6` when `ResolutionOptions.minConf` is not set: six confirmations * is the accepted standard for a settled Bitcoin transaction. A resolution * request can raise or lower it through `minConf`. */ export const DEFAULT_MIN_CONF = 6; /** * The response object for DID Resolution. `metadata` is the DID document metadata * of the specification. `versionId`, `confirmations`, and `deactivated` are always * present. `updated` is present if the resolved version is more than 1. For a request * with `versionId` or `versionTime`, the document and the metadata are the state at the * requested version (`requested_state` of the specification). Later updates do not * change them. */ export interface DidResolutionResponse { didDocument: DidDocument; metadata: { /** * Number of confirmations of the Bitcoin block that contains the unique update * that yields the resolved version. `0` if the resolved version is 1. */ confirmations: number; /** The version of the resolved document as an ASCII string. `"1"` for the genesis document. */ versionId: string; /** * XML Datetime (UTC, no fraction) of the `mediantime` of the block that contains * the update that yields the resolved version. Absent if the resolved version is 1. */ updated?: string; /** Whether the resolved document is deactivated. */ deactivated: boolean; } } /** The resolver needs a genesis document whose hash matches genesisHash. */ export interface NeedGenesisDocument { readonly kind: 'NeedGenesisDocument'; /** Hex-encoded SHA-256 hash from the DID identifier's genesisBytes. */ readonly genesisHash: string; } /** The resolver needs beacon signals for these beacon service addresses. */ export interface NeedBeaconSignals { readonly kind: 'NeedBeaconSignals'; /** The beacon services that need signal data. Pass directly to BeaconSignalDiscovery. */ readonly beaconServices: ReadonlyArray; } /** The resolver needs a CAS Announcement whose canonical hash matches announcementHash. */ export interface NeedCASAnnouncement { readonly kind: 'NeedCASAnnouncement'; /** Hex-encoded canonical hash of the CAS Announcement. */ readonly announcementHash: string; /** The beacon service that produced this signal. */ readonly beaconServiceId: string; } /** The resolver needs a SignedBTCR2Update whose canonical hash matches updateHash. */ export interface NeedSignedUpdate { readonly kind: 'NeedSignedUpdate'; /** Hex-encoded canonical hash of the signed update. */ readonly updateHash: string; /** The beacon service that produced this signal. */ readonly beaconServiceId: string; } /** The resolver needs an SMT Proof whose root hash matches smtRootHash. */ export interface NeedSMTProof { readonly kind: 'NeedSMTProof'; /** Hex-encoded SHA-256 root hash of the Sparse Merkle Tree. */ readonly smtRootHash: string; /** The beacon service that produced this signal. */ readonly beaconServiceId: string; } /** Discriminated union of all data the resolver may request from the caller. */ export type DataNeed = NeedGenesisDocument | NeedBeaconSignals | NeedCASAnnouncement | NeedSignedUpdate | NeedSMTProof; /** * Output of {@link Resolver.resolve}. Analogous to Rust's `ResolverState` enum. * Either the resolver needs data from the caller, or resolution is complete. */ export type ResolverState = | { status: 'action-required'; needs: ReadonlyArray } | { status: 'resolved'; result: DidResolutionResponse }; /** * Return type from {@link SinglePartyBeacon.processSignals}. * Contains successfully resolved updates and any data needs that must be * satisfied before the remaining signals can be processed. */ export interface BeaconProcessResult { updates: Array<[SignedBTCR2Update, BlockMetadata]>; needs: Array; } /** * One entry of the `updates` list of the specification: the signed update, the block * that announced it, and the beacon address of the signal. The address feeds the * removed-beacon test of "Process Next Update", step 4. */ type UpdateTuple = [update: SignedBTCR2Update, block: BlockMetadata, address: string]; /** * `requested_state` of the specification: the state at the version that `versionId` or * `versionTime` requests. "Process Next Update" saves it at step 1 or step 5. The * resolver then processes the rest of the history, so a historical request raises each * error that a request for the current version raises. Step 2 restores the saved state * as the result (ADR 145). */ interface RequestedState { /** `current_document` at the requested version. */ document: DidDocument; /** `current_version_id` at the requested version. */ versionId: number; /** `block_confirmations` at the requested version. */ confirmations: number; /** `block_mediantime` as an XML Datetime. `undefined` at version 1. */ updated?: string; } // ─── provide() payload guards ──────────────────────────────────────────────── // Runtime shape checks so a malformed payload fails fast at the provide() // boundary rather than flowing downstream as an unchecked `as` cast. /** True if `value` is a non-null, non-array object. */ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** True if `value` has the shape of a CAS Announcement (a flat record of string hashes). */ function isCASAnnouncement(value: unknown): value is CASAnnouncement { return isRecord(value) && Object.values(value).every(v => typeof v === 'string'); } /** * True if `value` has the shape of a signed BTCR2 update. targetVersionId must be an * integer of at least 2: an update targets the version after the one it patches, and * genesis is version 1, so no conformant update can target a lower version (ADR 068). */ function isSignedBTCR2Update(value: unknown): value is SignedBTCR2Update { if(!isRecord(value)) return false; return Array.isArray(value.patch) && typeof value.sourceHash === 'string' && typeof value.targetHash === 'string' && Number.isInteger(value.targetVersionId) && (value.targetVersionId as number) >= 2 && isRecord(value.proof); } /** True if `value` has the shape of an SMT proof: string fields, `hashes` an array of strings. */ function isSMTProof(value: unknown): value is SMTProof { if(!isRecord(value)) return false; return typeof value.id === 'string' && typeof value.collapsed === 'string' && Array.isArray(value.hashes) && value.hashes.every(h => typeof h === 'string') && (value.nonce === undefined || typeof value.nonce === 'string') && (value.updateId === undefined || typeof value.updateId === 'string'); } /** * Validate `ResolutionOptions.minConf`. `undefined` selects the specification * default, {@link DEFAULT_MIN_CONF}. Any other value must be an integer of at * least 1, as the specification defines the option. * @throws {ResolveError} `INVALID_OPTIONS` for every other value. */ function validateMinConf(value: unknown): number { if(value === undefined) return DEFAULT_MIN_CONF; if(typeof value === 'number' && Number.isInteger(value) && value >= 1) return value; throw new ResolveError( `Invalid resolution option minConf: expected a positive integer (minimum 1), got ${shown(value)}.`, INVALID_OPTIONS, { minConf: value } ); } /** Render an option value for an error message: a string in quotes, any other value as is. */ function shown(value: unknown): string { return typeof value === 'string' ? JSON.stringify(value) : String(value); } /** An ASCII string of an integer: an optional minus sign, then digits. */ const ASCII_INTEGER = /^-?[0-9]+$/; /** * Parse `ResolutionOptions.versionId`. The specification says that the value MUST * parse as an integer, and DID Resolution v1 types the option as a string. The * accepted form is an ASCII string of an integer inside the safe integer range. * @returns {number | undefined} The integer, or `undefined` when the option is absent. * @throws {ResolveError} `INVALID_OPTIONS` for every other value. */ function validateVersionId(value: unknown): number | undefined { if(value === undefined) return undefined; if(typeof value === 'string' && ASCII_INTEGER.test(value) && Number.isSafeInteger(Number(value))) { return Number(value); } throw new ResolveError( `Invalid resolution option versionId: expected an ASCII string of an integer, got ${shown(value)}.`, INVALID_OPTIONS, { versionId: value } ); } /** An XML Datetime in UTC with the `Z` designator and no fraction, for example `2026-07-01T00:00:00Z`. */ const UTC_XSD_DATETIME = /^-?\d{4,}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; /** * Parse `ResolutionOptions.versionTime`. DID Resolution v1 requires an XML Datetime * normalized to UTC without sub-second precision. The specification raises * `INVALID_OPTIONS` for a value that does not parse. * @returns {number | undefined} The instant in milliseconds since the Unix epoch, or `undefined` when the option is absent. * @throws {ResolveError} `INVALID_OPTIONS` for every other value. */ function validateVersionTime(value: unknown): number | undefined { if(value === undefined) return undefined; if(typeof value === 'string' && UTC_XSD_DATETIME.test(value) && DateUtils.isValidXsdDateTime(value)) { const ms = Date.parse(value); if(Number.isFinite(ms)) return ms; } throw new ResolveError( 'Invalid resolution option versionTime: expected an XML Datetime in UTC without a fraction ' + `(for example "2026-07-01T00:00:00Z"), got ${shown(value)}.`, INVALID_OPTIONS, { versionTime: value } ); } /** * The phases of the resolution process. Each pass of the specification loop is * BeaconDiscovery (the scan of the beacon addresses the resolver did not scan yet), * BeaconProcess (the tuples of the signals the caller provided), and ProcessUpdate * (one tuple). GenesisDocument runs once, for an EXTERNAL identifier whose genesis * document is not in the sidecar. */ enum ResolverPhase { GenesisDocument = 'GenesisDocument', BeaconDiscovery = 'BeaconDiscovery', BeaconProcess = 'BeaconProcess', ProcessUpdate = 'ProcessUpdate', Complete = 'Complete', } /** * Sans-I/O state machine for did:btcr2 resolution. * * Created by {@link DidBtcr2.resolve} (the factory). The caller drives resolution * by repeatedly calling {@link resolve} and {@link provide}: * * ```typescript * const resolver = DidBtcr2.resolve(did, { sidecar }); * let state = resolver.resolve(); * * while (state.status === 'action-required') { * for (const need of state.needs) { ... fetch & provide ... } * state = resolver.resolve(); * } * const { didDocument, metadata } = state.result; * ``` * * The Resolver performs **zero I/O**. All external data (Bitcoin signals, CAS * data, genesis documents) flows through the advance/provide protocol. * * @class Resolver */ export class Resolver { // --- Immutable inputs --- readonly #didComponents: DidComponents; /** * The DID under resolution: `did` of the specification. The proof checks, the patched * document id, and the beacons compare with it, not with `current_document.id`. */ readonly #did: string; /** The parsed `ResolutionOptions.versionId`, or `undefined` when the option is absent. */ readonly #versionId?: number; /** The parsed `ResolutionOptions.versionTime` in milliseconds since the Unix epoch, or `undefined`. */ readonly #versionTime?: number; /** * The specific phase the Resolver is current in. */ #phase: ResolverPhase; #sidecarData: SidecarData; #currentDocument: DidDocument | null; #providedGenesisDocument: object | null = null; #beaconServicesSignals: Map> = new Map(); /** * The beacon addresses whose signals became update tuples. Keyed by address, not by * service id: a rotation keeps the id and changes the address, and the specification * has no per-service state (ADR 118). */ #processedAddresses: Set = new Set(); /** The beacon addresses the resolver requested signals for: `scanned_beacons` of the specification. */ #requestCache: Set = new Set(); /** * The tuples of the specification's `updates` list: a signed update, the metadata of * the block that announced it, and the beacon address of the signal. BeaconProcess * appends; ProcessUpdate sorts the list and removes one tuple per step. A tuple that * one pass does not reach waits for the next. */ #unsortedUpdates: Array = []; #resolvedResponse: DidResolutionResponse | null = null; /** * The state of the specification loop, carried across every pass: the version counter * (`current_version_id`), the update-hash history that backs duplicate confirmation * (`update_hash_history`), the confirmations of the block that contains the most * recently applied unique update (`block_confirmations`), the height of that block * (`current_block_height`), and the `mediantime` of that block as `updated` * (`block_mediantime`). A pass that finds a new beacon address returns to discovery, * so the state must not restart: a restart would reject a linear history whose later * updates are announced on beacons that earlier updates added. */ #currentVersionId = 1; #updateHashHistory: HashBytes[] = []; /** `requested_state` of the specification. `undefined` until step 1 or step 5 saves it. */ #requestedState?: RequestedState; #blockConfirmations = 0; #updated?: string; /** * The height of the block that contains the most recently applied update * (`current_block_height`). "Find Beacon Signals" keeps only the signals at or above * it: a beacon address that an update added has no signals for this DID before the * block of that update. */ #currentBlockHeight = 0; /** * Opt-in upper bound on multi-round beacon-discovery passes. `Infinity` (the * default) leaves discovery unbounded; termination is already guaranteed by * de-duplicating already-queried beacon addresses. A positive value is a * caller-imposed resource guard; a non-positive value or omission means no limit. */ readonly #maxDiscoveryRounds: number; /** Count of beacon-discovery passes driven by updates adding new beacon services. */ #discoveryRounds = 0; /** * Minimum block confirmations a Beacon Signal must have before this resolver * processes it: `ResolutionOptions.minConf`, default {@link DEFAULT_MIN_CONF}. * Applied at signal intake in the BeaconProcess phase. A signal below the * threshold is excluded from the resolution; the rest of the signals are * processed. */ readonly #minConf: number; /** * @internal Use {@link DidBtcr2.resolve} to create instances. */ constructor( didComponents: DidComponents, sidecarData: SidecarData, currentDocument: DidDocument | null, options?: { versionId?: string; versionTime?: string; genesisDocument?: object; maxDiscoveryRounds?: number; minConf?: number; } ) { this.#didComponents = didComponents; this.#did = Identifier.encode(didComponents.genesisBytes, didComponents); this.#sidecarData = sidecarData; this.#currentDocument = currentDocument; // The resolution options fail here, before any data need is emitted, so the // caller does no I/O for a request it cannot serve. DID Resolution v1 defines // versionId and versionTime as mutually exclusive; the specification raises // INVALID_OPTIONS for a request with both, and for a value that does not parse. if(options?.versionId !== undefined && options?.versionTime !== undefined) { throw new ResolveError( 'Invalid resolution options: versionId and versionTime are mutually exclusive. Pass one of them.', INVALID_OPTIONS, { versionId: options.versionId, versionTime: options.versionTime } ); } this.#versionId = validateVersionId(options?.versionId); this.#versionTime = validateVersionTime(options?.versionTime); // Discovery is unbounded by default; a positive maxDiscoveryRounds opts into a // finite resource guard. A non-positive or omitted value means no limit. const rounds = options?.maxDiscoveryRounds; this.#maxDiscoveryRounds = typeof rounds === 'number' && rounds > 0 ? rounds : Infinity; // The signal confirmation threshold. this.#minConf = validateMinConf(options?.minConf); // If a genesis document was provided (from sidecar), pre-seed it for validation if(options?.genesisDocument) { this.#providedGenesisDocument = options.genesisDocument; } // If current document was established by the factory, skip GenesisDocument phase this.#phase = currentDocument ? ResolverPhase.BeaconDiscovery : ResolverPhase.GenesisDocument; } /** * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#if-genesis_bytes-is-a-secp256k1-public-key | 7.2.d.1 if genesis bytes is a secp256k1 Public Key}. * @param {DidComponents} didComponents The decoded components of the did. * @returns {DidDocument} The resolved DID Document object. */ static deterministic(didComponents: DidComponents): DidDocument { // Deconstruct the bytes from the given components const genesisBytes = didComponents.genesisBytes; // Encode the did from the didComponents const did = Identifier.encode(genesisBytes, didComponents); // Construct a new CompressedSecp256k1PublicKey and deconstruct the publicKey and publicKeyMultibase const { multibase } = new CompressedSecp256k1PublicKey(genesisBytes); // Generate the service field for the DID Document const service = BeaconUtils.generateBeaconServices({ id : did, publicKey : genesisBytes, network : getNetwork(didComponents.network), beaconType : 'SingletonBeacon' }); // The KEY template: one verification method, and four relationships that reference it. return DidDocument.fromKeyIdentifier(did, multibase.encoded, service); } /** * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#if-genesis_bytes-is-a-sha-256-hash | 7.2.d.2 if genesis_bytes is a SHA-256 Hash}. * @param {DidComponents} didComponents BTCR2 DID components used to resolve the DID Document * @param {object} genesisDocument The genesis document for resolving the DID Document. * @returns {DidDocument} The resolved DID Document object * @throws {ResolveError} `INVALID_DID` if the hash of the genesis document is not the genesis bytes of the identifier * @throws {ResolveError} `INVALID_DID_DOCUMENT` if the id of the genesis document is not the placeholder * @throws {DidDocumentError} `INVALID_DID_DOCUMENT` if the result does not conform, for example * if it has no "@context" */ static external( didComponents: DidComponents, genesisDocument: object, ): DidDocument { // Canonicalize and sha256 hash the genesis document const genesisDocumentHash = canonicalHashBytes(genesisDocument); // Compare genesis bytes from identifier against the document hash (byte comparison). // The specification raises INVALID_DID when the computed hash does not match genesis_bytes. if (!equalBytes(didComponents.genesisBytes, genesisDocumentHash)) { throw new ResolveError( `Initial document mismatch: genesisBytes !== genesisDocumentHash`, INVALID_DID, { genesisBytes : encodeHash(didComponents.genesisBytes, 'hex'), genesisDocumentHash : encodeHash(genesisDocumentHash, 'hex') } ); } // A Genesis Document has the placeholder as its id. The replacement below then makes // the id of the document the DID, as a DID document must have. A genesis document // with another id gives a document for another DID. const genesisId = (genesisDocument as { id?: unknown }).id; if (genesisId !== ID_PLACEHOLDER_VALUE) { throw new ResolveError( `Invalid genesis document: the id must be "${ID_PLACEHOLDER_VALUE}", got ${JSON.stringify(genesisId)}`, INVALID_DID_DOCUMENT, { id: genesisId } ); } // Encode the did from the didComponents const did = Identifier.encode(didComponents.genesisBytes, didComponents); // Replace the placeholder did with the did throughout the currentDocument. // Spec "If `genesis_bytes` is a SHA-256 Hash": serialize with JCS, replace each placeholder // with the DID, and parse. JSON.stringify writes the same string tokens in another member // order. Thus the parsed value is the value of the JCS steps, with the same JCS hash. const currentDocument = JSON.parse( JSON.stringify(genesisDocument).replaceAll(ID_PLACEHOLDER_VALUE, did) ); // Return a W3C conformant DID Document return new DidDocument(currentDocument); } /** * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#process-sidecar-data | Process Sidecar Data}. * The SMT map (`smt_lookup_table`) holds a proof only if its `id` decodes to 32 bytes with * the strict decoder (ADR 146). * @param {Sidecar} sidecar The sidecar data to process. * @returns {SidecarData} The maps of updates, CAS Announcements, and SMT proofs. */ static sidecarData(sidecar: Sidecar = {} as Sidecar): SidecarData { // BTCR2 Signed Updates map const updateMap = new Map(); if(sidecar.updates?.length) for(const update of sidecar.updates) { updateMap.set(canonicalHash(update, { encoding: 'hex' }), update); } // CAS Announcements map. The map keeps the JSON form of each announcement, // because the hash covers only that content. Thus an entry that JSON drops (for // example an entry whose value is undefined) is not an entry for the DID. const casMap = new Map(); if(sidecar.casUpdates?.length) for(const update of sidecar.casUpdates) { const announcement = JSON.parse(JSON.stringify(update)); casMap.set(canonicalHash(announcement, { encoding: 'hex' }), announcement); } // SMT Proofs map (smt_lookup_table). The key is the hex of the decoded proof id, // so a lookup matches the hex signalBytes from the OP_RETURN. "Process Sidecar // Data": the resolver MUST ignore a proof whose id has non-zero pad bits. The // resolver also ignores an item that is not an object, and a proof whose id does // not decode to 32 bytes. smtProofRootHex gives no value for such an id, so no // smt_root can match it. Unused sidecar data has no effect on the result. const smtMap = new Map(); if(sidecar.smtProofs?.length) for(const proof of sidecar.smtProofs) { const rootHex = smtProofRootHex(proof); if(rootHex !== undefined) smtMap.set(rootHex, proof); } return { updateMap, casMap, smtMap }; } /** * Hashes the unsigned update of a BTCR2 Signed Update with the JSON Document Hashing * algorithm. The unsigned update is the update without its top-level `proof` property. * A member named `proof` inside the patch is data of the update and stays in the hash. * "Apply Update" and "Confirm Duplicate Update" use the same words, so both use this * function (ADR 141). * @param {SignedBTCR2Update} update The BTCR2 Signed Update. * @returns {HashBytes} The hash of the unsigned update (raw bytes). */ private static unsignedUpdateHash(update: SignedBTCR2Update): HashBytes { const { proof: _, ...unsignedUpdate } = update; return canonicalHashBytes(unsignedUpdate); } /** * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#confirm-duplicate-update | Confirm Duplicate Update}. * This step confirms that an update with a lower-than-expected targetVersionId is a true duplicate. * @param {SignedBTCR2Update} update The BTCR2 Signed Update to confirm as a duplicate. * @param {HashBytes[]} updateHashHistory The accumulated hash history for comparison. * @returns {void} Does not return a value, but throws an error if the update is not a valid duplicate. */ private static confirmDuplicate(update: SignedBTCR2Update, updateHashHistory: HashBytes[]): void { // A conformant update targets the version after the one it patches, so targetVersionId // is an integer of at least 2 (genesis is version 1). Anything else cannot name an // applied update: it is a malformed update, not a duplicate, and without this guard it // would read a nonexistent history slot below and crash on the byte comparison (ADR 068). if (!Number.isInteger(update.targetVersionId) || update.targetVersionId < 2) { throw new ResolveError( `Invalid duplicate: targetVersionId must be an integer >= 2`, INVALID_DID_UPDATE, { targetVersionId: update.targetVersionId } ); } // Create unsigned_update by removing the proof property from update, then hash it // with the JSON Document Hashing algorithm (raw bytes). const unsignedUpdateHash = Resolver.unsignedUpdateHash(update); // Let historicalUpdateHash equal updateHashHistory[updateHashIndex]. const historicalUpdateHash = updateHashHistory[update.targetVersionId - 2]; // The resolver's own loop records one history entry per applied version, so this slot // always exists on that path; a standalone caller, however, can pass a resolutionState // whose version counter outruns its history. A duplicate that cannot be checked against // an applied update is unconfirmable, which is late-publishing evidence, not a pass. if (historicalUpdateHash === undefined) { throw new ResolveError( `Invalid duplicate: no applied update in history for targetVersionId`, LATE_PUBLISHING, { targetVersionId : update.targetVersionId, historyLength : updateHashHistory.length } ); } // Check if the updateHash matches the historical hash (byte comparison) if (!equalBytes(historicalUpdateHash, unsignedUpdateHash)) { throw new ResolveError( `Invalid duplicate: unsigned update hash does not match historical hash`, LATE_PUBLISHING, { unsignedUpdateHash : encodeHash(unsignedUpdateHash, 'hex'), historicalHash : encodeHash(historicalUpdateHash, 'hex') } ); } } /** * Decode a hash of a BTCR2 Update (`sourceHash` or `targetHash`). The specification encodes * both with base64url without padding. * @param {unknown} value The encoded hash. * @param {'sourceHash' | 'targetHash'} field The name of the field, for the error. * @returns {HashBytes} The decoded bytes. * @throws {ResolveError} `INVALID_DID_UPDATE` if the value is not a string or does not decode. */ private static decodeUpdateHash(value: unknown, field: 'sourceHash' | 'targetHash'): HashBytes { if(typeof value === 'string') { try { return decodeHash(value, 'base64urlnopad'); } catch(error) { throw new ResolveError( `Invalid update: ${field} does not decode as base64url: ${errorCause(error).message}`, INVALID_DID_UPDATE, { [field]: value, cause: errorCause(error) } ); } } throw new ResolveError(`Invalid update: ${field} is not a string`, INVALID_DID_UPDATE, { [field]: value }); } /** * Parse a `created` or `expires` value of an update proof. Data Integrity types both as an * XML Schema `dateTimeStamp`: an XML Datetime with a timezone. A value without a timezone * names no fixed instant, so two resolvers would read two instants; it is rejected. * @param {Btcr2DataIntegrityProof} proof The update proof. * @param {'created' | 'expires'} field The field to parse. * @returns {number | undefined} The instant in milliseconds since the Unix epoch, or `undefined` when the field is absent. * @throws {ResolveError} `INVALID_DID_UPDATE` for a value that is not an XML Datetime with a timezone. */ private static proofInstant(proof: Btcr2DataIntegrityProof, field: 'created' | 'expires'): number | undefined { const value = proof[field]; if(value === undefined) return undefined; if(isDateTimeStamp(value)) { const ms = Date.parse(value); if(Number.isFinite(ms)) return ms; } throw new ResolveError( `Invalid update: proof.${field} is not an XML Datetime with a timezone`, INVALID_DID_UPDATE, { [field]: value } ); } /** * Spec "Check `update.proof`": the proof time window against the block that contains the * Beacon Signal. `created` must not be after the header time of the block: a controller * signs a short time before the block, and on mainnet the header time is about one hour * after the `mediantime`. `expires` must not be before the block `mediantime`: it limits a * replay, and a single miner cannot change `mediantime`. `expires` must not be before * `created`. Each comparison has no tolerance. * @param {Btcr2DataIntegrityProof} proof The update proof. * @param {BlockMetadata} block The block of the Beacon Signal. * @throws {ResolveError} `INVALID_DID_UPDATE` if a value is outside the window. */ private static checkProofWindow(proof: Btcr2DataIntegrityProof, block: BlockMetadata): void { const created = Resolver.proofInstant(proof, 'created'); const expires = Resolver.proofInstant(proof, 'expires'); if(created !== undefined && created > block.time * 1000) { throw new ResolveError( 'Invalid update: proof.created is after the header time of the block that contains the Beacon Signal', INVALID_DID_UPDATE, { created: proof.created, blockTime: block.time } ); } if(expires !== undefined && expires < block.mediantime * 1000) { throw new ResolveError( 'Invalid update: proof.expires is before the mediantime of the block that contains the Beacon Signal', INVALID_DID_UPDATE, { expires: proof.expires, mediantime: block.mediantime } ); } if(created !== undefined && expires !== undefined && expires < created) { throw new ResolveError( 'Invalid update: proof.expires is before proof.created', INVALID_DID_UPDATE, { created: proof.created, expires: proof.expires } ); } } /** * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#apply-update | Apply update} * and its step {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#check-update-proof | Check update.proof}. * Every failure that the specification names raises `INVALID_DID_UPDATE`. An error of the * cryptosuite, the multikey, the hash decoder, or the patch rides along as `data.cause`. * @param {string} did The DID under resolution. * @param {DidDocument} currentDocument The current DID Document to apply the update to. * @param {SignedBTCR2Update} update The BTCR2 Signed Update to apply. * @param {BlockMetadata} block The block that contains the Beacon Signal that announced the update. * @returns {DidDocument} The updated DID Document after applying the update. * @throws {ResolveError} `INVALID_DID_UPDATE` if the update is invalid or cannot be applied. */ private static applyUpdate( did: string, currentDocument: DidDocument, update: SignedBTCR2Update, block: BlockMetadata ): DidDocument { // Spec "Apply update": the hash of the current document must be the decoded // update.sourceHash (byte comparison). const currentDocumentHash = canonicalHashBytes(currentDocument); const sourceHashBytes = Resolver.decodeUpdateHash(update.sourceHash, 'sourceHash'); if (!equalBytes(sourceHashBytes, currentDocumentHash)) { throw new ResolveError( `Hash mismatch: update.sourceHash !== currentDocumentHash`, INVALID_DID_UPDATE, { sourceHash : update.sourceHash, currentDocumentHash : encodeHash(currentDocumentHash, 'hex') } ); } // Spec "Check update.proof": the update @context must be the array that the BTCR2 // Unsigned Update data structure pins, and the proof @context must equal it, member // for member and in order. The array is inside the hashed and signed bytes, so an // update with another array is a different update. The check runs before signature // verification so that the failure names the array and not the signature. if(!isBtcr2UpdateContext(update['@context'])) { throw new ResolveError( 'Invalid update: @context is not the array the specification pins for a BTCR2 Update', INVALID_DID_UPDATE, { context: update['@context'], expected: [ ...BTCR2_UPDATE_CONTEXT ] } ); } if(!isBtcr2UpdateContext(update.proof?.['@context'], update['@context'])) { throw new ResolveError( 'Invalid update: proof @context does not equal the update @context', INVALID_DID_UPDATE, { proofContext: update.proof?.['@context'], context: update['@context'] } ); } // Spec "Check update.proof": each proof field by string equality, before the method // lookup and before signature verification, so that a failure names the field. The // capability is the URN that the Data Integrity Config specifies for this DID, and the // invocation target is the DID; the root capability is not derived, the specification // makes that optional. const proof = update.proof; const expectedFields: Array<[ string, string ]> = [ [ 'type', 'DataIntegrityProof' ], [ 'cryptosuite', 'bip340-jcs-2025' ], [ 'proofPurpose', 'capabilityInvocation' ], [ 'capabilityAction', 'Write' ], [ 'capability', `urn:zcap:root:${encodeURIComponent(did)}` ], [ 'invocationTarget', did ], ]; for(const [ field, expected ] of expectedFields) { const actual = (proof as Record)[field]; if(actual !== expected) { throw new ResolveError( `Invalid update: proof.${field} must equal "${expected}"`, INVALID_DID_UPDATE, { field, expected, actual } ); } } // Spec "Check update.proof": the entry of currentDocument.capabilityInvocation that // identifies update.proof.verificationMethod, in the reference form or the embedded // form. A key the controller published for authentication only, or for no relationship // at all, must not authorize an update; the membership test runs before the method // lookup so that such a key always fails with this typed error. The method is the // entry itself when embedded, else the member of verificationMethod[] with that id. const verificationMethodId = proof.verificationMethod; const entry = Appendix.capabilityInvocationEntry(currentDocument, verificationMethodId); if(entry === undefined) { throw new ResolveError( 'Invalid update: verificationMethod is not authorized for capabilityInvocation', INVALID_DID_UPDATE, { verificationMethodId, capabilityInvocation : currentDocument.capabilityInvocation } ); } const vm = Appendix.verificationMethodOfEntry(currentDocument, entry); if(vm === undefined) { throw new ResolveError( 'Invalid update: verificationMethod is not found in the verificationMethod of the current document', INVALID_DID_UPDATE, { verificationMethodId } ); } // Spec "Check update.proof": the method that signs, embedded or referenced, must have // the type Multikey, the DID as its controller, and a secp256k1 public key in the BIP340 // Multikey encoding. The check runs after the method lookup and before the time window, // in the order of the specification. It also runs before signature verification, so // that a failure names the field of the method and not the signature. const problem = signingMethodProblem(vm, did); if(problem !== undefined) { const reason = problem.cause !== undefined ? `: ${problem.cause.message}` : ''; throw new ResolveError( `Invalid update: verificationMethod.${problem.message}${reason}`, INVALID_DID_UPDATE, { verificationMethodId, field : problem.field, actual : vm[problem.field], ...(problem.cause !== undefined && { cause: problem.cause }) } ); } // Spec "Check update.proof": the proof time window against the block of the signal. Resolver.checkProofWindow(proof, block); // Verify the proof with the public key that the verification method publishes. The // multikey names the method by its absolute DID URL, as the proof does. The key decodes: // the method check above decodes it. An error of the cryptosuite (a proof value that does // not decode, a created value the suite rejects) is an invalid update. let verified: boolean; try { const multikey = SchnorrMultikey.fromVerificationMethod({ ...vm, id : Appendix.absoluteDidUrl(vm.id, currentDocument.id) ?? vm.id }); const diProof = new BIP340DataIntegrityProof(new BIP340Cryptosuite(multikey)); verified = diProof.verifyProof(canonicalize(update), 'capabilityInvocation').verified; } catch(error) { throw new ResolveError( `Invalid update: proof verification failed: ${errorCause(error).message}`, INVALID_DID_UPDATE, { verificationMethodId, cause: errorCause(error) } ); } if(!verified) { throw new ResolveError('Invalid update: proof not verified', INVALID_DID_UPDATE, { verificationMethodId }); } // Spec "Apply update": apply update.patch strictly. The first operation that fails, // including a failed test, fails the whole patch. let updatedDocument: DidDocument; try { updatedDocument = JSONPatch.apply(currentDocument, update.patch, { strict: true }) as DidDocument; } catch(error) { throw new ResolveError( `Invalid update: ${errorCause(error).message}`, INVALID_DID_UPDATE, { cause: errorCause(error) } ); } // Spec "Apply update": the patched document keeps the DID as its id and conforms to // DID Core v1.1. if(updatedDocument?.id !== did) { throw new ResolveError( `Invalid update: the patched document id is not the DID (expected "${did}", got "${String(updatedDocument?.id)}")`, INVALID_DID_UPDATE, { did, id: updatedDocument?.id } ); } try { DidDocument.validate(updatedDocument); } catch(error) { throw new ResolveError( `Invalid update: the patched document does not conform to DID Core: ${errorCause(error).message}`, INVALID_DID_UPDATE, { cause: errorCause(error) } ); } // Canonicalize and hash the updatedDocument (raw bytes). const updatedDocumentHash = canonicalHashBytes(updatedDocument); // Prepare the update targetHash for comparison with updatedDocumentHash. const updateTargetHash = Resolver.decodeUpdateHash(update.targetHash, 'targetHash'); // Make sure the update.targetHash equals updatedDocumentHash. if (!equalBytes(updateTargetHash, updatedDocumentHash)) { throw new ResolveError( `Invalid update: update.targetHash !== updatedDocumentHash`, INVALID_DID_UPDATE, { updateTargetHash, updatedDocumentHash } ); } // Return final updatedDocument. return updatedDocument; } /** * Advance the state machine. Returns either: * - `{ status: 'action-required', needs }` - caller must provide data via {@link provide} * - `{ status: 'resolved', result }` - resolution complete * * Analogous to Rust's `Resolver::resolve()`. */ resolve(): ResolverState { // Internal loop: keeps advancing through phases until data is needed or done while(true) { switch(this.#phase) { // Phase: GenesisDocument // Only entered for EXTERNAL (x HRP) identifiers when genesis doc was not in sidecar. case ResolverPhase.GenesisDocument: { if(this.#providedGenesisDocument) { // Genesis doc was provided, establish the current document this.#currentDocument = Resolver.external( this.#didComponents, this.#providedGenesisDocument ); this.#providedGenesisDocument = null; this.#phase = ResolverPhase.BeaconDiscovery; continue; } // Need genesis document from caller const genesisHash = encodeHash(this.#didComponents.genesisBytes, 'hex'); return { status : 'action-required', needs : [{ kind: 'NeedGenesisDocument', genesisHash }] }; } // Phase: BeaconDiscovery // Extract beacon services, emit NeedBeaconSignals for addresses not yet queried. case ResolverPhase.BeaconDiscovery: { const beaconServices = BeaconUtils.getBeaconServices(this.#currentDocument!); // Filter to services whose addresses haven't been requested yet const newServices = beaconServices.filter(service => { const address = BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string); return !this.#requestCache.has(address); }); if(newServices.length > 0) { // Mark addresses as requested so we don't re-request on subsequent rounds for(const service of newServices) { const address = BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string); this.#requestCache.add(address); } return { status : 'action-required', needs : [{ kind: 'NeedBeaconSignals', beaconServices: newServices }] }; } // No new beacon services to query, move to processing this.#phase = ResolverPhase.BeaconProcess; continue; } // Phase: BeaconProcess // Process each beacon's signals. Collect updates and data needs. case ResolverPhase.BeaconProcess: { const allNeeds: Array = []; for(const [service, signals] of this.#beaconServicesSignals) { // Skip a processed address and a service with no signals const address = BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string); if(this.#processedAddresses.has(address) || !signals.length) continue; // Keep only the signals at or above the confirmation threshold. A // service whose signals are all below it is treated like a service // with no signals: it is not processed and not marked processed. const eligible = this.#eligibleSignals(signals); if(!eligible.length) continue; // Establish a typed beacon and process its signals // The beacon is bound to the DID under resolution: a beacon service // `id` may be a relative DID URL, so it cannot supply the subject. const beacon = BeaconFactory.establish(service, this.#did); const result = beacon.processSignals(eligible, this.#sidecarData); if(result.needs.length > 0) { // This service has unmet data needs, collect them allNeeds.push(...result.needs); } else { // All signals of this address resolved: collect the updates with the // beacon address, mark the address processed. this.#unsortedUpdates.push(...result.updates.map( ([update, block]): UpdateTuple => [update, block, address] )); this.#processedAddresses.add(address); } } if(allNeeds.length > 0) { return { status: 'action-required', needs: allNeeds }; } this.#phase = ResolverPhase.ProcessUpdate; continue; } // Phase: ProcessUpdate // Spec "Process Next Update": one tuple per step. The phase repeats until // the document resolves, or until an applied update adds a beacon address // that the resolver did not scan (then the pass returns to BeaconDiscovery). // A tuple whose beacon address the current document no longer carries is // ignored (step 4). case ResolverPhase.ProcessUpdate: { const document = this.#currentDocument!; // Step 1: the resolver reaches the requested version. Save the state if no // state is saved yet. The resolver then processes the rest of the history // (ADR 145). The test runs before Apply, so version 1 is reachable. if(this.#versionId !== undefined && this.#currentVersionId === this.#versionId) { this.#saveRequestedState(); } // Step 2: no tuple is left, or current_document.deactivated is true. A truthy // value that is not the boolean true does not stop resolution (ADR 142). A saved // requested state is the result. Else a requested version that the history does // not reach is NOT_FOUND. const deactivated = document.deactivated === true; if(this.#unsortedUpdates.length === 0 || deactivated) { const requested = this.#requestedState; if(requested !== undefined) { this.#currentDocument = requested.document; this.#currentVersionId = requested.versionId; this.#blockConfirmations = requested.confirmations; this.#updated = requested.updated; } else if(this.#versionId !== undefined) { throw new ResolveError( `Version ${this.#versionId} of the DID does not exist: the history ` + (deactivated ? `ends with the deactivation at version ${this.#currentVersionId}.` : `ends at version ${this.#currentVersionId}.`), NOT_FOUND, { versionId: this.#versionId, currentVersionId: this.#currentVersionId } ); } this.#phase = ResolverPhase.Complete; continue; } // Step 3: sort the tuples by targetVersionId (ascending), then by block // height, and remove the first one. The sort runs on every step because // a scan between two steps can add a tuple with a lower version. this.#unsortedUpdates.sort(([upd0, blk0], [upd1, blk1]) => upd0.targetVersionId - upd1.targetVersionId || blk0.height - blk1.height ); const [update, block, address] = this.#unsortedUpdates.shift()!; // Step 4: the current document has no beacon with the address of the signal: // an applied update removed the beacon. Ignore the tuple (ADR 114). It stamps // nothing, it enters no history, and it does not reach the duplicate check // below: a conflicting re-announcement at a removed address is not an // equivocation of the DID. The test reads the document before Apply, so an // update announced at the address that it removes itself applies. const removed = !BeaconUtils.getBeaconServices(document).some(service => BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string) === address ); if(removed) continue; // Step 6, "Check targetVersionId", first arm: targetVersionId <= currentVersionId // re-announces an applied version. Confirm that it is a true duplicate, then // skip it. A duplicate does not advance the version counter, does not append // to the history (the slot already holds the applied update, ADR 067), and // does not stamp the metadata: confirmations refers to the block of the most // recently applied unique update. The branch runs before the versionTime // test (ADR 068). A re-announcement mined after versionTime does not save the // requested state. Thus a next version mined at or before versionTime still // applies before the save (footnote 4 of the specification). if(update.targetVersionId <= this.#currentVersionId) { Resolver.confirmDuplicate(update, this.#updateHashHistory); continue; } // Step 5: if no state is saved yet and the block mediantime of the tuple is // after versionTime, the current state is the requested state. Save it. The // tuple then goes to step 6, and the resolver processes the rest of the history // (ADR 145). The boundary is inclusive, so a tuple whose mediantime equals // versionTime does not save. if(this.#versionTime !== undefined && block.mediantime * 1000 > this.#versionTime) { this.#saveRequestedState(); } // Step 6, third arm: a version was skipped, so raise LATE_PUBLISHING. if(update.targetVersionId !== this.#currentVersionId + 1) { throw new ResolveError( `Version Id Mismatch: targetVersionId cannot be > currentVersionId + 1`, LATE_PUBLISHING, { targetVersionId : update.targetVersionId, currentVersionId : this.#currentVersionId + 1 } ); } // Step 6, second arm: targetVersionId == currentVersionId + 1. Apply the update, // append the unsigned update hash to the history, increment the version. this.#currentDocument = Resolver.applyUpdate(this.#did, document, update, block); this.#updateHashHistory.push(Resolver.unsignedUpdateHash(update)); this.#currentVersionId++; // "Apply Update": block_confirmations, current_block_height, and the block // mediantime as `updated` (block_mediantime). On the apply path only: the // duplicate branch stamps nothing. A saved requested state keeps the values // from before this stamp. this.#blockConfirmations = block.confirmations; this.#currentBlockHeight = block.height; this.#updated = DateUtils.toISOStringNonFractional(DateUtils.blocktimeToTimestamp(block.mediantime)); // The applied update can add a beacon service. "Find Beacon Signals" runs at // the top of every pass for the addresses that are not scanned yet, so the // pass returns to BeaconDiscovery before the next tuple. Discovery is // unbounded by default: termination is guaranteed by address // de-duplication (#requestCache). An opt-in maxDiscoveryRounds lets a caller // bound the work as a resource guard. Exceeding it is a limit the caller // imposed, not a malformed document, so it surfaces as INTERNAL_ERROR. if(this.#hasUnscannedBeacons()) { if(++this.#discoveryRounds > this.#maxDiscoveryRounds) { throw new ResolveError( `Exceeded the configured maximum of ${this.#maxDiscoveryRounds} beacon-discovery ` + 'rounds. Raise or remove ResolutionOptions.maxDiscoveryRounds to resolve this DID.', INTERNAL_ERROR, { maxDiscoveryRounds: this.#maxDiscoveryRounds, discoveryRounds: this.#discoveryRounds } ); } this.#phase = ResolverPhase.BeaconDiscovery; } continue; } // Phase: Complete // The document metadata of the specification: versionId is current_version_id, // confirmations is block_confirmations (0 at version 1), deactivated is // deactivated === true of the document, thus always a boolean (ADR 142). // `updated` is present if current_version_id is more than 1. For a historical // request, step 2 restored these values from the requested state, so the // metadata reports the requested version. case ResolverPhase.Complete: { this.#resolvedResponse ??= { didDocument : this.#currentDocument!, metadata : { versionId : `${this.#currentVersionId}`, confirmations : this.#blockConfirmations, ...(this.#updated !== undefined ? { updated: this.#updated } : {}), deactivated : this.#currentDocument!.deactivated === true } }; return { status: 'resolved', result: this.#resolvedResponse }; } } } } /** * Save the current state as `requested_state` ("Process Next Update", steps 1 and 5) * if no saved state exists. The function does not copy the document. An apply sets * `#currentDocument` to a new object, because `JSONPatch.apply` patches a clone. Thus * the saved object does not change. */ #saveRequestedState(): void { this.#requestedState ??= { document : this.#currentDocument!, versionId : this.#currentVersionId, confirmations : this.#blockConfirmations, updated : this.#updated }; } /** * True if the current document carries a beacon service whose address the resolver * did not request signals for. "Find Beacon Signals" scans such an address on the * next pass. */ #hasUnscannedBeacons(): boolean { return BeaconUtils.getBeaconServices(this.#currentDocument!).some(service => !this.#requestCache.has(BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string)) ); } /** * Return the signals of one beacon service that resolution may process: the * signals with at least `#minConf` confirmations. The specification removes a * transaction below the threshold from the set of Beacon Signals, so an * excluded signal emits no data need and applies no update. A signal with no * integer confirmation count is excluded too: that is a mempool transaction * from a driver that did not skip it. * * An eligible signal must carry a finite block height, block time, and block * median time past. A signal that passes the count but lacks them is * malformed. It fails fast here with a typed error, in the style of the * {@link provide} guards, and not later with an invalid date or a false * `versionTime` comparison in the ProcessUpdate phase. * * "Find Beacon Signals" finds only the transactions at or above * `current_block_height`, the height of the block of the most recently applied * update. A signal below it is excluded: it emits no data need and applies no update. * @param {Array} signals The signals the caller provided for one service. * @returns {Array} The signals at or above the threshold and the height, in the given order. * @throws {ResolveError} `INVALID_DID_UPDATE` for an eligible signal with no valid block metadata. */ #eligibleSignals(signals: Array): Array { const eligible: Array = []; for(const signal of signals) { const block = signal.blockMetadata as Partial | undefined; const confirmations = block?.confirmations; if(!Number.isInteger(confirmations) || (confirmations as number) < this.#minConf) { continue; } if(!Number.isFinite(block?.height) || !Number.isFinite(block?.time) || !Number.isFinite(block?.mediantime)) { throw new ResolveError( `Beacon signal ${signal.signalBytes} has ${confirmations} confirmations ` + 'but no valid block height, block time, or block mediantime.', INVALID_DID_UPDATE, { signalBytes : signal.signalBytes, confirmations, height : block?.height, time : block?.time, mediantime : block?.mediantime } ); } // "Find Beacon Signals" finds only the transactions at or above current_block_height. // A signal before the block of the update that added the address is not a signal // of this DID. if((block!.height as number) < this.#currentBlockHeight) continue; eligible.push(signal); } return eligible; } /** * Provide data the resolver requested in a previous {@link resolve} call. * Call once per need, then call {@link resolve} again to continue. * * Analogous to Rust's `Resolver::process_responses()`. * * @param need The DataNeed being fulfilled (from the `needs` array). * @param data The data payload corresponding to the need kind. */ provide(need: NeedGenesisDocument, data: object): void; provide(need: NeedBeaconSignals, data: Map>): void; provide(need: NeedCASAnnouncement, data: CASAnnouncement): void; provide(need: NeedSignedUpdate, data: SignedBTCR2Update): void; provide(need: NeedSMTProof, data: SMTProof): void; provide(need: DataNeed, data: object | Map> | CASAnnouncement | SignedBTCR2Update | SMTProof): void { switch(need.kind) { case 'NeedGenesisDocument': { if(!isRecord(data)) { throw new ResolveError( 'Provided data for NeedGenesisDocument must be a document object.', INVALID_DID_UPDATE, { kind: need.kind } ); } this.#providedGenesisDocument = data; break; } case 'NeedBeaconSignals': { if(!(data instanceof Map)) { throw new ResolveError( 'Provided data for NeedBeaconSignals must be a Map of beacon services to signals.', INVALID_DID_UPDATE, { kind: need.kind } ); } for(const [service, serviceSignals] of data) { this.#beaconServicesSignals.set(service, serviceSignals); } break; } case 'NeedCASAnnouncement': { if(!isCASAnnouncement(data)) { throw new ResolveError( 'Provided data for NeedCASAnnouncement is not a CAS announcement.', INVALID_DID_UPDATE, { kind: need.kind } ); } // Fail fast if the provided announcement is not the one the on-chain // signal requested: its canonical hash must equal the need's hash. The // specification ("Process CAS Beacon") treats an announcement whose hash is // not map_update_hash as not available from CAS: MISSING_UPDATE_DATA. const announcementHash = canonicalHash(data, { encoding: 'hex' }); if(announcementHash !== need.announcementHash) { throw new ResolveError( `CAS announcement hash mismatch: expected ${need.announcementHash}, got ${announcementHash}.`, MISSING_UPDATE_DATA, { expected: need.announcementHash, actual: announcementHash } ); } // Keep the JSON form: the hash covers only that content. this.#sidecarData.casMap.set(announcementHash, JSON.parse(JSON.stringify(data))); break; } case 'NeedSignedUpdate': { if(!isSignedBTCR2Update(data)) { throw new ResolveError( 'Provided data for NeedSignedUpdate is not a signed BTCR2 update.', INVALID_DID_UPDATE, { kind: need.kind } ); } // Fail fast if the provided update is not the one the on-chain signal // requested: the specification compares the JSON Document Hash of a // retrieved update to update_hash, and a mismatch is INVALID_SIGNAL_DATA. const updateHash = canonicalHash(data, { encoding: 'hex' }); if(updateHash !== need.updateHash) { throw new ResolveError( `Signed update hash mismatch: expected ${need.updateHash}, got ${updateHash}.`, INVALID_SIGNAL_DATA, { expected: need.updateHash, actual: updateHash } ); } this.#sidecarData.updateMap.set(updateHash, data); break; } case 'NeedSMTProof': { // "Process Sidecar Data": the resolver MUST ignore a sidecar proof whose id // has non-zero pad bits. ADR 146 applies the same rule to a proof that // provide() gets. An ignored proof leaves the need open: MISSING_UPDATE_DATA, // as for an smt_lookup_table with no entry for smt_root. const id = isRecord(data) ? (data as { id?: unknown }).id : undefined; if(hasNonZeroPadBits(id)) { throw new ResolveError( `The resolver ignores the SMT proof for the root hash ${need.smtRootHash}: its id has non-zero pad bits.`, MISSING_UPDATE_DATA, { smtRootHash: need.smtRootHash, id } ); } // A proof of another shape is data for the signal that does not agree with // its Signal Bytes: INVALID_SIGNAL_DATA, as for the id and the walk below. if(!isSMTProof(data)) { throw new ResolveError( 'Provided data for NeedSMTProof is not an SMT proof.', INVALID_SIGNAL_DATA, { kind: need.kind } ); } // proof.id is base64url per spec; smtRootHash is the hex on-chain signal. // "Process SMT Beacon" raises INVALID_SIGNAL_DATA if the decoded id of // smt_proof is not smt_root. This library also raises it for an id that the // strict decoder refuses for a reason other than pad bits (ADR 120, ADR 146). const proofIdHex = smtProofRootHex(data); if(proofIdHex !== need.smtRootHash) { throw new ResolveError( `SMT proof root hash mismatch: expected ${need.smtRootHash}, got ${proofIdHex ?? 'an id that does not decode'}.`, INVALID_SIGNAL_DATA, { expected: need.smtRootHash, actual: proofIdHex } ); } this.#sidecarData.smtMap.set(need.smtRootHash, data); break; } } } }