@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,256 @@
1
+ /**
2
+ * Address Validation Module for Access Control
3
+ *
4
+ * Centralizes address validation for access control operations.
5
+ * Uses Stellar-specific validation from the validation module and
6
+ * shared normalization utilities from @openzeppelin/ui-builder-utils.
7
+ */
8
+
9
+ import { ConfigurationInvalid } from '@openzeppelin/ui-builder-types';
10
+ import { normalizeAddress } from '@openzeppelin/ui-builder-utils';
11
+
12
+ import { isValidAccountAddress, isValidContractAddress } from '../validation/address';
13
+
14
+ /**
15
+ * Validates a Stellar contract address
16
+ *
17
+ * @param address The contract address to validate
18
+ * @param paramName Optional parameter name for error messages (defaults to 'contractAddress')
19
+ * @throws ConfigurationInvalid if the address is invalid
20
+ */
21
+ export function validateContractAddress(address: string, paramName = 'contractAddress'): void {
22
+ if (!address || typeof address !== 'string' || address.trim() === '') {
23
+ throw new ConfigurationInvalid(
24
+ `${paramName} is required and must be a non-empty string`,
25
+ address,
26
+ paramName
27
+ );
28
+ }
29
+
30
+ if (!isValidContractAddress(address)) {
31
+ throw new ConfigurationInvalid(
32
+ `Invalid Stellar contract address: ${address}. Contract addresses must start with 'C' and be valid StrKey format.`,
33
+ address,
34
+ paramName
35
+ );
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Validates a Stellar account address (for role grants, ownership transfers, etc.)
41
+ *
42
+ * @param address The account address to validate
43
+ * @param paramName Optional parameter name for error messages (defaults to 'account')
44
+ * @throws ConfigurationInvalid if the address is invalid
45
+ */
46
+ export function validateAccountAddress(address: string, paramName = 'account'): void {
47
+ if (!address || typeof address !== 'string' || address.trim() === '') {
48
+ throw new ConfigurationInvalid(
49
+ `${paramName} is required and must be a non-empty string`,
50
+ address,
51
+ paramName
52
+ );
53
+ }
54
+
55
+ if (!isValidAccountAddress(address)) {
56
+ throw new ConfigurationInvalid(
57
+ `Invalid Stellar account address: ${address}. Account addresses must start with 'G' and be valid Ed25519 public keys.`,
58
+ address,
59
+ paramName
60
+ );
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Validates a Stellar address that can be either an account or contract address
66
+ * (e.g., for ownership transfers where the new owner can be either type)
67
+ *
68
+ * @param address The address to validate (account or contract)
69
+ * @param paramName Optional parameter name for error messages (defaults to 'address')
70
+ * @throws ConfigurationInvalid if the address is invalid
71
+ */
72
+ export function validateAddress(address: string, paramName = 'address'): void {
73
+ if (!address || typeof address !== 'string' || address.trim() === '') {
74
+ throw new ConfigurationInvalid(
75
+ `${paramName} is required and must be a non-empty string`,
76
+ address,
77
+ paramName
78
+ );
79
+ }
80
+
81
+ // Check if it's a valid account address OR contract address
82
+ if (!isValidAccountAddress(address) && !isValidContractAddress(address)) {
83
+ throw new ConfigurationInvalid(
84
+ `Invalid Stellar address: ${address}. Address must be a valid account address (starts with 'G') or contract address (starts with 'C').`,
85
+ address,
86
+ paramName
87
+ );
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Validates both contract and account addresses for operations
93
+ *
94
+ * @param contractAddress The contract address
95
+ * @param accountAddress The account address
96
+ * @throws ConfigurationInvalid if either address is invalid
97
+ */
98
+ export function validateAddresses(contractAddress: string, accountAddress: string): void {
99
+ validateContractAddress(contractAddress);
100
+ validateAccountAddress(accountAddress);
101
+ }
102
+
103
+ /**
104
+ * Normalizes a Stellar address using shared utils
105
+ * This is useful for case-insensitive and whitespace-insensitive comparison
106
+ *
107
+ * @param address The address to normalize
108
+ * @returns The normalized address
109
+ */
110
+ export function normalizeStellarAddress(address: string): string {
111
+ return normalizeAddress(address);
112
+ }
113
+
114
+ /**
115
+ * Maximum length for a Soroban Symbol (role identifier)
116
+ * Soroban symbols are limited to 32 characters
117
+ */
118
+ const MAX_ROLE_ID_LENGTH = 32;
119
+
120
+ /**
121
+ * Valid pattern for Soroban Symbol characters
122
+ * Symbols can contain alphanumeric characters and underscores
123
+ */
124
+ const VALID_ROLE_ID_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
125
+
126
+ /**
127
+ * Validates a role identifier (Soroban Symbol)
128
+ *
129
+ * Role IDs must be:
130
+ * - Non-empty strings
131
+ * - Max 32 characters (Soroban Symbol limit)
132
+ * - Start with a letter or underscore
133
+ * - Contain only alphanumeric characters and underscores
134
+ *
135
+ * @param roleId The role identifier to validate
136
+ * @param paramName Optional parameter name for error messages
137
+ * @throws ConfigurationInvalid if the role ID is invalid
138
+ */
139
+ export function validateRoleId(roleId: string, paramName = 'roleId'): void {
140
+ if (!roleId || typeof roleId !== 'string') {
141
+ throw new ConfigurationInvalid(
142
+ `${paramName} is required and must be a non-empty string`,
143
+ roleId,
144
+ paramName
145
+ );
146
+ }
147
+
148
+ const trimmed = roleId.trim();
149
+ if (trimmed === '') {
150
+ throw new ConfigurationInvalid(
151
+ `${paramName} cannot be empty or whitespace-only`,
152
+ roleId,
153
+ paramName
154
+ );
155
+ }
156
+
157
+ if (trimmed.length > MAX_ROLE_ID_LENGTH) {
158
+ throw new ConfigurationInvalid(
159
+ `${paramName} exceeds maximum length of ${MAX_ROLE_ID_LENGTH} characters: "${trimmed}" (${trimmed.length} chars)`,
160
+ roleId,
161
+ paramName
162
+ );
163
+ }
164
+
165
+ if (!VALID_ROLE_ID_PATTERN.test(trimmed)) {
166
+ throw new ConfigurationInvalid(
167
+ `${paramName} contains invalid characters: "${trimmed}". Role IDs must start with a letter or underscore and contain only alphanumeric characters and underscores.`,
168
+ roleId,
169
+ paramName
170
+ );
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Validates an array of role identifiers
176
+ *
177
+ * @param roleIds The array of role identifiers to validate
178
+ * @param paramName Optional parameter name for error messages
179
+ * @throws ConfigurationInvalid if any role ID is invalid or if the array is invalid
180
+ * @returns The validated and deduplicated array of role IDs
181
+ */
182
+ export function validateRoleIds(roleIds: string[], paramName = 'roleIds'): string[] {
183
+ if (!Array.isArray(roleIds)) {
184
+ throw new ConfigurationInvalid(`${paramName} must be an array`, String(roleIds), paramName);
185
+ }
186
+
187
+ // Validate each role ID
188
+ for (let i = 0; i < roleIds.length; i++) {
189
+ validateRoleId(roleIds[i], `${paramName}[${i}]`);
190
+ }
191
+
192
+ // Deduplicate and return
193
+ return [...new Set(roleIds.map((r) => r.trim()))];
194
+ }
195
+
196
+ /**
197
+ * Result of expiration ledger validation
198
+ *
199
+ * Returned by {@link validateExpirationLedger} to indicate whether
200
+ * a proposed expiration ledger is valid for a two-step ownership transfer.
201
+ *
202
+ * @example
203
+ * ```typescript
204
+ * const currentLedger = await getCurrentLedger(networkConfig);
205
+ * const result = validateExpirationLedger(expirationLedger, currentLedger);
206
+ * if (!result.valid) {
207
+ * throw new ConfigurationInvalid(result.error!, String(expirationLedger), 'expirationLedger');
208
+ * }
209
+ * ```
210
+ */
211
+ export interface ExpirationValidationResult {
212
+ /** Whether the expiration ledger is valid (must be strictly greater than current ledger) */
213
+ valid: boolean;
214
+ /** The current ledger sequence used for comparison */
215
+ currentLedger: number;
216
+ /** Human-readable error message if validation failed */
217
+ error?: string;
218
+ }
219
+
220
+ /**
221
+ * Validates an expiration ledger against the current ledger sequence
222
+ *
223
+ * For two-step Ownable contracts, the expiration ledger must be strictly greater
224
+ * than the current ledger (per FR-020: expirationLedger == currentLedger is invalid).
225
+ *
226
+ * @param expirationLedger The proposed expiration ledger sequence
227
+ * @param currentLedger The current ledger sequence number
228
+ * @returns Validation result with valid flag, currentLedger, and optional error message
229
+ *
230
+ * @example
231
+ * ```typescript
232
+ * const currentLedger = await getCurrentLedger(networkConfig);
233
+ * const result = validateExpirationLedger(expirationLedger, currentLedger);
234
+ * if (!result.valid) {
235
+ * throw new ConfigurationInvalid(result.error, 'expirationLedger');
236
+ * }
237
+ * ```
238
+ */
239
+ export function validateExpirationLedger(
240
+ expirationLedger: number,
241
+ currentLedger: number
242
+ ): ExpirationValidationResult {
243
+ // Per FR-020: expirationLedger must be strictly greater than currentLedger
244
+ if (expirationLedger <= currentLedger) {
245
+ return {
246
+ valid: false,
247
+ currentLedger,
248
+ error: `Expiration ledger ${expirationLedger} has already passed or equals current ledger. Current ledger is ${currentLedger}. Expiration must be strictly greater than current ledger.`,
249
+ };
250
+ }
251
+
252
+ return {
253
+ valid: true,
254
+ currentLedger,
255
+ };
256
+ }
package/src/adapter.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type React from 'react';
2
2
 
3
3
  import type {
4
+ AccessControlService,
4
5
  AvailableUiKit,
5
6
  Connector,
6
7
  ContractAdapter,
@@ -28,6 +29,8 @@ import type {
28
29
  import { isStellarNetworkConfig } from '@openzeppelin/ui-builder-types';
29
30
  import { logger } from '@openzeppelin/ui-builder-utils';
30
31
 
32
+ import { getCurrentLedger } from './access-control/onchain-reader';
33
+ import { createStellarAccessControlService } from './access-control/service';
31
34
  import {
32
35
  getStellarNetworkServiceForms,
33
36
  testStellarNetworkServiceConnection,
@@ -83,6 +86,7 @@ import {
83
86
  export class StellarAdapter implements ContractAdapter {
84
87
  readonly networkConfig: StellarNetworkConfig;
85
88
  readonly initialAppServiceKitName: UiKitConfiguration['kitName'];
89
+ private readonly accessControlService: AccessControlService;
86
90
 
87
91
  constructor(networkConfig: StellarNetworkConfig) {
88
92
  if (!isStellarNetworkConfig(networkConfig)) {
@@ -90,6 +94,9 @@ export class StellarAdapter implements ContractAdapter {
90
94
  }
91
95
  this.networkConfig = networkConfig;
92
96
 
97
+ // Initialize Access Control Service
98
+ this.accessControlService = createStellarAccessControlService(networkConfig);
99
+
93
100
  // Set the network config in the wallet UI kit manager
94
101
  stellarUiKitManager.setNetworkConfig(networkConfig);
95
102
 
@@ -418,6 +425,13 @@ export class StellarAdapter implements ContractAdapter {
418
425
  return null;
419
426
  }
420
427
 
428
+ /**
429
+ * @inheritdoc
430
+ */
431
+ async getCurrentBlock(): Promise<number> {
432
+ return getCurrentLedger(this.networkConfig);
433
+ }
434
+
421
435
  /**
422
436
  * @inheritdoc
423
437
  */
@@ -615,6 +629,13 @@ export class StellarAdapter implements ContractAdapter {
615
629
  lastTransaction: 'Last Transaction',
616
630
  };
617
631
  }
632
+
633
+ /**
634
+ * @inheritdoc
635
+ */
636
+ public getAccessControlService(): AccessControlService {
637
+ return this.accessControlService;
638
+ }
618
639
  }
619
640
 
620
641
  // Also export as default to ensure compatibility with various import styles
@@ -1,13 +1,17 @@
1
1
  import type { NetworkServiceForm, UserRpcProviderConfig } from '@openzeppelin/ui-builder-types';
2
+ import { isValidUrl } from '@openzeppelin/ui-builder-utils';
2
3
 
3
4
  import { testStellarRpcConnection, validateStellarRpcEndpoint } from './rpc';
4
5
 
5
6
  /**
6
7
  * Returns the network service forms for Stellar networks.
7
- * Defines the UI configuration for the RPC service.
8
+ * Defines the UI configuration for the RPC and Indexer services.
9
+ *
10
+ * @param exclude Optional array of service IDs to exclude from the returned forms
11
+ * @returns Array of network service forms
8
12
  */
9
- export function getStellarNetworkServiceForms(): NetworkServiceForm[] {
10
- return [
13
+ export function getStellarNetworkServiceForms(exclude: string[] = []): NetworkServiceForm[] {
14
+ const forms: NetworkServiceForm[] = [
11
15
  {
12
16
  id: 'rpc',
13
17
  label: 'RPC Provider',
@@ -23,7 +27,37 @@ export function getStellarNetworkServiceForms(): NetworkServiceForm[] {
23
27
  },
24
28
  ],
25
29
  },
30
+ {
31
+ id: 'indexer',
32
+ label: 'Indexer',
33
+ description: 'Optional GraphQL indexer endpoint for historical access control data',
34
+ supportsConnectionTest: true,
35
+ fields: [
36
+ {
37
+ id: 'stellar-indexer-uri',
38
+ name: 'indexerUri',
39
+ type: 'text',
40
+ label: 'Indexer GraphQL HTTP Endpoint',
41
+ placeholder: 'https://indexer.example.com/graphql',
42
+ validation: { required: false, pattern: '^https?://.+' },
43
+ width: 'full',
44
+ helperText: 'Optional. Used for querying historical access control events.',
45
+ },
46
+ {
47
+ id: 'stellar-indexer-ws-uri',
48
+ name: 'indexerWsUri',
49
+ type: 'text',
50
+ label: 'Indexer GraphQL WebSocket Endpoint',
51
+ placeholder: 'wss://indexer.example.com/graphql',
52
+ validation: { required: false, pattern: '^wss?://.+' },
53
+ width: 'full',
54
+ helperText: 'Optional. Used for real-time subscriptions.',
55
+ },
56
+ ],
57
+ },
26
58
  ];
59
+
60
+ return forms.filter((form) => !exclude.includes(form.id));
27
61
  }
28
62
 
29
63
  /**
@@ -33,9 +67,37 @@ export async function validateStellarNetworkServiceConfig(
33
67
  serviceId: string,
34
68
  values: Record<string, unknown>
35
69
  ): Promise<boolean> {
36
- if (serviceId !== 'rpc') return true;
37
- const cfg = { url: String(values.sorobanRpcUrl || ''), isCustom: true } as UserRpcProviderConfig;
38
- return validateStellarRpcEndpoint(cfg);
70
+ if (serviceId === 'rpc') {
71
+ const cfg = {
72
+ url: String(values.sorobanRpcUrl || ''),
73
+ isCustom: true,
74
+ } as UserRpcProviderConfig;
75
+ return validateStellarRpcEndpoint(cfg);
76
+ }
77
+
78
+ if (serviceId === 'indexer') {
79
+ // Validate indexerUri if provided
80
+ if (values.indexerUri !== undefined && values.indexerUri !== null && values.indexerUri !== '') {
81
+ if (!isValidUrl(String(values.indexerUri))) {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ // Validate indexerWsUri if provided
87
+ if (
88
+ values.indexerWsUri !== undefined &&
89
+ values.indexerWsUri !== null &&
90
+ values.indexerWsUri !== ''
91
+ ) {
92
+ if (!isValidUrl(String(values.indexerWsUri))) {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ return true;
98
+ }
99
+
100
+ return true;
39
101
  }
40
102
 
41
103
  /**
@@ -45,7 +107,66 @@ export async function testStellarNetworkServiceConnection(
45
107
  serviceId: string,
46
108
  values: Record<string, unknown>
47
109
  ): Promise<{ success: boolean; latency?: number; error?: string }> {
48
- if (serviceId !== 'rpc') return { success: true };
49
- const cfg = { url: String(values.sorobanRpcUrl || ''), isCustom: true } as UserRpcProviderConfig;
50
- return testStellarRpcConnection(cfg);
110
+ if (serviceId === 'rpc') {
111
+ const cfg = {
112
+ url: String(values.sorobanRpcUrl || ''),
113
+ isCustom: true,
114
+ } as UserRpcProviderConfig;
115
+ return testStellarRpcConnection(cfg);
116
+ }
117
+
118
+ if (serviceId === 'indexer') {
119
+ const indexerUri = values.indexerUri;
120
+
121
+ // If no indexer URI is provided, indexer is optional - return success (nothing to test)
122
+ if (!indexerUri || typeof indexerUri !== 'string' || indexerUri.trim() === '') {
123
+ return { success: true };
124
+ }
125
+
126
+ if (!isValidUrl(indexerUri)) {
127
+ return { success: false, error: 'Invalid indexer URI format' };
128
+ }
129
+
130
+ try {
131
+ const startTime = Date.now();
132
+ // Perform a simple GraphQL introspection query to test connectivity
133
+ const response = await fetch(indexerUri, {
134
+ method: 'POST',
135
+ headers: {
136
+ 'Content-Type': 'application/json',
137
+ },
138
+ body: JSON.stringify({
139
+ query: '{ __typename }',
140
+ }),
141
+ });
142
+
143
+ const latency = Date.now() - startTime;
144
+
145
+ if (!response.ok) {
146
+ return {
147
+ success: false,
148
+ latency,
149
+ error: `HTTP ${response.status}: ${response.statusText}`,
150
+ };
151
+ }
152
+
153
+ const data = await response.json();
154
+ if (data.errors) {
155
+ return {
156
+ success: false,
157
+ latency,
158
+ error: `GraphQL errors: ${JSON.stringify(data.errors)}`,
159
+ };
160
+ }
161
+
162
+ return { success: true, latency };
163
+ } catch (error) {
164
+ return {
165
+ success: false,
166
+ error: error instanceof Error ? error.message : 'Unknown error',
167
+ };
168
+ }
169
+ }
170
+
171
+ return { success: true };
51
172
  }
@@ -16,4 +16,5 @@ export const stellarPublic: StellarNetworkConfig = {
16
16
  networkPassphrase: 'Public Global Stellar Network ; September 2015',
17
17
  explorerUrl: 'https://stellar.expert/explorer/public',
18
18
  iconComponent: NetworkStellar,
19
+ // indexerUri and indexerWsUri will be added here when stable mainnet indexer endpoints are available
19
20
  };
@@ -16,4 +16,5 @@ export const stellarTestnet: StellarNetworkConfig = {
16
16
  networkPassphrase: 'Test SDF Network ; September 2015',
17
17
  explorerUrl: 'https://stellar.expert/explorer/testnet',
18
18
  iconComponent: NetworkStellar,
19
+ // indexerUri and indexerWsUri will be added here when stable testnet indexer endpoints are available
19
20
  };
@@ -3,7 +3,6 @@ import {
3
3
  Address,
4
4
  BASE_FEE,
5
5
  Contract,
6
- Keypair,
7
6
  nativeToScVal,
8
7
  rpc as StellarRpc,
9
8
  TransactionBuilder,
@@ -61,9 +60,12 @@ async function createSimulationTransaction(
61
60
  ): Promise<TransactionBuilder> {
62
61
  try {
63
62
  // Create a dummy source account for simulation
64
- // We'll use a well-known testnet account for simulation
65
- const dummyKeypair = Keypair.random();
66
- const sourceAccount = new Account(dummyKeypair.publicKey(), '0');
63
+ // For simulations, we only need a valid public key - no private key required.
64
+ // Using a hardcoded test public key avoids Keypair.random() issues in test environments
65
+ // where @noble/curves crypto may not work correctly.
66
+ // This is a valid Stellar public key (the "zero" key derived from 32 zero bytes).
67
+ const SIMULATION_PUBLIC_KEY = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF';
68
+ const sourceAccount = new Account(SIMULATION_PUBLIC_KEY, '0');
67
69
 
68
70
  // Create contract instance
69
71
  const contract = new Contract(contractAddress);
package/src/sac/xdr.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import { xdr } from '@stellar/stellar-sdk';
2
+ import stellarXdrJsonPackage from '@stellar/stellar-xdr-json/package.json' with { type: 'json' };
2
3
  import { parse, stringify } from 'lossless-json';
3
4
 
4
- import stellarXdrJsonPackage from '@stellar/stellar-xdr-json/package.json' assert { type: 'json' };
5
-
6
5
  import { logger } from '@openzeppelin/ui-builder-utils';
7
6
 
8
7
  /**
@@ -14,6 +14,7 @@ import type {
14
14
  } from '@openzeppelin/ui-builder-types';
15
15
  import { logger, userRpcConfigService } from '@openzeppelin/ui-builder-utils';
16
16
 
17
+ import { CALLER_PLACEHOLDER } from '../access-control/actions';
17
18
  import { valueToScVal } from '../transform/input-parser';
18
19
  import { getStellarWalletConnectionStatus, signTransaction } from '../wallet/connection';
19
20
  import { ExecutionStrategy } from './execution-strategy';
@@ -93,8 +94,14 @@ export class EoaExecutionStrategy implements ExecutionStrategy {
93
94
  networkPassphrase: stellarConfig.networkPassphrase,
94
95
  });
95
96
 
97
+ // Replace CALLER_PLACEHOLDER with the connected wallet address
98
+ // This supports OpenZeppelin Stellar access control functions that require a caller parameter
99
+ const resolvedArgs = txData.args.map((arg) =>
100
+ arg === CALLER_PLACEHOLDER ? connectedAddress : arg
101
+ );
102
+
96
103
  // Add the contract call operation (convert args to ScVal with comprehensive type support)
97
- const scValArgs = txData.args.map((arg, index) => {
104
+ const scValArgs = resolvedArgs.map((arg, index) => {
98
105
  const argType = txData.argTypes[index];
99
106
  const argSchema = txData.argSchema?.[index]; // Pass schema for struct field type resolution
100
107
 
@@ -27,6 +27,7 @@ import type {
27
27
  } from '@openzeppelin/ui-builder-types';
28
28
  import { logger } from '@openzeppelin/ui-builder-utils';
29
29
 
30
+ import { CALLER_PLACEHOLDER } from '../access-control/actions';
30
31
  import { valueToScVal } from '../transform/input-parser';
31
32
  import { getStellarWalletConnectionStatus, signTransaction } from '../wallet/connection';
32
33
  import { ExecutionStrategy } from './execution-strategy';
@@ -371,7 +372,13 @@ export class RelayerExecutionStrategy implements ExecutionStrategy {
371
372
  networkPassphrase: stellarConfig.networkPassphrase,
372
373
  });
373
374
 
374
- const scValArgs = txData.args.map((arg, index) => {
375
+ // Replace CALLER_PLACEHOLDER with the connected wallet address
376
+ // This supports OpenZeppelin Stellar access control functions that require a caller parameter
377
+ const resolvedArgs = txData.args.map((arg) =>
378
+ arg === CALLER_PLACEHOLDER ? connectedAddress : arg
379
+ );
380
+
381
+ const scValArgs = resolvedArgs.map((arg, index) => {
375
382
  const argType = txData.argTypes[index];
376
383
  const argSchema = txData.argSchema?.[index];
377
384
  return valueToScVal(arg, argType, argSchema);