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.map((pair, i) => (
+ -
+
+ {assetLabel(pair.base)} / {assetLabel(pair.counter)}
+
+
+
+ ))}
+
+ )}
+
+ {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 && (
+
+ {error}
+
+ )}
+ {statusMessage && (
+
+ {STATUS_ICONS[connectivityStatus]}
+ {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 (
+
+
+ {hasError && (
+
+ {error}
+
+ )}
+
+ );
+}
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 && (
+
+ {error}
+
+ )}
+ {statusMessage && (
+
+ {STATUS_ICONS[connectivityStatus]}
+ {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 (
+
+ );
+}
+
+function ConfigSection({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
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'),
},
},
});