@openzeppelin/ui-builder-adapter-stellar 0.16.0 → 1.1.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,450 @@
1
+ /**
2
+ * On-Chain Reader Module
3
+ *
4
+ * Reads current access control state (ownership and roles) from Stellar (Soroban) contracts
5
+ * using on-chain queries. Supports both Ownable and AccessControl patterns.
6
+ */
7
+
8
+ import { rpc as StellarRpc } from '@stellar/stellar-sdk';
9
+
10
+ import type {
11
+ ContractSchema,
12
+ OwnershipInfo,
13
+ RoleAssignment,
14
+ RoleIdentifier,
15
+ StellarNetworkConfig,
16
+ } from '@openzeppelin/ui-builder-types';
17
+ import { OperationFailed } from '@openzeppelin/ui-builder-types';
18
+ import {
19
+ DEFAULT_CONCURRENCY_LIMIT,
20
+ logger,
21
+ promiseAllWithLimit,
22
+ } from '@openzeppelin/ui-builder-utils';
23
+
24
+ import { queryStellarViewFunction } from '../query/handler';
25
+
26
+ /**
27
+ * Helper to load a minimal contract schema for access control functions
28
+ * This allows us to use the existing query infrastructure
29
+ */
30
+ function createMinimalSchema(
31
+ contractAddress: string,
32
+ functionName: string,
33
+ inputs: Array<{ name: string; type: string }> = [],
34
+ outputType = 'Val'
35
+ ): ContractSchema {
36
+ return {
37
+ ecosystem: 'stellar',
38
+ address: contractAddress,
39
+ functions: [
40
+ {
41
+ id: functionName,
42
+ name: functionName,
43
+ displayName: functionName,
44
+ type: 'function',
45
+ inputs,
46
+ outputs: [{ name: 'result', type: outputType }],
47
+ modifiesState: false,
48
+ stateMutability: 'view',
49
+ },
50
+ ],
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Simple wrapper around queryStellarViewFunction for access control queries
56
+ */
57
+ async function queryAccessControlFunction(
58
+ contractAddress: string,
59
+ functionName: string,
60
+ params: unknown[],
61
+ networkConfig: StellarNetworkConfig,
62
+ inputs: Array<{ name: string; type: string }> = []
63
+ ): Promise<unknown> {
64
+ const schema = createMinimalSchema(contractAddress, functionName, inputs);
65
+ return queryStellarViewFunction(contractAddress, functionName, networkConfig, params, schema);
66
+ }
67
+
68
+ /**
69
+ * Reads the current owner from an Ownable contract
70
+ *
71
+ * @param contractAddress The contract address
72
+ * @param networkConfig The network configuration
73
+ * @returns Ownership information
74
+ */
75
+ export async function readOwnership(
76
+ contractAddress: string,
77
+ networkConfig: StellarNetworkConfig
78
+ ): Promise<OwnershipInfo> {
79
+ logger.info('readOwnership', `Reading owner for contract ${contractAddress}`);
80
+
81
+ try {
82
+ const result = await queryAccessControlFunction(
83
+ contractAddress,
84
+ 'get_owner',
85
+ [],
86
+ networkConfig
87
+ );
88
+
89
+ // get_owner returns Option<Address>
90
+ if (result === undefined || result === null) {
91
+ return { owner: null };
92
+ }
93
+
94
+ const ownerAddress = typeof result === 'string' ? result : String(result);
95
+ logger.debug('readOwnership', `Owner: ${ownerAddress}`);
96
+
97
+ return { owner: ownerAddress };
98
+ } catch (error) {
99
+ logger.error('readOwnership', 'Failed to read ownership:', error);
100
+ throw new OperationFailed(
101
+ `Failed to read ownership: ${(error as Error).message}`,
102
+ contractAddress,
103
+ 'readOwnership',
104
+ error as Error
105
+ );
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Checks if an account has a specific role
111
+ *
112
+ * @param contractAddress The contract address
113
+ * @param roleId The role identifier (Symbol)
114
+ * @param account The account address to check
115
+ * @param networkConfig The network configuration
116
+ * @returns True if the account has the role, false otherwise
117
+ */
118
+ export async function hasRole(
119
+ contractAddress: string,
120
+ roleId: string,
121
+ account: string,
122
+ networkConfig: StellarNetworkConfig
123
+ ): Promise<boolean> {
124
+ logger.debug('hasRole', `Checking role ${roleId} for ${account}`);
125
+
126
+ try {
127
+ const inputs = [
128
+ { name: 'account', type: 'Address' },
129
+ { name: 'role', type: 'Symbol' },
130
+ ];
131
+ const result = await queryAccessControlFunction(
132
+ contractAddress,
133
+ 'has_role',
134
+ [account, roleId],
135
+ networkConfig,
136
+ inputs
137
+ );
138
+
139
+ // has_role returns Option<u32> (Some(index) if has role, None otherwise)
140
+ return typeof result === 'number';
141
+ } catch (error) {
142
+ logger.error('hasRole', `Failed to check role ${roleId}:`, error);
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Gets the count of members for a specific role
149
+ *
150
+ * @param contractAddress The contract address
151
+ * @param roleId The role identifier (Symbol)
152
+ * @param networkConfig The network configuration
153
+ * @returns The count of role members
154
+ */
155
+ export async function getRoleMemberCount(
156
+ contractAddress: string,
157
+ roleId: string,
158
+ networkConfig: StellarNetworkConfig
159
+ ): Promise<number> {
160
+ logger.debug('getRoleMemberCount', `Getting member count for role ${roleId}`);
161
+
162
+ try {
163
+ const inputs = [{ name: 'role', type: 'Symbol' }];
164
+ const result = await queryAccessControlFunction(
165
+ contractAddress,
166
+ 'get_role_member_count',
167
+ [roleId],
168
+ networkConfig,
169
+ inputs
170
+ );
171
+
172
+ // Handle both number and string results (formatter may return string for large numbers)
173
+ if (typeof result === 'number') {
174
+ return result;
175
+ }
176
+ if (typeof result === 'string') {
177
+ const parsed = parseInt(result, 10);
178
+ return isNaN(parsed) ? 0 : parsed;
179
+ }
180
+ return 0;
181
+ } catch (error) {
182
+ logger.error('getRoleMemberCount', `Failed to get member count for role ${roleId}:`, error);
183
+ return 0;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Gets the member address at a specific index for a role
189
+ *
190
+ * @param contractAddress The contract address
191
+ * @param roleId The role identifier (Symbol)
192
+ * @param index The index of the member to retrieve
193
+ * @param networkConfig The network configuration
194
+ * @returns The member address, or null if index out of bounds
195
+ */
196
+ export async function getRoleMember(
197
+ contractAddress: string,
198
+ roleId: string,
199
+ index: number,
200
+ networkConfig: StellarNetworkConfig
201
+ ): Promise<string | null> {
202
+ logger.debug('getRoleMember', `Getting member at index ${index} for role ${roleId}`);
203
+
204
+ try {
205
+ const inputs = [
206
+ { name: 'role', type: 'Symbol' },
207
+ { name: 'index', type: 'u32' },
208
+ ];
209
+ const result = await queryAccessControlFunction(
210
+ contractAddress,
211
+ 'get_role_member',
212
+ [roleId, index],
213
+ networkConfig,
214
+ inputs
215
+ );
216
+
217
+ if (result === undefined || result === null) {
218
+ return null;
219
+ }
220
+
221
+ return String(result);
222
+ } catch (error) {
223
+ logger.error('getRoleMember', `Failed to get role member at index ${index}:`, error);
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Enumerates all members of a specific role
230
+ *
231
+ * Fetches all members in parallel with controlled concurrency for improved
232
+ * performance while avoiding overwhelming RPC endpoints.
233
+ *
234
+ * @param contractAddress The contract address
235
+ * @param roleId The role identifier (Symbol)
236
+ * @param networkConfig The network configuration
237
+ * @returns Array of member addresses
238
+ */
239
+ export async function enumerateRoleMembers(
240
+ contractAddress: string,
241
+ roleId: string,
242
+ networkConfig: StellarNetworkConfig
243
+ ): Promise<string[]> {
244
+ logger.info('enumerateRoleMembers', `Enumerating members for role ${roleId}`);
245
+
246
+ try {
247
+ // Get the count of members
248
+ const count = await getRoleMemberCount(contractAddress, roleId, networkConfig);
249
+
250
+ logger.debug('enumerateRoleMembers', `Role ${roleId} has ${count} members`);
251
+
252
+ if (count === 0) {
253
+ return [];
254
+ }
255
+
256
+ // Create task functions for controlled concurrent execution
257
+ const memberTasks = Array.from(
258
+ { length: count },
259
+ (_, i) => () => getRoleMember(contractAddress, roleId, i, networkConfig)
260
+ );
261
+
262
+ // Fetch members with concurrency limit to avoid overwhelming RPC endpoints
263
+ const results = await promiseAllWithLimit(memberTasks, DEFAULT_CONCURRENCY_LIMIT);
264
+
265
+ // Filter out null results and return valid members
266
+ const members = results.filter((m): m is string => m !== null);
267
+
268
+ logger.debug('enumerateRoleMembers', `Retrieved ${members.length} members for role ${roleId}`);
269
+
270
+ return members;
271
+ } catch (error) {
272
+ logger.error('enumerateRoleMembers', `Failed to enumerate role ${roleId}:`, error);
273
+ throw new OperationFailed(
274
+ `Failed to enumerate role members: ${(error as Error).message}`,
275
+ contractAddress,
276
+ 'enumerateRoleMembers',
277
+ error as Error
278
+ );
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Reads all current role assignments for a contract
284
+ *
285
+ * Fetches all roles in parallel for improved performance.
286
+ *
287
+ * @param contractAddress The contract address
288
+ * @param roleIds Array of role identifiers to query
289
+ * @param networkConfig The network configuration
290
+ * @returns Array of role assignments
291
+ */
292
+ export async function readCurrentRoles(
293
+ contractAddress: string,
294
+ roleIds: string[],
295
+ networkConfig: StellarNetworkConfig
296
+ ): Promise<RoleAssignment[]> {
297
+ logger.info(
298
+ 'readCurrentRoles',
299
+ `Reading ${roleIds.length} roles for contract ${contractAddress}`
300
+ );
301
+
302
+ if (roleIds.length === 0) {
303
+ return [];
304
+ }
305
+
306
+ // Process all roles in parallel for improved performance
307
+ const assignmentPromises = roleIds.map(async (roleId) => {
308
+ const role: RoleIdentifier = {
309
+ id: roleId,
310
+ label: roleId.replace(/_/g, ' ').toLowerCase(),
311
+ };
312
+
313
+ try {
314
+ const members = await enumerateRoleMembers(contractAddress, roleId, networkConfig);
315
+
316
+ logger.debug('readCurrentRoles', `Role ${roleId} has ${members.length} members`);
317
+
318
+ return {
319
+ role,
320
+ members,
321
+ };
322
+ } catch (error) {
323
+ logger.warn('readCurrentRoles', `Failed to read role ${roleId}:`, error);
324
+ // Return role with empty members array to maintain array length consistency
325
+ return {
326
+ role,
327
+ members: [],
328
+ };
329
+ }
330
+ });
331
+
332
+ const assignments = await Promise.all(assignmentPromises);
333
+
334
+ logger.info(
335
+ 'readCurrentRoles',
336
+ `Completed reading ${assignments.length} roles with ${assignments.reduce((sum, a) => sum + a.members.length, 0)} total members`
337
+ );
338
+
339
+ return assignments;
340
+ }
341
+
342
+ /**
343
+ * Gets the admin role for a specific role
344
+ *
345
+ * @param contractAddress The contract address
346
+ * @param roleId The role identifier (Symbol)
347
+ * @param networkConfig The network configuration
348
+ * @returns The admin role identifier, or null if no admin role set
349
+ */
350
+ export async function getRoleAdmin(
351
+ contractAddress: string,
352
+ roleId: string,
353
+ networkConfig: StellarNetworkConfig
354
+ ): Promise<string | null> {
355
+ logger.debug('getRoleAdmin', `Getting admin role for ${roleId}`);
356
+
357
+ try {
358
+ const inputs = [{ name: 'role', type: 'Symbol' }];
359
+ const result = await queryAccessControlFunction(
360
+ contractAddress,
361
+ 'get_role_admin',
362
+ [roleId],
363
+ networkConfig,
364
+ inputs
365
+ );
366
+
367
+ // get_role_admin returns Option<Symbol>
368
+ if (result === undefined || result === null) {
369
+ return null;
370
+ }
371
+
372
+ return String(result);
373
+ } catch (error) {
374
+ logger.error('getRoleAdmin', `Failed to get admin role for ${roleId}:`, error);
375
+ return null;
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Gets the top-level admin account
381
+ *
382
+ * @param contractAddress The contract address
383
+ * @param networkConfig The network configuration
384
+ * @returns The admin address, or null if no admin set
385
+ */
386
+ export async function getAdmin(
387
+ contractAddress: string,
388
+ networkConfig: StellarNetworkConfig
389
+ ): Promise<string | null> {
390
+ logger.info('getAdmin', `Reading admin for contract ${contractAddress}`);
391
+
392
+ try {
393
+ const result = await queryAccessControlFunction(
394
+ contractAddress,
395
+ 'get_admin',
396
+ [],
397
+ networkConfig
398
+ );
399
+
400
+ // get_admin returns Option<Address>
401
+ if (result === undefined || result === null) {
402
+ return null;
403
+ }
404
+
405
+ return String(result);
406
+ } catch (error) {
407
+ logger.error('getAdmin', 'Failed to read admin:', error);
408
+ return null;
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Gets the current ledger sequence number from the Soroban RPC
414
+ *
415
+ * Used for two-step Ownable contracts to:
416
+ * - Calculate appropriate expiration ledgers for ownership transfers
417
+ * - Validate expiration ledgers before submitting transactions
418
+ * - Determine if pending ownership transfers have expired
419
+ *
420
+ * @param networkConfig The network configuration containing the Soroban RPC URL
421
+ * @returns Promise resolving to the current ledger sequence number
422
+ * @throws OperationFailed if the RPC call fails
423
+ *
424
+ * @example
425
+ * ```typescript
426
+ * const currentLedger = await getCurrentLedger(networkConfig);
427
+ * // Set expiration to ~1 hour from now (~720 ledgers at 5s/ledger)
428
+ * const expirationLedger = currentLedger + 720;
429
+ * ```
430
+ */
431
+ export async function getCurrentLedger(networkConfig: StellarNetworkConfig): Promise<number> {
432
+ logger.info('getCurrentLedger', `Fetching current ledger from ${networkConfig.sorobanRpcUrl}`);
433
+
434
+ try {
435
+ const server = new StellarRpc.Server(networkConfig.sorobanRpcUrl);
436
+ const latestLedger = await server.getLatestLedger();
437
+
438
+ logger.debug('getCurrentLedger', `Current ledger: ${latestLedger.sequence}`);
439
+
440
+ return latestLedger.sequence;
441
+ } catch (error) {
442
+ logger.error('getCurrentLedger', 'Failed to fetch current ledger:', error);
443
+ throw new OperationFailed(
444
+ `Failed to get current ledger: ${(error as Error).message}`,
445
+ networkConfig.sorobanRpcUrl,
446
+ 'getCurrentLedger',
447
+ error as Error
448
+ );
449
+ }
450
+ }