@1sat/wallet-node 0.0.38 → 0.0.40

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.
@@ -1,57 +1,2634 @@
1
1
  /**
2
- * StoragePg — production Postgres backend for `@1sat/wallet-node` wallets.
2
+ * StoragePg — native Postgres backend for `@1sat/wallet-node` wallets.
3
3
  *
4
- * Thin subclass of wallet-toolbox's `StorageKnex` (which already handles the
5
- * full schema, migrations, and every `WalletStorageProvider` method over
6
- * knex/pg). The only addition is `measureUsedBytes(userId)`, the accounts
7
- * metering helper that the `@1sat/wallet-server` gate middleware casts for
8
- * via `StorageWithFinders`.
4
+ * Drop-in replacement for StorageKnex that uses `pg` directly, eliminating
5
+ * the knex + wallet-toolbox StorageKnex dependency chain. Extends
6
+ * StorageProvider (same base as StorageBunSqlite) so it can be used anywhere
7
+ * a WalletStorageProvider is expected.
9
8
  *
10
- * Shape matches `StorageBunSqlite.measureUsedBytes` summing bytes across
11
- * `transactions`, `proven_txs`, `proven_tx_reqs`, and `outputs` for a single
12
- * userId. Pg's `OCTET_LENGTH(bytea)` replaces sqlite's `LENGTH(blob)`.
9
+ * Structure mirrors StorageBunSqlite method-for-method. Schema is the same
10
+ * wallet-toolbox layout translated to Postgres types. Booleans stay as
11
+ * SMALLINT 0/1 to match the validate-helpers' coercion pattern exactly.
13
12
  */
14
- import { StorageKnex, } from '@bsv/wallet-toolbox/out/src/storage/StorageKnex.js';
15
- export class StoragePg extends StorageKnex {
13
+ import { Beef, Transaction as BsvTransaction } from '@bsv/sdk';
14
+ import { WERR_UNAUTHORIZED } from '@bsv/wallet-toolbox/out/src/sdk/WERR_errors.js';
15
+ import { WERR_INVALID_PARAMETER } from '@bsv/wallet-toolbox/out/src/sdk/WERR_errors.js';
16
+ import { WERR_INTERNAL } from '@bsv/wallet-toolbox/out/src/sdk/WERR_errors.js';
17
+ import { WERR_NOT_IMPLEMENTED } from '@bsv/wallet-toolbox/out/src/sdk/WERR_errors.js';
18
+ import { isListActionsSpecOp } from '@bsv/wallet-toolbox/out/src/sdk/types.js';
19
+ import { StorageProvider, } from '@bsv/wallet-toolbox/out/src/storage/StorageProvider.js';
20
+ import { getLabelToSpecOp } from '@bsv/wallet-toolbox/out/src/storage/methods/ListActionsSpecOp.js';
21
+ import { getListOutputsSpecOp } from '@bsv/wallet-toolbox/out/src/storage/methods/ListOutputsSpecOp.js';
22
+ import { outputColumnsWithoutLockingScript } from '@bsv/wallet-toolbox/out/src/storage/schema/tables/TableOutput.js';
23
+ import { transactionColumnsWithoutRawTx } from '@bsv/wallet-toolbox/out/src/storage/schema/tables/TableTransaction.js';
24
+ import { makeBrc114ActionTimeLabel, parseBrc114ActionTimeLabels, } from '@bsv/wallet-toolbox/out/src/utility/brc114ActionTimeLabels.js';
25
+ import { verifyId, verifyOneOrNone, verifyTruthy, } from '@bsv/wallet-toolbox/out/src/utility/utilityHelpers.js';
26
+ import { asString } from '@bsv/wallet-toolbox/out/src/utility/utilityHelpers.noBuffer.js';
27
+ const TRX_BRAND = Symbol('PgTrx');
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+ function toBuffer(val) {
32
+ if (val == null)
33
+ return null;
34
+ if (Buffer.isBuffer(val))
35
+ return val;
36
+ if (val instanceof Uint8Array)
37
+ return Buffer.from(val);
38
+ if (Array.isArray(val))
39
+ return Buffer.from(val);
40
+ return null;
41
+ }
42
+ /**
43
+ * Normalize a JS value for a Postgres parameter binding. Booleans are
44
+ * coerced to SMALLINT 0/1 (our schema stores booleans that way to match
45
+ * bun-sqlite's validation pattern). Buffers/arrays are left as Buffer.
46
+ * sqlite tolerates raw booleans via implicit coercion; pg does not, so
47
+ * any caller path that skipped the `booleanFields` list would otherwise
48
+ * fail with "invalid input syntax for type smallint".
49
+ */
50
+ function bindValue(v) {
51
+ if (v === null || v === undefined)
52
+ return v;
53
+ if (typeof v === 'boolean')
54
+ return v ? 1 : 0;
55
+ if (Buffer.isBuffer(v))
56
+ return v;
57
+ if (v instanceof Uint8Array)
58
+ return Buffer.from(v);
59
+ if (Array.isArray(v) && (v.length === 0 || typeof v[0] === 'number'))
60
+ return Buffer.from(v);
61
+ return v;
62
+ }
63
+ /**
64
+ * Rewrite `?`-style placeholders to Postgres `$1, $2, ...` form. Walks the
65
+ * string in one pass and skips `?` inside single- or double-quoted literals
66
+ * and `--` / `/* *\/` comments so we don't clobber embedded question marks
67
+ * in string values or miscount placeholders inside comments.
68
+ */
69
+ function convertPlaceholders(sql) {
70
+ let out = '';
71
+ let n = 0;
72
+ let inSingle = false;
73
+ let inDouble = false;
74
+ let inLine = false;
75
+ let inBlock = false;
76
+ for (let i = 0; i < sql.length; i++) {
77
+ const c = sql[i];
78
+ if (inLine) {
79
+ out += c;
80
+ if (c === '\n')
81
+ inLine = false;
82
+ continue;
83
+ }
84
+ if (inBlock) {
85
+ out += c;
86
+ if (c === '*' && sql[i + 1] === '/') {
87
+ out += '/';
88
+ i++;
89
+ inBlock = false;
90
+ }
91
+ continue;
92
+ }
93
+ if (inSingle) {
94
+ out += c;
95
+ if (c === "'") {
96
+ if (sql[i + 1] === "'") {
97
+ out += "'";
98
+ i++;
99
+ }
100
+ else {
101
+ inSingle = false;
102
+ }
103
+ }
104
+ continue;
105
+ }
106
+ if (inDouble) {
107
+ out += c;
108
+ if (c === '"') {
109
+ if (sql[i + 1] === '"') {
110
+ out += '"';
111
+ i++;
112
+ }
113
+ else {
114
+ inDouble = false;
115
+ }
116
+ }
117
+ continue;
118
+ }
119
+ if (c === '-' && sql[i + 1] === '-') {
120
+ inLine = true;
121
+ out += c;
122
+ continue;
123
+ }
124
+ if (c === '/' && sql[i + 1] === '*') {
125
+ inBlock = true;
126
+ out += c;
127
+ continue;
128
+ }
129
+ if (c === "'") {
130
+ inSingle = true;
131
+ out += c;
132
+ continue;
133
+ }
134
+ if (c === '"') {
135
+ inDouble = true;
136
+ out += c;
137
+ continue;
138
+ }
139
+ if (c === '?') {
140
+ n += 1;
141
+ out += `$${n}`;
142
+ continue;
143
+ }
144
+ out += c;
145
+ }
146
+ return out;
147
+ }
148
+ /**
149
+ * Build the SQL fragment that restricts `outputs` rows to those carrying the
150
+ * supplied output tag ids. `isQueryModeAll` requires every tag (HAVING COUNT
151
+ * match); default "any" requires at least one (EXISTS). Returns undefined when
152
+ * no tag filter is requested so callers can skip the AND.
153
+ */
154
+ function buildOutputTagFilterSql(tagIds, isQueryModeAll) {
155
+ if (!tagIds || tagIds.length === 0)
156
+ return undefined;
157
+ const placeholders = tagIds.map(() => '?').join(',');
158
+ if (isQueryModeAll) {
159
+ return {
160
+ sql: `(SELECT COUNT(*) FROM output_tags_map m WHERE m."outputId" = outputs."outputId" AND m."outputTagId" IN (${placeholders})) = ${tagIds.length}`,
161
+ params: [...tagIds],
162
+ };
163
+ }
164
+ return {
165
+ sql: `EXISTS (SELECT 1 FROM output_tags_map m WHERE m."outputId" = outputs."outputId" AND m."outputTagId" IN (${placeholders}))`,
166
+ params: [...tagIds],
167
+ };
168
+ }
169
+ function buildTxLabelFilterSql(labelIds, isQueryModeAll) {
170
+ if (!labelIds || labelIds.length === 0)
171
+ return undefined;
172
+ const placeholders = labelIds.map(() => '?').join(',');
173
+ if (isQueryModeAll) {
174
+ return {
175
+ sql: `(SELECT COUNT(*) FROM tx_labels_map m WHERE m."transactionId" = transactions."transactionId" AND m."txLabelId" IN (${placeholders})) = ${labelIds.length}`,
176
+ params: [...labelIds],
177
+ };
178
+ }
179
+ return {
180
+ sql: `EXISTS (SELECT 1 FROM tx_labels_map m WHERE m."transactionId" = transactions."transactionId" AND m."txLabelId" IN (${placeholders}))`,
181
+ params: [...labelIds],
182
+ };
183
+ }
184
+ // ---------------------------------------------------------------------------
185
+ // StoragePg
186
+ // ---------------------------------------------------------------------------
187
+ export class StoragePg extends StorageProvider {
188
+ pool;
189
+ ownsPool;
190
+ tableColumns = new Map();
191
+ savepointCounter = 0;
192
+ _verifiedReadyForDatabaseAccess = false;
16
193
  constructor(options) {
17
194
  super(options);
195
+ if (options.pool) {
196
+ // Caller-managed pool: we cannot safely set type parsers here (would
197
+ // leak into the caller's other pg consumers in the same process).
198
+ // The caller is responsible for configuring BIGINT → number parsing
199
+ // if they want satoshis returned as JS numbers.
200
+ this.pool = options.pool;
201
+ this.ownsPool = false;
202
+ }
203
+ else if (options.dbUrl) {
204
+ // Lazy-require so browsers/other runtimes that will never reach this
205
+ // path don't fail on `pg`'s node-specific imports.
206
+ const pgMod = require('pg');
207
+ // Per-pool type resolver instead of the global `types.setTypeParser`.
208
+ // Returning bigint as JS number is safe for satoshis / byte lengths
209
+ // (values well under 2^53). Scoping to this pool prevents leaking
210
+ // the override into other pg consumers in the same process.
211
+ const customTypes = {
212
+ getTypeParser(oid, format) {
213
+ if (oid === 20)
214
+ return (v) => Number.parseInt(v, 10);
215
+ return pgMod.types.getTypeParser(oid, format);
216
+ },
217
+ };
218
+ this.pool = new pgMod.Pool({
219
+ connectionString: options.dbUrl,
220
+ min: options.poolConfig?.min,
221
+ max: options.poolConfig?.max,
222
+ types: customTypes,
223
+ });
224
+ this.ownsPool = true;
225
+ }
226
+ else {
227
+ throw new WERR_INVALID_PARAMETER('options', 'either `dbUrl` or `pool` must be supplied');
228
+ }
229
+ }
230
+ // -----------------------------------------------------------------------
231
+ // Core infrastructure
232
+ // -----------------------------------------------------------------------
233
+ async readSettings(trx) {
234
+ const row = await this.getSql('SELECT * FROM settings LIMIT 1', [], trx);
235
+ if (!row)
236
+ throw new WERR_INTERNAL('No settings row found');
237
+ return this.validateEntity(row);
238
+ }
239
+ async destroy() {
240
+ if (this.ownsPool)
241
+ await this.pool.end();
242
+ }
243
+ async migrate(storageName, storageIdentityKey) {
244
+ const exec = this.pool;
245
+ await exec.query(`
246
+ CREATE TABLE IF NOT EXISTS knex_migrations (
247
+ id SERIAL PRIMARY KEY,
248
+ name TEXT NOT NULL,
249
+ batch INTEGER NOT NULL,
250
+ migration_time TIMESTAMPTZ DEFAULT NOW()
251
+ )
252
+ `);
253
+ await exec.query(`
254
+ CREATE TABLE IF NOT EXISTS knex_migrations_lock (
255
+ "index" INTEGER PRIMARY KEY,
256
+ is_locked INTEGER
257
+ )
258
+ `);
259
+ await exec.query(`INSERT INTO knex_migrations_lock ("index", is_locked) VALUES (1, 0) ON CONFLICT DO NOTHING`);
260
+ const existingRes = await exec.query('SELECT name FROM knex_migrations');
261
+ const existingMigrations = new Set(existingRes.rows.map((r) => r.name));
262
+ const migrations = this.getMigrationDefinitions(storageName, storageIdentityKey);
263
+ const sortedNames = Object.keys(migrations).sort();
264
+ let batch = 0;
265
+ const batchRes = await exec.query('SELECT MAX(batch) AS maxbatch FROM knex_migrations');
266
+ if (batchRes.rows[0]?.maxbatch != null)
267
+ batch = Number(batchRes.rows[0].maxbatch);
268
+ batch++;
269
+ for (const name of sortedNames) {
270
+ if (existingMigrations.has(name))
271
+ continue;
272
+ const migration = migrations[name];
273
+ await migration.up(exec);
274
+ await exec.query('INSERT INTO knex_migrations (name, batch) VALUES ($1, $2)', [name, batch]);
275
+ }
276
+ await this.refreshTableColumns();
277
+ const latestRes = await exec.query('SELECT name FROM knex_migrations ORDER BY id DESC LIMIT 1');
278
+ return latestRes.rows[0]?.name ?? 'none';
18
279
  }
280
+ /**
281
+ * Introspect the live schema and cache the column set for every user
282
+ * table. Used by `filterToSchema` to drop wire-format fields that don't
283
+ * correspond to real columns before building INSERT/UPDATE SQL.
284
+ */
285
+ async refreshTableColumns() {
286
+ this.tableColumns.clear();
287
+ const tables = await this.pool.query(`SELECT table_name FROM information_schema.tables
288
+ WHERE table_schema = current_schema()
289
+ AND table_type = 'BASE TABLE'
290
+ AND table_name NOT LIKE 'knex_migrations%'`);
291
+ for (const { table_name } of tables.rows) {
292
+ const cols = await this.pool.query(`SELECT column_name FROM information_schema.columns
293
+ WHERE table_schema = current_schema() AND table_name = $1`, [table_name]);
294
+ this.tableColumns.set(table_name, new Set(cols.rows.map((c) => c.column_name)));
295
+ }
296
+ }
297
+ filterToSchema(table, entity) {
298
+ const allowed = this.tableColumns.get(table);
299
+ if (!allowed)
300
+ return entity;
301
+ const out = {};
302
+ for (const k of Object.keys(entity)) {
303
+ if (allowed.has(k))
304
+ out[k] = entity[k];
305
+ }
306
+ return out;
307
+ }
308
+ getMigrationDefinitions(storageName, storageIdentityKey) {
309
+ const migrations = {};
310
+ migrations['2024-12-26-001 initial migration'] = {
311
+ up: async (db) => {
312
+ await db.query(`
313
+ CREATE TABLE IF NOT EXISTS proven_txs (
314
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
315
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
316
+ "provenTxId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
317
+ txid TEXT NOT NULL UNIQUE,
318
+ height INTEGER NOT NULL,
319
+ "index" INTEGER NOT NULL,
320
+ "merklePath" BYTEA NOT NULL,
321
+ "rawTx" BYTEA NOT NULL,
322
+ "blockHash" TEXT NOT NULL,
323
+ "merkleRoot" TEXT NOT NULL
324
+ )
325
+ `);
326
+ await db.query(`
327
+ CREATE TABLE IF NOT EXISTS proven_tx_reqs (
328
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
329
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
330
+ "provenTxReqId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
331
+ "provenTxId" INTEGER REFERENCES proven_txs("provenTxId"),
332
+ status TEXT NOT NULL DEFAULT 'unknown',
333
+ attempts INTEGER NOT NULL DEFAULT 0,
334
+ notified SMALLINT NOT NULL DEFAULT 0,
335
+ txid TEXT NOT NULL UNIQUE,
336
+ batch TEXT,
337
+ history TEXT NOT NULL DEFAULT '{}',
338
+ notify TEXT NOT NULL DEFAULT '{}',
339
+ "rawTx" BYTEA NOT NULL,
340
+ "inputBEEF" BYTEA
341
+ )
342
+ `);
343
+ await db.query('CREATE INDEX IF NOT EXISTS proven_tx_reqs_status ON proven_tx_reqs(status)');
344
+ await db.query('CREATE INDEX IF NOT EXISTS proven_tx_reqs_batch ON proven_tx_reqs(batch)');
345
+ await db.query(`
346
+ CREATE TABLE IF NOT EXISTS users (
347
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
348
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
349
+ "userId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
350
+ "identityKey" TEXT NOT NULL UNIQUE
351
+ )
352
+ `);
353
+ await db.query(`
354
+ CREATE TABLE IF NOT EXISTS certificates (
355
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
356
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
357
+ "certificateId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
358
+ "userId" INTEGER NOT NULL REFERENCES users("userId"),
359
+ "serialNumber" TEXT NOT NULL,
360
+ type TEXT NOT NULL,
361
+ certifier TEXT NOT NULL,
362
+ subject TEXT NOT NULL,
363
+ verifier TEXT,
364
+ "revocationOutpoint" TEXT NOT NULL,
365
+ signature TEXT NOT NULL,
366
+ "isDeleted" SMALLINT NOT NULL DEFAULT 0,
367
+ UNIQUE("userId", type, certifier, "serialNumber")
368
+ )
369
+ `);
370
+ await db.query(`
371
+ CREATE TABLE IF NOT EXISTS certificate_fields (
372
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
373
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
374
+ "userId" INTEGER NOT NULL REFERENCES users("userId"),
375
+ "certificateId" INTEGER NOT NULL REFERENCES certificates("certificateId"),
376
+ "fieldName" TEXT NOT NULL,
377
+ "fieldValue" TEXT NOT NULL,
378
+ "masterKey" TEXT NOT NULL DEFAULT '',
379
+ UNIQUE("fieldName", "certificateId")
380
+ )
381
+ `);
382
+ await db.query(`
383
+ CREATE TABLE IF NOT EXISTS output_baskets (
384
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
385
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
386
+ "basketId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
387
+ "userId" INTEGER NOT NULL REFERENCES users("userId"),
388
+ name TEXT NOT NULL,
389
+ "numberOfDesiredUTXOs" INTEGER NOT NULL DEFAULT 6,
390
+ "minimumDesiredUTXOValue" INTEGER NOT NULL DEFAULT 10000,
391
+ "isDeleted" SMALLINT NOT NULL DEFAULT 0,
392
+ UNIQUE(name, "userId")
393
+ )
394
+ `);
395
+ await db.query(`
396
+ CREATE TABLE IF NOT EXISTS transactions (
397
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
398
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
399
+ "transactionId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
400
+ "userId" INTEGER NOT NULL REFERENCES users("userId"),
401
+ "provenTxId" INTEGER REFERENCES proven_txs("provenTxId"),
402
+ status TEXT NOT NULL,
403
+ reference TEXT NOT NULL UNIQUE,
404
+ "isOutgoing" SMALLINT NOT NULL,
405
+ satoshis BIGINT NOT NULL DEFAULT 0,
406
+ version INTEGER,
407
+ "lockTime" INTEGER,
408
+ description TEXT NOT NULL,
409
+ txid TEXT,
410
+ "inputBEEF" BYTEA,
411
+ "rawTx" BYTEA
412
+ )
413
+ `);
414
+ await db.query('CREATE INDEX IF NOT EXISTS transactions_status ON transactions(status)');
415
+ await db.query(`
416
+ CREATE TABLE IF NOT EXISTS commissions (
417
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
418
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
419
+ "commissionId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
420
+ "userId" INTEGER NOT NULL REFERENCES users("userId"),
421
+ "transactionId" INTEGER NOT NULL UNIQUE REFERENCES transactions("transactionId"),
422
+ satoshis BIGINT NOT NULL,
423
+ "keyOffset" TEXT NOT NULL,
424
+ "isRedeemed" SMALLINT NOT NULL DEFAULT 0,
425
+ "lockingScript" BYTEA NOT NULL
426
+ )
427
+ `);
428
+ await db.query('CREATE INDEX IF NOT EXISTS commissions_transactionId ON commissions("transactionId")');
429
+ await db.query(`
430
+ CREATE TABLE IF NOT EXISTS outputs (
431
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
432
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
433
+ "outputId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
434
+ "userId" INTEGER NOT NULL REFERENCES users("userId"),
435
+ "transactionId" INTEGER NOT NULL REFERENCES transactions("transactionId"),
436
+ "basketId" INTEGER REFERENCES output_baskets("basketId"),
437
+ spendable SMALLINT NOT NULL DEFAULT 0,
438
+ "change" SMALLINT NOT NULL DEFAULT 0,
439
+ vout INTEGER NOT NULL,
440
+ satoshis BIGINT NOT NULL,
441
+ "providedBy" TEXT NOT NULL,
442
+ purpose TEXT NOT NULL,
443
+ type TEXT NOT NULL,
444
+ "outputDescription" TEXT,
445
+ txid TEXT,
446
+ "senderIdentityKey" TEXT,
447
+ "derivationPrefix" TEXT,
448
+ "derivationSuffix" TEXT,
449
+ "customInstructions" TEXT,
450
+ "spentBy" INTEGER REFERENCES transactions("transactionId"),
451
+ "sequenceNumber" INTEGER,
452
+ "spendingDescription" TEXT,
453
+ "scriptLength" BIGINT,
454
+ "scriptOffset" BIGINT,
455
+ "lockingScript" BYTEA,
456
+ UNIQUE("transactionId", vout, "userId")
457
+ )
458
+ `);
459
+ await db.query(`
460
+ CREATE TABLE IF NOT EXISTS output_tags (
461
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
462
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
463
+ "outputTagId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
464
+ "userId" INTEGER NOT NULL REFERENCES users("userId"),
465
+ tag TEXT NOT NULL,
466
+ "isDeleted" SMALLINT NOT NULL DEFAULT 0,
467
+ UNIQUE(tag, "userId")
468
+ )
469
+ `);
470
+ await db.query(`
471
+ CREATE TABLE IF NOT EXISTS output_tags_map (
472
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
473
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
474
+ "outputTagId" INTEGER NOT NULL REFERENCES output_tags("outputTagId"),
475
+ "outputId" INTEGER NOT NULL REFERENCES outputs("outputId"),
476
+ "isDeleted" SMALLINT NOT NULL DEFAULT 0,
477
+ UNIQUE("outputTagId", "outputId")
478
+ )
479
+ `);
480
+ await db.query('CREATE INDEX IF NOT EXISTS output_tags_map_outputId ON output_tags_map("outputId")');
481
+ await db.query(`
482
+ CREATE TABLE IF NOT EXISTS tx_labels (
483
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
484
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
485
+ "txLabelId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
486
+ "userId" INTEGER NOT NULL REFERENCES users("userId"),
487
+ label TEXT NOT NULL,
488
+ "isDeleted" SMALLINT NOT NULL DEFAULT 0,
489
+ UNIQUE(label, "userId")
490
+ )
491
+ `);
492
+ await db.query(`
493
+ CREATE TABLE IF NOT EXISTS tx_labels_map (
494
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
495
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
496
+ "txLabelId" INTEGER NOT NULL REFERENCES tx_labels("txLabelId"),
497
+ "transactionId" INTEGER NOT NULL REFERENCES transactions("transactionId"),
498
+ "isDeleted" SMALLINT NOT NULL DEFAULT 0,
499
+ UNIQUE("txLabelId", "transactionId")
500
+ )
501
+ `);
502
+ await db.query('CREATE INDEX IF NOT EXISTS tx_labels_map_transactionId ON tx_labels_map("transactionId")');
503
+ await db.query(`
504
+ CREATE TABLE IF NOT EXISTS monitor_events (
505
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
506
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
507
+ id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
508
+ event TEXT NOT NULL,
509
+ details TEXT
510
+ )
511
+ `);
512
+ await db.query(`
513
+ CREATE TABLE IF NOT EXISTS settings (
514
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
515
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
516
+ "storageIdentityKey" TEXT NOT NULL,
517
+ "storageName" TEXT NOT NULL,
518
+ chain TEXT NOT NULL,
519
+ dbtype TEXT NOT NULL,
520
+ "maxOutputScript" INTEGER NOT NULL
521
+ )
522
+ `);
523
+ await db.query(`
524
+ CREATE TABLE IF NOT EXISTS sync_states (
525
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
526
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
527
+ "syncStateId" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
528
+ "userId" INTEGER NOT NULL REFERENCES users("userId"),
529
+ "storageIdentityKey" TEXT NOT NULL DEFAULT '',
530
+ "storageName" TEXT NOT NULL,
531
+ status TEXT NOT NULL DEFAULT 'unknown',
532
+ init SMALLINT NOT NULL DEFAULT 0,
533
+ "refNum" TEXT NOT NULL UNIQUE,
534
+ "syncMap" TEXT NOT NULL,
535
+ "when" TIMESTAMPTZ,
536
+ satoshis BIGINT,
537
+ "errorLocal" TEXT,
538
+ "errorOther" TEXT
539
+ )
540
+ `);
541
+ await db.query('CREATE INDEX IF NOT EXISTS sync_states_status ON sync_states(status)');
542
+ await db.query('CREATE INDEX IF NOT EXISTS sync_states_refNum ON sync_states("refNum")');
543
+ await db.query(`INSERT INTO settings ("storageIdentityKey", "storageName", chain, dbtype, "maxOutputScript")
544
+ VALUES ($1, $2, $3, 'Postgres', 1024)`, [storageIdentityKey, storageName, this.chain]);
545
+ },
546
+ down: async (db) => {
547
+ for (const table of [
548
+ 'sync_states',
549
+ 'settings',
550
+ 'monitor_events',
551
+ 'certificate_fields',
552
+ 'certificates',
553
+ 'commissions',
554
+ 'output_tags_map',
555
+ 'output_tags',
556
+ 'outputs',
557
+ 'output_baskets',
558
+ 'tx_labels_map',
559
+ 'tx_labels',
560
+ 'transactions',
561
+ 'users',
562
+ 'proven_tx_reqs',
563
+ 'proven_txs',
564
+ ]) {
565
+ await db.query(`DROP TABLE IF EXISTS ${table}`);
566
+ }
567
+ },
568
+ };
569
+ migrations['2025-01-21-001 add activeStorage to users'] = {
570
+ up: async (db) => {
571
+ await db.query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS "activeStorage" TEXT DEFAULT NULL`);
572
+ },
573
+ down: async (db) => {
574
+ await db.query(`ALTER TABLE users DROP COLUMN IF EXISTS "activeStorage"`);
575
+ },
576
+ };
577
+ migrations['2025-02-22-001 nonNULL activeStorage'] = {
578
+ up: async (db) => {
579
+ const settings = await db.query('SELECT "storageIdentityKey" FROM settings LIMIT 1');
580
+ const key = settings.rows[0]?.storageIdentityKey;
581
+ if (key) {
582
+ await db.query('UPDATE users SET "activeStorage" = $1 WHERE "activeStorage" IS NULL', [key]);
583
+ }
584
+ },
585
+ down: async () => { },
586
+ };
587
+ migrations['2025-02-28-001 derivations to 200'] = {
588
+ up: async () => {
589
+ // TEXT columns in pg have no practical length cap — nothing to do.
590
+ },
591
+ down: async () => { },
592
+ };
593
+ migrations['2025-03-01-001 reset req history'] = {
594
+ up: async (db) => {
595
+ await db.query(`UPDATE proven_tx_reqs SET history = '{}'`);
596
+ },
597
+ down: async () => { },
598
+ };
599
+ migrations['2025-03-03-001 descriptions to 2000'] = {
600
+ up: async () => { },
601
+ down: async () => { },
602
+ };
603
+ migrations['2025-05-13-001 add monitor events event index'] = {
604
+ up: async (db) => {
605
+ await db.query('CREATE INDEX IF NOT EXISTS monitor_events_event ON monitor_events(event)');
606
+ },
607
+ down: async (db) => {
608
+ await db.query('DROP INDEX IF EXISTS monitor_events_event');
609
+ },
610
+ };
611
+ migrations['2025-09-06-001 add proven txs blockHash index'] = {
612
+ up: async (db) => {
613
+ await db.query('CREATE INDEX IF NOT EXISTS proven_txs_blockHash ON proven_txs("blockHash")');
614
+ },
615
+ down: async (db) => {
616
+ await db.query('DROP INDEX IF EXISTS proven_txs_blockHash');
617
+ },
618
+ };
619
+ migrations['2025-10-13-001 add outputs spendable index'] = {
620
+ up: async (db) => {
621
+ await db.query('CREATE INDEX IF NOT EXISTS outputs_spendable ON outputs(spendable)');
622
+ },
623
+ down: async (db) => {
624
+ await db.query('DROP INDEX IF EXISTS outputs_spendable');
625
+ },
626
+ };
627
+ migrations['2026-02-27-001 add listOutputs path indexes'] = {
628
+ up: async (db) => {
629
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_outputs_user_spendable_outputid ON outputs ("userId", spendable, "outputId")`);
630
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_outputs_user_basket_spendable_outputid ON outputs ("userId", "basketId", spendable, "outputId")`);
631
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_output_tags_map_output_deleted_tag ON output_tags_map ("outputId", "isDeleted", "outputTagId")`);
632
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_tx_labels_map_tx_deleted ON tx_labels_map ("transactionId", "isDeleted")`);
633
+ },
634
+ down: async (db) => {
635
+ await db.query(`DROP INDEX IF EXISTS idx_tx_labels_map_tx_deleted`);
636
+ await db.query(`DROP INDEX IF EXISTS idx_output_tags_map_output_deleted_tag`);
637
+ await db.query(`DROP INDEX IF EXISTS idx_outputs_user_basket_spendable_outputid`);
638
+ await db.query(`DROP INDEX IF EXISTS idx_outputs_user_spendable_outputid`);
639
+ },
640
+ };
641
+ migrations['2026-02-27-002 add createAction path indexes'] = {
642
+ up: async (db) => {
643
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_outputs_user_basket_spendable_satoshis ON outputs ("userId", "basketId", spendable, satoshis)`);
644
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_outputs_spentby ON outputs ("spentBy")`);
645
+ },
646
+ down: async (db) => {
647
+ await db.query(`DROP INDEX IF EXISTS idx_outputs_spentby`);
648
+ await db.query(`DROP INDEX IF EXISTS idx_outputs_user_basket_spendable_satoshis`);
649
+ },
650
+ };
651
+ migrations['2025-10-18-001 add transactions txid index'] = {
652
+ up: async (db) => {
653
+ await db.query('CREATE INDEX IF NOT EXISTS transactions_txid ON transactions(txid)');
654
+ },
655
+ down: async (db) => {
656
+ await db.query('DROP INDEX IF EXISTS transactions_txid');
657
+ },
658
+ };
659
+ migrations['2025-10-18-002 add proven_tx_reqs txid index'] = {
660
+ up: async (db) => {
661
+ await db.query('CREATE INDEX IF NOT EXISTS proven_tx_reqs_txid ON proven_tx_reqs(txid)');
662
+ },
663
+ down: async (db) => {
664
+ await db.query('DROP INDEX IF EXISTS proven_tx_reqs_txid');
665
+ },
666
+ };
667
+ migrations['2026-04-20-001 add transactions userId index'] = {
668
+ up: async (db) => {
669
+ await db.query('CREATE INDEX IF NOT EXISTS transactions_userId ON transactions("userId")');
670
+ },
671
+ down: async (db) => {
672
+ await db.query('DROP INDEX IF EXISTS transactions_userId');
673
+ },
674
+ };
675
+ migrations['2026-04-20-002 add outputs userId index'] = {
676
+ up: async (db) => {
677
+ await db.query('CREATE INDEX IF NOT EXISTS outputs_userId ON outputs("userId")');
678
+ },
679
+ down: async (db) => {
680
+ await db.query('DROP INDEX IF EXISTS outputs_userId');
681
+ },
682
+ };
683
+ return migrations;
684
+ }
685
+ async dropAllData() {
686
+ for (const table of [
687
+ 'sync_states',
688
+ 'settings',
689
+ 'monitor_events',
690
+ 'certificate_fields',
691
+ 'certificates',
692
+ 'commissions',
693
+ 'output_tags_map',
694
+ 'output_tags',
695
+ 'outputs',
696
+ 'output_baskets',
697
+ 'tx_labels_map',
698
+ 'tx_labels',
699
+ 'transactions',
700
+ 'users',
701
+ 'proven_tx_reqs',
702
+ 'proven_txs',
703
+ 'knex_migrations',
704
+ 'knex_migrations_lock',
705
+ ]) {
706
+ await this.pool.query(`DROP TABLE IF EXISTS ${table} CASCADE`);
707
+ }
708
+ }
709
+ async transaction(scope, trx) {
710
+ if (trx) {
711
+ // Nested: issue a SAVEPOINT on the caller's client so partial
712
+ // failures can roll back without tearing down the outer tx.
713
+ // Monotonic counter + process id suffix for uniqueness under
714
+ // concurrent nested calls on distinct clients.
715
+ const t = trx;
716
+ this.savepointCounter++;
717
+ const spName = `sp_${this.savepointCounter}`;
718
+ await t.client.query(`SAVEPOINT ${spName}`);
719
+ let r;
720
+ try {
721
+ r = await scope(trx);
722
+ }
723
+ catch (err) {
724
+ try {
725
+ await t.client.query(`ROLLBACK TO SAVEPOINT ${spName}`);
726
+ await t.client.query(`RELEASE SAVEPOINT ${spName}`);
727
+ }
728
+ catch {
729
+ // Outer transaction will abort; swallow cleanup errors so
730
+ // the original scope error propagates unchanged.
731
+ }
732
+ throw err;
733
+ }
734
+ await t.client.query(`RELEASE SAVEPOINT ${spName}`);
735
+ return r;
736
+ }
737
+ const client = await this.pool.connect();
738
+ try {
739
+ await client.query('BEGIN');
740
+ const token = { [TRX_BRAND]: true, client };
741
+ let r;
742
+ try {
743
+ r = await scope(token);
744
+ }
745
+ catch (err) {
746
+ try {
747
+ await client.query('ROLLBACK');
748
+ }
749
+ catch { }
750
+ throw err;
751
+ }
752
+ await client.query('COMMIT');
753
+ return r;
754
+ }
755
+ finally {
756
+ client.release();
757
+ }
758
+ }
759
+ // -----------------------------------------------------------------------
760
+ // Low-level query helpers
761
+ // -----------------------------------------------------------------------
762
+ exec(trx) {
763
+ if (trx)
764
+ return trx.client;
765
+ return this.pool;
766
+ }
767
+ toDb(_trx) {
768
+ this.whenLastAccess = new Date();
769
+ return this.pool;
770
+ }
771
+ async runSql(sql, params = [], trx) {
772
+ this.whenLastAccess = new Date();
773
+ return this.exec(trx).query(convertPlaceholders(sql), params);
774
+ }
775
+ async allSql(sql, params = [], trx) {
776
+ this.whenLastAccess = new Date();
777
+ const r = await this.exec(trx).query(convertPlaceholders(sql), params);
778
+ return r.rows;
779
+ }
780
+ async getSql(sql, params = [], trx) {
781
+ this.whenLastAccess = new Date();
782
+ const r = await this.exec(trx).query(convertPlaceholders(sql), params);
783
+ return r.rows[0] ?? null;
784
+ }
785
+ /**
786
+ * Build a WHERE clause from a partial object.
787
+ * Returns { clause: string, params: unknown[] } with `?` placeholders; the
788
+ * caller feeds into runSql/allSql/getSql which convert to `$n` form.
789
+ */
790
+ buildWhere(partial, prefix = '') {
791
+ for (const k of Object.keys(partial)) {
792
+ if (partial[k] === undefined) {
793
+ throw new WERR_INVALID_PARAMETER(`partial.${k}`, `not undefined. Passing undefined as a filter value is not supported — omit the key to skip filtering, or pass null for IS NULL. Matches Knex behavior.`);
794
+ }
795
+ }
796
+ const keys = Object.keys(partial);
797
+ if (keys.length === 0)
798
+ return { clause: '', params: [] };
799
+ const parts = [];
800
+ const params = [];
801
+ for (const k of keys) {
802
+ const col = prefix ? `${prefix}.${this.quoteCol(k)}` : this.quoteCol(k);
803
+ const val = partial[k];
804
+ if (val === null) {
805
+ parts.push(`${col} IS NULL`);
806
+ }
807
+ else {
808
+ parts.push(`${col} = ?`);
809
+ params.push(bindValue(val));
810
+ }
811
+ }
812
+ return { clause: parts.join(' AND '), params };
813
+ }
814
+ quoteCol(name) {
815
+ // Always double-quote camelCase column names on Postgres. Unquoted
816
+ // identifiers are folded to lowercase by the pg parser, which would
817
+ // break every `transactionId`, `provenTxId`, etc. Reserved words are
818
+ // quoted by default as a side effect.
819
+ return `"${name}"`;
820
+ }
821
+ orderByColumn(table) {
822
+ switch (table) {
823
+ case 'certificates':
824
+ return '"certificateId"';
825
+ case 'commissions':
826
+ return '"commissionId"';
827
+ case 'output_baskets':
828
+ return '"basketId"';
829
+ case 'outputs':
830
+ return '"outputId"';
831
+ case 'output_tags':
832
+ return '"outputTagId"';
833
+ case 'proven_tx_reqs':
834
+ return '"provenTxReqId"';
835
+ case 'proven_txs':
836
+ return '"provenTxId"';
837
+ case 'sync_states':
838
+ return '"syncStateId"';
839
+ case 'transactions':
840
+ return '"transactionId"';
841
+ case 'tx_labels':
842
+ return '"txLabelId"';
843
+ case 'users':
844
+ return '"userId"';
845
+ case 'monitor_events':
846
+ return 'id';
847
+ default:
848
+ return '';
849
+ }
850
+ }
851
+ /**
852
+ * Map table name to its primary-key column, used by `insertRow` to
853
+ * emit a `RETURNING` clause.
854
+ */
855
+ pkColumn(table) {
856
+ switch (table) {
857
+ case 'certificates':
858
+ return 'certificateId';
859
+ case 'commissions':
860
+ return 'commissionId';
861
+ case 'output_baskets':
862
+ return 'basketId';
863
+ case 'outputs':
864
+ return 'outputId';
865
+ case 'output_tags':
866
+ return 'outputTagId';
867
+ case 'proven_tx_reqs':
868
+ return 'provenTxReqId';
869
+ case 'proven_txs':
870
+ return 'provenTxId';
871
+ case 'sync_states':
872
+ return 'syncStateId';
873
+ case 'transactions':
874
+ return 'transactionId';
875
+ case 'tx_labels':
876
+ return 'txLabelId';
877
+ case 'users':
878
+ return 'userId';
879
+ case 'monitor_events':
880
+ return 'id';
881
+ default:
882
+ return '';
883
+ }
884
+ }
885
+ async selectQuery(table, args, extraWhere, extraParams, columns) {
886
+ const whereParts = [];
887
+ const params = [];
888
+ if (args.partial && Object.keys(args.partial).length > 0) {
889
+ const w = this.buildWhere(args.partial);
890
+ if (w.clause) {
891
+ whereParts.push(w.clause);
892
+ params.push(...w.params);
893
+ }
894
+ }
895
+ if (args.since) {
896
+ whereParts.push('updated_at >= ?');
897
+ params.push(this.validateDateForWhere(args.since));
898
+ }
899
+ if (extraWhere) {
900
+ whereParts.push(extraWhere);
901
+ if (extraParams)
902
+ params.push(...extraParams);
903
+ }
904
+ const colStr = columns ? columns.join(', ') : '*';
905
+ let sql = `SELECT ${colStr} FROM ${table}`;
906
+ if (whereParts.length > 0)
907
+ sql += ` WHERE ${whereParts.join(' AND ')}`;
908
+ if (args.orderDescending) {
909
+ const col = this.orderByColumn(table);
910
+ if (col)
911
+ sql += ` ORDER BY ${col} DESC`;
912
+ }
913
+ if (args.paged) {
914
+ sql += ' LIMIT ?';
915
+ params.push(args.paged.limit);
916
+ if (args.paged.offset) {
917
+ sql += ' OFFSET ?';
918
+ params.push(args.paged.offset);
919
+ }
920
+ }
921
+ return await this.allSql(sql, params, args.trx);
922
+ }
923
+ async countQuery(table, args, extraWhere, extraParams) {
924
+ const whereParts = [];
925
+ const params = [];
926
+ if (args.partial && Object.keys(args.partial).length > 0) {
927
+ const w = this.buildWhere(args.partial);
928
+ if (w.clause) {
929
+ whereParts.push(w.clause);
930
+ params.push(...w.params);
931
+ }
932
+ }
933
+ if (args.since) {
934
+ whereParts.push('updated_at >= ?');
935
+ params.push(this.validateDateForWhere(args.since));
936
+ }
937
+ if (extraWhere) {
938
+ whereParts.push(extraWhere);
939
+ if (extraParams)
940
+ params.push(...extraParams);
941
+ }
942
+ let sql = `SELECT COUNT(*) as cnt FROM ${table}`;
943
+ if (whereParts.length > 0)
944
+ sql += ` WHERE ${whereParts.join(' AND ')}`;
945
+ const row = await this.getSql(sql, params, args.trx);
946
+ return Number(row?.cnt ?? 0);
947
+ }
948
+ /**
949
+ * INSERT a row and return the newly-assigned primary key via RETURNING.
950
+ */
951
+ async insertRow(table, entity, trx) {
952
+ const scoped = this.filterToSchema(table, entity);
953
+ // Drop only undefined (caller didn't provide the field). Explicit
954
+ // null is preserved and bound as SQL NULL — matches prior behavior,
955
+ // so schema NOT NULL violations surface instead of being silently
956
+ // rewritten to DEFAULT.
957
+ const filteredKeys = Object.keys(scoped).filter((k) => scoped[k] !== undefined);
958
+ if (filteredKeys.length === 0)
959
+ throw new WERR_INTERNAL(`Cannot insert empty entity into ${table}`);
960
+ const cols = filteredKeys.map((k) => this.quoteCol(k)).join(', ');
961
+ const placeholders = filteredKeys.map(() => '?').join(', ');
962
+ const values = filteredKeys.map((k) => bindValue(scoped[k]));
963
+ const pk = this.pkColumn(table);
964
+ const returning = pk ? ` RETURNING ${this.quoteCol(pk)} AS id` : '';
965
+ const sql = `INSERT INTO ${table} (${cols}) VALUES (${placeholders})${returning}`;
966
+ const r = await this.runSql(sql, values, trx);
967
+ if (!pk)
968
+ return 0;
969
+ const id = r.rows[0]?.id;
970
+ return typeof id === 'number' ? id : Number(id ?? 0);
971
+ }
972
+ /**
973
+ * UPDATE rows, returning the number of affected rows.
974
+ */
975
+ async updateRows(table, where, update, trx) {
976
+ const scoped = this.filterToSchema(table, update);
977
+ const setClauses = [];
978
+ const setParams = [];
979
+ for (const [k, v] of Object.entries(scoped)) {
980
+ if (v === undefined)
981
+ continue;
982
+ setClauses.push(`${this.quoteCol(k)} = ?`);
983
+ setParams.push(bindValue(v));
984
+ }
985
+ if (setClauses.length === 0)
986
+ return 0;
987
+ const w = this.buildWhere(where);
988
+ let sql = `UPDATE ${table} SET ${setClauses.join(', ')}`;
989
+ if (w.clause)
990
+ sql += ` WHERE ${w.clause}`;
991
+ const params = [...setParams, ...w.params];
992
+ const r = await this.runSql(sql, params, trx);
993
+ return r.rowCount ?? 0;
994
+ }
995
+ // -----------------------------------------------------------------------
996
+ // verifyReadyForDatabaseAccess
997
+ // -----------------------------------------------------------------------
998
+ async verifyReadyForDatabaseAccess(trx) {
999
+ if (!this._settings) {
1000
+ this._settings = await this.readSettings(trx);
1001
+ }
1002
+ this._verifiedReadyForDatabaseAccess = true;
1003
+ // 'Postgres' isn't in wallet-toolbox's DBType union. Our overridden
1004
+ // validate helpers never call back into the base class's switch, so
1005
+ // the cast is safe; only the settings row's label is affected.
1006
+ return this._settings.dbtype;
1007
+ }
1008
+ // -----------------------------------------------------------------------
1009
+ // Date conversion — Postgres TIMESTAMPTZ stores Date natively. Pass Date
1010
+ // in, get Date out. Overrides the base class's dbtype-switching logic.
1011
+ // -----------------------------------------------------------------------
1012
+ validateDate(date) {
1013
+ if (typeof date === 'string') {
1014
+ const d = new Date(date);
1015
+ if (!Number.isNaN(d.getTime()))
1016
+ return d;
1017
+ return new Date();
1018
+ }
1019
+ if (typeof date === 'number')
1020
+ return new Date(date);
1021
+ if (date instanceof Date)
1022
+ return date;
1023
+ return new Date();
1024
+ }
1025
+ validateEntityDate(date) {
1026
+ // pg driver accepts Date objects directly for TIMESTAMPTZ columns.
1027
+ return this.validateDate(date);
1028
+ }
1029
+ validateOptionalEntityDate(date, useNowAsDefault) {
1030
+ if (date === null || date === undefined) {
1031
+ return useNowAsDefault ? new Date() : undefined;
1032
+ }
1033
+ return this.validateDate(date);
1034
+ }
1035
+ validateDateForWhere(date) {
1036
+ // Base class throws default on anything that isn't SQLite/MySQL/IndexedDB.
1037
+ // pg's TIMESTAMPTZ driver accepts Date directly in parameter bindings.
1038
+ if (!this.dbtype)
1039
+ throw new WERR_INTERNAL('must call verifyReadyForDatabaseAccess first');
1040
+ return this.validateDate(date);
1041
+ }
1042
+ // -----------------------------------------------------------------------
1043
+ // Validate helpers
1044
+ // -----------------------------------------------------------------------
1045
+ validatePartialForUpdate(update, dateFields, booleanFields) {
1046
+ if (!this.dbtype)
1047
+ throw new WERR_INTERNAL('must call verifyReadyForDatabaseAccess first');
1048
+ const v = update;
1049
+ if (v.created_at)
1050
+ v.created_at = this.validateEntityDate(v.created_at);
1051
+ if (v.updated_at)
1052
+ v.updated_at = this.validateEntityDate(v.updated_at);
1053
+ if (!v.created_at)
1054
+ delete v.created_at;
1055
+ if (!v.updated_at)
1056
+ v.updated_at = this.validateEntityDate(new Date());
1057
+ if (dateFields) {
1058
+ for (const df of dateFields) {
1059
+ if (v[df])
1060
+ v[df] = this.validateOptionalEntityDate(v[df]);
1061
+ }
1062
+ }
1063
+ if (booleanFields) {
1064
+ for (const df of booleanFields) {
1065
+ if (update[df] !== undefined) {
1066
+ ;
1067
+ update[df] = update[df]
1068
+ ? 1
1069
+ : 0;
1070
+ }
1071
+ }
1072
+ }
1073
+ for (const key of Object.keys(v)) {
1074
+ const val = v[key];
1075
+ if (Array.isArray(val) &&
1076
+ (val.length === 0 || typeof val[0] === 'number')) {
1077
+ v[key] = Buffer.from(val);
1078
+ }
1079
+ else if (val === undefined) {
1080
+ v[key] = null;
1081
+ }
1082
+ }
1083
+ this.isDirty = true;
1084
+ return v;
1085
+ }
1086
+ async validateEntityForInsert(entity, trx, dateFields, booleanFields) {
1087
+ await this.verifyReadyForDatabaseAccess(trx);
1088
+ const v = { ...entity };
1089
+ v.created_at = this.validateOptionalEntityDate(v.created_at, true);
1090
+ v.updated_at = this.validateOptionalEntityDate(v.updated_at, true);
1091
+ if (dateFields) {
1092
+ for (const df of dateFields) {
1093
+ if (v[df])
1094
+ v[df] = this.validateOptionalEntityDate(v[df]);
1095
+ }
1096
+ }
1097
+ if (booleanFields) {
1098
+ for (const df of booleanFields) {
1099
+ if (entity[df] !== undefined) {
1100
+ ;
1101
+ entity[df] = entity[df]
1102
+ ? 1
1103
+ : 0;
1104
+ }
1105
+ }
1106
+ }
1107
+ for (const key of Object.keys(v)) {
1108
+ const val = v[key];
1109
+ if (Array.isArray(val) &&
1110
+ (val.length === 0 || typeof val[0] === 'number')) {
1111
+ v[key] = Buffer.from(val);
1112
+ }
1113
+ else if (val === undefined) {
1114
+ v[key] = null;
1115
+ }
1116
+ }
1117
+ this.isDirty = true;
1118
+ return v;
1119
+ }
1120
+ validateEntity(entity, dateFields, booleanFields) {
1121
+ const e = entity;
1122
+ e.created_at = this.validateDate(e.created_at);
1123
+ e.updated_at = this.validateDate(e.updated_at);
1124
+ if (dateFields) {
1125
+ for (const df of dateFields) {
1126
+ if (e[df])
1127
+ e[df] = this.validateDate(e[df]);
1128
+ }
1129
+ }
1130
+ if (booleanFields) {
1131
+ for (const df of booleanFields) {
1132
+ if (e[df] !== undefined)
1133
+ e[df] = !!e[df];
1134
+ }
1135
+ }
1136
+ for (const key of Object.keys(e)) {
1137
+ const val = e[key];
1138
+ if (val === null) {
1139
+ e[key] = undefined;
1140
+ }
1141
+ else if (Buffer.isBuffer(val)) {
1142
+ e[key] = Array.from(val);
1143
+ }
1144
+ else if (val instanceof Uint8Array) {
1145
+ e[key] = Array.from(val);
1146
+ }
1147
+ }
1148
+ return entity;
1149
+ }
1150
+ validateEntities(entities, dateFields, booleanFields) {
1151
+ for (let i = 0; i < entities.length; i++) {
1152
+ entities[i] = this.validateEntity(entities[i], dateFields, booleanFields);
1153
+ }
1154
+ return entities;
1155
+ }
1156
+ // -----------------------------------------------------------------------
1157
+ // measureUsedBytes / getProvenOrRawTx / getRawTxOfKnownValidTransaction
1158
+ // -----------------------------------------------------------------------
19
1159
  /**
20
1160
  * Sum of stored bytes attributable to a single wallet-toolbox user.
21
1161
  * Per-user tables (transactions, outputs) are summed directly. Shared
22
1162
  * rows (proven_txs, proven_tx_reqs) are attributed to every user that
23
1163
  * references them via `transactions.provenTxId` or `transactions.txid`.
1164
+ * In multi-tenant deployments this over-counts aggregate disk usage
1165
+ * but reflects each user's standalone storage cost fairly. Shape matches
1166
+ * StorageBunSqlite.measureUsedBytes; OCTET_LENGTH replaces LENGTH.
24
1167
  */
25
1168
  async measureUsedBytes(userId) {
26
- const k = this.toDb(undefined);
27
- const [tx] = await k('transactions')
28
- .where({ userId })
29
- .select(k.raw('COALESCE(SUM(COALESCE(OCTET_LENGTH("rawTx"), 0) + COALESCE(OCTET_LENGTH("inputBEEF"), 0)), 0) AS total'));
30
- const [proven] = await k('proven_txs AS pt')
31
- .innerJoin('transactions AS t', 't.provenTxId', 'pt.provenTxId')
32
- .where('t.userId', userId)
33
- .select(k.raw('COALESCE(SUM(COALESCE(OCTET_LENGTH(pt."rawTx"), 0) + COALESCE(OCTET_LENGTH(pt."merklePath"), 0)), 0) AS total'));
34
- const [reqs] = await k('proven_tx_reqs AS ptr')
35
- .innerJoin('transactions AS t', 't.txid', 'ptr.txid')
36
- .where('t.userId', userId)
37
- .select(k.raw('COALESCE(SUM(COALESCE(OCTET_LENGTH(ptr."rawTx"), 0) + COALESCE(OCTET_LENGTH(ptr."inputBEEF"), 0)), 0) AS total'));
38
- const [out] = await k('outputs')
39
- .where({ userId })
40
- .select(k.raw('COALESCE(SUM(COALESCE("scriptLength", OCTET_LENGTH("lockingScript"), 0)), 0) AS total'));
1169
+ const tx = await this.getSql(`SELECT COALESCE(SUM(COALESCE(OCTET_LENGTH("rawTx"), 0) + COALESCE(OCTET_LENGTH("inputBEEF"), 0)), 0) AS total
1170
+ FROM transactions WHERE "userId" = ?`, [userId]);
1171
+ const proven = await this.getSql(`SELECT COALESCE(SUM(COALESCE(OCTET_LENGTH(pt."rawTx"), 0) + COALESCE(OCTET_LENGTH(pt."merklePath"), 0)), 0) AS total
1172
+ FROM proven_txs pt
1173
+ INNER JOIN transactions t ON t."provenTxId" = pt."provenTxId"
1174
+ WHERE t."userId" = ?`, [userId]);
1175
+ const reqs = await this.getSql(`SELECT COALESCE(SUM(COALESCE(OCTET_LENGTH(ptr."rawTx"), 0) + COALESCE(OCTET_LENGTH(ptr."inputBEEF"), 0)), 0) AS total
1176
+ FROM proven_tx_reqs ptr
1177
+ INNER JOIN transactions t ON t.txid = ptr.txid
1178
+ WHERE t."userId" = ?`, [userId]);
1179
+ const out = await this.getSql(`SELECT COALESCE(SUM(COALESCE("scriptLength", OCTET_LENGTH("lockingScript"), 0)), 0) AS total
1180
+ FROM outputs WHERE "userId" = ?`, [userId]);
1181
+ const toNum = (v) => {
1182
+ if (v == null)
1183
+ return 0;
1184
+ if (typeof v === 'number')
1185
+ return v;
1186
+ if (typeof v === 'bigint')
1187
+ return Number(v);
1188
+ const n = Number(v);
1189
+ return Number.isFinite(n) ? n : 0;
1190
+ };
41
1191
  return (toNum(tx?.total) +
42
1192
  toNum(proven?.total) +
43
1193
  toNum(reqs?.total) +
44
1194
  toNum(out?.total));
45
1195
  }
46
- }
47
- function toNum(v) {
48
- if (v == null)
49
- return 0;
50
- if (typeof v === 'number')
51
- return v;
52
- if (typeof v === 'bigint')
53
- return Number(v);
54
- const n = Number(v);
55
- return Number.isFinite(n) ? n : 0;
1196
+ async getProvenOrRawTx(txid, trx) {
1197
+ const r = {
1198
+ proven: undefined,
1199
+ rawTx: undefined,
1200
+ inputBEEF: undefined,
1201
+ };
1202
+ r.proven = verifyOneOrNone(await this.findProvenTxs({ partial: { txid }, trx }));
1203
+ if (!r.proven) {
1204
+ const row = await this.getSql(`SELECT "rawTx", "inputBEEF" FROM proven_tx_reqs WHERE txid = ? AND status IN ('unsent','unmined','unconfirmed','sending','nosend','completed')`, [txid], trx);
1205
+ if (row) {
1206
+ if (row.rawTx)
1207
+ r.rawTx = Array.from(row.rawTx);
1208
+ if (row.inputBEEF)
1209
+ r.inputBEEF = Array.from(row.inputBEEF);
1210
+ }
1211
+ }
1212
+ return r;
1213
+ }
1214
+ dbTypeSubstring(source, fromOffset, forLength) {
1215
+ return forLength !== undefined
1216
+ ? `substring(${source} from ${fromOffset} for ${forLength})`
1217
+ : `substring(${source} from ${fromOffset})`;
1218
+ }
1219
+ async getRawTxOfKnownValidTransaction(txid, offset, length, trx) {
1220
+ if (!txid)
1221
+ return undefined;
1222
+ if (!this.isAvailable())
1223
+ await this.makeAvailable();
1224
+ let rawTx = undefined;
1225
+ if (Number.isInteger(offset) && Number.isInteger(length)) {
1226
+ let row = await this.getSql(`SELECT ${this.dbTypeSubstring('"rawTx"', offset + 1, length)} as "rawTx" FROM proven_txs WHERE txid = ?`, [txid], trx);
1227
+ if (row?.rawTx) {
1228
+ rawTx = Array.from(row.rawTx);
1229
+ }
1230
+ else {
1231
+ row = await this.getSql(`SELECT ${this.dbTypeSubstring('"rawTx"', offset + 1, length)} as "rawTx" FROM proven_tx_reqs WHERE txid = ? AND status IN ('unsent','nosend','sending','unmined','completed','unfail')`, [txid], trx);
1232
+ if (row?.rawTx)
1233
+ rawTx = Array.from(row.rawTx);
1234
+ }
1235
+ }
1236
+ else {
1237
+ const r = await this.getProvenOrRawTx(txid, trx);
1238
+ if (r.proven)
1239
+ rawTx = r.proven.rawTx;
1240
+ else
1241
+ rawTx = r.rawTx;
1242
+ }
1243
+ return rawTx;
1244
+ }
1245
+ // -----------------------------------------------------------------------
1246
+ // getXxxForUser queries
1247
+ // -----------------------------------------------------------------------
1248
+ async getProvenTxsForUser(args) {
1249
+ const whereParts = [];
1250
+ const params = [];
1251
+ whereParts.push('EXISTS (SELECT * FROM transactions WHERE proven_txs."provenTxId" = transactions."provenTxId" AND transactions."userId" = ?)');
1252
+ params.push(args.userId);
1253
+ if (args.since) {
1254
+ whereParts.push('updated_at >= ?');
1255
+ params.push(this.validateDateForWhere(args.since));
1256
+ }
1257
+ let sql = `SELECT * FROM proven_txs WHERE ${whereParts.join(' AND ')}`;
1258
+ if (args.paged) {
1259
+ sql += ' LIMIT ?';
1260
+ params.push(args.paged.limit);
1261
+ if (args.paged.offset) {
1262
+ sql += ' OFFSET ?';
1263
+ params.push(args.paged.offset);
1264
+ }
1265
+ }
1266
+ return this.validateEntities((await this.allSql(sql, params, args.trx)));
1267
+ }
1268
+ async getProvenTxReqsForUser(args) {
1269
+ const whereParts = [];
1270
+ const params = [];
1271
+ whereParts.push('EXISTS (SELECT * FROM transactions WHERE proven_tx_reqs.txid = transactions.txid AND transactions."userId" = ?)');
1272
+ params.push(args.userId);
1273
+ if (args.since) {
1274
+ whereParts.push('updated_at >= ?');
1275
+ params.push(this.validateDateForWhere(args.since));
1276
+ }
1277
+ let sql = `SELECT * FROM proven_tx_reqs WHERE ${whereParts.join(' AND ')}`;
1278
+ if (args.paged) {
1279
+ sql += ' LIMIT ?';
1280
+ params.push(args.paged.limit);
1281
+ if (args.paged.offset) {
1282
+ sql += ' OFFSET ?';
1283
+ params.push(args.paged.offset);
1284
+ }
1285
+ }
1286
+ return this.validateEntities((await this.allSql(sql, params, args.trx)), undefined, ['notified']);
1287
+ }
1288
+ async getTxLabelMapsForUser(args) {
1289
+ const whereParts = [];
1290
+ const params = [];
1291
+ whereParts.push('EXISTS (SELECT * FROM tx_labels WHERE tx_labels."txLabelId" = tx_labels_map."txLabelId" AND tx_labels."userId" = ?)');
1292
+ params.push(args.userId);
1293
+ if (args.since) {
1294
+ whereParts.push('updated_at >= ?');
1295
+ params.push(this.validateDateForWhere(args.since));
1296
+ }
1297
+ let sql = `SELECT * FROM tx_labels_map WHERE ${whereParts.join(' AND ')}`;
1298
+ if (args.paged) {
1299
+ sql += ' LIMIT ?';
1300
+ params.push(args.paged.limit);
1301
+ if (args.paged.offset) {
1302
+ sql += ' OFFSET ?';
1303
+ params.push(args.paged.offset);
1304
+ }
1305
+ }
1306
+ return this.validateEntities((await this.allSql(sql, params, args.trx)), undefined, ['isDeleted']);
1307
+ }
1308
+ async getOutputTagMapsForUser(args) {
1309
+ const whereParts = [];
1310
+ const params = [];
1311
+ whereParts.push('EXISTS (SELECT * FROM output_tags WHERE output_tags."outputTagId" = output_tags_map."outputTagId" AND output_tags."userId" = ?)');
1312
+ params.push(args.userId);
1313
+ if (args.since) {
1314
+ whereParts.push('updated_at >= ?');
1315
+ params.push(this.validateDateForWhere(args.since));
1316
+ }
1317
+ let sql = `SELECT * FROM output_tags_map WHERE ${whereParts.join(' AND ')}`;
1318
+ if (args.paged) {
1319
+ sql += ' LIMIT ?';
1320
+ params.push(args.paged.limit);
1321
+ if (args.paged.offset) {
1322
+ sql += ' OFFSET ?';
1323
+ params.push(args.paged.offset);
1324
+ }
1325
+ }
1326
+ return this.validateEntities((await this.allSql(sql, params, args.trx)), undefined, ['isDeleted']);
1327
+ }
1328
+ // -----------------------------------------------------------------------
1329
+ // INSERT methods
1330
+ // -----------------------------------------------------------------------
1331
+ async insertProvenTx(tx, trx) {
1332
+ const e = (await this.validateEntityForInsert(tx, trx));
1333
+ if (e.provenTxId === 0)
1334
+ e.provenTxId = undefined;
1335
+ const id = await this.insertRow('proven_txs', e, trx);
1336
+ tx.provenTxId = id;
1337
+ return id;
1338
+ }
1339
+ async insertProvenTxReq(tx, trx) {
1340
+ const e = (await this.validateEntityForInsert(tx, trx));
1341
+ if (e.provenTxReqId === 0)
1342
+ e.provenTxReqId = undefined;
1343
+ const id = await this.insertRow('proven_tx_reqs', e, trx);
1344
+ tx.provenTxReqId = id;
1345
+ return id;
1346
+ }
1347
+ async insertUser(user, trx) {
1348
+ const e = (await this.validateEntityForInsert(user, trx));
1349
+ if (e.userId === 0)
1350
+ e.userId = undefined;
1351
+ const id = await this.insertRow('users', e, trx);
1352
+ user.userId = id;
1353
+ return id;
1354
+ }
1355
+ async insertCertificateAuth(auth, certificate) {
1356
+ if (!auth.userId ||
1357
+ (certificate.userId && certificate.userId !== auth.userId))
1358
+ throw new WERR_UNAUTHORIZED();
1359
+ certificate.userId = auth.userId;
1360
+ return await this.insertCertificate(certificate);
1361
+ }
1362
+ async insertCertificate(certificate, trx) {
1363
+ const e = (await this.validateEntityForInsert(certificate, trx, undefined, [
1364
+ 'isDeleted',
1365
+ ]));
1366
+ if (e.certificateId === 0)
1367
+ e.certificateId = undefined;
1368
+ if (e.logger)
1369
+ e.logger = undefined;
1370
+ const fields = e.fields;
1371
+ if (e.fields)
1372
+ e.fields = undefined;
1373
+ const id = await this.insertRow('certificates', e, trx);
1374
+ certificate.certificateId = id;
1375
+ if (fields) {
1376
+ for (const field of fields) {
1377
+ field.certificateId = id;
1378
+ field.userId = certificate.userId;
1379
+ await this.insertCertificateField(field, trx);
1380
+ }
1381
+ }
1382
+ return id;
1383
+ }
1384
+ async insertCertificateField(certificateField, trx) {
1385
+ const e = (await this.validateEntityForInsert(certificateField, trx));
1386
+ await this.insertRow('certificate_fields', e, trx);
1387
+ }
1388
+ async insertOutputBasket(basket, trx) {
1389
+ const e = (await this.validateEntityForInsert(basket, trx, undefined, [
1390
+ 'isDeleted',
1391
+ ]));
1392
+ if (e.basketId === 0)
1393
+ e.basketId = undefined;
1394
+ const id = await this.insertRow('output_baskets', e, trx);
1395
+ basket.basketId = id;
1396
+ return id;
1397
+ }
1398
+ async insertTransaction(tx, trx) {
1399
+ const e = (await this.validateEntityForInsert(tx, trx));
1400
+ if (e.transactionId === 0)
1401
+ e.transactionId = undefined;
1402
+ const id = await this.insertRow('transactions', e, trx);
1403
+ tx.transactionId = id;
1404
+ return id;
1405
+ }
1406
+ async insertCommission(commission, trx) {
1407
+ const e = (await this.validateEntityForInsert(commission, trx));
1408
+ if (e.commissionId === 0)
1409
+ e.commissionId = undefined;
1410
+ const id = await this.insertRow('commissions', e, trx);
1411
+ commission.commissionId = id;
1412
+ return id;
1413
+ }
1414
+ async insertOutput(output, trx) {
1415
+ const e = (await this.validateEntityForInsert(output, trx));
1416
+ if (e.outputId === 0)
1417
+ e.outputId = undefined;
1418
+ const id = await this.insertRow('outputs', e, trx);
1419
+ output.outputId = id;
1420
+ return id;
1421
+ }
1422
+ async insertOutputTag(tag, trx) {
1423
+ const e = (await this.validateEntityForInsert(tag, trx, undefined, [
1424
+ 'isDeleted',
1425
+ ]));
1426
+ if (e.outputTagId === 0)
1427
+ e.outputTagId = undefined;
1428
+ const id = await this.insertRow('output_tags', e, trx);
1429
+ tag.outputTagId = id;
1430
+ return id;
1431
+ }
1432
+ async insertOutputTagMap(tagMap, trx) {
1433
+ const e = (await this.validateEntityForInsert(tagMap, trx, undefined, [
1434
+ 'isDeleted',
1435
+ ]));
1436
+ await this.insertRow('output_tags_map', e, trx);
1437
+ }
1438
+ async insertTxLabel(label, trx) {
1439
+ const e = (await this.validateEntityForInsert(label, trx, undefined, [
1440
+ 'isDeleted',
1441
+ ]));
1442
+ if (e.txLabelId === 0)
1443
+ e.txLabelId = undefined;
1444
+ const id = await this.insertRow('tx_labels', e, trx);
1445
+ label.txLabelId = id;
1446
+ return id;
1447
+ }
1448
+ async insertTxLabelMap(labelMap, trx) {
1449
+ const e = (await this.validateEntityForInsert(labelMap, trx, undefined, [
1450
+ 'isDeleted',
1451
+ ]));
1452
+ await this.insertRow('tx_labels_map', e, trx);
1453
+ }
1454
+ async insertMonitorEvent(event, trx) {
1455
+ const e = (await this.validateEntityForInsert(event, trx));
1456
+ if (e.id === 0)
1457
+ e.id = undefined;
1458
+ const id = await this.insertRow('monitor_events', e, trx);
1459
+ event.id = id;
1460
+ return id;
1461
+ }
1462
+ async insertSyncState(syncState, trx) {
1463
+ const e = (await this.validateEntityForInsert(syncState, trx, ['when'], ['init']));
1464
+ if (e.syncStateId === 0)
1465
+ e.syncStateId = undefined;
1466
+ const id = await this.insertRow('sync_states', e, trx);
1467
+ syncState.syncStateId = id;
1468
+ return id;
1469
+ }
1470
+ // -----------------------------------------------------------------------
1471
+ // UPDATE methods
1472
+ // -----------------------------------------------------------------------
1473
+ async updateCertificateField(certificateId, fieldName, update, trx) {
1474
+ await this.verifyReadyForDatabaseAccess(trx);
1475
+ const validated = this.validatePartialForUpdate(update);
1476
+ return await this.updateRows('certificate_fields', { certificateId, fieldName }, validated, trx);
1477
+ }
1478
+ async updateCertificate(id, update, trx) {
1479
+ await this.verifyReadyForDatabaseAccess(trx);
1480
+ return await this.updateRows('certificates', { certificateId: id }, this.validatePartialForUpdate(update, undefined, ['isDeleted']), trx);
1481
+ }
1482
+ async updateCommission(id, update, trx) {
1483
+ await this.verifyReadyForDatabaseAccess(trx);
1484
+ return await this.updateRows('commissions', { commissionId: id }, this.validatePartialForUpdate(update), trx);
1485
+ }
1486
+ async updateOutputBasket(id, update, trx) {
1487
+ await this.verifyReadyForDatabaseAccess(trx);
1488
+ return await this.updateRows('output_baskets', { basketId: id }, this.validatePartialForUpdate(update, undefined, ['isDeleted']), trx);
1489
+ }
1490
+ async updateOutput(id, update, trx) {
1491
+ await this.verifyReadyForDatabaseAccess(trx);
1492
+ return await this.updateRows('outputs', { outputId: id }, this.validatePartialForUpdate(update), trx);
1493
+ }
1494
+ async updateOutputTagMap(outputId, tagId, update, trx) {
1495
+ await this.verifyReadyForDatabaseAccess(trx);
1496
+ return await this.updateRows('output_tags_map', { outputId, outputTagId: tagId }, this.validatePartialForUpdate(update, undefined, ['isDeleted']), trx);
1497
+ }
1498
+ async updateOutputTag(id, update, trx) {
1499
+ await this.verifyReadyForDatabaseAccess(trx);
1500
+ return await this.updateRows('output_tags', { outputTagId: id }, this.validatePartialForUpdate(update, undefined, ['isDeleted']), trx);
1501
+ }
1502
+ async updateProvenTxReq(id, update, trx) {
1503
+ await this.verifyReadyForDatabaseAccess(trx);
1504
+ const validated = this.filterToSchema('proven_tx_reqs', this.validatePartialForUpdate(update));
1505
+ if (Array.isArray(id)) {
1506
+ if (id.length === 0)
1507
+ return 0;
1508
+ const setClauses = [];
1509
+ const setParams = [];
1510
+ for (const [k, v] of Object.entries(validated)) {
1511
+ if (v === undefined)
1512
+ continue;
1513
+ setClauses.push(`${this.quoteCol(k)} = ?`);
1514
+ setParams.push(bindValue(v));
1515
+ }
1516
+ if (setClauses.length === 0)
1517
+ return 0;
1518
+ const placeholders = id.map(() => '?').join(',');
1519
+ const sql = `UPDATE proven_tx_reqs SET ${setClauses.join(', ')} WHERE "provenTxReqId" IN (${placeholders})`;
1520
+ const r = await this.runSql(sql, [...setParams, ...id], trx);
1521
+ return r.rowCount ?? 0;
1522
+ }
1523
+ if (!Number.isInteger(id))
1524
+ throw new WERR_INVALID_PARAMETER('id', 'transactionId or array of transactionId');
1525
+ return await this.updateRows('proven_tx_reqs', { provenTxReqId: id }, validated, trx);
1526
+ }
1527
+ async updateProvenTx(id, update, trx) {
1528
+ await this.verifyReadyForDatabaseAccess(trx);
1529
+ return await this.updateRows('proven_txs', { provenTxId: id }, this.validatePartialForUpdate(update), trx);
1530
+ }
1531
+ async updateSyncState(id, update, trx) {
1532
+ await this.verifyReadyForDatabaseAccess(trx);
1533
+ return await this.updateRows('sync_states', { syncStateId: id }, this.validatePartialForUpdate(update, ['when'], ['init']), trx);
1534
+ }
1535
+ async updateTransaction(id, update, trx) {
1536
+ await this.verifyReadyForDatabaseAccess(trx);
1537
+ const validated = this.filterToSchema('transactions', this.validatePartialForUpdate(update));
1538
+ if (Array.isArray(id)) {
1539
+ if (id.length === 0)
1540
+ return 0;
1541
+ const setClauses = [];
1542
+ const setParams = [];
1543
+ for (const [k, v] of Object.entries(validated)) {
1544
+ if (v === undefined)
1545
+ continue;
1546
+ setClauses.push(`${this.quoteCol(k)} = ?`);
1547
+ setParams.push(bindValue(v));
1548
+ }
1549
+ if (setClauses.length === 0)
1550
+ return 0;
1551
+ const placeholders = id.map(() => '?').join(',');
1552
+ const sql = `UPDATE transactions SET ${setClauses.join(', ')} WHERE "transactionId" IN (${placeholders})`;
1553
+ const r = await this.runSql(sql, [...setParams, ...id], trx);
1554
+ return r.rowCount ?? 0;
1555
+ }
1556
+ if (!Number.isInteger(id))
1557
+ throw new WERR_INVALID_PARAMETER('id', 'transactionId or array of transactionId');
1558
+ return await this.updateRows('transactions', { transactionId: id }, validated, trx);
1559
+ }
1560
+ async updateTxLabelMap(transactionId, txLabelId, update, trx) {
1561
+ await this.verifyReadyForDatabaseAccess(trx);
1562
+ return await this.updateRows('tx_labels_map', { transactionId, txLabelId }, this.validatePartialForUpdate(update, undefined, ['isDeleted']), trx);
1563
+ }
1564
+ async updateTxLabel(id, update, trx) {
1565
+ await this.verifyReadyForDatabaseAccess(trx);
1566
+ return await this.updateRows('tx_labels', { txLabelId: id }, this.validatePartialForUpdate(update, undefined, ['isDeleted']), trx);
1567
+ }
1568
+ async updateUser(id, update, trx) {
1569
+ await this.verifyReadyForDatabaseAccess(trx);
1570
+ return await this.updateRows('users', { userId: id }, this.validatePartialForUpdate(update), trx);
1571
+ }
1572
+ async updateMonitorEvent(id, update, trx) {
1573
+ await this.verifyReadyForDatabaseAccess(trx);
1574
+ return await this.updateRows('monitor_events', { id }, this.validatePartialForUpdate(update), trx);
1575
+ }
1576
+ // -----------------------------------------------------------------------
1577
+ // FIND methods
1578
+ // -----------------------------------------------------------------------
1579
+ async findCertificateFields(args) {
1580
+ return this.validateEntities((await this.selectQuery('certificate_fields', args)));
1581
+ }
1582
+ async findCertificates(args) {
1583
+ let extraWhere = '';
1584
+ const extraParams = [];
1585
+ if (args.certifiers && args.certifiers.length > 0) {
1586
+ extraWhere += `certifier IN (${args.certifiers.map(() => '?').join(',')})`;
1587
+ extraParams.push(...args.certifiers);
1588
+ }
1589
+ if (args.types && args.types.length > 0) {
1590
+ const typeClause = `type IN (${args.types.map(() => '?').join(',')})`;
1591
+ extraWhere = extraWhere ? `${extraWhere} AND ${typeClause}` : typeClause;
1592
+ extraParams.push(...args.types);
1593
+ }
1594
+ const r = this.validateEntities((await this.selectQuery('certificates', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined)), undefined, ['isDeleted']);
1595
+ if (args.includeFields) {
1596
+ for (const c of r) {
1597
+ c.fields = this.validateEntities(await this.findCertificateFields({
1598
+ partial: { certificateId: c.certificateId, userId: c.userId },
1599
+ trx: args.trx,
1600
+ }));
1601
+ }
1602
+ }
1603
+ return r;
1604
+ }
1605
+ async findCommissions(args) {
1606
+ if (args.partial.lockingScript)
1607
+ throw new WERR_INVALID_PARAMETER('partial.lockingScript', 'undefined. Commissions may not be found by lockingScript value.');
1608
+ return this.validateEntities((await this.selectQuery('commissions', args)), undefined, ['isRedeemed']);
1609
+ }
1610
+ async findOutputBaskets(args) {
1611
+ return this.validateEntities((await this.selectQuery('output_baskets', args)), undefined, ['isDeleted']);
1612
+ }
1613
+ async findOutputs(args, tagIds, isQueryModeAll) {
1614
+ if (args.partial.lockingScript)
1615
+ throw new WERR_INVALID_PARAMETER('args.partial.lockingScript', 'undefined. Outputs may not be found by lockingScript value.');
1616
+ let extraWhere = '';
1617
+ const extraParams = [];
1618
+ if (args.txStatus && args.txStatus.length > 0) {
1619
+ const statusList = args.txStatus.map((s) => `'${s}'`).join(',');
1620
+ extraWhere = `(SELECT status FROM transactions WHERE transactions."transactionId" = outputs."transactionId") IN (${statusList})`;
1621
+ }
1622
+ const tagClause = buildOutputTagFilterSql(tagIds, isQueryModeAll);
1623
+ if (tagClause) {
1624
+ extraWhere = extraWhere ? `${extraWhere} AND ${tagClause.sql}` : tagClause.sql;
1625
+ extraParams.push(...tagClause.params);
1626
+ }
1627
+ const columns = args.noScript
1628
+ ? outputColumnsWithoutLockingScript.map((c) => `outputs.${this.quoteCol(c)}`)
1629
+ : undefined;
1630
+ const r = (await this.selectQuery('outputs', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined, columns));
1631
+ if (!args.noScript) {
1632
+ for (const o of r) {
1633
+ await this.validateOutputScript(o, args.trx);
1634
+ }
1635
+ }
1636
+ return this.validateEntities(r, undefined, ['spendable', 'change']);
1637
+ }
1638
+ async findOutputTagMaps(args) {
1639
+ let extraWhere = '';
1640
+ const extraParams = [];
1641
+ if (args.tagIds && args.tagIds.length > 0) {
1642
+ extraWhere = `"outputTagId" IN (${args.tagIds.map(() => '?').join(',')})`;
1643
+ extraParams.push(...args.tagIds);
1644
+ }
1645
+ return this.validateEntities((await this.selectQuery('output_tags_map', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined)), undefined, ['isDeleted']);
1646
+ }
1647
+ async findOutputTags(args) {
1648
+ return this.validateEntities((await this.selectQuery('output_tags', args)), undefined, ['isDeleted']);
1649
+ }
1650
+ async findProvenTxReqs(args) {
1651
+ if (args.partial.rawTx)
1652
+ throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. ProvenTxReqs may not be found by rawTx value.');
1653
+ if (args.partial.inputBEEF)
1654
+ throw new WERR_INVALID_PARAMETER('args.partial.inputBEEF', 'undefined. ProvenTxReqs may not be found by inputBEEF value.');
1655
+ let extraWhere = '';
1656
+ const extraParams = [];
1657
+ if (args.status && args.status.length > 0) {
1658
+ extraWhere = `status IN (${args.status.map(() => '?').join(',')})`;
1659
+ extraParams.push(...args.status);
1660
+ }
1661
+ if (args.txids) {
1662
+ const txids = args.txids.filter((t) => t !== undefined);
1663
+ if (txids.length > 0) {
1664
+ const txidClause = `txid IN (${txids.map(() => '?').join(',')})`;
1665
+ extraWhere = extraWhere ? `${extraWhere} AND ${txidClause}` : txidClause;
1666
+ extraParams.push(...txids);
1667
+ }
1668
+ }
1669
+ return this.validateEntities((await this.selectQuery('proven_tx_reqs', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined)), undefined, ['notified']);
1670
+ }
1671
+ async findProvenTxs(args) {
1672
+ if (args.partial.rawTx)
1673
+ throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. ProvenTxs may not be found by rawTx value.');
1674
+ if (args.partial.merklePath)
1675
+ throw new WERR_INVALID_PARAMETER('args.partial.merklePath', 'undefined. ProvenTxs may not be found by merklePath value.');
1676
+ return this.validateEntities((await this.selectQuery('proven_txs', args)));
1677
+ }
1678
+ async findSyncStates(args) {
1679
+ return this.validateEntities((await this.selectQuery('sync_states', args)), ['when'], ['init']);
1680
+ }
1681
+ async findTransactions(args, labelIds, isQueryModeAll) {
1682
+ if (args.partial.rawTx)
1683
+ throw new WERR_INVALID_PARAMETER('args.partial.rawTx', 'undefined. Transactions may not be found by rawTx value.');
1684
+ if (args.partial.inputBEEF)
1685
+ throw new WERR_INVALID_PARAMETER('args.partial.inputBEEF', 'undefined. Transactions may not be found by inputBEEF value.');
1686
+ let extraWhere = '';
1687
+ const extraParams = [];
1688
+ if (args.status && args.status.length > 0) {
1689
+ extraWhere = `status IN (${args.status.map(() => '?').join(',')})`;
1690
+ extraParams.push(...args.status);
1691
+ }
1692
+ if (args.from) {
1693
+ const fromClause = 'created_at >= ?';
1694
+ extraWhere = extraWhere ? `${extraWhere} AND ${fromClause}` : fromClause;
1695
+ extraParams.push(this.validateDateForWhere(args.from));
1696
+ }
1697
+ if (args.to) {
1698
+ const toClause = 'created_at < ?';
1699
+ extraWhere = extraWhere ? `${extraWhere} AND ${toClause}` : toClause;
1700
+ extraParams.push(this.validateDateForWhere(args.to));
1701
+ }
1702
+ const labelClause = buildTxLabelFilterSql(labelIds, isQueryModeAll);
1703
+ if (labelClause) {
1704
+ extraWhere = extraWhere ? `${extraWhere} AND ${labelClause.sql}` : labelClause.sql;
1705
+ extraParams.push(...labelClause.params);
1706
+ }
1707
+ const columns = args.noRawTx
1708
+ ? transactionColumnsWithoutRawTx.map((c) => `transactions.${this.quoteCol(c)}`)
1709
+ : undefined;
1710
+ const r = (await this.selectQuery('transactions', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined, columns));
1711
+ if (!args.noRawTx) {
1712
+ for (const t of r) {
1713
+ await this.validateRawTransaction(t, args.trx);
1714
+ }
1715
+ }
1716
+ return this.validateEntities(r, undefined, ['isOutgoing']);
1717
+ }
1718
+ async findTxLabelMaps(args) {
1719
+ let extraWhere = '';
1720
+ const extraParams = [];
1721
+ if (args.labelIds && args.labelIds.length > 0) {
1722
+ extraWhere = `"txLabelId" IN (${args.labelIds.map(() => '?').join(',')})`;
1723
+ extraParams.push(...args.labelIds);
1724
+ }
1725
+ return this.validateEntities((await this.selectQuery('tx_labels_map', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined)), undefined, ['isDeleted']);
1726
+ }
1727
+ async findTxLabels(args) {
1728
+ return this.validateEntities((await this.selectQuery('tx_labels', args)), undefined, ['isDeleted']);
1729
+ }
1730
+ async findUsers(args) {
1731
+ return this.validateEntities((await this.selectQuery('users', args)));
1732
+ }
1733
+ /**
1734
+ * SQL-backed implementation replacing the base-class JS scan. Matches
1735
+ * StorageBunSqlite.recentlyActiveUsers and Knex canon at
1736
+ * StorageKnex.ts:796.
1737
+ */
1738
+ async recentlyActiveUsers(limit = 50, trx) {
1739
+ const rows = await this.allSql(`SELECT u.*
1740
+ FROM users u
1741
+ JOIN (
1742
+ SELECT "userId", MAX(created_at) AS "lastOutputCreatedAt"
1743
+ FROM outputs
1744
+ GROUP BY "userId"
1745
+ ) latest ON u."userId" = latest."userId"
1746
+ ORDER BY latest."lastOutputCreatedAt" DESC
1747
+ LIMIT ?`, [limit], trx);
1748
+ return this.validateEntities(rows);
1749
+ }
1750
+ async findMonitorEvents(args) {
1751
+ return this.validateEntities((await this.selectQuery('monitor_events', args)), ['when'], undefined);
1752
+ }
1753
+ // -----------------------------------------------------------------------
1754
+ // COUNT methods
1755
+ // -----------------------------------------------------------------------
1756
+ async countCertificateFields(args) {
1757
+ return await this.countQuery('certificate_fields', args);
1758
+ }
1759
+ async countCertificates(args) {
1760
+ let extraWhere = '';
1761
+ const extraParams = [];
1762
+ if (args.certifiers && args.certifiers.length > 0) {
1763
+ extraWhere += `certifier IN (${args.certifiers.map(() => '?').join(',')})`;
1764
+ extraParams.push(...args.certifiers);
1765
+ }
1766
+ if (args.types && args.types.length > 0) {
1767
+ const typeClause = `type IN (${args.types.map(() => '?').join(',')})`;
1768
+ extraWhere = extraWhere ? `${extraWhere} AND ${typeClause}` : typeClause;
1769
+ extraParams.push(...args.types);
1770
+ }
1771
+ return await this.countQuery('certificates', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined);
1772
+ }
1773
+ async countCommissions(args) {
1774
+ return await this.countQuery('commissions', args);
1775
+ }
1776
+ async countOutputBaskets(args) {
1777
+ return await this.countQuery('output_baskets', args);
1778
+ }
1779
+ async countOutputs(args, tagIds, isQueryModeAll) {
1780
+ let extraWhere = '';
1781
+ const extraParams = [];
1782
+ if (args.txStatus && args.txStatus.length > 0) {
1783
+ const statusList = args.txStatus.map((s) => `'${s}'`).join(',');
1784
+ extraWhere = `(SELECT status FROM transactions WHERE transactions."transactionId" = outputs."transactionId") IN (${statusList})`;
1785
+ }
1786
+ const tagClause = buildOutputTagFilterSql(tagIds, isQueryModeAll);
1787
+ if (tagClause) {
1788
+ extraWhere = extraWhere ? `${extraWhere} AND ${tagClause.sql}` : tagClause.sql;
1789
+ extraParams.push(...tagClause.params);
1790
+ }
1791
+ return await this.countQuery('outputs', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined);
1792
+ }
1793
+ async countOutputTagMaps(args) {
1794
+ let extraWhere = '';
1795
+ const extraParams = [];
1796
+ if (args.tagIds && args.tagIds.length > 0) {
1797
+ extraWhere = `"outputTagId" IN (${args.tagIds.map(() => '?').join(',')})`;
1798
+ extraParams.push(...args.tagIds);
1799
+ }
1800
+ return await this.countQuery('output_tags_map', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined);
1801
+ }
1802
+ async countOutputTags(args) {
1803
+ return await this.countQuery('output_tags', args);
1804
+ }
1805
+ async countProvenTxReqs(args) {
1806
+ let extraWhere = '';
1807
+ const extraParams = [];
1808
+ if (args.status && args.status.length > 0) {
1809
+ extraWhere = `status IN (${args.status.map(() => '?').join(',')})`;
1810
+ extraParams.push(...args.status);
1811
+ }
1812
+ if (args.txids) {
1813
+ const txids = args.txids.filter((t) => t !== undefined);
1814
+ if (txids.length > 0) {
1815
+ const txidClause = `txid IN (${txids.map(() => '?').join(',')})`;
1816
+ extraWhere = extraWhere ? `${extraWhere} AND ${txidClause}` : txidClause;
1817
+ extraParams.push(...txids);
1818
+ }
1819
+ }
1820
+ return await this.countQuery('proven_tx_reqs', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined);
1821
+ }
1822
+ async countProvenTxs(args) {
1823
+ return await this.countQuery('proven_txs', args);
1824
+ }
1825
+ async countSyncStates(args) {
1826
+ return await this.countQuery('sync_states', args);
1827
+ }
1828
+ async countTransactions(args, labelIds, isQueryModeAll) {
1829
+ let extraWhere = '';
1830
+ const extraParams = [];
1831
+ if (args.status && args.status.length > 0) {
1832
+ extraWhere = `status IN (${args.status.map(() => '?').join(',')})`;
1833
+ extraParams.push(...args.status);
1834
+ }
1835
+ if (args.from) {
1836
+ const c = 'created_at >= ?';
1837
+ extraWhere = extraWhere ? `${extraWhere} AND ${c}` : c;
1838
+ extraParams.push(this.validateDateForWhere(args.from));
1839
+ }
1840
+ if (args.to) {
1841
+ const c = 'created_at < ?';
1842
+ extraWhere = extraWhere ? `${extraWhere} AND ${c}` : c;
1843
+ extraParams.push(this.validateDateForWhere(args.to));
1844
+ }
1845
+ const labelClause = buildTxLabelFilterSql(labelIds, isQueryModeAll);
1846
+ if (labelClause) {
1847
+ extraWhere = extraWhere ? `${extraWhere} AND ${labelClause.sql}` : labelClause.sql;
1848
+ extraParams.push(...labelClause.params);
1849
+ }
1850
+ return await this.countQuery('transactions', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined);
1851
+ }
1852
+ async countTxLabelMaps(args) {
1853
+ let extraWhere = '';
1854
+ const extraParams = [];
1855
+ if (args.labelIds && args.labelIds.length > 0) {
1856
+ extraWhere = `"txLabelId" IN (${args.labelIds.map(() => '?').join(',')})`;
1857
+ extraParams.push(...args.labelIds);
1858
+ }
1859
+ return await this.countQuery('tx_labels_map', args, extraWhere || undefined, extraParams.length > 0 ? extraParams : undefined);
1860
+ }
1861
+ async countTxLabels(args) {
1862
+ return await this.countQuery('tx_labels', args);
1863
+ }
1864
+ async countUsers(args) {
1865
+ return await this.countQuery('users', args);
1866
+ }
1867
+ async countMonitorEvents(args) {
1868
+ return await this.countQuery('monitor_events', args);
1869
+ }
1870
+ async countChangeInputs(userId, basketId, excludeSending) {
1871
+ const status = ['completed', 'unproven'];
1872
+ if (!excludeSending)
1873
+ status.push('sending');
1874
+ const statusText = status.map((s) => `'${s}'`).join(',');
1875
+ const txStatusCondition = `(SELECT status FROM transactions WHERE outputs."transactionId" = transactions."transactionId") IN (${statusText})`;
1876
+ const row = await this.getSql(`SELECT COUNT(*) as cnt FROM outputs WHERE "userId" = ? AND spendable = 1 AND "basketId" = ? AND ${txStatusCondition}`, [userId, basketId]);
1877
+ return Number(row?.cnt ?? 0);
1878
+ }
1879
+ // -----------------------------------------------------------------------
1880
+ // Auth-gated find methods
1881
+ // -----------------------------------------------------------------------
1882
+ async findCertificatesAuth(auth, args) {
1883
+ if (!auth.userId ||
1884
+ (args.partial.userId &&
1885
+ args.partial.userId !== auth.userId))
1886
+ throw new WERR_UNAUTHORIZED();
1887
+ args.partial.userId = auth.userId;
1888
+ return await this.findCertificates(args);
1889
+ }
1890
+ async findOutputBasketsAuth(auth, args) {
1891
+ if (!auth.userId ||
1892
+ (args.partial.userId &&
1893
+ args.partial.userId !== auth.userId))
1894
+ throw new WERR_UNAUTHORIZED();
1895
+ args.partial.userId = auth.userId;
1896
+ return await this.findOutputBaskets(args);
1897
+ }
1898
+ async findOutputsAuth(auth, args) {
1899
+ if (!auth.userId ||
1900
+ (args.partial.userId &&
1901
+ args.partial.userId !== auth.userId))
1902
+ throw new WERR_UNAUTHORIZED();
1903
+ args.partial.userId = auth.userId;
1904
+ return await this.findOutputs(args);
1905
+ }
1906
+ // -----------------------------------------------------------------------
1907
+ // Label / Tag helpers
1908
+ // -----------------------------------------------------------------------
1909
+ async getLabelsForTransactionId(transactionId, trx) {
1910
+ if (transactionId === undefined)
1911
+ return [];
1912
+ const rows = await this.allSql(`SELECT tx_labels.* FROM tx_labels
1913
+ JOIN tx_labels_map ON tx_labels_map."txLabelId" = tx_labels."txLabelId"
1914
+ WHERE tx_labels_map."transactionId" = ? AND tx_labels_map."isDeleted" != 1 AND tx_labels."isDeleted" != 1`, [transactionId], trx);
1915
+ return this.validateEntities(rows, undefined, ['isDeleted']);
1916
+ }
1917
+ async getTagsForOutputId(outputId, trx) {
1918
+ const rows = await this.allSql(`SELECT output_tags.* FROM output_tags
1919
+ JOIN output_tags_map ON output_tags_map."outputTagId" = output_tags."outputTagId"
1920
+ WHERE output_tags_map."outputId" = ? AND output_tags_map."isDeleted" != 1 AND output_tags."isDeleted" != 1`, [outputId], trx);
1921
+ return this.validateEntities(rows, undefined, ['isDeleted']);
1922
+ }
1923
+ // -----------------------------------------------------------------------
1924
+ // allocateChangeInput
1925
+ // -----------------------------------------------------------------------
1926
+ async allocateChangeInput(userId, basketId, targetSatoshis, exactSatoshis, excludeSending, transactionId) {
1927
+ const status = ['completed', 'unproven'];
1928
+ if (!excludeSending)
1929
+ status.push('sending');
1930
+ const statusText = status.map((s) => `'${s}'`).join(',');
1931
+ const txStatusCondition = `AND (SELECT status FROM transactions WHERE outputs."transactionId" = transactions."transactionId") IN (${statusText})`;
1932
+ const r = await this.transaction(async (trx) => {
1933
+ let outputId;
1934
+ // These inlined SQL fragments embed userId/basketId/targetSatoshis
1935
+ // as literals (they are validated integers from call sites), which
1936
+ // matches the StorageBunSqlite structure. Parameter binding here
1937
+ // would complicate the correlated-subquery shape without changing
1938
+ // the SQL semantics.
1939
+ const setOutputId = async (rawQuery) => {
1940
+ const row = await this.getSql(rawQuery, [], trx);
1941
+ outputId = row?.outputId;
1942
+ };
1943
+ if (exactSatoshis !== undefined) {
1944
+ await setOutputId(`
1945
+ SELECT "outputId" FROM outputs
1946
+ WHERE "userId" = ${userId} AND spendable = 1 AND "basketId" = ${basketId}
1947
+ ${txStatusCondition} AND satoshis = ${exactSatoshis}
1948
+ LIMIT 1
1949
+ `);
1950
+ }
1951
+ if (outputId === undefined) {
1952
+ await setOutputId(`
1953
+ SELECT "outputId" FROM outputs
1954
+ WHERE "userId" = ${userId} AND spendable = 1 AND "basketId" = ${basketId}
1955
+ ${txStatusCondition}
1956
+ AND satoshis - ${targetSatoshis} = (
1957
+ SELECT MIN(satoshis - ${targetSatoshis}) FROM outputs
1958
+ WHERE "userId" = ${userId} AND spendable = 1 AND "basketId" = ${basketId}
1959
+ ${txStatusCondition} AND satoshis - ${targetSatoshis} >= 0
1960
+ )
1961
+ LIMIT 1
1962
+ `);
1963
+ }
1964
+ if (outputId === undefined) {
1965
+ await setOutputId(`
1966
+ SELECT "outputId" FROM outputs
1967
+ WHERE "userId" = ${userId} AND spendable = 1 AND "basketId" = ${basketId}
1968
+ ${txStatusCondition}
1969
+ AND satoshis - ${targetSatoshis} = (
1970
+ SELECT MAX(satoshis - ${targetSatoshis}) FROM outputs
1971
+ WHERE "userId" = ${userId} AND spendable = 1 AND "basketId" = ${basketId}
1972
+ ${txStatusCondition} AND satoshis - ${targetSatoshis} < 0
1973
+ )
1974
+ LIMIT 1
1975
+ `);
1976
+ }
1977
+ if (outputId === undefined)
1978
+ return undefined;
1979
+ await this.updateOutput(outputId, { spendable: false, spentBy: transactionId }, trx);
1980
+ const output = verifyTruthy(await this.findOutputById(outputId, trx));
1981
+ // Hydrate locking script if it was offloaded due to exceeding
1982
+ // maxOutputScript. MED-14: only the chosen output needs hydration,
1983
+ // not every candidate during the SELECT scan.
1984
+ await this.validateOutputScript(output, trx);
1985
+ return output;
1986
+ });
1987
+ return r;
1988
+ }
1989
+ // -----------------------------------------------------------------------
1990
+ // validateRawTransaction
1991
+ // -----------------------------------------------------------------------
1992
+ async validateRawTransaction(t, trx) {
1993
+ if (t.rawTx || !t.txid)
1994
+ return;
1995
+ const rawTx = await this.getRawTxOfKnownValidTransaction(t.txid, undefined, undefined, trx);
1996
+ if (!rawTx)
1997
+ return;
1998
+ t.rawTx = rawTx;
1999
+ }
2000
+ // -----------------------------------------------------------------------
2001
+ // listActions
2002
+ // -----------------------------------------------------------------------
2003
+ async listActions(auth, vargs) {
2004
+ if (!auth.userId)
2005
+ throw new WERR_UNAUTHORIZED();
2006
+ const limit = vargs.limit;
2007
+ const offset = vargs.offset;
2008
+ const r = { totalActions: 0, actions: [] };
2009
+ const { from: actionTimeFrom, to: actionTimeTo, timeFilterRequested, remainingLabels: ordinaryLabelsPreSpecOp, } = parseBrc114ActionTimeLabels(vargs.labels);
2010
+ const createdAtFrom = actionTimeFrom !== undefined ? new Date(actionTimeFrom) : undefined;
2011
+ const createdAtTo = actionTimeTo !== undefined ? new Date(actionTimeTo) : undefined;
2012
+ let specOp = undefined;
2013
+ let specOpLabels = [];
2014
+ let labels = [];
2015
+ for (const label of ordinaryLabelsPreSpecOp) {
2016
+ if (isListActionsSpecOp(label)) {
2017
+ specOp = getLabelToSpecOp()[label];
2018
+ }
2019
+ else {
2020
+ labels.push(label);
2021
+ }
2022
+ }
2023
+ if (specOp?.labelsToIntercept !== undefined) {
2024
+ const intercept = specOp.labelsToIntercept;
2025
+ const labels2 = labels;
2026
+ labels = [];
2027
+ if (intercept.length === 0) {
2028
+ specOpLabels = labels2;
2029
+ }
2030
+ for (const label of labels2) {
2031
+ if (intercept.indexOf(label) >= 0) {
2032
+ specOpLabels.push(label);
2033
+ }
2034
+ else {
2035
+ labels.push(label);
2036
+ }
2037
+ }
2038
+ }
2039
+ let labelIds = [];
2040
+ if (labels.length > 0) {
2041
+ const placeholders = labels.map(() => '?').join(',');
2042
+ const rows = await this.allSql(`SELECT "txLabelId" FROM tx_labels WHERE "userId" = ? AND "isDeleted" = 0 AND "txLabelId" IS NOT NULL AND label IN (${placeholders})`, [auth.userId, ...labels]);
2043
+ labelIds = rows.map((r) => r.txLabelId);
2044
+ }
2045
+ const isQueryModeAll = vargs.labelQueryMode === 'all';
2046
+ if (isQueryModeAll && labelIds.length < labels.length)
2047
+ return r;
2048
+ if (!isQueryModeAll && labelIds.length === 0 && labels.length > 0)
2049
+ return r;
2050
+ const columns = [
2051
+ 'created_at',
2052
+ '"transactionId"',
2053
+ 'reference',
2054
+ 'txid',
2055
+ 'satoshis',
2056
+ 'status',
2057
+ '"isOutgoing"',
2058
+ 'description',
2059
+ 'version',
2060
+ '"lockTime"',
2061
+ ];
2062
+ const stati = specOp?.setStatusFilter
2063
+ ? specOp.setStatusFilter()
2064
+ : [
2065
+ 'completed',
2066
+ 'unprocessed',
2067
+ 'sending',
2068
+ 'unproven',
2069
+ 'unsigned',
2070
+ 'nosend',
2071
+ 'nonfinal',
2072
+ ];
2073
+ const statusText = stati.map((s) => `'${s}'`).join(',');
2074
+ const noLabels = labelIds.length === 0;
2075
+ const buildTimestampFilter = () => {
2076
+ if (!timeFilterRequested)
2077
+ return { clause: '', params: [] };
2078
+ const parts = ['created_at IS NOT NULL'];
2079
+ const params = [];
2080
+ if (createdAtFrom) {
2081
+ parts.push('created_at >= ?');
2082
+ params.push(this.validateDateForWhere(createdAtFrom));
2083
+ }
2084
+ if (createdAtTo) {
2085
+ parts.push('created_at < ?');
2086
+ params.push(this.validateDateForWhere(createdAtTo));
2087
+ }
2088
+ return { clause: parts.join(' AND '), params };
2089
+ };
2090
+ let txs;
2091
+ let totalActions;
2092
+ if (noLabels) {
2093
+ const tsFilter = buildTimestampFilter();
2094
+ const whereParts = ['"userId" = ?', `status IN (${statusText})`];
2095
+ const params = [auth.userId];
2096
+ if (tsFilter.clause) {
2097
+ whereParts.push(tsFilter.clause);
2098
+ params.push(...tsFilter.params);
2099
+ }
2100
+ const whereStr = whereParts.join(' AND ');
2101
+ const countRow = await this.getSql(`SELECT COUNT("transactionId") as total FROM transactions WHERE ${whereStr}`, params);
2102
+ totalActions = Number(countRow?.total ?? 0);
2103
+ txs = await this.allSql(`SELECT ${columns.join(',')} FROM transactions WHERE ${whereStr} ORDER BY "transactionId" ASC LIMIT ? OFFSET ?`, [...params, limit, offset]);
2104
+ }
2105
+ else {
2106
+ const labelIdList = labelIds.join(',');
2107
+ const tsFilter = buildTimestampFilter();
2108
+ const cteSql = `
2109
+ SELECT ${columns.map((c) => `t.${c.startsWith('"') ? c : `"${c}"`}`).join(',')},
2110
+ (SELECT COUNT(*) FROM tx_labels_map AS m
2111
+ WHERE m."transactionId" = t."transactionId" AND m."txLabelId" IN (${labelIdList})) AS lc
2112
+ FROM transactions AS t
2113
+ WHERE t."userId" = ? AND t.status IN (${statusText})
2114
+ `;
2115
+ const cteParams = [auth.userId];
2116
+ const filterParts = [];
2117
+ if (isQueryModeAll)
2118
+ filterParts.push(`lc = ${labelIds.length}`);
2119
+ else
2120
+ filterParts.push('lc > 0');
2121
+ if (tsFilter.clause) {
2122
+ filterParts.push(tsFilter.clause);
2123
+ cteParams.push(...tsFilter.params);
2124
+ }
2125
+ const filterStr = filterParts.join(' AND ');
2126
+ const countRow = await this.getSql(`WITH tlc AS (${cteSql}) SELECT COUNT("transactionId") as total FROM tlc WHERE ${filterStr}`, cteParams);
2127
+ totalActions = Number(countRow?.total ?? 0);
2128
+ txs = await this.allSql(`WITH tlc AS (${cteSql}) SELECT ${columns.join(',')} FROM tlc WHERE ${filterStr} ORDER BY "transactionId" ASC LIMIT ? OFFSET ?`, [...cteParams, limit, offset]);
2129
+ }
2130
+ if (specOp?.postProcess) {
2131
+ await specOp.postProcess(this, auth, vargs, specOpLabels, txs);
2132
+ }
2133
+ if (!limit)
2134
+ r.totalActions = txs.length;
2135
+ else if (txs.length < limit)
2136
+ r.totalActions = (offset || 0) + txs.length;
2137
+ else
2138
+ r.totalActions = totalActions;
2139
+ for (const tx of txs) {
2140
+ r.actions.push({
2141
+ txid: tx.txid || '',
2142
+ satoshis: Number(tx.satoshis || 0),
2143
+ status: tx.status,
2144
+ isOutgoing: !!tx.isOutgoing,
2145
+ description: tx.description || '',
2146
+ version: Number(tx.version || 0),
2147
+ lockTime: Number(tx.lockTime || 0),
2148
+ });
2149
+ }
2150
+ if (vargs.includeLabels || vargs.includeInputs || vargs.includeOutputs) {
2151
+ await Promise.all(txs.map(async (tx, i) => {
2152
+ const action = r.actions[i];
2153
+ if (vargs.includeLabels) {
2154
+ action.labels = (await this.getLabelsForTransactionId(tx.transactionId)).map((l) => l.label);
2155
+ if (timeFilterRequested) {
2156
+ const ts = tx.created_at
2157
+ ? new Date(tx.created_at).getTime()
2158
+ : Number.NaN;
2159
+ if (!Number.isNaN(ts)) {
2160
+ const timeLabel = makeBrc114ActionTimeLabel(ts);
2161
+ if (!action.labels.includes(timeLabel))
2162
+ action.labels.push(timeLabel);
2163
+ }
2164
+ }
2165
+ }
2166
+ if (vargs.includeOutputs) {
2167
+ const outputs = await this.findOutputs({
2168
+ partial: { transactionId: tx.transactionId },
2169
+ noScript: !vargs.includeOutputLockingScripts,
2170
+ });
2171
+ action.outputs = [];
2172
+ for (const o of outputs) {
2173
+ await this.extendOutput(o, true, true);
2174
+ const ox = o;
2175
+ const wo = {
2176
+ satoshis: Number(o.satoshis || 0),
2177
+ spendable: !!o.spendable,
2178
+ tags: ox.tags?.map((t) => t.tag) || [],
2179
+ outputIndex: Number(o.vout),
2180
+ outputDescription: o.outputDescription || '',
2181
+ basket: ox.basket?.name || '',
2182
+ };
2183
+ if (vargs.includeOutputLockingScripts)
2184
+ wo.lockingScript = asString(o.lockingScript || []);
2185
+ action.outputs.push(wo);
2186
+ }
2187
+ }
2188
+ if (vargs.includeInputs) {
2189
+ const inputs = await this.findOutputs({
2190
+ partial: { spentBy: tx.transactionId },
2191
+ noScript: !vargs.includeInputSourceLockingScripts,
2192
+ });
2193
+ action.inputs = [];
2194
+ if (inputs.length > 0) {
2195
+ const rawTx = await this.getRawTxOfKnownValidTransaction(tx.txid);
2196
+ let bsvTx = undefined;
2197
+ if (rawTx)
2198
+ bsvTx = BsvTransaction.fromBinary(rawTx);
2199
+ for (const o of inputs) {
2200
+ await this.extendOutput(o, true, true);
2201
+ const input = bsvTx?.inputs.find((v) => v.sourceTXID === o.txid && v.sourceOutputIndex === o.vout);
2202
+ const wo = {
2203
+ sourceOutpoint: `${o.txid}.${o.vout}`,
2204
+ sourceSatoshis: Number(o.satoshis || 0),
2205
+ inputDescription: o.outputDescription || '',
2206
+ sequenceNumber: input?.sequence || 0,
2207
+ };
2208
+ action.inputs.push(wo);
2209
+ if (vargs.includeInputSourceLockingScripts) {
2210
+ wo.sourceLockingScript = asString(o.lockingScript || []);
2211
+ }
2212
+ if (vargs.includeInputUnlockingScripts) {
2213
+ wo.unlockingScript = input?.unlockingScript?.toHex();
2214
+ }
2215
+ }
2216
+ }
2217
+ }
2218
+ }));
2219
+ }
2220
+ return r;
2221
+ }
2222
+ // -----------------------------------------------------------------------
2223
+ // listOutputs
2224
+ // -----------------------------------------------------------------------
2225
+ async listOutputs(auth, vargs) {
2226
+ if (!auth.userId)
2227
+ throw new WERR_UNAUTHORIZED();
2228
+ const trx = undefined;
2229
+ const userId = verifyId(auth.userId);
2230
+ const limit = vargs.limit;
2231
+ let offset = vargs.offset;
2232
+ let orderBy = 'ASC';
2233
+ if (offset < 0) {
2234
+ // MED-16: negative offset = tail pagination with reversed cursor.
2235
+ offset = -offset - 1;
2236
+ orderBy = 'DESC';
2237
+ }
2238
+ const r = { totalOutputs: 0, outputs: [] };
2239
+ let { specOp, basket, tags } = getListOutputsSpecOp(vargs.basket, vargs.tags);
2240
+ let basketId = undefined;
2241
+ const basketsById = {};
2242
+ if (basket) {
2243
+ const baskets = await this.findOutputBaskets({
2244
+ partial: { userId, name: basket },
2245
+ trx,
2246
+ });
2247
+ if (baskets.length !== 1)
2248
+ return r;
2249
+ basketId = baskets[0].basketId;
2250
+ basketsById[basketId] = baskets[0];
2251
+ }
2252
+ let tagIds = [];
2253
+ const specOpTags = [];
2254
+ if (specOp?.tagsParamsCount) {
2255
+ specOpTags.push(...tags.splice(0, Math.min(tags.length, specOp.tagsParamsCount)));
2256
+ }
2257
+ if (specOp?.tagsToIntercept) {
2258
+ const ts = tags;
2259
+ tags = [];
2260
+ for (const t of ts) {
2261
+ if (specOp.tagsToIntercept.length === 0 ||
2262
+ specOp.tagsToIntercept.indexOf(t) >= 0) {
2263
+ specOpTags.push(t);
2264
+ if (t === 'all')
2265
+ basketId = undefined;
2266
+ }
2267
+ else {
2268
+ tags.push(t);
2269
+ }
2270
+ }
2271
+ }
2272
+ if (specOp?.resultFromTags) {
2273
+ return await specOp.resultFromTags(this, auth, vargs, specOpTags);
2274
+ }
2275
+ if (tags && tags.length > 0) {
2276
+ const placeholders = tags.map(() => '?').join(',');
2277
+ const rows = await this.allSql(`SELECT "outputTagId" FROM output_tags WHERE "userId" = ? AND "isDeleted" = 0 AND "outputTagId" IS NOT NULL AND tag IN (${placeholders})`, [userId, ...tags]);
2278
+ tagIds = rows.map((r) => r.outputTagId);
2279
+ }
2280
+ const isQueryModeAll = vargs.tagQueryMode === 'all';
2281
+ if (isQueryModeAll && tagIds.length < tags.length)
2282
+ return r;
2283
+ if (!isQueryModeAll && tagIds.length === 0 && tags.length > 0)
2284
+ return r;
2285
+ let columns = [
2286
+ '"outputId"',
2287
+ '"transactionId"',
2288
+ '"basketId"',
2289
+ 'spendable',
2290
+ 'txid',
2291
+ 'vout',
2292
+ 'satoshis',
2293
+ '"customInstructions"',
2294
+ '"outputDescription"',
2295
+ '"spendingDescription"',
2296
+ ];
2297
+ if (vargs.includeLockingScripts || specOp?.includeOutputScripts)
2298
+ columns = [...columns, '"lockingScript"', '"scriptLength"', '"scriptOffset"'];
2299
+ const noTags = tagIds.length === 0;
2300
+ const includeSpent = specOp?.includeSpent ? specOp.includeSpent : false;
2301
+ // HIGH-7: 'sending' must be in the status filter so users mid-broadcast
2302
+ // don't see outputs disappear.
2303
+ const txStatusOk = `(SELECT status FROM transactions WHERE transactions."transactionId" = outputs."transactionId") IN ('completed','unproven','nosend','sending')`;
2304
+ if (specOp?.totalOutputsIsSumOfSatoshis) {
2305
+ if (noTags) {
2306
+ const whereParts = ['"userId" = ?'];
2307
+ const params = [userId];
2308
+ if (basketId) {
2309
+ whereParts.push('"basketId" = ?');
2310
+ params.push(basketId);
2311
+ }
2312
+ if (!includeSpent) {
2313
+ whereParts.push('spendable = 1');
2314
+ }
2315
+ whereParts.push(txStatusOk);
2316
+ const row = await this.getSql(`SELECT SUM(satoshis) as "totalSatoshis" FROM outputs WHERE ${whereParts.join(' AND ')}`, params);
2317
+ r.totalOutputs = Number(row?.totalSatoshis ?? 0);
2318
+ return r;
2319
+ }
2320
+ const tagIdList = tagIds.join(',');
2321
+ let cteOpts = '';
2322
+ const params = [userId];
2323
+ if (basketId) {
2324
+ cteOpts += ' AND o."basketId" = ?';
2325
+ params.push(basketId);
2326
+ }
2327
+ if (!includeSpent)
2328
+ cteOpts += ' AND o.spendable = 1';
2329
+ const txStatusOkCte = `(SELECT status FROM transactions WHERE transactions."transactionId" = o."transactionId") IN ('completed','unproven','nosend','sending')`;
2330
+ const cteSql = `
2331
+ SELECT o.satoshis,
2332
+ (SELECT COUNT(*) FROM output_tags_map AS m WHERE m."outputId" = o."outputId" AND m."outputTagId" IN (${tagIdList})) AS tc
2333
+ FROM outputs AS o
2334
+ WHERE o."userId" = ?${cteOpts} AND ${txStatusOkCte}
2335
+ `;
2336
+ const filterStr = isQueryModeAll ? `tc = ${tagIds.length}` : 'tc > 0';
2337
+ const row = await this.getSql(`WITH otc AS (${cteSql}) SELECT SUM(satoshis) as "totalSatoshis" FROM otc WHERE ${filterStr}`, params);
2338
+ r.totalOutputs = Number(row?.totalSatoshis ?? 0);
2339
+ return r;
2340
+ }
2341
+ let outputs;
2342
+ let totalCount;
2343
+ if (noTags) {
2344
+ const whereParts = ['"userId" = ?'];
2345
+ const params = [userId];
2346
+ if (basketId) {
2347
+ whereParts.push('"basketId" = ?');
2348
+ params.push(basketId);
2349
+ }
2350
+ if (!includeSpent)
2351
+ whereParts.push('spendable = 1');
2352
+ whereParts.push(txStatusOk);
2353
+ const whereStr = whereParts.join(' AND ');
2354
+ if (!specOp || !specOp.ignoreLimit) {
2355
+ outputs = await this.allSql(`SELECT ${columns.join(',')} FROM outputs WHERE ${whereStr} ORDER BY "outputId" ${orderBy} LIMIT ? OFFSET ?`, [...params, limit, offset]);
2356
+ }
2357
+ else {
2358
+ outputs = await this.allSql(`SELECT ${columns.join(',')} FROM outputs WHERE ${whereStr} ORDER BY "outputId" ${orderBy}`, params);
2359
+ }
2360
+ // LOW-26: gate COUNT on truncation.
2361
+ if (limit && outputs.length >= limit) {
2362
+ const countRow = await this.getSql(`SELECT COUNT("outputId") as total FROM outputs WHERE ${whereStr}`, params);
2363
+ totalCount = Number(countRow?.total ?? 0);
2364
+ }
2365
+ else {
2366
+ totalCount = outputs.length;
2367
+ }
2368
+ }
2369
+ else {
2370
+ const tagIdList = tagIds.join(',');
2371
+ let cteOpts = '';
2372
+ const params = [userId];
2373
+ if (basketId) {
2374
+ cteOpts += ' AND o."basketId" = ?';
2375
+ params.push(basketId);
2376
+ }
2377
+ if (!includeSpent)
2378
+ cteOpts += ' AND o.spendable = 1';
2379
+ const txStatusOkCte = `(SELECT status FROM transactions WHERE transactions."transactionId" = o."transactionId") IN ('completed','unproven','nosend','sending')`;
2380
+ const cteSql = `
2381
+ SELECT ${columns.map((c) => `o.${c}`).join(',')},
2382
+ (SELECT COUNT(*) FROM output_tags_map AS m WHERE m."outputId" = o."outputId" AND m."outputTagId" IN (${tagIdList})) AS tc
2383
+ FROM outputs AS o
2384
+ WHERE o."userId" = ?${cteOpts} AND ${txStatusOkCte}
2385
+ `;
2386
+ const filterStr = isQueryModeAll ? `tc = ${tagIds.length}` : 'tc > 0';
2387
+ if (!specOp || !specOp.ignoreLimit) {
2388
+ outputs = await this.allSql(`WITH otc AS (${cteSql}) SELECT ${columns.join(',')} FROM otc WHERE ${filterStr} ORDER BY "outputId" ${orderBy} LIMIT ? OFFSET ?`, [...params, limit, offset]);
2389
+ }
2390
+ else {
2391
+ outputs = await this.allSql(`WITH otc AS (${cteSql}) SELECT ${columns.join(',')} FROM otc WHERE ${filterStr} ORDER BY "outputId" ${orderBy}`, params);
2392
+ }
2393
+ if (limit && outputs.length >= limit) {
2394
+ const countRow = await this.getSql(`WITH otc AS (${cteSql}) SELECT COUNT("outputId") as total FROM otc WHERE ${filterStr}`, params);
2395
+ totalCount = Number(countRow?.total ?? 0);
2396
+ }
2397
+ else {
2398
+ totalCount = outputs.length;
2399
+ }
2400
+ }
2401
+ if (specOp) {
2402
+ if (specOp.filterOutputs)
2403
+ outputs = (await specOp.filterOutputs(this, auth, vargs, specOpTags, outputs));
2404
+ if (specOp.resultFromOutputs)
2405
+ return await specOp.resultFromOutputs(this, auth, vargs, specOpTags, outputs);
2406
+ }
2407
+ if (!limit || outputs.length < limit)
2408
+ r.totalOutputs = outputs.length;
2409
+ else
2410
+ r.totalOutputs = totalCount;
2411
+ const labelsByTxid = {};
2412
+ const beef = new Beef();
2413
+ for (const o of outputs) {
2414
+ const wo = {
2415
+ satoshis: Number(o.satoshis),
2416
+ spendable: !!o.spendable,
2417
+ outpoint: `${o.txid}.${o.vout}`,
2418
+ };
2419
+ r.outputs.push(wo);
2420
+ if (vargs.includeCustomInstructions && o.customInstructions)
2421
+ wo.customInstructions = o.customInstructions;
2422
+ if (vargs.includeLabels && o.txid) {
2423
+ const txid = o.txid;
2424
+ if (labelsByTxid[txid] === undefined) {
2425
+ labelsByTxid[txid] = (await this.getLabelsForTransactionId(o.transactionId, trx)).map((l) => l.label);
2426
+ }
2427
+ wo.labels = labelsByTxid[txid];
2428
+ }
2429
+ if (vargs.includeTags) {
2430
+ wo.tags = (await this.getTagsForOutputId(o.outputId, trx)).map((t) => t.tag);
2431
+ }
2432
+ if (vargs.includeLockingScripts) {
2433
+ await this.validateOutputScript(o, trx);
2434
+ if (o.lockingScript)
2435
+ wo.lockingScript = asString(o.lockingScript);
2436
+ }
2437
+ if (vargs.includeTransactions && !beef.findTxid(o.txid)) {
2438
+ await this.getValidBeefForKnownTxid(o.txid, beef, undefined, vargs.knownTxids, trx);
2439
+ }
2440
+ }
2441
+ if (vargs.includeTransactions) {
2442
+ r.BEEF = beef.toBinary();
2443
+ }
2444
+ return r;
2445
+ }
2446
+ // -----------------------------------------------------------------------
2447
+ // purgeData
2448
+ // -----------------------------------------------------------------------
2449
+ async purgeData(params, _trx) {
2450
+ const r = { count: 0, log: '' };
2451
+ const defaultAge = 1000 * 60 * 60 * 24 * 14;
2452
+ if (params.purgeCompleted) {
2453
+ const age = params.purgeCompletedAge || defaultAge;
2454
+ const before = new Date(Date.now() - age);
2455
+ const updRes = await this.runSql(`UPDATE transactions SET "inputBEEF" = NULL, "rawTx" = NULL
2456
+ WHERE updated_at < ? AND status = 'completed' AND "provenTxId" IS NOT NULL
2457
+ AND ("inputBEEF" IS NOT NULL OR "rawTx" IS NOT NULL)`, [before]);
2458
+ let cnt = updRes.rowCount ?? 0;
2459
+ if (cnt > 0) {
2460
+ r.count += cnt;
2461
+ r.log += `${cnt} completed transactions purged of transient data\n`;
2462
+ }
2463
+ const completedReqs = await this.allSql(`SELECT "provenTxReqId" FROM proven_tx_reqs
2464
+ WHERE updated_at < ? AND status = 'completed' AND "provenTxId" IS NOT NULL AND notified = 1`, [before]);
2465
+ if (completedReqs.length > 0) {
2466
+ const ids = completedReqs.map((r) => r.provenTxReqId);
2467
+ const placeholders = ids.map(() => '?').join(',');
2468
+ const delRes = await this.runSql(`DELETE FROM proven_tx_reqs WHERE "provenTxReqId" IN (${placeholders})`, ids);
2469
+ cnt = delRes.rowCount ?? 0;
2470
+ if (cnt > 0) {
2471
+ r.count += cnt;
2472
+ r.log += `${cnt} completed proven_tx_reqs deleted\n`;
2473
+ }
2474
+ }
2475
+ }
2476
+ if (params.purgeFailed) {
2477
+ const age = params.purgeFailedAge || defaultAge;
2478
+ const before = new Date(Date.now() - age);
2479
+ const failedTxs = await this.allSql(`SELECT "transactionId" FROM transactions WHERE updated_at < ? AND status = 'failed'`, [before]);
2480
+ const failedTxIds = failedTxs.map((t) => t.transactionId);
2481
+ if (failedTxIds.length > 0) {
2482
+ await this.deleteTransactions(failedTxIds, r, 'failed', true);
2483
+ }
2484
+ const invalidReqs = await this.allSql(`SELECT "provenTxReqId" FROM proven_tx_reqs WHERE updated_at < ? AND status = 'invalid'`, [before]);
2485
+ if (invalidReqs.length > 0) {
2486
+ const ids = invalidReqs.map((r) => r.provenTxReqId);
2487
+ const placeholders = ids.map(() => '?').join(',');
2488
+ const delRes = await this.runSql(`DELETE FROM proven_tx_reqs WHERE "provenTxReqId" IN (${placeholders})`, ids);
2489
+ const cnt = delRes.rowCount ?? 0;
2490
+ if (cnt > 0) {
2491
+ r.count += cnt;
2492
+ r.log += `${cnt} invalid proven_tx_reqs deleted\n`;
2493
+ }
2494
+ }
2495
+ const dsReqs = await this.allSql(`SELECT "provenTxReqId" FROM proven_tx_reqs WHERE updated_at < ? AND status = 'doubleSpend'`, [before]);
2496
+ if (dsReqs.length > 0) {
2497
+ const ids = dsReqs.map((r) => r.provenTxReqId);
2498
+ const placeholders = ids.map(() => '?').join(',');
2499
+ const delRes = await this.runSql(`DELETE FROM proven_tx_reqs WHERE "provenTxReqId" IN (${placeholders})`, ids);
2500
+ const cnt = delRes.rowCount ?? 0;
2501
+ if (cnt > 0) {
2502
+ r.count += cnt;
2503
+ r.log += `${cnt} doubleSpend proven_tx_reqs deleted\n`;
2504
+ }
2505
+ }
2506
+ }
2507
+ if (params.purgeSpent) {
2508
+ const age = params.purgeSpentAge || defaultAge;
2509
+ const before = new Date(Date.now() - age);
2510
+ const beef = new Beef();
2511
+ const utxos = await this.findOutputs({
2512
+ partial: { spendable: true },
2513
+ txStatus: ['sending', 'unproven', 'completed', 'nosend'],
2514
+ });
2515
+ for (const utxo of utxos) {
2516
+ const options = { mergeToBeef: beef, ignoreServices: true };
2517
+ if (utxo.txid)
2518
+ await this.getBeefForTransaction(utxo.txid, options);
2519
+ }
2520
+ const proofTxids = {};
2521
+ for (const btx of beef.txs)
2522
+ proofTxids[btx.txid] = true;
2523
+ const spentTxs = await this.allSql(`SELECT * FROM transactions WHERE updated_at < ? AND status = 'completed'
2524
+ AND NOT EXISTS (SELECT "outputId" FROM outputs AS o WHERE o."transactionId" = transactions."transactionId" AND o.spendable = 1)`, [before]);
2525
+ const nptxs = spentTxs.filter((t) => !proofTxids[t.txid || '']);
2526
+ const spentTxIds = nptxs.map((tx) => tx.transactionId);
2527
+ if (spentTxIds.length > 0) {
2528
+ const placeholders = spentTxIds.map(() => '?').join(',');
2529
+ const updRes = await this.runSql(`UPDATE outputs SET "spentBy" = NULL, updated_at = ? WHERE spendable = 0 AND "spentBy" IN (${placeholders})`, [this.validateEntityDate(new Date()), ...spentTxIds]);
2530
+ const cnt = updRes.rowCount ?? 0;
2531
+ if (cnt > 0) {
2532
+ r.count += cnt;
2533
+ r.log += `${cnt} spent outputs no longer tracked by spentBy\n`;
2534
+ }
2535
+ await this.deleteTransactions(spentTxIds, r, 'spent', false);
2536
+ }
2537
+ }
2538
+ const orphanRes = await this.runSql(`DELETE FROM proven_txs
2539
+ WHERE NOT EXISTS (SELECT * FROM transactions AS t WHERE t.txid = proven_txs.txid OR t."provenTxId" = proven_txs."provenTxId")
2540
+ AND NOT EXISTS (SELECT * FROM proven_tx_reqs AS r WHERE r.txid = proven_txs.txid OR r."provenTxId" = proven_txs."provenTxId")`);
2541
+ const cnt = orphanRes.rowCount ?? 0;
2542
+ if (cnt > 0) {
2543
+ r.count += cnt;
2544
+ r.log += `${cnt} orphan proven_txs deleted\n`;
2545
+ }
2546
+ return r;
2547
+ }
2548
+ async deleteTransactions(transactionIds, r, reason, markNotSpentBy) {
2549
+ if (transactionIds.length === 0)
2550
+ return;
2551
+ const placeholders = transactionIds.map(() => '?').join(',');
2552
+ const outputs = await this.allSql(`SELECT "outputId" FROM outputs WHERE "transactionId" IN (${placeholders})`, transactionIds);
2553
+ const outputIds = outputs.map((o) => o.outputId);
2554
+ if (outputIds.length > 0) {
2555
+ const oPlaceholders = outputIds.map(() => '?').join(',');
2556
+ let res = await this.runSql(`DELETE FROM output_tags_map WHERE "outputId" IN (${oPlaceholders})`, outputIds);
2557
+ let cnt = res.rowCount ?? 0;
2558
+ if (cnt > 0) {
2559
+ r.count += cnt;
2560
+ r.log += `${cnt} ${reason} output_tags_map deleted\n`;
2561
+ }
2562
+ res = await this.runSql(`DELETE FROM outputs WHERE "outputId" IN (${oPlaceholders})`, outputIds);
2563
+ cnt = res.rowCount ?? 0;
2564
+ if (cnt > 0) {
2565
+ r.count += cnt;
2566
+ r.log += `${cnt} ${reason} outputs deleted\n`;
2567
+ }
2568
+ }
2569
+ let res = await this.runSql(`DELETE FROM tx_labels_map WHERE "transactionId" IN (${placeholders})`, transactionIds);
2570
+ let cnt = res.rowCount ?? 0;
2571
+ if (cnt > 0) {
2572
+ r.count += cnt;
2573
+ r.log += `${cnt} ${reason} tx_labels_map deleted\n`;
2574
+ }
2575
+ res = await this.runSql(`DELETE FROM commissions WHERE "transactionId" IN (${placeholders})`, transactionIds);
2576
+ cnt = res.rowCount ?? 0;
2577
+ if (cnt > 0) {
2578
+ r.count += cnt;
2579
+ r.log += `${cnt} ${reason} commissions deleted\n`;
2580
+ }
2581
+ if (markNotSpentBy) {
2582
+ res = await this.runSql(`UPDATE outputs SET spendable = 1, "spentBy" = NULL WHERE "spentBy" IN (${placeholders})`, transactionIds);
2583
+ cnt = res.rowCount ?? 0;
2584
+ if (cnt > 0) {
2585
+ r.count += cnt;
2586
+ r.log += `${cnt} unspent outputs updated to spendable\n`;
2587
+ }
2588
+ }
2589
+ res = await this.runSql(`DELETE FROM transactions WHERE "transactionId" IN (${placeholders})`, transactionIds);
2590
+ cnt = res.rowCount ?? 0;
2591
+ if (cnt > 0) {
2592
+ r.count += cnt;
2593
+ r.log += `${cnt} ${reason} transactions deleted\n`;
2594
+ }
2595
+ }
2596
+ // -----------------------------------------------------------------------
2597
+ // reviewStatus
2598
+ // -----------------------------------------------------------------------
2599
+ async reviewStatus(_args) {
2600
+ const r = { log: '' };
2601
+ const r1 = await this.runSql(`
2602
+ UPDATE transactions SET status = 'failed'
2603
+ WHERE status != 'failed'
2604
+ AND EXISTS (SELECT 1 FROM proven_tx_reqs AS r WHERE transactions.txid = r.txid AND r.status = 'invalid')
2605
+ `);
2606
+ let cnt = r1.rowCount ?? 0;
2607
+ if (cnt > 0)
2608
+ r.log += `${cnt} transactions updated to status of 'failed' where provenTxReq with matching txid is 'invalid'\n`;
2609
+ const r2 = await this.runSql(`
2610
+ UPDATE outputs SET "spentBy" = NULL, spendable = 1
2611
+ WHERE EXISTS (SELECT 1 FROM transactions AS t WHERE outputs."spentBy" = t."transactionId" AND t.status = 'failed')
2612
+ `);
2613
+ cnt = r2.rowCount ?? 0;
2614
+ if (cnt > 0)
2615
+ r.log += `${cnt} outputs updated to spendable where spentBy is a transaction with status 'failed'\n`;
2616
+ const r3 = await this.runSql(`
2617
+ UPDATE transactions SET status = 'completed',
2618
+ "provenTxId" = (SELECT "provenTxId" FROM proven_txs AS p WHERE transactions.txid = p.txid)
2619
+ WHERE "provenTxId" IS NULL
2620
+ AND EXISTS (SELECT 1 FROM proven_txs AS p WHERE transactions.txid = p.txid)
2621
+ `);
2622
+ cnt = r3.rowCount ?? 0;
2623
+ if (cnt > 0)
2624
+ r.log += `${cnt} transactions updated with provenTxId and status of 'completed' where provenTx with matching txid exists\n`;
2625
+ return r;
2626
+ }
2627
+ // -----------------------------------------------------------------------
2628
+ // adminStats — MySQL-only in StorageKnex, throw here.
2629
+ // -----------------------------------------------------------------------
2630
+ async adminStats(_adminIdentityKey) {
2631
+ throw new WERR_NOT_IMPLEMENTED('adminStats, only MySQL is supported');
2632
+ }
56
2633
  }
57
2634
  //# sourceMappingURL=storage-pg.js.map