@utxopia/sdk 0.1.0-alpha.2 → 0.1.0-alpha.3

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 (142) hide show
  1. package/README.md +214 -108
  2. package/{packages/sdk/dist → dist}/client.d.ts +25 -1
  3. package/{packages/sdk/dist → dist}/client.js +36 -1
  4. package/{packages/sdk/dist → dist}/crypto-ed25519.d.ts +14 -0
  5. package/{packages/sdk/dist → dist}/crypto-ed25519.js +14 -0
  6. package/{packages/sdk/dist → dist}/index.d.ts +3 -3
  7. package/{packages/sdk/dist → dist}/index.js +3 -3
  8. package/{packages/sdk/dist → dist}/instructions.d.ts +28 -11
  9. package/{packages/sdk/dist → dist}/instructions.js +45 -15
  10. package/{packages/sdk/dist → dist}/stealth.d.ts +104 -9
  11. package/{packages/sdk/dist → dist}/stealth.js +146 -14
  12. package/{packages/sdk/dist → dist}/taproot.d.ts +39 -2
  13. package/{packages/sdk/dist → dist}/taproot.js +54 -2
  14. package/package.json +86 -63
  15. package/src/announcement-client.ts +457 -0
  16. package/src/auditor-ciphertext.ts +181 -0
  17. package/src/auditor.ts +409 -0
  18. package/src/bitcoin/ika.ts +103 -0
  19. package/src/bitcoin/index.ts +5 -0
  20. package/src/bound-params.ts +322 -0
  21. package/src/chadbuffer.ts +603 -0
  22. package/src/circomlibjs.d.ts +51 -0
  23. package/src/claim-link.ts +53 -0
  24. package/src/client.ts +638 -0
  25. package/src/commitment-tree.ts +736 -0
  26. package/src/config.ts +772 -0
  27. package/src/core/esplora.ts +332 -0
  28. package/src/core/mempool.ts +159 -0
  29. package/src/crypto-babyjub.ts +385 -0
  30. package/src/crypto-ed25519.ts +297 -0
  31. package/src/crypto.ts +199 -0
  32. package/src/event-client.ts +231 -0
  33. package/src/events.ts +384 -0
  34. package/src/explorer.ts +300 -0
  35. package/src/index.ts +902 -0
  36. package/src/instructions.ts +2820 -0
  37. package/src/keys.ts +1228 -0
  38. package/src/logger.ts +41 -0
  39. package/src/magicblock.ts +278 -0
  40. package/src/merkle.ts +197 -0
  41. package/src/note.ts +754 -0
  42. package/src/pda.ts +516 -0
  43. package/src/pool-state.ts +176 -0
  44. package/src/poseidon.ts +175 -0
  45. package/src/prover/index.ts +19 -0
  46. package/src/prover/mobile.ts +303 -0
  47. package/src/prover/web.ts +771 -0
  48. package/src/psbt.ts +333 -0
  49. package/src/selective-disclosure.ts +284 -0
  50. package/src/sender-memo.ts +343 -0
  51. package/src/snarkjs.d.ts +19 -0
  52. package/src/sns-resolver.ts +333 -0
  53. package/src/solana/connection.ts +189 -0
  54. package/src/solana/priority-fee.ts +201 -0
  55. package/src/spend-doc.ts +163 -0
  56. package/src/stealth.ts +1477 -0
  57. package/src/taproot.ts +707 -0
  58. package/src/token-registry.ts +207 -0
  59. package/src/utils/encoding.ts +33 -0
  60. package/src/vk-registry.ts +295 -0
  61. package/LICENSE +0 -21
  62. package/packages/btc-client/src/esplora-client.ts +0 -153
  63. package/packages/btc-client/src/index.ts +0 -3
  64. package/packages/btc-client/src/op-return.ts +0 -93
  65. package/packages/btc-client/src/types.ts +0 -112
  66. package/packages/sdk/README.md +0 -277
  67. /package/{packages/sdk/dist → dist}/announcement-client.d.ts +0 -0
  68. /package/{packages/sdk/dist → dist}/announcement-client.js +0 -0
  69. /package/{packages/sdk/dist → dist}/auditor-ciphertext.d.ts +0 -0
  70. /package/{packages/sdk/dist → dist}/auditor-ciphertext.js +0 -0
  71. /package/{packages/sdk/dist → dist}/auditor.d.ts +0 -0
  72. /package/{packages/sdk/dist → dist}/auditor.js +0 -0
  73. /package/{packages/sdk/dist → dist}/bitcoin/ika.d.ts +0 -0
  74. /package/{packages/sdk/dist → dist}/bitcoin/ika.js +0 -0
  75. /package/{packages/sdk/dist → dist}/bitcoin/index.d.ts +0 -0
  76. /package/{packages/sdk/dist → dist}/bitcoin/index.js +0 -0
  77. /package/{packages/sdk/dist → dist}/bound-params.d.ts +0 -0
  78. /package/{packages/sdk/dist → dist}/bound-params.js +0 -0
  79. /package/{packages/sdk/dist → dist}/chadbuffer.d.ts +0 -0
  80. /package/{packages/sdk/dist → dist}/chadbuffer.js +0 -0
  81. /package/{packages/sdk/dist → dist}/claim-link.d.ts +0 -0
  82. /package/{packages/sdk/dist → dist}/claim-link.js +0 -0
  83. /package/{packages/sdk/dist → dist}/commitment-tree.d.ts +0 -0
  84. /package/{packages/sdk/dist → dist}/commitment-tree.js +0 -0
  85. /package/{packages/sdk/dist → dist}/config.d.ts +0 -0
  86. /package/{packages/sdk/dist → dist}/config.js +0 -0
  87. /package/{packages/sdk/dist → dist}/core/esplora.d.ts +0 -0
  88. /package/{packages/sdk/dist → dist}/core/esplora.js +0 -0
  89. /package/{packages/sdk/dist → dist}/core/mempool.d.ts +0 -0
  90. /package/{packages/sdk/dist → dist}/core/mempool.js +0 -0
  91. /package/{packages/sdk/dist → dist}/crypto-babyjub.d.ts +0 -0
  92. /package/{packages/sdk/dist → dist}/crypto-babyjub.js +0 -0
  93. /package/{packages/sdk/dist → dist}/crypto.d.ts +0 -0
  94. /package/{packages/sdk/dist → dist}/crypto.js +0 -0
  95. /package/{packages/sdk/dist → dist}/event-client.d.ts +0 -0
  96. /package/{packages/sdk/dist → dist}/event-client.js +0 -0
  97. /package/{packages/sdk/dist → dist}/events.d.ts +0 -0
  98. /package/{packages/sdk/dist → dist}/events.js +0 -0
  99. /package/{packages/sdk/dist → dist}/explorer.d.ts +0 -0
  100. /package/{packages/sdk/dist → dist}/explorer.js +0 -0
  101. /package/{packages/sdk/dist → dist}/keys.d.ts +0 -0
  102. /package/{packages/sdk/dist → dist}/keys.js +0 -0
  103. /package/{packages/sdk/dist → dist}/logger.d.ts +0 -0
  104. /package/{packages/sdk/dist → dist}/logger.js +0 -0
  105. /package/{packages/sdk/dist → dist}/magicblock.d.ts +0 -0
  106. /package/{packages/sdk/dist → dist}/magicblock.js +0 -0
  107. /package/{packages/sdk/dist → dist}/merkle.d.ts +0 -0
  108. /package/{packages/sdk/dist → dist}/merkle.js +0 -0
  109. /package/{packages/sdk/dist → dist}/note.d.ts +0 -0
  110. /package/{packages/sdk/dist → dist}/note.js +0 -0
  111. /package/{packages/sdk/dist → dist}/pda.d.ts +0 -0
  112. /package/{packages/sdk/dist → dist}/pda.js +0 -0
  113. /package/{packages/sdk/dist → dist}/pool-state.d.ts +0 -0
  114. /package/{packages/sdk/dist → dist}/pool-state.js +0 -0
  115. /package/{packages/sdk/dist → dist}/poseidon.d.ts +0 -0
  116. /package/{packages/sdk/dist → dist}/poseidon.js +0 -0
  117. /package/{packages/sdk/dist → dist}/prover/index.d.ts +0 -0
  118. /package/{packages/sdk/dist → dist}/prover/index.js +0 -0
  119. /package/{packages/sdk/dist → dist}/prover/mobile.d.ts +0 -0
  120. /package/{packages/sdk/dist → dist}/prover/mobile.js +0 -0
  121. /package/{packages/sdk/dist → dist}/prover/web.d.ts +0 -0
  122. /package/{packages/sdk/dist → dist}/prover/web.js +0 -0
  123. /package/{packages/sdk/dist → dist}/psbt.d.ts +0 -0
  124. /package/{packages/sdk/dist → dist}/psbt.js +0 -0
  125. /package/{packages/sdk/dist → dist}/selective-disclosure.d.ts +0 -0
  126. /package/{packages/sdk/dist → dist}/selective-disclosure.js +0 -0
  127. /package/{packages/sdk/dist → dist}/sender-memo.d.ts +0 -0
  128. /package/{packages/sdk/dist → dist}/sender-memo.js +0 -0
  129. /package/{packages/sdk/dist → dist}/sns-resolver.d.ts +0 -0
  130. /package/{packages/sdk/dist → dist}/sns-resolver.js +0 -0
  131. /package/{packages/sdk/dist → dist}/solana/connection.d.ts +0 -0
  132. /package/{packages/sdk/dist → dist}/solana/connection.js +0 -0
  133. /package/{packages/sdk/dist → dist}/solana/priority-fee.d.ts +0 -0
  134. /package/{packages/sdk/dist → dist}/solana/priority-fee.js +0 -0
  135. /package/{packages/sdk/dist → dist}/spend-doc.d.ts +0 -0
  136. /package/{packages/sdk/dist → dist}/spend-doc.js +0 -0
  137. /package/{packages/sdk/dist → dist}/token-registry.d.ts +0 -0
  138. /package/{packages/sdk/dist → dist}/token-registry.js +0 -0
  139. /package/{packages/sdk/dist → dist}/utils/encoding.d.ts +0 -0
  140. /package/{packages/sdk/dist → dist}/utils/encoding.js +0 -0
  141. /package/{packages/sdk/dist → dist}/vk-registry.d.ts +0 -0
  142. /package/{packages/sdk/dist → dist}/vk-registry.js +0 -0
package/src/taproot.ts ADDED
@@ -0,0 +1,707 @@
1
+ /**
2
+ * Taproot address utilities for UTXOpia
3
+ *
4
+ * Generates commitment-bound Taproot addresses following BIP-340/341.
5
+ * The deposit address is derived from the commitment, ensuring
6
+ * cryptographic binding between the BTC deposit and the claim.
7
+ */
8
+
9
+ import { sha256 } from "@noble/hashes/sha2.js";
10
+ import { taggedHash, hexToBytes, bytesToHex } from "./crypto";
11
+ import * as bech32 from "bech32";
12
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
13
+
14
+ // Never use the secp256k1 generator as a custody key. Its discrete log is
15
+ // public, so a key-path output derived from it is sweepable by anyone.
16
+ const UNSAFE_GENERATOR_INTERNAL_KEY_HEX =
17
+ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; // secp256k1 generator x-coord
18
+
19
+ /**
20
+ * Derive a Taproot address from a commitment
21
+ *
22
+ * Following BIP-341:
23
+ * tweak = H_TapTweak(internal_key || commitment)
24
+ * output_key = internal_key + tweak * G
25
+ * address = bech32m encode(output_key)
26
+ *
27
+ * @param commitment - 32-byte commitment hash
28
+ * @param network - 'mainnet' | 'testnet' | 'regtest'
29
+ * @param internalKey - Configured FROST/Ika custody key (x-only, 32 bytes; required)
30
+ * @returns Taproot address (bc1p... or tb1p...)
31
+ */
32
+ export function deriveTaprootAddress(
33
+ commitment: Uint8Array,
34
+ network: "mainnet" | "testnet" | "regtest" = "testnet",
35
+ internalKey?: Uint8Array
36
+ ): {
37
+ address: string;
38
+ outputKey: Uint8Array;
39
+ tweak: Uint8Array;
40
+ /** y-parity of the output key — the low bit of a script-path control block. */
41
+ parity: number;
42
+ } {
43
+ if (!internalKey) {
44
+ throw new Error("Internal key is required; pass the configured FROST/Ika x-only key");
45
+ }
46
+ const key = internalKey;
47
+ if (key.length !== 32) {
48
+ throw new Error("Internal key must be 32 bytes (x-only)");
49
+ }
50
+ // Refuse the secp256k1 generator x-coord (discrete log = 1): its key-path secret
51
+ // 1+tweak is publicly computable, so any observer could sweep the output.
52
+ if (bytesToHex(key) === UNSAFE_GENERATOR_INTERNAL_KEY_HEX) {
53
+ throw new Error("Refusing to derive Taproot with the generator internal key; pass a real FROST/Ika key");
54
+ }
55
+
56
+ // Compute tweak = H_TapTweak(internal_key || commitment)
57
+ const tweakInput = new Uint8Array(64);
58
+ tweakInput.set(key, 0);
59
+ tweakInput.set(commitment, 32);
60
+ const tweak = taggedHash("TapTweak", tweakInput);
61
+
62
+ // BIP-341: output_key = lift_x(internal_key) + tweak * G
63
+ const tweakScalar = bytesToBigInt(tweak);
64
+ const SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n;
65
+ if (tweakScalar >= SECP256K1_ORDER) {
66
+ throw new Error("Tweak scalar exceeds curve order");
67
+ }
68
+
69
+ // lift_x: recover full point from x-only key (even y per BIP-340)
70
+ const keyHex = "02" + bytesToHex(key);
71
+ const internalPoint = secp256k1.Point.fromHex(keyHex);
72
+ // tweak * G
73
+ const tweakPoint = secp256k1.Point.BASE.multiply(tweakScalar);
74
+ // Q = P + t*G
75
+ const outputPoint = internalPoint.add(tweakPoint);
76
+ // x-only output key (BIP-340): drop prefix from compressed form
77
+ const outputKeyHex = outputPoint.toHex(true); // 33-byte compressed hex
78
+ const outputKey = hexToBytes(outputKeyHex.slice(2)); // drop "02"/"03" prefix
79
+
80
+ // Encode as bech32m address
81
+ const hrp = network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
82
+ const words = bech32.bech32m.toWords(outputKey);
83
+ // Witness version 1 for taproot
84
+ const address = bech32.bech32m.encode(hrp, [1, ...words]);
85
+
86
+ return {
87
+ address,
88
+ outputKey,
89
+ tweak,
90
+ parity: outputKeyHex.startsWith("03") ? 1 : 0,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Per-deposit commitment carried in a `verify_deposit` (disc 25) address's tapleaf.
96
+ *
97
+ * Both keys are hashed in. Binding the note key alone would leave the ephemeral
98
+ * pubkey caller-chosen on the Solana side: the credited amount and owner would
99
+ * still be right, but a substituted ephemeral key makes the stealth announcement
100
+ * undecryptable and the recipient never finds their note.
101
+ *
102
+ * Feed the result to `deriveDepositAddress`.
103
+ */
104
+ export function depositTweakCommitment(
105
+ notePublicKey: Uint8Array,
106
+ ephemeralPubkey: Uint8Array
107
+ ): Uint8Array {
108
+ if (notePublicKey.length !== 32 || ephemeralPubkey.length !== 32) {
109
+ throw new Error("notePublicKey and ephemeralPubkey must be 32 bytes");
110
+ }
111
+ const material = new Uint8Array(64);
112
+ material.set(notePublicKey, 0);
113
+ material.set(ephemeralPubkey, 32);
114
+ return sha256(material);
115
+ }
116
+
117
+ /**
118
+ * BIP-341's suggested NUMS point, used as the deposit address's internal key.
119
+ *
120
+ * Nobody knows its discrete log, so the key path is unspendable and custody
121
+ * rests entirely on the script path. That is deliberate: Ika's MPC cannot sign
122
+ * for a tweaked key, so an address whose internal key were the dWallet key plus
123
+ * a per-deposit tweak would be unspendable by the custodian meant to sweep it.
124
+ */
125
+ export const DEPOSIT_NUMS_INTERNAL_KEY = hexToBytes(
126
+ "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"
127
+ );
128
+
129
+ /**
130
+ * The single tapleaf a deposit address commits to:
131
+ *
132
+ * ```text
133
+ * <commitment> OP_DROP <ika_xonly> OP_CHECKSIG
134
+ * ```
135
+ *
136
+ * The commitment rides in the script purely to make the leaf — and so the
137
+ * address — unique per deposit; `OP_DROP` discards it at spend time. Only the
138
+ * dWallet key can satisfy the `OP_CHECKSIG`, and it signs untweaked.
139
+ */
140
+ export function depositLeafScript(
141
+ commitment: Uint8Array,
142
+ ikaXOnlyPubkey: Uint8Array
143
+ ): Uint8Array {
144
+ if (commitment.length !== 32 || ikaXOnlyPubkey.length !== 32) {
145
+ throw new Error("commitment and ikaXOnlyPubkey must be 32 bytes");
146
+ }
147
+ const script = new Uint8Array(68);
148
+ script[0] = 0x20; // push 32 bytes
149
+ script.set(commitment, 1);
150
+ script[33] = 0x75; // OP_DROP
151
+ script[34] = 0x20; // push 32 bytes
152
+ script.set(ikaXOnlyPubkey, 35);
153
+ script[67] = 0xac; // OP_CHECKSIG
154
+ return script;
155
+ }
156
+
157
+ /**
158
+ * Derive a deposit address bound to `commitment`, spendable only by the pool's
159
+ * Ika dWallet via the script path.
160
+ *
161
+ * Returns everything the sweeper needs to spend it: a script-path witness is
162
+ * `[signature, leafScript, controlBlock]`.
163
+ */
164
+ export function deriveDepositAddress(
165
+ commitment: Uint8Array,
166
+ ikaXOnlyPubkey: Uint8Array,
167
+ network: "mainnet" | "testnet" | "regtest" = "testnet"
168
+ ): {
169
+ address: string;
170
+ outputKey: Uint8Array;
171
+ leafScript: Uint8Array;
172
+ leafHash: Uint8Array;
173
+ controlBlock: Uint8Array;
174
+ } {
175
+ const leafScript = depositLeafScript(commitment, ikaXOnlyPubkey);
176
+ // One leaf, so the leaf hash IS the merkle root.
177
+ const leafHash = computeTapLeafHash(leafScript);
178
+ const { address, outputKey, parity } = deriveTaprootAddress(
179
+ leafHash,
180
+ network,
181
+ DEPOSIT_NUMS_INTERNAL_KEY
182
+ );
183
+
184
+ // <leaf_version | parity> || internal_key. No merkle path: single leaf.
185
+ const controlBlock = new Uint8Array(33);
186
+ controlBlock[0] = 0xc0 | parity;
187
+ controlBlock.set(DEPOSIT_NUMS_INTERNAL_KEY, 1);
188
+
189
+ return { address, outputKey, leafScript, leafHash, controlBlock };
190
+ }
191
+
192
+ /**
193
+ * Verify that a Taproot address is correctly derived from a commitment
194
+ *
195
+ * @param address - Taproot address to verify
196
+ * @param commitment - Expected commitment
197
+ * @param internalKey - Configured FROST/Ika custody key (x-only, 32 bytes; required)
198
+ * @returns true if address matches expected derivation
199
+ */
200
+ export function verifyTaprootAddress(
201
+ address: string,
202
+ commitment: Uint8Array,
203
+ internalKey?: Uint8Array
204
+ ): boolean {
205
+ try {
206
+ const decoded = bech32.bech32m.decode(address);
207
+ const witnessVersion = decoded.words[0];
208
+ if (witnessVersion !== 1) {
209
+ return false;
210
+ }
211
+
212
+ const actualOutputKey = new Uint8Array(
213
+ bech32.bech32m.fromWords(decoded.words.slice(1))
214
+ );
215
+
216
+ const network = decoded.prefix === "bc" ? "mainnet" : "testnet";
217
+ const expected = deriveTaprootAddress(commitment, network, internalKey);
218
+
219
+ return arraysEqual(actualOutputKey, expected.outputKey);
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Generate a P2TR (Pay-to-Taproot) script pubkey
227
+ *
228
+ * @param outputKey - 32-byte output key (x-only)
229
+ * @returns Script pubkey bytes (OP_1 <32-byte key>)
230
+ */
231
+ export function createP2TRScriptPubkey(outputKey: Uint8Array): Uint8Array {
232
+ if (outputKey.length !== 32) {
233
+ throw new Error("Output key must be 32 bytes");
234
+ }
235
+
236
+ // OP_1 (0x51) + push 32 bytes (0x20) + key
237
+ const script = new Uint8Array(34);
238
+ script[0] = 0x51; // OP_1 (witness version 1)
239
+ script[1] = 0x20; // Push 32 bytes
240
+ script.set(outputKey, 2);
241
+
242
+ return script;
243
+ }
244
+
245
+ /**
246
+ * Parse P2TR script pubkey to extract output key
247
+ *
248
+ * @param scriptPubkey - Script pubkey bytes
249
+ * @returns Output key or null if not P2TR
250
+ */
251
+ export function parseP2TRScriptPubkey(
252
+ scriptPubkey: Uint8Array
253
+ ): Uint8Array | null {
254
+ if (scriptPubkey.length !== 34) return null;
255
+ if (scriptPubkey[0] !== 0x51) return null; // OP_1
256
+ if (scriptPubkey[1] !== 0x20) return null; // Push 32
257
+
258
+ return scriptPubkey.slice(2);
259
+ }
260
+
261
+ /**
262
+ * Validate a Bitcoin address format
263
+ */
264
+ export function isValidBitcoinAddress(address: string): {
265
+ valid: boolean;
266
+ type: "p2pkh" | "p2sh" | "p2wpkh" | "p2wsh" | "p2tr" | "unknown";
267
+ network: "mainnet" | "testnet" | "unknown";
268
+ } {
269
+ try {
270
+ // Bech32m (Taproot)
271
+ if (address.startsWith("bc1p") || address.startsWith("tb1p")) {
272
+ const decoded = bech32.bech32m.decode(address);
273
+ if (decoded.words[0] === 1 && decoded.words.length === 53) {
274
+ return {
275
+ valid: true,
276
+ type: "p2tr",
277
+ network: decoded.prefix === "bc" ? "mainnet" : "testnet",
278
+ };
279
+ }
280
+ }
281
+
282
+ // Bech32 (SegWit v0)
283
+ if (
284
+ address.startsWith("bc1q") ||
285
+ address.startsWith("tb1q") ||
286
+ address.startsWith("bcrt1q")
287
+ ) {
288
+ const decoded = bech32.bech32.decode(address);
289
+ if (decoded.words[0] === 0) {
290
+ const type = decoded.words.length === 33 ? "p2wpkh" : "p2wsh";
291
+ return {
292
+ valid: true,
293
+ type,
294
+ network:
295
+ decoded.prefix === "bc"
296
+ ? "mainnet"
297
+ : decoded.prefix === "bcrt"
298
+ ? "testnet"
299
+ : "testnet",
300
+ };
301
+ }
302
+ }
303
+
304
+ // Legacy (base58check)
305
+ const len = address.length;
306
+ if (len >= 26 && len <= 35) {
307
+ if (address.startsWith("1")) {
308
+ return { valid: true, type: "p2pkh", network: "mainnet" };
309
+ }
310
+ if (address.startsWith("3")) {
311
+ return { valid: true, type: "p2sh", network: "mainnet" };
312
+ }
313
+ if (address.startsWith("m") || address.startsWith("n")) {
314
+ return { valid: true, type: "p2pkh", network: "testnet" };
315
+ }
316
+ if (address.startsWith("2")) {
317
+ return { valid: true, type: "p2sh", network: "testnet" };
318
+ }
319
+ }
320
+
321
+ return { valid: false, type: "unknown", network: "unknown" };
322
+ } catch {
323
+ return { valid: false, type: "unknown", network: "unknown" };
324
+ }
325
+ }
326
+
327
+ function bytesToBigInt(bytes: Uint8Array): bigint {
328
+ let result = 0n;
329
+ for (let i = 0; i < bytes.length; i++) {
330
+ result = (result << 8n) | BigInt(bytes[i]);
331
+ }
332
+ return result;
333
+ }
334
+
335
+ function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
336
+ if (a.length !== b.length) return false;
337
+ for (let i = 0; i < a.length; i++) {
338
+ if (a[i] !== b[i]) return false;
339
+ }
340
+ return true;
341
+ }
342
+
343
+ // ========== OP_RETURN Helpers ==========
344
+
345
+ /** Destination chain encoded in the compact deposit OP_RETURN header. */
346
+ export const DEPOSIT_DESTINATION_CHAIN = {
347
+ SOLANA: 1,
348
+ } as const;
349
+
350
+ export type DepositDestinationChain =
351
+ (typeof DEPOSIT_DESTINATION_CHAIN)[keyof typeof DEPOSIT_DESTINATION_CHAIN];
352
+
353
+ /** Bitcoin network encoded in the compact deposit OP_RETURN header. */
354
+ export const DEPOSIT_BITCOIN_NETWORK = {
355
+ MAINNET: 0,
356
+ TESTNET4: 2,
357
+ REGTEST: 3,
358
+ } as const;
359
+
360
+ export type DepositBitcoinNetwork =
361
+ (typeof DEPOSIT_BITCOIN_NETWORK)[keyof typeof DEPOSIT_BITCOIN_NETWORK];
362
+
363
+ export const DEPOSIT_OP_RETURN_VERSION = 1;
364
+ export const DEPOSIT_POOL_TAG_SIZE = 8;
365
+ /** OP_RETURN payload size for deposit: header(1) + poolTag(8) + ephemeralPubkey(32) + notePublicKey(32). */
366
+ export const DEPOSIT_OP_RETURN_SIZE = 73;
367
+
368
+ export interface DepositOpReturnContext {
369
+ destinationChain: DepositDestinationChain;
370
+ bitcoinNetwork: DepositBitcoinNetwork;
371
+ poolTag: Uint8Array;
372
+ }
373
+
374
+ export interface ParsedDepositOpReturn extends DepositOpReturnContext {
375
+ version: number;
376
+ ephemeralPubkey: Uint8Array;
377
+ notePublicKey: Uint8Array;
378
+ }
379
+
380
+ /**
381
+ * Build the compact deposit OP_RETURN payload for non-interactive stealth deposits.
382
+ *
383
+ * Layout:
384
+ * [0] header — version + destination chain + Bitcoin network
385
+ * [1..9) poolTag — destination deployment tag
386
+ * [9..41) ephemeralPubkey — Ed25519 public key
387
+ * [41..73) notePublicKey — Note public key (Poseidon hash)
388
+ *
389
+ * Amount is no longer embedded — the on-chain program reads it from the BTC output.
390
+ * The caller wraps this in an OP_RETURN script (0x6a + push opcode + payload).
391
+ */
392
+ export function buildDepositOpReturn(
393
+ ephemeralPubkey: Uint8Array,
394
+ notePublicKey: Uint8Array,
395
+ context: DepositOpReturnContext,
396
+ ): Uint8Array {
397
+ if (ephemeralPubkey.length !== 32) throw new Error("ephemeralPubkey must be 32 bytes");
398
+ if (notePublicKey.length !== 32) throw new Error("notePublicKey must be 32 bytes");
399
+ validateDepositOpReturnContext(context);
400
+
401
+ const payload = new Uint8Array(DEPOSIT_OP_RETURN_SIZE);
402
+ payload[0] = encodeDepositOpReturnHeader(context.destinationChain, context.bitcoinNetwork);
403
+ payload.set(context.poolTag, 1);
404
+ payload.set(ephemeralPubkey, 1 + DEPOSIT_POOL_TAG_SIZE);
405
+ payload.set(notePublicKey, 1 + DEPOSIT_POOL_TAG_SIZE + 32);
406
+ return payload;
407
+ }
408
+
409
+ /**
410
+ * Parse the compact deposit OP_RETURN payload back into its constituent fields.
411
+ *
412
+ * @returns Parsed fields, or null if data is not exactly the expected size.
413
+ */
414
+ export function parseDepositOpReturn(data: Uint8Array): ParsedDepositOpReturn | null {
415
+ if (data.length !== DEPOSIT_OP_RETURN_SIZE) return null;
416
+ const header = decodeDepositOpReturnHeader(data[0]);
417
+ if (!header) return null;
418
+
419
+ return {
420
+ ...header,
421
+ poolTag: data.slice(1, 1 + DEPOSIT_POOL_TAG_SIZE),
422
+ ephemeralPubkey: data.slice(1 + DEPOSIT_POOL_TAG_SIZE, 1 + DEPOSIT_POOL_TAG_SIZE + 32),
423
+ notePublicKey: data.slice(1 + DEPOSIT_POOL_TAG_SIZE + 32, DEPOSIT_OP_RETURN_SIZE),
424
+ };
425
+ }
426
+
427
+ export function encodeDepositOpReturnHeader(
428
+ destinationChain: DepositDestinationChain,
429
+ bitcoinNetwork: DepositBitcoinNetwork,
430
+ ): number {
431
+ if (destinationChain !== DEPOSIT_DESTINATION_CHAIN.SOLANA) {
432
+ throw new Error("invalid deposit destination chain");
433
+ }
434
+ if (
435
+ bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.MAINNET
436
+ && bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.TESTNET4
437
+ && bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.REGTEST
438
+ ) {
439
+ throw new Error("invalid deposit bitcoin network");
440
+ }
441
+ return (DEPOSIT_OP_RETURN_VERSION << 6) | (destinationChain << 4) | bitcoinNetwork;
442
+ }
443
+
444
+ export function decodeDepositOpReturnHeader(header: number): {
445
+ version: number;
446
+ destinationChain: DepositDestinationChain;
447
+ bitcoinNetwork: DepositBitcoinNetwork;
448
+ } | null {
449
+ const version = header >> 6;
450
+ const destinationChain = (header >> 4) & 0x03;
451
+ const bitcoinNetwork = header & 0x0f;
452
+ if (version !== DEPOSIT_OP_RETURN_VERSION) return null;
453
+ if (destinationChain !== DEPOSIT_DESTINATION_CHAIN.SOLANA) {
454
+ return null;
455
+ }
456
+ if (
457
+ bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.MAINNET
458
+ && bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.TESTNET4
459
+ && bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.REGTEST
460
+ ) {
461
+ return null;
462
+ }
463
+ return {
464
+ version,
465
+ destinationChain: destinationChain as DepositDestinationChain,
466
+ bitcoinNetwork: bitcoinNetwork as DepositBitcoinNetwork,
467
+ };
468
+ }
469
+
470
+ export function validateDepositOpReturnContext(context: DepositOpReturnContext): void {
471
+ encodeDepositOpReturnHeader(context.destinationChain, context.bitcoinNetwork);
472
+ if (context.poolTag.length !== DEPOSIT_POOL_TAG_SIZE) {
473
+ throw new Error(`poolTag must be ${DEPOSIT_POOL_TAG_SIZE} bytes`);
474
+ }
475
+ }
476
+
477
+ export function computeDepositPoolTag(parts: Uint8Array[]): Uint8Array {
478
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
479
+ const bytes = new Uint8Array(total);
480
+ let offset = 0;
481
+ for (const part of parts) {
482
+ bytes.set(part, offset);
483
+ offset += part.length;
484
+ }
485
+ return sha256(bytes).slice(0, DEPOSIT_POOL_TAG_SIZE);
486
+ }
487
+
488
+ /**
489
+ * Create an OP_RETURN script from an arbitrary payload (up to 80 bytes).
490
+ *
491
+ * Format: OP_RETURN (0x6a) + OP_PUSHDATA (length byte) + payload
492
+ */
493
+ export function createOpReturnScriptFromPayload(payload: Uint8Array): Uint8Array {
494
+ if (payload.length > 80) throw new Error("OP_RETURN payload exceeds 80 bytes");
495
+
496
+ // For payloads <= 75 bytes, use a single-byte push opcode (OP_PUSH_N).
497
+ // For 76..80 bytes, use OP_PUSHDATA1 (0x4c) + 1-byte length.
498
+ if (payload.length <= 75) {
499
+ const script = new Uint8Array(2 + payload.length);
500
+ script[0] = 0x6a; // OP_RETURN
501
+ script[1] = payload.length; // direct push opcode
502
+ script.set(payload, 2);
503
+ return script;
504
+ } else {
505
+ const script = new Uint8Array(3 + payload.length);
506
+ script[0] = 0x6a; // OP_RETURN
507
+ script[1] = 0x4c; // OP_PUSHDATA1
508
+ script[2] = payload.length;
509
+ script.set(payload, 3);
510
+ return script;
511
+ }
512
+ }
513
+
514
+ // ========== Refund Script Taproot Helpers ==========
515
+
516
+ /**
517
+ * Encode an integer as Bitcoin Script compact size (CompactSize/varint).
518
+ * - 0-252: single byte
519
+ * - 253-65535: 0xfd + 2 bytes LE
520
+ */
521
+ function compactSizeEncode(n: number): Uint8Array {
522
+ if (n < 0) throw new Error("compactSize cannot be negative");
523
+ if (n <= 252) {
524
+ return new Uint8Array([n]);
525
+ }
526
+ if (n <= 0xffff) {
527
+ const buf = new Uint8Array(3);
528
+ buf[0] = 0xfd;
529
+ buf[1] = n & 0xff;
530
+ buf[2] = (n >> 8) & 0xff;
531
+ return buf;
532
+ }
533
+ throw new Error("compactSize > 65535 not supported");
534
+ }
535
+
536
+ /**
537
+ * Build a refund script for time-locked user recovery.
538
+ *
539
+ * Script:
540
+ * <npk_32> OP_DROP <144> OP_CHECKSEQUENCEVERIFY OP_DROP <user_x_only_pubkey_32> OP_CHECKSIG
541
+ *
542
+ * @param npk - 32-byte note public key (commitment binding)
543
+ * @param userPubkey - 32-byte x-only public key for the refund path
544
+ * @returns Script bytes (73 bytes)
545
+ */
546
+ export function buildRefundScript(npk: Uint8Array, userPubkey: Uint8Array): Uint8Array {
547
+ if (npk.length !== 32) throw new Error("npk must be 32 bytes");
548
+ if (userPubkey.length !== 32) throw new Error("userPubkey must be 32 bytes (x-only)");
549
+
550
+ // Total: 1+32+1+1+2+1+1+1+32+1 = 73 bytes
551
+ const script = new Uint8Array(73);
552
+ let offset = 0;
553
+
554
+ // OP_PUSHBYTES_32 + npk
555
+ script[offset++] = 0x20;
556
+ script.set(npk, offset);
557
+ offset += 32;
558
+
559
+ // OP_DROP
560
+ script[offset++] = 0x75;
561
+
562
+ // Push 144 as minimal signed LE: 144 = 0x90, high bit set → needs 0x00 padding → [0x90, 0x00]
563
+ // OP_PUSHBYTES_2
564
+ script[offset++] = 0x02;
565
+ script[offset++] = 0x90;
566
+ script[offset++] = 0x00;
567
+
568
+ // OP_CHECKSEQUENCEVERIFY
569
+ script[offset++] = 0xb2;
570
+
571
+ // OP_DROP
572
+ script[offset++] = 0x75;
573
+
574
+ // OP_PUSHBYTES_32 + user x-only pubkey
575
+ script[offset++] = 0x20;
576
+ script.set(userPubkey, offset);
577
+ offset += 32;
578
+
579
+ // OP_CHECKSIG
580
+ script[offset++] = 0xac;
581
+
582
+ return script;
583
+ }
584
+
585
+ /**
586
+ * Compute a TapLeaf hash per BIP-341.
587
+ *
588
+ * TapLeaf = H_TapLeaf(leafVersion || compactSize(script.length) || script)
589
+ *
590
+ * @param script - The leaf script bytes
591
+ * @param leafVersion - Leaf version byte (default 0xc0)
592
+ * @returns 32-byte tagged hash
593
+ */
594
+ export function computeTapLeafHash(script: Uint8Array, leafVersion: number = 0xc0): Uint8Array {
595
+ const scriptLenBytes = compactSizeEncode(script.length);
596
+ const data = new Uint8Array(1 + scriptLenBytes.length + script.length);
597
+ data[0] = leafVersion;
598
+ data.set(scriptLenBytes, 1);
599
+ data.set(script, 1 + scriptLenBytes.length);
600
+ return taggedHash("TapLeaf", data);
601
+ }
602
+
603
+ /**
604
+ * Derive a Taproot address with a refund script path.
605
+ *
606
+ * The address commits to both the FROST group key (internal key) and a
607
+ * time-locked refund script that allows the user to reclaim funds after
608
+ * 144 blocks (~1 day) if the bridge fails to sweep.
609
+ *
610
+ * Taproot construction:
611
+ * - Internal key = FROST group key (x-only)
612
+ * - Single TapLeaf = refund script
613
+ * - Merkle root = TapLeaf hash (single leaf, no branching)
614
+ * - Tweak = H_TapTweak(internal_key || merkle_root)
615
+ * - Output key = internal_key + tweak * G
616
+ *
617
+ * @param npk - 32-byte note public key (embedded in refund script for binding)
618
+ * @param userRefundPubkey - 32-byte x-only pubkey for the refund spending path
619
+ * @param internalKey - 32-byte x-only FROST group public key
620
+ * @param network - Bitcoin network for address encoding
621
+ */
622
+ export function deriveTaprootAddressWithRefund(
623
+ npk: Uint8Array,
624
+ userRefundPubkey: Uint8Array,
625
+ internalKey: Uint8Array,
626
+ network: "mainnet" | "testnet" | "regtest" = "testnet"
627
+ ): {
628
+ address: string;
629
+ outputKey: Uint8Array;
630
+ merkleRoot: Uint8Array;
631
+ controlBlock: Uint8Array;
632
+ refundScript: Uint8Array;
633
+ tweak: Uint8Array;
634
+ } {
635
+ if (internalKey.length !== 32) throw new Error("Internal key must be 32 bytes (x-only)");
636
+ if (npk.length !== 32) throw new Error("npk must be 32 bytes");
637
+ if (userRefundPubkey.length !== 32) throw new Error("userRefundPubkey must be 32 bytes (x-only)");
638
+
639
+ // 1. Build the refund script
640
+ const refundScript = buildRefundScript(npk, userRefundPubkey);
641
+
642
+ // 2. Compute the TapLeaf hash (single leaf = merkle root)
643
+ const merkleRoot = computeTapLeafHash(refundScript);
644
+
645
+ // 3. Compute tweak = H_TapTweak(internal_key || merkle_root)
646
+ const tweakInput = new Uint8Array(64);
647
+ tweakInput.set(internalKey, 0);
648
+ tweakInput.set(merkleRoot, 32);
649
+ const tweak = taggedHash("TapTweak", tweakInput);
650
+
651
+ // 4. Compute output key = lift_x(internal_key) + tweak * G
652
+ const tweakScalar = bytesToBigInt(tweak);
653
+ const SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n;
654
+ if (tweakScalar >= SECP256K1_ORDER) {
655
+ throw new Error("Tweak scalar exceeds curve order");
656
+ }
657
+
658
+ const keyHex = "02" + bytesToHex(internalKey);
659
+ const internalPoint = secp256k1.Point.fromHex(keyHex);
660
+ const tweakPoint = secp256k1.Point.BASE.multiply(tweakScalar);
661
+ const outputPoint = internalPoint.add(tweakPoint);
662
+
663
+ const outputKeyHex = outputPoint.toHex(true); // 33-byte compressed hex
664
+ const outputKey = hexToBytes(outputKeyHex.slice(2)); // drop "02"/"03" prefix
665
+
666
+ // 5. Determine parity bit for the control block
667
+ const parityBit = outputKeyHex.startsWith("03") ? 1 : 0;
668
+
669
+ // 6. Build the control block: <leaf_version | parity_bit> <internal_key>
670
+ const controlBlock = new Uint8Array(33);
671
+ controlBlock[0] = 0xc0 | parityBit;
672
+ controlBlock.set(internalKey, 1);
673
+
674
+ // 7. Encode as bech32m address
675
+ const hrp = network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
676
+ const words = bech32.bech32m.toWords(outputKey);
677
+ const address = bech32.bech32m.encode(hrp, [1, ...words]);
678
+
679
+ return {
680
+ address,
681
+ outputKey,
682
+ merkleRoot,
683
+ controlBlock,
684
+ refundScript,
685
+ tweak,
686
+ };
687
+ }
688
+
689
+ /**
690
+ * Get the internal key used by UTXOpia
691
+ * In production, this would be the FROST threshold public key
692
+ */
693
+ export function getInternalKey(): Uint8Array {
694
+ // The historical default is the secp256k1 generator (sweepable). Refuse it —
695
+ // callers must supply a real FROST/Ika group key explicitly.
696
+ throw new Error("getInternalKey() is disabled: configure a real FROST/Ika internal key");
697
+ }
698
+
699
+ /**
700
+ * Set a custom internal key (for testing or custom deployments)
701
+ */
702
+ export function createCustomInternalKey(key: Uint8Array): Uint8Array {
703
+ if (key.length !== 32) {
704
+ throw new Error("Internal key must be 32 bytes (x-only pubkey)");
705
+ }
706
+ return new Uint8Array(key);
707
+ }