Skip to content

Commit 31894f7

Browse files
authored
Merge pull request #1306 from shinzoxD/feature/confirmmodal-fee-breakdown-663
feat: add gross/fee/net fee breakdown to ConfirmModal
2 parents f8aae08 + 5588fde commit 31894f7

3 files changed

Lines changed: 227 additions & 16 deletions

File tree

components/features/lending/components/ConfirmModal.test.tsx

Lines changed: 136 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -340,22 +340,33 @@ describe("ConfirmModal withdraw variant", () => {
340340
});
341341
});
342342

343-
describe("ConfirmModal protocol fee display", () => {
344-
it("matches calculateProtocolFee output for the same market, action, and amount", async () => {
343+
function formatModalAmount(amount: number, asset: string): string {
344+
return `${amount.toLocaleString(undefined, {
345+
minimumFractionDigits: 2,
346+
maximumFractionDigits: 4,
347+
})} ${asset}`;
348+
}
349+
350+
describe("ConfirmModal protocol fee breakdown", () => {
351+
it("shows gross, fee, and net matching calculateProtocolFee for lend/borrow/repay", async () => {
345352
const { calculateProtocolFee } = await import("@/lib/fee-calculator");
346-
const user = userEvent.setup();
347-
const testCases: Array<{ asset: string; type: "lend" | "borrow" | "repay"; amount: number }> = [
353+
const testCases: Array<{
354+
asset: string;
355+
type: "lend" | "borrow" | "repay";
356+
amount: number;
357+
}> = [
348358
{ asset: "XLM", type: "lend", amount: 1000 },
349359
{ asset: "USDC", type: "borrow", amount: 500 },
350360
{ asset: "XLM", type: "repay", amount: 200 },
351361
];
352362

353363
for (const testCase of testCases) {
354-
const expectedFeeResult = calculateProtocolFee(testCase.asset, testCase.type, testCase.amount);
355-
const formattedFee = `${expectedFeeResult.feeAmount.toLocaleString(undefined, {
356-
minimumFractionDigits: 2,
357-
maximumFractionDigits: 4,
358-
})} ${testCase.asset}`;
364+
const expectedFeeResult = calculateProtocolFee(
365+
testCase.asset,
366+
testCase.type,
367+
testCase.amount,
368+
);
369+
const net = Math.max(0, testCase.amount - expectedFeeResult.feeAmount);
359370

360371
const { unmount } = render(
361372
<ConfirmModal
@@ -373,11 +384,125 @@ describe("ConfirmModal protocol fee display", () => {
373384
);
374385

375386
const dialog = screen.getByRole("dialog");
376-
expect(within(dialog).getByText("Protocol Fee")).toBeInTheDocument();
377-
expect(within(dialog).getByText(formattedFee)).toBeInTheDocument();
387+
const breakdown = within(dialog).getByTestId("fee-breakdown");
388+
expect(breakdown).toHaveAttribute("aria-label", "Fee breakdown");
389+
390+
expect(within(breakdown).getByText("Gross Amount")).toBeInTheDocument();
391+
expect(
392+
within(breakdown).getByText(
393+
formatModalAmount(testCase.amount, testCase.asset),
394+
),
395+
).toBeInTheDocument();
396+
397+
expect(within(breakdown).getByText(/Protocol Fee/)).toBeInTheDocument();
398+
expect(
399+
within(breakdown).getByText(
400+
formatModalAmount(expectedFeeResult.feeAmount, testCase.asset),
401+
),
402+
).toBeInTheDocument();
403+
404+
expect(within(breakdown).getByText("Net Amount")).toBeInTheDocument();
405+
expect(
406+
within(breakdown).getByText(formatModalAmount(net, testCase.asset)),
407+
).toBeInTheDocument();
378408

379409
unmount();
380410
}
381411
});
412+
413+
it("recomputes the breakdown when the amount changes", async () => {
414+
const { calculateProtocolFee } = await import("@/lib/fee-calculator");
415+
const baseProps = {
416+
isOpen: true,
417+
onClose: vi.fn(),
418+
onConfirm: vi.fn(),
419+
calculation: { dailyEarnings: 0.1, totalEarnings: 10 },
420+
type: "lend" as const,
421+
};
422+
423+
const { rerender } = render(
424+
<ConfirmModal
425+
{...baseProps}
426+
data={{ asset: "XLM", amount: 1000, interestRate: 5 }}
427+
/>,
428+
);
429+
430+
const fee1000 = calculateProtocolFee("XLM", "lend", 1000);
431+
expect(
432+
screen.getByText(formatModalAmount(fee1000.feeAmount, "XLM")),
433+
).toBeInTheDocument();
434+
435+
rerender(
436+
<ConfirmModal
437+
{...baseProps}
438+
data={{ asset: "XLM", amount: 250, interestRate: 5 }}
439+
/>,
440+
);
441+
442+
const fee250 = calculateProtocolFee("XLM", "lend", 250);
443+
const breakdown = screen.getByTestId("fee-breakdown");
444+
expect(
445+
within(breakdown).getByText(formatModalAmount(fee250.feeAmount, "XLM")),
446+
).toBeInTheDocument();
447+
expect(
448+
within(breakdown).getByText(formatModalAmount(250, "XLM")),
449+
).toBeInTheDocument();
450+
expect(
451+
within(breakdown).getByText(
452+
formatModalAmount(Math.max(0, 250 - fee250.feeAmount), "XLM"),
453+
),
454+
).toBeInTheDocument();
455+
});
456+
457+
it("shows a zero fee and zero net for a zero amount", () => {
458+
render(
459+
<ConfirmModal
460+
isOpen={true}
461+
onClose={vi.fn()}
462+
onConfirm={vi.fn()}
463+
data={{ asset: "XLM", amount: 0, interestRate: 5 }}
464+
calculation={null}
465+
type="lend"
466+
/>,
467+
);
468+
469+
const breakdown = screen.getByTestId("fee-breakdown");
470+
// Gross, fee, and net all format as 0.00 XLM — three rows.
471+
const zeros = within(breakdown).getAllByText(formatModalAmount(0, "XLM"));
472+
expect(zeros.length).toBeGreaterThanOrEqual(3);
473+
});
474+
475+
it("omits the fee breakdown for withdraw (no fee schedule)", () => {
476+
render(
477+
<ConfirmModal
478+
isOpen={true}
479+
onClose={vi.fn()}
480+
onConfirm={vi.fn()}
481+
data={{ asset: "XLM", amount: 100, interestRate: 0 }}
482+
calculation={null}
483+
type="withdraw"
484+
/>,
485+
);
486+
487+
expect(screen.queryByTestId("fee-breakdown")).not.toBeInTheDocument();
488+
});
489+
490+
it("omits the fee breakdown for an unknown market instead of blocking confirm", () => {
491+
render(
492+
<ConfirmModal
493+
isOpen={true}
494+
onClose={vi.fn()}
495+
onConfirm={vi.fn()}
496+
data={{ asset: "UNKNOWN", amount: 100, interestRate: 5 }}
497+
calculation={null}
498+
type="lend"
499+
/>,
500+
);
501+
502+
expect(screen.queryByTestId("fee-breakdown")).not.toBeInTheDocument();
503+
expect(
504+
screen.getByRole("button", { name: /confirm/i }),
505+
).toBeInTheDocument();
506+
});
382507
});
383508

components/features/lending/components/ConfirmModal.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -296,22 +296,55 @@ export default function ConfirmModal({
296296
)}
297297

298298
{(() => {
299+
// Withdraw has no protocol fee schedule in fee-calculator.
299300
if (type === "withdraw") return null;
300301
try {
301302
const feeResult = calculateProtocolFee(
302303
data.asset,
303304
type,
304305
data.amount,
305306
);
307+
const gross = data.amount;
308+
const fee = feeResult.feeAmount;
309+
// Net is the amount that actually settles after the protocol cut.
310+
// Zero-amount actions short-circuit to zero fee inside the calculator.
311+
const net = Math.max(0, gross - fee);
306312
return (
307-
<div className="flex justify-between">
308-
<span className="text-gray-600">Protocol Fee</span>
309-
<span className="font-medium">
310-
{formatCurrency(feeResult.feeAmount, data.asset)}
311-
</span>
313+
<div
314+
className="space-y-2 border-t pt-3"
315+
data-testid="fee-breakdown"
316+
aria-label="Fee breakdown"
317+
>
318+
<div className="flex justify-between">
319+
<span className="text-gray-600">Gross Amount</span>
320+
<span className="font-medium">
321+
{formatCurrency(gross, data.asset)}
322+
</span>
323+
</div>
324+
<div className="flex justify-between">
325+
<span className="text-gray-600">
326+
Protocol Fee
327+
{feeResult.feeBps > 0 ? (
328+
<span className="text-gray-400">
329+
{" "}
330+
({feeResult.feeBps} bps)
331+
</span>
332+
) : null}
333+
</span>
334+
<span className="font-medium">
335+
{formatCurrency(fee, data.asset)}
336+
</span>
337+
</div>
338+
<div className="flex justify-between border-t pt-2">
339+
<span className="font-medium">Net Amount</span>
340+
<span className="font-semibold text-gray-900">
341+
{formatCurrency(net, data.asset)}
342+
</span>
343+
</div>
312344
</div>
313345
);
314346
} catch {
347+
// Unknown market / action — omit the breakdown rather than block confirm.
315348
return null;
316349
}
317350
})()}

docs/confirm-modal-fees.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# ConfirmModal fee breakdown
2+
3+
How the pre-submit confirmation dialog surfaces protocol fees.
4+
5+
Source: [`components/features/lending/components/ConfirmModal.tsx`](../components/features/lending/components/ConfirmModal.tsx)
6+
7+
Fee math: [`lib/fee-calculator.ts`](../lib/fee-calculator.ts)
8+
9+
Tests: [`ConfirmModal.test.tsx`](../components/features/lending/components/ConfirmModal.test.tsx)
10+
11+
## What the user sees
12+
13+
For `lend`, `borrow`, and `repay` actions the dialog shows a **Fee breakdown**
14+
block (after the amount/duration rows):
15+
16+
| Row | Value |
17+
| -------------- | ------------------------------------------ |
18+
| Gross Amount | `data.amount` |
19+
| Protocol Fee | `calculateProtocolFee(...).feeAmount` (+ bps) |
20+
| Net Amount | `max(0, gross - fee)` |
21+
22+
`withdraw` has no entry in the fee schedule, so the block is omitted.
23+
24+
## How the fee is computed
25+
26+
```ts
27+
const { feeAmount, feeBps } = calculateProtocolFee(marketId, action, amount);
28+
```
29+
30+
- `marketId` is the asset symbol (looked up case-insensitively in the registry).
31+
- `action` is `lend` | `borrow` | `repay`.
32+
- Fee = `max(amount * bps / 10000, minFeeAmount)`, except **zero amount → zero fee**.
33+
- Negative amounts throw; the modal catches and hides the breakdown rather than
34+
blocking confirm.
35+
36+
## Rounding / display
37+
38+
Amounts go through the modal's local `formatCurrency` helper (2–4 fractional
39+
digits + asset symbol). The calculator returns a raw JS number; display rounding
40+
is presentation-only and does not change the fee math.
41+
42+
## Recompute on change
43+
44+
The breakdown is derived during render from `data.amount`, `data.asset`, and
45+
`type`. Changing any of those props (e.g. the user edits the form behind the
46+
modal) recomputes gross/fee/net on the next render — there is no cached fee
47+
state inside the modal.
48+
49+
## Unknown market fallback
50+
51+
If `calculateProtocolFee` throws (unknown market id), the breakdown is omitted
52+
and the rest of the confirm flow still works. Operators should register the
53+
market in `lib/registry.ts` before enabling the action in production.

0 commit comments

Comments
 (0)