@openzeppelin/ui-builder-adapter-stellar 0.16.0 → 1.0.0

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