From 1723d86d5d0533df579f01a15118550b337e2c81 Mon Sep 17 00:00:00 2001 From: KingFRANKHOOD Date: Wed, 29 Jul 2026 23:00:05 +0100 Subject: [PATCH] feat: add withdrawal validation with balance and liquidity checks (#89) --- context/progress-tracker.md | 14 ++ src/lucide-react.d.ts | 113 +++++++++++ src/pages/Sponsors.tsx | 79 ++++++-- src/pages/__tests__/Sponsors.test.tsx | 261 ++++++++++++++++++++++++++ src/services/queryKeys.ts | 1 + src/services/sponsors.service.ts | 6 + src/types/index.ts | 6 + 7 files changed, 469 insertions(+), 11 deletions(-) create mode 100644 src/lucide-react.d.ts create mode 100644 src/pages/__tests__/Sponsors.test.tsx diff --git a/context/progress-tracker.md b/context/progress-tracker.md index f512a8f..e082d24 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -6,6 +6,20 @@ pure chore/docs commits). Direct pushes to main must also be logged here. --- +## 2026-07-29 + +- Added withdrawal validation in Sponsors page (#89): the withdraw form now + fetches `GET /liquidity/my-summary` via `sponsorsService.getMySummary()` and + validates the requested shares against both the sponsor's actual share balance + and the pool's available liquidity. A "Max" button fills the field with the + sponsor's full share balance. The Preview Value block is suppressed when the + amount is invalid. The submit button is disabled while the position query is + loading. Zero-share sponsors see an empty state with an explanatory message. + Added `MySummary` type to `types/index.ts` and a `mySummary` key to + `queryKeys.pool`. Tests cover over-balance, over-liquidity, exact-balance + boundary, and zero-share sponsor states. Also fixed pre-existing + `lucide-react` missing declaration issue with a `.d.ts` shim. + ## 2026-07-17 - Fixed intermittent 401 on wallet-connect / role selection: the axios diff --git a/src/lucide-react.d.ts b/src/lucide-react.d.ts new file mode 100644 index 0000000..802a3e9 --- /dev/null +++ b/src/lucide-react.d.ts @@ -0,0 +1,113 @@ +declare module 'lucide-react' { + import type { FC, SVGProps } from 'react' + export interface LucideProps extends SVGProps { + size?: number | string + } + export type Icon = FC + export type LucideIcon = Icon + export const Activity: Icon + export const AlertCircle: Icon + export const AlertTriangle: Icon + export const ArrowDownLeft: Icon + export const ArrowLeft: Icon + export const ArrowRight: Icon + export const ArrowUpRight: Icon + export const Award: Icon + export const Ban: Icon + export const BarChart3: Icon + export const Bell: Icon + export const BellRing: Icon + export const Binary: Icon + export const BookOpen: Icon + export const Bookmark: Icon + export const Building: Icon + export const Building2: Icon + export const Calendar: Icon + export const Check: Icon + export const CheckCircle: Icon + export const CheckCircle2: Icon + export const ChevronDown: Icon + export const ChevronLeft: Icon + export const ChevronRight: Icon + export const ChevronUp: Icon + export const ClipboardList: Icon + export const Clock: Icon + export const Code: Icon + export const Copy: Icon + export const CreditCard: Icon + export const Crown: Icon + export const DollarSign: Icon + export const Download: Icon + export const Edit3: Icon + export const ExternalLink: Icon + export const ExternalLinkIcon: Icon + export const Eye: Icon + export const EyeOff: Icon + export const FileText: Icon + export const Filter: Icon + export const Flag: Icon + export const Gift: Icon + export const Github: Icon + export const Globe: Icon + export const GraduationCap: Icon + export const Grid: Icon + export const Hash: Icon + export const Heart: Icon + export const HelpCircle: Icon + export const Info: Icon + export const Layers: Icon + export const Link: Icon + export const Linkedin: Icon + export const List: Icon + export const Loader2: Icon + export const Lock: Icon + export const LogOut: Icon + export const LucideIcon: Icon + export const Mail: Icon + export const MapPin: Icon + export const Medal: Icon + export const Menu: Icon + export const MessageSquare: Icon + export const Minus: Icon + export const Moon: Icon + export const MoreHorizontal: Icon + export const Package: Icon + export const Percent: Icon + export const Phone: Icon + export const PieChart: Icon + export const Plus: Icon + export const RefreshCw: Icon + export const Rocket: Icon + export const RotateCw: Icon + export const Search: Icon + export const Send: Icon + export const Settings: Icon + export const Share2: Icon + export const Shield: Icon + export const ShieldCheck: Icon + export const SlidersHorizontal: Icon + export const SortAsc: Icon + export const SortDesc: Icon + export const Sparkles: Icon + export const Star: Icon + export const Store: Icon + export const Sun: Icon + export const Tag: Icon + export const Target: Icon + export const Terminal: Icon + export const ThumbsDown: Icon + export const ThumbsUp: Icon + export const Trash2: Icon + export const TrendingDown: Icon + export const TrendingUp: Icon + export const Twitter: Icon + export const Unlock: Icon + export const Upload: Icon + export const User: Icon + export const UserCheck: Icon + export const Users: Icon + export const Wallet: Icon + export const X: Icon + export const XCircle: Icon + export const Zap: Icon +} diff --git a/src/pages/Sponsors.tsx b/src/pages/Sponsors.tsx index bb1d0b7..b54c41f 100644 --- a/src/pages/Sponsors.tsx +++ b/src/pages/Sponsors.tsx @@ -10,7 +10,7 @@ import { Button } from '../components/ui/Button' import { Card } from '../components/ui/Card' import { Badge } from '../components/ui/Badge' import { Spinner } from '../components/ui/Spinner' -import type { PoolInfo } from '../types' +import type { MySummary, PoolInfo } from '../types' import { colors } from '../constants/colors' import { TrendingUp, @@ -45,6 +45,16 @@ export function Sponsors() { enabled: isConnected, }) + const { + data: mySummary, + isLoading: summaryLoading, + isError: summaryError, + } = useQuery({ + queryKey: queryKeys.pool.mySummary(), + queryFn: sponsorsService.getMySummary, + enabled: isConnected, + }) + const { execute, isLoading: txLoading, error: txError } = useTransaction() const handleDeposit = async (e: React.FormEvent) => { @@ -74,10 +84,18 @@ export function Sponsors() { } } + const sharesNum = Number(withdrawShares) + const estimatedPayout = sharesNum * (poolInfo?.sharePrice || 0) + const overBalance = mySummary ? sharesNum > mySummary.shares : false + const overLiquidity = poolInfo ? estimatedPayout > poolInfo.availableLiquidity : false + const hasShares = mySummary ? mySummary.shares > 0 : false + const hasValidationError = overBalance || overLiquidity + const positionQueryLoading = summaryLoading + const handleWithdraw = async (e: React.FormEvent) => { e.preventDefault() const shares = Number(withdrawShares) - if (!shares || shares <= 0) return + if (!shares || shares <= 0 || hasValidationError) return try { const result = await execute( @@ -121,8 +139,6 @@ export function Sponsors() { ) } - const previewUsdc = Number(withdrawShares) * (poolInfo?.sharePrice || 0) - return (
@@ -302,6 +318,23 @@ export function Sponsors() { + ) : summaryError ? ( +
+ +

Failed to load your position. Please try again later.

+
+ ) : mySummary && !hasShares ? ( +
+

+ Withdraw Funds +

+
+

You have no shares to withdraw.

+

+ Deposit USDC to receive pool shares and start earning yield. +

+
+
) : (

@@ -311,6 +344,9 @@ export function Sponsors() { +

+ Your balance: {mySummary?.shares.toLocaleString() ?? '—'} shares +

setWithdrawShares(e.target.value)} placeholder="0.00" - className="w-full bg-bg border border-border rounded-xl px-4 py-3 + className="w-full bg-bg border border-border rounded-xl px-4 py-3 pr-20 text-text-primary focus:outline-none focus:border-brand transition-colors" aria-describedby="shares-hint" /> - -

Enter the number of pool shares to withdraw.

+

+ Enter the number of pool shares to withdraw. +

+ {overBalance && ( +

+ + You only have {mySummary!.shares.toLocaleString()} shares. +

+ )} + {overLiquidity && ( +

+ + Withdrawal exceeds available liquidity ({poolInfo!.availableLiquidity.toLocaleString()} USDC). +

+ )}
- {Number(withdrawShares) > 0 && ( + {sharesNum > 0 && !hasValidationError && (
Preview Value: - {previewUsdc.toFixed(2)} USDC + {estimatedPayout.toFixed(2)} USDC

Estimated amount based on current share price. Final amount may vary slightly. @@ -352,7 +409,7 @@ export function Sponsors() { type="submit" className="w-full" loading={txLoading} - disabled={!withdrawShares || Number(withdrawShares) <= 0} + disabled={!withdrawShares || Number(withdrawShares) <= 0 || positionQueryLoading || hasValidationError} > Withdraw USDC diff --git a/src/pages/__tests__/Sponsors.test.tsx b/src/pages/__tests__/Sponsors.test.tsx new file mode 100644 index 0000000..7cbcfed --- /dev/null +++ b/src/pages/__tests__/Sponsors.test.tsx @@ -0,0 +1,261 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useQuery } from '@tanstack/react-query' +import { Sponsors } from '../Sponsors' +import type { MySummary, PoolInfo } from '../../types' + +const mockPoolInfo: PoolInfo = { + totalDeposits: 500000, + totalLiquidity: 600000, + lockedLiquidity: 100000, + availableLiquidity: 500000, + totalShares: 50000, + sharePrice: 10, + apy: 12.5, + utilization: 16.7, + totalInvestors: 150, + activeLoans: 25, +} + +const mockMySummary: MySummary = { + shares: 1000, + value: 10000, + sharePrice: 10, +} + +const mockZeroSummary: MySummary = { + shares: 0, + value: 0, + sharePrice: 10, +} + +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query') + return { + ...actual as object, + useQuery: vi.fn(), + useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })), + } +}) + +vi.mock('../../hooks/useWallet', () => ({ + useWallet: vi.fn(), +})) + +vi.mock('../../hooks/useToast', () => ({ + useToast: vi.fn(), +})) + +vi.mock('../../hooks/useTransaction', () => ({ + useTransaction: vi.fn(), +})) + +vi.mock('../../services/sponsors.service', () => ({ + sponsorsService: { + getPoolInfo: vi.fn(), + getMySummary: vi.fn(), + deposit: vi.fn(), + withdraw: vi.fn(), + }, +})) + +vi.mock('../../services/transactions.service', () => ({ + transactionsService: { submit: vi.fn() }, +})) + +const mockUseWallet = vi.mocked((await import('../../hooks/useWallet')).useWallet) +const mockUseToast = vi.mocked((await import('../../hooks/useToast')).useToast) +const mockUseTransaction = vi.mocked((await import('../../hooks/useTransaction')).useTransaction) + +function setupMocks({ + isConnected = true, + poolInfo = mockPoolInfo, + mySummary = mockMySummary, + summaryLoading = false, + summaryError = false, +}: { + isConnected?: boolean + poolInfo?: PoolInfo | null + mySummary?: MySummary | null + summaryLoading?: boolean + summaryError?: boolean +} = {}) { + const mockExecute = vi.fn() + const mockToast = { success: vi.fn(), error: vi.fn() } + + mockUseWallet.mockReturnValue({ + address: 'GABCDEF123', + walletType: 'freighter', + isConnected, + isAuthenticated: true, + isConnecting: false, + connectError: null, + connectFreighter: vi.fn(), + disconnectWallet: vi.fn(), + }) + mockUseToast.mockReturnValue({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, + dismissToast: vi.fn(), + }) + mockUseTransaction.mockReturnValue({ execute: mockExecute, isLoading: false, error: null }) + + vi.mocked(useQuery).mockImplementation((options) => { + const opts = options as { queryKey: readonly unknown[] } + const key = opts.queryKey as readonly string[] + if (key.includes('mySummary')) { + if (summaryError) { + return { data: undefined, isLoading: false, isError: true, error: new Error('Failed') } as never + } + return { data: mySummary, isLoading: summaryLoading, isError: false, error: null } as never + } + return { data: poolInfo, isLoading: false, isError: false, error: null } as never + }) + + return { mockExecute, mockToast } +} + +describe('Sponsors', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows connect prompt when wallet is not connected', () => { + setupMocks({ isConnected: false }) + render() + expect(screen.getByText('Connect your wallet to sponsor')).toBeInTheDocument() + }) + + it('renders pool info cards when connected', () => { + setupMocks() + render() + expect(screen.getByText('Sponsor Dashboard')).toBeInTheDocument() + expect(screen.getAllByText(/500,000 USDC/).length).toBeGreaterThanOrEqual(1) + expect(screen.getByText(/12.5% APY/)).toBeInTheDocument() + }) + + it('disables withdraw button while position is loading', () => { + setupMocks({ summaryLoading: true, mySummary: null }) + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + const submitBtn = screen.getByRole('button', { name: /withdraw usdc/i }) + expect(submitBtn).toBeDisabled() + }) + + it('shows empty state for sponsor with zero shares', () => { + setupMocks({ mySummary: mockZeroSummary }) + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + expect(screen.getByText('You have no shares to withdraw.')).toBeInTheDocument() + expect(screen.getByText(/Deposit USDC to receive pool shares/)).toBeInTheDocument() + }) + + it('shows over-balance error when shares exceed balance', () => { + setupMocks() + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + + const input = screen.getByLabelText('Amount of Shares') as HTMLInputElement + fireEvent.change(input, { target: { value: '2000' } }) + + expect(screen.getByText(/You only have 1,000 shares/)).toBeInTheDocument() + }) + + it('shows over-liquidity error when payout exceeds available liquidity', () => { + const lowLiquidityPool = { ...mockPoolInfo, availableLiquidity: 5000 } + setupMocks({ poolInfo: lowLiquidityPool }) + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + + const input = screen.getByLabelText('Amount of Shares') as HTMLInputElement + fireEvent.change(input, { target: { value: '1000' } }) + + expect(screen.getByText(/Withdrawal exceeds available liquidity \(5,000 USDC\)/)).toBeInTheDocument() + }) + + it('allows withdrawal at exact share balance boundary', () => { + setupMocks() + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + + const input = screen.getByLabelText('Amount of Shares') as HTMLInputElement + fireEvent.change(input, { target: { value: '1000' } }) + + expect(screen.queryByText(/You only have/)).not.toBeInTheDocument() + expect(screen.getByText(/Preview Value/)).toBeInTheDocument() + }) + + it('suppresses preview value when amount exceeds balance', () => { + setupMocks() + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + + const input = screen.getByLabelText('Amount of Shares') as HTMLInputElement + fireEvent.change(input, { target: { value: '1500' } }) + + expect(screen.queryByText(/Preview Value/)).not.toBeInTheDocument() + }) + + it('does not show preview value for zero or empty input', () => { + setupMocks() + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + + expect(screen.queryByText(/Preview Value/)).not.toBeInTheDocument() + }) + + it('Max button fills input with sponsor full share balance', () => { + setupMocks() + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + + const maxButton = screen.getByText('Max') + fireEvent.click(maxButton) + + const input = screen.getByLabelText('Amount of Shares') as HTMLInputElement + expect(input.value).toBe('1000') + }) + + it('disabled submit button when validation error exists', () => { + setupMocks() + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + + const input = screen.getByLabelText('Amount of Shares') as HTMLInputElement + fireEvent.change(input, { target: { value: '2000' } }) + + const submitBtn = screen.getByRole('button', { name: /withdraw usdc/i }) + expect(submitBtn).toBeDisabled() + }) + + it('shows error state when summary query fails', () => { + setupMocks({ summaryError: true }) + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + + expect(screen.getByText('Failed to load your position. Please try again later.')).toBeInTheDocument() + }) + + it('shows sponsor balance in withdraw form', () => { + setupMocks() + render() + const withdrawTab = screen.getByRole('tab', { name: 'Withdraw' }) + fireEvent.click(withdrawTab) + + expect(screen.getByText(/Your balance: 1,000 shares/)).toBeInTheDocument() + }) +}) diff --git a/src/services/queryKeys.ts b/src/services/queryKeys.ts index 829f2c3..a09d951 100644 --- a/src/services/queryKeys.ts +++ b/src/services/queryKeys.ts @@ -17,6 +17,7 @@ export const queryKeys = { all: ['pool'] as const, info: () => [...queryKeys.pool.all, 'info'] as const, stats: () => [...queryKeys.pool.all, 'stats'] as const, + mySummary: () => [...queryKeys.pool.all, 'mySummary'] as const, }, loans: { all: ['loans'] as const, diff --git a/src/services/sponsors.service.ts b/src/services/sponsors.service.ts index 654266e..d6f4cf7 100644 --- a/src/services/sponsors.service.ts +++ b/src/services/sponsors.service.ts @@ -1,5 +1,6 @@ import { api } from './api' import type { + MySummary, PoolInfo, UnsignedTransaction, DepositPreview, @@ -12,6 +13,11 @@ export const sponsorsService = { return res.data.data }, + getMySummary: async (): Promise => { + const res = await api.get('/liquidity/my-summary') + return res.data.data + }, + deposit: async (amount: number): Promise> => { const res = await api.post('/liquidity/deposit', { amount }) return res.data.data diff --git a/src/types/index.ts b/src/types/index.ts index 255f252..019e8a1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -263,6 +263,12 @@ export interface ContractWasmInfo { error?: string } +export interface MySummary { + shares: number + value: number + sharePrice: number +} + export interface VerificationReconciliation { totalLiquidityMatch: boolean availableLiquidityMatch: boolean