From 43672768a483b483ec7815ba7e129a0eea2f7a73 Mon Sep 17 00:00:00 2001 From: abore9769 Date: Tue, 24 Mar 2026 22:49:55 +0000 Subject: [PATCH] feat: add contract and horizon validation - Implement Soroban contract address format validation (56-char base32, 'C' prefix) - Add network connectivity checks with configurable timeouts (default 5s) - Distinguish between validation errors, transient errors, and configuration errors - Provide actionable error messages for deployment troubleshooting - Comprehensive unit and integration tests with property-based testing - Support async endpoint validation for pre-deployment verification Resolves issue #052 --- .../customization/validate-endpoints.test.ts | 279 +++++++++++++ .../src/lib/customization/validate.test.ts | 64 +++ apps/web/src/lib/customization/validate.ts | 94 ++++- .../lib/stellar/contract-validation.test.ts | 268 ++++++++++++ .../src/lib/stellar/contract-validation.ts | 97 +++++ .../lib/stellar/endpoint-connectivity.test.ts | 391 ++++++++++++++++++ .../src/lib/stellar/endpoint-connectivity.ts | 306 ++++++++++++++ 7 files changed, 1498 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/customization/validate-endpoints.test.ts create mode 100644 apps/web/src/lib/stellar/contract-validation.test.ts create mode 100644 apps/web/src/lib/stellar/contract-validation.ts create mode 100644 apps/web/src/lib/stellar/endpoint-connectivity.test.ts create mode 100644 apps/web/src/lib/stellar/endpoint-connectivity.ts diff --git a/apps/web/src/lib/customization/validate-endpoints.test.ts b/apps/web/src/lib/customization/validate-endpoints.test.ts new file mode 100644 index 0000000..64e4b02 --- /dev/null +++ b/apps/web/src/lib/customization/validate-endpoints.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { validateStellarEndpoints } from './validate'; +import type { CustomizationConfig } from '@craft/types'; + +// ── Test Setup ─────────────────────────────────────────────────────────────── + +let fetchMock: typeof global.fetch; + +beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock as any; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +// ── Test Config ────────────────────────────────────────────────────────────── + +const testConfig: CustomizationConfig = { + branding: { + appName: 'Test App', + primaryColor: '#ff0000', + secondaryColor: '#00ff00', + fontFamily: 'Inter', + }, + features: { + enableCharts: true, + enableTransactionHistory: true, + enableAnalytics: false, + enableNotifications: false, + }, + stellar: { + network: 'testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + }, +}; + +// ── Mock Helpers ───────────────────────────────────────────────────────────── + +function mockFetchSuccess(responseTime: number = 50) { + return vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, responseTime)); + return { + ok: true, + status: 200, + statusText: 'OK', + }; + }); +} + +function mockFetchFailure(status: number = 503, responseTime: number = 50) { + return vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, responseTime)); + return { + ok: false, + status, + statusText: 'Service Unavailable', + }; + }); +} + +function mockFetchTimeout() { + return vi.fn( + () => + new Promise((_, reject) => { + setTimeout( + () => reject(new DOMException('The operation was aborted.', 'AbortError')), + 100 + ); + }) + ); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('validateStellarEndpoints', () => { + describe('successful endpoint checks', () => { + it('returns valid when both endpoints are reachable', async () => { + global.fetch = mockFetchSuccess(); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.valid).toBe(true); + expect(result.horizon.reachable).toBe(true); + expect(result.sorobanRpc?.reachable).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + it('returns valid when only Horizon is configured and reachable', async () => { + global.fetch = mockFetchSuccess(); + + const config = { ...testConfig, stellar: { ...testConfig.stellar, sorobanRpcUrl: undefined } }; + const result = await validateStellarEndpoints(config); + + expect(result.valid).toBe(true); + expect(result.horizon.reachable).toBe(true); + expect(result.sorobanRpc).toBeUndefined(); + }); + + it('includes response time metrics', async () => { + global.fetch = mockFetchSuccess(100); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.horizon.responseTime).toBeGreaterThanOrEqual(100); + }); + }); + + describe('Horizon endpoint failures', () => { + it('returns invalid when Horizon is unreachable (transient error)', async () => { + let callCount = 0; + global.fetch = vi.fn(async () => { + callCount++; + if (callCount === 1) { + return { ok: false, status: 503 }; + } + return { ok: false, status: 503 }; + }); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.valid).toBe(false); + expect(result.horizon.reachable).toBe(false); + expect(result.horizon.errorType).toBe('TRANSIENT'); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].field).toBe('stellar.horizonUrl'); + expect(result.errors?.[0].code).toBe('HORIZON_TRANSIENT_ERROR'); + expect(result.errors?.[0].message).toContain('temporarily unreachable'); + }); + + it('returns CONFIGURATION error for 404 on Horizon', async () => { + let callCount = 0; + global.fetch = vi.fn(async () => { + callCount++; + if (callCount === 1) { + return { ok: false, status: 404 }; + } + return { ok: false, status: 503 }; + }); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.valid).toBe(false); + expect(result.horizon.errorType).toBe('CONFIGURATION'); + expect(result.errors?.[0].code).toBe('HORIZON_CONFIGURATION_ERROR'); + expect(result.errors?.[0].message).toContain('not reachable'); + }); + + it('returns VALIDATION error for invalid Horizon URL format', async () => { + const config = { ...testConfig, stellar: { ...testConfig.stellar, horizonUrl: 'invalid-url' } }; + const result = await validateStellarEndpoints(config); + + expect(result.valid).toBe(false); + expect(result.horizon.reachable).toBe(false); + expect(result.horizon.errorType).toBe('VALIDATION'); + expect(result.errors?.[0].code).toBe('HORIZON_VALIDATION_ERROR'); + }); + + it('does not call fetch for invalid Horizon URL', async () => { + const config = { ...testConfig, stellar: { ...testConfig.stellar, horizonUrl: 'invalid' } }; + await validateStellarEndpoints(config); + + // Fetch shouldn't be called (or only called once for Soroban after Horizon validation fails) + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('Soroban RPC endpoint failures', () => { + it('returns invalid when Soroban RPC is unreachable (transient)', async () => { + let callCount = 0; + global.fetch = vi.fn(async () => { + callCount++; + if (callCount === 1) { + return { ok: true, status: 200 }; // Horizon OK + } + return { ok: false, status: 503 }; // Soroban RPC fails + }); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.valid).toBe(false); + expect(result.horizon.reachable).toBe(true); + expect(result.sorobanRpc?.reachable).toBe(false); + expect(result.sorobanRpc?.errorType).toBe('TRANSIENT'); + expect(result.errors).toBeDefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].field).toBe('stellar.sorobanRpcUrl'); + expect(result.errors?.[0].code).toBe('SOROBAN_TRANSIENT_ERROR'); + }); + + it('returns CONFIGURATION error for 404 on Soroban RPC', async () => { + let callCount = 0; + global.fetch = vi.fn(async () => { + callCount++; + if (callCount === 1) { + return { ok: true, status: 200 }; + } + return { ok: false, status: 404 }; + }); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.valid).toBe(false); + expect(result.sorobanRpc?.errorType).toBe('CONFIGURATION'); + expect(result.errors?.[0].code).toBe('SOROBAN_CONFIGURATION_ERROR'); + }); + }); + + describe('multiple endpoint failures', () => { + it('returns errors for both Horizon and Soroban when both fail', async () => { + global.fetch = mockFetchFailure(503); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.valid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors?.length).toBe(2); + expect(result.errors?.[0].field).toBe('stellar.horizonUrl'); + expect(result.errors?.[1].field).toBe('stellar.sorobanRpcUrl'); + }); + }); + + describe('timeout handling', () => { + it('respects custom timeout option', async () => { + global.fetch = mockFetchTimeout(); + + const result = await validateStellarEndpoints(testConfig, { timeout: 200 }); + + expect(result.valid).toBe(false); + expect(result.horizon.errorType).toBe('TRANSIENT'); + }); + }); + + describe('endpoint URLs in results', () => { + it('includes Horizon URL in result', async () => { + global.fetch = mockFetchSuccess(); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.horizon.endpoint).toBe('https://horizon-testnet.stellar.org'); + }); + + it('includes Soroban RPC URL in result', async () => { + global.fetch = mockFetchSuccess(); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.sorobanRpc?.endpoint).toBe('https://soroban-testnet.stellar.org'); + }); + }); + + describe('error differentiation', () => { + it('clearly distinguishes transient errors for retry guidance', async () => { + global.fetch = mockFetchFailure(503); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.errors?.[0].message).toContain('temporarily unreachable'); + expect(result.errors?.[0].message).toContain('retry'); + }); + + it('provides actionable guidance for configuration errors', async () => { + let callCount = 0; + global.fetch = vi.fn(async () => { + callCount++; + if (callCount === 1) { + return { ok: false, status: 404 }; + } + return { ok: true, status: 200 }; + }); + + const result = await validateStellarEndpoints(testConfig); + + expect(result.errors?.[0].message).toContain('Check configuration'); + }); + }); +}); diff --git a/apps/web/src/lib/customization/validate.test.ts b/apps/web/src/lib/customization/validate.test.ts index 7e88675..ddcf760 100644 --- a/apps/web/src/lib/customization/validate.test.ts +++ b/apps/web/src/lib/customization/validate.test.ts @@ -115,4 +115,68 @@ describe('validateCustomizationConfig', () => { }); expect(result.valid).toBe(true); }); + + // ── Contract address validation ──────────────────────────────────────────── + + it('accepts config without contract addresses', () => { + const result = validateCustomizationConfig(valid); + expect(result.valid).toBe(true); + }); + + it('accepts config with valid contract addresses', () => { + const result = validateCustomizationConfig({ + ...valid, + stellar: { + ...valid.stellar, + contractAddresses: { + usdcContract: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST', + nativeTokenContract: 'CATPNZ2SJRSVZJBWXGFSMZQHQ47JM5PXNQRVJLGHGHVKPZ2OVH3FHXP', + }, + }, + }); + expect(result.valid).toBe(true); + }); + + it('returns error for invalid contract address (wrong length)', () => { + const result = validateCustomizationConfig({ + ...valid, + stellar: { + ...valid.stellar, + contractAddresses: { + badContract: 'CBQWI64FZ2NKSJC7D45HJZ', + }, + }, + }); + expect(result.valid).toBe(false); + expect(result.errors[0].field).toBe('stellar.contractAddresses.badContract'); + expect(result.errors[0].code).toBe('CONTRACT_ADDRESS_INVALID_LENGTH'); + }); + + it('returns error for invalid contract address (wrong prefix)', () => { + const result = validateCustomizationConfig({ + ...valid, + stellar: { + ...valid.stellar, + contractAddresses: { + badContract: 'GBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST', + }, + }, + }); + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe('CONTRACT_ADDRESS_INVALID_PREFIX'); + }); + + it('returns error for contract with invalid characters', () => { + const result = validateCustomizationConfig({ + ...valid, + stellar: { + ...valid.stellar, + contractAddresses: { + badContract: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7-FWVGNQST', + }, + }, + }); + expect(result.valid).toBe(false); + expect(result.errors[0].code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET'); + }); }); diff --git a/apps/web/src/lib/customization/validate.ts b/apps/web/src/lib/customization/validate.ts index 0ac41a2..f6849d4 100644 --- a/apps/web/src/lib/customization/validate.ts +++ b/apps/web/src/lib/customization/validate.ts @@ -1,5 +1,11 @@ import { z } from 'zod'; import type { CustomizationConfig, ValidationResult, ValidationError } from '@craft/types'; +import { validateContractAddresses } from '@/lib/stellar/contract-validation'; +import { + checkStellarEndpoints, + type ConnectivityCheckResult, + type ConnectivityErrorType, +} from '@/lib/stellar/endpoint-connectivity'; // ── Zod schema (single source of truth) ────────────────────────────────────── @@ -35,7 +41,7 @@ const TESTNET_HORIZON = 'https://horizon-testnet.stellar.org'; function businessRuleErrors(config: CustomizationConfig): ValidationError[] { const errors: ValidationError[] = []; - const { network, horizonUrl } = config.stellar; + const { network, horizonUrl, contractAddresses } = config.stellar; if (network === 'mainnet' && horizonUrl === TESTNET_HORIZON) { errors.push({ @@ -61,6 +67,16 @@ function businessRuleErrors(config: CustomizationConfig): ValidationError[] { }); } + // Validate contract addresses if provided + const contractValidation = validateContractAddresses(contractAddresses); + if (!contractValidation.valid) { + errors.push({ + field: contractValidation.field, + message: contractValidation.reason, + code: contractValidation.code, + }); + } + return errors; } @@ -90,3 +106,79 @@ export function validateCustomizationConfig(input: unknown): ValidationResult { return { valid: true, errors: [] }; } + +// ── Endpoint Connectivity Validation (Async) ───────────────────────────────── + +export interface EndpointValidationResult { + valid: boolean; + horizon: ConnectivityCheckResult; + sorobanRpc?: ConnectivityCheckResult; + errors?: ValidationError[]; +} + +/** + * Validate that configured Stellar endpoints are reachable. + * Checks both Horizon and optional Soroban RPC endpoints with timeouts. + * Distinguishes between transient errors (retry-able) and configuration errors. + * + * Should be called during deployment or after configuration changes. + * + * @param config - Customization config with Stellar endpoints + * @param options - Optional timeout in milliseconds (default 5000) + * @returns Endpoint validation result with reachability status and error details + */ +export async function validateStellarEndpoints( + config: CustomizationConfig, + options?: { timeout?: number } +): Promise { + const { horizonUrl, sorobanRpcUrl } = config.stellar; + + const checks = await checkStellarEndpoints(horizonUrl, sorobanRpcUrl, options); + + // First check must be Horizon + const horizonResult = checks[0]; + const sorobanResult = checks[1]; + + const errors: ValidationError[] = []; + + // Check if critical Horizon endpoint is unreachable + if (!horizonResult.reachable) { + const errorType = horizonResult.errorType; + const errorMessage = + errorType === 'VALIDATION' + ? `Invalid Horizon URL format: ${horizonResult.error}` + : errorType === 'TRANSIENT' + ? `Horizon endpoint temporarily unreachable (${horizonResult.error}). Please retry deployment.` + : `Horizon endpoint not reachable (${horizonResult.error}). Check configuration.`; + + errors.push({ + field: 'stellar.horizonUrl', + message: errorMessage, + code: `HORIZON_${errorType}_ERROR`, + }); + } + + // Check optional Soroban RPC endpoint if configured + if (sorobanResult && !sorobanResult.reachable) { + const errorType = sorobanResult.errorType; + const errorMessage = + errorType === 'VALIDATION' + ? `Invalid Soroban RPC URL format: ${sorobanResult.error}` + : errorType === 'TRANSIENT' + ? `Soroban RPC endpoint temporarily unreachable (${sorobanResult.error}). Please retry deployment.` + : `Soroban RPC endpoint not reachable (${sorobanResult.error}). Check configuration.`; + + errors.push({ + field: 'stellar.sorobanRpcUrl', + message: errorMessage, + code: `SOROBAN_${errorType}_ERROR`, + }); + } + + return { + valid: errors.length === 0, + horizon: horizonResult, + sorobanRpc: sorobanResult, + errors: errors.length > 0 ? errors : undefined, + }; +} diff --git a/apps/web/src/lib/stellar/contract-validation.test.ts b/apps/web/src/lib/stellar/contract-validation.test.ts new file mode 100644 index 0000000..011bc9b --- /dev/null +++ b/apps/web/src/lib/stellar/contract-validation.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { + validateContractAddress, + validateContractAddresses, + type ContractValidationResult, +} from './contract-validation'; + +// ── Valid Contract Addresses ───────────────────────────────────────────────── + +const VALID_TESTNET_CONTRACTS = { + usdcContract: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST', + nativeTokenContract: 'CATPNZ2SJRSVZJBWXGFSMZQHQ47JM5PXNQRVJLGHGHVKPZ2OVH3FHXP', +}; + +const VALID_MAINNET_CONTRACTS = { + someContract: 'CATHQD7JDJFQ4WVQXVJDAJX4CSJM3XDYPRMHMV35FVPVLCZDWJYC5WD', +}; + +// ── Invalid Contract Addresses ─────────────────────────────────────────────── + +const INVALID_CONTRACTS = { + tooShort: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHK', + tooLong: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQSTX', + wrongPrefix: 'GBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST', + invalidCharacters: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7-FWVGNQST', + invalidChars2: 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQSI', // I is invalid (not base32) +}; + +// ── Arbitraries for Property-Based Tests ───────────────────────────────────── + +// Valid contract arbitraries: 55 chars of base32 + 'C' prefix +const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +const arbValidContractAddress = fc + .string({ minLength: 55, maxLength: 55, characters: fc.constantFrom(...validChars.split('')) }) + .map((chars) => `C${chars}`); + +// Contract names +const arbContractName = fc.regex(/^[a-zA-Z][a-zA-Z0-9]*$/); + +// ── Unit Tests: validateContractAddress ────────────────────────────────────── + +describe('validateContractAddress', () => { + describe('valid addresses', () => { + it('accepts valid testnet contract address', () => { + const result = validateContractAddress(VALID_TESTNET_CONTRACTS.usdcContract); + expect(result).toEqual({ valid: true }); + }); + + it('accepts another valid testnet contract', () => { + const result = validateContractAddress(VALID_TESTNET_CONTRACTS.nativeTokenContract); + expect(result).toEqual({ valid: true }); + }); + + it('accepts valid mainnet contract address', () => { + const result = validateContractAddress(VALID_MAINNET_CONTRACTS.someContract); + expect(result).toEqual({ valid: true }); + }); + }); + + describe('format validation', () => { + it('rejects empty string', () => { + const result = validateContractAddress(''); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_EMPTY'); + }); + + it('rejects null address', () => { + const result = validateContractAddress(null as any); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_NOT_STRING'); + }); + + it('rejects undefined address', () => { + const result = validateContractAddress(undefined as any); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_NOT_STRING'); + }); + + it('rejects number input', () => { + const result = validateContractAddress(123 as any); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_NOT_STRING'); + }); + }); + + describe('length validation', () => { + it('rejects address too short', () => { + const result = validateContractAddress(INVALID_CONTRACTS.tooShort); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_LENGTH'); + }); + + it('rejects address too long', () => { + const result = validateContractAddress(INVALID_CONTRACTS.tooLong); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_LENGTH'); + }); + }); + + describe('prefix validation', () => { + it('rejects address with wrong prefix (G)', () => { + const result = validateContractAddress(INVALID_CONTRACTS.wrongPrefix); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_PREFIX'); + }); + + it('rejects address with lowercase prefix', () => { + const result = validateContractAddress('c' + VALID_TESTNET_CONTRACTS.usdcContract.slice(1)); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_PREFIX'); + }); + }); + + describe('charset validation', () => { + it('rejects address with invalid characters', () => { + const result = validateContractAddress(INVALID_CONTRACTS.invalidCharacters); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET'); + }); + + it('rejects address with I (invalid base32)', () => { + const result = validateContractAddress(INVALID_CONTRACTS.invalidChars2); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET'); + }); + + it('rejects address with O (invalid base32)', () => { + const result = validateContractAddress('CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7OFWVGNQST'); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_CHARSET'); + }); + }); +}); + +// ── Property Tests: validateContractAddress ────────────────────────────────── + +describe('validateContractAddress — Property Tests', () => { + it('always validates generated valid addresses', () => { + fc.assert( + fc.property(arbValidContractAddress, (address) => { + const result = validateContractAddress(address); + expect(result.valid).toBe(true); + }) + ); + }); + + it('always rejects addresses shorter than 56 chars', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 55 }), (len) => { + const shortAddr = 'C' + 'A'.repeat(len - 1); + const result = validateContractAddress(shortAddr); + expect(result.valid).toBe(false); + expect(result.code).toBe('CONTRACT_ADDRESS_INVALID_LENGTH'); + }) + ); + }); + + it('always rejects addresses starting with non-C character', () => { + fc.assert( + fc.property( + fc.constantFrom(...'GABDEFGHIJKLMNOPQRSTUVWXYZ234567'.split('')), + (prefix) => { + const addr = prefix + 'A'.repeat(55); + const result = validateContractAddress(addr); + if (prefix !== 'C') { + expect(result.valid).toBe(false); + } + } + ) + ); + }); +}); + +// ── Unit Tests: validateContractAddresses ──────────────────────────────────── + +describe('validateContractAddresses', () => { + it('returns valid for undefined contracts', () => { + const result = validateContractAddresses(undefined); + expect(result.valid).toBe(true); + }); + + it('returns valid for empty contract object', () => { + const result = validateContractAddresses({}); + expect(result.valid).toBe(true); + }); + + it('returns valid for valid contracts', () => { + const result = validateContractAddresses(VALID_TESTNET_CONTRACTS); + expect(result.valid).toBe(true); + }); + + it('returns valid for single valid contract', () => { + const result = validateContractAddresses({ + primary: VALID_TESTNET_CONTRACTS.usdcContract, + }); + expect(result.valid).toBe(true); + }); + + it('rejects invalid contract and includes field path', () => { + const result = validateContractAddresses({ + usdcContract: VALID_TESTNET_CONTRACTS.usdcContract, + badContract: INVALID_CONTRACTS.tooShort, + }); + + expect(result.valid).toBe(false); + expect((result as any).field).toBe('stellar.contractAddresses.badContract'); + expect((result as any).code).toBe('CONTRACT_ADDRESS_INVALID_LENGTH'); + }); + + it('returns error for first invalid contract encountered', () => { + const result = validateContractAddresses({ + first: VALID_TESTNET_CONTRACTS.usdcContract, + second: INVALID_CONTRACTS.wrongPrefix, + third: INVALID_CONTRACTS.tooShort, // This won't be checked + }); + + expect(result.valid).toBe(false); + expect((result as any).code).toBe('CONTRACT_ADDRESS_INVALID_PREFIX'); + }); + + it('includes descriptive error message for invalid contract', () => { + const result = validateContractAddresses({ + defi: INVALID_CONTRACTS.invalidCharacters, + }); + + expect(result.valid).toBe(false); + expect((result as any).reason).toContain('invalid characters'); + }); +}); + +// ── Property Tests: validateContractAddresses ──────────────────────────────── + +describe('validateContractAddresses — Property Tests', () => { + it('always validates records with all valid contracts', () => { + fc.assert( + fc.property( + fc.dictionary(arbContractName, arbValidContractAddress, { minKeys: 1, maxKeys: 5 }), + (contracts) => { + const result = validateContractAddresses(contracts); + expect(result.valid).toBe(true); + } + ) + ); + }); + + it('always rejects records containing any invalid address', () => { + fc.assert( + fc.property( + fc + .dictionary(arbContractName, arbValidContractAddress, { minKeys: 1, maxKeys: 4 }) + .chain((valid) => + fc.tuple( + fc.constant(valid), + arbContractName, + fc.constantFrom(...Object.values(INVALID_CONTRACTS)) + ) + ), + ([validContracts, invalidName, invalidAddr]) => { + const contracts = { ...validContracts, [invalidName]: invalidAddr }; + const result = validateContractAddresses(contracts); + expect(result.valid).toBe(false); + } + ) + ); + }); +}); diff --git a/apps/web/src/lib/stellar/contract-validation.ts b/apps/web/src/lib/stellar/contract-validation.ts new file mode 100644 index 0000000..8b487cb --- /dev/null +++ b/apps/web/src/lib/stellar/contract-validation.ts @@ -0,0 +1,97 @@ +/** + * Soroban Contract Address Validation + * + * Validates Soroban contract addresses according to Stellar's contract address + * specifications. Contract addresses are 56-character base32 encoded strings + * starting with 'C'. + */ + +export type ContractValidationResult = + | { valid: true } + | { valid: false; reason: string; code: string }; + +/** + * Validate a single Soroban contract address format. + * + * Soroban contract addresses follow SEP-0023 and are: + * - 56 characters in length + * - Base32 encoded (characters A-Z and 2-7) + * - Start with 'C' (indicating contract) + * + * @param address - The contract address to validate + * @returns Validation result with validity and reason if invalid + */ +export function validateContractAddress(address: string): ContractValidationResult { + if (!address) { + return { + valid: false, + reason: 'Contract address cannot be empty', + code: 'CONTRACT_ADDRESS_EMPTY', + }; + } + + if (typeof address !== 'string') { + return { + valid: false, + reason: 'Contract address must be a string', + code: 'CONTRACT_ADDRESS_NOT_STRING', + }; + } + + if (address.length !== 56) { + return { + valid: false, + reason: `Contract address must be 56 characters long, got ${address.length}`, + code: 'CONTRACT_ADDRESS_INVALID_LENGTH', + }; + } + + if (address[0] !== 'C') { + return { + valid: false, + reason: 'Contract address must start with "C"', + code: 'CONTRACT_ADDRESS_INVALID_PREFIX', + }; + } + + // Validate base32 encoding (valid characters: A-Z, 2-7) + const base32Regex = /^[A-Z2-7]{56}$/; + if (!base32Regex.test(address)) { + return { + valid: false, + reason: 'Contract address contains invalid characters (must be base32: A-Z, 2-7)', + code: 'CONTRACT_ADDRESS_INVALID_CHARSET', + }; + } + + return { valid: true }; +} + +/** + * Validate all contract addresses in a record. + * Returns first validation error encountered, or success. + * + * @param contracts - Object with contract name keys and address values + * @returns Validation result with field path if invalid + */ +export function validateContractAddresses( + contracts: Record | undefined +): { valid: true } | { valid: false; field: string; reason: string; code: string } { + if (!contracts || Object.keys(contracts).length === 0) { + return { valid: true }; + } + + for (const [name, address] of Object.entries(contracts)) { + const result = validateContractAddress(address); + if (!result.valid) { + return { + valid: false, + field: `stellar.contractAddresses.${name}`, + reason: result.reason, + code: result.code, + }; + } + } + + return { valid: true }; +} diff --git a/apps/web/src/lib/stellar/endpoint-connectivity.test.ts b/apps/web/src/lib/stellar/endpoint-connectivity.test.ts new file mode 100644 index 0000000..ef5f43b --- /dev/null +++ b/apps/web/src/lib/stellar/endpoint-connectivity.test.ts @@ -0,0 +1,391 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + checkHorizonEndpoint, + checkSorobanRpcEndpoint, + checkStellarEndpoints, + type ConnectivityCheckResult, +} from './endpoint-connectivity'; + +// ── Test Setup ─────────────────────────────────────────────────────────────── + +let fetchMock: typeof global.fetch; + +beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock as any; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +// ── Mock Helpers ───────────────────────────────────────────────────────────── + +function mockFetchSuccess(status: number = 200, responseTime: number = 50) { + return vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, responseTime)); + return { + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + }; + }); +} + +function mockFetchError(message: string) { + return vi.fn(async () => { + throw new Error(message); + }); +} + +function mockFetchTimeout(timeout: number = 5000) { + return vi.fn( + () => + new Promise((_, reject) => { + const err = new DOMException('The operation was aborted.', 'AbortError'); + setTimeout(() => reject(err), timeout); + }) + ); +} + +// ── Horizon Endpoint Tests ─────────────────────────────────────────────────── + +describe('checkHorizonEndpoint', () => { + describe('valid endpoints', () => { + it('returns reachable=true for successful response', async () => { + global.fetch = mockFetchSuccess(200); + + const result = await checkHorizonEndpoint('https://horizon-testnet.stellar.org'); + + expect(result.reachable).toBe(true); + expect(result.endpoint).toBe('https://horizon-testnet.stellar.org'); + expect(result.status).toBe(200); + expect(result.errorType).toBeUndefined(); + expect(result.error).toBeUndefined(); + }); + + it('captures response time', async () => { + global.fetch = mockFetchSuccess(200, 100); + + const result = await checkHorizonEndpoint('https://horizon.stellar.org'); + + expect(result.reachable).toBe(true); + expect(result.responseTime).toBeGreaterThanOrEqual(100); + }); + + it('calls fetch with correct method and headers', async () => { + global.fetch = mockFetchSuccess(200); + + await checkHorizonEndpoint('https://horizon-testnet.stellar.org'); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('horizon-testnet.stellar.org'), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Accept: 'application/json', + }), + }) + ); + }); + }); + + describe('validation errors', () => { + it('returns VALIDATION error for invalid URL format', async () => { + const result = await checkHorizonEndpoint('not-a-url'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('VALIDATION'); + expect(result.error).toContain('Invalid Horizon URL format'); + }); + + it('does not call fetch for invalid URL', async () => { + await checkHorizonEndpoint('invalid://url'); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('transient errors', () => { + it('returns TRANSIENT for timeout', async () => { + global.fetch = vi.fn( + () => + new Promise((_, reject) => { + const err = new DOMException('Aborted', 'AbortError'); + setTimeout(() => reject(err), 10); + }) + ); + + const result = await checkHorizonEndpoint('https://horizon.stellar.org', { timeout: 100 }); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('TRANSIENT'); + expect(result.error).toContain('Timeout'); + }); + + it('returns TRANSIENT for 408 (Request Timeout)', async () => { + global.fetch = mockFetchSuccess(408); + + const result = await checkHorizonEndpoint('https://horizon.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('TRANSIENT'); + expect(result.status).toBe(408); + }); + + it('returns TRANSIENT for 429 (Too Many Requests)', async () => { + global.fetch = mockFetchSuccess(429); + + const result = await checkHorizonEndpoint('https://horizon.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('TRANSIENT'); + expect(result.status).toBe(429); + }); + + it('returns TRANSIENT for 503 (Service Unavailable)', async () => { + global.fetch = mockFetchSuccess(503); + + const result = await checkHorizonEndpoint('https://horizon.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('TRANSIENT'); + expect(result.status).toBe(503); + }); + + it('returns TRANSIENT for 504 (Gateway Timeout)', async () => { + global.fetch = mockFetchSuccess(504); + + const result = await checkHorizonEndpoint('https://horizon.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('TRANSIENT'); + expect(result.status).toBe(504); + }); + + it('returns TRANSIENT for network errors', async () => { + global.fetch = mockFetchError('ECONNREFUSED'); + + const result = await checkHorizonEndpoint('https://horizon.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('TRANSIENT'); + }); + }); + + describe('configuration errors', () => { + it('returns CONFIGURATION for 404 (Not Found)', async () => { + global.fetch = mockFetchSuccess(404); + + const result = await checkHorizonEndpoint('https://wrong.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('CONFIGURATION'); + expect(result.status).toBe(404); + }); + + it('returns CONFIGURATION for 401 (Unauthorized)', async () => { + global.fetch = mockFetchSuccess(401); + + const result = await checkHorizonEndpoint('https://protected.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('CONFIGURATION'); + expect(result.status).toBe(401); + }); + + it('returns CONFIGURATION for 403 (Forbidden)', async () => { + global.fetch = mockFetchSuccess(403); + + const result = await checkHorizonEndpoint('https://denied.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('CONFIGURATION'); + expect(result.status).toBe(403); + }); + }); + + describe('timeout handling', () => { + it('respects custom timeout value', async () => { + const timeoutMs = 1000; + global.fetch = vi.fn( + () => + new Promise((_, reject) => { + setTimeout( + () => reject(new DOMException('Aborted', 'AbortError')), + timeoutMs + 100 + ); + }) + ); + + const result = await checkHorizonEndpoint('https://horizon.stellar.org', { + timeout: timeoutMs, + }); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('TRANSIENT'); + }); + }); +}); + +// ── Soroban RPC Endpoint Tests ─────────────────────────────────────────────── + +describe('checkSorobanRpcEndpoint', () => { + describe('valid endpoints', () => { + it('returns reachable=true for successful response', async () => { + global.fetch = mockFetchSuccess(200); + + const result = await checkSorobanRpcEndpoint('https://soroban-testnet.stellar.org'); + + expect(result.reachable).toBe(true); + expect(result.endpoint).toBe('https://soroban-testnet.stellar.org'); + expect(result.status).toBe(200); + }); + + it('sends JSON-RPC getNetwork request', async () => { + global.fetch = mockFetchSuccess(200); + + await checkSorobanRpcEndpoint('https://soroban-testnet.stellar.org'); + + const callArgs = (global.fetch as any).mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + + expect(body).toEqual({ + jsonrpc: '2.0', + id: 'craft-health-check', + method: 'getNetwork', + params: [], + }); + }); + }); + + describe('validation errors', () => { + it('returns VALIDATION error for invalid URL format', async () => { + const result = await checkSorobanRpcEndpoint('not-a-rpc-url'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('VALIDATION'); + expect(result.error).toContain('Invalid Soroban RPC URL format'); + }); + }); + + describe('error handling', () => { + it('returns TRANSIENT for 503', async () => { + global.fetch = mockFetchSuccess(503); + + const result = await checkSorobanRpcEndpoint('https://soroban-testnet.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('TRANSIENT'); + }); + + it('returns CONFIGURATION for 404', async () => { + global.fetch = mockFetchSuccess(404); + + const result = await checkSorobanRpcEndpoint('https://wrong.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('CONFIGURATION'); + }); + }); +}); + +// ── Combined Endpoint Checks ───────────────────────────────────────────────── + +describe('checkStellarEndpoints', () => { + it('checks both Horizon and Soroban RPC when both provided', async () => { + global.fetch = mockFetchSuccess(200); + + const results = await checkStellarEndpoints( + 'https://horizon-testnet.stellar.org', + 'https://soroban-testnet.stellar.org' + ); + + expect(results).toHaveLength(2); + expect(results[0].endpoint).toBe('https://horizon-testnet.stellar.org'); + expect(results[1].endpoint).toBe('https://soroban-testnet.stellar.org'); + expect(results[0].reachable).toBe(true); + expect(results[1].reachable).toBe(true); + }); + + it('checks only Horizon when Soroban URL not provided', async () => { + global.fetch = mockFetchSuccess(200); + + const results = await checkStellarEndpoints('https://horizon-testnet.stellar.org'); + + expect(results).toHaveLength(1); + expect(results[0].endpoint).toBe('https://horizon-testnet.stellar.org'); + }); + + it('returns both successful and failed checks', async () => { + let callCount = 0; + global.fetch = vi.fn(async () => { + callCount++; + if (callCount === 1) { + return { ok: true, status: 200 }; + } + return { ok: false, status: 503 }; + }); + + const results = await checkStellarEndpoints( + 'https://horizon-testnet.stellar.org', + 'https://soroban-testnet.stellar.org' + ); + + expect(results[0].reachable).toBe(true); + expect(results[1].reachable).toBe(false); + expect(results[1].errorType).toBe('TRANSIENT'); + }); + + it('respects custom timeout for all endpoints', async () => { + global.fetch = mockFetchSuccess(200); + + await checkStellarEndpoints( + 'https://horizon-testnet.stellar.org', + 'https://soroban-testnet.stellar.org', + { timeout: 3000 } + ); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }) + ); + }); +}); + +// ── Edge Cases ─────────────────────────────────────────────────────────────── + +describe('Endpoint checks — Edge Cases', () => { + it('handles URL with trailing slash', async () => { + global.fetch = mockFetchSuccess(200); + + const result = await checkHorizonEndpoint('https://horizon-testnet.stellar.org/'); + + expect(result.reachable).toBe(true); + }); + + it('handles URL without protocol', async () => { + const result = await checkHorizonEndpoint('horizon-testnet.stellar.org'); + + expect(result.reachable).toBe(false); + expect(result.errorType).toBe('VALIDATION'); + }); + + it('preserves endpoint URL in result even on failure', async () => { + global.fetch = mockFetchSuccess(503); + + const result = await checkHorizonEndpoint('https://horizon-testnet.stellar.org'); + + expect(result.endpoint).toBe('https://horizon-testnet.stellar.org'); + }); + + it('includes error message for network failures', async () => { + global.fetch = mockFetchError('Network error'); + + const result = await checkHorizonEndpoint('https://horizon-testnet.stellar.org'); + + expect(result.error).toBeDefined(); + }); +}); diff --git a/apps/web/src/lib/stellar/endpoint-connectivity.ts b/apps/web/src/lib/stellar/endpoint-connectivity.ts new file mode 100644 index 0000000..9d9276f --- /dev/null +++ b/apps/web/src/lib/stellar/endpoint-connectivity.ts @@ -0,0 +1,306 @@ +/** + * Horizon Endpoint Connectivity Check + * + * Validates that configured Horizon and Soroban RPC endpoints are reachable + * and responsive. Distinguishes between: + * - Format errors (invalid URLs) + * - Connectivity errors (temporary/transient - timeout, 503, etc.) + * - Configuration errors (permanent - wrong endpoint, 404, auth failures) + */ + +export type ConnectivityErrorType = 'VALIDATION' | 'TRANSIENT' | 'CONFIGURATION'; + +export interface ConnectivityCheckResult { + reachable: boolean; + endpoint: string; + status?: number; + responseTime?: number; + errorType?: ConnectivityErrorType; + error?: string; +} + +export interface HorizonCheckOptions { + timeout?: number; // milliseconds, default 5000 + retries?: number; // default 1 (no retries) +} + +const DEFAULT_TIMEOUT = 5000; +const DEFAULT_RETRIES = 1; + +/** + * Transient error codes that should be classified as temporary/recoverable. + */ +const TRANSIENT_HTTP_CODES = new Set([ + 408, // Request Timeout + 429, // Too Many Requests + 500, // Internal Server Error + 502, // Bad Gateway + 503, // Service Unavailable + 504, // Gateway Timeout +]); + +/** + * Check if a Horizon endpoint is reachable by testing GET / + * + * @param horizonUrl - The Horizon endpoint URL + * @param options - Check options (timeout, retries) + * @returns Connectivity check result with reachability status + */ +export async function checkHorizonEndpoint( + horizonUrl: string, + options: HorizonCheckOptions = {} +): Promise { + const timeout = options.timeout ?? DEFAULT_TIMEOUT; + const maxRetries = options.retries ?? DEFAULT_RETRIES; + + // Validate URL format first + try { + new URL(horizonUrl); + } catch { + return { + reachable: false, + endpoint: horizonUrl, + errorType: 'VALIDATION', + error: 'Invalid Horizon URL format', + }; + } + + let lastError: Error | null = null; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const startTime = performance.now(); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(new URL('/', horizonUrl).toString(), { + method: 'GET', + signal: controller.signal, + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Craft-Platform-Validator/1.0', + }, + }); + + clearTimeout(timeoutId); + const responseTime = performance.now() - startTime; + + // Health check: Horizon returns 200 OK with JSON + if (response.ok) { + return { + reachable: true, + endpoint: horizonUrl, + status: response.status, + responseTime, + }; + } + + // Determine error type based on status code + const errorType = TRANSIENT_HTTP_CODES.has(response.status) + ? 'TRANSIENT' + : response.status === 404 + ? 'CONFIGURATION' + : response.status >= 400 && response.status < 500 + ? 'CONFIGURATION' + : 'TRANSIENT'; + + return { + reachable: false, + endpoint: horizonUrl, + status: response.status, + responseTime, + errorType, + error: `HTTP ${response.status}`, + }; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + lastError = new Error(`Timeout after ${timeout}ms`); + + if (attempt === maxRetries - 1) { + return { + reachable: false, + endpoint: horizonUrl, + errorType: 'TRANSIENT', + error: lastError.message, + }; + } + // Retry on timeout + continue; + } + + lastError = err instanceof Error ? err : new Error(String(err)); + clearTimeout(timeoutId); + + // Network errors are typically transient + if (attempt === maxRetries - 1) { + return { + reachable: false, + endpoint: horizonUrl, + errorType: 'TRANSIENT', + error: lastError.message, + }; + } + } + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + } + + return { + reachable: false, + endpoint: horizonUrl, + errorType: 'TRANSIENT', + error: lastError?.message ?? 'Unknown error', + }; +} + +/** + * Check if a Soroban RPC endpoint is reachable. + * Soroban RPC uses JSON-RPC so we test with a simple getNetwork call. + * + * @param sorobanRpcUrl - The Soroban RPC endpoint URL + * @param options - Check options (timeout, retries) + * @returns Connectivity check result + */ +export async function checkSorobanRpcEndpoint( + sorobanRpcUrl: string, + options: HorizonCheckOptions = {} +): Promise { + const timeout = options.timeout ?? DEFAULT_TIMEOUT; + const maxRetries = options.retries ?? DEFAULT_RETRIES; + + // Validate URL format first + try { + new URL(sorobanRpcUrl); + } catch { + return { + reachable: false, + endpoint: sorobanRpcUrl, + errorType: 'VALIDATION', + error: 'Invalid Soroban RPC URL format', + }; + } + + let lastError: Error | null = null; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const startTime = performance.now(); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(sorobanRpcUrl, { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Craft-Platform-Validator/1.0', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'craft-health-check', + method: 'getNetwork', + params: [], + }), + }); + + clearTimeout(timeoutId); + const responseTime = performance.now() - startTime; + + // Successful JSON-RPC response (even if it's an error response) + if (response.status === 200) { + return { + reachable: true, + endpoint: sorobanRpcUrl, + status: response.status, + responseTime, + }; + } + + // Determine error type based on status code + const errorType = TRANSIENT_HTTP_CODES.has(response.status) + ? 'TRANSIENT' + : response.status === 404 + ? 'CONFIGURATION' + : response.status >= 400 && response.status < 500 + ? 'CONFIGURATION' + : 'TRANSIENT'; + + return { + reachable: false, + endpoint: sorobanRpcUrl, + status: response.status, + responseTime, + errorType, + error: `HTTP ${response.status}`, + }; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + lastError = new Error(`Timeout after ${timeout}ms`); + + if (attempt === maxRetries - 1) { + return { + reachable: false, + endpoint: sorobanRpcUrl, + errorType: 'TRANSIENT', + error: lastError.message, + }; + } + // Retry on timeout + continue; + } + + lastError = err instanceof Error ? err : new Error(String(err)); + clearTimeout(timeoutId); + + // Network errors are typically transient + if (attempt === maxRetries - 1) { + return { + reachable: false, + endpoint: sorobanRpcUrl, + errorType: 'TRANSIENT', + error: lastError.message, + }; + } + } + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + } + + return { + reachable: false, + endpoint: sorobanRpcUrl, + errorType: 'TRANSIENT', + error: lastError?.message ?? 'Unknown error', + }; +} + +/** + * Check all configured Stellar endpoints (Horizon and optional Soroban RPC). + * Returns detailed results for each endpoint. + * + * @param horizonUrl - Horizon endpoint URL to test + * @param sorobanRpcUrl - Optional Soroban RPC endpoint URL to test + * @param options - Check options + * @returns Array of connectivity check results + */ +export async function checkStellarEndpoints( + horizonUrl: string, + sorobanRpcUrl?: string, + options: HorizonCheckOptions = {} +): Promise { + const checks: Promise[] = [ + checkHorizonEndpoint(horizonUrl, options), + ]; + + if (sorobanRpcUrl) { + checks.push(checkSorobanRpcEndpoint(sorobanRpcUrl, options)); + } + + return Promise.all(checks); +}