diff --git a/apps/frontend/src/components/app/stellar/StellarConfigPanel.tsx b/apps/frontend/src/components/app/stellar/StellarConfigPanel.tsx new file mode 100644 index 0000000..1e25304 --- /dev/null +++ b/apps/frontend/src/components/app/stellar/StellarConfigPanel.tsx @@ -0,0 +1,487 @@ +'use client'; + +import React, { useState } from 'react'; +import type { AssetPair, StellarAsset } from '@craft/types'; +import type { StellarConfigFormReturn } from './useStellarConfigForm'; + +interface StellarConfigPanelProps { + form: StellarConfigFormReturn; + onSubmit: () => void; + submitLabel?: string; + isSubmitting?: boolean; +} + +// ── Network selector ────────────────────────────────────────────────────────── + +const HORIZON_DEFAULTS: Record = { + testnet: 'https://horizon-testnet.stellar.org', + mainnet: 'https://horizon.stellar.org', +}; + +const SOROBAN_DEFAULTS: Record = { + testnet: 'https://soroban-testnet.stellar.org', + mainnet: 'https://mainnet.stellar.validationcloud.io/v1/soroban/rpc', +}; + +// ── Main component ──────────────────────────────────────────────────────────── + +/** + * StellarConfigPanel — form panel for configuring Stellar network settings. + * + * Covers: + * - Network selection (mainnet / testnet) + * - Horizon URL with auto-fill from network selection + * - Soroban RPC URL (optional) + * - Asset pair management (add / remove) + * - Contract address management (add / remove) + * + * Contextual help text is shown for each Stellar-specific field. + */ +export function StellarConfigPanel({ + form, + onSubmit, + submitLabel = 'Save changes', + isSubmitting = false, +}: StellarConfigPanelProps) { + const { state, errors, isDirty, setField, setAssetPairs, setContractAddress, removeContractAddress, validate, reset } = form; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const errs = validate(); + if (errs.length === 0) { + onSubmit(); + } + } + + function handleNetworkChange(network: 'mainnet' | 'testnet') { + setField('network', network); + // Auto-fill default URLs when switching networks + if (state.horizonUrl === HORIZON_DEFAULTS[state.network]) { + setField('horizonUrl', HORIZON_DEFAULTS[network]); + } + if (state.sorobanRpcUrl === SOROBAN_DEFAULTS[state.network]) { + setField('sorobanRpcUrl', SOROBAN_DEFAULTS[network]); + } + } + + return ( +
+ {/* Network */} + +
+ +
+ {(['testnet', 'mainnet'] as const).map((net) => ( + + ))} +
+ {errors.get('network') && {errors.get('network')}} +
+
+ + {/* Endpoints */} + + setField('horizonUrl', v)} + error={errors.get('horizonUrl')} + help="The Horizon API endpoint for your chosen network. Used for account queries and transaction submission." + placeholder={HORIZON_DEFAULTS[state.network]} + /> + setField('sorobanRpcUrl', v || undefined)} + error={errors.get('sorobanRpcUrl')} + help="Optional. Required only if your application interacts with Soroban smart contracts." + placeholder={SOROBAN_DEFAULTS[state.network]} + optional + /> + + + {/* Asset pairs */} + +

+ Define the trading pairs your application will display. Each pair consists of a base and counter asset. + Up to 20 pairs are supported. +

+ +
+ + {/* Contract addresses */} + +

+ Map contract names to their Soroban contract IDs (56-character addresses starting with C). + Leave empty if your application does not use smart contracts. +

+ +
+ + {/* Actions */} +
+ + +
+
+ ); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function ConfigSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function HelpText({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +function FieldError({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +interface UrlFieldProps { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + error?: string; + help: string; + placeholder?: string; + optional?: boolean; +} + +function UrlField({ id, label, value, onChange, error, help, placeholder, optional }: UrlFieldProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + aria-describedby={`${id}-help${error ? ` ${id}-error` : ''}`} + aria-invalid={!!error} + className={`w-full rounded-lg border px-3 py-2.5 text-sm bg-surface-container-low text-on-surface placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors ${ + error ? 'border-error' : 'border-outline-variant/30' + }`} + /> +

+ {help} +

+ {error && ( + + )} +
+ ); +} + +// ── Asset pair list ─────────────────────────────────────────────────────────── + +const EMPTY_ASSET: StellarAsset = { code: '', issuer: '', type: 'credit_alphanum4' }; +const EMPTY_PAIR: AssetPair = { base: { ...EMPTY_ASSET }, counter: { ...EMPTY_ASSET } }; + +interface AssetPairListProps { + pairs: AssetPair[]; + onChange: (pairs: AssetPair[]) => void; + errors: Map; +} + +function AssetPairList({ pairs, onChange, errors }: AssetPairListProps) { + function addPair() { + onChange([...pairs, { ...EMPTY_PAIR, base: { ...EMPTY_ASSET }, counter: { ...EMPTY_ASSET } }]); + } + + function removePair(index: number) { + onChange(pairs.filter((_, i) => i !== index)); + } + + function updatePair(index: number, side: 'base' | 'counter', field: keyof StellarAsset, value: string) { + const next = pairs.map((p, i) => + i === index ? { ...p, [side]: { ...p[side], [field]: value } } : p + ); + onChange(next); + } + + return ( +
+ {pairs.map((pair, i) => ( +
+
+ Pair {i + 1} + +
+
+ updatePair(i, 'base', field, value)} + errors={errors} + /> + updatePair(i, 'counter', field, value)} + errors={errors} + /> +
+
+ ))} + {pairs.length < 20 && ( + + )} +
+ ); +} + +interface AssetFieldsProps { + label: string; + prefix: string; + asset: StellarAsset; + onChange: (field: keyof StellarAsset, value: string) => void; + errors: Map; +} + +function AssetFields({ label, prefix, asset, onChange, errors }: AssetFieldsProps) { + const codeError = errors.get(`${prefix}.code`); + const issuerError = errors.get(`${prefix}.issuer`); + + return ( +
+ {label} +
+ + +
+ {asset.type !== 'native' && ( + <> +
+ + onChange('code', e.target.value)} + placeholder={asset.type === 'credit_alphanum4' ? 'e.g. USDC' : 'e.g. LONGCODE'} + aria-invalid={!!codeError} + className={`rounded-lg border px-3 py-2 text-sm bg-surface-container-low text-on-surface placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-2 focus:ring-primary/50 ${ + codeError ? 'border-error' : 'border-outline-variant/30' + }`} + /> + {codeError && {codeError}} +
+
+ + onChange('issuer', e.target.value)} + placeholder="G…" + aria-invalid={!!issuerError} + className={`rounded-lg border px-3 py-2 text-sm font-mono bg-surface-container-low text-on-surface placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-2 focus:ring-primary/50 ${ + issuerError ? 'border-error' : 'border-outline-variant/30' + }`} + /> + {issuerError && {issuerError}} +
+ + )} +
+ ); +} + +// ── Contract address list ───────────────────────────────────────────────────── + +interface ContractAddressListProps { + addresses: Record; + onSet: (key: string, value: string) => void; + onRemove: (key: string) => void; + errors: Map; +} + +function ContractAddressList({ addresses, onSet, onRemove, errors }: ContractAddressListProps) { + const [newKey, setNewKey] = useState(''); + const [newValue, setNewValue] = useState(''); + const [addError, setAddError] = useState(''); + + const entries = Object.entries(addresses); + + function handleAdd() { + const key = newKey.trim(); + if (!key) { + setAddError('Contract name is required'); + return; + } + if (key in addresses) { + setAddError('A contract with this name already exists'); + return; + } + setAddError(''); + onSet(key, newValue.trim()); + setNewKey(''); + setNewValue(''); + } + + return ( +
+ {entries.map(([key, value]) => { + const fieldError = errors.get(`contractAddresses.${key}`); + return ( +
+
+ {key} + onSet(key, e.target.value)} + aria-label={`Contract address for ${key}`} + aria-invalid={!!fieldError} + className={`w-full rounded-lg border px-3 py-2 text-sm font-mono bg-surface-container-low text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50 ${ + fieldError ? 'border-error' : 'border-outline-variant/30' + }`} + /> + {fieldError && {fieldError}} +
+ +
+ ); + })} + + {/* Add new contract */} +
+ Add contract +
+ { setNewKey(e.target.value); setAddError(''); }} + placeholder="Contract name (e.g. amm)" + aria-label="New contract name" + className="flex-1 rounded-lg border border-outline-variant/30 px-3 py-2 text-sm bg-surface-container-low text-on-surface placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-2 focus:ring-primary/50" + /> + setNewValue(e.target.value)} + placeholder="C… (56 chars)" + aria-label="New contract address" + className="flex-1 rounded-lg border border-outline-variant/30 px-3 py-2 text-sm font-mono bg-surface-container-low text-on-surface placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-2 focus:ring-primary/50" + /> + +
+ {addError && {addError}} +
+
+ ); +} diff --git a/apps/frontend/src/components/app/stellar/index.ts b/apps/frontend/src/components/app/stellar/index.ts new file mode 100644 index 0000000..d2af44b --- /dev/null +++ b/apps/frontend/src/components/app/stellar/index.ts @@ -0,0 +1,3 @@ +export { StellarConfigPanel } from './StellarConfigPanel'; +export { useStellarConfigForm } from './useStellarConfigForm'; +export type { StellarConfigFormReturn } from './useStellarConfigForm'; diff --git a/apps/frontend/src/components/app/stellar/useStellarConfigForm.ts b/apps/frontend/src/components/app/stellar/useStellarConfigForm.ts new file mode 100644 index 0000000..6409e4f --- /dev/null +++ b/apps/frontend/src/components/app/stellar/useStellarConfigForm.ts @@ -0,0 +1,93 @@ +'use client'; + +import { useState, useCallback, useMemo, useRef } from 'react'; +import type { StellarConfig, AssetPair, ValidationError } from '@craft/types'; +import { validateStellarConfig, DEFAULT_STELLAR_CONFIG } from '@/lib/customization/validate-stellar'; + +export interface StellarConfigFormReturn { + state: StellarConfig; + errors: Map; + isDirty: boolean; + setField: (key: K, value: StellarConfig[K]) => void; + setAssetPairs: (pairs: AssetPair[]) => void; + setContractAddress: (key: string, value: string) => void; + removeContractAddress: (key: string) => void; + validate: () => ValidationError[]; + reset: () => void; +} + +export function useStellarConfigForm( + initial: StellarConfig = DEFAULT_STELLAR_CONFIG +): StellarConfigFormReturn { + const [state, setState] = useState(initial); + const [validationErrors, setValidationErrors] = useState([]); + const initialRef = useRef(initial); + + const isDirty = useMemo(() => { + const init = initialRef.current; + return ( + state.network !== init.network || + state.horizonUrl !== init.horizonUrl || + state.sorobanRpcUrl !== init.sorobanRpcUrl || + JSON.stringify(state.assetPairs) !== JSON.stringify(init.assetPairs) || + JSON.stringify(state.contractAddresses) !== JSON.stringify(init.contractAddresses) + ); + }, [state]); + + const setField = useCallback((key: K, value: StellarConfig[K]) => { + setState((prev) => ({ ...prev, [key]: value })); + setValidationErrors((prev) => prev.filter((e) => !e.field.startsWith(`stellar.${key}`) && e.field !== key)); + }, []); + + const setAssetPairs = useCallback((pairs: AssetPair[]) => { + setState((prev) => ({ ...prev, assetPairs: pairs })); + setValidationErrors((prev) => prev.filter((e) => !e.field.startsWith('assetPairs'))); + }, []); + + const setContractAddress = useCallback((key: string, value: string) => { + setState((prev) => ({ + ...prev, + contractAddresses: { ...prev.contractAddresses, [key]: value }, + })); + setValidationErrors((prev) => prev.filter((e) => !e.field.startsWith(`contractAddresses.${key}`))); + }, []); + + const removeContractAddress = useCallback((key: string) => { + setState((prev) => { + const next = { ...prev.contractAddresses }; + delete next[key]; + return { ...prev, contractAddresses: next }; + }); + }, []); + + const validate = useCallback((): ValidationError[] => { + const result = validateStellarConfig(state); + setValidationErrors(result.errors); + return result.errors; + }, [state]); + + const reset = useCallback(() => { + setState(initialRef.current); + setValidationErrors([]); + }, []); + + const errors = useMemo(() => { + const map = new Map(); + for (const err of validationErrors) { + map.set(err.field, err.message); + } + return map; + }, [validationErrors]); + + return { + state, + errors, + isDirty, + setField, + setAssetPairs, + setContractAddress, + removeContractAddress, + validate, + reset, + }; +} diff --git a/apps/frontend/src/lib/customization/validate-stellar.test.ts b/apps/frontend/src/lib/customization/validate-stellar.test.ts new file mode 100644 index 0000000..ffdf879 --- /dev/null +++ b/apps/frontend/src/lib/customization/validate-stellar.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect } from 'vitest'; +import { validateStellarConfig, DEFAULT_STELLAR_CONFIG } from './validate-stellar'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** A valid 56-char Soroban contract address (starts with C). */ +const VALID_CONTRACT = 'CBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST'; + +/** A valid Stellar account ID (starts with G). */ +const VALID_ISSUER = 'GBQWI64FZ2NKSJC7D45HJZVVMQZ3T7KHXOJSLZPZ5LHKQM7FFWVGNQST'; + +const validTestnet = { + network: 'testnet' as const, + horizonUrl: 'https://horizon-testnet.stellar.org', +}; + +const validMainnet = { + network: 'mainnet' as const, + horizonUrl: 'https://horizon.stellar.org', +}; + +// ── Basic validation ────────────────────────────────────────────────────────── + +describe('validateStellarConfig — basic', () => { + it('accepts a minimal valid testnet config', () => { + expect(validateStellarConfig(validTestnet)).toEqual({ valid: true, errors: [] }); + }); + + it('accepts a minimal valid mainnet config', () => { + expect(validateStellarConfig(validMainnet)).toEqual({ valid: true, errors: [] }); + }); + + it('accepts a config with optional sorobanRpcUrl', () => { + const result = validateStellarConfig({ + ...validTestnet, + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + }); + expect(result.valid).toBe(true); + }); + + it('returns errors for null input', () => { + const result = validateStellarConfig(null); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('returns errors for empty object', () => { + const result = validateStellarConfig({}); + expect(result.valid).toBe(false); + }); +}); + +// ── Network field ───────────────────────────────────────────────────────────── + +describe('validateStellarConfig — network', () => { + it('rejects an unsupported network value', () => { + const result = validateStellarConfig({ ...validTestnet, network: 'devnet' }); + expect(result.valid).toBe(false); + expect(result.errors[0].field).toBe('network'); + expect(result.errors[0].message).toMatch(/mainnet or testnet/i); + }); + + it('rejects a missing network', () => { + const { network: _n, ...rest } = validTestnet; + const result = validateStellarConfig(rest); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'network')).toBe(true); + }); +}); + +// ── Horizon URL ─────────────────────────────────────────────────────────────── + +describe('validateStellarConfig — horizonUrl', () => { + it('rejects a non-URL horizon value', () => { + const result = validateStellarConfig({ ...validTestnet, horizonUrl: 'not-a-url' }); + expect(result.valid).toBe(false); + expect(result.errors[0].field).toBe('horizonUrl'); + }); + + it('rejects a missing horizonUrl', () => { + const { horizonUrl: _h, ...rest } = validTestnet; + const result = validateStellarConfig(rest); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === 'horizonUrl')).toBe(true); + }); + + it('rejects mainnet network with testnet Horizon URL', () => { + const result = validateStellarConfig({ + network: 'mainnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + }); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.field === 'horizonUrl'); + expect(err).toBeDefined(); + expect(err!.message).toMatch(/testnet.*mainnet|mainnet.*testnet/i); + }); + + it('rejects testnet network with mainnet Horizon URL', () => { + const result = validateStellarConfig({ + network: 'testnet', + horizonUrl: 'https://horizon.stellar.org', + }); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.field === 'horizonUrl'); + expect(err).toBeDefined(); + }); + + it('accepts a custom Horizon URL that does not match defaults', () => { + const result = validateStellarConfig({ + ...validTestnet, + horizonUrl: 'https://my-custom-horizon.example.com', + }); + expect(result.valid).toBe(true); + }); +}); + +// ── Soroban RPC URL ─────────────────────────────────────────────────────────── + +describe('validateStellarConfig — sorobanRpcUrl', () => { + it('accepts a valid sorobanRpcUrl', () => { + const result = validateStellarConfig({ + ...validTestnet, + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + }); + expect(result.valid).toBe(true); + }); + + it('rejects an invalid sorobanRpcUrl', () => { + const result = validateStellarConfig({ ...validTestnet, sorobanRpcUrl: 'bad-url' }); + expect(result.valid).toBe(false); + expect(result.errors[0].field).toBe('sorobanRpcUrl'); + }); + + it('accepts config without sorobanRpcUrl', () => { + expect(validateStellarConfig(validTestnet)).toEqual({ valid: true, errors: [] }); + }); +}); + +// ── Asset pairs ─────────────────────────────────────────────────────────────── + +describe('validateStellarConfig — assetPairs', () => { + const nativeAsset = { code: 'XLM', issuer: '', type: 'native' as const }; + const usdcAsset = { code: 'USDC', issuer: VALID_ISSUER, type: 'credit_alphanum4' as const }; + + it('accepts a valid asset pair', () => { + const result = validateStellarConfig({ + ...validTestnet, + assetPairs: [{ base: nativeAsset, counter: usdcAsset }], + }); + expect(result.valid).toBe(true); + }); + + it('accepts an empty assetPairs array', () => { + const result = validateStellarConfig({ ...validTestnet, assetPairs: [] }); + expect(result.valid).toBe(true); + }); + + it('rejects more than 20 asset pairs', () => { + const pairs = Array.from({ length: 21 }, () => ({ + base: nativeAsset, + counter: usdcAsset, + })); + const result = validateStellarConfig({ ...validTestnet, assetPairs: pairs }); + expect(result.valid).toBe(false); + expect(result.errors[0].field).toBe('assetPairs'); + }); + + it('rejects a non-native asset without an issuer', () => { + const result = validateStellarConfig({ + ...validTestnet, + assetPairs: [ + { + base: nativeAsset, + counter: { code: 'USDC', issuer: '', type: 'credit_alphanum4' as const }, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field.includes('issuer'))).toBe(true); + }); + + it('rejects a non-native asset with an invalid issuer', () => { + const result = validateStellarConfig({ + ...validTestnet, + assetPairs: [ + { + base: nativeAsset, + counter: { code: 'USDC', issuer: 'NOTANACCOUNTID', type: 'credit_alphanum4' as const }, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field.includes('issuer'))).toBe(true); + }); + + it('rejects a credit_alphanum4 asset with a code longer than 4 chars', () => { + const result = validateStellarConfig({ + ...validTestnet, + assetPairs: [ + { + base: nativeAsset, + counter: { code: 'TOOLONG', issuer: VALID_ISSUER, type: 'credit_alphanum4' as const }, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field.includes('code'))).toBe(true); + }); + + it('accepts a credit_alphanum12 asset with a long code', () => { + const result = validateStellarConfig({ + ...validTestnet, + assetPairs: [ + { + base: nativeAsset, + counter: { code: 'LONGCODE1234', issuer: VALID_ISSUER, type: 'credit_alphanum12' as const }, + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects an asset code with invalid characters', () => { + const result = validateStellarConfig({ + ...validTestnet, + assetPairs: [ + { + base: nativeAsset, + counter: { code: 'US$C', issuer: VALID_ISSUER, type: 'credit_alphanum4' as const }, + }, + ], + }); + expect(result.valid).toBe(false); + }); +}); + +// ── Contract addresses ──────────────────────────────────────────────────────── + +describe('validateStellarConfig — contractAddresses', () => { + it('accepts a valid contract address', () => { + const result = validateStellarConfig({ + ...validTestnet, + contractAddresses: { amm: VALID_CONTRACT }, + }); + expect(result.valid).toBe(true); + }); + + it('accepts an empty contractAddresses object', () => { + const result = validateStellarConfig({ ...validTestnet, contractAddresses: {} }); + expect(result.valid).toBe(true); + }); + + it('rejects a contract address that does not start with C', () => { + const result = validateStellarConfig({ + ...validTestnet, + contractAddresses: { amm: VALID_ISSUER }, // starts with G + }); + expect(result.valid).toBe(false); + expect(result.errors[0].field).toBe('contractAddresses.amm'); + }); + + it('rejects a contract address that is too short', () => { + const result = validateStellarConfig({ + ...validTestnet, + contractAddresses: { amm: 'CSHORT' }, + }); + expect(result.valid).toBe(false); + expect(result.errors[0].field).toBe('contractAddresses.amm'); + }); + + it('rejects a contract address with invalid characters', () => { + const invalid = 'C' + '1'.repeat(54) + '-'; // 56 chars but last is invalid + const result = validateStellarConfig({ + ...validTestnet, + contractAddresses: { amm: invalid }, + }); + expect(result.valid).toBe(false); + }); + + it('accepts multiple valid contract addresses', () => { + const result = validateStellarConfig({ + ...validTestnet, + contractAddresses: { + amm: VALID_CONTRACT, + lending: 'CATPNZ2SJRSVZJBWXGFSMZQHQ47JM5PXNQRVJLGHGHVKPZ2OVH3FHXPA', + }, + }); + expect(result.valid).toBe(true); + }); +}); + +// ── DEFAULT_STELLAR_CONFIG ──────────────────────────────────────────────────── + +describe('DEFAULT_STELLAR_CONFIG', () => { + it('is valid according to the schema', () => { + expect(validateStellarConfig(DEFAULT_STELLAR_CONFIG)).toEqual({ valid: true, errors: [] }); + }); + + it('defaults to testnet', () => { + expect(DEFAULT_STELLAR_CONFIG.network).toBe('testnet'); + }); + + it('uses the testnet Horizon URL', () => { + expect(DEFAULT_STELLAR_CONFIG.horizonUrl).toBe('https://horizon-testnet.stellar.org'); + }); +}); diff --git a/apps/frontend/src/lib/customization/validate-stellar.ts b/apps/frontend/src/lib/customization/validate-stellar.ts new file mode 100644 index 0000000..7f3233e --- /dev/null +++ b/apps/frontend/src/lib/customization/validate-stellar.ts @@ -0,0 +1,144 @@ +import { z } from 'zod'; +import type { StellarConfig, ValidationError } from '@craft/types'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Soroban contract address: 56-character base32 (C + 55 alphanumeric). */ +const CONTRACT_ADDRESS_RE = /^C[A-Z2-7]{55}$/; + +/** Stellar asset code: 1–12 alphanumeric characters. */ +const ASSET_CODE_RE = /^[A-Za-z0-9]{1,12}$/; + +/** Stellar account ID (G…): 56-character base32. */ +const ACCOUNT_ID_RE = /^G[A-Z2-7]{55}$/; + +const TESTNET_HORIZON = 'https://horizon-testnet.stellar.org'; +const MAINNET_HORIZON = 'https://horizon.stellar.org'; + +// ── Sub-schemas ─────────────────────────────────────────────────────────────── + +/** + * Stellar asset schema. + * - native XLM has no issuer; all other types require one. + */ +const stellarAssetSchema = z + .object({ + code: z.string().regex(ASSET_CODE_RE, 'Asset code must be 1–12 alphanumeric characters'), + issuer: z.string().optional(), + type: z.enum(['native', 'credit_alphanum4', 'credit_alphanum12']), + }) + .superRefine((asset, ctx) => { + if (asset.type !== 'native') { + if (!asset.issuer) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Issuer is required for non-native assets', + path: ['issuer'], + }); + } else if (!ACCOUNT_ID_RE.test(asset.issuer)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Issuer must be a valid Stellar account ID (starts with G)', + path: ['issuer'], + }); + } + } + if (asset.type === 'credit_alphanum4' && asset.code.length > 4) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'credit_alphanum4 asset code must be 1–4 characters', + path: ['code'], + }); + } + }); + +const assetPairSchema = z.object({ + base: stellarAssetSchema, + counter: stellarAssetSchema, +}); + +/** + * Full Stellar configuration schema. + * Validates network, URLs, asset pairs, and contract addresses. + */ +export const stellarConfigSchema = z + .object({ + network: z.enum(['mainnet', 'testnet'], { + errorMap: () => ({ message: 'Network must be mainnet or testnet' }), + }), + horizonUrl: z.string().url('Horizon URL must be a valid URL'), + sorobanRpcUrl: z.string().url('Soroban RPC URL must be a valid URL').optional(), + assetPairs: z + .array(assetPairSchema) + .max(20, 'A maximum of 20 asset pairs is supported') + .optional(), + contractAddresses: z + .record( + z.string().min(1, 'Contract key must not be empty'), + z.string().regex(CONTRACT_ADDRESS_RE, 'Contract address must be a valid Soroban contract ID (starts with C, 56 chars)') + ) + .optional(), + }) + .superRefine((cfg, ctx) => { + // Horizon URL / network mismatch + if (cfg.network === 'mainnet' && cfg.horizonUrl === TESTNET_HORIZON) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Horizon URL points to testnet but network is set to mainnet', + path: ['horizonUrl'], + }); + } + if (cfg.network === 'testnet' && cfg.horizonUrl === MAINNET_HORIZON) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Horizon URL points to mainnet but network is set to testnet', + path: ['horizonUrl'], + }); + } + }); + +// ── Public API ──────────────────────────────────────────────────────────────── + +export interface StellarConfigValidationResult { + valid: boolean; + errors: ValidationError[]; +} + +/** + * Validate a StellarConfig object. + * + * Returns field-level errors compatible with the shared ValidationError type. + * Safe to call from both API routes and React form hooks. + * + * @example + * const result = validateStellarConfig(config.stellar); + * if (!result.valid) { + * result.errors.forEach(e => console.error(e.field, e.message)); + * } + */ +export function validateStellarConfig(input: unknown): StellarConfigValidationResult { + const parsed = stellarConfigSchema.safeParse(input); + + if (!parsed.success) { + const errors: ValidationError[] = parsed.error.errors.map((e) => ({ + field: e.path.join('.'), + message: e.message, + code: e.code.toUpperCase(), + })); + return { valid: false, errors }; + } + + return { valid: true, errors: [] }; +} + +/** + * Default Stellar config values for the configuration panel. + * Uses testnet defaults to avoid accidental mainnet deployments. + */ +export const DEFAULT_STELLAR_CONFIG: StellarConfig = { + network: 'testnet', + horizonUrl: TESTNET_HORIZON, + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + assetPairs: [], + contractAddresses: {}, +};