@did-btcr2/method 0.29.0 → 0.32.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 (104) hide show
  1. package/README.md +13 -5
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/browser.js +8174 -7157
  4. package/dist/browser.mjs +8174 -7157
  5. package/dist/cjs/index.js +1845 -455
  6. package/dist/esm/core/aggregation/transport/factory.js +15 -6
  7. package/dist/esm/core/aggregation/transport/factory.js.map +1 -1
  8. package/dist/esm/core/aggregation/transport/http/client.js +350 -0
  9. package/dist/esm/core/aggregation/transport/http/client.js.map +1 -0
  10. package/dist/esm/core/aggregation/transport/http/envelope.js +126 -0
  11. package/dist/esm/core/aggregation/transport/http/envelope.js.map +1 -0
  12. package/dist/esm/core/aggregation/transport/http/errors.js +11 -0
  13. package/dist/esm/core/aggregation/transport/http/errors.js.map +1 -0
  14. package/dist/esm/core/aggregation/transport/http/inbox-buffer.js +45 -0
  15. package/dist/esm/core/aggregation/transport/http/inbox-buffer.js.map +1 -0
  16. package/dist/esm/core/aggregation/transport/http/index.js +12 -0
  17. package/dist/esm/core/aggregation/transport/http/index.js.map +1 -0
  18. package/dist/esm/core/aggregation/transport/http/nonce-cache.js +38 -0
  19. package/dist/esm/core/aggregation/transport/http/nonce-cache.js.map +1 -0
  20. package/dist/esm/core/aggregation/transport/http/protocol.js +28 -0
  21. package/dist/esm/core/aggregation/transport/http/protocol.js.map +1 -0
  22. package/dist/esm/core/aggregation/transport/http/rate-limiter.js +45 -0
  23. package/dist/esm/core/aggregation/transport/http/rate-limiter.js.map +1 -0
  24. package/dist/esm/core/aggregation/transport/http/request-auth.js +100 -0
  25. package/dist/esm/core/aggregation/transport/http/request-auth.js.map +1 -0
  26. package/dist/esm/core/aggregation/transport/http/server.js +481 -0
  27. package/dist/esm/core/aggregation/transport/http/server.js.map +1 -0
  28. package/dist/esm/core/aggregation/transport/http/sse-stream.js +110 -0
  29. package/dist/esm/core/aggregation/transport/http/sse-stream.js.map +1 -0
  30. package/dist/esm/core/aggregation/transport/http/sse-writer.js +25 -0
  31. package/dist/esm/core/aggregation/transport/http/sse-writer.js.map +1 -0
  32. package/dist/esm/core/aggregation/transport/index.js +1 -0
  33. package/dist/esm/core/aggregation/transport/index.js.map +1 -1
  34. package/dist/esm/core/beacon/beacon.js +197 -51
  35. package/dist/esm/core/beacon/beacon.js.map +1 -1
  36. package/dist/esm/core/beacon/cas-beacon.js +3 -3
  37. package/dist/esm/core/beacon/cas-beacon.js.map +1 -1
  38. package/dist/esm/core/beacon/singleton-beacon.js +3 -3
  39. package/dist/esm/core/beacon/singleton-beacon.js.map +1 -1
  40. package/dist/esm/core/beacon/smt-beacon.js +3 -3
  41. package/dist/esm/core/beacon/smt-beacon.js.map +1 -1
  42. package/dist/esm/core/updater.js +63 -55
  43. package/dist/esm/core/updater.js.map +1 -1
  44. package/dist/types/core/aggregation/transport/factory.d.ts +22 -7
  45. package/dist/types/core/aggregation/transport/factory.d.ts.map +1 -1
  46. package/dist/types/core/aggregation/transport/http/client.d.ts +48 -0
  47. package/dist/types/core/aggregation/transport/http/client.d.ts.map +1 -0
  48. package/dist/types/core/aggregation/transport/http/envelope.d.ts +64 -0
  49. package/dist/types/core/aggregation/transport/http/envelope.d.ts.map +1 -0
  50. package/dist/types/core/aggregation/transport/http/errors.d.ts +9 -0
  51. package/dist/types/core/aggregation/transport/http/errors.d.ts.map +1 -0
  52. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts +32 -0
  53. package/dist/types/core/aggregation/transport/http/inbox-buffer.d.ts.map +1 -0
  54. package/dist/types/core/aggregation/transport/http/index.d.ts +12 -0
  55. package/dist/types/core/aggregation/transport/http/index.d.ts.map +1 -0
  56. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts +26 -0
  57. package/dist/types/core/aggregation/transport/http/nonce-cache.d.ts.map +1 -0
  58. package/dist/types/core/aggregation/transport/http/protocol.d.ts +53 -0
  59. package/dist/types/core/aggregation/transport/http/protocol.d.ts.map +1 -0
  60. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts +41 -0
  61. package/dist/types/core/aggregation/transport/http/rate-limiter.d.ts.map +1 -0
  62. package/dist/types/core/aggregation/transport/http/request-auth.d.ts +50 -0
  63. package/dist/types/core/aggregation/transport/http/request-auth.d.ts.map +1 -0
  64. package/dist/types/core/aggregation/transport/http/server.d.ts +110 -0
  65. package/dist/types/core/aggregation/transport/http/server.d.ts.map +1 -0
  66. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts +34 -0
  67. package/dist/types/core/aggregation/transport/http/sse-stream.d.ts.map +1 -0
  68. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts +12 -0
  69. package/dist/types/core/aggregation/transport/http/sse-writer.d.ts.map +1 -0
  70. package/dist/types/core/aggregation/transport/index.d.ts +1 -0
  71. package/dist/types/core/aggregation/transport/index.d.ts.map +1 -1
  72. package/dist/types/core/aggregation/transport/transport.d.ts +1 -1
  73. package/dist/types/core/aggregation/transport/transport.d.ts.map +1 -1
  74. package/dist/types/core/beacon/beacon.d.ts +72 -12
  75. package/dist/types/core/beacon/beacon.d.ts.map +1 -1
  76. package/dist/types/core/beacon/cas-beacon.d.ts +3 -3
  77. package/dist/types/core/beacon/cas-beacon.d.ts.map +1 -1
  78. package/dist/types/core/beacon/singleton-beacon.d.ts +3 -3
  79. package/dist/types/core/beacon/singleton-beacon.d.ts.map +1 -1
  80. package/dist/types/core/beacon/smt-beacon.d.ts +3 -3
  81. package/dist/types/core/beacon/smt-beacon.d.ts.map +1 -1
  82. package/dist/types/core/updater.d.ts +27 -12
  83. package/dist/types/core/updater.d.ts.map +1 -1
  84. package/package.json +5 -5
  85. package/src/core/aggregation/transport/factory.ts +48 -12
  86. package/src/core/aggregation/transport/http/client.ts +409 -0
  87. package/src/core/aggregation/transport/http/envelope.ts +204 -0
  88. package/src/core/aggregation/transport/http/errors.ts +11 -0
  89. package/src/core/aggregation/transport/http/inbox-buffer.ts +53 -0
  90. package/src/core/aggregation/transport/http/index.ts +11 -0
  91. package/src/core/aggregation/transport/http/nonce-cache.ts +43 -0
  92. package/src/core/aggregation/transport/http/protocol.ts +57 -0
  93. package/src/core/aggregation/transport/http/rate-limiter.ts +75 -0
  94. package/src/core/aggregation/transport/http/request-auth.ts +164 -0
  95. package/src/core/aggregation/transport/http/server.ts +615 -0
  96. package/src/core/aggregation/transport/http/sse-stream.ts +121 -0
  97. package/src/core/aggregation/transport/http/sse-writer.ts +23 -0
  98. package/src/core/aggregation/transport/index.ts +1 -0
  99. package/src/core/aggregation/transport/transport.ts +1 -1
  100. package/src/core/beacon/beacon.ts +255 -64
  101. package/src/core/beacon/cas-beacon.ts +4 -4
  102. package/src/core/beacon/singleton-beacon.ts +4 -4
  103. package/src/core/beacon/smt-beacon.ts +4 -4
  104. package/src/core/updater.ts +113 -67
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Parsed Server-Sent Events record.
3
+ *
4
+ * Events without a `data` field are never yielded (per the SSE spec — only a
5
+ * blank line that follows at least one `data:` line dispatches an event).
6
+ */
7
+ export interface SseEvent {
8
+ /** Optional event name (from `event:` field). Defaults to "message" if omitted. */
9
+ event?: string;
10
+ /** Accumulated data payload (multiple `data:` lines joined with `\n`). */
11
+ data: string;
12
+ /** Last-Event-ID value for reconnect resumption. */
13
+ id?: string;
14
+ /** Retry delay hint in milliseconds. */
15
+ retry?: number;
16
+ }
17
+
18
+ /**
19
+ * Parse an SSE stream into an async iterable of {@link SseEvent} records.
20
+ *
21
+ * The parser follows the HTML Living Standard ({@link https://html.spec.whatwg.org/multipage/server-sent-events.html})
22
+ * closely enough for our needs: LF and CRLF line terminators, multi-line
23
+ * `data` fields, `event` / `id` / `retry` fields, and `:`-prefixed comments.
24
+ * CR-only line terminators are not supported (every mainstream SSE
25
+ * implementation emits LF or CRLF).
26
+ *
27
+ * Pure, runtime-agnostic — works anywhere `ReadableStream<Uint8Array>` and
28
+ * `TextDecoder` exist (browsers and Node 22+).
29
+ *
30
+ * The caller owns stream lifecycle: cancellation should be effected via an
31
+ * `AbortController` on the producing `fetch`, which propagates as a read
32
+ * error and cleanly unwinds this generator's `finally`.
33
+ */
34
+ export async function* parseSseStream(
35
+ readable: ReadableStream<Uint8Array>,
36
+ ): AsyncGenerator<SseEvent, void, void> {
37
+ const decoder = new TextDecoder('utf-8');
38
+ const reader = readable.getReader();
39
+
40
+ let buffer = '';
41
+ let pending: { event?: string; data?: string; id?: string; retry?: number } = {};
42
+
43
+ const dispatchPending = (): SseEvent | null => {
44
+ if(pending.data === undefined) {
45
+ pending = {};
46
+ return null;
47
+ }
48
+ const ev: SseEvent = { data: pending.data };
49
+ if(pending.event !== undefined) ev.event = pending.event;
50
+ if(pending.id !== undefined) ev.id = pending.id;
51
+ if(pending.retry !== undefined) ev.retry = pending.retry;
52
+ pending = {};
53
+ return ev;
54
+ };
55
+
56
+ const processLine = (line: string): void => {
57
+ if(line.startsWith(':')) return; // comment
58
+
59
+ const colon = line.indexOf(':');
60
+ const field = colon === -1 ? line : line.slice(0, colon);
61
+ let value = colon === -1 ? '' : line.slice(colon + 1);
62
+ if(value.startsWith(' ')) value = value.slice(1);
63
+
64
+ switch(field) {
65
+ case 'data':
66
+ pending.data = pending.data === undefined ? value : `${pending.data}\n${value}`;
67
+ break;
68
+ case 'event':
69
+ pending.event = value;
70
+ break;
71
+ case 'id':
72
+ // Per spec: ignore ids containing NUL.
73
+ if(!value.includes('\0')) pending.id = value;
74
+ break;
75
+ case 'retry': {
76
+ const n = Number(value);
77
+ if(Number.isInteger(n) && n >= 0) pending.retry = n;
78
+ break;
79
+ }
80
+ // Other fields (including unknown names) are ignored per the spec.
81
+ }
82
+ };
83
+
84
+ try {
85
+ for(;;) {
86
+ const { value, done } = await reader.read();
87
+ if(done) {
88
+ // Flush any bytes the decoder is still holding.
89
+ buffer += decoder.decode();
90
+ if(buffer.length > 0) {
91
+ const line = buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer;
92
+ if(line.length > 0) processLine(line);
93
+ buffer = '';
94
+ }
95
+ const tail = dispatchPending();
96
+ if(tail) yield tail;
97
+ return;
98
+ }
99
+
100
+ buffer += decoder.decode(value, { stream: true });
101
+
102
+ // Drain as many complete lines as are available.
103
+ let lineEnd = buffer.indexOf('\n');
104
+ while(lineEnd !== -1) {
105
+ let line = buffer.slice(0, lineEnd);
106
+ if(line.endsWith('\r')) line = line.slice(0, -1);
107
+ buffer = buffer.slice(lineEnd + 1);
108
+
109
+ if(line.length === 0) {
110
+ const ev = dispatchPending();
111
+ if(ev) yield ev;
112
+ } else {
113
+ processLine(line);
114
+ }
115
+ lineEnd = buffer.indexOf('\n');
116
+ }
117
+ }
118
+ } finally {
119
+ try { reader.releaseLock(); } catch { /* already released */ }
120
+ }
121
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Format an SSE event frame. Pairs with {@link parseSseStream}.
3
+ *
4
+ * Multi-line `data` is split across multiple `data:` lines per the SSE spec —
5
+ * each embedded `\n` becomes its own line, and the parser rejoins them.
6
+ *
7
+ * The returned string includes a trailing blank line (the dispatch marker).
8
+ */
9
+ export function formatSseEvent(event: string, data: string, id?: string): string {
10
+ const lines: string[] = [];
11
+ if(id !== undefined) lines.push(`id: ${id}`);
12
+ lines.push(`event: ${event}`);
13
+ for(const part of data.split('\n')) lines.push(`data: ${part}`);
14
+ lines.push('');
15
+ lines.push('');
16
+ return lines.join('\n');
17
+ }
18
+
19
+ /** SSE comment frame (server keepalive). Lines starting with `:` are ignored by compliant parsers. */
20
+ export function formatSseComment(comment: string): string {
21
+ const safe = comment.replace(/\n/g, ' ');
22
+ return `: ${safe}\n\n`;
23
+ }
@@ -3,3 +3,4 @@ export * from './error.js';
3
3
  export * from './factory.js';
4
4
  export * from './nostr.js';
5
5
  export * from './didcomm.js';
6
+ export * from './http/index.js';
@@ -5,7 +5,7 @@ export type SyncMessageHandler = (msg: any) => void;
5
5
  export type AsyncMessageHandler = (msg: any) => Promise<void>;
6
6
  export type MessageHandler = SyncMessageHandler | AsyncMessageHandler;
7
7
 
8
- export type TransportType = 'nostr' | 'didcomm';
8
+ export type TransportType = 'nostr' | 'didcomm' | 'http';
9
9
 
10
10
  /**
11
11
  * Multi-actor message transport.
@@ -1,9 +1,9 @@
1
1
  import type { AddressUtxo, BitcoinConnection, BTCNetwork } from '@did-btcr2/bitcoin';
2
2
  import type { KeyBytes } from '@did-btcr2/common';
3
3
  import type { SignedBTCR2Update } from '@did-btcr2/cryptosuite';
4
- import { getPublicKey } from '@noble/secp256k1';
5
- import { hexToBytes } from '@noble/hashes/utils';
6
- import { OP, p2tr, p2wpkh, Script, Transaction } from '@scure/btc-signer';
4
+ import type { Signer } from '@did-btcr2/keypair';
5
+ import { concatBytes, hexToBytes } from '@noble/hashes/utils.js';
6
+ import { Address, OutScript, p2pkh, p2tr, p2wpkh, Script, SigHash, Transaction } from '@scure/btc-signer';
7
7
  import type { BeaconProcessResult } from '../resolver.js';
8
8
  import type { SidecarData } from '../types.js';
9
9
  import { BeaconError } from './error.js';
@@ -14,13 +14,79 @@ import type { BeaconService, BeaconSignal } from './interfaces.js';
14
14
  /** Default fee estimator used when none is supplied. ~5 sat/vB static rate. */
15
15
  const DEFAULT_FEE_ESTIMATOR: FeeEstimator = new StaticFeeEstimator(5);
16
16
 
17
+ /**
18
+ * Singleton beacon script kinds. Per the did:btcr2 spec, deterministic DID documents
19
+ * include three beacon services: P2PKH, P2WPKH, and P2TR (taproot key-path) — all
20
+ * derived from the genesis secp256k1 public key. The singleton broadcast path must
21
+ * support signing for all three.
22
+ */
23
+ export type SingletonScriptKind = 'p2pkh' | 'p2wpkh' | 'p2tr';
24
+
17
25
  /**
18
26
  * Conservative vsize estimate for a 1-input P2TR key-path → 1 P2TR change + 1 OP_RETURN(32) tx.
19
- * Taproot key-path witness is a fixed 64-byte Schnorr signature, so vsize is predictable
20
- * without having to sign. Used for fee estimation in the aggregation path where MuSig2
21
- * signatures are produced externally.
27
+ * Stripped 137 + witness 68 (marker + flag + stack-count + sig-len + 64 BIP-340 sig).
28
+ * Weight = 137*4 + 68 = 616, vsize 154, rounded to 160 for headroom.
29
+ */
30
+ export const P2TR_BEACON_TX_VSIZE = 160;
31
+
32
+ /**
33
+ * Conservative vsize estimate for a 1-input P2WPKH → 1 P2WPKH change + 1 OP_RETURN(32) tx.
34
+ * Stripped 125 + witness ≈ 110 (worst-case DER ECDSA sig 72 + sighash byte + 33 pubkey + framing).
35
+ * vsize = ceil((125*4 + 110) / 4) ≈ 153, rounded to 155.
36
+ */
37
+ export const P2WPKH_BEACON_TX_VSIZE = 155;
38
+
39
+ /**
40
+ * Conservative vsize estimate for a 1-input P2PKH → 1 P2PKH change + 1 OP_RETURN(32) tx.
41
+ * Legacy (non-segwit): scriptSig carries the full sig+pubkey (~108 bytes), no witness
42
+ * discount. Stripped ≈ 4 nVer + 1 vin-count + (32+4+1+108+4) input + 1 vout-count +
43
+ * 34 P2PKH-change + 43 OP_RETURN + 4 nLockTime ≈ 236 bytes. vsize = 236, rounded to 240.
44
+ */
45
+ export const P2PKH_BEACON_TX_VSIZE = 240;
46
+
47
+ /** Per-kind vsize lookup for singleton beacon fee estimation. */
48
+ export const SINGLETON_BEACON_TX_VSIZE: Readonly<Record<SingletonScriptKind, number>> = {
49
+ p2pkh : P2PKH_BEACON_TX_VSIZE,
50
+ p2wpkh : P2WPKH_BEACON_TX_VSIZE,
51
+ p2tr : P2TR_BEACON_TX_VSIZE,
52
+ };
53
+
54
+ /**
55
+ * Detect the singleton script kind of a Bitcoin address (P2PKH / P2WPKH / P2TR).
56
+ * The deterministic-DID document emits all three kinds; the broadcast path needs
57
+ * to know which is in use to construct the input and dispatch the signing primitive.
58
+ */
59
+ export function detectSingletonScriptKind(
60
+ bitcoinAddress: string,
61
+ network: BTCNetwork,
62
+ ): SingletonScriptKind {
63
+ const decoded = Address(network).decode(bitcoinAddress);
64
+ if(decoded.type === 'pkh') return 'p2pkh';
65
+ if(decoded.type === 'wpkh') return 'p2wpkh';
66
+ if(decoded.type === 'tr') return 'p2tr';
67
+ throw new BeaconError(
68
+ `Unsupported singleton beacon address type "${decoded.type}". `
69
+ + 'Expected P2PKH, P2WPKH, or P2TR (taproot key-path).',
70
+ 'UNSUPPORTED_BEACON_ADDRESS_TYPE',
71
+ { address: bitcoinAddress, kind: decoded.type }
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Derive the address that `pubkey` produces under the given script kind. Used to
77
+ * fail-fast when a caller wires a signer to a beacon address that the signer's
78
+ * pubkey cannot actually spend.
22
79
  */
23
- const P2TR_BEACON_TX_VSIZE = 140;
80
+ export function deriveSingletonAddress(
81
+ kind: SingletonScriptKind,
82
+ pubkey: KeyBytes,
83
+ network: BTCNetwork,
84
+ ): string {
85
+ if(kind === 'p2pkh') return p2pkh(pubkey, network).address!;
86
+ if(kind === 'p2wpkh') return p2wpkh(pubkey, network).address!;
87
+ // P2TR key-path: x-only internal key (drop the SEC prefix byte).
88
+ return p2tr(pubkey.slice(1, 33), undefined, network).address!;
89
+ }
24
90
 
25
91
  /**
26
92
  * Options accepted by {@link Beacon.buildSignAndBroadcast} and related helpers.
@@ -47,15 +113,28 @@ export interface BeaconTxPlan {
47
113
  utxo: AddressUtxo;
48
114
  /** The fee (sats) already deducted from the change output. */
49
115
  feeSats: bigint;
116
+ /**
117
+ * Singleton beacon script kind, when applicable. Drives the signing dispatch
118
+ * in {@link Beacon.signSinglePartyTx}. Aggregation plans set this to `'p2tr'`.
119
+ */
120
+ scriptKind: SingletonScriptKind;
50
121
  }
51
122
 
52
123
  /**
53
124
  * Build an OP_RETURN script carrying a 32-byte beacon signal.
54
125
  * Exported as a utility so callers building txs outside Beacon (e.g., the aggregation
55
126
  * `onProvideTxData` callback) can produce identical output.
127
+ *
128
+ * Uses the opcode *string* `'RETURN'` rather than the numeric `OP.RETURN`
129
+ * constant because scure's `Script.encode` interprets a number as a byte to
130
+ * push, not as the opcode. The string form emits the bare opcode (0x6a)
131
+ * followed by an `OP_PUSHBYTES_32` push, producing the standard NULL_DATA
132
+ * shape Bitcoin Core's `IsStandard` accepts. The numeric form silently
133
+ * produces `OP_PUSHBYTES_1 0x6a OP_PUSHBYTES_32 <32 bytes>`, which is
134
+ * non-standard and rejected at broadcast with `RPC error -26: scriptpubkey`.
56
135
  */
57
136
  export function opReturnScript(signalBytes: Uint8Array): Uint8Array {
58
- return Script.encode([OP.RETURN, signalBytes]);
137
+ return Script.encode(['RETURN', signalBytes]);
59
138
  }
60
139
 
61
140
  /**
@@ -70,14 +149,14 @@ async function fetchSpendableUtxo(
70
149
  if(!utxos.length) {
71
150
  throw new BeaconError(
72
151
  'No UTXOs found, please fund address!',
73
- 'UNFUNDED_BEACON_ADDRESS', { bitcoinAddress }
152
+ 'UNFUNDED_BEACON_ADDRESS', { address: bitcoinAddress }
74
153
  );
75
154
  }
76
155
  const utxo = utxos.sort((a, b) => b.status.block_height - a.status.block_height).shift();
77
156
  if(!utxo) {
78
157
  throw new BeaconError(
79
158
  'Beacon bitcoin address unfunded or utxos unconfirmed.',
80
- 'UNFUNDED_BEACON_ADDRESS', { bitcoinAddress }
159
+ 'UNFUNDED_BEACON_ADDRESS', { address: bitcoinAddress }
81
160
  );
82
161
  }
83
162
  const prevTxHex = await bitcoin.rest.transaction.getHex(utxo.txid);
@@ -122,11 +201,14 @@ export async function buildAggregationBeaconTx(opts: {
122
201
  throw new BeaconError(
123
202
  `UTXO value (${utxo.value}) insufficient to cover fee (${feeSats}).`,
124
203
  'INSUFFICIENT_FUNDS',
125
- { bitcoinAddress: opts.beaconAddress, utxoValue: utxo.value, fee: feeSats.toString() }
204
+ { address: opts.beaconAddress, valueSats: utxo.value, feeSats }
126
205
  );
127
206
  }
128
207
 
129
- const tx = new Transaction();
208
+ // allowUnknownOutputs: scure does not classify OP_RETURN as a "known" output
209
+ // type because it is unspendable by design. The opt-in flag tells scure we
210
+ // know the output is intentional (the beacon signal embedded in OP_RETURN).
211
+ const tx = new Transaction({ allowUnknownOutputs: true });
130
212
  tx.addInput({
131
213
  txid : utxo.txid,
132
214
  index : utxo.vout,
@@ -144,9 +226,90 @@ export async function buildAggregationBeaconTx(opts: {
144
226
  beaconAddress : opts.beaconAddress,
145
227
  utxo,
146
228
  feeSats,
229
+ scriptKind : 'p2tr',
147
230
  };
148
231
  }
149
232
 
233
+ /**
234
+ * Sign the single input of a singleton beacon transaction. Dispatches to the
235
+ * correct sighash + signature-application path based on `kind`, finalizes the
236
+ * tx, and returns the signed raw hex.
237
+ *
238
+ * - **P2PKH**: legacy ECDSA sighash; scure assembles the scriptSig from `partialSig`.
239
+ * - **P2WPKH**: BIP-143 segwit-v0 sighash (P2PKH-shaped scriptCode); scure assembles
240
+ * the witness from `partialSig`.
241
+ * - **P2TR**: BIP-341 taproot key-path sighash (SIGHASH_DEFAULT); 64-byte BIP-340
242
+ * Schnorr signature applied via `tapKeySig`.
243
+ */
244
+ async function signSingletonInput(
245
+ tx: Transaction,
246
+ inputIdx: number,
247
+ kind: SingletonScriptKind,
248
+ signer: Signer,
249
+ prevOutScript: Uint8Array,
250
+ amount: bigint,
251
+ ): Promise<string> {
252
+ const pubkey = signer.publicKey;
253
+
254
+ if(kind === 'p2pkh') {
255
+ // Legacy sighash: scriptCode is the prev-output P2PKH script itself.
256
+ // scure-btc-signer marks `preimageLegacy` as TypeScript-private but does not
257
+ // expose a public alternative; its own `signIdx` consumes the secret key
258
+ // directly. We need only the sighash bytes so an external Signer can produce
259
+ // the signature, so we reach through the type system here. If scure ever
260
+ // renames this method, the P2PKH path tests fail loudly.
261
+ // TODO: track https://github.com/paulmillr/scure-btc-signer/issues/142 —
262
+ // drop the cast once a public preimage (e.g. `preimageP2PKH`) lands upstream.
263
+ const sighashType = SigHash.ALL;
264
+ const sighash = (tx as unknown as {
265
+ preimageLegacy: (idx: number, prevScript: Uint8Array, hashType: number) => Uint8Array;
266
+ }).preimageLegacy(inputIdx, prevOutScript, sighashType);
267
+ const sig = signer.sign(sighash, 'ecdsa');
268
+ const sigWithType = concatBytes(sig, new Uint8Array([sighashType]));
269
+ tx.updateInput(inputIdx, { partialSig: [[pubkey, sigWithType]] }, true);
270
+ tx.finalize();
271
+ return tx.hex;
272
+ }
273
+
274
+ if(kind === 'p2wpkh') {
275
+ // BIP-143: scriptCode for a P2WPKH input is the equivalent legacy P2PKH script
276
+ // (`OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG`). The P2PKH-shaped
277
+ // script appearing here in P2WPKH signing is intentional, not a bug.
278
+ //
279
+ // Derive the hash from `prevOutScript` (the bytes actually committed on-chain),
280
+ // not by re-hashing `signer.publicKey`. BIP-143 commits to the prev output, so
281
+ // the sighash must follow those bytes exactly. Rebuilding from the signer's
282
+ // pubkey assumes (rather than verifies) the two are in sync.
283
+ const decoded = OutScript.decode(prevOutScript);
284
+ if(decoded.type !== 'wpkh') {
285
+ throw new BeaconError(
286
+ `Expected P2WPKH prev-output script, got "${decoded.type}".`,
287
+ 'PREVOUT_SCRIPT_MISMATCH',
288
+ { kind, observedScriptType: decoded.type }
289
+ );
290
+ }
291
+ const sighashScript = OutScript.encode({ type: 'pkh', hash: decoded.hash });
292
+ const sighashType = SigHash.ALL;
293
+ const sighash = tx.preimageWitnessV0(inputIdx, sighashScript, sighashType, amount);
294
+ const sig = signer.sign(sighash, 'ecdsa');
295
+ const sigWithType = concatBytes(sig, new Uint8Array([sighashType]));
296
+ tx.updateInput(inputIdx, { partialSig: [[pubkey, sigWithType]] }, true);
297
+ tx.finalize();
298
+ return tx.hex;
299
+ }
300
+
301
+ // P2TR key-path. BIP-341 requires signing with the taproot-tweaked secret
302
+ // `d' = taprootTweakPrivKey(d, merkleRoot)`; the verifier checks against the
303
+ // tweaked output internal key `Q = P + tG`. The tweak lives inside the Signer
304
+ // (it needs the secret key), so we use scheme 'bip341' rather than the raw
305
+ // 'bip340' scheme. No script tree on singleton beacons → no merkleRoot.
306
+ const sighash = tx.preimageWitnessV1(inputIdx, [prevOutScript], SigHash.DEFAULT, [amount]);
307
+ const sig = signer.sign(sighash, 'bip341');
308
+ tx.updateInput(inputIdx, { tapKeySig: sig });
309
+ tx.finalize();
310
+ return tx.hex;
311
+ }
312
+
150
313
  /**
151
314
  * Abstract base class for all BTCR2 Beacon types.
152
315
  * A Beacon is a service listed in a BTCR2 DID document that informs resolvers
@@ -192,20 +355,23 @@ export abstract class Beacon {
192
355
  * Broadcasts a signed update as a Beacon Signal to the Bitcoin network.
193
356
  * Used during the update path.
194
357
  * @param {SignedBTCR2Update} signedUpdate The signed BTCR2 update to broadcast.
195
- * @param {KeyBytes} secretKey The secret key for signing the Bitcoin transaction.
358
+ * @param {Signer} signer Signer that produces the signature for the spending input.
359
+ * ECDSA for P2PKH / P2WPKH singletons, Schnorr (BIP-340) for P2TR key-path.
196
360
  * @param {BitcoinConnection} bitcoin The Bitcoin network connection.
197
361
  * @param {BroadcastOptions} [options] Optional broadcast configuration (e.g. fee estimator).
198
362
  * @returns {Promise<SignedBTCR2Update>} The signed update that was broadcast.
199
363
  */
200
364
  abstract broadcastSignal(
201
365
  signedUpdate: SignedBTCR2Update,
202
- secretKey: KeyBytes,
366
+ signer: Signer,
203
367
  bitcoin: BitcoinConnection,
204
368
  options?: BroadcastOptions
205
369
  ): Promise<SignedBTCR2Update>;
206
370
 
207
371
  /**
208
- * Build + sign + broadcast a single-party beacon signal transaction (P2WPKH spend).
372
+ * Build + sign + broadcast a singleton beacon signal transaction. The beacon
373
+ * address's script kind (P2PKH / P2WPKH / P2TR) is detected automatically
374
+ * and the input is constructed and signed accordingly.
209
375
  *
210
376
  * Composed from the three extracted phases ({@link buildSinglePartyTx},
211
377
  * {@link signSinglePartyTx}, {@link broadcastRawTx}) so each piece can be exercised
@@ -214,7 +380,7 @@ export abstract class Beacon {
214
380
  * plumbing (UTXO fetch + OP_RETURN output + change output) is shared.
215
381
  *
216
382
  * @param signalBytes 32-byte payload to embed in OP_RETURN.
217
- * @param secretKey Secret key used to sign the spending input.
383
+ * @param signer Signer used to sign the spending input.
218
384
  * @param bitcoin Bitcoin network connection.
219
385
  * @param options Broadcast options (fee estimator, etc.).
220
386
  * @returns The txid of the broadcast transaction.
@@ -222,7 +388,7 @@ export abstract class Beacon {
222
388
  */
223
389
  protected async buildSignAndBroadcast(
224
390
  signalBytes: Uint8Array,
225
- secretKey: KeyBytes,
391
+ signer: Signer,
226
392
  bitcoin: BitcoinConnection,
227
393
  options?: BroadcastOptions
228
394
  ): Promise<string> {
@@ -230,83 +396,113 @@ export abstract class Beacon {
230
396
  const beaconAddress = this.service.serviceEndpoint.replace('bitcoin:', '');
231
397
  const { utxo, prevTxBytes } = await fetchSpendableUtxo(beaconAddress, bitcoin);
232
398
  const plan = await this.buildSinglePartyTx({
233
- signalBytes, beaconAddress, utxo, prevTxBytes, secretKey, bitcoin, feeEstimator,
399
+ signalBytes, beaconAddress, utxo, prevTxBytes, signer, bitcoin, feeEstimator,
234
400
  });
235
- const signedHex = this.signSinglePartyTx(plan.tx, secretKey);
401
+ const signedHex = await this.signSinglePartyTx(plan, signer);
236
402
  return this.broadcastRawTx(bitcoin, signedHex);
237
403
  }
238
404
 
239
405
  /**
240
- * Build an unsigned P2WPKH single-party beacon tx + probe-sign to determine vsize,
241
- * then rebuild with the real fee. Returns the tx and prev-output metadata.
406
+ * Build an unsigned singleton beacon tx ready for {@link signSinglePartyTx}.
242
407
  *
243
- * The secret key is required here (not just in `signSinglePartyTx`) because the
244
- * two-pass fee estimation requires an actual signature to measure vsize accurately.
408
+ * Detects the beacon address script kind (P2PKH / P2WPKH / P2TR) and configures
409
+ * the input accordingly. Validates that the signer's pubkey produces the beacon
410
+ * address under that script kind — without this check, a misconfigured caller
411
+ * would burn a real UTXO on a tx that fails at broadcast. Fees are computed from
412
+ * the per-kind {@link SINGLETON_BEACON_TX_VSIZE} constant, avoiding any probe-sign
413
+ * round-trip.
245
414
  */
246
415
  protected async buildSinglePartyTx(opts: {
247
416
  signalBytes: Uint8Array;
248
417
  beaconAddress: string;
249
418
  utxo: AddressUtxo;
250
419
  prevTxBytes: Uint8Array;
251
- secretKey: KeyBytes;
420
+ signer: Signer;
252
421
  bitcoin: BitcoinConnection;
253
422
  feeEstimator: FeeEstimator;
254
423
  }): Promise<BeaconTxPlan> {
255
- const pubkey = this.#derivePubkey(opts.secretKey);
256
- const witnessOut = p2wpkh(pubkey, opts.bitcoin.data);
257
- const witnessScript = witnessOut.script;
424
+ const network = opts.bitcoin.data;
425
+ const pubkey = opts.signer.publicKey;
426
+ const kind = detectSingletonScriptKind(opts.beaconAddress, network);
258
427
 
259
- const build = (feeSats: bigint): Transaction => {
260
- const tx = new Transaction();
261
- tx.addInput({
262
- txid : opts.utxo.txid,
263
- index : opts.utxo.vout,
264
- nonWitnessUtxo : opts.prevTxBytes,
265
- witnessUtxo : { amount: BigInt(opts.utxo.value), script: witnessScript },
266
- });
267
- tx.addOutputAddress(
268
- opts.beaconAddress,
269
- BigInt(opts.utxo.value) - feeSats,
270
- opts.bitcoin.data,
428
+ const derivedAddress = deriveSingletonAddress(kind, pubkey, network);
429
+ if(derivedAddress !== opts.beaconAddress) {
430
+ throw new BeaconError(
431
+ `Signer pubkey produces ${kind.toUpperCase()} address "${derivedAddress}", but beacon address is "${opts.beaconAddress}".`,
432
+ 'SIGNER_KEY_MISMATCH',
433
+ { kind, address: opts.beaconAddress, derivedAddress }
271
434
  );
272
- tx.addOutput({ script: opReturnScript(opts.signalBytes), amount: 0n });
273
- return tx;
274
- };
275
-
276
- // First pass: sign with zero fee to measure vsize.
277
- const probe = build(0n);
278
- probe.signIdx(opts.secretKey, 0);
279
- probe.finalize();
280
- const vsize = probe.vsize;
435
+ }
281
436
 
282
- const feeSats = await opts.feeEstimator.estimateFee(vsize);
283
- if(BigInt(opts.utxo.value) <= feeSats) {
437
+ const feeSats = await opts.feeEstimator.estimateFee(SINGLETON_BEACON_TX_VSIZE[kind]);
438
+ const amount = BigInt(opts.utxo.value);
439
+ if(amount <= feeSats) {
284
440
  throw new BeaconError(
285
441
  `UTXO value (${opts.utxo.value}) insufficient to cover fee (${feeSats}).`,
286
442
  'INSUFFICIENT_FUNDS',
287
- { bitcoinAddress: opts.beaconAddress, utxoValue: opts.utxo.value, fee: feeSats.toString() }
443
+ { address: opts.beaconAddress, valueSats: opts.utxo.value, feeSats }
288
444
  );
289
445
  }
290
446
 
291
- // Second pass: real fee.
292
- const tx = build(feeSats);
447
+ // allowUnknownOutputs: scure does not classify OP_RETURN as a "known" output
448
+ // type because it is unspendable by design. The opt-in flag tells scure we
449
+ // know the output is intentional (the beacon signal embedded in OP_RETURN).
450
+ const tx = new Transaction({ allowUnknownOutputs: true });
451
+
452
+ // Per-kind input setup: P2PKH consumes via nonWitnessUtxo only (legacy);
453
+ // P2WPKH and P2TR also carry a witnessUtxo (and P2TR carries tapInternalKey).
454
+ let prevOutScript: Uint8Array;
455
+ if(kind === 'p2pkh') {
456
+ prevOutScript = p2pkh(pubkey, network).script;
457
+ tx.addInput({
458
+ txid : opts.utxo.txid,
459
+ index : opts.utxo.vout,
460
+ nonWitnessUtxo : opts.prevTxBytes,
461
+ });
462
+ } else if(kind === 'p2wpkh') {
463
+ prevOutScript = p2wpkh(pubkey, network).script;
464
+ tx.addInput({
465
+ txid : opts.utxo.txid,
466
+ index : opts.utxo.vout,
467
+ nonWitnessUtxo : opts.prevTxBytes,
468
+ witnessUtxo : { amount, script: prevOutScript },
469
+ });
470
+ } else {
471
+ // p2tr key-path
472
+ const internalKey = pubkey.slice(1, 33);
473
+ prevOutScript = p2tr(internalKey, undefined, network).script;
474
+ tx.addInput({
475
+ txid : opts.utxo.txid,
476
+ index : opts.utxo.vout,
477
+ nonWitnessUtxo : opts.prevTxBytes,
478
+ witnessUtxo : { amount, script: prevOutScript },
479
+ tapInternalKey : internalKey,
480
+ });
481
+ }
482
+
483
+ tx.addOutputAddress(opts.beaconAddress, amount - feeSats, network);
484
+ tx.addOutput({ script: opReturnScript(opts.signalBytes), amount: 0n });
485
+
293
486
  return {
294
487
  tx,
295
- prevOutScripts : [witnessScript],
296
- prevOutValues : [BigInt(opts.utxo.value)],
488
+ prevOutScripts : [prevOutScript],
489
+ prevOutValues : [amount],
297
490
  beaconAddress : opts.beaconAddress,
298
491
  utxo : opts.utxo,
299
492
  feeSats,
493
+ scriptKind : kind,
300
494
  };
301
495
  }
302
496
 
303
497
  /**
304
498
  * Sign + finalize the unsigned single-party tx and return its raw hex.
499
+ * Dispatches to the correct signing primitive based on `plan.scriptKind`.
305
500
  */
306
- protected signSinglePartyTx(tx: Transaction, secretKey: KeyBytes): string {
307
- tx.signIdx(secretKey, 0);
308
- tx.finalize();
309
- return tx.hex;
501
+ protected async signSinglePartyTx(plan: BeaconTxPlan, signer: Signer): Promise<string> {
502
+ return signSingletonInput(
503
+ plan.tx, 0, plan.scriptKind, signer,
504
+ plan.prevOutScripts[0]!, plan.prevOutValues[0]!,
505
+ );
310
506
  }
311
507
 
312
508
  /**
@@ -315,9 +511,4 @@ export abstract class Beacon {
315
511
  protected async broadcastRawTx(bitcoin: BitcoinConnection, rawHex: string): Promise<string> {
316
512
  return bitcoin.rest.transaction.send(rawHex);
317
513
  }
318
-
319
- /** Derive the compressed secp256k1 public key from a raw secret key. */
320
- #derivePubkey(secretKey: KeyBytes): Uint8Array {
321
- return getPublicKey(secretKey, true);
322
- }
323
514
  }
@@ -1,7 +1,7 @@
1
1
  import type { BitcoinConnection } from '@did-btcr2/bitcoin';
2
- import type { KeyBytes } from '@did-btcr2/common';
3
2
  import { canonicalHash, canonicalize, decode, encode, 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';
@@ -118,7 +118,7 @@ export class CASBeacon extends Beacon {
118
118
  * and broadcast are delegated to {@link Beacon.buildSignAndBroadcast}.
119
119
  *
120
120
  * @param {SignedBTCR2Update} signedUpdate The signed BTCR2 update to broadcast.
121
- * @param {KeyBytes} secretKey The secret key for signing the Bitcoin transaction.
121
+ * @param {Signer} signer Signer that produces the ECDSA signature for the Bitcoin transaction.
122
122
  * @param {BitcoinConnection} bitcoin The Bitcoin network connection.
123
123
  * @param {CASBroadcastOptions} [options] Optional broadcast configuration, including a
124
124
  * `casPublish` callback to publish the announcement off-chain and a `feeEstimator`.
@@ -127,7 +127,7 @@ export class CASBeacon extends Beacon {
127
127
  */
128
128
  async broadcastSignal(
129
129
  signedUpdate: SignedBTCR2Update,
130
- secretKey: KeyBytes,
130
+ signer: Signer,
131
131
  bitcoin: BitcoinConnection,
132
132
  options?: CASBroadcastOptions
133
133
  ): Promise<SignedBTCR2Update> {
@@ -144,7 +144,7 @@ export class CASBeacon extends Beacon {
144
144
  const announcementHash = hash(canonicalize(casAnnouncement));
145
145
 
146
146
  // Delegate UTXO selection, PSBT construction, fee estimation, signing, and broadcast
147
- await this.buildSignAndBroadcast(announcementHash, secretKey, bitcoin, options);
147
+ await this.buildSignAndBroadcast(announcementHash, signer, bitcoin, options);
148
148
 
149
149
  // Publish CAS Announcement to content-addressed store if callback provided
150
150
  if(options?.casPublish) {