@fairmint/canton-fairmint-sdk 0.0.2

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.
Files changed (54) hide show
  1. package/README.md +64 -0
  2. package/dist/clients/index.d.ts +5 -0
  3. package/dist/clients/index.d.ts.map +1 -0
  4. package/dist/clients/index.js +21 -0
  5. package/dist/clients/index.js.map +1 -0
  6. package/dist/clients/json-api/index.d.ts +2 -0
  7. package/dist/clients/json-api/index.d.ts.map +1 -0
  8. package/dist/clients/json-api/index.js +18 -0
  9. package/dist/clients/json-api/index.js.map +1 -0
  10. package/dist/clients/json-api/sdkHelper.d.ts +27 -0
  11. package/dist/clients/json-api/sdkHelper.d.ts.map +1 -0
  12. package/dist/clients/json-api/sdkHelper.js +73 -0
  13. package/dist/clients/json-api/sdkHelper.js.map +1 -0
  14. package/dist/clients/postgres-db-api/cantonDbClient.d.ts +335 -0
  15. package/dist/clients/postgres-db-api/cantonDbClient.d.ts.map +1 -0
  16. package/dist/clients/postgres-db-api/cantonDbClient.js +2703 -0
  17. package/dist/clients/postgres-db-api/cantonDbClient.js.map +1 -0
  18. package/dist/clients/postgres-db-api/fairmintDbClient.d.ts +241 -0
  19. package/dist/clients/postgres-db-api/fairmintDbClient.d.ts.map +1 -0
  20. package/dist/clients/postgres-db-api/fairmintDbClient.js +3078 -0
  21. package/dist/clients/postgres-db-api/fairmintDbClient.js.map +1 -0
  22. package/dist/clients/postgres-db-api/index.d.ts +5 -0
  23. package/dist/clients/postgres-db-api/index.d.ts.map +1 -0
  24. package/dist/clients/postgres-db-api/index.js +25 -0
  25. package/dist/clients/postgres-db-api/index.js.map +1 -0
  26. package/dist/clients/postgres-db-api/postgresDbClient.d.ts +118 -0
  27. package/dist/clients/postgres-db-api/postgresDbClient.d.ts.map +1 -0
  28. package/dist/clients/postgres-db-api/postgresDbClient.js +212 -0
  29. package/dist/clients/postgres-db-api/postgresDbClient.js.map +1 -0
  30. package/dist/clients/postgres-db-api/types.d.ts +330 -0
  31. package/dist/clients/postgres-db-api/types.d.ts.map +1 -0
  32. package/dist/clients/postgres-db-api/types.js +30 -0
  33. package/dist/clients/postgres-db-api/types.js.map +1 -0
  34. package/dist/clients/shared/config.d.ts +19 -0
  35. package/dist/clients/shared/config.d.ts.map +1 -0
  36. package/dist/clients/shared/config.js +208 -0
  37. package/dist/clients/shared/config.js.map +1 -0
  38. package/dist/clients/shared/index.d.ts +3 -0
  39. package/dist/clients/shared/index.d.ts.map +1 -0
  40. package/dist/clients/shared/index.js +19 -0
  41. package/dist/clients/shared/index.js.map +1 -0
  42. package/dist/clients/shared/types.d.ts +29 -0
  43. package/dist/clients/shared/types.d.ts.map +1 -0
  44. package/dist/clients/shared/types.js +3 -0
  45. package/dist/clients/shared/types.js.map +1 -0
  46. package/dist/clients/validator-api/index.d.ts +4 -0
  47. package/dist/clients/validator-api/index.d.ts.map +1 -0
  48. package/dist/clients/validator-api/index.js +61 -0
  49. package/dist/clients/validator-api/index.js.map +1 -0
  50. package/dist/index.d.ts +7 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +24 -0
  53. package/dist/index.js.map +1 -0
  54. package/package.json +60 -0
@@ -0,0 +1,2703 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CantonDbClient = void 0;
4
+ const pg_1 = require("pg");
5
+ const config_1 = require("../shared/config");
6
+ function assertEqualLengths(arrays, context) {
7
+ if (arrays.length === 0)
8
+ return;
9
+ const expected = arrays[0].length;
10
+ for (let i = 1; i < arrays.length; i++) {
11
+ if (arrays[i].length !== expected) {
12
+ throw new Error(`${context}: array length mismatch (expected ${expected}, got ${arrays[i].length})`);
13
+ }
14
+ }
15
+ }
16
+ class CantonDbClient {
17
+ constructor(network) {
18
+ // Initialize the shared config which handles environment loading
19
+ this.config = new config_1.ProviderConfig();
20
+ // Use provided network or get from environment variable, defaulting to devnet
21
+ this.network = network;
22
+ const databaseUrl = this.getCantonTransactionsDatabaseUrl();
23
+ if (databaseUrl) {
24
+ // Extract database name from URL for debugging (without exposing credentials)
25
+ const dbNameMatch = databaseUrl.match(/\/\/([^:]+:[^@]+@)?[^\/]+\/([^?]+)/);
26
+ const _dbName = dbNameMatch ? dbNameMatch[2] : 'unknown';
27
+ }
28
+ if (!databaseUrl) {
29
+ throw new Error(`Canton transactions database URL for ${this.network} is not configured. Please set CANTON_TRANSACTIONS_DB_URL_${this.network.toUpperCase()} environment variable.`);
30
+ }
31
+ this.pool = new pg_1.Pool({
32
+ connectionString: databaseUrl,
33
+ ssl: { rejectUnauthorized: false },
34
+ });
35
+ // Test the connection
36
+ this.pool.on('error', err => {
37
+ console.error('Unexpected error on idle client', err);
38
+ process.exit(-1);
39
+ });
40
+ }
41
+ getCantonTransactionsDatabaseUrl() {
42
+ return this.config.getCantonTransactionsDatabaseUrl(this.network);
43
+ }
44
+ getNetwork() {
45
+ return this.network;
46
+ }
47
+ async connect() {
48
+ return this.pool.connect();
49
+ }
50
+ async close() {
51
+ await this.pool.end();
52
+ }
53
+ // Transaction helper - runs a callback within a database transaction
54
+ // Includes deadlock handling with retry logic and lock timeout
55
+ async runInTransaction(callback) {
56
+ const MAX_RETRIES = 5;
57
+ const LOCK_TIMEOUT_MS = 30000; // 30 seconds
58
+ const INITIAL_BACKOFF_MS = 100; // Start with 100ms
59
+ let lastError;
60
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
61
+ const client = await this.pool.connect();
62
+ try {
63
+ // Set lock timeout to prevent indefinite waiting
64
+ await client.query(`SET LOCAL lock_timeout = '${LOCK_TIMEOUT_MS}ms'`);
65
+ await client.query('BEGIN');
66
+ const result = await callback(client);
67
+ await client.query('COMMIT');
68
+ client.release();
69
+ return result;
70
+ }
71
+ catch (error) {
72
+ try {
73
+ await client.query('ROLLBACK');
74
+ }
75
+ catch {
76
+ // Ignore rollback errors, we're already in error handling
77
+ }
78
+ client.release();
79
+ // Check if this is a deadlock error (PostgreSQL error code 40P01)
80
+ // pg library exposes error code in error.code property
81
+ const pgError = error;
82
+ const isDeadlock = pgError.code === '40P01' ||
83
+ (pgError.message &&
84
+ (pgError.message.includes('deadlock detected') ||
85
+ pgError.message.toLowerCase().includes('deadlock')));
86
+ // Check if this is a lock timeout error
87
+ const isLockTimeout = pgError.message &&
88
+ (pgError.message.includes('lock_timeout') ||
89
+ pgError.message.includes('canceling statement due to lock timeout') ||
90
+ (pgError.message.includes('timeout') &&
91
+ pgError.message.includes('lock')));
92
+ if ((isDeadlock || isLockTimeout) && attempt < MAX_RETRIES - 1) {
93
+ // Exponential backoff with jitter
94
+ const backoffMs = INITIAL_BACKOFF_MS * 2 ** attempt + Math.random() * 100;
95
+ const errorType = isDeadlock ? 'deadlock' : 'lock timeout';
96
+ console.warn(`⚠️ Database ${errorType} detected (attempt ${attempt + 1}/${MAX_RETRIES}). ` +
97
+ `Retrying after ${Math.round(backoffMs)}ms...`);
98
+ await new Promise(resolve => setTimeout(resolve, backoffMs));
99
+ lastError = error;
100
+ continue;
101
+ }
102
+ // Not a retryable error, or max retries reached
103
+ throw error;
104
+ }
105
+ }
106
+ // This should never be reached, but TypeScript needs it
107
+ if (lastError instanceof Error) {
108
+ throw lastError;
109
+ }
110
+ throw new Error('Transaction failed after maximum retries');
111
+ }
112
+ // Canton Transactions CRUD operations
113
+ async insertCantonTransaction(tx, client = this.pool) {
114
+ const query = `
115
+ INSERT INTO transactions (
116
+ network, update_id, command_id, workflow_id, effective_at, record_time,
117
+ external_transaction_hash
118
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
119
+ ON CONFLICT (network, update_id) DO NOTHING
120
+ RETURNING id
121
+ `;
122
+ const values = [
123
+ tx.network,
124
+ tx.update_id,
125
+ tx.command_id,
126
+ tx.workflow_id,
127
+ tx.effective_at,
128
+ tx.record_time,
129
+ tx.external_transaction_hash,
130
+ ];
131
+ const result = await client.query(query, values);
132
+ if (result.rows.length === 0) {
133
+ // Conflict occurred, fetch existing
134
+ const existingResult = await client.query('SELECT id FROM transactions WHERE network = $1 AND update_id = $2', [tx.network, tx.update_id]);
135
+ return { id: BigInt(existingResult.rows[0].id) };
136
+ }
137
+ return { id: BigInt(result.rows[0].id) };
138
+ }
139
+ async insertCantonTransactionProvider(provider, client = this.pool) {
140
+ const query = `
141
+ INSERT INTO transaction_providers (
142
+ transaction_id, provider_id, ledger_offset
143
+ ) VALUES ($1, $2, $3)
144
+ ON CONFLICT (transaction_id, provider_id) DO UPDATE
145
+ SET ledger_offset = EXCLUDED.ledger_offset
146
+ RETURNING id
147
+ `;
148
+ const values = [
149
+ provider.transaction_id.toString(),
150
+ provider.provider_id.toString(),
151
+ provider.ledger_offset.toString(),
152
+ ];
153
+ const result = await client.query(query, values);
154
+ return { id: BigInt(result.rows[0].id) };
155
+ }
156
+ async insertCantonTransactionEvent(event, client = this.pool) {
157
+ const query = `
158
+ INSERT INTO transaction_events (
159
+ transaction_id, kind,
160
+ contract_id, package_id
161
+ ) VALUES ($1, $2, $3, $4)
162
+ ON CONFLICT (transaction_id, kind, contract_id, package_id) DO UPDATE SET
163
+ transaction_id = EXCLUDED.transaction_id
164
+ RETURNING id
165
+ `;
166
+ const values = [
167
+ event.transaction_id.toString(),
168
+ event.kind,
169
+ event.contract_id.toString(),
170
+ event.package_id.toString(),
171
+ ];
172
+ const result = await client.query(query, values);
173
+ return { id: BigInt(result.rows[0].id) };
174
+ }
175
+ async insertCantonTransactionEventProvider(provider, client = this.pool) {
176
+ const query = `
177
+ INSERT INTO transaction_event_providers (
178
+ event_id, provider_id, node_id
179
+ ) VALUES ($1, $2, $3)
180
+ ON CONFLICT (event_id, provider_id, node_id) DO NOTHING
181
+ `;
182
+ const values = [
183
+ provider.event_id.toString(),
184
+ provider.provider_id.toString(),
185
+ provider.node_id.toString(),
186
+ ];
187
+ await client.query(query, values);
188
+ }
189
+ // Created event payload now stored on contracts.create_argument; this method is deprecated.
190
+ async insertCantonTransactionEventCreated() {
191
+ throw new Error('insertCantonTransactionEventCreated is deprecated; use contracts.create_argument instead.');
192
+ }
193
+ async insertCantonTransactionEventExercised(details, client = this.pool) {
194
+ const query = `
195
+ INSERT INTO transaction_events_exercised (
196
+ event_id, interface_id, choice_id, choice_argument,
197
+ exercise_result
198
+ ) VALUES ($1, $2, $3, $4, $5)
199
+ ON CONFLICT (event_id) DO NOTHING
200
+ `;
201
+ const values = [
202
+ details.event_id.toString(),
203
+ details.interface_id,
204
+ details.choice_id.toString(),
205
+ JSON.stringify(details.choice_argument),
206
+ JSON.stringify(details.exercise_result),
207
+ ];
208
+ await client.query(query, values);
209
+ }
210
+ async insertCantonTransactionEventExercisedProvider(details, client = this.pool) {
211
+ const query = `
212
+ INSERT INTO transaction_events_exercised_providers (
213
+ event_id, provider_id, node_id, last_descendant_node_id
214
+ ) VALUES ($1, $2, $3, $4)
215
+ ON CONFLICT (event_id, provider_id, node_id) DO NOTHING
216
+ `;
217
+ const values = [
218
+ details.event_id.toString(),
219
+ details.provider_id.toString(),
220
+ details.node_id.toString(),
221
+ details.last_descendant_node_id.toString(),
222
+ ];
223
+ await client.query(query, values);
224
+ }
225
+ async insertCantonTransactionEventAssigned(_details, _client = this.pool) {
226
+ // Note: Assigned/Unassigned tables are not in V2 schema anymore, but keeping method if caller exists?
227
+ // If not used, we should remove. User said "update dependencies".
228
+ // I will leave them failing or remove? The V2 schema dropped them.
229
+ // I'll comment out or throw for now if I removed the tables.
230
+ // Actually, I removed them in the SQL script. So these queries will fail.
231
+ // I should probably remove these methods or make them throw.
232
+ throw new Error('insertCantonTransactionEventAssigned is deprecated in V2 schema.');
233
+ }
234
+ async insertCantonTransactionEventUnassigned(_details, _client = this.pool) {
235
+ throw new Error('insertCantonTransactionEventUnassigned is deprecated in V2 schema.');
236
+ }
237
+ // Junction table methods for party relationships
238
+ async upsertCantonParty(partyId, network, client = this.pool) {
239
+ const query = `
240
+ INSERT INTO parties (network, party_id)
241
+ VALUES ($1, $2)
242
+ ON CONFLICT (network, party_id) DO NOTHING
243
+ RETURNING id
244
+ `;
245
+ const result = await client.query(query, [network, partyId]);
246
+ if (result.rows.length === 0) {
247
+ // Conflict occurred, fetch existing
248
+ const existingResult = await client.query('SELECT id FROM parties WHERE network = $1 AND party_id = $2', [network, partyId]);
249
+ return BigInt(existingResult.rows[0].id);
250
+ }
251
+ return BigInt(result.rows[0].id);
252
+ }
253
+ async insertActors(contractId, partyIds, actorType, network, client = this.pool) {
254
+ if (partyIds.length === 0)
255
+ return;
256
+ for (const partyId of partyIds) {
257
+ const partyDbId = await this.upsertCantonParty(partyId, network, client);
258
+ // Signatory should override observer on conflict; observer should not downgrade signatory
259
+ const query = actorType === 'signatory'
260
+ ? `INSERT INTO contract_actors (contract_id, party_id, actor_type)
261
+ VALUES ($1, $2, 'signatory')
262
+ ON CONFLICT (contract_id, party_id) DO UPDATE SET actor_type = 'signatory'`
263
+ : `INSERT INTO contract_actors (contract_id, party_id, actor_type)
264
+ VALUES ($1, $2, 'observer')
265
+ ON CONFLICT (contract_id, party_id) DO NOTHING`;
266
+ await client.query(query, [contractId.toString(), partyDbId.toString()]);
267
+ }
268
+ }
269
+ async insertActingParties(eventId, partyIds, network, client = this.pool) {
270
+ if (partyIds.length === 0)
271
+ return;
272
+ for (const partyId of partyIds) {
273
+ const partyDbId = await this.upsertCantonParty(partyId, network, client);
274
+ await client.query(`INSERT INTO transaction_events_exercised_acting_parties
275
+ (event_id, party_id)
276
+ VALUES ($1, $2)
277
+ ON CONFLICT (event_id, party_id) DO NOTHING`, [eventId.toString(), partyDbId.toString()]);
278
+ }
279
+ }
280
+ // Bulk insert methods for improved performance
281
+ async bulkInsertCantonTransactions(transactions, client = this.pool) {
282
+ if (transactions.length === 0) {
283
+ return new Map();
284
+ }
285
+ const networks = [];
286
+ const updateIds = [];
287
+ const commandIds = [];
288
+ const workflowIds = [];
289
+ const effectiveAts = [];
290
+ const recordTimes = [];
291
+ const extHashes = [];
292
+ for (const tx of transactions) {
293
+ if (!tx.network) {
294
+ throw new Error(`Transaction missing network: ${JSON.stringify(tx)}`);
295
+ }
296
+ if (!tx.update_id) {
297
+ throw new Error(`Transaction missing update_id: ${JSON.stringify(tx)}`);
298
+ }
299
+ networks.push(tx.network);
300
+ updateIds.push(tx.update_id);
301
+ commandIds.push(tx.command_id ?? null);
302
+ workflowIds.push(tx.workflow_id ?? null);
303
+ effectiveAts.push(tx.effective_at);
304
+ recordTimes.push(tx.record_time);
305
+ extHashes.push(tx.external_transaction_hash ?? null);
306
+ }
307
+ assertEqualLengths([
308
+ networks,
309
+ updateIds,
310
+ commandIds,
311
+ workflowIds,
312
+ effectiveAts,
313
+ recordTimes,
314
+ extHashes,
315
+ ], 'bulkInsertCantonTransactions');
316
+ const query = `
317
+ WITH data AS (
318
+ SELECT
319
+ unnest($1::text[]) AS network,
320
+ unnest($2::text[]) AS update_id,
321
+ unnest($3::text[]) AS command_id,
322
+ unnest($4::text[]) AS workflow_id,
323
+ unnest($5::timestamptz[]) AS effective_at,
324
+ unnest($6::timestamptz[]) AS record_time,
325
+ unnest($7::text[]) AS external_transaction_hash
326
+ )
327
+ INSERT INTO transactions (
328
+ network, update_id, command_id, workflow_id, effective_at, record_time,
329
+ external_transaction_hash
330
+ )
331
+ SELECT
332
+ network, update_id, command_id, workflow_id, effective_at, record_time, external_transaction_hash
333
+ FROM data
334
+ ON CONFLICT (network, update_id) DO UPDATE SET
335
+ command_id = EXCLUDED.command_id,
336
+ workflow_id = EXCLUDED.workflow_id,
337
+ effective_at = EXCLUDED.effective_at,
338
+ record_time = EXCLUDED.record_time,
339
+ external_transaction_hash = EXCLUDED.external_transaction_hash
340
+ RETURNING id, network, update_id
341
+ `;
342
+ const result = await client.query(query, [
343
+ networks,
344
+ updateIds,
345
+ commandIds,
346
+ workflowIds,
347
+ effectiveAts,
348
+ recordTimes,
349
+ extHashes,
350
+ ]);
351
+ const idMap = new Map();
352
+ for (const row of result.rows) {
353
+ // Use network + update_id as key to ensure uniqueness across networks
354
+ const key = `${row.network}:${row.update_id}`;
355
+ idMap.set(key, BigInt(row.id));
356
+ }
357
+ // CRITICAL: With DO UPDATE, all transactions should now be in idMap
358
+ // Verify we got IDs for all transactions to prevent data loss
359
+ if (idMap.size !== transactions.length) {
360
+ throw new Error(`Transaction ID map incomplete: Expected ${transactions.length} transactions, got ${idMap.size} IDs. ` +
361
+ `This indicates a critical database issue that could result in data loss.`);
362
+ }
363
+ return idMap;
364
+ }
365
+ async bulkInsertCantonTransactionProviders(providers, client = this.pool) {
366
+ if (providers.length === 0) {
367
+ return;
368
+ }
369
+ const transactionIds = [];
370
+ const providerIds = [];
371
+ const ledgerOffsets = [];
372
+ for (const provider of providers) {
373
+ transactionIds.push(provider.transaction_id.toString());
374
+ providerIds.push(provider.provider_id.toString());
375
+ ledgerOffsets.push(provider.ledger_offset.toString());
376
+ }
377
+ assertEqualLengths([transactionIds, providerIds, ledgerOffsets], 'bulkInsertCantonTransactionProviders');
378
+ const query = `
379
+ WITH data AS (
380
+ SELECT
381
+ unnest($1::bigint[]) AS transaction_id,
382
+ unnest($2::bigint[]) AS provider_id,
383
+ unnest($3::bigint[]) AS ledger_offset
384
+ )
385
+ INSERT INTO transaction_providers (
386
+ transaction_id, provider_id, ledger_offset
387
+ )
388
+ SELECT transaction_id, provider_id, ledger_offset
389
+ FROM data
390
+ ON CONFLICT (transaction_id, provider_id) DO UPDATE
391
+ SET ledger_offset = EXCLUDED.ledger_offset
392
+ `;
393
+ await client.query(query, [transactionIds, providerIds, ledgerOffsets]);
394
+ }
395
+ async bulkInsertCantonTransactionEvents(events, client = this.pool) {
396
+ if (events.length === 0) {
397
+ return new Map();
398
+ }
399
+ const transactionIds = [];
400
+ const kinds = [];
401
+ const contractIds = [];
402
+ const packageIds = [];
403
+ for (const event of events) {
404
+ if (!event.transaction_id) {
405
+ throw new Error(`Event missing transaction_id: ${JSON.stringify(event)}`);
406
+ }
407
+ if (!event.package_id || event.package_id === BigInt(0)) {
408
+ throw new Error(`Event missing or invalid package_id: ${JSON.stringify(event)}`);
409
+ }
410
+ if (!event.contract_id) {
411
+ throw new Error(`Event missing contract_id: ${JSON.stringify(event)}`);
412
+ }
413
+ transactionIds.push(event.transaction_id.toString());
414
+ kinds.push(event.kind);
415
+ contractIds.push(event.contract_id.toString());
416
+ packageIds.push(event.package_id.toString());
417
+ }
418
+ assertEqualLengths([transactionIds, kinds, contractIds, packageIds], 'bulkInsertCantonTransactionEvents');
419
+ const query = `
420
+ WITH data AS (
421
+ SELECT
422
+ unnest($1::bigint[]) AS transaction_id,
423
+ unnest($2::text[]) AS kind,
424
+ unnest($3::bigint[]) AS contract_id,
425
+ unnest($4::bigint[]) AS package_id
426
+ )
427
+ INSERT INTO transaction_events (
428
+ transaction_id, kind,
429
+ contract_id, package_id
430
+ )
431
+ SELECT transaction_id, kind, contract_id, package_id
432
+ FROM data
433
+ ON CONFLICT (transaction_id, kind, contract_id, package_id) DO UPDATE SET
434
+ transaction_id = EXCLUDED.transaction_id
435
+ RETURNING id, transaction_id, kind, contract_id, package_id
436
+ `;
437
+ const result = await client.query(query, [
438
+ transactionIds,
439
+ kinds,
440
+ contractIds,
441
+ packageIds,
442
+ ]);
443
+ const idMap = new Map();
444
+ for (const row of result.rows) {
445
+ // Build key from unique constraint fields (must include transaction_id)
446
+ const key = `${row.transaction_id}-${row.kind}-${row.contract_id}-${row.package_id}`;
447
+ idMap.set(key, BigInt(row.id));
448
+ }
449
+ return idMap;
450
+ }
451
+ async bulkInsertCantonTransactionEventProviders(providers, client = this.pool) {
452
+ if (providers.length === 0) {
453
+ return;
454
+ }
455
+ const eventIds = [];
456
+ const providerIds = [];
457
+ const nodeIds = [];
458
+ for (const provider of providers) {
459
+ eventIds.push(provider.event_id.toString());
460
+ providerIds.push(provider.provider_id.toString());
461
+ nodeIds.push(provider.node_id.toString());
462
+ }
463
+ assertEqualLengths([eventIds, providerIds, nodeIds], 'bulkInsertCantonTransactionEventProviders');
464
+ const query = `
465
+ WITH data AS (
466
+ SELECT
467
+ unnest($1::bigint[]) AS event_id,
468
+ unnest($2::bigint[]) AS provider_id,
469
+ unnest($3::bigint[]) AS node_id
470
+ )
471
+ INSERT INTO transaction_event_providers (
472
+ event_id, provider_id, node_id
473
+ )
474
+ SELECT event_id, provider_id, node_id
475
+ FROM data
476
+ ON CONFLICT (event_id, provider_id, node_id) DO NOTHING
477
+ `;
478
+ await client.query(query, [eventIds, providerIds, nodeIds]);
479
+ }
480
+ async bulkInsertCantonTransactionEventExercised(details, client = this.pool) {
481
+ if (details.length === 0) {
482
+ return;
483
+ }
484
+ const eventIds = [];
485
+ const interfaceIds = [];
486
+ const choiceIds = [];
487
+ const choiceArgs = [];
488
+ const exerciseResults = [];
489
+ for (const detail of details) {
490
+ eventIds.push(detail.event_id.toString());
491
+ interfaceIds.push(detail.interface_id ?? null);
492
+ choiceIds.push(detail.choice_id.toString());
493
+ choiceArgs.push(JSON.stringify(detail.choice_argument));
494
+ exerciseResults.push(JSON.stringify(detail.exercise_result));
495
+ }
496
+ assertEqualLengths([eventIds, interfaceIds, choiceIds, choiceArgs, exerciseResults], 'bulkInsertExercised');
497
+ const query = `
498
+ WITH data AS (
499
+ SELECT
500
+ unnest($1::bigint[]) AS event_id,
501
+ unnest($2::text[]) AS interface_id,
502
+ unnest($3::bigint[]) AS choice_id,
503
+ unnest($4::jsonb[]) AS choice_argument,
504
+ unnest($5::jsonb[]) AS exercise_result
505
+ )
506
+ INSERT INTO transaction_events_exercised (
507
+ event_id, interface_id, choice_id, choice_argument,
508
+ exercise_result
509
+ )
510
+ SELECT event_id, interface_id, choice_id, choice_argument, exercise_result
511
+ FROM data
512
+ ON CONFLICT (event_id) DO UPDATE SET
513
+ interface_id = EXCLUDED.interface_id,
514
+ choice_id = EXCLUDED.choice_id,
515
+ choice_argument = EXCLUDED.choice_argument,
516
+ exercise_result = EXCLUDED.exercise_result
517
+ `;
518
+ await client.query(query, [
519
+ eventIds,
520
+ interfaceIds,
521
+ choiceIds,
522
+ choiceArgs,
523
+ exerciseResults,
524
+ ]);
525
+ }
526
+ async bulkInsertCantonTransactionEventExercisedProviders(details, client = this.pool) {
527
+ if (details.length === 0) {
528
+ return;
529
+ }
530
+ const eventIds = [];
531
+ const providerIds = [];
532
+ const nodeIds = [];
533
+ const lastDescendantIds = [];
534
+ for (const detail of details) {
535
+ eventIds.push(detail.event_id.toString());
536
+ providerIds.push(detail.provider_id.toString());
537
+ nodeIds.push(detail.node_id.toString());
538
+ lastDescendantIds.push(detail.last_descendant_node_id.toString());
539
+ }
540
+ assertEqualLengths([eventIds, providerIds, nodeIds, lastDescendantIds], 'bulkInsertExercisedProviders');
541
+ const query = `
542
+ WITH data AS (
543
+ SELECT
544
+ unnest($1::bigint[]) AS event_id,
545
+ unnest($2::bigint[]) AS provider_id,
546
+ unnest($3::bigint[]) AS node_id,
547
+ unnest($4::bigint[]) AS last_descendant_node_id
548
+ )
549
+ INSERT INTO transaction_events_exercised_providers (
550
+ event_id, provider_id, node_id, last_descendant_node_id
551
+ )
552
+ SELECT event_id, provider_id, node_id, last_descendant_node_id
553
+ FROM data
554
+ ON CONFLICT (event_id, provider_id, node_id) DO NOTHING
555
+ `;
556
+ await client.query(query, [
557
+ eventIds,
558
+ providerIds,
559
+ nodeIds,
560
+ lastDescendantIds,
561
+ ]);
562
+ }
563
+ async getLastProcessedOffset(provider) {
564
+ let query;
565
+ let values = [];
566
+ if (provider) {
567
+ // Get the provider_id first
568
+ const providerQuery = `
569
+ SELECT id FROM providers
570
+ WHERE network = $1 AND provider = $2
571
+ LIMIT 1
572
+ `;
573
+ const providerResult = await this.pool.query(providerQuery, [
574
+ this.network,
575
+ provider,
576
+ ]);
577
+ if (providerResult.rows.length === 0) {
578
+ return BigInt(0);
579
+ }
580
+ const providerId = providerResult.rows[0].id;
581
+ query = `
582
+ SELECT MAX(ledger_offset) as max_offset
583
+ FROM transaction_providers
584
+ WHERE provider_id = $1
585
+ `;
586
+ values = [providerId];
587
+ }
588
+ else {
589
+ query = `
590
+ SELECT MAX(ledger_offset) as max_offset
591
+ FROM transaction_providers
592
+ `;
593
+ }
594
+ const result = await this.pool.query(query, values);
595
+ const maxOffset = result.rows[0].max_offset;
596
+ return maxOffset ? BigInt(maxOffset) : BigInt(0);
597
+ }
598
+ async getAllPartiesForNetwork() {
599
+ const query = `
600
+ SELECT party_id
601
+ FROM parties
602
+ WHERE network = $1
603
+ ORDER BY party_id
604
+ `;
605
+ const result = await this.pool.query(query, [this.network]);
606
+ return result.rows;
607
+ }
608
+ /**
609
+ * Find all transaction update IDs that involve a specific contract ID Returns transactions ordered by record_time
610
+ * (most recent first)
611
+ */
612
+ async findTransactionsByContractId(contractId) {
613
+ const query = `
614
+ SELECT DISTINCT t.update_id, t.record_time, e.kind
615
+ FROM transaction_events e
616
+ INNER JOIN transactions t ON e.transaction_id = t.id
617
+ INNER JOIN contracts c ON e.contract_id = c.id
618
+ WHERE c.canton_contract_id = $1 AND t.network = $2
619
+ ORDER BY t.record_time DESC
620
+ `;
621
+ const result = await this.pool.query(query, [contractId, this.network]);
622
+ return result.rows;
623
+ }
624
+ /**
625
+ * Get comprehensive contract history for a specific contract ID Returns Created event, Archive event (if exists), and
626
+ * all Exercise events with updateIds
627
+ */
628
+ async getContractHistory(contractId) {
629
+ // Created event (uses contracts.create_argument; actors via transaction_event_actors)
630
+ const createdQuery = `
631
+ SELECT
632
+ t.update_id,
633
+ t.record_time,
634
+ t.effective_at,
635
+ c.create_argument,
636
+ pkg.package_id || ':' || ct.module_name || ':' || ct.template_name as template_id,
637
+ ct.package_name,
638
+ COALESCE(
639
+ json_agg(DISTINCT sp.party_id) FILTER (WHERE sp.party_id IS NOT NULL),
640
+ '[]'::json
641
+ ) as signatories,
642
+ COALESCE(
643
+ json_agg(DISTINCT op.party_id) FILTER (WHERE op.party_id IS NOT NULL),
644
+ '[]'::json
645
+ ) as observers
646
+ FROM transaction_events e
647
+ INNER JOIN transactions t ON e.transaction_id = t.id
648
+ INNER JOIN contracts c ON e.contract_id = c.id
649
+ INNER JOIN templates ct ON c.template_id = ct.id
650
+ INNER JOIN packages pkg ON e.package_id = pkg.id
651
+ LEFT JOIN contract_actors act_sig ON act_sig.contract_id = c.id AND act_sig.actor_type = 'signatory'
652
+ LEFT JOIN parties sp ON act_sig.party_id = sp.id
653
+ LEFT JOIN contract_actors act_obs ON act_obs.contract_id = c.id
654
+ LEFT JOIN parties op ON act_obs.party_id = op.id
655
+ WHERE c.canton_contract_id = $1
656
+ AND e.kind = 'created'
657
+ AND t.network = $2
658
+ GROUP BY t.update_id, t.record_time, t.effective_at, c.create_argument, pkg.package_id, ct.module_name, ct.template_name, ct.package_name
659
+ ORDER BY t.record_time ASC
660
+ LIMIT 1
661
+ `;
662
+ // Archive event (if exists)
663
+ const archivedQuery = `
664
+ SELECT
665
+ t.update_id,
666
+ t.record_time,
667
+ t.effective_at,
668
+ pkg.package_id || ':' || ct.module_name || ':' || ct.template_name as template_id,
669
+ ct.package_name,
670
+ e.kind as event_kind
671
+ FROM transaction_events e
672
+ INNER JOIN transactions t ON e.transaction_id = t.id
673
+ INNER JOIN contracts c ON e.contract_id = c.id
674
+ INNER JOIN templates ct ON c.template_id = ct.id
675
+ INNER JOIN packages pkg ON e.package_id = pkg.id
676
+ WHERE c.canton_contract_id = $1
677
+ AND e.kind = 'archived'
678
+ AND t.network = $2
679
+ ORDER BY t.record_time ASC
680
+ LIMIT 1
681
+ `;
682
+ // All choice events (consuming and non-consuming)
683
+ const exercisedQuery = `
684
+ SELECT
685
+ t.update_id,
686
+ t.record_time,
687
+ t.effective_at,
688
+ cc.choice,
689
+ eee.interface_id,
690
+ e.kind as event_kind,
691
+ eee.choice_argument,
692
+ eee.exercise_result,
693
+ pkg.package_id || ':' || ct.module_name || ':' || ct.template_name as template_id,
694
+ ct.package_name,
695
+ COALESCE(
696
+ json_agg(DISTINCT ap.party_id) FILTER (WHERE ap.party_id IS NOT NULL),
697
+ '[]'::json
698
+ ) as acting_parties,
699
+ (
700
+ SELECT COUNT(*)
701
+ FROM transaction_events tx_events
702
+ WHERE tx_events.transaction_id = t.id
703
+ ) as event_count,
704
+ (
705
+ SELECT COALESCE(
706
+ json_agg(
707
+ json_build_object(
708
+ 'templateName', tx_templates.template_name,
709
+ 'packageName', tx_templates.package_name,
710
+ 'kind', tx_counts.kind,
711
+ 'count', tx_counts.count
712
+ ) ORDER BY tx_templates.package_name, tx_templates.template_name, tx_counts.kind
713
+ ),
714
+ '[]'::json
715
+ )
716
+ FROM (
717
+ SELECT
718
+ tx_c.template_id,
719
+ tx_events.kind,
720
+ COUNT(*) as count
721
+ FROM transaction_events tx_events
722
+ JOIN contracts tx_c ON tx_events.contract_id = tx_c.id
723
+ WHERE tx_events.transaction_id = t.id
724
+ GROUP BY tx_c.template_id, tx_events.kind
725
+ ) tx_counts
726
+ INNER JOIN templates tx_templates ON tx_counts.template_id = tx_templates.id
727
+ ) as event_breakdown
728
+ FROM transaction_events e
729
+ INNER JOIN transactions t ON e.transaction_id = t.id
730
+ INNER JOIN contracts c ON e.contract_id = c.id
731
+ INNER JOIN templates ct ON c.template_id = ct.id
732
+ INNER JOIN packages pkg ON e.package_id = pkg.id
733
+ INNER JOIN transaction_events_exercised eee ON e.id = eee.event_id
734
+ INNER JOIN choices cc ON eee.choice_id = cc.id
735
+ LEFT JOIN transaction_events_exercised_acting_parties aep ON eee.event_id = aep.event_id
736
+ LEFT JOIN parties ap ON aep.party_id = ap.id
737
+ WHERE c.canton_contract_id = $1
738
+ AND e.kind IN ('consuming_choice', 'non_consuming_choice')
739
+ AND t.network = $2
740
+ GROUP BY t.update_id, t.record_time, t.effective_at, cc.choice, eee.interface_id, eee.choice_argument, eee.exercise_result, pkg.package_id, ct.module_name, ct.template_name, ct.package_name, e.kind, t.id
741
+ ORDER BY t.record_time DESC
742
+ `;
743
+ const [createdResult, archivedResult, exercisedResult] = await Promise.all([
744
+ this.pool.query(createdQuery, [contractId, this.network]),
745
+ this.pool.query(archivedQuery, [contractId, this.network]),
746
+ this.pool.query(exercisedQuery, [contractId, this.network]),
747
+ ]);
748
+ const created = createdResult.rows.length > 0
749
+ ? {
750
+ updateId: createdResult.rows[0].update_id,
751
+ recordTime: createdResult.rows[0].record_time,
752
+ effectiveAt: createdResult.rows[0].effective_at,
753
+ createArgument: createdResult.rows[0].create_argument,
754
+ templateId: createdResult.rows[0].template_id,
755
+ packageName: createdResult.rows[0].package_name,
756
+ signatories: createdResult.rows[0].signatories ?? [],
757
+ observers: createdResult.rows[0].observers ?? [],
758
+ }
759
+ : null;
760
+ const archived = archivedResult.rows.length > 0
761
+ ? {
762
+ updateId: archivedResult.rows[0].update_id,
763
+ recordTime: archivedResult.rows[0].record_time,
764
+ effectiveAt: archivedResult.rows[0].effective_at,
765
+ eventKind: archivedResult.rows[0].event_kind,
766
+ templateId: archivedResult.rows[0].template_id,
767
+ packageName: archivedResult.rows[0].package_name,
768
+ }
769
+ : null;
770
+ const exercises = exercisedResult.rows.map(row => {
771
+ const eventCountRaw = Number(row.event_count);
772
+ const eventCount = Number.isFinite(eventCountRaw) ? eventCountRaw : 0;
773
+ const eventBreakdownSource = Array.isArray(row.event_breakdown)
774
+ ? row.event_breakdown
775
+ : [];
776
+ const consuming = row.event_kind === 'consuming_choice';
777
+ return {
778
+ updateId: row.update_id,
779
+ recordTime: row.record_time,
780
+ effectiveAt: row.effective_at,
781
+ choice: row.choice,
782
+ interfaceId: row.interface_id,
783
+ eventKind: row.event_kind,
784
+ consuming,
785
+ choiceArgument: row.choice_argument,
786
+ exerciseResult: row.exercise_result,
787
+ actingParties: row.acting_parties ?? [],
788
+ templateId: row.template_id,
789
+ packageName: row.package_name,
790
+ eventCount,
791
+ eventBreakdown: eventBreakdownSource.map((item) => {
792
+ const countRaw = Number(item.count);
793
+ return {
794
+ templateName: item.templateName,
795
+ packageName: item.packageName,
796
+ kind: item.kind,
797
+ count: Number.isFinite(countRaw) ? countRaw : 0,
798
+ };
799
+ }),
800
+ };
801
+ });
802
+ return { created, archived, exercises };
803
+ }
804
+ /** Execute a query directly on the pool Useful for read operations that don't have dedicated methods */
805
+ async query(queryText, values) {
806
+ const result = await this.pool.query(queryText, values);
807
+ return { rows: result.rows };
808
+ }
809
+ /**
810
+ * Get traffic costs aggregated by round from MemberTraffic events. Queries the
811
+ * transactions schema for MemberTraffic created events.
812
+ */
813
+ async getTrafficCostsByRound(timeRange, timePeriod, customStartDate, partyFilter, providerFilter) {
814
+ let timeRangeInterval;
815
+ let timeFilter;
816
+ switch (timeRange) {
817
+ case '15m':
818
+ timeRangeInterval = '15 minutes';
819
+ timeFilter = "t.record_time >= NOW() - INTERVAL '15 minutes'";
820
+ break;
821
+ case '1h':
822
+ timeRangeInterval = '1 hour';
823
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 hour'";
824
+ break;
825
+ case '6h':
826
+ timeRangeInterval = '6 hours';
827
+ timeFilter = "t.record_time >= NOW() - INTERVAL '6 hours'";
828
+ break;
829
+ case '1d':
830
+ timeRangeInterval = '1 day';
831
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 day'";
832
+ break;
833
+ case '7d':
834
+ timeRangeInterval = '7 days';
835
+ timeFilter = "t.record_time >= NOW() - INTERVAL '7 days'";
836
+ break;
837
+ case '30d':
838
+ timeRangeInterval = '30 days';
839
+ timeFilter = "t.record_time >= NOW() - INTERVAL '30 days'";
840
+ break;
841
+ case 'last-month':
842
+ timeRangeInterval = '1 month';
843
+ timeFilter =
844
+ "t.record_time >= date_trunc('month', current_date - interval '1 month') AND t.record_time < date_trunc('month', current_date)";
845
+ break;
846
+ case 'all':
847
+ timeRangeInterval = 'all';
848
+ timeFilter = 'TRUE';
849
+ break;
850
+ default:
851
+ timeRangeInterval = '1 day';
852
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 day'";
853
+ }
854
+ if (timePeriod === 'custom-start' && customStartDate) {
855
+ timeFilter = `t.record_time >= '${customStartDate}:00' AND t.record_time < ('${customStartDate}:00'::timestamp + INTERVAL '${timeRangeInterval}')`;
856
+ }
857
+ // Build parameterized query
858
+ const queryParams = [this.network];
859
+ let partyCondition = '';
860
+ let providerCondition = '';
861
+ if (partyFilter) {
862
+ queryParams.push(partyFilter);
863
+ partyCondition = ` AND c.create_argument->>'memberId' = $${queryParams.length}`;
864
+ }
865
+ if (providerFilter) {
866
+ queryParams.push(providerFilter);
867
+ providerCondition = ` AND p.provider = $${queryParams.length}`;
868
+ }
869
+ const query = `
870
+ WITH traffic_events AS (
871
+ SELECT
872
+ t.record_time,
873
+ FLOOR(EXTRACT(EPOCH FROM t.record_time) / 600) AS round,
874
+ (c.create_argument->>'amuletSpent')::numeric AS amulet_spent,
875
+ (c.create_argument->>'usdSpent')::numeric AS usd_spent,
876
+ (c.create_argument->>'totalPurchased')::numeric AS total_purchased,
877
+ (c.create_argument->>'numPurchases')::integer AS num_purchases,
878
+ p.provider
879
+ FROM transactions t
880
+ JOIN transaction_events e ON e.transaction_id = t.id
881
+ JOIN contracts c ON c.id = e.contract_id
882
+ JOIN templates tmpl ON c.template_id = tmpl.id
883
+ LEFT JOIN transaction_providers tp ON tp.transaction_id = t.id
884
+ LEFT JOIN providers p ON tp.provider_id = p.id
885
+ WHERE t.network = $1
886
+ AND ${timeFilter}
887
+ AND e.kind = 'created'
888
+ AND tmpl.package_name = 'splice-amulet'
889
+ AND tmpl.module_name = 'Splice.DecentralizedSynchronizer'
890
+ AND tmpl.template_name = 'MemberTraffic'
891
+ ${partyCondition}
892
+ ${providerCondition}
893
+ ),
894
+ round_bounds AS (
895
+ SELECT
896
+ COALESCE(MIN(round), 0) AS min_round,
897
+ COALESCE(MAX(round), 0) AS max_round
898
+ FROM traffic_events
899
+ ),
900
+ rounds AS (
901
+ SELECT generate_series(
902
+ (SELECT min_round FROM round_bounds),
903
+ (SELECT max_round FROM round_bounds)
904
+ ) AS round
905
+ WHERE (SELECT max_round FROM round_bounds) > 0
906
+ ),
907
+ aggregated_traffic AS (
908
+ SELECT
909
+ round,
910
+ MIN(record_time) AS timestamp,
911
+ COALESCE(SUM(amulet_spent), 0) AS amulet_spent,
912
+ COALESCE(SUM(usd_spent), 0) AS usd_spent,
913
+ COALESCE(SUM(total_purchased), 0) AS total_purchased,
914
+ COALESCE(SUM(num_purchases), 0) AS num_purchases,
915
+ ARRAY_REMOVE(ARRAY_AGG(DISTINCT provider), NULL) AS providers
916
+ FROM traffic_events
917
+ GROUP BY round
918
+ )
919
+ SELECT
920
+ r.round,
921
+ at.timestamp,
922
+ COALESCE(at.amulet_spent, 0) AS amulet_spent,
923
+ COALESCE(at.usd_spent, 0) AS usd_spent,
924
+ COALESCE(at.total_purchased, 0) AS total_purchased,
925
+ COALESCE(at.num_purchases, 0) AS num_purchases,
926
+ COALESCE(at.providers, ARRAY[]::text[]) AS providers
927
+ FROM rounds r
928
+ LEFT JOIN aggregated_traffic at ON at.round = r.round
929
+ ORDER BY r.round ASC
930
+ `;
931
+ const result = await this.pool.query(query, queryParams);
932
+ return result.rows.map(row => ({
933
+ round: typeof row.round === 'number' ? row.round : parseInt(row.round, 10),
934
+ timestamp: row.timestamp
935
+ ? new Date(row.timestamp).toISOString()
936
+ : new Date().toISOString(),
937
+ amuletSpent: parseFloat(row.amulet_spent),
938
+ usdSpent: parseFloat(row.usd_spent),
939
+ trafficPurchased: parseFloat(row.total_purchased),
940
+ numPurchases: parseInt(row.num_purchases, 10),
941
+ providers: Array.isArray(row.providers)
942
+ ? row.providers
943
+ : typeof row.providers === 'string'
944
+ ? [row.providers]
945
+ : [],
946
+ }));
947
+ }
948
+ async getMemberTrafficEvents(timeRange, timePeriod, customStartDate, partyFilter, providerFilter) {
949
+ let timeFilterCondition = null;
950
+ let timeRangeInterval = null;
951
+ switch (timeRange) {
952
+ case '15m':
953
+ timeFilterCondition = "t.record_time >= NOW() - INTERVAL '15 minutes'";
954
+ timeRangeInterval = '15 minutes';
955
+ break;
956
+ case '1h':
957
+ timeFilterCondition = "t.record_time >= NOW() - INTERVAL '1 hour'";
958
+ timeRangeInterval = '1 hour';
959
+ break;
960
+ case '6h':
961
+ timeFilterCondition = "t.record_time >= NOW() - INTERVAL '6 hours'";
962
+ timeRangeInterval = '6 hours';
963
+ break;
964
+ case '1d':
965
+ timeFilterCondition = "t.record_time >= NOW() - INTERVAL '1 day'";
966
+ timeRangeInterval = '1 day';
967
+ break;
968
+ case '7d':
969
+ timeFilterCondition = "t.record_time >= NOW() - INTERVAL '7 days'";
970
+ timeRangeInterval = '7 days';
971
+ break;
972
+ case '30d':
973
+ timeFilterCondition = "t.record_time >= NOW() - INTERVAL '30 days'";
974
+ timeRangeInterval = '30 days';
975
+ break;
976
+ case 'last-month':
977
+ timeFilterCondition =
978
+ "t.record_time >= date_trunc('month', current_date - interval '1 month') AND t.record_time < date_trunc('month', current_date)";
979
+ timeRangeInterval = '1 month';
980
+ break;
981
+ case 'all':
982
+ default:
983
+ timeFilterCondition = null;
984
+ timeRangeInterval = null;
985
+ break;
986
+ }
987
+ if (timePeriod === 'custom-start' && customStartDate) {
988
+ const interval = timeRangeInterval ?? '30 days';
989
+ timeFilterCondition = `t.record_time >= '${customStartDate}:00' AND t.record_time < ('${customStartDate}:00'::timestamp + INTERVAL '${interval}')`;
990
+ }
991
+ const queryParams = [this.network];
992
+ const conditions = [
993
+ 't.network = $1',
994
+ "e.kind = 'created'",
995
+ "tmpl.package_name = 'splice-amulet'",
996
+ "tmpl.module_name = 'Splice.DecentralizedSynchronizer'",
997
+ "tmpl.template_name = 'MemberTraffic'",
998
+ ];
999
+ if (timeFilterCondition) {
1000
+ conditions.push(timeFilterCondition);
1001
+ }
1002
+ if (partyFilter) {
1003
+ queryParams.push(partyFilter);
1004
+ conditions.push(`c.create_argument->>'memberId' = $${queryParams.length}`);
1005
+ }
1006
+ if (providerFilter) {
1007
+ queryParams.push(providerFilter);
1008
+ conditions.push(`p.provider = $${queryParams.length}`);
1009
+ }
1010
+ const query = `
1011
+ SELECT
1012
+ t.record_time AS record_time,
1013
+ FLOOR(EXTRACT(EPOCH FROM t.record_time) / 600) AS round,
1014
+ (c.create_argument->>'amuletSpent')::numeric AS amulet_spent,
1015
+ (c.create_argument->>'usdSpent')::numeric AS usd_spent,
1016
+ (c.create_argument->>'totalPurchased')::numeric AS total_purchased,
1017
+ e.contract_id,
1018
+ t.update_id,
1019
+ c.create_argument->>'memberId' AS member_id,
1020
+ MAX(tp.ledger_offset) AS ledger_offset,
1021
+ ARRAY_REMOVE(ARRAY_AGG(DISTINCT p.provider), NULL) AS providers,
1022
+ ARRAY_REMOVE(
1023
+ ARRAY_AGG(
1024
+ DISTINCT CASE
1025
+ WHEN act.party_id IS NOT NULL
1026
+ AND act.party_id <> c.create_argument->>'memberId'
1027
+ AND tea.actor_type = 'signatory'
1028
+ THEN act.party_id
1029
+ ELSE NULL
1030
+ END
1031
+ ),
1032
+ NULL
1033
+ ) AS payer_parties
1034
+ FROM transactions t
1035
+ JOIN transaction_events e ON e.transaction_id = t.id
1036
+ JOIN contracts c ON c.id = e.contract_id
1037
+ JOIN templates tmpl ON c.template_id = tmpl.id
1038
+ LEFT JOIN transaction_providers tp ON tp.transaction_id = t.id
1039
+ LEFT JOIN providers p ON tp.provider_id = p.id
1040
+ LEFT JOIN contract_actors tea ON tea.contract_id = e.contract_id AND tea.actor_type = 'signatory'
1041
+ LEFT JOIN parties act ON act.id = tea.party_id
1042
+ WHERE ${conditions.join('\n AND ')}
1043
+ GROUP BY
1044
+ t.record_time,
1045
+ round,
1046
+ amulet_spent,
1047
+ usd_spent,
1048
+ total_purchased,
1049
+ e.contract_id,
1050
+ t.update_id,
1051
+ member_id
1052
+ ORDER BY t.record_time ASC
1053
+ `;
1054
+ const normalizeTextArray = (value) => {
1055
+ if (Array.isArray(value)) {
1056
+ return value.filter((item) => typeof item === 'string');
1057
+ }
1058
+ if (typeof value === 'string') {
1059
+ const trimmed = value.trim();
1060
+ if (!trimmed || trimmed === '{}') {
1061
+ return [];
1062
+ }
1063
+ const withoutBraces = trimmed.replace(/^{|}$/g, '');
1064
+ return withoutBraces
1065
+ .split(',')
1066
+ .map(item => item.replace(/^"|"$/g, '').trim())
1067
+ .filter(Boolean);
1068
+ }
1069
+ return [];
1070
+ };
1071
+ const result = await this.pool.query(query, queryParams);
1072
+ return result.rows.map(row => ({
1073
+ round: typeof row.round === 'number' ? row.round : parseInt(row.round, 10),
1074
+ timestamp: row.record_time
1075
+ ? new Date(row.record_time).toISOString()
1076
+ : new Date().toISOString(),
1077
+ amuletSpent: parseFloat(row.amulet_spent),
1078
+ usdSpent: parseFloat(row.usd_spent),
1079
+ trafficPurchased: parseFloat(row.total_purchased),
1080
+ contractId: row.contract_id,
1081
+ updateId: row.update_id,
1082
+ ledgerOffset: row.ledger_offset != null ? parseInt(row.ledger_offset, 10) : undefined,
1083
+ providers: normalizeTextArray(row.providers),
1084
+ payerParties: normalizeTextArray(row.payer_parties),
1085
+ }));
1086
+ }
1087
+ /** Get all available providers for a network */
1088
+ async getAvailableProviders() {
1089
+ const query = `
1090
+ SELECT DISTINCT provider, synchronizer_id
1091
+ FROM providers
1092
+ WHERE network = $1
1093
+ ORDER BY provider
1094
+ `;
1095
+ const result = await this.pool.query(query, [this.network]);
1096
+ return result.rows.map(row => ({
1097
+ provider: row.provider,
1098
+ synchronizer_id: row.synchronizer_id,
1099
+ }));
1100
+ }
1101
+ /** Get member traffic history for a specific party Returns all MemberTraffic events for the given party ID */
1102
+ async getMemberTrafficHistory(partyId) {
1103
+ const query = `
1104
+ SELECT
1105
+ tp.ledger_offset AS offset,
1106
+ t.record_time AS created_at,
1107
+ c.create_argument->>'memberId' AS member_id,
1108
+ c.create_argument->>'totalPurchased' AS total_purchased,
1109
+ c.create_argument->>'numPurchases' AS num_purchases,
1110
+ c.create_argument->>'amuletSpent' AS amulet_spent,
1111
+ c.create_argument->>'usdSpent' AS usd_spent
1112
+ FROM transactions t
1113
+ JOIN transaction_providers tp ON tp.transaction_id = t.id
1114
+ JOIN transaction_events e ON e.transaction_id = t.id
1115
+ JOIN contracts c ON c.id = e.contract_id
1116
+ JOIN templates tmpl ON c.template_id = tmpl.id
1117
+ WHERE t.network = $1
1118
+ AND e.kind = 'created'
1119
+ AND tmpl.package_name = 'splice-amulet'
1120
+ AND tmpl.module_name = 'Splice.DecentralizedSynchronizer'
1121
+ AND tmpl.template_name = 'MemberTraffic'
1122
+ AND c.create_argument->>'memberId' = $2
1123
+ ORDER BY t.record_time DESC
1124
+ `;
1125
+ const result = await this.pool.query(query, [this.network, partyId]);
1126
+ return result.rows.map(row => ({
1127
+ offset: parseInt(row.offset, 10),
1128
+ createdAt: new Date(row.created_at).toISOString(),
1129
+ memberId: row.member_id,
1130
+ totalPurchased: row.total_purchased,
1131
+ numPurchases: row.num_purchases,
1132
+ amuletSpent: row.amulet_spent,
1133
+ usdSpent: row.usd_spent,
1134
+ }));
1135
+ }
1136
+ // Compatibility wrappers for actor inserts
1137
+ async insertSignatories(contractId, partyIds, client = this.pool) {
1138
+ await this.insertActors(contractId, partyIds, 'signatory', this.network, client);
1139
+ }
1140
+ async insertObservers(contractId, partyIds, client = this.pool) {
1141
+ await this.insertActors(contractId, partyIds, 'observer', this.network, client);
1142
+ }
1143
+ // Deprecated in V2 schema: create payload stored on contracts.create_argument
1144
+ async bulkInsertCantonTransactionEventCreated(_details, _client = this.pool) {
1145
+ throw new Error('bulkInsertCantonTransactionEventCreated is deprecated; use contracts.create_argument instead.');
1146
+ }
1147
+ /**
1148
+ * Latest offset and last transaction time for a provider.
1149
+ */
1150
+ async getProviderOffset(provider) {
1151
+ const query = `
1152
+ SELECT
1153
+ MAX(tp.ledger_offset) AS last_offset,
1154
+ MAX(t.record_time) AS last_updated
1155
+ FROM transaction_providers tp
1156
+ INNER JOIN providers p ON tp.provider_id = p.id
1157
+ INNER JOIN transactions t ON tp.transaction_id = t.id
1158
+ WHERE p.network = $1 AND p.provider = $2
1159
+ `;
1160
+ const result = await this.pool.query(query, [this.network, provider]);
1161
+ if (result.rows.length === 0) {
1162
+ return { last_offset: null, last_updated: null };
1163
+ }
1164
+ const row = result.rows[0];
1165
+ return {
1166
+ last_offset: row.last_offset !== null && row.last_offset !== undefined
1167
+ ? Number(row.last_offset)
1168
+ : null,
1169
+ last_updated: row.last_updated ?? null,
1170
+ };
1171
+ }
1172
+ /**
1173
+ * Active Amulet contracts grouped by owner (signatory). Filters out DSO and empty owners.
1174
+ */
1175
+ async getActiveAmuletContractsByOwner() {
1176
+ const query = `
1177
+ WITH amulet_contracts AS (
1178
+ SELECT
1179
+ c.id AS contract_id
1180
+ FROM contracts c
1181
+ INNER JOIN templates t ON t.id = c.template_id
1182
+ WHERE c.network = $1
1183
+ AND t.package_name = 'splice-amulet'
1184
+ AND t.module_name = 'Splice.Amulet'
1185
+ AND t.template_name = 'Amulet'
1186
+ ),
1187
+ active AS (
1188
+ SELECT contract_id
1189
+ FROM active_contracts ac
1190
+ WHERE ac.contract_id IN (SELECT contract_id FROM amulet_contracts)
1191
+ ),
1192
+ signatories AS (
1193
+ SELECT
1194
+ ca.contract_id,
1195
+ TRIM(p.party_id)::text AS owner
1196
+ FROM contract_actors ca
1197
+ INNER JOIN parties p ON p.id = ca.party_id
1198
+ WHERE ca.actor_type = 'signatory'
1199
+ AND p.network = $1
1200
+ )
1201
+ SELECT
1202
+ s.owner,
1203
+ COUNT(*)::bigint AS contract_count
1204
+ FROM active a
1205
+ INNER JOIN signatories s ON s.contract_id = a.contract_id
1206
+ WHERE s.owner IS NOT NULL
1207
+ AND s.owner <> ''
1208
+ AND s.owner NOT ILIKE 'DSO::%'
1209
+ GROUP BY s.owner
1210
+ ORDER BY contract_count DESC, s.owner
1211
+ `;
1212
+ const result = await this.pool.query(query, [this.network]);
1213
+ return result.rows.map(row => ({
1214
+ owner: row.owner,
1215
+ contract_count: Number(row.contract_count) || 0,
1216
+ }));
1217
+ }
1218
+ /**
1219
+ * Locked amulets for a signatory or owner. Filters to active contracts.
1220
+ */
1221
+ async getLockedAmuletsBySignatory(partyId) {
1222
+ if (!partyId) {
1223
+ throw new Error('partyId is required to fetch LockedAmulets');
1224
+ }
1225
+ const query = `
1226
+ WITH locked_amulets AS (
1227
+ SELECT
1228
+ c.id AS contract_db_id,
1229
+ c.canton_contract_id,
1230
+ t.update_id,
1231
+ t.record_time,
1232
+ pkg.package_id,
1233
+ tmpl.module_name,
1234
+ tmpl.template_name,
1235
+ c.create_argument,
1236
+ COALESCE(
1237
+ json_agg(DISTINCT CASE WHEN ca.actor_type = 'signatory' THEN p.party_id END)
1238
+ FILTER (WHERE p.party_id IS NOT NULL),
1239
+ '[]'::json
1240
+ ) AS signatories,
1241
+ COALESCE(
1242
+ json_agg(DISTINCT CASE WHEN ca.actor_type = 'observer' THEN p.party_id END)
1243
+ FILTER (WHERE p.party_id IS NOT NULL),
1244
+ '[]'::json
1245
+ ) AS observers
1246
+ FROM transactions t
1247
+ INNER JOIN transaction_events e ON e.transaction_id = t.id
1248
+ INNER JOIN contracts c ON c.id = e.contract_id
1249
+ INNER JOIN templates tmpl ON tmpl.id = c.template_id
1250
+ INNER JOIN packages pkg ON pkg.id = e.package_id
1251
+ LEFT JOIN contract_actors ca ON ca.contract_id = c.id
1252
+ LEFT JOIN parties p ON p.id = ca.party_id
1253
+ WHERE
1254
+ t.network = $1
1255
+ AND e.kind = 'created'
1256
+ AND tmpl.package_name = 'splice-amulet'
1257
+ AND tmpl.module_name = 'Splice.Amulet'
1258
+ AND tmpl.template_name = 'LockedAmulet'
1259
+ AND (
1260
+ EXISTS (
1261
+ SELECT 1
1262
+ FROM contract_actors ca_filter
1263
+ INNER JOIN parties p_filter ON ca_filter.party_id = p_filter.id
1264
+ WHERE ca_filter.contract_id = c.id
1265
+ AND ca_filter.actor_type = 'signatory'
1266
+ AND p_filter.party_id = $2
1267
+ AND p_filter.network = $1
1268
+ )
1269
+ OR c.create_argument::jsonb -> 'amulet' ->> 'owner' = $2
1270
+ )
1271
+ GROUP BY
1272
+ c.id,
1273
+ c.canton_contract_id,
1274
+ t.update_id,
1275
+ t.record_time,
1276
+ pkg.package_id,
1277
+ tmpl.module_name,
1278
+ tmpl.template_name,
1279
+ c.create_argument
1280
+ )
1281
+ SELECT *
1282
+ FROM locked_amulets la
1283
+ WHERE EXISTS (SELECT 1 FROM active_contracts ac WHERE ac.contract_id = la.contract_db_id)
1284
+ ORDER BY la.record_time DESC
1285
+ `;
1286
+ const result = await this.pool.query(query, [this.network, partyId]);
1287
+ const ensureStringArray = (value) => {
1288
+ if (Array.isArray(value)) {
1289
+ return value.filter((item) => typeof item === 'string');
1290
+ }
1291
+ if (value === null || value === undefined) {
1292
+ return [];
1293
+ }
1294
+ if (typeof value === 'string') {
1295
+ try {
1296
+ const parsed = JSON.parse(value);
1297
+ if (Array.isArray(parsed)) {
1298
+ return parsed.filter((item) => typeof item === 'string');
1299
+ }
1300
+ }
1301
+ catch {
1302
+ const trimmed = value.trim();
1303
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
1304
+ return trimmed
1305
+ .slice(1, -1)
1306
+ .split(',')
1307
+ .map(item => item.replace(/^"|"$/g, '').trim())
1308
+ .filter(Boolean);
1309
+ }
1310
+ }
1311
+ }
1312
+ return [];
1313
+ };
1314
+ const toIsoString = (value) => {
1315
+ if (!value) {
1316
+ return new Date(0).toISOString();
1317
+ }
1318
+ const dateValue = value instanceof Date ? value : new Date(value);
1319
+ if (Number.isNaN(dateValue.getTime())) {
1320
+ return new Date(0).toISOString();
1321
+ }
1322
+ return dateValue.toISOString();
1323
+ };
1324
+ return result.rows.map(row => {
1325
+ const createArgumentRaw = row.create_argument;
1326
+ let createArgument = null;
1327
+ if (createArgumentRaw && typeof createArgumentRaw === 'object') {
1328
+ createArgument = createArgumentRaw;
1329
+ }
1330
+ else if (typeof createArgumentRaw === 'string') {
1331
+ try {
1332
+ createArgument = JSON.parse(createArgumentRaw);
1333
+ }
1334
+ catch {
1335
+ createArgument = null;
1336
+ }
1337
+ }
1338
+ let owner = null;
1339
+ let amountRaw = null;
1340
+ let amount = null;
1341
+ if (createArgument && 'amulet' in createArgument) {
1342
+ const amuletCandidate = createArgument['amulet'];
1343
+ if (amuletCandidate &&
1344
+ typeof amuletCandidate === 'object' &&
1345
+ !Array.isArray(amuletCandidate)) {
1346
+ const amuletObj = amuletCandidate;
1347
+ const ownerCandidate = amuletObj['owner'];
1348
+ owner = typeof ownerCandidate === 'string' ? ownerCandidate : null;
1349
+ const amountCandidate = amuletObj['amount'];
1350
+ if (typeof amountCandidate === 'string') {
1351
+ amountRaw = amountCandidate;
1352
+ }
1353
+ else if (amountCandidate &&
1354
+ typeof amountCandidate === 'object' &&
1355
+ !Array.isArray(amountCandidate)) {
1356
+ const amountObj = amountCandidate;
1357
+ const initialAmount = amountObj['initialAmount'];
1358
+ if (typeof initialAmount === 'string') {
1359
+ amountRaw = initialAmount;
1360
+ }
1361
+ else {
1362
+ const nestedAmount = amountObj['amount'];
1363
+ if (typeof nestedAmount === 'string') {
1364
+ amountRaw = nestedAmount;
1365
+ }
1366
+ }
1367
+ }
1368
+ else if (typeof amountCandidate === 'number') {
1369
+ amountRaw = amountCandidate.toString();
1370
+ }
1371
+ if (amountRaw) {
1372
+ const parsed = Number.parseFloat(amountRaw);
1373
+ amount = Number.isFinite(parsed) ? parsed : null;
1374
+ }
1375
+ }
1376
+ }
1377
+ let lockExpiresAt = null;
1378
+ let lockDescription = null;
1379
+ let lockHolders = [];
1380
+ if (createArgument && 'lock' in createArgument) {
1381
+ const lockCandidate = createArgument['lock'];
1382
+ if (lockCandidate &&
1383
+ typeof lockCandidate === 'object' &&
1384
+ !Array.isArray(lockCandidate)) {
1385
+ const lockObj = lockCandidate;
1386
+ const expiresCandidate = lockObj['expiresAt'];
1387
+ if (typeof expiresCandidate === 'string') {
1388
+ lockExpiresAt = expiresCandidate;
1389
+ }
1390
+ const descriptionCandidate = lockObj['optContext'];
1391
+ if (typeof descriptionCandidate === 'string') {
1392
+ lockDescription = descriptionCandidate;
1393
+ }
1394
+ const holdersCandidate = lockObj['holders'];
1395
+ if (Array.isArray(holdersCandidate)) {
1396
+ lockHolders = holdersCandidate.filter((item) => typeof item === 'string');
1397
+ }
1398
+ }
1399
+ }
1400
+ const templateIdParts = [
1401
+ typeof row.package_id === 'string' ? row.package_id : null,
1402
+ typeof row.module_name === 'string' ? row.module_name : null,
1403
+ typeof row.template_name === 'string' ? row.template_name : null,
1404
+ ].filter((part) => part !== null);
1405
+ const templateId = templateIdParts.join(':');
1406
+ return {
1407
+ contractId: row.canton_contract_id ?? '',
1408
+ updateId: row.update_id ?? '',
1409
+ createdAt: toIsoString(row.record_time),
1410
+ owner,
1411
+ amount,
1412
+ amountRaw,
1413
+ lockExpiresAt,
1414
+ lockHolders,
1415
+ lockDescription,
1416
+ signatories: ensureStringArray(row.signatories),
1417
+ observers: ensureStringArray(row.observers),
1418
+ templateId,
1419
+ };
1420
+ });
1421
+ }
1422
+ /**
1423
+ * Transaction lookup by updateId or offset with aggregated tree data.
1424
+ * Returns a shape compatible with UI transaction-tree expectations.
1425
+ */
1426
+ async getTransactionTree(search) {
1427
+ // 1) Locate transaction
1428
+ let txQuery;
1429
+ let txParams;
1430
+ if (search.type === 'updateId') {
1431
+ txQuery = `
1432
+ SELECT
1433
+ t.id,
1434
+ t.update_id,
1435
+ t.command_id,
1436
+ t.workflow_id,
1437
+ t.effective_at,
1438
+ t.record_time,
1439
+ MAX(tp.ledger_offset) AS ledger_offset
1440
+ FROM transactions t
1441
+ LEFT JOIN transaction_providers tp ON tp.transaction_id = t.id
1442
+ WHERE t.network = $1 AND t.update_id = $2
1443
+ GROUP BY t.id, t.update_id, t.command_id, t.workflow_id, t.effective_at, t.record_time
1444
+ `;
1445
+ txParams = [this.network, search.value];
1446
+ }
1447
+ else {
1448
+ txQuery = `
1449
+ SELECT
1450
+ t.id,
1451
+ t.update_id,
1452
+ t.command_id,
1453
+ t.workflow_id,
1454
+ t.effective_at,
1455
+ t.record_time,
1456
+ tp.ledger_offset
1457
+ FROM transactions t
1458
+ INNER JOIN transaction_providers tp ON tp.transaction_id = t.id
1459
+ INNER JOIN providers p ON p.id = tp.provider_id
1460
+ WHERE t.network = $1 AND p.network = $1 AND tp.ledger_offset = $2
1461
+ LIMIT 1
1462
+ `;
1463
+ txParams = [this.network, search.value];
1464
+ }
1465
+ const txResult = await this.pool.query(txQuery, txParams);
1466
+ if (txResult.rows.length === 0) {
1467
+ throw new Error('Transaction not found');
1468
+ }
1469
+ const txRow = txResult.rows[0];
1470
+ const transactionId = txRow.id;
1471
+ // 2) Fetch canonical events with provider/node + template info
1472
+ const eventsQuery = `
1473
+ SELECT
1474
+ e.id,
1475
+ e.kind,
1476
+ e.contract_id,
1477
+ e.package_id,
1478
+ c.canton_contract_id,
1479
+ tplt.module_name,
1480
+ tplt.template_name,
1481
+ tplt.package_name,
1482
+ pkg.package_id AS package_hash,
1483
+ ep.node_id,
1484
+ p.provider,
1485
+ tp.ledger_offset
1486
+ FROM transaction_events e
1487
+ INNER JOIN contracts c ON c.id = e.contract_id
1488
+ INNER JOIN templates tplt ON tplt.id = c.template_id
1489
+ INNER JOIN packages pkg ON pkg.id = e.package_id
1490
+ INNER JOIN transaction_event_providers ep ON ep.event_id = e.id
1491
+ INNER JOIN providers p ON p.id = ep.provider_id
1492
+ INNER JOIN transaction_providers tp ON tp.transaction_id = e.transaction_id AND tp.provider_id = ep.provider_id
1493
+ WHERE e.transaction_id = $1
1494
+ ORDER BY e.id, ep.node_id
1495
+ `;
1496
+ const eventsResult = await this.pool.query(eventsQuery, [transactionId]);
1497
+ const eventRows = eventsResult.rows;
1498
+ if (eventRows.length === 0) {
1499
+ return {
1500
+ transaction: {
1501
+ updateId: txRow.update_id ?? undefined,
1502
+ commandId: txRow.command_id ?? undefined,
1503
+ workflowId: txRow.workflow_id ?? undefined,
1504
+ effectiveAt: txRow.effective_at?.toISOString?.() ?? txRow.effective_at,
1505
+ recordTime: txRow.record_time?.toISOString?.() ?? txRow.record_time,
1506
+ offset: txRow.ledger_offset ? String(txRow.ledger_offset) : undefined,
1507
+ traceContext: txRow.trace_context ?? undefined,
1508
+ eventsById: {},
1509
+ },
1510
+ };
1511
+ }
1512
+ const eventIds = Array.from(new Set(eventRows.map(r => r.id)));
1513
+ const contractIds = Array.from(new Set(eventRows.map(r => r.contract_id)));
1514
+ // 3) Exercised details
1515
+ const exercisedDetailsQuery = `
1516
+ SELECT
1517
+ ex.event_id,
1518
+ ex.interface_id,
1519
+ ch.choice,
1520
+ ex.choice_argument,
1521
+ ex.exercise_result
1522
+ FROM transaction_events_exercised ex
1523
+ INNER JOIN choices ch ON ch.id = ex.choice_id
1524
+ WHERE ex.event_id = ANY($1)
1525
+ `;
1526
+ const exercisedDetailsResult = await this.pool.query(exercisedDetailsQuery, [eventIds]);
1527
+ const exercisedDetailsMap = new Map(exercisedDetailsResult.rows.map(row => [row.event_id, row]));
1528
+ // 4) Acting parties
1529
+ const actingPartiesQuery = `
1530
+ SELECT
1531
+ ap.event_id,
1532
+ parties.party_id
1533
+ FROM transaction_events_exercised_acting_parties ap
1534
+ INNER JOIN parties ON parties.id = ap.party_id
1535
+ WHERE parties.network = $1 AND ap.event_id = ANY($2)
1536
+ `;
1537
+ const actingPartiesResult = await this.pool.query(actingPartiesQuery, [
1538
+ this.network,
1539
+ eventIds,
1540
+ ]);
1541
+ const actingByEvent = new Map();
1542
+ actingPartiesResult.rows.forEach(row => {
1543
+ const list = actingByEvent.get(row.event_id) ?? [];
1544
+ list.push(row.party_id);
1545
+ actingByEvent.set(row.event_id, list);
1546
+ });
1547
+ // 5) Contract actors (signatories/observers) at contract level
1548
+ const contractActorsQuery = `
1549
+ SELECT
1550
+ ca.contract_id,
1551
+ ca.actor_type,
1552
+ p.party_id
1553
+ FROM contract_actors ca
1554
+ INNER JOIN parties p ON p.id = ca.party_id
1555
+ WHERE p.network = $1 AND ca.contract_id = ANY($2)
1556
+ `;
1557
+ const contractActorsResult = await this.pool.query(contractActorsQuery, [
1558
+ this.network,
1559
+ contractIds,
1560
+ ]);
1561
+ const signatoriesByContract = new Map();
1562
+ const observersByContract = new Map();
1563
+ contractActorsResult.rows.forEach(row => {
1564
+ if (row.actor_type === 'signatory') {
1565
+ const list = signatoriesByContract.get(row.contract_id) ?? [];
1566
+ list.push(row.party_id);
1567
+ signatoriesByContract.set(row.contract_id, list);
1568
+ }
1569
+ if (row.actor_type === 'observer') {
1570
+ const list = observersByContract.get(row.contract_id) ?? [];
1571
+ list.push(row.party_id);
1572
+ observersByContract.set(row.contract_id, list);
1573
+ }
1574
+ });
1575
+ // 6) Build event objects
1576
+ const eventsById = {};
1577
+ eventRows.forEach(row => {
1578
+ const canonicalKind = row.kind === 'consuming_choice' || row.kind === 'non_consuming_choice'
1579
+ ? 'exercised'
1580
+ : row.kind;
1581
+ const consuming = row.kind === 'consuming_choice'
1582
+ ? true
1583
+ : row.kind === 'non_consuming_choice'
1584
+ ? false
1585
+ : undefined;
1586
+ const exercisedDetails = exercisedDetailsMap.get(row.id);
1587
+ const acting = actingByEvent.get(row.id) ?? [];
1588
+ const signatories = signatoriesByContract.get(row.contract_id) ?? [];
1589
+ const observers = observersByContract.get(row.contract_id) ?? [];
1590
+ const base = {
1591
+ contractId: row.canton_contract_id,
1592
+ templateId: row.template_name,
1593
+ packageName: row.package_name,
1594
+ offset: row.ledger_offset ? String(row.ledger_offset) : undefined,
1595
+ nodeId: row.node_id ? String(row.node_id) : undefined,
1596
+ provider: row.provider,
1597
+ reassignmentCounter: '0',
1598
+ };
1599
+ if (canonicalKind === 'created') {
1600
+ eventsById[row.id] = {
1601
+ CreatedTreeEvent: {
1602
+ value: {
1603
+ contractId: base.contractId,
1604
+ templateId: `${row.package_name}:${row.module_name}:${row.template_name}`,
1605
+ packageName: row.package_name,
1606
+ createdAt: txRow.record_time?.toISOString?.() ?? txRow.record_time,
1607
+ createArgument: undefined,
1608
+ offset: base.offset,
1609
+ reassignmentCounter: base.reassignmentCounter,
1610
+ nodeId: base.nodeId,
1611
+ contractKey: undefined,
1612
+ signatories,
1613
+ observers,
1614
+ },
1615
+ },
1616
+ };
1617
+ }
1618
+ else if (canonicalKind === 'archived') {
1619
+ eventsById[row.id] = {
1620
+ ArchivedTreeEvent: {
1621
+ value: {
1622
+ contractId: base.contractId,
1623
+ templateId: `${row.package_name}:${row.module_name}:${row.template_name}`,
1624
+ packageName: row.package_name,
1625
+ offset: base.offset,
1626
+ reassignmentCounter: base.reassignmentCounter,
1627
+ nodeId: base.nodeId,
1628
+ },
1629
+ },
1630
+ };
1631
+ }
1632
+ else {
1633
+ eventsById[row.id] = {
1634
+ ExercisedTreeEvent: {
1635
+ value: {
1636
+ contractId: base.contractId,
1637
+ templateId: `${row.package_name}:${row.module_name}:${row.template_name}`,
1638
+ packageName: row.package_name,
1639
+ interfaceId: exercisedDetails?.interface_id ?? undefined,
1640
+ choice: exercisedDetails?.choice ?? undefined,
1641
+ choiceArgument: exercisedDetails?.choice_argument ?? undefined,
1642
+ actingParties: acting,
1643
+ consuming,
1644
+ witnessParties: [],
1645
+ lastDescendantNodeId: undefined,
1646
+ exerciseResult: exercisedDetails?.exercise_result ?? undefined,
1647
+ offset: base.offset,
1648
+ reassignmentCounter: base.reassignmentCounter,
1649
+ nodeId: base.nodeId,
1650
+ },
1651
+ },
1652
+ };
1653
+ }
1654
+ });
1655
+ return {
1656
+ transaction: {
1657
+ updateId: txRow.update_id ?? undefined,
1658
+ commandId: txRow.command_id ?? undefined,
1659
+ workflowId: txRow.workflow_id ?? undefined,
1660
+ effectiveAt: txRow.effective_at?.toISOString?.() ?? txRow.effective_at,
1661
+ recordTime: txRow.record_time?.toISOString?.() ?? txRow.record_time,
1662
+ offset: txRow.ledger_offset ? String(txRow.ledger_offset) : undefined,
1663
+ traceContext: txRow.trace_context ?? undefined,
1664
+ eventsById,
1665
+ },
1666
+ };
1667
+ }
1668
+ /**
1669
+ * Given a canton_contract_id, return related transactions (newest first).
1670
+ */
1671
+ async findTransactionsByCantonContractId(cantonContractId) {
1672
+ const query = `
1673
+ SELECT DISTINCT
1674
+ t.update_id,
1675
+ t.record_time,
1676
+ e.kind
1677
+ FROM contracts c
1678
+ INNER JOIN transaction_events e ON e.contract_id = c.id
1679
+ INNER JOIN transactions t ON t.id = e.transaction_id
1680
+ WHERE c.network = $1 AND c.canton_contract_id = $2
1681
+ ORDER BY t.record_time DESC
1682
+ `;
1683
+ const result = await this.pool.query(query, [
1684
+ this.network,
1685
+ cantonContractId,
1686
+ ]);
1687
+ return result.rows;
1688
+ }
1689
+ /**
1690
+ * Stats for a party (signatory/observer via contract_actors, acting via exercised_acting_parties).
1691
+ */
1692
+ async getPartyStats(partyId) {
1693
+ const query = `
1694
+ WITH party_row AS (
1695
+ SELECT id FROM parties WHERE party_id = $1 AND network = $2
1696
+ ),
1697
+ party_events AS (
1698
+ SELECT DISTINCT e.id, e.transaction_id, e.contract_id, e.template_id, e.kind, t.record_time
1699
+ FROM transaction_events e
1700
+ INNER JOIN transactions t ON t.id = e.transaction_id
1701
+ WHERE t.network = $2
1702
+ AND (
1703
+ EXISTS (
1704
+ SELECT 1
1705
+ FROM contract_actors ca
1706
+ WHERE ca.contract_id = e.contract_id
1707
+ AND ca.party_id = (SELECT id FROM party_row)
1708
+ )
1709
+ OR EXISTS (
1710
+ SELECT 1
1711
+ FROM transaction_events_exercised_acting_parties ap
1712
+ WHERE ap.event_id = e.id
1713
+ AND ap.party_id = (SELECT id FROM party_row)
1714
+ )
1715
+ )
1716
+ )
1717
+ SELECT
1718
+ COUNT(DISTINCT transaction_id)::int AS total_transactions,
1719
+ COUNT(*)::int AS total_events,
1720
+ COUNT(DISTINCT CASE WHEN kind = 'created' THEN id END)::int AS contracts_created,
1721
+ COUNT(DISTINCT CASE WHEN kind IN ('consuming_choice','non_consuming_choice') THEN id END)::int AS contracts_exercised,
1722
+ COUNT(DISTINCT CASE WHEN kind = 'archived' THEN id END)::int AS contracts_archived,
1723
+ COUNT(DISTINCT contract_id)::int AS unique_contracts,
1724
+ COUNT(DISTINCT template_id)::int AS unique_templates,
1725
+ MIN(record_time)::text AS first_seen,
1726
+ MAX(record_time)::text AS last_seen
1727
+ FROM party_events;
1728
+ `;
1729
+ const result = await this.pool.query(query, [partyId, this.network]);
1730
+ if (result.rows.length === 0) {
1731
+ return {
1732
+ total_transactions: 0,
1733
+ total_events: 0,
1734
+ contracts_created: 0,
1735
+ contracts_exercised: 0,
1736
+ contracts_archived: 0,
1737
+ unique_contracts: 0,
1738
+ unique_templates: 0,
1739
+ first_seen: null,
1740
+ last_seen: null,
1741
+ };
1742
+ }
1743
+ return result.rows[0];
1744
+ }
1745
+ async getPartyTopChoices(partyId, limit = 10) {
1746
+ const query = `
1747
+ WITH party_row AS (
1748
+ SELECT id FROM parties WHERE party_id = $1 AND network = $2
1749
+ )
1750
+ SELECT
1751
+ ch.choice AS choice_name,
1752
+ COUNT(*)::int AS exercise_count
1753
+ FROM transaction_events e
1754
+ INNER JOIN transaction_events_exercised ex ON ex.event_id = e.id
1755
+ INNER JOIN choices ch ON ch.id = ex.choice_id
1756
+ WHERE e.kind IN ('consuming_choice','non_consuming_choice')
1757
+ AND EXISTS (
1758
+ SELECT 1
1759
+ FROM transaction_events_exercised_acting_parties ap
1760
+ WHERE ap.event_id = e.id
1761
+ AND ap.party_id = (SELECT id FROM party_row)
1762
+ )
1763
+ GROUP BY ch.choice
1764
+ ORDER BY exercise_count DESC
1765
+ LIMIT $3
1766
+ `;
1767
+ const result = await this.pool.query(query, [partyId, this.network, limit]);
1768
+ return result.rows;
1769
+ }
1770
+ async getPartyTopTemplates(partyId, limit = 10) {
1771
+ const query = `
1772
+ WITH party_row AS (
1773
+ SELECT id FROM parties WHERE party_id = $1 AND network = $2
1774
+ ),
1775
+ party_events AS (
1776
+ SELECT DISTINCT e.id, e.template_id, e.kind
1777
+ FROM transaction_events e
1778
+ INNER JOIN transactions t ON t.id = e.transaction_id
1779
+ WHERE t.network = $2
1780
+ AND (
1781
+ EXISTS (
1782
+ SELECT 1
1783
+ FROM contract_actors ca
1784
+ WHERE ca.contract_id = e.contract_id
1785
+ AND ca.party_id = (SELECT id FROM party_row)
1786
+ )
1787
+ OR EXISTS (
1788
+ SELECT 1
1789
+ FROM transaction_events_exercised_acting_parties ap
1790
+ WHERE ap.event_id = e.id
1791
+ AND ap.party_id = (SELECT id FROM party_row)
1792
+ )
1793
+ )
1794
+ )
1795
+ SELECT
1796
+ tpl.template_name,
1797
+ COUNT(*)::int AS event_count,
1798
+ COUNT(CASE WHEN pe.kind = 'created' THEN 1 END)::int AS created_count,
1799
+ COUNT(CASE WHEN pe.kind IN ('consuming_choice','non_consuming_choice') THEN 1 END)::int AS exercised_count,
1800
+ COUNT(CASE WHEN pe.kind = 'archived' THEN 1 END)::int AS archived_count
1801
+ FROM party_events pe
1802
+ INNER JOIN templates tpl ON tpl.id = pe.template_id
1803
+ GROUP BY tpl.template_name
1804
+ ORDER BY event_count DESC
1805
+ LIMIT $3
1806
+ `;
1807
+ const result = await this.pool.query(query, [partyId, this.network, limit]);
1808
+ return result.rows;
1809
+ }
1810
+ /**
1811
+ * Paginated transactions for a party with role and summary filters.
1812
+ */
1813
+ async getPartyTransactions(options) {
1814
+ const { partyId, role, templateName, choiceName, limit, offset } = options;
1815
+ const roleFilter = role === 'signatory'
1816
+ ? 'AND is_signatory = true'
1817
+ : role === 'observer'
1818
+ ? 'AND is_observer = true'
1819
+ : role === 'acting'
1820
+ ? 'AND is_acting = true'
1821
+ : '';
1822
+ const templateFilter = templateName
1823
+ ? `
1824
+ AND EXISTS (
1825
+ SELECT 1
1826
+ FROM transaction_events tef
1827
+ INNER JOIN templates tf ON tf.id = tef.template_id
1828
+ WHERE tef.transaction_id = tx.id
1829
+ AND tf.template_name = $5
1830
+ )
1831
+ `
1832
+ : '';
1833
+ const choiceFilter = choiceName
1834
+ ? `
1835
+ AND EXISTS (
1836
+ SELECT 1
1837
+ FROM transaction_events tec
1838
+ INNER JOIN transaction_events_exercised ex ON ex.event_id = tec.id
1839
+ INNER JOIN choices ch ON ch.id = ex.choice_id
1840
+ WHERE tec.transaction_id = tx.id
1841
+ AND ch.choice = $6
1842
+ )
1843
+ `
1844
+ : '';
1845
+ const params = [
1846
+ partyId,
1847
+ this.network,
1848
+ limit,
1849
+ offset,
1850
+ ];
1851
+ if (templateName)
1852
+ params.push(templateName);
1853
+ if (choiceName)
1854
+ params.push(choiceName);
1855
+ const query = `
1856
+ WITH party_row AS (
1857
+ SELECT id FROM parties WHERE party_id = $1 AND network = $2
1858
+ ),
1859
+ roles AS (
1860
+ SELECT
1861
+ tx.id as transaction_id,
1862
+ tx.update_id,
1863
+ tx.record_time,
1864
+ -- Roles
1865
+ EXISTS (
1866
+ SELECT 1
1867
+ FROM contract_actors ca
1868
+ WHERE ca.contract_id = ev.contract_id
1869
+ AND ca.party_id = (SELECT id FROM party_row)
1870
+ AND ca.actor_type = 'signatory'
1871
+ ) AS is_signatory,
1872
+ EXISTS (
1873
+ SELECT 1
1874
+ FROM contract_actors ca
1875
+ WHERE ca.contract_id = ev.contract_id
1876
+ AND ca.party_id = (SELECT id FROM party_row)
1877
+ AND ca.actor_type = 'observer'
1878
+ ) AS is_observer,
1879
+ EXISTS (
1880
+ SELECT 1
1881
+ FROM transaction_events_exercised_acting_parties ap
1882
+ WHERE ap.event_id = ev.id
1883
+ AND ap.party_id = (SELECT id FROM party_row)
1884
+ ) AS is_acting
1885
+ FROM transactions tx
1886
+ INNER JOIN transaction_events ev ON ev.transaction_id = tx.id
1887
+ WHERE tx.network = $2
1888
+ ),
1889
+ filtered AS (
1890
+ SELECT DISTINCT *
1891
+ FROM roles
1892
+ WHERE (is_signatory OR is_observer OR is_acting)
1893
+ ${roleFilter}
1894
+ )
1895
+ SELECT
1896
+ f.transaction_id,
1897
+ f.update_id,
1898
+ f.record_time,
1899
+ ARRAY(
1900
+ SELECT DISTINCT role_name FROM (
1901
+ SELECT CASE WHEN f.is_signatory THEN 'signatory' END AS role_name
1902
+ UNION ALL
1903
+ SELECT CASE WHEN f.is_observer THEN 'observer' END AS role_name
1904
+ UNION ALL
1905
+ SELECT CASE WHEN f.is_acting THEN 'acting' END AS role_name
1906
+ ) roles_flat
1907
+ WHERE role_name IS NOT NULL
1908
+ ) AS roles,
1909
+ (
1910
+ SELECT jsonb_agg(
1911
+ jsonb_build_object(
1912
+ 'template_name', tpl.template_name,
1913
+ 'kind', ev.kind,
1914
+ 'count', COUNT(*)::int
1915
+ )
1916
+ ORDER BY COUNT(*) DESC
1917
+ )
1918
+ FROM transaction_events ev
1919
+ INNER JOIN templates tpl ON tpl.id = ev.template_id
1920
+ WHERE ev.transaction_id = f.transaction_id
1921
+ ) AS event_summary
1922
+ FROM filtered f
1923
+ INNER JOIN transactions tx ON tx.id = f.transaction_id
1924
+ WHERE tx.network = $2
1925
+ ${templateFilter}
1926
+ ${choiceFilter}
1927
+ ORDER BY f.record_time DESC
1928
+ LIMIT $3 OFFSET $4
1929
+ `;
1930
+ const result = await this.pool.query(query, params);
1931
+ return result.rows.map(row => ({
1932
+ transaction_id: Number(row.transaction_id),
1933
+ update_id: row.update_id,
1934
+ record_time: row.record_time,
1935
+ roles: row.roles ?? [],
1936
+ event_summary: row.event_summary ?? [],
1937
+ }));
1938
+ }
1939
+ /**
1940
+ * Get AppRewardCoupon time series data from Canton transactions
1941
+ * Queries AppRewardCoupon created events and aggregates by time interval
1942
+ */
1943
+ async getAppRewardCouponTimeSeriesData(timeRange, metric, timePeriod, customStartDate, partyFilter, couponStatus = 'good') {
1944
+ let interval;
1945
+ let timeFilter;
1946
+ let timeSeriesStart;
1947
+ let timeSeriesEnd = 'NOW()';
1948
+ let timeRangeInterval;
1949
+ switch (timeRange) {
1950
+ case '15m':
1951
+ interval = 'minute';
1952
+ timeFilter = "t.record_time >= NOW() - INTERVAL '15 minutes'";
1953
+ timeRangeInterval = '15 minutes';
1954
+ timeSeriesStart = "NOW() - INTERVAL '15 minutes'";
1955
+ break;
1956
+ case '1h':
1957
+ interval = 'minute';
1958
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 hour'";
1959
+ timeRangeInterval = '1 hour';
1960
+ timeSeriesStart = "NOW() - INTERVAL '1 hour'";
1961
+ break;
1962
+ case '6h':
1963
+ interval = 'minute';
1964
+ timeFilter = "t.record_time >= NOW() - INTERVAL '6 hours'";
1965
+ timeRangeInterval = '6 hours';
1966
+ timeSeriesStart = "NOW() - INTERVAL '6 hours'";
1967
+ break;
1968
+ case '1d':
1969
+ interval = 'hour';
1970
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 day'";
1971
+ timeRangeInterval = '1 day';
1972
+ timeSeriesStart = "NOW() - INTERVAL '1 day'";
1973
+ break;
1974
+ case '7d':
1975
+ interval = 'hour';
1976
+ timeFilter = "t.record_time >= NOW() - INTERVAL '7 days'";
1977
+ timeRangeInterval = '7 days';
1978
+ timeSeriesStart = "NOW() - INTERVAL '7 days'";
1979
+ break;
1980
+ case '30d':
1981
+ interval = 'day';
1982
+ timeFilter = "t.record_time >= NOW() - INTERVAL '30 days'";
1983
+ timeRangeInterval = '30 days';
1984
+ timeSeriesStart = "NOW() - INTERVAL '30 days'";
1985
+ break;
1986
+ case 'last-month':
1987
+ interval = 'day';
1988
+ timeFilter =
1989
+ "t.record_time >= date_trunc('month', current_date - interval '1 month') AND t.record_time < date_trunc('month', current_date)";
1990
+ timeRangeInterval = '1 month';
1991
+ timeSeriesStart =
1992
+ "date_trunc('month', current_date - interval '1 month')";
1993
+ timeSeriesEnd = "date_trunc('month', current_date)";
1994
+ break;
1995
+ case 'all':
1996
+ interval = 'day';
1997
+ timeFilter = 'TRUE';
1998
+ timeRangeInterval = '100 years';
1999
+ // For 'all', we'll query the actual min/max from the data itself
2000
+ // Set a reasonable default that will be overridden in the query
2001
+ timeSeriesStart = "NOW() - INTERVAL '365 days'";
2002
+ break;
2003
+ default:
2004
+ interval = 'hour';
2005
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 day'";
2006
+ timeRangeInterval = '1 day';
2007
+ timeSeriesStart = "NOW() - INTERVAL '1 day'";
2008
+ }
2009
+ if (timePeriod === 'custom-start' && customStartDate) {
2010
+ timeFilter = `t.record_time >= '${customStartDate}:00' AND t.record_time < ('${customStartDate}:00'::timestamp + INTERVAL '${timeRangeInterval}')`;
2011
+ timeSeriesStart = `'${customStartDate}:00'::timestamp`;
2012
+ timeSeriesEnd = `('${customStartDate}:00'::timestamp + INTERVAL '${timeRangeInterval}')`;
2013
+ }
2014
+ const queryParams = [this.network];
2015
+ let partyCondition = '';
2016
+ if (partyFilter) {
2017
+ queryParams.push(partyFilter);
2018
+ // beneficiary can be in create_argument->>'beneficiary' or fallback to provider
2019
+ partyCondition = ` AND (
2020
+ c.create_argument->>'beneficiary' = $${queryParams.length}
2021
+ OR (c.create_argument->>'beneficiary' IS NULL AND c.create_argument->>'provider' = $${queryParams.length})
2022
+ )`;
2023
+ }
2024
+ // For couponStatus, we check if contract is archived (expired) or active (good)
2025
+ // 'good' means not archived, 'expired' means archived
2026
+ const statusCondition = couponStatus === 'expired'
2027
+ ? ` AND EXISTS (
2028
+ SELECT 1 FROM transaction_events e_archived
2029
+ WHERE e_archived.contract_id = c.id AND e_archived.kind = 'archived'
2030
+ )`
2031
+ : ` AND NOT EXISTS (
2032
+ SELECT 1 FROM transaction_events e_archived
2033
+ WHERE e_archived.contract_id = c.id AND e_archived.kind = 'archived'
2034
+ )`;
2035
+ // For 'all' time range, we need to get the actual min/max from the data
2036
+ const useDynamicRange = timeRange === 'all';
2037
+ const query = useDynamicRange
2038
+ ? `
2039
+ WITH coupon_data AS (
2040
+ SELECT
2041
+ date_trunc('${interval}', t.record_time) AS timestamp,
2042
+ COUNT(*) as count,
2043
+ -- appRewardAmount is calculated from couponAmount * issuanceRate, but we don't have issuanceRate here
2044
+ -- For now, we'll use 0 for amount (appRewardAmount) and only return couponAmount
2045
+ -- TODO: Calculate appRewardAmount by joining with IssuingMiningRound contracts
2046
+ COALESCE(SUM(0), 0) as amount,
2047
+ COALESCE(SUM((c.create_argument->>'amount')::numeric), 0) as coupon_amount
2048
+ FROM transactions t
2049
+ JOIN transaction_events e ON e.transaction_id = t.id
2050
+ JOIN contracts c ON c.id = e.contract_id
2051
+ JOIN templates tmpl ON c.template_id = tmpl.id
2052
+ WHERE t.network = $1
2053
+ AND ${timeFilter}
2054
+ AND e.kind = 'created'
2055
+ AND tmpl.package_name = 'splice-amulet'
2056
+ AND tmpl.module_name = 'Splice.Amulet'
2057
+ AND tmpl.template_name = 'AppRewardCoupon'
2058
+ ${partyCondition}
2059
+ ${statusCondition}
2060
+ GROUP BY date_trunc('${interval}', t.record_time)
2061
+ ),
2062
+ time_bounds AS (
2063
+ SELECT
2064
+ COALESCE(MIN(timestamp), NOW() - INTERVAL '1 day') AS min_time,
2065
+ COALESCE(MAX(timestamp), NOW()) AS max_time
2066
+ FROM coupon_data
2067
+ ),
2068
+ time_series AS (
2069
+ SELECT generate_series(
2070
+ date_trunc('${interval}', (SELECT min_time FROM time_bounds)),
2071
+ date_trunc('${interval}', (SELECT max_time FROM time_bounds)),
2072
+ INTERVAL '1 ${interval}'
2073
+ ) AS timestamp
2074
+ )
2075
+ SELECT
2076
+ ts.timestamp::text,
2077
+ COALESCE(cd.count, 0) as count,
2078
+ COALESCE(cd.amount, 0) as amount,
2079
+ COALESCE(cd.coupon_amount, 0) as coupon_amount
2080
+ FROM time_series ts
2081
+ LEFT JOIN coupon_data cd ON ts.timestamp = cd.timestamp
2082
+ ORDER BY ts.timestamp ASC
2083
+ `
2084
+ : `
2085
+ WITH time_series AS (
2086
+ SELECT generate_series(
2087
+ date_trunc('${interval}', ${timeSeriesStart}),
2088
+ date_trunc('${interval}', ${timeSeriesEnd}),
2089
+ INTERVAL '1 ${interval}'
2090
+ ) AS timestamp
2091
+ ),
2092
+ coupon_data AS (
2093
+ SELECT
2094
+ date_trunc('${interval}', t.record_time) AS timestamp,
2095
+ COUNT(*) as count,
2096
+ -- appRewardAmount is calculated from couponAmount * issuanceRate, but we don't have issuanceRate here
2097
+ -- For now, we'll use 0 for amount (appRewardAmount) and only return couponAmount
2098
+ -- TODO: Calculate appRewardAmount by joining with IssuingMiningRound contracts
2099
+ COALESCE(SUM(0), 0) as amount,
2100
+ COALESCE(SUM((c.create_argument->>'amount')::numeric), 0) as coupon_amount
2101
+ FROM transactions t
2102
+ JOIN transaction_events e ON e.transaction_id = t.id
2103
+ JOIN contracts c ON c.id = e.contract_id
2104
+ JOIN templates tmpl ON c.template_id = tmpl.id
2105
+ WHERE t.network = $1
2106
+ AND ${timeFilter}
2107
+ AND e.kind = 'created'
2108
+ AND tmpl.package_name = 'splice-amulet'
2109
+ AND tmpl.module_name = 'Splice.Amulet'
2110
+ AND tmpl.template_name = 'AppRewardCoupon'
2111
+ ${partyCondition}
2112
+ ${statusCondition}
2113
+ GROUP BY date_trunc('${interval}', t.record_time)
2114
+ )
2115
+ SELECT
2116
+ ts.timestamp::text,
2117
+ COALESCE(cd.count, 0) as count,
2118
+ COALESCE(cd.amount, 0) as amount,
2119
+ COALESCE(cd.coupon_amount, 0) as coupon_amount
2120
+ FROM time_series ts
2121
+ LEFT JOIN coupon_data cd ON ts.timestamp = cd.timestamp
2122
+ ORDER BY ts.timestamp ASC
2123
+ `;
2124
+ const result = await this.pool.query(query, queryParams);
2125
+ return result.rows.map(row => ({
2126
+ timestamp: row.timestamp,
2127
+ count: parseInt(row.count, 10),
2128
+ amount: parseFloat(row.amount),
2129
+ couponAmount: parseFloat(row.coupon_amount),
2130
+ }));
2131
+ }
2132
+ /**
2133
+ * Get AppRewardCoupon round series data from Canton transactions
2134
+ * Queries AppRewardCoupon created events and aggregates by round number
2135
+ */
2136
+ async getAppRewardCouponRoundSeriesData(timeRange, metric, timePeriod, customStartDate, partyFilter, couponStatus = 'good') {
2137
+ let timeRangeInterval;
2138
+ let timeFilter;
2139
+ switch (timeRange) {
2140
+ case '15m':
2141
+ timeRangeInterval = '15 minutes';
2142
+ timeFilter = "t.record_time >= NOW() - INTERVAL '15 minutes'";
2143
+ break;
2144
+ case '1h':
2145
+ timeRangeInterval = '1 hour';
2146
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 hour'";
2147
+ break;
2148
+ case '6h':
2149
+ timeRangeInterval = '6 hours';
2150
+ timeFilter = "t.record_time >= NOW() - INTERVAL '6 hours'";
2151
+ break;
2152
+ case '1d':
2153
+ timeRangeInterval = '1 day';
2154
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 day'";
2155
+ break;
2156
+ case '7d':
2157
+ timeRangeInterval = '7 days';
2158
+ timeFilter = "t.record_time >= NOW() - INTERVAL '7 days'";
2159
+ break;
2160
+ case '30d':
2161
+ timeRangeInterval = '30 days';
2162
+ timeFilter = "t.record_time >= NOW() - INTERVAL '30 days'";
2163
+ break;
2164
+ case 'last-month':
2165
+ timeRangeInterval = '1 month';
2166
+ timeFilter =
2167
+ "t.record_time >= date_trunc('month', current_date - interval '1 month') AND t.record_time < date_trunc('month', current_date)";
2168
+ break;
2169
+ case 'all':
2170
+ default:
2171
+ timeRangeInterval = '1 day';
2172
+ timeFilter = 'TRUE';
2173
+ }
2174
+ if (timePeriod === 'custom-start' && customStartDate) {
2175
+ timeFilter = `t.record_time >= '${customStartDate}:00' AND t.record_time < ('${customStartDate}:00'::timestamp + INTERVAL '${timeRangeInterval}')`;
2176
+ }
2177
+ const queryParams = [this.network];
2178
+ let partyCondition = '';
2179
+ if (partyFilter) {
2180
+ queryParams.push(partyFilter);
2181
+ // beneficiary can be in create_argument->>'beneficiary' or fallback to provider
2182
+ partyCondition = ` AND (
2183
+ c.create_argument->>'beneficiary' = $${queryParams.length}
2184
+ OR (c.create_argument->>'beneficiary' IS NULL AND c.create_argument->>'provider' = $${queryParams.length})
2185
+ )`;
2186
+ }
2187
+ // For couponStatus, we check if contract is archived (expired) or active (good)
2188
+ const statusCondition = couponStatus === 'expired'
2189
+ ? ` AND EXISTS (
2190
+ SELECT 1 FROM transaction_events e_archived
2191
+ WHERE e_archived.contract_id = c.id AND e_archived.kind = 'archived'
2192
+ )`
2193
+ : ` AND NOT EXISTS (
2194
+ SELECT 1 FROM transaction_events e_archived
2195
+ WHERE e_archived.contract_id = c.id AND e_archived.kind = 'archived'
2196
+ )`;
2197
+ const query = `
2198
+ WITH time_filtered AS (
2199
+ SELECT
2200
+ (c.create_argument->'round'->>'number')::integer AS round_number
2201
+ FROM transactions t
2202
+ JOIN transaction_events e ON e.transaction_id = t.id
2203
+ JOIN contracts c ON c.id = e.contract_id
2204
+ JOIN templates tmpl ON c.template_id = tmpl.id
2205
+ WHERE t.network = $1
2206
+ AND ${timeFilter}
2207
+ AND e.kind = 'created'
2208
+ AND tmpl.package_name = 'splice-amulet'
2209
+ AND tmpl.module_name = 'Splice.Amulet'
2210
+ AND tmpl.template_name = 'AppRewardCoupon'
2211
+ ${partyCondition}
2212
+ ${statusCondition}
2213
+ AND (c.create_argument->'round'->>'number') IS NOT NULL
2214
+ ),
2215
+ round_bounds AS (
2216
+ SELECT
2217
+ COALESCE(MIN(round_number), 0) AS min_round,
2218
+ COALESCE(MAX(round_number), 0) AS max_round
2219
+ FROM time_filtered
2220
+ ),
2221
+ rounds AS (
2222
+ SELECT generate_series(
2223
+ GREATEST((SELECT min_round FROM round_bounds), 0),
2224
+ GREATEST((SELECT max_round FROM round_bounds), 0)
2225
+ ) AS round
2226
+ WHERE (SELECT max_round FROM round_bounds) > 0
2227
+ ),
2228
+ coupon_data AS (
2229
+ SELECT
2230
+ (c.create_argument->'round'->>'number')::integer AS round,
2231
+ MIN(t.record_time) AS timestamp,
2232
+ COUNT(*) AS count,
2233
+ -- appRewardAmount is calculated from couponAmount * issuanceRate, but we don't have issuanceRate here
2234
+ -- For now, we'll use 0 for amount (appRewardAmount) and only return couponAmount
2235
+ -- TODO: Calculate appRewardAmount by joining with IssuingMiningRound contracts
2236
+ COALESCE(SUM(0), 0) AS amount,
2237
+ COALESCE(SUM((c.create_argument->>'amount')::numeric), 0) AS coupon_amount
2238
+ FROM transactions t
2239
+ JOIN transaction_events e ON e.transaction_id = t.id
2240
+ JOIN contracts c ON c.id = e.contract_id
2241
+ JOIN templates tmpl ON c.template_id = tmpl.id
2242
+ WHERE t.network = $1
2243
+ AND ${timeFilter}
2244
+ AND e.kind = 'created'
2245
+ AND tmpl.package_name = 'splice-amulet'
2246
+ AND tmpl.module_name = 'Splice.Amulet'
2247
+ AND tmpl.template_name = 'AppRewardCoupon'
2248
+ ${partyCondition}
2249
+ ${statusCondition}
2250
+ AND (c.create_argument->'round'->>'number') IS NOT NULL
2251
+ GROUP BY (c.create_argument->'round'->>'number')::integer
2252
+ )
2253
+ SELECT
2254
+ r.round,
2255
+ cd.timestamp,
2256
+ COALESCE(cd.count, 0) AS count,
2257
+ COALESCE(cd.amount, 0) AS amount,
2258
+ COALESCE(cd.coupon_amount, 0) AS coupon_amount
2259
+ FROM rounds r
2260
+ LEFT JOIN coupon_data cd ON cd.round = r.round
2261
+ ORDER BY r.round ASC
2262
+ `;
2263
+ const result = await this.pool.query(query, queryParams);
2264
+ return result.rows.map(row => ({
2265
+ timestamp: row.timestamp
2266
+ ? new Date(row.timestamp).toISOString()
2267
+ : new Date().toISOString(),
2268
+ count: parseInt(row.count, 10),
2269
+ amount: parseFloat(row.amount),
2270
+ couponAmount: parseFloat(row.coupon_amount),
2271
+ round: typeof row.round === 'number' ? row.round : parseInt(row.round, 10),
2272
+ }));
2273
+ }
2274
+ /**
2275
+ * Get AppMarker (FeaturedAppActivityMarker) time series data from Canton transactions
2276
+ */
2277
+ async getAppMarkerTimeSeriesData(timeRange, metric, timePeriod, customStartDate, partyFilter, status) {
2278
+ let interval;
2279
+ let timeFilter;
2280
+ let timeSeriesStart;
2281
+ let timeSeriesEnd = 'NOW()';
2282
+ let timeRangeInterval;
2283
+ switch (timeRange) {
2284
+ case '15m':
2285
+ interval = 'minute';
2286
+ timeFilter = "t.record_time >= NOW() - INTERVAL '15 minutes'";
2287
+ timeRangeInterval = '15 minutes';
2288
+ timeSeriesStart = "NOW() - INTERVAL '15 minutes'";
2289
+ break;
2290
+ case '1h':
2291
+ interval = 'minute';
2292
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 hour'";
2293
+ timeRangeInterval = '1 hour';
2294
+ timeSeriesStart = "NOW() - INTERVAL '1 hour'";
2295
+ break;
2296
+ case '6h':
2297
+ interval = 'minute';
2298
+ timeFilter = "t.record_time >= NOW() - INTERVAL '6 hours'";
2299
+ timeRangeInterval = '6 hours';
2300
+ timeSeriesStart = "NOW() - INTERVAL '6 hours'";
2301
+ break;
2302
+ case '1d':
2303
+ interval = 'hour';
2304
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 day'";
2305
+ timeRangeInterval = '1 day';
2306
+ timeSeriesStart = "NOW() - INTERVAL '1 day'";
2307
+ break;
2308
+ case '7d':
2309
+ interval = 'hour';
2310
+ timeFilter = "t.record_time >= NOW() - INTERVAL '7 days'";
2311
+ timeRangeInterval = '7 days';
2312
+ timeSeriesStart = "NOW() - INTERVAL '7 days'";
2313
+ break;
2314
+ case '30d':
2315
+ interval = 'day';
2316
+ timeFilter = "t.record_time >= NOW() - INTERVAL '30 days'";
2317
+ timeRangeInterval = '30 days';
2318
+ timeSeriesStart = "NOW() - INTERVAL '30 days'";
2319
+ break;
2320
+ case 'last-month':
2321
+ interval = 'day';
2322
+ timeFilter =
2323
+ "t.record_time >= date_trunc('month', current_date - interval '1 month') AND t.record_time < date_trunc('month', current_date)";
2324
+ timeRangeInterval = '1 month';
2325
+ timeSeriesStart =
2326
+ "date_trunc('month', current_date - interval '1 month')";
2327
+ timeSeriesEnd = "date_trunc('month', current_date)";
2328
+ break;
2329
+ case 'all':
2330
+ interval = 'day';
2331
+ timeFilter = 'TRUE';
2332
+ timeRangeInterval = '100 years';
2333
+ // For 'all', we'll query the actual min/max from the data itself
2334
+ // Set a reasonable default that will be overridden in the query
2335
+ timeSeriesStart = "NOW() - INTERVAL '365 days'";
2336
+ break;
2337
+ default:
2338
+ interval = 'hour';
2339
+ timeFilter = "t.record_time >= NOW() - INTERVAL '1 day'";
2340
+ timeRangeInterval = '1 day';
2341
+ timeSeriesStart = "NOW() - INTERVAL '1 day'";
2342
+ }
2343
+ if (timePeriod === 'custom-start' && customStartDate) {
2344
+ timeFilter = `t.record_time >= '${customStartDate}:00' AND t.record_time < ('${customStartDate}:00'::timestamp + INTERVAL '${timeRangeInterval}')`;
2345
+ timeSeriesStart = `'${customStartDate}:00'::timestamp`;
2346
+ timeSeriesEnd = `('${customStartDate}:00'::timestamp + INTERVAL '${timeRangeInterval}')`;
2347
+ }
2348
+ const queryParams = [this.network];
2349
+ let partyCondition = '';
2350
+ if (partyFilter) {
2351
+ queryParams.push(partyFilter);
2352
+ // beneficiary can be in create_argument->>'beneficiary' or fallback to provider
2353
+ partyCondition = ` AND (
2354
+ c.create_argument->>'beneficiary' = $${queryParams.length}
2355
+ OR (c.create_argument->>'beneficiary' IS NULL AND c.create_argument->>'provider' = $${queryParams.length})
2356
+ )`;
2357
+ }
2358
+ let statusFilter = '';
2359
+ if (status === 'created') {
2360
+ statusFilter = ` AND NOT EXISTS (
2361
+ SELECT 1 FROM transaction_events e_archived
2362
+ WHERE e_archived.contract_id = c.id AND e_archived.kind = 'archived'
2363
+ )`;
2364
+ }
2365
+ else if (status === 'archived') {
2366
+ statusFilter = ` AND EXISTS (
2367
+ SELECT 1 FROM transaction_events e_archived
2368
+ WHERE e_archived.contract_id = c.id AND e_archived.kind = 'archived'
2369
+ )`;
2370
+ }
2371
+ // For 'all' time range, we need to get the actual min/max from the data
2372
+ const useDynamicRange = timeRange === 'all';
2373
+ const query = useDynamicRange
2374
+ ? `
2375
+ WITH marker_data AS (
2376
+ SELECT
2377
+ date_trunc('${interval}', t.record_time) AS timestamp,
2378
+ COUNT(*) FILTER (WHERE e.kind = 'created') as created_count,
2379
+ COUNT(*) FILTER (WHERE e_archived.kind = 'archived') as archived_count,
2380
+ COALESCE(SUM((c.create_argument->>'weight')::numeric) FILTER (WHERE e.kind = 'created'), 0) as created_weight,
2381
+ COALESCE(SUM((c.create_argument->>'weight')::numeric) FILTER (WHERE e_archived.kind = 'archived'), 0) as archived_weight
2382
+ FROM transactions t
2383
+ JOIN transaction_events e ON e.transaction_id = t.id
2384
+ JOIN contracts c ON c.id = e.contract_id
2385
+ JOIN templates tmpl ON c.template_id = tmpl.id
2386
+ LEFT JOIN transaction_events e_archived ON e_archived.contract_id = c.id AND e_archived.kind = 'archived'
2387
+ WHERE t.network = $1
2388
+ AND ${timeFilter}
2389
+ AND tmpl.package_name = 'splice-amulet'
2390
+ AND tmpl.module_name = 'Splice.Amulet'
2391
+ AND tmpl.template_name = 'FeaturedAppActivityMarker'
2392
+ ${partyCondition}
2393
+ ${statusFilter}
2394
+ AND (e.kind = 'created' OR e_archived.kind = 'archived')
2395
+ GROUP BY date_trunc('${interval}', t.record_time)
2396
+ ),
2397
+ time_bounds AS (
2398
+ SELECT
2399
+ COALESCE(MIN(timestamp), NOW() - INTERVAL '1 day') AS min_time,
2400
+ COALESCE(MAX(timestamp), NOW()) AS max_time
2401
+ FROM marker_data
2402
+ ),
2403
+ time_series AS (
2404
+ SELECT generate_series(
2405
+ date_trunc('${interval}', (SELECT min_time FROM time_bounds)),
2406
+ date_trunc('${interval}', (SELECT max_time FROM time_bounds)),
2407
+ INTERVAL '1 ${interval}'
2408
+ ) AS timestamp
2409
+ )
2410
+ SELECT
2411
+ ts.timestamp::text,
2412
+ COALESCE(md.created_count, 0) + COALESCE(md.archived_count, 0) as count,
2413
+ COALESCE(md.created_count, 0) as created_count,
2414
+ COALESCE(md.archived_count, 0) as archived_count,
2415
+ COALESCE(md.created_weight, 0) as created_weight,
2416
+ COALESCE(md.archived_weight, 0) as archived_weight
2417
+ FROM time_series ts
2418
+ LEFT JOIN marker_data md ON ts.timestamp = md.timestamp
2419
+ ORDER BY ts.timestamp ASC
2420
+ `
2421
+ : `
2422
+ WITH time_series AS (
2423
+ SELECT generate_series(
2424
+ date_trunc('${interval}', ${timeSeriesStart}),
2425
+ date_trunc('${interval}', ${timeSeriesEnd}),
2426
+ INTERVAL '1 ${interval}'
2427
+ ) AS timestamp
2428
+ ),
2429
+ marker_data AS (
2430
+ SELECT
2431
+ date_trunc('${interval}', t.record_time) AS timestamp,
2432
+ COUNT(*) FILTER (WHERE e.kind = 'created') as created_count,
2433
+ COUNT(*) FILTER (WHERE e_archived.kind = 'archived') as archived_count,
2434
+ COALESCE(SUM((c.create_argument->>'weight')::numeric) FILTER (WHERE e.kind = 'created'), 0) as created_weight,
2435
+ COALESCE(SUM((c.create_argument->>'weight')::numeric) FILTER (WHERE e_archived.kind = 'archived'), 0) as archived_weight
2436
+ FROM transactions t
2437
+ JOIN transaction_events e ON e.transaction_id = t.id
2438
+ JOIN contracts c ON c.id = e.contract_id
2439
+ JOIN templates tmpl ON c.template_id = tmpl.id
2440
+ LEFT JOIN transaction_events e_archived ON e_archived.contract_id = c.id AND e_archived.kind = 'archived'
2441
+ WHERE t.network = $1
2442
+ AND ${timeFilter}
2443
+ AND tmpl.package_name = 'splice-amulet'
2444
+ AND tmpl.module_name = 'Splice.Amulet'
2445
+ AND tmpl.template_name = 'FeaturedAppActivityMarker'
2446
+ ${partyCondition}
2447
+ ${statusFilter}
2448
+ AND (e.kind = 'created' OR e_archived.kind = 'archived')
2449
+ GROUP BY date_trunc('${interval}', t.record_time)
2450
+ )
2451
+ SELECT
2452
+ ts.timestamp::text,
2453
+ COALESCE(md.created_count, 0) + COALESCE(md.archived_count, 0) as count,
2454
+ COALESCE(md.created_count, 0) as created_count,
2455
+ COALESCE(md.archived_count, 0) as archived_count,
2456
+ COALESCE(md.created_weight, 0) as created_weight,
2457
+ COALESCE(md.archived_weight, 0) as archived_weight
2458
+ FROM time_series ts
2459
+ LEFT JOIN marker_data md ON ts.timestamp = md.timestamp
2460
+ ORDER BY ts.timestamp ASC
2461
+ `;
2462
+ const result = await this.pool.query(query, queryParams);
2463
+ return result.rows.map(row => ({
2464
+ timestamp: row.timestamp,
2465
+ count: parseInt(row.count, 10),
2466
+ createdCount: row.created_count
2467
+ ? parseInt(row.created_count, 10)
2468
+ : undefined,
2469
+ archivedCount: row.archived_count
2470
+ ? parseInt(row.archived_count, 10)
2471
+ : undefined,
2472
+ createdWeight: row.created_weight
2473
+ ? parseFloat(row.created_weight)
2474
+ : undefined,
2475
+ archivedWeight: row.archived_weight
2476
+ ? parseFloat(row.archived_weight)
2477
+ : undefined,
2478
+ }));
2479
+ }
2480
+ /**
2481
+ * Get unique beneficiary parties from AppRewardCoupon contracts
2482
+ */
2483
+ async getUniqueCouponBeneficiaryParties() {
2484
+ const query = `
2485
+ SELECT DISTINCT
2486
+ COALESCE(c.create_argument->>'beneficiary', c.create_argument->>'provider') AS party_id,
2487
+ COALESCE(c.create_argument->>'beneficiary', c.create_argument->>'provider') AS display_name
2488
+ FROM transactions t
2489
+ JOIN transaction_events e ON e.transaction_id = t.id
2490
+ JOIN contracts c ON c.id = e.contract_id
2491
+ JOIN templates tmpl ON c.template_id = tmpl.id
2492
+ WHERE t.network = $1
2493
+ AND e.kind = 'created'
2494
+ AND tmpl.package_name = 'splice-amulet'
2495
+ AND tmpl.module_name = 'Splice.Amulet'
2496
+ AND tmpl.template_name = 'AppRewardCoupon'
2497
+ AND (c.create_argument->>'beneficiary' IS NOT NULL OR c.create_argument->>'provider' IS NOT NULL)
2498
+ ORDER BY party_id
2499
+ `;
2500
+ const result = await this.pool.query(query, [this.network]);
2501
+ return result.rows.map(row => ({
2502
+ party_id: row.party_id,
2503
+ display_name: row.display_name || row.party_id,
2504
+ }));
2505
+ }
2506
+ /**
2507
+ * Get unique beneficiary parties from FeaturedAppActivityMarker contracts
2508
+ */
2509
+ async getUniqueMarkerBeneficiaryParties() {
2510
+ const query = `
2511
+ SELECT DISTINCT
2512
+ COALESCE(c.create_argument->>'beneficiary', c.create_argument->>'provider') AS party_id,
2513
+ COALESCE(c.create_argument->>'beneficiary', c.create_argument->>'provider') AS display_name
2514
+ FROM transactions t
2515
+ JOIN transaction_events e ON e.transaction_id = t.id
2516
+ JOIN contracts c ON c.id = e.contract_id
2517
+ JOIN templates tmpl ON c.template_id = tmpl.id
2518
+ WHERE t.network = $1
2519
+ AND e.kind = 'created'
2520
+ AND tmpl.package_name = 'splice-amulet'
2521
+ AND tmpl.module_name = 'Splice.Amulet'
2522
+ AND tmpl.template_name = 'FeaturedAppActivityMarker'
2523
+ AND (c.create_argument->>'beneficiary' IS NOT NULL OR c.create_argument->>'provider' IS NOT NULL)
2524
+ ORDER BY party_id
2525
+ `;
2526
+ const result = await this.pool.query(query, [this.network]);
2527
+ return result.rows.map(row => ({
2528
+ party_id: row.party_id,
2529
+ display_name: row.display_name || row.party_id,
2530
+ }));
2531
+ }
2532
+ /**
2533
+ * Get last AppRewardCoupon timestamp
2534
+ */
2535
+ async getLastAppRewardCouponTimestamp(partyFilter) {
2536
+ const queryParams = [this.network];
2537
+ let partyCondition = '';
2538
+ if (partyFilter) {
2539
+ queryParams.push(partyFilter);
2540
+ // beneficiary can be in create_argument->>'beneficiary' or fallback to provider
2541
+ partyCondition = ` AND (
2542
+ c.create_argument->>'beneficiary' = $${queryParams.length}
2543
+ OR (c.create_argument->>'beneficiary' IS NULL AND c.create_argument->>'provider' = $${queryParams.length})
2544
+ )`;
2545
+ }
2546
+ const query = `
2547
+ SELECT MAX(t.record_time) AS last_timestamp
2548
+ FROM transactions t
2549
+ JOIN transaction_events e ON e.transaction_id = t.id
2550
+ JOIN contracts c ON c.id = e.contract_id
2551
+ JOIN templates tmpl ON c.template_id = tmpl.id
2552
+ WHERE t.network = $1
2553
+ AND e.kind = 'created'
2554
+ AND tmpl.package_name = 'splice-amulet'
2555
+ AND tmpl.module_name = 'Splice.Amulet'
2556
+ AND tmpl.template_name = 'AppRewardCoupon'
2557
+ ${partyCondition}
2558
+ `;
2559
+ const result = await this.pool.query(query, queryParams);
2560
+ if (result.rows.length === 0 || !result.rows[0].last_timestamp) {
2561
+ return null;
2562
+ }
2563
+ return new Date(result.rows[0].last_timestamp).toISOString();
2564
+ }
2565
+ /**
2566
+ * Get lifetime app rewards (sum of all AppRewardCoupon amounts)
2567
+ */
2568
+ async getLifetimeAppRewards() {
2569
+ const query = `
2570
+ -- appRewardAmount is calculated, not stored in contract
2571
+ -- For now return 0, TODO: Calculate by joining with IssuingMiningRound
2572
+ SELECT COALESCE(SUM(0), 0) AS total_rewards
2573
+ FROM transactions t
2574
+ JOIN transaction_events e ON e.transaction_id = t.id
2575
+ JOIN contracts c ON c.id = e.contract_id
2576
+ JOIN templates tmpl ON c.template_id = tmpl.id
2577
+ WHERE t.network = $1
2578
+ AND e.kind = 'created'
2579
+ AND tmpl.package_name = 'splice-amulet'
2580
+ AND tmpl.module_name = 'Splice.Amulet'
2581
+ AND tmpl.template_name = 'AppRewardCoupon'
2582
+ `;
2583
+ const result = await this.pool.query(query, [this.network]);
2584
+ return parseFloat(result.rows[0]?.total_rewards ?? '0');
2585
+ }
2586
+ /**
2587
+ * Get latest coupon amounts (featured and unfeatured)
2588
+ */
2589
+ async getLatestCouponAmounts() {
2590
+ const query = `
2591
+ WITH latest_coupons AS (
2592
+ SELECT DISTINCT ON (
2593
+ CASE WHEN (c.create_argument->>'featured')::boolean THEN 'featured' ELSE 'unfeatured' END
2594
+ )
2595
+ (c.create_argument->>'featured')::boolean AS featured,
2596
+ (c.create_argument->>'amount')::numeric AS coupon_amount,
2597
+ t.record_time
2598
+ FROM transactions t
2599
+ JOIN transaction_events e ON e.transaction_id = t.id
2600
+ JOIN contracts c ON c.id = e.contract_id
2601
+ JOIN templates tmpl ON c.template_id = tmpl.id
2602
+ WHERE t.network = $1
2603
+ AND e.kind = 'created'
2604
+ AND tmpl.package_name = 'splice-amulet'
2605
+ AND tmpl.module_name = 'Splice.Amulet'
2606
+ AND tmpl.template_name = 'AppRewardCoupon'
2607
+ AND c.create_argument->>'amount' IS NOT NULL
2608
+ ORDER BY
2609
+ CASE WHEN (c.create_argument->>'featured')::boolean THEN 'featured' ELSE 'unfeatured' END,
2610
+ t.record_time DESC
2611
+ )
2612
+ SELECT
2613
+ MAX(coupon_amount) FILTER (WHERE featured = true) AS featured_amount,
2614
+ MAX(coupon_amount) FILTER (WHERE featured = false OR featured IS NULL) AS unfeatured_amount
2615
+ FROM latest_coupons
2616
+ `;
2617
+ const result = await this.pool.query(query, [this.network]);
2618
+ const row = result.rows[0] ?? {};
2619
+ return {
2620
+ featured: row.featured_amount !== null && row.featured_amount !== undefined
2621
+ ? parseFloat(row.featured_amount)
2622
+ : null,
2623
+ unfeatured: row.unfeatured_amount !== null && row.unfeatured_amount !== undefined
2624
+ ? parseFloat(row.unfeatured_amount)
2625
+ : null,
2626
+ };
2627
+ }
2628
+ /**
2629
+ * Get active contracts with optional filtering.
2630
+ * Mirrors the SDK's getActiveContracts but runs against the local database.
2631
+ * Note: For performance, signatories/observers/witnessParties are not included.
2632
+ */
2633
+ async getActiveContracts(options) {
2634
+ const { partyId, templateIds } = options;
2635
+ const queryParams = [this.network];
2636
+ const conditions = ['c.network = $1'];
2637
+ // 1. Filter by Party (readAs)
2638
+ if (partyId) {
2639
+ queryParams.push(partyId);
2640
+ conditions.push(`
2641
+ EXISTS (
2642
+ SELECT 1 FROM contract_actors ca
2643
+ INNER JOIN parties p ON ca.party_id = p.id
2644
+ WHERE ca.contract_id = c.id
2645
+ AND p.party_id = $${queryParams.length}
2646
+ )
2647
+ `);
2648
+ }
2649
+ // 2. Filter by Template IDs
2650
+ if (templateIds && templateIds.length > 0) {
2651
+ queryParams.push(templateIds);
2652
+ conditions.push(`
2653
+ (pkg.package_id || ':' || tmpl.module_name || ':' || tmpl.template_name) = ANY($${queryParams.length})
2654
+ `);
2655
+ }
2656
+ const query = `
2657
+ SELECT
2658
+ c.canton_contract_id AS contract_id,
2659
+ t.record_time,
2660
+ (pkg.package_id || ':' || tmpl.module_name || ':' || tmpl.template_name) AS template_id,
2661
+ tmpl.package_name,
2662
+ c.create_argument
2663
+ FROM contracts c
2664
+ INNER JOIN active_contracts ac ON ac.contract_id = c.id
2665
+ INNER JOIN transaction_events e ON e.contract_id = c.id AND e.kind = 'created'
2666
+ INNER JOIN transactions t ON t.id = e.transaction_id
2667
+ INNER JOIN templates tmpl ON tmpl.id = c.template_id
2668
+ INNER JOIN packages pkg ON pkg.id = e.package_id
2669
+ WHERE ${conditions.join(' AND ')}
2670
+ ORDER BY t.record_time DESC
2671
+ `;
2672
+ try {
2673
+ const result = await this.pool.query(query, queryParams);
2674
+ return result.rows.map(row => {
2675
+ const createArgument = typeof row.create_argument === 'string'
2676
+ ? JSON.parse(row.create_argument)
2677
+ : row.create_argument;
2678
+ return {
2679
+ contractEntry: {
2680
+ JsActiveContract: {
2681
+ createdEvent: {
2682
+ contractId: row.contract_id,
2683
+ templateId: row.template_id,
2684
+ createArgument: (createArgument || {}),
2685
+ createdAt: row.record_time
2686
+ ? new Date(row.record_time).toISOString()
2687
+ : new Date().toISOString(),
2688
+ packageName: row.package_name,
2689
+ },
2690
+ synchronizerId: 'unknown',
2691
+ reassignmentCounter: '0',
2692
+ },
2693
+ },
2694
+ };
2695
+ });
2696
+ }
2697
+ catch (error) {
2698
+ throw error;
2699
+ }
2700
+ }
2701
+ }
2702
+ exports.CantonDbClient = CantonDbClient;
2703
+ //# sourceMappingURL=cantonDbClient.js.map