@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.
- package/CHANGELOG.md +12 -0
- package/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml +1 -1
- package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/arm64-v8a/libspark_frost.so +0 -0
- package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/arm64-v8a/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/armeabi-v7a/libspark_frost.so +0 -0
- package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/armeabi-v7a/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/x86/libspark_frost.so +0 -0
- package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/x86/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/x86_64/libspark_frost.so +0 -0
- package/android/build/intermediates/library_jni/debug/copyDebugJniLibsProjectOnly/jni/x86_64/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/arm64-v8a/libspark_frost.so +0 -0
- package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/arm64-v8a/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/armeabi-v7a/libspark_frost.so +0 -0
- package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/armeabi-v7a/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/x86/libspark_frost.so +0 -0
- package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/x86/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/x86_64/libspark_frost.so +0 -0
- package/android/build/intermediates/merged_jni_libs/debug/mergeDebugJniLibFolders/out/x86_64/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/arm64-v8a/libspark_frost.so +0 -0
- package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/arm64-v8a/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/armeabi-v7a/libspark_frost.so +0 -0
- package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/armeabi-v7a/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/x86/libspark_frost.so +0 -0
- package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/x86/libuniffi_spark_frost.so +0 -0
- package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/x86_64/libspark_frost.so +0 -0
- package/android/build/intermediates/merged_native_libs/debug/mergeDebugNativeLibs/out/lib/x86_64/libuniffi_spark_frost.so +0 -0
- package/dist/{RequestLightningSendInput-B4JdzclX.d.ts → RequestLightningSendInput-CJtcHOnu.d.ts} +1 -1
- package/dist/{RequestLightningSendInput-39_zGri6.d.cts → RequestLightningSendInput-DfmfqzZo.d.cts} +1 -1
- package/dist/address/index.d.cts +1 -1
- package/dist/address/index.d.ts +1 -1
- package/dist/address/index.js +2 -2
- package/dist/{chunk-W3EC5XSA.js → chunk-5MNQB2T4.js} +2 -2
- package/dist/chunk-ED3ZAFDI.js +784 -0
- package/dist/{chunk-VJTDG4BQ.js → chunk-HK6LPV6Z.js} +10 -1
- package/dist/{chunk-7WRK6WNJ.js → chunk-LHT4QTFK.js} +556 -41
- package/dist/{chunk-RAPBVYJY.js → chunk-RFCXPGDM.js} +26 -4
- package/dist/{chunk-DI7QXUQJ.js → chunk-W2VXS35Y.js} +4 -4
- package/dist/graphql/objects/index.d.cts +5 -4
- package/dist/graphql/objects/index.d.ts +5 -4
- package/dist/{index-CxAi2L8y.d.ts → index-BDEYgYxP.d.ts} +42 -4
- package/dist/{index-Dm17Ggfe.d.cts → index-CLdtdMU4.d.cts} +42 -4
- package/dist/index.cjs +1069 -40
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +33 -17
- package/dist/index.node.cjs +1069 -40
- package/dist/index.node.d.cts +6 -6
- package/dist/index.node.d.ts +6 -6
- package/dist/index.node.js +33 -17
- package/dist/native/index.cjs +1069 -40
- package/dist/native/index.d.cts +108 -5
- package/dist/native/index.d.ts +108 -5
- package/dist/native/index.js +1065 -40
- package/dist/{network-GFGEHkS4.d.cts → network-B10hBoHp.d.cts} +8 -1
- package/dist/{network-DobHpaV6.d.ts → network-CCgyIsGl.d.ts} +8 -1
- package/dist/services/config.cjs +29 -12
- package/dist/services/config.d.cts +4 -4
- package/dist/services/config.d.ts +4 -4
- package/dist/services/config.js +5 -5
- package/dist/services/connection.d.cts +4 -4
- package/dist/services/connection.d.ts +4 -4
- package/dist/services/connection.js +2 -2
- package/dist/services/index.cjs +30 -13
- package/dist/services/index.d.cts +4 -4
- package/dist/services/index.d.ts +4 -4
- package/dist/services/index.js +8 -8
- package/dist/services/lrc-connection.d.cts +4 -4
- package/dist/services/lrc-connection.d.ts +4 -4
- package/dist/services/lrc-connection.js +1 -1
- package/dist/services/token-transactions.cjs +1 -1
- package/dist/services/token-transactions.d.cts +4 -4
- package/dist/services/token-transactions.d.ts +4 -4
- package/dist/services/token-transactions.js +3 -3
- package/dist/services/wallet-config.d.cts +4 -4
- package/dist/services/wallet-config.d.ts +4 -4
- package/dist/signer/signer.cjs +23 -6
- package/dist/signer/signer.d.cts +3 -2
- package/dist/signer/signer.d.ts +3 -2
- package/dist/signer/signer.js +1 -1
- package/dist/{signer-DFGw9RRp.d.ts → signer-C5h1DpjF.d.ts} +4 -1
- package/dist/{signer-C1t40Wus.d.cts → signer-CYwn7h9U.d.cts} +4 -1
- package/dist/types/index.d.cts +4 -3
- package/dist/types/index.d.ts +4 -3
- package/dist/utils/index.cjs +891 -2
- package/dist/utils/index.d.cts +62 -6
- package/dist/utils/index.d.ts +62 -6
- package/dist/utils/index.js +23 -7
- package/package.json +1 -1
- package/src/services/deposit.ts +23 -5
- package/src/services/token-transactions.ts +1 -1
- package/src/services/transfer.ts +218 -11
- package/src/services/tree-creation.ts +29 -14
- package/src/signer/signer.ts +47 -5
- package/src/spark-wallet/spark-wallet.ts +430 -4
- package/src/tests/integration/swap.test.ts +225 -0
- package/src/tests/integration/tree-creation.test.ts +5 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/mempool.ts +26 -1
- package/src/utils/network.ts +15 -0
- package/src/utils/transaction.ts +22 -2
- package/src/utils/unilateral-exit.ts +729 -0
- package/dist/chunk-E5SL7XTO.js +0 -301
- package/dist/{chunk-LIP2K6KR.js → chunk-2CDJZQN4.js} +3 -3
- 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
|
+
}
|