@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.
@@ -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
+ }