@did-btcr2/method 0.58.0 → 0.60.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 +2 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/browser.js +3 -3
- package/dist/browser.mjs +3 -3
- package/dist/cjs/index.js +985 -753
- package/dist/esm/core/beacon/beacon.js +8 -1
- package/dist/esm/core/beacon/beacon.js.map +1 -1
- package/dist/esm/core/beacon/signal-discovery.js +24 -3
- package/dist/esm/core/beacon/signal-discovery.js.map +1 -1
- package/dist/esm/core/identifier.js +175 -23
- package/dist/esm/core/identifier.js.map +1 -1
- package/dist/esm/core/resolver.js +77 -3
- package/dist/esm/core/resolver.js.map +1 -1
- package/dist/esm/did-btcr2.js +2 -1
- package/dist/esm/did-btcr2.js.map +1 -1
- package/dist/types/core/beacon/beacon.d.ts.map +1 -1
- package/dist/types/core/beacon/signal-discovery.d.ts +14 -0
- package/dist/types/core/beacon/signal-discovery.d.ts.map +1 -1
- package/dist/types/core/identifier.d.ts +67 -0
- package/dist/types/core/identifier.d.ts.map +1 -1
- package/dist/types/core/interfaces.d.ts +13 -0
- package/dist/types/core/interfaces.d.ts.map +1 -1
- package/dist/types/core/resolver.d.ts +13 -0
- package/dist/types/core/resolver.d.ts.map +1 -1
- package/dist/types/did-btcr2.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/core/beacon/beacon.ts +14 -3
- package/src/core/beacon/signal-discovery.ts +25 -3
- package/src/core/identifier.ts +267 -22
- package/src/core/interfaces.ts +13 -0
- package/src/core/resolver.ts +87 -3
- package/src/did-btcr2.ts +2 -1
package/src/core/identifier.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import type { Bytes, DocumentBytes, KeyBytes, SchnorrKeyPairObject } from '@did-btcr2/common';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BitcoinNetworkNames,
|
|
4
|
+
canonicalHashBytes,
|
|
5
|
+
IdentifierError,
|
|
6
|
+
IdentifierTypes,
|
|
7
|
+
INVALID_DID,
|
|
8
|
+
METHOD_NOT_SUPPORTED
|
|
9
|
+
} from '@did-btcr2/common';
|
|
3
10
|
import { CompressedSecp256k1PublicKey, SchnorrKeyPair } from '@did-btcr2/keypair';
|
|
4
|
-
import {
|
|
11
|
+
import { equalBytes } from '@noble/curves/utils.js';
|
|
12
|
+
import { bech32m, hex } from '@scure/base';
|
|
5
13
|
import type { DidCreateOptions } from '../did-btcr2.js';
|
|
14
|
+
// did-document.js imports this module. Both modules use the other only inside a
|
|
15
|
+
// method body, never at module evaluation, so the cycle is safe in ESM and CJS.
|
|
16
|
+
import { GenesisDocument, ID_PLACEHOLDER_VALUE } from '../utils/did-document.js';
|
|
6
17
|
|
|
7
18
|
/**
|
|
8
19
|
* Components of a did:btcr2 identifier.
|
|
@@ -28,6 +39,69 @@ export interface IdentifierComponents {
|
|
|
28
39
|
network: string;
|
|
29
40
|
genesisBytes: Bytes;
|
|
30
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The name of one check that {@link Identifier.validate} runs. The names are in run order.
|
|
45
|
+
* @typedef {string} IdentifierCheckName
|
|
46
|
+
*/
|
|
47
|
+
export type IdentifierCheckName =
|
|
48
|
+
| 'prefix'
|
|
49
|
+
| 'lowercase'
|
|
50
|
+
| 'bech32m'
|
|
51
|
+
| 'version'
|
|
52
|
+
| 'network'
|
|
53
|
+
| 'genesisBytes'
|
|
54
|
+
| 'roundTrip'
|
|
55
|
+
| 'genesisBytesMatch'
|
|
56
|
+
| 'genesisDocument';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The result of one check that {@link Identifier.validate} ran.
|
|
60
|
+
* @interface IdentifierCheck
|
|
61
|
+
* @property {IdentifierCheckName} name The name of the check.
|
|
62
|
+
* @property {boolean} ok True if the check passed.
|
|
63
|
+
* @property {string} [detail] What the check found.
|
|
64
|
+
*/
|
|
65
|
+
export interface IdentifierCheck {
|
|
66
|
+
name: IdentifierCheckName;
|
|
67
|
+
ok: boolean;
|
|
68
|
+
detail?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Options for {@link Identifier.validate}.
|
|
73
|
+
* @interface IdentifierValidateOptions
|
|
74
|
+
* @property {Bytes} [genesisBytes] The genesis bytes that the identifier must encode: the 33-byte
|
|
75
|
+
* compressed public key of a KEY identifier, or the 32-byte genesis document hash of an EXTERNAL
|
|
76
|
+
* identifier. If present, the report includes the `genesisBytesMatch` check.
|
|
77
|
+
* @property {object} [genesisDocument] The genesis document of an EXTERNAL identifier.
|
|
78
|
+
* If present, the report includes the `genesisDocument` check.
|
|
79
|
+
*/
|
|
80
|
+
export interface IdentifierValidateOptions {
|
|
81
|
+
genesisBytes?: Bytes;
|
|
82
|
+
genesisDocument?: object;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The report that {@link Identifier.validate} returns.
|
|
87
|
+
* @interface IdentifierReport
|
|
88
|
+
* @property {string} did The identifier that was verified.
|
|
89
|
+
* @property {boolean} valid True if every check passed.
|
|
90
|
+
* @property {IdentifierTypes} [idType] The identifier type, known after the `bech32m` check.
|
|
91
|
+
* @property {string} [network] The network name, known after the `network` check.
|
|
92
|
+
* @property {Array<IdentifierCheck>} checks The checks that ran, in run order. The run stops at the first failed check.
|
|
93
|
+
*/
|
|
94
|
+
export interface IdentifierReport {
|
|
95
|
+
did: string;
|
|
96
|
+
valid: boolean;
|
|
97
|
+
idType?: IdentifierTypes;
|
|
98
|
+
network?: string;
|
|
99
|
+
checks: Array<IdentifierCheck>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The prefix of every did:btcr2 identifier. */
|
|
103
|
+
const DID_PREFIX = 'did:btcr2:';
|
|
104
|
+
|
|
31
105
|
/**
|
|
32
106
|
* Implements {@link https://dcdpr.github.io/did-btcr2/#syntax | 3 Syntax}.
|
|
33
107
|
* A did:btcr2 DID consists of a did:btcr2 prefix, followed by an id-bech32 value, which is a Bech32m encoding of:
|
|
@@ -106,7 +180,7 @@ export class Identifier {
|
|
|
106
180
|
// byte, then append genesisBytes. Bech32m-encode the result.
|
|
107
181
|
const firstByte = ((version - 1) << 4) | networkValue;
|
|
108
182
|
const dataBytes = new Uint8Array([firstByte, ...genesisBytes]);
|
|
109
|
-
return
|
|
183
|
+
return `${DID_PREFIX}${bech32m.encodeFromBytes(hrp, dataBytes)}`;
|
|
110
184
|
}
|
|
111
185
|
|
|
112
186
|
/**
|
|
@@ -143,23 +217,29 @@ export class Identifier {
|
|
|
143
217
|
throw new IdentifierError(`Invalid method-specific id: ${identifier}`, INVALID_DID, { identifier });
|
|
144
218
|
}
|
|
145
219
|
|
|
146
|
-
// 6.
|
|
220
|
+
// 6. The method-specific id MUST be lowercase. A Bech32m decoder accepts an all-uppercase
|
|
221
|
+
// string, so this check runs before the Bech32m step.
|
|
222
|
+
if (encoded !== encoded.toLowerCase()) {
|
|
223
|
+
throw new IdentifierError(`Invalid method-specific id (must be lowercase): ${identifier}`, INVALID_DID, { identifier });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 7. Bech32m-decode the id into its hrp and dataBytes.
|
|
147
227
|
const { prefix: hrp, bytes: dataBytes } = bech32m.decodeToBytes(encoded);
|
|
148
228
|
|
|
149
|
-
//
|
|
229
|
+
// 8. The hrp MUST be "k" (KEY) or "x" (EXTERNAL).
|
|
150
230
|
if (!['x', 'k'].includes(hrp)) {
|
|
151
231
|
throw new IdentifierError(`Invalid hrp: ${hrp}`, INVALID_DID, { identifier });
|
|
152
232
|
}
|
|
153
233
|
|
|
154
|
-
//
|
|
234
|
+
// 9. There MUST be at least one byte to read btcr2_version and network_value from.
|
|
155
235
|
if (!dataBytes || dataBytes.length < 1) {
|
|
156
236
|
throw new IdentifierError(`Failed to decode id: ${encoded}`, INVALID_DID, { identifier });
|
|
157
237
|
}
|
|
158
238
|
|
|
159
|
-
//
|
|
239
|
+
// 10. Map hrp to idType.
|
|
160
240
|
const idType = hrp === 'k' ? 'KEY' : 'EXTERNAL';
|
|
161
241
|
|
|
162
|
-
//
|
|
242
|
+
// 11. btcr2_version is the high nibble of the first byte and MUST be 0, which is version_number 1.
|
|
163
243
|
// The version-extension scheme (a leading nibble of 0xF chaining into further bytes) is reserved
|
|
164
244
|
// and not valid under v1, so any non-zero high nibble (0x1 through 0xF) is a malformed or forged
|
|
165
245
|
// identifier and is rejected here. Reading a single flat nibble (rather than looping on 0xF) is
|
|
@@ -171,23 +251,20 @@ export class Identifier {
|
|
|
171
251
|
}
|
|
172
252
|
const version = 1;
|
|
173
253
|
|
|
174
|
-
//
|
|
175
|
-
//
|
|
254
|
+
// 12. network_value is the low nibble of the first byte. 0-5 map to named networks. 6-11 are
|
|
255
|
+
// reserved. 12-15 are custom networks; this implementation supports no custom network, so the
|
|
256
|
+
// decoder rejects them, as the specification recommends (ADR 107).
|
|
176
257
|
const networkValue = dataBytes[0] & 0x0F;
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
network
|
|
181
|
-
} else if (networkValue >= 12 && networkValue <= 14) {
|
|
182
|
-
network = networkValue - 11;
|
|
183
|
-
} else {
|
|
184
|
-
throw new IdentifierError(`Invalid network: ${networkValue}`, INVALID_DID, { identifier });
|
|
258
|
+
const network = BitcoinNetworkNames[networkValue] as string | undefined;
|
|
259
|
+
if (typeof network !== 'string') {
|
|
260
|
+
const reason = networkValue >= 12 ? 'custom network not supported' : 'reserved';
|
|
261
|
+
throw new IdentifierError(`Invalid network (${reason}): ${networkValue}`, INVALID_DID, { identifier });
|
|
185
262
|
}
|
|
186
263
|
|
|
187
|
-
//
|
|
264
|
+
// 13. genesisBytes is everything after the first byte.
|
|
188
265
|
const genesisBytes = dataBytes.slice(1);
|
|
189
266
|
|
|
190
|
-
//
|
|
267
|
+
// 14. genesisBytes MUST match the identifier type: a valid compressed secp256k1 public key for KEY,
|
|
191
268
|
// or a 32-byte hash for EXTERNAL.
|
|
192
269
|
if (idType === 'KEY') {
|
|
193
270
|
try {
|
|
@@ -199,10 +276,178 @@ export class Identifier {
|
|
|
199
276
|
throw new IdentifierError(`Invalid genesisBytes: ${genesisBytes}`, INVALID_DID, { identifier });
|
|
200
277
|
}
|
|
201
278
|
|
|
202
|
-
//
|
|
279
|
+
// 15. Return idType, hrp, version, network, and genesisBytes.
|
|
203
280
|
return { idType, hrp, version, network, genesisBytes } as DidComponents;
|
|
204
281
|
}
|
|
205
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Validates that a did:btcr2 identifier conforms to
|
|
285
|
+
* {@link https://dcdpr.github.io/did-btcr2/#didbtcr2-identifier-decoding | 3.3 did:btcr2 Identifier Decoding}
|
|
286
|
+
* and returns a report of the checks. The method does not throw on an invalid identifier.
|
|
287
|
+
*
|
|
288
|
+
* The checks run in this order: `prefix`, `lowercase`, `bech32m`, `version`, `network`,
|
|
289
|
+
* `genesisBytes`, `roundTrip`, `genesisBytesMatch`, and `genesisDocument`. The run stops at the
|
|
290
|
+
* first failed check. The `network` check accepts a named network only: a reserved value (6 to
|
|
291
|
+
* 11) and a custom value (12 to 15) fail, because this implementation supports no custom network.
|
|
292
|
+
* The `genesisBytesMatch` check runs only if `options.genesisBytes` is present: the supplied bytes
|
|
293
|
+
* must equal the genesis bytes of the identifier, for a KEY or an EXTERNAL identifier. The
|
|
294
|
+
* `genesisDocument` check runs only if `options.genesisDocument` is present. For an EXTERNAL
|
|
295
|
+
* identifier it confirms that the document is a valid Genesis Document and that its canonical
|
|
296
|
+
* SHA-256 hash equals the genesis bytes. For a KEY identifier it fails.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} identifier The did:btcr2 identifier to validate.
|
|
299
|
+
* @param {IdentifierValidateOptions} [options] The validation options.
|
|
300
|
+
* @returns {IdentifierReport} The report. See {@link IdentifierReport} for details.
|
|
301
|
+
*/
|
|
302
|
+
static validate(identifier: string, options: IdentifierValidateOptions = {}): IdentifierReport {
|
|
303
|
+
const checks: Array<IdentifierCheck> = [];
|
|
304
|
+
const pass = (name: IdentifierCheckName, detail?: string): void => {
|
|
305
|
+
checks.push(detail === undefined ? { name, ok: true } : { name, ok: true, detail });
|
|
306
|
+
};
|
|
307
|
+
const fail = (name: IdentifierCheckName, detail: string, partial: Partial<IdentifierReport> = {}): IdentifierReport => {
|
|
308
|
+
checks.push({ name, ok: false, detail });
|
|
309
|
+
return { did: identifier, valid: false, ...partial, checks };
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// prefix: the string is "did:btcr2:" followed by a non-empty method-specific id.
|
|
313
|
+
if (typeof identifier !== 'string') {
|
|
314
|
+
return fail('prefix', 'The identifier is not a string.');
|
|
315
|
+
}
|
|
316
|
+
const parts = identifier.split(':');
|
|
317
|
+
if (parts.length !== 3 || parts[0] !== 'did' || parts[1] !== 'btcr2') {
|
|
318
|
+
return fail('prefix', `The identifier must be "${DID_PREFIX}" followed by the method-specific id.`);
|
|
319
|
+
}
|
|
320
|
+
const encoded = parts[2];
|
|
321
|
+
if (encoded.length === 0) {
|
|
322
|
+
return fail('prefix', 'The method-specific id is empty.');
|
|
323
|
+
}
|
|
324
|
+
pass('prefix');
|
|
325
|
+
|
|
326
|
+
// lowercase: the method-specific id is lowercase.
|
|
327
|
+
if (encoded !== encoded.toLowerCase()) {
|
|
328
|
+
return fail('lowercase', 'The method-specific id must be lowercase.');
|
|
329
|
+
}
|
|
330
|
+
pass('lowercase');
|
|
331
|
+
|
|
332
|
+
// bech32m: the id decodes, the hrp is "k" or "x", and the data bytes are not empty.
|
|
333
|
+
let hrp: string;
|
|
334
|
+
let dataBytes: Uint8Array;
|
|
335
|
+
try {
|
|
336
|
+
({ prefix: hrp, bytes: dataBytes } = bech32m.decodeToBytes(encoded));
|
|
337
|
+
} catch (error: unknown) {
|
|
338
|
+
return fail('bech32m', `Bech32m decoding failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
339
|
+
}
|
|
340
|
+
if (hrp !== 'k' && hrp !== 'x') {
|
|
341
|
+
return fail('bech32m', `The hrp must be "k" or "x", got "${hrp}".`);
|
|
342
|
+
}
|
|
343
|
+
const idType = hrp === 'k' ? IdentifierTypes.KEY : IdentifierTypes.EXTERNAL;
|
|
344
|
+
if (dataBytes.length < 1) {
|
|
345
|
+
return fail('bech32m', 'The data bytes are empty.', { idType });
|
|
346
|
+
}
|
|
347
|
+
pass('bech32m', `hrp "${hrp}", ${dataBytes.length} data bytes`);
|
|
348
|
+
|
|
349
|
+
// version: btcr2_version (the high nibble of the first byte) is 0.
|
|
350
|
+
const btcr2Version = dataBytes[0] >>> 4;
|
|
351
|
+
if (btcr2Version !== 0) {
|
|
352
|
+
return fail('version', `btcr2_version must be 0, got ${btcr2Version}.`, { idType });
|
|
353
|
+
}
|
|
354
|
+
pass('version', 'btcr2_version 0 (version_number 1)');
|
|
355
|
+
|
|
356
|
+
// network: network_value (the low nibble of the first byte) names a network.
|
|
357
|
+
const networkValue = dataBytes[0] & 0x0F;
|
|
358
|
+
const network = BitcoinNetworkNames[networkValue] as string | undefined;
|
|
359
|
+
if (typeof network !== 'string') {
|
|
360
|
+
const detail = networkValue >= 12
|
|
361
|
+
? `network_value ${networkValue} is a custom network, not supported by this implementation.`
|
|
362
|
+
: `network_value ${networkValue} is reserved.`;
|
|
363
|
+
return fail('network', detail, { idType });
|
|
364
|
+
}
|
|
365
|
+
pass('network', `network_value ${networkValue} (${network})`);
|
|
366
|
+
|
|
367
|
+
// genesisBytes: a 33-byte SEC compressed secp256k1 public key (KEY) or a 32-byte hash (EXTERNAL).
|
|
368
|
+
const genesisBytes = dataBytes.slice(1);
|
|
369
|
+
if (idType === IdentifierTypes.KEY) {
|
|
370
|
+
try {
|
|
371
|
+
new CompressedSecp256k1PublicKey(genesisBytes);
|
|
372
|
+
} catch {
|
|
373
|
+
return fail(
|
|
374
|
+
'genesisBytes',
|
|
375
|
+
`Expected a 33-byte SEC compressed secp256k1 public key, got ${genesisBytes.length} bytes that are not a valid key.`,
|
|
376
|
+
{ idType, network }
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
pass('genesisBytes', '33-byte SEC compressed secp256k1 public key');
|
|
380
|
+
} else {
|
|
381
|
+
if (genesisBytes.length !== 32) {
|
|
382
|
+
return fail('genesisBytes', `Expected a 32-byte SHA-256 hash, got ${genesisBytes.length} bytes.`, { idType, network });
|
|
383
|
+
}
|
|
384
|
+
pass('genesisBytes', '32-byte SHA-256 hash');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// roundTrip: encoding the decoded components reproduces the identifier.
|
|
388
|
+
let reEncoded: string;
|
|
389
|
+
try {
|
|
390
|
+
reEncoded = Identifier.encode(genesisBytes, { idType, version: 1, network: network as DidCreateOptions['network'] });
|
|
391
|
+
} catch (error: unknown) {
|
|
392
|
+
return fail('roundTrip', `Re-encoding failed: ${error instanceof Error ? error.message : String(error)}`, { idType, network });
|
|
393
|
+
}
|
|
394
|
+
if (reEncoded !== identifier) {
|
|
395
|
+
return fail('roundTrip', `Re-encoding produced "${reEncoded}".`, { idType, network });
|
|
396
|
+
}
|
|
397
|
+
pass('roundTrip');
|
|
398
|
+
|
|
399
|
+
// genesisBytesMatch: only if the caller supplied genesis bytes.
|
|
400
|
+
if (options.genesisBytes !== undefined) {
|
|
401
|
+
const supplied = options.genesisBytes;
|
|
402
|
+
if (!(supplied instanceof Uint8Array)) {
|
|
403
|
+
return fail('genesisBytesMatch', 'The supplied genesis bytes are not a Uint8Array.', { idType, network });
|
|
404
|
+
}
|
|
405
|
+
if (supplied.length !== genesisBytes.length) {
|
|
406
|
+
return fail(
|
|
407
|
+
'genesisBytesMatch',
|
|
408
|
+
`Expected ${genesisBytes.length} genesis bytes for a ${idType} identifier, got ${supplied.length}.`,
|
|
409
|
+
{ idType, network }
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (!equalBytes(supplied, genesisBytes)) {
|
|
413
|
+
return fail(
|
|
414
|
+
'genesisBytesMatch',
|
|
415
|
+
`The supplied genesis bytes ${hex.encode(supplied)} do not equal the genesis bytes of the identifier ${hex.encode(genesisBytes)}.`,
|
|
416
|
+
{ idType, network }
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
pass('genesisBytesMatch', 'The supplied genesis bytes equal the genesis bytes of the identifier.');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// genesisDocument: only if the caller supplied a document.
|
|
423
|
+
if (options.genesisDocument !== undefined) {
|
|
424
|
+
const document = options.genesisDocument;
|
|
425
|
+
if (idType === IdentifierTypes.KEY) {
|
|
426
|
+
return fail('genesisDocument', 'A KEY identifier has no genesis document.', { idType, network });
|
|
427
|
+
}
|
|
428
|
+
const id = (document as { id?: unknown }).id;
|
|
429
|
+
if (id !== ID_PLACEHOLDER_VALUE) {
|
|
430
|
+
return fail('genesisDocument', `The genesis document id must be "${ID_PLACEHOLDER_VALUE}", got ${JSON.stringify(id)}.`, { idType, network });
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
GenesisDocument.fromJSON(document);
|
|
434
|
+
} catch (error: unknown) {
|
|
435
|
+
return fail('genesisDocument', `Invalid genesis document: ${error instanceof Error ? error.message : String(error)}`, { idType, network });
|
|
436
|
+
}
|
|
437
|
+
const documentHash = canonicalHashBytes(document);
|
|
438
|
+
if (!equalBytes(documentHash, genesisBytes)) {
|
|
439
|
+
return fail(
|
|
440
|
+
'genesisDocument',
|
|
441
|
+
`The genesis document hash ${hex.encode(documentHash)} does not equal the genesis bytes ${hex.encode(genesisBytes)}.`,
|
|
442
|
+
{ idType, network }
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
pass('genesisDocument', 'The genesis document hashes to the genesis bytes.');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return { did: identifier, valid: true, idType, network, checks };
|
|
449
|
+
}
|
|
450
|
+
|
|
206
451
|
/**
|
|
207
452
|
* Generates a new did:btcr2 identifier based on a newly generated key pair.
|
|
208
453
|
* @returns {string} The new did:btcr2 identifier.
|
|
@@ -249,4 +494,4 @@ export class Identifier {
|
|
|
249
494
|
return false;
|
|
250
495
|
}
|
|
251
496
|
}
|
|
252
|
-
}
|
|
497
|
+
}
|
package/src/core/interfaces.ts
CHANGED
|
@@ -47,6 +47,19 @@ export interface ResolutionOptions extends DidResolutionOptions {
|
|
|
47
47
|
*/
|
|
48
48
|
maxDiscoveryRounds?: number;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Minimum number of Bitcoin block confirmations a Beacon Signal transaction
|
|
52
|
+
* must have before resolution processes it. A positive integer, minimum `1`.
|
|
53
|
+
* Defaults to `6` ({@link DEFAULT_MIN_CONF}), the value the specification
|
|
54
|
+
* mandates. A signal below the threshold is excluded from the resolution
|
|
55
|
+
* as if it did not exist yet; the rest of the signals are processed. A lower
|
|
56
|
+
* value shows a fresh update sooner and raises the exposure to a block
|
|
57
|
+
* reorganization. The `confirmations` field of the resolution metadata
|
|
58
|
+
* reports the depth of the last applied signal, so a consumer can judge it.
|
|
59
|
+
* Any other value (`0`, a negative number, a fraction, `NaN`, a string)
|
|
60
|
+
* fails with a `ResolveError` of type `INVALID_OPTIONS`.
|
|
61
|
+
*/
|
|
62
|
+
minConf?: number;
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
/**
|
package/src/core/resolver.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
INTERNAL_ERROR,
|
|
10
10
|
INVALID_DID_DOCUMENT,
|
|
11
11
|
INVALID_DID_UPDATE,
|
|
12
|
+
INVALID_OPTIONS,
|
|
12
13
|
JSONPatch,
|
|
13
14
|
JSONUtils,
|
|
14
15
|
LATE_PUBLISHING_ERROR,
|
|
@@ -37,6 +38,15 @@ import type { SMTProof } from './interfaces.js';
|
|
|
37
38
|
import type { CASAnnouncement, Sidecar, SidecarData } from './types.js';
|
|
38
39
|
import { equalBytes } from '@noble/curves/utils.js';
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Default minimum number of Bitcoin block confirmations a Beacon Signal
|
|
43
|
+
* transaction must have before resolution processes it. The specification
|
|
44
|
+
* mandates `6` when `ResolutionOptions.minConf` is not set: six confirmations
|
|
45
|
+
* is the accepted standard for a settled Bitcoin transaction. A resolution
|
|
46
|
+
* request can raise or lower it through `minConf`.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_MIN_CONF = 6;
|
|
49
|
+
|
|
40
50
|
/**
|
|
41
51
|
* The response object for DID Resolution.
|
|
42
52
|
*/
|
|
@@ -149,6 +159,22 @@ function isSMTProof(value: unknown): value is SMTProof {
|
|
|
149
159
|
&& Array.isArray(value.hashes);
|
|
150
160
|
}
|
|
151
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Validate `ResolutionOptions.minConf`. `undefined` selects the specification
|
|
164
|
+
* default, {@link DEFAULT_MIN_CONF}. Any other value must be an integer of at
|
|
165
|
+
* least 1, as the specification defines the option.
|
|
166
|
+
* @throws {ResolveError} `INVALID_OPTIONS` for every other value.
|
|
167
|
+
*/
|
|
168
|
+
function validateMinConf(value: unknown): number {
|
|
169
|
+
if(value === undefined) return DEFAULT_MIN_CONF;
|
|
170
|
+
if(typeof value === 'number' && Number.isInteger(value) && value >= 1) return value;
|
|
171
|
+
const shown = typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
172
|
+
throw new ResolveError(
|
|
173
|
+
`Invalid resolution option minConf: expected a positive integer (minimum 1), got ${shown}.`,
|
|
174
|
+
INVALID_OPTIONS, { minConf: value }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
152
178
|
/**
|
|
153
179
|
* Different possible Resolver states representing phases in the resolution process.
|
|
154
180
|
*/
|
|
@@ -225,6 +251,15 @@ export class Resolver {
|
|
|
225
251
|
/** Count of beacon-discovery passes driven by updates adding new beacon services. */
|
|
226
252
|
#discoveryRounds = 0;
|
|
227
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Minimum block confirmations a Beacon Signal must have before this resolver
|
|
256
|
+
* processes it: `ResolutionOptions.minConf`, default {@link DEFAULT_MIN_CONF}.
|
|
257
|
+
* Applied at signal intake in the BeaconProcess phase. A signal below the
|
|
258
|
+
* threshold is excluded from the resolution; the rest of the signals are
|
|
259
|
+
* processed.
|
|
260
|
+
*/
|
|
261
|
+
readonly #minConf: number;
|
|
262
|
+
|
|
228
263
|
|
|
229
264
|
/**
|
|
230
265
|
* @internal Use {@link DidBtcr2.resolve} to create instances.
|
|
@@ -238,6 +273,7 @@ export class Resolver {
|
|
|
238
273
|
versionTime?: string;
|
|
239
274
|
genesisDocument?: object;
|
|
240
275
|
maxDiscoveryRounds?: number;
|
|
276
|
+
minConf?: number;
|
|
241
277
|
}
|
|
242
278
|
) {
|
|
243
279
|
this.#didComponents = didComponents;
|
|
@@ -249,6 +285,9 @@ export class Resolver {
|
|
|
249
285
|
// finite resource guard. A non-positive or omitted value means no limit.
|
|
250
286
|
const rounds = options?.maxDiscoveryRounds;
|
|
251
287
|
this.#maxDiscoveryRounds = typeof rounds === 'number' && rounds > 0 ? rounds : Infinity;
|
|
288
|
+
// The signal confirmation threshold. An invalid value fails here, before any
|
|
289
|
+
// data need is emitted, so the caller does no I/O for a request it cannot serve.
|
|
290
|
+
this.#minConf = validateMinConf(options?.minConf);
|
|
252
291
|
|
|
253
292
|
// If a genesis document was provided (from sidecar), pre-seed it for validation
|
|
254
293
|
if(options?.genesisDocument) {
|
|
@@ -374,6 +413,10 @@ export class Resolver {
|
|
|
374
413
|
* Version counter and update-hash history carried from earlier discovery rounds.
|
|
375
414
|
* Standalone callers omit it and start fresh at version 1 with an empty history.
|
|
376
415
|
* @returns {DidResolutionResponse} The updated DID Document, number of confirmations, and version id.
|
|
416
|
+
*
|
|
417
|
+
* Confirmation depth is not checked here. The BeaconProcess phase excludes a
|
|
418
|
+
* signal below `ResolutionOptions.minConf` before its update reaches this method,
|
|
419
|
+
* so every tuple here comes from a block at or above the threshold.
|
|
377
420
|
*/
|
|
378
421
|
static updates(
|
|
379
422
|
currentDocument: DidDocument,
|
|
@@ -414,8 +457,6 @@ export class Resolver {
|
|
|
414
457
|
// Safely convert block.time to timestamp
|
|
415
458
|
const blocktime = DateUtils.blocktimeToTimestamp(block.time);
|
|
416
459
|
|
|
417
|
-
// TODO: How to detect if block is unconfirmed and exit gracefully or return without it
|
|
418
|
-
|
|
419
460
|
// Set the updated field to the blocktime of the current update
|
|
420
461
|
response.metadata.updated = DateUtils.toISOStringNonFractional(blocktime);
|
|
421
462
|
|
|
@@ -752,11 +793,17 @@ export class Resolver {
|
|
|
752
793
|
// Skip already-processed services and services with no signals
|
|
753
794
|
if(this.#processedServices.has(service.id) || !signals.length) continue;
|
|
754
795
|
|
|
796
|
+
// Keep only the signals at or above the confirmation threshold. A
|
|
797
|
+
// service whose signals are all below it is treated like a service
|
|
798
|
+
// with no signals: it is not processed and not marked processed.
|
|
799
|
+
const eligible = this.#eligibleSignals(signals);
|
|
800
|
+
if(!eligible.length) continue;
|
|
801
|
+
|
|
755
802
|
// Establish a typed beacon and process its signals
|
|
756
803
|
// The beacon is bound to the DID under resolution: a beacon service
|
|
757
804
|
// `id` may be a relative DID URL, so it cannot supply the subject.
|
|
758
805
|
const beacon = BeaconFactory.establish(service, this.#currentDocument!.id);
|
|
759
|
-
const result = beacon.processSignals(
|
|
806
|
+
const result = beacon.processSignals(eligible, this.#sidecarData);
|
|
760
807
|
|
|
761
808
|
if(result.needs.length > 0) {
|
|
762
809
|
// This service has unmet data needs, collect them
|
|
@@ -846,6 +893,43 @@ export class Resolver {
|
|
|
846
893
|
}
|
|
847
894
|
}
|
|
848
895
|
|
|
896
|
+
/**
|
|
897
|
+
* Return the signals of one beacon service that resolution may process: the
|
|
898
|
+
* signals with at least `#minConf` confirmations. The specification removes a
|
|
899
|
+
* transaction below the threshold from the set of Beacon Signals, so an
|
|
900
|
+
* excluded signal emits no data need and applies no update. A signal with no
|
|
901
|
+
* integer confirmation count is excluded too: that is a mempool transaction
|
|
902
|
+
* from a driver that did not skip it.
|
|
903
|
+
*
|
|
904
|
+
* An eligible signal must carry a finite block height and block time. A
|
|
905
|
+
* signal that passes the count but lacks them is malformed. It fails fast
|
|
906
|
+
* here with a typed error, in the style of the {@link provide} guards, and
|
|
907
|
+
* not later with an invalid date inside {@link updates}.
|
|
908
|
+
* @param {Array<BeaconSignal>} signals The signals the caller provided for one service.
|
|
909
|
+
* @returns {Array<BeaconSignal>} The signals at or above the threshold, in the given order.
|
|
910
|
+
* @throws {ResolveError} `INVALID_DID_UPDATE` for an eligible signal with no valid block metadata.
|
|
911
|
+
*/
|
|
912
|
+
#eligibleSignals(signals: Array<BeaconSignal>): Array<BeaconSignal> {
|
|
913
|
+
const eligible: Array<BeaconSignal> = [];
|
|
914
|
+
for(const signal of signals) {
|
|
915
|
+
const block = signal.blockMetadata as Partial<BlockMetadata> | undefined;
|
|
916
|
+
const confirmations = block?.confirmations;
|
|
917
|
+
if(!Number.isInteger(confirmations) || (confirmations as number) < this.#minConf) {
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if(!Number.isFinite(block?.height) || !Number.isFinite(block?.time)) {
|
|
921
|
+
throw new ResolveError(
|
|
922
|
+
`Beacon signal ${signal.signalBytes} has ${confirmations} confirmations `
|
|
923
|
+
+ 'but no valid block height or block time.',
|
|
924
|
+
INVALID_DID_UPDATE,
|
|
925
|
+
{ signalBytes: signal.signalBytes, confirmations, height: block?.height, time: block?.time }
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
eligible.push(signal);
|
|
929
|
+
}
|
|
930
|
+
return eligible;
|
|
931
|
+
}
|
|
932
|
+
|
|
849
933
|
/**
|
|
850
934
|
* Provide data the resolver requested in a previous {@link resolve} call.
|
|
851
935
|
* Call once per need, then call {@link resolve} again to continue.
|
package/src/did-btcr2.ts
CHANGED
|
@@ -131,7 +131,8 @@ export class DidBtcr2 implements DidMethod {
|
|
|
131
131
|
versionId : resolutionOptions.versionId,
|
|
132
132
|
versionTime : resolutionOptions.versionTime,
|
|
133
133
|
genesisDocument : resolutionOptions.sidecar?.genesisDocument,
|
|
134
|
-
maxDiscoveryRounds : resolutionOptions.maxDiscoveryRounds
|
|
134
|
+
maxDiscoveryRounds : resolutionOptions.maxDiscoveryRounds,
|
|
135
|
+
minConf : resolutionOptions.minConf
|
|
135
136
|
});
|
|
136
137
|
}
|
|
137
138
|
|