@openzeppelin/ui-builder-adapter-stellar 0.16.0 → 1.0.0
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 +87 -0
- package/dist/index.cjs +6235 -3679
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +6206 -3643
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/src/access-control/actions.ts +214 -0
- package/src/access-control/feature-detection.ts +226 -0
- package/src/access-control/index.ts +54 -0
- package/src/access-control/indexer-client.ts +1297 -0
- package/src/access-control/onchain-reader.ts +450 -0
- package/src/access-control/service.ts +1347 -0
- package/src/access-control/validation.ts +256 -0
- package/src/adapter.ts +21 -0
- package/src/configuration/network-services.ts +130 -9
- package/src/networks/mainnet.ts +1 -0
- package/src/networks/testnet.ts +1 -0
- package/src/query/handler.ts +6 -4
- package/src/sac/xdr.ts +1 -2
- package/src/transaction/eoa.ts +8 -1
- package/src/transaction/relayer.ts +8 -1
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stellar Indexer Client
|
|
3
|
+
*
|
|
4
|
+
* Provides access to historical access control events via a GraphQL indexer.
|
|
5
|
+
* Implements config precedence: runtime override > network-config default > derived-from-RPC (if safe) > none.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
HistoryChangeType,
|
|
10
|
+
HistoryEntry,
|
|
11
|
+
HistoryQueryOptions,
|
|
12
|
+
IndexerEndpointConfig,
|
|
13
|
+
PageInfo,
|
|
14
|
+
PaginatedHistoryResult,
|
|
15
|
+
RoleIdentifier,
|
|
16
|
+
StellarNetworkConfig,
|
|
17
|
+
} from '@openzeppelin/ui-builder-types';
|
|
18
|
+
import {
|
|
19
|
+
ConfigurationInvalid,
|
|
20
|
+
IndexerUnavailable,
|
|
21
|
+
OperationFailed,
|
|
22
|
+
} from '@openzeppelin/ui-builder-types';
|
|
23
|
+
import { appConfigService, logger } from '@openzeppelin/ui-builder-utils';
|
|
24
|
+
|
|
25
|
+
const LOG_SYSTEM = 'StellarIndexerClient';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* GraphQL query response types for indexer
|
|
29
|
+
*/
|
|
30
|
+
interface IndexerHistoryEntry {
|
|
31
|
+
id: string;
|
|
32
|
+
role?: string; // Nullable for Ownership/Admin events
|
|
33
|
+
account: string;
|
|
34
|
+
type:
|
|
35
|
+
| 'ROLE_GRANTED'
|
|
36
|
+
| 'ROLE_REVOKED'
|
|
37
|
+
| 'OWNERSHIP_TRANSFER_COMPLETED'
|
|
38
|
+
| 'OWNERSHIP_TRANSFER_STARTED'
|
|
39
|
+
| 'ADMIN_TRANSFER_INITIATED'
|
|
40
|
+
| 'ADMIN_TRANSFER_COMPLETED';
|
|
41
|
+
txHash: string;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
blockHeight: string;
|
|
44
|
+
/** Ledger sequence of the event */
|
|
45
|
+
ledger?: string;
|
|
46
|
+
/** Admin/owner who initiated the transfer (for OWNERSHIP_TRANSFER_STARTED, ADMIN_TRANSFER_INITIATED) */
|
|
47
|
+
admin?: string;
|
|
48
|
+
/** Expiration ledger for pending transfers (OWNERSHIP_TRANSFER_STARTED, ADMIN_TRANSFER_INITIATED) */
|
|
49
|
+
liveUntilLedger?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Ownership Transfer Started Event
|
|
54
|
+
*
|
|
55
|
+
* Contains details about a pending two-step ownership transfer from the indexer.
|
|
56
|
+
* Includes the expiration ledger (`liveUntilLedger`) for determining if the
|
|
57
|
+
* transfer has expired without requiring an additional on-chain query.
|
|
58
|
+
*/
|
|
59
|
+
export interface OwnershipTransferStartedEvent {
|
|
60
|
+
/** Previous owner (admin) who initiated the transfer */
|
|
61
|
+
previousOwner: string;
|
|
62
|
+
/** Pending owner address (account) - the new owner */
|
|
63
|
+
pendingOwner: string;
|
|
64
|
+
/** Transaction hash of the initiation */
|
|
65
|
+
txHash: string;
|
|
66
|
+
/** ISO8601 timestamp of the event */
|
|
67
|
+
timestamp: string;
|
|
68
|
+
/** Ledger sequence of the event */
|
|
69
|
+
ledger: number;
|
|
70
|
+
/** Expiration ledger - transfer must be accepted before this ledger */
|
|
71
|
+
liveUntilLedger: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Admin Transfer Initiated Event
|
|
76
|
+
*
|
|
77
|
+
* Contains details about a pending two-step admin transfer from the indexer.
|
|
78
|
+
* Includes the expiration ledger (`liveUntilLedger`) for determining if the
|
|
79
|
+
* transfer has expired without requiring an additional on-chain query.
|
|
80
|
+
*/
|
|
81
|
+
export interface AdminTransferInitiatedEvent {
|
|
82
|
+
/** Previous admin who initiated the transfer */
|
|
83
|
+
previousAdmin: string;
|
|
84
|
+
/** Pending admin address (account) - the new admin */
|
|
85
|
+
pendingAdmin: string;
|
|
86
|
+
/** Transaction hash of the initiation */
|
|
87
|
+
txHash: string;
|
|
88
|
+
/** ISO8601 timestamp of the event */
|
|
89
|
+
timestamp: string;
|
|
90
|
+
/** Ledger sequence of the event */
|
|
91
|
+
ledger: number;
|
|
92
|
+
/** Expiration ledger - transfer must be accepted before this ledger */
|
|
93
|
+
liveUntilLedger: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface IndexerPageInfo {
|
|
97
|
+
hasNextPage: boolean;
|
|
98
|
+
endCursor?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface IndexerHistoryResponse {
|
|
102
|
+
data?: {
|
|
103
|
+
accessControlEvents?: {
|
|
104
|
+
nodes: IndexerHistoryEntry[];
|
|
105
|
+
pageInfo: IndexerPageInfo;
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
errors?: Array<{
|
|
109
|
+
message: string;
|
|
110
|
+
}>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Response type for ownership transfer queries
|
|
115
|
+
*/
|
|
116
|
+
interface IndexerOwnershipTransferResponse {
|
|
117
|
+
data?: {
|
|
118
|
+
accessControlEvents?: {
|
|
119
|
+
nodes: IndexerHistoryEntry[];
|
|
120
|
+
pageInfo?: IndexerPageInfo;
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
errors?: Array<{
|
|
124
|
+
message: string;
|
|
125
|
+
}>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Response type for role discovery query
|
|
130
|
+
*/
|
|
131
|
+
interface IndexerRoleDiscoveryResponse {
|
|
132
|
+
data?: {
|
|
133
|
+
accessControlEvents?: {
|
|
134
|
+
nodes: Array<{
|
|
135
|
+
role: string | null;
|
|
136
|
+
}>;
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
errors?: Array<{
|
|
140
|
+
message: string;
|
|
141
|
+
}>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Grant information for a specific member
|
|
146
|
+
*/
|
|
147
|
+
export interface GrantInfo {
|
|
148
|
+
/** ISO8601 timestamp of the grant */
|
|
149
|
+
timestamp: string;
|
|
150
|
+
/** Transaction ID of the grant */
|
|
151
|
+
txId: string;
|
|
152
|
+
/** Block/ledger number of the grant */
|
|
153
|
+
ledger: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Stellar Indexer Client
|
|
158
|
+
* Handles GraphQL queries to the configured indexer for historical access control data
|
|
159
|
+
*/
|
|
160
|
+
export class StellarIndexerClient {
|
|
161
|
+
private readonly networkConfig: StellarNetworkConfig;
|
|
162
|
+
private resolvedEndpoints: IndexerEndpointConfig | null = null;
|
|
163
|
+
private availabilityChecked = false;
|
|
164
|
+
private isAvailable = false;
|
|
165
|
+
|
|
166
|
+
constructor(networkConfig: StellarNetworkConfig) {
|
|
167
|
+
this.networkConfig = networkConfig;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check if indexer is available and configured
|
|
172
|
+
* @returns True if indexer endpoints are configured and reachable
|
|
173
|
+
*/
|
|
174
|
+
async checkAvailability(): Promise<boolean> {
|
|
175
|
+
if (this.availabilityChecked) {
|
|
176
|
+
return this.isAvailable;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const endpoints = this.resolveIndexerEndpoints();
|
|
180
|
+
if (!endpoints.http) {
|
|
181
|
+
logger.info(LOG_SYSTEM, `No indexer configured for network ${this.networkConfig.id}`);
|
|
182
|
+
this.availabilityChecked = true;
|
|
183
|
+
this.isAvailable = false;
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
// Simple connectivity check with a minimal query
|
|
189
|
+
const response = await fetch(endpoints.http, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { 'Content-Type': 'application/json' },
|
|
192
|
+
body: JSON.stringify({
|
|
193
|
+
query: '{ __typename }',
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (response.ok) {
|
|
198
|
+
logger.info(
|
|
199
|
+
LOG_SYSTEM,
|
|
200
|
+
`Indexer available for network ${this.networkConfig.id} at ${endpoints.http}`
|
|
201
|
+
);
|
|
202
|
+
this.isAvailable = true;
|
|
203
|
+
} else {
|
|
204
|
+
logger.warn(
|
|
205
|
+
LOG_SYSTEM,
|
|
206
|
+
`Indexer endpoint ${endpoints.http} returned status ${response.status}`
|
|
207
|
+
);
|
|
208
|
+
this.isAvailable = false;
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
logger.warn(
|
|
212
|
+
LOG_SYSTEM,
|
|
213
|
+
`Failed to connect to indexer at ${endpoints.http}: ${error instanceof Error ? error.message : String(error)}`
|
|
214
|
+
);
|
|
215
|
+
this.isAvailable = false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.availabilityChecked = true;
|
|
219
|
+
return this.isAvailable;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Query history entries for a contract with pagination support
|
|
224
|
+
* @param contractAddress The contract address to query
|
|
225
|
+
* @param options Optional filtering and pagination options
|
|
226
|
+
* @returns Promise resolving to paginated history result
|
|
227
|
+
* @throws IndexerUnavailable if indexer is not available
|
|
228
|
+
* @throws OperationFailed if query fails
|
|
229
|
+
*/
|
|
230
|
+
async queryHistory(
|
|
231
|
+
contractAddress: string,
|
|
232
|
+
options?: HistoryQueryOptions
|
|
233
|
+
): Promise<PaginatedHistoryResult> {
|
|
234
|
+
const isAvailable = await this.checkAvailability();
|
|
235
|
+
if (!isAvailable) {
|
|
236
|
+
throw new IndexerUnavailable(
|
|
237
|
+
'Indexer not available for this network',
|
|
238
|
+
contractAddress,
|
|
239
|
+
this.networkConfig.id
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const endpoints = this.resolveIndexerEndpoints();
|
|
244
|
+
if (!endpoints.http) {
|
|
245
|
+
throw new ConfigurationInvalid(
|
|
246
|
+
'No indexer HTTP endpoint configured',
|
|
247
|
+
contractAddress,
|
|
248
|
+
'indexer.http'
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Build query with server-side filtering and pagination
|
|
253
|
+
const query = this.buildHistoryQuery(contractAddress, options);
|
|
254
|
+
const variables = this.buildQueryVariables(contractAddress, options);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const response = await fetch(endpoints.http, {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'Content-Type': 'application/json' },
|
|
260
|
+
body: JSON.stringify({ query, variables }),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
throw new OperationFailed(
|
|
265
|
+
`Indexer query failed with status ${response.status}`,
|
|
266
|
+
contractAddress,
|
|
267
|
+
'queryHistory'
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const result = (await response.json()) as IndexerHistoryResponse;
|
|
272
|
+
|
|
273
|
+
if (result.errors && result.errors.length > 0) {
|
|
274
|
+
const errorMessages = result.errors.map((e) => e.message).join('; ');
|
|
275
|
+
throw new OperationFailed(
|
|
276
|
+
`Indexer query errors: ${errorMessages}`,
|
|
277
|
+
contractAddress,
|
|
278
|
+
'queryHistory'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!result.data?.accessControlEvents?.nodes) {
|
|
283
|
+
logger.debug(LOG_SYSTEM, `No history data returned for contract ${contractAddress}`);
|
|
284
|
+
return {
|
|
285
|
+
items: [],
|
|
286
|
+
pageInfo: { hasNextPage: false },
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const items = this.transformIndexerEntries(result.data.accessControlEvents.nodes);
|
|
291
|
+
const pageInfo: PageInfo = {
|
|
292
|
+
hasNextPage: result.data.accessControlEvents.pageInfo.hasNextPage,
|
|
293
|
+
endCursor: result.data.accessControlEvents.pageInfo.endCursor,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return { items, pageInfo };
|
|
297
|
+
} catch (error) {
|
|
298
|
+
logger.error(
|
|
299
|
+
LOG_SYSTEM,
|
|
300
|
+
`Failed to query indexer history: ${error instanceof Error ? error.message : String(error)}`
|
|
301
|
+
);
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Discover all unique role identifiers for a contract by querying historical events
|
|
308
|
+
*
|
|
309
|
+
* Queries all ROLE_GRANTED and ROLE_REVOKED events and extracts unique role values.
|
|
310
|
+
* This enables role enumeration even when knownRoleIds are not provided.
|
|
311
|
+
*
|
|
312
|
+
* @param contractAddress The contract address to discover roles for
|
|
313
|
+
* @returns Promise resolving to array of unique role identifiers
|
|
314
|
+
* @throws IndexerUnavailable if indexer is not available
|
|
315
|
+
* @throws OperationFailed if query fails
|
|
316
|
+
*/
|
|
317
|
+
async discoverRoleIds(contractAddress: string): Promise<string[]> {
|
|
318
|
+
const isAvailable = await this.checkAvailability();
|
|
319
|
+
if (!isAvailable) {
|
|
320
|
+
throw new IndexerUnavailable(
|
|
321
|
+
'Indexer not available for this network',
|
|
322
|
+
contractAddress,
|
|
323
|
+
this.networkConfig.id
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const endpoints = this.resolveIndexerEndpoints();
|
|
328
|
+
if (!endpoints.http) {
|
|
329
|
+
throw new ConfigurationInvalid(
|
|
330
|
+
'No indexer HTTP endpoint configured',
|
|
331
|
+
contractAddress,
|
|
332
|
+
'indexer.http'
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
logger.info(LOG_SYSTEM, `Discovering role IDs for contract ${contractAddress}`);
|
|
337
|
+
|
|
338
|
+
const query = this.buildRoleDiscoveryQuery();
|
|
339
|
+
const variables = { contract: contractAddress };
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const response = await fetch(endpoints.http, {
|
|
343
|
+
method: 'POST',
|
|
344
|
+
headers: { 'Content-Type': 'application/json' },
|
|
345
|
+
body: JSON.stringify({ query, variables }),
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (!response.ok) {
|
|
349
|
+
throw new OperationFailed(
|
|
350
|
+
`Indexer query failed with status ${response.status}`,
|
|
351
|
+
contractAddress,
|
|
352
|
+
'discoverRoleIds'
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const result = (await response.json()) as IndexerRoleDiscoveryResponse;
|
|
357
|
+
|
|
358
|
+
if (result.errors && result.errors.length > 0) {
|
|
359
|
+
const errorMessages = result.errors.map((e) => e.message).join('; ');
|
|
360
|
+
throw new OperationFailed(
|
|
361
|
+
`Indexer query errors: ${errorMessages}`,
|
|
362
|
+
contractAddress,
|
|
363
|
+
'discoverRoleIds'
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!result.data?.accessControlEvents?.nodes) {
|
|
368
|
+
logger.debug(LOG_SYSTEM, `No role events found for contract ${contractAddress}`);
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Extract unique role IDs, filtering out null/undefined values (ownership events)
|
|
373
|
+
const uniqueRoles = new Set<string>();
|
|
374
|
+
for (const node of result.data.accessControlEvents.nodes) {
|
|
375
|
+
if (node.role) {
|
|
376
|
+
uniqueRoles.add(node.role);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const roleIds = Array.from(uniqueRoles);
|
|
381
|
+
logger.info(
|
|
382
|
+
LOG_SYSTEM,
|
|
383
|
+
`Discovered ${roleIds.length} unique role(s) for ${contractAddress}`,
|
|
384
|
+
{
|
|
385
|
+
roles: roleIds,
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
return roleIds;
|
|
390
|
+
} catch (error) {
|
|
391
|
+
logger.error(
|
|
392
|
+
LOG_SYSTEM,
|
|
393
|
+
`Failed to discover role IDs: ${error instanceof Error ? error.message : String(error)}`
|
|
394
|
+
);
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Query the latest grant events for a set of members with a specific role
|
|
401
|
+
*
|
|
402
|
+
* Returns the most recent ROLE_GRANTED event for each member address.
|
|
403
|
+
* This is used to enrich role assignments with grant timestamps.
|
|
404
|
+
*
|
|
405
|
+
* @param contractAddress The contract address
|
|
406
|
+
* @param roleId The role identifier to query
|
|
407
|
+
* @param memberAddresses Array of member addresses to look up
|
|
408
|
+
* @returns Promise resolving to a Map of address -> GrantInfo
|
|
409
|
+
* @throws IndexerUnavailable if indexer is not available
|
|
410
|
+
* @throws OperationFailed if query fails
|
|
411
|
+
*/
|
|
412
|
+
async queryLatestGrants(
|
|
413
|
+
contractAddress: string,
|
|
414
|
+
roleId: string,
|
|
415
|
+
memberAddresses: string[]
|
|
416
|
+
): Promise<Map<string, GrantInfo>> {
|
|
417
|
+
if (memberAddresses.length === 0) {
|
|
418
|
+
return new Map();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const isAvailable = await this.checkAvailability();
|
|
422
|
+
if (!isAvailable) {
|
|
423
|
+
throw new IndexerUnavailable(
|
|
424
|
+
'Indexer not available for this network',
|
|
425
|
+
contractAddress,
|
|
426
|
+
this.networkConfig.id
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const endpoints = this.resolveIndexerEndpoints();
|
|
431
|
+
if (!endpoints.http) {
|
|
432
|
+
throw new ConfigurationInvalid(
|
|
433
|
+
'No indexer HTTP endpoint configured',
|
|
434
|
+
contractAddress,
|
|
435
|
+
'indexer.http'
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
logger.debug(
|
|
440
|
+
LOG_SYSTEM,
|
|
441
|
+
`Querying latest grants for ${memberAddresses.length} member(s) with role ${roleId}`
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const query = this.buildLatestGrantsQuery();
|
|
445
|
+
const variables = {
|
|
446
|
+
contract: contractAddress,
|
|
447
|
+
role: roleId,
|
|
448
|
+
accounts: memberAddresses,
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
const response = await fetch(endpoints.http, {
|
|
453
|
+
method: 'POST',
|
|
454
|
+
headers: { 'Content-Type': 'application/json' },
|
|
455
|
+
body: JSON.stringify({ query, variables }),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
if (!response.ok) {
|
|
459
|
+
throw new OperationFailed(
|
|
460
|
+
`Indexer query failed with status ${response.status}`,
|
|
461
|
+
contractAddress,
|
|
462
|
+
'queryLatestGrants'
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const result = (await response.json()) as IndexerHistoryResponse;
|
|
467
|
+
|
|
468
|
+
if (result.errors && result.errors.length > 0) {
|
|
469
|
+
const errorMessages = result.errors.map((e) => e.message).join('; ');
|
|
470
|
+
throw new OperationFailed(
|
|
471
|
+
`Indexer query errors: ${errorMessages}`,
|
|
472
|
+
contractAddress,
|
|
473
|
+
'queryLatestGrants'
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (!result.data?.accessControlEvents?.nodes) {
|
|
478
|
+
logger.debug(LOG_SYSTEM, `No grant events found for role ${roleId}`);
|
|
479
|
+
return new Map();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Build map of address -> latest grant info
|
|
483
|
+
// Since we order by TIMESTAMP_DESC, we take the first occurrence per account
|
|
484
|
+
const grantMap = new Map<string, GrantInfo>();
|
|
485
|
+
for (const entry of result.data.accessControlEvents.nodes) {
|
|
486
|
+
if (!grantMap.has(entry.account)) {
|
|
487
|
+
grantMap.set(entry.account, {
|
|
488
|
+
timestamp: entry.timestamp,
|
|
489
|
+
txId: entry.txHash,
|
|
490
|
+
ledger: parseInt(entry.blockHeight, 10),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
logger.debug(
|
|
496
|
+
LOG_SYSTEM,
|
|
497
|
+
`Found grant info for ${grantMap.size} of ${memberAddresses.length} member(s)`
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
return grantMap;
|
|
501
|
+
} catch (error) {
|
|
502
|
+
logger.error(
|
|
503
|
+
LOG_SYSTEM,
|
|
504
|
+
`Failed to query latest grants: ${error instanceof Error ? error.message : String(error)}`
|
|
505
|
+
);
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Query the latest pending ownership transfer for a contract
|
|
512
|
+
*
|
|
513
|
+
* Queries the indexer for `OWNERSHIP_TRANSFER_INITIATED` events and checks if
|
|
514
|
+
* a corresponding `OWNERSHIP_TRANSFER_COMPLETED` event exists after the initiation.
|
|
515
|
+
* If no completion event exists, returns the pending transfer details.
|
|
516
|
+
*
|
|
517
|
+
* @param contractAddress The contract address to query
|
|
518
|
+
* @returns Promise resolving to pending transfer info, or null if no pending transfer
|
|
519
|
+
* @throws IndexerUnavailable if indexer is not available
|
|
520
|
+
* @throws OperationFailed if query fails
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* ```typescript
|
|
524
|
+
* const pending = await client.queryPendingOwnershipTransfer('CABC...XYZ');
|
|
525
|
+
* if (pending) {
|
|
526
|
+
* console.log(`Pending owner: ${pending.pendingOwner}`);
|
|
527
|
+
* console.log(`Transfer started at ledger: ${pending.ledger}`);
|
|
528
|
+
* }
|
|
529
|
+
* ```
|
|
530
|
+
*/
|
|
531
|
+
async queryPendingOwnershipTransfer(
|
|
532
|
+
contractAddress: string
|
|
533
|
+
): Promise<OwnershipTransferStartedEvent | null> {
|
|
534
|
+
const isAvailable = await this.checkAvailability();
|
|
535
|
+
if (!isAvailable) {
|
|
536
|
+
throw new IndexerUnavailable(
|
|
537
|
+
'Indexer not available for this network',
|
|
538
|
+
contractAddress,
|
|
539
|
+
this.networkConfig.id
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const endpoints = this.resolveIndexerEndpoints();
|
|
544
|
+
if (!endpoints.http) {
|
|
545
|
+
throw new ConfigurationInvalid(
|
|
546
|
+
'No indexer HTTP endpoint configured',
|
|
547
|
+
contractAddress,
|
|
548
|
+
'indexer.http'
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
logger.info(LOG_SYSTEM, `Querying pending ownership transfer for ${contractAddress}`);
|
|
553
|
+
|
|
554
|
+
// Step 1: Query latest OWNERSHIP_TRANSFER_STARTED event
|
|
555
|
+
const initiationQuery = this.buildOwnershipTransferStartedQuery();
|
|
556
|
+
const initiationVariables = { contract: contractAddress };
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
const initiationResponse = await fetch(endpoints.http, {
|
|
560
|
+
method: 'POST',
|
|
561
|
+
headers: { 'Content-Type': 'application/json' },
|
|
562
|
+
body: JSON.stringify({ query: initiationQuery, variables: initiationVariables }),
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
if (!initiationResponse.ok) {
|
|
566
|
+
throw new OperationFailed(
|
|
567
|
+
`Indexer query failed with status ${initiationResponse.status}`,
|
|
568
|
+
contractAddress,
|
|
569
|
+
'queryPendingOwnershipTransfer'
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const initiationResult =
|
|
574
|
+
(await initiationResponse.json()) as IndexerOwnershipTransferResponse;
|
|
575
|
+
|
|
576
|
+
if (initiationResult.errors && initiationResult.errors.length > 0) {
|
|
577
|
+
const errorMessages = initiationResult.errors.map((e) => e.message).join('; ');
|
|
578
|
+
throw new OperationFailed(
|
|
579
|
+
`Indexer query errors: ${errorMessages}`,
|
|
580
|
+
contractAddress,
|
|
581
|
+
'queryPendingOwnershipTransfer'
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const initiatedNodes = initiationResult.data?.accessControlEvents?.nodes;
|
|
586
|
+
if (!initiatedNodes || initiatedNodes.length === 0) {
|
|
587
|
+
logger.debug(LOG_SYSTEM, `No ownership transfer initiated for ${contractAddress}`);
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const latestInitiation = initiatedNodes[0];
|
|
592
|
+
|
|
593
|
+
// Step 2: Check if a completion event exists after the initiation
|
|
594
|
+
const completionQuery = this.buildOwnershipTransferCompletedQuery();
|
|
595
|
+
const completionVariables = {
|
|
596
|
+
contract: contractAddress,
|
|
597
|
+
afterTimestamp: latestInitiation.timestamp,
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const completionResponse = await fetch(endpoints.http, {
|
|
601
|
+
method: 'POST',
|
|
602
|
+
headers: { 'Content-Type': 'application/json' },
|
|
603
|
+
body: JSON.stringify({ query: completionQuery, variables: completionVariables }),
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
if (!completionResponse.ok) {
|
|
607
|
+
throw new OperationFailed(
|
|
608
|
+
`Indexer completion query failed with status ${completionResponse.status}`,
|
|
609
|
+
contractAddress,
|
|
610
|
+
'queryPendingOwnershipTransfer'
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const completionResult =
|
|
615
|
+
(await completionResponse.json()) as IndexerOwnershipTransferResponse;
|
|
616
|
+
|
|
617
|
+
// Check for GraphQL errors in completion query (same as initiation query)
|
|
618
|
+
if (completionResult.errors && completionResult.errors.length > 0) {
|
|
619
|
+
const errorMessages = completionResult.errors.map((e) => e.message).join('; ');
|
|
620
|
+
throw new OperationFailed(
|
|
621
|
+
`Indexer completion query errors: ${errorMessages}`,
|
|
622
|
+
contractAddress,
|
|
623
|
+
'queryPendingOwnershipTransfer'
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const completedNodes = completionResult.data?.accessControlEvents?.nodes;
|
|
628
|
+
if (completedNodes && completedNodes.length > 0) {
|
|
629
|
+
// Transfer was completed after initiation - no pending transfer
|
|
630
|
+
logger.debug(LOG_SYSTEM, `Ownership transfer was completed for ${contractAddress}`);
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// No completion - validate required fields before returning pending transfer info
|
|
635
|
+
// The admin field is required for OWNERSHIP_TRANSFER_STARTED events
|
|
636
|
+
if (!latestInitiation.admin) {
|
|
637
|
+
logger.warn(
|
|
638
|
+
LOG_SYSTEM,
|
|
639
|
+
`Indexer returned OWNERSHIP_TRANSFER_STARTED event without admin field for ${contractAddress}. ` +
|
|
640
|
+
`This indicates incomplete indexer data. Treating as no valid pending transfer.`
|
|
641
|
+
);
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Validate liveUntilLedger is present (required for expiration checking)
|
|
646
|
+
if (
|
|
647
|
+
latestInitiation.liveUntilLedger === undefined ||
|
|
648
|
+
latestInitiation.liveUntilLedger === null
|
|
649
|
+
) {
|
|
650
|
+
logger.warn(
|
|
651
|
+
LOG_SYSTEM,
|
|
652
|
+
`Indexer returned OWNERSHIP_TRANSFER_STARTED event without liveUntilLedger for ${contractAddress}. ` +
|
|
653
|
+
`This may indicate an older indexer version. Treating as no valid pending transfer.`
|
|
654
|
+
);
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
logger.info(
|
|
659
|
+
LOG_SYSTEM,
|
|
660
|
+
`Found pending ownership transfer for ${contractAddress}: pending owner=${latestInitiation.account}, expires at ledger ${latestInitiation.liveUntilLedger}`
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
previousOwner: latestInitiation.admin,
|
|
665
|
+
pendingOwner: latestInitiation.account,
|
|
666
|
+
txHash: latestInitiation.txHash,
|
|
667
|
+
timestamp: latestInitiation.timestamp,
|
|
668
|
+
ledger: parseInt(latestInitiation.ledger || latestInitiation.blockHeight, 10),
|
|
669
|
+
liveUntilLedger: latestInitiation.liveUntilLedger,
|
|
670
|
+
};
|
|
671
|
+
} catch (error) {
|
|
672
|
+
if (error instanceof IndexerUnavailable || error instanceof OperationFailed) {
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
logger.error(
|
|
676
|
+
LOG_SYSTEM,
|
|
677
|
+
`Failed to query pending ownership transfer: ${error instanceof Error ? error.message : String(error)}`
|
|
678
|
+
);
|
|
679
|
+
throw new OperationFailed(
|
|
680
|
+
`Failed to query pending ownership transfer: ${(error as Error).message}`,
|
|
681
|
+
contractAddress,
|
|
682
|
+
'queryPendingOwnershipTransfer'
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Query the latest pending admin transfer for a contract
|
|
689
|
+
*
|
|
690
|
+
* Queries the indexer for `ADMIN_TRANSFER_INITIATED` events and checks if
|
|
691
|
+
* a corresponding `ADMIN_TRANSFER_COMPLETED` event exists after the initiation.
|
|
692
|
+
* If no completion event exists, returns the pending transfer details.
|
|
693
|
+
*
|
|
694
|
+
* @param contractAddress The contract address to query
|
|
695
|
+
* @returns Promise resolving to pending transfer info, or null if no pending transfer
|
|
696
|
+
* @throws IndexerUnavailable if indexer is not available
|
|
697
|
+
* @throws OperationFailed if query fails
|
|
698
|
+
*
|
|
699
|
+
* @example
|
|
700
|
+
* ```typescript
|
|
701
|
+
* const pending = await client.queryPendingAdminTransfer('CABC...XYZ');
|
|
702
|
+
* if (pending) {
|
|
703
|
+
* console.log(`Pending admin: ${pending.pendingAdmin}`);
|
|
704
|
+
* console.log(`Transfer started at ledger: ${pending.ledger}`);
|
|
705
|
+
* }
|
|
706
|
+
* ```
|
|
707
|
+
*/
|
|
708
|
+
async queryPendingAdminTransfer(
|
|
709
|
+
contractAddress: string
|
|
710
|
+
): Promise<AdminTransferInitiatedEvent | null> {
|
|
711
|
+
const isAvailable = await this.checkAvailability();
|
|
712
|
+
if (!isAvailable) {
|
|
713
|
+
throw new IndexerUnavailable(
|
|
714
|
+
'Indexer not available for this network',
|
|
715
|
+
contractAddress,
|
|
716
|
+
this.networkConfig.id
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const endpoints = this.resolveIndexerEndpoints();
|
|
721
|
+
if (!endpoints.http) {
|
|
722
|
+
throw new ConfigurationInvalid(
|
|
723
|
+
'No indexer HTTP endpoint configured',
|
|
724
|
+
contractAddress,
|
|
725
|
+
'indexer.http'
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
logger.info(LOG_SYSTEM, `Querying pending admin transfer for ${contractAddress}`);
|
|
730
|
+
|
|
731
|
+
// Step 1: Query latest ADMIN_TRANSFER_INITIATED event
|
|
732
|
+
const initiationQuery = this.buildAdminTransferInitiatedQuery();
|
|
733
|
+
const initiationVariables = { contract: contractAddress };
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
const initiationResponse = await fetch(endpoints.http, {
|
|
737
|
+
method: 'POST',
|
|
738
|
+
headers: { 'Content-Type': 'application/json' },
|
|
739
|
+
body: JSON.stringify({ query: initiationQuery, variables: initiationVariables }),
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
if (!initiationResponse.ok) {
|
|
743
|
+
throw new OperationFailed(
|
|
744
|
+
`Indexer query failed with status ${initiationResponse.status}`,
|
|
745
|
+
contractAddress,
|
|
746
|
+
'queryPendingAdminTransfer'
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const initiationResult =
|
|
751
|
+
(await initiationResponse.json()) as IndexerOwnershipTransferResponse;
|
|
752
|
+
|
|
753
|
+
if (initiationResult.errors && initiationResult.errors.length > 0) {
|
|
754
|
+
const errorMessages = initiationResult.errors.map((e) => e.message).join('; ');
|
|
755
|
+
throw new OperationFailed(
|
|
756
|
+
`Indexer query errors: ${errorMessages}`,
|
|
757
|
+
contractAddress,
|
|
758
|
+
'queryPendingAdminTransfer'
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const initiatedNodes = initiationResult.data?.accessControlEvents?.nodes;
|
|
763
|
+
if (!initiatedNodes || initiatedNodes.length === 0) {
|
|
764
|
+
logger.debug(LOG_SYSTEM, `No admin transfer initiated for ${contractAddress}`);
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const latestInitiation = initiatedNodes[0];
|
|
769
|
+
|
|
770
|
+
// Step 2: Check if a completion event exists after the initiation
|
|
771
|
+
const completionQuery = this.buildAdminTransferCompletedQuery();
|
|
772
|
+
const completionVariables = {
|
|
773
|
+
contract: contractAddress,
|
|
774
|
+
afterTimestamp: latestInitiation.timestamp,
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const completionResponse = await fetch(endpoints.http, {
|
|
778
|
+
method: 'POST',
|
|
779
|
+
headers: { 'Content-Type': 'application/json' },
|
|
780
|
+
body: JSON.stringify({ query: completionQuery, variables: completionVariables }),
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
if (!completionResponse.ok) {
|
|
784
|
+
throw new OperationFailed(
|
|
785
|
+
`Indexer completion query failed with status ${completionResponse.status}`,
|
|
786
|
+
contractAddress,
|
|
787
|
+
'queryPendingAdminTransfer'
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const completionResult =
|
|
792
|
+
(await completionResponse.json()) as IndexerOwnershipTransferResponse;
|
|
793
|
+
|
|
794
|
+
// Check for GraphQL errors in completion query
|
|
795
|
+
if (completionResult.errors && completionResult.errors.length > 0) {
|
|
796
|
+
const errorMessages = completionResult.errors.map((e) => e.message).join('; ');
|
|
797
|
+
throw new OperationFailed(
|
|
798
|
+
`Indexer completion query errors: ${errorMessages}`,
|
|
799
|
+
contractAddress,
|
|
800
|
+
'queryPendingAdminTransfer'
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const completedNodes = completionResult.data?.accessControlEvents?.nodes;
|
|
805
|
+
if (completedNodes && completedNodes.length > 0) {
|
|
806
|
+
// Transfer was completed after initiation - no pending transfer
|
|
807
|
+
logger.debug(LOG_SYSTEM, `Admin transfer was completed for ${contractAddress}`);
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// No completion - validate required fields before returning pending transfer info
|
|
812
|
+
// The admin field is required for ADMIN_TRANSFER_INITIATED events
|
|
813
|
+
if (!latestInitiation.admin) {
|
|
814
|
+
logger.warn(
|
|
815
|
+
LOG_SYSTEM,
|
|
816
|
+
`Indexer returned ADMIN_TRANSFER_INITIATED event without admin field for ${contractAddress}. ` +
|
|
817
|
+
`This indicates incomplete indexer data. Treating as no valid pending transfer.`
|
|
818
|
+
);
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Validate liveUntilLedger is present (required for expiration checking)
|
|
823
|
+
if (
|
|
824
|
+
latestInitiation.liveUntilLedger === undefined ||
|
|
825
|
+
latestInitiation.liveUntilLedger === null
|
|
826
|
+
) {
|
|
827
|
+
logger.warn(
|
|
828
|
+
LOG_SYSTEM,
|
|
829
|
+
`Indexer returned ADMIN_TRANSFER_INITIATED event without liveUntilLedger for ${contractAddress}. ` +
|
|
830
|
+
`This may indicate an older indexer version. Treating as no valid pending transfer.`
|
|
831
|
+
);
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
logger.info(
|
|
836
|
+
LOG_SYSTEM,
|
|
837
|
+
`Found pending admin transfer for ${contractAddress}: pending admin=${latestInitiation.account}, expires at ledger ${latestInitiation.liveUntilLedger}`
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
previousAdmin: latestInitiation.admin,
|
|
842
|
+
pendingAdmin: latestInitiation.account,
|
|
843
|
+
txHash: latestInitiation.txHash,
|
|
844
|
+
timestamp: latestInitiation.timestamp,
|
|
845
|
+
ledger: parseInt(latestInitiation.ledger || latestInitiation.blockHeight, 10),
|
|
846
|
+
liveUntilLedger: latestInitiation.liveUntilLedger,
|
|
847
|
+
};
|
|
848
|
+
} catch (error) {
|
|
849
|
+
if (error instanceof IndexerUnavailable || error instanceof OperationFailed) {
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
852
|
+
logger.error(
|
|
853
|
+
LOG_SYSTEM,
|
|
854
|
+
`Failed to query pending admin transfer: ${error instanceof Error ? error.message : String(error)}`
|
|
855
|
+
);
|
|
856
|
+
throw new OperationFailed(
|
|
857
|
+
`Failed to query pending admin transfer: ${(error as Error).message}`,
|
|
858
|
+
contractAddress,
|
|
859
|
+
'queryPendingAdminTransfer'
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Build GraphQL query for OWNERSHIP_TRANSFER_STARTED events
|
|
866
|
+
*
|
|
867
|
+
* Note: The OpenZeppelin Stellar contract emits `ownership_transfer` event
|
|
868
|
+
* which is indexed as `OWNERSHIP_TRANSFER_STARTED`.
|
|
869
|
+
*
|
|
870
|
+
* Schema mapping:
|
|
871
|
+
* - `account`: pending new owner
|
|
872
|
+
* - `admin`: current owner who initiated the transfer
|
|
873
|
+
* - `ledger`: block height of the event
|
|
874
|
+
* - `liveUntilLedger`: expiration ledger for the pending transfer
|
|
875
|
+
*/
|
|
876
|
+
private buildOwnershipTransferStartedQuery(): string {
|
|
877
|
+
return `
|
|
878
|
+
query GetOwnershipTransferStarted($contract: String!) {
|
|
879
|
+
accessControlEvents(
|
|
880
|
+
filter: {
|
|
881
|
+
contract: { equalTo: $contract }
|
|
882
|
+
type: { equalTo: OWNERSHIP_TRANSFER_STARTED }
|
|
883
|
+
}
|
|
884
|
+
orderBy: TIMESTAMP_DESC
|
|
885
|
+
first: 1
|
|
886
|
+
) {
|
|
887
|
+
nodes {
|
|
888
|
+
id
|
|
889
|
+
account
|
|
890
|
+
admin
|
|
891
|
+
txHash
|
|
892
|
+
timestamp
|
|
893
|
+
ledger
|
|
894
|
+
blockHeight
|
|
895
|
+
liveUntilLedger
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
`;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Build GraphQL query for OWNERSHIP_TRANSFER_COMPLETED events after a given timestamp
|
|
904
|
+
*/
|
|
905
|
+
private buildOwnershipTransferCompletedQuery(): string {
|
|
906
|
+
return `
|
|
907
|
+
query GetOwnershipTransferCompleted($contract: String!, $afterTimestamp: Datetime!) {
|
|
908
|
+
accessControlEvents(
|
|
909
|
+
filter: {
|
|
910
|
+
contract: { equalTo: $contract }
|
|
911
|
+
type: { equalTo: OWNERSHIP_TRANSFER_COMPLETED }
|
|
912
|
+
timestamp: { greaterThan: $afterTimestamp }
|
|
913
|
+
}
|
|
914
|
+
orderBy: TIMESTAMP_DESC
|
|
915
|
+
first: 1
|
|
916
|
+
) {
|
|
917
|
+
nodes {
|
|
918
|
+
id
|
|
919
|
+
txHash
|
|
920
|
+
timestamp
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
`;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Build GraphQL query for ADMIN_TRANSFER_INITIATED events
|
|
929
|
+
*
|
|
930
|
+
* Note: The OpenZeppelin Stellar contract emits `admin_transfer_initiated` event
|
|
931
|
+
* which is indexed as `ADMIN_TRANSFER_INITIATED`.
|
|
932
|
+
*
|
|
933
|
+
* Schema mapping:
|
|
934
|
+
* - `account`: pending new admin
|
|
935
|
+
* - `admin`: current admin who initiated the transfer
|
|
936
|
+
* - `ledger`: block height of the event
|
|
937
|
+
* - `liveUntilLedger`: expiration ledger for the pending transfer
|
|
938
|
+
*/
|
|
939
|
+
private buildAdminTransferInitiatedQuery(): string {
|
|
940
|
+
return `
|
|
941
|
+
query GetAdminTransferInitiated($contract: String!) {
|
|
942
|
+
accessControlEvents(
|
|
943
|
+
filter: {
|
|
944
|
+
contract: { equalTo: $contract }
|
|
945
|
+
type: { equalTo: ADMIN_TRANSFER_INITIATED }
|
|
946
|
+
}
|
|
947
|
+
orderBy: TIMESTAMP_DESC
|
|
948
|
+
first: 1
|
|
949
|
+
) {
|
|
950
|
+
nodes {
|
|
951
|
+
id
|
|
952
|
+
account
|
|
953
|
+
admin
|
|
954
|
+
txHash
|
|
955
|
+
timestamp
|
|
956
|
+
ledger
|
|
957
|
+
blockHeight
|
|
958
|
+
liveUntilLedger
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
`;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Build GraphQL query for ADMIN_TRANSFER_COMPLETED events after a given timestamp
|
|
967
|
+
*/
|
|
968
|
+
private buildAdminTransferCompletedQuery(): string {
|
|
969
|
+
return `
|
|
970
|
+
query GetAdminTransferCompleted($contract: String!, $afterTimestamp: Datetime!) {
|
|
971
|
+
accessControlEvents(
|
|
972
|
+
filter: {
|
|
973
|
+
contract: { equalTo: $contract }
|
|
974
|
+
type: { equalTo: ADMIN_TRANSFER_COMPLETED }
|
|
975
|
+
timestamp: { greaterThan: $afterTimestamp }
|
|
976
|
+
}
|
|
977
|
+
orderBy: TIMESTAMP_DESC
|
|
978
|
+
first: 1
|
|
979
|
+
) {
|
|
980
|
+
nodes {
|
|
981
|
+
id
|
|
982
|
+
txHash
|
|
983
|
+
timestamp
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
`;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Resolve indexer endpoints with config precedence
|
|
992
|
+
* Priority:
|
|
993
|
+
* 1. Runtime override from AppConfigService
|
|
994
|
+
* 2. Network config defaults (indexerUri/indexerWsUri)
|
|
995
|
+
* 3. Derived from RPC (if safe pattern exists)
|
|
996
|
+
* 4. None (returns empty object)
|
|
997
|
+
*
|
|
998
|
+
* @returns Resolved indexer endpoints
|
|
999
|
+
*/
|
|
1000
|
+
private resolveIndexerEndpoints(): IndexerEndpointConfig {
|
|
1001
|
+
if (this.resolvedEndpoints) {
|
|
1002
|
+
return this.resolvedEndpoints;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const networkId = this.networkConfig.id;
|
|
1006
|
+
const endpoints: IndexerEndpointConfig = {};
|
|
1007
|
+
|
|
1008
|
+
// Priority 1: Check AppConfigService for runtime override
|
|
1009
|
+
const indexerOverride = appConfigService.getIndexerEndpointOverride(networkId);
|
|
1010
|
+
if (indexerOverride) {
|
|
1011
|
+
if (typeof indexerOverride === 'string') {
|
|
1012
|
+
endpoints.http = indexerOverride;
|
|
1013
|
+
logger.info(
|
|
1014
|
+
LOG_SYSTEM,
|
|
1015
|
+
`Using runtime indexer override for ${networkId}: ${indexerOverride}`
|
|
1016
|
+
);
|
|
1017
|
+
} else if (typeof indexerOverride === 'object') {
|
|
1018
|
+
if ('http' in indexerOverride && indexerOverride.http) {
|
|
1019
|
+
endpoints.http = indexerOverride.http;
|
|
1020
|
+
}
|
|
1021
|
+
if ('ws' in indexerOverride && indexerOverride.ws) {
|
|
1022
|
+
endpoints.ws = indexerOverride.ws;
|
|
1023
|
+
}
|
|
1024
|
+
logger.info(
|
|
1025
|
+
LOG_SYSTEM,
|
|
1026
|
+
`Using runtime indexer override for ${networkId}: http=${endpoints.http}, ws=${endpoints.ws}`
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
this.resolvedEndpoints = endpoints;
|
|
1030
|
+
return endpoints;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Priority 2: Network config defaults
|
|
1034
|
+
if (this.networkConfig.indexerUri) {
|
|
1035
|
+
endpoints.http = this.networkConfig.indexerUri;
|
|
1036
|
+
logger.info(
|
|
1037
|
+
LOG_SYSTEM,
|
|
1038
|
+
`Using network config indexer URI for ${networkId}: ${endpoints.http}`
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
if (this.networkConfig.indexerWsUri) {
|
|
1042
|
+
endpoints.ws = this.networkConfig.indexerWsUri;
|
|
1043
|
+
logger.debug(
|
|
1044
|
+
LOG_SYSTEM,
|
|
1045
|
+
`Using network config indexer WS URI for ${networkId}: ${endpoints.ws}`
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (endpoints.http || endpoints.ws) {
|
|
1050
|
+
this.resolvedEndpoints = endpoints;
|
|
1051
|
+
return endpoints;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Priority 3: Derive from RPC (only if safe, known pattern exists)
|
|
1055
|
+
// Currently DISABLED - no safe derivation pattern implemented
|
|
1056
|
+
// This would be enabled in the future when indexer/RPC relationship is well-defined
|
|
1057
|
+
logger.debug(LOG_SYSTEM, `No indexer derivation pattern available for ${networkId}`);
|
|
1058
|
+
|
|
1059
|
+
// Priority 4: None - no indexer configured
|
|
1060
|
+
logger.debug(LOG_SYSTEM, `No indexer endpoints configured for ${networkId}`);
|
|
1061
|
+
this.resolvedEndpoints = endpoints;
|
|
1062
|
+
return endpoints;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Maps internal changeType to GraphQL EventType enum
|
|
1067
|
+
* GraphQL enum values: ROLE_GRANTED, ROLE_REVOKED, OWNERSHIP_TRANSFER_STARTED,
|
|
1068
|
+
* OWNERSHIP_TRANSFER_COMPLETED, ADMIN_TRANSFER_INITIATED, ADMIN_TRANSFER_COMPLETED
|
|
1069
|
+
*/
|
|
1070
|
+
private mapChangeTypeToGraphQLEnum(changeType: HistoryChangeType): string {
|
|
1071
|
+
const mapping: Record<HistoryChangeType, string> = {
|
|
1072
|
+
GRANTED: 'ROLE_GRANTED',
|
|
1073
|
+
REVOKED: 'ROLE_REVOKED',
|
|
1074
|
+
OWNERSHIP_TRANSFER_STARTED: 'OWNERSHIP_TRANSFER_STARTED',
|
|
1075
|
+
OWNERSHIP_TRANSFER_COMPLETED: 'OWNERSHIP_TRANSFER_COMPLETED',
|
|
1076
|
+
ADMIN_TRANSFER_INITIATED: 'ADMIN_TRANSFER_INITIATED',
|
|
1077
|
+
ADMIN_TRANSFER_COMPLETED: 'ADMIN_TRANSFER_COMPLETED',
|
|
1078
|
+
};
|
|
1079
|
+
return mapping[changeType];
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Build GraphQL query for history with SubQuery filtering and pagination
|
|
1084
|
+
*/
|
|
1085
|
+
private buildHistoryQuery(_contractAddress: string, options?: HistoryQueryOptions): string {
|
|
1086
|
+
const roleFilter = options?.roleId ? ', role: { equalTo: $role }' : '';
|
|
1087
|
+
const accountFilter = options?.account ? ', account: { equalTo: $account }' : '';
|
|
1088
|
+
// Type filter uses inline enum value (consistent with buildLatestGrantsQuery pattern)
|
|
1089
|
+
const typeFilter = options?.changeType
|
|
1090
|
+
? `, type: { equalTo: ${this.mapChangeTypeToGraphQLEnum(options.changeType)} }`
|
|
1091
|
+
: '';
|
|
1092
|
+
const txFilter = options?.txId ? ', txHash: { equalTo: $txHash }' : '';
|
|
1093
|
+
// Build combined timestamp filter to avoid duplicate keys
|
|
1094
|
+
const timestampConditions: string[] = [];
|
|
1095
|
+
if (options?.timestampFrom) {
|
|
1096
|
+
timestampConditions.push('greaterThanOrEqualTo: $timestampFrom');
|
|
1097
|
+
}
|
|
1098
|
+
if (options?.timestampTo) {
|
|
1099
|
+
timestampConditions.push('lessThanOrEqualTo: $timestampTo');
|
|
1100
|
+
}
|
|
1101
|
+
const timestampFilter =
|
|
1102
|
+
timestampConditions.length > 0 ? `, timestamp: { ${timestampConditions.join(', ')} }` : '';
|
|
1103
|
+
const ledgerFilter = options?.ledger ? ', blockHeight: { equalTo: $blockHeight }' : '';
|
|
1104
|
+
const limitClause = options?.limit ? ', first: $limit' : '';
|
|
1105
|
+
const cursorClause = options?.cursor ? ', after: $cursor' : '';
|
|
1106
|
+
|
|
1107
|
+
// Build variable declarations
|
|
1108
|
+
// Note: SubQuery uses Datetime for timestamp filters and BigFloat for blockHeight filtering
|
|
1109
|
+
const varDeclarations = [
|
|
1110
|
+
'$contract: String!',
|
|
1111
|
+
options?.roleId ? '$role: String' : '',
|
|
1112
|
+
options?.account ? '$account: String' : '',
|
|
1113
|
+
options?.txId ? '$txHash: String' : '',
|
|
1114
|
+
options?.timestampFrom ? '$timestampFrom: Datetime' : '',
|
|
1115
|
+
options?.timestampTo ? '$timestampTo: Datetime' : '',
|
|
1116
|
+
options?.ledger ? '$blockHeight: BigFloat' : '',
|
|
1117
|
+
options?.limit ? '$limit: Int' : '',
|
|
1118
|
+
options?.cursor ? '$cursor: Cursor' : '',
|
|
1119
|
+
]
|
|
1120
|
+
.filter(Boolean)
|
|
1121
|
+
.join(', ');
|
|
1122
|
+
|
|
1123
|
+
return `
|
|
1124
|
+
query GetHistory(${varDeclarations}) {
|
|
1125
|
+
accessControlEvents(
|
|
1126
|
+
filter: {
|
|
1127
|
+
contract: { equalTo: $contract }${roleFilter}${accountFilter}${typeFilter}${txFilter}${timestampFilter}${ledgerFilter}
|
|
1128
|
+
}
|
|
1129
|
+
orderBy: TIMESTAMP_DESC${limitClause}${cursorClause}
|
|
1130
|
+
) {
|
|
1131
|
+
nodes {
|
|
1132
|
+
id
|
|
1133
|
+
role
|
|
1134
|
+
account
|
|
1135
|
+
type
|
|
1136
|
+
txHash
|
|
1137
|
+
timestamp
|
|
1138
|
+
blockHeight
|
|
1139
|
+
}
|
|
1140
|
+
pageInfo {
|
|
1141
|
+
hasNextPage
|
|
1142
|
+
endCursor
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
`;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Build query variables including pagination cursor
|
|
1151
|
+
*/
|
|
1152
|
+
private buildQueryVariables(
|
|
1153
|
+
contractAddress: string,
|
|
1154
|
+
options?: HistoryQueryOptions
|
|
1155
|
+
): Record<string, unknown> {
|
|
1156
|
+
const variables: Record<string, unknown> = {
|
|
1157
|
+
contract: contractAddress,
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
if (options?.roleId) {
|
|
1161
|
+
variables.role = options.roleId;
|
|
1162
|
+
}
|
|
1163
|
+
if (options?.account) {
|
|
1164
|
+
variables.account = options.account;
|
|
1165
|
+
}
|
|
1166
|
+
if (options?.txId) {
|
|
1167
|
+
variables.txHash = options.txId;
|
|
1168
|
+
}
|
|
1169
|
+
if (options?.timestampFrom) {
|
|
1170
|
+
variables.timestampFrom = options.timestampFrom;
|
|
1171
|
+
}
|
|
1172
|
+
if (options?.timestampTo) {
|
|
1173
|
+
variables.timestampTo = options.timestampTo;
|
|
1174
|
+
}
|
|
1175
|
+
if (options?.ledger) {
|
|
1176
|
+
// GraphQL expects blockHeight as string
|
|
1177
|
+
variables.blockHeight = String(options.ledger);
|
|
1178
|
+
}
|
|
1179
|
+
if (options?.limit) {
|
|
1180
|
+
variables.limit = options.limit;
|
|
1181
|
+
}
|
|
1182
|
+
if (options?.cursor) {
|
|
1183
|
+
variables.cursor = options.cursor;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
return variables;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Build GraphQL query for role discovery
|
|
1191
|
+
* Queries all ROLE_GRANTED and ROLE_REVOKED events to extract unique role identifiers
|
|
1192
|
+
* Note: EventType is a GraphQL enum, so values must not be quoted
|
|
1193
|
+
*/
|
|
1194
|
+
private buildRoleDiscoveryQuery(): string {
|
|
1195
|
+
return `
|
|
1196
|
+
query DiscoverRoles($contract: String!) {
|
|
1197
|
+
accessControlEvents(
|
|
1198
|
+
filter: {
|
|
1199
|
+
contract: { equalTo: $contract }
|
|
1200
|
+
type: { in: [ROLE_GRANTED, ROLE_REVOKED] }
|
|
1201
|
+
}
|
|
1202
|
+
) {
|
|
1203
|
+
nodes {
|
|
1204
|
+
role
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
`;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Build GraphQL query for latest grants
|
|
1213
|
+
* Queries ROLE_GRANTED events for a specific role and set of accounts
|
|
1214
|
+
* Ordered by timestamp descending so first occurrence per account is the latest
|
|
1215
|
+
*/
|
|
1216
|
+
private buildLatestGrantsQuery(): string {
|
|
1217
|
+
return `
|
|
1218
|
+
query LatestGrants($contract: String!, $role: String!, $accounts: [String!]!) {
|
|
1219
|
+
accessControlEvents(
|
|
1220
|
+
filter: {
|
|
1221
|
+
contract: { equalTo: $contract }
|
|
1222
|
+
role: { equalTo: $role }
|
|
1223
|
+
account: { in: $accounts }
|
|
1224
|
+
type: { equalTo: ROLE_GRANTED }
|
|
1225
|
+
}
|
|
1226
|
+
orderBy: TIMESTAMP_DESC
|
|
1227
|
+
) {
|
|
1228
|
+
nodes {
|
|
1229
|
+
account
|
|
1230
|
+
txHash
|
|
1231
|
+
timestamp
|
|
1232
|
+
blockHeight
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
`;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Transform indexer entries to standard HistoryEntry format
|
|
1241
|
+
*/
|
|
1242
|
+
private transformIndexerEntries(entries: IndexerHistoryEntry[]): HistoryEntry[] {
|
|
1243
|
+
return entries.map((entry) => {
|
|
1244
|
+
const role: RoleIdentifier = {
|
|
1245
|
+
id: entry.role || 'OWNER', // Map ownership events to special role
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
// Map SubQuery event types to internal types
|
|
1249
|
+
let changeType: HistoryChangeType;
|
|
1250
|
+
switch (entry.type) {
|
|
1251
|
+
case 'ROLE_GRANTED':
|
|
1252
|
+
changeType = 'GRANTED';
|
|
1253
|
+
break;
|
|
1254
|
+
case 'ROLE_REVOKED':
|
|
1255
|
+
changeType = 'REVOKED';
|
|
1256
|
+
break;
|
|
1257
|
+
case 'OWNERSHIP_TRANSFER_STARTED':
|
|
1258
|
+
changeType = 'OWNERSHIP_TRANSFER_STARTED';
|
|
1259
|
+
break;
|
|
1260
|
+
case 'OWNERSHIP_TRANSFER_COMPLETED':
|
|
1261
|
+
changeType = 'OWNERSHIP_TRANSFER_COMPLETED';
|
|
1262
|
+
break;
|
|
1263
|
+
case 'ADMIN_TRANSFER_INITIATED':
|
|
1264
|
+
changeType = 'ADMIN_TRANSFER_INITIATED';
|
|
1265
|
+
break;
|
|
1266
|
+
case 'ADMIN_TRANSFER_COMPLETED':
|
|
1267
|
+
changeType = 'ADMIN_TRANSFER_COMPLETED';
|
|
1268
|
+
break;
|
|
1269
|
+
default:
|
|
1270
|
+
// Use UNKNOWN for unrecognized types to make indexer schema issues visible
|
|
1271
|
+
logger.warn(
|
|
1272
|
+
LOG_SYSTEM,
|
|
1273
|
+
`Unknown event type: ${entry.type}, assigning changeType to UNKNOWN`
|
|
1274
|
+
);
|
|
1275
|
+
changeType = 'UNKNOWN';
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
return {
|
|
1279
|
+
role,
|
|
1280
|
+
account: entry.account,
|
|
1281
|
+
changeType,
|
|
1282
|
+
txId: entry.txHash,
|
|
1283
|
+
timestamp: entry.timestamp,
|
|
1284
|
+
ledger: parseInt(entry.blockHeight, 10),
|
|
1285
|
+
};
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Factory function to create an indexer client for a network
|
|
1292
|
+
* @param networkConfig The Stellar network configuration
|
|
1293
|
+
* @returns A new indexer client instance
|
|
1294
|
+
*/
|
|
1295
|
+
export function createIndexerClient(networkConfig: StellarNetworkConfig): StellarIndexerClient {
|
|
1296
|
+
return new StellarIndexerClient(networkConfig);
|
|
1297
|
+
}
|