@breeztech/breez-sdk-spark 0.13.10-dev → 0.13.11-dev1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/breez-sdk-spark.tgz +0 -0
- package/bundler/breez_sdk_spark_wasm.d.ts +33 -0
- package/bundler/breez_sdk_spark_wasm.js +1 -1
- package/bundler/breez_sdk_spark_wasm_bg.js +66 -24
- package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
- package/deno/breez_sdk_spark_wasm.d.ts +33 -0
- package/deno/breez_sdk_spark_wasm.js +66 -24
- package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
- package/nodejs/breez_sdk_spark_wasm.d.ts +33 -0
- package/nodejs/breez_sdk_spark_wasm.js +67 -24
- package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
- package/nodejs/index.js +34 -0
- package/nodejs/index.mjs +1 -0
- package/nodejs/mysql-storage/errors.cjs +19 -0
- package/nodejs/mysql-storage/index.cjs +1366 -0
- package/nodejs/mysql-storage/migrations.cjs +387 -0
- package/nodejs/mysql-storage/package.json +9 -0
- package/nodejs/mysql-token-store/errors.cjs +9 -0
- package/nodejs/mysql-token-store/index.cjs +988 -0
- package/nodejs/mysql-token-store/migrations.cjs +255 -0
- package/nodejs/mysql-token-store/package.json +9 -0
- package/nodejs/mysql-tree-store/errors.cjs +9 -0
- package/nodejs/mysql-tree-store/index.cjs +939 -0
- package/nodejs/mysql-tree-store/migrations.cjs +221 -0
- package/nodejs/mysql-tree-store/package.json +9 -0
- package/nodejs/package.json +3 -0
- package/nodejs/postgres-storage/index.cjs +147 -92
- package/nodejs/postgres-storage/migrations.cjs +85 -4
- package/nodejs/postgres-token-store/index.cjs +176 -89
- package/nodejs/postgres-token-store/migrations.cjs +92 -3
- package/nodejs/postgres-tree-store/index.cjs +168 -83
- package/nodejs/postgres-tree-store/migrations.cjs +80 -3
- package/package.json +1 -1
- package/ssr/index.js +5 -0
- package/web/breez_sdk_spark_wasm.d.ts +40 -5
- package/web/breez_sdk_spark_wasm.js +66 -24
- package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +7 -5
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommonJS implementation for Node.js MySQL Token Store.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `postgres-token-store/index.cjs` for MySQL 8.0+. See
|
|
5
|
+
* `mysql-storage/index.cjs` for SQL translation rules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
let mysql;
|
|
9
|
+
try {
|
|
10
|
+
const mainModule = require.main;
|
|
11
|
+
if (mainModule) {
|
|
12
|
+
mysql = mainModule.require("mysql2/promise");
|
|
13
|
+
} else {
|
|
14
|
+
mysql = require("mysql2/promise");
|
|
15
|
+
}
|
|
16
|
+
} catch (error) {
|
|
17
|
+
try {
|
|
18
|
+
mysql = require("mysql2/promise");
|
|
19
|
+
} catch (fallbackError) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`mysql2 not found. Please install it in your project: npm install mysql2@^3.11.0\n` +
|
|
22
|
+
`Original error: ${error.message}\nFallback error: ${fallbackError.message}`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { TokenStoreError } = require("./errors.cjs");
|
|
28
|
+
const { MysqlTokenStoreMigrationManager } = require("./migrations.cjs");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Domain prefix mixed into the per-tenant `GET_LOCK` name. Distinct prefixes
|
|
32
|
+
* guarantee that tree-store and token-store locks never collide.
|
|
33
|
+
*/
|
|
34
|
+
const TOKEN_STORE_LOCK_PREFIX = "breez-spark-sdk:token:";
|
|
35
|
+
/** Seconds to wait when acquiring the write lock. */
|
|
36
|
+
const WRITE_LOCK_TIMEOUT_SECS = 30;
|
|
37
|
+
|
|
38
|
+
const SPENT_MARKER_CLEANUP_THRESHOLD_MS = 5 * 60 * 1000;
|
|
39
|
+
const RESERVATION_TIMEOUT_SECS = 300;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Derive a stable per-tenant lock name from a tenant identity pubkey. Hashes
|
|
43
|
+
* a domain prefix together with the identity (SHA-256, first 8 bytes hex).
|
|
44
|
+
*/
|
|
45
|
+
function _identityLockName(prefix, identity) {
|
|
46
|
+
const crypto = require("crypto");
|
|
47
|
+
const hash = crypto.createHash("sha256");
|
|
48
|
+
hash.update(prefix);
|
|
49
|
+
hash.update(Buffer.from(identity));
|
|
50
|
+
return prefix + hash.digest("hex").slice(0, 16);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseJson(value) {
|
|
54
|
+
if (value == null) return null;
|
|
55
|
+
if (typeof value === "string") return JSON.parse(value);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toBool(value) {
|
|
60
|
+
if (value == null) return null;
|
|
61
|
+
if (typeof value === "boolean") return value;
|
|
62
|
+
return value === 1 || value === "1" || value === true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildPlaceholders(n) {
|
|
66
|
+
return new Array(n).fill("?").join(", ");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class MysqlTokenStore {
|
|
70
|
+
/**
|
|
71
|
+
* @param {import('mysql2/promise').Pool} pool
|
|
72
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
|
|
73
|
+
* identifying the tenant. All reads and writes are scoped by this.
|
|
74
|
+
* @param {object} [logger]
|
|
75
|
+
*/
|
|
76
|
+
constructor(pool, identity, logger = null) {
|
|
77
|
+
if (!identity || identity.length !== 33) {
|
|
78
|
+
throw new TokenStoreError(
|
|
79
|
+
"tenant identity (33-byte secp256k1 pubkey) is required"
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
this.pool = pool;
|
|
83
|
+
this.identity = Buffer.from(identity);
|
|
84
|
+
this.lockName = _identityLockName(TOKEN_STORE_LOCK_PREFIX, identity);
|
|
85
|
+
this.logger = logger;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async initialize() {
|
|
89
|
+
try {
|
|
90
|
+
const migrationManager = new MysqlTokenStoreMigrationManager(this.logger);
|
|
91
|
+
await migrationManager.migrate(this.pool, this.identity);
|
|
92
|
+
return this;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new TokenStoreError(
|
|
95
|
+
`Failed to initialize MySQL token store: ${error.message}`,
|
|
96
|
+
error
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async close() {
|
|
102
|
+
if (this.pool) {
|
|
103
|
+
await this.pool.end();
|
|
104
|
+
this.pool = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run a function inside a transaction holding the named write lock. Reserved
|
|
110
|
+
* for operations whose correctness depends on serializing the
|
|
111
|
+
* available-output set (`reserveTokenOutputs`, `setTokensOutputs`).
|
|
112
|
+
* @param {function(import('mysql2/promise').PoolConnection): Promise<T>} fn
|
|
113
|
+
* @returns {Promise<T>}
|
|
114
|
+
* @template T
|
|
115
|
+
*/
|
|
116
|
+
async _withWriteTransaction(fn) {
|
|
117
|
+
const conn = await this.pool.getConnection();
|
|
118
|
+
let lockAcquired = false;
|
|
119
|
+
try {
|
|
120
|
+
const [lockRows] = await conn.query(
|
|
121
|
+
"SELECT GET_LOCK(?, ?) AS acquired",
|
|
122
|
+
[this.lockName, WRITE_LOCK_TIMEOUT_SECS]
|
|
123
|
+
);
|
|
124
|
+
if (!lockRows || lockRows[0].acquired !== 1) {
|
|
125
|
+
throw new TokenStoreError(
|
|
126
|
+
`Failed to acquire token store write lock within ${WRITE_LOCK_TIMEOUT_SECS}s`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
lockAcquired = true;
|
|
130
|
+
|
|
131
|
+
await conn.beginTransaction();
|
|
132
|
+
const result = await fn(conn);
|
|
133
|
+
await conn.commit();
|
|
134
|
+
return result;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
await conn.rollback().catch(() => {});
|
|
137
|
+
throw error;
|
|
138
|
+
} finally {
|
|
139
|
+
if (lockAcquired) {
|
|
140
|
+
await conn
|
|
141
|
+
.query("SELECT RELEASE_LOCK(?)", [this.lockName])
|
|
142
|
+
.catch(() => {});
|
|
143
|
+
}
|
|
144
|
+
conn.release();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run a function inside a transaction without the advisory lock. Used by
|
|
150
|
+
* operations scoped to a single reservation_id (`cancelReservation`)
|
|
151
|
+
* where row-level FK + InnoDB MVCC suffice and the global lock would only
|
|
152
|
+
* add contention.
|
|
153
|
+
* @param {function(import('mysql2/promise').PoolConnection): Promise<T>} fn
|
|
154
|
+
* @returns {Promise<T>}
|
|
155
|
+
* @template T
|
|
156
|
+
*/
|
|
157
|
+
async _withTransaction(fn) {
|
|
158
|
+
const conn = await this.pool.getConnection();
|
|
159
|
+
try {
|
|
160
|
+
await conn.beginTransaction();
|
|
161
|
+
const result = await fn(conn);
|
|
162
|
+
await conn.commit();
|
|
163
|
+
return result;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
await conn.rollback().catch(() => {});
|
|
166
|
+
throw error;
|
|
167
|
+
} finally {
|
|
168
|
+
conn.release();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ===== TokenOutputStore Methods =====
|
|
173
|
+
|
|
174
|
+
async setTokensOutputs(tokenOutputs, refreshStartedAtMs) {
|
|
175
|
+
try {
|
|
176
|
+
const refreshTimestamp = new Date(refreshStartedAtMs);
|
|
177
|
+
|
|
178
|
+
await this._withWriteTransaction(async (conn) => {
|
|
179
|
+
// Drop expired reservations BEFORE evaluating has_active_swap, otherwise a stale
|
|
180
|
+
// Swap reservation (from a crashed client or a swap whose finalize/cancel never
|
|
181
|
+
// ran) keeps has_active_swap true forever, which makes setTokensOutputs
|
|
182
|
+
// early-return and never reach any subsequent reconciliation. The reservation
|
|
183
|
+
// pins itself in place and the local token-output set freezes.
|
|
184
|
+
await this._cleanupStaleReservations(conn);
|
|
185
|
+
|
|
186
|
+
const [swapRows] = await conn.query(
|
|
187
|
+
`SELECT
|
|
188
|
+
(SELECT EXISTS(SELECT 1 FROM token_reservations WHERE user_id = ? AND purpose = 'Swap')) AS has_active_swap,
|
|
189
|
+
COALESCE(
|
|
190
|
+
(SELECT (last_completed_at >= ?) FROM token_swap_status WHERE user_id = ?),
|
|
191
|
+
0
|
|
192
|
+
) AS swap_completed`,
|
|
193
|
+
[this.identity, refreshTimestamp, this.identity]
|
|
194
|
+
);
|
|
195
|
+
const hasActiveSwap = !!swapRows[0].has_active_swap;
|
|
196
|
+
const swapCompleted = !!swapRows[0].swap_completed;
|
|
197
|
+
if (hasActiveSwap || swapCompleted) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const cleanupCutoff = new Date(
|
|
202
|
+
refreshTimestamp.getTime() - SPENT_MARKER_CLEANUP_THRESHOLD_MS
|
|
203
|
+
);
|
|
204
|
+
await conn.query(
|
|
205
|
+
"DELETE FROM token_spent_outputs WHERE user_id = ? AND spent_at < ?",
|
|
206
|
+
[this.identity, cleanupCutoff]
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const [spentRows] = await conn.query(
|
|
210
|
+
"SELECT output_id FROM token_spent_outputs WHERE user_id = ? AND spent_at >= ?",
|
|
211
|
+
[this.identity, refreshTimestamp]
|
|
212
|
+
);
|
|
213
|
+
const spentIds = new Set(spentRows.map((r) => r.output_id));
|
|
214
|
+
|
|
215
|
+
await conn.query(
|
|
216
|
+
"DELETE FROM token_outputs WHERE user_id = ? AND reservation_id IS NULL AND added_at < ?",
|
|
217
|
+
[this.identity, refreshTimestamp]
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const incomingOutputIds = new Set();
|
|
221
|
+
for (const to of tokenOutputs) {
|
|
222
|
+
for (const o of to.outputs) {
|
|
223
|
+
incomingOutputIds.add(o.output.id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const [reservedRows] = await conn.query(
|
|
228
|
+
`SELECT r.id, o.id AS output_id
|
|
229
|
+
FROM token_reservations r
|
|
230
|
+
JOIN token_outputs o
|
|
231
|
+
ON o.reservation_id = r.id AND o.user_id = r.user_id
|
|
232
|
+
WHERE r.user_id = ?`,
|
|
233
|
+
[this.identity]
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const reservationOutputs = new Map();
|
|
237
|
+
for (const row of reservedRows) {
|
|
238
|
+
if (!reservationOutputs.has(row.id)) {
|
|
239
|
+
reservationOutputs.set(row.id, []);
|
|
240
|
+
}
|
|
241
|
+
reservationOutputs.get(row.id).push(row.output_id);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const reservationsToDelete = [];
|
|
245
|
+
const outputsToRemoveFromReservation = [];
|
|
246
|
+
for (const [reservationId, outputIds] of reservationOutputs) {
|
|
247
|
+
const validIds = outputIds.filter((id) => incomingOutputIds.has(id));
|
|
248
|
+
if (validIds.length === 0) {
|
|
249
|
+
reservationsToDelete.push(reservationId);
|
|
250
|
+
} else {
|
|
251
|
+
for (const id of outputIds) {
|
|
252
|
+
if (!incomingOutputIds.has(id)) {
|
|
253
|
+
outputsToRemoveFromReservation.push(id);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (reservationsToDelete.length > 0) {
|
|
260
|
+
const placeholders = buildPlaceholders(reservationsToDelete.length);
|
|
261
|
+
await conn.query(
|
|
262
|
+
`DELETE FROM token_outputs WHERE user_id = ? AND reservation_id IN (${placeholders})`,
|
|
263
|
+
[this.identity, ...reservationsToDelete]
|
|
264
|
+
);
|
|
265
|
+
await conn.query(
|
|
266
|
+
`DELETE FROM token_reservations WHERE user_id = ? AND id IN (${placeholders})`,
|
|
267
|
+
[this.identity, ...reservationsToDelete]
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (outputsToRemoveFromReservation.length > 0) {
|
|
272
|
+
const placeholders = buildPlaceholders(
|
|
273
|
+
outputsToRemoveFromReservation.length
|
|
274
|
+
);
|
|
275
|
+
await conn.query(
|
|
276
|
+
`DELETE FROM token_outputs WHERE user_id = ? AND id IN (${placeholders})`,
|
|
277
|
+
[this.identity, ...outputsToRemoveFromReservation]
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const [emptyRows] = await conn.query(
|
|
281
|
+
`SELECT r.id FROM token_reservations r
|
|
282
|
+
LEFT JOIN token_outputs o
|
|
283
|
+
ON o.reservation_id = r.id AND o.user_id = r.user_id
|
|
284
|
+
WHERE r.user_id = ? AND o.id IS NULL`,
|
|
285
|
+
[this.identity]
|
|
286
|
+
);
|
|
287
|
+
const emptyIds = emptyRows.map((r) => r.id);
|
|
288
|
+
if (emptyIds.length > 0) {
|
|
289
|
+
const emptyPlaceholders = buildPlaceholders(emptyIds.length);
|
|
290
|
+
await conn.query(
|
|
291
|
+
`DELETE FROM token_reservations WHERE user_id = ? AND id IN (${emptyPlaceholders})`,
|
|
292
|
+
[this.identity, ...emptyIds]
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const [reservedOutputRows] = await conn.query(
|
|
298
|
+
"SELECT id FROM token_outputs WHERE user_id = ? AND reservation_id IS NOT NULL",
|
|
299
|
+
[this.identity]
|
|
300
|
+
);
|
|
301
|
+
const reservedOutputIds = new Set(reservedOutputRows.map((r) => r.id));
|
|
302
|
+
|
|
303
|
+
await conn.query(
|
|
304
|
+
`DELETE FROM token_metadata
|
|
305
|
+
WHERE user_id = ?
|
|
306
|
+
AND identifier NOT IN (
|
|
307
|
+
SELECT DISTINCT token_identifier FROM token_outputs WHERE user_id = ?
|
|
308
|
+
)`,
|
|
309
|
+
[this.identity, this.identity]
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
for (const to of tokenOutputs) {
|
|
313
|
+
await this._upsertMetadata(conn, to.metadata);
|
|
314
|
+
|
|
315
|
+
for (const output of to.outputs) {
|
|
316
|
+
if (
|
|
317
|
+
reservedOutputIds.has(output.output.id) ||
|
|
318
|
+
spentIds.has(output.output.id)
|
|
319
|
+
) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
await this._insertSingleOutput(
|
|
323
|
+
conn,
|
|
324
|
+
to.metadata.identifier,
|
|
325
|
+
output
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error instanceof TokenStoreError) throw error;
|
|
332
|
+
throw new TokenStoreError(
|
|
333
|
+
`Failed to set token outputs: ${error.message}`,
|
|
334
|
+
error
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Returns the spendable per-token balances aggregated server-side.
|
|
341
|
+
* Each entry includes full token metadata + the available + swap-reserved sum.
|
|
342
|
+
* Matches the in-memory default impl which returns all tokens that have
|
|
343
|
+
* at least one output (including zero spendable balance).
|
|
344
|
+
* @returns {Promise<Array<{metadata: object, balance: string}>>}
|
|
345
|
+
*/
|
|
346
|
+
async getTokenBalances() {
|
|
347
|
+
try {
|
|
348
|
+
const [rows] = await this.pool.query(
|
|
349
|
+
`SELECT m.identifier, m.issuer_public_key, m.name, m.ticker, m.decimals,
|
|
350
|
+
m.max_supply, m.is_freezable, m.creation_entity_public_key,
|
|
351
|
+
CAST(COALESCE(SUM(
|
|
352
|
+
CASE
|
|
353
|
+
WHEN o.reservation_id IS NULL THEN CAST(o.token_amount AS DECIMAL(65,0))
|
|
354
|
+
WHEN r.purpose = 'Swap' THEN CAST(o.token_amount AS DECIMAL(65,0))
|
|
355
|
+
ELSE 0
|
|
356
|
+
END
|
|
357
|
+
), 0) AS CHAR) AS balance
|
|
358
|
+
FROM token_metadata m
|
|
359
|
+
JOIN token_outputs o
|
|
360
|
+
ON o.token_identifier = m.identifier AND o.user_id = m.user_id
|
|
361
|
+
LEFT JOIN token_reservations r
|
|
362
|
+
ON o.reservation_id = r.id AND o.user_id = r.user_id
|
|
363
|
+
WHERE m.user_id = ?
|
|
364
|
+
GROUP BY m.identifier, m.issuer_public_key, m.name, m.ticker,
|
|
365
|
+
m.decimals, m.max_supply, m.is_freezable, m.creation_entity_public_key`,
|
|
366
|
+
[this.identity]
|
|
367
|
+
);
|
|
368
|
+
return rows.map((row) => ({
|
|
369
|
+
metadata: {
|
|
370
|
+
identifier: row.identifier,
|
|
371
|
+
issuerPublicKey: row.issuer_public_key,
|
|
372
|
+
name: row.name,
|
|
373
|
+
ticker: row.ticker,
|
|
374
|
+
decimals: row.decimals,
|
|
375
|
+
maxSupply: row.max_supply,
|
|
376
|
+
isFreezable: toBool(row.is_freezable) ?? false,
|
|
377
|
+
creationEntityPublicKey: row.creation_entity_public_key || null,
|
|
378
|
+
},
|
|
379
|
+
balance: row.balance,
|
|
380
|
+
}));
|
|
381
|
+
} catch (error) {
|
|
382
|
+
throw new TokenStoreError(
|
|
383
|
+
`Failed to get token balances: ${error.message}`,
|
|
384
|
+
error
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async listTokensOutputs() {
|
|
390
|
+
try {
|
|
391
|
+
const [rows] = await this.pool.query(
|
|
392
|
+
`SELECT m.identifier, m.issuer_public_key, m.name, m.ticker, m.decimals,
|
|
393
|
+
m.max_supply, m.is_freezable, m.creation_entity_public_key,
|
|
394
|
+
o.id AS output_id, o.owner_public_key, o.revocation_commitment,
|
|
395
|
+
o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
|
|
396
|
+
o.token_public_key, o.token_amount, o.token_identifier,
|
|
397
|
+
o.prev_tx_hash, o.prev_tx_vout, o.reservation_id,
|
|
398
|
+
r.purpose
|
|
399
|
+
FROM token_metadata m
|
|
400
|
+
LEFT JOIN token_outputs o
|
|
401
|
+
ON o.token_identifier = m.identifier AND o.user_id = m.user_id
|
|
402
|
+
LEFT JOIN token_reservations r
|
|
403
|
+
ON o.reservation_id = r.id AND o.user_id = r.user_id
|
|
404
|
+
WHERE m.user_id = ?
|
|
405
|
+
ORDER BY m.identifier, CAST(o.token_amount AS DECIMAL(65,0)) ASC`,
|
|
406
|
+
[this.identity]
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const map = new Map();
|
|
410
|
+
|
|
411
|
+
for (const row of rows) {
|
|
412
|
+
if (!map.has(row.identifier)) {
|
|
413
|
+
map.set(row.identifier, {
|
|
414
|
+
metadata: this._metadataFromRow(row),
|
|
415
|
+
available: [],
|
|
416
|
+
reservedForPayment: [],
|
|
417
|
+
reservedForSwap: [],
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const entry = map.get(row.identifier);
|
|
422
|
+
|
|
423
|
+
if (!row.output_id) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const output = this._outputFromRow(row);
|
|
428
|
+
|
|
429
|
+
if (row.purpose === "Payment") {
|
|
430
|
+
entry.reservedForPayment.push(output);
|
|
431
|
+
} else if (row.purpose === "Swap") {
|
|
432
|
+
entry.reservedForSwap.push(output);
|
|
433
|
+
} else {
|
|
434
|
+
entry.available.push(output);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return Array.from(map.values());
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (error instanceof TokenStoreError) throw error;
|
|
441
|
+
throw new TokenStoreError(
|
|
442
|
+
`Failed to list token outputs: ${error.message}`,
|
|
443
|
+
error
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async getTokenOutputs(filter) {
|
|
449
|
+
try {
|
|
450
|
+
let whereClause;
|
|
451
|
+
let param;
|
|
452
|
+
|
|
453
|
+
if (filter.type === "identifier") {
|
|
454
|
+
whereClause = "m.identifier = ?";
|
|
455
|
+
param = filter.identifier;
|
|
456
|
+
} else if (filter.type === "issuerPublicKey") {
|
|
457
|
+
whereClause = "m.issuer_public_key = ?";
|
|
458
|
+
param = filter.issuerPublicKey;
|
|
459
|
+
} else {
|
|
460
|
+
throw new TokenStoreError(`Unknown filter type: ${filter.type}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const [rows] = await this.pool.query(
|
|
464
|
+
`SELECT m.identifier, m.issuer_public_key, m.name, m.ticker, m.decimals,
|
|
465
|
+
m.max_supply, m.is_freezable, m.creation_entity_public_key,
|
|
466
|
+
o.id AS output_id, o.owner_public_key, o.revocation_commitment,
|
|
467
|
+
o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
|
|
468
|
+
o.token_public_key, o.token_amount, o.token_identifier,
|
|
469
|
+
o.prev_tx_hash, o.prev_tx_vout, o.reservation_id,
|
|
470
|
+
r.purpose
|
|
471
|
+
FROM token_metadata m
|
|
472
|
+
LEFT JOIN token_outputs o
|
|
473
|
+
ON o.token_identifier = m.identifier AND o.user_id = m.user_id
|
|
474
|
+
LEFT JOIN token_reservations r
|
|
475
|
+
ON o.reservation_id = r.id AND o.user_id = r.user_id
|
|
476
|
+
WHERE m.user_id = ? AND ${whereClause}
|
|
477
|
+
ORDER BY CAST(o.token_amount AS DECIMAL(65,0)) ASC`,
|
|
478
|
+
[this.identity, param]
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
if (rows.length === 0) {
|
|
482
|
+
throw new TokenStoreError("Token outputs not found");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const metadata = this._metadataFromRow(rows[0]);
|
|
486
|
+
const entry = {
|
|
487
|
+
metadata,
|
|
488
|
+
available: [],
|
|
489
|
+
reservedForPayment: [],
|
|
490
|
+
reservedForSwap: [],
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
for (const row of rows) {
|
|
494
|
+
if (!row.output_id) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const output = this._outputFromRow(row);
|
|
499
|
+
|
|
500
|
+
if (row.purpose === "Payment") {
|
|
501
|
+
entry.reservedForPayment.push(output);
|
|
502
|
+
} else if (row.purpose === "Swap") {
|
|
503
|
+
entry.reservedForSwap.push(output);
|
|
504
|
+
} else {
|
|
505
|
+
entry.available.push(output);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return entry;
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if (error instanceof TokenStoreError) throw error;
|
|
512
|
+
throw new TokenStoreError(
|
|
513
|
+
`Failed to get token outputs: ${error.message}`,
|
|
514
|
+
error
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async insertTokenOutputs(tokenOutputs) {
|
|
520
|
+
try {
|
|
521
|
+
const conn = await this.pool.getConnection();
|
|
522
|
+
try {
|
|
523
|
+
await conn.beginTransaction();
|
|
524
|
+
|
|
525
|
+
await this._upsertMetadata(conn, tokenOutputs.metadata);
|
|
526
|
+
|
|
527
|
+
const outputIds = tokenOutputs.outputs.map((o) => o.output.id);
|
|
528
|
+
if (outputIds.length > 0) {
|
|
529
|
+
const placeholders = buildPlaceholders(outputIds.length);
|
|
530
|
+
await conn.query(
|
|
531
|
+
`DELETE FROM token_spent_outputs WHERE user_id = ? AND output_id IN (${placeholders})`,
|
|
532
|
+
[this.identity, ...outputIds]
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
for (const output of tokenOutputs.outputs) {
|
|
537
|
+
await this._insertSingleOutput(
|
|
538
|
+
conn,
|
|
539
|
+
tokenOutputs.metadata.identifier,
|
|
540
|
+
output
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
await conn.commit();
|
|
545
|
+
} catch (error) {
|
|
546
|
+
await conn.rollback().catch(() => {});
|
|
547
|
+
throw error;
|
|
548
|
+
} finally {
|
|
549
|
+
conn.release();
|
|
550
|
+
}
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (error instanceof TokenStoreError) throw error;
|
|
553
|
+
throw new TokenStoreError(
|
|
554
|
+
`Failed to insert token outputs: ${error.message}`,
|
|
555
|
+
error
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async reserveTokenOutputs(
|
|
561
|
+
tokenIdentifier,
|
|
562
|
+
target,
|
|
563
|
+
purpose,
|
|
564
|
+
preferredOutputs,
|
|
565
|
+
selectionStrategy
|
|
566
|
+
) {
|
|
567
|
+
try {
|
|
568
|
+
return await this._withWriteTransaction(async (conn) => {
|
|
569
|
+
if (
|
|
570
|
+
target.type === "minTotalValue" &&
|
|
571
|
+
(!target.value || target.value === "0")
|
|
572
|
+
) {
|
|
573
|
+
throw new TokenStoreError(
|
|
574
|
+
"Amount to reserve must be greater than zero"
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
if (
|
|
578
|
+
target.type === "maxOutputCount" &&
|
|
579
|
+
(!target.value || target.value === 0)
|
|
580
|
+
) {
|
|
581
|
+
throw new TokenStoreError(
|
|
582
|
+
"Count to reserve must be greater than zero"
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const [metadataRows] = await conn.query(
|
|
587
|
+
"SELECT * FROM token_metadata WHERE user_id = ? AND identifier = ?",
|
|
588
|
+
[this.identity, tokenIdentifier]
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
if (metadataRows.length === 0) {
|
|
592
|
+
throw new TokenStoreError(
|
|
593
|
+
`Token outputs not found for identifier: ${tokenIdentifier}`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const metadata = this._metadataFromRow(metadataRows[0]);
|
|
598
|
+
|
|
599
|
+
const [outputRows] = await conn.query(
|
|
600
|
+
`SELECT o.id AS output_id, o.owner_public_key, o.revocation_commitment,
|
|
601
|
+
o.withdraw_bond_sats, o.withdraw_relative_block_locktime,
|
|
602
|
+
o.token_public_key, o.token_amount, o.token_identifier,
|
|
603
|
+
o.prev_tx_hash, o.prev_tx_vout
|
|
604
|
+
FROM token_outputs o
|
|
605
|
+
WHERE o.user_id = ? AND o.token_identifier = ? AND o.reservation_id IS NULL`,
|
|
606
|
+
[this.identity, tokenIdentifier]
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
let outputs = outputRows.map((row) => this._outputFromRow(row));
|
|
610
|
+
|
|
611
|
+
if (preferredOutputs && preferredOutputs.length > 0) {
|
|
612
|
+
const preferredIds = new Set(
|
|
613
|
+
preferredOutputs.map((p) => p.output.id)
|
|
614
|
+
);
|
|
615
|
+
outputs = outputs.filter((o) => preferredIds.has(o.output.id));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let selectedOutputs;
|
|
619
|
+
|
|
620
|
+
if (target.type === "minTotalValue") {
|
|
621
|
+
const amount = BigInt(target.value);
|
|
622
|
+
|
|
623
|
+
const totalAvailable = outputs.reduce(
|
|
624
|
+
(sum, o) => sum + BigInt(o.output.tokenAmount),
|
|
625
|
+
0n
|
|
626
|
+
);
|
|
627
|
+
if (totalAvailable < amount) {
|
|
628
|
+
throw new TokenStoreError("InsufficientFunds");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const exactMatch = outputs.find(
|
|
632
|
+
(o) => BigInt(o.output.tokenAmount) === amount
|
|
633
|
+
);
|
|
634
|
+
if (exactMatch) {
|
|
635
|
+
selectedOutputs = [exactMatch];
|
|
636
|
+
} else {
|
|
637
|
+
if (selectionStrategy === "LargestFirst") {
|
|
638
|
+
outputs.sort(
|
|
639
|
+
(a, b) =>
|
|
640
|
+
Number(
|
|
641
|
+
BigInt(b.output.tokenAmount) - BigInt(a.output.tokenAmount)
|
|
642
|
+
)
|
|
643
|
+
);
|
|
644
|
+
} else {
|
|
645
|
+
outputs.sort(
|
|
646
|
+
(a, b) =>
|
|
647
|
+
Number(
|
|
648
|
+
BigInt(a.output.tokenAmount) - BigInt(b.output.tokenAmount)
|
|
649
|
+
)
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
selectedOutputs = [];
|
|
654
|
+
let remaining = amount;
|
|
655
|
+
for (const output of outputs) {
|
|
656
|
+
if (remaining <= 0n) break;
|
|
657
|
+
selectedOutputs.push(output);
|
|
658
|
+
remaining -= BigInt(output.output.tokenAmount);
|
|
659
|
+
}
|
|
660
|
+
if (remaining > 0n) {
|
|
661
|
+
throw new TokenStoreError("InsufficientFunds");
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
} else if (target.type === "maxOutputCount") {
|
|
665
|
+
const count = target.value;
|
|
666
|
+
|
|
667
|
+
if (selectionStrategy === "LargestFirst") {
|
|
668
|
+
outputs.sort(
|
|
669
|
+
(a, b) =>
|
|
670
|
+
Number(
|
|
671
|
+
BigInt(b.output.tokenAmount) - BigInt(a.output.tokenAmount)
|
|
672
|
+
)
|
|
673
|
+
);
|
|
674
|
+
} else {
|
|
675
|
+
outputs.sort(
|
|
676
|
+
(a, b) =>
|
|
677
|
+
Number(
|
|
678
|
+
BigInt(a.output.tokenAmount) - BigInt(b.output.tokenAmount)
|
|
679
|
+
)
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
selectedOutputs = outputs.slice(0, count);
|
|
684
|
+
} else {
|
|
685
|
+
throw new TokenStoreError(`Unknown target type: ${target.type}`);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const reservationId = this._generateId();
|
|
689
|
+
|
|
690
|
+
await conn.query(
|
|
691
|
+
"INSERT INTO token_reservations (user_id, id, purpose) VALUES (?, ?, ?)",
|
|
692
|
+
[this.identity, reservationId, purpose]
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
const selectedIds = selectedOutputs.map((o) => o.output.id);
|
|
696
|
+
if (selectedIds.length > 0) {
|
|
697
|
+
const placeholders = buildPlaceholders(selectedIds.length);
|
|
698
|
+
await conn.query(
|
|
699
|
+
`UPDATE token_outputs SET reservation_id = ? WHERE user_id = ? AND id IN (${placeholders})`,
|
|
700
|
+
[reservationId, this.identity, ...selectedIds]
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
id: reservationId,
|
|
706
|
+
tokenOutputs: { metadata, outputs: selectedOutputs },
|
|
707
|
+
};
|
|
708
|
+
});
|
|
709
|
+
} catch (error) {
|
|
710
|
+
if (error instanceof TokenStoreError) throw error;
|
|
711
|
+
throw new TokenStoreError(
|
|
712
|
+
`Failed to reserve token outputs: ${error.message}`,
|
|
713
|
+
error
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async cancelReservation(id) {
|
|
719
|
+
try {
|
|
720
|
+
await this._withTransaction(async (conn) => {
|
|
721
|
+
// Clear reservation_id from outputs first — the composite FK uses NO
|
|
722
|
+
// ACTION (a whole-row SET NULL would null user_id, which is NOT NULL).
|
|
723
|
+
await conn.query(
|
|
724
|
+
"UPDATE token_outputs SET reservation_id = NULL WHERE user_id = ? AND reservation_id = ?",
|
|
725
|
+
[this.identity, id]
|
|
726
|
+
);
|
|
727
|
+
await conn.query(
|
|
728
|
+
"DELETE FROM token_reservations WHERE user_id = ? AND id = ?",
|
|
729
|
+
[this.identity, id]
|
|
730
|
+
);
|
|
731
|
+
});
|
|
732
|
+
} catch (error) {
|
|
733
|
+
if (error instanceof TokenStoreError) throw error;
|
|
734
|
+
throw new TokenStoreError(
|
|
735
|
+
`Failed to cancel reservation '${id}': ${error.message}`,
|
|
736
|
+
error
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async finalizeReservation(id) {
|
|
742
|
+
try {
|
|
743
|
+
// _withWriteTransaction acquires the GET_LOCK so this serializes
|
|
744
|
+
// against `setTokensOutputs`. Without it, a concurrent setTokensOutputs
|
|
745
|
+
// could read token_spent_outputs before our marker commits and re-insert
|
|
746
|
+
// the just-spent output as Available.
|
|
747
|
+
await this._withWriteTransaction(async (conn) => {
|
|
748
|
+
const [reservationRows] = await conn.query(
|
|
749
|
+
"SELECT purpose FROM token_reservations WHERE user_id = ? AND id = ?",
|
|
750
|
+
[this.identity, id]
|
|
751
|
+
);
|
|
752
|
+
if (reservationRows.length === 0) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const isSwap = reservationRows[0].purpose === "Swap";
|
|
756
|
+
|
|
757
|
+
const [reservedRows] = await conn.query(
|
|
758
|
+
"SELECT id FROM token_outputs WHERE user_id = ? AND reservation_id = ?",
|
|
759
|
+
[this.identity, id]
|
|
760
|
+
);
|
|
761
|
+
const reservedOutputIds = reservedRows.map((r) => r.id);
|
|
762
|
+
|
|
763
|
+
if (reservedOutputIds.length > 0) {
|
|
764
|
+
const valueClauses = new Array(reservedOutputIds.length)
|
|
765
|
+
.fill("(?, ?)")
|
|
766
|
+
.join(", ");
|
|
767
|
+
const params = [];
|
|
768
|
+
for (const outputId of reservedOutputIds) {
|
|
769
|
+
params.push(this.identity, outputId);
|
|
770
|
+
}
|
|
771
|
+
// Suppress duplicate-PK errors only.
|
|
772
|
+
await conn.query(
|
|
773
|
+
`INSERT INTO token_spent_outputs (user_id, output_id) VALUES ${valueClauses}
|
|
774
|
+
ON DUPLICATE KEY UPDATE output_id = output_id`,
|
|
775
|
+
params
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
await conn.query(
|
|
780
|
+
"DELETE FROM token_outputs WHERE user_id = ? AND reservation_id = ?",
|
|
781
|
+
[this.identity, id]
|
|
782
|
+
);
|
|
783
|
+
await conn.query(
|
|
784
|
+
"DELETE FROM token_reservations WHERE user_id = ? AND id = ?",
|
|
785
|
+
[this.identity, id]
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
// UPSERT so a tenant that joined after the multi-tenant migration
|
|
789
|
+
// (and thus has no row) gets one created lazily.
|
|
790
|
+
if (isSwap) {
|
|
791
|
+
await conn.query(
|
|
792
|
+
`INSERT INTO token_swap_status (user_id, last_completed_at) VALUES (?, NOW(6))
|
|
793
|
+
ON DUPLICATE KEY UPDATE last_completed_at = VALUES(last_completed_at)`,
|
|
794
|
+
[this.identity]
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
await conn.query(
|
|
799
|
+
`DELETE FROM token_metadata
|
|
800
|
+
WHERE user_id = ?
|
|
801
|
+
AND identifier NOT IN (
|
|
802
|
+
SELECT DISTINCT token_identifier FROM token_outputs WHERE user_id = ?
|
|
803
|
+
)`,
|
|
804
|
+
[this.identity, this.identity]
|
|
805
|
+
);
|
|
806
|
+
});
|
|
807
|
+
} catch (error) {
|
|
808
|
+
if (error instanceof TokenStoreError) throw error;
|
|
809
|
+
throw new TokenStoreError(
|
|
810
|
+
`Failed to finalize reservation '${id}': ${error.message}`,
|
|
811
|
+
error
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async now() {
|
|
817
|
+
try {
|
|
818
|
+
const [rows] = await this.pool.query("SELECT NOW(6) AS now");
|
|
819
|
+
const value = rows[0].now;
|
|
820
|
+
if (value instanceof Date) return value.getTime();
|
|
821
|
+
return new Date(value).getTime();
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (error instanceof TokenStoreError) throw error;
|
|
824
|
+
throw new TokenStoreError(
|
|
825
|
+
`Failed to get current time: ${error.message}`,
|
|
826
|
+
error
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ===== Private Helpers =====
|
|
832
|
+
|
|
833
|
+
_generateId() {
|
|
834
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
835
|
+
return crypto.randomUUID();
|
|
836
|
+
}
|
|
837
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
838
|
+
const r = (Math.random() * 16) | 0;
|
|
839
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
840
|
+
return v.toString(16);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/// Cleans up stale reservations for THIS tenant. Releases dependent outputs
|
|
845
|
+
/// by clearing reservation_id first, then deletes the parent rows — the
|
|
846
|
+
/// composite FK uses NO ACTION because column-list SET NULL would null
|
|
847
|
+
/// user_id (NOT NULL).
|
|
848
|
+
async _cleanupStaleReservations(conn) {
|
|
849
|
+
await conn.query(
|
|
850
|
+
`UPDATE token_outputs SET reservation_id = NULL
|
|
851
|
+
WHERE user_id = ?
|
|
852
|
+
AND reservation_id IN (
|
|
853
|
+
SELECT id FROM (
|
|
854
|
+
SELECT id FROM token_reservations
|
|
855
|
+
WHERE user_id = ?
|
|
856
|
+
AND created_at < DATE_SUB(NOW(6), INTERVAL ? SECOND)
|
|
857
|
+
) AS stale
|
|
858
|
+
)`,
|
|
859
|
+
[this.identity, this.identity, RESERVATION_TIMEOUT_SECS]
|
|
860
|
+
);
|
|
861
|
+
await conn.query(
|
|
862
|
+
`DELETE FROM token_reservations
|
|
863
|
+
WHERE user_id = ? AND created_at < DATE_SUB(NOW(6), INTERVAL ? SECOND)`,
|
|
864
|
+
[this.identity, RESERVATION_TIMEOUT_SECS]
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async _upsertMetadata(conn, metadata) {
|
|
869
|
+
await conn.query(
|
|
870
|
+
`INSERT INTO token_metadata
|
|
871
|
+
(user_id, identifier, issuer_public_key, name, ticker, decimals, max_supply,
|
|
872
|
+
is_freezable, creation_entity_public_key)
|
|
873
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
874
|
+
ON DUPLICATE KEY UPDATE
|
|
875
|
+
issuer_public_key = VALUES(issuer_public_key),
|
|
876
|
+
name = VALUES(name),
|
|
877
|
+
ticker = VALUES(ticker),
|
|
878
|
+
decimals = VALUES(decimals),
|
|
879
|
+
max_supply = VALUES(max_supply),
|
|
880
|
+
is_freezable = VALUES(is_freezable),
|
|
881
|
+
creation_entity_public_key = VALUES(creation_entity_public_key)`,
|
|
882
|
+
[
|
|
883
|
+
this.identity,
|
|
884
|
+
metadata.identifier,
|
|
885
|
+
metadata.issuerPublicKey,
|
|
886
|
+
metadata.name,
|
|
887
|
+
metadata.ticker,
|
|
888
|
+
metadata.decimals,
|
|
889
|
+
metadata.maxSupply,
|
|
890
|
+
metadata.isFreezable ? 1 : 0,
|
|
891
|
+
metadata.creationEntityPublicKey || null,
|
|
892
|
+
]
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async _insertSingleOutput(conn, tokenIdentifier, output) {
|
|
897
|
+
// ON DUPLICATE KEY UPDATE id = id no-ops on the (user_id, id) primary key
|
|
898
|
+
// conflict only — unlike INSERT IGNORE, FK / NOT NULL / type errors
|
|
899
|
+
// still propagate.
|
|
900
|
+
await conn.query(
|
|
901
|
+
`INSERT INTO token_outputs
|
|
902
|
+
(user_id, id, token_identifier, owner_public_key, revocation_commitment,
|
|
903
|
+
withdraw_bond_sats, withdraw_relative_block_locktime,
|
|
904
|
+
token_public_key, token_amount, prev_tx_hash, prev_tx_vout, added_at)
|
|
905
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(6))
|
|
906
|
+
ON DUPLICATE KEY UPDATE id = id`,
|
|
907
|
+
[
|
|
908
|
+
this.identity,
|
|
909
|
+
output.output.id,
|
|
910
|
+
tokenIdentifier,
|
|
911
|
+
output.output.ownerPublicKey,
|
|
912
|
+
output.output.revocationCommitment,
|
|
913
|
+
output.output.withdrawBondSats,
|
|
914
|
+
output.output.withdrawRelativeBlockLocktime,
|
|
915
|
+
output.output.tokenPublicKey || null,
|
|
916
|
+
output.output.tokenAmount,
|
|
917
|
+
output.prevTxHash,
|
|
918
|
+
output.prevTxVout,
|
|
919
|
+
]
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
_metadataFromRow(row) {
|
|
924
|
+
return {
|
|
925
|
+
identifier: row.identifier,
|
|
926
|
+
issuerPublicKey: row.issuer_public_key,
|
|
927
|
+
name: row.name,
|
|
928
|
+
ticker: row.ticker,
|
|
929
|
+
decimals: row.decimals,
|
|
930
|
+
maxSupply: row.max_supply,
|
|
931
|
+
isFreezable: toBool(row.is_freezable) ?? false,
|
|
932
|
+
creationEntityPublicKey: row.creation_entity_public_key || null,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
_outputFromRow(row) {
|
|
937
|
+
return {
|
|
938
|
+
output: {
|
|
939
|
+
id: row.output_id,
|
|
940
|
+
ownerPublicKey: row.owner_public_key,
|
|
941
|
+
revocationCommitment: row.revocation_commitment,
|
|
942
|
+
withdrawBondSats: Number(row.withdraw_bond_sats),
|
|
943
|
+
withdrawRelativeBlockLocktime: Number(
|
|
944
|
+
row.withdraw_relative_block_locktime
|
|
945
|
+
),
|
|
946
|
+
tokenPublicKey: row.token_public_key || null,
|
|
947
|
+
tokenIdentifier: row.token_identifier || row.identifier,
|
|
948
|
+
tokenAmount: row.token_amount,
|
|
949
|
+
},
|
|
950
|
+
prevTxHash: row.prev_tx_hash,
|
|
951
|
+
prevTxVout: row.prev_tx_vout,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function createMysqlPool(config) {
|
|
957
|
+
return mysql.createPool({
|
|
958
|
+
uri: config.connectionString,
|
|
959
|
+
connectionLimit: config.maxPoolSize,
|
|
960
|
+
connectTimeout: (config.createTimeoutSecs || 0) * 1000 || 10000,
|
|
961
|
+
idleTimeout: (config.recycleTimeoutSecs || 0) * 1000 || 10000,
|
|
962
|
+
waitForConnections: true,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* @param {object} config - MySQL configuration
|
|
968
|
+
* @param {Buffer|Uint8Array} identity - 33-byte secp256k1 compressed pubkey
|
|
969
|
+
* identifying the tenant. All reads and writes are scoped by this.
|
|
970
|
+
* @param {object} [logger]
|
|
971
|
+
*/
|
|
972
|
+
async function createMysqlTokenStore(config, identity, logger = null) {
|
|
973
|
+
const pool = createMysqlPool(config);
|
|
974
|
+
return createMysqlTokenStoreWithPool(pool, identity, logger);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function createMysqlTokenStoreWithPool(pool, identity, logger = null) {
|
|
978
|
+
const store = new MysqlTokenStore(pool, identity, logger);
|
|
979
|
+
await store.initialize();
|
|
980
|
+
return store;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
module.exports = {
|
|
984
|
+
MysqlTokenStore,
|
|
985
|
+
createMysqlTokenStore,
|
|
986
|
+
createMysqlTokenStoreWithPool,
|
|
987
|
+
TokenStoreError,
|
|
988
|
+
};
|