@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.
- package/dist/createNodeWallet.d.ts +4 -4
- package/dist/createNodeWallet.d.ts.map +1 -1
- package/dist/createNodeWallet.js +5 -10
- package/dist/createNodeWallet.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/storage-pg.d.ts +178 -12
- package/dist/storage-pg.d.ts.map +1 -1
- package/dist/storage-pg.js +2613 -36
- package/dist/storage-pg.js.map +1 -1
- package/package.json +5 -10
package/dist/storage-pg.js
CHANGED
|
@@ -1,57 +1,2634 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* StoragePg —
|
|
2
|
+
* StoragePg — native Postgres backend for `@1sat/wallet-node` wallets.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
15
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|