@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.
- package/README.md +14 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/browser.js +3 -3
- package/dist/browser.mjs +3 -3
- package/dist/cjs/index.js +1269 -986
- package/dist/esm/core/beacon/signal-discovery.js +34 -1
- package/dist/esm/core/beacon/signal-discovery.js.map +1 -1
- package/dist/esm/core/btcr2-update.js +11 -0
- package/dist/esm/core/btcr2-update.js.map +1 -1
- package/dist/esm/core/resolver.js +345 -252
- package/dist/esm/core/resolver.js.map +1 -1
- package/dist/esm/core/updater.js +33 -3
- package/dist/esm/core/updater.js.map +1 -1
- package/dist/esm/did-btcr2.js +53 -20
- package/dist/esm/did-btcr2.js.map +1 -1
- package/dist/esm/utils/appendix.js +39 -2
- package/dist/esm/utils/appendix.js.map +1 -1
- package/dist/esm/utils/error-cause.js +16 -0
- package/dist/esm/utils/error-cause.js.map +1 -0
- package/dist/types/core/beacon/interfaces.d.ts +9 -1
- package/dist/types/core/beacon/interfaces.d.ts.map +1 -1
- package/dist/types/core/beacon/signal-discovery.d.ts +12 -0
- package/dist/types/core/beacon/signal-discovery.d.ts.map +1 -1
- package/dist/types/core/btcr2-update.d.ts +15 -8
- package/dist/types/core/btcr2-update.d.ts.map +1 -1
- package/dist/types/core/interfaces.d.ts +16 -5
- package/dist/types/core/interfaces.d.ts.map +1 -1
- package/dist/types/core/resolver.d.ts +37 -23
- package/dist/types/core/resolver.d.ts.map +1 -1
- package/dist/types/core/updater.d.ts.map +1 -1
- package/dist/types/did-btcr2.d.ts +24 -3
- package/dist/types/did-btcr2.d.ts.map +1 -1
- package/dist/types/utils/appendix.d.ts +24 -0
- package/dist/types/utils/appendix.d.ts.map +1 -1
- package/dist/types/utils/error-cause.d.ts +16 -0
- package/dist/types/utils/error-cause.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/core/beacon/interfaces.ts +10 -1
- package/src/core/beacon/signal-discovery.ts +44 -1
- package/src/core/btcr2-update.ts +20 -8
- package/src/core/interfaces.ts +16 -5
- package/src/core/resolver.ts +420 -315
- package/src/core/updater.ts +41 -3
- package/src/did-btcr2.ts +70 -25
- package/src/utils/appendix.ts +48 -2
- package/src/utils/error-cause.ts +23 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { getNetwork } from '@did-btcr2/bitcoin';
|
|
2
|
-
import { canonicalHash, canonicalHashBytes, canonicalize, DateUtils, encode as encodeHash, decode as decodeHash, INTERNAL_ERROR, INVALID_DID, INVALID_DID_UPDATE, INVALID_OPTIONS, JSONPatch, JSONUtils, LATE_PUBLISHING_ERROR, ResolveError } from '@did-btcr2/common';
|
|
2
|
+
import { canonicalHash, canonicalHashBytes, canonicalize, DateUtils, encode as encodeHash, decode as decodeHash, INTERNAL_ERROR, INVALID_DID, INVALID_DID_UPDATE, INVALID_OPTIONS, JSONPatch, JSONUtils, LATE_PUBLISHING_ERROR, NOT_FOUND, ResolveError } from '@did-btcr2/common';
|
|
3
3
|
import { BTCR2_UPDATE_CONTEXT, isBtcr2UpdateContext } from './btcr2-update.js';
|
|
4
4
|
import { BIP340Cryptosuite, BIP340DataIntegrityProof, SchnorrMultikey } from '@did-btcr2/cryptosuite';
|
|
5
5
|
import { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
|
|
6
|
-
import { DidBtcr2 } from '../did-btcr2.js';
|
|
7
6
|
import { Appendix } from '../utils/appendix.js';
|
|
8
7
|
import { DidDocument, ID_PLACEHOLDER_VALUE } from '../utils/did-document.js';
|
|
8
|
+
import { errorCause } from '../utils/error-cause.js';
|
|
9
9
|
import { BeaconFactory } from './beacon/factory.js';
|
|
10
10
|
import { BeaconUtils } from './beacon/utils.js';
|
|
11
11
|
import { Identifier } from './identifier.js';
|
|
@@ -63,18 +63,64 @@ function validateMinConf(value) {
|
|
|
63
63
|
return DEFAULT_MIN_CONF;
|
|
64
64
|
if (typeof value === 'number' && Number.isInteger(value) && value >= 1)
|
|
65
65
|
return value;
|
|
66
|
-
|
|
67
|
-
throw new ResolveError(`Invalid resolution option minConf: expected a positive integer (minimum 1), got ${shown}.`, INVALID_OPTIONS, { minConf: value });
|
|
66
|
+
throw new ResolveError(`Invalid resolution option minConf: expected a positive integer (minimum 1), got ${shown(value)}.`, INVALID_OPTIONS, { minConf: value });
|
|
68
67
|
}
|
|
68
|
+
/** Render an option value for an error message: a string in quotes, any other value as is. */
|
|
69
|
+
function shown(value) {
|
|
70
|
+
return typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
71
|
+
}
|
|
72
|
+
/** An ASCII string of an integer: an optional minus sign, then digits. */
|
|
73
|
+
const ASCII_INTEGER = /^-?[0-9]+$/;
|
|
69
74
|
/**
|
|
70
|
-
*
|
|
75
|
+
* Parse `ResolutionOptions.versionId`. The specification says that the value MUST
|
|
76
|
+
* parse as an integer, and DID Resolution v1 types the option as a string. The
|
|
77
|
+
* accepted form is an ASCII string of an integer inside the safe integer range.
|
|
78
|
+
* @returns {number | undefined} The integer, or `undefined` when the option is absent.
|
|
79
|
+
* @throws {ResolveError} `INVALID_OPTIONS` for every other value.
|
|
80
|
+
*/
|
|
81
|
+
function validateVersionId(value) {
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
return undefined;
|
|
84
|
+
if (typeof value === 'string' && ASCII_INTEGER.test(value) && Number.isSafeInteger(Number(value))) {
|
|
85
|
+
return Number(value);
|
|
86
|
+
}
|
|
87
|
+
throw new ResolveError(`Invalid resolution option versionId: expected an ASCII string of an integer, got ${shown(value)}.`, INVALID_OPTIONS, { versionId: value });
|
|
88
|
+
}
|
|
89
|
+
/** An XML Datetime in UTC with the `Z` designator and no fraction, for example `2026-07-01T00:00:00Z`. */
|
|
90
|
+
const UTC_XSD_DATETIME = /^-?\d{4,}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
91
|
+
/** The timezone part that an XML Schema `dateTimeStamp` requires: `Z` or an offset. */
|
|
92
|
+
const XSD_TIMEZONE = /(Z|[+-]\d{2}:\d{2})$/;
|
|
93
|
+
/**
|
|
94
|
+
* Parse `ResolutionOptions.versionTime`. DID Resolution v1 requires an XML Datetime
|
|
95
|
+
* normalized to UTC without sub-second precision. The specification raises
|
|
96
|
+
* `INVALID_OPTIONS` for a value that does not parse.
|
|
97
|
+
* @returns {number | undefined} The instant in milliseconds since the Unix epoch, or `undefined` when the option is absent.
|
|
98
|
+
* @throws {ResolveError} `INVALID_OPTIONS` for every other value.
|
|
99
|
+
*/
|
|
100
|
+
function validateVersionTime(value) {
|
|
101
|
+
if (value === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
if (typeof value === 'string' && UTC_XSD_DATETIME.test(value) && DateUtils.isValidXsdDateTime(value)) {
|
|
104
|
+
const ms = Date.parse(value);
|
|
105
|
+
if (Number.isFinite(ms))
|
|
106
|
+
return ms;
|
|
107
|
+
}
|
|
108
|
+
throw new ResolveError('Invalid resolution option versionTime: expected an XML Datetime in UTC without a fraction '
|
|
109
|
+
+ `(for example "2026-07-01T00:00:00Z"), got ${shown(value)}.`, INVALID_OPTIONS, { versionTime: value });
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The phases of the resolution process. Each pass of the specification loop is
|
|
113
|
+
* BeaconDiscovery (the scan of the beacon addresses the resolver did not scan yet),
|
|
114
|
+
* BeaconProcess (the tuples of the signals the caller provided), and ProcessUpdate
|
|
115
|
+
* (one tuple). GenesisDocument runs once, for an EXTERNAL identifier whose genesis
|
|
116
|
+
* document is not in the sidecar.
|
|
71
117
|
*/
|
|
72
118
|
var ResolverPhase;
|
|
73
119
|
(function (ResolverPhase) {
|
|
74
120
|
ResolverPhase["GenesisDocument"] = "GenesisDocument";
|
|
75
121
|
ResolverPhase["BeaconDiscovery"] = "BeaconDiscovery";
|
|
76
122
|
ResolverPhase["BeaconProcess"] = "BeaconProcess";
|
|
77
|
-
ResolverPhase["
|
|
123
|
+
ResolverPhase["ProcessUpdate"] = "ProcessUpdate";
|
|
78
124
|
ResolverPhase["Complete"] = "Complete";
|
|
79
125
|
})(ResolverPhase || (ResolverPhase = {}));
|
|
80
126
|
/**
|
|
@@ -102,7 +148,9 @@ var ResolverPhase;
|
|
|
102
148
|
export class Resolver {
|
|
103
149
|
// --- Immutable inputs ---
|
|
104
150
|
#didComponents;
|
|
151
|
+
/** The parsed `ResolutionOptions.versionId`, or `undefined` when the option is absent. */
|
|
105
152
|
#versionId;
|
|
153
|
+
/** The parsed `ResolutionOptions.versionTime` in milliseconds since the Unix epoch, or `undefined`. */
|
|
106
154
|
#versionTime;
|
|
107
155
|
/**
|
|
108
156
|
* The specific phase the Resolver is current in.
|
|
@@ -113,22 +161,28 @@ export class Resolver {
|
|
|
113
161
|
#providedGenesisDocument = null;
|
|
114
162
|
#beaconServicesSignals = new Map();
|
|
115
163
|
#processedServices = new Set();
|
|
164
|
+
/** The beacon addresses the resolver requested signals for: `scanned_beacons` of the specification. */
|
|
116
165
|
#requestCache = new Set();
|
|
166
|
+
/**
|
|
167
|
+
* The tuples of the specification's `updates` list: a signed update and the metadata of
|
|
168
|
+
* the block that announced it. BeaconProcess appends; ProcessUpdate sorts the list and
|
|
169
|
+
* removes one tuple per step. A tuple that one pass does not reach waits for the next.
|
|
170
|
+
*/
|
|
117
171
|
#unsortedUpdates = [];
|
|
118
172
|
#resolvedResponse = null;
|
|
119
173
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* announced on beacons that earlier updates added: round two would forget it had
|
|
128
|
-
* already reached version two, see version three, and raise a late-publishing error.
|
|
174
|
+
* The state of the specification loop, carried across every pass: the version counter
|
|
175
|
+
* (`current_version_id`), the update-hash history that backs duplicate confirmation
|
|
176
|
+
* (`update_hash_history`), the confirmations of the block that contains the most
|
|
177
|
+
* recently applied unique update (`block_confirmations`), and the header time of that
|
|
178
|
+
* block as `updated`. A pass that finds a new beacon address returns to discovery, so
|
|
179
|
+
* the state must not restart: a restart would reject a linear history whose later
|
|
180
|
+
* updates are announced on beacons that earlier updates added.
|
|
129
181
|
*/
|
|
130
182
|
#currentVersionId = 1;
|
|
131
183
|
#updateHashHistory = [];
|
|
184
|
+
#blockConfirmations = 0;
|
|
185
|
+
#updated;
|
|
132
186
|
/**
|
|
133
187
|
* Opt-in upper bound on multi-round beacon-discovery passes. `Infinity` (the
|
|
134
188
|
* default) leaves discovery unbounded; termination is already guaranteed by
|
|
@@ -153,14 +207,20 @@ export class Resolver {
|
|
|
153
207
|
this.#didComponents = didComponents;
|
|
154
208
|
this.#sidecarData = sidecarData;
|
|
155
209
|
this.#currentDocument = currentDocument;
|
|
156
|
-
|
|
157
|
-
|
|
210
|
+
// The resolution options fail here, before any data need is emitted, so the
|
|
211
|
+
// caller does no I/O for a request it cannot serve. DID Resolution v1 defines
|
|
212
|
+
// versionId and versionTime as mutually exclusive; the specification raises
|
|
213
|
+
// INVALID_OPTIONS for a request with both, and for a value that does not parse.
|
|
214
|
+
if (options?.versionId !== undefined && options?.versionTime !== undefined) {
|
|
215
|
+
throw new ResolveError('Invalid resolution options: versionId and versionTime are mutually exclusive. Pass one of them.', INVALID_OPTIONS, { versionId: options.versionId, versionTime: options.versionTime });
|
|
216
|
+
}
|
|
217
|
+
this.#versionId = validateVersionId(options?.versionId);
|
|
218
|
+
this.#versionTime = validateVersionTime(options?.versionTime);
|
|
158
219
|
// Discovery is unbounded by default; a positive maxDiscoveryRounds opts into a
|
|
159
220
|
// finite resource guard. A non-positive or omitted value means no limit.
|
|
160
221
|
const rounds = options?.maxDiscoveryRounds;
|
|
161
222
|
this.#maxDiscoveryRounds = typeof rounds === 'number' && rounds > 0 ? rounds : Infinity;
|
|
162
|
-
// The signal confirmation threshold.
|
|
163
|
-
// data need is emitted, so the caller does no I/O for a request it cannot serve.
|
|
223
|
+
// The signal confirmation threshold.
|
|
164
224
|
this.#minConf = validateMinConf(options?.minConf);
|
|
165
225
|
// If a genesis document was provided (from sidecar), pre-seed it for validation
|
|
166
226
|
if (options?.genesisDocument) {
|
|
@@ -254,124 +314,7 @@ export class Resolver {
|
|
|
254
314
|
return { updateMap, casMap, smtMap };
|
|
255
315
|
}
|
|
256
316
|
/**
|
|
257
|
-
* Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#
|
|
258
|
-
* @param {DidDocument} currentDocument The current DID Document to apply the updates to.
|
|
259
|
-
* @param {Array<[SignedBTCR2Update, BlockMetadata]>} unsortedUpdates The unsorted array of BTCR2 Signed Updates and their associated Block Metadata.
|
|
260
|
-
* @param {string} [versionTime] The optional version time to limit updates to.
|
|
261
|
-
* @param {string} [versionId] The optional version id to limit updates to.
|
|
262
|
-
* @param {{ currentVersionId: number; updateHashHistory: HashBytes[] }} [resolutionState]
|
|
263
|
-
* Version counter and update-hash history carried from earlier discovery rounds.
|
|
264
|
-
* Standalone callers omit it and start fresh at version 1 with an empty history.
|
|
265
|
-
* @returns {DidResolutionResponse} The updated DID Document, number of confirmations, and version id.
|
|
266
|
-
*
|
|
267
|
-
* Confirmation depth is not checked here. The BeaconProcess phase excludes a
|
|
268
|
-
* signal below `ResolutionOptions.minConf` before its update reaches this method,
|
|
269
|
-
* so every tuple here comes from a block at or above the threshold.
|
|
270
|
-
*/
|
|
271
|
-
static updates(currentDocument, unsortedUpdates, versionTime, versionId, resolutionState = { currentVersionId: 1, updateHashHistory: [] }) {
|
|
272
|
-
// Continue the version counter and update-hash history from earlier discovery
|
|
273
|
-
// rounds so the whole resolution is one monotonic sequence, matching the spec's
|
|
274
|
-
// single signal-processing loop. updateHashHistory is shared by reference, so the
|
|
275
|
-
// appends made below are visible to the next round.
|
|
276
|
-
let currentVersionId = resolutionState.currentVersionId;
|
|
277
|
-
const updateHashHistory = resolutionState.updateHashHistory;
|
|
278
|
-
// 1. Sort updates by targetVersionId (ascending), using blockheight as tie-breaker
|
|
279
|
-
const updates = unsortedUpdates.sort(([upd0, blk0], [upd1, blk1]) => upd0.targetVersionId - upd1.targetVersionId || blk0.height - blk1.height);
|
|
280
|
-
// Create a default response object. `updated` is absent until an update applies.
|
|
281
|
-
const response = {
|
|
282
|
-
didDocument: currentDocument,
|
|
283
|
-
metadata: {
|
|
284
|
-
versionId: `${currentVersionId}`,
|
|
285
|
-
confirmations: 0,
|
|
286
|
-
deactivated: currentDocument.deactivated || false
|
|
287
|
-
}
|
|
288
|
-
};
|
|
289
|
-
// Iterate over each (update block) pair
|
|
290
|
-
for (const [update, block] of updates) {
|
|
291
|
-
// Get the hash of the current document as raw bytes
|
|
292
|
-
const currentDocumentHash = canonicalHashBytes(response.didDocument);
|
|
293
|
-
// Safely convert block.time to timestamp
|
|
294
|
-
const blocktime = DateUtils.blocktimeToTimestamp(block.time);
|
|
295
|
-
// Set the updated field to the blocktime of the current update
|
|
296
|
-
response.metadata.updated = DateUtils.toISOStringNonFractional(blocktime);
|
|
297
|
-
// Set confirmations to the block confirmations
|
|
298
|
-
response.metadata.confirmations = block.confirmations;
|
|
299
|
-
// Check update.targetVersionId against currentVersionId.
|
|
300
|
-
// If update.targetVersionId <= currentVersionId, this update re-announces a version
|
|
301
|
-
// that has already been applied. Confirm it is a true duplicate, then skip it: a
|
|
302
|
-
// duplicate does not advance the version counter (the increment and the
|
|
303
|
-
// metadata.versionId it sets run only on the apply path below), and confirmation
|
|
304
|
-
// compares against the update-hash history without appending to it, because the
|
|
305
|
-
// history already holds the applied update at updateHashHistory[targetVersionId - 2].
|
|
306
|
-
// Holding the increment off the duplicate path is the deliberate did:btcr2 deviation
|
|
307
|
-
// recorded in ADR 067: the read algorithm's "Increment current_version_id" belongs
|
|
308
|
-
// to the apply branch, not to every tuple. Duplicates are confirmed whatever their
|
|
309
|
-
// blocktime, before the versionTime check below, so a re-announcement mined after
|
|
310
|
-
// versionTime can neither truncate the in-window history nor dodge late-publishing
|
|
311
|
-
// detection (ADR 068).
|
|
312
|
-
if (update.targetVersionId <= currentVersionId) {
|
|
313
|
-
this.confirmDuplicate(update, updateHashHistory);
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
// if resolutionOptions.versionTime is defined and the blocktime is more recent, return
|
|
317
|
-
// currentDocument. Evaluated only for tuples that would change state (apply or late
|
|
318
|
-
// publishing). The spec places this check before the duplicate branch, where the sort
|
|
319
|
-
// by targetVersionId lets a duplicate of an early version mined after versionTime end
|
|
320
|
-
// resolution before genuine in-window updates are processed; checking it here is the
|
|
321
|
-
// deliberate deviation recorded in ADR 068.
|
|
322
|
-
if (versionTime) {
|
|
323
|
-
// Safely convert versionTime to timestamp
|
|
324
|
-
if (blocktime > DateUtils.dateStringToTimestamp(versionTime)) {
|
|
325
|
-
return response;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
// If update.targetVersionId == currentVersionId + 1, apply the update
|
|
329
|
-
if (update.targetVersionId === currentVersionId + 1) {
|
|
330
|
-
// Check if update.sourceHash !== currentDocumentHash (byte comparison)
|
|
331
|
-
const sourceHashBytes = decodeHash(update.sourceHash, 'base64urlnopad');
|
|
332
|
-
if (!equalBytes(sourceHashBytes, currentDocumentHash)) {
|
|
333
|
-
throw new ResolveError(`Hash mismatch: update.sourceHash !== currentDocumentHash`, INVALID_DID_UPDATE, {
|
|
334
|
-
sourceHash: update.sourceHash,
|
|
335
|
-
currentDocumentHash: encodeHash(currentDocumentHash, 'hex')
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
// Apply the update to the currentDocument and set it in the response
|
|
339
|
-
response.didDocument = this.applyUpdate(response.didDocument, update);
|
|
340
|
-
// Create unsigned_update by removing the proof property from update.
|
|
341
|
-
const unsignedUpdate = JSONUtils.deleteKeys(update, ['proof']);
|
|
342
|
-
// Push the canonicalized unsigned update hash bytes to the updateHashHistory
|
|
343
|
-
updateHashHistory.push(canonicalHashBytes(unsignedUpdate));
|
|
344
|
-
}
|
|
345
|
-
// Otherwise update.targetVersionId > currentVersionId + 1: a version was skipped,
|
|
346
|
-
// so throw LATE_PUBLISHING error. The duplicate case already continued above.
|
|
347
|
-
else {
|
|
348
|
-
throw new ResolveError(`Version Id Mismatch: targetVersionId cannot be > currentVersionId + 1`, LATE_PUBLISHING_ERROR, {
|
|
349
|
-
targetVersionId: update.targetVersionId,
|
|
350
|
-
currentVersionId: currentVersionId + 1
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
// Increment currentVersionId
|
|
354
|
-
currentVersionId++;
|
|
355
|
-
// Set response.versionId to be the new currentVersionId
|
|
356
|
-
response.metadata.versionId = `${currentVersionId}`;
|
|
357
|
-
// If resolutionOptions.versionId is defined and <= currentVersionId, return currentDocument
|
|
358
|
-
const versionIdNumber = Number(versionId);
|
|
359
|
-
if (!isNaN(versionIdNumber) && versionIdNumber <= currentVersionId) {
|
|
360
|
-
return response;
|
|
361
|
-
}
|
|
362
|
-
// Check if the current document is deactivated before further processing
|
|
363
|
-
if (response.didDocument.deactivated) {
|
|
364
|
-
// Set the response deactivated flag to true
|
|
365
|
-
response.metadata.deactivated = response.didDocument.deactivated;
|
|
366
|
-
// If deactivated, stop processing further updates and return the response
|
|
367
|
-
return response;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
// Return response data
|
|
371
|
-
return response;
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* Implements subsection {@link https://dcdpr.github.io/did-btcr2/#confirm-duplicate-update | 7.2.f.1 Confirm Duplicate Update}.
|
|
317
|
+
* Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#confirm-duplicate-update | Confirm Duplicate Update}.
|
|
375
318
|
* This step confirms that an update with a lower-than-expected targetVersionId is a true duplicate.
|
|
376
319
|
* @param {SignedBTCR2Update} update The BTCR2 Signed Update to confirm as a duplicate.
|
|
377
320
|
* @param {HashBytes[]} updateHashHistory The accumulated hash history for comparison.
|
|
@@ -410,14 +353,90 @@ export class Resolver {
|
|
|
410
353
|
}
|
|
411
354
|
}
|
|
412
355
|
/**
|
|
413
|
-
*
|
|
356
|
+
* Decode a hash of a BTCR2 Update (`sourceHash` or `targetHash`). The specification encodes
|
|
357
|
+
* both with base64url without padding.
|
|
358
|
+
* @param {unknown} value The encoded hash.
|
|
359
|
+
* @param {'sourceHash' | 'targetHash'} field The name of the field, for the error.
|
|
360
|
+
* @returns {HashBytes} The decoded bytes.
|
|
361
|
+
* @throws {ResolveError} `INVALID_DID_UPDATE` if the value is not a string or does not decode.
|
|
362
|
+
*/
|
|
363
|
+
static decodeUpdateHash(value, field) {
|
|
364
|
+
if (typeof value === 'string') {
|
|
365
|
+
try {
|
|
366
|
+
return decodeHash(value, 'base64urlnopad');
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
throw new ResolveError(`Invalid update: ${field} does not decode as base64url: ${errorCause(error).message}`, INVALID_DID_UPDATE, { [field]: value, cause: errorCause(error) });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
throw new ResolveError(`Invalid update: ${field} is not a string`, INVALID_DID_UPDATE, { [field]: value });
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Parse a `created` or `expires` value of an update proof. Data Integrity types both as an
|
|
376
|
+
* XML Schema `dateTimeStamp`: an XML Datetime with a timezone. A value without a timezone
|
|
377
|
+
* names no fixed instant, so two resolvers would read two instants; it is rejected.
|
|
378
|
+
* @param {Btcr2DataIntegrityProof} proof The update proof.
|
|
379
|
+
* @param {'created' | 'expires'} field The field to parse.
|
|
380
|
+
* @returns {number | undefined} The instant in milliseconds since the Unix epoch, or `undefined` when the field is absent.
|
|
381
|
+
* @throws {ResolveError} `INVALID_DID_UPDATE` for a value that is not an XML Datetime with a timezone.
|
|
382
|
+
*/
|
|
383
|
+
static proofInstant(proof, field) {
|
|
384
|
+
const value = proof[field];
|
|
385
|
+
if (value === undefined)
|
|
386
|
+
return undefined;
|
|
387
|
+
if (typeof value === 'string' && XSD_TIMEZONE.test(value) && DateUtils.isValidXsdDateTime(value)) {
|
|
388
|
+
const ms = Date.parse(value);
|
|
389
|
+
if (Number.isFinite(ms))
|
|
390
|
+
return ms;
|
|
391
|
+
}
|
|
392
|
+
throw new ResolveError(`Invalid update: proof.${field} is not an XML Datetime with a timezone`, INVALID_DID_UPDATE, { [field]: value });
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Spec "Check `update.proof`": the proof time window against the block that contains the
|
|
396
|
+
* Beacon Signal. `created` must not be after the header time of the block: a controller
|
|
397
|
+
* signs a short time before the block, and on mainnet the header time is about one hour
|
|
398
|
+
* after the `mediantime`. `expires` must not be before the block `mediantime`: it limits a
|
|
399
|
+
* replay, and a single miner cannot change `mediantime`. `expires` must not be before
|
|
400
|
+
* `created`. Each comparison has no tolerance.
|
|
401
|
+
* @param {Btcr2DataIntegrityProof} proof The update proof.
|
|
402
|
+
* @param {BlockMetadata} block The block of the Beacon Signal.
|
|
403
|
+
* @throws {ResolveError} `INVALID_DID_UPDATE` if a value is outside the window.
|
|
404
|
+
*/
|
|
405
|
+
static checkProofWindow(proof, block) {
|
|
406
|
+
const created = Resolver.proofInstant(proof, 'created');
|
|
407
|
+
const expires = Resolver.proofInstant(proof, 'expires');
|
|
408
|
+
if (created !== undefined && created > block.time * 1000) {
|
|
409
|
+
throw new ResolveError('Invalid update: proof.created is after the header time of the block that contains the Beacon Signal', INVALID_DID_UPDATE, { created: proof.created, blockTime: block.time });
|
|
410
|
+
}
|
|
411
|
+
if (expires !== undefined && expires < block.mediantime * 1000) {
|
|
412
|
+
throw new ResolveError('Invalid update: proof.expires is before the mediantime of the block that contains the Beacon Signal', INVALID_DID_UPDATE, { expires: proof.expires, mediantime: block.mediantime });
|
|
413
|
+
}
|
|
414
|
+
if (created !== undefined && expires !== undefined && expires < created) {
|
|
415
|
+
throw new ResolveError('Invalid update: proof.expires is before proof.created', INVALID_DID_UPDATE, { created: proof.created, expires: proof.expires });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#apply-update | Apply update}
|
|
414
420
|
* and its step {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#check-update-proof | Check update.proof}.
|
|
421
|
+
* Every failure that the specification names raises `INVALID_DID_UPDATE`. An error of the
|
|
422
|
+
* cryptosuite, the multikey, the hash decoder, or the patch rides along as `data.cause`.
|
|
415
423
|
* @param {DidDocument} currentDocument The current DID Document to apply the update to.
|
|
416
424
|
* @param {SignedBTCR2Update} update The BTCR2 Signed Update to apply.
|
|
425
|
+
* @param {BlockMetadata} block The block that contains the Beacon Signal that announced the update.
|
|
417
426
|
* @returns {DidDocument} The updated DID Document after applying the update.
|
|
418
|
-
* @throws {ResolveError}
|
|
427
|
+
* @throws {ResolveError} `INVALID_DID_UPDATE` if the update is invalid or cannot be applied.
|
|
419
428
|
*/
|
|
420
|
-
static applyUpdate(currentDocument, update) {
|
|
429
|
+
static applyUpdate(currentDocument, update, block) {
|
|
430
|
+
// Spec "Apply update": the hash of the current document must be the decoded
|
|
431
|
+
// update.sourceHash (byte comparison).
|
|
432
|
+
const currentDocumentHash = canonicalHashBytes(currentDocument);
|
|
433
|
+
const sourceHashBytes = Resolver.decodeUpdateHash(update.sourceHash, 'sourceHash');
|
|
434
|
+
if (!equalBytes(sourceHashBytes, currentDocumentHash)) {
|
|
435
|
+
throw new ResolveError(`Hash mismatch: update.sourceHash !== currentDocumentHash`, INVALID_DID_UPDATE, {
|
|
436
|
+
sourceHash: update.sourceHash,
|
|
437
|
+
currentDocumentHash: encodeHash(currentDocumentHash, 'hex')
|
|
438
|
+
});
|
|
439
|
+
}
|
|
421
440
|
// Spec "Check update.proof": the update @context must be the array that the BTCR2
|
|
422
441
|
// Unsigned Update data structure pins, and the proof @context must equal it, member
|
|
423
442
|
// for member and in order. The array is inside the hashed and signed bytes, so an
|
|
@@ -429,74 +448,89 @@ export class Resolver {
|
|
|
429
448
|
if (!isBtcr2UpdateContext(update.proof?.['@context'], update['@context'])) {
|
|
430
449
|
throw new ResolveError('Invalid update: proof @context does not equal the update @context', INVALID_DID_UPDATE, { proofContext: update.proof?.['@context'], context: update['@context'] });
|
|
431
450
|
}
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const verificationMethodId = update.proof?.verificationMethod;
|
|
450
|
-
// Since this field is optional, check that it exists
|
|
451
|
-
if (!verificationMethodId) {
|
|
452
|
-
// If it does not exist, throw INVALID_DID_UPDATE error
|
|
453
|
-
throw new ResolveError('No verificationMethod found in update', INVALID_DID_UPDATE, update);
|
|
451
|
+
// Spec "Check update.proof": each proof field by string equality, before the method
|
|
452
|
+
// lookup and before signature verification, so that a failure names the field. The
|
|
453
|
+
// capability is the URN that the Data Integrity Config specifies for this DID; the root
|
|
454
|
+
// capability is not derived, the specification makes that optional.
|
|
455
|
+
const proof = update.proof;
|
|
456
|
+
const expectedFields = [
|
|
457
|
+
['type', 'DataIntegrityProof'],
|
|
458
|
+
['cryptosuite', 'bip340-jcs-2025'],
|
|
459
|
+
['proofPurpose', 'capabilityInvocation'],
|
|
460
|
+
['capabilityAction', 'Write'],
|
|
461
|
+
['capability', `urn:zcap:root:${encodeURIComponent(currentDocument.id)}`],
|
|
462
|
+
];
|
|
463
|
+
for (const [field, expected] of expectedFields) {
|
|
464
|
+
const actual = proof[field];
|
|
465
|
+
if (actual !== expected) {
|
|
466
|
+
throw new ResolveError(`Invalid update: proof.${field} must equal "${expected}"`, INVALID_DID_UPDATE, { field, expected, actual });
|
|
467
|
+
}
|
|
454
468
|
}
|
|
455
|
-
// Spec "Check update.proof":
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const authorizedMethodId = Appendix.relationshipMethodId(verificationMethodId, currentDocument.id);
|
|
465
|
-
const authorized = authorizedMethodId !== undefined && currentDocument.capabilityInvocation?.some(entry => Appendix.relationshipMethodId(entry, currentDocument.id) === authorizedMethodId);
|
|
466
|
-
if (!authorized) {
|
|
469
|
+
// Spec "Check update.proof": the entry of currentDocument.capabilityInvocation that
|
|
470
|
+
// identifies update.proof.verificationMethod, in the reference form or the embedded
|
|
471
|
+
// form. A key the controller published for authentication only, or for no relationship
|
|
472
|
+
// at all, must not authorize an update; the membership test runs before the method
|
|
473
|
+
// lookup so that such a key always fails with this typed error. The method is the
|
|
474
|
+
// entry itself when embedded, else the member of verificationMethod[] with that id.
|
|
475
|
+
const verificationMethodId = proof.verificationMethod;
|
|
476
|
+
const entry = Appendix.capabilityInvocationEntry(currentDocument, verificationMethodId);
|
|
477
|
+
if (entry === undefined) {
|
|
467
478
|
throw new ResolveError('Invalid update: verificationMethod is not authorized for capabilityInvocation', INVALID_DID_UPDATE, {
|
|
468
479
|
verificationMethodId,
|
|
469
480
|
capabilityInvocation: currentDocument.capabilityInvocation
|
|
470
481
|
});
|
|
471
482
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
483
|
+
const vm = Appendix.verificationMethodOfEntry(currentDocument, entry);
|
|
484
|
+
if (vm === undefined) {
|
|
485
|
+
throw new ResolveError('Invalid update: verificationMethod is not found in the verificationMethod of the current document', INVALID_DID_UPDATE, { verificationMethodId });
|
|
486
|
+
}
|
|
487
|
+
// Spec "Check update.proof": the proof time window against the block of the signal.
|
|
488
|
+
Resolver.checkProofWindow(proof, block);
|
|
489
|
+
// Verify the proof with the public key that the verification method publishes. The
|
|
490
|
+
// multikey names the method by its absolute DID URL, as the proof does. An error of the
|
|
491
|
+
// multikey or the cryptosuite (a key that does not decode, a proof value that does not
|
|
492
|
+
// decode, a created value the suite rejects) is an invalid update.
|
|
493
|
+
let verified;
|
|
494
|
+
try {
|
|
495
|
+
const multikey = SchnorrMultikey.fromVerificationMethod({
|
|
496
|
+
...vm, id: Appendix.absoluteDidUrl(vm.id, currentDocument.id) ?? vm.id
|
|
497
|
+
});
|
|
498
|
+
const diProof = new BIP340DataIntegrityProof(new BIP340Cryptosuite(multikey));
|
|
499
|
+
verified = diProof.verifyProof(canonicalize(update), 'capabilityInvocation').verified;
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
throw new ResolveError(`Invalid update: proof verification failed: ${errorCause(error).message}`, INVALID_DID_UPDATE, { verificationMethodId, cause: errorCause(error) });
|
|
503
|
+
}
|
|
504
|
+
if (!verified) {
|
|
505
|
+
throw new ResolveError('Invalid update: proof not verified', INVALID_DID_UPDATE, { verificationMethodId });
|
|
506
|
+
}
|
|
507
|
+
// Spec "Apply update": apply update.patch strictly. The first operation that fails,
|
|
508
|
+
// including a failed test, fails the whole patch.
|
|
509
|
+
let updatedDocument;
|
|
510
|
+
try {
|
|
511
|
+
updatedDocument = JSONPatch.apply(currentDocument, update.patch, { strict: true });
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
throw new ResolveError(`Invalid update: ${errorCause(error).message}`, INVALID_DID_UPDATE, { cause: errorCause(error) });
|
|
515
|
+
}
|
|
516
|
+
// Spec "Apply update": the patched document keeps the DID as its id and conforms to
|
|
517
|
+
// DID Core v1.1.
|
|
518
|
+
if (updatedDocument?.id !== currentDocument.id) {
|
|
519
|
+
throw new ResolveError(`Invalid update: the patch changes the document id (from "${currentDocument.id}" to "${String(updatedDocument?.id)}")`, INVALID_DID_UPDATE, { sourceId: currentDocument.id, targetId: updatedDocument?.id });
|
|
487
520
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
//
|
|
495
|
-
const
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
521
|
+
try {
|
|
522
|
+
DidDocument.validate(updatedDocument);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
throw new ResolveError(`Invalid update: the patched document does not conform to DID Core: ${errorCause(error).message}`, INVALID_DID_UPDATE, { cause: errorCause(error) });
|
|
526
|
+
}
|
|
527
|
+
// Canonicalize and hash the updatedDocument (raw bytes).
|
|
528
|
+
const updatedDocumentHash = canonicalHashBytes(updatedDocument);
|
|
529
|
+
// Prepare the update targetHash for comparison with updatedDocumentHash.
|
|
530
|
+
const updateTargetHash = Resolver.decodeUpdateHash(update.targetHash, 'targetHash');
|
|
531
|
+
// Make sure the update.targetHash equals updatedDocumentHash.
|
|
532
|
+
if (!equalBytes(updateTargetHash, updatedDocumentHash)) {
|
|
533
|
+
throw new ResolveError(`Invalid update: update.targetHash !== updatedDocumentHash`, INVALID_DID_UPDATE, { updateTargetHash, updatedDocumentHash });
|
|
500
534
|
}
|
|
501
535
|
// Return final updatedDocument.
|
|
502
536
|
return updatedDocument;
|
|
@@ -585,66 +619,118 @@ export class Resolver {
|
|
|
585
619
|
if (allNeeds.length > 0) {
|
|
586
620
|
return { status: 'action-required', needs: allNeeds };
|
|
587
621
|
}
|
|
588
|
-
this.#phase = ResolverPhase.
|
|
622
|
+
this.#phase = ResolverPhase.ProcessUpdate;
|
|
589
623
|
continue;
|
|
590
624
|
}
|
|
591
|
-
// Phase:
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
625
|
+
// Phase: ProcessUpdate
|
|
626
|
+
// Spec "Process Next Update": one tuple per step. The phase repeats until
|
|
627
|
+
// the document resolves, or until an applied update adds a beacon address
|
|
628
|
+
// that the resolver did not scan (then the pass returns to BeaconDiscovery).
|
|
629
|
+
case ResolverPhase.ProcessUpdate: {
|
|
630
|
+
const document = this.#currentDocument;
|
|
631
|
+
// Step 1: the requested version is reached. The test runs before Apply,
|
|
632
|
+
// so version 1 is reachable.
|
|
633
|
+
if (this.#versionId !== undefined && this.#currentVersionId === this.#versionId) {
|
|
634
|
+
this.#phase = ResolverPhase.Complete;
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
// Step 2: no tuple is left, or the document is deactivated. A requested
|
|
638
|
+
// version that the history does not reach is NOT_FOUND.
|
|
639
|
+
if (this.#unsortedUpdates.length === 0 || document.deactivated) {
|
|
640
|
+
if (this.#versionId !== undefined) {
|
|
641
|
+
throw new ResolveError(`Version ${this.#versionId} of the DID does not exist: the history `
|
|
642
|
+
+ (document.deactivated
|
|
643
|
+
? `ends with the deactivation at version ${this.#currentVersionId}.`
|
|
644
|
+
: `ends at version ${this.#currentVersionId}.`), NOT_FOUND, { versionId: this.#versionId, currentVersionId: this.#currentVersionId });
|
|
645
|
+
}
|
|
646
|
+
this.#phase = ResolverPhase.Complete;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
// Step 3: sort the tuples by targetVersionId (ascending), then by block
|
|
650
|
+
// height, and remove the first one. The sort runs on every step because
|
|
651
|
+
// a scan between two steps can add a tuple with a lower version.
|
|
652
|
+
this.#unsortedUpdates.sort(([upd0, blk0], [upd1, blk1]) => upd0.targetVersionId - upd1.targetVersionId || blk0.height - blk1.height);
|
|
653
|
+
const [update, block] = this.#unsortedUpdates.shift();
|
|
654
|
+
// Check targetVersionId, first arm: update.targetVersionId <= currentVersionId
|
|
655
|
+
// re-announces an applied version. Confirm that it is a true duplicate, then
|
|
656
|
+
// skip it. A duplicate does not advance the version counter, does not append
|
|
657
|
+
// to the history (the slot already holds the applied update, ADR 067), and
|
|
658
|
+
// does not stamp the metadata: confirmations refers to the block of the most
|
|
659
|
+
// recently applied unique update. The branch runs before the versionTime
|
|
660
|
+
// test (ADR 068): a re-announcement mined after versionTime can neither end
|
|
661
|
+
// the resolution early nor dodge late-publishing detection.
|
|
662
|
+
if (update.targetVersionId <= this.#currentVersionId) {
|
|
663
|
+
Resolver.confirmDuplicate(update, this.#updateHashHistory);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
// Step 4: the versionTime stop. The block mediantime of the tuple is after
|
|
667
|
+
// versionTime: resolve the current document. The boundary is inclusive, so a
|
|
668
|
+
// tuple whose mediantime equals versionTime applies. The stopped tuple stamps
|
|
669
|
+
// nothing: the metadata reports the last applied update.
|
|
670
|
+
if (this.#versionTime !== undefined && block.mediantime * 1000 > this.#versionTime) {
|
|
671
|
+
this.#phase = ResolverPhase.Complete;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
// Check targetVersionId, third arm: a version was skipped, so raise LATE_PUBLISHING.
|
|
675
|
+
if (update.targetVersionId !== this.#currentVersionId + 1) {
|
|
676
|
+
throw new ResolveError(`Version Id Mismatch: targetVersionId cannot be > currentVersionId + 1`, LATE_PUBLISHING_ERROR, {
|
|
677
|
+
targetVersionId: update.targetVersionId,
|
|
678
|
+
currentVersionId: this.#currentVersionId + 1
|
|
610
679
|
});
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
680
|
+
}
|
|
681
|
+
// Second arm: update.targetVersionId == currentVersionId + 1. Apply the update,
|
|
682
|
+
// append the unsigned update hash to the history, increment the version.
|
|
683
|
+
this.#currentDocument = Resolver.applyUpdate(document, update, block);
|
|
684
|
+
const unsignedUpdate = JSONUtils.deleteKeys(update, ['proof']);
|
|
685
|
+
this.#updateHashHistory.push(canonicalHashBytes(unsignedUpdate));
|
|
686
|
+
this.#currentVersionId++;
|
|
687
|
+
// Step 5: block_confirmations, and the header time as `updated`. On the apply
|
|
688
|
+
// path only: the stop above and the duplicate branch stamp nothing.
|
|
689
|
+
this.#blockConfirmations = block.confirmations;
|
|
690
|
+
this.#updated = DateUtils.toISOStringNonFractional(DateUtils.blocktimeToTimestamp(block.time));
|
|
691
|
+
// The applied update can add a beacon service. "Find Beacon Signals" runs at
|
|
692
|
+
// the top of every pass for the addresses that are not scanned yet, so the
|
|
693
|
+
// pass returns to BeaconDiscovery before the next tuple. Discovery is
|
|
694
|
+
// unbounded by default: termination is guaranteed by address
|
|
695
|
+
// de-duplication (#requestCache). An opt-in maxDiscoveryRounds lets a caller
|
|
696
|
+
// bound the work as a resource guard. Exceeding it is a limit the caller
|
|
697
|
+
// imposed, not a malformed document, so it surfaces as INTERNAL_ERROR.
|
|
698
|
+
if (this.#hasUnscannedBeacons()) {
|
|
699
|
+
if (++this.#discoveryRounds > this.#maxDiscoveryRounds) {
|
|
700
|
+
throw new ResolveError(`Exceeded the configured maximum of ${this.#maxDiscoveryRounds} beacon-discovery `
|
|
701
|
+
+ 'rounds. Raise or remove ResolutionOptions.maxDiscoveryRounds to resolve this DID.', INTERNAL_ERROR, { maxDiscoveryRounds: this.#maxDiscoveryRounds, discoveryRounds: this.#discoveryRounds });
|
|
625
702
|
}
|
|
703
|
+
this.#phase = ResolverPhase.BeaconDiscovery;
|
|
626
704
|
}
|
|
627
|
-
this.#phase = ResolverPhase.Complete;
|
|
628
705
|
continue;
|
|
629
706
|
}
|
|
630
707
|
// Phase: Complete
|
|
708
|
+
// The document metadata of the specification: versionId is current_version_id,
|
|
709
|
+
// confirmations is block_confirmations (0 when no update applied), deactivated
|
|
710
|
+
// is the flag of the document. `updated` is present after the first apply.
|
|
631
711
|
case ResolverPhase.Complete: {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
confirmations: 0,
|
|
640
|
-
deactivated: this.#currentDocument.deactivated || false
|
|
641
|
-
}
|
|
712
|
+
this.#resolvedResponse ??= {
|
|
713
|
+
didDocument: this.#currentDocument,
|
|
714
|
+
metadata: {
|
|
715
|
+
versionId: `${this.#currentVersionId}`,
|
|
716
|
+
confirmations: this.#blockConfirmations,
|
|
717
|
+
...(this.#updated !== undefined ? { updated: this.#updated } : {}),
|
|
718
|
+
deactivated: this.#currentDocument.deactivated || false
|
|
642
719
|
}
|
|
643
720
|
};
|
|
721
|
+
return { status: 'resolved', result: this.#resolvedResponse };
|
|
644
722
|
}
|
|
645
723
|
}
|
|
646
724
|
}
|
|
647
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* True if the current document carries a beacon service whose address the resolver
|
|
728
|
+
* did not request signals for. "Find Beacon Signals" scans such an address on the
|
|
729
|
+
* next pass.
|
|
730
|
+
*/
|
|
731
|
+
#hasUnscannedBeacons() {
|
|
732
|
+
return BeaconUtils.getBeaconServices(this.#currentDocument).some(service => !this.#requestCache.has(BeaconUtils.parseBitcoinAddress(service.serviceEndpoint)));
|
|
733
|
+
}
|
|
648
734
|
/**
|
|
649
735
|
* Return the signals of one beacon service that resolution may process: the
|
|
650
736
|
* signals with at least `#minConf` confirmations. The specification removes a
|
|
@@ -653,10 +739,11 @@ export class Resolver {
|
|
|
653
739
|
* integer confirmation count is excluded too: that is a mempool transaction
|
|
654
740
|
* from a driver that did not skip it.
|
|
655
741
|
*
|
|
656
|
-
* An eligible signal must carry a finite block height
|
|
657
|
-
* signal that passes the count but lacks them is
|
|
658
|
-
* here with a typed error, in the style of the
|
|
659
|
-
* not later with an invalid date
|
|
742
|
+
* An eligible signal must carry a finite block height, block time, and block
|
|
743
|
+
* median time past. A signal that passes the count but lacks them is
|
|
744
|
+
* malformed. It fails fast here with a typed error, in the style of the
|
|
745
|
+
* {@link provide} guards, and not later with an invalid date or a false
|
|
746
|
+
* `versionTime` comparison in the ProcessUpdate phase.
|
|
660
747
|
* @param {Array<BeaconSignal>} signals The signals the caller provided for one service.
|
|
661
748
|
* @returns {Array<BeaconSignal>} The signals at or above the threshold, in the given order.
|
|
662
749
|
* @throws {ResolveError} `INVALID_DID_UPDATE` for an eligible signal with no valid block metadata.
|
|
@@ -669,9 +756,15 @@ export class Resolver {
|
|
|
669
756
|
if (!Number.isInteger(confirmations) || confirmations < this.#minConf) {
|
|
670
757
|
continue;
|
|
671
758
|
}
|
|
672
|
-
if (!Number.isFinite(block?.height) || !Number.isFinite(block?.time)) {
|
|
759
|
+
if (!Number.isFinite(block?.height) || !Number.isFinite(block?.time) || !Number.isFinite(block?.mediantime)) {
|
|
673
760
|
throw new ResolveError(`Beacon signal ${signal.signalBytes} has ${confirmations} confirmations `
|
|
674
|
-
+ 'but no valid block height or block
|
|
761
|
+
+ 'but no valid block height, block time, or block mediantime.', INVALID_DID_UPDATE, {
|
|
762
|
+
signalBytes: signal.signalBytes,
|
|
763
|
+
confirmations,
|
|
764
|
+
height: block?.height,
|
|
765
|
+
time: block?.time,
|
|
766
|
+
mediantime: block?.mediantime
|
|
767
|
+
});
|
|
675
768
|
}
|
|
676
769
|
eligible.push(signal);
|
|
677
770
|
}
|