@did-btcr2/method 0.49.0 → 0.51.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@did-btcr2/method",
3
- "version": "0.49.0",
3
+ "version": "0.51.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",
@@ -66,49 +66,35 @@
66
66
  "dependencies": {
67
67
  "@noble/curves": "^1.9.7",
68
68
  "@noble/hashes": "^1.8.0",
69
- "@noble/secp256k1": "^2.3.0",
70
69
  "@scure/base": "^1.2.6",
71
- "@scure/bip32": "^1.7.0",
72
- "@scure/bip39": "^1.6.0",
73
70
  "@scure/btc-signer": "^1.8.1",
74
- "@web5/common": "^1.1.0",
75
- "@web5/crypto": "^1.0.6",
76
71
  "@web5/dids": "^1.2.0",
77
- "canonicalize": "^2.1.0",
78
- "dotenv": "^16.6.1",
79
- "nostr-tools": "^2.23.3",
80
72
  "@did-btcr2/bitcoin": "^0.8.0",
81
- "@did-btcr2/common": "^9.1.0",
82
- "@did-btcr2/cryptosuite": "^9.0.0",
73
+ "@did-btcr2/smt": "^0.3.0",
83
74
  "@did-btcr2/keypair": "^0.13.1",
84
- "@did-btcr2/smt": "^0.3.0"
75
+ "@did-btcr2/common": "^9.1.0",
76
+ "@did-btcr2/cryptosuite": "^9.0.0"
85
77
  },
86
78
  "devDependencies": {
87
79
  "@eslint/js": "^9.39.4",
88
80
  "@types/chai": "^5.2.3",
89
- "@types/chai-as-promised": "^8.0.2",
90
- "@types/eslint": "^9.6.1",
91
81
  "@types/mocha": "^10.0.10",
92
82
  "@types/node": "^25.5.0",
93
83
  "@typescript-eslint/eslint-plugin": "^8.58.0",
94
84
  "@typescript-eslint/parser": "^8.58.0",
95
85
  "c8": "^10.1.3",
96
86
  "chai": "^5.3.3",
97
- "chai-as-promised": "^8.0.2",
98
87
  "commander": "^13.1.0",
99
88
  "esbuild": "^0.24.2",
100
89
  "eslint": "^9.39.4",
101
90
  "eslint-plugin-mocha": "^10.5.0",
102
91
  "globals": "^15.15.0",
103
92
  "mocha": "^10.8.2",
104
- "mocha-junit-reporter": "^2.2.1",
105
93
  "multiformats": "^13.4.2",
106
94
  "node-stdlib-browser": "^1.3.1",
107
95
  "rimraf": "^6.1.3",
108
96
  "tsup": "^8.5.1",
109
- "typedoc-plugin-markdown": "^4.11.0",
110
- "typescript": "^5.9.3",
111
- "typescript-eslint": "^8.58.0"
97
+ "typescript": "^5.9.3"
112
98
  },
113
99
  "scripts": {
114
100
  "clean": "rimraf dist coverage tests/compiled",
@@ -229,27 +229,97 @@ export function opReturnScript(signalBytes: Uint8Array): Uint8Array {
229
229
  }
230
230
 
231
231
  /**
232
- * Fetch the most recent confirmed UTXO at `bitcoinAddress` + the raw bytes of its
233
- * parent transaction (needed by PSBT inputs). Throws if unfunded.
232
+ * Minimum value (sats) a beacon UTXO must exceed for {@link selectSpendableUtxo} to
233
+ * treat it as spendable. This is a fixed, conservative, script-kind-agnostic floor:
234
+ * an output at or below it is too small to be worth spending, so selection discards
235
+ * it in favor of a larger confirmed UTXO. Keeping the floor a constant (rather than
236
+ * deriving it from a fee estimate) keeps selection pure and fee-estimator-independent.
237
+ *
238
+ * The floor is a coarse pre-filter, not the fee-coverage boundary: whether a selected
239
+ * UTXO actually covers the transaction fee is a separate check, enforced against the
240
+ * live {@link FeeEstimator} by the builders' `value <= feeSats` guard
241
+ * ({@link SinglePartyBeacon.buildSinglePartyTx} and {@link buildAggregationBeaconTx}).
242
+ * At the default 5 sat/vB rate that fee (roughly 775 to 1200 sats across the three
243
+ * script kinds) sits above this floor, so a UTXO can clear the dust filter and still
244
+ * be rejected as insufficient; conversely, at a very low fee rate an output near the
245
+ * floor could cover the fee. The floor's job is only to skip trivially small inputs.
246
+ *
247
+ * The value is the standard Bitcoin Core P2PKH dust threshold, the largest of the
248
+ * three singleton beacon script kinds (P2PKH 546, P2TR 330, P2WPKH 294 per
249
+ * {@link DUST_LIMIT_SATS}): a UTXO above it is non-dust under any beacon address kind.
250
+ * Distinct from {@link DUST_LIMIT_SATS}, which sizes the outgoing change output by
251
+ * kind; this bounds the incoming UTXO chosen to fund the transaction.
234
252
  */
235
- async function fetchSpendableUtxo(
236
- bitcoinAddress: string,
237
- bitcoin: BitcoinConnection,
238
- ): Promise<{ utxo: AddressUtxo; prevTxBytes: Uint8Array }> {
239
- const utxos = await bitcoin.rest.address.getUtxos(bitcoinAddress);
253
+ export const SPENDABLE_DUST_LIMIT_SATS = 546;
254
+
255
+ /**
256
+ * Deterministic ordering for spendable UTXO selection: deepest first (ascending
257
+ * block height, so the most-confirmed UTXO sorts first), tie-broken by `txid` then
258
+ * `vout`. The tie-break makes the winner independent of the order the REST API
259
+ * returns UTXOs in, so retries and independent resolvers converge on the same input.
260
+ */
261
+ function byDepthThenId(a: AddressUtxo, b: AddressUtxo): number {
262
+ if(a.status.block_height !== b.status.block_height) {
263
+ return a.status.block_height - b.status.block_height;
264
+ }
265
+ const txidOrder = a.txid.localeCompare(b.txid);
266
+ return txidOrder !== 0 ? txidOrder : a.vout - b.vout;
267
+ }
268
+
269
+ /**
270
+ * Select the beacon UTXO to fund a signal transaction from the set of UTXOs at a
271
+ * beacon address. Pure and deterministic: the same address state always yields the
272
+ * same input, across broadcast retries and across independent resolvers.
273
+ *
274
+ * Filters to confirmed UTXOs (an unconfirmed input is reorg- and RBF-unsafe: a
275
+ * signal built on it can be orphaned or double-spent before it confirms), then drops
276
+ * dust at or below {@link SPENDABLE_DUST_LIMIT_SATS}, then picks the deepest via
277
+ * {@link byDepthThenId}. The did:btcr2 spec does not mandate a selection rule or a
278
+ * confirmation depth (only the security considerations favor deeper confirmations),
279
+ * so this is an implementation policy: prefer safety and reproducibility over
280
+ * spending the newest or largest output.
281
+ *
282
+ * @param utxos UTXOs reported at the beacon address.
283
+ * @param address Beacon address, used only to annotate thrown errors.
284
+ * @returns The confirmed, non-dust, deepest UTXO.
285
+ * @throws {BeaconError} `UNFUNDED_BEACON_ADDRESS` when no UTXOs exist at all;
286
+ * `NO_SPENDABLE_BEACON_UTXO` when UTXOs exist but none are both confirmed and above
287
+ * the dust limit (the message distinguishes all-unconfirmed from all-dust).
288
+ */
289
+ export function selectSpendableUtxo(utxos: Array<AddressUtxo>, address?: string): AddressUtxo {
240
290
  if(!utxos.length) {
241
291
  throw new BeaconError(
242
292
  'No UTXOs found, please fund address!',
243
- 'UNFUNDED_BEACON_ADDRESS', { address: bitcoinAddress }
293
+ 'UNFUNDED_BEACON_ADDRESS', { address }
244
294
  );
245
295
  }
246
- const utxo = utxos.sort((a, b) => b.status.block_height - a.status.block_height).shift();
247
- if(!utxo) {
296
+ const confirmed = utxos.filter(utxo => utxo.status.confirmed === true);
297
+ const spendable = confirmed.filter(utxo => utxo.value > SPENDABLE_DUST_LIMIT_SATS);
298
+ if(!spendable.length) {
299
+ const reason = confirmed.length === 0
300
+ ? `all ${utxos.length} UTXO(s) are unconfirmed`
301
+ : `all ${confirmed.length} confirmed UTXO(s) are at or below the ${SPENDABLE_DUST_LIMIT_SATS}-sat dust limit`;
248
302
  throw new BeaconError(
249
- 'Beacon bitcoin address unfunded or utxos unconfirmed.',
250
- 'UNFUNDED_BEACON_ADDRESS', { address: bitcoinAddress }
303
+ `No spendable UTXO at beacon address: ${reason}.`,
304
+ 'NO_SPENDABLE_BEACON_UTXO',
305
+ { address, total: utxos.length, confirmed: confirmed.length, dustLimit: SPENDABLE_DUST_LIMIT_SATS }
251
306
  );
252
307
  }
308
+ return [ ...spendable ].sort(byDepthThenId)[0]!;
309
+ }
310
+
311
+ /**
312
+ * Fetch the deepest confirmed, non-dust spendable UTXO at `bitcoinAddress` plus the
313
+ * raw bytes of its parent transaction (needed by PSBT inputs). Selection is delegated
314
+ * to {@link selectSpendableUtxo}; throws {@link BeaconError} when the address is
315
+ * unfunded or has no confirmed, non-dust UTXO.
316
+ */
317
+ async function fetchSpendableUtxo(
318
+ bitcoinAddress: string,
319
+ bitcoin: BitcoinConnection,
320
+ ): Promise<{ utxo: AddressUtxo; prevTxBytes: Uint8Array }> {
321
+ const utxos = await bitcoin.rest.address.getUtxos(bitcoinAddress);
322
+ const utxo = selectSpendableUtxo(utxos, bitcoinAddress);
253
323
  const prevTxHex = await bitcoin.rest.transaction.getHex(utxo.txid);
254
324
  return { utxo, prevTxBytes: hexToBytes(prevTxHex) };
255
325
  }
@@ -1,25 +1,106 @@
1
+ import { DidDocumentError, INVALID_DID_DOCUMENT } from '@did-btcr2/common';
2
+ import { SchnorrMultikey } from '@did-btcr2/cryptosuite';
1
3
  import { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
4
+ import type { DidDocument, DidVerificationMethod } from '../utils/did-document.js';
2
5
  import { Identifier } from './identifier.js';
6
+ import { Resolver } from './resolver.js';
7
+
8
+ /**
9
+ * Derive the aggregation communication public key from a resolved did:btcr2 DID Document.
10
+ *
11
+ * The communication key is the verification method referenced by
12
+ * `capabilityInvocation[0]`, resolved to its public key. This is the exact relationship
13
+ * the method already enforces for DID updates (construct and sign require the signing
14
+ * method to be in `capabilityInvocation`, the update proof is built and verified with
15
+ * `proofPurpose: 'capabilityInvocation'`), so binding the transport communication key to
16
+ * it yields the invariant "transport-authenticated as D implies authorized to update D."
17
+ * For a KEY (`k1`) document the single deterministic key is already the sole
18
+ * `capabilityInvocation` entry, so this is a no-op for KEY DIDs.
19
+ *
20
+ * There is deliberately no `verificationMethod[0]` fallback: a document without
21
+ * `capabilityInvocation` cannot be updated at all, so it is useless for aggregation and is
22
+ * rejected here rather than bound to an unrelated key.
23
+ *
24
+ * @param {DidDocument} document The resolved DID Document (placeholder id already replaced).
25
+ * @returns {CompressedSecp256k1PublicKey} The compressed public key of the communication method.
26
+ * @throws {DidDocumentError} If `capabilityInvocation` is absent or its first entry does not
27
+ * resolve to a verification method in the document.
28
+ */
29
+ export function getAggregationCommunicationKey(
30
+ document: DidDocument,
31
+ ): CompressedSecp256k1PublicKey {
32
+ const invocation = document.capabilityInvocation?.[0];
33
+ if(invocation === undefined) {
34
+ throw new DidDocumentError(
35
+ 'Cannot derive aggregation communication key: capabilityInvocation is absent',
36
+ INVALID_DID_DOCUMENT, { id: document.id }
37
+ );
38
+ }
39
+
40
+ // Resolve capabilityInvocation[0] to a verification method: a string reference is
41
+ // dereferenced by id against verificationMethod; an embedded method is used directly.
42
+ // A local id lookup is used rather than getSigningMethod, which defaults to #initialKey
43
+ // and does not resolve embedded methods.
44
+ const vm: DidVerificationMethod | undefined = typeof invocation === 'string'
45
+ ? document.verificationMethod?.find(method => method.id === invocation)
46
+ : invocation;
47
+
48
+ if(!vm) {
49
+ throw new DidDocumentError(
50
+ `Cannot derive aggregation communication key: capabilityInvocation[0] "${invocation}" `
51
+ + 'does not resolve to a verification method',
52
+ INVALID_DID_DOCUMENT, { id: document.id, invocation }
53
+ );
54
+ }
55
+
56
+ return SchnorrMultikey.fromVerificationMethod(vm).publicKey;
57
+ }
3
58
 
4
59
  /**
5
60
  * Resolve a did:btcr2 sender's communication public key from its DID, for the
6
- * aggregation HTTP transport's `resolveSenderPk` option: a KEY identifier decodes to
7
- * its genesis public key. The aggregation transport is DID-method-agnostic and does
8
- * not name `Identifier`; method supplies this resolver when it wires the transport so
9
- * a sender that is not a pre-registered peer can still be authenticated from its DID.
61
+ * aggregation HTTP transport's `resolveSenderPk` option.
10
62
  *
11
- * @param did The sender's DID.
12
- * @returns The sender's compressed public key, or `undefined` when the DID is not a
13
- * decodable did:btcr2 KEY identifier (resolution then falls back to registered peers).
63
+ * A KEY (`k1`) identifier decodes directly to its genesis public key: the DID string is
64
+ * the key. An EXTERNAL (`x1`) identifier is a commitment to the hash of a genesis
65
+ * document, so there is no key in the DID string. When the genesis document is supplied
66
+ * in-band (via `opts.genesisDocument`), it is self-verifying against the DID: `Resolver`'s
67
+ * external path recomputes its canonical hash, compares it to the identifier's genesis
68
+ * bytes (throwing on mismatch), and resolves it, after which the communication key is
69
+ * derived from `capabilityInvocation[0]` ({@link getAggregationCommunicationKey}). Without
70
+ * a genesis document, an `x1` DID still resolves to `undefined`, so callers that pass no
71
+ * second argument behave exactly as before.
72
+ *
73
+ * The aggregation transport is DID-method-agnostic and does not name `Identifier`; method
74
+ * supplies this resolver when it wires the transport so a sender that is not a
75
+ * pre-registered peer can still be authenticated from its DID.
76
+ *
77
+ * @param {string} did The sender's DID.
78
+ * @param {object} [opts] Optional resolution inputs.
79
+ * @param {object} [opts.genesisDocument] The `x1` sender's genesis document, carried in-band
80
+ * on the bootstrap opt-in. Ignored for KEY identifiers.
81
+ * @returns {CompressedSecp256k1PublicKey | undefined} The sender's compressed public key, or
82
+ * `undefined` when the DID is not a decodable did:btcr2 identifier, is an `x1` identifier
83
+ * with no (or a non-matching) genesis document, or has no usable communication key.
14
84
  */
15
- export function resolveBtcr2SenderPk(did: string): CompressedSecp256k1PublicKey | undefined {
85
+ export function resolveBtcr2SenderPk(
86
+ did: string,
87
+ opts?: { genesisDocument?: object },
88
+ ): CompressedSecp256k1PublicKey | undefined {
16
89
  try {
17
90
  const components = Identifier.decode(did);
18
91
  if(components.idType === 'KEY') {
19
92
  return new CompressedSecp256k1PublicKey(components.genesisBytes);
20
93
  }
94
+ // EXTERNAL (x1): the DID commits to the hash of a genesis document. With the genesis
95
+ // supplied in-band, verify it hashes to the DID and derive the communication key from
96
+ // it; without it, there is no key to return.
97
+ if(opts?.genesisDocument) {
98
+ const document = Resolver.external(components, opts.genesisDocument);
99
+ return getAggregationCommunicationKey(document);
100
+ }
21
101
  } catch {
22
- // Not a decodable did:btcr2 KEY identifier.
102
+ // Not a decodable did:btcr2 identifier, the genesis does not hash to the DID, or the
103
+ // document has no usable capabilityInvocation communication key.
23
104
  }
24
105
  return undefined;
25
106
  }