diff --git a/apps/frontend/src/components/app/stellar/AssetPairEditor.tsx b/apps/frontend/src/components/app/stellar/AssetPairEditor.tsx new file mode 100644 index 0000000..b727379 --- /dev/null +++ b/apps/frontend/src/components/app/stellar/AssetPairEditor.tsx @@ -0,0 +1,189 @@ +'use client'; + +import React, { useState } from 'react'; +import type { AssetPair, StellarAsset, StellarAssetType } from '@craft/types'; + +interface AssetPairEditorProps { + pairs: AssetPair[]; + onAdd: (pair: AssetPair) => void; + onRemove: (index: number) => void; + error?: string; +} + +const EMPTY_ASSET: StellarAsset = { code: '', issuer: '', type: 'credit_alphanum4' }; +const EMPTY_PAIR: AssetPair = { base: { ...EMPTY_ASSET }, counter: { ...EMPTY_ASSET } }; + +function assetLabel(asset: StellarAsset): string { + if (asset.type === 'native') return 'XLM (native)'; + return asset.issuer ? `${asset.code}:${asset.issuer.slice(0, 8)}…` : asset.code; +} + +function AssetFields({ + prefix, + asset, + onChange, +}: { + prefix: string; + asset: StellarAsset; + onChange: (a: StellarAsset) => void; +}) { + const isNative = asset.type === 'native'; + return ( +
+
+ + +
+ {!isNative && ( + <> +
+ + onChange({ ...asset, code: e.target.value.toUpperCase() })} + placeholder="USDC" + maxLength={12} + className="flex-1 rounded border border-outline-variant/30 px-2 py-1 text-xs bg-surface-container-lowest text-on-surface placeholder:text-on-surface-variant/50" + /> +
+
+ + onChange({ ...asset, issuer: e.target.value })} + placeholder="G…" + className="flex-1 rounded border border-outline-variant/30 px-2 py-1 text-xs bg-surface-container-lowest text-on-surface placeholder:text-on-surface-variant/50 font-mono" + /> +
+ + )} +
+ ); +} + +export function AssetPairEditor({ pairs, onAdd, onRemove, error }: AssetPairEditorProps) { + const [draft, setDraft] = useState(EMPTY_PAIR); + const [open, setOpen] = useState(false); + + function handleAdd() { + onAdd(draft); + setDraft(EMPTY_PAIR); + setOpen(false); + } + + const canAdd = + (draft.base.type === 'native' || (draft.base.code && draft.base.issuer)) && + (draft.counter.type === 'native' || (draft.counter.code && draft.counter.issuer)); + + return ( +
+
+ Asset Pairs + +
+ + {error && ( +

+ {error} +

+ )} + + {pairs.length > 0 && ( + + )} + + {pairs.length === 0 && !open && ( +

No asset pairs configured.

+ )} + + {open && ( +
+
+
+ + Base asset + + setDraft((d) => ({ ...d, base: a }))} + /> +
+
+ + Counter asset + + setDraft((d) => ({ ...d, counter: a }))} + /> +
+
+
+ +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/app/stellar/ContractAddressInputs.tsx b/apps/frontend/src/components/app/stellar/ContractAddressInputs.tsx new file mode 100644 index 0000000..8f37de5 --- /dev/null +++ b/apps/frontend/src/components/app/stellar/ContractAddressInputs.tsx @@ -0,0 +1,159 @@ +'use client'; + +import React, { useState } from 'react'; +import { validateContractAddress } from '@/lib/stellar/contract-validation'; + +interface ContractAddressInputsProps { + contracts: Record; + onSet: (name: string, address: string) => void; + onRemove: (name: string) => void; + /** Field-level errors keyed by `stellar.contractAddresses.` */ + errors?: Map; +} + +export function ContractAddressInputs({ + contracts, + onSet, + onRemove, + errors, +}: ContractAddressInputsProps) { + const [newName, setNewName] = useState(''); + const [newAddress, setNewAddress] = useState(''); + const [addError, setAddError] = useState(null); + const [open, setOpen] = useState(false); + + function handleAdd() { + const trimmedName = newName.trim(); + const trimmedAddress = newAddress.trim(); + + if (!trimmedName) { + setAddError('Contract name is required'); + return; + } + if (trimmedName in contracts) { + setAddError(`A contract named "${trimmedName}" already exists`); + return; + } + const result = validateContractAddress(trimmedAddress); + if (!result.valid) { + setAddError(result.reason); + return; + } + + onSet(trimmedName, trimmedAddress); + setNewName(''); + setNewAddress(''); + setAddError(null); + setOpen(false); + } + + const entries = Object.entries(contracts); + + return ( +
+
+ Contract Addresses + +
+ + {entries.length > 0 && ( +
    + {entries.map(([name, address]) => { + const fieldError = errors?.get(`stellar.contractAddresses.${name}`); + return ( +
  • +
    +
    + {name} + + {address} + +
    + +
    + {fieldError && ( +

    + {fieldError} +

    + )} +
  • + ); + })} +
+ )} + + {entries.length === 0 && !open && ( +

No contract addresses configured.

+ )} + + {open && ( +
+
+ + { + setNewName(e.target.value); + setAddError(null); + }} + placeholder="e.g. amm_pool" + className="rounded border border-outline-variant/30 px-3 py-1.5 text-sm bg-surface-container-lowest text-on-surface placeholder:text-on-surface-variant/50" + /> +
+
+ + { + setNewAddress(e.target.value); + setAddError(null); + }} + placeholder="C…" + className="rounded border border-outline-variant/30 px-3 py-1.5 text-sm font-mono bg-surface-container-lowest text-on-surface placeholder:text-on-surface-variant/50" + /> +
+ {addError && ( +

+ {addError} +

+ )} +
+ +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/app/stellar/HorizonUrlInput.tsx b/apps/frontend/src/components/app/stellar/HorizonUrlInput.tsx new file mode 100644 index 0000000..1287036 --- /dev/null +++ b/apps/frontend/src/components/app/stellar/HorizonUrlInput.tsx @@ -0,0 +1,106 @@ +'use client'; + +import React from 'react'; +import type { ConnectivityStatus } from './useStellarConfigForm'; +import type { ConnectivityCheckResult } from '@/lib/stellar/endpoint-connectivity'; + +interface HorizonUrlInputProps { + value: string; + onChange: (value: string) => void; + onCheckConnectivity: () => void; + connectivityStatus: ConnectivityStatus; + connectivityResult: ConnectivityCheckResult | null; + error?: string; + label?: string; + id?: string; +} + +const STATUS_ICONS: Record = { + idle: '', + checking: '⏳', + ok: '✓', + error: '✗', +}; + +const STATUS_CLASSES: Record = { + idle: 'text-on-surface-variant', + checking: 'text-on-surface-variant', + ok: 'text-success', + error: 'text-error', +}; + +export function HorizonUrlInput({ + value, + onChange, + onCheckConnectivity, + connectivityStatus, + connectivityResult, + error, + label = 'Horizon URL', + id = 'horizon-url', +}: HorizonUrlInputProps) { + const hasError = !!error; + const statusDescId = `${id}-status`; + const errorId = `${id}-error`; + + const statusMessage = (() => { + if (connectivityStatus === 'checking') return 'Checking connectivity…'; + if (connectivityStatus === 'ok') { + const ms = connectivityResult?.responseTime; + return ms !== undefined ? `Reachable (${Math.round(ms)}ms)` : 'Reachable'; + } + if (connectivityStatus === 'error') { + return connectivityResult?.error ?? 'Endpoint unreachable'; + } + return null; + })(); + + return ( +
+ +
+ onChange(e.target.value)} + placeholder="https://horizon-testnet.stellar.org" + aria-invalid={hasError} + aria-describedby={[hasError ? errorId : '', statusMessage ? statusDescId : ''] + .filter(Boolean) + .join(' ') || undefined} + className={`flex-1 rounded-lg border px-3 py-2 text-sm text-on-surface bg-surface-container-lowest placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 transition-colors ${ + hasError + ? 'border-error focus:ring-error/40' + : 'border-outline-variant/30 focus:ring-primary/40' + }`} + /> + +
+ {hasError && ( + + )} + {statusMessage && ( +

+ + {statusMessage} +

+ )} +
+ ); +} diff --git a/apps/frontend/src/components/app/stellar/NetworkSelector.tsx b/apps/frontend/src/components/app/stellar/NetworkSelector.tsx new file mode 100644 index 0000000..38327d9 --- /dev/null +++ b/apps/frontend/src/components/app/stellar/NetworkSelector.tsx @@ -0,0 +1,82 @@ +'use client'; + +import React from 'react'; + +type Network = 'mainnet' | 'testnet'; + +interface NetworkSelectorProps { + value: Network; + onChange: (value: Network) => void; + error?: string; +} + +const NETWORKS: { value: Network; label: string; description: string }[] = [ + { + value: 'testnet', + label: 'Testnet', + description: 'Stellar test network — safe for development', + }, + { + value: 'mainnet', + label: 'Mainnet', + description: 'Stellar public network — real assets', + }, +]; + +export function NetworkSelector({ value, onChange, error }: NetworkSelectorProps) { + const hasError = !!error; + + return ( +
+
+ + Network + +
+ {NETWORKS.map((net) => { + const checked = value === net.value; + return ( + + ); + })} +
+
+ {hasError && ( + + )} +
+ ); +} diff --git a/apps/frontend/src/components/app/stellar/SorobanRpcInput.tsx b/apps/frontend/src/components/app/stellar/SorobanRpcInput.tsx new file mode 100644 index 0000000..1fe1c46 --- /dev/null +++ b/apps/frontend/src/components/app/stellar/SorobanRpcInput.tsx @@ -0,0 +1,105 @@ +'use client'; + +import React from 'react'; +import type { ConnectivityStatus } from './useStellarConfigForm'; +import type { ConnectivityCheckResult } from '@/lib/stellar/endpoint-connectivity'; + +interface SorobanRpcInputProps { + value: string; + onChange: (value: string) => void; + onCheckConnectivity: () => void; + connectivityStatus: ConnectivityStatus; + connectivityResult: ConnectivityCheckResult | null; + error?: string; +} + +const STATUS_ICONS: Record = { + idle: '', + checking: '⏳', + ok: '✓', + error: '✗', +}; + +const STATUS_CLASSES: Record = { + idle: 'text-on-surface-variant', + checking: 'text-on-surface-variant', + ok: 'text-success', + error: 'text-error', +}; + +export function SorobanRpcInput({ + value, + onChange, + onCheckConnectivity, + connectivityStatus, + connectivityResult, + error, +}: SorobanRpcInputProps) { + const hasError = !!error; + const statusDescId = 'soroban-rpc-status'; + const errorId = 'soroban-rpc-error'; + + const statusMessage = (() => { + if (connectivityStatus === 'checking') return 'Checking connectivity…'; + if (connectivityStatus === 'ok') { + const ms = connectivityResult?.responseTime; + return ms !== undefined ? `Reachable (${Math.round(ms)}ms)` : 'Reachable'; + } + if (connectivityStatus === 'error') { + return connectivityResult?.error ?? 'Endpoint unreachable'; + } + return null; + })(); + + return ( +
+ +
+ onChange(e.target.value)} + placeholder="https://soroban-testnet.stellar.org" + aria-invalid={hasError} + aria-describedby={ + [hasError ? errorId : '', statusMessage ? statusDescId : ''] + .filter(Boolean) + .join(' ') || undefined + } + className={`flex-1 rounded-lg border px-3 py-2 text-sm text-on-surface bg-surface-container-lowest placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 transition-colors ${ + hasError + ? 'border-error focus:ring-error/40' + : 'border-outline-variant/30 focus:ring-primary/40' + }`} + /> + +
+ {hasError && ( + + )} + {statusMessage && ( +

+ + {statusMessage} +

+ )} +
+ ); +} 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..556328e --- /dev/null +++ b/apps/frontend/src/components/app/stellar/StellarConfigPanel.tsx @@ -0,0 +1,131 @@ +'use client'; + +import React from 'react'; +import { NetworkSelector } from './NetworkSelector'; +import { HorizonUrlInput } from './HorizonUrlInput'; +import { SorobanRpcInput } from './SorobanRpcInput'; +import { AssetPairEditor } from './AssetPairEditor'; +import { ContractAddressInputs } from './ContractAddressInputs'; +import type { StellarConfigFormReturn } from './useStellarConfigForm'; + +interface StellarConfigPanelProps { + form: StellarConfigFormReturn; + onSubmit: () => void; + submitLabel?: string; + isSubmitting?: boolean; +} + +export function StellarConfigPanel({ + form, + onSubmit, + submitLabel = 'Save changes', + isSubmitting = false, +}: StellarConfigPanelProps) { + const { + state, + errors, + isDirty, + connectivityStatus, + connectivityResult, + sorobanConnectivityStatus, + sorobanConnectivityResult, + setStellar, + addAssetPair, + removeAssetPair, + setContractAddress, + removeContractAddress, + validate, + checkConnectivity, + checkSorobanConnectivity, + reset, + } = form; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const validationErrors = validate(); + if (validationErrors.length === 0) { + onSubmit(); + } + } + + return ( +
+ + setStellar('network', v)} + error={errors.get('stellar.network')} + /> + + + + setStellar('horizonUrl', v)} + onCheckConnectivity={checkConnectivity} + connectivityStatus={connectivityStatus} + connectivityResult={connectivityResult} + error={errors.get('stellar.horizonUrl')} + /> + setStellar('sorobanRpcUrl', v || undefined)} + onCheckConnectivity={checkSorobanConnectivity} + connectivityStatus={sorobanConnectivityStatus} + connectivityResult={sorobanConnectivityResult} + error={errors.get('stellar.sorobanRpcUrl')} + /> + + + + + + + + + + +
+ + +
+
+ ); +} + +function ConfigSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+
{children}
+
+ ); +} 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..833bb9a --- /dev/null +++ b/apps/frontend/src/components/app/stellar/index.ts @@ -0,0 +1,12 @@ +export { NetworkSelector } from './NetworkSelector'; +export { HorizonUrlInput } from './HorizonUrlInput'; +export { SorobanRpcInput } from './SorobanRpcInput'; +export { AssetPairEditor } from './AssetPairEditor'; +export { ContractAddressInputs } from './ContractAddressInputs'; +export { StellarConfigPanel } from './StellarConfigPanel'; +export { useStellarConfigForm } from './useStellarConfigForm'; +export type { + ConnectivityStatus, + StellarConfigFormState, + StellarConfigFormReturn, +} from './useStellarConfigForm'; diff --git a/apps/frontend/src/components/app/stellar/stellar.test.tsx b/apps/frontend/src/components/app/stellar/stellar.test.tsx new file mode 100644 index 0000000..c2fdd79 --- /dev/null +++ b/apps/frontend/src/components/app/stellar/stellar.test.tsx @@ -0,0 +1,495 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react'; +import { AssetPairEditor } from './AssetPairEditor'; +import { ContractAddressInputs } from './ContractAddressInputs'; +import { SorobanRpcInput } from './SorobanRpcInput'; +import { StellarConfigPanel } from './StellarConfigPanel'; +import { useStellarConfigForm, type StellarConfigFormState, type StellarConfigFormReturn } from './useStellarConfigForm'; +import type { AssetPair } from '@craft/types'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const XLM_PAIR: AssetPair = { + base: { code: 'XLM', issuer: '', type: 'native' }, + counter: { code: 'USDC', issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', type: 'credit_alphanum4' }, +}; + +const VALID_CONTRACT = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; + +const INITIAL_STATE: StellarConfigFormState = { + stellar: { + network: 'testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + sorobanRpcUrl: '', + assetPairs: [], + contractAddresses: {}, + }, +}; + +function createMockForm(overrides: Partial = {}): StellarConfigFormReturn { + return { + state: INITIAL_STATE, + errors: new Map(), + isDirty: false, + connectivityStatus: 'idle', + connectivityResult: null, + sorobanConnectivityStatus: 'idle', + sorobanConnectivityResult: null, + setStellar: vi.fn(), + addAssetPair: vi.fn(), + removeAssetPair: vi.fn(), + setContractAddress: vi.fn(), + removeContractAddress: vi.fn(), + validate: vi.fn(() => []), + checkConnectivity: vi.fn(), + checkSorobanConnectivity: vi.fn(), + reset: vi.fn(), + ...overrides, + }; +} + +// ── AssetPairEditor ─────────────────────────────────────────────────────────── + +describe('AssetPairEditor', () => { + it('renders empty state message when no pairs', () => { + render(); + expect(screen.getByText('No asset pairs configured.')).toBeDefined(); + }); + + it('renders existing pairs', () => { + render(); + expect(screen.getByText(/XLM.*USDC/)).toBeDefined(); + }); + + it('calls onRemove when remove button clicked', () => { + const onRemove = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /Remove pair/i })); + expect(onRemove).toHaveBeenCalledWith(0); + }); + + it('opens add form when Add pair button clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: '+ Add pair' })); + expect(screen.getByText('Base asset')).toBeDefined(); + expect(screen.getByText('Counter asset')).toBeDefined(); + }); + + it('closes add form when Cancel clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: '+ Add pair' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByText('Base asset')).toBeNull(); + }); + + it('Add pair button is disabled when fields are incomplete', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: '+ Add pair' })); + const addBtn = screen.getByRole('button', { name: 'Add pair' }) as HTMLButtonElement; + expect(addBtn.disabled).toBe(true); + }); + + it('shows error message when error prop provided', () => { + render(); + expect(screen.getByRole('alert')).toBeDefined(); + expect(screen.getByText('At least one pair required')).toBeDefined(); + }); +}); + +// ── ContractAddressInputs ───────────────────────────────────────────────────── + +describe('ContractAddressInputs', () => { + it('renders empty state message when no contracts', () => { + render(); + expect(screen.getByText('No contract addresses configured.')).toBeDefined(); + }); + + it('renders existing contracts', () => { + render( + , + ); + expect(screen.getByText('amm_pool')).toBeDefined(); + expect(screen.getByText(VALID_CONTRACT)).toBeDefined(); + }); + + it('calls onRemove when remove button clicked', () => { + const onRemove = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /Remove contract amm_pool/i })); + expect(onRemove).toHaveBeenCalledWith('amm_pool'); + }); + + it('opens add form when Add contract button clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: '+ Add contract' })); + expect(screen.getByLabelText('Contract name')).toBeDefined(); + expect(screen.getByLabelText('Contract address')).toBeDefined(); + }); + + it('shows validation error for invalid contract address', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: '+ Add contract' })); + fireEvent.change(screen.getByLabelText('Contract name'), { target: { value: 'pool' } }); + fireEvent.change(screen.getByLabelText('Contract address'), { target: { value: 'INVALID' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add contract' })); + expect(screen.getByRole('alert')).toBeDefined(); + }); + + it('calls onSet with valid name and address', () => { + const onSet = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: '+ Add contract' })); + fireEvent.change(screen.getByLabelText('Contract name'), { target: { value: 'pool' } }); + fireEvent.change(screen.getByLabelText('Contract address'), { target: { value: VALID_CONTRACT } }); + fireEvent.click(screen.getByRole('button', { name: 'Add contract' })); + expect(onSet).toHaveBeenCalledWith('pool', VALID_CONTRACT); + }); + + it('shows field-level error from errors map', () => { + const errors = new Map([['stellar.contractAddresses.amm_pool', 'Invalid address']]); + render( + , + ); + expect(screen.getByText('Invalid address')).toBeDefined(); + }); + + it('shows error when duplicate contract name added', () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: '+ Add contract' })); + fireEvent.change(screen.getByLabelText('Contract name'), { target: { value: 'pool' } }); + fireEvent.change(screen.getByLabelText('Contract address'), { target: { value: VALID_CONTRACT } }); + fireEvent.click(screen.getByRole('button', { name: 'Add contract' })); + expect(screen.getByRole('alert')).toBeDefined(); + expect(screen.getByText(/already exists/i)).toBeDefined(); + }); +}); + +// ── SorobanRpcInput ─────────────────────────────────────────────────────────── + +describe('SorobanRpcInput', () => { + it('renders label and input', () => { + render( + , + ); + expect(screen.getByLabelText(/Soroban RPC URL/i)).toBeDefined(); + }); + + it('calls onChange when input changes', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText(/Soroban RPC URL/i), { + target: { value: 'https://soroban-testnet.stellar.org' }, + }); + expect(onChange).toHaveBeenCalledWith('https://soroban-testnet.stellar.org'); + }); + + it('disables Check button when value is empty', () => { + render( + , + ); + const btn = screen.getByRole('button', { name: /Check Soroban RPC connectivity/i }) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it('calls onCheckConnectivity when Check button clicked', () => { + const onCheck = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /Check Soroban RPC connectivity/i })); + expect(onCheck).toHaveBeenCalledOnce(); + }); + + it('shows reachable status with response time', () => { + render( + , + ); + expect(screen.getByText(/Reachable.*123ms/)).toBeDefined(); + }); + + it('shows error status message', () => { + render( + , + ); + expect(screen.getByText('Timeout after 5000ms')).toBeDefined(); + }); + + it('shows validation error', () => { + render( + , + ); + expect(screen.getByRole('alert')).toBeDefined(); + expect(screen.getByText('Soroban RPC URL must be a valid http/https URL')).toBeDefined(); + }); +}); + +// ── StellarConfigPanel ──────────────────────────────────────────────────────── + +describe('StellarConfigPanel', () => { + it('renders all sections', () => { + render(); + // Section headings may share text with child component labels + expect(screen.getAllByText('Network').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Endpoints')).toBeDefined(); + expect(screen.getAllByText('Asset Pairs').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Smart Contracts')).toBeDefined(); + }); + + it('disables submit and reset when not dirty', () => { + render(); + const submit = screen.getByRole('button', { name: 'Save changes' }) as HTMLButtonElement; + const reset = screen.getByRole('button', { name: 'Reset' }) as HTMLButtonElement; + expect(submit.disabled).toBe(true); + expect(reset.disabled).toBe(true); + }); + + it('enables submit and reset when dirty', () => { + render(); + const submit = screen.getByRole('button', { name: 'Save changes' }) as HTMLButtonElement; + expect(submit.disabled).toBe(false); + }); + + it('calls validate then onSubmit when form submitted with no errors', () => { + const onSubmit = vi.fn(); + const validate = vi.fn(() => []); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + expect(validate).toHaveBeenCalledOnce(); + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + it('does not call onSubmit when validation fails', () => { + const onSubmit = vi.fn(); + const validate = vi.fn(() => [ + { field: 'stellar.horizonUrl', message: 'Invalid URL', code: 'INVALID_URL' }, + ]); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('calls reset when Reset button clicked', () => { + const reset = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + expect(reset).toHaveBeenCalledOnce(); + }); + + it('shows custom submit label', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Deploy' })).toBeDefined(); + }); + + it('shows submitting state', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Saving…' })).toBeDefined(); + }); +}); + +// ── useStellarConfigForm ────────────────────────────────────────────────────── + +describe('useStellarConfigForm', () => { + it('initializes with given state', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + expect(result.current.state).toEqual(INITIAL_STATE); + expect(result.current.isDirty).toBe(false); + expect(result.current.errors.size).toBe(0); + }); + + it('tracks dirty state when network changes', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => result.current.setStellar('network', 'mainnet')); + expect(result.current.isDirty).toBe(true); + }); + + it('auto-updates horizonUrl when network changes from default', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => result.current.setStellar('network', 'mainnet')); + expect(result.current.state.stellar.horizonUrl).toBe('https://horizon.stellar.org'); + }); + + it('does not auto-update horizonUrl when it was customized', () => { + const custom: StellarConfigFormState = { + stellar: { ...INITIAL_STATE.stellar, horizonUrl: 'https://custom.horizon.example.com' }, + }; + const { result } = renderHook(() => useStellarConfigForm(custom)); + act(() => result.current.setStellar('network', 'mainnet')); + expect(result.current.state.stellar.horizonUrl).toBe('https://custom.horizon.example.com'); + }); + + it('resets connectivity status when horizonUrl changes', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => result.current.setStellar('horizonUrl', 'https://new.horizon.example.com')); + expect(result.current.connectivityStatus).toBe('idle'); + expect(result.current.connectivityResult).toBeNull(); + }); + + it('resets soroban connectivity status when sorobanRpcUrl changes', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => result.current.setStellar('sorobanRpcUrl', 'https://new.soroban.example.com')); + expect(result.current.sorobanConnectivityStatus).toBe('idle'); + expect(result.current.sorobanConnectivityResult).toBeNull(); + }); + + it('adds and removes asset pairs', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => result.current.addAssetPair(XLM_PAIR)); + expect(result.current.state.stellar.assetPairs).toHaveLength(1); + act(() => result.current.removeAssetPair(0)); + expect(result.current.state.stellar.assetPairs).toHaveLength(0); + }); + + it('sets and removes contract addresses', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => result.current.setContractAddress('pool', VALID_CONTRACT)); + expect(result.current.state.stellar.contractAddresses?.pool).toBe(VALID_CONTRACT); + act(() => result.current.removeContractAddress('pool')); + expect(result.current.state.stellar.contractAddresses?.pool).toBeUndefined(); + }); + + it('validates invalid horizonUrl', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => result.current.setStellar('horizonUrl', 'not-a-url')); + let errors: any[]; + act(() => { errors = result.current.validate(); }); + expect(errors!.some((e) => e.field === 'stellar.horizonUrl')).toBe(true); + }); + + it('validates network/horizon mismatch', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => { + result.current.setStellar('network', 'mainnet'); + result.current.setStellar('horizonUrl', 'https://horizon-testnet.stellar.org'); + }); + let errors: any[]; + act(() => { errors = result.current.validate(); }); + expect(errors!.some((e) => e.code === 'HORIZON_NETWORK_MISMATCH')).toBe(true); + }); + + it('validates invalid sorobanRpcUrl', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => result.current.setStellar('sorobanRpcUrl', 'not-a-url')); + let errors: any[]; + act(() => { errors = result.current.validate(); }); + expect(errors!.some((e) => e.field === 'stellar.sorobanRpcUrl')).toBe(true); + }); + + it('returns no errors for valid state', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + let errors: any[]; + act(() => { errors = result.current.validate(); }); + expect(errors!).toEqual([]); + }); + + it('resets to initial state', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => result.current.setStellar('network', 'mainnet')); + act(() => result.current.reset()); + expect(result.current.state).toEqual(INITIAL_STATE); + expect(result.current.isDirty).toBe(false); + expect(result.current.errors.size).toBe(0); + }); + + it('clears field error when that field changes', () => { + const { result } = renderHook(() => useStellarConfigForm(INITIAL_STATE)); + act(() => result.current.setStellar('horizonUrl', 'bad')); + act(() => { result.current.validate(); }); + expect(result.current.errors.has('stellar.horizonUrl')).toBe(true); + act(() => result.current.setStellar('horizonUrl', 'https://horizon-testnet.stellar.org')); + expect(result.current.errors.has('stellar.horizonUrl')).toBe(false); + }); +}); 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..c905442 --- /dev/null +++ b/apps/frontend/src/components/app/stellar/useStellarConfigForm.ts @@ -0,0 +1,271 @@ +'use client'; + +import { useState, useCallback, useMemo, useRef } from 'react'; +import type { StellarConfig, AssetPair, ValidationError } from '@craft/types'; +import { HORIZON_URLS } from '@craft/stellar'; +import { validateContractAddresses } from '@/lib/stellar/contract-validation'; +import { + checkHorizonEndpoint, + checkSorobanRpcEndpoint, + type ConnectivityCheckResult, +} from '@/lib/stellar/endpoint-connectivity'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type ConnectivityStatus = 'idle' | 'checking' | 'ok' | 'error'; + +export interface StellarConfigFormState { + stellar: StellarConfig; +} + +export interface StellarConfigFormReturn { + state: StellarConfigFormState; + errors: Map; + isDirty: boolean; + connectivityStatus: ConnectivityStatus; + connectivityResult: ConnectivityCheckResult | null; + sorobanConnectivityStatus: ConnectivityStatus; + sorobanConnectivityResult: ConnectivityCheckResult | null; + setStellar: (key: K, value: StellarConfig[K]) => void; + addAssetPair: (pair: AssetPair) => void; + removeAssetPair: (index: number) => void; + setContractAddress: (name: string, address: string) => void; + removeContractAddress: (name: string) => void; + validate: () => ValidationError[]; + checkConnectivity: () => Promise; + checkSorobanConnectivity: () => Promise; + reset: () => void; +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +const URL_PATTERN = /^https?:\/\/.+/; +const MAINNET_HORIZON = 'https://horizon.stellar.org'; +const TESTNET_HORIZON = 'https://horizon-testnet.stellar.org'; + +function validateStellarFields(state: StellarConfigFormState): ValidationError[] { + const errors: ValidationError[] = []; + const { network, horizonUrl, sorobanRpcUrl, contractAddresses } = state.stellar; + + if (network !== 'mainnet' && network !== 'testnet') { + errors.push({ + field: 'stellar.network', + message: 'Network must be mainnet or testnet', + code: 'UNSUPPORTED_NETWORK', + }); + } + + if (!horizonUrl || !URL_PATTERN.test(horizonUrl)) { + errors.push({ + field: 'stellar.horizonUrl', + message: 'Horizon URL must be a valid http/https URL', + code: 'INVALID_URL', + }); + } else { + if (network === 'mainnet' && horizonUrl === TESTNET_HORIZON) { + errors.push({ + field: 'stellar.horizonUrl', + message: 'Horizon URL points to testnet but network is mainnet', + code: 'HORIZON_NETWORK_MISMATCH', + }); + } + if (network === 'testnet' && horizonUrl === MAINNET_HORIZON) { + errors.push({ + field: 'stellar.horizonUrl', + message: 'Horizon URL points to mainnet but network is testnet', + code: 'HORIZON_NETWORK_MISMATCH', + }); + } + } + + if (sorobanRpcUrl && !URL_PATTERN.test(sorobanRpcUrl)) { + errors.push({ + field: 'stellar.sorobanRpcUrl', + message: 'Soroban RPC URL must be a valid http/https URL', + code: 'INVALID_URL', + }); + } + + const contractValidation = validateContractAddresses(contractAddresses); + if (!contractValidation.valid) { + errors.push({ + field: contractValidation.field, + message: contractValidation.reason, + code: contractValidation.code, + }); + } + + return errors; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export function useStellarConfigForm(initial: StellarConfigFormState): StellarConfigFormReturn { + const [state, setState] = useState(initial); + const [validationErrors, setValidationErrors] = useState([]); + const [connectivityStatus, setConnectivityStatus] = useState('idle'); + const [connectivityResult, setConnectivityResult] = useState(null); + const [sorobanConnectivityStatus, setSorobanConnectivityStatus] = useState('idle'); + const [sorobanConnectivityResult, setSorobanConnectivityResult] = useState(null); + const initialRef = useRef(initial); + + const isDirty = useMemo(() => { + const init = initialRef.current.stellar; + const curr = state.stellar; + return ( + curr.network !== init.network || + curr.horizonUrl !== init.horizonUrl || + curr.sorobanRpcUrl !== init.sorobanRpcUrl || + JSON.stringify(curr.assetPairs) !== JSON.stringify(init.assetPairs) || + JSON.stringify(curr.contractAddresses) !== JSON.stringify(init.contractAddresses) + ); + }, [state]); + + const setStellar = useCallback((key: K, value: StellarConfig[K]) => { + setState((prev) => { + const updated = { ...prev.stellar, [key]: value }; + // Auto-update horizonUrl when network changes (only if it matches the old default) + if (key === 'network') { + const net = value as 'mainnet' | 'testnet'; + const oldDefault = HORIZON_URLS[prev.stellar.network as 'mainnet' | 'testnet']; + if (prev.stellar.horizonUrl === oldDefault) { + updated.horizonUrl = HORIZON_URLS[net]; + } + } + return { ...prev, stellar: updated }; + }); + setValidationErrors((prev) => prev.filter((e) => e.field !== `stellar.${key}`)); + // Reset connectivity when URL changes + if (key === 'horizonUrl') { + setConnectivityStatus('idle'); + setConnectivityResult(null); + } + if (key === 'sorobanRpcUrl') { + setSorobanConnectivityStatus('idle'); + setSorobanConnectivityResult(null); + } + }, []); + + const addAssetPair = useCallback((pair: AssetPair) => { + setState((prev) => ({ + ...prev, + stellar: { + ...prev.stellar, + assetPairs: [...(prev.stellar.assetPairs ?? []), pair], + }, + })); + }, []); + + const removeAssetPair = useCallback((index: number) => { + setState((prev) => ({ + ...prev, + stellar: { + ...prev.stellar, + assetPairs: (prev.stellar.assetPairs ?? []).filter((_, i) => i !== index), + }, + })); + }, []); + + const setContractAddress = useCallback((name: string, address: string) => { + setState((prev) => ({ + ...prev, + stellar: { + ...prev.stellar, + contractAddresses: { ...(prev.stellar.contractAddresses ?? {}), [name]: address }, + }, + })); + setValidationErrors((prev) => + prev.filter((e) => e.field !== `stellar.contractAddresses.${name}`) + ); + }, []); + + const removeContractAddress = useCallback((name: string) => { + setState((prev) => { + const { [name]: _, ...rest } = prev.stellar.contractAddresses ?? {}; + return { + ...prev, + stellar: { ...prev.stellar, contractAddresses: rest }, + }; + }); + }, []); + + const validate = useCallback((): ValidationError[] => { + const errors = validateStellarFields(state); + setValidationErrors(errors); + return errors; + }, [state]); + + const checkConnectivity = useCallback(async () => { + setConnectivityStatus('checking'); + setConnectivityResult(null); + try { + const result = await checkHorizonEndpoint(state.stellar.horizonUrl); + setConnectivityResult(result); + setConnectivityStatus(result.reachable ? 'ok' : 'error'); + } catch { + setConnectivityStatus('error'); + setConnectivityResult({ + reachable: false, + endpoint: state.stellar.horizonUrl, + errorType: 'TRANSIENT', + error: 'Unexpected error during connectivity check', + }); + } + }, [state.stellar.horizonUrl]); + + const checkSorobanConnectivity = useCallback(async () => { + const url = state.stellar.sorobanRpcUrl; + if (!url) return; + setSorobanConnectivityStatus('checking'); + setSorobanConnectivityResult(null); + try { + const result = await checkSorobanRpcEndpoint(url); + setSorobanConnectivityResult(result); + setSorobanConnectivityStatus(result.reachable ? 'ok' : 'error'); + } catch { + setSorobanConnectivityStatus('error'); + setSorobanConnectivityResult({ + reachable: false, + endpoint: url, + errorType: 'TRANSIENT', + error: 'Unexpected error during connectivity check', + }); + } + }, [state.stellar.sorobanRpcUrl]); + + const reset = useCallback(() => { + setState(initialRef.current); + setValidationErrors([]); + setConnectivityStatus('idle'); + setConnectivityResult(null); + setSorobanConnectivityStatus('idle'); + setSorobanConnectivityResult(null); + }, []); + + 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, + connectivityStatus, + connectivityResult, + sorobanConnectivityStatus, + sorobanConnectivityResult, + setStellar, + addAssetPair, + removeAssetPair, + setContractAddress, + removeContractAddress, + validate, + checkConnectivity, + checkSorobanConnectivity, + reset, + }; +} diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts index 774d8ac..b6468f1 100644 --- a/apps/frontend/vitest.config.ts +++ b/apps/frontend/vitest.config.ts @@ -13,6 +13,8 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@craft/types': path.resolve(__dirname, '../../packages/types/src'), + '@craft/stellar': path.resolve(__dirname, '../../packages/stellar/src'), }, }, });