@buildonspark/spark-sdk 0.1.38 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml +1 -1
  3. package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/arm64-v8a/libspark_frost.so +0 -0
  4. package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/arm64-v8a/libuniffi_spark_frost.so +0 -0
  5. package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/armeabi-v7a/libspark_frost.so +0 -0
  6. package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/armeabi-v7a/libuniffi_spark_frost.so +0 -0
  7. package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/x86/libspark_frost.so +0 -0
  8. package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/x86/libuniffi_spark_frost.so +0 -0
  9. package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/x86_64/libspark_frost.so +0 -0
  10. package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/x86_64/libuniffi_spark_frost.so +0 -0
  11. package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/arm64-v8a/libspark_frost.so +0 -0
  12. package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/arm64-v8a/libuniffi_spark_frost.so +0 -0
  13. package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/armeabi-v7a/libspark_frost.so +0 -0
  14. package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/armeabi-v7a/libuniffi_spark_frost.so +0 -0
  15. package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/x86/libspark_frost.so +0 -0
  16. package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/x86/libuniffi_spark_frost.so +0 -0
  17. package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/x86_64/libspark_frost.so +0 -0
  18. package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/x86_64/libuniffi_spark_frost.so +0 -0
  19. package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/arm64-v8a/libspark_frost.so +0 -0
  20. package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/arm64-v8a/libuniffi_spark_frost.so +0 -0
  21. package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/armeabi-v7a/libspark_frost.so +0 -0
  22. package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/armeabi-v7a/libuniffi_spark_frost.so +0 -0
  23. package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/x86/libspark_frost.so +0 -0
  24. package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/x86/libuniffi_spark_frost.so +0 -0
  25. package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/x86_64/libspark_frost.so +0 -0
  26. package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/x86_64/libuniffi_spark_frost.so +0 -0
  27. package/dist/{RequestLightningSendInput-B4JdzclX.d.ts → RequestLightningSendInput-CJtcHOnu.d.ts} +1 -1
  28. package/dist/{RequestLightningSendInput-39_zGri6.d.cts → RequestLightningSendInput-DfmfqzZo.d.cts} +1 -1
  29. package/dist/address/index.d.cts +1 -1
  30. package/dist/address/index.d.ts +1 -1
  31. package/dist/address/index.js +2 -2
  32. package/dist/{chunk-W3EC5XSA.js → chunk-5MNQB2T4.js} +2 -2
  33. package/dist/chunk-ED3ZAFDI.js +784 -0
  34. package/dist/{chunk-VJTDG4BQ.js → chunk-HK6LPV6Z.js} +10 -1
  35. package/dist/{chunk-7WRK6WNJ.js → chunk-LHT4QTFK.js} +556 -41
  36. package/dist/{chunk-RAPBVYJY.js → chunk-RFCXPGDM.js} +26 -4
  37. package/dist/{chunk-DI7QXUQJ.js → chunk-W2VXS35Y.js} +4 -4
  38. package/dist/graphql/objects/index.d.cts +5 -4
  39. package/dist/graphql/objects/index.d.ts +5 -4
  40. package/dist/{index-CxAi2L8y.d.ts → index-BDEYgYxP.d.ts} +42 -4
  41. package/dist/{index-Dm17Ggfe.d.cts → index-CLdtdMU4.d.cts} +42 -4
  42. package/dist/index.cjs +1069 -40
  43. package/dist/index.d.cts +6 -6
  44. package/dist/index.d.ts +6 -6
  45. package/dist/index.js +33 -17
  46. package/dist/index.node.cjs +1069 -40
  47. package/dist/index.node.d.cts +6 -6
  48. package/dist/index.node.d.ts +6 -6
  49. package/dist/index.node.js +33 -17
  50. package/dist/native/index.cjs +1069 -40
  51. package/dist/native/index.d.cts +108 -5
  52. package/dist/native/index.d.ts +108 -5
  53. package/dist/native/index.js +1065 -40
  54. package/dist/{network-GFGEHkS4.d.cts → network-B10hBoHp.d.cts} +8 -1
  55. package/dist/{network-DobHpaV6.d.ts → network-CCgyIsGl.d.ts} +8 -1
  56. package/dist/services/config.cjs +29 -12
  57. package/dist/services/config.d.cts +4 -4
  58. package/dist/services/config.d.ts +4 -4
  59. package/dist/services/config.js +5 -5
  60. package/dist/services/connection.d.cts +4 -4
  61. package/dist/services/connection.d.ts +4 -4
  62. package/dist/services/connection.js +2 -2
  63. package/dist/services/index.cjs +30 -13
  64. package/dist/services/index.d.cts +4 -4
  65. package/dist/services/index.d.ts +4 -4
  66. package/dist/services/index.js +8 -8
  67. package/dist/services/lrc-connection.d.cts +4 -4
  68. package/dist/services/lrc-connection.d.ts +4 -4
  69. package/dist/services/lrc-connection.js +1 -1
  70. package/dist/services/token-transactions.cjs +1 -1
  71. package/dist/services/token-transactions.d.cts +4 -4
  72. package/dist/services/token-transactions.d.ts +4 -4
  73. package/dist/services/token-transactions.js +3 -3
  74. package/dist/services/wallet-config.d.cts +4 -4
  75. package/dist/services/wallet-config.d.ts +4 -4
  76. package/dist/signer/signer.cjs +23 -6
  77. package/dist/signer/signer.d.cts +3 -2
  78. package/dist/signer/signer.d.ts +3 -2
  79. package/dist/signer/signer.js +1 -1
  80. package/dist/{signer-DFGw9RRp.d.ts → signer-C5h1DpjF.d.ts} +4 -1
  81. package/dist/{signer-C1t40Wus.d.cts → signer-CYwn7h9U.d.cts} +4 -1
  82. package/dist/types/index.d.cts +4 -3
  83. package/dist/types/index.d.ts +4 -3
  84. package/dist/utils/index.cjs +891 -2
  85. package/dist/utils/index.d.cts +62 -6
  86. package/dist/utils/index.d.ts +62 -6
  87. package/dist/utils/index.js +23 -7
  88. package/package.json +1 -1
  89. package/src/services/deposit.ts +23 -5
  90. package/src/services/token-transactions.ts +1 -1
  91. package/src/services/transfer.ts +218 -11
  92. package/src/services/tree-creation.ts +29 -14
  93. package/src/signer/signer.ts +47 -5
  94. package/src/spark-wallet/spark-wallet.ts +430 -4
  95. package/src/tests/integration/swap.test.ts +225 -0
  96. package/src/tests/integration/tree-creation.test.ts +5 -1
  97. package/src/utils/index.ts +1 -0
  98. package/src/utils/mempool.ts +26 -1
  99. package/src/utils/network.ts +15 -0
  100. package/src/utils/transaction.ts +22 -2
  101. package/src/utils/unilateral-exit.ts +729 -0
  102. package/dist/chunk-E5SL7XTO.js +0 -301
  103. package/dist/{chunk-LIP2K6KR.js → chunk-2CDJZQN4.js} +3 -3
  104. package/dist/{chunk-RGWBSZIO.js → chunk-I4JI6TYN.js} +4 -4
@@ -0,0 +1,729 @@
1
+ // unilateral-exit.ts
2
+
3
+ import { bytesToHex, hexToBytes } from "@noble/curves/abstract/utils";
4
+ import { ripemd160 } from "@noble/hashes/legacy";
5
+ import { sha256 } from "@noble/hashes/sha2";
6
+ import * as btc from "@scure/btc-signer";
7
+ import * as psbt from "@scure/btc-signer/psbt";
8
+ import type { SparkServiceClient } from "../proto/spark.js";
9
+ import { TreeNode } from "../proto/spark.js";
10
+ import { getTxFromRawTxHex, getTxId } from "../utils/bitcoin.js";
11
+ import { isTxBroadcast } from "../utils/mempool.js";
12
+
13
+ // Types
14
+ export interface LeafInfo {
15
+ leafId: string;
16
+ nodeTx: string; // raw tx hex
17
+ refundTx: string; // raw tx hex
18
+ // Add other fields as needed
19
+ }
20
+
21
+ export interface Utxo {
22
+ txid: string;
23
+ vout: number;
24
+ value: bigint;
25
+ script: string;
26
+ publicKey: string;
27
+ }
28
+
29
+ export interface FeeRate {
30
+ satPerVbyte: number;
31
+ }
32
+
33
+ export interface FeeBumpTxPackage {
34
+ tx: string;
35
+ feeBumpPsbt?: string;
36
+ }
37
+
38
+ export interface FeeBumpTxChain {
39
+ leafId: string;
40
+ txPackages: FeeBumpTxPackage[];
41
+ }
42
+
43
+ export interface TxChain {
44
+ leafId: string;
45
+ transactions: string[];
46
+ }
47
+
48
+ export interface BroadcastConfig {
49
+ bitcoinCoreRpcUrl?: string;
50
+ rpcUsername?: string;
51
+ rpcPassword?: string;
52
+ autoBroadcast?: boolean;
53
+ network?: "MAINNET" | "REGTEST" | "TESTNET" | "SIGNET" | "LOCAL";
54
+ }
55
+
56
+ export interface BroadcastResult {
57
+ success: boolean;
58
+ txids?: string[];
59
+ error?: string;
60
+ broadcastedPackages?: number;
61
+ }
62
+
63
+ // Helper function to convert WIF private key to hex
64
+ function wifToHex(wif: string): string {
65
+ try {
66
+ // WIF decoding using base58 (simplified version)
67
+ const base58Alphabet =
68
+ "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
69
+
70
+ // Decode base58
71
+ let decoded = BigInt(0);
72
+ for (let i = 0; i < wif.length; i++) {
73
+ const char = wif[i];
74
+ if (!char) {
75
+ throw new Error("Invalid character in WIF at position " + i);
76
+ }
77
+ const index = base58Alphabet.indexOf(char);
78
+ if (index === -1) {
79
+ throw new Error("Invalid character in WIF");
80
+ }
81
+ decoded = decoded * BigInt(58) + BigInt(index);
82
+ }
83
+
84
+ // Convert to hex and pad to ensure proper length
85
+ let hex = decoded.toString(16);
86
+
87
+ // WIF format: [version][32-byte private key][compression flag][4-byte checksum]
88
+ // We want the 32-byte private key part (skip version byte, take 32 bytes)
89
+ if (hex.length >= 74) {
90
+ // 1 + 32 + 1 + 4 = 38 bytes = 76 hex chars minimum
91
+ // Skip version byte (2 hex chars) and take 32 bytes (64 hex chars)
92
+ const privateKeyHex = hex.substring(2, 66);
93
+ return privateKeyHex;
94
+ }
95
+
96
+ throw new Error("Invalid WIF length");
97
+ } catch (error) {
98
+ throw new Error(`Failed to convert WIF to hex: ${error}`);
99
+ }
100
+ }
101
+
102
+ export function isEphemeralAnchorOutput(
103
+ script?: Uint8Array,
104
+ amount?: bigint,
105
+ ): boolean {
106
+ return Boolean(
107
+ amount === 0n &&
108
+ script &&
109
+ // Pattern 1: Bare OP_TRUE (single byte 0x51)
110
+ ((script.length === 1 && script[0] === 0x51) ||
111
+ // Pattern 2: Push OP_TRUE (two bytes 0x01 0x51) - MALFORMED but we detect it
112
+ (script.length === 2 && script[0] === 0x01 && script[1] === 0x51) ||
113
+ // Pattern 3: Bitcoin v29 ephemeral anchor script (7 bytes: 015152014e0173)
114
+ (script.length === 7 &&
115
+ script[0] === 0x01 &&
116
+ script[1] === 0x51 &&
117
+ script[2] === 0x52 &&
118
+ script[3] === 0x01 &&
119
+ script[4] === 0x4e &&
120
+ script[5] === 0x01 &&
121
+ script[6] === 0x73) ||
122
+ // Pattern 4: Bitcoin ephemeral anchor OP_1 + push 2 bytes (4 bytes: 51024e73)
123
+ (script.length === 4 &&
124
+ script[0] === 0x51 &&
125
+ script[1] === 0x02 &&
126
+ script[2] === 0x4e &&
127
+ script[3] === 0x73)),
128
+ );
129
+ }
130
+
131
+ // Main function to generate unilateral exit tx chains for broadcasting non-CPFP transactions
132
+ export async function constructUnilateralExitTxs(
133
+ nodeHexStrings: string[],
134
+ sparkClient?: SparkServiceClient,
135
+ network?: any, // Network enum from the proto
136
+ ): Promise<TxChain[]> {
137
+ const result: TxChain[] = [];
138
+
139
+ // Convert hex strings to TreeNode objects
140
+ const nodes = nodeHexStrings.map((hex) => TreeNode.decode(hexToBytes(hex)));
141
+
142
+ // Create a map of nodes by ID for easy lookup
143
+ const nodeMap = new Map<string, TreeNode>();
144
+ for (const node of nodes) {
145
+ nodeMap.set(node.id, node);
146
+ }
147
+
148
+ // For each provided node, build its complete chain to the root
149
+ for (const node of nodes) {
150
+ const transactions: string[] = [];
151
+
152
+ // Build the chain from this node to the root
153
+ const chain: TreeNode[] = [];
154
+ let currentNode = node;
155
+
156
+ // Walk up the chain to find the root, querying for each parent
157
+ while (currentNode) {
158
+ chain.unshift(currentNode);
159
+
160
+ if (currentNode.parentNodeId) {
161
+ // Check if we already have the parent in our map (from previous queries)
162
+ let parentNode = nodeMap.get(currentNode.parentNodeId);
163
+
164
+ if (!parentNode && sparkClient) {
165
+ // Query for the parent node
166
+ try {
167
+ const response = await sparkClient.query_nodes({
168
+ source: {
169
+ $case: "nodeIds",
170
+ nodeIds: {
171
+ nodeIds: [currentNode.parentNodeId],
172
+ },
173
+ },
174
+ includeParents: true,
175
+ network: network || 0, // Default to mainnet if not provided
176
+ });
177
+
178
+ parentNode = response.nodes[currentNode.parentNodeId];
179
+
180
+ if (parentNode) {
181
+ // Add to our map for future use
182
+ nodeMap.set(currentNode.parentNodeId, parentNode);
183
+ }
184
+ } catch (error) {
185
+ console.warn(
186
+ `Failed to query parent node ${currentNode.parentNodeId}: ${error}`,
187
+ );
188
+ break;
189
+ }
190
+ }
191
+
192
+ if (parentNode) {
193
+ currentNode = parentNode;
194
+ } else {
195
+ if (!sparkClient) {
196
+ console.warn(
197
+ `Parent node ${currentNode.parentNodeId} not found. Provide a sparkClient to fetch missing parents.`,
198
+ );
199
+ } else {
200
+ console.warn(
201
+ `Parent node ${currentNode.parentNodeId} not found in database. Chain may be incomplete.`,
202
+ );
203
+ }
204
+ break;
205
+ }
206
+ } else {
207
+ // We've reached the root
208
+ break;
209
+ }
210
+ }
211
+
212
+ // Now walk down the chain from root to leaf to build transactions
213
+ for (const chainNode of chain) {
214
+ // Add node tx
215
+ const nodeTx = bytesToHex(chainNode.nodeTx);
216
+ transactions.push(nodeTx);
217
+
218
+ // If this is the original node we started with, also add its refund tx
219
+ if (chainNode.id === node.id) {
220
+ const refundTx = bytesToHex(chainNode.refundTx);
221
+ transactions.push(refundTx);
222
+ }
223
+ }
224
+
225
+ result.push({
226
+ leafId: node.id,
227
+ transactions,
228
+ });
229
+ }
230
+
231
+ return result;
232
+ }
233
+
234
+ // Main function to generate unilateral exit tx chains for broadcasting CPFP transactions
235
+ export async function constructUnilateralExitFeeBumpPackages(
236
+ nodeHexStrings: string[],
237
+ utxos: Utxo[],
238
+ feeRate: FeeRate,
239
+ electrsUrl: string,
240
+ sparkClient?: SparkServiceClient,
241
+ network?: any, // Network enum from the proto
242
+ ): Promise<FeeBumpTxChain[]> {
243
+ const result: FeeBumpTxChain[] = [];
244
+
245
+ // Sort UTXOs by value in descending order and make a copy we can modify
246
+ const availableUtxos = [...utxos].sort((a, b) => {
247
+ if (a.value > b.value) return -1;
248
+ if (a.value < b.value) return 1;
249
+ return 0;
250
+ });
251
+
252
+ // Convert hex strings to TreeNode objects with better error handling
253
+ const nodes: TreeNode[] = [];
254
+ for (let i = 0; i < nodeHexStrings.length; i++) {
255
+ const hex = nodeHexStrings[i];
256
+ try {
257
+ // Validate that this looks like a proper hex string
258
+ if (!hex || hex.length === 0) {
259
+ throw new Error(`Node hex string at index ${i} is empty`);
260
+ }
261
+
262
+ // Check if this might be a raw transaction hex instead of TreeNode hex
263
+ // Raw transaction hex typically starts with version (03000000 for version 3, required for TRUC/ephemeral anchors)
264
+ if (
265
+ hex.startsWith("03000000") ||
266
+ hex.startsWith("02000000") ||
267
+ hex.startsWith("01000000")
268
+ ) {
269
+ throw new Error(
270
+ `Node hex string at index ${i} appears to be a raw transaction hex, not a TreeNode protobuf. Use 'leafidtohex' command to convert node IDs to proper hex strings.`,
271
+ );
272
+ }
273
+
274
+ const nodeBytes = hexToBytes(hex);
275
+ const node = TreeNode.decode(nodeBytes);
276
+
277
+ // Validate that the decoded node has required fields
278
+ if (!node.id) {
279
+ throw new Error(
280
+ `Decoded TreeNode at index ${i} is missing required 'id' field`,
281
+ );
282
+ }
283
+ if (!node.nodeTx || node.nodeTx.length === 0) {
284
+ throw new Error(
285
+ `Decoded TreeNode at index ${i} is missing required 'nodeTx' field`,
286
+ );
287
+ }
288
+
289
+ nodes.push(node);
290
+ } catch (decodeError) {
291
+ throw new Error(
292
+ `Failed to decode TreeNode hex string at index ${i}: ${decodeError}. Make sure you're providing TreeNode protobuf hex strings, not raw transaction hex. Use 'leafidtohex' command to get proper hex strings.`,
293
+ );
294
+ }
295
+ }
296
+
297
+ // Create a map of nodes by ID for easy lookup
298
+ const nodeMap = new Map<string, TreeNode>();
299
+ for (const node of nodes) {
300
+ nodeMap.set(node.id, node);
301
+ }
302
+
303
+ // Create a map of transactions that have already been broadcast to prevent
304
+ // attempting to do so again.
305
+ const broadcastTxs = new Map<string, boolean>();
306
+
307
+ // For each provided node, build its complete chain to the root
308
+ for (const node of nodes) {
309
+ const txPackages: FeeBumpTxPackage[] = [];
310
+ let previousFeeBumpTx: string | undefined;
311
+
312
+ // Build the chain from this node to the root
313
+ // TODO(aakselrod): check whether
314
+ // - two leaves are hanging off the same tree
315
+ // - any ancestor nodes are already broadcast/confirmed
316
+ const chain: TreeNode[] = [];
317
+ let currentNode = node;
318
+
319
+ // Walk up the chain to find the root, querying for each parent
320
+ while (currentNode) {
321
+ chain.unshift(currentNode);
322
+
323
+ if (currentNode.parentNodeId) {
324
+ // Check if we already have the parent in our map (from previous queries)
325
+ let parentNode = nodeMap.get(currentNode.parentNodeId);
326
+
327
+ if (!parentNode && sparkClient) {
328
+ // Query for the parent node
329
+ try {
330
+ const response = await sparkClient.query_nodes({
331
+ source: {
332
+ $case: "nodeIds",
333
+ nodeIds: {
334
+ nodeIds: [currentNode.parentNodeId],
335
+ },
336
+ },
337
+ includeParents: true,
338
+ network: network || 0, // Default to mainnet if not provided
339
+ });
340
+
341
+ parentNode = response.nodes[currentNode.parentNodeId];
342
+
343
+ if (parentNode) {
344
+ // Add to our map for future use
345
+ nodeMap.set(currentNode.parentNodeId, parentNode);
346
+ }
347
+ } catch (error) {
348
+ console.warn(
349
+ `Failed to query parent node ${currentNode.parentNodeId}: ${error}`,
350
+ );
351
+ break;
352
+ }
353
+ }
354
+
355
+ if (parentNode) {
356
+ currentNode = parentNode;
357
+ } else {
358
+ if (!sparkClient) {
359
+ console.warn(
360
+ `Parent node ${currentNode.parentNodeId} not found. Provide a sparkClient to fetch missing parents.`,
361
+ );
362
+ } else {
363
+ console.warn(
364
+ `Parent node ${currentNode.parentNodeId} not found in database. Chain may be incomplete.`,
365
+ );
366
+ }
367
+ break;
368
+ }
369
+ } else {
370
+ // We've reached the root
371
+ break;
372
+ }
373
+ }
374
+
375
+ // Now walk down the chain from root to leaf to build fee bump packages
376
+ for (const chainNode of chain) {
377
+ // Add node tx and its fee bump
378
+ let nodeTxHex = bytesToHex(chainNode.nodeTx);
379
+
380
+ // Robust check for malformed ephemeral anchor in nodeTx
381
+ try {
382
+ const txObj = getTxFromRawTxHex(nodeTxHex);
383
+ const txid = getTxId(txObj);
384
+ if (broadcastTxs.get(txid)) {
385
+ // We already created a package for this node in another leaf.
386
+ continue;
387
+ }
388
+ broadcastTxs.set(txid, true);
389
+ const isBroadcast = await isTxBroadcast(txid, electrsUrl, network);
390
+ if (isBroadcast) {
391
+ // This node has already been broadcast, so we don't need to do so.
392
+ continue;
393
+ } else {
394
+ }
395
+ let anchorOutputScriptHex: string | undefined;
396
+ for (let i = txObj.outputsLength - 1; i >= 0; i--) {
397
+ const output = txObj.getOutput(i);
398
+ if (output?.amount === 0n && output.script) {
399
+ anchorOutputScriptHex = bytesToHex(output.script);
400
+ break;
401
+ }
402
+ }
403
+ } catch (parseError) {
404
+ console.error(
405
+ `❌ Error parsing nodeTx for anchor check (node ${chainNode.id}): ${parseError}`,
406
+ );
407
+ console.log(
408
+ ` This may indicate a corrupted transaction in the TreeNode.`,
409
+ );
410
+ console.log(` Transaction hex: ${nodeTxHex}`);
411
+
412
+ // Try to proceed anyway, but warn the user
413
+ console.log(
414
+ ` Attempting to continue with original hex, but fee bump may fail.`,
415
+ );
416
+ }
417
+
418
+ const {
419
+ feeBumpPsbt: nodeFeeBumpPsbt,
420
+ usedUtxos,
421
+ correctedParentTx,
422
+ } = constructFeeBumpTx(nodeTxHex, availableUtxos, feeRate, undefined);
423
+
424
+ const feeBumpTx = btc.Transaction.fromPSBT(hexToBytes(nodeFeeBumpPsbt));
425
+
426
+ // Get the fee bump transaction's output, if any
427
+ var feeBumpOut: psbt.TransactionOutput | null =
428
+ feeBumpTx.outputsLength === 1 ? feeBumpTx.getOutput(0) : null;
429
+ var feeBumpOutPubKey: string | null = null;
430
+
431
+ // Remove used UTXOs from the available list
432
+ for (const usedUtxo of usedUtxos) {
433
+ if (feeBumpOut && bytesToHex(feeBumpOut.script!) == usedUtxo.script) {
434
+ feeBumpOutPubKey = usedUtxo.publicKey;
435
+ }
436
+ const index = availableUtxos.findIndex(
437
+ (u) => u.txid === usedUtxo.txid && u.vout === usedUtxo.vout,
438
+ );
439
+ if (index !== -1) {
440
+ availableUtxos.splice(index, 1);
441
+ }
442
+ }
443
+
444
+ // If the fee bump TX has an output, it should have the same key as the
445
+ // input. We can add the output to the beginning of the list.
446
+ if (feeBumpOut)
447
+ availableUtxos.unshift({
448
+ txid: getTxId(feeBumpTx),
449
+ vout: 0,
450
+ value: feeBumpOut.amount!,
451
+ script: bytesToHex(feeBumpOut.script!),
452
+ publicKey: feeBumpOutPubKey!,
453
+ });
454
+
455
+ // Use the corrected parent transaction if it was fixed
456
+ const finalNodeTx = correctedParentTx || nodeTxHex;
457
+ txPackages.push({ tx: finalNodeTx, feeBumpPsbt: nodeFeeBumpPsbt });
458
+
459
+ // If this is the original node we started with, also add its refund tx
460
+ if (chainNode.id === node.id) {
461
+ let refundTxHex = bytesToHex(chainNode.refundTx);
462
+
463
+ // Robust check for malformed ephemeral anchor in refundTx
464
+ try {
465
+ const txObj = getTxFromRawTxHex(refundTxHex);
466
+ let anchorOutputScriptHex: string | undefined;
467
+ for (let i = txObj.outputsLength - 1; i >= 0; i--) {
468
+ const output = txObj.getOutput(i);
469
+ if (output?.amount === 0n && output.script) {
470
+ anchorOutputScriptHex = bytesToHex(output.script);
471
+ break;
472
+ }
473
+ }
474
+ } catch (parseError) {
475
+ console.error(
476
+ `❌ Error parsing refundTx for anchor check (node ${chainNode.id}): ${parseError}`,
477
+ );
478
+ console.log(
479
+ ` This may indicate a corrupted refund transaction in the TreeNode.`,
480
+ );
481
+ console.log(` Refund transaction hex: ${refundTxHex}`);
482
+
483
+ // Try to proceed anyway, but warn the user
484
+ console.log(
485
+ ` Attempting to continue with original refund hex, but this transaction may be invalid.`,
486
+ );
487
+ }
488
+
489
+ const refundFeeBump = constructFeeBumpTx(
490
+ refundTxHex,
491
+ availableUtxos,
492
+ feeRate,
493
+ undefined,
494
+ );
495
+
496
+ txPackages.push({
497
+ tx: refundTxHex,
498
+ feeBumpPsbt: refundFeeBump.feeBumpPsbt,
499
+ });
500
+ }
501
+ }
502
+
503
+ result.push({
504
+ leafId: node.id,
505
+ txPackages,
506
+ });
507
+ }
508
+
509
+ return result;
510
+ }
511
+
512
+ // Helper function to create RIPEMD160(SHA256(data)) hash
513
+ function hash160(data: Uint8Array): Uint8Array {
514
+ // Proper implementation using RIPEMD160(SHA256(data))
515
+ const sha256Hash = sha256(data);
516
+ return ripemd160(sha256Hash);
517
+ }
518
+
519
+ // Helper function to construct a fee bump tx for a given tx using available UTXOs
520
+ export function constructFeeBumpTx(
521
+ txHex: string,
522
+ utxos: Utxo[],
523
+ feeRate: FeeRate,
524
+ previousFeeBumpTx?: string, // Optional previous fee bump tx to chain from
525
+ ): { feeBumpPsbt: string; usedUtxos: Utxo[]; correctedParentTx?: string } {
526
+ // Validate inputs first
527
+ if (!txHex || txHex.length === 0) {
528
+ throw new Error("Transaction hex string is empty or undefined");
529
+ }
530
+
531
+ if (utxos.length === 0) {
532
+ throw new Error("No UTXOs available for fee bump");
533
+ }
534
+
535
+ // Check for and fix malformed ephemeral anchor BEFORE parsing
536
+ let correctedTxHex = txHex;
537
+
538
+ // Decode the parent tx using the utility function with error handling
539
+ let parentTx: any;
540
+ try {
541
+ parentTx = getTxFromRawTxHex(correctedTxHex);
542
+ if (!parentTx) {
543
+ throw new Error("getTxFromRawTxHex returned null/undefined");
544
+ }
545
+ } catch (parseError) {
546
+ throw new Error(
547
+ `Failed to parse parent transaction hex: ${parseError}. Transaction hex: ${correctedTxHex}`,
548
+ );
549
+ }
550
+
551
+ // Validate the parsed transaction has the expected structure
552
+ try {
553
+ const outputsLength = parentTx.outputsLength;
554
+ const inputsLength = parentTx.inputsLength;
555
+ if (typeof outputsLength !== "number" || outputsLength < 0) {
556
+ throw new Error(
557
+ "Invalid transaction: outputsLength is not a valid number",
558
+ );
559
+ }
560
+ if (typeof inputsLength !== "number" || inputsLength < 0) {
561
+ throw new Error(
562
+ "Invalid transaction: inputsLength is not a valid number",
563
+ );
564
+ }
565
+ } catch (validationError) {
566
+ throw new Error(
567
+ `Transaction validation failed: ${validationError}. This may indicate a corrupted or malformed transaction.`,
568
+ );
569
+ }
570
+
571
+ // Find the ephemeral anchor output (should be the last output with 0 value and OP_TRUE script)
572
+ const parentTxIdFromLib = parentTx.id; // Use the library's built-in ID property
573
+
574
+ let ephemeralAnchorIndex = -1;
575
+
576
+ for (let i = 0; i < parentTx.outputsLength; i++) {
577
+ const output = parentTx.getOutput(i);
578
+
579
+ // Check for ephemeral anchor: 0 value and OP_TRUE script patterns
580
+ const isEphemeralAnchor = isEphemeralAnchorOutput(
581
+ output?.script,
582
+ output?.amount,
583
+ );
584
+
585
+ if (isEphemeralAnchor) {
586
+ ephemeralAnchorIndex = i;
587
+ break;
588
+ }
589
+ }
590
+ if (ephemeralAnchorIndex === -1) {
591
+ throw new Error(
592
+ "No ephemeral anchor output found in parent transaction. Expected a 0-value output with OP_TRUE script (0x51), malformed OP_TRUE (0x0151), Bitcoin v29 ephemeral anchor script (015152014e0173), or Bitcoin OP_1 + push 2 bytes script (51024e73).",
593
+ );
594
+ }
595
+
596
+ const ephemeralAnchorOutput = parentTx.getOutput(ephemeralAnchorIndex);
597
+ if (!ephemeralAnchorOutput)
598
+ throw new Error("No ephemeral anchor output found");
599
+ if (!ephemeralAnchorOutput.script)
600
+ throw new Error("No script found in ephemeral anchor output");
601
+
602
+ // Use all available UTXOs for funding
603
+ if (utxos.length === 0) {
604
+ throw new Error("No UTXOs available for fee bump");
605
+ }
606
+
607
+ // Create a new transaction using the builder pattern
608
+ const builder = new btc.Transaction({
609
+ version: 3,
610
+ allowUnknown: true,
611
+ allowLegacyWitnessUtxo: true,
612
+ }); // ✅ set v3 in constructor
613
+
614
+ // Track total value and process each funding UTXO
615
+ let totalValue = 0n;
616
+ const processedUtxos: Array<{
617
+ utxo: Utxo;
618
+ p2wpkhScript: Uint8Array;
619
+ }> = [];
620
+
621
+ for (let i = 0; i < utxos.length; i++) {
622
+ const fundingUtxo = utxos[i];
623
+ if (!fundingUtxo) {
624
+ throw new Error(`UTXO at index ${i} is undefined`);
625
+ }
626
+
627
+ // Derive the public key from the private key and create the correct script
628
+ const pubKeyHash = hash160(hexToBytes(fundingUtxo.publicKey));
629
+
630
+ const scriptToUse = new Uint8Array([0x00, 0x14, ...pubKeyHash]); // OP_0 + 20-byte hash (P2WPKH)
631
+
632
+ // Check if provided script is P2PKH and warn user
633
+ const providedScript = hexToBytes(fundingUtxo.script);
634
+ if (bytesToHex(scriptToUse) !== bytesToHex(providedScript)) {
635
+ throw new Error(
636
+ `❌ Derived script doesn't match provided script for UTXO ${i + 1}.`,
637
+ );
638
+ }
639
+
640
+ // Add funding UTXO as segwit input using witnessUtxo (P2WPKH only)
641
+ builder.addInput({
642
+ txid: fundingUtxo.txid,
643
+ index: fundingUtxo.vout,
644
+ sequence: 0xffffffff,
645
+ witnessUtxo: {
646
+ script: scriptToUse, // Always P2WPKH
647
+ amount: fundingUtxo.value,
648
+ },
649
+ });
650
+
651
+ totalValue += fundingUtxo.value;
652
+ processedUtxos.push({
653
+ utxo: fundingUtxo,
654
+ p2wpkhScript: scriptToUse,
655
+ });
656
+ }
657
+
658
+ // Add ephemeral anchor output as the last input - use direct script spend (not P2WSH)
659
+ builder.addInput({
660
+ txid: parentTxIdFromLib,
661
+ index: ephemeralAnchorIndex,
662
+ sequence: 0xffffffff,
663
+ witnessUtxo: {
664
+ script: ephemeralAnchorOutput.script, // Use the original script directly (not P2WSH wrapped)
665
+ amount: 0n,
666
+ },
667
+ });
668
+
669
+ // Use fixed fee of 1500 satoshis
670
+ // TODO(aakelrod): fix this
671
+ const fee = 1500n;
672
+
673
+ // Minimum change amount (546 satoshis for a standard output)
674
+ const remainingValue = totalValue - fee;
675
+
676
+ if (remainingValue <= 0n) {
677
+ throw new Error(
678
+ `Insufficient funds for fee bump. Required fee: ${fee} sats, Available: ${totalValue} sats`,
679
+ );
680
+ }
681
+
682
+ // Add output with remaining value using the first UTXO's P2WPKH script
683
+ if (processedUtxos.length === 0) {
684
+ throw new Error("No processed UTXOs available for change output");
685
+ }
686
+
687
+ const firstProcessedUtxo = processedUtxos[0];
688
+ if (!firstProcessedUtxo) {
689
+ throw new Error("First processed UTXO is undefined");
690
+ }
691
+
692
+ builder.addOutput({
693
+ script: firstProcessedUtxo.p2wpkhScript,
694
+ amount: remainingValue,
695
+ });
696
+
697
+ // Sign all funding UTXO inputs on the builder
698
+ for (let i = 0; i < processedUtxos.length; i++) {
699
+ const processed = processedUtxos[i];
700
+ if (!processed) {
701
+ throw new Error(`Processed UTXO at index ${i} is undefined`);
702
+ }
703
+
704
+ try {
705
+ // Set proper witness script for P2WPKH input
706
+ builder.updateInput(i, {
707
+ witnessScript: processed.p2wpkhScript,
708
+ });
709
+ builder.signIdx;
710
+ } catch (error) {
711
+ throw new Error(`Failed to handle funding UTXO input ${i + 1}: ${error}`);
712
+ }
713
+ }
714
+
715
+ // Extract transaction bytes (funding inputs are finalized, ephemeral anchor has empty witness)
716
+ let psbtHex;
717
+ try {
718
+ psbtHex = bytesToHex(builder.toPSBT());
719
+ } catch (error) {
720
+ throw new Error(`Failed to extract transaction: ${error}`);
721
+ }
722
+
723
+ // Return both the fee bump transaction hex and the UTXOs used
724
+ return {
725
+ feeBumpPsbt: psbtHex,
726
+ usedUtxos: utxos,
727
+ correctedParentTx: correctedTxHex !== txHex ? correctedTxHex : undefined,
728
+ };
729
+ }