@breeztech/breez-sdk-spark 0.11.0-dev2 → 0.11.0-dev3
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/breez-sdk-spark.tgz +0 -0
- package/bundler/breez_sdk_spark_wasm.d.ts +760 -592
- package/bundler/breez_sdk_spark_wasm_bg.js +342 -42
- package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +10 -3
- package/deno/breez_sdk_spark_wasm.d.ts +760 -592
- package/deno/breez_sdk_spark_wasm.js +310 -42
- package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +10 -3
- package/nodejs/breez_sdk_spark_wasm.d.ts +760 -592
- package/nodejs/breez_sdk_spark_wasm.js +343 -42
- package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +10 -3
- package/nodejs/index.js +10 -0
- package/nodejs/package.json +1 -0
- package/nodejs/postgres-tree-store/errors.cjs +13 -0
- package/nodejs/postgres-tree-store/index.cjs +798 -0
- package/nodejs/postgres-tree-store/migrations.cjs +150 -0
- package/nodejs/postgres-tree-store/package.json +9 -0
- package/package.json +1 -1
- package/web/breez_sdk_spark_wasm.d.ts +770 -595
- package/web/breez_sdk_spark_wasm.js +310 -42
- package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +10 -3
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommonJS implementation for Node.js PostgreSQL Tree Store
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
let pg;
|
|
6
|
+
try {
|
|
7
|
+
const mainModule = require.main;
|
|
8
|
+
if (mainModule) {
|
|
9
|
+
pg = mainModule.require("pg");
|
|
10
|
+
} else {
|
|
11
|
+
pg = require("pg");
|
|
12
|
+
}
|
|
13
|
+
} catch (error) {
|
|
14
|
+
try {
|
|
15
|
+
pg = require("pg");
|
|
16
|
+
} catch (fallbackError) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`pg not found. Please install it in your project: npm install pg@^8.18.0\n` +
|
|
19
|
+
`Original error: ${error.message}\nFallback error: ${fallbackError.message}`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { TreeStoreError } = require("./errors.cjs");
|
|
25
|
+
const { TreeStoreMigrationManager } = require("./migrations.cjs");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Advisory lock key for serializing tree store write operations.
|
|
29
|
+
* Matches the Rust constant TREE_STORE_WRITE_LOCK_KEY = 0x7472_6565_5354_4f52
|
|
30
|
+
*/
|
|
31
|
+
const TREE_STORE_WRITE_LOCK_KEY = "8391086132283252818"; // 0x7472656553544f52 as decimal string
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Timeout for reservations in seconds. Reservations older than this are stale.
|
|
35
|
+
*/
|
|
36
|
+
const RESERVATION_TIMEOUT_SECS = 300; // 5 minutes
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Threshold in milliseconds for cleaning up spent leaf markers.
|
|
40
|
+
*/
|
|
41
|
+
const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
42
|
+
|
|
43
|
+
class PostgresTreeStore {
|
|
44
|
+
constructor(pool, logger = null) {
|
|
45
|
+
this.pool = pool;
|
|
46
|
+
this.logger = logger;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Initialize the database (run migrations)
|
|
51
|
+
*/
|
|
52
|
+
async initialize() {
|
|
53
|
+
try {
|
|
54
|
+
const migrationManager = new TreeStoreMigrationManager(this.logger);
|
|
55
|
+
await migrationManager.migrate(this.pool);
|
|
56
|
+
return this;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new TreeStoreError(
|
|
59
|
+
`Failed to initialize PostgreSQL tree store: ${error.message}`,
|
|
60
|
+
error
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Close the pool
|
|
67
|
+
*/
|
|
68
|
+
async close() {
|
|
69
|
+
if (this.pool) {
|
|
70
|
+
await this.pool.end();
|
|
71
|
+
this.pool = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run a function inside a transaction with the advisory lock.
|
|
77
|
+
* @param {function(import('pg').PoolClient): Promise<T>} fn
|
|
78
|
+
* @returns {Promise<T>}
|
|
79
|
+
* @template T
|
|
80
|
+
*/
|
|
81
|
+
async _withWriteTransaction(fn) {
|
|
82
|
+
const client = await this.pool.connect();
|
|
83
|
+
try {
|
|
84
|
+
await client.query("BEGIN");
|
|
85
|
+
await client.query(`SELECT pg_advisory_xact_lock(${TREE_STORE_WRITE_LOCK_KEY})`);
|
|
86
|
+
const result = await fn(client);
|
|
87
|
+
await client.query("COMMIT");
|
|
88
|
+
return result;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
91
|
+
throw error;
|
|
92
|
+
} finally {
|
|
93
|
+
client.release();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ===== TreeStore Methods =====
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Add leaves to the store. Removes from spent leaves first, then upserts.
|
|
101
|
+
* @param {Array} leaves - Array of TreeNode objects
|
|
102
|
+
*/
|
|
103
|
+
async addLeaves(leaves) {
|
|
104
|
+
try {
|
|
105
|
+
if (!leaves || leaves.length === 0) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await this._withWriteTransaction(async (client) => {
|
|
110
|
+
// Remove these leaves from spent_leaves table
|
|
111
|
+
const leafIds = leaves.map((l) => l.id);
|
|
112
|
+
await this._batchRemoveSpentLeaves(client, leafIds);
|
|
113
|
+
|
|
114
|
+
// Batch upsert all leaves
|
|
115
|
+
await this._batchUpsertLeaves(client, leaves, false, null);
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error instanceof TreeStoreError) throw error;
|
|
119
|
+
throw new TreeStoreError(
|
|
120
|
+
`Failed to add leaves: ${error.message}`,
|
|
121
|
+
error
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get all leaves categorized by status.
|
|
128
|
+
* @returns {Promise<Object>} Leaves object with available, notAvailable, etc.
|
|
129
|
+
*/
|
|
130
|
+
async getLeaves() {
|
|
131
|
+
try {
|
|
132
|
+
const result = await this.pool.query(`
|
|
133
|
+
SELECT l.id, l.status, l.is_missing_from_operators, l.data,
|
|
134
|
+
l.reservation_id, r.purpose
|
|
135
|
+
FROM tree_leaves l
|
|
136
|
+
LEFT JOIN tree_reservations r ON l.reservation_id = r.id
|
|
137
|
+
`);
|
|
138
|
+
|
|
139
|
+
const available = [];
|
|
140
|
+
const notAvailable = [];
|
|
141
|
+
const availableMissingFromOperators = [];
|
|
142
|
+
const reservedForPayment = [];
|
|
143
|
+
const reservedForSwap = [];
|
|
144
|
+
|
|
145
|
+
for (const row of result.rows) {
|
|
146
|
+
const node = row.data;
|
|
147
|
+
|
|
148
|
+
if (row.purpose) {
|
|
149
|
+
if (row.purpose === "Payment") {
|
|
150
|
+
reservedForPayment.push(node);
|
|
151
|
+
} else if (row.purpose === "Swap") {
|
|
152
|
+
reservedForSwap.push(node);
|
|
153
|
+
}
|
|
154
|
+
} else if (row.is_missing_from_operators) {
|
|
155
|
+
if (node.status === "Available") {
|
|
156
|
+
availableMissingFromOperators.push(node);
|
|
157
|
+
}
|
|
158
|
+
} else if (node.status === "Available") {
|
|
159
|
+
available.push(node);
|
|
160
|
+
} else {
|
|
161
|
+
notAvailable.push(node);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
available,
|
|
167
|
+
notAvailable,
|
|
168
|
+
availableMissingFromOperators,
|
|
169
|
+
reservedForPayment,
|
|
170
|
+
reservedForSwap,
|
|
171
|
+
};
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof TreeStoreError) throw error;
|
|
174
|
+
throw new TreeStoreError(
|
|
175
|
+
`Failed to get leaves: ${error.message}`,
|
|
176
|
+
error
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Set leaves from a refresh operation.
|
|
183
|
+
* @param {Array} leaves - Available leaves from operators
|
|
184
|
+
* @param {Array} missingLeaves - Leaves missing from some operators
|
|
185
|
+
* @param {number} refreshStartedAtMs - Epoch milliseconds when refresh started
|
|
186
|
+
*/
|
|
187
|
+
async setLeaves(leaves, missingLeaves, refreshStartedAtMs) {
|
|
188
|
+
try {
|
|
189
|
+
await this._withWriteTransaction(async (client) => {
|
|
190
|
+
const refreshTimestamp = new Date(refreshStartedAtMs);
|
|
191
|
+
|
|
192
|
+
// Check for active swap or swap completed during refresh
|
|
193
|
+
const swapCheckResult = await client.query(`
|
|
194
|
+
SELECT
|
|
195
|
+
EXISTS(SELECT 1 FROM tree_reservations WHERE purpose = 'Swap') AS has_active_swap,
|
|
196
|
+
COALESCE((SELECT last_completed_at >= $1 FROM tree_swap_status WHERE id = 1), FALSE) AS swap_completed_during_refresh
|
|
197
|
+
`, [refreshTimestamp]);
|
|
198
|
+
|
|
199
|
+
const { has_active_swap, swap_completed_during_refresh } = swapCheckResult.rows[0];
|
|
200
|
+
|
|
201
|
+
if (has_active_swap || swap_completed_during_refresh) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Clean up old spent markers
|
|
206
|
+
await this._cleanupSpentMarkers(client, refreshTimestamp);
|
|
207
|
+
|
|
208
|
+
// Get recent spent leaf IDs (spent_at >= refresh_timestamp)
|
|
209
|
+
const spentResult = await client.query(
|
|
210
|
+
"SELECT leaf_id FROM tree_spent_leaves WHERE spent_at >= $1",
|
|
211
|
+
[refreshTimestamp]
|
|
212
|
+
);
|
|
213
|
+
const spentIds = new Set(spentResult.rows.map((r) => r.leaf_id));
|
|
214
|
+
|
|
215
|
+
// Delete non-reserved leaves added before refresh started
|
|
216
|
+
await client.query(
|
|
217
|
+
"DELETE FROM tree_leaves WHERE reservation_id IS NULL AND added_at < $1",
|
|
218
|
+
[refreshTimestamp]
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Clean up stale reservations (after leaf delete)
|
|
222
|
+
await this._cleanupStaleReservations(client);
|
|
223
|
+
|
|
224
|
+
// Upsert all leaves (filtering spent)
|
|
225
|
+
await this._batchUpsertLeaves(client, leaves, false, spentIds);
|
|
226
|
+
await this._batchUpsertLeaves(client, missingLeaves, true, spentIds);
|
|
227
|
+
});
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof TreeStoreError) throw error;
|
|
230
|
+
throw new TreeStoreError(
|
|
231
|
+
`Failed to set leaves: ${error.message}`,
|
|
232
|
+
error
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Cancel a reservation, releasing reserved leaves.
|
|
239
|
+
* @param {string} id - Reservation ID
|
|
240
|
+
*/
|
|
241
|
+
async cancelReservation(id) {
|
|
242
|
+
try {
|
|
243
|
+
await this._withWriteTransaction(async (client) => {
|
|
244
|
+
// Check if reservation exists
|
|
245
|
+
const res = await client.query(
|
|
246
|
+
"SELECT id FROM tree_reservations WHERE id = $1",
|
|
247
|
+
[id]
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
if (res.rows.length === 0) {
|
|
251
|
+
return; // Already cancelled or finalized
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Delete reservation (ON DELETE SET NULL releases leaves)
|
|
255
|
+
await client.query(
|
|
256
|
+
"DELETE FROM tree_reservations WHERE id = $1",
|
|
257
|
+
[id]
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error instanceof TreeStoreError) throw error;
|
|
262
|
+
throw new TreeStoreError(
|
|
263
|
+
`Failed to cancel reservation '${id}': ${error.message}`,
|
|
264
|
+
error
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Finalize a reservation, marking leaves as spent.
|
|
271
|
+
* @param {string} id - Reservation ID
|
|
272
|
+
* @param {Array|null} newLeaves - Optional new leaves to add
|
|
273
|
+
*/
|
|
274
|
+
async finalizeReservation(id, newLeaves) {
|
|
275
|
+
try {
|
|
276
|
+
await this._withWriteTransaction(async (client) => {
|
|
277
|
+
// Check if reservation exists and get purpose
|
|
278
|
+
const res = await client.query(
|
|
279
|
+
"SELECT id, purpose FROM tree_reservations WHERE id = $1",
|
|
280
|
+
[id]
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
if (res.rows.length === 0) {
|
|
284
|
+
return; // Already finalized or cancelled
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const isSwap = res.rows[0].purpose === "Swap";
|
|
288
|
+
|
|
289
|
+
// Get reserved leaf IDs
|
|
290
|
+
const leafResult = await client.query(
|
|
291
|
+
"SELECT id FROM tree_leaves WHERE reservation_id = $1",
|
|
292
|
+
[id]
|
|
293
|
+
);
|
|
294
|
+
const reservedLeafIds = leafResult.rows.map((r) => r.id);
|
|
295
|
+
|
|
296
|
+
// Mark as spent
|
|
297
|
+
await this._batchInsertSpentLeaves(client, reservedLeafIds);
|
|
298
|
+
|
|
299
|
+
// Delete reserved leaves and reservation
|
|
300
|
+
await client.query(
|
|
301
|
+
"DELETE FROM tree_leaves WHERE reservation_id = $1",
|
|
302
|
+
[id]
|
|
303
|
+
);
|
|
304
|
+
await client.query(
|
|
305
|
+
"DELETE FROM tree_reservations WHERE id = $1",
|
|
306
|
+
[id]
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// Add new leaves if provided
|
|
310
|
+
if (newLeaves && newLeaves.length > 0) {
|
|
311
|
+
await this._batchUpsertLeaves(client, newLeaves, false, null);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// If swap with new leaves, update last_completed_at
|
|
315
|
+
if (isSwap && newLeaves && newLeaves.length > 0) {
|
|
316
|
+
await client.query(
|
|
317
|
+
"UPDATE tree_swap_status SET last_completed_at = NOW() WHERE id = 1"
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error instanceof TreeStoreError) throw error;
|
|
323
|
+
throw new TreeStoreError(
|
|
324
|
+
`Failed to finalize reservation '${id}': ${error.message}`,
|
|
325
|
+
error
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Try to reserve leaves matching target amounts.
|
|
332
|
+
* @param {Object|null} targetAmounts - Target amounts spec
|
|
333
|
+
* @param {boolean} exactOnly - If true, only exact matches
|
|
334
|
+
* @param {string} purpose - "Payment" or "Swap"
|
|
335
|
+
* @returns {Promise<Object>} ReserveResult
|
|
336
|
+
*/
|
|
337
|
+
async tryReserveLeaves(targetAmounts, exactOnly, purpose) {
|
|
338
|
+
try {
|
|
339
|
+
return await this._withWriteTransaction(async (client) => {
|
|
340
|
+
const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
|
|
341
|
+
|
|
342
|
+
// Get available leaves
|
|
343
|
+
const availableResult = await client.query(`
|
|
344
|
+
SELECT data
|
|
345
|
+
FROM tree_leaves
|
|
346
|
+
WHERE status = 'Available'
|
|
347
|
+
AND is_missing_from_operators = FALSE
|
|
348
|
+
AND reservation_id IS NULL
|
|
349
|
+
`);
|
|
350
|
+
|
|
351
|
+
const availableLeaves = availableResult.rows.map((r) => r.data);
|
|
352
|
+
const available = availableLeaves.reduce((sum, l) => sum + l.value, 0);
|
|
353
|
+
|
|
354
|
+
// Calculate pending balance
|
|
355
|
+
const pending = await this._calculatePendingBalance(client);
|
|
356
|
+
|
|
357
|
+
// Try exact selection first
|
|
358
|
+
const selected = this._selectLeavesByTargetAmounts(availableLeaves, targetAmounts);
|
|
359
|
+
|
|
360
|
+
if (selected !== null) {
|
|
361
|
+
if (selected.length === 0) {
|
|
362
|
+
throw new TreeStoreError("NonReservableLeaves");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const reservationId = this._generateId();
|
|
366
|
+
await this._createReservation(client, reservationId, selected, purpose, 0);
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
type: "success",
|
|
370
|
+
reservation: {
|
|
371
|
+
id: reservationId,
|
|
372
|
+
leaves: selected,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!exactOnly) {
|
|
378
|
+
// Try minimum amount selection
|
|
379
|
+
const minSelected = this._selectLeavesByMinimumAmount(availableLeaves, targetAmount);
|
|
380
|
+
if (minSelected !== null) {
|
|
381
|
+
const reservedAmount = minSelected.reduce((sum, l) => sum + l.value, 0);
|
|
382
|
+
const pendingChange = reservedAmount > targetAmount && targetAmount > 0
|
|
383
|
+
? reservedAmount - targetAmount
|
|
384
|
+
: 0;
|
|
385
|
+
|
|
386
|
+
const reservationId = this._generateId();
|
|
387
|
+
await this._createReservation(client, reservationId, minSelected, purpose, pendingChange);
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
type: "success",
|
|
391
|
+
reservation: {
|
|
392
|
+
id: reservationId,
|
|
393
|
+
leaves: minSelected,
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// No suitable leaves found
|
|
400
|
+
if (available + pending >= targetAmount) {
|
|
401
|
+
return {
|
|
402
|
+
type: "waitForPending",
|
|
403
|
+
needed: targetAmount,
|
|
404
|
+
available,
|
|
405
|
+
pending,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { type: "insufficientFunds" };
|
|
410
|
+
});
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (error instanceof TreeStoreError) throw error;
|
|
413
|
+
throw new TreeStoreError(
|
|
414
|
+
`Failed to try reserve leaves: ${error.message}`,
|
|
415
|
+
error
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Get current database time as epoch milliseconds.
|
|
422
|
+
* @returns {Promise<number>}
|
|
423
|
+
*/
|
|
424
|
+
async now() {
|
|
425
|
+
try {
|
|
426
|
+
const result = await this.pool.query("SELECT NOW()");
|
|
427
|
+
return result.rows[0].now.getTime();
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw new TreeStoreError(
|
|
430
|
+
`Failed to get current time: ${error.message}`,
|
|
431
|
+
error
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Update a reservation after a swap.
|
|
438
|
+
* @param {string} reservationId - Existing reservation ID
|
|
439
|
+
* @param {Array} reservedLeaves - New reserved leaves
|
|
440
|
+
* @param {Array} changeLeaves - Change leaves to add to available pool
|
|
441
|
+
* @returns {Promise<Object>} Updated reservation
|
|
442
|
+
*/
|
|
443
|
+
async updateReservation(reservationId, reservedLeaves, changeLeaves) {
|
|
444
|
+
try {
|
|
445
|
+
return await this._withWriteTransaction(async (client) => {
|
|
446
|
+
// Check if reservation exists
|
|
447
|
+
const res = await client.query(
|
|
448
|
+
"SELECT id FROM tree_reservations WHERE id = $1",
|
|
449
|
+
[reservationId]
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
if (res.rows.length === 0) {
|
|
453
|
+
throw new TreeStoreError(`Reservation ${reservationId} not found`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Get old reserved leaf IDs and mark as spent
|
|
457
|
+
const oldLeavesResult = await client.query(
|
|
458
|
+
"SELECT id FROM tree_leaves WHERE reservation_id = $1",
|
|
459
|
+
[reservationId]
|
|
460
|
+
);
|
|
461
|
+
const oldLeafIds = oldLeavesResult.rows.map((r) => r.id);
|
|
462
|
+
|
|
463
|
+
await this._batchInsertSpentLeaves(client, oldLeafIds);
|
|
464
|
+
await client.query(
|
|
465
|
+
"DELETE FROM tree_leaves WHERE reservation_id = $1",
|
|
466
|
+
[reservationId]
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// Upsert change leaves to available pool
|
|
470
|
+
await this._batchUpsertLeaves(client, changeLeaves, false, null);
|
|
471
|
+
|
|
472
|
+
// Upsert reserved leaves
|
|
473
|
+
await this._batchUpsertLeaves(client, reservedLeaves, false, null);
|
|
474
|
+
|
|
475
|
+
// Set reservation_id on reserved leaves
|
|
476
|
+
const reservedLeafIds = reservedLeaves.map((l) => l.id);
|
|
477
|
+
await this._batchSetReservationId(client, reservationId, reservedLeafIds);
|
|
478
|
+
|
|
479
|
+
// Clear pending change amount
|
|
480
|
+
await client.query(
|
|
481
|
+
"UPDATE tree_reservations SET pending_change_amount = 0 WHERE id = $1",
|
|
482
|
+
[reservationId]
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
id: reservationId,
|
|
487
|
+
leaves: reservedLeaves,
|
|
488
|
+
};
|
|
489
|
+
});
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (error instanceof TreeStoreError) throw error;
|
|
492
|
+
throw new TreeStoreError(
|
|
493
|
+
`Failed to update reservation '${reservationId}': ${error.message}`,
|
|
494
|
+
error
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ===== Private Helpers =====
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Generate a unique reservation ID (UUIDv4).
|
|
503
|
+
*/
|
|
504
|
+
_generateId() {
|
|
505
|
+
// Use crypto.randomUUID if available, otherwise manual
|
|
506
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
507
|
+
return crypto.randomUUID();
|
|
508
|
+
}
|
|
509
|
+
// Fallback UUIDv4
|
|
510
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
511
|
+
const r = (Math.random() * 16) | 0;
|
|
512
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
513
|
+
return v.toString(16);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Calculate total sats from target amounts.
|
|
519
|
+
*/
|
|
520
|
+
_totalSats(targetAmounts) {
|
|
521
|
+
if (targetAmounts.type === "amountAndFee") {
|
|
522
|
+
return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
|
|
523
|
+
}
|
|
524
|
+
if (targetAmounts.type === "exactDenominations") {
|
|
525
|
+
return targetAmounts.denominations.reduce((sum, d) => sum + d, 0);
|
|
526
|
+
}
|
|
527
|
+
return 0;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Select leaves by target amounts. Returns null if no exact match found.
|
|
532
|
+
*/
|
|
533
|
+
_selectLeavesByTargetAmounts(leaves, targetAmounts) {
|
|
534
|
+
if (!targetAmounts) {
|
|
535
|
+
// No target: return all leaves (may be empty)
|
|
536
|
+
return [...leaves];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (targetAmounts.type === "amountAndFee") {
|
|
540
|
+
const amountLeaves = this._selectLeavesByExactAmount(leaves, targetAmounts.amountSats);
|
|
541
|
+
if (amountLeaves === null) return null;
|
|
542
|
+
|
|
543
|
+
if (targetAmounts.feeSats != null && targetAmounts.feeSats > 0) {
|
|
544
|
+
const amountIds = new Set(amountLeaves.map((l) => l.id));
|
|
545
|
+
const remaining = leaves.filter((l) => !amountIds.has(l.id));
|
|
546
|
+
const feeLeaves = this._selectLeavesByExactAmount(remaining, targetAmounts.feeSats);
|
|
547
|
+
if (feeLeaves === null) return null;
|
|
548
|
+
return [...amountLeaves, ...feeLeaves];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return amountLeaves;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (targetAmounts.type === "exactDenominations") {
|
|
555
|
+
return this._selectLeavesByExactDenominations(leaves, targetAmounts.denominations);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Select leaves that sum to exactly the target amount.
|
|
563
|
+
*/
|
|
564
|
+
_selectLeavesByExactAmount(leaves, targetAmount) {
|
|
565
|
+
if (targetAmount === 0) return null; // Invalid amount
|
|
566
|
+
|
|
567
|
+
const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
|
|
568
|
+
if (totalAvailable < targetAmount) return null; // Insufficient funds
|
|
569
|
+
|
|
570
|
+
// Try single exact match
|
|
571
|
+
const single = leaves.find((l) => l.value === targetAmount);
|
|
572
|
+
if (single) return [single];
|
|
573
|
+
|
|
574
|
+
// Try greedy multiple match
|
|
575
|
+
const multipleResult = this._findExactMultipleMatch(leaves, targetAmount);
|
|
576
|
+
return multipleResult;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Select leaves that match exact denominations.
|
|
581
|
+
*/
|
|
582
|
+
_selectLeavesByExactDenominations(leaves, denominations) {
|
|
583
|
+
const remaining = [...leaves];
|
|
584
|
+
const selected = [];
|
|
585
|
+
|
|
586
|
+
for (const denomination of denominations) {
|
|
587
|
+
const idx = remaining.findIndex((l) => l.value === denomination);
|
|
588
|
+
if (idx === -1) return null; // Can't match this denomination
|
|
589
|
+
selected.push(remaining[idx]);
|
|
590
|
+
remaining.splice(idx, 1);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return selected;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Select leaves summing to at least the target amount.
|
|
598
|
+
*/
|
|
599
|
+
_selectLeavesByMinimumAmount(leaves, targetAmount) {
|
|
600
|
+
if (targetAmount === 0) return null;
|
|
601
|
+
|
|
602
|
+
const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
|
|
603
|
+
if (totalAvailable < targetAmount) return null;
|
|
604
|
+
|
|
605
|
+
const result = [];
|
|
606
|
+
let sum = 0;
|
|
607
|
+
for (const leaf of leaves) {
|
|
608
|
+
sum += leaf.value;
|
|
609
|
+
result.push(leaf);
|
|
610
|
+
if (sum >= targetAmount) break;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return sum >= targetAmount ? result : null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Find exact multiple match using greedy algorithm.
|
|
618
|
+
*/
|
|
619
|
+
_findExactMultipleMatch(leaves, targetAmount) {
|
|
620
|
+
if (targetAmount === 0) return [];
|
|
621
|
+
if (leaves.length === 0) return null;
|
|
622
|
+
|
|
623
|
+
// Pass 1: Try greedy on all leaves
|
|
624
|
+
const result = this._greedyExactMatch(leaves, targetAmount);
|
|
625
|
+
if (result) return result;
|
|
626
|
+
|
|
627
|
+
// Pass 2: Try with only power-of-two leaves
|
|
628
|
+
const powerOfTwoLeaves = leaves.filter((l) => this._isPowerOfTwo(l.value));
|
|
629
|
+
if (powerOfTwoLeaves.length === leaves.length) return null;
|
|
630
|
+
|
|
631
|
+
return this._greedyExactMatch(powerOfTwoLeaves, targetAmount);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Greedy exact match algorithm.
|
|
636
|
+
*/
|
|
637
|
+
_greedyExactMatch(leaves, targetAmount) {
|
|
638
|
+
const sorted = [...leaves].sort((a, b) => b.value - a.value);
|
|
639
|
+
const result = [];
|
|
640
|
+
let remaining = targetAmount;
|
|
641
|
+
|
|
642
|
+
for (const leaf of sorted) {
|
|
643
|
+
if (leaf.value > remaining) continue;
|
|
644
|
+
remaining -= leaf.value;
|
|
645
|
+
result.push(leaf);
|
|
646
|
+
if (remaining === 0) return result;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Check if value is a power of two.
|
|
654
|
+
*/
|
|
655
|
+
_isPowerOfTwo(value) {
|
|
656
|
+
return value > 0 && (value & (value - 1)) === 0;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Calculate pending balance from in-flight swaps.
|
|
661
|
+
*/
|
|
662
|
+
async _calculatePendingBalance(client) {
|
|
663
|
+
const result = await client.query(
|
|
664
|
+
"SELECT COALESCE(SUM(pending_change_amount), 0)::BIGINT AS pending FROM tree_reservations"
|
|
665
|
+
);
|
|
666
|
+
return Number(result.rows[0].pending);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Create a reservation with the given leaves.
|
|
671
|
+
*/
|
|
672
|
+
async _createReservation(client, reservationId, leaves, purpose, pendingChange) {
|
|
673
|
+
await client.query(
|
|
674
|
+
"INSERT INTO tree_reservations (id, purpose, pending_change_amount) VALUES ($1, $2, $3)",
|
|
675
|
+
[reservationId, purpose, pendingChange]
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
const leafIds = leaves.map((l) => l.id);
|
|
679
|
+
await this._batchSetReservationId(client, reservationId, leafIds);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Batch upsert leaves into tree_leaves table.
|
|
684
|
+
*/
|
|
685
|
+
async _batchUpsertLeaves(client, leaves, isMissingFromOperators, skipIds) {
|
|
686
|
+
if (!leaves || leaves.length === 0) return;
|
|
687
|
+
|
|
688
|
+
const filtered = skipIds
|
|
689
|
+
? leaves.filter((l) => !skipIds.has(l.id))
|
|
690
|
+
: leaves;
|
|
691
|
+
|
|
692
|
+
if (filtered.length === 0) return;
|
|
693
|
+
|
|
694
|
+
const ids = filtered.map((l) => l.id);
|
|
695
|
+
const statuses = filtered.map((l) => l.status);
|
|
696
|
+
const missingFlags = filtered.map(() => isMissingFromOperators);
|
|
697
|
+
const dataValues = filtered.map((l) => JSON.stringify(l));
|
|
698
|
+
|
|
699
|
+
await client.query(
|
|
700
|
+
`INSERT INTO tree_leaves (id, status, is_missing_from_operators, data, added_at)
|
|
701
|
+
SELECT id, status, missing, data::jsonb, NOW()
|
|
702
|
+
FROM UNNEST($1::text[], $2::text[], $3::bool[], $4::text[])
|
|
703
|
+
AS t(id, status, missing, data)
|
|
704
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
705
|
+
status = EXCLUDED.status,
|
|
706
|
+
is_missing_from_operators = EXCLUDED.is_missing_from_operators,
|
|
707
|
+
data = EXCLUDED.data,
|
|
708
|
+
added_at = NOW()`,
|
|
709
|
+
[ids, statuses, missingFlags, dataValues]
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Batch set reservation_id on leaves.
|
|
715
|
+
*/
|
|
716
|
+
async _batchSetReservationId(client, reservationId, leafIds) {
|
|
717
|
+
if (leafIds.length === 0) return;
|
|
718
|
+
|
|
719
|
+
await client.query(
|
|
720
|
+
"UPDATE tree_leaves SET reservation_id = $1 WHERE id = ANY($2)",
|
|
721
|
+
[reservationId, leafIds]
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Batch insert spent leaf markers.
|
|
727
|
+
*/
|
|
728
|
+
async _batchInsertSpentLeaves(client, leafIds) {
|
|
729
|
+
if (leafIds.length === 0) return;
|
|
730
|
+
|
|
731
|
+
await client.query(
|
|
732
|
+
"INSERT INTO tree_spent_leaves (leaf_id) SELECT * FROM UNNEST($1::text[]) ON CONFLICT DO NOTHING",
|
|
733
|
+
[leafIds]
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Batch remove spent leaf markers.
|
|
739
|
+
*/
|
|
740
|
+
async _batchRemoveSpentLeaves(client, leafIds) {
|
|
741
|
+
if (leafIds.length === 0) return;
|
|
742
|
+
|
|
743
|
+
await client.query(
|
|
744
|
+
"DELETE FROM tree_spent_leaves WHERE leaf_id = ANY($1)",
|
|
745
|
+
[leafIds]
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Clean up stale reservations.
|
|
751
|
+
*/
|
|
752
|
+
async _cleanupStaleReservations(client) {
|
|
753
|
+
await client.query(
|
|
754
|
+
`DELETE FROM tree_reservations
|
|
755
|
+
WHERE created_at < NOW() - make_interval(secs => $1)`,
|
|
756
|
+
[RESERVATION_TIMEOUT_SECS]
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Clean up old spent markers.
|
|
762
|
+
*/
|
|
763
|
+
async _cleanupSpentMarkers(client, refreshTimestamp) {
|
|
764
|
+
const thresholdMs = SPENT_MARKER_CLEANUP_THRESHOLD_MS;
|
|
765
|
+
const cleanupCutoff = new Date(refreshTimestamp.getTime() - thresholdMs);
|
|
766
|
+
|
|
767
|
+
await client.query(
|
|
768
|
+
"DELETE FROM tree_spent_leaves WHERE spent_at < $1",
|
|
769
|
+
[cleanupCutoff]
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Create a PostgresTreeStore instance from a config object.
|
|
776
|
+
*
|
|
777
|
+
* @param {object} config - PostgreSQL configuration
|
|
778
|
+
* @param {string} config.connectionString - PostgreSQL connection string
|
|
779
|
+
* @param {number} config.maxPoolSize - Maximum number of connections in the pool
|
|
780
|
+
* @param {number} config.createTimeoutSecs - Timeout in seconds for establishing a new connection
|
|
781
|
+
* @param {number} config.recycleTimeoutSecs - Timeout in seconds before recycling an idle connection
|
|
782
|
+
* @param {object} [logger] - Optional logger
|
|
783
|
+
* @returns {Promise<PostgresTreeStore>}
|
|
784
|
+
*/
|
|
785
|
+
async function createPostgresTreeStore(config, logger = null) {
|
|
786
|
+
const pool = new pg.Pool({
|
|
787
|
+
connectionString: config.connectionString,
|
|
788
|
+
max: config.maxPoolSize,
|
|
789
|
+
connectionTimeoutMillis: config.createTimeoutSecs * 1000,
|
|
790
|
+
idleTimeoutMillis: config.recycleTimeoutSecs * 1000,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
const store = new PostgresTreeStore(pool, logger);
|
|
794
|
+
await store.initialize();
|
|
795
|
+
return store;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
module.exports = { PostgresTreeStore, createPostgresTreeStore, TreeStoreError };
|