@did-btcr2/method 0.55.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +10 -4
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/browser.js +3 -3
  4. package/dist/browser.mjs +3 -3
  5. package/dist/cjs/index.js +172 -66
  6. package/dist/esm/core/beacon/beacon.js +11 -1
  7. package/dist/esm/core/beacon/beacon.js.map +1 -1
  8. package/dist/esm/core/beacon/cas-beacon.js +7 -6
  9. package/dist/esm/core/beacon/cas-beacon.js.map +1 -1
  10. package/dist/esm/core/beacon/factory.js +6 -4
  11. package/dist/esm/core/beacon/factory.js.map +1 -1
  12. package/dist/esm/core/beacon/signal-discovery.js +124 -38
  13. package/dist/esm/core/beacon/signal-discovery.js.map +1 -1
  14. package/dist/esm/core/beacon/singleton-beacon.js +2 -2
  15. package/dist/esm/core/beacon/singleton-beacon.js.map +1 -1
  16. package/dist/esm/core/beacon/smt-beacon.js +7 -6
  17. package/dist/esm/core/beacon/smt-beacon.js.map +1 -1
  18. package/dist/esm/core/beacon/utils.js +0 -10
  19. package/dist/esm/core/beacon/utils.js.map +1 -1
  20. package/dist/esm/core/did-sender-resolver.js +8 -2
  21. package/dist/esm/core/did-sender-resolver.js.map +1 -1
  22. package/dist/esm/core/resolver.js +20 -1
  23. package/dist/esm/core/resolver.js.map +1 -1
  24. package/dist/esm/core/updater.js +17 -4
  25. package/dist/esm/core/updater.js.map +1 -1
  26. package/dist/esm/did-btcr2.js +23 -7
  27. package/dist/esm/did-btcr2.js.map +1 -1
  28. package/dist/esm/utils/appendix.js +36 -5
  29. package/dist/esm/utils/appendix.js.map +1 -1
  30. package/dist/types/core/beacon/beacon.d.ts +10 -1
  31. package/dist/types/core/beacon/beacon.d.ts.map +1 -1
  32. package/dist/types/core/beacon/cas-beacon.d.ts +2 -1
  33. package/dist/types/core/beacon/cas-beacon.d.ts.map +1 -1
  34. package/dist/types/core/beacon/factory.d.ts +3 -1
  35. package/dist/types/core/beacon/factory.d.ts.map +1 -1
  36. package/dist/types/core/beacon/signal-discovery.d.ts +36 -9
  37. package/dist/types/core/beacon/signal-discovery.d.ts.map +1 -1
  38. package/dist/types/core/beacon/singleton-beacon.d.ts +1 -1
  39. package/dist/types/core/beacon/singleton-beacon.d.ts.map +1 -1
  40. package/dist/types/core/beacon/smt-beacon.d.ts +2 -1
  41. package/dist/types/core/beacon/smt-beacon.d.ts.map +1 -1
  42. package/dist/types/core/beacon/utils.d.ts +0 -6
  43. package/dist/types/core/beacon/utils.d.ts.map +1 -1
  44. package/dist/types/core/did-sender-resolver.d.ts.map +1 -1
  45. package/dist/types/core/interfaces.d.ts.map +1 -1
  46. package/dist/types/core/resolver.d.ts.map +1 -1
  47. package/dist/types/core/updater.d.ts +11 -4
  48. package/dist/types/core/updater.d.ts.map +1 -1
  49. package/dist/types/did-btcr2.d.ts.map +1 -1
  50. package/dist/types/utils/appendix.d.ts +30 -4
  51. package/dist/types/utils/appendix.d.ts.map +1 -1
  52. package/package.json +5 -5
  53. package/src/core/beacon/beacon.ts +12 -1
  54. package/src/core/beacon/cas-beacon.ts +7 -6
  55. package/src/core/beacon/factory.ts +6 -4
  56. package/src/core/beacon/interfaces.ts +1 -1
  57. package/src/core/beacon/signal-discovery.ts +141 -42
  58. package/src/core/beacon/singleton-beacon.ts +2 -2
  59. package/src/core/beacon/smt-beacon.ts +7 -6
  60. package/src/core/beacon/utils.ts +0 -13
  61. package/src/core/did-sender-resolver.ts +10 -2
  62. package/src/core/interfaces.ts +1 -0
  63. package/src/core/resolver.ts +33 -2
  64. package/src/core/updater.ts +25 -5
  65. package/src/did-btcr2.ts +28 -8
  66. package/src/utils/appendix.ts +37 -5
@@ -544,8 +544,19 @@ export abstract class SinglePartyBeacon {
544
544
  */
545
545
  readonly service: BeaconService;
546
546
 
547
- constructor(service: BeaconService) {
547
+ /**
548
+ * The absolute did:btcr2 identifier this beacon instance serves.
549
+ *
550
+ * Injected by the caller rather than recovered from {@link service}. A beacon
551
+ * service `id` is permitted to be a relative DID URL (`#beacon-1`), which
552
+ * carries no DID to strip a fragment from, so deriving the subject from it is
553
+ * only correct for the absolute spelling.
554
+ */
555
+ readonly did: string;
556
+
557
+ constructor(service: BeaconService, did: string) {
548
558
  this.service = service;
559
+ this.did = did;
549
560
  }
550
561
 
551
562
  /**
@@ -57,9 +57,10 @@ export class CASBeacon extends SinglePartyBeacon {
57
57
  /**
58
58
  * Creates an instance of CASBeacon.
59
59
  * @param {BeaconService} service The service of the Beacon.
60
+ * @param {string} did The absolute did:btcr2 identifier this beacon serves.
60
61
  */
61
- constructor(service: BeaconService) {
62
- super({ ...service, type: 'CASBeacon' });
62
+ constructor(service: BeaconService, did: string) {
63
+ super({ ...service, type: 'CASBeacon' }, did);
63
64
  }
64
65
 
65
66
  /**
@@ -82,8 +83,8 @@ export class CASBeacon extends SinglePartyBeacon {
82
83
  const updates = new Array<[SignedBTCR2Update, BlockMetadata]>();
83
84
  const needs = new Array<DataNeed>();
84
85
 
85
- // Extract the DID from the beacon service id (strip the #fragment)
86
- const did = this.service.id.split('#')[0];
86
+ // The DID under resolution keys this beacon's announcement entry.
87
+ const did = this.did;
87
88
 
88
89
  for(const signal of signals) {
89
90
  // Signal bytes are hex, matches hex-keyed sidecar maps directly
@@ -160,8 +161,8 @@ export class CASBeacon extends SinglePartyBeacon {
160
161
  bitcoin: BitcoinConnection,
161
162
  options?: CASBroadcastOptions
162
163
  ): Promise<BroadcastResult> {
163
- // Extract the DID from the beacon service id (strip the #fragment)
164
- const did = this.service.id.split('#')[0];
164
+ // The DID this beacon serves keys its announcement entry.
165
+ const did = this.did;
165
166
 
166
167
  // Hash the signed update (base64urlnopad for the CAS Announcement entry per spec)
167
168
  const updateHash = canonicalHash(signedUpdate);
@@ -14,16 +14,18 @@ export class BeaconFactory {
14
14
  /**
15
15
  * Establish a Beacon instance based on the provided service and optional sidecar data.
16
16
  * @param {BeaconService} service The beacon service configuration.
17
+ * @param {string} did The absolute did:btcr2 identifier the beacon serves. Supplied
18
+ * by the caller because a beacon service `id` may be a relative DID URL.
17
19
  * @returns {SinglePartyBeacon} The established Beacon instance.
18
20
  */
19
- static establish(service: BeaconService): SinglePartyBeacon {
21
+ static establish(service: BeaconService, did: string): SinglePartyBeacon {
20
22
  switch (service.type) {
21
23
  case 'SingletonBeacon':
22
- return new SingletonBeacon(service);
24
+ return new SingletonBeacon(service, did);
23
25
  case 'CASBeacon':
24
- return new CASBeacon(service);
26
+ return new CASBeacon(service, did);
25
27
  case 'SMTBeacon':
26
- return new SMTBeacon(service);
28
+ return new SMTBeacon(service, did);
27
29
  default:
28
30
  throw new MethodError('Invalid Beacon Type', 'INVALID_BEACON_ERROR', service);
29
31
  }
@@ -76,4 +76,4 @@ export interface BeaconSignal {
76
76
  *
77
77
  * @param announcement The CAS Announcement object (DID to update hash mapping).
78
78
  */
79
- export type CasPublishFn = (announcement: Record<string, string>) => Promise<void>;
79
+ export type CasPublishFn = (announcement: Record<string, string>) => Promise<void>;
@@ -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({
@@ -20,8 +20,8 @@ export class SingletonBeacon extends SinglePartyBeacon {
20
20
  * Creates an instance of SingletonBeacon.
21
21
  * @param {BeaconService} service The BeaconService object representing the funded beacon to announce the update to.
22
22
  */
23
- constructor(service: BeaconService) {
24
- super({ ...service, type: 'SingletonBeacon' });
23
+ constructor(service: BeaconService, did: string) {
24
+ super({ ...service, type: 'SingletonBeacon' }, did);
25
25
  }
26
26
 
27
27
  /**
@@ -27,9 +27,10 @@ export class SMTBeacon extends SinglePartyBeacon {
27
27
  /**
28
28
  * Creates an instance of SMTBeacon.
29
29
  * @param {BeaconService} service The Beacon service.
30
+ * @param {string} did The absolute did:btcr2 identifier this beacon serves.
30
31
  */
31
- constructor(service: BeaconService) {
32
- super({ ...service, type: 'SMTBeacon' });
32
+ constructor(service: BeaconService, did: string) {
33
+ super({ ...service, type: 'SMTBeacon' }, did);
33
34
  }
34
35
 
35
36
  /**
@@ -52,8 +53,8 @@ export class SMTBeacon extends SinglePartyBeacon {
52
53
  const updates = new Array<[SignedBTCR2Update, BlockMetadata]>();
53
54
  const needs = new Array<DataNeed>();
54
55
 
55
- // Extract the DID from the beacon service id (strip the #fragment)
56
- const did = this.service.id.split('#')[0];
56
+ // The DID under resolution keys this beacon's leaf index.
57
+ const did = this.did;
57
58
 
58
59
  for(const signal of signals) {
59
60
  // Signal bytes are the hex-encoded SMT root hash; smtMap is keyed by proof.id (also hex)
@@ -144,8 +145,8 @@ export class SMTBeacon extends SinglePartyBeacon {
144
145
  bitcoin: BitcoinConnection,
145
146
  options?: BroadcastOptions
146
147
  ): Promise<BroadcastResult> {
147
- // Extract the DID from the beacon service id (strip the #fragment)
148
- const did = this.service.id.split('#')[0];
148
+ // The DID keys this beacon's leaf index in the tree.
149
+ const did = this.did;
149
150
 
150
151
  // Build a single-entry SMT from the signed update
151
152
  const canonicalBytes = new TextEncoder().encode(canonicalize(signedUpdate));
@@ -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.
@@ -1,6 +1,7 @@
1
1
  import { DidDocumentError, INVALID_DID_DOCUMENT } from '@did-btcr2/common';
2
2
  import { SchnorrMultikey } from '@did-btcr2/cryptosuite';
3
3
  import { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
4
+ import { Appendix } from '../utils/appendix.js';
4
5
  import type { DidDocument, DidVerificationMethod } from '../utils/did-document.js';
5
6
  import { Identifier } from './identifier.js';
6
7
  import { Resolver } from './resolver.js';
@@ -40,9 +41,16 @@ export function getAggregationCommunicationKey(
40
41
  // Resolve capabilityInvocation[0] to a verification method: a string reference is
41
42
  // dereferenced by id against verificationMethod; an embedded method is used directly.
42
43
  // A local id lookup is used rather than getSigningMethod, which defaults to #initialKey
43
- // and does not resolve embedded methods.
44
+ // and does not resolve embedded methods. Reference and method id are each resolved to an
45
+ // absolute DID URL first, because either may be spelled as a relative DID URL; an
46
+ // unusable reference resolves to nothing rather than to the first unusable method id.
47
+ const invocationId = Appendix.absoluteDidUrl(invocation, document.id);
44
48
  const vm: DidVerificationMethod | undefined = typeof invocation === 'string'
45
- ? document.verificationMethod?.find(method => method.id === invocation)
49
+ ? (invocationId === undefined
50
+ ? undefined
51
+ : document.verificationMethod?.find(
52
+ method => Appendix.absoluteDidUrl(method.id, document.id) === invocationId
53
+ ))
46
54
  : invocation;
47
55
 
48
56
  if(!vm) {
@@ -46,6 +46,7 @@ export interface ResolutionOptions extends DidResolutionOptions {
46
46
  * document is well-formed, the resolver simply stopped at the caller's limit.
47
47
  */
48
48
  maxDiscoveryRounds?: number;
49
+
49
50
  }
50
51
 
51
52
  /**
@@ -225,6 +225,7 @@ export class Resolver {
225
225
  /** Count of beacon-discovery passes driven by updates adding new beacon services. */
226
226
  #discoveryRounds = 0;
227
227
 
228
+
228
229
  /**
229
230
  * @internal Use {@link DidBtcr2.resolve} to create instances.
230
231
  */
@@ -232,7 +233,12 @@ export class Resolver {
232
233
  didComponents: DidComponents,
233
234
  sidecarData: SidecarData,
234
235
  currentDocument: DidDocument | null,
235
- options?: { versionId?: string; versionTime?: string; genesisDocument?: object; maxDiscoveryRounds?: number }
236
+ options?: {
237
+ versionId?: string;
238
+ versionTime?: string;
239
+ genesisDocument?: object;
240
+ maxDiscoveryRounds?: number;
241
+ }
236
242
  ) {
237
243
  this.#didComponents = didComponents;
238
244
  this.#sidecarData = sidecarData;
@@ -601,6 +607,29 @@ export class Resolver {
601
607
  throw new ResolveError('No verificationMethod found in update', INVALID_DID_UPDATE, update);
602
608
  }
603
609
 
610
+ // Spec "Check update.proof": raise INVALID_DID_UPDATE if
611
+ // currentDocument.capabilityInvocation does not contain
612
+ // update.proof.verificationMethod. Locating the method in verificationMethod[] and
613
+ // verifying its signature is not sufficient on its own: a key the controller
614
+ // published only for authentication (or for no relationship at all) must not be
615
+ // able to authorize a DID update. The write path enforces this in DidBtcr2.update();
616
+ // without it here the read path applies an update signed by any key in the document.
617
+ // Checked before the method is located so an unauthorized method always fails with
618
+ // this typed error, whether or not it also appears in verificationMethod[].
619
+ const authorizedMethodId = Appendix.relationshipMethodId(verificationMethodId, currentDocument.id);
620
+ const authorized = authorizedMethodId !== undefined && currentDocument.capabilityInvocation?.some(
621
+ entry => Appendix.relationshipMethodId(entry, currentDocument.id) === authorizedMethodId
622
+ );
623
+ if(!authorized) {
624
+ throw new ResolveError(
625
+ 'Invalid update: verificationMethod is not authorized for capabilityInvocation',
626
+ INVALID_DID_UPDATE, {
627
+ verificationMethodId,
628
+ capabilityInvocation : currentDocument.capabilityInvocation
629
+ }
630
+ );
631
+ }
632
+
604
633
  // Get the verificationMethod from the DID Document using the verificationMethodId.
605
634
  const vm = DidBtcr2.getSigningMethod(currentDocument, verificationMethodId);
606
635
 
@@ -724,7 +753,9 @@ export class Resolver {
724
753
  if(this.#processedServices.has(service.id) || !signals.length) continue;
725
754
 
726
755
  // Establish a typed beacon and process its signals
727
- const beacon = BeaconFactory.establish(service);
756
+ // The beacon is bound to the DID under resolution: a beacon service
757
+ // `id` may be a relative DID URL, so it cannot supply the subject.
758
+ const beacon = BeaconFactory.establish(service, this.#currentDocument!.id);
728
759
  const result = beacon.processSignals(signals, this.#sidecarData);
729
760
 
730
761
  if(result.needs.length > 0) {
@@ -58,8 +58,8 @@ export interface FundingProof {
58
58
  * The updater needs the caller to broadcast the signed update via the beacon.
59
59
  *
60
60
  * The caller decides how: for single-party beacons, call
61
- * `Updater.announce(beaconService, signedUpdate, signer, bitcoin, options?)` or
62
- * `BeaconFactory.establish(beaconService).broadcastSignal(...)`. For multi-party
61
+ * `Updater.announce(beaconService, did, signedUpdate, signer, bitcoin, options?)` or
62
+ * `BeaconFactory.establish(beaconService, did).broadcastSignal(...)`. For multi-party
63
63
  * aggregate beacons, hand off to the aggregation protocol.
64
64
  *
65
65
  * Both single-party paths return a `BroadcastResult`; capture it. Beyond the txid,
@@ -76,6 +76,11 @@ export interface NeedBroadcast {
76
76
  readonly beaconService: BeaconService;
77
77
  /** The signed update ready for broadcast. */
78
78
  readonly signedUpdate: SignedBTCR2Update;
79
+ /**
80
+ * The DID being updated. Supplied because a beacon service `id` may be a
81
+ * relative DID URL and so cannot be used to recover the subject.
82
+ */
83
+ readonly did: string;
79
84
  }
80
85
 
81
86
  /** Discriminated union of all data needs the updater may request from the caller. */
@@ -148,7 +153,7 @@ export interface UpdaterParams {
148
153
  * // Capture the BroadcastResult: broadcast.txid, plus broadcast.announcement
149
154
  * // (CAS beacons) and broadcast.proof (SMT beacons; must be retained, the
150
155
  * // proof's nonce exists nowhere else).
151
- * const broadcast = await Updater.announce(need.beaconService, need.signedUpdate, signer, bitcoin);
156
+ * const broadcast = await Updater.announce(need.beaconService, need.did, need.signedUpdate, signer, bitcoin);
152
157
  * updater.provide(need);
153
158
  * break;
154
159
  * }
@@ -278,6 +283,12 @@ export class Updater {
278
283
  const id = verificationMethod.id.slice(hashIdx);
279
284
  const multikey = SchnorrMultikey.fromSigner(id, controller, signer);
280
285
 
286
+ // The absolute spelling of the method id, for the proof to name it by. `hashIdx === 0`
287
+ // means the document writes this method's own id as a relative DID URL, which per DID
288
+ // Core denotes that fragment of the document's `id`: the same rule as
289
+ // Appendix.absoluteDidUrl, whose `undefined` case the fragment check above excludes.
290
+ const absoluteMethodId = hashIdx === 0 ? `${did}${id}` : verificationMethod.id;
291
+
281
292
  // Fail fast if the signer's public key is not the key published in the named
282
293
  // verification method. Signing with the wrong key yields a Data Integrity
283
294
  // proof that cannot verify against the method, so the resulting on-chain
@@ -308,7 +319,12 @@ export class Updater {
308
319
  ],
309
320
  cryptosuite : 'bip340-jcs-2025',
310
321
  type : 'DataIntegrityProof',
311
- verificationMethod : verificationMethod.id,
322
+ // The proof names the signing method by absolute DID URL, even when the document
323
+ // spells that method's own id relatively: a proof travels apart from the document
324
+ // that defines the method, so a bare `#initialKey` in it resolves against nothing.
325
+ // For a document that already spells its ids absolutely this is the id unchanged,
326
+ // so proofs over such documents are byte-identical to before.
327
+ verificationMethod : absoluteMethodId,
312
328
  proofPurpose : 'capabilityInvocation',
313
329
  capability : `urn:zcap:root:${encodeURIComponent(did)}`,
314
330
  capabilityAction : 'Write',
@@ -323,6 +339,8 @@ export class Updater {
323
339
  * Announces a signed update to the Bitcoin blockchain via the specified beacon.
324
340
  *
325
341
  * @param {BeaconService} beaconService The beacon service to broadcast through.
342
+ * @param {string} did The DID being updated. Required because a beacon service
343
+ * `id` may be a relative DID URL and so cannot supply the subject.
326
344
  * @param {SignedBTCR2Update} update The signed update to announce.
327
345
  * @param {Signer} signer Signer that produces the ECDSA signature for the Bitcoin transaction.
328
346
  * @param {BitcoinConnection} bitcoin The Bitcoin network connection.
@@ -334,12 +352,13 @@ export class Updater {
334
352
  */
335
353
  static async announce(
336
354
  beaconService: BeaconService,
355
+ did: string,
337
356
  update: SignedBTCR2Update,
338
357
  signer: Signer,
339
358
  bitcoin: BitcoinConnection,
340
359
  options?: CASBroadcastOptions
341
360
  ): Promise<BroadcastResult> {
342
- const beacon = BeaconFactory.establish(beaconService);
361
+ const beacon = BeaconFactory.establish(beaconService, did);
343
362
  return beacon.broadcastSignal(update, signer, bitcoin, options);
344
363
  }
345
364
 
@@ -406,6 +425,7 @@ export class Updater {
406
425
  kind : 'NeedBroadcast',
407
426
  beaconService : this.#beaconService,
408
427
  signedUpdate : this.#state.signedUpdate,
428
+ did : this.#sourceDocument.id,
409
429
  }],
410
430
  };
411
431
  }