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