@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,1431 @@
1
+ /**
2
+ * Stellar Access Control Service
3
+ *
4
+ * Implements the AccessControlService interface for Stellar (Soroban) contracts.
5
+ * Provides methods to inspect and manage access control (Ownable/AccessControl) on contracts.
6
+ */
7
+
8
+ import type {
9
+ AccessControlCapabilities,
10
+ AccessControlService,
11
+ AccessSnapshot,
12
+ AdminInfo,
13
+ ContractSchema,
14
+ EnrichedRoleAssignment,
15
+ EnrichedRoleMember,
16
+ ExecutionConfig,
17
+ ExpirationMetadata,
18
+ HistoryQueryOptions,
19
+ OperationResult,
20
+ OwnershipInfo,
21
+ PaginatedHistoryResult,
22
+ RoleAssignment,
23
+ StellarNetworkConfig,
24
+ TransactionStatusUpdate,
25
+ TxStatus,
26
+ } from '@openzeppelin/ui-types';
27
+ import { ConfigurationInvalid, OperationFailed } from '@openzeppelin/ui-types';
28
+ import { logger, validateSnapshot } from '@openzeppelin/ui-utils';
29
+
30
+ import { signAndBroadcastStellarTransaction } from '../transaction/sender';
31
+ import {
32
+ assembleAcceptAdminTransferAction,
33
+ assembleAcceptOwnershipAction,
34
+ assembleGrantRoleAction,
35
+ assembleRevokeRoleAction,
36
+ assembleTransferAdminRoleAction,
37
+ assembleTransferOwnershipAction,
38
+ } from './actions';
39
+ import { detectAccessControlCapabilities } from './feature-detection';
40
+ import { createIndexerClient, StellarIndexerClient } from './indexer-client';
41
+ import { getAdmin, getCurrentLedger, readCurrentRoles, readOwnership } from './onchain-reader';
42
+ import {
43
+ validateAccountAddress,
44
+ validateAddress,
45
+ validateContractAddress,
46
+ validateExpirationLedger,
47
+ validateRoleIds,
48
+ } from './validation';
49
+
50
+ /**
51
+ * Context for Stellar Access Control operations
52
+ * Stores contract schema and role information for a specific contract
53
+ */
54
+ interface StellarAccessControlContext {
55
+ contractSchema: ContractSchema;
56
+ /** Role IDs explicitly provided via registerContract() */
57
+ knownRoleIds?: string[];
58
+ /** Role IDs discovered via indexer query (cached) */
59
+ discoveredRoleIds?: string[];
60
+ /** Flag to prevent repeated discovery attempts when indexer is unavailable */
61
+ roleDiscoveryAttempted?: boolean;
62
+ }
63
+
64
+ /**
65
+ * Stellar implementation of AccessControlService
66
+ */
67
+ export class StellarAccessControlService implements AccessControlService {
68
+ private readonly contractContexts = new Map<string, StellarAccessControlContext>();
69
+ private readonly indexerClient: StellarIndexerClient;
70
+
71
+ constructor(private readonly networkConfig: StellarNetworkConfig) {
72
+ this.indexerClient = createIndexerClient(networkConfig);
73
+ }
74
+
75
+ /**
76
+ * Registers a contract with its schema and known roles
77
+ *
78
+ * @param contractAddress The contract address
79
+ * @param contractSchema The contract schema (required for capability detection)
80
+ * @param knownRoleIds Optional array of known role identifiers
81
+ * @throws ConfigurationInvalid if the contract address or role IDs are invalid
82
+ */
83
+ registerContract(
84
+ contractAddress: string,
85
+ contractSchema: ContractSchema,
86
+ knownRoleIds?: string[]
87
+ ): void {
88
+ // Validate contract address
89
+ validateContractAddress(contractAddress);
90
+
91
+ // Validate and deduplicate role IDs if provided
92
+ const validatedRoleIds = knownRoleIds ? validateRoleIds(knownRoleIds) : undefined;
93
+
94
+ this.contractContexts.set(contractAddress, {
95
+ contractSchema,
96
+ knownRoleIds: validatedRoleIds,
97
+ });
98
+
99
+ logger.debug('StellarAccessControlService.registerContract', `Registered ${contractAddress}`, {
100
+ roleCount: validatedRoleIds?.length || 0,
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Adds additional known role IDs to a registered contract
106
+ *
107
+ * This method allows consumers to manually add role IDs that may not have been
108
+ * discovered via the indexer (e.g., newly created roles that haven't been granted yet).
109
+ * Role IDs are validated and deduplicated before being added.
110
+ *
111
+ * @param contractAddress The contract address
112
+ * @param roleIds Array of role identifiers to add
113
+ * @throws ConfigurationInvalid if the contract address is invalid, contract not registered, or role IDs are invalid
114
+ * @returns The updated array of all known role IDs for the contract
115
+ */
116
+ addKnownRoleIds(contractAddress: string, roleIds: string[]): string[] {
117
+ // Validate contract address
118
+ validateContractAddress(contractAddress);
119
+
120
+ const context = this.contractContexts.get(contractAddress);
121
+ if (!context) {
122
+ throw new ConfigurationInvalid(
123
+ 'Contract not registered. Call registerContract() first.',
124
+ contractAddress,
125
+ 'contractAddress'
126
+ );
127
+ }
128
+
129
+ // Validate the new role IDs
130
+ const validatedNewRoleIds = validateRoleIds(roleIds);
131
+
132
+ if (validatedNewRoleIds.length === 0) {
133
+ logger.debug(
134
+ 'StellarAccessControlService.addKnownRoleIds',
135
+ `No valid role IDs to add for ${contractAddress}`
136
+ );
137
+ return context.knownRoleIds || context.discoveredRoleIds || [];
138
+ }
139
+
140
+ // Merge with existing role IDs (prioritize knownRoleIds over discoveredRoleIds)
141
+ const existingRoleIds = context.knownRoleIds || context.discoveredRoleIds || [];
142
+ const mergedRoleIds = [...new Set([...existingRoleIds, ...validatedNewRoleIds])];
143
+
144
+ // Update the context - always store as knownRoleIds since user is explicitly providing them
145
+ context.knownRoleIds = mergedRoleIds;
146
+
147
+ logger.info(
148
+ 'StellarAccessControlService.addKnownRoleIds',
149
+ `Added ${validatedNewRoleIds.length} role ID(s) for ${contractAddress}`,
150
+ {
151
+ added: validatedNewRoleIds,
152
+ total: mergedRoleIds.length,
153
+ }
154
+ );
155
+
156
+ return mergedRoleIds;
157
+ }
158
+
159
+ /**
160
+ * Gets the access control capabilities of a contract
161
+ *
162
+ * @param contractAddress The contract address
163
+ * @returns Promise resolving to capabilities
164
+ * @throws ConfigurationInvalid if the contract address is invalid or contract not registered
165
+ */
166
+ async getCapabilities(contractAddress: string): Promise<AccessControlCapabilities> {
167
+ // Validate contract address
168
+ validateContractAddress(contractAddress);
169
+
170
+ logger.info(
171
+ 'StellarAccessControlService.getCapabilities',
172
+ `Detecting capabilities for ${contractAddress}`
173
+ );
174
+
175
+ const context = this.contractContexts.get(contractAddress);
176
+ if (!context) {
177
+ throw new ConfigurationInvalid(
178
+ 'Contract not registered. Call registerContract() first.',
179
+ contractAddress,
180
+ 'contractAddress'
181
+ );
182
+ }
183
+
184
+ // Check if indexer is configured
185
+ const indexerAvailable = await this.indexerClient.checkAvailability();
186
+
187
+ const capabilities = detectAccessControlCapabilities(context.contractSchema, indexerAvailable);
188
+
189
+ logger.debug('StellarAccessControlService.getCapabilities', 'Detected capabilities:', {
190
+ hasOwnable: capabilities.hasOwnable,
191
+ hasAccessControl: capabilities.hasAccessControl,
192
+ hasEnumerableRoles: capabilities.hasEnumerableRoles,
193
+ supportsHistory: capabilities.supportsHistory,
194
+ verifiedAgainstOZInterfaces: capabilities.verifiedAgainstOZInterfaces,
195
+ });
196
+
197
+ return capabilities;
198
+ }
199
+
200
+ /**
201
+ * Gets the current owner and ownership state of an Ownable contract
202
+ *
203
+ * Retrieves the current owner via on-chain query, then checks for pending
204
+ * two-step transfers via indexer to determine the ownership state:
205
+ * - 'owned': Has owner, no pending transfer
206
+ * - 'pending': Has owner, pending transfer not yet expired
207
+ * - 'expired': Has owner, pending transfer has expired
208
+ * - 'renounced': No owner (null)
209
+ *
210
+ * Gracefully degrades when indexer is unavailable, returning basic ownership
211
+ * info with 'owned' state and logging a warning.
212
+ *
213
+ * @param contractAddress The contract address
214
+ * @returns Promise resolving to ownership information with state
215
+ * @throws ConfigurationInvalid if the contract address is invalid
216
+ *
217
+ * @example
218
+ * ```typescript
219
+ * const ownership = await service.getOwnership(contractAddress);
220
+ *
221
+ * console.log('Owner:', ownership.owner);
222
+ * console.log('State:', ownership.state); // 'owned' | 'pending' | 'expired' | 'renounced'
223
+ *
224
+ * if (ownership.state === 'pending' && ownership.pendingTransfer) {
225
+ * console.log('Pending owner:', ownership.pendingTransfer.pendingOwner);
226
+ * console.log('Expires at ledger:', ownership.pendingTransfer.expirationBlock);
227
+ * }
228
+ * ```
229
+ */
230
+ async getOwnership(contractAddress: string): Promise<OwnershipInfo> {
231
+ // Validate contract address
232
+ validateContractAddress(contractAddress);
233
+
234
+ // T025: INFO logging for ownership queries per NFR-004
235
+ logger.info(
236
+ 'StellarAccessControlService.getOwnership',
237
+ `Reading ownership status for ${contractAddress}`
238
+ );
239
+
240
+ // Defense-in-depth: check capabilities before calling get_owner()
241
+ // Only applies when contract is registered (context available for schema-based detection)
242
+ const context = this.contractContexts.get(contractAddress);
243
+ if (context) {
244
+ const capabilities = detectAccessControlCapabilities(context.contractSchema);
245
+ if (!capabilities.hasOwnable) {
246
+ throw new OperationFailed(
247
+ 'Contract does not implement the Ownable interface — no get_owner() function available',
248
+ contractAddress,
249
+ 'getOwnership'
250
+ );
251
+ }
252
+ }
253
+
254
+ // T020: Call get_owner() for current owner
255
+ const basicOwnership = await readOwnership(contractAddress, this.networkConfig);
256
+
257
+ // T018/T023: Renounced state - owner is null
258
+ if (basicOwnership.owner === null) {
259
+ logger.debug(
260
+ 'StellarAccessControlService.getOwnership',
261
+ `Contract ${contractAddress} has renounced ownership`
262
+ );
263
+ return {
264
+ owner: null,
265
+ state: 'renounced',
266
+ };
267
+ }
268
+
269
+ // T024/T019: Check indexer availability for pending transfer detection
270
+ const indexerAvailable = await this.indexerClient.checkAvailability();
271
+
272
+ if (!indexerAvailable) {
273
+ // T026: WARN logging for indexer unavailability per NFR-006
274
+ logger.warn(
275
+ 'StellarAccessControlService.getOwnership',
276
+ `Indexer unavailable for ${this.networkConfig.id}: pending transfer status cannot be determined`
277
+ );
278
+ // T024: Graceful degradation - return basic ownership with 'owned' state
279
+ return {
280
+ owner: basicOwnership.owner,
281
+ state: 'owned',
282
+ };
283
+ }
284
+
285
+ // T021: Query indexer for pending transfer (includes liveUntilLedger)
286
+ let pendingTransfer;
287
+ try {
288
+ pendingTransfer = await this.indexerClient.queryPendingOwnershipTransfer(contractAddress);
289
+ } catch (error) {
290
+ // T026: Graceful degradation on indexer query error
291
+ logger.warn(
292
+ 'StellarAccessControlService.getOwnership',
293
+ `Failed to query pending transfer: ${error instanceof Error ? error.message : String(error)}`
294
+ );
295
+ return {
296
+ owner: basicOwnership.owner,
297
+ state: 'owned',
298
+ };
299
+ }
300
+
301
+ // T015/T023: No pending transfer in indexer - state is 'owned'
302
+ if (!pendingTransfer) {
303
+ logger.debug(
304
+ 'StellarAccessControlService.getOwnership',
305
+ `Contract ${contractAddress} has owner with no pending transfer`
306
+ );
307
+ return {
308
+ owner: basicOwnership.owner,
309
+ state: 'owned',
310
+ };
311
+ }
312
+
313
+ // T022: Get current ledger to check expiration
314
+ let currentLedger: number;
315
+ try {
316
+ currentLedger = await getCurrentLedger(this.networkConfig);
317
+ } catch (error) {
318
+ // Graceful degradation if ledger query fails
319
+ logger.warn(
320
+ 'StellarAccessControlService.getOwnership',
321
+ `Failed to get current ledger: ${error instanceof Error ? error.message : String(error)}`
322
+ );
323
+ // Return owned state since we can't determine expiration
324
+ return {
325
+ owner: basicOwnership.owner,
326
+ state: 'owned',
327
+ };
328
+ }
329
+
330
+ // Use indexer's liveUntilLedger for expiration check
331
+ const liveUntilLedger = pendingTransfer.liveUntilLedger;
332
+
333
+ // T022/T023: Determine state based on expiration
334
+ // Per FR-020: expirationLedger must be > currentLedger, so >= means expired
335
+ const isExpired = currentLedger >= liveUntilLedger;
336
+
337
+ // Build pending transfer info for the response
338
+ const pendingTransferInfo = {
339
+ pendingOwner: pendingTransfer.pendingOwner,
340
+ expirationBlock: liveUntilLedger,
341
+ initiatedAt: pendingTransfer.timestamp,
342
+ initiatedTxId: pendingTransfer.txHash,
343
+ initiatedBlock: pendingTransfer.ledger,
344
+ };
345
+
346
+ if (isExpired) {
347
+ // T017/T023: Expired state
348
+ logger.debug(
349
+ 'StellarAccessControlService.getOwnership',
350
+ `Contract ${contractAddress} has expired pending transfer (current: ${currentLedger}, expiration: ${liveUntilLedger})`
351
+ );
352
+ return {
353
+ owner: basicOwnership.owner,
354
+ state: 'expired',
355
+ pendingTransfer: pendingTransferInfo,
356
+ };
357
+ }
358
+
359
+ // T016/T023: Pending state
360
+ logger.debug(
361
+ 'StellarAccessControlService.getOwnership',
362
+ `Contract ${contractAddress} has pending transfer to ${pendingTransfer.pendingOwner} (expires at ledger ${liveUntilLedger})`
363
+ );
364
+ return {
365
+ owner: basicOwnership.owner,
366
+ state: 'pending',
367
+ pendingTransfer: pendingTransferInfo,
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Gets current role assignments for a contract
373
+ *
374
+ * Uses the known role IDs registered with the contract. If no role IDs were provided
375
+ * via registerContract(), attempts to discover them dynamically via the indexer.
376
+ *
377
+ * @param contractAddress The contract address
378
+ * @returns Promise resolving to array of role assignments
379
+ * @throws ConfigurationInvalid if the contract address is invalid or contract not registered
380
+ */
381
+ async getCurrentRoles(contractAddress: string): Promise<RoleAssignment[]> {
382
+ // Validate contract address
383
+ validateContractAddress(contractAddress);
384
+
385
+ logger.info(
386
+ 'StellarAccessControlService.getCurrentRoles',
387
+ `Reading roles for ${contractAddress}`
388
+ );
389
+
390
+ const context = this.contractContexts.get(contractAddress);
391
+ if (!context) {
392
+ throw new ConfigurationInvalid(
393
+ 'Contract not registered. Call registerContract() first.',
394
+ contractAddress,
395
+ 'contractAddress'
396
+ );
397
+ }
398
+
399
+ // Use known role IDs if provided, otherwise attempt discovery
400
+ let roleIds = context.knownRoleIds || [];
401
+
402
+ if (roleIds.length === 0) {
403
+ // Attempt to discover roles via indexer
404
+ logger.debug(
405
+ 'StellarAccessControlService.getCurrentRoles',
406
+ 'No role IDs provided, attempting discovery via indexer'
407
+ );
408
+ roleIds = await this.discoverKnownRoleIds(contractAddress);
409
+ }
410
+
411
+ if (roleIds.length === 0) {
412
+ logger.warn(
413
+ 'StellarAccessControlService.getCurrentRoles',
414
+ 'No role IDs available (neither provided nor discoverable), returning empty array'
415
+ );
416
+ return [];
417
+ }
418
+
419
+ return readCurrentRoles(contractAddress, roleIds, this.networkConfig);
420
+ }
421
+
422
+ /**
423
+ * Gets current role assignments with enriched member information including grant timestamps
424
+ *
425
+ * This method returns role assignments with detailed metadata about when each member
426
+ * was granted the role. If the indexer is unavailable, it gracefully degrades to
427
+ * returning members without timestamp information.
428
+ *
429
+ * @param contractAddress The contract address
430
+ * @returns Promise resolving to array of enriched role assignments
431
+ * @throws ConfigurationInvalid if the contract address is invalid or contract not registered
432
+ */
433
+ async getCurrentRolesEnriched(contractAddress: string): Promise<EnrichedRoleAssignment[]> {
434
+ // Validate contract address
435
+ validateContractAddress(contractAddress);
436
+
437
+ logger.info(
438
+ 'StellarAccessControlService.getCurrentRolesEnriched',
439
+ `Reading enriched roles for ${contractAddress}`
440
+ );
441
+
442
+ // First, get the current role assignments via on-chain queries
443
+ const currentRoles = await this.getCurrentRoles(contractAddress);
444
+
445
+ if (currentRoles.length === 0) {
446
+ return [];
447
+ }
448
+
449
+ // Check indexer availability for enrichment
450
+ const indexerAvailable = await this.indexerClient.checkAvailability();
451
+
452
+ if (!indexerAvailable) {
453
+ logger.debug(
454
+ 'StellarAccessControlService.getCurrentRolesEnriched',
455
+ 'Indexer not available, returning roles without timestamps'
456
+ );
457
+ // Graceful degradation: return enriched structure without timestamps
458
+ return this.convertToEnrichedWithoutTimestamps(currentRoles);
459
+ }
460
+
461
+ // Enrich all roles in parallel for improved performance
462
+ const enrichmentPromises = currentRoles.map(async (roleAssignment) => {
463
+ const enrichedMembers = await this.enrichMembersWithGrantInfo(
464
+ contractAddress,
465
+ roleAssignment.role.id,
466
+ roleAssignment.members
467
+ );
468
+
469
+ return {
470
+ role: roleAssignment.role,
471
+ members: enrichedMembers,
472
+ };
473
+ });
474
+
475
+ const enrichedAssignments = await Promise.all(enrichmentPromises);
476
+
477
+ logger.debug(
478
+ 'StellarAccessControlService.getCurrentRolesEnriched',
479
+ `Enriched ${enrichedAssignments.length} role(s) with grant timestamps`
480
+ );
481
+
482
+ return enrichedAssignments;
483
+ }
484
+
485
+ /**
486
+ * Converts standard role assignments to enriched format without timestamps
487
+ * Used when indexer is unavailable (graceful degradation)
488
+ */
489
+ private convertToEnrichedWithoutTimestamps(
490
+ roleAssignments: RoleAssignment[]
491
+ ): EnrichedRoleAssignment[] {
492
+ return roleAssignments.map((assignment) => ({
493
+ role: assignment.role,
494
+ members: assignment.members.map((address) => ({
495
+ address,
496
+ // Timestamps are undefined when indexer is unavailable
497
+ })),
498
+ }));
499
+ }
500
+
501
+ /**
502
+ * Enriches member addresses with grant information from the indexer
503
+ */
504
+ private async enrichMembersWithGrantInfo(
505
+ contractAddress: string,
506
+ roleId: string,
507
+ memberAddresses: string[]
508
+ ): Promise<EnrichedRoleMember[]> {
509
+ if (memberAddresses.length === 0) {
510
+ return [];
511
+ }
512
+
513
+ try {
514
+ // Query indexer for grant information
515
+ const grantInfoMap = await this.indexerClient.queryLatestGrants(
516
+ contractAddress,
517
+ roleId,
518
+ memberAddresses
519
+ );
520
+
521
+ // Build enriched members, using grant info when available
522
+ return memberAddresses.map((address) => {
523
+ const grantInfo = grantInfoMap.get(address);
524
+ if (grantInfo) {
525
+ return {
526
+ address,
527
+ grantedAt: grantInfo.timestamp,
528
+ grantedTxId: grantInfo.txId,
529
+ grantedLedger: grantInfo.ledger,
530
+ };
531
+ }
532
+ // No grant info found (shouldn't happen for current members, but handle gracefully)
533
+ return { address };
534
+ });
535
+ } catch (error) {
536
+ logger.warn(
537
+ 'StellarAccessControlService.enrichMembersWithGrantInfo',
538
+ `Failed to fetch grant info for role ${roleId}, returning members without timestamps: ${error instanceof Error ? error.message : String(error)}`
539
+ );
540
+ // Graceful degradation on error
541
+ return memberAddresses.map((address) => ({ address }));
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Grants a role to an account
547
+ *
548
+ * @param contractAddress The contract address
549
+ * @param roleId The role identifier
550
+ * @param account The account to grant the role to
551
+ * @param executionConfig Execution configuration specifying method (eoa, relayer, etc.)
552
+ * @param onStatusChange Optional callback for status updates
553
+ * @param runtimeApiKey Optional session-only API key for methods like Relayer
554
+ * @returns Promise resolving to operation result
555
+ * @throws ConfigurationInvalid if addresses are invalid
556
+ */
557
+ async grantRole(
558
+ contractAddress: string,
559
+ roleId: string,
560
+ account: string,
561
+ executionConfig: ExecutionConfig,
562
+ onStatusChange?: (status: TxStatus, details: TransactionStatusUpdate) => void,
563
+ runtimeApiKey?: string
564
+ ): Promise<OperationResult> {
565
+ // Validate addresses
566
+ validateContractAddress(contractAddress);
567
+ validateAccountAddress(account, 'account');
568
+
569
+ logger.info(
570
+ 'StellarAccessControlService.grantRole',
571
+ `Granting role ${roleId} to ${account} on ${contractAddress}`
572
+ );
573
+
574
+ // Assemble the transaction data
575
+ const txData = assembleGrantRoleAction(contractAddress, roleId, account);
576
+
577
+ logger.debug('StellarAccessControlService.grantRole', 'Transaction data prepared:', {
578
+ contractAddress: txData.contractAddress,
579
+ functionName: txData.functionName,
580
+ argTypes: txData.argTypes,
581
+ });
582
+
583
+ // Execute the transaction
584
+ const result = await signAndBroadcastStellarTransaction(
585
+ txData,
586
+ executionConfig,
587
+ this.networkConfig,
588
+ onStatusChange,
589
+ runtimeApiKey
590
+ );
591
+
592
+ logger.info('StellarAccessControlService.grantRole', `Role granted. TxHash: ${result.txHash}`);
593
+
594
+ return { id: result.txHash };
595
+ }
596
+
597
+ /**
598
+ * Revokes a role from an account
599
+ *
600
+ * @param contractAddress The contract address
601
+ * @param roleId The role identifier
602
+ * @param account The account to revoke the role from
603
+ * @param executionConfig Execution configuration specifying method (eoa, relayer, etc.)
604
+ * @param onStatusChange Optional callback for status updates
605
+ * @param runtimeApiKey Optional session-only API key for methods like Relayer
606
+ * @returns Promise resolving to operation result
607
+ * @throws ConfigurationInvalid if addresses are invalid
608
+ */
609
+ async revokeRole(
610
+ contractAddress: string,
611
+ roleId: string,
612
+ account: string,
613
+ executionConfig: ExecutionConfig,
614
+ onStatusChange?: (status: TxStatus, details: TransactionStatusUpdate) => void,
615
+ runtimeApiKey?: string
616
+ ): Promise<OperationResult> {
617
+ // Validate addresses
618
+ validateContractAddress(contractAddress);
619
+ validateAccountAddress(account, 'account');
620
+
621
+ logger.info(
622
+ 'StellarAccessControlService.revokeRole',
623
+ `Revoking role ${roleId} from ${account} on ${contractAddress}`
624
+ );
625
+
626
+ // Assemble the transaction data
627
+ const txData = assembleRevokeRoleAction(contractAddress, roleId, account);
628
+
629
+ logger.debug('StellarAccessControlService.revokeRole', 'Transaction data prepared:', {
630
+ contractAddress: txData.contractAddress,
631
+ functionName: txData.functionName,
632
+ argTypes: txData.argTypes,
633
+ });
634
+
635
+ // Execute the transaction
636
+ const result = await signAndBroadcastStellarTransaction(
637
+ txData,
638
+ executionConfig,
639
+ this.networkConfig,
640
+ onStatusChange,
641
+ runtimeApiKey
642
+ );
643
+
644
+ logger.info('StellarAccessControlService.revokeRole', `Role revoked. TxHash: ${result.txHash}`);
645
+
646
+ return { id: result.txHash };
647
+ }
648
+
649
+ // ── Expiration Metadata ────────────────────────────────────────────────
650
+
651
+ /**
652
+ * Get expiration metadata for a transfer type.
653
+ *
654
+ * Stellar semantics: Both ownership and admin transfers require a user-provided
655
+ * expiration ledger number.
656
+ *
657
+ * @param contractAddress - Contract address (validated but not used for Stellar)
658
+ * @param _transferType - 'ownership' or 'admin' (same semantics for both on Stellar)
659
+ * @returns Expiration metadata indicating required ledger number input
660
+ */
661
+ async getExpirationMetadata(
662
+ contractAddress: string,
663
+ _transferType: 'ownership' | 'admin'
664
+ ): Promise<ExpirationMetadata> {
665
+ validateContractAddress(contractAddress);
666
+
667
+ return {
668
+ mode: 'required',
669
+ label: 'Expiration Ledger',
670
+ unit: 'ledger number',
671
+ };
672
+ }
673
+
674
+ /**
675
+ * Transfers ownership of the contract using two-step transfer
676
+ *
677
+ * Initiates a two-step ownership transfer with an expiration ledger.
678
+ * The pending owner must call acceptOwnership() before the expiration
679
+ * ledger to complete the transfer.
680
+ *
681
+ * @param contractAddress The contract address
682
+ * @param newOwner The new owner address (pending owner)
683
+ * @param expirationLedger The ledger sequence by which the transfer must be accepted
684
+ * @param executionConfig Execution configuration specifying method (eoa, relayer, etc.)
685
+ * @param onStatusChange Optional callback for status updates
686
+ * @param runtimeApiKey Optional session-only API key for methods like Relayer
687
+ * @returns Promise resolving to operation result with transaction ID
688
+ * @throws ConfigurationInvalid if addresses are invalid or expiration is invalid
689
+ *
690
+ * @example
691
+ * ```typescript
692
+ * // Calculate expiration ~12 hours from now (Stellar ledgers advance ~5s each)
693
+ * const currentLedger = await getCurrentLedger(networkConfig);
694
+ * const expirationLedger = currentLedger + 8640; // 12 * 60 * 60 / 5
695
+ *
696
+ * const result = await service.transferOwnership(
697
+ * contractAddress,
698
+ * newOwnerAddress,
699
+ * expirationLedger,
700
+ * executionConfig
701
+ * );
702
+ * console.log('Transfer initiated, txHash:', result.id);
703
+ * ```
704
+ */
705
+ async transferOwnership(
706
+ contractAddress: string,
707
+ newOwner: string,
708
+ expirationLedger: number,
709
+ executionConfig: ExecutionConfig,
710
+ onStatusChange?: (status: TxStatus, details: TransactionStatusUpdate) => void,
711
+ runtimeApiKey?: string
712
+ ): Promise<OperationResult> {
713
+ // Validate addresses
714
+ // newOwner can be either an account address (G...) or contract address (C...)
715
+ validateContractAddress(contractAddress);
716
+ validateAddress(newOwner, 'newOwner');
717
+
718
+ // T037: INFO logging for transfer initiation per NFR-004
719
+ logger.info(
720
+ 'StellarAccessControlService.transferOwnership',
721
+ `Initiating two-step ownership transfer to ${newOwner} on ${contractAddress} with expiration at ledger ${expirationLedger}`
722
+ );
723
+
724
+ // T034/T035: Client-side expiration validation (must be > current ledger)
725
+ const currentLedger = await getCurrentLedger(this.networkConfig);
726
+ const validationResult = validateExpirationLedger(expirationLedger, currentLedger);
727
+
728
+ if (!validationResult.valid) {
729
+ // T036: Specific error messages per FR-018
730
+ throw new ConfigurationInvalid(
731
+ validationResult.error ||
732
+ `Expiration ledger ${expirationLedger} must be strictly greater than current ledger ${currentLedger}.`,
733
+ String(expirationLedger),
734
+ 'expirationLedger'
735
+ );
736
+ }
737
+
738
+ // Assemble the transaction data with live_until_ledger parameter
739
+ const txData = assembleTransferOwnershipAction(contractAddress, newOwner, expirationLedger);
740
+
741
+ logger.debug('StellarAccessControlService.transferOwnership', 'Transaction data prepared:', {
742
+ contractAddress: txData.contractAddress,
743
+ functionName: txData.functionName,
744
+ argTypes: txData.argTypes,
745
+ expirationLedger,
746
+ currentLedger,
747
+ });
748
+
749
+ // Execute the transaction
750
+ const result = await signAndBroadcastStellarTransaction(
751
+ txData,
752
+ executionConfig,
753
+ this.networkConfig,
754
+ onStatusChange,
755
+ runtimeApiKey
756
+ );
757
+
758
+ logger.info(
759
+ 'StellarAccessControlService.transferOwnership',
760
+ `Ownership transfer initiated. TxHash: ${result.txHash}, pending owner: ${newOwner}, expires at ledger: ${expirationLedger}`
761
+ );
762
+
763
+ return { id: result.txHash };
764
+ }
765
+
766
+ /**
767
+ * Accepts a pending ownership transfer (two-step transfer)
768
+ *
769
+ * Must be called by the pending owner (the address specified in transferOwnership)
770
+ * before the expiration ledger. The on-chain contract validates:
771
+ * 1. Caller is the pending owner
772
+ * 2. Transfer has not expired
773
+ *
774
+ * @param contractAddress The contract address
775
+ * @param executionConfig Execution configuration specifying method (eoa, relayer, etc.)
776
+ * @param onStatusChange Optional callback for status updates
777
+ * @param runtimeApiKey Optional session-only API key for methods like Relayer
778
+ * @returns Promise resolving to operation result with transaction ID
779
+ * @throws ConfigurationInvalid if contract address is invalid
780
+ * @throws OperationFailed if on-chain rejection (expired or unauthorized)
781
+ *
782
+ * @example
783
+ * ```typescript
784
+ * // Check ownership state before accepting
785
+ * const ownership = await service.getOwnership(contractAddress);
786
+ * if (ownership.state === 'pending') {
787
+ * const result = await service.acceptOwnership(contractAddress, executionConfig);
788
+ * console.log('Ownership accepted, txHash:', result.id);
789
+ * }
790
+ * ```
791
+ */
792
+ async acceptOwnership(
793
+ contractAddress: string,
794
+ executionConfig: ExecutionConfig,
795
+ onStatusChange?: (status: TxStatus, details: TransactionStatusUpdate) => void,
796
+ runtimeApiKey?: string
797
+ ): Promise<OperationResult> {
798
+ // Validate contract address
799
+ validateContractAddress(contractAddress);
800
+
801
+ // T047: INFO logging for acceptance operations per NFR-004
802
+ logger.info(
803
+ 'StellarAccessControlService.acceptOwnership',
804
+ `Accepting pending ownership transfer for ${contractAddress}`
805
+ );
806
+
807
+ // T045: Per spec clarification, expiration is enforced on-chain
808
+ // No pre-check required - the contract will reject if expired or caller is not pending owner
809
+
810
+ // T043: Assemble the accept_ownership transaction data
811
+ const txData = assembleAcceptOwnershipAction(contractAddress);
812
+
813
+ logger.debug('StellarAccessControlService.acceptOwnership', 'Transaction data prepared:', {
814
+ contractAddress: txData.contractAddress,
815
+ functionName: txData.functionName,
816
+ });
817
+
818
+ // Execute the transaction
819
+ const result = await signAndBroadcastStellarTransaction(
820
+ txData,
821
+ executionConfig,
822
+ this.networkConfig,
823
+ onStatusChange,
824
+ runtimeApiKey
825
+ );
826
+
827
+ logger.info(
828
+ 'StellarAccessControlService.acceptOwnership',
829
+ `Ownership transfer accepted. TxHash: ${result.txHash}`
830
+ );
831
+
832
+ return { id: result.txHash };
833
+ }
834
+
835
+ /**
836
+ * Gets the current admin and admin transfer state of an AccessControl contract
837
+ *
838
+ * Retrieves the current admin via on-chain query, then checks for pending
839
+ * two-step admin transfers via indexer to determine the admin state:
840
+ * - 'active': Has admin, no pending transfer
841
+ * - 'pending': Has admin, pending transfer not yet expired
842
+ * - 'expired': Has admin, pending transfer has expired
843
+ * - 'renounced': No admin (null)
844
+ *
845
+ * Gracefully degrades when indexer is unavailable, returning basic admin
846
+ * info with 'active' state and logging a warning.
847
+ *
848
+ * @param contractAddress The contract address
849
+ * @returns Promise resolving to admin information with state
850
+ * @throws ConfigurationInvalid if the contract address is invalid
851
+ *
852
+ * @example
853
+ * ```typescript
854
+ * const adminInfo = await service.getAdminInfo(contractAddress);
855
+ * console.log('Admin:', adminInfo.admin);
856
+ * console.log('State:', adminInfo.state); // 'active' | 'pending' | 'expired' | 'renounced'
857
+ *
858
+ * if (adminInfo.state === 'pending' && adminInfo.pendingTransfer) {
859
+ * console.log('Pending admin:', adminInfo.pendingTransfer.pendingAdmin);
860
+ * console.log('Expires at ledger:', adminInfo.pendingTransfer.expirationBlock);
861
+ * }
862
+ * ```
863
+ */
864
+ async getAdminInfo(contractAddress: string): Promise<AdminInfo> {
865
+ // Validate contract address
866
+ validateContractAddress(contractAddress);
867
+
868
+ logger.info(
869
+ 'StellarAccessControlService.getAdminInfo',
870
+ `Reading admin status for ${contractAddress}`
871
+ );
872
+
873
+ // Defense-in-depth: check capabilities before calling get_admin()
874
+ // Only applies when contract is registered (context available for schema-based detection)
875
+ const context = this.contractContexts.get(contractAddress);
876
+ if (context) {
877
+ const capabilities = detectAccessControlCapabilities(context.contractSchema);
878
+ if (!capabilities.hasTwoStepAdmin) {
879
+ throw new OperationFailed(
880
+ 'Contract does not implement the two-step admin interface — no get_admin() / accept_admin_transfer() functions available',
881
+ contractAddress,
882
+ 'getAdminInfo'
883
+ );
884
+ }
885
+ }
886
+
887
+ // Call get_admin() for current admin
888
+ const currentAdmin = await getAdmin(contractAddress, this.networkConfig);
889
+
890
+ // Renounced state - admin is null
891
+ if (currentAdmin === null) {
892
+ logger.debug(
893
+ 'StellarAccessControlService.getAdminInfo',
894
+ `Contract ${contractAddress} has renounced admin`
895
+ );
896
+ return {
897
+ admin: null,
898
+ state: 'renounced',
899
+ };
900
+ }
901
+
902
+ // Check indexer availability for pending transfer detection
903
+ const indexerAvailable = await this.indexerClient.checkAvailability();
904
+
905
+ if (!indexerAvailable) {
906
+ logger.warn(
907
+ 'StellarAccessControlService.getAdminInfo',
908
+ `Indexer unavailable for ${this.networkConfig.id}: pending admin transfer status cannot be determined`
909
+ );
910
+ // Graceful degradation - return basic admin with 'active' state
911
+ return {
912
+ admin: currentAdmin,
913
+ state: 'active',
914
+ };
915
+ }
916
+
917
+ // Query indexer for pending admin transfer
918
+ let pendingTransfer;
919
+ try {
920
+ pendingTransfer = await this.indexerClient.queryPendingAdminTransfer(contractAddress);
921
+ } catch (error) {
922
+ // Graceful degradation on indexer query error
923
+ logger.warn(
924
+ 'StellarAccessControlService.getAdminInfo',
925
+ `Failed to query pending admin transfer: ${error instanceof Error ? error.message : String(error)}`
926
+ );
927
+ return {
928
+ admin: currentAdmin,
929
+ state: 'active',
930
+ };
931
+ }
932
+
933
+ // No pending transfer in indexer - state is 'active'
934
+ if (!pendingTransfer) {
935
+ logger.debug(
936
+ 'StellarAccessControlService.getAdminInfo',
937
+ `Contract ${contractAddress} has admin with no pending transfer`
938
+ );
939
+ return {
940
+ admin: currentAdmin,
941
+ state: 'active',
942
+ };
943
+ }
944
+
945
+ // Get current ledger to check expiration
946
+ let currentLedger: number;
947
+ try {
948
+ currentLedger = await getCurrentLedger(this.networkConfig);
949
+ } catch (error) {
950
+ // Graceful degradation if ledger query fails
951
+ logger.warn(
952
+ 'StellarAccessControlService.getAdminInfo',
953
+ `Failed to get current ledger: ${error instanceof Error ? error.message : String(error)}`
954
+ );
955
+ // Return active state since we can't determine expiration
956
+ return {
957
+ admin: currentAdmin,
958
+ state: 'active',
959
+ };
960
+ }
961
+
962
+ // Use indexer's liveUntilLedger for expiration check
963
+ const liveUntilLedger = pendingTransfer.liveUntilLedger;
964
+
965
+ // Determine state based on expiration
966
+ // expirationLedger must be > currentLedger, so >= means expired
967
+ const isExpired = currentLedger >= liveUntilLedger;
968
+
969
+ // Build pending transfer info for the response
970
+ const pendingTransferInfo = {
971
+ pendingAdmin: pendingTransfer.pendingAdmin,
972
+ expirationBlock: liveUntilLedger,
973
+ initiatedAt: pendingTransfer.timestamp,
974
+ initiatedTxId: pendingTransfer.txHash,
975
+ initiatedBlock: pendingTransfer.ledger,
976
+ };
977
+
978
+ if (isExpired) {
979
+ // Expired state
980
+ logger.debug(
981
+ 'StellarAccessControlService.getAdminInfo',
982
+ `Contract ${contractAddress} has expired pending admin transfer (current: ${currentLedger}, expiration: ${liveUntilLedger})`
983
+ );
984
+ return {
985
+ admin: currentAdmin,
986
+ state: 'expired',
987
+ pendingTransfer: pendingTransferInfo,
988
+ };
989
+ }
990
+
991
+ // Pending state
992
+ logger.debug(
993
+ 'StellarAccessControlService.getAdminInfo',
994
+ `Contract ${contractAddress} has pending admin transfer to ${pendingTransfer.pendingAdmin} (expires at ledger ${liveUntilLedger})`
995
+ );
996
+ return {
997
+ admin: currentAdmin,
998
+ state: 'pending',
999
+ pendingTransfer: pendingTransferInfo,
1000
+ };
1001
+ }
1002
+
1003
+ /**
1004
+ * Initiates an admin role transfer using two-step transfer
1005
+ *
1006
+ * Initiates a two-step admin transfer with an expiration ledger.
1007
+ * The pending admin must call acceptAdminTransfer() before the expiration
1008
+ * ledger to complete the transfer.
1009
+ *
1010
+ * @param contractAddress The contract address
1011
+ * @param newAdmin The new admin address (pending admin)
1012
+ * @param expirationLedger The ledger sequence by which the transfer must be accepted
1013
+ * @param executionConfig Execution configuration specifying method (eoa, relayer, etc.)
1014
+ * @param onStatusChange Optional callback for status updates
1015
+ * @param runtimeApiKey Optional session-only API key for methods like Relayer
1016
+ * @returns Promise resolving to operation result with transaction ID
1017
+ * @throws ConfigurationInvalid if addresses are invalid or expiration is invalid
1018
+ *
1019
+ * @example
1020
+ * ```typescript
1021
+ * // Calculate expiration ~12 hours from now (Stellar ledgers advance ~5s each)
1022
+ * const currentLedger = await getCurrentLedger(networkConfig);
1023
+ * const expirationLedger = currentLedger + 8640; // 12 * 60 * 60 / 5
1024
+ *
1025
+ * const result = await service.transferAdminRole(
1026
+ * contractAddress,
1027
+ * newAdminAddress,
1028
+ * expirationLedger,
1029
+ * executionConfig
1030
+ * );
1031
+ * console.log('Admin transfer initiated, txHash:', result.id);
1032
+ * ```
1033
+ */
1034
+ async transferAdminRole(
1035
+ contractAddress: string,
1036
+ newAdmin: string,
1037
+ expirationLedger: number,
1038
+ executionConfig: ExecutionConfig,
1039
+ onStatusChange?: (status: TxStatus, details: TransactionStatusUpdate) => void,
1040
+ runtimeApiKey?: string
1041
+ ): Promise<OperationResult> {
1042
+ // Validate addresses
1043
+ // newAdmin can be either an account address (G...) or contract address (C...)
1044
+ validateContractAddress(contractAddress);
1045
+ validateAddress(newAdmin, 'newAdmin');
1046
+
1047
+ logger.info(
1048
+ 'StellarAccessControlService.transferAdminRole',
1049
+ `Initiating two-step admin transfer to ${newAdmin} on ${contractAddress} with expiration at ledger ${expirationLedger}`
1050
+ );
1051
+
1052
+ // Client-side expiration validation (must be > current ledger)
1053
+ const currentLedger = await getCurrentLedger(this.networkConfig);
1054
+ const validationResult = validateExpirationLedger(expirationLedger, currentLedger);
1055
+
1056
+ if (!validationResult.valid) {
1057
+ throw new ConfigurationInvalid(
1058
+ validationResult.error ||
1059
+ `Expiration ledger ${expirationLedger} must be strictly greater than current ledger ${currentLedger}.`,
1060
+ String(expirationLedger),
1061
+ 'expirationLedger'
1062
+ );
1063
+ }
1064
+
1065
+ // Assemble the transaction data with live_until_ledger parameter
1066
+ const txData = assembleTransferAdminRoleAction(contractAddress, newAdmin, expirationLedger);
1067
+
1068
+ logger.debug('StellarAccessControlService.transferAdminRole', 'Transaction data prepared:', {
1069
+ contractAddress: txData.contractAddress,
1070
+ functionName: txData.functionName,
1071
+ argTypes: txData.argTypes,
1072
+ expirationLedger,
1073
+ currentLedger,
1074
+ });
1075
+
1076
+ // Execute the transaction
1077
+ const result = await signAndBroadcastStellarTransaction(
1078
+ txData,
1079
+ executionConfig,
1080
+ this.networkConfig,
1081
+ onStatusChange,
1082
+ runtimeApiKey
1083
+ );
1084
+
1085
+ logger.info(
1086
+ 'StellarAccessControlService.transferAdminRole',
1087
+ `Admin transfer initiated. TxHash: ${result.txHash}, pending admin: ${newAdmin}, expires at ledger: ${expirationLedger}`
1088
+ );
1089
+
1090
+ return { id: result.txHash };
1091
+ }
1092
+
1093
+ /**
1094
+ * Accepts a pending admin transfer (two-step transfer)
1095
+ *
1096
+ * Must be called by the pending admin (the address specified in transferAdminRole)
1097
+ * before the expiration ledger. The on-chain contract validates:
1098
+ * 1. Caller is the pending admin
1099
+ * 2. Transfer has not expired
1100
+ *
1101
+ * @param contractAddress The contract address
1102
+ * @param executionConfig Execution configuration specifying method (eoa, relayer, etc.)
1103
+ * @param onStatusChange Optional callback for status updates
1104
+ * @param runtimeApiKey Optional session-only API key for methods like Relayer
1105
+ * @returns Promise resolving to operation result with transaction ID
1106
+ * @throws ConfigurationInvalid if contract address is invalid
1107
+ * @throws OperationFailed if on-chain rejection (expired or unauthorized)
1108
+ *
1109
+ * @example
1110
+ * ```typescript
1111
+ * // Check admin state before accepting
1112
+ * const adminInfo = await service.getAdminInfo(contractAddress);
1113
+ * if (adminInfo.state === 'pending') {
1114
+ * const result = await service.acceptAdminTransfer(contractAddress, executionConfig);
1115
+ * console.log('Admin transfer accepted, txHash:', result.id);
1116
+ * }
1117
+ * ```
1118
+ */
1119
+ async acceptAdminTransfer(
1120
+ contractAddress: string,
1121
+ executionConfig: ExecutionConfig,
1122
+ onStatusChange?: (status: TxStatus, details: TransactionStatusUpdate) => void,
1123
+ runtimeApiKey?: string
1124
+ ): Promise<OperationResult> {
1125
+ // Validate contract address
1126
+ validateContractAddress(contractAddress);
1127
+
1128
+ logger.info(
1129
+ 'StellarAccessControlService.acceptAdminTransfer',
1130
+ `Accepting pending admin transfer for ${contractAddress}`
1131
+ );
1132
+
1133
+ // Expiration is enforced on-chain
1134
+ // No pre-check required - the contract will reject if expired or caller is not pending admin
1135
+
1136
+ // Assemble the accept_admin_transfer transaction data
1137
+ const txData = assembleAcceptAdminTransferAction(contractAddress);
1138
+
1139
+ logger.debug('StellarAccessControlService.acceptAdminTransfer', 'Transaction data prepared:', {
1140
+ contractAddress: txData.contractAddress,
1141
+ functionName: txData.functionName,
1142
+ });
1143
+
1144
+ // Execute the transaction
1145
+ const result = await signAndBroadcastStellarTransaction(
1146
+ txData,
1147
+ executionConfig,
1148
+ this.networkConfig,
1149
+ onStatusChange,
1150
+ runtimeApiKey
1151
+ );
1152
+
1153
+ logger.info(
1154
+ 'StellarAccessControlService.acceptAdminTransfer',
1155
+ `Admin transfer accepted. TxHash: ${result.txHash}`
1156
+ );
1157
+
1158
+ return { id: result.txHash };
1159
+ }
1160
+
1161
+ /**
1162
+ * Exports a snapshot of current access control state
1163
+ *
1164
+ * @param contractAddress The contract address
1165
+ * @returns Promise resolving to access snapshot
1166
+ * @throws Error if snapshot validation fails
1167
+ * @throws ConfigurationInvalid if the contract address is invalid
1168
+ */
1169
+ async exportSnapshot(contractAddress: string): Promise<AccessSnapshot> {
1170
+ // Validate contract address
1171
+ validateContractAddress(contractAddress);
1172
+
1173
+ logger.info(
1174
+ 'StellarAccessControlService.exportSnapshot',
1175
+ `Exporting snapshot for ${contractAddress}`
1176
+ );
1177
+
1178
+ // Read ownership (if supported)
1179
+ let ownership: OwnershipInfo | undefined;
1180
+ try {
1181
+ ownership = await this.getOwnership(contractAddress);
1182
+ } catch (error) {
1183
+ logger.debug('StellarAccessControlService.exportSnapshot', 'Ownership not available:', error);
1184
+ // Contract may not be Ownable, continue without ownership
1185
+ }
1186
+
1187
+ // Read roles (if contract is registered and has roles)
1188
+ let roles: RoleAssignment[] = [];
1189
+ try {
1190
+ roles = await this.getCurrentRoles(contractAddress);
1191
+ } catch (error) {
1192
+ logger.debug('StellarAccessControlService.exportSnapshot', 'Roles not available:', error);
1193
+ // Contract may not be registered or have no roles, continue with empty roles array
1194
+ }
1195
+
1196
+ const snapshot: AccessSnapshot = {
1197
+ roles,
1198
+ ownership,
1199
+ };
1200
+
1201
+ // Validate snapshot using utils
1202
+ if (!validateSnapshot(snapshot)) {
1203
+ const errorMsg = `Invalid snapshot structure for contract ${contractAddress}`;
1204
+ logger.error('StellarAccessControlService.exportSnapshot', errorMsg);
1205
+ throw new OperationFailed(errorMsg, contractAddress, 'exportSnapshot');
1206
+ }
1207
+
1208
+ logger.debug('StellarAccessControlService.exportSnapshot', 'Snapshot created and validated:', {
1209
+ hasOwnership: !!ownership?.owner,
1210
+ roleCount: roles.length,
1211
+ totalMembers: roles.reduce((sum, r) => sum + r.members.length, 0),
1212
+ });
1213
+
1214
+ return snapshot;
1215
+ }
1216
+
1217
+ /**
1218
+ * Gets history of role changes with pagination support
1219
+ *
1220
+ * Supports cursor-based pagination. Use `pageInfo.endCursor` from the response
1221
+ * as the `cursor` option in subsequent calls to fetch more pages.
1222
+ *
1223
+ * @param contractAddress The contract address
1224
+ * @param options Optional filtering and pagination options
1225
+ * @returns Promise resolving to paginated history result, or empty result if not supported
1226
+ * @throws ConfigurationInvalid if the contract address is invalid
1227
+ */
1228
+ async getHistory(
1229
+ contractAddress: string,
1230
+ options?: HistoryQueryOptions
1231
+ ): Promise<PaginatedHistoryResult> {
1232
+ // Validate contract address
1233
+ validateContractAddress(contractAddress);
1234
+
1235
+ // Validate account if provided
1236
+ if (options?.account) {
1237
+ validateAccountAddress(options.account, 'options.account');
1238
+ }
1239
+
1240
+ logger.info(
1241
+ 'StellarAccessControlService.getHistory',
1242
+ `Fetching history for ${contractAddress}`,
1243
+ options
1244
+ );
1245
+
1246
+ const isAvailable = await this.indexerClient.checkAvailability();
1247
+ if (!isAvailable) {
1248
+ logger.warn(
1249
+ 'StellarAccessControlService.getHistory',
1250
+ `Indexer not available for network ${this.networkConfig.id}, returning empty history`
1251
+ );
1252
+ return {
1253
+ items: [],
1254
+ pageInfo: { hasNextPage: false },
1255
+ };
1256
+ }
1257
+
1258
+ return this.indexerClient.queryHistory(contractAddress, options);
1259
+ }
1260
+
1261
+ /**
1262
+ * Helper to get the admin account (AccessControl contracts only)
1263
+ *
1264
+ * @param contractAddress The contract address
1265
+ * @returns Promise resolving to admin address or null
1266
+ * @throws ConfigurationInvalid if the contract address is invalid
1267
+ */
1268
+ async getAdminAccount(contractAddress: string): Promise<string | null> {
1269
+ // Validate contract address
1270
+ validateContractAddress(contractAddress);
1271
+
1272
+ logger.info(
1273
+ 'StellarAccessControlService.getAdminAccount',
1274
+ `Reading admin for ${contractAddress}`
1275
+ );
1276
+
1277
+ // Defense-in-depth: check capabilities before calling get_admin()
1278
+ // Only applies when contract is registered (context available for schema-based detection)
1279
+ const context = this.contractContexts.get(contractAddress);
1280
+ if (context) {
1281
+ const capabilities = detectAccessControlCapabilities(context.contractSchema);
1282
+ if (!capabilities.hasTwoStepAdmin) {
1283
+ throw new OperationFailed(
1284
+ 'Contract does not implement the two-step admin interface — no get_admin() function available',
1285
+ contractAddress,
1286
+ 'getAdminAccount'
1287
+ );
1288
+ }
1289
+ }
1290
+
1291
+ return getAdmin(contractAddress, this.networkConfig);
1292
+ }
1293
+
1294
+ /**
1295
+ * Discovers known role IDs for a contract by querying historical events from the indexer
1296
+ *
1297
+ * This method queries all role_granted and role_revoked events from the indexer and
1298
+ * extracts unique role identifiers. Results are cached to avoid repeated queries.
1299
+ *
1300
+ * If knownRoleIds were provided via registerContract(), those take precedence and
1301
+ * this method returns them without querying the indexer.
1302
+ *
1303
+ * @param contractAddress The contract address to discover roles for
1304
+ * @returns Promise resolving to array of unique role identifiers
1305
+ * @throws ConfigurationInvalid if contract address is invalid or contract not registered
1306
+ */
1307
+ async discoverKnownRoleIds(contractAddress: string): Promise<string[]> {
1308
+ // Validate contract address
1309
+ validateContractAddress(contractAddress);
1310
+
1311
+ const context = this.contractContexts.get(contractAddress);
1312
+ if (!context) {
1313
+ throw new ConfigurationInvalid(
1314
+ 'Contract not registered. Call registerContract() first.',
1315
+ contractAddress,
1316
+ 'contractAddress'
1317
+ );
1318
+ }
1319
+
1320
+ // If knownRoleIds were explicitly provided, return them (they take precedence)
1321
+ if (context.knownRoleIds && context.knownRoleIds.length > 0) {
1322
+ logger.debug(
1323
+ 'StellarAccessControlService.discoverKnownRoleIds',
1324
+ `Using ${context.knownRoleIds.length} explicitly provided role IDs for ${contractAddress}`
1325
+ );
1326
+ return context.knownRoleIds;
1327
+ }
1328
+
1329
+ // Return cached discovered roles if available
1330
+ if (context.discoveredRoleIds) {
1331
+ logger.debug(
1332
+ 'StellarAccessControlService.discoverKnownRoleIds',
1333
+ `Using ${context.discoveredRoleIds.length} cached discovered role IDs for ${contractAddress}`
1334
+ );
1335
+ return context.discoveredRoleIds;
1336
+ }
1337
+
1338
+ // If we already attempted discovery and found nothing, don't retry
1339
+ if (context.roleDiscoveryAttempted) {
1340
+ logger.debug(
1341
+ 'StellarAccessControlService.discoverKnownRoleIds',
1342
+ `Discovery already attempted for ${contractAddress}, returning empty array`
1343
+ );
1344
+ return [];
1345
+ }
1346
+
1347
+ logger.info(
1348
+ 'StellarAccessControlService.discoverKnownRoleIds',
1349
+ `Discovering role IDs via indexer for ${contractAddress}`
1350
+ );
1351
+
1352
+ // Check if indexer is available
1353
+ const isAvailable = await this.indexerClient.checkAvailability();
1354
+ if (!isAvailable) {
1355
+ logger.warn(
1356
+ 'StellarAccessControlService.discoverKnownRoleIds',
1357
+ `Indexer not available for network ${this.networkConfig.id}, cannot discover roles`
1358
+ );
1359
+ // Mark as attempted so we don't retry
1360
+ context.roleDiscoveryAttempted = true;
1361
+ return [];
1362
+ }
1363
+
1364
+ try {
1365
+ // Query indexer for unique role IDs
1366
+ const roleIds = await this.indexerClient.discoverRoleIds(contractAddress);
1367
+
1368
+ // Cache the results
1369
+ context.discoveredRoleIds = roleIds;
1370
+ context.roleDiscoveryAttempted = true;
1371
+
1372
+ logger.info(
1373
+ 'StellarAccessControlService.discoverKnownRoleIds',
1374
+ `Discovered ${roleIds.length} role(s) for ${contractAddress}`,
1375
+ { roles: roleIds }
1376
+ );
1377
+
1378
+ return roleIds;
1379
+ } catch (error) {
1380
+ logger.error(
1381
+ 'StellarAccessControlService.discoverKnownRoleIds',
1382
+ `Failed to discover roles: ${error instanceof Error ? error.message : String(error)}`
1383
+ );
1384
+ // Mark as attempted so we don't retry on transient errors
1385
+ context.roleDiscoveryAttempted = true;
1386
+ return [];
1387
+ }
1388
+ }
1389
+
1390
+ /**
1391
+ * Disposes of the service and cleans up resources.
1392
+ *
1393
+ * Cleans up the indexer client's subscriptions to prevent memory leaks.
1394
+ * Call this method when the service is no longer needed.
1395
+ *
1396
+ * Note: In typical usage where the service is application-scoped and lives
1397
+ * for the duration of the application, calling dispose is not strictly necessary.
1398
+ * However, it should be called if the service is created/destroyed dynamically
1399
+ * (e.g., in tests or when switching networks).
1400
+ */
1401
+ dispose(): void {
1402
+ this.indexerClient.dispose();
1403
+ logger.debug('StellarAccessControlService.dispose', 'Service disposed');
1404
+ }
1405
+ }
1406
+
1407
+ /**
1408
+ * Factory function to create a StellarAccessControlService instance
1409
+ *
1410
+ * Creates a service instance configured for a specific Stellar network.
1411
+ * The service supports both Ownable and AccessControl patterns including
1412
+ * two-step ownership transfers with ledger-based expiration.
1413
+ *
1414
+ * @param networkConfig The Stellar network configuration
1415
+ * @returns A new StellarAccessControlService instance
1416
+ *
1417
+ * @example
1418
+ * ```typescript
1419
+ * import { createStellarAccessControlService } from '@openzeppelin/adapter-stellar';
1420
+ *
1421
+ * const service = createStellarAccessControlService(networkConfig);
1422
+ * service.registerContract(contractAddress, contractSchema);
1423
+ *
1424
+ * const ownership = await service.getOwnership(contractAddress);
1425
+ * ```
1426
+ */
1427
+ export function createStellarAccessControlService(
1428
+ networkConfig: StellarNetworkConfig
1429
+ ): StellarAccessControlService {
1430
+ return new StellarAccessControlService(networkConfig);
1431
+ }