@did-btcr2/method 0.29.0 → 0.33.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 (115) hide show
  1. package/README.md +36 -16
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/browser.js +6763 -6170
  4. package/dist/browser.mjs +6763 -6170
  5. package/dist/cjs/index.js +1860 -467
  6. package/dist/esm/core/aggregation/beacon-strategy.js +5 -4
  7. package/dist/esm/core/aggregation/beacon-strategy.js.map +1 -1
  8. package/dist/esm/core/aggregation/transport/factory.js +15 -6
  9. package/dist/esm/core/aggregation/transport/factory.js.map +1 -1
  10. package/dist/esm/core/aggregation/transport/http/client.js +350 -0
  11. package/dist/esm/core/aggregation/transport/http/client.js.map +1 -0
  12. package/dist/esm/core/aggregation/transport/http/envelope.js +126 -0
  13. package/dist/esm/core/aggregation/transport/http/envelope.js.map +1 -0
  14. package/dist/esm/core/aggregation/transport/http/errors.js +11 -0
  15. package/dist/esm/core/aggregation/transport/http/errors.js.map +1 -0
  16. package/dist/esm/core/aggregation/transport/http/inbox-buffer.js +45 -0
  17. package/dist/esm/core/aggregation/transport/http/inbox-buffer.js.map +1 -0
  18. package/dist/esm/core/aggregation/transport/http/index.js +12 -0
  19. package/dist/esm/core/aggregation/transport/http/index.js.map +1 -0
  20. package/dist/esm/core/aggregation/transport/http/nonce-cache.js +38 -0
  21. package/dist/esm/core/aggregation/transport/http/nonce-cache.js.map +1 -0
  22. package/dist/esm/core/aggregation/transport/http/protocol.js +28 -0
  23. package/dist/esm/core/aggregation/transport/http/protocol.js.map +1 -0
  24. package/dist/esm/core/aggregation/transport/http/rate-limiter.js +45 -0
  25. package/dist/esm/core/aggregation/transport/http/rate-limiter.js.map +1 -0
  26. package/dist/esm/core/aggregation/transport/http/request-auth.js +100 -0
  27. package/dist/esm/core/aggregation/transport/http/request-auth.js.map +1 -0
  28. package/dist/esm/core/aggregation/transport/http/server.js +481 -0
  29. package/dist/esm/core/aggregation/transport/http/server.js.map +1 -0
  30. package/dist/esm/core/aggregation/transport/http/sse-stream.js +110 -0
  31. package/dist/esm/core/aggregation/transport/http/sse-stream.js.map +1 -0
  32. package/dist/esm/core/aggregation/transport/http/sse-writer.js +25 -0
  33. package/dist/esm/core/aggregation/transport/http/sse-writer.js.map +1 -0
  34. package/dist/esm/core/aggregation/transport/index.js +1 -0
  35. package/dist/esm/core/aggregation/transport/index.js.map +1 -1
  36. package/dist/esm/core/beacon/beacon.js +197 -51
  37. package/dist/esm/core/beacon/beacon.js.map +1 -1
  38. package/dist/esm/core/beacon/cas-beacon.js +3 -3
  39. package/dist/esm/core/beacon/cas-beacon.js.map +1 -1
  40. package/dist/esm/core/beacon/singleton-beacon.js +3 -3
  41. package/dist/esm/core/beacon/singleton-beacon.js.map +1 -1
  42. package/dist/esm/core/beacon/smt-beacon.js +22 -14
  43. package/dist/esm/core/beacon/smt-beacon.js.map +1 -1
  44. package/dist/esm/core/resolver.js +7 -4
  45. package/dist/esm/core/resolver.js.map +1 -1
  46. package/dist/esm/core/updater.js +63 -55
  47. package/dist/esm/core/updater.js.map +1 -1
  48. package/dist/types/core/aggregation/beacon-strategy.d.ts.map +1 -1
  49. package/dist/types/core/aggregation/transport/factory.d.ts +22 -7
  50. package/dist/types/core/aggregation/transport/factory.d.ts.map +1 -1
  51. package/dist/types/core/aggregation/transport/http/client.d.ts +48 -0
  52. package/dist/types/core/aggregation/transport/http/client.d.ts.map +1 -0
  53. package/dist/types/core/aggregation/transport/http/envelope.d.ts +64 -0
  54. package/dist/types/core/aggregation/transport/http/envelope.d.ts.map +1 -0
  55. package/dist/types/core/aggregation/transport/http/errors.d.ts +9 -0
  56. package/dist/types/core/aggregation/transport/http/errors.d.ts.map +1 -0
  57. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts +32 -0
  58. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts.map +1 -0
  59. package/dist/types/core/aggregation/transport/http/index.d.ts +12 -0
  60. package/dist/types/core/aggregation/transport/http/index.d.ts.map +1 -0
  61. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts +26 -0
  62. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts.map +1 -0
  63. package/dist/types/core/aggregation/transport/http/protocol.d.ts +53 -0
  64. package/dist/types/core/aggregation/transport/http/protocol.d.ts.map +1 -0
  65. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts +41 -0
  66. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts.map +1 -0
  67. package/dist/types/core/aggregation/transport/http/request-auth.d.ts +50 -0
  68. package/dist/types/core/aggregation/transport/http/request-auth.d.ts.map +1 -0
  69. package/dist/types/core/aggregation/transport/http/server.d.ts +110 -0
  70. package/dist/types/core/aggregation/transport/http/server.d.ts.map +1 -0
  71. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts +34 -0
  72. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts.map +1 -0
  73. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts +12 -0
  74. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts.map +1 -0
  75. package/dist/types/core/aggregation/transport/index.d.ts +1 -0
  76. package/dist/types/core/aggregation/transport/index.d.ts.map +1 -1
  77. package/dist/types/core/aggregation/transport/transport.d.ts +1 -1
  78. package/dist/types/core/aggregation/transport/transport.d.ts.map +1 -1
  79. package/dist/types/core/beacon/beacon.d.ts +72 -12
  80. package/dist/types/core/beacon/beacon.d.ts.map +1 -1
  81. package/dist/types/core/beacon/cas-beacon.d.ts +3 -3
  82. package/dist/types/core/beacon/cas-beacon.d.ts.map +1 -1
  83. package/dist/types/core/beacon/singleton-beacon.d.ts +3 -3
  84. package/dist/types/core/beacon/singleton-beacon.d.ts.map +1 -1
  85. package/dist/types/core/beacon/smt-beacon.d.ts +3 -3
  86. package/dist/types/core/beacon/smt-beacon.d.ts.map +1 -1
  87. package/dist/types/core/interfaces.d.ts +14 -11
  88. package/dist/types/core/interfaces.d.ts.map +1 -1
  89. package/dist/types/core/resolver.d.ts.map +1 -1
  90. package/dist/types/core/updater.d.ts +27 -12
  91. package/dist/types/core/updater.d.ts.map +1 -1
  92. package/package.json +20 -8
  93. package/src/core/aggregation/beacon-strategy.ts +5 -4
  94. package/src/core/aggregation/transport/factory.ts +48 -12
  95. package/src/core/aggregation/transport/http/client.ts +409 -0
  96. package/src/core/aggregation/transport/http/envelope.ts +204 -0
  97. package/src/core/aggregation/transport/http/errors.ts +11 -0
  98. package/src/core/aggregation/transport/http/inbox-buffer.ts +53 -0
  99. package/src/core/aggregation/transport/http/index.ts +11 -0
  100. package/src/core/aggregation/transport/http/nonce-cache.ts +43 -0
  101. package/src/core/aggregation/transport/http/protocol.ts +57 -0
  102. package/src/core/aggregation/transport/http/rate-limiter.ts +75 -0
  103. package/src/core/aggregation/transport/http/request-auth.ts +164 -0
  104. package/src/core/aggregation/transport/http/server.ts +615 -0
  105. package/src/core/aggregation/transport/http/sse-stream.ts +121 -0
  106. package/src/core/aggregation/transport/http/sse-writer.ts +23 -0
  107. package/src/core/aggregation/transport/index.ts +1 -0
  108. package/src/core/aggregation/transport/transport.ts +1 -1
  109. package/src/core/beacon/beacon.ts +255 -64
  110. package/src/core/beacon/cas-beacon.ts +4 -4
  111. package/src/core/beacon/singleton-beacon.ts +4 -4
  112. package/src/core/beacon/smt-beacon.ts +24 -16
  113. package/src/core/interfaces.ts +14 -11
  114. package/src/core/resolver.ts +8 -5
  115. package/src/core/updater.ts +113 -67
@@ -1,7 +1,7 @@
1
1
  import type { BitcoinConnection } from '@did-btcr2/bitcoin';
2
- import type { KeyBytes } from '@did-btcr2/common';
3
2
  import { canonicalize, hash } from '@did-btcr2/common';
4
3
  import type { SignedBTCR2Update } from '@did-btcr2/cryptosuite';
4
+ import type { Signer } from '@did-btcr2/keypair';
5
5
  import type { BeaconProcessResult, DataNeed } from '../resolver.js';
6
6
  import type { SidecarData } from '../types.js';
7
7
  import type { BroadcastOptions } from './beacon.js';
@@ -67,7 +67,7 @@ export class SingletonBeacon extends Beacon {
67
67
  * {@link Beacon.buildSignAndBroadcast}.
68
68
  *
69
69
  * @param {SignedBTCR2Update} signedUpdate The signed BTCR2 update to broadcast.
70
- * @param {KeyBytes} secretKey The secret key for signing the Bitcoin transaction.
70
+ * @param {Signer} signer Signer that produces the ECDSA signature for the Bitcoin transaction.
71
71
  * @param {BitcoinConnection} bitcoin The Bitcoin network connection.
72
72
  * @param {BroadcastOptions} [options] Optional broadcast configuration (e.g. fee estimator).
73
73
  * @returns {Promise<SignedBTCR2Update>} The signed update that was broadcast.
@@ -75,12 +75,12 @@ export class SingletonBeacon extends Beacon {
75
75
  */
76
76
  async broadcastSignal(
77
77
  signedUpdate: SignedBTCR2Update,
78
- secretKey: KeyBytes,
78
+ signer: Signer,
79
79
  bitcoin: BitcoinConnection,
80
80
  options?: BroadcastOptions
81
81
  ): Promise<SignedBTCR2Update> {
82
82
  const signalBytes = hash(canonicalize(signedUpdate));
83
- await this.buildSignAndBroadcast(signalBytes, secretKey, bitcoin, options);
83
+ await this.buildSignAndBroadcast(signalBytes, signer, bitcoin, options);
84
84
  return signedUpdate;
85
85
  }
86
86
  }
@@ -1,8 +1,8 @@
1
1
  import type { BitcoinConnection } from '@did-btcr2/bitcoin';
2
2
  import { canonicalize } from '@did-btcr2/common';
3
- import type { KeyBytes } from '@did-btcr2/common';
4
3
  import type { SignedBTCR2Update } from '@did-btcr2/cryptosuite';
5
- import { blockHash, BTCR2MerkleTree, didToIndex, hexToHash, verifySerializedProof } from '@did-btcr2/smt';
4
+ import type { Signer } from '@did-btcr2/keypair';
5
+ import { base64UrlToHash, blockHash, BTCR2MerkleTree, didToIndex, hashToHex, verifySerializedProof } from '@did-btcr2/smt';
6
6
  import { randomBytes } from '@noble/hashes/utils';
7
7
  import type { BeaconProcessResult, DataNeed } from '../resolver.js';
8
8
  import type { SidecarData } from '../types.js';
@@ -69,12 +69,7 @@ export class SMTBeacon extends Beacon {
69
69
  continue;
70
70
  }
71
71
 
72
- // Non-inclusion proof no update for this DID in this epoch, skip
73
- if(!smtProof.updateId) {
74
- continue;
75
- }
76
-
77
- // Nonce is required for proof verification
72
+ // Nonce is required for proof verification (inclusion and non-inclusion).
78
73
  if(!smtProof.nonce) {
79
74
  throw new SMTBeaconError(
80
75
  'SMT proof missing required nonce field.',
@@ -82,9 +77,15 @@ export class SMTBeacon extends Beacon {
82
77
  );
83
78
  }
84
79
 
85
- // Verify Merkle inclusion: leaf = hash(hash(nonce) || updateId)
80
+ // Verify the SMT proof against the on-chain root. Leaf value per spec:
81
+ // inclusion = hash(hash(nonce) || updateId); non-inclusion = hash(hash(nonce)).
82
+ // Hash fields are base64url (no padding) per the SMT Proof spec. A
83
+ // non-inclusion proof (absent updateId) is verified too, not trusted.
86
84
  const index = didToIndex(did);
87
- const candidateHash = blockHash(blockHash(hexToHash(smtProof.nonce)), hexToHash(smtProof.updateId));
85
+ const nonceHash = base64UrlToHash(smtProof.nonce);
86
+ const candidateHash = smtProof.updateId
87
+ ? blockHash(blockHash(nonceHash), base64UrlToHash(smtProof.updateId))
88
+ : blockHash(blockHash(nonceHash));
88
89
  const valid = verifySerializedProof(smtProof, index, candidateHash);
89
90
 
90
91
  if(!valid) {
@@ -94,14 +95,21 @@ export class SMTBeacon extends Beacon {
94
95
  );
95
96
  }
96
97
 
97
- // Look up the signed update in sidecar updateMap (keyed by hex canonical hash)
98
- const signedUpdate = sidecar.updateMap.get(smtProof.updateId);
98
+ // Non-inclusion proof verified no update for this DID this epoch, skip.
99
+ if(!smtProof.updateId) {
100
+ continue;
101
+ }
102
+
103
+ // Look up the signed update in sidecar updateMap (keyed by hex canonical
104
+ // hash). The proof's updateId is base64url, so convert to hex to match.
105
+ const updateHashHex = hashToHex(base64UrlToHash(smtProof.updateId));
106
+ const signedUpdate = sidecar.updateMap.get(updateHashHex);
99
107
 
100
108
  if(!signedUpdate) {
101
109
  // Signed update not available — emit a need
102
110
  needs.push({
103
111
  kind : 'NeedSignedUpdate',
104
- updateHash : smtProof.updateId,
112
+ updateHash : updateHashHex,
105
113
  beaconServiceId : this.service.id
106
114
  });
107
115
  continue;
@@ -122,7 +130,7 @@ export class SMTBeacon extends Beacon {
122
130
  * signing, and broadcast are delegated to {@link Beacon.buildSignAndBroadcast}.
123
131
  *
124
132
  * @param {SignedBTCR2Update} signedUpdate The signed BTCR2 update to broadcast.
125
- * @param {KeyBytes} secretKey The secret key for signing the Bitcoin transaction.
133
+ * @param {Signer} signer Signer that produces the ECDSA signature for the Bitcoin transaction.
126
134
  * @param {BitcoinConnection} bitcoin The Bitcoin network connection.
127
135
  * @param {BroadcastOptions} [options] Optional broadcast configuration (e.g. fee estimator).
128
136
  * @return {Promise<SignedBTCR2Update>} The signed update that was broadcast.
@@ -130,7 +138,7 @@ export class SMTBeacon extends Beacon {
130
138
  */
131
139
  async broadcastSignal(
132
140
  signedUpdate: SignedBTCR2Update,
133
- secretKey: KeyBytes,
141
+ signer: Signer,
134
142
  bitcoin: BitcoinConnection,
135
143
  options?: BroadcastOptions
136
144
  ): Promise<SignedBTCR2Update> {
@@ -145,7 +153,7 @@ export class SMTBeacon extends Beacon {
145
153
  tree.finalize();
146
154
 
147
155
  // Root hash is the signal bytes for the OP_RETURN output
148
- await this.buildSignAndBroadcast(tree.rootHash, secretKey, bitcoin, options);
156
+ await this.buildSignAndBroadcast(tree.rootHash, signer, bitcoin, options);
149
157
 
150
158
  return signedUpdate;
151
159
  }
@@ -43,36 +43,39 @@ export interface ResolutionOptions extends DidResolutionOptions {
43
43
  * a path from a leaf in the tree to the Merkle root, proving that the leaf is in the tree.
44
44
  * See {@link https://dcdpr.github.io/did-btcr2/data-structures.html#smt-proof | SMT Proof (data structure)}.
45
45
  *
46
+ * All SHA-256 hash fields (`id`, `nonce`, `updateId`, `hashes`) are "base64url"
47
+ * [RFC4648] encoded without padding (43 chars each). `collapsed` is the 256-bit
48
+ * zero-node bitmap, also base64url no-pad (43 chars).
49
+ *
46
50
  * @example
47
51
  * ```json
48
52
  * {
49
- * "id": "<< Hexadecimal of Root Hash >>",
50
- * "nonce": "<< Hexadecimal of Nonce 1101 >>",
51
- * "updateId": "<< Hexadecimal of hash(Data Block 1101) >>",
52
- * "collapsed": "<< Hexadecimal of 0001 >>",
53
+ * "id": "q1H_iaYG0Oq6gbrycYL-r7FjUsJLnIpHDn49TLeONNA",
54
+ * "nonce": "99jndCBWHpZfmObXlIvRGHaPMgoQKXIETdD4H-XqryE",
55
+ * "updateId": "njYNViJq2OmhSw1fLfARPCj12RY3VXKGWdS3-7OQ2BE",
56
+ * "collapsed": "v_________________________________________8",
53
57
  * "hashes": [
54
- * "<< Hexadecimal of Hash 1110 >>",
55
- * "<< Hexadecimal of Hash 1001 >>",
56
- * "<< Hexadecimal of Hash 0 >>"
58
+ * "8JWXL7chPKJXwg-i9O1EFTHan_oOO_RmglDpu_ugax0"
57
59
  * ]
58
60
  * }
59
61
  * ```
60
62
  */
61
63
  export interface SMTProof {
62
64
  /**
63
- * The SHA-256 hash of the root node of the Sparse Merkle Tree.
65
+ * base64url (no padding) SHA-256 hash of the root node of the Sparse Merkle Tree.
64
66
  */
65
67
  id: string;
66
68
  /**
67
- * Optional 256-bit nonce generated for each update. Hex-encoded (64 chars).
69
+ * Optional 256-bit nonce generated for each update. base64url, no padding (43 chars).
68
70
  */
69
71
  nonce?: string;
70
72
  /**
71
- * Optional hex-encoded canonical hash of the BTCR2 Signed Update.
73
+ * Optional base64url (no padding) canonical hash of the BTCR2 Signed Update.
72
74
  */
73
75
  updateId?: string;
74
76
  /**
75
- * Bitmap of zero nodes within the path (see: collapsed leaves).
77
+ * base64url (no padding) bitmap of zero nodes within the path (see: collapsed
78
+ * leaves). Bit set = empty/zero sibling; bit clear = a sibling hash is present.
76
79
  */
77
80
  collapsed: string;
78
81
  /**
@@ -281,11 +281,12 @@ export class Resolver {
281
281
  casMap.set(canonicalHash(update, { encoding: 'hex' }), update);
282
282
  }
283
283
 
284
- // SMT Proofs map
284
+ // SMT Proofs map. proof.id is base64url per the SMT Proof spec; key by the
285
+ // hex root hash so lookups match the hex signalBytes from the OP_RETURN.
285
286
  const smtMap = new Map<string, SMTProof>();
286
287
  if(sidecar.smtProofs?.length)
287
288
  for(const proof of sidecar.smtProofs) {
288
- smtMap.set(proof.id, proof);
289
+ smtMap.set(encodeHash(decodeHash(proof.id, 'base64urlnopad'), 'hex'), proof);
289
290
  }
290
291
 
291
292
  return { updateMap, casMap, smtMap };
@@ -723,10 +724,12 @@ export class Resolver {
723
724
  case 'NeedSMTProof': {
724
725
  const smtNeed = need as NeedSMTProof;
725
726
  const proof = data as SMTProof;
726
- if(proof.id !== smtNeed.smtRootHash) {
727
+ // proof.id is base64url per spec; smtRootHash is the hex on-chain signal.
728
+ const proofIdHex = encodeHash(decodeHash(proof.id, 'base64urlnopad'), 'hex');
729
+ if(proofIdHex !== smtNeed.smtRootHash) {
727
730
  throw new ResolveError(
728
- `SMT proof root hash mismatch: expected ${smtNeed.smtRootHash}, got ${proof.id}`,
729
- INVALID_DID_UPDATE, { expected: smtNeed.smtRootHash, actual: proof.id }
731
+ `SMT proof root hash mismatch: expected ${smtNeed.smtRootHash}, got ${proofIdHex}`,
732
+ INVALID_DID_UPDATE, { expected: smtNeed.smtRootHash, actual: proofIdHex }
730
733
  );
731
734
  }
732
735
  this.#sidecarData.smtMap.set(smtNeed.smtRootHash, proof);
@@ -1,7 +1,8 @@
1
1
  import type { BitcoinConnection } from '@did-btcr2/bitcoin';
2
- import type { KeyBytes, PatchOperation } from '@did-btcr2/common';
2
+ import type { PatchOperation } from '@did-btcr2/common';
3
3
  import { canonicalHash, INVALID_DID_UPDATE, JSONPatch, UpdateError } from '@did-btcr2/common';
4
4
  import { SchnorrMultikey, type DataIntegrityConfig, type SignedBTCR2Update, type UnsignedBTCR2Update } from '@did-btcr2/cryptosuite';
5
+ import type { Signer } from '@did-btcr2/keypair';
5
6
  import { DidDocument, type Btcr2DidDocument, type DidVerificationMethod } from '../utils/did-document.js';
6
7
  import { BeaconFactory } from './beacon/factory.js';
7
8
  import type { BeaconService } from './beacon/interfaces.js';
@@ -9,9 +10,10 @@ import type { BeaconService } from './beacon/interfaces.js';
9
10
  // ─── DataNeed types ──────────────────────────────────────────────────────────
10
11
 
11
12
  /**
12
- * The updater needs the caller to supply a signing key (or a KMS-backed signature)
13
- * for the given verification method. The unsigned update is attached so the caller
14
- * can inspect it before producing a signature.
13
+ * The updater needs the caller to supply a {@link Signer} for the given
14
+ * verification method. The unsigned update is attached so the caller can
15
+ * inspect it before producing a signature. The signer can wrap a local secret
16
+ * key (`LocalSigner`), a KMS-managed key (`KeyManagerSigner`), or any custom backend.
15
17
  */
16
18
  export interface NeedSigningKey {
17
19
  readonly kind: 'NeedSigningKey';
@@ -36,6 +38,19 @@ export interface NeedFunding {
36
38
  readonly beaconService: BeaconService;
37
39
  }
38
40
 
41
+ /**
42
+ * Optional proof the caller passes when fulfilling {@link NeedFunding}. The
43
+ * state machine asserts the proof before transitioning to Broadcast. Sans-I/O
44
+ * is preserved: the caller still performs the UTXO lookup; this is just a
45
+ * contract-level handshake.
46
+ */
47
+ export interface FundingProof {
48
+ /** Number of spendable UTXOs the caller observed at the beacon address. Must be >= 1. */
49
+ utxoCount: number;
50
+ /** Optional txid the caller funded with, for diagnostics. */
51
+ txid?: string;
52
+ }
53
+
39
54
  /**
40
55
  * The updater needs the caller to broadcast the signed update via the beacon.
41
56
  *
@@ -75,16 +90,18 @@ export type UpdaterState =
75
90
  | { status: 'complete'; result: UpdaterResult };
76
91
 
77
92
  /**
78
- * Internal phases of the Updater state machine.
93
+ * Discriminated union of the updater's internal state. Each phase tag pins the
94
+ * exact set of values the state machine has computed so far, so consumers of
95
+ * `#state` narrow correctly under `switch (this.#state.phase)`. No nullable
96
+ * scratch slots, no `!`-asserts.
79
97
  * @internal
80
98
  */
81
- enum UpdaterPhase {
82
- Construct = 'Construct',
83
- Sign = 'Sign',
84
- Fund = 'Fund',
85
- Broadcast = 'Broadcast',
86
- Complete = 'Complete',
87
- }
99
+ type InternalState =
100
+ | { phase: 'Construct' }
101
+ | { phase: 'Sign'; unsignedUpdate: UnsignedBTCR2Update }
102
+ | { phase: 'Fund'; unsignedUpdate: UnsignedBTCR2Update; signedUpdate: SignedBTCR2Update }
103
+ | { phase: 'Broadcast'; unsignedUpdate: UnsignedBTCR2Update; signedUpdate: SignedBTCR2Update }
104
+ | { phase: 'Complete'; signedUpdate: SignedBTCR2Update };
88
105
 
89
106
  /**
90
107
  * Parameters for constructing an {@link Updater}. Built by
@@ -106,20 +123,21 @@ export interface UpdaterParams {
106
123
  *
107
124
  * ```typescript
108
125
  * const updater = DidBtcr2.update({ sourceDocument, patches, ... });
126
+ * const signer = new LocalSigner(secretKeyBytes); // or KeyManagerSigner / custom
109
127
  * let state = updater.advance();
110
128
  *
111
129
  * while(state.status === 'action-required') {
112
130
  * for(const need of state.needs) {
113
131
  * switch(need.kind) {
114
132
  * case 'NeedSigningKey':
115
- * updater.provide(need, secretKeyBytes);
133
+ * updater.provide(need, signer);
116
134
  * break;
117
135
  * case 'NeedFunding':
118
136
  * // Check UTXOs at need.beaconAddress, fund if needed
119
137
  * updater.provide(need);
120
138
  * break;
121
139
  * case 'NeedBroadcast':
122
- * await Updater.announce(need.beaconService, need.signedUpdate, secretKey, bitcoin);
140
+ * await Updater.announce(need.beaconService, need.signedUpdate, signer, bitcoin);
123
141
  * updater.provide(need);
124
142
  * break;
125
143
  * }
@@ -142,16 +160,13 @@ export interface UpdaterParams {
142
160
  * @class Updater
143
161
  */
144
162
  export class Updater {
145
- #phase: UpdaterPhase = UpdaterPhase.Construct;
163
+ #state: InternalState = { phase: 'Construct' };
146
164
  readonly #sourceDocument: Btcr2DidDocument;
147
165
  readonly #patches: PatchOperation[];
148
166
  readonly #sourceVersionId: number;
149
167
  readonly #verificationMethod: DidVerificationMethod;
150
168
  readonly #beaconService: BeaconService;
151
169
 
152
- #unsignedUpdate: UnsignedBTCR2Update | null = null;
153
- #signedUpdate: SignedBTCR2Update | null = null;
154
-
155
170
  /**
156
171
  * @internal — Use {@link DidBtcr2.update} to create instances.
157
172
  */
@@ -196,6 +211,16 @@ export class Updater {
196
211
 
197
212
  const targetDocument = JSONPatch.apply(sourceDocument, patches);
198
213
 
214
+ // Spec (operations/update.md): "An INVALID_DID_UPDATE error MUST be raised if
215
+ // didTargetDocument.id is not equal to didSourceDocument.id." `DidDocument.isValid`
216
+ // checks W3C conformance but not this equality, so it's enforced explicitly here.
217
+ if(targetDocument.id !== sourceDocument.id) {
218
+ throw new UpdateError(
219
+ `Patches must not change the DID document id (source "${sourceDocument.id}" → target "${targetDocument.id}").`,
220
+ INVALID_DID_UPDATE, { sourceId: sourceDocument.id, targetId: targetDocument.id }
221
+ );
222
+ }
223
+
199
224
  try {
200
225
  DidDocument.isValid(targetDocument);
201
226
  } catch (error) {
@@ -215,18 +240,31 @@ export class Updater {
215
240
  * @param {string} did The did-btcr2 identifier to derive the root capability from.
216
241
  * @param {UnsignedBTCR2Update} unsignedUpdate The unsigned update to sign.
217
242
  * @param {DidVerificationMethod} verificationMethod The verification method for signing.
218
- * @param {KeyBytes} secretKey The secret key bytes.
243
+ * @param {Signer} signer Signer that produces the BIP-340 Schnorr signature.
219
244
  * @returns {SignedBTCR2Update} The signed update with a Data Integrity proof.
220
245
  */
221
246
  static sign(
222
247
  did: string,
223
248
  unsignedUpdate: UnsignedBTCR2Update,
224
249
  verificationMethod: DidVerificationMethod,
225
- secretKey: KeyBytes,
250
+ signer: Signer,
226
251
  ): SignedBTCR2Update {
252
+ if(!did.startsWith('did:btcr2:')) {
253
+ throw new UpdateError(
254
+ `Expected a did:btcr2 identifier for the root capability; got "${did}".`,
255
+ INVALID_DID_UPDATE, { did }
256
+ );
257
+ }
227
258
  const controller = verificationMethod.controller;
228
- const id = verificationMethod.id.slice(verificationMethod.id.indexOf('#'));
229
- const multikey = SchnorrMultikey.fromSecretKey(id, controller, secretKey);
259
+ const hashIdx = verificationMethod.id.indexOf('#');
260
+ if(hashIdx < 0) {
261
+ throw new UpdateError(
262
+ `Verification method id must contain a fragment (e.g. "${verificationMethod.id}#initialKey"); got "${verificationMethod.id}".`,
263
+ INVALID_DID_UPDATE, { verificationMethodId: verificationMethod.id }
264
+ );
265
+ }
266
+ const id = verificationMethod.id.slice(hashIdx);
267
+ const multikey = SchnorrMultikey.fromSigner(id, controller, signer);
230
268
 
231
269
  const config: DataIntegrityConfig = {
232
270
  '@context' : [
@@ -253,21 +291,21 @@ export class Updater {
253
291
  *
254
292
  * @param {BeaconService} beaconService The beacon service to broadcast through.
255
293
  * @param {SignedBTCR2Update} update The signed update to announce.
256
- * @param {KeyBytes} secretKey The secret key for signing the Bitcoin transaction.
294
+ * @param {Signer} signer Signer that produces the ECDSA signature for the Bitcoin transaction.
257
295
  * @param {BitcoinConnection} bitcoin The Bitcoin network connection.
258
296
  * @returns {Promise<SignedBTCR2Update>} The signed update that was broadcast.
259
297
  */
260
298
  static async announce(
261
299
  beaconService: BeaconService,
262
300
  update: SignedBTCR2Update,
263
- secretKey: KeyBytes,
301
+ signer: Signer,
264
302
  bitcoin: BitcoinConnection
265
303
  ): Promise<SignedBTCR2Update> {
266
304
  const beacon = BeaconFactory.establish(beaconService);
267
- return beacon.broadcastSignal(update, secretKey, bitcoin);
305
+ return beacon.broadcastSignal(update, signer, bitcoin);
268
306
  }
269
307
 
270
- // ─── Private instance wrappers ─────────────────────────────────────────────
308
+ // Private instance wrappers
271
309
  // Delegate to the public statics with bound instance fields for cleaner
272
310
  // advance/provide code.
273
311
 
@@ -275,12 +313,6 @@ export class Updater {
275
313
  return Updater.construct(this.#sourceDocument, this.#patches, this.#sourceVersionId);
276
314
  }
277
315
 
278
- #sign(secretKey: KeyBytes): SignedBTCR2Update {
279
- return Updater.sign(this.#sourceDocument.id, this.#unsignedUpdate!, this.#verificationMethod, secretKey);
280
- }
281
-
282
- // ─── State machine ─────────────────────────────────────────────────────────
283
-
284
316
  /**
285
317
  * Advance the state machine. Returns either:
286
318
  * - `{ status: 'action-required', needs }` — caller must provide data via {@link provide}
@@ -288,25 +320,25 @@ export class Updater {
288
320
  */
289
321
  advance(): UpdaterState {
290
322
  while(true) {
291
- switch(this.#phase) {
323
+ switch(this.#state.phase) {
292
324
 
293
325
  // Phase: Construct
294
326
  // Build the unsigned update from source doc + patches. Pure, synchronous.
295
- case UpdaterPhase.Construct: {
296
- this.#unsignedUpdate = this.#construct();
297
- this.#phase = UpdaterPhase.Sign;
327
+ case 'Construct': {
328
+ const unsignedUpdate = this.#construct();
329
+ this.#state = { phase: 'Sign', unsignedUpdate };
298
330
  continue;
299
331
  }
300
332
 
301
333
  // Phase: Sign
302
334
  // Emit NeedSigningKey — the caller supplies the secret key (or a KMS signature).
303
- case UpdaterPhase.Sign: {
335
+ case 'Sign': {
304
336
  return {
305
337
  status : 'action-required',
306
338
  needs : [{
307
339
  kind : 'NeedSigningKey',
308
340
  verificationMethodId : this.#verificationMethod.id,
309
- unsignedUpdate : this.#unsignedUpdate!,
341
+ unsignedUpdate : this.#state.unsignedUpdate,
310
342
  }],
311
343
  };
312
344
  }
@@ -314,7 +346,7 @@ export class Updater {
314
346
  // Phase: Fund
315
347
  // Emit NeedFunding with the beacon address. The caller checks UTXOs,
316
348
  // funds the address if needed, and provides to continue.
317
- case UpdaterPhase.Fund: {
349
+ case 'Fund': {
318
350
  const beaconAddress = this.#beaconService.serviceEndpoint.replace('bitcoin:', '');
319
351
  return {
320
352
  status : 'action-required',
@@ -329,22 +361,22 @@ export class Updater {
329
361
  // Phase: Broadcast
330
362
  // Emit NeedBroadcast with the signed update + beacon service. The caller performs
331
363
  // the actual on-chain announcement (or hands off to the aggregation protocol).
332
- case UpdaterPhase.Broadcast: {
364
+ case 'Broadcast': {
333
365
  return {
334
366
  status : 'action-required',
335
367
  needs : [{
336
368
  kind : 'NeedBroadcast',
337
369
  beaconService : this.#beaconService,
338
- signedUpdate : this.#signedUpdate!,
370
+ signedUpdate : this.#state.signedUpdate,
339
371
  }],
340
372
  };
341
373
  }
342
374
 
343
375
  // Phase: Complete
344
- case UpdaterPhase.Complete: {
376
+ case 'Complete': {
345
377
  return {
346
378
  status : 'complete',
347
- result : { signedUpdate: this.#signedUpdate! },
379
+ result : { signedUpdate: this.#state.signedUpdate },
348
380
  };
349
381
  }
350
382
  }
@@ -358,56 +390,70 @@ export class Updater {
358
390
  * @param need The DataNeed being fulfilled (from the `needs` array).
359
391
  * @param data The data payload corresponding to the need kind (omit for NeedFunding/NeedBroadcast).
360
392
  */
361
- provide(need: NeedSigningKey, data: KeyBytes): void;
362
- provide(need: NeedFunding): void;
393
+ provide(need: NeedSigningKey, data: Signer): void;
394
+ provide(need: NeedFunding, proof?: FundingProof): void;
363
395
  provide(need: NeedBroadcast): void;
364
- provide(need: UpdaterDataNeed, data?: KeyBytes): void {
396
+ provide(need: UpdaterDataNeed, data?: Signer | FundingProof): void {
365
397
  switch(need.kind) {
366
398
  case 'NeedSigningKey': {
367
- if(this.#phase !== UpdaterPhase.Sign) {
399
+ if(this.#state.phase !== 'Sign') {
368
400
  throw new UpdateError(
369
- `Cannot provide NeedSigningKey: updater phase is ${this.#phase}, expected Sign.`,
370
- INVALID_DID_UPDATE, { phase: this.#phase }
401
+ `Cannot provide NeedSigningKey: updater phase is ${this.#state.phase}, expected Sign.`,
402
+ INVALID_DID_UPDATE, { phase: this.#state.phase }
371
403
  );
372
404
  }
373
405
  if(!data) {
374
406
  throw new UpdateError(
375
- 'NeedSigningKey requires secret key bytes.',
376
- INVALID_DID_UPDATE
377
- );
378
- }
379
- if(!this.#unsignedUpdate) {
380
- throw new UpdateError(
381
- 'Internal error: unsigned update missing in Sign phase.',
407
+ 'NeedSigningKey requires a Signer.',
382
408
  INVALID_DID_UPDATE
383
409
  );
384
410
  }
385
- this.#signedUpdate = this.#sign(data);
386
- this.#phase = UpdaterPhase.Fund;
411
+ const unsignedUpdate = this.#state.unsignedUpdate;
412
+ const signedUpdate = Updater.sign(
413
+ this.#sourceDocument.id, unsignedUpdate, this.#verificationMethod, data as Signer,
414
+ );
415
+ this.#state = { phase: 'Fund', unsignedUpdate, signedUpdate };
387
416
  break;
388
417
  }
389
418
 
390
419
  case 'NeedFunding': {
391
- if(this.#phase !== UpdaterPhase.Fund) {
420
+ if(this.#state.phase !== 'Fund') {
392
421
  throw new UpdateError(
393
- `Cannot provide NeedFunding: updater phase is ${this.#phase}, expected Fund.`,
394
- INVALID_DID_UPDATE, { phase: this.#phase }
422
+ `Cannot provide NeedFunding: updater phase is ${this.#state.phase}, expected Fund.`,
423
+ INVALID_DID_UPDATE, { phase: this.#state.phase }
395
424
  );
396
425
  }
397
- // Caller has confirmed funding (or it was already funded). Continue.
398
- this.#phase = UpdaterPhase.Broadcast;
426
+ // If the caller supplies a FundingProof, assert it before transitioning.
427
+ // Optional payload preserves the sans-I/O contract: the caller still does
428
+ // the actual UTXO lookup; this is a contract-level handshake that catches
429
+ // a class of caller bugs (forgot to fund, race with mempool, etc.) at the
430
+ // state-machine boundary rather than at broadcast time.
431
+ if(data !== undefined) {
432
+ const proof = data as FundingProof;
433
+ if(typeof proof.utxoCount !== 'number' || !Number.isFinite(proof.utxoCount) || proof.utxoCount < 1) {
434
+ throw new UpdateError(
435
+ `NeedFunding proof must have utxoCount >= 1; got ${String(proof.utxoCount)}.`,
436
+ INVALID_DID_UPDATE, { utxoCount: proof.utxoCount }
437
+ );
438
+ }
439
+ }
440
+ this.#state = {
441
+ phase : 'Broadcast',
442
+ unsignedUpdate : this.#state.unsignedUpdate,
443
+ signedUpdate : this.#state.signedUpdate,
444
+ };
399
445
  break;
400
446
  }
401
447
 
402
448
  case 'NeedBroadcast': {
403
- if(this.#phase !== UpdaterPhase.Broadcast) {
449
+ if(this.#state.phase !== 'Broadcast') {
404
450
  throw new UpdateError(
405
- `Cannot provide NeedBroadcast: updater phase is ${this.#phase}, expected Broadcast.`,
406
- INVALID_DID_UPDATE, { phase: this.#phase }
451
+ `Cannot provide NeedBroadcast: updater phase is ${this.#state.phase}, expected Broadcast.`,
452
+ INVALID_DID_UPDATE, { phase: this.#state.phase }
407
453
  );
408
454
  }
409
455
  // Caller has broadcast externally. Transition to Complete.
410
- this.#phase = UpdaterPhase.Complete;
456
+ this.#state = { phase: 'Complete', signedUpdate: this.#state.signedUpdate };
411
457
  break;
412
458
  }
413
459
  }