diff --git a/__tests__/components/create-form.test.tsx b/__tests__/components/create-form.test.tsx
new file mode 100644
index 0000000..c07363e
--- /dev/null
+++ b/__tests__/components/create-form.test.tsx
@@ -0,0 +1,170 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { CreateForm } from '@/app/app/create/create-form'
+
+// ─── Next.js navigation ───────────────────────────────────────────────────────
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+ useSearchParams: () => ({ get: () => null }),
+}))
+
+// ─── Wallet / contract hooks ──────────────────────────────────────────────────
+vi.mock('@/hooks/use-wallet', () => ({
+ useWallet: () => ({ address: 'GSENDERTEST111111111111111111111111111111111111' }),
+}))
+
+vi.mock('@/hooks/use-contract', () => ({
+ useContract: () => ({
+ createStream: vi.fn(),
+ estimateFee: vi.fn(),
+ pending: false,
+ error: null,
+ }),
+}))
+
+vi.mock('@/hooks/use-token-price', () => ({
+ useTokenPrice: () => ({ usdPrice: null, stale: false, loading: false }),
+}))
+
+vi.mock('@/hooks/use-form-draft', () => ({
+ useFormDraft: () => ({ loadDraft: () => null, restore: vi.fn(), discard: vi.fn() }),
+ clearExpiredDrafts: vi.fn(),
+}))
+
+// ─── Stellar / contract lib ───────────────────────────────────────────────────
+vi.mock('@/lib/stellar', () => ({
+ getAllTokens: () => [{ address: 'CNATIVE', symbol: 'XLM', decimals: 7 }],
+ saveCustomToken: vi.fn(),
+ checkAccountInfo: vi.fn().mockResolvedValue({ exists: true, funded: true, transactionCount: 1 }),
+}))
+
+vi.mock('@/lib/contract', () => ({
+ getTokenMetadata: vi.fn(),
+ getTokenBalance: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/lib/federation', () => ({
+ isFederationAddress: () => false,
+ resolveFederationAddress: vi.fn(),
+}))
+
+vi.mock('@/lib/address-book', () => ({
+ addAddressBookEntry: vi.fn(),
+ deleteAddressBookEntry: vi.fn(),
+ getAddressBookEntries: () => [],
+ touchAddressBookEntry: vi.fn(),
+ updateAddressBookEntry: vi.fn(),
+}))
+
+vi.mock('@/lib/recurring', () => ({
+ buildNextRunAt: vi.fn(),
+ saveRecurringRule: vi.fn(),
+}))
+
+// ─── Network provider ─────────────────────────────────────────────────────────
+vi.mock('@/components/providers/network-provider', () => ({
+ useNetwork: () => ({ network: 'testnet' }),
+}))
+
+// ─── Heavy UI components (not under test here) ────────────────────────────────
+vi.mock('@/components/streams/stream-preview', () => ({
+ StreamPreview: () => null,
+}))
+
+vi.mock('@/components/streams/create-confirmation', () => ({
+ CreateConfirmation: () => null,
+}))
+
+vi.mock('@/components/streams/stream-templates', () => ({
+ StreamTemplates: () => null,
+}))
+
+vi.mock('@/components/ui/tx-preview-dialog', () => ({
+ TxPreviewDialog: () => null,
+}))
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
+}))
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+const VALID_ADDRESS = 'GBWEDYWFGPNPAWCYOKWMCRPTR4IMV4SNZ7CVOZHPUXGHVXXPJSCFKVXQ'
+
+function submitForm() {
+ const btn = screen.getByRole('button', { name: /create stream/i })
+ fireEvent.click(btn)
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+describe('CreateForm — validation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('required field validation', () => {
+ it('shows recipient and amount errors when form is submitted empty', async () => {
+ render()
+ submitForm()
+
+ await waitFor(() => {
+ expect(screen.getByText('Invalid Stellar address format')).toBeInTheDocument()
+ expect(screen.getByText('Enter a valid amount greater than 0')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('invalid recipient address', () => {
+ it('shows address-format error for a plainly invalid recipient', async () => {
+ render()
+
+ fireEvent.change(screen.getByPlaceholderText(/GABC/i), {
+ target: { value: 'not-a-stellar-address' },
+ })
+ fireEvent.change(screen.getByLabelText(/total amount/i), {
+ target: { value: '100' },
+ })
+
+ submitForm()
+
+ await waitFor(() => {
+ expect(screen.getByText('Invalid Stellar address format')).toBeInTheDocument()
+ })
+
+ // No amount error should be present
+ expect(screen.queryByText('Enter a valid amount greater than 0')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('cliff amount exceeds total amount', () => {
+ it('shows cliff-exceeds-total error when cliff amount > total amount', async () => {
+ render()
+
+ // Set a valid recipient
+ fireEvent.change(screen.getByPlaceholderText(/GABC/i), {
+ target: { value: VALID_ADDRESS },
+ })
+
+ // Set total amount
+ fireEvent.change(screen.getByLabelText(/total amount/i), {
+ target: { value: '100' },
+ })
+
+ // Enable cliff via checkbox
+ fireEvent.click(screen.getByLabelText(/add a cliff/i))
+
+ // Set cliff amount higher than total
+ await waitFor(() => {
+ expect(screen.getByLabelText(/cliff amount/i)).toBeInTheDocument()
+ })
+ fireEvent.change(screen.getByLabelText(/cliff amount/i), {
+ target: { value: '200' },
+ })
+
+ submitForm()
+
+ await waitFor(() => {
+ expect(screen.getByText('Cliff amount cannot exceed total amount')).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/__tests__/components/unlock-chart-render.test.tsx b/__tests__/components/unlock-chart-render.test.tsx
new file mode 100644
index 0000000..c73094b
--- /dev/null
+++ b/__tests__/components/unlock-chart-render.test.tsx
@@ -0,0 +1,91 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { UnlockChart } from '@/components/streams/unlock-chart'
+import type { StreamData } from '@/types/stream'
+
+vi.mock('@/lib/stream-utils', () => ({
+ formatTokenAmount: (raw: bigint) => String(raw),
+ SECONDS_PER_DAY: 86400,
+}))
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const NOW = 1_700_050_000
+
+const baseStream: StreamData = {
+ id: '1',
+ sender: 'GSENDER1111111111111111111111111111111111111111',
+ recipient: 'GRECIPIENT111111111111111111111111111111111111',
+ token: { address: 'CXLM', symbol: 'XLM', decimals: 7 },
+ depositedAmount: 1_000_000n,
+ withdrawnAmount: 0n,
+ startTime: 1_700_000_000n,
+ endTime: 1_700_100_000n,
+ cliffTime: 1_700_000_000n, // cliff == start → no cliff
+ cliffAmount: 0n,
+ amountPerSecond: 10n,
+ linearAmount: 1_000_000n,
+ duration: 100_000n,
+ cancelled: false,
+}
+
+const noCliffStream: StreamData = { ...baseStream }
+
+const cliffStream: StreamData = {
+ ...baseStream,
+ id: '2',
+ cliffTime: 1_700_030_000n, // 30,000 s after start
+ cliffAmount: 300_000n,
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe('UnlockChart — no-cliff stream', () => {
+ it('renders the SVG chart element', () => {
+ render()
+ expect(screen.getByRole('img')).toBeInTheDocument()
+ })
+
+ it('renders the unlock curve path', () => {
+ const { container } = render()
+ // The unlock curve is the with stroke and strokeWidth=2
+ const curvePath = container.querySelector('path[stroke="currentColor"][stroke-width="2"]')
+ expect(curvePath).not.toBeNull()
+ })
+
+ it('does NOT render a dashed cliff indicator line', () => {
+ const { container } = render()
+ const cliffLine = container.querySelector('line[stroke-dasharray="4 3"]')
+ expect(cliffLine).toBeNull()
+ })
+
+ it('does NOT show the Cliff legend item', () => {
+ render()
+ expect(screen.queryByText('Cliff')).not.toBeInTheDocument()
+ })
+})
+
+describe('UnlockChart — cliff stream', () => {
+ it('renders the SVG chart element', () => {
+ render()
+ expect(screen.getByRole('img')).toBeInTheDocument()
+ })
+
+ it('renders the unlock curve path', () => {
+ const { container } = render()
+ const curvePath = container.querySelector('path[stroke="currentColor"][stroke-width="2"]')
+ expect(curvePath).not.toBeNull()
+ })
+
+ it('renders a dashed cliff indicator line', () => {
+ const { container } = render()
+ const cliffLine = container.querySelector('line[stroke-dasharray="4 3"]')
+ expect(cliffLine).not.toBeNull()
+ })
+
+ it('shows the Cliff legend item', () => {
+ render()
+ // The legend renders "Cliff" as a text node in the DOM
+ expect(screen.getAllByText('Cliff').length).toBeGreaterThan(0)
+ })
+})