UNPKG

@openzeppelin/ui-builder-adapter-stellar

Version:
276 lines (246 loc) 8.54 kB
import type { StellarNetworkConfig, UserRpcProviderConfig } from '@openzeppelin/ui-builder-types'; import { appConfigService, isValidUrl, logger, userRpcConfigService, } from '@openzeppelin/ui-builder-utils'; /** * Builds a complete RPC URL from a user RPC provider configuration. * For Stellar (Soroban), this just returns the URL as-is since * users are providing complete RPC URLs including any API keys. * * @param config The user RPC provider configuration * @returns The RPC URL */ export function buildRpcUrl(config: UserRpcProviderConfig): string { return config.url; } /** * Resolves the RPC URL for a given Stellar network configuration. * Priority order: * 1. User-provided RPC configuration (from UserRpcConfigService) * 2. RPC URL override from AppConfigService * 3. Default sorobanRpcUrl from the network configuration * * @param networkConfig - The Stellar network configuration. * @returns The resolved RPC URL string. * @throws If no RPC URL can be resolved (neither user config, override, nor default is present and valid). */ export function resolveRpcUrl(networkConfig: StellarNetworkConfig): string { const logSystem = 'StellarRpcResolver'; const networkId = networkConfig.id; // First priority: Check user-provided RPC configuration const userRpcConfig = userRpcConfigService.getUserRpcConfig(networkId); if (userRpcConfig) { const userRpcUrl = buildRpcUrl(userRpcConfig); if (isValidUrl(userRpcUrl)) { logger.info( logSystem, `Using user-configured Soroban RPC URL for network ${networkId}: ${userRpcConfig.name || 'Custom'}` ); return userRpcUrl; } else { logger.warn( logSystem, `User-configured Soroban RPC URL for ${networkId} is invalid: ${userRpcUrl}. Falling back.` ); } } // Second priority: Check AppConfigService for an override const rpcOverrideSetting = appConfigService.getRpcEndpointOverride(networkId); let rpcUrlFromOverride: string | undefined; if (typeof rpcOverrideSetting === 'string') { rpcUrlFromOverride = rpcOverrideSetting; } else if (typeof rpcOverrideSetting === 'object' && rpcOverrideSetting) { // Check if it's a UserRpcProviderConfig if ('url' in rpcOverrideSetting && 'isCustom' in rpcOverrideSetting) { const userConfig = rpcOverrideSetting as UserRpcProviderConfig; rpcUrlFromOverride = buildRpcUrl(userConfig); } else if ('http' in rpcOverrideSetting) { // It's an RpcEndpointConfig rpcUrlFromOverride = rpcOverrideSetting.http; } } if (rpcUrlFromOverride) { logger.info( logSystem, `Using overridden Soroban RPC URL for network ${networkId}: ${rpcUrlFromOverride}` ); if (isValidUrl(rpcUrlFromOverride)) { return rpcUrlFromOverride; } else { logger.warn( logSystem, `Overridden Soroban RPC URL for ${networkId} is invalid: ${rpcUrlFromOverride}. Falling back.` ); } } // Third priority: Fallback to the sorobanRpcUrl in the networkConfig if (networkConfig.sorobanRpcUrl && isValidUrl(networkConfig.sorobanRpcUrl)) { logger.debug( logSystem, `Using default Soroban RPC URL for network ${networkId}: ${networkConfig.sorobanRpcUrl}` ); return networkConfig.sorobanRpcUrl; } logger.error( logSystem, `No valid Soroban RPC URL could be resolved for network ${networkId}. Checked user config, override, and networkConfig.sorobanRpcUrl.` ); throw new Error( `No valid Soroban RPC URL configured for network ${networkConfig.name} (ID: ${networkId}).` ); } /** * Validates an RPC endpoint configuration for Stellar networks. * @param rpcConfig - The RPC provider configuration to validate * @returns True if the configuration is valid, false otherwise */ export function validateStellarRpcEndpoint(rpcConfig: UserRpcProviderConfig): boolean { try { // Check if it's a valid URL (our validator already ensures HTTP/HTTPS) if (!isValidUrl(rpcConfig.url)) { logger.error('validateStellarRpcEndpoint', `Invalid RPC URL format: ${rpcConfig.url}`); return false; } // Additional Stellar-specific validation could be added here // For example, checking if the URL follows known provider patterns return true; } catch (error) { logger.error('validateStellarRpcEndpoint', 'Error validating RPC endpoint:', error); return false; } } /** * Tests the connection to a Stellar (Soroban) RPC endpoint with a timeout. * Uses the Soroban RPC getHealth method to test connectivity. * @param rpcConfig - The RPC provider configuration to test * @param timeoutMs - Timeout in milliseconds (default: 5000ms) * @returns Connection test results including success status, latency, and any errors */ export async function testStellarRpcConnection( rpcConfig: UserRpcProviderConfig, timeoutMs: number = 5000 ): Promise<{ success: boolean; latency?: number; error?: string; }> { if (!rpcConfig.url) { return { success: false, error: 'Soroban RPC URL is required' }; } // Create an AbortController for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const startTime = Date.now(); // Use fetch to make a JSON-RPC call to test the connection // Using getHealth method which is standard for Soroban RPC endpoints const response = await fetch(rpcConfig.url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getHealth', }), signal: controller.signal, }); if (!response.ok) { return { success: false, error: `HTTP error: ${response.status}` }; } const data = await response.json(); const latency = Date.now() - startTime; // Check for JSON-RPC error if (data.error) { return { success: false, error: `Soroban RPC error: ${data.error.message || 'Unknown RPC error'}`, }; } // For Soroban RPC getHealth, a successful response should contain result if (!data.result) { // Try fallback method - getLatestLedger return await testWithFallbackMethod(rpcConfig, controller.signal, startTime); } // Check if the health status indicates the service is healthy const healthStatus = data.result.status; if (healthStatus && healthStatus !== 'healthy') { return { success: false, error: `Soroban RPC service unhealthy: ${healthStatus}`, latency, }; } return { success: true, latency }; } catch (error) { logger.error('testStellarRpcConnection', 'Connection test failed:', error); // Check if the error was due to timeout if (error instanceof Error && error.name === 'AbortError') { return { success: false, error: `Connection timeout after ${timeoutMs}ms`, }; } // Try fallback method if primary test failed try { return await testWithFallbackMethod(rpcConfig, controller.signal, Date.now()); } catch { return { success: false, error: error instanceof Error ? error.message : 'Connection failed', }; } } finally { // Clear the timeout clearTimeout(timeoutId); } } /** * Fallback method to test Soroban RPC connection using getLatestLedger. * This is used when getHealth is not available or fails. */ async function testWithFallbackMethod( rpcConfig: UserRpcProviderConfig, signal: AbortSignal, startTime: number ): Promise<{ success: boolean; latency?: number; error?: string; }> { const response = await fetch(rpcConfig.url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getLatestLedger', }), signal, }); if (!response.ok) { return { success: false, error: `HTTP error: ${response.status}` }; } const data = await response.json(); const latency = Date.now() - startTime; if (data.error) { return { success: false, error: `Soroban RPC error: ${data.error.message || 'Unknown RPC error'}`, }; } // If we get a valid response with ledger info, the connection is working if (data.result && data.result.sequence) { return { success: true, latency }; } return { success: false, error: 'Unexpected response format from Soroban RPC endpoint', }; }