@utxopia/sdk 0.1.0-alpha.1

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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -0
  3. package/package.json +108 -0
  4. package/packages/btc-client/src/esplora-client.ts +153 -0
  5. package/packages/btc-client/src/index.ts +3 -0
  6. package/packages/btc-client/src/op-return.ts +93 -0
  7. package/packages/btc-client/src/types.ts +112 -0
  8. package/packages/sdk/README.md +277 -0
  9. package/packages/sdk/dist/announcement-client.d.ts +64 -0
  10. package/packages/sdk/dist/announcement-client.js +337 -0
  11. package/packages/sdk/dist/auditor-ciphertext.d.ts +72 -0
  12. package/packages/sdk/dist/auditor-ciphertext.js +135 -0
  13. package/packages/sdk/dist/auditor.d.ts +115 -0
  14. package/packages/sdk/dist/auditor.js +292 -0
  15. package/packages/sdk/dist/bitcoin/ika.d.ts +47 -0
  16. package/packages/sdk/dist/bitcoin/ika.js +74 -0
  17. package/packages/sdk/dist/bitcoin/index.d.ts +2 -0
  18. package/packages/sdk/dist/bitcoin/index.js +1 -0
  19. package/packages/sdk/dist/bound-params.d.ts +96 -0
  20. package/packages/sdk/dist/bound-params.js +249 -0
  21. package/packages/sdk/dist/chadbuffer.d.ts +124 -0
  22. package/packages/sdk/dist/chadbuffer.js +405 -0
  23. package/packages/sdk/dist/claim-link.d.ts +30 -0
  24. package/packages/sdk/dist/claim-link.js +50 -0
  25. package/packages/sdk/dist/client.d.ts +233 -0
  26. package/packages/sdk/dist/client.js +416 -0
  27. package/packages/sdk/dist/commitment-tree.d.ts +268 -0
  28. package/packages/sdk/dist/commitment-tree.js +564 -0
  29. package/packages/sdk/dist/config.d.ts +177 -0
  30. package/packages/sdk/dist/config.js +426 -0
  31. package/packages/sdk/dist/core/esplora.d.ts +110 -0
  32. package/packages/sdk/dist/core/esplora.js +187 -0
  33. package/packages/sdk/dist/core/mempool.d.ts +60 -0
  34. package/packages/sdk/dist/core/mempool.js +107 -0
  35. package/packages/sdk/dist/crypto-babyjub.d.ts +105 -0
  36. package/packages/sdk/dist/crypto-babyjub.js +312 -0
  37. package/packages/sdk/dist/crypto-ed25519.d.ts +116 -0
  38. package/packages/sdk/dist/crypto-ed25519.js +235 -0
  39. package/packages/sdk/dist/crypto.d.ts +59 -0
  40. package/packages/sdk/dist/crypto.js +140 -0
  41. package/packages/sdk/dist/event-client.d.ts +64 -0
  42. package/packages/sdk/dist/event-client.js +165 -0
  43. package/packages/sdk/dist/events.d.ts +127 -0
  44. package/packages/sdk/dist/events.js +305 -0
  45. package/packages/sdk/dist/explorer.d.ts +86 -0
  46. package/packages/sdk/dist/explorer.js +197 -0
  47. package/packages/sdk/dist/index.d.ts +61 -0
  48. package/packages/sdk/dist/index.js +216 -0
  49. package/packages/sdk/dist/instructions.d.ts +1125 -0
  50. package/packages/sdk/dist/instructions.js +1760 -0
  51. package/packages/sdk/dist/keys.d.ts +467 -0
  52. package/packages/sdk/dist/keys.js +799 -0
  53. package/packages/sdk/dist/logger.d.ts +10 -0
  54. package/packages/sdk/dist/logger.js +40 -0
  55. package/packages/sdk/dist/magicblock.d.ts +90 -0
  56. package/packages/sdk/dist/magicblock.js +164 -0
  57. package/packages/sdk/dist/merkle.d.ts +82 -0
  58. package/packages/sdk/dist/merkle.js +141 -0
  59. package/packages/sdk/dist/note.d.ts +348 -0
  60. package/packages/sdk/dist/note.js +483 -0
  61. package/packages/sdk/dist/pda.d.ts +160 -0
  62. package/packages/sdk/dist/pda.js +384 -0
  63. package/packages/sdk/dist/pool-state.d.ts +100 -0
  64. package/packages/sdk/dist/pool-state.js +126 -0
  65. package/packages/sdk/dist/poseidon.d.ts +53 -0
  66. package/packages/sdk/dist/poseidon.js +136 -0
  67. package/packages/sdk/dist/prover/index.d.ts +10 -0
  68. package/packages/sdk/dist/prover/index.js +10 -0
  69. package/packages/sdk/dist/prover/mobile.d.ts +45 -0
  70. package/packages/sdk/dist/prover/mobile.js +227 -0
  71. package/packages/sdk/dist/prover/web.d.ts +136 -0
  72. package/packages/sdk/dist/prover/web.js +597 -0
  73. package/packages/sdk/dist/psbt.d.ts +73 -0
  74. package/packages/sdk/dist/psbt.js +202 -0
  75. package/packages/sdk/dist/selective-disclosure.d.ts +181 -0
  76. package/packages/sdk/dist/selective-disclosure.js +172 -0
  77. package/packages/sdk/dist/sender-memo.d.ts +149 -0
  78. package/packages/sdk/dist/sender-memo.js +250 -0
  79. package/packages/sdk/dist/sns-resolver.d.ts +121 -0
  80. package/packages/sdk/dist/sns-resolver.js +229 -0
  81. package/packages/sdk/dist/solana/connection.d.ts +77 -0
  82. package/packages/sdk/dist/solana/connection.js +133 -0
  83. package/packages/sdk/dist/solana/priority-fee.d.ts +78 -0
  84. package/packages/sdk/dist/solana/priority-fee.js +137 -0
  85. package/packages/sdk/dist/stealth.d.ts +429 -0
  86. package/packages/sdk/dist/stealth.js +703 -0
  87. package/packages/sdk/dist/taproot.d.ts +172 -0
  88. package/packages/sdk/dist/taproot.js +494 -0
  89. package/packages/sdk/dist/token-registry.d.ts +96 -0
  90. package/packages/sdk/dist/token-registry.js +122 -0
  91. package/packages/sdk/dist/utils/encoding.d.ts +19 -0
  92. package/packages/sdk/dist/utils/encoding.js +29 -0
  93. package/packages/sdk/dist/vk-registry.d.ts +100 -0
  94. package/packages/sdk/dist/vk-registry.js +212 -0
@@ -0,0 +1,172 @@
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
+ * Derive a Taproot address from a commitment
10
+ *
11
+ * Following BIP-341:
12
+ * tweak = H_TapTweak(internal_key || commitment)
13
+ * output_key = internal_key + tweak * G
14
+ * address = bech32m encode(output_key)
15
+ *
16
+ * @param commitment - 32-byte commitment hash
17
+ * @param network - 'mainnet' | 'testnet' | 'regtest'
18
+ * @param internalKey - Configured FROST/Ika custody key (x-only, 32 bytes; required)
19
+ * @returns Taproot address (bc1p... or tb1p...)
20
+ */
21
+ export declare function deriveTaprootAddress(commitment: Uint8Array, network?: "mainnet" | "testnet" | "regtest", internalKey?: Uint8Array): {
22
+ address: string;
23
+ outputKey: Uint8Array;
24
+ tweak: Uint8Array;
25
+ };
26
+ /**
27
+ * Verify that a Taproot address is correctly derived from a commitment
28
+ *
29
+ * @param address - Taproot address to verify
30
+ * @param commitment - Expected commitment
31
+ * @param internalKey - Configured FROST/Ika custody key (x-only, 32 bytes; required)
32
+ * @returns true if address matches expected derivation
33
+ */
34
+ export declare function verifyTaprootAddress(address: string, commitment: Uint8Array, internalKey?: Uint8Array): boolean;
35
+ /**
36
+ * Generate a P2TR (Pay-to-Taproot) script pubkey
37
+ *
38
+ * @param outputKey - 32-byte output key (x-only)
39
+ * @returns Script pubkey bytes (OP_1 <32-byte key>)
40
+ */
41
+ export declare function createP2TRScriptPubkey(outputKey: Uint8Array): Uint8Array;
42
+ /**
43
+ * Parse P2TR script pubkey to extract output key
44
+ *
45
+ * @param scriptPubkey - Script pubkey bytes
46
+ * @returns Output key or null if not P2TR
47
+ */
48
+ export declare function parseP2TRScriptPubkey(scriptPubkey: Uint8Array): Uint8Array | null;
49
+ /**
50
+ * Validate a Bitcoin address format
51
+ */
52
+ export declare function isValidBitcoinAddress(address: string): {
53
+ valid: boolean;
54
+ type: "p2pkh" | "p2sh" | "p2wpkh" | "p2wsh" | "p2tr" | "unknown";
55
+ network: "mainnet" | "testnet" | "unknown";
56
+ };
57
+ /** Destination chain encoded in the compact deposit OP_RETURN header. */
58
+ export declare const DEPOSIT_DESTINATION_CHAIN: {
59
+ readonly SOLANA: 1;
60
+ };
61
+ export type DepositDestinationChain = (typeof DEPOSIT_DESTINATION_CHAIN)[keyof typeof DEPOSIT_DESTINATION_CHAIN];
62
+ /** Bitcoin network encoded in the compact deposit OP_RETURN header. */
63
+ export declare const DEPOSIT_BITCOIN_NETWORK: {
64
+ readonly MAINNET: 0;
65
+ readonly TESTNET4: 2;
66
+ readonly REGTEST: 3;
67
+ };
68
+ export type DepositBitcoinNetwork = (typeof DEPOSIT_BITCOIN_NETWORK)[keyof typeof DEPOSIT_BITCOIN_NETWORK];
69
+ export declare const DEPOSIT_OP_RETURN_VERSION = 1;
70
+ export declare const DEPOSIT_POOL_TAG_SIZE = 8;
71
+ /** OP_RETURN payload size for deposit: header(1) + poolTag(8) + ephemeralPubkey(32) + notePublicKey(32). */
72
+ export declare const DEPOSIT_OP_RETURN_SIZE = 73;
73
+ export interface DepositOpReturnContext {
74
+ destinationChain: DepositDestinationChain;
75
+ bitcoinNetwork: DepositBitcoinNetwork;
76
+ poolTag: Uint8Array;
77
+ }
78
+ export interface ParsedDepositOpReturn extends DepositOpReturnContext {
79
+ version: number;
80
+ ephemeralPubkey: Uint8Array;
81
+ notePublicKey: Uint8Array;
82
+ }
83
+ /**
84
+ * Build the compact deposit OP_RETURN payload for non-interactive stealth deposits.
85
+ *
86
+ * Layout:
87
+ * [0] header — version + destination chain + Bitcoin network
88
+ * [1..9) poolTag — destination deployment tag
89
+ * [9..41) ephemeralPubkey — Ed25519 public key
90
+ * [41..73) notePublicKey — Note public key (Poseidon hash)
91
+ *
92
+ * Amount is no longer embedded — the on-chain program reads it from the BTC output.
93
+ * The caller wraps this in an OP_RETURN script (0x6a + push opcode + payload).
94
+ */
95
+ export declare function buildDepositOpReturn(ephemeralPubkey: Uint8Array, notePublicKey: Uint8Array, context: DepositOpReturnContext): Uint8Array;
96
+ /**
97
+ * Parse the compact deposit OP_RETURN payload back into its constituent fields.
98
+ *
99
+ * @returns Parsed fields, or null if data is not exactly the expected size.
100
+ */
101
+ export declare function parseDepositOpReturn(data: Uint8Array): ParsedDepositOpReturn | null;
102
+ export declare function encodeDepositOpReturnHeader(destinationChain: DepositDestinationChain, bitcoinNetwork: DepositBitcoinNetwork): number;
103
+ export declare function decodeDepositOpReturnHeader(header: number): {
104
+ version: number;
105
+ destinationChain: DepositDestinationChain;
106
+ bitcoinNetwork: DepositBitcoinNetwork;
107
+ } | null;
108
+ export declare function validateDepositOpReturnContext(context: DepositOpReturnContext): void;
109
+ export declare function computeDepositPoolTag(parts: Uint8Array[]): Uint8Array;
110
+ /**
111
+ * Create an OP_RETURN script from an arbitrary payload (up to 80 bytes).
112
+ *
113
+ * Format: OP_RETURN (0x6a) + OP_PUSHDATA (length byte) + payload
114
+ */
115
+ export declare function createOpReturnScriptFromPayload(payload: Uint8Array): Uint8Array;
116
+ /**
117
+ * Build a refund script for time-locked user recovery.
118
+ *
119
+ * Script:
120
+ * <npk_32> OP_DROP <144> OP_CHECKSEQUENCEVERIFY OP_DROP <user_x_only_pubkey_32> OP_CHECKSIG
121
+ *
122
+ * @param npk - 32-byte note public key (commitment binding)
123
+ * @param userPubkey - 32-byte x-only public key for the refund path
124
+ * @returns Script bytes (73 bytes)
125
+ */
126
+ export declare function buildRefundScript(npk: Uint8Array, userPubkey: Uint8Array): Uint8Array;
127
+ /**
128
+ * Compute a TapLeaf hash per BIP-341.
129
+ *
130
+ * TapLeaf = H_TapLeaf(leafVersion || compactSize(script.length) || script)
131
+ *
132
+ * @param script - The leaf script bytes
133
+ * @param leafVersion - Leaf version byte (default 0xc0)
134
+ * @returns 32-byte tagged hash
135
+ */
136
+ export declare function computeTapLeafHash(script: Uint8Array, leafVersion?: number): Uint8Array;
137
+ /**
138
+ * Derive a Taproot address with a refund script path.
139
+ *
140
+ * The address commits to both the FROST group key (internal key) and a
141
+ * time-locked refund script that allows the user to reclaim funds after
142
+ * 144 blocks (~1 day) if the bridge fails to sweep.
143
+ *
144
+ * Taproot construction:
145
+ * - Internal key = FROST group key (x-only)
146
+ * - Single TapLeaf = refund script
147
+ * - Merkle root = TapLeaf hash (single leaf, no branching)
148
+ * - Tweak = H_TapTweak(internal_key || merkle_root)
149
+ * - Output key = internal_key + tweak * G
150
+ *
151
+ * @param npk - 32-byte note public key (embedded in refund script for binding)
152
+ * @param userRefundPubkey - 32-byte x-only pubkey for the refund spending path
153
+ * @param internalKey - 32-byte x-only FROST group public key
154
+ * @param network - Bitcoin network for address encoding
155
+ */
156
+ export declare function deriveTaprootAddressWithRefund(npk: Uint8Array, userRefundPubkey: Uint8Array, internalKey: Uint8Array, network?: "mainnet" | "testnet" | "regtest"): {
157
+ address: string;
158
+ outputKey: Uint8Array;
159
+ merkleRoot: Uint8Array;
160
+ controlBlock: Uint8Array;
161
+ refundScript: Uint8Array;
162
+ tweak: Uint8Array;
163
+ };
164
+ /**
165
+ * Get the internal key used by UTXOpia
166
+ * In production, this would be the FROST threshold public key
167
+ */
168
+ export declare function getInternalKey(): Uint8Array;
169
+ /**
170
+ * Set a custom internal key (for testing or custom deployments)
171
+ */
172
+ export declare function createCustomInternalKey(key: Uint8Array): Uint8Array;
@@ -0,0 +1,494 @@
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
+ import { sha256 } from "@noble/hashes/sha2.js";
9
+ import { taggedHash, hexToBytes, bytesToHex } from "./crypto";
10
+ import * as bech32 from "bech32";
11
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
12
+ // Never use the secp256k1 generator as a custody key. Its discrete log is
13
+ // public, so a key-path output derived from it is sweepable by anyone.
14
+ const UNSAFE_GENERATOR_INTERNAL_KEY_HEX = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; // secp256k1 generator x-coord
15
+ /**
16
+ * Derive a Taproot address from a commitment
17
+ *
18
+ * Following BIP-341:
19
+ * tweak = H_TapTweak(internal_key || commitment)
20
+ * output_key = internal_key + tweak * G
21
+ * address = bech32m encode(output_key)
22
+ *
23
+ * @param commitment - 32-byte commitment hash
24
+ * @param network - 'mainnet' | 'testnet' | 'regtest'
25
+ * @param internalKey - Configured FROST/Ika custody key (x-only, 32 bytes; required)
26
+ * @returns Taproot address (bc1p... or tb1p...)
27
+ */
28
+ export function deriveTaprootAddress(commitment, network = "testnet", internalKey) {
29
+ if (!internalKey) {
30
+ throw new Error("Internal key is required; pass the configured FROST/Ika x-only key");
31
+ }
32
+ const key = internalKey;
33
+ if (key.length !== 32) {
34
+ throw new Error("Internal key must be 32 bytes (x-only)");
35
+ }
36
+ // Refuse the secp256k1 generator x-coord (discrete log = 1): its key-path secret
37
+ // 1+tweak is publicly computable, so any observer could sweep the output.
38
+ if (bytesToHex(key) === UNSAFE_GENERATOR_INTERNAL_KEY_HEX) {
39
+ throw new Error("Refusing to derive Taproot with the generator internal key; pass a real FROST/Ika key");
40
+ }
41
+ // Compute tweak = H_TapTweak(internal_key || commitment)
42
+ const tweakInput = new Uint8Array(64);
43
+ tweakInput.set(key, 0);
44
+ tweakInput.set(commitment, 32);
45
+ const tweak = taggedHash("TapTweak", tweakInput);
46
+ // BIP-341: output_key = lift_x(internal_key) + tweak * G
47
+ const tweakScalar = bytesToBigInt(tweak);
48
+ const SECP256K1_ORDER = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
49
+ if (tweakScalar >= SECP256K1_ORDER) {
50
+ throw new Error("Tweak scalar exceeds curve order");
51
+ }
52
+ // lift_x: recover full point from x-only key (even y per BIP-340)
53
+ const keyHex = "02" + bytesToHex(key);
54
+ const internalPoint = secp256k1.Point.fromHex(keyHex);
55
+ // tweak * G
56
+ const tweakPoint = secp256k1.Point.BASE.multiply(tweakScalar);
57
+ // Q = P + t*G
58
+ const outputPoint = internalPoint.add(tweakPoint);
59
+ // x-only output key (BIP-340): drop prefix from compressed form
60
+ const outputKeyHex = outputPoint.toHex(true); // 33-byte compressed hex
61
+ const outputKey = hexToBytes(outputKeyHex.slice(2)); // drop "02"/"03" prefix
62
+ // Encode as bech32m address
63
+ const hrp = network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
64
+ const words = bech32.bech32m.toWords(outputKey);
65
+ // Witness version 1 for taproot
66
+ const address = bech32.bech32m.encode(hrp, [1, ...words]);
67
+ return {
68
+ address,
69
+ outputKey,
70
+ tweak,
71
+ };
72
+ }
73
+ /**
74
+ * Verify that a Taproot address is correctly derived from a commitment
75
+ *
76
+ * @param address - Taproot address to verify
77
+ * @param commitment - Expected commitment
78
+ * @param internalKey - Configured FROST/Ika custody key (x-only, 32 bytes; required)
79
+ * @returns true if address matches expected derivation
80
+ */
81
+ export function verifyTaprootAddress(address, commitment, internalKey) {
82
+ try {
83
+ const decoded = bech32.bech32m.decode(address);
84
+ const witnessVersion = decoded.words[0];
85
+ if (witnessVersion !== 1) {
86
+ return false;
87
+ }
88
+ const actualOutputKey = new Uint8Array(bech32.bech32m.fromWords(decoded.words.slice(1)));
89
+ const network = decoded.prefix === "bc" ? "mainnet" : "testnet";
90
+ const expected = deriveTaprootAddress(commitment, network, internalKey);
91
+ return arraysEqual(actualOutputKey, expected.outputKey);
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * Generate a P2TR (Pay-to-Taproot) script pubkey
99
+ *
100
+ * @param outputKey - 32-byte output key (x-only)
101
+ * @returns Script pubkey bytes (OP_1 <32-byte key>)
102
+ */
103
+ export function createP2TRScriptPubkey(outputKey) {
104
+ if (outputKey.length !== 32) {
105
+ throw new Error("Output key must be 32 bytes");
106
+ }
107
+ // OP_1 (0x51) + push 32 bytes (0x20) + key
108
+ const script = new Uint8Array(34);
109
+ script[0] = 0x51; // OP_1 (witness version 1)
110
+ script[1] = 0x20; // Push 32 bytes
111
+ script.set(outputKey, 2);
112
+ return script;
113
+ }
114
+ /**
115
+ * Parse P2TR script pubkey to extract output key
116
+ *
117
+ * @param scriptPubkey - Script pubkey bytes
118
+ * @returns Output key or null if not P2TR
119
+ */
120
+ export function parseP2TRScriptPubkey(scriptPubkey) {
121
+ if (scriptPubkey.length !== 34)
122
+ return null;
123
+ if (scriptPubkey[0] !== 0x51)
124
+ return null; // OP_1
125
+ if (scriptPubkey[1] !== 0x20)
126
+ return null; // Push 32
127
+ return scriptPubkey.slice(2);
128
+ }
129
+ /**
130
+ * Validate a Bitcoin address format
131
+ */
132
+ export function isValidBitcoinAddress(address) {
133
+ try {
134
+ // Bech32m (Taproot)
135
+ if (address.startsWith("bc1p") || address.startsWith("tb1p")) {
136
+ const decoded = bech32.bech32m.decode(address);
137
+ if (decoded.words[0] === 1 && decoded.words.length === 53) {
138
+ return {
139
+ valid: true,
140
+ type: "p2tr",
141
+ network: decoded.prefix === "bc" ? "mainnet" : "testnet",
142
+ };
143
+ }
144
+ }
145
+ // Bech32 (SegWit v0)
146
+ if (address.startsWith("bc1q") ||
147
+ address.startsWith("tb1q") ||
148
+ address.startsWith("bcrt1q")) {
149
+ const decoded = bech32.bech32.decode(address);
150
+ if (decoded.words[0] === 0) {
151
+ const type = decoded.words.length === 33 ? "p2wpkh" : "p2wsh";
152
+ return {
153
+ valid: true,
154
+ type,
155
+ network: decoded.prefix === "bc"
156
+ ? "mainnet"
157
+ : decoded.prefix === "bcrt"
158
+ ? "testnet"
159
+ : "testnet",
160
+ };
161
+ }
162
+ }
163
+ // Legacy (base58check)
164
+ const len = address.length;
165
+ if (len >= 26 && len <= 35) {
166
+ if (address.startsWith("1")) {
167
+ return { valid: true, type: "p2pkh", network: "mainnet" };
168
+ }
169
+ if (address.startsWith("3")) {
170
+ return { valid: true, type: "p2sh", network: "mainnet" };
171
+ }
172
+ if (address.startsWith("m") || address.startsWith("n")) {
173
+ return { valid: true, type: "p2pkh", network: "testnet" };
174
+ }
175
+ if (address.startsWith("2")) {
176
+ return { valid: true, type: "p2sh", network: "testnet" };
177
+ }
178
+ }
179
+ return { valid: false, type: "unknown", network: "unknown" };
180
+ }
181
+ catch {
182
+ return { valid: false, type: "unknown", network: "unknown" };
183
+ }
184
+ }
185
+ function bytesToBigInt(bytes) {
186
+ let result = 0n;
187
+ for (let i = 0; i < bytes.length; i++) {
188
+ result = (result << 8n) | BigInt(bytes[i]);
189
+ }
190
+ return result;
191
+ }
192
+ function arraysEqual(a, b) {
193
+ if (a.length !== b.length)
194
+ return false;
195
+ for (let i = 0; i < a.length; i++) {
196
+ if (a[i] !== b[i])
197
+ return false;
198
+ }
199
+ return true;
200
+ }
201
+ // ========== OP_RETURN Helpers ==========
202
+ /** Destination chain encoded in the compact deposit OP_RETURN header. */
203
+ export const DEPOSIT_DESTINATION_CHAIN = {
204
+ SOLANA: 1,
205
+ };
206
+ /** Bitcoin network encoded in the compact deposit OP_RETURN header. */
207
+ export const DEPOSIT_BITCOIN_NETWORK = {
208
+ MAINNET: 0,
209
+ TESTNET4: 2,
210
+ REGTEST: 3,
211
+ };
212
+ export const DEPOSIT_OP_RETURN_VERSION = 1;
213
+ export const DEPOSIT_POOL_TAG_SIZE = 8;
214
+ /** OP_RETURN payload size for deposit: header(1) + poolTag(8) + ephemeralPubkey(32) + notePublicKey(32). */
215
+ export const DEPOSIT_OP_RETURN_SIZE = 73;
216
+ /**
217
+ * Build the compact deposit OP_RETURN payload for non-interactive stealth deposits.
218
+ *
219
+ * Layout:
220
+ * [0] header — version + destination chain + Bitcoin network
221
+ * [1..9) poolTag — destination deployment tag
222
+ * [9..41) ephemeralPubkey — Ed25519 public key
223
+ * [41..73) notePublicKey — Note public key (Poseidon hash)
224
+ *
225
+ * Amount is no longer embedded — the on-chain program reads it from the BTC output.
226
+ * The caller wraps this in an OP_RETURN script (0x6a + push opcode + payload).
227
+ */
228
+ export function buildDepositOpReturn(ephemeralPubkey, notePublicKey, context) {
229
+ if (ephemeralPubkey.length !== 32)
230
+ throw new Error("ephemeralPubkey must be 32 bytes");
231
+ if (notePublicKey.length !== 32)
232
+ throw new Error("notePublicKey must be 32 bytes");
233
+ validateDepositOpReturnContext(context);
234
+ const payload = new Uint8Array(DEPOSIT_OP_RETURN_SIZE);
235
+ payload[0] = encodeDepositOpReturnHeader(context.destinationChain, context.bitcoinNetwork);
236
+ payload.set(context.poolTag, 1);
237
+ payload.set(ephemeralPubkey, 1 + DEPOSIT_POOL_TAG_SIZE);
238
+ payload.set(notePublicKey, 1 + DEPOSIT_POOL_TAG_SIZE + 32);
239
+ return payload;
240
+ }
241
+ /**
242
+ * Parse the compact deposit OP_RETURN payload back into its constituent fields.
243
+ *
244
+ * @returns Parsed fields, or null if data is not exactly the expected size.
245
+ */
246
+ export function parseDepositOpReturn(data) {
247
+ if (data.length !== DEPOSIT_OP_RETURN_SIZE)
248
+ return null;
249
+ const header = decodeDepositOpReturnHeader(data[0]);
250
+ if (!header)
251
+ return null;
252
+ return {
253
+ ...header,
254
+ poolTag: data.slice(1, 1 + DEPOSIT_POOL_TAG_SIZE),
255
+ ephemeralPubkey: data.slice(1 + DEPOSIT_POOL_TAG_SIZE, 1 + DEPOSIT_POOL_TAG_SIZE + 32),
256
+ notePublicKey: data.slice(1 + DEPOSIT_POOL_TAG_SIZE + 32, DEPOSIT_OP_RETURN_SIZE),
257
+ };
258
+ }
259
+ export function encodeDepositOpReturnHeader(destinationChain, bitcoinNetwork) {
260
+ if (destinationChain !== DEPOSIT_DESTINATION_CHAIN.SOLANA) {
261
+ throw new Error("invalid deposit destination chain");
262
+ }
263
+ if (bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.MAINNET
264
+ && bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.TESTNET4
265
+ && bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.REGTEST) {
266
+ throw new Error("invalid deposit bitcoin network");
267
+ }
268
+ return (DEPOSIT_OP_RETURN_VERSION << 6) | (destinationChain << 4) | bitcoinNetwork;
269
+ }
270
+ export function decodeDepositOpReturnHeader(header) {
271
+ const version = header >> 6;
272
+ const destinationChain = (header >> 4) & 0x03;
273
+ const bitcoinNetwork = header & 0x0f;
274
+ if (version !== DEPOSIT_OP_RETURN_VERSION)
275
+ return null;
276
+ if (destinationChain !== DEPOSIT_DESTINATION_CHAIN.SOLANA) {
277
+ return null;
278
+ }
279
+ if (bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.MAINNET
280
+ && bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.TESTNET4
281
+ && bitcoinNetwork !== DEPOSIT_BITCOIN_NETWORK.REGTEST) {
282
+ return null;
283
+ }
284
+ return {
285
+ version,
286
+ destinationChain: destinationChain,
287
+ bitcoinNetwork: bitcoinNetwork,
288
+ };
289
+ }
290
+ export function validateDepositOpReturnContext(context) {
291
+ encodeDepositOpReturnHeader(context.destinationChain, context.bitcoinNetwork);
292
+ if (context.poolTag.length !== DEPOSIT_POOL_TAG_SIZE) {
293
+ throw new Error(`poolTag must be ${DEPOSIT_POOL_TAG_SIZE} bytes`);
294
+ }
295
+ }
296
+ export function computeDepositPoolTag(parts) {
297
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
298
+ const bytes = new Uint8Array(total);
299
+ let offset = 0;
300
+ for (const part of parts) {
301
+ bytes.set(part, offset);
302
+ offset += part.length;
303
+ }
304
+ return sha256(bytes).slice(0, DEPOSIT_POOL_TAG_SIZE);
305
+ }
306
+ /**
307
+ * Create an OP_RETURN script from an arbitrary payload (up to 80 bytes).
308
+ *
309
+ * Format: OP_RETURN (0x6a) + OP_PUSHDATA (length byte) + payload
310
+ */
311
+ export function createOpReturnScriptFromPayload(payload) {
312
+ if (payload.length > 80)
313
+ throw new Error("OP_RETURN payload exceeds 80 bytes");
314
+ // For payloads <= 75 bytes, use a single-byte push opcode (OP_PUSH_N).
315
+ // For 76..80 bytes, use OP_PUSHDATA1 (0x4c) + 1-byte length.
316
+ if (payload.length <= 75) {
317
+ const script = new Uint8Array(2 + payload.length);
318
+ script[0] = 0x6a; // OP_RETURN
319
+ script[1] = payload.length; // direct push opcode
320
+ script.set(payload, 2);
321
+ return script;
322
+ }
323
+ else {
324
+ const script = new Uint8Array(3 + payload.length);
325
+ script[0] = 0x6a; // OP_RETURN
326
+ script[1] = 0x4c; // OP_PUSHDATA1
327
+ script[2] = payload.length;
328
+ script.set(payload, 3);
329
+ return script;
330
+ }
331
+ }
332
+ // ========== Refund Script Taproot Helpers ==========
333
+ /**
334
+ * Encode an integer as Bitcoin Script compact size (CompactSize/varint).
335
+ * - 0-252: single byte
336
+ * - 253-65535: 0xfd + 2 bytes LE
337
+ */
338
+ function compactSizeEncode(n) {
339
+ if (n < 0)
340
+ throw new Error("compactSize cannot be negative");
341
+ if (n <= 252) {
342
+ return new Uint8Array([n]);
343
+ }
344
+ if (n <= 0xffff) {
345
+ const buf = new Uint8Array(3);
346
+ buf[0] = 0xfd;
347
+ buf[1] = n & 0xff;
348
+ buf[2] = (n >> 8) & 0xff;
349
+ return buf;
350
+ }
351
+ throw new Error("compactSize > 65535 not supported");
352
+ }
353
+ /**
354
+ * Build a refund script for time-locked user recovery.
355
+ *
356
+ * Script:
357
+ * <npk_32> OP_DROP <144> OP_CHECKSEQUENCEVERIFY OP_DROP <user_x_only_pubkey_32> OP_CHECKSIG
358
+ *
359
+ * @param npk - 32-byte note public key (commitment binding)
360
+ * @param userPubkey - 32-byte x-only public key for the refund path
361
+ * @returns Script bytes (73 bytes)
362
+ */
363
+ export function buildRefundScript(npk, userPubkey) {
364
+ if (npk.length !== 32)
365
+ throw new Error("npk must be 32 bytes");
366
+ if (userPubkey.length !== 32)
367
+ throw new Error("userPubkey must be 32 bytes (x-only)");
368
+ // Total: 1+32+1+1+2+1+1+1+32+1 = 73 bytes
369
+ const script = new Uint8Array(73);
370
+ let offset = 0;
371
+ // OP_PUSHBYTES_32 + npk
372
+ script[offset++] = 0x20;
373
+ script.set(npk, offset);
374
+ offset += 32;
375
+ // OP_DROP
376
+ script[offset++] = 0x75;
377
+ // Push 144 as minimal signed LE: 144 = 0x90, high bit set → needs 0x00 padding → [0x90, 0x00]
378
+ // OP_PUSHBYTES_2
379
+ script[offset++] = 0x02;
380
+ script[offset++] = 0x90;
381
+ script[offset++] = 0x00;
382
+ // OP_CHECKSEQUENCEVERIFY
383
+ script[offset++] = 0xb2;
384
+ // OP_DROP
385
+ script[offset++] = 0x75;
386
+ // OP_PUSHBYTES_32 + user x-only pubkey
387
+ script[offset++] = 0x20;
388
+ script.set(userPubkey, offset);
389
+ offset += 32;
390
+ // OP_CHECKSIG
391
+ script[offset++] = 0xac;
392
+ return script;
393
+ }
394
+ /**
395
+ * Compute a TapLeaf hash per BIP-341.
396
+ *
397
+ * TapLeaf = H_TapLeaf(leafVersion || compactSize(script.length) || script)
398
+ *
399
+ * @param script - The leaf script bytes
400
+ * @param leafVersion - Leaf version byte (default 0xc0)
401
+ * @returns 32-byte tagged hash
402
+ */
403
+ export function computeTapLeafHash(script, leafVersion = 0xc0) {
404
+ const scriptLenBytes = compactSizeEncode(script.length);
405
+ const data = new Uint8Array(1 + scriptLenBytes.length + script.length);
406
+ data[0] = leafVersion;
407
+ data.set(scriptLenBytes, 1);
408
+ data.set(script, 1 + scriptLenBytes.length);
409
+ return taggedHash("TapLeaf", data);
410
+ }
411
+ /**
412
+ * Derive a Taproot address with a refund script path.
413
+ *
414
+ * The address commits to both the FROST group key (internal key) and a
415
+ * time-locked refund script that allows the user to reclaim funds after
416
+ * 144 blocks (~1 day) if the bridge fails to sweep.
417
+ *
418
+ * Taproot construction:
419
+ * - Internal key = FROST group key (x-only)
420
+ * - Single TapLeaf = refund script
421
+ * - Merkle root = TapLeaf hash (single leaf, no branching)
422
+ * - Tweak = H_TapTweak(internal_key || merkle_root)
423
+ * - Output key = internal_key + tweak * G
424
+ *
425
+ * @param npk - 32-byte note public key (embedded in refund script for binding)
426
+ * @param userRefundPubkey - 32-byte x-only pubkey for the refund spending path
427
+ * @param internalKey - 32-byte x-only FROST group public key
428
+ * @param network - Bitcoin network for address encoding
429
+ */
430
+ export function deriveTaprootAddressWithRefund(npk, userRefundPubkey, internalKey, network = "testnet") {
431
+ if (internalKey.length !== 32)
432
+ throw new Error("Internal key must be 32 bytes (x-only)");
433
+ if (npk.length !== 32)
434
+ throw new Error("npk must be 32 bytes");
435
+ if (userRefundPubkey.length !== 32)
436
+ throw new Error("userRefundPubkey must be 32 bytes (x-only)");
437
+ // 1. Build the refund script
438
+ const refundScript = buildRefundScript(npk, userRefundPubkey);
439
+ // 2. Compute the TapLeaf hash (single leaf = merkle root)
440
+ const merkleRoot = computeTapLeafHash(refundScript);
441
+ // 3. Compute tweak = H_TapTweak(internal_key || merkle_root)
442
+ const tweakInput = new Uint8Array(64);
443
+ tweakInput.set(internalKey, 0);
444
+ tweakInput.set(merkleRoot, 32);
445
+ const tweak = taggedHash("TapTweak", tweakInput);
446
+ // 4. Compute output key = lift_x(internal_key) + tweak * G
447
+ const tweakScalar = bytesToBigInt(tweak);
448
+ const SECP256K1_ORDER = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
449
+ if (tweakScalar >= SECP256K1_ORDER) {
450
+ throw new Error("Tweak scalar exceeds curve order");
451
+ }
452
+ const keyHex = "02" + bytesToHex(internalKey);
453
+ const internalPoint = secp256k1.Point.fromHex(keyHex);
454
+ const tweakPoint = secp256k1.Point.BASE.multiply(tweakScalar);
455
+ const outputPoint = internalPoint.add(tweakPoint);
456
+ const outputKeyHex = outputPoint.toHex(true); // 33-byte compressed hex
457
+ const outputKey = hexToBytes(outputKeyHex.slice(2)); // drop "02"/"03" prefix
458
+ // 5. Determine parity bit for the control block
459
+ const parityBit = outputKeyHex.startsWith("03") ? 1 : 0;
460
+ // 6. Build the control block: <leaf_version | parity_bit> <internal_key>
461
+ const controlBlock = new Uint8Array(33);
462
+ controlBlock[0] = 0xc0 | parityBit;
463
+ controlBlock.set(internalKey, 1);
464
+ // 7. Encode as bech32m address
465
+ const hrp = network === "mainnet" ? "bc" : network === "regtest" ? "bcrt" : "tb";
466
+ const words = bech32.bech32m.toWords(outputKey);
467
+ const address = bech32.bech32m.encode(hrp, [1, ...words]);
468
+ return {
469
+ address,
470
+ outputKey,
471
+ merkleRoot,
472
+ controlBlock,
473
+ refundScript,
474
+ tweak,
475
+ };
476
+ }
477
+ /**
478
+ * Get the internal key used by UTXOpia
479
+ * In production, this would be the FROST threshold public key
480
+ */
481
+ export function getInternalKey() {
482
+ // The historical default is the secp256k1 generator (sweepable). Refuse it —
483
+ // callers must supply a real FROST/Ika group key explicitly.
484
+ throw new Error("getInternalKey() is disabled: configure a real FROST/Ika internal key");
485
+ }
486
+ /**
487
+ * Set a custom internal key (for testing or custom deployments)
488
+ */
489
+ export function createCustomInternalKey(key) {
490
+ if (key.length !== 32) {
491
+ throw new Error("Internal key must be 32 bytes (x-only pubkey)");
492
+ }
493
+ return new Uint8Array(key);
494
+ }