@breeztech/breez-sdk-spark 0.13.10-dev → 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 +33 -0
- package/bundler/breez_sdk_spark_wasm.js +1 -1
- package/bundler/breez_sdk_spark_wasm_bg.js +66 -24
- package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
- package/deno/breez_sdk_spark_wasm.d.ts +33 -0
- package/deno/breez_sdk_spark_wasm.js +66 -24
- package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
- package/nodejs/breez_sdk_spark_wasm.d.ts +33 -0
- package/nodejs/breez_sdk_spark_wasm.js +67 -24
- package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
- package/nodejs/index.js +34 -0
- package/nodejs/index.mjs +1 -0
- 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 +176 -89
- package/nodejs/postgres-token-store/migrations.cjs +92 -3
- package/nodejs/postgres-tree-store/index.cjs +168 -83
- package/nodejs/postgres-tree-store/migrations.cjs +80 -3
- package/package.json +1 -1
- package/ssr/index.js +5 -0
- package/web/breez_sdk_spark_wasm.d.ts +40 -5
- package/web/breez_sdk_spark_wasm.js +66 -24
- package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
|
@@ -20,9 +20,14 @@ class PostgresMigrationManager {
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Run all pending migrations inside a single transaction with an advisory lock.
|
|
23
|
+
*
|
|
23
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 migration so that pre-existing single-tenant data remains
|
|
28
|
+
* readable.
|
|
24
29
|
*/
|
|
25
|
-
async migrate(pool) {
|
|
30
|
+
async migrate(pool, identity) {
|
|
26
31
|
const client = await pool.connect();
|
|
27
32
|
try {
|
|
28
33
|
await client.query("BEGIN");
|
|
@@ -44,7 +49,7 @@ class PostgresMigrationManager {
|
|
|
44
49
|
);
|
|
45
50
|
const currentVersion = versionResult.rows[0].version;
|
|
46
51
|
|
|
47
|
-
const migrations = this._getMigrations();
|
|
52
|
+
const migrations = this._getMigrations(identity);
|
|
48
53
|
|
|
49
54
|
if (currentVersion >= migrations.length) {
|
|
50
55
|
this._log("info", `Database is up to date (version ${currentVersion})`);
|
|
@@ -97,8 +102,27 @@ class PostgresMigrationManager {
|
|
|
97
102
|
* Single migration creating all tables at their final schema.
|
|
98
103
|
* This mirrors the Rust-native PostgresStorage schema but uses camelCase
|
|
99
104
|
* enum values (as produced by the WASM bridge).
|
|
105
|
+
*
|
|
106
|
+
* @param {Buffer|Uint8Array} identity - 33-byte tenant identity. Inlined as
|
|
107
|
+
* a hex BYTEA literal in the multi-tenant scoping migration. Safe because
|
|
108
|
+
* the bytes come from a typed secp256k1 pubkey (character set
|
|
109
|
+
* `[0-9a-f]{66}` after hex encoding) — not user-controlled input.
|
|
100
110
|
*/
|
|
101
|
-
_getMigrations() {
|
|
111
|
+
_getMigrations(identity) {
|
|
112
|
+
const idHex = Buffer.from(identity).toString("hex");
|
|
113
|
+
const idLit = `'\\x${idHex}'::bytea`;
|
|
114
|
+
|
|
115
|
+
// Helper for the per-table backfill: ADD COLUMN nullable -> UPDATE -> SET
|
|
116
|
+
// NOT NULL + drop/recreate PK. Returns an array of statements.
|
|
117
|
+
const scopeTable = (table, pkCols) => [
|
|
118
|
+
`ALTER TABLE ${table} ADD COLUMN user_id BYTEA`,
|
|
119
|
+
`UPDATE ${table} SET user_id = ${idLit}`,
|
|
120
|
+
`ALTER TABLE ${table}
|
|
121
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
122
|
+
DROP CONSTRAINT IF EXISTS ${table}_pkey,
|
|
123
|
+
ADD PRIMARY KEY (user_id, ${pkCols})`,
|
|
124
|
+
];
|
|
125
|
+
|
|
102
126
|
return [
|
|
103
127
|
{
|
|
104
128
|
name: "Create all tables at final schema",
|
|
@@ -252,12 +276,69 @@ class PostgresMigrationManager {
|
|
|
252
276
|
`ALTER TABLE unclaimed_deposits ADD COLUMN is_mature BOOLEAN NOT NULL DEFAULT TRUE`,
|
|
253
277
|
],
|
|
254
278
|
},
|
|
255
|
-
{
|
|
279
|
+
{
|
|
256
280
|
name: "Add conversion_status to payment_metadata",
|
|
257
281
|
sql: [
|
|
258
282
|
`ALTER TABLE payment_metadata ADD COLUMN IF NOT EXISTS conversion_status TEXT`,
|
|
259
283
|
],
|
|
260
284
|
},
|
|
285
|
+
{
|
|
286
|
+
name: "Multi-tenant scoping: add user_id and rewrite primary keys",
|
|
287
|
+
sql: [
|
|
288
|
+
// Per-user tables
|
|
289
|
+
...scopeTable("payments", "id"),
|
|
290
|
+
`DROP INDEX IF EXISTS idx_payments_timestamp`,
|
|
291
|
+
`DROP INDEX IF EXISTS idx_payments_payment_type`,
|
|
292
|
+
`DROP INDEX IF EXISTS idx_payments_status`,
|
|
293
|
+
`CREATE INDEX idx_payments_user_timestamp ON payments(user_id, timestamp)`,
|
|
294
|
+
`CREATE INDEX idx_payments_user_payment_type ON payments(user_id, payment_type)`,
|
|
295
|
+
`CREATE INDEX idx_payments_user_status ON payments(user_id, status)`,
|
|
296
|
+
|
|
297
|
+
...scopeTable("payment_metadata", "payment_id"),
|
|
298
|
+
`DROP INDEX IF EXISTS idx_payment_metadata_parent`,
|
|
299
|
+
`CREATE INDEX idx_payment_metadata_user_parent
|
|
300
|
+
ON payment_metadata(user_id, parent_payment_id)`,
|
|
301
|
+
|
|
302
|
+
...scopeTable("payment_details_lightning", "payment_id"),
|
|
303
|
+
`DROP INDEX IF EXISTS idx_payment_details_lightning_invoice`,
|
|
304
|
+
`DROP INDEX IF EXISTS idx_payment_details_lightning_payment_hash`,
|
|
305
|
+
`CREATE INDEX idx_payment_details_lightning_user_invoice
|
|
306
|
+
ON payment_details_lightning(user_id, invoice)`,
|
|
307
|
+
`CREATE INDEX idx_payment_details_lightning_user_payment_hash
|
|
308
|
+
ON payment_details_lightning(user_id, payment_hash)`,
|
|
309
|
+
|
|
310
|
+
...scopeTable("payment_details_token", "payment_id"),
|
|
311
|
+
...scopeTable("payment_details_spark", "payment_id"),
|
|
312
|
+
...scopeTable("lnurl_receive_metadata", "payment_hash"),
|
|
313
|
+
...scopeTable("unclaimed_deposits", "txid, vout"),
|
|
314
|
+
...scopeTable("contacts", "id"),
|
|
315
|
+
...scopeTable("settings", "key"),
|
|
316
|
+
|
|
317
|
+
// sync_revision: drop the singleton id (CASCADE removes PK + CHECK),
|
|
318
|
+
// then re-key by user_id so each tenant has its own revision row.
|
|
319
|
+
`ALTER TABLE sync_revision DROP COLUMN id CASCADE`,
|
|
320
|
+
`ALTER TABLE sync_revision ADD COLUMN user_id BYTEA`,
|
|
321
|
+
`UPDATE sync_revision SET user_id = ${idLit}`,
|
|
322
|
+
`ALTER TABLE sync_revision
|
|
323
|
+
ALTER COLUMN user_id SET NOT NULL,
|
|
324
|
+
ADD PRIMARY KEY (user_id)`,
|
|
325
|
+
|
|
326
|
+
// sync_outgoing has no PK, only an index — just add user_id and rewrite the index.
|
|
327
|
+
`ALTER TABLE sync_outgoing ADD COLUMN user_id BYTEA`,
|
|
328
|
+
`UPDATE sync_outgoing SET user_id = ${idLit}`,
|
|
329
|
+
`ALTER TABLE sync_outgoing ALTER COLUMN user_id SET NOT NULL`,
|
|
330
|
+
`DROP INDEX IF EXISTS idx_sync_outgoing_data_id_record_type`,
|
|
331
|
+
`CREATE INDEX idx_sync_outgoing_user_record_type_data_id
|
|
332
|
+
ON sync_outgoing(user_id, record_type, data_id)`,
|
|
333
|
+
|
|
334
|
+
...scopeTable("sync_state", "record_type, data_id"),
|
|
335
|
+
|
|
336
|
+
...scopeTable("sync_incoming", "record_type, data_id, revision"),
|
|
337
|
+
`DROP INDEX IF EXISTS idx_sync_incoming_revision`,
|
|
338
|
+
`CREATE INDEX idx_sync_incoming_user_revision
|
|
339
|
+
ON sync_incoming(user_id, revision)`,
|
|
340
|
+
],
|
|
341
|
+
},
|
|
261
342
|
];
|
|
262
343
|
}
|
|
263
344
|
}
|
|
@@ -25,10 +25,10 @@ const { TokenStoreError } = require("./errors.cjs");
|
|
|
25
25
|
const { TokenStoreMigrationManager } = 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 TOKEN_STORE_LOCK_PREFIX = "breez-spark-sdk:token:";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Spent markers are kept for this duration to support multiple SDK instances.
|
|
@@ -42,9 +42,37 @@ const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
42
42
|
*/
|
|
43
43
|
const RESERVATION_TIMEOUT_SECS = 300; // 5 minutes
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Derive a stable per-tenant 64-bit advisory-lock key by hashing a domain
|
|
47
|
+
* prefix together with the identity pubkey and folding the first 8 bytes of
|
|
48
|
+
* the SHA-256 digest into a signed big-endian i64 — the type expected by
|
|
49
|
+
* `pg_advisory_xact_lock(bigint)`. The 64-bit space keeps cross-tenant
|
|
50
|
+
* collisions negligible (~1.2e-10 at 65k tenants).
|
|
51
|
+
*/
|
|
52
|
+
function _identityLockKey(prefix, identity) {
|
|
53
|
+
const crypto = require("crypto");
|
|
54
|
+
const hash = crypto.createHash("sha256");
|
|
55
|
+
hash.update(prefix);
|
|
56
|
+
hash.update(Buffer.from(identity));
|
|
57
|
+
return hash.digest().readBigInt64BE(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
class PostgresTokenStore {
|
|
46
|
-
|
|
61
|
+
/**
|
|
62
|
+
* @param {import('pg').Pool} pool
|
|
63
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
|
|
64
|
+
* identifying the tenant. All reads and writes are scoped by this.
|
|
65
|
+
* @param {object} [logger]
|
|
66
|
+
*/
|
|
67
|
+
constructor(pool, identity, logger = null) {
|
|
68
|
+
if (!identity || identity.length !== 33) {
|
|
69
|
+
throw new TokenStoreError(
|
|
70
|
+
"tenant identity (33-byte secp256k1 pubkey) is required"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
47
73
|
this.pool = pool;
|
|
74
|
+
this.identity = Buffer.from(identity);
|
|
75
|
+
this.lockKey = _identityLockKey(TOKEN_STORE_LOCK_PREFIX, identity);
|
|
48
76
|
this.logger = logger;
|
|
49
77
|
}
|
|
50
78
|
|
|
@@ -54,7 +82,7 @@ class PostgresTokenStore {
|
|
|
54
82
|
async initialize() {
|
|
55
83
|
try {
|
|
56
84
|
const migrationManager = new TokenStoreMigrationManager(this.logger);
|
|
57
|
-
await migrationManager.migrate(this.pool);
|
|
85
|
+
await migrationManager.migrate(this.pool, this.identity);
|
|
58
86
|
return this;
|
|
59
87
|
} catch (error) {
|
|
60
88
|
throw new TokenStoreError(
|
|
@@ -86,7 +114,10 @@ class PostgresTokenStore {
|
|
|
86
114
|
const client = await this.pool.connect();
|
|
87
115
|
try {
|
|
88
116
|
await client.query("BEGIN");
|
|
89
|
-
|
|
117
|
+
// Per-tenant advisory lock: 64-bit key derived from a token-store domain
|
|
118
|
+
// prefix and the tenant identity, so different tenants don't serialize
|
|
119
|
+
// on each other and tree/token locks never collide.
|
|
120
|
+
await client.query("SELECT pg_advisory_xact_lock($1)", [this.lockKey]);
|
|
90
121
|
const result = await fn(client);
|
|
91
122
|
await client.query("COMMIT");
|
|
92
123
|
return result;
|
|
@@ -144,9 +175,16 @@ class PostgresTokenStore {
|
|
|
144
175
|
// Skip if swap is active or completed during this refresh
|
|
145
176
|
const swapCheckResult = await client.query(
|
|
146
177
|
`SELECT
|
|
147
|
-
EXISTS(
|
|
148
|
-
|
|
149
|
-
|
|
178
|
+
EXISTS(
|
|
179
|
+
SELECT 1 FROM token_reservations
|
|
180
|
+
WHERE user_id = $1 AND purpose = 'Swap'
|
|
181
|
+
) AS has_active_swap,
|
|
182
|
+
COALESCE(
|
|
183
|
+
(SELECT last_completed_at >= $2
|
|
184
|
+
FROM token_swap_status WHERE user_id = $1),
|
|
185
|
+
FALSE
|
|
186
|
+
) AS swap_completed`,
|
|
187
|
+
[this.identity, refreshTimestamp]
|
|
150
188
|
);
|
|
151
189
|
const { has_active_swap, swap_completed } = swapCheckResult.rows[0];
|
|
152
190
|
if (has_active_swap || swap_completed) {
|
|
@@ -156,21 +194,21 @@ class PostgresTokenStore {
|
|
|
156
194
|
// Clean up old spent markers
|
|
157
195
|
const cleanupCutoff = new Date(refreshTimestamp.getTime() - SPENT_MARKER_CLEANUP_THRESHOLD_MS);
|
|
158
196
|
await client.query(
|
|
159
|
-
"DELETE FROM token_spent_outputs WHERE spent_at < $
|
|
160
|
-
[cleanupCutoff]
|
|
197
|
+
"DELETE FROM token_spent_outputs WHERE user_id = $1 AND spent_at < $2",
|
|
198
|
+
[this.identity, cleanupCutoff]
|
|
161
199
|
);
|
|
162
200
|
|
|
163
201
|
// Get recent spent output IDs (spent_at >= refresh_timestamp)
|
|
164
202
|
const spentResult = await client.query(
|
|
165
|
-
"SELECT output_id FROM token_spent_outputs WHERE spent_at >= $
|
|
166
|
-
[refreshTimestamp]
|
|
203
|
+
"SELECT output_id FROM token_spent_outputs WHERE user_id = $1 AND spent_at >= $2",
|
|
204
|
+
[this.identity, refreshTimestamp]
|
|
167
205
|
);
|
|
168
206
|
const spentIds = new Set(spentResult.rows.map((r) => r.output_id));
|
|
169
207
|
|
|
170
208
|
// Delete non-reserved outputs added BEFORE the refresh started
|
|
171
209
|
await client.query(
|
|
172
|
-
"DELETE FROM token_outputs WHERE reservation_id IS NULL AND added_at < $
|
|
173
|
-
[refreshTimestamp]
|
|
210
|
+
"DELETE FROM token_outputs WHERE user_id = $1 AND reservation_id IS NULL AND added_at < $2",
|
|
211
|
+
[this.identity, refreshTimestamp]
|
|
174
212
|
);
|
|
175
213
|
|
|
176
214
|
// Build a set of all incoming output IDs for reconciliation
|
|
@@ -185,7 +223,10 @@ class PostgresTokenStore {
|
|
|
185
223
|
const reservedRows = await client.query(
|
|
186
224
|
`SELECT r.id, o.id AS output_id
|
|
187
225
|
FROM token_reservations r
|
|
188
|
-
JOIN token_outputs o
|
|
226
|
+
JOIN token_outputs o
|
|
227
|
+
ON o.reservation_id = r.id AND o.user_id = r.user_id
|
|
228
|
+
WHERE r.user_id = $1`,
|
|
229
|
+
[this.identity]
|
|
189
230
|
);
|
|
190
231
|
|
|
191
232
|
// Group reserved outputs by reservation ID
|
|
@@ -216,51 +257,57 @@ class PostgresTokenStore {
|
|
|
216
257
|
// Delete outputs whose reservations are being removed entirely
|
|
217
258
|
if (reservationsToDelete.length > 0) {
|
|
218
259
|
await client.query(
|
|
219
|
-
"DELETE FROM token_outputs WHERE reservation_id = ANY($
|
|
220
|
-
[reservationsToDelete]
|
|
260
|
+
"DELETE FROM token_outputs WHERE user_id = $1 AND reservation_id = ANY($2)",
|
|
261
|
+
[this.identity, reservationsToDelete]
|
|
221
262
|
);
|
|
222
263
|
await client.query(
|
|
223
|
-
"DELETE FROM token_reservations WHERE id = ANY($
|
|
224
|
-
[reservationsToDelete]
|
|
264
|
+
"DELETE FROM token_reservations WHERE user_id = $1 AND id = ANY($2)",
|
|
265
|
+
[this.identity, reservationsToDelete]
|
|
225
266
|
);
|
|
226
267
|
}
|
|
227
268
|
|
|
228
269
|
// Delete individual reserved outputs that no longer exist
|
|
229
270
|
if (outputsToRemoveFromReservation.length > 0) {
|
|
230
271
|
await client.query(
|
|
231
|
-
"DELETE FROM token_outputs WHERE id = ANY($
|
|
232
|
-
[outputsToRemoveFromReservation]
|
|
272
|
+
"DELETE FROM token_outputs WHERE user_id = $1 AND id = ANY($2)",
|
|
273
|
+
[this.identity, outputsToRemoveFromReservation]
|
|
233
274
|
);
|
|
234
275
|
|
|
235
276
|
// Check if any reservations are now empty
|
|
236
277
|
const emptyReservations = await client.query(
|
|
237
278
|
`SELECT r.id FROM token_reservations r
|
|
238
|
-
LEFT JOIN token_outputs o
|
|
239
|
-
|
|
279
|
+
LEFT JOIN token_outputs o
|
|
280
|
+
ON o.reservation_id = r.id AND o.user_id = r.user_id
|
|
281
|
+
WHERE r.user_id = $1 AND o.id IS NULL`,
|
|
282
|
+
[this.identity]
|
|
240
283
|
);
|
|
241
284
|
const emptyIds = emptyReservations.rows.map((r) => r.id);
|
|
242
285
|
if (emptyIds.length > 0) {
|
|
243
286
|
await client.query(
|
|
244
|
-
"DELETE FROM token_reservations WHERE id = ANY($
|
|
245
|
-
[emptyIds]
|
|
287
|
+
"DELETE FROM token_reservations WHERE user_id = $1 AND id = ANY($2)",
|
|
288
|
+
[this.identity, emptyIds]
|
|
246
289
|
);
|
|
247
290
|
}
|
|
248
291
|
}
|
|
249
292
|
|
|
250
293
|
// Collect IDs of currently reserved outputs (that survived reconciliation)
|
|
251
294
|
const reservedOutputIdsResult = await client.query(
|
|
252
|
-
"SELECT id FROM token_outputs WHERE reservation_id IS NOT NULL"
|
|
295
|
+
"SELECT id FROM token_outputs WHERE user_id = $1 AND reservation_id IS NOT NULL",
|
|
296
|
+
[this.identity]
|
|
253
297
|
);
|
|
254
298
|
const reservedOutputIds = new Set(
|
|
255
299
|
reservedOutputIdsResult.rows.map((r) => r.id)
|
|
256
300
|
);
|
|
257
301
|
|
|
258
|
-
// Delete orphan metadata
|
|
302
|
+
// Delete orphan metadata (per-tenant)
|
|
259
303
|
await client.query(
|
|
260
304
|
`DELETE FROM token_metadata
|
|
261
|
-
WHERE
|
|
262
|
-
|
|
263
|
-
|
|
305
|
+
WHERE user_id = $1
|
|
306
|
+
AND identifier NOT IN (
|
|
307
|
+
SELECT DISTINCT token_identifier
|
|
308
|
+
FROM token_outputs WHERE user_id = $1
|
|
309
|
+
)`,
|
|
310
|
+
[this.identity]
|
|
264
311
|
);
|
|
265
312
|
|
|
266
313
|
// Insert new metadata and outputs, excluding spent and reserved
|
|
@@ -301,7 +348,8 @@ class PostgresTokenStore {
|
|
|
301
348
|
*/
|
|
302
349
|
async getTokenBalances() {
|
|
303
350
|
try {
|
|
304
|
-
const result = await this.pool.query(
|
|
351
|
+
const result = await this.pool.query(
|
|
352
|
+
`
|
|
305
353
|
SELECT m.identifier, m.issuer_public_key, m.name, m.ticker, m.decimals,
|
|
306
354
|
m.max_supply, m.is_freezable, m.creation_entity_public_key,
|
|
307
355
|
COALESCE(SUM(
|
|
@@ -312,11 +360,16 @@ class PostgresTokenStore {
|
|
|
312
360
|
END
|
|
313
361
|
), 0)::text AS balance
|
|
314
362
|
FROM token_metadata m
|
|
315
|
-
JOIN token_outputs o
|
|
316
|
-
|
|
363
|
+
JOIN token_outputs o
|
|
364
|
+
ON o.token_identifier = m.identifier AND o.user_id = m.user_id
|
|
365
|
+
LEFT JOIN token_reservations r
|
|
366
|
+
ON o.reservation_id = r.id AND o.user_id = r.user_id
|
|
367
|
+
WHERE m.user_id = $1
|
|
317
368
|
GROUP BY m.identifier, m.issuer_public_key, m.name, m.ticker,
|
|
318
369
|
m.decimals, m.max_supply, m.is_freezable, m.creation_entity_public_key
|
|
319
|
-
|
|
370
|
+
`,
|
|
371
|
+
[this.identity]
|
|
372
|
+
);
|
|
320
373
|
return result.rows.map((row) => ({
|
|
321
374
|
metadata: {
|
|
322
375
|
identifier: row.identifier,
|
|
@@ -349,9 +402,13 @@ class PostgresTokenStore {
|
|
|
349
402
|
o.prev_tx_hash, o.prev_tx_vout, o.reservation_id,
|
|
350
403
|
r.purpose
|
|
351
404
|
FROM token_metadata m
|
|
352
|
-
LEFT JOIN token_outputs o
|
|
353
|
-
|
|
354
|
-
|
|
405
|
+
LEFT JOIN token_outputs o
|
|
406
|
+
ON o.token_identifier = m.identifier AND o.user_id = m.user_id
|
|
407
|
+
LEFT JOIN token_reservations r
|
|
408
|
+
ON o.reservation_id = r.id AND o.user_id = r.user_id
|
|
409
|
+
WHERE m.user_id = $1
|
|
410
|
+
ORDER BY m.identifier, o.token_amount::NUMERIC ASC`,
|
|
411
|
+
[this.identity]
|
|
355
412
|
);
|
|
356
413
|
|
|
357
414
|
const map = new Map();
|
|
@@ -422,11 +479,13 @@ class PostgresTokenStore {
|
|
|
422
479
|
o.prev_tx_hash, o.prev_tx_vout, o.reservation_id,
|
|
423
480
|
r.purpose
|
|
424
481
|
FROM token_metadata m
|
|
425
|
-
LEFT JOIN token_outputs o
|
|
426
|
-
|
|
427
|
-
|
|
482
|
+
LEFT JOIN token_outputs o
|
|
483
|
+
ON o.token_identifier = m.identifier AND o.user_id = m.user_id
|
|
484
|
+
LEFT JOIN token_reservations r
|
|
485
|
+
ON o.reservation_id = r.id AND o.user_id = r.user_id
|
|
486
|
+
WHERE m.user_id = $2 AND ${whereClause}
|
|
428
487
|
ORDER BY o.token_amount::NUMERIC ASC`,
|
|
429
|
-
[param]
|
|
488
|
+
[param, this.identity]
|
|
430
489
|
);
|
|
431
490
|
|
|
432
491
|
if (result.rows.length === 0) {
|
|
@@ -483,8 +542,8 @@ class PostgresTokenStore {
|
|
|
483
542
|
const outputIds = tokenOutputs.outputs.map((o) => o.output.id);
|
|
484
543
|
if (outputIds.length > 0) {
|
|
485
544
|
await client.query(
|
|
486
|
-
"DELETE FROM token_spent_outputs WHERE output_id = ANY($
|
|
487
|
-
[outputIds]
|
|
545
|
+
"DELETE FROM token_spent_outputs WHERE user_id = $1 AND output_id = ANY($2)",
|
|
546
|
+
[this.identity, outputIds]
|
|
488
547
|
);
|
|
489
548
|
}
|
|
490
549
|
|
|
@@ -544,8 +603,8 @@ class PostgresTokenStore {
|
|
|
544
603
|
|
|
545
604
|
// Get metadata
|
|
546
605
|
const metadataResult = await client.query(
|
|
547
|
-
"SELECT * FROM token_metadata WHERE identifier = $
|
|
548
|
-
[tokenIdentifier]
|
|
606
|
+
"SELECT * FROM token_metadata WHERE user_id = $1 AND identifier = $2",
|
|
607
|
+
[this.identity, tokenIdentifier]
|
|
549
608
|
);
|
|
550
609
|
|
|
551
610
|
if (metadataResult.rows.length === 0) {
|
|
@@ -563,8 +622,10 @@ class PostgresTokenStore {
|
|
|
563
622
|
o.token_public_key, o.token_amount, o.token_identifier,
|
|
564
623
|
o.prev_tx_hash, o.prev_tx_vout
|
|
565
624
|
FROM token_outputs o
|
|
566
|
-
WHERE o.
|
|
567
|
-
|
|
625
|
+
WHERE o.user_id = $1
|
|
626
|
+
AND o.token_identifier = $2
|
|
627
|
+
AND o.reservation_id IS NULL`,
|
|
628
|
+
[this.identity, tokenIdentifier]
|
|
568
629
|
);
|
|
569
630
|
|
|
570
631
|
let outputs = outputRows.rows.map((row) => this._outputFromRow(row));
|
|
@@ -650,16 +711,16 @@ class PostgresTokenStore {
|
|
|
650
711
|
const reservationId = this._generateId();
|
|
651
712
|
|
|
652
713
|
await client.query(
|
|
653
|
-
"INSERT INTO token_reservations (id, purpose) VALUES ($1, $2)",
|
|
654
|
-
[reservationId, purpose]
|
|
714
|
+
"INSERT INTO token_reservations (user_id, id, purpose) VALUES ($1, $2, $3)",
|
|
715
|
+
[this.identity, reservationId, purpose]
|
|
655
716
|
);
|
|
656
717
|
|
|
657
718
|
// Set reservation_id on selected outputs
|
|
658
719
|
const selectedIds = selectedOutputs.map((o) => o.output.id);
|
|
659
720
|
if (selectedIds.length > 0) {
|
|
660
721
|
await client.query(
|
|
661
|
-
"UPDATE token_outputs SET reservation_id = $1 WHERE id = ANY($2)",
|
|
662
|
-
[reservationId, selectedIds]
|
|
722
|
+
"UPDATE token_outputs SET reservation_id = $1 WHERE user_id = $3 AND id = ANY($2)",
|
|
723
|
+
[reservationId, selectedIds, this.identity]
|
|
663
724
|
);
|
|
664
725
|
}
|
|
665
726
|
|
|
@@ -687,16 +748,18 @@ class PostgresTokenStore {
|
|
|
687
748
|
async cancelReservation(id) {
|
|
688
749
|
try {
|
|
689
750
|
await this._withTransaction(async (client) => {
|
|
690
|
-
// Clear reservation_id from outputs
|
|
751
|
+
// Clear reservation_id from outputs first — the composite FK uses NO
|
|
752
|
+
// ACTION (column-list SET NULL is PG15+ and a whole-row SET NULL would
|
|
753
|
+
// null user_id, which is NOT NULL).
|
|
691
754
|
await client.query(
|
|
692
|
-
"UPDATE token_outputs SET reservation_id = NULL WHERE reservation_id = $
|
|
693
|
-
[id]
|
|
755
|
+
"UPDATE token_outputs SET reservation_id = NULL WHERE user_id = $1 AND reservation_id = $2",
|
|
756
|
+
[this.identity, id]
|
|
694
757
|
);
|
|
695
758
|
|
|
696
759
|
// Delete the reservation
|
|
697
760
|
await client.query(
|
|
698
|
-
"DELETE FROM token_reservations WHERE id = $
|
|
699
|
-
[id]
|
|
761
|
+
"DELETE FROM token_reservations WHERE user_id = $1 AND id = $2",
|
|
762
|
+
[this.identity, id]
|
|
700
763
|
);
|
|
701
764
|
});
|
|
702
765
|
} catch (error) {
|
|
@@ -721,8 +784,8 @@ class PostgresTokenStore {
|
|
|
721
784
|
await this._withWriteTransaction(async (client) => {
|
|
722
785
|
// Get reservation purpose
|
|
723
786
|
const reservationResult = await client.query(
|
|
724
|
-
"SELECT purpose FROM token_reservations WHERE id = $
|
|
725
|
-
[id]
|
|
787
|
+
"SELECT purpose FROM token_reservations WHERE user_id = $1 AND id = $2",
|
|
788
|
+
[this.identity, id]
|
|
726
789
|
);
|
|
727
790
|
if (reservationResult.rows.length === 0) {
|
|
728
791
|
return; // Non-existing reservation
|
|
@@ -731,45 +794,53 @@ class PostgresTokenStore {
|
|
|
731
794
|
|
|
732
795
|
// Get reserved output IDs and mark them as spent
|
|
733
796
|
const reservedOutputsResult = await client.query(
|
|
734
|
-
"SELECT id FROM token_outputs WHERE reservation_id = $
|
|
735
|
-
[id]
|
|
797
|
+
"SELECT id FROM token_outputs WHERE user_id = $1 AND reservation_id = $2",
|
|
798
|
+
[this.identity, id]
|
|
736
799
|
);
|
|
737
800
|
const reservedOutputIds = reservedOutputsResult.rows.map((r) => r.id);
|
|
738
801
|
|
|
739
802
|
if (reservedOutputIds.length > 0) {
|
|
740
803
|
await client.query(
|
|
741
|
-
`INSERT INTO token_spent_outputs (output_id)
|
|
742
|
-
SELECT
|
|
804
|
+
`INSERT INTO token_spent_outputs (user_id, output_id)
|
|
805
|
+
SELECT $2, output_id FROM UNNEST($1::text[]) AS t(output_id)
|
|
743
806
|
ON CONFLICT DO NOTHING`,
|
|
744
|
-
[reservedOutputIds]
|
|
807
|
+
[reservedOutputIds, this.identity]
|
|
745
808
|
);
|
|
746
809
|
}
|
|
747
810
|
|
|
748
811
|
// Delete reserved outputs
|
|
749
812
|
await client.query(
|
|
750
|
-
"DELETE FROM token_outputs WHERE reservation_id = $
|
|
751
|
-
[id]
|
|
813
|
+
"DELETE FROM token_outputs WHERE user_id = $1 AND reservation_id = $2",
|
|
814
|
+
[this.identity, id]
|
|
752
815
|
);
|
|
753
816
|
|
|
754
817
|
// Delete the reservation
|
|
755
818
|
await client.query(
|
|
756
|
-
"DELETE FROM token_reservations WHERE id = $
|
|
757
|
-
[id]
|
|
819
|
+
"DELETE FROM token_reservations WHERE user_id = $1 AND id = $2",
|
|
820
|
+
[this.identity, id]
|
|
758
821
|
);
|
|
759
822
|
|
|
760
|
-
// If this was a swap reservation, update last_completed_at
|
|
823
|
+
// If this was a swap reservation, update last_completed_at. UPSERT so a
|
|
824
|
+
// tenant that joined after migration 2 (and thus has no row) gets one.
|
|
761
825
|
if (isSwap) {
|
|
762
826
|
await client.query(
|
|
763
|
-
|
|
827
|
+
`INSERT INTO token_swap_status (user_id, last_completed_at)
|
|
828
|
+
VALUES ($1, NOW())
|
|
829
|
+
ON CONFLICT (user_id) DO UPDATE
|
|
830
|
+
SET last_completed_at = EXCLUDED.last_completed_at`,
|
|
831
|
+
[this.identity]
|
|
764
832
|
);
|
|
765
833
|
}
|
|
766
834
|
|
|
767
|
-
// Clean up orphaned metadata
|
|
835
|
+
// Clean up orphaned metadata (per-tenant)
|
|
768
836
|
await client.query(
|
|
769
837
|
`DELETE FROM token_metadata
|
|
770
|
-
WHERE
|
|
771
|
-
|
|
772
|
-
|
|
838
|
+
WHERE user_id = $1
|
|
839
|
+
AND identifier NOT IN (
|
|
840
|
+
SELECT DISTINCT token_identifier
|
|
841
|
+
FROM token_outputs WHERE user_id = $1
|
|
842
|
+
)`,
|
|
843
|
+
[this.identity]
|
|
773
844
|
);
|
|
774
845
|
});
|
|
775
846
|
} catch (error) {
|
|
@@ -815,15 +886,27 @@ class PostgresTokenStore {
|
|
|
815
886
|
}
|
|
816
887
|
|
|
817
888
|
/**
|
|
818
|
-
* Delete reservations that have exceeded the timeout.
|
|
819
|
-
*
|
|
820
|
-
*
|
|
889
|
+
* Delete reservations that have exceeded the timeout. Releases outputs by
|
|
890
|
+
* clearing reservation_id explicitly, then deletes the parents — the
|
|
891
|
+
* composite FK uses NO ACTION (column-list SET NULL is PG15+ and a
|
|
892
|
+
* whole-row SET NULL would null user_id, NOT NULL).
|
|
821
893
|
*/
|
|
822
894
|
async _cleanupStaleReservations(client) {
|
|
895
|
+
await client.query(
|
|
896
|
+
`UPDATE token_outputs SET reservation_id = NULL
|
|
897
|
+
WHERE user_id = $2
|
|
898
|
+
AND reservation_id IN (
|
|
899
|
+
SELECT id FROM token_reservations
|
|
900
|
+
WHERE user_id = $2
|
|
901
|
+
AND created_at < NOW() - make_interval(secs => $1)
|
|
902
|
+
)`,
|
|
903
|
+
[RESERVATION_TIMEOUT_SECS, this.identity]
|
|
904
|
+
);
|
|
823
905
|
await client.query(
|
|
824
906
|
`DELETE FROM token_reservations
|
|
825
|
-
WHERE
|
|
826
|
-
|
|
907
|
+
WHERE user_id = $2
|
|
908
|
+
AND created_at < NOW() - make_interval(secs => $1)`,
|
|
909
|
+
[RESERVATION_TIMEOUT_SECS, this.identity]
|
|
827
910
|
);
|
|
828
911
|
}
|
|
829
912
|
|
|
@@ -833,10 +916,10 @@ class PostgresTokenStore {
|
|
|
833
916
|
async _upsertMetadata(client, metadata) {
|
|
834
917
|
await client.query(
|
|
835
918
|
`INSERT INTO token_metadata
|
|
836
|
-
(identifier, issuer_public_key, name, ticker, decimals, max_supply,
|
|
919
|
+
(user_id, identifier, issuer_public_key, name, ticker, decimals, max_supply,
|
|
837
920
|
is_freezable, creation_entity_public_key)
|
|
838
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
839
|
-
ON CONFLICT (identifier) DO UPDATE SET
|
|
921
|
+
VALUES ($9, $1, $2, $3, $4, $5, $6, $7, $8)
|
|
922
|
+
ON CONFLICT (user_id, identifier) DO UPDATE SET
|
|
840
923
|
issuer_public_key = EXCLUDED.issuer_public_key,
|
|
841
924
|
name = EXCLUDED.name,
|
|
842
925
|
ticker = EXCLUDED.ticker,
|
|
@@ -853,6 +936,7 @@ class PostgresTokenStore {
|
|
|
853
936
|
metadata.maxSupply,
|
|
854
937
|
metadata.isFreezable,
|
|
855
938
|
metadata.creationEntityPublicKey || null,
|
|
939
|
+
this.identity,
|
|
856
940
|
]
|
|
857
941
|
);
|
|
858
942
|
}
|
|
@@ -863,11 +947,11 @@ class PostgresTokenStore {
|
|
|
863
947
|
async _insertSingleOutput(client, tokenIdentifier, output) {
|
|
864
948
|
await client.query(
|
|
865
949
|
`INSERT INTO token_outputs
|
|
866
|
-
(id, token_identifier, owner_public_key, revocation_commitment,
|
|
950
|
+
(user_id, id, token_identifier, owner_public_key, revocation_commitment,
|
|
867
951
|
withdraw_bond_sats, withdraw_relative_block_locktime,
|
|
868
952
|
token_public_key, token_amount, prev_tx_hash, prev_tx_vout, added_at)
|
|
869
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
|
|
870
|
-
ON CONFLICT (id) DO NOTHING`,
|
|
953
|
+
VALUES ($11, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
|
|
954
|
+
ON CONFLICT (user_id, id) DO NOTHING`,
|
|
871
955
|
[
|
|
872
956
|
output.output.id,
|
|
873
957
|
tokenIdentifier,
|
|
@@ -879,6 +963,7 @@ class PostgresTokenStore {
|
|
|
879
963
|
output.output.tokenAmount,
|
|
880
964
|
output.prevTxHash,
|
|
881
965
|
output.prevTxVout,
|
|
966
|
+
this.identity,
|
|
882
967
|
]
|
|
883
968
|
);
|
|
884
969
|
}
|
|
@@ -930,28 +1015,30 @@ class PostgresTokenStore {
|
|
|
930
1015
|
* @param {number} config.maxPoolSize - Maximum number of connections in the pool
|
|
931
1016
|
* @param {number} config.createTimeoutSecs - Timeout in seconds for establishing a new connection
|
|
932
1017
|
* @param {number} config.recycleTimeoutSecs - Timeout in seconds before recycling an idle connection
|
|
1018
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
|
|
933
1019
|
* @param {object} [logger] - Optional logger
|
|
934
1020
|
* @returns {Promise<PostgresTokenStore>}
|
|
935
1021
|
*/
|
|
936
|
-
async function createPostgresTokenStore(config, logger = null) {
|
|
1022
|
+
async function createPostgresTokenStore(config, identity, logger = null) {
|
|
937
1023
|
const pool = new pg.Pool({
|
|
938
1024
|
connectionString: config.connectionString,
|
|
939
1025
|
max: config.maxPoolSize,
|
|
940
1026
|
connectionTimeoutMillis: config.createTimeoutSecs * 1000,
|
|
941
1027
|
idleTimeoutMillis: config.recycleTimeoutSecs * 1000,
|
|
942
1028
|
});
|
|
943
|
-
return createPostgresTokenStoreWithPool(pool, logger);
|
|
1029
|
+
return createPostgresTokenStoreWithPool(pool, identity, logger);
|
|
944
1030
|
}
|
|
945
1031
|
|
|
946
1032
|
/**
|
|
947
1033
|
* Create a PostgresTokenStore instance from an existing pg.Pool.
|
|
948
1034
|
*
|
|
949
1035
|
* @param {pg.Pool} pool - An existing connection pool
|
|
1036
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey scoping reads/writes
|
|
950
1037
|
* @param {object} [logger] - Optional logger
|
|
951
1038
|
* @returns {Promise<PostgresTokenStore>}
|
|
952
1039
|
*/
|
|
953
|
-
async function createPostgresTokenStoreWithPool(pool, logger = null) {
|
|
954
|
-
const store = new PostgresTokenStore(pool, logger);
|
|
1040
|
+
async function createPostgresTokenStoreWithPool(pool, identity, logger = null) {
|
|
1041
|
+
const store = new PostgresTokenStore(pool, identity, logger);
|
|
955
1042
|
await store.initialize();
|
|
956
1043
|
return store;
|
|
957
1044
|
}
|