@breeztech/breez-sdk-spark 0.13.9-debug → 0.13.11-dev1
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 +1113 -1050
- package/bundler/breez_sdk_spark_wasm.js +5 -1
- package/bundler/breez_sdk_spark_wasm_bg.js +1493 -1628
- package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +14 -6
- package/deno/breez_sdk_spark_wasm.d.ts +1113 -1050
- package/deno/breez_sdk_spark_wasm.js +1394 -1284
- package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +14 -6
- package/nodejs/breez_sdk_spark_wasm.d.ts +1113 -1050
- package/nodejs/breez_sdk_spark_wasm.js +2527 -2654
- package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +14 -6
- package/nodejs/index.js +34 -0
- package/nodejs/index.mjs +5 -4
- package/nodejs/mysql-storage/errors.cjs +19 -0
- package/nodejs/mysql-storage/index.cjs +1366 -0
- package/nodejs/mysql-storage/migrations.cjs +387 -0
- package/nodejs/mysql-storage/package.json +9 -0
- package/nodejs/mysql-token-store/errors.cjs +9 -0
- package/nodejs/mysql-token-store/index.cjs +988 -0
- package/nodejs/mysql-token-store/migrations.cjs +255 -0
- package/nodejs/mysql-token-store/package.json +9 -0
- package/nodejs/mysql-tree-store/errors.cjs +9 -0
- package/nodejs/mysql-tree-store/index.cjs +939 -0
- package/nodejs/mysql-tree-store/migrations.cjs +221 -0
- package/nodejs/mysql-tree-store/package.json +9 -0
- package/nodejs/package.json +3 -0
- package/nodejs/postgres-storage/index.cjs +147 -92
- package/nodejs/postgres-storage/migrations.cjs +85 -4
- package/nodejs/postgres-token-store/index.cjs +186 -101
- package/nodejs/postgres-token-store/migrations.cjs +92 -3
- package/nodejs/postgres-tree-store/index.cjs +177 -93
- package/nodejs/postgres-tree-store/migrations.cjs +80 -3
- package/package.json +1 -1
- package/ssr/index.js +19 -14
- package/web/breez_sdk_spark_wasm.d.ts +1267 -1195
- package/web/breez_sdk_spark_wasm.js +2295 -2169
- package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +14 -6
|
@@ -22,8 +22,16 @@ class TokenStoreMigrationManager {
|
|
|
22
22
|
/**
|
|
23
23
|
* Run all pending migrations inside a single transaction with an advisory lock.
|
|
24
24
|
* @param {import('pg').Pool} pool
|
|
25
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
|
|
26
|
+
* identifying the tenant. Used to backfill `user_id` columns in the
|
|
27
|
+
* multi-tenant scoping migration. Required.
|
|
25
28
|
*/
|
|
26
|
-
async migrate(pool) {
|
|
29
|
+
async migrate(pool, identity) {
|
|
30
|
+
if (!identity || identity.length !== 33) {
|
|
31
|
+
throw new TokenStoreError(
|
|
32
|
+
"tenant identity (33-byte secp256k1 pubkey) is required"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
27
35
|
const client = await pool.connect();
|
|
28
36
|
try {
|
|
29
37
|
await client.query("BEGIN");
|
|
@@ -45,7 +53,7 @@ class TokenStoreMigrationManager {
|
|
|
45
53
|
);
|
|
46
54
|
const currentVersion = versionResult.rows[0].version;
|
|
47
55
|
|
|
48
|
-
const migrations = this._getMigrations();
|
|
56
|
+
const migrations = this._getMigrations(identity);
|
|
49
57
|
|
|
50
58
|
if (currentVersion >= migrations.length) {
|
|
51
59
|
this._log("info", `Token store database is up to date (version ${currentVersion})`);
|
|
@@ -96,8 +104,16 @@ class TokenStoreMigrationManager {
|
|
|
96
104
|
|
|
97
105
|
/**
|
|
98
106
|
* Migrations matching the Rust PostgresTokenStore schema exactly.
|
|
107
|
+
*
|
|
108
|
+
* @param {Buffer|Uint8Array} identity - tenant identity inlined as a hex
|
|
109
|
+
* BYTEA literal in the multi-tenant scoping migration. Safe because the
|
|
110
|
+
* bytes come from a typed secp256k1 pubkey (`[0-9a-f]{66}` after hex
|
|
111
|
+
* encoding) — not user-controlled input.
|
|
99
112
|
*/
|
|
100
|
-
_getMigrations() {
|
|
113
|
+
_getMigrations(identity) {
|
|
114
|
+
const idHex = Buffer.from(identity).toString("hex");
|
|
115
|
+
const idLit = `'\\x${idHex}'::bytea`;
|
|
116
|
+
|
|
101
117
|
return [
|
|
102
118
|
{
|
|
103
119
|
name: "Create token store tables with race condition protection",
|
|
@@ -156,6 +172,79 @@ class TokenStoreMigrationManager {
|
|
|
156
172
|
`INSERT INTO token_swap_status (id) VALUES (1) ON CONFLICT DO NOTHING`,
|
|
157
173
|
],
|
|
158
174
|
},
|
|
175
|
+
{
|
|
176
|
+
// Mirrors Rust migration 2 in spark-postgres/src/token_store.rs.
|
|
177
|
+
// Adds user_id to every token-store table (including token_metadata —
|
|
178
|
+
// per-tenant to avoid 0-balance leakage for tokens a tenant never
|
|
179
|
+
// owned), backfills with the connecting tenant, and rewrites primary
|
|
180
|
+
// keys / FKs / indexes to lead with user_id. Composite FKs use NO
|
|
181
|
+
// ACTION because column-list SET NULL is PG15+ and a whole-row SET
|
|
182
|
+
// NULL would null user_id (NOT NULL).
|
|
183
|
+
name: "Multi-tenant scoping: add user_id and rewrite primary keys",
|
|
184
|
+
sql: [
|
|
185
|
+
// Drop dependent FKs FIRST so we can rebuild parent PKs they
|
|
186
|
+
// reference. Inline `REFERENCES` clauses get auto-named
|
|
187
|
+
// `<table>_<column>_fkey`.
|
|
188
|
+
`ALTER TABLE token_outputs
|
|
189
|
+
DROP CONSTRAINT IF EXISTS token_outputs_reservation_id_fkey`,
|
|
190
|
+
`ALTER TABLE token_outputs
|
|
191
|
+
DROP CONSTRAINT IF EXISTS token_outputs_token_identifier_fkey`,
|
|
192
|
+
|
|
193
|
+
// token_metadata: per-tenant scoping (privacy — see header).
|
|
194
|
+
`ALTER TABLE token_metadata ADD COLUMN user_id BYTEA`,
|
|
195
|
+
`UPDATE token_metadata SET user_id = ${idLit}`,
|
|
196
|
+
`ALTER TABLE token_metadata
|
|
197
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
198
|
+
DROP CONSTRAINT IF EXISTS token_metadata_pkey,
|
|
199
|
+
ADD PRIMARY KEY (user_id, identifier)`,
|
|
200
|
+
`DROP INDEX IF EXISTS idx_token_metadata_issuer_pk`,
|
|
201
|
+
`CREATE INDEX idx_token_metadata_user_issuer_pk
|
|
202
|
+
ON token_metadata (user_id, issuer_public_key)`,
|
|
203
|
+
|
|
204
|
+
// token_reservations: scope by user_id.
|
|
205
|
+
`ALTER TABLE token_reservations ADD COLUMN user_id BYTEA`,
|
|
206
|
+
`UPDATE token_reservations SET user_id = ${idLit}`,
|
|
207
|
+
`ALTER TABLE token_reservations
|
|
208
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
209
|
+
DROP CONSTRAINT IF EXISTS token_reservations_pkey,
|
|
210
|
+
ADD PRIMARY KEY (user_id, id)`,
|
|
211
|
+
|
|
212
|
+
// token_outputs: scope by user_id, rekey, re-add composite FKs.
|
|
213
|
+
`ALTER TABLE token_outputs ADD COLUMN user_id BYTEA`,
|
|
214
|
+
`UPDATE token_outputs SET user_id = ${idLit}`,
|
|
215
|
+
`ALTER TABLE token_outputs
|
|
216
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
217
|
+
DROP CONSTRAINT IF EXISTS token_outputs_pkey,
|
|
218
|
+
ADD PRIMARY KEY (user_id, id),
|
|
219
|
+
ADD FOREIGN KEY (user_id, token_identifier)
|
|
220
|
+
REFERENCES token_metadata(user_id, identifier),
|
|
221
|
+
ADD FOREIGN KEY (user_id, reservation_id)
|
|
222
|
+
REFERENCES token_reservations(user_id, id)`,
|
|
223
|
+
`DROP INDEX IF EXISTS idx_token_outputs_identifier`,
|
|
224
|
+
`DROP INDEX IF EXISTS idx_token_outputs_reservation`,
|
|
225
|
+
`CREATE INDEX idx_token_outputs_user_identifier
|
|
226
|
+
ON token_outputs (user_id, token_identifier)`,
|
|
227
|
+
`CREATE INDEX idx_token_outputs_user_reservation
|
|
228
|
+
ON token_outputs (user_id, reservation_id)
|
|
229
|
+
WHERE reservation_id IS NOT NULL`,
|
|
230
|
+
|
|
231
|
+
// token_spent_outputs: scope by user_id.
|
|
232
|
+
`ALTER TABLE token_spent_outputs ADD COLUMN user_id BYTEA`,
|
|
233
|
+
`UPDATE token_spent_outputs SET user_id = ${idLit}`,
|
|
234
|
+
`ALTER TABLE token_spent_outputs
|
|
235
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
236
|
+
DROP CONSTRAINT IF EXISTS token_spent_outputs_pkey,
|
|
237
|
+
ADD PRIMARY KEY (user_id, output_id)`,
|
|
238
|
+
|
|
239
|
+
// token_swap_status: drop the singleton id, rekey by user_id.
|
|
240
|
+
`ALTER TABLE token_swap_status DROP COLUMN id CASCADE`,
|
|
241
|
+
`ALTER TABLE token_swap_status ADD COLUMN user_id BYTEA`,
|
|
242
|
+
`UPDATE token_swap_status SET user_id = ${idLit}`,
|
|
243
|
+
`ALTER TABLE token_swap_status
|
|
244
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
245
|
+
ADD PRIMARY KEY (user_id)`,
|
|
246
|
+
],
|
|
247
|
+
},
|
|
159
248
|
];
|
|
160
249
|
}
|
|
161
250
|
}
|
|
@@ -25,10 +25,10 @@ const { TreeStoreError } = require("./errors.cjs");
|
|
|
25
25
|
const { TreeStoreMigrationManager } = require("./migrations.cjs");
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Domain prefix mixed into the per-tenant advisory-lock key. Distinct prefixes
|
|
29
|
+
* guarantee that locks from different stores (tree, token, …) never collide.
|
|
30
30
|
*/
|
|
31
|
-
const
|
|
31
|
+
const TREE_STORE_LOCK_PREFIX = "breez-spark-sdk:tree:";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Timeout for reservations in seconds. Reservations older than this are stale.
|
|
@@ -40,9 +40,37 @@ const RESERVATION_TIMEOUT_SECS = 300; // 5 minutes
|
|
|
40
40
|
*/
|
|
41
41
|
const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Derive a stable per-tenant 64-bit advisory-lock key by hashing a domain
|
|
45
|
+
* prefix together with the identity pubkey and folding the first 8 bytes of
|
|
46
|
+
* the SHA-256 digest into a signed big-endian i64 — the type expected by
|
|
47
|
+
* `pg_advisory_xact_lock(bigint)`. The 64-bit space keeps cross-tenant
|
|
48
|
+
* collisions negligible (~1.2e-10 at 65k tenants).
|
|
49
|
+
*/
|
|
50
|
+
function _identityLockKey(prefix, identity) {
|
|
51
|
+
const crypto = require("crypto");
|
|
52
|
+
const hash = crypto.createHash("sha256");
|
|
53
|
+
hash.update(prefix);
|
|
54
|
+
hash.update(Buffer.from(identity));
|
|
55
|
+
return hash.digest().readBigInt64BE(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
class PostgresTreeStore {
|
|
44
|
-
|
|
59
|
+
/**
|
|
60
|
+
* @param {import('pg').Pool} pool
|
|
61
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
|
|
62
|
+
* identifying the tenant. All reads and writes are scoped by this.
|
|
63
|
+
* @param {object} [logger]
|
|
64
|
+
*/
|
|
65
|
+
constructor(pool, identity, logger = null) {
|
|
66
|
+
if (!identity || identity.length !== 33) {
|
|
67
|
+
throw new TreeStoreError(
|
|
68
|
+
"tenant identity (33-byte secp256k1 pubkey) is required"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
45
71
|
this.pool = pool;
|
|
72
|
+
this.identity = Buffer.from(identity);
|
|
73
|
+
this.lockKey = _identityLockKey(TREE_STORE_LOCK_PREFIX, identity);
|
|
46
74
|
this.logger = logger;
|
|
47
75
|
}
|
|
48
76
|
|
|
@@ -52,7 +80,7 @@ class PostgresTreeStore {
|
|
|
52
80
|
async initialize() {
|
|
53
81
|
try {
|
|
54
82
|
const migrationManager = new TreeStoreMigrationManager(this.logger);
|
|
55
|
-
await migrationManager.migrate(this.pool);
|
|
83
|
+
await migrationManager.migrate(this.pool, this.identity);
|
|
56
84
|
return this;
|
|
57
85
|
} catch (error) {
|
|
58
86
|
throw new TreeStoreError(
|
|
@@ -84,7 +112,10 @@ class PostgresTreeStore {
|
|
|
84
112
|
const client = await this.pool.connect();
|
|
85
113
|
try {
|
|
86
114
|
await client.query("BEGIN");
|
|
87
|
-
|
|
115
|
+
// Per-tenant advisory lock: 64-bit key derived from a tree-store domain
|
|
116
|
+
// prefix and the tenant identity, so different tenants don't serialize
|
|
117
|
+
// on each other and tree/token locks never collide.
|
|
118
|
+
await client.query("SELECT pg_advisory_xact_lock($1)", [this.lockKey]);
|
|
88
119
|
const result = await fn(client);
|
|
89
120
|
await client.query("COMMIT");
|
|
90
121
|
return result;
|
|
@@ -99,9 +130,8 @@ class PostgresTreeStore {
|
|
|
99
130
|
/**
|
|
100
131
|
* Run a function inside a transaction without the advisory lock. Used by
|
|
101
132
|
* operations scoped to a single reservation_id (`addLeaves`,
|
|
102
|
-
* `cancelReservation`, `
|
|
103
|
-
*
|
|
104
|
-
* contention.
|
|
133
|
+
* `cancelReservation`, `updateReservation`) where MVCC + row-level locks
|
|
134
|
+
* suffice and the global lock would only add contention.
|
|
105
135
|
* @param {function(import('pg').PoolClient): Promise<T>} fn
|
|
106
136
|
* @returns {Promise<T>}
|
|
107
137
|
* @template T
|
|
@@ -161,14 +191,20 @@ class PostgresTreeStore {
|
|
|
161
191
|
*/
|
|
162
192
|
async getAvailableBalance() {
|
|
163
193
|
try {
|
|
164
|
-
const result = await this.pool.query(
|
|
194
|
+
const result = await this.pool.query(
|
|
195
|
+
`
|
|
165
196
|
SELECT COALESCE(SUM((l.data->>'value')::bigint), 0)::bigint AS balance
|
|
166
197
|
FROM tree_leaves l
|
|
167
|
-
LEFT JOIN tree_reservations r
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
198
|
+
LEFT JOIN tree_reservations r
|
|
199
|
+
ON l.reservation_id = r.id AND l.user_id = r.user_id
|
|
200
|
+
WHERE l.user_id = $1
|
|
201
|
+
AND (
|
|
202
|
+
(l.reservation_id IS NULL AND l.status = 'Available')
|
|
203
|
+
OR r.purpose = 'Swap'
|
|
204
|
+
)
|
|
205
|
+
`,
|
|
206
|
+
[this.identity]
|
|
207
|
+
);
|
|
172
208
|
return BigInt(result.rows[0].balance);
|
|
173
209
|
} catch (error) {
|
|
174
210
|
throw new TreeStoreError(
|
|
@@ -180,12 +216,17 @@ class PostgresTreeStore {
|
|
|
180
216
|
|
|
181
217
|
async getLeaves() {
|
|
182
218
|
try {
|
|
183
|
-
const result = await this.pool.query(
|
|
219
|
+
const result = await this.pool.query(
|
|
220
|
+
`
|
|
184
221
|
SELECT l.id, l.status, l.is_missing_from_operators, l.data,
|
|
185
222
|
l.reservation_id, r.purpose
|
|
186
223
|
FROM tree_leaves l
|
|
187
|
-
LEFT JOIN tree_reservations r
|
|
188
|
-
|
|
224
|
+
LEFT JOIN tree_reservations r
|
|
225
|
+
ON l.reservation_id = r.id AND l.user_id = r.user_id
|
|
226
|
+
WHERE l.user_id = $1
|
|
227
|
+
`,
|
|
228
|
+
[this.identity]
|
|
229
|
+
);
|
|
189
230
|
|
|
190
231
|
const available = [];
|
|
191
232
|
const notAvailable = [];
|
|
@@ -247,11 +288,21 @@ class PostgresTreeStore {
|
|
|
247
288
|
await this._cleanupStaleReservations(client);
|
|
248
289
|
|
|
249
290
|
// Check for active swap or swap completed during refresh
|
|
250
|
-
const swapCheckResult = await client.query(
|
|
291
|
+
const swapCheckResult = await client.query(
|
|
292
|
+
`
|
|
251
293
|
SELECT
|
|
252
|
-
EXISTS(
|
|
253
|
-
|
|
254
|
-
|
|
294
|
+
EXISTS(
|
|
295
|
+
SELECT 1 FROM tree_reservations
|
|
296
|
+
WHERE user_id = $1 AND purpose = 'Swap'
|
|
297
|
+
) AS has_active_swap,
|
|
298
|
+
COALESCE(
|
|
299
|
+
(SELECT last_completed_at >= $2
|
|
300
|
+
FROM tree_swap_status WHERE user_id = $1),
|
|
301
|
+
FALSE
|
|
302
|
+
) AS swap_completed_during_refresh
|
|
303
|
+
`,
|
|
304
|
+
[this.identity, refreshTimestamp]
|
|
305
|
+
);
|
|
255
306
|
|
|
256
307
|
const { has_active_swap, swap_completed_during_refresh } = swapCheckResult.rows[0];
|
|
257
308
|
|
|
@@ -263,18 +314,18 @@ class PostgresTreeStore {
|
|
|
263
314
|
await this._cleanupSpentMarkers(client, refreshTimestamp);
|
|
264
315
|
|
|
265
316
|
const spentResult = await client.query(
|
|
266
|
-
"SELECT leaf_id FROM tree_spent_leaves WHERE spent_at >= $
|
|
267
|
-
[refreshTimestamp]
|
|
317
|
+
"SELECT leaf_id FROM tree_spent_leaves WHERE user_id = $1 AND spent_at >= $2",
|
|
318
|
+
[this.identity, refreshTimestamp]
|
|
268
319
|
);
|
|
269
320
|
const spentIds = new Set(spentResult.rows.map((r) => r.leaf_id));
|
|
270
321
|
|
|
271
322
|
// Delete non-reserved leaves added before refresh started.
|
|
272
|
-
// Includes leaves released earlier in this transaction by
|
|
273
|
-
// (
|
|
274
|
-
//
|
|
323
|
+
// Includes leaves released earlier in this transaction by
|
|
324
|
+
// _cleanupStaleReservations (which now NULLs reservation_id explicitly,
|
|
325
|
+
// since the composite FK uses NO ACTION).
|
|
275
326
|
await client.query(
|
|
276
|
-
"DELETE FROM tree_leaves WHERE reservation_id IS NULL AND added_at < $
|
|
277
|
-
[refreshTimestamp]
|
|
327
|
+
"DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id IS NULL AND added_at < $2",
|
|
328
|
+
[this.identity, refreshTimestamp]
|
|
278
329
|
);
|
|
279
330
|
|
|
280
331
|
// Upsert all leaves (filtering spent)
|
|
@@ -307,8 +358,8 @@ class PostgresTreeStore {
|
|
|
307
358
|
try {
|
|
308
359
|
await this._withTransaction(async (client) => {
|
|
309
360
|
const res = await client.query(
|
|
310
|
-
"SELECT id FROM tree_reservations WHERE id = $
|
|
311
|
-
[id]
|
|
361
|
+
"SELECT id FROM tree_reservations WHERE user_id = $1 AND id = $2",
|
|
362
|
+
[this.identity, id]
|
|
312
363
|
);
|
|
313
364
|
|
|
314
365
|
if (res.rows.length === 0) {
|
|
@@ -316,13 +367,13 @@ class PostgresTreeStore {
|
|
|
316
367
|
}
|
|
317
368
|
|
|
318
369
|
await client.query(
|
|
319
|
-
"DELETE FROM tree_leaves WHERE reservation_id = $
|
|
320
|
-
[id]
|
|
370
|
+
"DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
|
|
371
|
+
[this.identity, id]
|
|
321
372
|
);
|
|
322
373
|
|
|
323
374
|
await client.query(
|
|
324
|
-
"DELETE FROM tree_reservations WHERE id = $
|
|
325
|
-
[id]
|
|
375
|
+
"DELETE FROM tree_reservations WHERE user_id = $1 AND id = $2",
|
|
376
|
+
[this.identity, id]
|
|
326
377
|
);
|
|
327
378
|
|
|
328
379
|
if (leavesToKeep && leavesToKeep.length > 0) {
|
|
@@ -345,11 +396,15 @@ class PostgresTreeStore {
|
|
|
345
396
|
*/
|
|
346
397
|
async finalizeReservation(id, newLeaves) {
|
|
347
398
|
try {
|
|
348
|
-
|
|
399
|
+
// _withWriteTransaction acquires the advisory lock so this serializes
|
|
400
|
+
// against `setLeaves`. Without it, a concurrent setLeaves could read
|
|
401
|
+
// tree_spent_leaves before our marker commits and re-insert the
|
|
402
|
+
// just-spent leaf as Available.
|
|
403
|
+
await this._withWriteTransaction(async (client) => {
|
|
349
404
|
// Check if reservation exists and get purpose
|
|
350
405
|
const res = await client.query(
|
|
351
|
-
"SELECT id, purpose FROM tree_reservations WHERE id = $
|
|
352
|
-
[id]
|
|
406
|
+
"SELECT id, purpose FROM tree_reservations WHERE user_id = $1 AND id = $2",
|
|
407
|
+
[this.identity, id]
|
|
353
408
|
);
|
|
354
409
|
|
|
355
410
|
let isSwap = false;
|
|
@@ -357,18 +412,18 @@ class PostgresTreeStore {
|
|
|
357
412
|
if (res.rows.length > 0) {
|
|
358
413
|
isSwap = res.rows[0].purpose === "Swap";
|
|
359
414
|
const leafResult = await client.query(
|
|
360
|
-
"SELECT id FROM tree_leaves WHERE reservation_id = $
|
|
361
|
-
[id]
|
|
415
|
+
"SELECT id FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
|
|
416
|
+
[this.identity, id]
|
|
362
417
|
);
|
|
363
418
|
reservedLeafIds = leafResult.rows.map((r) => r.id);
|
|
364
419
|
await this._batchInsertSpentLeaves(client, reservedLeafIds);
|
|
365
420
|
await client.query(
|
|
366
|
-
"DELETE FROM tree_leaves WHERE reservation_id = $
|
|
367
|
-
[id]
|
|
421
|
+
"DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
|
|
422
|
+
[this.identity, id]
|
|
368
423
|
);
|
|
369
424
|
await client.query(
|
|
370
|
-
"DELETE FROM tree_reservations WHERE id = $
|
|
371
|
-
[id]
|
|
425
|
+
"DELETE FROM tree_reservations WHERE user_id = $1 AND id = $2",
|
|
426
|
+
[this.identity, id]
|
|
372
427
|
);
|
|
373
428
|
}
|
|
374
429
|
|
|
@@ -377,10 +432,15 @@ class PostgresTreeStore {
|
|
|
377
432
|
await this._batchUpsertLeaves(client, newLeaves, false, null);
|
|
378
433
|
}
|
|
379
434
|
|
|
380
|
-
// If swap with new leaves, update last_completed_at
|
|
435
|
+
// If swap with new leaves, update last_completed_at. UPSERT so a tenant
|
|
436
|
+
// that joined after migration 3 (and thus has no row) gets one created.
|
|
381
437
|
if (isSwap && newLeaves && newLeaves.length > 0) {
|
|
382
438
|
await client.query(
|
|
383
|
-
|
|
439
|
+
`INSERT INTO tree_swap_status (user_id, last_completed_at)
|
|
440
|
+
VALUES ($1, NOW())
|
|
441
|
+
ON CONFLICT (user_id) DO UPDATE
|
|
442
|
+
SET last_completed_at = EXCLUDED.last_completed_at`,
|
|
443
|
+
[this.identity]
|
|
384
444
|
);
|
|
385
445
|
}
|
|
386
446
|
});
|
|
@@ -409,13 +469,17 @@ class PostgresTreeStore {
|
|
|
409
469
|
// True total available, computed server-side over ALL eligible leaves.
|
|
410
470
|
// Required for the WaitForPending decision below — must NOT be derived
|
|
411
471
|
// from the prefiltered set since the prefilter may exclude big leaves.
|
|
412
|
-
const totalResult = await client.query(
|
|
472
|
+
const totalResult = await client.query(
|
|
473
|
+
`
|
|
413
474
|
SELECT COALESCE(SUM((data->>'value')::bigint), 0)::bigint AS total
|
|
414
475
|
FROM tree_leaves
|
|
415
|
-
WHERE
|
|
476
|
+
WHERE user_id = $1
|
|
477
|
+
AND status = 'Available'
|
|
416
478
|
AND is_missing_from_operators = FALSE
|
|
417
479
|
AND reservation_id IS NULL
|
|
418
|
-
|
|
480
|
+
`,
|
|
481
|
+
[this.identity]
|
|
482
|
+
);
|
|
419
483
|
const available = Number(totalResult.rows[0].total);
|
|
420
484
|
|
|
421
485
|
// Slim projection: only (id, value) for leaves the selection might use.
|
|
@@ -423,25 +487,30 @@ class PostgresTreeStore {
|
|
|
423
487
|
// small-leaf accumulators for the minimum-amount path) plus the single
|
|
424
488
|
// smallest leaf with value > maxTarget (covers the minimum-amount
|
|
425
489
|
// fallback case where one larger leaf is sufficient).
|
|
426
|
-
const slimResult = await client.query(
|
|
490
|
+
const slimResult = await client.query(
|
|
491
|
+
`
|
|
427
492
|
SELECT id, (data->>'value')::bigint AS value
|
|
428
493
|
FROM tree_leaves
|
|
429
|
-
WHERE
|
|
494
|
+
WHERE user_id = $1
|
|
495
|
+
AND status = 'Available'
|
|
430
496
|
AND is_missing_from_operators = FALSE
|
|
431
497
|
AND reservation_id IS NULL
|
|
432
498
|
AND (
|
|
433
|
-
(data->>'value')::bigint <= $
|
|
499
|
+
(data->>'value')::bigint <= $2
|
|
434
500
|
OR id = (
|
|
435
501
|
SELECT id FROM tree_leaves
|
|
436
|
-
WHERE
|
|
502
|
+
WHERE user_id = $1
|
|
503
|
+
AND status = 'Available'
|
|
437
504
|
AND is_missing_from_operators = FALSE
|
|
438
505
|
AND reservation_id IS NULL
|
|
439
|
-
AND (data->>'value')::bigint > $
|
|
506
|
+
AND (data->>'value')::bigint > $2
|
|
440
507
|
ORDER BY (data->>'value')::bigint
|
|
441
508
|
LIMIT 1
|
|
442
509
|
)
|
|
443
510
|
)
|
|
444
|
-
`,
|
|
511
|
+
`,
|
|
512
|
+
[this.identity, maxTarget]
|
|
513
|
+
);
|
|
445
514
|
|
|
446
515
|
const slimLeaves = slimResult.rows.map((r) => ({
|
|
447
516
|
id: r.id,
|
|
@@ -522,17 +591,13 @@ class PostgresTreeStore {
|
|
|
522
591
|
}
|
|
523
592
|
}
|
|
524
593
|
|
|
525
|
-
/**
|
|
526
|
-
* Largest single value the selection algorithm could possibly need.
|
|
527
|
-
* For an unbounded target we have to return all leaves (no prefilter).
|
|
528
|
-
*/
|
|
529
594
|
_maxTargetForPrefilter(targetAmounts) {
|
|
530
595
|
if (!targetAmounts) return Number.MAX_SAFE_INTEGER;
|
|
531
596
|
if (targetAmounts.type === "amountAndFee") {
|
|
532
|
-
return
|
|
597
|
+
return targetAmounts.amountSats + (targetAmounts.feeSats || 0);
|
|
533
598
|
}
|
|
534
599
|
if (targetAmounts.type === "exactDenominations") {
|
|
535
|
-
return targetAmounts.denominations.reduce((m, v) =>
|
|
600
|
+
return targetAmounts.denominations.reduce((m, v) => m + v, 0);
|
|
536
601
|
}
|
|
537
602
|
return Number.MAX_SAFE_INTEGER;
|
|
538
603
|
}
|
|
@@ -544,8 +609,8 @@ class PostgresTreeStore {
|
|
|
544
609
|
async _fetchFullLeavesByIds(client, ids) {
|
|
545
610
|
if (!ids || ids.length === 0) return [];
|
|
546
611
|
const result = await client.query(
|
|
547
|
-
"SELECT data FROM tree_leaves WHERE id = ANY($1)",
|
|
548
|
-
[ids]
|
|
612
|
+
"SELECT data FROM tree_leaves WHERE user_id = $2 AND id = ANY($1)",
|
|
613
|
+
[ids, this.identity]
|
|
549
614
|
);
|
|
550
615
|
return result.rows.map((r) => r.data);
|
|
551
616
|
}
|
|
@@ -578,8 +643,8 @@ class PostgresTreeStore {
|
|
|
578
643
|
return await this._withTransaction(async (client) => {
|
|
579
644
|
// Check if reservation exists
|
|
580
645
|
const res = await client.query(
|
|
581
|
-
"SELECT id FROM tree_reservations WHERE id = $
|
|
582
|
-
[reservationId]
|
|
646
|
+
"SELECT id FROM tree_reservations WHERE user_id = $1 AND id = $2",
|
|
647
|
+
[this.identity, reservationId]
|
|
583
648
|
);
|
|
584
649
|
|
|
585
650
|
if (res.rows.length === 0) {
|
|
@@ -588,15 +653,15 @@ class PostgresTreeStore {
|
|
|
588
653
|
|
|
589
654
|
// Get old reserved leaf IDs and mark as spent
|
|
590
655
|
const oldLeavesResult = await client.query(
|
|
591
|
-
"SELECT id FROM tree_leaves WHERE reservation_id = $
|
|
592
|
-
[reservationId]
|
|
656
|
+
"SELECT id FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
|
|
657
|
+
[this.identity, reservationId]
|
|
593
658
|
);
|
|
594
659
|
const oldLeafIds = oldLeavesResult.rows.map((r) => r.id);
|
|
595
660
|
|
|
596
661
|
await this._batchInsertSpentLeaves(client, oldLeafIds);
|
|
597
662
|
await client.query(
|
|
598
|
-
"DELETE FROM tree_leaves WHERE reservation_id = $
|
|
599
|
-
[reservationId]
|
|
663
|
+
"DELETE FROM tree_leaves WHERE user_id = $1 AND reservation_id = $2",
|
|
664
|
+
[this.identity, reservationId]
|
|
600
665
|
);
|
|
601
666
|
|
|
602
667
|
// Upsert change leaves to available pool
|
|
@@ -611,8 +676,8 @@ class PostgresTreeStore {
|
|
|
611
676
|
|
|
612
677
|
// Clear pending change amount
|
|
613
678
|
await client.query(
|
|
614
|
-
"UPDATE tree_reservations SET pending_change_amount = 0 WHERE id = $
|
|
615
|
-
[reservationId]
|
|
679
|
+
"UPDATE tree_reservations SET pending_change_amount = 0 WHERE user_id = $1 AND id = $2",
|
|
680
|
+
[this.identity, reservationId]
|
|
616
681
|
);
|
|
617
682
|
|
|
618
683
|
return {
|
|
@@ -794,7 +859,8 @@ class PostgresTreeStore {
|
|
|
794
859
|
*/
|
|
795
860
|
async _calculatePendingBalance(client) {
|
|
796
861
|
const result = await client.query(
|
|
797
|
-
"SELECT COALESCE(SUM(pending_change_amount), 0)::BIGINT AS pending FROM tree_reservations"
|
|
862
|
+
"SELECT COALESCE(SUM(pending_change_amount), 0)::BIGINT AS pending FROM tree_reservations WHERE user_id = $1",
|
|
863
|
+
[this.identity]
|
|
798
864
|
);
|
|
799
865
|
return Number(result.rows[0].pending);
|
|
800
866
|
}
|
|
@@ -804,8 +870,8 @@ class PostgresTreeStore {
|
|
|
804
870
|
*/
|
|
805
871
|
async _createReservation(client, reservationId, leaves, purpose, pendingChange) {
|
|
806
872
|
await client.query(
|
|
807
|
-
"INSERT INTO tree_reservations (id, purpose, pending_change_amount) VALUES ($1, $2, $3)",
|
|
808
|
-
[reservationId, purpose, pendingChange]
|
|
873
|
+
"INSERT INTO tree_reservations (user_id, id, purpose, pending_change_amount) VALUES ($1, $2, $3, $4)",
|
|
874
|
+
[this.identity, reservationId, purpose, pendingChange]
|
|
809
875
|
);
|
|
810
876
|
|
|
811
877
|
const leafIds = leaves.map((l) => l.id);
|
|
@@ -830,16 +896,16 @@ class PostgresTreeStore {
|
|
|
830
896
|
const dataValues = filtered.map((l) => JSON.stringify(l));
|
|
831
897
|
|
|
832
898
|
await client.query(
|
|
833
|
-
`INSERT INTO tree_leaves (id, status, is_missing_from_operators, data, added_at)
|
|
834
|
-
SELECT id, status, missing, data::jsonb, NOW()
|
|
899
|
+
`INSERT INTO tree_leaves (user_id, id, status, is_missing_from_operators, data, added_at)
|
|
900
|
+
SELECT $5, id, status, missing, data::jsonb, NOW()
|
|
835
901
|
FROM UNNEST($1::text[], $2::text[], $3::bool[], $4::text[])
|
|
836
902
|
AS t(id, status, missing, data)
|
|
837
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
903
|
+
ON CONFLICT (user_id, id) DO UPDATE SET
|
|
838
904
|
status = EXCLUDED.status,
|
|
839
905
|
is_missing_from_operators = EXCLUDED.is_missing_from_operators,
|
|
840
906
|
data = EXCLUDED.data,
|
|
841
907
|
added_at = NOW()`,
|
|
842
|
-
[ids, statuses, missingFlags, dataValues]
|
|
908
|
+
[ids, statuses, missingFlags, dataValues, this.identity]
|
|
843
909
|
);
|
|
844
910
|
}
|
|
845
911
|
|
|
@@ -850,8 +916,8 @@ class PostgresTreeStore {
|
|
|
850
916
|
if (leafIds.length === 0) return;
|
|
851
917
|
|
|
852
918
|
await client.query(
|
|
853
|
-
"UPDATE tree_leaves SET reservation_id = $1 WHERE id = ANY($2)",
|
|
854
|
-
[reservationId, leafIds]
|
|
919
|
+
"UPDATE tree_leaves SET reservation_id = $1 WHERE user_id = $3 AND id = ANY($2)",
|
|
920
|
+
[reservationId, leafIds, this.identity]
|
|
855
921
|
);
|
|
856
922
|
}
|
|
857
923
|
|
|
@@ -862,8 +928,10 @@ class PostgresTreeStore {
|
|
|
862
928
|
if (leafIds.length === 0) return;
|
|
863
929
|
|
|
864
930
|
await client.query(
|
|
865
|
-
|
|
866
|
-
|
|
931
|
+
`INSERT INTO tree_spent_leaves (user_id, leaf_id)
|
|
932
|
+
SELECT $2, leaf_id FROM UNNEST($1::text[]) AS t(leaf_id)
|
|
933
|
+
ON CONFLICT DO NOTHING`,
|
|
934
|
+
[leafIds, this.identity]
|
|
867
935
|
);
|
|
868
936
|
}
|
|
869
937
|
|
|
@@ -874,19 +942,33 @@ class PostgresTreeStore {
|
|
|
874
942
|
if (leafIds.length === 0) return;
|
|
875
943
|
|
|
876
944
|
await client.query(
|
|
877
|
-
"DELETE FROM tree_spent_leaves WHERE leaf_id = ANY($1)",
|
|
878
|
-
[leafIds]
|
|
945
|
+
"DELETE FROM tree_spent_leaves WHERE user_id = $2 AND leaf_id = ANY($1)",
|
|
946
|
+
[leafIds, this.identity]
|
|
879
947
|
);
|
|
880
948
|
}
|
|
881
949
|
|
|
882
950
|
/**
|
|
883
|
-
* Clean up stale reservations.
|
|
951
|
+
* Clean up stale reservations. Releases the leaves by clearing their
|
|
952
|
+
* reservation_id first, then deletes the parent reservations — the composite
|
|
953
|
+
* FK uses NO ACTION (the default) since column-list SET NULL is PG15+ and a
|
|
954
|
+
* whole-row SET NULL would null user_id (NOT NULL).
|
|
884
955
|
*/
|
|
885
956
|
async _cleanupStaleReservations(client) {
|
|
957
|
+
await client.query(
|
|
958
|
+
`UPDATE tree_leaves SET reservation_id = NULL
|
|
959
|
+
WHERE user_id = $2
|
|
960
|
+
AND reservation_id IN (
|
|
961
|
+
SELECT id FROM tree_reservations
|
|
962
|
+
WHERE user_id = $2
|
|
963
|
+
AND created_at < NOW() - make_interval(secs => $1)
|
|
964
|
+
)`,
|
|
965
|
+
[RESERVATION_TIMEOUT_SECS, this.identity]
|
|
966
|
+
);
|
|
886
967
|
await client.query(
|
|
887
968
|
`DELETE FROM tree_reservations
|
|
888
|
-
WHERE
|
|
889
|
-
|
|
969
|
+
WHERE user_id = $2
|
|
970
|
+
AND created_at < NOW() - make_interval(secs => $1)`,
|
|
971
|
+
[RESERVATION_TIMEOUT_SECS, this.identity]
|
|
890
972
|
);
|
|
891
973
|
}
|
|
892
974
|
|
|
@@ -898,8 +980,8 @@ class PostgresTreeStore {
|
|
|
898
980
|
const cleanupCutoff = new Date(refreshTimestamp.getTime() - thresholdMs);
|
|
899
981
|
|
|
900
982
|
await client.query(
|
|
901
|
-
"DELETE FROM tree_spent_leaves WHERE spent_at < $1",
|
|
902
|
-
[cleanupCutoff]
|
|
983
|
+
"DELETE FROM tree_spent_leaves WHERE user_id = $2 AND spent_at < $1",
|
|
984
|
+
[cleanupCutoff, this.identity]
|
|
903
985
|
);
|
|
904
986
|
}
|
|
905
987
|
}
|
|
@@ -912,28 +994,30 @@ class PostgresTreeStore {
|
|
|
912
994
|
* @param {number} config.maxPoolSize - Maximum number of connections in the pool
|
|
913
995
|
* @param {number} config.createTimeoutSecs - Timeout in seconds for establishing a new connection
|
|
914
996
|
* @param {number} config.recycleTimeoutSecs - Timeout in seconds before recycling an idle connection
|
|
997
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
|
|
915
998
|
* @param {object} [logger] - Optional logger
|
|
916
999
|
* @returns {Promise<PostgresTreeStore>}
|
|
917
1000
|
*/
|
|
918
|
-
async function createPostgresTreeStore(config, logger = null) {
|
|
1001
|
+
async function createPostgresTreeStore(config, identity, logger = null) {
|
|
919
1002
|
const pool = new pg.Pool({
|
|
920
1003
|
connectionString: config.connectionString,
|
|
921
1004
|
max: config.maxPoolSize,
|
|
922
1005
|
connectionTimeoutMillis: config.createTimeoutSecs * 1000,
|
|
923
1006
|
idleTimeoutMillis: config.recycleTimeoutSecs * 1000,
|
|
924
1007
|
});
|
|
925
|
-
return createPostgresTreeStoreWithPool(pool, logger);
|
|
1008
|
+
return createPostgresTreeStoreWithPool(pool, identity, logger);
|
|
926
1009
|
}
|
|
927
1010
|
|
|
928
1011
|
/**
|
|
929
1012
|
* Create a PostgresTreeStore instance from an existing pg.Pool.
|
|
930
1013
|
*
|
|
931
1014
|
* @param {pg.Pool} pool - An existing connection pool
|
|
1015
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
|
|
932
1016
|
* @param {object} [logger] - Optional logger
|
|
933
1017
|
* @returns {Promise<PostgresTreeStore>}
|
|
934
1018
|
*/
|
|
935
|
-
async function createPostgresTreeStoreWithPool(pool, logger = null) {
|
|
936
|
-
const store = new PostgresTreeStore(pool, logger);
|
|
1019
|
+
async function createPostgresTreeStoreWithPool(pool, identity, logger = null) {
|
|
1020
|
+
const store = new PostgresTreeStore(pool, identity, logger);
|
|
937
1021
|
await store.initialize();
|
|
938
1022
|
return store;
|
|
939
1023
|
}
|