@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.
- package/README.md +214 -108
- package/{packages/sdk/dist → dist}/client.d.ts +25 -1
- package/{packages/sdk/dist → dist}/client.js +36 -1
- package/{packages/sdk/dist → dist}/crypto-ed25519.d.ts +14 -0
- package/{packages/sdk/dist → dist}/crypto-ed25519.js +14 -0
- package/{packages/sdk/dist → dist}/index.d.ts +3 -3
- package/{packages/sdk/dist → dist}/index.js +3 -3
- package/{packages/sdk/dist → dist}/instructions.d.ts +28 -11
- package/{packages/sdk/dist → dist}/instructions.js +45 -15
- package/{packages/sdk/dist → dist}/stealth.d.ts +104 -9
- package/{packages/sdk/dist → dist}/stealth.js +146 -14
- package/{packages/sdk/dist → dist}/taproot.d.ts +39 -2
- package/{packages/sdk/dist → dist}/taproot.js +54 -2
- package/package.json +86 -63
- package/src/announcement-client.ts +457 -0
- package/src/auditor-ciphertext.ts +181 -0
- package/src/auditor.ts +409 -0
- package/src/bitcoin/ika.ts +103 -0
- package/src/bitcoin/index.ts +5 -0
- package/src/bound-params.ts +322 -0
- package/src/chadbuffer.ts +603 -0
- package/src/circomlibjs.d.ts +51 -0
- package/src/claim-link.ts +53 -0
- package/src/client.ts +638 -0
- package/src/commitment-tree.ts +736 -0
- package/src/config.ts +772 -0
- package/src/core/esplora.ts +332 -0
- package/src/core/mempool.ts +159 -0
- package/src/crypto-babyjub.ts +385 -0
- package/src/crypto-ed25519.ts +297 -0
- package/src/crypto.ts +199 -0
- package/src/event-client.ts +231 -0
- package/src/events.ts +384 -0
- package/src/explorer.ts +300 -0
- package/src/index.ts +902 -0
- package/src/instructions.ts +2820 -0
- package/src/keys.ts +1228 -0
- package/src/logger.ts +41 -0
- package/src/magicblock.ts +278 -0
- package/src/merkle.ts +197 -0
- package/src/note.ts +754 -0
- package/src/pda.ts +516 -0
- package/src/pool-state.ts +176 -0
- package/src/poseidon.ts +175 -0
- package/src/prover/index.ts +19 -0
- package/src/prover/mobile.ts +303 -0
- package/src/prover/web.ts +771 -0
- package/src/psbt.ts +333 -0
- package/src/selective-disclosure.ts +284 -0
- package/src/sender-memo.ts +343 -0
- package/src/snarkjs.d.ts +19 -0
- package/src/sns-resolver.ts +333 -0
- package/src/solana/connection.ts +189 -0
- package/src/solana/priority-fee.ts +201 -0
- package/src/spend-doc.ts +163 -0
- package/src/stealth.ts +1477 -0
- package/src/taproot.ts +707 -0
- package/src/token-registry.ts +207 -0
- package/src/utils/encoding.ts +33 -0
- package/src/vk-registry.ts +295 -0
- package/LICENSE +0 -21
- package/packages/btc-client/src/esplora-client.ts +0 -153
- package/packages/btc-client/src/index.ts +0 -3
- package/packages/btc-client/src/op-return.ts +0 -93
- package/packages/btc-client/src/types.ts +0 -112
- package/packages/sdk/README.md +0 -277
- /package/{packages/sdk/dist → dist}/announcement-client.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/announcement-client.js +0 -0
- /package/{packages/sdk/dist → dist}/auditor-ciphertext.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/auditor-ciphertext.js +0 -0
- /package/{packages/sdk/dist → dist}/auditor.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/auditor.js +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/ika.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/ika.js +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/index.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/bitcoin/index.js +0 -0
- /package/{packages/sdk/dist → dist}/bound-params.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/bound-params.js +0 -0
- /package/{packages/sdk/dist → dist}/chadbuffer.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/chadbuffer.js +0 -0
- /package/{packages/sdk/dist → dist}/claim-link.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/claim-link.js +0 -0
- /package/{packages/sdk/dist → dist}/commitment-tree.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/commitment-tree.js +0 -0
- /package/{packages/sdk/dist → dist}/config.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/config.js +0 -0
- /package/{packages/sdk/dist → dist}/core/esplora.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/core/esplora.js +0 -0
- /package/{packages/sdk/dist → dist}/core/mempool.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/core/mempool.js +0 -0
- /package/{packages/sdk/dist → dist}/crypto-babyjub.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/crypto-babyjub.js +0 -0
- /package/{packages/sdk/dist → dist}/crypto.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/crypto.js +0 -0
- /package/{packages/sdk/dist → dist}/event-client.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/event-client.js +0 -0
- /package/{packages/sdk/dist → dist}/events.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/events.js +0 -0
- /package/{packages/sdk/dist → dist}/explorer.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/explorer.js +0 -0
- /package/{packages/sdk/dist → dist}/keys.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/keys.js +0 -0
- /package/{packages/sdk/dist → dist}/logger.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/logger.js +0 -0
- /package/{packages/sdk/dist → dist}/magicblock.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/magicblock.js +0 -0
- /package/{packages/sdk/dist → dist}/merkle.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/merkle.js +0 -0
- /package/{packages/sdk/dist → dist}/note.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/note.js +0 -0
- /package/{packages/sdk/dist → dist}/pda.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/pda.js +0 -0
- /package/{packages/sdk/dist → dist}/pool-state.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/pool-state.js +0 -0
- /package/{packages/sdk/dist → dist}/poseidon.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/poseidon.js +0 -0
- /package/{packages/sdk/dist → dist}/prover/index.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/prover/index.js +0 -0
- /package/{packages/sdk/dist → dist}/prover/mobile.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/prover/mobile.js +0 -0
- /package/{packages/sdk/dist → dist}/prover/web.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/prover/web.js +0 -0
- /package/{packages/sdk/dist → dist}/psbt.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/psbt.js +0 -0
- /package/{packages/sdk/dist → dist}/selective-disclosure.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/selective-disclosure.js +0 -0
- /package/{packages/sdk/dist → dist}/sender-memo.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/sender-memo.js +0 -0
- /package/{packages/sdk/dist → dist}/sns-resolver.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/sns-resolver.js +0 -0
- /package/{packages/sdk/dist → dist}/solana/connection.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/solana/connection.js +0 -0
- /package/{packages/sdk/dist → dist}/solana/priority-fee.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/solana/priority-fee.js +0 -0
- /package/{packages/sdk/dist → dist}/spend-doc.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/spend-doc.js +0 -0
- /package/{packages/sdk/dist → dist}/token-registry.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/token-registry.js +0 -0
- /package/{packages/sdk/dist → dist}/utils/encoding.d.ts +0 -0
- /package/{packages/sdk/dist → dist}/utils/encoding.js +0 -0
- /package/{packages/sdk/dist → dist}/vk-registry.d.ts +0 -0
- /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
|
+
}
|