@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.
- package/README.md +272 -0
- package/dist/config.cjs +21 -0
- package/dist/config.cjs.map +1 -0
- package/dist/config.d.cts +8 -0
- package/dist/config.d.cts.map +1 -0
- package/dist/config.d.mts +8 -0
- package/dist/config.d.mts.map +1 -0
- package/dist/config.mjs +20 -0
- package/dist/config.mjs.map +1 -0
- package/dist/index.cjs +7564 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +261 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +263 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +7529 -0
- package/dist/index.mjs.map +1 -0
- package/dist/metadata.cjs +22 -0
- package/dist/metadata.cjs.map +1 -0
- package/dist/metadata.d.cts +7 -0
- package/dist/metadata.d.cts.map +1 -0
- package/dist/metadata.d.mts +7 -0
- package/dist/metadata.d.mts.map +1 -0
- package/dist/metadata.mjs +21 -0
- package/dist/metadata.mjs.map +1 -0
- package/dist/networks-BrV516-R.d.cts +15 -0
- package/dist/networks-BrV516-R.d.cts.map +1 -0
- package/dist/networks-C0MmhJcu.d.mts +15 -0
- package/dist/networks-C0MmhJcu.d.mts.map +1 -0
- package/dist/networks-DgUFSTiC.cjs +76 -0
- package/dist/networks-DgUFSTiC.cjs.map +1 -0
- package/dist/networks-QbEPbaGT.mjs +46 -0
- package/dist/networks-QbEPbaGT.mjs.map +1 -0
- package/dist/networks.cjs +8 -0
- package/dist/networks.d.cts +2 -0
- package/dist/networks.d.mts +2 -0
- package/dist/networks.mjs +3 -0
- package/dist/vite-config.cjs +43 -0
- package/dist/vite-config.cjs.map +1 -0
- package/dist/vite-config.d.cts +35 -0
- package/dist/vite-config.d.cts.map +1 -0
- package/dist/vite-config.d.mts +35 -0
- package/dist/vite-config.d.mts.map +1 -0
- package/dist/vite-config.mjs +42 -0
- package/dist/vite-config.mjs.map +1 -0
- package/package.json +114 -0
- package/src/__tests__/getDefaultServiceConfig.test.ts +105 -0
- package/src/access-control/actions.ts +214 -0
- package/src/access-control/feature-detection.ts +238 -0
- package/src/access-control/index.ts +54 -0
- package/src/access-control/indexer-client.ts +1474 -0
- package/src/access-control/onchain-reader.ts +446 -0
- package/src/access-control/service.ts +1431 -0
- package/src/access-control/validation.ts +256 -0
- package/src/adapter.ts +659 -0
- package/src/config.ts +43 -0
- package/src/configuration/__tests__/explorer.test.ts +80 -0
- package/src/configuration/__tests__/rpc.test.ts +355 -0
- package/src/configuration/execution.ts +83 -0
- package/src/configuration/explorer.ts +105 -0
- package/src/configuration/index.ts +5 -0
- package/src/configuration/network-services.ts +210 -0
- package/src/configuration/rpc.ts +270 -0
- package/src/configuration.ts +2 -0
- package/src/contract/__tests__/complete-type-coverage.test.ts +78 -0
- package/src/contract/index.ts +3 -0
- package/src/contract/loader.ts +498 -0
- package/src/contract/transformer.ts +1 -0
- package/src/contract/type.ts +65 -0
- package/src/index.ts +23 -0
- package/src/mapping/constants.ts +89 -0
- package/src/mapping/enum-metadata.ts +237 -0
- package/src/mapping/field-generator.ts +296 -0
- package/src/mapping/index.ts +5 -0
- package/src/mapping/struct-fields.ts +106 -0
- package/src/mapping/tuple-components.ts +43 -0
- package/src/mapping/type-coverage-validator.ts +151 -0
- package/src/mapping/type-mapper.ts +203 -0
- package/src/metadata.ts +16 -0
- package/src/networks/README.md +84 -0
- package/src/networks/index.ts +19 -0
- package/src/networks/mainnet.ts +20 -0
- package/src/networks/testnet.ts +20 -0
- package/src/networks.ts +2 -0
- package/src/query/handler.ts +411 -0
- package/src/query/index.ts +4 -0
- package/src/query/view-checker.ts +32 -0
- package/src/sac/spec-cache.ts +68 -0
- package/src/sac/spec-source.ts +35 -0
- package/src/sac/xdr.ts +101 -0
- package/src/transaction/components/AdvancedInfo.tsx +34 -0
- package/src/transaction/components/FeeConfiguration.tsx +41 -0
- package/src/transaction/components/StellarRelayerOptions.tsx +60 -0
- package/src/transaction/components/TransactionTiming.tsx +77 -0
- package/src/transaction/components/index.ts +5 -0
- package/src/transaction/components/useStellarRelayerOptions.ts +114 -0
- package/src/transaction/eoa.ts +229 -0
- package/src/transaction/execution-strategy.ts +33 -0
- package/src/transaction/formatter.ts +296 -0
- package/src/transaction/index.ts +4 -0
- package/src/transaction/relayer.ts +575 -0
- package/src/transaction/sender.ts +156 -0
- package/src/transform/index.ts +4 -0
- package/src/transform/input-parser.ts +9 -0
- package/src/transform/output-formatter.ts +133 -0
- package/src/transform/parsers/complex-parser.ts +157 -0
- package/src/transform/parsers/generic-parser.ts +171 -0
- package/src/transform/parsers/index.ts +86 -0
- package/src/transform/parsers/primitive-parser.ts +123 -0
- package/src/transform/parsers/scval-converter.ts +405 -0
- package/src/transform/parsers/struct-parser.ts +324 -0
- package/src/transform/parsers/types.ts +35 -0
- package/src/types/__tests__/artifacts.test.ts +89 -0
- package/src/types/artifacts.ts +19 -0
- package/src/utils/__tests__/artifacts.test.ts +77 -0
- package/src/utils/artifacts.ts +30 -0
- package/src/utils/formatting.ts +122 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/input-parsing.ts +336 -0
- package/src/utils/safe-type-parser.ts +303 -0
- package/src/utils/stellar-types.ts +35 -0
- package/src/utils/type-detection.ts +163 -0
- package/src/utils/xdr-ordering.ts +36 -0
- package/src/validation/__tests__/address.test.ts +267 -0
- package/src/validation/address.ts +136 -0
- package/src/validation/eoa.ts +33 -0
- package/src/validation/index.ts +3 -0
- package/src/validation/relayer.ts +13 -0
- package/src/vite-config.ts +67 -0
- package/src/wallet/README.md +93 -0
- package/src/wallet/__tests__/connection.test.ts +72 -0
- package/src/wallet/components/StellarWalletUiRoot.tsx +161 -0
- package/src/wallet/components/account/AccountDisplay.tsx +50 -0
- package/src/wallet/components/connect/ConnectButton.tsx +100 -0
- package/src/wallet/components/connect/ConnectorDialog.tsx +125 -0
- package/src/wallet/components/index.ts +3 -0
- package/src/wallet/connection.ts +151 -0
- package/src/wallet/context/StellarWalletContext.ts +32 -0
- package/src/wallet/context/index.ts +4 -0
- package/src/wallet/context/useStellarWalletContext.ts +17 -0
- package/src/wallet/hooks/facade-hooks.ts +31 -0
- package/src/wallet/hooks/index.ts +7 -0
- package/src/wallet/hooks/useStellarAccount.ts +27 -0
- package/src/wallet/hooks/useStellarConnect.ts +60 -0
- package/src/wallet/hooks/useStellarDisconnect.ts +47 -0
- package/src/wallet/hooks/useUiKitConfig.ts +40 -0
- package/src/wallet/implementation/wallets-kit-implementation.ts +379 -0
- package/src/wallet/index.ts +11 -0
- package/src/wallet/services/__tests__/configResolutionService.test.ts +163 -0
- package/src/wallet/services/configResolutionService.ts +65 -0
- package/src/wallet/stellar-wallets-kit/StellarWalletsKitConnectButton.tsx +82 -0
- package/src/wallet/stellar-wallets-kit/__mocks__/@creit.tech/stellar-wallets-kit.ts +48 -0
- package/src/wallet/stellar-wallets-kit/__tests__/export-service.test.ts +93 -0
- package/src/wallet/stellar-wallets-kit/__tests__/stellarUiKitManager.test.ts +0 -0
- package/src/wallet/stellar-wallets-kit/config-generator.ts +75 -0
- package/src/wallet/stellar-wallets-kit/export-service.ts +19 -0
- package/src/wallet/stellar-wallets-kit/index.ts +3 -0
- package/src/wallet/stellar-wallets-kit/stellarUiKitManager.ts +235 -0
- package/src/wallet/types.ts +19 -0
- package/src/wallet/utils/__tests__/filterWalletComponents.test.ts +150 -0
- package/src/wallet/utils/__tests__/uiKitService.test.ts +189 -0
- package/src/wallet/utils/filterWalletComponents.ts +89 -0
- package/src/wallet/utils/index.ts +3 -0
- package/src/wallet/utils/stellarWalletImplementationManager.ts +118 -0
- 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
|
+
}
|