Skip to content

Commit 67ce379

Browse files
committed
fix: validate batch buy liquid balance
1 parent 13ac4c6 commit 67ce379

3 files changed

Lines changed: 177 additions & 37 deletions

File tree

src/components/common/BatchBuyModal.tsx

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,64 +3,85 @@ import { Button } from '@/components/ui/button';
33
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
44
import { useFormatXlm } from '@/hooks/useFormatXlm';
55
import showToast from '@/utils/toast.util';
6-
import { useBatchBuyMutation } from '@/hooks/useWallet';
6+
import { useBatchBuyMutation, type BatchOrder } from '@/hooks/useWallet';
77

88
export interface BatchOrderRow {
9-
creatorId: string;
10-
priceStroops: number;
9+
address: string;
10+
creatorId?: string;
11+
priceStroops?: number;
1112
quantity: number;
1213
}
1314

1415
interface Props {
1516
open: boolean;
1617
onOpenChange: (open: boolean) => void;
18+
liquidBalance?: number;
19+
initialRows?: BatchOrderRow[];
1720
}
1821

1922
const MAX_KEYS = 5;
20-
21-
export default function BatchBuyModal({ open, onOpenChange }: Props) {
22-
const [rows, setRows] = useState<BatchOrderRow[]>([]);
23+
const DEFAULT_LIQUID_BALANCE = 100;
24+
const STELLAR_ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/;
25+
26+
export default function BatchBuyModal({
27+
open,
28+
onOpenChange,
29+
liquidBalance = DEFAULT_LIQUID_BALANCE,
30+
initialRows = [],
31+
}: Props) {
32+
const [rows, setRows] = useState<BatchOrderRow[]>(initialRows);
2333
const [addInput, setAddInput] = useState('');
2434
const { format } = useFormatXlm();
2535
const mutation = useBatchBuyMutation();
2636

27-
const totalStroops = useMemo(() => {
28-
return rows.reduce((acc, r) => acc + (r.priceStroops * r.quantity), 0);
29-
}, [rows]);
30-
31-
const totalXlm = format(totalStroops);
37+
const totalQuantity = useMemo(
38+
() => rows.reduce((total, row) => total + row.quantity, 0),
39+
[rows]
40+
);
41+
const totalStroops = useMemo(
42+
() => rows.reduce((total, row) => total + (row.priceStroops ?? 0) * row.quantity, 0),
43+
[rows]
44+
);
45+
const invalidAddress = rows.some(row => !STELLAR_ADDRESS_PATTERN.test(row.address));
46+
const balanceExceeded = totalQuantity > liquidBalance;
47+
const validationError = invalidAddress
48+
? 'Enter valid Stellar addresses for every recipient.'
49+
: balanceExceeded
50+
? `Total quantity cannot exceed your liquid balance of ${liquidBalance} keys.`
51+
: undefined;
52+
const canSubmit = rows.length > 0 && !validationError;
3253

3354
const handleAdd = () => {
34-
if (!addInput) return;
55+
const address = addInput.trim();
56+
if (!address) return;
3557
if (rows.length >= MAX_KEYS) {
3658
showToast.error(`Maximum ${MAX_KEYS} keys per batch`);
3759
return;
3860
}
3961

40-
// For demo: assume price 1 XLM = 10_000_000 stroops
41-
const defaultPrice = 10_000_000;
42-
const newRow: BatchOrderRow = {
43-
creatorId: addInput,
44-
priceStroops: defaultPrice,
45-
quantity: 1,
46-
};
47-
setRows(r => [...r, newRow]);
62+
setRows(rows => [
63+
...rows,
64+
{ address, creatorId: address, priceStroops: 10_000_000, quantity: 1 },
65+
]);
4866
setAddInput('');
4967
};
5068

5169
const handleRemove = (idx: number) => {
52-
setRows(r => r.filter((_, i) => i !== idx));
70+
setRows(rows => rows.filter((_, i) => i !== idx));
5371
};
5472

55-
const handleQuantityChange = (idx: number, q: number) => {
56-
setRows(r => r.map((row, i) => (i === idx ? { ...row, quantity: q } : row)));
73+
const handleQuantityChange = (idx: number, quantity: number) => {
74+
setRows(rows =>
75+
rows.map((row, i) => (i === idx ? { ...row, quantity: Math.max(1, quantity) } : row))
76+
);
5777
};
5878

5979
const handleConfirm = async () => {
60-
if (rows.length === 0) return;
80+
if (!canSubmit) return;
6181
try {
6282
showToast.loading('Submitting batch buy...');
63-
await mutation.mutateAsync({ orders: rows });
83+
const orders: BatchOrder[] = rows.map(({ address, quantity }) => ({ address, quantity }));
84+
await mutation.mutateAsync({ orders });
6485
showToast.transactionSuccess('Batch buy submitted');
6586
onOpenChange(false);
6687
setRows([]);
@@ -81,7 +102,8 @@ export default function BatchBuyModal({ open, onOpenChange }: Props) {
81102
<input
82103
value={addInput}
83104
onChange={e => setAddInput(e.target.value)}
84-
placeholder="Search or enter creator id"
105+
placeholder="Enter Stellar recipient address"
106+
aria-label="Recipient address"
85107
className="flex-1 rounded-xl bg-white/[0.04] px-3 py-2 text-white"
86108
/>
87109
<Button onClick={handleAdd}>Add</Button>
@@ -92,35 +114,43 @@ export default function BatchBuyModal({ open, onOpenChange }: Props) {
92114
) : (
93115
<div className="space-y-2">
94116
{rows.map((row, idx) => (
95-
<div key={row.creatorId} className="flex items-center gap-2">
96-
<div className="w-40 text-sm truncate">{row.creatorId}</div>
97-
<div className="w-28 text-sm">{format(row.priceStroops)}</div>
117+
<div key={`${row.address}-${idx}`} className="flex items-center gap-2">
118+
<div className="w-40 truncate text-sm">{row.address}</div>
119+
<div className="w-28 text-sm">{format(row.priceStroops ?? 0)}</div>
98120
<input
99121
type="number"
100122
min={1}
101123
value={row.quantity}
102-
onChange={e => handleQuantityChange(idx, Math.max(1, Number(e.target.value || 1)))}
124+
aria-label={`Quantity for ${row.address}`}
125+
onChange={e => handleQuantityChange(idx, Number(e.target.value || 1))}
103126
className="w-24 rounded-xl bg-white/[0.04] px-2 py-1 text-white"
104127
/>
105128
<div className="flex-1 text-sm text-white/60">
106-
Subtotal: {format(row.priceStroops * row.quantity)} XLM
129+
Subtotal: {format((row.priceStroops ?? 0) * row.quantity)} XLM
107130
</div>
108131
<Button variant="ghost" onClick={() => handleRemove(idx)}>Remove</Button>
109132
</div>
110133
))}
111134
</div>
112135
)}
113136

114-
<div className="flex items-center justify-between pt-4 border-t border-white/5">
115-
<div className="text-sm text-white/60">Total</div>
116-
<div className="font-bold text-white">{totalXlm} XLM</div>
137+
<div className="flex items-center justify-between border-t border-white/5 pt-4">
138+
<div className="text-sm text-white/60">
139+
Total: {totalQuantity} / {liquidBalance} keys
140+
</div>
141+
<div className="font-bold text-white">{format(totalStroops)} XLM</div>
117142
</div>
143+
{validationError && (
144+
<p role="alert" data-testid="batch-buy-validation-error" className="text-sm text-red-400">
145+
{validationError}
146+
</p>
147+
)}
118148
</div>
119149

120150
<DialogFooter>
121151
<div className="flex justify-end gap-2">
122152
<Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
123-
<Button onClick={handleConfirm} disabled={rows.length === 0}>Confirm</Button>
153+
<Button onClick={handleConfirm} disabled={!canSubmit}>Confirm</Button>
124154
</div>
125155
</DialogFooter>
126156
</DialogContent>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import BatchBuyModal from '@/components/common/BatchBuyModal';
5+
6+
const mutateAsync = vi.fn();
7+
8+
vi.mock('@/hooks/useWallet', () => ({
9+
useBatchBuyMutation: () => ({ mutateAsync }),
10+
}));
11+
12+
vi.mock('@/utils/toast.util', () => ({
13+
default: {
14+
loading: vi.fn(),
15+
error: vi.fn(),
16+
transactionSuccess: vi.fn(),
17+
},
18+
}));
19+
20+
const validAddress = (character: string) => `G${character.repeat(55)}`;
21+
22+
const renderModal = (liquidBalance = 3) => {
23+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
24+
return render(
25+
<QueryClientProvider client={queryClient}>
26+
<BatchBuyModal open onOpenChange={vi.fn()} liquidBalance={liquidBalance} />
27+
</QueryClientProvider>
28+
);
29+
};
30+
31+
describe('BatchBuyModal (#832)', () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks();
34+
mutateAsync.mockResolvedValue({ success: true });
35+
});
36+
37+
const addRecipient = (address: string) => {
38+
fireEvent.change(screen.getByRole('textbox', { name: /recipient address/i }), {
39+
target: { value: address },
40+
});
41+
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
42+
};
43+
44+
it('allows a single recipient quantity equal to liquidBalance', () => {
45+
renderModal(3);
46+
addRecipient(validAddress('A'));
47+
48+
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } });
49+
50+
expect(screen.queryByTestId('batch-buy-validation-error')).not.toBeInTheDocument();
51+
expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled();
52+
});
53+
54+
it('blocks submission when combined quantities exceed liquidBalance', () => {
55+
renderModal(3);
56+
addRecipient(validAddress('A'));
57+
addRecipient(validAddress('B'));
58+
59+
fireEvent.change(screen.getAllByRole('spinbutton')[0], { target: { value: '2' } });
60+
fireEvent.change(screen.getAllByRole('spinbutton')[1], { target: { value: '2' } });
61+
62+
expect(screen.getByTestId('batch-buy-validation-error')).toHaveTextContent(
63+
'Total quantity cannot exceed your liquid balance of 3 keys.'
64+
);
65+
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
66+
});
67+
68+
it('re-evaluates the balance after removing a row', () => {
69+
renderModal(3);
70+
addRecipient(validAddress('A'));
71+
addRecipient(validAddress('B'));
72+
fireEvent.change(screen.getAllByRole('spinbutton')[0], { target: { value: '2' } });
73+
fireEvent.change(screen.getAllByRole('spinbutton')[1], { target: { value: '2' } });
74+
75+
fireEvent.click(screen.getAllByRole('button', { name: 'Remove' })[1]);
76+
77+
expect(screen.queryByTestId('batch-buy-validation-error')).not.toBeInTheDocument();
78+
expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled();
79+
});
80+
81+
it('blocks submission when any recipient address is invalid', () => {
82+
renderModal(3);
83+
addRecipient('not-a-stellar-address');
84+
85+
expect(screen.getByTestId('batch-buy-validation-error')).toHaveTextContent(
86+
'Enter valid Stellar addresses for every recipient.'
87+
);
88+
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
89+
});
90+
91+
it('submits the valid recipient address and quantity pairs', async () => {
92+
renderModal(5);
93+
const firstAddress = validAddress('A');
94+
const secondAddress = validAddress('B');
95+
addRecipient(firstAddress);
96+
addRecipient(secondAddress);
97+
fireEvent.change(screen.getAllByRole('spinbutton')[0], { target: { value: '2' } });
98+
fireEvent.change(screen.getAllByRole('spinbutton')[1], { target: { value: '1' } });
99+
100+
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
101+
102+
await waitFor(() =>
103+
expect(mutateAsync).toHaveBeenCalledWith({
104+
orders: [
105+
{ address: firstAddress, quantity: 2 },
106+
{ address: secondAddress, quantity: 1 },
107+
],
108+
})
109+
);
110+
});
111+
});

src/hooks/useWallet.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,8 @@ export function useTradeMutation(address: string) {
196196
}
197197

198198
export interface BatchOrder {
199-
creatorId: string;
199+
address: string;
200200
quantity: number;
201-
priceStroops: number;
202201
ref?: string | null;
203202
}
204203

0 commit comments

Comments
 (0)