@breeztech/breez-sdk-spark 0.22.2 → 0.23.0
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 +65 -5
- package/bundler/breez_sdk_spark_wasm_bg.js +66 -27
- package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
- package/bundler/index.js +15 -2
- package/bundler/package.json +5 -0
- package/bundler/storage/index.js +6 -0
- package/bundler/tree-store/index.js +1518 -0
- package/bundler/tree-store/package.json +12 -0
- package/deno/breez_sdk_spark_wasm.d.ts +65 -5
- package/deno/breez_sdk_spark_wasm.js +66 -27
- package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
- package/nodejs/breez_sdk_spark_wasm.d.ts +65 -5
- package/nodejs/breez_sdk_spark_wasm.js +66 -27
- package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
- package/nodejs/index.js +11 -0
- package/nodejs/mysql-storage/index.cjs +9 -1
- package/nodejs/mysql-storage/migrations.cjs +6 -0
- package/nodejs/mysql-tree-store/index.cjs +296 -29
- package/nodejs/mysql-tree-store/migrations.cjs +198 -34
- package/nodejs/package.json +1 -0
- package/nodejs/postgres-storage/index.cjs +9 -1
- package/nodejs/postgres-storage/migrations.cjs +6 -0
- package/nodejs/postgres-tree-store/index.cjs +301 -40
- package/nodejs/postgres-tree-store/migrations.cjs +42 -0
- package/nodejs/storage/index.cjs +14 -3
- package/nodejs/storage/migrations.cjs +6 -0
- package/nodejs/tree-store/errors.cjs +13 -0
- package/nodejs/tree-store/index.cjs +1185 -0
- package/nodejs/tree-store/migrations.cjs +185 -0
- package/nodejs/tree-store/package.json +9 -0
- package/package.json +1 -1
- package/web/breez_sdk_spark_wasm.d.ts +74 -11
- package/web/breez_sdk_spark_wasm.js +66 -27
- package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +9 -6
- package/web/index.js +15 -2
- package/web/package.json +5 -0
- package/web/storage/index.js +6 -0
- package/web/tree-store/index.js +1518 -0
- package/web/tree-store/package.json +12 -0
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommonJS implementation for the Node.js SQLite Tree Store.
|
|
3
|
+
*
|
|
4
|
+
* The single durable source of truth for one wallet's leaves, ancestors,
|
|
5
|
+
* reservations, and spent records. It shares the wallet's main better-sqlite3
|
|
6
|
+
* database file. Its `tree` table names are what keep it clear of the main
|
|
7
|
+
* storage's; the `brz_` prefix on top of those is for consistency with the
|
|
8
|
+
* Postgres and MySQL backends. There is no tenant/user column and no advisory
|
|
9
|
+
* locking:
|
|
10
|
+
* better-sqlite3 is synchronous and each method runs its transaction to
|
|
11
|
+
* completion without yielding.
|
|
12
|
+
*
|
|
13
|
+
* Two-table model (see migrations.cjs): `brz_tree_leaves` is the spendable pool;
|
|
14
|
+
* `brz_tree_ancestors` holds the intermediate exit-chain nodes, one row per
|
|
15
|
+
* leaf that descends from it (keyed by `leaf_id`), so fetching or dropping a
|
|
16
|
+
* leaf's chain never touches another leaf's copy of a shared node. SQL mirrors
|
|
17
|
+
* the Rust `spark-sqlite` store (crates/spark-sqlite/src/lib.rs). Selection
|
|
18
|
+
* logic mirrors the PostgreSQL tree store (js/postgres-tree-store/index.cjs).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ids bound per statement. SQLite caps a statement at 32766 parameters, which a
|
|
23
|
+
* wallet-wide chain backfill would otherwise pass. Low enough that the query
|
|
24
|
+
* binding each id twice stays under it as well. Mirrors `IDS_PER_STATEMENT` in
|
|
25
|
+
* the Rust `spark-sqlite` store.
|
|
26
|
+
*/
|
|
27
|
+
const IDS_PER_STATEMENT = 8000;
|
|
28
|
+
|
|
29
|
+
/** Splits `ids` into slices small enough to bind in one statement. */
|
|
30
|
+
function idChunks(ids) {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
for (let i = 0; i < ids.length; i += IDS_PER_STATEMENT) {
|
|
33
|
+
chunks.push(ids.slice(i, i + IDS_PER_STATEMENT));
|
|
34
|
+
}
|
|
35
|
+
return chunks;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Resolve better-sqlite3 from the calling module's context, same as node-storage.
|
|
39
|
+
let Database;
|
|
40
|
+
try {
|
|
41
|
+
const mainModule = require.main;
|
|
42
|
+
if (mainModule) {
|
|
43
|
+
Database = mainModule.require("better-sqlite3");
|
|
44
|
+
} else {
|
|
45
|
+
Database = require("better-sqlite3");
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
try {
|
|
49
|
+
Database = require("better-sqlite3");
|
|
50
|
+
} catch (fallbackError) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`better-sqlite3 not found. Please install it in your project: npm install better-sqlite3@^9.2.2\n` +
|
|
53
|
+
`Original error: ${error.message}\nFallback error: ${fallbackError.message}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { TreeStoreError } = require("./errors.cjs");
|
|
59
|
+
const { TreeStoreMigrationManager } = require("./migrations.cjs");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reservations idle longer than this are treated as abandoned by a crashed
|
|
63
|
+
* client and released during setLeaves.
|
|
64
|
+
*/
|
|
65
|
+
const RESERVATION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Spent markers older than this (relative to a refresh) are pruned.
|
|
69
|
+
*/
|
|
70
|
+
const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Slim projection: only (id, value) for leaves the selection might use. Every
|
|
74
|
+
* eligible leaf with value <= ? plus the single smallest eligible leaf above it
|
|
75
|
+
* (the minimum-amount fallback where one larger leaf suffices). The bound is
|
|
76
|
+
* bound twice positionally.
|
|
77
|
+
*/
|
|
78
|
+
const SLIM_LEAF_CANDIDATES_SQL = `
|
|
79
|
+
SELECT id, value FROM brz_tree_leaves
|
|
80
|
+
WHERE status = 'Available'
|
|
81
|
+
AND is_missing_from_operators = 0
|
|
82
|
+
AND reservation_id IS NULL
|
|
83
|
+
AND (
|
|
84
|
+
value <= ?
|
|
85
|
+
OR id = (
|
|
86
|
+
SELECT id FROM brz_tree_leaves
|
|
87
|
+
WHERE status = 'Available'
|
|
88
|
+
AND is_missing_from_operators = 0
|
|
89
|
+
AND reservation_id IS NULL
|
|
90
|
+
AND value > ?
|
|
91
|
+
ORDER BY value
|
|
92
|
+
LIMIT 1
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Pair a leaf with its ancestors (nearest first) by walking `parent_node_id`
|
|
99
|
+
* through `nodes`. Returns null if the leaf itself is absent; stops at a gap or
|
|
100
|
+
* cycle, returning a partial chain.
|
|
101
|
+
* @param {Map<string, object>} nodes
|
|
102
|
+
* @param {string} leafId
|
|
103
|
+
* @returns {{leaf: object, ancestors: Array<object>}|null}
|
|
104
|
+
*/
|
|
105
|
+
function assembleExitChain(nodes, leafId) {
|
|
106
|
+
const leaf = nodes.get(leafId);
|
|
107
|
+
if (!leaf) return null;
|
|
108
|
+
const ancestors = [];
|
|
109
|
+
const visited = new Set([leafId]);
|
|
110
|
+
let current = leaf.parent_node_id;
|
|
111
|
+
while (current != null && !visited.has(current)) {
|
|
112
|
+
visited.add(current);
|
|
113
|
+
const node = nodes.get(current);
|
|
114
|
+
if (!node) break;
|
|
115
|
+
ancestors.push(node);
|
|
116
|
+
current = node.parent_node_id;
|
|
117
|
+
}
|
|
118
|
+
return { leaf, ancestors };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
class NodeTreeStore {
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} dbPath - Path to the SQLite database file for this wallet.
|
|
124
|
+
* @param {object} [logger]
|
|
125
|
+
* @param {boolean} [runMigration]
|
|
126
|
+
*/
|
|
127
|
+
constructor(dbPath, logger = null, runMigration = true) {
|
|
128
|
+
this.dbPath = dbPath;
|
|
129
|
+
this.db = null;
|
|
130
|
+
this.migrationManager = null;
|
|
131
|
+
this.logger = logger;
|
|
132
|
+
this.runMigration = runMigration;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Open the database and run migrations. Returns the store instance.
|
|
137
|
+
*/
|
|
138
|
+
initialize() {
|
|
139
|
+
try {
|
|
140
|
+
this.db = new Database(this.dbPath);
|
|
141
|
+
// Shared file: WAL lets this connection read and write alongside the main
|
|
142
|
+
// storage's.
|
|
143
|
+
this.db.pragma("journal_mode = WAL");
|
|
144
|
+
if (this.runMigration) {
|
|
145
|
+
this.migrationManager = new TreeStoreMigrationManager(
|
|
146
|
+
this.db,
|
|
147
|
+
TreeStoreError,
|
|
148
|
+
this.logger
|
|
149
|
+
);
|
|
150
|
+
this.migrationManager.migrate();
|
|
151
|
+
}
|
|
152
|
+
return this;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new TreeStoreError(
|
|
155
|
+
`Failed to initialize tree store at '${this.dbPath}': ${error.message}`,
|
|
156
|
+
error
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Close the database connection.
|
|
163
|
+
*/
|
|
164
|
+
close() {
|
|
165
|
+
if (this.db) {
|
|
166
|
+
this.db.close();
|
|
167
|
+
this.db = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ===== TreeStore Methods =====
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Add leaves to the pool. Receiving a leaf back clears any prior spent
|
|
175
|
+
* marker. Re-adding an id refreshes its mutable fields.
|
|
176
|
+
* @param {Array} leaves - Array of TreeNode
|
|
177
|
+
*/
|
|
178
|
+
async addLeaves(leaves) {
|
|
179
|
+
try {
|
|
180
|
+
if (!leaves || leaves.length === 0) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const leafNodes = leaves;
|
|
184
|
+
this.db.transaction(() => {
|
|
185
|
+
this._removeSpent(leafNodes.map((l) => l.id));
|
|
186
|
+
this._upsertLeaves(leafNodes, false, null);
|
|
187
|
+
})();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error instanceof TreeStoreError) throw error;
|
|
190
|
+
throw new TreeStoreError(`Failed to add leaves: ${error.message}`, error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Store the ancestor chain of each pedigree, leaving the leaf pool and any
|
|
196
|
+
* spent marker untouched.
|
|
197
|
+
* @param {Array} pedigrees - Array of LeafPedigree { leaf, ancestors }
|
|
198
|
+
*/
|
|
199
|
+
async storeAncestors(pedigrees) {
|
|
200
|
+
try {
|
|
201
|
+
if (!pedigrees || pedigrees.length === 0) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const leafIds = pedigrees.map((p) => p.leaf.id);
|
|
205
|
+
this.db.transaction(() => {
|
|
206
|
+
// A leaf can be spent between its chain being resolved and this write, and a
|
|
207
|
+
// chain is only ever removed with its leaf. Writing one for a leaf that is
|
|
208
|
+
// already gone would leave it behind for good.
|
|
209
|
+
const storedLeafIds = new Set();
|
|
210
|
+
for (const chunk of idChunks(leafIds)) {
|
|
211
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
212
|
+
for (const row of this.db
|
|
213
|
+
.prepare(`SELECT id FROM brz_tree_leaves WHERE id IN (${placeholders})`)
|
|
214
|
+
.all(...chunk)) {
|
|
215
|
+
storedLeafIds.add(row.id);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const pedigree of pedigrees) {
|
|
219
|
+
if (!storedLeafIds.has(pedigree.leaf.id)) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
this._upsertAncestors(pedigree.leaf.id, pedigree.ancestors);
|
|
223
|
+
}
|
|
224
|
+
})();
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error instanceof TreeStoreError) throw error;
|
|
227
|
+
throw new TreeStoreError(`Failed to store ancestors: ${error.message}`, error);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Ids of the stored leaves whose chain cannot back an exit: the leaf has a
|
|
233
|
+
* parent, and no ancestor row of its own holds that parent.
|
|
234
|
+
* @returns {Promise<Array<string>>}
|
|
235
|
+
*/
|
|
236
|
+
async leavesMissingExitChains() {
|
|
237
|
+
try {
|
|
238
|
+
const rows = this.db
|
|
239
|
+
.prepare(
|
|
240
|
+
// A stored chain runs from its leaf's parent to a root, so a leaf
|
|
241
|
+
// whose chain holds the parent it has now is exitable. The join binds
|
|
242
|
+
// both primary key columns, making it one index probe per leaf. A leaf
|
|
243
|
+
// that is itself a root needs no chain.
|
|
244
|
+
`SELECT l.id
|
|
245
|
+
FROM brz_tree_leaves l
|
|
246
|
+
LEFT JOIN brz_tree_ancestors link
|
|
247
|
+
ON link.leaf_id = l.id AND link.id = l.parent_node_id
|
|
248
|
+
WHERE l.parent_node_id IS NOT NULL
|
|
249
|
+
AND link.leaf_id IS NULL`
|
|
250
|
+
)
|
|
251
|
+
.all();
|
|
252
|
+
return rows.map((r) => r.id);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (error instanceof TreeStoreError) throw error;
|
|
255
|
+
throw new TreeStoreError(
|
|
256
|
+
`Failed to get leaves missing exit chains: ${error.message}`,
|
|
257
|
+
error
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Reconstruct the exit chains for many leaves, each as { leaf, ancestors } with
|
|
264
|
+
* ancestors nearest first. A leaf absent from the store is skipped; a chain that
|
|
265
|
+
* hits a gap comes back partial.
|
|
266
|
+
* @param {Array<string>} leafIds
|
|
267
|
+
* @returns {Promise<Array<{leaf: object, ancestors: Array<object>}>>}
|
|
268
|
+
*/
|
|
269
|
+
async getExitChains(leafIds) {
|
|
270
|
+
try {
|
|
271
|
+
if (!leafIds || leafIds.length === 0) return [];
|
|
272
|
+
// Each statement pairs a leaf's own row with its ancestor rows, both
|
|
273
|
+
// tagged by the owning leaf id, and one transaction spans however many it
|
|
274
|
+
// takes so no two are read at different points in time. Grouping by that
|
|
275
|
+
// tag keeps each leaf's node set separate, so a node id stored under
|
|
276
|
+
// another leaf can never cross-contaminate this one.
|
|
277
|
+
const byLeaf = new Map();
|
|
278
|
+
this.db.transaction(() => {
|
|
279
|
+
for (const chunk of idChunks(leafIds)) {
|
|
280
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
281
|
+
const rows = this.db
|
|
282
|
+
.prepare(
|
|
283
|
+
`SELECT leaf_id, data FROM brz_tree_ancestors WHERE leaf_id IN (${placeholders})
|
|
284
|
+
UNION ALL
|
|
285
|
+
SELECT id AS leaf_id, data FROM brz_tree_leaves WHERE id IN (${placeholders})`
|
|
286
|
+
)
|
|
287
|
+
.all(...chunk, ...chunk);
|
|
288
|
+
for (const r of rows) {
|
|
289
|
+
let nodes = byLeaf.get(r.leaf_id);
|
|
290
|
+
if (!nodes) {
|
|
291
|
+
nodes = new Map();
|
|
292
|
+
byLeaf.set(r.leaf_id, nodes);
|
|
293
|
+
}
|
|
294
|
+
const node = JSON.parse(r.data);
|
|
295
|
+
nodes.set(node.id, node);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
})();
|
|
299
|
+
|
|
300
|
+
const result = [];
|
|
301
|
+
for (const id of leafIds) {
|
|
302
|
+
const nodes = byLeaf.get(id);
|
|
303
|
+
if (!nodes) continue;
|
|
304
|
+
const pedigree = assembleExitChain(nodes, id);
|
|
305
|
+
if (pedigree) result.push(pedigree);
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error instanceof TreeStoreError) throw error;
|
|
310
|
+
throw new TreeStoreError(`Failed to get exit chains: ${error.message}`, error);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Return the wallet's spendable balance (available + swap-reserved) as a
|
|
316
|
+
* BigInt. Aggregated in SQL so we don't fetch every leaf.
|
|
317
|
+
* @returns {Promise<bigint>}
|
|
318
|
+
*/
|
|
319
|
+
async getAvailableBalance() {
|
|
320
|
+
try {
|
|
321
|
+
const row = this.db
|
|
322
|
+
.prepare(
|
|
323
|
+
`SELECT COALESCE(SUM(l.value), 0) AS balance
|
|
324
|
+
FROM brz_tree_leaves l
|
|
325
|
+
LEFT JOIN brz_tree_reservations r ON l.reservation_id = r.id
|
|
326
|
+
WHERE (l.reservation_id IS NULL AND l.status = 'Available')
|
|
327
|
+
OR r.purpose = 'Swap'`
|
|
328
|
+
)
|
|
329
|
+
.get();
|
|
330
|
+
return BigInt(row.balance);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw new TreeStoreError(
|
|
333
|
+
`Failed to get available balance: ${error.message}`,
|
|
334
|
+
error
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Return [id, verifyingPublicKey, signingKeysharePublicKey] triples for every
|
|
341
|
+
* reserved or Available leaf. The two pubkeys are projected out of the JSON so
|
|
342
|
+
* we skip each leaf's transaction blob.
|
|
343
|
+
* @returns {Promise<Array<[string, string, string]>>}
|
|
344
|
+
*/
|
|
345
|
+
async getVerifiedLeafKeys() {
|
|
346
|
+
try {
|
|
347
|
+
const rows = this.db
|
|
348
|
+
.prepare(
|
|
349
|
+
`SELECT l.id AS id,
|
|
350
|
+
l.verifying_public_key AS verifying,
|
|
351
|
+
l.signing_public_key AS keyshare
|
|
352
|
+
FROM brz_tree_leaves l
|
|
353
|
+
LEFT JOIN brz_tree_reservations r ON l.reservation_id = r.id
|
|
354
|
+
WHERE r.purpose IS NOT NULL OR l.status = 'Available'`
|
|
355
|
+
)
|
|
356
|
+
.all();
|
|
357
|
+
return rows.map((row) => [row.id, row.verifying, row.keyshare]);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw new TreeStoreError(
|
|
360
|
+
`Failed to get verified leaf keys: ${error.message}`,
|
|
361
|
+
error
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Return all pool leaves categorized by status and reservation purpose.
|
|
368
|
+
* @returns {Promise<Object>}
|
|
369
|
+
*/
|
|
370
|
+
async getLeaves() {
|
|
371
|
+
try {
|
|
372
|
+
const rows = this.db
|
|
373
|
+
.prepare(
|
|
374
|
+
`SELECT l.status, l.is_missing_from_operators, l.data,
|
|
375
|
+
l.reservation_id, r.purpose
|
|
376
|
+
FROM brz_tree_leaves l
|
|
377
|
+
LEFT JOIN brz_tree_reservations r ON l.reservation_id = r.id`
|
|
378
|
+
)
|
|
379
|
+
.all();
|
|
380
|
+
|
|
381
|
+
const available = [];
|
|
382
|
+
const notAvailable = [];
|
|
383
|
+
const availableMissingFromOperators = [];
|
|
384
|
+
const reservedForPayment = [];
|
|
385
|
+
const reservedForSwap = [];
|
|
386
|
+
|
|
387
|
+
for (const row of rows) {
|
|
388
|
+
const node = JSON.parse(row.data);
|
|
389
|
+
const spendable = node.status === "Available";
|
|
390
|
+
|
|
391
|
+
if (row.purpose) {
|
|
392
|
+
if (row.purpose === "Payment") {
|
|
393
|
+
reservedForPayment.push(node);
|
|
394
|
+
} else if (row.purpose === "Swap") {
|
|
395
|
+
reservedForSwap.push(node);
|
|
396
|
+
}
|
|
397
|
+
} else if (!spendable) {
|
|
398
|
+
notAvailable.push(node);
|
|
399
|
+
} else if (row.is_missing_from_operators) {
|
|
400
|
+
availableMissingFromOperators.push(node);
|
|
401
|
+
} else {
|
|
402
|
+
available.push(node);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
available,
|
|
408
|
+
notAvailable,
|
|
409
|
+
availableMissingFromOperators,
|
|
410
|
+
reservedForPayment,
|
|
411
|
+
reservedForSwap,
|
|
412
|
+
};
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (error instanceof TreeStoreError) throw error;
|
|
415
|
+
throw new TreeStoreError(`Failed to get leaves: ${error.message}`, error);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Replace the pool from a refresh. Skipped while a swap is in flight or one
|
|
421
|
+
* completed during the refresh, so a swap's leaves are never clobbered.
|
|
422
|
+
* @param {Array} leaves - Available TreeNode
|
|
423
|
+
* @param {Array} missingLeaves - TreeNode missing from some operators
|
|
424
|
+
* @param {number} refreshStartedAtMs - Epoch milliseconds when refresh started
|
|
425
|
+
*/
|
|
426
|
+
async setLeaves(leaves, missingLeaves, refreshStartedAtMs) {
|
|
427
|
+
try {
|
|
428
|
+
const refreshMs = refreshStartedAtMs;
|
|
429
|
+
this.db.transaction(() => {
|
|
430
|
+
// Release abandoned reservations before evaluating the swap guard so a
|
|
431
|
+
// stale swap cannot pin setLeaves forever.
|
|
432
|
+
this._cleanupStaleReservations();
|
|
433
|
+
|
|
434
|
+
const hasActiveSwap =
|
|
435
|
+
this.db
|
|
436
|
+
.prepare(
|
|
437
|
+
"SELECT EXISTS(SELECT 1 FROM brz_tree_reservations WHERE purpose = 'Swap') AS has_active_swap"
|
|
438
|
+
)
|
|
439
|
+
.get().has_active_swap !== 0;
|
|
440
|
+
const statusRow = this.db
|
|
441
|
+
.prepare("SELECT last_completed_at FROM brz_tree_swap_status WHERE id = 1")
|
|
442
|
+
.get();
|
|
443
|
+
const swapCompleted =
|
|
444
|
+
statusRow != null &&
|
|
445
|
+
statusRow.last_completed_at != null &&
|
|
446
|
+
statusRow.last_completed_at >= refreshMs;
|
|
447
|
+
|
|
448
|
+
if (hasActiveSwap || swapCompleted) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
this._cleanupSpentMarkers(refreshMs);
|
|
453
|
+
const spentIds = this._spentIdsSince(refreshMs);
|
|
454
|
+
|
|
455
|
+
// Delete non-reserved pool leaves older than the refresh; reserved and
|
|
456
|
+
// after-refresh leaves are immune. A leaf reported again in this same
|
|
457
|
+
// refresh is re-inserted below, so its ancestor rows must survive: only
|
|
458
|
+
// ids that do NOT reappear in this refresh (truly gone, e.g. spent) get
|
|
459
|
+
// their ancestor rows dropped alongside them.
|
|
460
|
+
const deletedIds = this.db
|
|
461
|
+
.prepare(
|
|
462
|
+
"DELETE FROM brz_tree_leaves WHERE reservation_id IS NULL AND added_at < ? RETURNING id"
|
|
463
|
+
)
|
|
464
|
+
.all(refreshMs)
|
|
465
|
+
.map((r) => r.id);
|
|
466
|
+
|
|
467
|
+
this._upsertLeaves(leaves, false, spentIds);
|
|
468
|
+
this._upsertLeaves(missingLeaves, true, spentIds);
|
|
469
|
+
|
|
470
|
+
const survivingIds = new Set();
|
|
471
|
+
for (const leaf of leaves.concat(missingLeaves)) {
|
|
472
|
+
if (!spentIds.has(leaf.id)) survivingIds.add(leaf.id);
|
|
473
|
+
}
|
|
474
|
+
const goneIds = deletedIds.filter((id) => !survivingIds.has(id));
|
|
475
|
+
this._deleteAncestorsForLeaves(goneIds);
|
|
476
|
+
})();
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (error instanceof TreeStoreError) throw error;
|
|
479
|
+
throw new TreeStoreError(`Failed to set leaves: ${error.message}`, error);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Cancel a reservation. Its leaves are deleted from the pool and the row is
|
|
485
|
+
* dropped. `leavesToKeep` are re-inserted into the available pool; their
|
|
486
|
+
* ancestors are already stored (they stayed while the leaves were reserved).
|
|
487
|
+
* @param {string} id
|
|
488
|
+
* @param {Array} leavesToKeep - TreeNode leaves to return to the available pool
|
|
489
|
+
*/
|
|
490
|
+
async cancelReservation(id, leavesToKeep) {
|
|
491
|
+
try {
|
|
492
|
+
this.db.transaction(() => {
|
|
493
|
+
// Return leavesToKeep to the pool even when the reservation is already
|
|
494
|
+
// gone (e.g. released by stale cleanup): dropping them here would lose
|
|
495
|
+
// the leaves until the next refresh. The deletes no-op in that case.
|
|
496
|
+
// Only the leaves are re-inserted: a kept leaf's ancestor rows stay put
|
|
497
|
+
// (they are not touched below); a dropped leaf's are removed with it.
|
|
498
|
+
const keepIds = new Set((leavesToKeep || []).map((l) => l.id));
|
|
499
|
+
const droppedIds = this.db
|
|
500
|
+
.prepare("SELECT id FROM brz_tree_leaves WHERE reservation_id = ?")
|
|
501
|
+
.all(id)
|
|
502
|
+
.map((r) => r.id)
|
|
503
|
+
.filter((rid) => !keepIds.has(rid));
|
|
504
|
+
|
|
505
|
+
this.db.prepare("DELETE FROM brz_tree_leaves WHERE reservation_id = ?").run(id);
|
|
506
|
+
this.db.prepare("DELETE FROM brz_tree_reservations WHERE id = ?").run(id);
|
|
507
|
+
this._deleteAncestorsForLeaves(droppedIds);
|
|
508
|
+
this._upsertLeaves(leavesToKeep, false, null);
|
|
509
|
+
})();
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if (error instanceof TreeStoreError) throw error;
|
|
512
|
+
throw new TreeStoreError(
|
|
513
|
+
`Failed to cancel reservation '${id}': ${error.message}`,
|
|
514
|
+
error
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Finalize a reservation, marking its leaves spent and adding any new leaves.
|
|
521
|
+
* @param {string} id
|
|
522
|
+
* @param {Array|null} newLeaves - Optional new TreeNode
|
|
523
|
+
*/
|
|
524
|
+
async finalizeReservation(id, newLeaves) {
|
|
525
|
+
try {
|
|
526
|
+
this.db.transaction(() => {
|
|
527
|
+
const res = this.db
|
|
528
|
+
.prepare("SELECT id, purpose FROM brz_tree_reservations WHERE id = ?")
|
|
529
|
+
.get(id);
|
|
530
|
+
|
|
531
|
+
let isSwap = false;
|
|
532
|
+
if (res) {
|
|
533
|
+
isSwap = res.purpose === "Swap";
|
|
534
|
+
const reservedLeafIds = this.db
|
|
535
|
+
.prepare("SELECT id FROM brz_tree_leaves WHERE reservation_id = ?")
|
|
536
|
+
.all(id)
|
|
537
|
+
.map((r) => r.id);
|
|
538
|
+
this._insertSpent(reservedLeafIds);
|
|
539
|
+
this.db.prepare("DELETE FROM brz_tree_leaves WHERE reservation_id = ?").run(id);
|
|
540
|
+
this.db.prepare("DELETE FROM brz_tree_reservations WHERE id = ?").run(id);
|
|
541
|
+
// The spent leaves own these ancestor rows; remove them in the same
|
|
542
|
+
// transaction rather than leaving them to a separate reclaim pass.
|
|
543
|
+
this._deleteAncestorsForLeaves(reservedLeafIds);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (newLeaves && newLeaves.length > 0) {
|
|
547
|
+
this._upsertLeaves(newLeaves, false, null);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Record the swap only when it produced change: the setLeaves guard uses
|
|
551
|
+
// this to skip a refresh that raced the swap.
|
|
552
|
+
if (isSwap && newLeaves && newLeaves.length > 0) {
|
|
553
|
+
this._markSwapCompleted();
|
|
554
|
+
}
|
|
555
|
+
})();
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (error instanceof TreeStoreError) throw error;
|
|
558
|
+
throw new TreeStoreError(
|
|
559
|
+
`Failed to finalize reservation '${id}': ${error.message}`,
|
|
560
|
+
error
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Try to reserve leaves matching target amounts.
|
|
567
|
+
* @param {Object|null} targetAmounts
|
|
568
|
+
* @param {boolean} exactOnly - If true, only exact matches
|
|
569
|
+
* @param {string} purpose - "Payment" or "Swap"
|
|
570
|
+
* @returns {Promise<Object>} ReserveResult
|
|
571
|
+
*/
|
|
572
|
+
async tryReserveLeaves(targetAmounts, exactOnly, purpose) {
|
|
573
|
+
try {
|
|
574
|
+
return this.db.transaction(() => {
|
|
575
|
+
const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
|
|
576
|
+
const maxTarget = this._maxTargetForPrefilter(targetAmounts);
|
|
577
|
+
|
|
578
|
+
// True total available over ALL eligible leaves, for the WaitForPending
|
|
579
|
+
// decision below: must not be derived from the prefiltered slim set.
|
|
580
|
+
const available = this._availableTotal();
|
|
581
|
+
const slimLeaves = this._slimCandidates(maxTarget);
|
|
582
|
+
const pending = this._pendingBalance();
|
|
583
|
+
|
|
584
|
+
const selected = this._selectLeavesByTargetAmounts(slimLeaves, targetAmounts);
|
|
585
|
+
if (selected !== null) {
|
|
586
|
+
if (selected.length === 0) {
|
|
587
|
+
throw new TreeStoreError("NonReservableLeaves");
|
|
588
|
+
}
|
|
589
|
+
const fullLeaves = this._resolveFullLeaves(selected.map((l) => l.id));
|
|
590
|
+
const reservationId = this._generateId();
|
|
591
|
+
this._createReservation(reservationId, fullLeaves, purpose, 0);
|
|
592
|
+
return {
|
|
593
|
+
type: "success",
|
|
594
|
+
reservation: { id: reservationId, leaves: fullLeaves },
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!exactOnly) {
|
|
599
|
+
const minSelected = this._selectLeavesByMinimumAmount(slimLeaves, targetAmount);
|
|
600
|
+
if (minSelected !== null) {
|
|
601
|
+
const fullLeaves = this._resolveFullLeaves(minSelected.map((l) => l.id));
|
|
602
|
+
const reservedAmount = fullLeaves.reduce((sum, l) => sum + l.value, 0);
|
|
603
|
+
const pendingChange =
|
|
604
|
+
reservedAmount > targetAmount && targetAmount > 0
|
|
605
|
+
? reservedAmount - targetAmount
|
|
606
|
+
: 0;
|
|
607
|
+
const reservationId = this._generateId();
|
|
608
|
+
this._createReservation(reservationId, fullLeaves, purpose, pendingChange);
|
|
609
|
+
return {
|
|
610
|
+
type: "success",
|
|
611
|
+
reservation: { id: reservationId, leaves: fullLeaves },
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (available + pending >= targetAmount) {
|
|
617
|
+
return { type: "waitForPending", needed: targetAmount, available, pending };
|
|
618
|
+
}
|
|
619
|
+
return { type: "insufficientFunds" };
|
|
620
|
+
})();
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (error instanceof TreeStoreError) throw error;
|
|
623
|
+
throw new TreeStoreError(
|
|
624
|
+
`Failed to try reserve leaves: ${error.message}`,
|
|
625
|
+
error
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Select (without reserving) leaves matching target amounts.
|
|
632
|
+
* @param {Object|null} targetAmounts
|
|
633
|
+
* @returns {Promise<Object>} LeafSelection
|
|
634
|
+
*/
|
|
635
|
+
async trySelectLeaves(targetAmounts) {
|
|
636
|
+
try {
|
|
637
|
+
const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
|
|
638
|
+
const maxTarget = this._maxTargetForPrefilter(targetAmounts);
|
|
639
|
+
|
|
640
|
+
return this.db.transaction(() => {
|
|
641
|
+
const slimLeaves = this._slimCandidates(maxTarget);
|
|
642
|
+
|
|
643
|
+
const selected = this._selectLeavesByTargetAmounts(slimLeaves, targetAmounts);
|
|
644
|
+
if (selected !== null && selected.length > 0) {
|
|
645
|
+
const fullLeaves = this._resolveFullLeaves(selected.map((l) => l.id));
|
|
646
|
+
return { type: "exact", leaves: fullLeaves };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const minSelected = this._selectLeavesByMinimumAmount(slimLeaves, targetAmount);
|
|
650
|
+
if (minSelected !== null) {
|
|
651
|
+
const fullLeaves = this._resolveFullLeaves(minSelected.map((l) => l.id));
|
|
652
|
+
return { type: "swapNeeded", leaves: fullLeaves };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return { type: "insufficientFunds" };
|
|
656
|
+
})();
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (error instanceof TreeStoreError) throw error;
|
|
659
|
+
throw new TreeStoreError(
|
|
660
|
+
`Failed to try select leaves: ${error.message}`,
|
|
661
|
+
error
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Reserve exactly the given leaves. Every id must be available and unreserved,
|
|
668
|
+
* or nothing is reserved.
|
|
669
|
+
* @param {Array<string>} leafIds
|
|
670
|
+
* @param {string} purpose - "Payment" or "Swap"
|
|
671
|
+
* @returns {Promise<Object>} { id, leaves }
|
|
672
|
+
*/
|
|
673
|
+
async tryReserveLeavesByIds(leafIds, purpose) {
|
|
674
|
+
try {
|
|
675
|
+
return this.db.transaction(() => {
|
|
676
|
+
if (!leafIds || leafIds.length === 0) {
|
|
677
|
+
throw new TreeStoreError("NonReservableLeaves");
|
|
678
|
+
}
|
|
679
|
+
// Count DISTINCT matching ids so a duplicate id can't satisfy two slots:
|
|
680
|
+
// every requested id must resolve to its own available, unreserved leaf.
|
|
681
|
+
const placeholders = leafIds.map(() => "?").join(", ");
|
|
682
|
+
const matched = this.db
|
|
683
|
+
.prepare(
|
|
684
|
+
`SELECT DISTINCT id FROM brz_tree_leaves
|
|
685
|
+
WHERE id IN (${placeholders}) AND status = 'Available'
|
|
686
|
+
AND is_missing_from_operators = 0 AND reservation_id IS NULL`
|
|
687
|
+
)
|
|
688
|
+
.all(...leafIds);
|
|
689
|
+
if (matched.length !== leafIds.length) {
|
|
690
|
+
throw new TreeStoreError("NonReservableLeaves");
|
|
691
|
+
}
|
|
692
|
+
const fullLeaves = this._resolveFullLeaves(leafIds);
|
|
693
|
+
const reservationId = this._generateId();
|
|
694
|
+
this._createReservation(reservationId, fullLeaves, purpose, 0);
|
|
695
|
+
return { id: reservationId, leaves: fullLeaves };
|
|
696
|
+
})();
|
|
697
|
+
} catch (error) {
|
|
698
|
+
if (error instanceof TreeStoreError) throw error;
|
|
699
|
+
throw new TreeStoreError(
|
|
700
|
+
`Failed to try reserve leaves by ids: ${error.message}`,
|
|
701
|
+
error
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Current wall-clock time as epoch milliseconds.
|
|
708
|
+
* @returns {Promise<number>}
|
|
709
|
+
*/
|
|
710
|
+
async now() {
|
|
711
|
+
return Date.now();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Update a reservation after a swap: spend the old reserved leaves, add the
|
|
716
|
+
* change leaves to the pool, and attach the new reserved leaves.
|
|
717
|
+
* @param {string} reservationId
|
|
718
|
+
* @param {Array} reservedLeaves - New reserved TreeNode
|
|
719
|
+
* @param {Array} changeLeaves - Change TreeNode for the available pool
|
|
720
|
+
* @returns {Promise<Object>} { id, leaves }
|
|
721
|
+
*/
|
|
722
|
+
async updateReservation(reservationId, reservedLeaves, changeLeaves) {
|
|
723
|
+
try {
|
|
724
|
+
return this.db.transaction(() => {
|
|
725
|
+
const res = this.db
|
|
726
|
+
.prepare("SELECT id FROM brz_tree_reservations WHERE id = ?")
|
|
727
|
+
.get(reservationId);
|
|
728
|
+
if (!res) {
|
|
729
|
+
throw new TreeStoreError(`Reservation ${reservationId} not found`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const oldLeafIds = this.db
|
|
733
|
+
.prepare("SELECT id FROM brz_tree_leaves WHERE reservation_id = ?")
|
|
734
|
+
.all(reservationId)
|
|
735
|
+
.map((r) => r.id);
|
|
736
|
+
this._insertSpent(oldLeafIds);
|
|
737
|
+
this.db
|
|
738
|
+
.prepare("DELETE FROM brz_tree_leaves WHERE reservation_id = ?")
|
|
739
|
+
.run(reservationId);
|
|
740
|
+
// The spent leaves own these ancestor rows; remove them now since there
|
|
741
|
+
// is no later reclaim pass.
|
|
742
|
+
this._deleteAncestorsForLeaves(oldLeafIds);
|
|
743
|
+
|
|
744
|
+
this._upsertLeaves(changeLeaves, false, null);
|
|
745
|
+
this._upsertLeaves(reservedLeaves, false, null);
|
|
746
|
+
this._setReservationId(
|
|
747
|
+
reservationId,
|
|
748
|
+
reservedLeaves.map((l) => l.id)
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
this.db
|
|
752
|
+
.prepare(
|
|
753
|
+
"UPDATE brz_tree_reservations SET pending_change_amount = 0 WHERE id = ?"
|
|
754
|
+
)
|
|
755
|
+
.run(reservationId);
|
|
756
|
+
|
|
757
|
+
// Return value must be plain TreeNodes: the Rust side deserializes
|
|
758
|
+
// Vec<TreeNode>.
|
|
759
|
+
return { id: reservationId, leaves: reservedLeaves };
|
|
760
|
+
})();
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error instanceof TreeStoreError) throw error;
|
|
763
|
+
throw new TreeStoreError(
|
|
764
|
+
`Failed to update reservation '${reservationId}': ${error.message}`,
|
|
765
|
+
error
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ===== Private DB helpers (synchronous) =====
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Upsert leaf pedigrees into the pool, skipping any id in `skipIds` (spent).
|
|
774
|
+
* Refreshes the leaf's mutable fields; preserves reservation_id (not in the
|
|
775
|
+
* SET list).
|
|
776
|
+
*/
|
|
777
|
+
_upsertLeaves(leaves, isMissing, skipIds) {
|
|
778
|
+
if (!leaves || leaves.length === 0) return;
|
|
779
|
+
const stmt = this.db.prepare(
|
|
780
|
+
`INSERT INTO brz_tree_leaves
|
|
781
|
+
(id, parent_node_id, status, value, verifying_public_key,
|
|
782
|
+
signing_public_key, data, is_missing_from_operators, added_at)
|
|
783
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
784
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
785
|
+
parent_node_id = excluded.parent_node_id,
|
|
786
|
+
status = excluded.status,
|
|
787
|
+
value = excluded.value,
|
|
788
|
+
verifying_public_key = excluded.verifying_public_key,
|
|
789
|
+
signing_public_key = excluded.signing_public_key,
|
|
790
|
+
data = excluded.data,
|
|
791
|
+
is_missing_from_operators = excluded.is_missing_from_operators,
|
|
792
|
+
added_at = excluded.added_at`
|
|
793
|
+
);
|
|
794
|
+
const now = Date.now();
|
|
795
|
+
for (const leaf of leaves) {
|
|
796
|
+
if (skipIds && skipIds.has(leaf.id)) continue;
|
|
797
|
+
stmt.run(
|
|
798
|
+
leaf.id,
|
|
799
|
+
leaf.parent_node_id ?? null,
|
|
800
|
+
leaf.status,
|
|
801
|
+
leaf.value,
|
|
802
|
+
leaf.verifying_public_key,
|
|
803
|
+
leaf.signing_keyshare.public_key,
|
|
804
|
+
JSON.stringify(leaf),
|
|
805
|
+
isMissing ? 1 : 0,
|
|
806
|
+
now
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Replaces `leafId`'s ancestor rows wholesale (delete then insert). An empty
|
|
813
|
+
* `nodes` list is a no-op: it means the chain is unknown, not that the leaf
|
|
814
|
+
* has none, so a stored chain must survive being re-added without one.
|
|
815
|
+
*/
|
|
816
|
+
_upsertAncestors(leafId, nodes) {
|
|
817
|
+
if (!nodes || nodes.length === 0) return;
|
|
818
|
+
this.db.prepare("DELETE FROM brz_tree_ancestors WHERE leaf_id = ?").run(leafId);
|
|
819
|
+
const stmt = this.db.prepare(
|
|
820
|
+
`INSERT INTO brz_tree_ancestors
|
|
821
|
+
(leaf_id, id, parent_node_id, status, value, verifying_public_key, data)
|
|
822
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
823
|
+
);
|
|
824
|
+
for (const node of nodes) {
|
|
825
|
+
stmt.run(
|
|
826
|
+
leafId,
|
|
827
|
+
node.id,
|
|
828
|
+
node.parent_node_id ?? null,
|
|
829
|
+
node.status,
|
|
830
|
+
node.value,
|
|
831
|
+
node.verifying_public_key,
|
|
832
|
+
JSON.stringify(node)
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Drops the ancestor rows of every named leaf. */
|
|
838
|
+
_deleteAncestorsForLeaves(leafIds) {
|
|
839
|
+
if (!leafIds || leafIds.length === 0) return;
|
|
840
|
+
for (const chunk of idChunks(leafIds)) {
|
|
841
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
842
|
+
this.db
|
|
843
|
+
.prepare(`DELETE FROM brz_tree_ancestors WHERE leaf_id IN (${placeholders})`)
|
|
844
|
+
.run(...chunk);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Full node data for the selected ids, preserving selection order. Errors if
|
|
850
|
+
* any selected leaf is missing.
|
|
851
|
+
*/
|
|
852
|
+
_resolveFullLeaves(ids) {
|
|
853
|
+
if (!ids || ids.length === 0) return [];
|
|
854
|
+
const stmt = this.db.prepare("SELECT data FROM brz_tree_leaves WHERE id = ?");
|
|
855
|
+
const leaves = [];
|
|
856
|
+
for (const id of ids) {
|
|
857
|
+
const row = stmt.get(id);
|
|
858
|
+
if (!row) {
|
|
859
|
+
throw new TreeStoreError(`selected leaf ${id} not found in store`);
|
|
860
|
+
}
|
|
861
|
+
leaves.push(JSON.parse(row.data));
|
|
862
|
+
}
|
|
863
|
+
return leaves;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Create a reservation and attach it to the given leaves.
|
|
868
|
+
*/
|
|
869
|
+
_createReservation(id, leaves, purpose, pendingChange) {
|
|
870
|
+
this.db
|
|
871
|
+
.prepare(
|
|
872
|
+
"INSERT INTO brz_tree_reservations (id, purpose, pending_change_amount, created_at) VALUES (?, ?, ?, ?)"
|
|
873
|
+
)
|
|
874
|
+
.run(id, purpose, pendingChange, Date.now());
|
|
875
|
+
this._setReservationId(
|
|
876
|
+
id,
|
|
877
|
+
leaves.map((l) => l.id)
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
_setReservationId(reservationId, leafIds) {
|
|
882
|
+
if (!leafIds || leafIds.length === 0) return;
|
|
883
|
+
const stmt = this.db.prepare(
|
|
884
|
+
"UPDATE brz_tree_leaves SET reservation_id = ? WHERE id = ?"
|
|
885
|
+
);
|
|
886
|
+
for (const id of leafIds) {
|
|
887
|
+
stmt.run(reservationId, id);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
_insertSpent(ids) {
|
|
892
|
+
if (!ids || ids.length === 0) return;
|
|
893
|
+
const stmt = this.db.prepare(
|
|
894
|
+
"INSERT OR IGNORE INTO brz_tree_spent (id, spent_at) VALUES (?, ?)"
|
|
895
|
+
);
|
|
896
|
+
const now = Date.now();
|
|
897
|
+
for (const id of ids) {
|
|
898
|
+
stmt.run(id, now);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
_removeSpent(ids) {
|
|
903
|
+
if (!ids || ids.length === 0) return;
|
|
904
|
+
const stmt = this.db.prepare("DELETE FROM brz_tree_spent WHERE id = ?");
|
|
905
|
+
for (const id of ids) {
|
|
906
|
+
stmt.run(id);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
_spentIdsSince(refreshMs) {
|
|
911
|
+
const rows = this.db
|
|
912
|
+
.prepare("SELECT id FROM brz_tree_spent WHERE spent_at >= ?")
|
|
913
|
+
.all(refreshMs);
|
|
914
|
+
return new Set(rows.map((r) => r.id));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
_cleanupStaleReservations() {
|
|
918
|
+
const cutoff = Date.now() - RESERVATION_TIMEOUT_MS;
|
|
919
|
+
this.db
|
|
920
|
+
.prepare(
|
|
921
|
+
`UPDATE brz_tree_leaves SET reservation_id = NULL
|
|
922
|
+
WHERE reservation_id IN (
|
|
923
|
+
SELECT id FROM brz_tree_reservations WHERE created_at < ?
|
|
924
|
+
)`
|
|
925
|
+
)
|
|
926
|
+
.run(cutoff);
|
|
927
|
+
this.db
|
|
928
|
+
.prepare("DELETE FROM brz_tree_reservations WHERE created_at < ?")
|
|
929
|
+
.run(cutoff);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
_cleanupSpentMarkers(refreshMs) {
|
|
933
|
+
this.db
|
|
934
|
+
.prepare("DELETE FROM brz_tree_spent WHERE spent_at < ?")
|
|
935
|
+
.run(refreshMs - SPENT_MARKER_CLEANUP_THRESHOLD_MS);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
_markSwapCompleted() {
|
|
939
|
+
this.db
|
|
940
|
+
.prepare("UPDATE brz_tree_swap_status SET last_completed_at = ? WHERE id = 1")
|
|
941
|
+
.run(Date.now());
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Total value of unreserved available pool leaves (drives WaitForPending).
|
|
946
|
+
*/
|
|
947
|
+
_availableTotal() {
|
|
948
|
+
return this.db
|
|
949
|
+
.prepare(
|
|
950
|
+
`SELECT COALESCE(SUM(value), 0) AS total FROM brz_tree_leaves
|
|
951
|
+
WHERE status = 'Available'
|
|
952
|
+
AND is_missing_from_operators = 0 AND reservation_id IS NULL`
|
|
953
|
+
)
|
|
954
|
+
.get().total;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
_pendingBalance() {
|
|
958
|
+
return this.db
|
|
959
|
+
.prepare(
|
|
960
|
+
"SELECT COALESCE(SUM(pending_change_amount), 0) AS pending FROM brz_tree_reservations"
|
|
961
|
+
)
|
|
962
|
+
.get().pending;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
_slimCandidates(maxTarget) {
|
|
966
|
+
return this.db
|
|
967
|
+
.prepare(SLIM_LEAF_CANDIDATES_SQL)
|
|
968
|
+
.all(maxTarget, maxTarget)
|
|
969
|
+
.map((r) => ({ id: r.id, value: r.value }));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ===== Private selection helpers (pure) =====
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Generate a unique reservation ID (UUIDv4).
|
|
976
|
+
*/
|
|
977
|
+
_generateId() {
|
|
978
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
979
|
+
return crypto.randomUUID();
|
|
980
|
+
}
|
|
981
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
982
|
+
const r = (Math.random() * 16) | 0;
|
|
983
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
984
|
+
return v.toString(16);
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Calculate total sats from target amounts.
|
|
990
|
+
*/
|
|
991
|
+
_totalSats(targetAmounts) {
|
|
992
|
+
if (targetAmounts.type === "amountAndFee") {
|
|
993
|
+
return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
|
|
994
|
+
}
|
|
995
|
+
if (targetAmounts.type === "exactDenominations") {
|
|
996
|
+
return targetAmounts.denominations.reduce((sum, d) => sum + d, 0);
|
|
997
|
+
}
|
|
998
|
+
return 0;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
_maxTargetForPrefilter(targetAmounts) {
|
|
1002
|
+
if (!targetAmounts) return Number.MAX_SAFE_INTEGER;
|
|
1003
|
+
if (targetAmounts.type === "amountAndFee") {
|
|
1004
|
+
return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
|
|
1005
|
+
}
|
|
1006
|
+
if (targetAmounts.type === "exactDenominations") {
|
|
1007
|
+
return targetAmounts.denominations.reduce((m, v) => m + v, 0);
|
|
1008
|
+
}
|
|
1009
|
+
return Number.MAX_SAFE_INTEGER;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Select leaves by target amounts. Returns null if no exact match found.
|
|
1014
|
+
*/
|
|
1015
|
+
_selectLeavesByTargetAmounts(leaves, targetAmounts) {
|
|
1016
|
+
if (!targetAmounts) {
|
|
1017
|
+
// No target: return all leaves (may be empty)
|
|
1018
|
+
return [...leaves];
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (targetAmounts.type === "amountAndFee") {
|
|
1022
|
+
const amountLeaves = this._selectLeavesByExactAmount(leaves, targetAmounts.amountSats);
|
|
1023
|
+
if (amountLeaves === null) return null;
|
|
1024
|
+
|
|
1025
|
+
if (targetAmounts.feeSats != null && targetAmounts.feeSats > 0) {
|
|
1026
|
+
const amountIds = new Set(amountLeaves.map((l) => l.id));
|
|
1027
|
+
const remaining = leaves.filter((l) => !amountIds.has(l.id));
|
|
1028
|
+
const feeLeaves = this._selectLeavesByExactAmount(remaining, targetAmounts.feeSats);
|
|
1029
|
+
if (feeLeaves === null) return null;
|
|
1030
|
+
return [...amountLeaves, ...feeLeaves];
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return amountLeaves;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (targetAmounts.type === "exactDenominations") {
|
|
1037
|
+
return this._selectLeavesByExactDenominations(leaves, targetAmounts.denominations);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Select leaves that sum to exactly the target amount.
|
|
1045
|
+
*/
|
|
1046
|
+
_selectLeavesByExactAmount(leaves, targetAmount) {
|
|
1047
|
+
if (targetAmount === 0) return null; // Invalid amount
|
|
1048
|
+
|
|
1049
|
+
const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
|
|
1050
|
+
if (totalAvailable < targetAmount) return null; // Insufficient funds
|
|
1051
|
+
|
|
1052
|
+
// Try single exact match
|
|
1053
|
+
const single = leaves.find((l) => l.value === targetAmount);
|
|
1054
|
+
if (single) return [single];
|
|
1055
|
+
|
|
1056
|
+
// Try greedy multiple match
|
|
1057
|
+
const multipleResult = this._findExactMultipleMatch(leaves, targetAmount);
|
|
1058
|
+
return multipleResult;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Select leaves that match exact denominations.
|
|
1063
|
+
*/
|
|
1064
|
+
_selectLeavesByExactDenominations(leaves, denominations) {
|
|
1065
|
+
const remaining = [...leaves];
|
|
1066
|
+
const selected = [];
|
|
1067
|
+
|
|
1068
|
+
for (const denomination of denominations) {
|
|
1069
|
+
const idx = remaining.findIndex((l) => l.value === denomination);
|
|
1070
|
+
if (idx === -1) return null; // Can't match this denomination
|
|
1071
|
+
selected.push(remaining[idx]);
|
|
1072
|
+
remaining.splice(idx, 1);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
return selected;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Select leaves summing to at least the target amount.
|
|
1080
|
+
*/
|
|
1081
|
+
_selectLeavesByMinimumAmount(leaves, targetAmount) {
|
|
1082
|
+
if (targetAmount === 0) return null;
|
|
1083
|
+
|
|
1084
|
+
const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
|
|
1085
|
+
if (totalAvailable < targetAmount) return null;
|
|
1086
|
+
|
|
1087
|
+
const result = [];
|
|
1088
|
+
let sum = 0;
|
|
1089
|
+
for (const leaf of leaves) {
|
|
1090
|
+
sum += leaf.value;
|
|
1091
|
+
result.push(leaf);
|
|
1092
|
+
if (sum >= targetAmount) break;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
return sum >= targetAmount ? result : null;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Find exact multiple match using greedy algorithm.
|
|
1100
|
+
*/
|
|
1101
|
+
_findExactMultipleMatch(leaves, targetAmount) {
|
|
1102
|
+
if (targetAmount === 0) return [];
|
|
1103
|
+
if (leaves.length === 0) return null;
|
|
1104
|
+
|
|
1105
|
+
// Pass 1: Try greedy on all leaves
|
|
1106
|
+
const result = this._greedyExactMatch(leaves, targetAmount);
|
|
1107
|
+
if (result) return result;
|
|
1108
|
+
|
|
1109
|
+
// Pass 2: Try with only power-of-two leaves
|
|
1110
|
+
const powerOfTwoLeaves = leaves.filter((l) => this._isPowerOfTwo(l.value));
|
|
1111
|
+
if (powerOfTwoLeaves.length === leaves.length) return null;
|
|
1112
|
+
|
|
1113
|
+
return this._greedyExactMatch(powerOfTwoLeaves, targetAmount);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Greedy exact match algorithm.
|
|
1118
|
+
*/
|
|
1119
|
+
_greedyExactMatch(leaves, targetAmount) {
|
|
1120
|
+
const sorted = [...leaves].sort((a, b) => b.value - a.value);
|
|
1121
|
+
const result = [];
|
|
1122
|
+
let remaining = targetAmount;
|
|
1123
|
+
|
|
1124
|
+
for (const leaf of sorted) {
|
|
1125
|
+
if (leaf.value > remaining) continue;
|
|
1126
|
+
remaining -= leaf.value;
|
|
1127
|
+
result.push(leaf);
|
|
1128
|
+
if (remaining === 0) return result;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Check if value is a power of two.
|
|
1136
|
+
*/
|
|
1137
|
+
_isPowerOfTwo(value) {
|
|
1138
|
+
return value > 0 && (value & (value - 1)) === 0;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Create and initialize a NodeTreeStore for the SQLite database at `dbPath`.
|
|
1144
|
+
* Returns the initialized store instance. Unlike the async PostgreSQL factory,
|
|
1145
|
+
* this is synchronous because better-sqlite3 initialization is synchronous; the
|
|
1146
|
+
* return value is still awaitable.
|
|
1147
|
+
*
|
|
1148
|
+
* @param {string} dbPath - Path to the SQLite database file for this wallet.
|
|
1149
|
+
* @param {object} [logger]
|
|
1150
|
+
* @param {boolean} [runMigration]
|
|
1151
|
+
* @returns {NodeTreeStore}
|
|
1152
|
+
*/
|
|
1153
|
+
// Async so it satisfies the wasm-bindgen `async fn` import the Rust bridge binds
|
|
1154
|
+
// to, even though better-sqlite3 initialization itself is synchronous.
|
|
1155
|
+
async function createNodeTreeStore(dbPath, logger = null, runMigration = true) {
|
|
1156
|
+
const store = new NodeTreeStore(dbPath, logger, runMigration);
|
|
1157
|
+
return store.initialize();
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Create and initialize the tree store for the wallet whose data lives in
|
|
1162
|
+
* `dataDir`. Takes the directory rather than a file so it mirrors
|
|
1163
|
+
* `createDefaultStorage`, which receives the same path from the WASM bridge.
|
|
1164
|
+
*
|
|
1165
|
+
* @param {string} dataDir - Directory holding this wallet's database.
|
|
1166
|
+
* @param {object} [logger]
|
|
1167
|
+
* @returns {Promise<NodeTreeStore>}
|
|
1168
|
+
*/
|
|
1169
|
+
async function createDefaultTreeStore(dataDir, logger = null) {
|
|
1170
|
+
const path = require("path");
|
|
1171
|
+
const fs = require("fs").promises;
|
|
1172
|
+
|
|
1173
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
1174
|
+
|
|
1175
|
+
// Same file name as node-storage's createDefaultStorage: the tree store
|
|
1176
|
+
// shares the main storage's database.
|
|
1177
|
+
return createNodeTreeStore(path.join(dataDir, "storage.sql"), logger, true);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
module.exports = {
|
|
1181
|
+
NodeTreeStore,
|
|
1182
|
+
createNodeTreeStore,
|
|
1183
|
+
createDefaultTreeStore,
|
|
1184
|
+
TreeStoreError,
|
|
1185
|
+
};
|