@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.
- package/README.md +64 -0
- package/dist/clients/index.d.ts +5 -0
- package/dist/clients/index.d.ts.map +1 -0
- package/dist/clients/index.js +21 -0
- package/dist/clients/index.js.map +1 -0
- package/dist/clients/json-api/index.d.ts +2 -0
- package/dist/clients/json-api/index.d.ts.map +1 -0
- package/dist/clients/json-api/index.js +18 -0
- package/dist/clients/json-api/index.js.map +1 -0
- package/dist/clients/json-api/sdkHelper.d.ts +27 -0
- package/dist/clients/json-api/sdkHelper.d.ts.map +1 -0
- package/dist/clients/json-api/sdkHelper.js +73 -0
- package/dist/clients/json-api/sdkHelper.js.map +1 -0
- package/dist/clients/postgres-db-api/cantonDbClient.d.ts +335 -0
- package/dist/clients/postgres-db-api/cantonDbClient.d.ts.map +1 -0
- package/dist/clients/postgres-db-api/cantonDbClient.js +2703 -0
- package/dist/clients/postgres-db-api/cantonDbClient.js.map +1 -0
- package/dist/clients/postgres-db-api/fairmintDbClient.d.ts +241 -0
- package/dist/clients/postgres-db-api/fairmintDbClient.d.ts.map +1 -0
- package/dist/clients/postgres-db-api/fairmintDbClient.js +3078 -0
- package/dist/clients/postgres-db-api/fairmintDbClient.js.map +1 -0
- package/dist/clients/postgres-db-api/index.d.ts +5 -0
- package/dist/clients/postgres-db-api/index.d.ts.map +1 -0
- package/dist/clients/postgres-db-api/index.js +25 -0
- package/dist/clients/postgres-db-api/index.js.map +1 -0
- package/dist/clients/postgres-db-api/postgresDbClient.d.ts +118 -0
- package/dist/clients/postgres-db-api/postgresDbClient.d.ts.map +1 -0
- package/dist/clients/postgres-db-api/postgresDbClient.js +212 -0
- package/dist/clients/postgres-db-api/postgresDbClient.js.map +1 -0
- package/dist/clients/postgres-db-api/types.d.ts +330 -0
- package/dist/clients/postgres-db-api/types.d.ts.map +1 -0
- package/dist/clients/postgres-db-api/types.js +30 -0
- package/dist/clients/postgres-db-api/types.js.map +1 -0
- package/dist/clients/shared/config.d.ts +19 -0
- package/dist/clients/shared/config.d.ts.map +1 -0
- package/dist/clients/shared/config.js +208 -0
- package/dist/clients/shared/config.js.map +1 -0
- package/dist/clients/shared/index.d.ts +3 -0
- package/dist/clients/shared/index.d.ts.map +1 -0
- package/dist/clients/shared/index.js +19 -0
- package/dist/clients/shared/index.js.map +1 -0
- package/dist/clients/shared/types.d.ts +29 -0
- package/dist/clients/shared/types.d.ts.map +1 -0
- package/dist/clients/shared/types.js +3 -0
- package/dist/clients/shared/types.js.map +1 -0
- package/dist/clients/validator-api/index.d.ts +4 -0
- package/dist/clients/validator-api/index.d.ts.map +1 -0
- package/dist/clients/validator-api/index.js +61 -0
- package/dist/clients/validator-api/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- 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
|