@did-btcr2/method 0.62.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.
@@ -13,6 +13,7 @@ import {
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';
@@ -181,21 +182,73 @@ function isSMTProof(value: unknown): value is SMTProof {
181
182
  function validateMinConf(value: unknown): number {
182
183
  if(value === undefined) return DEFAULT_MIN_CONF;
183
184
  if(typeof value === 'number' && Number.isInteger(value) && value >= 1) return value;
184
- const shown = typeof value === 'string' ? JSON.stringify(value) : String(value);
185
185
  throw new ResolveError(
186
- `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)}.`,
187
187
  INVALID_OPTIONS, { minConf: value }
188
188
  );
189
189
  }
190
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
+
191
220
  /**
192
- * 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.
193
246
  */
194
247
  enum ResolverPhase {
195
248
  GenesisDocument = 'GenesisDocument',
196
249
  BeaconDiscovery = 'BeaconDiscovery',
197
250
  BeaconProcess = 'BeaconProcess',
198
- ApplyUpdates = 'ApplyUpdates',
251
+ ProcessUpdate = 'ProcessUpdate',
199
252
  Complete = 'Complete',
200
253
  }
201
254
 
@@ -224,8 +277,10 @@ enum ResolverPhase {
224
277
  export class Resolver {
225
278
  // --- Immutable inputs ---
226
279
  readonly #didComponents: DidComponents;
227
- readonly #versionId?: string;
228
- 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;
229
284
 
230
285
  /**
231
286
  * The specific phase the Resolver is current in.
@@ -236,23 +291,29 @@ export class Resolver {
236
291
  #providedGenesisDocument: object | null = null;
237
292
  #beaconServicesSignals: Map<BeaconService, Array<BeaconSignal>> = new Map();
238
293
  #processedServices: Set<string> = new Set();
294
+ /** The beacon addresses the resolver requested signals for: `scanned_beacons` of the specification. */
239
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
+ */
240
301
  #unsortedUpdates: Array<[SignedBTCR2Update, BlockMetadata]> = [];
241
302
  #resolvedResponse: DidResolutionResponse | null = null;
242
303
 
243
304
  /**
244
- * Monotonic DID-document version counter and the update-hash history that backs
245
- * duplicate confirmation, both carried across the entire resolution. The spec's
246
- * read algorithm keeps a single version counter and a single update-hash history
247
- * for the whole signal-processing loop, re-deriving beacons from the contemporary
248
- * document on each pass. This sans-I/O resolver splits that one loop into discovery
249
- * rounds, so the two must persist across rounds rather than restart each pass.
250
- * Restarting them would reject a legitimate linear history whose later updates are
251
- * announced on beacons that earlier updates added: round two would forget it had
252
- * 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.
253
312
  */
254
313
  #currentVersionId = 1;
255
314
  #updateHashHistory: HashBytes[] = [];
315
+ #blockConfirmations = 0;
316
+ #updated?: string;
256
317
 
257
318
  /**
258
319
  * Opt-in upper bound on multi-round beacon-discovery passes. `Infinity` (the
@@ -292,14 +353,23 @@ export class Resolver {
292
353
  this.#didComponents = didComponents;
293
354
  this.#sidecarData = sidecarData;
294
355
  this.#currentDocument = currentDocument;
295
- this.#versionId = options?.versionId;
296
- 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);
297
368
  // Discovery is unbounded by default; a positive maxDiscoveryRounds opts into a
298
369
  // finite resource guard. A non-positive or omitted value means no limit.
299
370
  const rounds = options?.maxDiscoveryRounds;
300
371
  this.#maxDiscoveryRounds = typeof rounds === 'number' && rounds > 0 ? rounds : Infinity;
301
- // The signal confirmation threshold. An invalid value fails here, before any
302
- // data need is emitted, so the caller does no I/O for a request it cannot serve.
372
+ // The signal confirmation threshold.
303
373
  this.#minConf = validateMinConf(options?.minConf);
304
374
 
305
375
  // If a genesis document was provided (from sidecar), pre-seed it for validation
@@ -418,156 +488,7 @@ export class Resolver {
418
488
  }
419
489
 
420
490
  /**
421
- * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#process-updates | 7.2.f Process updates Array}.
422
- * @param {DidDocument} currentDocument The current DID Document to apply the updates to.
423
- * @param {Array<[SignedBTCR2Update, BlockMetadata]>} unsortedUpdates The unsorted array of BTCR2 Signed Updates and their associated Block Metadata.
424
- * @param {string} [versionTime] The optional version time to limit updates to.
425
- * @param {string} [versionId] The optional version id to limit updates to.
426
- * @param {{ currentVersionId: number; updateHashHistory: HashBytes[] }} [resolutionState]
427
- * Version counter and update-hash history carried from earlier discovery rounds.
428
- * Standalone callers omit it and start fresh at version 1 with an empty history.
429
- * @returns {DidResolutionResponse} The updated DID Document, number of confirmations, and version id.
430
- *
431
- * Confirmation depth is not checked here. The BeaconProcess phase excludes a
432
- * signal below `ResolutionOptions.minConf` before its update reaches this method,
433
- * so every tuple here comes from a block at or above the threshold.
434
- */
435
- static updates(
436
- currentDocument: DidDocument,
437
- unsortedUpdates: Array<[SignedBTCR2Update, BlockMetadata]>,
438
- versionTime?: string,
439
- versionId?: string,
440
- resolutionState: { currentVersionId: number; updateHashHistory: HashBytes[] } =
441
- { currentVersionId: 1, updateHashHistory: [] }
442
- ): DidResolutionResponse {
443
- // Continue the version counter and update-hash history from earlier discovery
444
- // rounds so the whole resolution is one monotonic sequence, matching the spec's
445
- // single signal-processing loop. updateHashHistory is shared by reference, so the
446
- // appends made below are visible to the next round.
447
- let currentVersionId = resolutionState.currentVersionId;
448
- const updateHashHistory: HashBytes[] = resolutionState.updateHashHistory;
449
-
450
- // 1. Sort updates by targetVersionId (ascending), using blockheight as tie-breaker
451
- const updates = unsortedUpdates.sort(([upd0, blk0], [upd1, blk1]) =>
452
- upd0.targetVersionId - upd1.targetVersionId || blk0.height - blk1.height
453
- );
454
-
455
- // Create a default response object. `updated` is absent until an update applies.
456
- const response: DidResolutionResponse = {
457
- didDocument : currentDocument,
458
- metadata : {
459
- versionId : `${currentVersionId}`,
460
- confirmations : 0,
461
- deactivated : currentDocument.deactivated || false
462
- }
463
- };
464
-
465
- // Iterate over each (update block) pair
466
- for(const [update, block] of updates) {
467
- // Get the hash of the current document as raw bytes
468
- const currentDocumentHash = canonicalHashBytes(response.didDocument);
469
-
470
- // Safely convert block.time to timestamp
471
- const blocktime = DateUtils.blocktimeToTimestamp(block.time);
472
-
473
- // Set the updated field to the blocktime of the current update
474
- response.metadata.updated = DateUtils.toISOStringNonFractional(blocktime);
475
-
476
- // Set confirmations to the block confirmations
477
- response.metadata.confirmations = block.confirmations;
478
-
479
- // Check update.targetVersionId against currentVersionId.
480
- // If update.targetVersionId <= currentVersionId, this update re-announces a version
481
- // that has already been applied. Confirm it is a true duplicate, then skip it: a
482
- // duplicate does not advance the version counter (the increment and the
483
- // metadata.versionId it sets run only on the apply path below), and confirmation
484
- // compares against the update-hash history without appending to it, because the
485
- // history already holds the applied update at updateHashHistory[targetVersionId - 2].
486
- // Holding the increment off the duplicate path is the deliberate did:btcr2 deviation
487
- // recorded in ADR 067: the read algorithm's "Increment current_version_id" belongs
488
- // to the apply branch, not to every tuple. Duplicates are confirmed whatever their
489
- // blocktime, before the versionTime check below, so a re-announcement mined after
490
- // versionTime can neither truncate the in-window history nor dodge late-publishing
491
- // detection (ADR 068).
492
- if(update.targetVersionId <= currentVersionId) {
493
- this.confirmDuplicate(update, updateHashHistory);
494
- continue;
495
- }
496
-
497
- // if resolutionOptions.versionTime is defined and the blocktime is more recent, return
498
- // currentDocument. Evaluated only for tuples that would change state (apply or late
499
- // publishing). The spec places this check before the duplicate branch, where the sort
500
- // by targetVersionId lets a duplicate of an early version mined after versionTime end
501
- // resolution before genuine in-window updates are processed; checking it here is the
502
- // deliberate deviation recorded in ADR 068.
503
- if(versionTime) {
504
- // Safely convert versionTime to timestamp
505
- if(blocktime > DateUtils.dateStringToTimestamp(versionTime)) {
506
- return response;
507
- }
508
- }
509
-
510
- // If update.targetVersionId == currentVersionId + 1, apply the update
511
- if (update.targetVersionId === currentVersionId + 1) {
512
- // Check if update.sourceHash !== currentDocumentHash (byte comparison)
513
- const sourceHashBytes = decodeHash(update.sourceHash, 'base64urlnopad');
514
- if (!equalBytes(sourceHashBytes, currentDocumentHash)) {
515
- throw new ResolveError(
516
- `Hash mismatch: update.sourceHash !== currentDocumentHash`,
517
- INVALID_DID_UPDATE, {
518
- sourceHash : update.sourceHash,
519
- currentDocumentHash : encodeHash(currentDocumentHash, 'hex')
520
- }
521
- );
522
- }
523
- // Apply the update to the currentDocument and set it in the response
524
- response.didDocument = this.applyUpdate(response.didDocument, update);
525
-
526
- // Create unsigned_update by removing the proof property from update.
527
- const unsignedUpdate = JSONUtils.deleteKeys(update, ['proof']) as UnsignedBTCR2Update;
528
- // Push the canonicalized unsigned update hash bytes to the updateHashHistory
529
- updateHashHistory.push(canonicalHashBytes(unsignedUpdate));
530
- }
531
-
532
- // Otherwise update.targetVersionId > currentVersionId + 1: a version was skipped,
533
- // so throw LATE_PUBLISHING error. The duplicate case already continued above.
534
- else {
535
- throw new ResolveError(
536
- `Version Id Mismatch: targetVersionId cannot be > currentVersionId + 1`,
537
- LATE_PUBLISHING_ERROR, {
538
- targetVersionId : update.targetVersionId,
539
- currentVersionId : currentVersionId + 1
540
- }
541
- );
542
- }
543
-
544
- // Increment currentVersionId
545
- currentVersionId++;
546
-
547
- // Set response.versionId to be the new currentVersionId
548
- response.metadata.versionId = `${currentVersionId}`;
549
-
550
- // If resolutionOptions.versionId is defined and <= currentVersionId, return currentDocument
551
- const versionIdNumber = Number(versionId);
552
- if(!isNaN(versionIdNumber) && versionIdNumber <= currentVersionId) {
553
- return response;
554
- }
555
-
556
- // Check if the current document is deactivated before further processing
557
- if(response.didDocument.deactivated) {
558
- // Set the response deactivated flag to true
559
- response.metadata.deactivated = response.didDocument.deactivated;
560
- // If deactivated, stop processing further updates and return the response
561
- return response;
562
- }
563
- }
564
-
565
- // Return response data
566
- return response;
567
- }
568
-
569
- /**
570
- * 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}.
571
492
  * This step confirms that an update with a lower-than-expected targetVersionId is a true duplicate.
572
493
  * @param {SignedBTCR2Update} update The BTCR2 Signed Update to confirm as a duplicate.
573
494
  * @param {HashBytes[]} updateHashHistory The accumulated hash history for comparison.
@@ -621,17 +542,31 @@ export class Resolver {
621
542
  }
622
543
 
623
544
  /**
624
- * 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}
625
546
  * and its step {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#check-update-proof | Check update.proof}.
626
547
  * @param {DidDocument} currentDocument The current DID Document to apply the update to.
627
548
  * @param {SignedBTCR2Update} update The BTCR2 Signed Update to apply.
628
549
  * @returns {DidDocument} The updated DID Document after applying the update.
629
- * @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.
630
551
  */
631
552
  private static applyUpdate(
632
553
  currentDocument: DidDocument,
633
554
  update: SignedBTCR2Update
634
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
+
635
570
  // Spec "Check update.proof": the update @context must be the array that the BTCR2
636
571
  // Unsigned Update data structure pins, and the proof @context must equal it, member
637
572
  // for member and in order. The array is inside the hashed and signed bytes, so an
@@ -735,18 +670,18 @@ export class Resolver {
735
670
  // Verify that updatedDocument is conformant to DID Core v1.1.
736
671
  DidDocument.validate(updatedDocument);
737
672
 
738
- // Canonicalize and hash the updatedDocument to get the currentDocumentHash (raw bytes).
739
- const currentDocumentHash = canonicalHashBytes(updatedDocument);
673
+ // Canonicalize and hash the updatedDocument (raw bytes).
674
+ const updatedDocumentHash = canonicalHashBytes(updatedDocument);
740
675
 
741
- // Prepare the update targetHash for comparison with currentDocumentHash.
676
+ // Prepare the update targetHash for comparison with updatedDocumentHash.
742
677
  const updateTargetHash = decodeHash(update.targetHash);
743
678
 
744
- // Make sure the update.targetHash equals currentDocumentHash.
745
- if (!equalBytes(updateTargetHash, currentDocumentHash)) {
679
+ // Make sure the update.targetHash equals updatedDocumentHash.
680
+ if (!equalBytes(updateTargetHash, updatedDocumentHash)) {
746
681
  // If they do not match, throw INVALID_DID_UPDATE error.
747
682
  throw new ResolveError(
748
- `Invalid update: update.targetHash !== currentDocumentHash`,
749
- INVALID_DID_UPDATE, { updateTargetHash, currentDocumentHash }
683
+ `Invalid update: update.targetHash !== updatedDocumentHash`,
684
+ INVALID_DID_UPDATE, { updateTargetHash, updatedDocumentHash }
750
685
  );
751
686
  }
752
687
 
@@ -851,82 +786,145 @@ export class Resolver {
851
786
  return { status: 'action-required', needs: allNeeds };
852
787
  }
853
788
 
854
- this.#phase = ResolverPhase.ApplyUpdates;
789
+ this.#phase = ResolverPhase.ProcessUpdate;
855
790
  continue;
856
791
  }
857
792
 
858
- // Phase: ApplyUpdates
859
- // Apply collected updates, then check for new beacon services (multi-round).
860
- case ResolverPhase.ApplyUpdates: {
861
- if(this.#unsortedUpdates.length > 0) {
862
- // Apply this round's updates, continuing the resolution-wide version
863
- // counter and update-hash history rather than restarting them. Without
864
- // this carry, a linear history split across discovery rounds would be
865
- // rejected at round two as late publishing.
866
- this.#resolvedResponse = Resolver.updates(
867
- this.#currentDocument!,
868
- this.#unsortedUpdates,
869
- this.#versionTime,
870
- this.#versionId,
871
- { currentVersionId: this.#currentVersionId, updateHashHistory: this.#updateHashHistory }
872
- );
873
- // updates() reports the version it reached via metadata.versionId; carry
874
- // it forward so the next round continues the monotonic sequence.
875
- this.#currentVersionId = Number(this.#resolvedResponse.metadata.versionId);
876
- this.#currentDocument = this.#resolvedResponse.didDocument;
877
- this.#unsortedUpdates = [];
878
-
879
- // Check for new beacon services added by updates (multi-round discovery)
880
- const beaconServices = BeaconUtils.getBeaconServices(this.#currentDocument);
881
- const hasNewServices = beaconServices.some(service => {
882
- const address = BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string);
883
- return !this.#requestCache.has(address);
884
- });
885
-
886
- if(hasNewServices) {
887
- // Discovery is unbounded by default: termination is guaranteed by
888
- // address de-duplication (#requestCache), so a well-formed DID
889
- // resolves in however many rounds its history requires. An opt-in
890
- // maxDiscoveryRounds lets a caller bound the work as a resource
891
- // guard. Exceeding it is a limit the caller imposed, not a malformed
892
- // document, so it surfaces as INTERNAL_ERROR, not INVALID_DID_DOCUMENT.
893
- if(++this.#discoveryRounds > this.#maxDiscoveryRounds) {
894
- throw new ResolveError(
895
- `Exceeded the configured maximum of ${this.#maxDiscoveryRounds} beacon-discovery `
896
- + 'rounds. Raise or remove ResolutionOptions.maxDiscoveryRounds to resolve this DID.',
897
- INTERNAL_ERROR,
898
- { maxDiscoveryRounds: this.#maxDiscoveryRounds, discoveryRounds: this.#discoveryRounds }
899
- );
900
- }
901
- // Loop back to discover signals for new beacon services
902
- this.#phase = ResolverPhase.BeaconDiscovery;
903
- 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
+ );
904
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
+ );
905
862
  }
906
863
 
907
- 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
+ }
908
894
  continue;
909
895
  }
910
896
 
911
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.
912
901
  case ResolverPhase.Complete: {
913
- return {
914
- status : 'resolved',
915
- // No update applied: confirmations is 0 per the specification.
916
- result : this.#resolvedResponse ?? {
917
- didDocument : this.#currentDocument!,
918
- metadata : {
919
- versionId : this.#versionId ?? '1',
920
- confirmations : 0,
921
- deactivated : this.#currentDocument!.deactivated || false
922
- }
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
923
909
  }
924
910
  };
911
+ return { status: 'resolved', result: this.#resolvedResponse };
925
912
  }
926
913
  }
927
914
  }
928
915
  }
929
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
+
930
928
  /**
931
929
  * Return the signals of one beacon service that resolution may process: the
932
930
  * signals with at least `#minConf` confirmations. The specification removes a
@@ -935,10 +933,11 @@ export class Resolver {
935
933
  * integer confirmation count is excluded too: that is a mempool transaction
936
934
  * from a driver that did not skip it.
937
935
  *
938
- * An eligible signal must carry a finite block height and block time. A
939
- * signal that passes the count but lacks them is malformed. It fails fast
940
- * here with a typed error, in the style of the {@link provide} guards, and
941
- * 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.
942
941
  * @param {Array<BeaconSignal>} signals The signals the caller provided for one service.
943
942
  * @returns {Array<BeaconSignal>} The signals at or above the threshold, in the given order.
944
943
  * @throws {ResolveError} `INVALID_DID_UPDATE` for an eligible signal with no valid block metadata.
@@ -951,12 +950,18 @@ export class Resolver {
951
950
  if(!Number.isInteger(confirmations) || (confirmations as number) < this.#minConf) {
952
951
  continue;
953
952
  }
954
- if(!Number.isFinite(block?.height) || !Number.isFinite(block?.time)) {
953
+ if(!Number.isFinite(block?.height) || !Number.isFinite(block?.time) || !Number.isFinite(block?.mediantime)) {
955
954
  throw new ResolveError(
956
955
  `Beacon signal ${signal.signalBytes} has ${confirmations} confirmations `
957
- + 'but no valid block height or block time.',
956
+ + 'but no valid block height, block time, or block mediantime.',
958
957
  INVALID_DID_UPDATE,
959
- { 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
+ }
960
965
  );
961
966
  }
962
967
  eligible.push(signal);