@did-btcr2/method 0.62.0 → 0.64.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.
Files changed (46) hide show
  1. package/README.md +14 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/browser.js +3 -3
  4. package/dist/browser.mjs +3 -3
  5. package/dist/cjs/index.js +1269 -986
  6. package/dist/esm/core/beacon/signal-discovery.js +34 -1
  7. package/dist/esm/core/beacon/signal-discovery.js.map +1 -1
  8. package/dist/esm/core/btcr2-update.js +11 -0
  9. package/dist/esm/core/btcr2-update.js.map +1 -1
  10. package/dist/esm/core/resolver.js +345 -252
  11. package/dist/esm/core/resolver.js.map +1 -1
  12. package/dist/esm/core/updater.js +33 -3
  13. package/dist/esm/core/updater.js.map +1 -1
  14. package/dist/esm/did-btcr2.js +53 -20
  15. package/dist/esm/did-btcr2.js.map +1 -1
  16. package/dist/esm/utils/appendix.js +39 -2
  17. package/dist/esm/utils/appendix.js.map +1 -1
  18. package/dist/esm/utils/error-cause.js +16 -0
  19. package/dist/esm/utils/error-cause.js.map +1 -0
  20. package/dist/types/core/beacon/interfaces.d.ts +9 -1
  21. package/dist/types/core/beacon/interfaces.d.ts.map +1 -1
  22. package/dist/types/core/beacon/signal-discovery.d.ts +12 -0
  23. package/dist/types/core/beacon/signal-discovery.d.ts.map +1 -1
  24. package/dist/types/core/btcr2-update.d.ts +15 -8
  25. package/dist/types/core/btcr2-update.d.ts.map +1 -1
  26. package/dist/types/core/interfaces.d.ts +16 -5
  27. package/dist/types/core/interfaces.d.ts.map +1 -1
  28. package/dist/types/core/resolver.d.ts +37 -23
  29. package/dist/types/core/resolver.d.ts.map +1 -1
  30. package/dist/types/core/updater.d.ts.map +1 -1
  31. package/dist/types/did-btcr2.d.ts +24 -3
  32. package/dist/types/did-btcr2.d.ts.map +1 -1
  33. package/dist/types/utils/appendix.d.ts +24 -0
  34. package/dist/types/utils/appendix.d.ts.map +1 -1
  35. package/dist/types/utils/error-cause.d.ts +16 -0
  36. package/dist/types/utils/error-cause.d.ts.map +1 -0
  37. package/package.json +3 -3
  38. package/src/core/beacon/interfaces.ts +10 -1
  39. package/src/core/beacon/signal-discovery.ts +44 -1
  40. package/src/core/btcr2-update.ts +20 -8
  41. package/src/core/interfaces.ts +16 -5
  42. package/src/core/resolver.ts +420 -315
  43. package/src/core/updater.ts +41 -3
  44. package/src/did-btcr2.ts +70 -25
  45. package/src/utils/appendix.ts +48 -2
  46. package/src/utils/error-cause.ts +23 -0
@@ -13,10 +13,12 @@ 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';
19
20
  import type {
21
+ Btcr2DataIntegrityProof,
20
22
  SignedBTCR2Update,
21
23
  UnsignedBTCR2Update
22
24
  } from './btcr2-update.js';
@@ -27,9 +29,9 @@ import {
27
29
  SchnorrMultikey
28
30
  } from '@did-btcr2/cryptosuite';
29
31
  import { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
30
- import { DidBtcr2 } from '../did-btcr2.js';
31
32
  import { Appendix } from '../utils/appendix.js';
32
33
  import { DidDocument, ID_PLACEHOLDER_VALUE } from '../utils/did-document.js';
34
+ import { errorCause } from '../utils/error-cause.js';
33
35
  import { BeaconFactory } from './beacon/factory.js';
34
36
  import type { BeaconService, BeaconSignal, BlockMetadata } from './beacon/interfaces.js';
35
37
  import { BeaconUtils } from './beacon/utils.js';
@@ -181,21 +183,76 @@ function isSMTProof(value: unknown): value is SMTProof {
181
183
  function validateMinConf(value: unknown): number {
182
184
  if(value === undefined) return DEFAULT_MIN_CONF;
183
185
  if(typeof value === 'number' && Number.isInteger(value) && value >= 1) return value;
184
- const shown = typeof value === 'string' ? JSON.stringify(value) : String(value);
185
186
  throw new ResolveError(
186
- `Invalid resolution option minConf: expected a positive integer (minimum 1), got ${shown}.`,
187
+ `Invalid resolution option minConf: expected a positive integer (minimum 1), got ${shown(value)}.`,
187
188
  INVALID_OPTIONS, { minConf: value }
188
189
  );
189
190
  }
190
191
 
192
+ /** Render an option value for an error message: a string in quotes, any other value as is. */
193
+ function shown(value: unknown): string {
194
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
195
+ }
196
+
197
+ /** An ASCII string of an integer: an optional minus sign, then digits. */
198
+ const ASCII_INTEGER = /^-?[0-9]+$/;
199
+
200
+ /**
201
+ * Parse `ResolutionOptions.versionId`. The specification says that the value MUST
202
+ * parse as an integer, and DID Resolution v1 types the option as a string. The
203
+ * accepted form is an ASCII string of an integer inside the safe integer range.
204
+ * @returns {number | undefined} The integer, or `undefined` when the option is absent.
205
+ * @throws {ResolveError} `INVALID_OPTIONS` for every other value.
206
+ */
207
+ function validateVersionId(value: unknown): number | undefined {
208
+ if(value === undefined) return undefined;
209
+ if(typeof value === 'string' && ASCII_INTEGER.test(value) && Number.isSafeInteger(Number(value))) {
210
+ return Number(value);
211
+ }
212
+ throw new ResolveError(
213
+ `Invalid resolution option versionId: expected an ASCII string of an integer, got ${shown(value)}.`,
214
+ INVALID_OPTIONS, { versionId: value }
215
+ );
216
+ }
217
+
218
+ /** An XML Datetime in UTC with the `Z` designator and no fraction, for example `2026-07-01T00:00:00Z`. */
219
+ const UTC_XSD_DATETIME = /^-?\d{4,}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
220
+
221
+ /** The timezone part that an XML Schema `dateTimeStamp` requires: `Z` or an offset. */
222
+ const XSD_TIMEZONE = /(Z|[+-]\d{2}:\d{2})$/;
223
+
224
+ /**
225
+ * Parse `ResolutionOptions.versionTime`. DID Resolution v1 requires an XML Datetime
226
+ * normalized to UTC without sub-second precision. The specification raises
227
+ * `INVALID_OPTIONS` for a value that does not parse.
228
+ * @returns {number | undefined} The instant in milliseconds since the Unix epoch, or `undefined` when the option is absent.
229
+ * @throws {ResolveError} `INVALID_OPTIONS` for every other value.
230
+ */
231
+ function validateVersionTime(value: unknown): number | undefined {
232
+ if(value === undefined) return undefined;
233
+ if(typeof value === 'string' && UTC_XSD_DATETIME.test(value) && DateUtils.isValidXsdDateTime(value)) {
234
+ const ms = Date.parse(value);
235
+ if(Number.isFinite(ms)) return ms;
236
+ }
237
+ throw new ResolveError(
238
+ 'Invalid resolution option versionTime: expected an XML Datetime in UTC without a fraction '
239
+ + `(for example "2026-07-01T00:00:00Z"), got ${shown(value)}.`,
240
+ INVALID_OPTIONS, { versionTime: value }
241
+ );
242
+ }
243
+
191
244
  /**
192
- * Different possible Resolver states representing phases in the resolution process.
245
+ * The phases of the resolution process. Each pass of the specification loop is
246
+ * BeaconDiscovery (the scan of the beacon addresses the resolver did not scan yet),
247
+ * BeaconProcess (the tuples of the signals the caller provided), and ProcessUpdate
248
+ * (one tuple). GenesisDocument runs once, for an EXTERNAL identifier whose genesis
249
+ * document is not in the sidecar.
193
250
  */
194
251
  enum ResolverPhase {
195
252
  GenesisDocument = 'GenesisDocument',
196
253
  BeaconDiscovery = 'BeaconDiscovery',
197
254
  BeaconProcess = 'BeaconProcess',
198
- ApplyUpdates = 'ApplyUpdates',
255
+ ProcessUpdate = 'ProcessUpdate',
199
256
  Complete = 'Complete',
200
257
  }
201
258
 
@@ -224,8 +281,10 @@ enum ResolverPhase {
224
281
  export class Resolver {
225
282
  // --- Immutable inputs ---
226
283
  readonly #didComponents: DidComponents;
227
- readonly #versionId?: string;
228
- readonly #versionTime?: string;
284
+ /** The parsed `ResolutionOptions.versionId`, or `undefined` when the option is absent. */
285
+ readonly #versionId?: number;
286
+ /** The parsed `ResolutionOptions.versionTime` in milliseconds since the Unix epoch, or `undefined`. */
287
+ readonly #versionTime?: number;
229
288
 
230
289
  /**
231
290
  * The specific phase the Resolver is current in.
@@ -236,23 +295,29 @@ export class Resolver {
236
295
  #providedGenesisDocument: object | null = null;
237
296
  #beaconServicesSignals: Map<BeaconService, Array<BeaconSignal>> = new Map();
238
297
  #processedServices: Set<string> = new Set();
298
+ /** The beacon addresses the resolver requested signals for: `scanned_beacons` of the specification. */
239
299
  #requestCache: Set<string> = new Set();
300
+ /**
301
+ * The tuples of the specification's `updates` list: a signed update and the metadata of
302
+ * the block that announced it. BeaconProcess appends; ProcessUpdate sorts the list and
303
+ * removes one tuple per step. A tuple that one pass does not reach waits for the next.
304
+ */
240
305
  #unsortedUpdates: Array<[SignedBTCR2Update, BlockMetadata]> = [];
241
306
  #resolvedResponse: DidResolutionResponse | null = null;
242
307
 
243
308
  /**
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.
309
+ * The state of the specification loop, carried across every pass: the version counter
310
+ * (`current_version_id`), the update-hash history that backs duplicate confirmation
311
+ * (`update_hash_history`), the confirmations of the block that contains the most
312
+ * recently applied unique update (`block_confirmations`), and the header time of that
313
+ * block as `updated`. A pass that finds a new beacon address returns to discovery, so
314
+ * the state must not restart: a restart would reject a linear history whose later
315
+ * updates are announced on beacons that earlier updates added.
253
316
  */
254
317
  #currentVersionId = 1;
255
318
  #updateHashHistory: HashBytes[] = [];
319
+ #blockConfirmations = 0;
320
+ #updated?: string;
256
321
 
257
322
  /**
258
323
  * Opt-in upper bound on multi-round beacon-discovery passes. `Infinity` (the
@@ -292,14 +357,23 @@ export class Resolver {
292
357
  this.#didComponents = didComponents;
293
358
  this.#sidecarData = sidecarData;
294
359
  this.#currentDocument = currentDocument;
295
- this.#versionId = options?.versionId;
296
- this.#versionTime = options?.versionTime;
360
+ // The resolution options fail here, before any data need is emitted, so the
361
+ // caller does no I/O for a request it cannot serve. DID Resolution v1 defines
362
+ // versionId and versionTime as mutually exclusive; the specification raises
363
+ // INVALID_OPTIONS for a request with both, and for a value that does not parse.
364
+ if(options?.versionId !== undefined && options?.versionTime !== undefined) {
365
+ throw new ResolveError(
366
+ 'Invalid resolution options: versionId and versionTime are mutually exclusive. Pass one of them.',
367
+ INVALID_OPTIONS, { versionId: options.versionId, versionTime: options.versionTime }
368
+ );
369
+ }
370
+ this.#versionId = validateVersionId(options?.versionId);
371
+ this.#versionTime = validateVersionTime(options?.versionTime);
297
372
  // Discovery is unbounded by default; a positive maxDiscoveryRounds opts into a
298
373
  // finite resource guard. A non-positive or omitted value means no limit.
299
374
  const rounds = options?.maxDiscoveryRounds;
300
375
  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.
376
+ // The signal confirmation threshold.
303
377
  this.#minConf = validateMinConf(options?.minConf);
304
378
 
305
379
  // If a genesis document was provided (from sidecar), pre-seed it for validation
@@ -418,156 +492,7 @@ export class Resolver {
418
492
  }
419
493
 
420
494
  /**
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}.
495
+ * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#confirm-duplicate-update | Confirm Duplicate Update}.
571
496
  * This step confirms that an update with a lower-than-expected targetVersionId is a true duplicate.
572
497
  * @param {SignedBTCR2Update} update The BTCR2 Signed Update to confirm as a duplicate.
573
498
  * @param {HashBytes[]} updateHashHistory The accumulated hash history for comparison.
@@ -621,17 +546,113 @@ export class Resolver {
621
546
  }
622
547
 
623
548
  /**
624
- * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#apply-update | 7.2.f.3 Apply Update}
549
+ * Decode a hash of a BTCR2 Update (`sourceHash` or `targetHash`). The specification encodes
550
+ * both with base64url without padding.
551
+ * @param {unknown} value The encoded hash.
552
+ * @param {'sourceHash' | 'targetHash'} field The name of the field, for the error.
553
+ * @returns {HashBytes} The decoded bytes.
554
+ * @throws {ResolveError} `INVALID_DID_UPDATE` if the value is not a string or does not decode.
555
+ */
556
+ private static decodeUpdateHash(value: unknown, field: 'sourceHash' | 'targetHash'): HashBytes {
557
+ if(typeof value === 'string') {
558
+ try {
559
+ return decodeHash(value, 'base64urlnopad');
560
+ } catch(error) {
561
+ throw new ResolveError(
562
+ `Invalid update: ${field} does not decode as base64url: ${errorCause(error).message}`,
563
+ INVALID_DID_UPDATE, { [field]: value, cause: errorCause(error) }
564
+ );
565
+ }
566
+ }
567
+ throw new ResolveError(`Invalid update: ${field} is not a string`, INVALID_DID_UPDATE, { [field]: value });
568
+ }
569
+
570
+ /**
571
+ * Parse a `created` or `expires` value of an update proof. Data Integrity types both as an
572
+ * XML Schema `dateTimeStamp`: an XML Datetime with a timezone. A value without a timezone
573
+ * names no fixed instant, so two resolvers would read two instants; it is rejected.
574
+ * @param {Btcr2DataIntegrityProof} proof The update proof.
575
+ * @param {'created' | 'expires'} field The field to parse.
576
+ * @returns {number | undefined} The instant in milliseconds since the Unix epoch, or `undefined` when the field is absent.
577
+ * @throws {ResolveError} `INVALID_DID_UPDATE` for a value that is not an XML Datetime with a timezone.
578
+ */
579
+ private static proofInstant(proof: Btcr2DataIntegrityProof, field: 'created' | 'expires'): number | undefined {
580
+ const value = proof[field];
581
+ if(value === undefined) return undefined;
582
+ if(typeof value === 'string' && XSD_TIMEZONE.test(value) && DateUtils.isValidXsdDateTime(value)) {
583
+ const ms = Date.parse(value);
584
+ if(Number.isFinite(ms)) return ms;
585
+ }
586
+ throw new ResolveError(
587
+ `Invalid update: proof.${field} is not an XML Datetime with a timezone`,
588
+ INVALID_DID_UPDATE, { [field]: value }
589
+ );
590
+ }
591
+
592
+ /**
593
+ * Spec "Check `update.proof`": the proof time window against the block that contains the
594
+ * Beacon Signal. `created` must not be after the header time of the block: a controller
595
+ * signs a short time before the block, and on mainnet the header time is about one hour
596
+ * after the `mediantime`. `expires` must not be before the block `mediantime`: it limits a
597
+ * replay, and a single miner cannot change `mediantime`. `expires` must not be before
598
+ * `created`. Each comparison has no tolerance.
599
+ * @param {Btcr2DataIntegrityProof} proof The update proof.
600
+ * @param {BlockMetadata} block The block of the Beacon Signal.
601
+ * @throws {ResolveError} `INVALID_DID_UPDATE` if a value is outside the window.
602
+ */
603
+ private static checkProofWindow(proof: Btcr2DataIntegrityProof, block: BlockMetadata): void {
604
+ const created = Resolver.proofInstant(proof, 'created');
605
+ const expires = Resolver.proofInstant(proof, 'expires');
606
+ if(created !== undefined && created > block.time * 1000) {
607
+ throw new ResolveError(
608
+ 'Invalid update: proof.created is after the header time of the block that contains the Beacon Signal',
609
+ INVALID_DID_UPDATE, { created: proof.created, blockTime: block.time }
610
+ );
611
+ }
612
+ if(expires !== undefined && expires < block.mediantime * 1000) {
613
+ throw new ResolveError(
614
+ 'Invalid update: proof.expires is before the mediantime of the block that contains the Beacon Signal',
615
+ INVALID_DID_UPDATE, { expires: proof.expires, mediantime: block.mediantime }
616
+ );
617
+ }
618
+ if(created !== undefined && expires !== undefined && expires < created) {
619
+ throw new ResolveError(
620
+ 'Invalid update: proof.expires is before proof.created',
621
+ INVALID_DID_UPDATE, { created: proof.created, expires: proof.expires }
622
+ );
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#apply-update | Apply update}
625
628
  * and its step {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#check-update-proof | Check update.proof}.
629
+ * Every failure that the specification names raises `INVALID_DID_UPDATE`. An error of the
630
+ * cryptosuite, the multikey, the hash decoder, or the patch rides along as `data.cause`.
626
631
  * @param {DidDocument} currentDocument The current DID Document to apply the update to.
627
632
  * @param {SignedBTCR2Update} update The BTCR2 Signed Update to apply.
633
+ * @param {BlockMetadata} block The block that contains the Beacon Signal that announced the update.
628
634
  * @returns {DidDocument} The updated DID Document after applying the update.
629
- * @throws {ResolveError} If the update is invalid or cannot be applied.
635
+ * @throws {ResolveError} `INVALID_DID_UPDATE` if the update is invalid or cannot be applied.
630
636
  */
631
637
  private static applyUpdate(
632
638
  currentDocument: DidDocument,
633
- update: SignedBTCR2Update
639
+ update: SignedBTCR2Update,
640
+ block: BlockMetadata
634
641
  ): DidDocument {
642
+ // Spec "Apply update": the hash of the current document must be the decoded
643
+ // update.sourceHash (byte comparison).
644
+ const currentDocumentHash = canonicalHashBytes(currentDocument);
645
+ const sourceHashBytes = Resolver.decodeUpdateHash(update.sourceHash, 'sourceHash');
646
+ if (!equalBytes(sourceHashBytes, currentDocumentHash)) {
647
+ throw new ResolveError(
648
+ `Hash mismatch: update.sourceHash !== currentDocumentHash`,
649
+ INVALID_DID_UPDATE, {
650
+ sourceHash : update.sourceHash,
651
+ currentDocumentHash : encodeHash(currentDocumentHash, 'hex')
652
+ }
653
+ );
654
+ }
655
+
635
656
  // Spec "Check update.proof": the update @context must be the array that the BTCR2
636
657
  // Unsigned Update data structure pins, and the proof @context must equal it, member
637
658
  // for member and in order. The array is inside the hashed and signed bytes, so an
@@ -650,50 +671,37 @@ export class Resolver {
650
671
  );
651
672
  }
652
673
 
653
- // Get the capability id from the to update proof.
654
- const capabilityId = update.proof?.capability;
655
- // Since this field is optional, check that it exists
656
- if (!capabilityId) {
657
- // If it does not exist, throw INVALID_DID_UPDATE error
658
- throw new ResolveError('No root capability found in update', INVALID_DID_UPDATE, update);
659
- }
660
-
661
- // Get the root capability object by dereferencing the capabilityId
662
- const rootCapability = Appendix.dereferenceZcapId(capabilityId);
663
-
664
- // Deconstruct the invocationTarget and controller from the root capability
665
- const { invocationTarget, controller: rootController } = rootCapability;
666
- // Check that both invocationTarget and rootController equal currentDocument.id
667
- if (![invocationTarget, rootController].every((id) => id === currentDocument.id)) {
668
- // If they do not all match, throw INVALID_DID_UPDATE error
669
- throw new ResolveError(
670
- 'Invalid root capability',
671
- INVALID_DID_UPDATE, { rootCapability, currentDocument }
672
- );
673
- }
674
-
675
- // Get the verificationMethod field from the update proof as verificationMethodId.
676
- const verificationMethodId = update.proof?.verificationMethod;
677
- // Since this field is optional, check that it exists
678
- if(!verificationMethodId) {
679
- // If it does not exist, throw INVALID_DID_UPDATE error
680
- throw new ResolveError('No verificationMethod found in update', INVALID_DID_UPDATE, update);
674
+ // Spec "Check update.proof": each proof field by string equality, before the method
675
+ // lookup and before signature verification, so that a failure names the field. The
676
+ // capability is the URN that the Data Integrity Config specifies for this DID; the root
677
+ // capability is not derived, the specification makes that optional.
678
+ const proof = update.proof;
679
+ const expectedFields: Array<[ string, string ]> = [
680
+ [ 'type', 'DataIntegrityProof' ],
681
+ [ 'cryptosuite', 'bip340-jcs-2025' ],
682
+ [ 'proofPurpose', 'capabilityInvocation' ],
683
+ [ 'capabilityAction', 'Write' ],
684
+ [ 'capability', `urn:zcap:root:${encodeURIComponent(currentDocument.id)}` ],
685
+ ];
686
+ for(const [ field, expected ] of expectedFields) {
687
+ const actual = (proof as Record<string, unknown>)[field];
688
+ if(actual !== expected) {
689
+ throw new ResolveError(
690
+ `Invalid update: proof.${field} must equal "${expected}"`,
691
+ INVALID_DID_UPDATE, { field, expected, actual }
692
+ );
693
+ }
681
694
  }
682
695
 
683
- // Spec "Check update.proof": raise INVALID_DID_UPDATE if
684
- // currentDocument.capabilityInvocation does not contain
685
- // update.proof.verificationMethod. Locating the method in verificationMethod[] and
686
- // verifying its signature is not sufficient on its own: a key the controller
687
- // published only for authentication (or for no relationship at all) must not be
688
- // able to authorize a DID update. The write path enforces this in DidBtcr2.update();
689
- // without it here the read path applies an update signed by any key in the document.
690
- // Checked before the method is located so an unauthorized method always fails with
691
- // this typed error, whether or not it also appears in verificationMethod[].
692
- const authorizedMethodId = Appendix.relationshipMethodId(verificationMethodId, currentDocument.id);
693
- const authorized = authorizedMethodId !== undefined && currentDocument.capabilityInvocation?.some(
694
- entry => Appendix.relationshipMethodId(entry, currentDocument.id) === authorizedMethodId
695
- );
696
- if(!authorized) {
696
+ // Spec "Check update.proof": the entry of currentDocument.capabilityInvocation that
697
+ // identifies update.proof.verificationMethod, in the reference form or the embedded
698
+ // form. A key the controller published for authentication only, or for no relationship
699
+ // at all, must not authorize an update; the membership test runs before the method
700
+ // lookup so that such a key always fails with this typed error. The method is the
701
+ // entry itself when embedded, else the member of verificationMethod[] with that id.
702
+ const verificationMethodId = proof.verificationMethod;
703
+ const entry = Appendix.capabilityInvocationEntry(currentDocument, verificationMethodId);
704
+ if(entry === undefined) {
697
705
  throw new ResolveError(
698
706
  'Invalid update: verificationMethod is not authorized for capabilityInvocation',
699
707
  INVALID_DID_UPDATE, {
@@ -702,51 +710,78 @@ export class Resolver {
702
710
  }
703
711
  );
704
712
  }
713
+ const vm = Appendix.verificationMethodOfEntry(currentDocument, entry);
714
+ if(vm === undefined) {
715
+ throw new ResolveError(
716
+ 'Invalid update: verificationMethod is not found in the verificationMethod of the current document',
717
+ INVALID_DID_UPDATE, { verificationMethodId }
718
+ );
719
+ }
705
720
 
706
- // Get the verificationMethod from the DID Document using the verificationMethodId.
707
- const vm = DidBtcr2.getSigningMethod(currentDocument, verificationMethodId);
708
-
709
- // Construct a new SchnorrMultikey.
710
- const multikey = SchnorrMultikey.fromVerificationMethod(vm);
711
-
712
- // Construct a new BIP340Cryptosuite with the SchnorrMultikey.
713
- const cryptosuite = new BIP340Cryptosuite(multikey);
714
-
715
- // Canonicalize the update
716
- const canonicalUpdate = canonicalize(update);
717
-
718
- // Construct a DataIntegrityProof with the cryptosuite
719
- const diProof = new BIP340DataIntegrityProof(cryptosuite);
720
-
721
- // Call the verifyProof method
722
- const verificationResult = diProof.verifyProof(canonicalUpdate, 'capabilityInvocation');
723
-
724
- // If the result is not verified, throw INVALID_DID_UPDATE error
725
- if (!verificationResult.verified) {
721
+ // Spec "Check update.proof": the proof time window against the block of the signal.
722
+ Resolver.checkProofWindow(proof, block);
723
+
724
+ // Verify the proof with the public key that the verification method publishes. The
725
+ // multikey names the method by its absolute DID URL, as the proof does. An error of the
726
+ // multikey or the cryptosuite (a key that does not decode, a proof value that does not
727
+ // decode, a created value the suite rejects) is an invalid update.
728
+ let verified: boolean;
729
+ try {
730
+ const multikey = SchnorrMultikey.fromVerificationMethod({
731
+ ...vm, id : Appendix.absoluteDidUrl(vm.id, currentDocument.id) ?? vm.id
732
+ });
733
+ const diProof = new BIP340DataIntegrityProof(new BIP340Cryptosuite(multikey));
734
+ verified = diProof.verifyProof(canonicalize(update), 'capabilityInvocation').verified;
735
+ } catch(error) {
726
736
  throw new ResolveError(
727
- 'Invalid update: proof not verified',
728
- INVALID_DID_UPDATE, verificationResult
737
+ `Invalid update: proof verification failed: ${errorCause(error).message}`,
738
+ INVALID_DID_UPDATE, { verificationMethodId, cause: errorCause(error) }
729
739
  );
730
740
  }
741
+ if(!verified) {
742
+ throw new ResolveError('Invalid update: proof not verified', INVALID_DID_UPDATE, { verificationMethodId });
743
+ }
731
744
 
732
- // Apply the update.patch to the currentDocument to get the updatedDocument.
733
- const updatedDocument = JSONPatch.apply(currentDocument, update.patch) as DidDocument;
745
+ // Spec "Apply update": apply update.patch strictly. The first operation that fails,
746
+ // including a failed test, fails the whole patch.
747
+ let updatedDocument: DidDocument;
748
+ try {
749
+ updatedDocument = JSONPatch.apply(currentDocument, update.patch, { strict: true }) as DidDocument;
750
+ } catch(error) {
751
+ throw new ResolveError(
752
+ `Invalid update: ${errorCause(error).message}`,
753
+ INVALID_DID_UPDATE, { cause: errorCause(error) }
754
+ );
755
+ }
734
756
 
735
- // Verify that updatedDocument is conformant to DID Core v1.1.
736
- DidDocument.validate(updatedDocument);
757
+ // Spec "Apply update": the patched document keeps the DID as its id and conforms to
758
+ // DID Core v1.1.
759
+ if(updatedDocument?.id !== currentDocument.id) {
760
+ throw new ResolveError(
761
+ `Invalid update: the patch changes the document id (from "${currentDocument.id}" to "${String(updatedDocument?.id)}")`,
762
+ INVALID_DID_UPDATE, { sourceId: currentDocument.id, targetId: updatedDocument?.id }
763
+ );
764
+ }
765
+ try {
766
+ DidDocument.validate(updatedDocument);
767
+ } catch(error) {
768
+ throw new ResolveError(
769
+ `Invalid update: the patched document does not conform to DID Core: ${errorCause(error).message}`,
770
+ INVALID_DID_UPDATE, { cause: errorCause(error) }
771
+ );
772
+ }
737
773
 
738
- // Canonicalize and hash the updatedDocument to get the currentDocumentHash (raw bytes).
739
- const currentDocumentHash = canonicalHashBytes(updatedDocument);
774
+ // Canonicalize and hash the updatedDocument (raw bytes).
775
+ const updatedDocumentHash = canonicalHashBytes(updatedDocument);
740
776
 
741
- // Prepare the update targetHash for comparison with currentDocumentHash.
742
- const updateTargetHash = decodeHash(update.targetHash);
777
+ // Prepare the update targetHash for comparison with updatedDocumentHash.
778
+ const updateTargetHash = Resolver.decodeUpdateHash(update.targetHash, 'targetHash');
743
779
 
744
- // Make sure the update.targetHash equals currentDocumentHash.
745
- if (!equalBytes(updateTargetHash, currentDocumentHash)) {
746
- // If they do not match, throw INVALID_DID_UPDATE error.
780
+ // Make sure the update.targetHash equals updatedDocumentHash.
781
+ if (!equalBytes(updateTargetHash, updatedDocumentHash)) {
747
782
  throw new ResolveError(
748
- `Invalid update: update.targetHash !== currentDocumentHash`,
749
- INVALID_DID_UPDATE, { updateTargetHash, currentDocumentHash }
783
+ `Invalid update: update.targetHash !== updatedDocumentHash`,
784
+ INVALID_DID_UPDATE, { updateTargetHash, updatedDocumentHash }
750
785
  );
751
786
  }
752
787
 
@@ -851,82 +886,145 @@ export class Resolver {
851
886
  return { status: 'action-required', needs: allNeeds };
852
887
  }
853
888
 
854
- this.#phase = ResolverPhase.ApplyUpdates;
889
+ this.#phase = ResolverPhase.ProcessUpdate;
855
890
  continue;
856
891
  }
857
892
 
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;
893
+ // Phase: ProcessUpdate
894
+ // Spec "Process Next Update": one tuple per step. The phase repeats until
895
+ // the document resolves, or until an applied update adds a beacon address
896
+ // that the resolver did not scan (then the pass returns to BeaconDiscovery).
897
+ case ResolverPhase.ProcessUpdate: {
898
+ const document = this.#currentDocument!;
899
+
900
+ // Step 1: the requested version is reached. The test runs before Apply,
901
+ // so version 1 is reachable.
902
+ if(this.#versionId !== undefined && this.#currentVersionId === this.#versionId) {
903
+ this.#phase = ResolverPhase.Complete;
904
+ continue;
905
+ }
906
+
907
+ // Step 2: no tuple is left, or the document is deactivated. A requested
908
+ // version that the history does not reach is NOT_FOUND.
909
+ if(this.#unsortedUpdates.length === 0 || document.deactivated) {
910
+ if(this.#versionId !== undefined) {
911
+ throw new ResolveError(
912
+ `Version ${this.#versionId} of the DID does not exist: the history `
913
+ + (document.deactivated
914
+ ? `ends with the deactivation at version ${this.#currentVersionId}.`
915
+ : `ends at version ${this.#currentVersionId}.`),
916
+ NOT_FOUND, { versionId: this.#versionId, currentVersionId: this.#currentVersionId }
917
+ );
904
918
  }
919
+ this.#phase = ResolverPhase.Complete;
920
+ continue;
921
+ }
922
+
923
+ // Step 3: sort the tuples by targetVersionId (ascending), then by block
924
+ // height, and remove the first one. The sort runs on every step because
925
+ // a scan between two steps can add a tuple with a lower version.
926
+ this.#unsortedUpdates.sort(([upd0, blk0], [upd1, blk1]) =>
927
+ upd0.targetVersionId - upd1.targetVersionId || blk0.height - blk1.height
928
+ );
929
+ const [update, block] = this.#unsortedUpdates.shift()!;
930
+
931
+ // Check targetVersionId, first arm: update.targetVersionId <= currentVersionId
932
+ // re-announces an applied version. Confirm that it is a true duplicate, then
933
+ // skip it. A duplicate does not advance the version counter, does not append
934
+ // to the history (the slot already holds the applied update, ADR 067), and
935
+ // does not stamp the metadata: confirmations refers to the block of the most
936
+ // recently applied unique update. The branch runs before the versionTime
937
+ // test (ADR 068): a re-announcement mined after versionTime can neither end
938
+ // the resolution early nor dodge late-publishing detection.
939
+ if(update.targetVersionId <= this.#currentVersionId) {
940
+ Resolver.confirmDuplicate(update, this.#updateHashHistory);
941
+ continue;
942
+ }
943
+
944
+ // Step 4: the versionTime stop. The block mediantime of the tuple is after
945
+ // versionTime: resolve the current document. The boundary is inclusive, so a
946
+ // tuple whose mediantime equals versionTime applies. The stopped tuple stamps
947
+ // nothing: the metadata reports the last applied update.
948
+ if(this.#versionTime !== undefined && block.mediantime * 1000 > this.#versionTime) {
949
+ this.#phase = ResolverPhase.Complete;
950
+ continue;
951
+ }
952
+
953
+ // Check targetVersionId, third arm: a version was skipped, so raise LATE_PUBLISHING.
954
+ if(update.targetVersionId !== this.#currentVersionId + 1) {
955
+ throw new ResolveError(
956
+ `Version Id Mismatch: targetVersionId cannot be > currentVersionId + 1`,
957
+ LATE_PUBLISHING_ERROR, {
958
+ targetVersionId : update.targetVersionId,
959
+ currentVersionId : this.#currentVersionId + 1
960
+ }
961
+ );
905
962
  }
906
963
 
907
- this.#phase = ResolverPhase.Complete;
964
+ // Second arm: update.targetVersionId == currentVersionId + 1. Apply the update,
965
+ // append the unsigned update hash to the history, increment the version.
966
+ this.#currentDocument = Resolver.applyUpdate(document, update, block);
967
+ const unsignedUpdate = JSONUtils.deleteKeys(update, ['proof']) as UnsignedBTCR2Update;
968
+ this.#updateHashHistory.push(canonicalHashBytes(unsignedUpdate));
969
+ this.#currentVersionId++;
970
+
971
+ // Step 5: block_confirmations, and the header time as `updated`. On the apply
972
+ // path only: the stop above and the duplicate branch stamp nothing.
973
+ this.#blockConfirmations = block.confirmations;
974
+ this.#updated = DateUtils.toISOStringNonFractional(DateUtils.blocktimeToTimestamp(block.time));
975
+
976
+ // The applied update can add a beacon service. "Find Beacon Signals" runs at
977
+ // the top of every pass for the addresses that are not scanned yet, so the
978
+ // pass returns to BeaconDiscovery before the next tuple. Discovery is
979
+ // unbounded by default: termination is guaranteed by address
980
+ // de-duplication (#requestCache). An opt-in maxDiscoveryRounds lets a caller
981
+ // bound the work as a resource guard. Exceeding it is a limit the caller
982
+ // imposed, not a malformed document, so it surfaces as INTERNAL_ERROR.
983
+ if(this.#hasUnscannedBeacons()) {
984
+ if(++this.#discoveryRounds > this.#maxDiscoveryRounds) {
985
+ throw new ResolveError(
986
+ `Exceeded the configured maximum of ${this.#maxDiscoveryRounds} beacon-discovery `
987
+ + 'rounds. Raise or remove ResolutionOptions.maxDiscoveryRounds to resolve this DID.',
988
+ INTERNAL_ERROR,
989
+ { maxDiscoveryRounds: this.#maxDiscoveryRounds, discoveryRounds: this.#discoveryRounds }
990
+ );
991
+ }
992
+ this.#phase = ResolverPhase.BeaconDiscovery;
993
+ }
908
994
  continue;
909
995
  }
910
996
 
911
997
  // Phase: Complete
998
+ // The document metadata of the specification: versionId is current_version_id,
999
+ // confirmations is block_confirmations (0 when no update applied), deactivated
1000
+ // is the flag of the document. `updated` is present after the first apply.
912
1001
  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
- }
1002
+ this.#resolvedResponse ??= {
1003
+ didDocument : this.#currentDocument!,
1004
+ metadata : {
1005
+ versionId : `${this.#currentVersionId}`,
1006
+ confirmations : this.#blockConfirmations,
1007
+ ...(this.#updated !== undefined ? { updated: this.#updated } : {}),
1008
+ deactivated : this.#currentDocument!.deactivated || false
923
1009
  }
924
1010
  };
1011
+ return { status: 'resolved', result: this.#resolvedResponse };
925
1012
  }
926
1013
  }
927
1014
  }
928
1015
  }
929
1016
 
1017
+ /**
1018
+ * True if the current document carries a beacon service whose address the resolver
1019
+ * did not request signals for. "Find Beacon Signals" scans such an address on the
1020
+ * next pass.
1021
+ */
1022
+ #hasUnscannedBeacons(): boolean {
1023
+ return BeaconUtils.getBeaconServices(this.#currentDocument!).some(service =>
1024
+ !this.#requestCache.has(BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string))
1025
+ );
1026
+ }
1027
+
930
1028
  /**
931
1029
  * Return the signals of one beacon service that resolution may process: the
932
1030
  * signals with at least `#minConf` confirmations. The specification removes a
@@ -935,10 +1033,11 @@ export class Resolver {
935
1033
  * integer confirmation count is excluded too: that is a mempool transaction
936
1034
  * from a driver that did not skip it.
937
1035
  *
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}.
1036
+ * An eligible signal must carry a finite block height, block time, and block
1037
+ * median time past. A signal that passes the count but lacks them is
1038
+ * malformed. It fails fast here with a typed error, in the style of the
1039
+ * {@link provide} guards, and not later with an invalid date or a false
1040
+ * `versionTime` comparison in the ProcessUpdate phase.
942
1041
  * @param {Array<BeaconSignal>} signals The signals the caller provided for one service.
943
1042
  * @returns {Array<BeaconSignal>} The signals at or above the threshold, in the given order.
944
1043
  * @throws {ResolveError} `INVALID_DID_UPDATE` for an eligible signal with no valid block metadata.
@@ -951,12 +1050,18 @@ export class Resolver {
951
1050
  if(!Number.isInteger(confirmations) || (confirmations as number) < this.#minConf) {
952
1051
  continue;
953
1052
  }
954
- if(!Number.isFinite(block?.height) || !Number.isFinite(block?.time)) {
1053
+ if(!Number.isFinite(block?.height) || !Number.isFinite(block?.time) || !Number.isFinite(block?.mediantime)) {
955
1054
  throw new ResolveError(
956
1055
  `Beacon signal ${signal.signalBytes} has ${confirmations} confirmations `
957
- + 'but no valid block height or block time.',
1056
+ + 'but no valid block height, block time, or block mediantime.',
958
1057
  INVALID_DID_UPDATE,
959
- { signalBytes: signal.signalBytes, confirmations, height: block?.height, time: block?.time }
1058
+ {
1059
+ signalBytes : signal.signalBytes,
1060
+ confirmations,
1061
+ height : block?.height,
1062
+ time : block?.time,
1063
+ mediantime : block?.mediantime
1064
+ }
960
1065
  );
961
1066
  }
962
1067
  eligible.push(signal);