@did-btcr2/method 0.54.1 → 0.56.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.
@@ -1 +1 @@
1
- {"version":3,"file":"appendix.d.ts","sourceRoot":"","sources":["../../../src/utils/appendix.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EACX,UAAU,EACV,qBAAqB,EAAC,MAAM,YAAY,CAAC;AAM3C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D;;;;;GAKG;AACH,qBAAa,QAAQ;IACnB;;;;OAIG;WACW,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS;IAMpE;;;;OAIG;WACW,uBAAuB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,qBAAqB;IAcjF;;;;OAIG;WACW,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,UAAU;IAW3D;;;;;OAKG;WACW,sBAAsB,CAAC,WAAW,EAAE,WAAW,GAAG,qBAAqB,EAAE;IAgBvF;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,MAAM,CAAC,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,cAAc;IAU/D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;WACW,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,cAAc;CA8CtE"}
1
+ {"version":3,"file":"appendix.d.ts","sourceRoot":"","sources":["../../../src/utils/appendix.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EACX,UAAU,EACV,qBAAqB,EAAC,MAAM,YAAY,CAAC;AAO3C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D;;;;;GAKG;AACH,qBAAa,QAAQ;IACnB;;;;OAIG;WACW,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS;IAMpE;;;;OAIG;WACW,uBAAuB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,qBAAqB;IAcjF;;;;OAIG;WACW,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,UAAU;IAW3D;;;;;OAKG;WACW,sBAAsB,CAAC,WAAW,EAAE,WAAW,GAAG,qBAAqB,EAAE;IAgBvF;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,MAAM,CAAC,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,cAAc;IAU/D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;WACW,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,cAAc;CA8CtE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@did-btcr2/method",
3
- "version": "0.54.1",
3
+ "version": "0.56.0",
4
4
  "type": "module",
5
5
  "description": "Reference implementation for the did:btcr2 DID method written in TypeScript and JavaScript. did:btcr2 is a censorship resistant DID Method using the Bitcoin blockchain as a Verifiable Data Registry to announce changes to the DID document. This is the core method implementation for the did-btcr2-js monorepo.",
6
6
  "main": "./dist/cjs/index.js",
@@ -69,11 +69,11 @@
69
69
  "@scure/base": "^1.2.6",
70
70
  "@scure/btc-signer": "^1.8.1",
71
71
  "@web5/dids": "^1.2.0",
72
- "@did-btcr2/bitcoin": "^0.9.0",
73
- "@did-btcr2/common": "^9.1.0",
74
- "@did-btcr2/keypair": "^0.13.1",
75
- "@did-btcr2/cryptosuite": "^9.0.0",
76
- "@did-btcr2/smt": "^0.3.0"
72
+ "@did-btcr2/common": "^9.3.0",
73
+ "@did-btcr2/bitcoin": "^0.10.0",
74
+ "@did-btcr2/smt": "^0.3.0",
75
+ "@did-btcr2/cryptosuite": "^10.0.0",
76
+ "@did-btcr2/keypair": "^0.13.1"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@eslint/js": "^9.39.4",
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  BitcoinConnection,
3
3
  BlockV3,
4
+ RawTransactionRest,
4
5
  RawTransactionV2} from '@did-btcr2/bitcoin';
5
6
  import {
6
7
  GENESIS_TX_ID,
@@ -11,37 +12,44 @@ import type { BeaconService, BeaconSignal } from './interfaces.js';
11
12
  import { BeaconUtils } from './utils.js';
12
13
 
13
14
  /**
14
- * Parses a scriptPubKey asm string and returns the beacon signal hash if and only
15
- * if the output is exactly `OP_RETURN OP_PUSHBYTES_32 <32-byte hex>`.
15
+ * The serialized form of a Beacon Signal output: `OP_RETURN` (`0x6a`), the 32-byte push
16
+ * opcode (`0x20`), then the 32-byte hash. This is the exact inverse of `opReturnScript`,
17
+ * which encodes signals as `0x6a 0x20 <32 bytes>` and is pinned byte for byte by
18
+ * `op-return-script.spec.ts`.
19
+ */
20
+ const BEACON_SIGNAL_SCRIPT = /^6a20([0-9a-f]{64})$/i;
21
+
22
+ /**
23
+ * Parses a serialized scriptPubKey and returns the beacon signal hash if and only if the
24
+ * output is exactly `OP_RETURN OP_PUSHBYTES_32 <32-byte hash>`.
16
25
  *
17
- * Beacon signals encode a single 32-byte update or announcement hash in an
18
- * OP_RETURN data push. Any other shape (a bare `OP_RETURN`, a push of the wrong
19
- * size, or a non-hex payload) is not a valid signal and returns `null`, so a
20
- * malformed or adversarial on-chain output cannot be mistaken for a real signal
21
- * downstream.
26
+ * Beacon signals encode a single 32-byte update or announcement hash in an OP_RETURN data
27
+ * push. Any other shape (a bare `OP_RETURN`, a push of the wrong size, a second push, or a
28
+ * non-canonical push opcode such as `OP_PUSHDATA1`) is not a valid signal and returns
29
+ * `null`, so a malformed or adversarial on-chain output cannot be mistaken for a real
30
+ * signal downstream.
22
31
  *
23
- * @param {string | undefined} asm The scriptPubKey asm string to parse.
32
+ * The input is the script itself, not a rendered `asm` string, because `asm` is a
33
+ * human-readable rendering whose dialect differs per backend: Bitcoin Core prints
34
+ * `OP_RETURN <hash>` while Esplora prints `OP_RETURN OP_PUSHBYTES_32 <hash>`. Both return
35
+ * the identical serialized script (Esplora as `scriptpubkey`, Core as `scriptPubKey.hex`),
36
+ * so decoding the script is backend-agnostic and matches the bytes actually committed to
37
+ * the chain.
38
+ *
39
+ * @param {string | undefined} scriptPubKey Hex-encoded scriptPubKey of the output to parse.
24
40
  * @returns {string | null} The lowercased 32-byte hex hash, or `null` if not a valid signal.
25
41
  */
26
- export function extractOpReturnSignal(asm: string | undefined): string | null {
27
- if(!asm) {
28
- return null;
29
- }
30
-
31
- // A standard NULL_DATA beacon output is exactly three asm tokens: the OP_RETURN
32
- // opcode, the 32-byte push opcode, and the 64-character hex payload.
33
- const tokens = asm.trim().split(/\s+/);
34
- if(tokens.length !== 3 || tokens[0] !== 'OP_RETURN' || tokens[1] !== 'OP_PUSHBYTES_32') {
42
+ export function extractOpReturnSignalHash(scriptPubKey: string | undefined): string | null {
43
+ if(!scriptPubKey) {
35
44
  return null;
36
45
  }
37
46
 
38
- // The payload must be exactly 32 bytes of hex (64 hex characters).
39
- const signalHash = tokens[2];
40
- if(!/^[0-9a-fA-F]{64}$/.test(signalHash)) {
47
+ const signal = BEACON_SIGNAL_SCRIPT.exec(scriptPubKey.trim());
48
+ if(!signal) {
41
49
  return null;
42
50
  }
43
51
 
44
- return signalHash.toLowerCase();
52
+ return signal[1].toLowerCase();
45
53
  }
46
54
 
47
55
  /**
@@ -52,6 +60,52 @@ export function extractOpReturnSignal(asm: string | undefined): string | null {
52
60
  */
53
61
  export class BeaconSignalDiscovery {
54
62
 
63
+ /**
64
+ * Determines whether a candidate transaction spends an output controlled by the given
65
+ * beacon address.
66
+ *
67
+ * A Beacon Signal is a transaction that *spends from* a Beacon Address, but an address
68
+ * transaction listing returns every transaction touching the address in either
69
+ * direction. Without this check, anyone able to pay dust to a beacon address could
70
+ * attach an arbitrary 32-byte OP_RETURN and have it read as a signal, so the input side
71
+ * has to be inspected before a transaction is treated as one.
72
+ *
73
+ * Esplora embeds the spent output in `vin[].prevout`; when a backend omits it the
74
+ * funding transaction is fetched instead, so a missing field cannot silently drop a
75
+ * real signal.
76
+ *
77
+ * @param {RawTransactionRest} tx The candidate transaction.
78
+ * @param {string} address The beacon address the transaction must spend from.
79
+ * @param {BitcoinConnection} bitcoin Bitcoin network connection to use for REST calls.
80
+ * @returns {Promise<boolean>} True if at least one input spends an output of the beacon address.
81
+ */
82
+ private static async spendsFromAddress(
83
+ tx: RawTransactionRest,
84
+ address: string,
85
+ bitcoin: BitcoinConnection
86
+ ): Promise<boolean> {
87
+ for(const vin of tx.vin ?? []) {
88
+ // A coinbase input spends no prior output, so it can never spend from a beacon.
89
+ if(vin.is_coinbase) {
90
+ continue;
91
+ }
92
+
93
+ let prevout = vin.prevout;
94
+
95
+ // Fall back to the funding transaction when the backend does not embed the prevout.
96
+ if(!prevout && vin.txid) {
97
+ const fundingTx = await bitcoin.rest.transaction.get(vin.txid);
98
+ prevout = fundingTx?.vout?.[vin.vout];
99
+ }
100
+
101
+ if(prevout?.scriptpubkey_address === address) {
102
+ return true;
103
+ }
104
+ }
105
+
106
+ return false;
107
+ }
108
+
55
109
  /**
56
110
  * Retrieves the beacon signals for the given array of BeaconService objects
57
111
  * using an esplora/electrs REST API connection via a bitcoin I/O driver.
@@ -71,10 +125,9 @@ export class BeaconSignalDiscovery {
71
125
  // Iterate over each beacon
72
126
  for (const beaconService of beaconServices) {
73
127
  beaconServiceSignals.set(beaconService, []);
128
+ const beaconAddress = BeaconUtils.parseBitcoinAddress(beaconService.serviceEndpoint as string);
74
129
  // Get the transactions for the beacon address via REST
75
- const beaconSignals = await bitcoin.rest.address.getTxs(
76
- BeaconUtils.parseBitcoinAddress(beaconService.serviceEndpoint as string)
77
- );
130
+ const beaconSignals = await bitcoin.rest.address.getTxs(beaconAddress);
78
131
 
79
132
  // If no signals are found, continue
80
133
  if (!beaconSignals || !beaconSignals.length) {
@@ -84,10 +137,11 @@ export class BeaconSignalDiscovery {
84
137
  // Iterate over each signal
85
138
  for (const beaconSignal of beaconSignals) {
86
139
  // Get the last vout in the transaction
87
- const signalVout = beaconSignal.vout.slice(-1)[0];
140
+ const lastSignalVout = beaconSignal.vout.slice(-1)[0];
88
141
 
89
142
  /**
90
- * Look for OP_RETURN in last vout scriptpubkey_asm
143
+ * Decode the signal from the serialized script, not from `scriptpubkey_asm`: the
144
+ * asm rendering is backend-specific, the script is not.
91
145
  * Vout (rest) format:
92
146
  * {
93
147
  * scriptpubkey: '6a20570f177c65e64fb5cf61180b664cdddf09ab76153c2b192e22006e5b22a3917a',
@@ -96,18 +150,24 @@ export class BeaconSignalDiscovery {
96
150
  * value: 0
97
151
  * }
98
152
  */
99
- if(!signalVout) {
153
+ if(!lastSignalVout) {
100
154
  continue;
101
155
  }
102
156
 
103
157
  // A beacon signal output must be exactly `OP_RETURN OP_PUSHBYTES_32 <32-byte hash>`.
104
158
  // Reject any other shape (bare OP_RETURN, wrong push size, non-hex payload) so a
105
159
  // malformed on-chain output cannot masquerade as a phantom signal downstream.
106
- const updateHash = extractOpReturnSignal(signalVout.scriptpubkey_asm);
160
+ const updateHash = extractOpReturnSignalHash(lastSignalVout.scriptpubkey);
107
161
  if(!updateHash) {
108
162
  continue;
109
163
  }
110
164
 
165
+ // The address listing returns inbound payments too, so require the transaction to
166
+ // spend from the beacon before treating its OP_RETURN as a signal.
167
+ if(!await BeaconSignalDiscovery.spendsFromAddress(beaconSignal, beaconAddress, bitcoin)) {
168
+ continue;
169
+ }
170
+
111
171
  // Use the pre-fetched block count instead of calling per-signal
112
172
  const confirmations = currentBlockCount - beaconSignal.status.block_height + 1;
113
173
 
@@ -154,8 +214,16 @@ export class BeaconSignalDiscovery {
154
214
  // Get the current block height once before the loop
155
215
  const targetHeight = await rpc.getBlockCount();
156
216
 
157
- // Hoist the beacon services map before the loop
158
- const beaconServicesMap = BeaconUtils.getBeaconServicesMap(beaconServices);
217
+ /**
218
+ * Hoist the beacon address lookup before the loop, mapping each address to the caller's
219
+ * own service object. The results below are keyed by object identity, so the map has to
220
+ * hold those exact instances rather than copies of them. Addresses are parsed the same
221
+ * way as on the indexer path, so a BIP21 endpoint carrying query parameters resolves to
222
+ * the bare address a spent output reports.
223
+ */
224
+ const beaconServicesMap = new Map<string, BeaconService>(
225
+ beaconServices.map(service => [BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string), service])
226
+ );
159
227
 
160
228
  // Set genesis height
161
229
  let height = 0;
@@ -167,11 +235,50 @@ export class BeaconSignalDiscovery {
167
235
  while (block.height <= targetHeight) {
168
236
  // Iterate over each transaction in the block
169
237
  for (const tx of block.tx) {
170
- // If the txid is a coinbase, continue ...
238
+ // If the txid is a genesis transaction, continue ...
171
239
  if (tx.txid === GENESIS_TX_ID) {
172
240
  continue;
173
241
  }
174
242
 
243
+ /**
244
+ * A Beacon Signal announces its update hash in the last output of the *spending*
245
+ * transaction, so the hash is resolved once per transaction, before the input side
246
+ * is inspected. The output being spent carries a plain locking script and never a
247
+ * signal. A beacon signal output must be exactly
248
+ * `OP_RETURN OP_PUSHBYTES_32 <32-byte hash>`; rejecting any other shape here also
249
+ * keeps the free filter ahead of the prevout lookups below.
250
+ *
251
+ * The signal is decoded from `scriptPubKey.hex`, the same serialized script the
252
+ * indexer path reads as `scriptpubkey`. Core's `asm` renders a data push as bare
253
+ * hex (`OP_RETURN <hash>`) where Esplora names the push opcode
254
+ * (`OP_RETURN OP_PUSHBYTES_32 <hash>`), so an asm-shaped check written against one
255
+ * backend silently discards every signal from the other.
256
+ * Vout (rpc) format:
257
+ * {
258
+ * value: 0,
259
+ * n: 1,
260
+ * scriptPubKey: {
261
+ * asm: "OP_RETURN 7af2be9fcb371dfcc5465a74373d499c11b1f7bba47e0507fce892ea12ec1cd6",
262
+ * desc: "raw(6a207af2be9fcb371dfcc5465a74373d499c11b1f7bba47e0507fce892ea12ec1cd6)#g064zwfr",
263
+ * hex: "6a207af2be9fcb371dfcc5465a74373d499c11b1f7bba47e0507fce892ea12ec1cd6",
264
+ * type: "nulldata",
265
+ * },
266
+ * }
267
+ */
268
+ const lastSignalVout = tx.vout.slice(-1)[0];
269
+ if (!lastSignalVout) {
270
+ continue;
271
+ }
272
+
273
+ const updateHash = extractOpReturnSignalHash(lastSignalVout.scriptPubKey?.hex);
274
+ if (!updateHash) {
275
+ continue;
276
+ }
277
+
278
+ // One transaction is one signal per beacon, however many of that beacon's UTXOs
279
+ // it spends, so track the services already credited with this transaction.
280
+ const signaled = new Set<BeaconService>();
281
+
175
282
  // Iterate over each input in the transaction
176
283
  for (const vin of tx.vin) {
177
284
 
@@ -213,21 +320,13 @@ export class BeaconSignalDiscovery {
213
320
 
214
321
  // Use the hoisted beaconServicesMap instead of rebuilding per-vin
215
322
  const beaconService = beaconServicesMap.get(scriptPubKey.address);
216
- if (!beaconService) {
217
- continue;
218
- }
219
-
220
- // A beacon signal output must be exactly `OP_RETURN OP_PUSHBYTES_32 <32-byte hash>`.
221
- // Reject any other shape so a malformed on-chain output cannot masquerade as a
222
- // phantom signal downstream.
223
- const txVoutScriptPubkeyAsm = prevout.vout[vin.vout].scriptPubKey.asm;
224
- const updateHash = extractOpReturnSignal(txVoutScriptPubkeyAsm);
225
- if(!updateHash) {
323
+ if (!beaconService || signaled.has(beaconService)) {
226
324
  continue;
227
325
  }
326
+ signaled.add(beaconService);
228
327
 
229
328
  // Log the found txid and beacon
230
- console.info(`Tx ${tx.txid} contains beacon service address ${scriptPubKey.address} and OP_RETURN!`, tx);
329
+ console.info(`Tx ${tx.txid} contains beacon address ${scriptPubKey.address}`);
231
330
 
232
331
  // Push the beacon signal object to the beacon signals array for that beacon service
233
332
  beaconServiceSignals.get(beaconService)?.push({
@@ -172,19 +172,6 @@ export class BeaconUtils {
172
172
  return { ...beacon, serviceEndpoint: beacon.serviceEndpoint.replace('bitcoin:', '')};
173
173
  }
174
174
 
175
- /**
176
- * Create a map of address => beaconService with address field.
177
- * @param {Array<BeaconService>} beacons The list of beacon services.
178
- * @returns {Map<string, BeaconService>} A map of address => beaconService.
179
- */
180
- static getBeaconServicesMap(beacons: Array<BeaconService>): Map<string, BeaconService> {
181
- return new Map<string, BeaconService>(
182
- beacons
183
- .map(this.parseBeaconServiceEndpoint)
184
- .map((beacon) => ([beacon.serviceEndpoint as string, beacon]))
185
- );
186
- }
187
-
188
175
  /**
189
176
  * Get the beacon service ids from a list of beacon services.
190
177
  * @param {DidDocument} didDocument The DID Document to extract the services from.
@@ -149,6 +149,32 @@ function isSMTProof(value: unknown): value is SMTProof {
149
149
  && Array.isArray(value.hashes);
150
150
  }
151
151
 
152
+ // ─── verification relationship membership ────────────────────────────────────
153
+
154
+ /**
155
+ * Resolves a verification relationship entry to the absolute DID URL of the
156
+ * method it names, or `undefined` when the entry names no method.
157
+ *
158
+ * An entry is either a reference (a bare fragment such as `#key-0`, or an
159
+ * absolute DID URL) or an embedded verification method object. A bare fragment
160
+ * is relative to the document that carries it, so it is resolved against
161
+ * `documentId` before comparison; every other string is returned unchanged, so
162
+ * a reference naming a different DID can never collapse onto this document's.
163
+ *
164
+ * Malformed entries yield `undefined` rather than a placeholder string, so two
165
+ * unusable values never compare equal to each other.
166
+ *
167
+ * @param {string} documentId The `id` of the DID document carrying the entry.
168
+ * @param {unknown} entry The relationship entry: a reference or an embedded method.
169
+ * @returns {string | undefined} The absolute DID URL, or undefined if unusable.
170
+ */
171
+ function relationshipMethodId(documentId: string, entry: unknown): string | undefined {
172
+ // An embedded method names itself with its `id`; a reference is the id itself.
173
+ const id = isRecord(entry) ? entry.id : entry;
174
+ if(typeof id !== 'string' || id.length === 0) return undefined;
175
+ return id.startsWith('#') ? `${documentId}${id}` : id;
176
+ }
177
+
152
178
  /**
153
179
  * Different possible Resolver states representing phases in the resolution process.
154
180
  */
@@ -601,6 +627,29 @@ export class Resolver {
601
627
  throw new ResolveError('No verificationMethod found in update', INVALID_DID_UPDATE, update);
602
628
  }
603
629
 
630
+ // Spec "Check update.proof": raise INVALID_DID_UPDATE if
631
+ // currentDocument.capabilityInvocation does not contain
632
+ // update.proof.verificationMethod. Locating the method in verificationMethod[] and
633
+ // verifying its signature is not sufficient on its own: a key the controller
634
+ // published only for authentication (or for no relationship at all) must not be
635
+ // able to authorize a DID update. The write path enforces this in DidBtcr2.update();
636
+ // without it here the read path applies an update signed by any key in the document.
637
+ // Checked before the method is located so an unauthorized method always fails with
638
+ // this typed error, whether or not it also appears in verificationMethod[].
639
+ const authorizedMethodId = relationshipMethodId(currentDocument.id, verificationMethodId);
640
+ const authorized = authorizedMethodId !== undefined && currentDocument.capabilityInvocation?.some(
641
+ entry => relationshipMethodId(currentDocument.id, entry) === authorizedMethodId
642
+ );
643
+ if(!authorized) {
644
+ throw new ResolveError(
645
+ 'Invalid update: verificationMethod is not authorized for capabilityInvocation',
646
+ INVALID_DID_UPDATE, {
647
+ verificationMethodId,
648
+ capabilityInvocation : currentDocument.capabilityInvocation
649
+ }
650
+ );
651
+ }
652
+
604
653
  // Get the verificationMethod from the DID Document using the verificationMethodId.
605
654
  const vm = DidBtcr2.getSigningMethod(currentDocument, verificationMethodId);
606
655
 
@@ -7,6 +7,7 @@ import {
7
7
  DidErrorCode,
8
8
  DidVerificationRelationship
9
9
  } from '@web5/dids';
10
+ import { DidDocumentError } from '@did-btcr2/common';
10
11
  import type { RootCapability } from '../core/interfaces.js';
11
12
 
12
13
  /**
@@ -66,10 +67,10 @@ export class Appendix {
66
67
  * Extracts the verification methods from a given DID Document.
67
68
  * @param didDocument The DID Document to extract the verification methods from
68
69
  * @returns An array of DidVerificationMethod objects
69
- * @throws TypeError if the didDocument is not provided
70
+ * @throws {DidDocumentError} if the didDocument is not provided
70
71
  */
71
72
  public static getVerificationMethods(didDocument: DidDocument): DidVerificationMethod[] {
72
- if (!didDocument) throw new TypeError(`Required parameter missing: 'didDocument'`);
73
+ if (!didDocument) throw new DidDocumentError(`Required parameter missing: 'didDocument'`);
73
74
  const verificationMethods: DidVerificationMethod[] = [];
74
75
  // Check the 'verificationMethod' array.
75
76
  verificationMethods.push(...didDocument.verificationMethod?.filter(Appendix.isDidVerificationMethod) ?? []);