@did-btcr2/method 0.61.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,12 +7,13 @@ import {
7
7
  encode as encodeHash,
8
8
  decode as decodeHash,
9
9
  INTERNAL_ERROR,
10
- INVALID_DID_DOCUMENT,
10
+ INVALID_DID,
11
11
  INVALID_DID_UPDATE,
12
12
  INVALID_OPTIONS,
13
13
  JSONPatch,
14
14
  JSONUtils,
15
15
  LATE_PUBLISHING_ERROR,
16
+ NOT_FOUND,
16
17
  ResolveError
17
18
  } from '@did-btcr2/common';
18
19
  import type { HashBytes } from '@did-btcr2/common';
@@ -49,15 +50,27 @@ import { equalBytes } from '@noble/curves/utils.js';
49
50
  export const DEFAULT_MIN_CONF = 6;
50
51
 
51
52
  /**
52
- * The response object for DID Resolution.
53
+ * The response object for DID Resolution. `metadata` is the DID document metadata
54
+ * of the specification: `versionId`, `confirmations`, and `deactivated` are always
55
+ * present; `updated` is present after the resolver applies an update.
53
56
  */
54
57
  export interface DidResolutionResponse {
55
58
  didDocument: DidDocument;
56
59
  metadata: {
57
- confirmations?: number;
60
+ /**
61
+ * Number of confirmations of the Bitcoin block that contains the last applied
62
+ * unique update. `0` when the resolver applied no update.
63
+ */
64
+ confirmations: number;
65
+ /** The version of the resolved document as an ASCII string. `"1"` when the resolver applied no update. */
58
66
  versionId: string;
67
+ /**
68
+ * XML Datetime (UTC, no fraction) of the block of the last applied update.
69
+ * Absent until the resolver applies an update.
70
+ */
59
71
  updated?: string;
60
- deactivated?: boolean;
72
+ /** Whether the resolved document is deactivated. */
73
+ deactivated: boolean;
61
74
  }
62
75
  }
63
76
 
@@ -169,21 +182,73 @@ function isSMTProof(value: unknown): value is SMTProof {
169
182
  function validateMinConf(value: unknown): number {
170
183
  if(value === undefined) return DEFAULT_MIN_CONF;
171
184
  if(typeof value === 'number' && Number.isInteger(value) && value >= 1) return value;
172
- const shown = typeof value === 'string' ? JSON.stringify(value) : String(value);
173
185
  throw new ResolveError(
174
- `Invalid resolution option minConf: expected a positive integer (minimum 1), got ${shown}.`,
186
+ `Invalid resolution option minConf: expected a positive integer (minimum 1), got ${shown(value)}.`,
175
187
  INVALID_OPTIONS, { minConf: value }
176
188
  );
177
189
  }
178
190
 
191
+ /** Render an option value for an error message: a string in quotes, any other value as is. */
192
+ function shown(value: unknown): string {
193
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
194
+ }
195
+
196
+ /** An ASCII string of an integer: an optional minus sign, then digits. */
197
+ const ASCII_INTEGER = /^-?[0-9]+$/;
198
+
199
+ /**
200
+ * Parse `ResolutionOptions.versionId`. The specification says that the value MUST
201
+ * parse as an integer, and DID Resolution v1 types the option as a string. The
202
+ * accepted form is an ASCII string of an integer inside the safe integer range.
203
+ * @returns {number | undefined} The integer, or `undefined` when the option is absent.
204
+ * @throws {ResolveError} `INVALID_OPTIONS` for every other value.
205
+ */
206
+ function validateVersionId(value: unknown): number | undefined {
207
+ if(value === undefined) return undefined;
208
+ if(typeof value === 'string' && ASCII_INTEGER.test(value) && Number.isSafeInteger(Number(value))) {
209
+ return Number(value);
210
+ }
211
+ throw new ResolveError(
212
+ `Invalid resolution option versionId: expected an ASCII string of an integer, got ${shown(value)}.`,
213
+ INVALID_OPTIONS, { versionId: value }
214
+ );
215
+ }
216
+
217
+ /** An XML Datetime in UTC with the `Z` designator and no fraction, for example `2026-07-01T00:00:00Z`. */
218
+ const UTC_XSD_DATETIME = /^-?\d{4,}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
219
+
179
220
  /**
180
- * Different possible Resolver states representing phases in the resolution process.
221
+ * Parse `ResolutionOptions.versionTime`. DID Resolution v1 requires an XML Datetime
222
+ * normalized to UTC without sub-second precision. The specification raises
223
+ * `INVALID_OPTIONS` for a value that does not parse.
224
+ * @returns {number | undefined} The instant in milliseconds since the Unix epoch, or `undefined` when the option is absent.
225
+ * @throws {ResolveError} `INVALID_OPTIONS` for every other value.
226
+ */
227
+ function validateVersionTime(value: unknown): number | undefined {
228
+ if(value === undefined) return undefined;
229
+ if(typeof value === 'string' && UTC_XSD_DATETIME.test(value) && DateUtils.isValidXsdDateTime(value)) {
230
+ const ms = Date.parse(value);
231
+ if(Number.isFinite(ms)) return ms;
232
+ }
233
+ throw new ResolveError(
234
+ 'Invalid resolution option versionTime: expected an XML Datetime in UTC without a fraction '
235
+ + `(for example "2026-07-01T00:00:00Z"), got ${shown(value)}.`,
236
+ INVALID_OPTIONS, { versionTime: value }
237
+ );
238
+ }
239
+
240
+ /**
241
+ * The phases of the resolution process. Each pass of the specification loop is
242
+ * BeaconDiscovery (the scan of the beacon addresses the resolver did not scan yet),
243
+ * BeaconProcess (the tuples of the signals the caller provided), and ProcessUpdate
244
+ * (one tuple). GenesisDocument runs once, for an EXTERNAL identifier whose genesis
245
+ * document is not in the sidecar.
181
246
  */
182
247
  enum ResolverPhase {
183
248
  GenesisDocument = 'GenesisDocument',
184
249
  BeaconDiscovery = 'BeaconDiscovery',
185
250
  BeaconProcess = 'BeaconProcess',
186
- ApplyUpdates = 'ApplyUpdates',
251
+ ProcessUpdate = 'ProcessUpdate',
187
252
  Complete = 'Complete',
188
253
  }
189
254
 
@@ -212,8 +277,10 @@ enum ResolverPhase {
212
277
  export class Resolver {
213
278
  // --- Immutable inputs ---
214
279
  readonly #didComponents: DidComponents;
215
- readonly #versionId?: string;
216
- readonly #versionTime?: string;
280
+ /** The parsed `ResolutionOptions.versionId`, or `undefined` when the option is absent. */
281
+ readonly #versionId?: number;
282
+ /** The parsed `ResolutionOptions.versionTime` in milliseconds since the Unix epoch, or `undefined`. */
283
+ readonly #versionTime?: number;
217
284
 
218
285
  /**
219
286
  * The specific phase the Resolver is current in.
@@ -224,23 +291,29 @@ export class Resolver {
224
291
  #providedGenesisDocument: object | null = null;
225
292
  #beaconServicesSignals: Map<BeaconService, Array<BeaconSignal>> = new Map();
226
293
  #processedServices: Set<string> = new Set();
294
+ /** The beacon addresses the resolver requested signals for: `scanned_beacons` of the specification. */
227
295
  #requestCache: Set<string> = new Set();
296
+ /**
297
+ * The tuples of the specification's `updates` list: a signed update and the metadata of
298
+ * the block that announced it. BeaconProcess appends; ProcessUpdate sorts the list and
299
+ * removes one tuple per step. A tuple that one pass does not reach waits for the next.
300
+ */
228
301
  #unsortedUpdates: Array<[SignedBTCR2Update, BlockMetadata]> = [];
229
302
  #resolvedResponse: DidResolutionResponse | null = null;
230
303
 
231
304
  /**
232
- * Monotonic DID-document version counter and the update-hash history that backs
233
- * duplicate confirmation, both carried across the entire resolution. The spec's
234
- * read algorithm keeps a single version counter and a single update-hash history
235
- * for the whole signal-processing loop, re-deriving beacons from the contemporary
236
- * document on each pass. This sans-I/O resolver splits that one loop into discovery
237
- * rounds, so the two must persist across rounds rather than restart each pass.
238
- * Restarting them would reject a legitimate linear history whose later updates are
239
- * announced on beacons that earlier updates added: round two would forget it had
240
- * already reached version two, see version three, and raise a late-publishing error.
305
+ * The state of the specification loop, carried across every pass: the version counter
306
+ * (`current_version_id`), the update-hash history that backs duplicate confirmation
307
+ * (`update_hash_history`), the confirmations of the block that contains the most
308
+ * recently applied unique update (`block_confirmations`), and the header time of that
309
+ * block as `updated`. A pass that finds a new beacon address returns to discovery, so
310
+ * the state must not restart: a restart would reject a linear history whose later
311
+ * updates are announced on beacons that earlier updates added.
241
312
  */
242
313
  #currentVersionId = 1;
243
314
  #updateHashHistory: HashBytes[] = [];
315
+ #blockConfirmations = 0;
316
+ #updated?: string;
244
317
 
245
318
  /**
246
319
  * Opt-in upper bound on multi-round beacon-discovery passes. `Infinity` (the
@@ -280,14 +353,23 @@ export class Resolver {
280
353
  this.#didComponents = didComponents;
281
354
  this.#sidecarData = sidecarData;
282
355
  this.#currentDocument = currentDocument;
283
- this.#versionId = options?.versionId;
284
- this.#versionTime = options?.versionTime;
356
+ // The resolution options fail here, before any data need is emitted, so the
357
+ // caller does no I/O for a request it cannot serve. DID Resolution v1 defines
358
+ // versionId and versionTime as mutually exclusive; the specification raises
359
+ // INVALID_OPTIONS for a request with both, and for a value that does not parse.
360
+ if(options?.versionId !== undefined && options?.versionTime !== undefined) {
361
+ throw new ResolveError(
362
+ 'Invalid resolution options: versionId and versionTime are mutually exclusive. Pass one of them.',
363
+ INVALID_OPTIONS, { versionId: options.versionId, versionTime: options.versionTime }
364
+ );
365
+ }
366
+ this.#versionId = validateVersionId(options?.versionId);
367
+ this.#versionTime = validateVersionTime(options?.versionTime);
285
368
  // Discovery is unbounded by default; a positive maxDiscoveryRounds opts into a
286
369
  // finite resource guard. A non-positive or omitted value means no limit.
287
370
  const rounds = options?.maxDiscoveryRounds;
288
371
  this.#maxDiscoveryRounds = typeof rounds === 'number' && rounds > 0 ? rounds : Infinity;
289
- // The signal confirmation threshold. An invalid value fails here, before any
290
- // data need is emitted, so the caller does no I/O for a request it cannot serve.
372
+ // The signal confirmation threshold.
291
373
  this.#minConf = validateMinConf(options?.minConf);
292
374
 
293
375
  // If a genesis document was provided (from sidecar), pre-seed it for validation
@@ -341,7 +423,7 @@ export class Resolver {
341
423
  * @param {DidComponents} didComponents BTCR2 DID components used to resolve the DID Document
342
424
  * @param {object} genesisDocument The genesis document for resolving the DID Document.
343
425
  * @returns {DidDocument} The resolved DID Document object
344
- * @throws {ResolveError} InvalidDidDocument if not conformant to DID Core v1.1
426
+ * @throws {ResolveError} `INVALID_DID` if the hash of the genesis document is not the genesis bytes of the identifier
345
427
  */
346
428
  static external(
347
429
  didComponents: DidComponents,
@@ -350,11 +432,12 @@ export class Resolver {
350
432
  // Canonicalize and sha256 hash the genesis document
351
433
  const genesisDocumentHash = canonicalHashBytes(genesisDocument);
352
434
 
353
- // Compare genesis bytes from identifier against the document hash (byte comparison)
435
+ // Compare genesis bytes from identifier against the document hash (byte comparison).
436
+ // The specification raises INVALID_DID when the computed hash does not match genesis_bytes.
354
437
  if (!equalBytes(didComponents.genesisBytes, genesisDocumentHash)) {
355
438
  throw new ResolveError(
356
439
  `Initial document mismatch: genesisBytes !== genesisDocumentHash`,
357
- INVALID_DID_DOCUMENT, {
440
+ INVALID_DID, {
358
441
  genesisBytes : encodeHash(didComponents.genesisBytes, 'hex'),
359
442
  genesisDocumentHash : encodeHash(genesisDocumentHash, 'hex')
360
443
  }
@@ -405,157 +488,7 @@ export class Resolver {
405
488
  }
406
489
 
407
490
  /**
408
- * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#process-updates | 7.2.f Process updates Array}.
409
- * @param {DidDocument} currentDocument The current DID Document to apply the updates to.
410
- * @param {Array<[SignedBTCR2Update, BlockMetadata]>} unsortedUpdates The unsorted array of BTCR2 Signed Updates and their associated Block Metadata.
411
- * @param {string} [versionTime] The optional version time to limit updates to.
412
- * @param {string} [versionId] The optional version id to limit updates to.
413
- * @param {{ currentVersionId: number; updateHashHistory: HashBytes[] }} [resolutionState]
414
- * Version counter and update-hash history carried from earlier discovery rounds.
415
- * Standalone callers omit it and start fresh at version 1 with an empty history.
416
- * @returns {DidResolutionResponse} The updated DID Document, number of confirmations, and version id.
417
- *
418
- * Confirmation depth is not checked here. The BeaconProcess phase excludes a
419
- * signal below `ResolutionOptions.minConf` before its update reaches this method,
420
- * so every tuple here comes from a block at or above the threshold.
421
- */
422
- static updates(
423
- currentDocument: DidDocument,
424
- unsortedUpdates: Array<[SignedBTCR2Update, BlockMetadata]>,
425
- versionTime?: string,
426
- versionId?: string,
427
- resolutionState: { currentVersionId: number; updateHashHistory: HashBytes[] } =
428
- { currentVersionId: 1, updateHashHistory: [] }
429
- ): DidResolutionResponse {
430
- // Continue the version counter and update-hash history from earlier discovery
431
- // rounds so the whole resolution is one monotonic sequence, matching the spec's
432
- // single signal-processing loop. updateHashHistory is shared by reference, so the
433
- // appends made below are visible to the next round.
434
- let currentVersionId = resolutionState.currentVersionId;
435
- const updateHashHistory: HashBytes[] = resolutionState.updateHashHistory;
436
-
437
- // 1. Sort updates by targetVersionId (ascending), using blockheight as tie-breaker
438
- const updates = unsortedUpdates.sort(([upd0, blk0], [upd1, blk1]) =>
439
- upd0.targetVersionId - upd1.targetVersionId || blk0.height - blk1.height
440
- );
441
-
442
- // Create a default response object
443
- const response: DidResolutionResponse = {
444
- didDocument : currentDocument,
445
- metadata : {
446
- versionId : `${currentVersionId}`,
447
- confirmations : 0,
448
- updated : '',
449
- deactivated : currentDocument.deactivated || false
450
- }
451
- };
452
-
453
- // Iterate over each (update block) pair
454
- for(const [update, block] of updates) {
455
- // Get the hash of the current document as raw bytes
456
- const currentDocumentHash = canonicalHashBytes(response.didDocument);
457
-
458
- // Safely convert block.time to timestamp
459
- const blocktime = DateUtils.blocktimeToTimestamp(block.time);
460
-
461
- // Set the updated field to the blocktime of the current update
462
- response.metadata.updated = DateUtils.toISOStringNonFractional(blocktime);
463
-
464
- // Set confirmations to the block confirmations
465
- response.metadata.confirmations = block.confirmations;
466
-
467
- // Check update.targetVersionId against currentVersionId.
468
- // If update.targetVersionId <= currentVersionId, this update re-announces a version
469
- // that has already been applied. Confirm it is a true duplicate, then skip it: a
470
- // duplicate does not advance the version counter (the increment and the
471
- // metadata.versionId it sets run only on the apply path below), and confirmation
472
- // compares against the update-hash history without appending to it, because the
473
- // history already holds the applied update at updateHashHistory[targetVersionId - 2].
474
- // Holding the increment off the duplicate path is the deliberate did:btcr2 deviation
475
- // recorded in ADR 067: the read algorithm's "Increment current_version_id" belongs
476
- // to the apply branch, not to every tuple. Duplicates are confirmed whatever their
477
- // blocktime, before the versionTime check below, so a re-announcement mined after
478
- // versionTime can neither truncate the in-window history nor dodge late-publishing
479
- // detection (ADR 068).
480
- if(update.targetVersionId <= currentVersionId) {
481
- this.confirmDuplicate(update, updateHashHistory);
482
- continue;
483
- }
484
-
485
- // if resolutionOptions.versionTime is defined and the blocktime is more recent, return
486
- // currentDocument. Evaluated only for tuples that would change state (apply or late
487
- // publishing). The spec places this check before the duplicate branch, where the sort
488
- // by targetVersionId lets a duplicate of an early version mined after versionTime end
489
- // resolution before genuine in-window updates are processed; checking it here is the
490
- // deliberate deviation recorded in ADR 068.
491
- if(versionTime) {
492
- // Safely convert versionTime to timestamp
493
- if(blocktime > DateUtils.dateStringToTimestamp(versionTime)) {
494
- return response;
495
- }
496
- }
497
-
498
- // If update.targetVersionId == currentVersionId + 1, apply the update
499
- if (update.targetVersionId === currentVersionId + 1) {
500
- // Check if update.sourceHash !== currentDocumentHash (byte comparison)
501
- const sourceHashBytes = decodeHash(update.sourceHash, 'base64urlnopad');
502
- if (!equalBytes(sourceHashBytes, currentDocumentHash)) {
503
- throw new ResolveError(
504
- `Hash mismatch: update.sourceHash !== currentDocumentHash`,
505
- INVALID_DID_UPDATE, {
506
- sourceHash : update.sourceHash,
507
- currentDocumentHash : encodeHash(currentDocumentHash, 'hex')
508
- }
509
- );
510
- }
511
- // Apply the update to the currentDocument and set it in the response
512
- response.didDocument = this.applyUpdate(response.didDocument, update);
513
-
514
- // Create unsigned_update by removing the proof property from update.
515
- const unsignedUpdate = JSONUtils.deleteKeys(update, ['proof']) as UnsignedBTCR2Update;
516
- // Push the canonicalized unsigned update hash bytes to the updateHashHistory
517
- updateHashHistory.push(canonicalHashBytes(unsignedUpdate));
518
- }
519
-
520
- // Otherwise update.targetVersionId > currentVersionId + 1: a version was skipped,
521
- // so throw LATE_PUBLISHING error. The duplicate case already continued above.
522
- else {
523
- throw new ResolveError(
524
- `Version Id Mismatch: targetVersionId cannot be > currentVersionId + 1`,
525
- LATE_PUBLISHING_ERROR, {
526
- targetVersionId : update.targetVersionId,
527
- currentVersionId : currentVersionId + 1
528
- }
529
- );
530
- }
531
-
532
- // Increment currentVersionId
533
- currentVersionId++;
534
-
535
- // Set response.versionId to be the new currentVersionId
536
- response.metadata.versionId = `${currentVersionId}`;
537
-
538
- // If resolutionOptions.versionId is defined and <= currentVersionId, return currentDocument
539
- const versionIdNumber = Number(versionId);
540
- if(!isNaN(versionIdNumber) && versionIdNumber <= currentVersionId) {
541
- return response;
542
- }
543
-
544
- // Check if the current document is deactivated before further processing
545
- if(response.didDocument.deactivated) {
546
- // Set the response deactivated flag to true
547
- response.metadata.deactivated = response.didDocument.deactivated;
548
- // If deactivated, stop processing further updates and return the response
549
- return response;
550
- }
551
- }
552
-
553
- // Return response data
554
- return response;
555
- }
556
-
557
- /**
558
- * Implements subsection {@link https://dcdpr.github.io/did-btcr2/#confirm-duplicate-update | 7.2.f.1 Confirm Duplicate Update}.
491
+ * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#confirm-duplicate-update | Confirm Duplicate Update}.
559
492
  * This step confirms that an update with a lower-than-expected targetVersionId is a true duplicate.
560
493
  * @param {SignedBTCR2Update} update The BTCR2 Signed Update to confirm as a duplicate.
561
494
  * @param {HashBytes[]} updateHashHistory The accumulated hash history for comparison.
@@ -609,17 +542,31 @@ export class Resolver {
609
542
  }
610
543
 
611
544
  /**
612
- * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#apply-update | 7.2.f.3 Apply Update}
545
+ * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#apply-update | Apply update}
613
546
  * and its step {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#check-update-proof | Check update.proof}.
614
547
  * @param {DidDocument} currentDocument The current DID Document to apply the update to.
615
548
  * @param {SignedBTCR2Update} update The BTCR2 Signed Update to apply.
616
549
  * @returns {DidDocument} The updated DID Document after applying the update.
617
- * @throws {ResolveError} If the update is invalid or cannot be applied.
550
+ * @throws {ResolveError} `INVALID_DID_UPDATE` if the update is invalid or cannot be applied.
618
551
  */
619
552
  private static applyUpdate(
620
553
  currentDocument: DidDocument,
621
554
  update: SignedBTCR2Update
622
555
  ): DidDocument {
556
+ // Spec "Apply update": the hash of the current document must be the decoded
557
+ // update.sourceHash (byte comparison).
558
+ const currentDocumentHash = canonicalHashBytes(currentDocument);
559
+ const sourceHashBytes = decodeHash(update.sourceHash, 'base64urlnopad');
560
+ if (!equalBytes(sourceHashBytes, currentDocumentHash)) {
561
+ throw new ResolveError(
562
+ `Hash mismatch: update.sourceHash !== currentDocumentHash`,
563
+ INVALID_DID_UPDATE, {
564
+ sourceHash : update.sourceHash,
565
+ currentDocumentHash : encodeHash(currentDocumentHash, 'hex')
566
+ }
567
+ );
568
+ }
569
+
623
570
  // Spec "Check update.proof": the update @context must be the array that the BTCR2
624
571
  // Unsigned Update data structure pins, and the proof @context must equal it, member
625
572
  // for member and in order. The array is inside the hashed and signed bytes, so an
@@ -723,18 +670,18 @@ export class Resolver {
723
670
  // Verify that updatedDocument is conformant to DID Core v1.1.
724
671
  DidDocument.validate(updatedDocument);
725
672
 
726
- // Canonicalize and hash the updatedDocument to get the currentDocumentHash (raw bytes).
727
- const currentDocumentHash = canonicalHashBytes(updatedDocument);
673
+ // Canonicalize and hash the updatedDocument (raw bytes).
674
+ const updatedDocumentHash = canonicalHashBytes(updatedDocument);
728
675
 
729
- // Prepare the update targetHash for comparison with currentDocumentHash.
676
+ // Prepare the update targetHash for comparison with updatedDocumentHash.
730
677
  const updateTargetHash = decodeHash(update.targetHash);
731
678
 
732
- // Make sure the update.targetHash equals currentDocumentHash.
733
- if (!equalBytes(updateTargetHash, currentDocumentHash)) {
679
+ // Make sure the update.targetHash equals updatedDocumentHash.
680
+ if (!equalBytes(updateTargetHash, updatedDocumentHash)) {
734
681
  // If they do not match, throw INVALID_DID_UPDATE error.
735
682
  throw new ResolveError(
736
- `Invalid update: update.targetHash !== currentDocumentHash`,
737
- INVALID_DID_UPDATE, { updateTargetHash, currentDocumentHash }
683
+ `Invalid update: update.targetHash !== updatedDocumentHash`,
684
+ INVALID_DID_UPDATE, { updateTargetHash, updatedDocumentHash }
738
685
  );
739
686
  }
740
687
 
@@ -839,80 +786,145 @@ export class Resolver {
839
786
  return { status: 'action-required', needs: allNeeds };
840
787
  }
841
788
 
842
- this.#phase = ResolverPhase.ApplyUpdates;
789
+ this.#phase = ResolverPhase.ProcessUpdate;
843
790
  continue;
844
791
  }
845
792
 
846
- // Phase: ApplyUpdates
847
- // Apply collected updates, then check for new beacon services (multi-round).
848
- case ResolverPhase.ApplyUpdates: {
849
- if(this.#unsortedUpdates.length > 0) {
850
- // Apply this round's updates, continuing the resolution-wide version
851
- // counter and update-hash history rather than restarting them. Without
852
- // this carry, a linear history split across discovery rounds would be
853
- // rejected at round two as late publishing.
854
- this.#resolvedResponse = Resolver.updates(
855
- this.#currentDocument!,
856
- this.#unsortedUpdates,
857
- this.#versionTime,
858
- this.#versionId,
859
- { currentVersionId: this.#currentVersionId, updateHashHistory: this.#updateHashHistory }
860
- );
861
- // updates() reports the version it reached via metadata.versionId; carry
862
- // it forward so the next round continues the monotonic sequence.
863
- this.#currentVersionId = Number(this.#resolvedResponse.metadata.versionId);
864
- this.#currentDocument = this.#resolvedResponse.didDocument;
865
- this.#unsortedUpdates = [];
866
-
867
- // Check for new beacon services added by updates (multi-round discovery)
868
- const beaconServices = BeaconUtils.getBeaconServices(this.#currentDocument);
869
- const hasNewServices = beaconServices.some(service => {
870
- const address = BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string);
871
- return !this.#requestCache.has(address);
872
- });
873
-
874
- if(hasNewServices) {
875
- // Discovery is unbounded by default: termination is guaranteed by
876
- // address de-duplication (#requestCache), so a well-formed DID
877
- // resolves in however many rounds its history requires. An opt-in
878
- // maxDiscoveryRounds lets a caller bound the work as a resource
879
- // guard. Exceeding it is a limit the caller imposed, not a malformed
880
- // document, so it surfaces as INTERNAL_ERROR, not INVALID_DID_DOCUMENT.
881
- if(++this.#discoveryRounds > this.#maxDiscoveryRounds) {
882
- throw new ResolveError(
883
- `Exceeded the configured maximum of ${this.#maxDiscoveryRounds} beacon-discovery `
884
- + 'rounds. Raise or remove ResolutionOptions.maxDiscoveryRounds to resolve this DID.',
885
- INTERNAL_ERROR,
886
- { maxDiscoveryRounds: this.#maxDiscoveryRounds, discoveryRounds: this.#discoveryRounds }
887
- );
888
- }
889
- // Loop back to discover signals for new beacon services
890
- this.#phase = ResolverPhase.BeaconDiscovery;
891
- continue;
793
+ // Phase: ProcessUpdate
794
+ // Spec "Process Next Update": one tuple per step. The phase repeats until
795
+ // the document resolves, or until an applied update adds a beacon address
796
+ // that the resolver did not scan (then the pass returns to BeaconDiscovery).
797
+ case ResolverPhase.ProcessUpdate: {
798
+ const document = this.#currentDocument!;
799
+
800
+ // Step 1: the requested version is reached. The test runs before Apply,
801
+ // so version 1 is reachable.
802
+ if(this.#versionId !== undefined && this.#currentVersionId === this.#versionId) {
803
+ this.#phase = ResolverPhase.Complete;
804
+ continue;
805
+ }
806
+
807
+ // Step 2: no tuple is left, or the document is deactivated. A requested
808
+ // version that the history does not reach is NOT_FOUND.
809
+ if(this.#unsortedUpdates.length === 0 || document.deactivated) {
810
+ if(this.#versionId !== undefined) {
811
+ throw new ResolveError(
812
+ `Version ${this.#versionId} of the DID does not exist: the history `
813
+ + (document.deactivated
814
+ ? `ends with the deactivation at version ${this.#currentVersionId}.`
815
+ : `ends at version ${this.#currentVersionId}.`),
816
+ NOT_FOUND, { versionId: this.#versionId, currentVersionId: this.#currentVersionId }
817
+ );
892
818
  }
819
+ this.#phase = ResolverPhase.Complete;
820
+ continue;
821
+ }
822
+
823
+ // Step 3: sort the tuples by targetVersionId (ascending), then by block
824
+ // height, and remove the first one. The sort runs on every step because
825
+ // a scan between two steps can add a tuple with a lower version.
826
+ this.#unsortedUpdates.sort(([upd0, blk0], [upd1, blk1]) =>
827
+ upd0.targetVersionId - upd1.targetVersionId || blk0.height - blk1.height
828
+ );
829
+ const [update, block] = this.#unsortedUpdates.shift()!;
830
+
831
+ // Check targetVersionId, first arm: update.targetVersionId <= currentVersionId
832
+ // re-announces an applied version. Confirm that it is a true duplicate, then
833
+ // skip it. A duplicate does not advance the version counter, does not append
834
+ // to the history (the slot already holds the applied update, ADR 067), and
835
+ // does not stamp the metadata: confirmations refers to the block of the most
836
+ // recently applied unique update. The branch runs before the versionTime
837
+ // test (ADR 068): a re-announcement mined after versionTime can neither end
838
+ // the resolution early nor dodge late-publishing detection.
839
+ if(update.targetVersionId <= this.#currentVersionId) {
840
+ Resolver.confirmDuplicate(update, this.#updateHashHistory);
841
+ continue;
842
+ }
843
+
844
+ // Step 4: the versionTime stop. The block mediantime of the tuple is after
845
+ // versionTime: resolve the current document. The boundary is inclusive, so a
846
+ // tuple whose mediantime equals versionTime applies. The stopped tuple stamps
847
+ // nothing: the metadata reports the last applied update.
848
+ if(this.#versionTime !== undefined && block.mediantime * 1000 > this.#versionTime) {
849
+ this.#phase = ResolverPhase.Complete;
850
+ continue;
851
+ }
852
+
853
+ // Check targetVersionId, third arm: a version was skipped, so raise LATE_PUBLISHING.
854
+ if(update.targetVersionId !== this.#currentVersionId + 1) {
855
+ throw new ResolveError(
856
+ `Version Id Mismatch: targetVersionId cannot be > currentVersionId + 1`,
857
+ LATE_PUBLISHING_ERROR, {
858
+ targetVersionId : update.targetVersionId,
859
+ currentVersionId : this.#currentVersionId + 1
860
+ }
861
+ );
893
862
  }
894
863
 
895
- this.#phase = ResolverPhase.Complete;
864
+ // Second arm: update.targetVersionId == currentVersionId + 1. Apply the update,
865
+ // append the unsigned update hash to the history, increment the version.
866
+ this.#currentDocument = Resolver.applyUpdate(document, update);
867
+ const unsignedUpdate = JSONUtils.deleteKeys(update, ['proof']) as UnsignedBTCR2Update;
868
+ this.#updateHashHistory.push(canonicalHashBytes(unsignedUpdate));
869
+ this.#currentVersionId++;
870
+
871
+ // Step 5: block_confirmations, and the header time as `updated`. On the apply
872
+ // path only: the stop above and the duplicate branch stamp nothing.
873
+ this.#blockConfirmations = block.confirmations;
874
+ this.#updated = DateUtils.toISOStringNonFractional(DateUtils.blocktimeToTimestamp(block.time));
875
+
876
+ // The applied update can add a beacon service. "Find Beacon Signals" runs at
877
+ // the top of every pass for the addresses that are not scanned yet, so the
878
+ // pass returns to BeaconDiscovery before the next tuple. Discovery is
879
+ // unbounded by default: termination is guaranteed by address
880
+ // de-duplication (#requestCache). An opt-in maxDiscoveryRounds lets a caller
881
+ // bound the work as a resource guard. Exceeding it is a limit the caller
882
+ // imposed, not a malformed document, so it surfaces as INTERNAL_ERROR.
883
+ if(this.#hasUnscannedBeacons()) {
884
+ if(++this.#discoveryRounds > this.#maxDiscoveryRounds) {
885
+ throw new ResolveError(
886
+ `Exceeded the configured maximum of ${this.#maxDiscoveryRounds} beacon-discovery `
887
+ + 'rounds. Raise or remove ResolutionOptions.maxDiscoveryRounds to resolve this DID.',
888
+ INTERNAL_ERROR,
889
+ { maxDiscoveryRounds: this.#maxDiscoveryRounds, discoveryRounds: this.#discoveryRounds }
890
+ );
891
+ }
892
+ this.#phase = ResolverPhase.BeaconDiscovery;
893
+ }
896
894
  continue;
897
895
  }
898
896
 
899
897
  // Phase: Complete
898
+ // The document metadata of the specification: versionId is current_version_id,
899
+ // confirmations is block_confirmations (0 when no update applied), deactivated
900
+ // is the flag of the document. `updated` is present after the first apply.
900
901
  case ResolverPhase.Complete: {
901
- return {
902
- status : 'resolved',
903
- result : this.#resolvedResponse ?? {
904
- didDocument : this.#currentDocument!,
905
- metadata : {
906
- versionId : this.#versionId ?? '1',
907
- deactivated : this.#currentDocument!.deactivated || false
908
- }
902
+ this.#resolvedResponse ??= {
903
+ didDocument : this.#currentDocument!,
904
+ metadata : {
905
+ versionId : `${this.#currentVersionId}`,
906
+ confirmations : this.#blockConfirmations,
907
+ ...(this.#updated !== undefined ? { updated: this.#updated } : {}),
908
+ deactivated : this.#currentDocument!.deactivated || false
909
909
  }
910
910
  };
911
+ return { status: 'resolved', result: this.#resolvedResponse };
911
912
  }
912
913
  }
913
914
  }
914
915
  }
915
916
 
917
+ /**
918
+ * True if the current document carries a beacon service whose address the resolver
919
+ * did not request signals for. "Find Beacon Signals" scans such an address on the
920
+ * next pass.
921
+ */
922
+ #hasUnscannedBeacons(): boolean {
923
+ return BeaconUtils.getBeaconServices(this.#currentDocument!).some(service =>
924
+ !this.#requestCache.has(BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string))
925
+ );
926
+ }
927
+
916
928
  /**
917
929
  * Return the signals of one beacon service that resolution may process: the
918
930
  * signals with at least `#minConf` confirmations. The specification removes a
@@ -921,10 +933,11 @@ export class Resolver {
921
933
  * integer confirmation count is excluded too: that is a mempool transaction
922
934
  * from a driver that did not skip it.
923
935
  *
924
- * An eligible signal must carry a finite block height and block time. A
925
- * signal that passes the count but lacks them is malformed. It fails fast
926
- * here with a typed error, in the style of the {@link provide} guards, and
927
- * not later with an invalid date inside {@link updates}.
936
+ * An eligible signal must carry a finite block height, block time, and block
937
+ * median time past. A signal that passes the count but lacks them is
938
+ * malformed. It fails fast here with a typed error, in the style of the
939
+ * {@link provide} guards, and not later with an invalid date or a false
940
+ * `versionTime` comparison in the ProcessUpdate phase.
928
941
  * @param {Array<BeaconSignal>} signals The signals the caller provided for one service.
929
942
  * @returns {Array<BeaconSignal>} The signals at or above the threshold, in the given order.
930
943
  * @throws {ResolveError} `INVALID_DID_UPDATE` for an eligible signal with no valid block metadata.
@@ -937,12 +950,18 @@ export class Resolver {
937
950
  if(!Number.isInteger(confirmations) || (confirmations as number) < this.#minConf) {
938
951
  continue;
939
952
  }
940
- if(!Number.isFinite(block?.height) || !Number.isFinite(block?.time)) {
953
+ if(!Number.isFinite(block?.height) || !Number.isFinite(block?.time) || !Number.isFinite(block?.mediantime)) {
941
954
  throw new ResolveError(
942
955
  `Beacon signal ${signal.signalBytes} has ${confirmations} confirmations `
943
- + 'but no valid block height or block time.',
956
+ + 'but no valid block height, block time, or block mediantime.',
944
957
  INVALID_DID_UPDATE,
945
- { signalBytes: signal.signalBytes, confirmations, height: block?.height, time: block?.time }
958
+ {
959
+ signalBytes : signal.signalBytes,
960
+ confirmations,
961
+ height : block?.height,
962
+ time : block?.time,
963
+ mediantime : block?.mediantime
964
+ }
946
965
  );
947
966
  }
948
967
  eligible.push(signal);