@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,1518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ES module implementation of the durable tree store for browsers, backed by
|
|
3
|
+
* IndexedDB. Ports the two-table (leaves + ancestors) model and reservation /
|
|
4
|
+
* refresh / spent-guard semantics of the PostgreSQL tree store to a single-user
|
|
5
|
+
* IndexedDB database: no user_id scoping and no `brz_` table prefix, since each
|
|
6
|
+
* browser origin has its own isolated database.
|
|
7
|
+
*
|
|
8
|
+
* IndexedDB transaction lifetime: a transaction auto-commits once its request
|
|
9
|
+
* queue drains and control returns to the event loop, so awaiting any non-IDB
|
|
10
|
+
* promise mid-transaction closes it. Every logical operation therefore runs in
|
|
11
|
+
* ONE transaction: all reads are issued up front, and the compute + writes run
|
|
12
|
+
* synchronously inside the last read's success handler (never awaiting anything
|
|
13
|
+
* that isn't part of the transaction). Because IndexedDB serializes readwrite
|
|
14
|
+
* transactions with overlapping store scope, this also gives the mutating ops
|
|
15
|
+
* the same serialization the Postgres backend gets from its per-tenant lock.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Reservations older than this are stale and get released on the next refresh. */
|
|
19
|
+
const RESERVATION_TIMEOUT_MS = 300 * 1000; // 5 minutes
|
|
20
|
+
|
|
21
|
+
/** Spent-leaf markers older than this (relative to a refresh) are pruned. */
|
|
22
|
+
const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
23
|
+
|
|
24
|
+
const DB_VERSION = 5;
|
|
25
|
+
|
|
26
|
+
const STORE_LEAVES = "leaves";
|
|
27
|
+
const STORE_ANCESTORS = "ancestors";
|
|
28
|
+
const STORE_RESERVATIONS = "reservations";
|
|
29
|
+
const STORE_SPENT = "spent";
|
|
30
|
+
const STORE_SWAP_STATUS = "swapStatus";
|
|
31
|
+
|
|
32
|
+
/** Singleton key of the swap-status row. */
|
|
33
|
+
const SWAP_STATUS_ID = 1;
|
|
34
|
+
|
|
35
|
+
class TreeStoreError extends Error {
|
|
36
|
+
constructor(message, cause = null) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "TreeStoreError";
|
|
39
|
+
this.cause = cause;
|
|
40
|
+
if (Error.captureStackTrace) {
|
|
41
|
+
Error.captureStackTrace(this, TreeStoreError);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _resolveIndexedDB() {
|
|
47
|
+
if (typeof indexedDB !== "undefined") return indexedDB;
|
|
48
|
+
if (typeof globalThis !== "undefined" && globalThis.indexedDB) {
|
|
49
|
+
return globalThis.indexedDB;
|
|
50
|
+
}
|
|
51
|
+
if (typeof self !== "undefined" && self.indexedDB) return self.indexedDB;
|
|
52
|
+
if (typeof window !== "undefined" && window.indexedDB) return window.indexedDB;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pair a leaf with its ancestors (nearest first) by walking `parent_node_id`
|
|
58
|
+
* through `nodes`. Returns null if the leaf itself is absent; stops at a gap or
|
|
59
|
+
* cycle, returning a partial chain.
|
|
60
|
+
* @param {Map<string, object>} nodes
|
|
61
|
+
* @param {string} leafId
|
|
62
|
+
* @returns {{leaf: object, ancestors: Array<object>}|null}
|
|
63
|
+
*/
|
|
64
|
+
function assembleExitChain(nodes, leafId) {
|
|
65
|
+
const leaf = nodes.get(leafId);
|
|
66
|
+
if (!leaf) return null;
|
|
67
|
+
const ancestors = [];
|
|
68
|
+
const visited = new Set([leafId]);
|
|
69
|
+
let current = leaf.parent_node_id;
|
|
70
|
+
while (current != null && !visited.has(current)) {
|
|
71
|
+
visited.add(current);
|
|
72
|
+
const node = nodes.get(current);
|
|
73
|
+
if (!node) break;
|
|
74
|
+
ancestors.push(node);
|
|
75
|
+
current = node.parent_node_id;
|
|
76
|
+
}
|
|
77
|
+
return { leaf, ancestors };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class WebTreeStore {
|
|
81
|
+
constructor(dbName = "BreezSdkSparkTree", logger = null) {
|
|
82
|
+
this.dbName = dbName;
|
|
83
|
+
this.db = null;
|
|
84
|
+
this.logger = logger;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async initialize() {
|
|
88
|
+
if (this.db) return this;
|
|
89
|
+
|
|
90
|
+
const idbFactory = _resolveIndexedDB();
|
|
91
|
+
if (!idbFactory) {
|
|
92
|
+
throw new TreeStoreError("IndexedDB is not available in this environment");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.db = await new Promise((resolve, reject) => {
|
|
96
|
+
const request = idbFactory.open(this.dbName, DB_VERSION);
|
|
97
|
+
|
|
98
|
+
request.onupgradeneeded = (event) => {
|
|
99
|
+
const db = event.target.result;
|
|
100
|
+
|
|
101
|
+
// Spendable leaf pool. `data` is the full TreeNode; the other columns
|
|
102
|
+
// are projected out of it so queries avoid deserializing the blob.
|
|
103
|
+
if (!db.objectStoreNames.contains(STORE_LEAVES)) {
|
|
104
|
+
const leaves = db.createObjectStore(STORE_LEAVES, { keyPath: "id" });
|
|
105
|
+
leaves.createIndex("reservation_id", "reservation_id", {
|
|
106
|
+
unique: false,
|
|
107
|
+
});
|
|
108
|
+
// Stored 0/1 rather than as a boolean: IndexedDB rejects booleans as
|
|
109
|
+
// index keys, and indexing this is what keeps leavesMissingExitChains
|
|
110
|
+
// off a full scan of the store.
|
|
111
|
+
leaves.createIndex("chain_complete", "chain_complete", {
|
|
112
|
+
unique: false,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Intermediate exit-chain nodes, kept separate from the leaf pool and
|
|
117
|
+
// carrying no pool metadata (no reservation / missing / added_at). Each
|
|
118
|
+
// row is owned by the leaf it belongs to (key `[leaf_id, id]`), so a
|
|
119
|
+
// node shared by several leaves' chains stores one row per leaf rather
|
|
120
|
+
// than one deduplicated row. Version 1 keyed this store by `id` alone;
|
|
121
|
+
// a keyPath cannot be changed in place, and there is no data worth
|
|
122
|
+
// migrating out of it, so it is dropped and recreated. Gated on
|
|
123
|
+
// `oldVersion` so a later, unrelated version bump does not wipe it again.
|
|
124
|
+
if (event.oldVersion < 2 && db.objectStoreNames.contains(STORE_ANCESTORS)) {
|
|
125
|
+
db.deleteObjectStore(STORE_ANCESTORS);
|
|
126
|
+
}
|
|
127
|
+
if (!db.objectStoreNames.contains(STORE_ANCESTORS)) {
|
|
128
|
+
const ancestors = db.createObjectStore(STORE_ANCESTORS, {
|
|
129
|
+
keyPath: ["leaf_id", "id"],
|
|
130
|
+
});
|
|
131
|
+
ancestors.createIndex("leaf_id", "leaf_id", { unique: false });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// An existing wallet gets the flag derived from the ancestor rows it
|
|
135
|
+
// already holds, so it does not refetch every chain. The index is
|
|
136
|
+
// created here and populated by the rewrite below, since createIndex
|
|
137
|
+
// cannot run from an async callback.
|
|
138
|
+
if (event.oldVersion > 0 && event.oldVersion < DB_VERSION) {
|
|
139
|
+
const tx = event.target.transaction;
|
|
140
|
+
const leaves = tx.objectStore(STORE_LEAVES);
|
|
141
|
+
if (!leaves.indexNames.contains("chain_complete")) {
|
|
142
|
+
leaves.createIndex("chain_complete", "chain_complete", {
|
|
143
|
+
unique: false,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ancestors = tx.objectStore(STORE_ANCESTORS);
|
|
148
|
+
const linked = new Set();
|
|
149
|
+
ancestors.openCursor().onsuccess = (cursorEvent) => {
|
|
150
|
+
const cursor = cursorEvent.target.result;
|
|
151
|
+
if (cursor) {
|
|
152
|
+
linked.add(`${cursor.value.leaf_id}\u0000${cursor.value.id}`);
|
|
153
|
+
cursor.continue();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
leaves.openCursor().onsuccess = (leafEvent) => {
|
|
157
|
+
const leafCursor = leafEvent.target.result;
|
|
158
|
+
if (!leafCursor) return;
|
|
159
|
+
const row = leafCursor.value;
|
|
160
|
+
row.chain_complete =
|
|
161
|
+
row.parent_node_id == null ||
|
|
162
|
+
linked.has(`${row.id}\u0000${row.parent_node_id}`)
|
|
163
|
+
? 1
|
|
164
|
+
: 0;
|
|
165
|
+
leafCursor.update(row);
|
|
166
|
+
leafCursor.continue();
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!db.objectStoreNames.contains(STORE_RESERVATIONS)) {
|
|
172
|
+
db.createObjectStore(STORE_RESERVATIONS, { keyPath: "id" });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!db.objectStoreNames.contains(STORE_SPENT)) {
|
|
176
|
+
db.createObjectStore(STORE_SPENT, { keyPath: "id" });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!db.objectStoreNames.contains(STORE_SWAP_STATUS)) {
|
|
180
|
+
db.createObjectStore(STORE_SWAP_STATUS, { keyPath: "id" });
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
request.onsuccess = () => {
|
|
185
|
+
const db = request.result;
|
|
186
|
+
// Close on a version change requested by another connection so it is
|
|
187
|
+
// not blocked by this one.
|
|
188
|
+
db.onversionchange = () => {
|
|
189
|
+
db.close();
|
|
190
|
+
this.db = null;
|
|
191
|
+
};
|
|
192
|
+
resolve(db);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
request.onerror = () =>
|
|
196
|
+
reject(
|
|
197
|
+
new TreeStoreError(
|
|
198
|
+
`Failed to open IndexedDB: ${request.error?.message || "Unknown error"}`,
|
|
199
|
+
request.error
|
|
200
|
+
)
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
request.onblocked = () => {
|
|
204
|
+
// Another open connection holds an older version. It will resolve once
|
|
205
|
+
// that connection closes (see onversionchange above).
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return this;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
close() {
|
|
213
|
+
if (this.db) {
|
|
214
|
+
this.db.close();
|
|
215
|
+
this.db = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ===== Transaction runner =====
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Runs one transaction. `reads` is a list of `{ name, store, key?, index?,
|
|
223
|
+
* all? }`: with a key it is a `get`, without it a `getAll`, and `all` with a
|
|
224
|
+
* key reads every record under that key; `index` reads through that
|
|
225
|
+
* index on `store` instead of the store directly. All reads are issued up
|
|
226
|
+
* front; once the last completes, `compute(results, tx)` runs synchronously
|
|
227
|
+
* in that read's success handler and may issue writes on `tx`. Its return
|
|
228
|
+
* value resolves the promise on `oncomplete`, so writes are durable before
|
|
229
|
+
* resolve. `compute` must never await a non-IDB promise (it would close `tx`).
|
|
230
|
+
*/
|
|
231
|
+
_txRun(storeNames, mode, reads, compute) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
if (!this.db) {
|
|
234
|
+
reject(new TreeStoreError("Database not initialized"));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let tx;
|
|
239
|
+
try {
|
|
240
|
+
tx = this.db.transaction(storeNames, mode);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
reject(e);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let outcome;
|
|
247
|
+
let settled = false;
|
|
248
|
+
let computeError = null;
|
|
249
|
+
const done = (fn) => {
|
|
250
|
+
if (!settled) {
|
|
251
|
+
settled = true;
|
|
252
|
+
fn();
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
tx.oncomplete = () => done(() => resolve(outcome));
|
|
257
|
+
tx.onabort = () =>
|
|
258
|
+
done(() =>
|
|
259
|
+
reject(
|
|
260
|
+
computeError ||
|
|
261
|
+
new TreeStoreError(
|
|
262
|
+
`transaction aborted: ${tx.error?.message || "unknown"}`,
|
|
263
|
+
tx.error
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
tx.onerror = () =>
|
|
268
|
+
done(() =>
|
|
269
|
+
reject(
|
|
270
|
+
computeError ||
|
|
271
|
+
new TreeStoreError(
|
|
272
|
+
`transaction error: ${tx.error?.message || "unknown"}`,
|
|
273
|
+
tx.error
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const runCompute = (results) => {
|
|
279
|
+
try {
|
|
280
|
+
outcome = compute(results, tx);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
computeError = e;
|
|
283
|
+
try {
|
|
284
|
+
tx.abort();
|
|
285
|
+
} catch (_) {
|
|
286
|
+
done(() => reject(e));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
if (reads.length === 0) {
|
|
292
|
+
runCompute({});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const results = {};
|
|
297
|
+
let pending = reads.length;
|
|
298
|
+
for (const r of reads) {
|
|
299
|
+
let req;
|
|
300
|
+
try {
|
|
301
|
+
const store = tx.objectStore(r.store);
|
|
302
|
+
const source = r.index ? store.index(r.index) : store;
|
|
303
|
+
if (r.keysOnly) {
|
|
304
|
+
// Primary keys of the matching records, without reading a value.
|
|
305
|
+
req = source.getAllKeys(r.key);
|
|
306
|
+
} else if (r.all) {
|
|
307
|
+
// Every record under `key`, rather than the single first match a
|
|
308
|
+
// bare `get` returns. An index key matches many records.
|
|
309
|
+
req = source.getAll(r.key);
|
|
310
|
+
} else {
|
|
311
|
+
req = "key" in r ? source.get(r.key) : source.getAll();
|
|
312
|
+
}
|
|
313
|
+
} catch (e) {
|
|
314
|
+
done(() => reject(e));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
req.onsuccess = () => {
|
|
318
|
+
results[r.name] = req.result;
|
|
319
|
+
pending -= 1;
|
|
320
|
+
if (pending === 0) runCompute(results);
|
|
321
|
+
};
|
|
322
|
+
// A failed request bubbles to the transaction and aborts it, surfacing
|
|
323
|
+
// via tx.onabort above.
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ===== Reads =====
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Reconstruct the exit chains for many leaves in one transaction, each as
|
|
332
|
+
* { leaf, ancestors } with ancestors nearest first. A leaf absent from the store
|
|
333
|
+
* is skipped; a chain that hits a gap comes back partial.
|
|
334
|
+
* @param {Array<string>} leafIds
|
|
335
|
+
* @returns {Promise<Array<{leaf: object, ancestors: Array<object>}>>}
|
|
336
|
+
*/
|
|
337
|
+
async getExitChains(leafIds) {
|
|
338
|
+
try {
|
|
339
|
+
if (!leafIds || leafIds.length === 0) return [];
|
|
340
|
+
return await new Promise((resolve, reject) => {
|
|
341
|
+
if (!this.db) {
|
|
342
|
+
reject(new TreeStoreError("Database not initialized"));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// Each requested leaf's own ancestor rows (via the leaf_id index) plus
|
|
346
|
+
// the leaf itself, walked one chain at a time so a node id stored under
|
|
347
|
+
// another leaf can never cross-contaminate this one. All reads are
|
|
348
|
+
// issued up front; oncomplete only fires once every one of them (and
|
|
349
|
+
// any request queued afterwards) has settled.
|
|
350
|
+
const tx = this.db.transaction([STORE_LEAVES, STORE_ANCESTORS], "readonly");
|
|
351
|
+
const leavesStore = tx.objectStore(STORE_LEAVES);
|
|
352
|
+
const ancestorsIndex = tx.objectStore(STORE_ANCESTORS).index("leaf_id");
|
|
353
|
+
|
|
354
|
+
const leafById = new Map();
|
|
355
|
+
const ancestorsByLeaf = new Map();
|
|
356
|
+
let settled = false;
|
|
357
|
+
const fail = (msg) => {
|
|
358
|
+
if (!settled) {
|
|
359
|
+
settled = true;
|
|
360
|
+
reject(new TreeStoreError(msg));
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
tx.onabort = () =>
|
|
364
|
+
fail(`Failed to get exit chains: ${tx.error?.message || "aborted"}`);
|
|
365
|
+
tx.onerror = () =>
|
|
366
|
+
fail(`Failed to get exit chains: ${tx.error?.message || "error"}`);
|
|
367
|
+
tx.oncomplete = () => {
|
|
368
|
+
if (settled) return;
|
|
369
|
+
settled = true;
|
|
370
|
+
const result = [];
|
|
371
|
+
for (const id of leafIds) {
|
|
372
|
+
const leafRow = leafById.get(id);
|
|
373
|
+
if (!leafRow) continue;
|
|
374
|
+
const nodes = ancestorsByLeaf.get(id) || new Map();
|
|
375
|
+
nodes.set(leafRow.id, leafRow.data);
|
|
376
|
+
const pedigree = assembleExitChain(nodes, id);
|
|
377
|
+
if (pedigree) result.push(pedigree);
|
|
378
|
+
}
|
|
379
|
+
resolve(result);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
for (const id of leafIds) {
|
|
383
|
+
const leafReq = leavesStore.get(id);
|
|
384
|
+
leafReq.onsuccess = () => {
|
|
385
|
+
if (leafReq.result) leafById.set(id, leafReq.result);
|
|
386
|
+
};
|
|
387
|
+
const ancestorsReq = ancestorsIndex.getAll(id);
|
|
388
|
+
ancestorsReq.onsuccess = () => {
|
|
389
|
+
const nodes = new Map();
|
|
390
|
+
for (const row of ancestorsReq.result || []) nodes.set(row.id, row.data);
|
|
391
|
+
ancestorsByLeaf.set(id, nodes);
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error instanceof TreeStoreError) throw error;
|
|
397
|
+
throw new TreeStoreError(`Failed to get exit chains: ${error.message}`, error);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Ids of the stored leaves whose chain cannot back an exit: the leaf has a
|
|
403
|
+
* parent, and no ancestor row of its own holds that parent.
|
|
404
|
+
* @returns {Promise<Array<string>>}
|
|
405
|
+
*/
|
|
406
|
+
async leavesMissingExitChains() {
|
|
407
|
+
try {
|
|
408
|
+
// A leaf that is itself a root is always flagged complete, so the flag
|
|
409
|
+
// alone selects what needs a chain, and the index yields the leaf ids
|
|
410
|
+
// without deserializing a single stored node.
|
|
411
|
+
return await this._txRun(
|
|
412
|
+
[STORE_LEAVES],
|
|
413
|
+
"readonly",
|
|
414
|
+
[
|
|
415
|
+
{
|
|
416
|
+
name: "ids",
|
|
417
|
+
store: STORE_LEAVES,
|
|
418
|
+
index: "chain_complete",
|
|
419
|
+
key: 0,
|
|
420
|
+
keysOnly: true,
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
(res) => res.ids
|
|
424
|
+
);
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error instanceof TreeStoreError) throw error;
|
|
427
|
+
throw new TreeStoreError(
|
|
428
|
+
`Failed to get leaves missing exit chains: ${error.message}`,
|
|
429
|
+
error
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async getLeaves() {
|
|
435
|
+
try {
|
|
436
|
+
return await this._txRun(
|
|
437
|
+
[STORE_LEAVES, STORE_RESERVATIONS],
|
|
438
|
+
"readonly",
|
|
439
|
+
[
|
|
440
|
+
{ name: "leaves", store: STORE_LEAVES },
|
|
441
|
+
{ name: "reservations", store: STORE_RESERVATIONS },
|
|
442
|
+
],
|
|
443
|
+
(res) => {
|
|
444
|
+
const resMap = new Map(res.reservations.map((r) => [r.id, r]));
|
|
445
|
+
const available = [];
|
|
446
|
+
const notAvailable = [];
|
|
447
|
+
const availableMissingFromOperators = [];
|
|
448
|
+
const reservedForPayment = [];
|
|
449
|
+
const reservedForSwap = [];
|
|
450
|
+
|
|
451
|
+
for (const row of res.leaves) {
|
|
452
|
+
const node = row.data;
|
|
453
|
+
const purpose =
|
|
454
|
+
row.reservation_id != null
|
|
455
|
+
? resMap.get(row.reservation_id)?.purpose
|
|
456
|
+
: undefined;
|
|
457
|
+
|
|
458
|
+
const spendable = node.status === "Available";
|
|
459
|
+
if (purpose) {
|
|
460
|
+
if (purpose === "Payment") reservedForPayment.push(node);
|
|
461
|
+
else if (purpose === "Swap") reservedForSwap.push(node);
|
|
462
|
+
} else if (!spendable) {
|
|
463
|
+
notAvailable.push(node);
|
|
464
|
+
} else if (row.is_missing_from_operators) {
|
|
465
|
+
availableMissingFromOperators.push(node);
|
|
466
|
+
} else {
|
|
467
|
+
available.push(node);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
available,
|
|
473
|
+
notAvailable,
|
|
474
|
+
availableMissingFromOperators,
|
|
475
|
+
reservedForPayment,
|
|
476
|
+
reservedForSwap,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (error instanceof TreeStoreError) throw error;
|
|
482
|
+
throw new TreeStoreError(`Failed to get leaves: ${error.message}`, error);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async getAvailableBalance() {
|
|
487
|
+
try {
|
|
488
|
+
return await this._txRun(
|
|
489
|
+
[STORE_LEAVES, STORE_RESERVATIONS],
|
|
490
|
+
"readonly",
|
|
491
|
+
[
|
|
492
|
+
{ name: "leaves", store: STORE_LEAVES },
|
|
493
|
+
{ name: "reservations", store: STORE_RESERVATIONS },
|
|
494
|
+
],
|
|
495
|
+
(res) => {
|
|
496
|
+
const resMap = new Map(res.reservations.map((r) => [r.id, r]));
|
|
497
|
+
// Spendable = unreserved-available + swap-reserved (mirrors
|
|
498
|
+
// Leaves::balance, which also counts missing-from-operators leaves
|
|
499
|
+
// that are still Available and unreserved).
|
|
500
|
+
let balance = 0n;
|
|
501
|
+
for (const row of res.leaves) {
|
|
502
|
+
const purpose =
|
|
503
|
+
row.reservation_id != null
|
|
504
|
+
? resMap.get(row.reservation_id)?.purpose
|
|
505
|
+
: undefined;
|
|
506
|
+
const included =
|
|
507
|
+
(row.reservation_id == null && row.status === "Available") ||
|
|
508
|
+
purpose === "Swap";
|
|
509
|
+
if (included) balance += BigInt(row.value);
|
|
510
|
+
}
|
|
511
|
+
return balance;
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (error instanceof TreeStoreError) throw error;
|
|
516
|
+
throw new TreeStoreError(
|
|
517
|
+
`Failed to get available balance: ${error.message}`,
|
|
518
|
+
error
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async getVerifiedLeafKeys() {
|
|
524
|
+
try {
|
|
525
|
+
return await this._txRun(
|
|
526
|
+
[STORE_LEAVES, STORE_RESERVATIONS],
|
|
527
|
+
"readonly",
|
|
528
|
+
[
|
|
529
|
+
{ name: "leaves", store: STORE_LEAVES },
|
|
530
|
+
{ name: "reservations", store: STORE_RESERVATIONS },
|
|
531
|
+
],
|
|
532
|
+
(res) => {
|
|
533
|
+
const resIds = new Set(res.reservations.map((r) => r.id));
|
|
534
|
+
const out = [];
|
|
535
|
+
for (const row of res.leaves) {
|
|
536
|
+
const hasReservation =
|
|
537
|
+
row.reservation_id != null && resIds.has(row.reservation_id);
|
|
538
|
+
// Every reserved leaf plus every Available one; nothing that is
|
|
539
|
+
// neither reserved nor Available.
|
|
540
|
+
if (hasReservation || row.status === "Available") {
|
|
541
|
+
out.push([
|
|
542
|
+
row.id,
|
|
543
|
+
row.verifying_public_key,
|
|
544
|
+
row.signing_public_key,
|
|
545
|
+
]);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (error instanceof TreeStoreError) throw error;
|
|
553
|
+
throw new TreeStoreError(
|
|
554
|
+
`Failed to get verified leaf keys: ${error.message}`,
|
|
555
|
+
error
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async now() {
|
|
561
|
+
return this._nowMs();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ===== Writes =====
|
|
565
|
+
|
|
566
|
+
async addLeaves(leaves) {
|
|
567
|
+
try {
|
|
568
|
+
if (!leaves || leaves.length === 0) return;
|
|
569
|
+
const leafIds = leaves.map((l) => l.id);
|
|
570
|
+
|
|
571
|
+
await this._txRun(
|
|
572
|
+
[STORE_LEAVES, STORE_SPENT],
|
|
573
|
+
"readwrite",
|
|
574
|
+
[{ name: "leaves", store: STORE_LEAVES }],
|
|
575
|
+
(res, tx) => {
|
|
576
|
+
const leavesStore = tx.objectStore(STORE_LEAVES);
|
|
577
|
+
const spentStore = tx.objectStore(STORE_SPENT);
|
|
578
|
+
|
|
579
|
+
const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
|
|
580
|
+
|
|
581
|
+
// Re-adding a leaf clears any stale spent marker for it.
|
|
582
|
+
for (const id of leafIds) spentStore.delete(id);
|
|
583
|
+
|
|
584
|
+
for (const leaf of leaves) {
|
|
585
|
+
leavesStore.put(this._leafRow(leaf, false, leafMap.get(leaf.id)));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (error instanceof TreeStoreError) throw error;
|
|
591
|
+
throw new TreeStoreError(`Failed to add leaves: ${error.message}`, error);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async storeAncestors(pedigrees) {
|
|
596
|
+
try {
|
|
597
|
+
if (!pedigrees || pedigrees.length === 0) return;
|
|
598
|
+
|
|
599
|
+
// Scoped to the pedigrees' own rows: reading the two stores whole would
|
|
600
|
+
// deserialize every leaf and ancestor row the wallet holds to write a
|
|
601
|
+
// handful of chains.
|
|
602
|
+
const reads = [];
|
|
603
|
+
for (const p of pedigrees) {
|
|
604
|
+
reads.push({
|
|
605
|
+
name: `leaf:${p.leaf.id}`,
|
|
606
|
+
store: STORE_LEAVES,
|
|
607
|
+
key: p.leaf.id,
|
|
608
|
+
});
|
|
609
|
+
reads.push({
|
|
610
|
+
name: `ancestors:${p.leaf.id}`,
|
|
611
|
+
store: STORE_ANCESTORS,
|
|
612
|
+
index: "leaf_id",
|
|
613
|
+
key: p.leaf.id,
|
|
614
|
+
all: true,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
await this._txRun(
|
|
619
|
+
[STORE_LEAVES, STORE_ANCESTORS],
|
|
620
|
+
"readwrite",
|
|
621
|
+
reads,
|
|
622
|
+
(res, tx) => {
|
|
623
|
+
const ancestorsStore = tx.objectStore(STORE_ANCESTORS);
|
|
624
|
+
const leavesStore = tx.objectStore(STORE_LEAVES);
|
|
625
|
+
const ancestorRowsByLeaf = this._ancestorRowsByLeaf(
|
|
626
|
+
pedigrees.flatMap((p) => res[`ancestors:${p.leaf.id}`] || [])
|
|
627
|
+
);
|
|
628
|
+
// A leaf can be spent between its chain being resolved and this write,
|
|
629
|
+
// and a chain is only ever removed with its leaf. Writing one for a leaf
|
|
630
|
+
// that is already gone would leave it behind for good.
|
|
631
|
+
const leafRowsById = new Map();
|
|
632
|
+
for (const p of pedigrees) {
|
|
633
|
+
const row = res[`leaf:${p.leaf.id}`];
|
|
634
|
+
if (row) {
|
|
635
|
+
leafRowsById.set(row.id, row);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
for (const p of pedigrees) {
|
|
640
|
+
const leafRow = leafRowsById.get(p.leaf.id);
|
|
641
|
+
if (!leafRow) {
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
this._replaceAncestors(
|
|
645
|
+
ancestorsStore,
|
|
646
|
+
ancestorRowsByLeaf,
|
|
647
|
+
p.leaf.id,
|
|
648
|
+
p.ancestors
|
|
649
|
+
);
|
|
650
|
+
// This write only touches ancestor rows, so the leaf's completeness
|
|
651
|
+
// flag has to be refreshed alongside them. Judged against the stored
|
|
652
|
+
// leaf, not the fetched one: a renewal may have reparented it while
|
|
653
|
+
// the chain was in flight, in which case the chain that just arrived
|
|
654
|
+
// no longer reaches it.
|
|
655
|
+
const chainComplete = this._chainComplete(
|
|
656
|
+
leafRow.data,
|
|
657
|
+
p.ancestors,
|
|
658
|
+
leafRow
|
|
659
|
+
);
|
|
660
|
+
if (chainComplete !== !!leafRow.chain_complete) {
|
|
661
|
+
leavesStore.put({
|
|
662
|
+
...leafRow,
|
|
663
|
+
chain_complete: chainComplete ? 1 : 0,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
);
|
|
669
|
+
} catch (error) {
|
|
670
|
+
if (error instanceof TreeStoreError) throw error;
|
|
671
|
+
throw new TreeStoreError(`Failed to store ancestors: ${error.message}`, error);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async setLeaves(leaves, missingLeaves, refreshStartedAtMs) {
|
|
676
|
+
try {
|
|
677
|
+
const reported = leaves || [];
|
|
678
|
+
const missing = missingLeaves || [];
|
|
679
|
+
const refreshMs = refreshStartedAtMs;
|
|
680
|
+
|
|
681
|
+
await this._txRun(
|
|
682
|
+
[STORE_LEAVES, STORE_ANCESTORS, STORE_RESERVATIONS, STORE_SPENT, STORE_SWAP_STATUS],
|
|
683
|
+
"readwrite",
|
|
684
|
+
[
|
|
685
|
+
{ name: "leaves", store: STORE_LEAVES },
|
|
686
|
+
{ name: "ancestors", store: STORE_ANCESTORS },
|
|
687
|
+
{ name: "reservations", store: STORE_RESERVATIONS },
|
|
688
|
+
{ name: "spent", store: STORE_SPENT },
|
|
689
|
+
{ name: "swap", store: STORE_SWAP_STATUS, key: SWAP_STATUS_ID },
|
|
690
|
+
],
|
|
691
|
+
(res, tx) => {
|
|
692
|
+
const leavesStore = tx.objectStore(STORE_LEAVES);
|
|
693
|
+
const ancestorsStore = tx.objectStore(STORE_ANCESTORS);
|
|
694
|
+
const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
|
|
695
|
+
const spentStore = tx.objectStore(STORE_SPENT);
|
|
696
|
+
|
|
697
|
+
const nowMs = this._nowMs();
|
|
698
|
+
const leafMap = new Map(res.leaves.map((r) => [r.id, { ...r }]));
|
|
699
|
+
const ancestorRowsByLeaf = this._ancestorRowsByLeaf(res.ancestors);
|
|
700
|
+
|
|
701
|
+
// Release + drop stale reservations BEFORE the swap guard, otherwise a
|
|
702
|
+
// stale Swap reservation would pin has_active_swap true forever and
|
|
703
|
+
// set_leaves could never make progress.
|
|
704
|
+
const staleCutoff = nowMs - RESERVATION_TIMEOUT_MS;
|
|
705
|
+
const staleIds = new Set(
|
|
706
|
+
res.reservations.filter((r) => r.created_at < staleCutoff).map((r) => r.id)
|
|
707
|
+
);
|
|
708
|
+
for (const id of staleIds) reservationsStore.delete(id);
|
|
709
|
+
for (const row of leafMap.values()) {
|
|
710
|
+
if (row.reservation_id != null && staleIds.has(row.reservation_id)) {
|
|
711
|
+
row.reservation_id = null;
|
|
712
|
+
leavesStore.put(row);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const remainingReservations = res.reservations.filter(
|
|
716
|
+
(r) => !staleIds.has(r.id)
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
// Swap guard: skip the refresh body if a swap is in flight or one
|
|
720
|
+
// completed during the refresh (its change would otherwise be lost).
|
|
721
|
+
const hasActiveSwap = remainingReservations.some((r) => r.purpose === "Swap");
|
|
722
|
+
const swapCompletedDuringRefresh =
|
|
723
|
+
!!res.swap && res.swap.last_completed_at >= refreshMs;
|
|
724
|
+
if (hasActiveSwap || swapCompletedDuringRefresh) {
|
|
725
|
+
return; // stale-reservation cleanup above still commits
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Prune old spent markers, then collect the ones still fresh enough to
|
|
729
|
+
// suppress re-adding a just-spent leaf during this refresh window.
|
|
730
|
+
const spentCleanupCutoff = refreshMs - SPENT_MARKER_CLEANUP_THRESHOLD_MS;
|
|
731
|
+
for (const s of res.spent) {
|
|
732
|
+
if (s.spent_at < spentCleanupCutoff) spentStore.delete(s.id);
|
|
733
|
+
}
|
|
734
|
+
const spentIds = new Set(
|
|
735
|
+
res.spent.filter((s) => s.spent_at >= refreshMs).map((s) => s.id)
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
// Delete non-reserved leaves added before the refresh started (this
|
|
739
|
+
// includes leaves released just above by the stale-reservation
|
|
740
|
+
// cleanup). A leaf reported again in this same refresh is re-inserted
|
|
741
|
+
// below, so its ancestor rows must survive: only ids that do NOT
|
|
742
|
+
// reappear (truly gone, e.g. spent) get their ancestor rows dropped
|
|
743
|
+
// alongside them.
|
|
744
|
+
// Taken before the deletion below: a refresh carries leaves alone, so
|
|
745
|
+
// the chain-complete flag a reported leaf keeps has to come from the
|
|
746
|
+
// row it already had, or every refresh would report every leaf as
|
|
747
|
+
// missing its chain.
|
|
748
|
+
const priorRows = new Map(leafMap);
|
|
749
|
+
|
|
750
|
+
const deletedIds = [];
|
|
751
|
+
for (const row of Array.from(leafMap.values())) {
|
|
752
|
+
if (row.reservation_id == null && row.added_at < refreshMs) {
|
|
753
|
+
leavesStore.delete(row.id);
|
|
754
|
+
leafMap.delete(row.id);
|
|
755
|
+
deletedIds.push(row.id);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const liveLeaves = reported.filter((p) => !spentIds.has(p.id));
|
|
760
|
+
const liveMissing = missing.filter((p) => !spentIds.has(p.id));
|
|
761
|
+
|
|
762
|
+
for (const p of liveLeaves) {
|
|
763
|
+
const row = this._leafRow(p, false, priorRows.get(p.id));
|
|
764
|
+
leavesStore.put(row);
|
|
765
|
+
leafMap.set(row.id, row);
|
|
766
|
+
}
|
|
767
|
+
for (const p of liveMissing) {
|
|
768
|
+
const row = this._leafRow(p, true, priorRows.get(p.id));
|
|
769
|
+
leavesStore.put(row);
|
|
770
|
+
leafMap.set(row.id, row);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const survivingIds = new Set();
|
|
774
|
+
for (const p of reported.concat(missing)) {
|
|
775
|
+
if (!spentIds.has(p.id)) survivingIds.add(p.id);
|
|
776
|
+
}
|
|
777
|
+
for (const id of deletedIds) {
|
|
778
|
+
if (survivingIds.has(id)) continue;
|
|
779
|
+
for (const existingId of (ancestorRowsByLeaf.get(id) || new Map()).keys()) {
|
|
780
|
+
ancestorsStore.delete([id, existingId]);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
);
|
|
785
|
+
} catch (error) {
|
|
786
|
+
if (error instanceof TreeStoreError) throw error;
|
|
787
|
+
throw new TreeStoreError(`Failed to set leaves: ${error.message}`, error);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async cancelReservation(id, leavesToKeep) {
|
|
792
|
+
try {
|
|
793
|
+
const keep = leavesToKeep || [];
|
|
794
|
+
await this._txRun(
|
|
795
|
+
[STORE_LEAVES, STORE_ANCESTORS, STORE_RESERVATIONS],
|
|
796
|
+
"readwrite",
|
|
797
|
+
[
|
|
798
|
+
{ name: "leaves", store: STORE_LEAVES },
|
|
799
|
+
{ name: "ancestors", store: STORE_ANCESTORS },
|
|
800
|
+
{ name: "res", store: STORE_RESERVATIONS, key: id },
|
|
801
|
+
],
|
|
802
|
+
(res, tx) => {
|
|
803
|
+
// Return leavesToKeep to the pool even when the reservation is already
|
|
804
|
+
// gone (e.g. released by stale cleanup): dropping them here would lose
|
|
805
|
+
// the leaves until the next refresh. The deletes below no-op in that case.
|
|
806
|
+
const leavesStore = tx.objectStore(STORE_LEAVES);
|
|
807
|
+
const ancestorsStore = tx.objectStore(STORE_ANCESTORS);
|
|
808
|
+
const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
|
|
809
|
+
|
|
810
|
+
const keepIds = new Set(keep.map((l) => l.id));
|
|
811
|
+
const ancestorRowsByLeaf = this._ancestorRowsByLeaf(res.ancestors);
|
|
812
|
+
const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
|
|
813
|
+
// A kept leaf keeps its ancestor rows, so the row rebuilt below has to
|
|
814
|
+
// keep the flag that describes them.
|
|
815
|
+
const priorRows = new Map(leafMap);
|
|
816
|
+
for (const l of res.leaves) {
|
|
817
|
+
if (l.reservation_id !== id) continue;
|
|
818
|
+
leavesStore.delete(l.id);
|
|
819
|
+
leafMap.delete(l.id);
|
|
820
|
+
// A kept leaf's ancestor rows stay put; a dropped leaf's are
|
|
821
|
+
// removed with it.
|
|
822
|
+
if (!keepIds.has(l.id)) {
|
|
823
|
+
for (const existingId of (ancestorRowsByLeaf.get(l.id) || new Map()).keys()) {
|
|
824
|
+
ancestorsStore.delete([l.id, existingId]);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
reservationsStore.delete(id);
|
|
829
|
+
|
|
830
|
+
if (keep.length > 0) {
|
|
831
|
+
// Only re-insert the leaves: a kept leaf's ancestor rows were left
|
|
832
|
+
// untouched above.
|
|
833
|
+
for (const leaf of keep) {
|
|
834
|
+
// The chain flag carries over: those rows were deliberately kept.
|
|
835
|
+
// So does any reservation other than this one, since a leaf the
|
|
836
|
+
// refresh released and another reservation then took is not this
|
|
837
|
+
// cancellation's to free.
|
|
838
|
+
const prior = priorRows.get(leaf.id);
|
|
839
|
+
const held = prior?.reservation_id === id ? undefined : prior;
|
|
840
|
+
const chainComplete = this._chainComplete(leaf, undefined, prior);
|
|
841
|
+
leavesStore.put(this._leafRow(leaf, false, held, chainComplete));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
);
|
|
846
|
+
} catch (error) {
|
|
847
|
+
if (error instanceof TreeStoreError) throw error;
|
|
848
|
+
throw new TreeStoreError(
|
|
849
|
+
`Failed to cancel reservation '${id}': ${error.message}`,
|
|
850
|
+
error
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async finalizeReservation(id, newLeaves) {
|
|
856
|
+
try {
|
|
857
|
+
const added = newLeaves || null;
|
|
858
|
+
await this._txRun(
|
|
859
|
+
[STORE_LEAVES, STORE_ANCESTORS, STORE_RESERVATIONS, STORE_SPENT, STORE_SWAP_STATUS],
|
|
860
|
+
"readwrite",
|
|
861
|
+
[
|
|
862
|
+
{ name: "leaves", store: STORE_LEAVES },
|
|
863
|
+
{ name: "ancestors", store: STORE_ANCESTORS },
|
|
864
|
+
{ name: "res", store: STORE_RESERVATIONS, key: id },
|
|
865
|
+
],
|
|
866
|
+
(res, tx) => {
|
|
867
|
+
const leavesStore = tx.objectStore(STORE_LEAVES);
|
|
868
|
+
const ancestorsStore = tx.objectStore(STORE_ANCESTORS);
|
|
869
|
+
const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
|
|
870
|
+
const spentStore = tx.objectStore(STORE_SPENT);
|
|
871
|
+
const swapStore = tx.objectStore(STORE_SWAP_STATUS);
|
|
872
|
+
|
|
873
|
+
const nowMs = this._nowMs();
|
|
874
|
+
const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
|
|
875
|
+
const ancestorRowsByLeaf = this._ancestorRowsByLeaf(res.ancestors);
|
|
876
|
+
|
|
877
|
+
let isSwap = false;
|
|
878
|
+
if (res.res) {
|
|
879
|
+
isSwap = res.res.purpose === "Swap";
|
|
880
|
+
for (const l of res.leaves) {
|
|
881
|
+
if (l.reservation_id === id) {
|
|
882
|
+
spentStore.put({ id: l.id, spent_at: nowMs });
|
|
883
|
+
leavesStore.delete(l.id);
|
|
884
|
+
leafMap.delete(l.id);
|
|
885
|
+
// The spent leaf owns these ancestor rows; remove them now
|
|
886
|
+
// since there is no later reclaim pass.
|
|
887
|
+
for (const existingId of (ancestorRowsByLeaf.get(l.id) || new Map()).keys()) {
|
|
888
|
+
ancestorsStore.delete([l.id, existingId]);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
reservationsStore.delete(id);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (added && added.length > 0) {
|
|
896
|
+
for (const p of added) {
|
|
897
|
+
const row = this._leafRow(p, false, leafMap.get(p.id));
|
|
898
|
+
leavesStore.put(row);
|
|
899
|
+
leafMap.set(row.id, row);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (isSwap && added && added.length > 0) {
|
|
904
|
+
swapStore.put({ id: SWAP_STATUS_ID, last_completed_at: nowMs });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
);
|
|
908
|
+
} catch (error) {
|
|
909
|
+
if (error instanceof TreeStoreError) throw error;
|
|
910
|
+
throw new TreeStoreError(
|
|
911
|
+
`Failed to finalize reservation '${id}': ${error.message}`,
|
|
912
|
+
error
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async updateReservation(reservationId, reservedLeaves, changeLeaves) {
|
|
918
|
+
try {
|
|
919
|
+
const reserved = reservedLeaves || [];
|
|
920
|
+
const change = changeLeaves || [];
|
|
921
|
+
return await this._txRun(
|
|
922
|
+
[STORE_LEAVES, STORE_ANCESTORS, STORE_RESERVATIONS, STORE_SPENT],
|
|
923
|
+
"readwrite",
|
|
924
|
+
[
|
|
925
|
+
{ name: "leaves", store: STORE_LEAVES },
|
|
926
|
+
{ name: "ancestors", store: STORE_ANCESTORS },
|
|
927
|
+
{ name: "res", store: STORE_RESERVATIONS, key: reservationId },
|
|
928
|
+
],
|
|
929
|
+
(res, tx) => {
|
|
930
|
+
if (!res.res) {
|
|
931
|
+
throw new TreeStoreError(`Reservation ${reservationId} not found`);
|
|
932
|
+
}
|
|
933
|
+
const leavesStore = tx.objectStore(STORE_LEAVES);
|
|
934
|
+
const ancestorsStore = tx.objectStore(STORE_ANCESTORS);
|
|
935
|
+
const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
|
|
936
|
+
const spentStore = tx.objectStore(STORE_SPENT);
|
|
937
|
+
|
|
938
|
+
const nowMs = this._nowMs();
|
|
939
|
+
const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
|
|
940
|
+
const ancestorRowsByLeaf = this._ancestorRowsByLeaf(res.ancestors);
|
|
941
|
+
|
|
942
|
+
// Old reserved leaves are consumed by the swap: mark spent and drop,
|
|
943
|
+
// along with the ancestor rows they own, since there is no later
|
|
944
|
+
// reclaim pass.
|
|
945
|
+
for (const l of res.leaves) {
|
|
946
|
+
if (l.reservation_id === reservationId) {
|
|
947
|
+
spentStore.put({ id: l.id, spent_at: nowMs });
|
|
948
|
+
leavesStore.delete(l.id);
|
|
949
|
+
leafMap.delete(l.id);
|
|
950
|
+
for (const existingId of (ancestorRowsByLeaf.get(l.id) || new Map()).keys()) {
|
|
951
|
+
ancestorsStore.delete([l.id, existingId]);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
const leafNodes = change.concat(reserved);
|
|
958
|
+
|
|
959
|
+
// Change leaves go back to the available pool.
|
|
960
|
+
for (const p of change) {
|
|
961
|
+
const row = this._leafRow(p, false, leafMap.get(p.id));
|
|
962
|
+
leavesStore.put(row);
|
|
963
|
+
leafMap.set(row.id, row);
|
|
964
|
+
}
|
|
965
|
+
// Reserved leaves stay attached to this same reservation.
|
|
966
|
+
for (const p of reserved) {
|
|
967
|
+
const row = this._leafRow(p, false, leafMap.get(p.id));
|
|
968
|
+
row.reservation_id = reservationId;
|
|
969
|
+
leavesStore.put(row);
|
|
970
|
+
leafMap.set(row.id, row);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
reservationsStore.put({ ...res.res, pending_change_amount: 0 });
|
|
974
|
+
|
|
975
|
+
// Return value must be plain TreeNodes: the Rust side deserializes
|
|
976
|
+
// Vec<TreeNode>.
|
|
977
|
+
return { id: reservationId, leaves: reserved };
|
|
978
|
+
}
|
|
979
|
+
);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
if (error instanceof TreeStoreError) throw error;
|
|
982
|
+
throw new TreeStoreError(
|
|
983
|
+
`Failed to update reservation '${reservationId}': ${error.message}`,
|
|
984
|
+
error
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async tryReserveLeaves(targetAmounts, exactOnly, purpose) {
|
|
990
|
+
try {
|
|
991
|
+
return await this._txRun(
|
|
992
|
+
[STORE_LEAVES, STORE_RESERVATIONS],
|
|
993
|
+
"readwrite",
|
|
994
|
+
[
|
|
995
|
+
{ name: "leaves", store: STORE_LEAVES },
|
|
996
|
+
{ name: "reservations", store: STORE_RESERVATIONS },
|
|
997
|
+
],
|
|
998
|
+
(res, tx) => {
|
|
999
|
+
const leavesStore = tx.objectStore(STORE_LEAVES);
|
|
1000
|
+
const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
|
|
1001
|
+
|
|
1002
|
+
const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
|
|
1003
|
+
const maxTarget = this._maxTargetForPrefilter(targetAmounts);
|
|
1004
|
+
|
|
1005
|
+
const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
|
|
1006
|
+
const eligible = this._eligibleSlim(res.leaves);
|
|
1007
|
+
// True total over ALL eligible leaves, not the prefiltered set: the
|
|
1008
|
+
// WaitForPending decision below must not be derived from the slim set.
|
|
1009
|
+
const available = eligible.reduce((s, l) => s + l.value, 0);
|
|
1010
|
+
const slim = this._slimCandidates(eligible, maxTarget);
|
|
1011
|
+
const pending = res.reservations.reduce(
|
|
1012
|
+
(s, r) => s + (r.pending_change_amount || 0),
|
|
1013
|
+
0
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
const selected = this._selectLeavesByTargetAmounts(slim, targetAmounts);
|
|
1017
|
+
if (selected !== null) {
|
|
1018
|
+
if (selected.length === 0) {
|
|
1019
|
+
throw new TreeStoreError("NonReservableLeaves");
|
|
1020
|
+
}
|
|
1021
|
+
const ids = selected.map((s) => s.id);
|
|
1022
|
+
const fullLeaves = ids.map((leafId) => leafMap.get(leafId).data);
|
|
1023
|
+
const reservationId = this._generateId();
|
|
1024
|
+
this._createReservation(
|
|
1025
|
+
reservationsStore,
|
|
1026
|
+
leavesStore,
|
|
1027
|
+
leafMap,
|
|
1028
|
+
reservationId,
|
|
1029
|
+
ids,
|
|
1030
|
+
purpose,
|
|
1031
|
+
0
|
|
1032
|
+
);
|
|
1033
|
+
return {
|
|
1034
|
+
type: "success",
|
|
1035
|
+
reservation: { id: reservationId, leaves: fullLeaves },
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
if (!exactOnly) {
|
|
1040
|
+
const minSelected = this._selectLeavesByMinimumAmount(slim, targetAmount);
|
|
1041
|
+
if (minSelected !== null) {
|
|
1042
|
+
const ids = minSelected.map((s) => s.id);
|
|
1043
|
+
const fullLeaves = ids.map((leafId) => leafMap.get(leafId).data);
|
|
1044
|
+
const reservedAmount = fullLeaves.reduce((s, l) => s + l.value, 0);
|
|
1045
|
+
const pendingChange =
|
|
1046
|
+
reservedAmount > targetAmount && targetAmount > 0
|
|
1047
|
+
? reservedAmount - targetAmount
|
|
1048
|
+
: 0;
|
|
1049
|
+
const reservationId = this._generateId();
|
|
1050
|
+
this._createReservation(
|
|
1051
|
+
reservationsStore,
|
|
1052
|
+
leavesStore,
|
|
1053
|
+
leafMap,
|
|
1054
|
+
reservationId,
|
|
1055
|
+
ids,
|
|
1056
|
+
purpose,
|
|
1057
|
+
pendingChange
|
|
1058
|
+
);
|
|
1059
|
+
return {
|
|
1060
|
+
type: "success",
|
|
1061
|
+
reservation: { id: reservationId, leaves: fullLeaves },
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (available + pending >= targetAmount) {
|
|
1067
|
+
return {
|
|
1068
|
+
type: "waitForPending",
|
|
1069
|
+
needed: targetAmount,
|
|
1070
|
+
available,
|
|
1071
|
+
pending,
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
return { type: "insufficientFunds" };
|
|
1075
|
+
}
|
|
1076
|
+
);
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
if (error instanceof TreeStoreError) throw error;
|
|
1079
|
+
throw new TreeStoreError(
|
|
1080
|
+
`Failed to try reserve leaves: ${error.message}`,
|
|
1081
|
+
error
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async tryReserveLeavesByIds(leafIds, purpose) {
|
|
1087
|
+
try {
|
|
1088
|
+
if (!leafIds || leafIds.length === 0) {
|
|
1089
|
+
throw new TreeStoreError("NonReservableLeaves");
|
|
1090
|
+
}
|
|
1091
|
+
return await this._txRun(
|
|
1092
|
+
[STORE_LEAVES, STORE_RESERVATIONS],
|
|
1093
|
+
"readwrite",
|
|
1094
|
+
[{ name: "leaves", store: STORE_LEAVES }],
|
|
1095
|
+
(res, tx) => {
|
|
1096
|
+
const leavesStore = tx.objectStore(STORE_LEAVES);
|
|
1097
|
+
const reservationsStore = tx.objectStore(STORE_RESERVATIONS);
|
|
1098
|
+
|
|
1099
|
+
const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
|
|
1100
|
+
const eligibleIds = new Set(
|
|
1101
|
+
res.leaves
|
|
1102
|
+
.filter(
|
|
1103
|
+
(r) =>
|
|
1104
|
+
r.status === "Available" &&
|
|
1105
|
+
!r.is_missing_from_operators &&
|
|
1106
|
+
r.reservation_id == null
|
|
1107
|
+
)
|
|
1108
|
+
.map((r) => r.id)
|
|
1109
|
+
);
|
|
1110
|
+
// Every requested leaf must be available and unreserved (and the ids
|
|
1111
|
+
// distinct); otherwise reserve nothing.
|
|
1112
|
+
const matched = new Set(leafIds.filter((id) => eligibleIds.has(id)));
|
|
1113
|
+
if (matched.size !== leafIds.length) {
|
|
1114
|
+
throw new TreeStoreError("NonReservableLeaves");
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const fullLeaves = leafIds.map((id) => leafMap.get(id).data);
|
|
1118
|
+
const reservationId = this._generateId();
|
|
1119
|
+
this._createReservation(
|
|
1120
|
+
reservationsStore,
|
|
1121
|
+
leavesStore,
|
|
1122
|
+
leafMap,
|
|
1123
|
+
reservationId,
|
|
1124
|
+
leafIds,
|
|
1125
|
+
purpose,
|
|
1126
|
+
0
|
|
1127
|
+
);
|
|
1128
|
+
return { id: reservationId, leaves: fullLeaves };
|
|
1129
|
+
}
|
|
1130
|
+
);
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
if (error instanceof TreeStoreError) throw error;
|
|
1133
|
+
throw new TreeStoreError(
|
|
1134
|
+
`Failed to try reserve leaves by ids: ${error.message}`,
|
|
1135
|
+
error
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async trySelectLeaves(targetAmounts) {
|
|
1141
|
+
try {
|
|
1142
|
+
const targetAmount = targetAmounts ? this._totalSats(targetAmounts) : 0;
|
|
1143
|
+
const maxTarget = this._maxTargetForPrefilter(targetAmounts);
|
|
1144
|
+
return await this._txRun(
|
|
1145
|
+
[STORE_LEAVES],
|
|
1146
|
+
"readonly",
|
|
1147
|
+
[{ name: "leaves", store: STORE_LEAVES }],
|
|
1148
|
+
(res) => {
|
|
1149
|
+
const leafMap = new Map(res.leaves.map((r) => [r.id, r]));
|
|
1150
|
+
const slim = this._slimCandidates(this._eligibleSlim(res.leaves), maxTarget);
|
|
1151
|
+
|
|
1152
|
+
const selected = this._selectLeavesByTargetAmounts(slim, targetAmounts);
|
|
1153
|
+
if (selected !== null && selected.length > 0) {
|
|
1154
|
+
return {
|
|
1155
|
+
type: "exact",
|
|
1156
|
+
leaves: selected.map((s) => leafMap.get(s.id).data),
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const minSelected = this._selectLeavesByMinimumAmount(slim, targetAmount);
|
|
1161
|
+
if (minSelected !== null) {
|
|
1162
|
+
return {
|
|
1163
|
+
type: "swapNeeded",
|
|
1164
|
+
leaves: minSelected.map((s) => leafMap.get(s.id).data),
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
return { type: "insufficientFunds" };
|
|
1169
|
+
}
|
|
1170
|
+
);
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
if (error instanceof TreeStoreError) throw error;
|
|
1173
|
+
throw new TreeStoreError(
|
|
1174
|
+
`Failed to try select leaves: ${error.message}`,
|
|
1175
|
+
error
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// ===== Private helpers =====
|
|
1181
|
+
|
|
1182
|
+
_nowMs() {
|
|
1183
|
+
return Date.now();
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
_generateId() {
|
|
1187
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
1188
|
+
return crypto.randomUUID();
|
|
1189
|
+
}
|
|
1190
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
1191
|
+
const r = (Math.random() * 16) | 0;
|
|
1192
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
1193
|
+
return v.toString(16);
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Row shape for the leaf pool. Preserves `reservation_id` from an existing
|
|
1198
|
+
* row; every other field comes from the operators' latest copy. */
|
|
1199
|
+
_leafRow(node, isMissing, existingRow, chainComplete) {
|
|
1200
|
+
return {
|
|
1201
|
+
id: node.id,
|
|
1202
|
+
parent_node_id: node.parent_node_id ?? null,
|
|
1203
|
+
status: node.status,
|
|
1204
|
+
value: node.value,
|
|
1205
|
+
verifying_public_key: node.verifying_public_key,
|
|
1206
|
+
signing_public_key: node.signing_keyshare.public_key,
|
|
1207
|
+
is_missing_from_operators: !!isMissing,
|
|
1208
|
+
reservation_id: existingRow ? existingRow.reservation_id ?? null : null,
|
|
1209
|
+
added_at: this._nowMs(),
|
|
1210
|
+
// Denormalized and indexed so leavesMissingExitChains reads only the ids
|
|
1211
|
+
// it wants, rather than every leaf row. Stored 0/1: IndexedDB rejects
|
|
1212
|
+
// booleans as index keys.
|
|
1213
|
+
chain_complete: (
|
|
1214
|
+
chainComplete !== undefined
|
|
1215
|
+
? chainComplete
|
|
1216
|
+
: this._chainComplete(node, undefined, existingRow)
|
|
1217
|
+
)
|
|
1218
|
+
? 1
|
|
1219
|
+
: 0,
|
|
1220
|
+
data: node,
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Whether `node` has a stored chain reaching its current parent, which is what
|
|
1226
|
+
* makes it exitable without the operators. A stored chain runs from some parent
|
|
1227
|
+
* to a root, so holding the parent the leaf has now means it spans the whole
|
|
1228
|
+
* path. A leaf that is itself a root needs no ancestors. An empty incoming
|
|
1229
|
+
* chain means "unknown", not "none", so it keeps whatever the row already
|
|
1230
|
+
* claimed, unless the leaf has been reparented since: the chain then describes
|
|
1231
|
+
* the parent it had before.
|
|
1232
|
+
*/
|
|
1233
|
+
_chainComplete(node, ancestors, existingRow) {
|
|
1234
|
+
if (node.parent_node_id == null) return true;
|
|
1235
|
+
if (ancestors && ancestors.length > 0) {
|
|
1236
|
+
return ancestors.some((a) => a.id === node.parent_node_id);
|
|
1237
|
+
}
|
|
1238
|
+
if (!existingRow) return false;
|
|
1239
|
+
return (
|
|
1240
|
+
existingRow.parent_node_id === node.parent_node_id &&
|
|
1241
|
+
!!existingRow.chain_complete
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Row shape for an ancestor: no pool metadata (reservation / missing / added_at).
|
|
1246
|
+
* `leaf_id` plus `id` is the key, so the same node stores one row per leaf
|
|
1247
|
+
* that descends from it rather than one row shared by all of them. */
|
|
1248
|
+
_ancestorRow(leafId, node) {
|
|
1249
|
+
return {
|
|
1250
|
+
leaf_id: leafId,
|
|
1251
|
+
id: node.id,
|
|
1252
|
+
parent_node_id: node.parent_node_id ?? null,
|
|
1253
|
+
status: node.status,
|
|
1254
|
+
value: node.value,
|
|
1255
|
+
verifying_public_key: node.verifying_public_key,
|
|
1256
|
+
signing_public_key: node.signing_keyshare.public_key,
|
|
1257
|
+
data: node,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/** Groups ancestor rows (from a full-store read) by the leaf that owns them,
|
|
1262
|
+
* as `Map<leaf_id, Map<id, row>>`, so a leaf's existing rows can be found
|
|
1263
|
+
* and deleted by key without a range scan on the compound `[leaf_id, id]`
|
|
1264
|
+
* keyPath. */
|
|
1265
|
+
_ancestorRowsByLeaf(ancestorRows) {
|
|
1266
|
+
const map = new Map();
|
|
1267
|
+
for (const row of ancestorRows || []) {
|
|
1268
|
+
let byId = map.get(row.leaf_id);
|
|
1269
|
+
if (!byId) {
|
|
1270
|
+
byId = new Map();
|
|
1271
|
+
map.set(row.leaf_id, byId);
|
|
1272
|
+
}
|
|
1273
|
+
byId.set(row.id, row);
|
|
1274
|
+
}
|
|
1275
|
+
return map;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Replaces `leafId`'s ancestor rows wholesale (delete then insert), keeping
|
|
1280
|
+
* `ancestorRowsByLeaf` current so a later step in the same transaction sees
|
|
1281
|
+
* this leaf's live rows rather than a stale snapshot. An empty `nodes` list
|
|
1282
|
+
* is a no-op: it means the chain is unknown, not that the leaf has none.
|
|
1283
|
+
*/
|
|
1284
|
+
_replaceAncestors(ancestorsStore, ancestorRowsByLeaf, leafId, nodes) {
|
|
1285
|
+
if (!nodes || nodes.length === 0) return;
|
|
1286
|
+
|
|
1287
|
+
const ownRows = ancestorRowsByLeaf.get(leafId);
|
|
1288
|
+
if (ownRows) {
|
|
1289
|
+
for (const existingId of ownRows.keys()) {
|
|
1290
|
+
ancestorsStore.delete([leafId, existingId]);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
const newRows = new Map();
|
|
1294
|
+
for (const node of nodes) {
|
|
1295
|
+
const row = this._ancestorRow(leafId, node);
|
|
1296
|
+
ancestorsStore.put(row);
|
|
1297
|
+
newRows.set(node.id, row);
|
|
1298
|
+
}
|
|
1299
|
+
ancestorRowsByLeaf.set(leafId, newRows);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
_createReservation(
|
|
1303
|
+
reservationsStore,
|
|
1304
|
+
leavesStore,
|
|
1305
|
+
leafMap,
|
|
1306
|
+
reservationId,
|
|
1307
|
+
leafIds,
|
|
1308
|
+
purpose,
|
|
1309
|
+
pendingChange
|
|
1310
|
+
) {
|
|
1311
|
+
reservationsStore.put({
|
|
1312
|
+
id: reservationId,
|
|
1313
|
+
purpose,
|
|
1314
|
+
pending_change_amount: pendingChange,
|
|
1315
|
+
created_at: this._nowMs(),
|
|
1316
|
+
});
|
|
1317
|
+
for (const id of leafIds) {
|
|
1318
|
+
const row = leafMap.get(id);
|
|
1319
|
+
if (row) {
|
|
1320
|
+
row.reservation_id = reservationId;
|
|
1321
|
+
leavesStore.put(row);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/** Slim `{id, value}` projection of the leaves eligible for selection. */
|
|
1327
|
+
_eligibleSlim(leafRows) {
|
|
1328
|
+
return leafRows
|
|
1329
|
+
.filter(
|
|
1330
|
+
(r) =>
|
|
1331
|
+
r.status === "Available" &&
|
|
1332
|
+
!r.is_missing_from_operators &&
|
|
1333
|
+
r.reservation_id == null
|
|
1334
|
+
)
|
|
1335
|
+
.map((r) => ({ id: r.id, value: r.value }));
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Prefilter mirroring the SQL slim candidate set: every eligible leaf with
|
|
1340
|
+
* value <= maxTarget, plus the single smallest leaf with value > maxTarget
|
|
1341
|
+
* (the minimum-amount fallback where one larger leaf alone suffices).
|
|
1342
|
+
*/
|
|
1343
|
+
_slimCandidates(eligible, maxTarget) {
|
|
1344
|
+
const small = eligible.filter((l) => l.value <= maxTarget);
|
|
1345
|
+
let smallestBig = null;
|
|
1346
|
+
for (const l of eligible) {
|
|
1347
|
+
if (l.value > maxTarget && (smallestBig === null || l.value < smallestBig.value)) {
|
|
1348
|
+
smallestBig = l;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
return smallestBig ? [...small, smallestBig] : small;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
_maxTargetForPrefilter(targetAmounts) {
|
|
1355
|
+
if (!targetAmounts) return Number.MAX_SAFE_INTEGER;
|
|
1356
|
+
if (targetAmounts.type === "amountAndFee") {
|
|
1357
|
+
return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
|
|
1358
|
+
}
|
|
1359
|
+
if (targetAmounts.type === "exactDenominations") {
|
|
1360
|
+
return targetAmounts.denominations.reduce((m, v) => m + v, 0);
|
|
1361
|
+
}
|
|
1362
|
+
return Number.MAX_SAFE_INTEGER;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
_totalSats(targetAmounts) {
|
|
1366
|
+
if (targetAmounts.type === "amountAndFee") {
|
|
1367
|
+
return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
|
|
1368
|
+
}
|
|
1369
|
+
if (targetAmounts.type === "exactDenominations") {
|
|
1370
|
+
return targetAmounts.denominations.reduce((sum, d) => sum + d, 0);
|
|
1371
|
+
}
|
|
1372
|
+
return 0;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
_selectLeavesByTargetAmounts(leaves, targetAmounts) {
|
|
1376
|
+
if (!targetAmounts) {
|
|
1377
|
+
return [...leaves];
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
if (targetAmounts.type === "amountAndFee") {
|
|
1381
|
+
const amountLeaves = this._selectLeavesByExactAmount(
|
|
1382
|
+
leaves,
|
|
1383
|
+
targetAmounts.amountSats
|
|
1384
|
+
);
|
|
1385
|
+
if (amountLeaves === null) return null;
|
|
1386
|
+
|
|
1387
|
+
if (targetAmounts.feeSats != null && targetAmounts.feeSats > 0) {
|
|
1388
|
+
const amountIds = new Set(amountLeaves.map((l) => l.id));
|
|
1389
|
+
const remaining = leaves.filter((l) => !amountIds.has(l.id));
|
|
1390
|
+
const feeLeaves = this._selectLeavesByExactAmount(
|
|
1391
|
+
remaining,
|
|
1392
|
+
targetAmounts.feeSats
|
|
1393
|
+
);
|
|
1394
|
+
if (feeLeaves === null) return null;
|
|
1395
|
+
return [...amountLeaves, ...feeLeaves];
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
return amountLeaves;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (targetAmounts.type === "exactDenominations") {
|
|
1402
|
+
return this._selectLeavesByExactDenominations(
|
|
1403
|
+
leaves,
|
|
1404
|
+
targetAmounts.denominations
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
return null;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
_selectLeavesByExactAmount(leaves, targetAmount) {
|
|
1412
|
+
if (targetAmount === 0) return null;
|
|
1413
|
+
|
|
1414
|
+
const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
|
|
1415
|
+
if (totalAvailable < targetAmount) return null;
|
|
1416
|
+
|
|
1417
|
+
const single = leaves.find((l) => l.value === targetAmount);
|
|
1418
|
+
if (single) return [single];
|
|
1419
|
+
|
|
1420
|
+
const multipleResult = this._findExactMultipleMatch(leaves, targetAmount);
|
|
1421
|
+
return multipleResult;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
_selectLeavesByExactDenominations(leaves, denominations) {
|
|
1425
|
+
const remaining = [...leaves];
|
|
1426
|
+
const selected = [];
|
|
1427
|
+
|
|
1428
|
+
for (const denomination of denominations) {
|
|
1429
|
+
const idx = remaining.findIndex((l) => l.value === denomination);
|
|
1430
|
+
if (idx === -1) return null;
|
|
1431
|
+
selected.push(remaining[idx]);
|
|
1432
|
+
remaining.splice(idx, 1);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
return selected;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
_selectLeavesByMinimumAmount(leaves, targetAmount) {
|
|
1439
|
+
if (targetAmount === 0) return null;
|
|
1440
|
+
|
|
1441
|
+
const totalAvailable = leaves.reduce((sum, l) => sum + l.value, 0);
|
|
1442
|
+
if (totalAvailable < targetAmount) return null;
|
|
1443
|
+
|
|
1444
|
+
const result = [];
|
|
1445
|
+
let sum = 0;
|
|
1446
|
+
for (const leaf of leaves) {
|
|
1447
|
+
sum += leaf.value;
|
|
1448
|
+
result.push(leaf);
|
|
1449
|
+
if (sum >= targetAmount) break;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
return sum >= targetAmount ? result : null;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
_findExactMultipleMatch(leaves, targetAmount) {
|
|
1456
|
+
if (targetAmount === 0) return [];
|
|
1457
|
+
if (leaves.length === 0) return null;
|
|
1458
|
+
|
|
1459
|
+
const result = this._greedyExactMatch(leaves, targetAmount);
|
|
1460
|
+
if (result) return result;
|
|
1461
|
+
|
|
1462
|
+
const powerOfTwoLeaves = leaves.filter((l) => this._isPowerOfTwo(l.value));
|
|
1463
|
+
if (powerOfTwoLeaves.length === leaves.length) return null;
|
|
1464
|
+
|
|
1465
|
+
return this._greedyExactMatch(powerOfTwoLeaves, targetAmount);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
_greedyExactMatch(leaves, targetAmount) {
|
|
1469
|
+
const sorted = [...leaves].sort((a, b) => b.value - a.value);
|
|
1470
|
+
const result = [];
|
|
1471
|
+
let remaining = targetAmount;
|
|
1472
|
+
|
|
1473
|
+
for (const leaf of sorted) {
|
|
1474
|
+
if (leaf.value > remaining) continue;
|
|
1475
|
+
remaining -= leaf.value;
|
|
1476
|
+
result.push(leaf);
|
|
1477
|
+
if (remaining === 0) return result;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
return null;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
_isPowerOfTwo(value) {
|
|
1484
|
+
return value > 0 && (value & (value - 1)) === 0;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Opens (or creates) the IndexedDB database and returns a ready tree store.
|
|
1490
|
+
*
|
|
1491
|
+
* @param {string} dbName - Database name (one database per SDK instance).
|
|
1492
|
+
* @param {object} [logger] - Optional logger.
|
|
1493
|
+
* @returns {Promise<WebTreeStore>}
|
|
1494
|
+
*/
|
|
1495
|
+
export async function createWebTreeStore(dbName, logger = null) {
|
|
1496
|
+
const store = new WebTreeStore(dbName, logger);
|
|
1497
|
+
await store.initialize();
|
|
1498
|
+
return store;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* Deletes a tree store database. Intended for tests that need a clean database;
|
|
1503
|
+
* production code never calls this.
|
|
1504
|
+
*
|
|
1505
|
+
* @param {string} dbName
|
|
1506
|
+
* @returns {Promise<void>}
|
|
1507
|
+
*/
|
|
1508
|
+
export async function deleteWebTreeStore(dbName) {
|
|
1509
|
+
await new Promise((resolve, reject) => {
|
|
1510
|
+
const req = indexedDB.deleteDatabase(dbName);
|
|
1511
|
+
req.onsuccess = () => resolve();
|
|
1512
|
+
req.onerror = () => reject(req.error);
|
|
1513
|
+
// A stale open connection can block deletion; proceed anyway.
|
|
1514
|
+
req.onblocked = () => resolve();
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
export { WebTreeStore, TreeStoreError };
|