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