Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/components/DepositForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { useVault as useVaultType } from '@/hooks/useVault';

jest.mock('@/hooks/useVault', () => ({
useVault: jest.fn(),
}));

const { DepositForm } = require('./DepositForm') as typeof import('./DepositForm');
const { useVault } = jest.requireMock('@/hooks/useVault') as {
useVault: jest.MockedFunction<typeof useVaultType>;
};

describe('DepositForm', () => {
const deposit = jest.fn();

beforeEach(() => {
jest.spyOn(window, 'alert').mockImplementation(() => {});
deposit.mockResolvedValue({ success: true });
useVault.mockReturnValue({
deposit,
isLoading: false,
} as ReturnType<typeof useVaultType>);
});

afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});

it('disables submit until the entered amount is valid', () => {
render(<DepositForm />);

const amountInput = screen.getByLabelText(/amount/i);
const submitButton = screen.getByRole('button', { name: /deposit into vault/i });

expect(submitButton).toBeDisabled();

fireEvent.change(amountInput, { target: { value: '0' } });

expect(screen.getByRole('alert')).toHaveTextContent('Amount must be greater than 0 XLM.');
expect(amountInput).toHaveAttribute('aria-invalid', 'true');
expect(submitButton).toBeDisabled();

fireEvent.change(amountInput, { target: { value: '12.5' } });

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(amountInput).toHaveAttribute('aria-invalid', 'false');
expect(submitButton).toBeEnabled();
});

it('submits a valid amount in atomic units', async () => {
render(<DepositForm />);

fireEvent.change(screen.getByLabelText(/amount/i), { target: { value: '12.5' } });
fireEvent.click(screen.getByRole('button', { name: /deposit into vault/i }));

await waitFor(() => {
expect(deposit).toHaveBeenCalledWith(BigInt(125000000));
});
});

it('shows a decimal precision error', () => {
render(<DepositForm />);

fireEvent.change(screen.getByLabelText(/amount/i), { target: { value: '1.12345678' } });

expect(screen.getByRole('alert')).toHaveTextContent('Use at most 7 decimal places.');
expect(screen.getByRole('button', { name: /deposit into vault/i })).toBeDisabled();
});
});
33 changes: 23 additions & 10 deletions src/components/DepositForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useVault } from '@/hooks/useVault';
import { ArrowUpCircle, Loader2 } from 'lucide-react';
import { validateXlmAmount } from '@/utils/amountValidation';

/**
* DepositForm Component
Expand All @@ -14,6 +15,11 @@ export const DepositForm: React.FC = () => {

// Access vault actions and state via custom hook
const { deposit, isLoading } = useVault();
const validation = validateXlmAmount(amount);
const showError = amount.trim().length > 0 && !validation.isValid;
const amountHelperId = 'deposit-amount-helper';
const amountErrorId = 'deposit-amount-error';
const submitDisabled = isLoading || !validation.isValid;

/**
* Handles the form submission event.
Expand All @@ -24,16 +30,11 @@ export const DepositForm: React.FC = () => {
// Prevent default form submission behavior (page refresh)
e.preventDefault();

// Basic validation: ensure amount is present and is a valid number
if (!amount || isNaN(Number(amount))) return;
if (!validation.isValid) return;

try {
// Convert the decimal XLM amount to atomic units (7 decimal places)
// Example: 1.5 XLM -> 15,000,000 atomic units
const atomicAmount = BigInt(Math.floor(Number(amount) * 10000000));

// Execute the deposit transaction
await deposit(atomicAmount);
await deposit(validation.atomicAmount);

// Clear the input field upon successful submission
setAmount('');
Expand Down Expand Up @@ -71,22 +72,34 @@ export const DepositForm: React.FC = () => {
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
step="0.01"
step="0.0000001"
min="0"
className="input pr-16 focus:ring-protox-primary/20 transition-all"
aria-describedby={`${amountHelperId}${showError ? ` ${amountErrorId}` : ''}`}
aria-invalid={showError}
className={`input pr-16 focus:ring-protox-primary/20 transition-all ${
showError ? 'border-red-300 bg-red-50/50 focus:border-red-400 focus:ring-red-100' : ''
}`}
required
/>
{/* Currency suffix badge */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold text-slate-400 group-focus-within:text-protox-primary transition-colors">
XLM
</div>
</div>
<p id={amountHelperId} className="text-xs text-slate-400">
Use up to 7 decimal places, e.g. 25.5 XLM.
</p>
{showError && (
<p id={amountErrorId} className="text-xs font-medium text-red-600" role="alert">
{validation.error}
</p>
)}
</div>

{/* Action button with loading state */}
<button
type="submit"
disabled={isLoading || !amount}
disabled={submitDisabled}
className="btn-primary w-full flex justify-center items-center space-x-2 py-3 disabled:cursor-not-allowed"
>
{isLoading ? (
Expand Down
2 changes: 1 addition & 1 deletion src/components/TransactionHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('TransactionHistory', () => {
// So it should show 1 successful deposit.
const transactions = screen.getAllByRole('link');
expect(transactions.length).toBe(1);
expect(screen.getByText('500.00 XLM')).toBeInTheDocument();
expect(screen.getByText('+500.00 XLM')).toBeInTheDocument();
});

it('filters by failed transactions', () => {
Expand Down
61 changes: 61 additions & 0 deletions src/components/WithdrawForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { useVault as useVaultType } from '@/hooks/useVault';

jest.mock('@/hooks/useVault', () => ({
useVault: jest.fn(),
}));

const { WithdrawForm } = require('./WithdrawForm') as typeof import('./WithdrawForm');
const { useVault } = jest.requireMock('@/hooks/useVault') as {
useVault: jest.MockedFunction<typeof useVaultType>;
};

describe('WithdrawForm', () => {
const withdraw = jest.fn();

beforeEach(() => {
jest.spyOn(window, 'alert').mockImplementation(() => {});
withdraw.mockResolvedValue({ success: true });
useVault.mockReturnValue({
withdraw,
isLoading: false,
balance: BigInt(15000000),
} as ReturnType<typeof useVaultType>);
});

afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});

it('rejects amounts above the available balance', () => {
render(<WithdrawForm />);

const amountInput = screen.getByLabelText(/amount/i);
const submitButton = screen.getByRole('button', { name: /withdraw from vault/i });

fireEvent.change(amountInput, { target: { value: '2' } });

expect(screen.getByRole('alert')).toHaveTextContent('Amount exceeds available balance.');
expect(amountInput).toHaveAttribute('aria-invalid', 'true');
expect(submitButton).toBeDisabled();
});

it('uses the max balance and submits atomic units', async () => {
render(<WithdrawForm />);

const amountInput = screen.getByLabelText(/amount/i);

fireEvent.click(screen.getByRole('button', { name: /max/i }));

expect(amountInput).toHaveValue(1.5);
expect(screen.getByRole('button', { name: /withdraw from vault/i })).toBeEnabled();

fireEvent.click(screen.getByRole('button', { name: /withdraw from vault/i }));

await waitFor(() => {
expect(withdraw).toHaveBeenCalledWith(BigInt(15000000));
});
});
});
36 changes: 24 additions & 12 deletions src/components/WithdrawForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useVault } from '@/hooks/useVault';
import { ArrowDownCircle, Loader2 } from 'lucide-react';
import { formatAtomicXlm, validateXlmAmount } from '@/utils/amountValidation';

/**
* WithdrawForm Component
Expand All @@ -14,15 +15,18 @@ export const WithdrawForm: React.FC = () => {

// Destructure required actions and state from the vault hook
const { withdraw, isLoading, balance } = useVault();
const validation = validateXlmAmount(amount, { maxAtomicAmount: balance });
const showError = amount.trim().length > 0 && !validation.isValid;
const amountHelperId = 'withdraw-amount-helper';
const amountErrorId = 'withdraw-amount-error';
const submitDisabled = isLoading || !validation.isValid;

/**
* Sets the input amount to the user's total vault balance.
* Converts from atomic units back to decimal XLM.
*/
const handleMax = () => {
// Convert BigInt balance (7 decimals) to a standard number/string
const decimalBalance = Number(balance) / 10000000;
setAmount(decimalBalance.toString());
setAmount(formatAtomicXlm(balance));
};

/**
Expand All @@ -34,15 +38,11 @@ export const WithdrawForm: React.FC = () => {
// Stop form from refreshing the page
e.preventDefault();

// Validate that amount is present and numeric
if (!amount || isNaN(Number(amount))) return;
if (!validation.isValid) return;

try {
// Calculate atomic amount (BigInt) to ensure precision for blockchain transaction
const atomicAmount = BigInt(Math.floor(Number(amount) * 10000000));

// Call the withdrawal function from VaultContext
await withdraw(atomicAmount);
await withdraw(validation.atomicAmount);

// Reset input on successful withdrawal
setAmount('');
Expand Down Expand Up @@ -91,22 +91,34 @@ export const WithdrawForm: React.FC = () => {
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
step="0.01"
step="0.0000001"
min="0"
className="input pr-16 focus:ring-protox-accent/20 transition-all"
aria-describedby={`${amountHelperId}${showError ? ` ${amountErrorId}` : ''}`}
aria-invalid={showError}
className={`input pr-16 focus:ring-protox-accent/20 transition-all ${
showError ? 'border-red-300 bg-red-50/50 focus:border-red-400 focus:ring-red-100' : ''
}`}
required
/>
{/* Currency suffix badge */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-sm font-bold text-slate-400 group-focus-within:text-protox-accent transition-colors">
XLM
</div>
</div>
<p id={amountHelperId} className="text-xs text-slate-400">
Available: {formatAtomicXlm(balance)} XLM. Use up to 7 decimal places.
</p>
{showError && (
<p id={amountErrorId} className="text-xs font-medium text-red-600" role="alert">
{validation.error}
</p>
)}
</div>

{/* Action button using outline variant */}
<button
type="submit"
disabled={isLoading || !amount}
disabled={submitDisabled}
className="btn-outline w-full flex justify-center items-center space-x-2 py-3 disabled:cursor-not-allowed"
>
{isLoading ? (
Expand Down
6 changes: 3 additions & 3 deletions src/context/VaultContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export const VaultProvider: React.FC<{ children: ReactNode }> = ({ children }) =

// Initialize state with default mock values
const [state, setState] = useState<VaultState>({
balance: 15000000000n, // Initial balance: 1500 XLM (mock)
totalShares: 100000n,
balance: BigInt(15000000000), // Initial balance: 1500 XLM (mock)
totalShares: BigInt(100000),
isLoading: false,
isRefreshing: false,
error: null,
Expand Down Expand Up @@ -111,7 +111,7 @@ export const VaultProvider: React.FC<{ children: ReactNode }> = ({ children }) =
fetchVaultData(true);
} else {
// Reset state when disconnected
setState(prev => ({ ...prev, balance: 0n, totalShares: 0n }));
setState(prev => ({ ...prev, balance: BigInt(0), totalShares: BigInt(0) }));
}
}, [isConnected, fetchVaultData]);

Expand Down
Loading