Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/perimeter-fee-display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'frontend': minor
---

feat: Sovryn Perimeter Fee display on withdraw and close flows. Shows the fee rate, amount, and net "You will receive" on lending withdrawals, borrower exits, Zero collateral withdrawal/close, and the surplus claim. Gated purely on on-chain state and fail-hidden: nothing renders until governance activates the perimeter and charging is enabled, so current forms are unchanged until then.
145 changes: 145 additions & 0 deletions apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { render, screen, fireEvent } from '@testing-library/react';

import React from 'react';

import 'jest-canvas-mock';

import { Decimal } from '@sovryn/utils';

import { i18n } from '../../../locales/i18n';
import { ExitFeeRow } from './ExitFeeRow';

jest.mock('nanoid', () => {
return { nanoid: () => '1234' };
});

jest.mock('../../../contexts/NotificationContext', () => {
return {
useNotificationContext: () => ({
addNotification: jest.fn(),
}),
};
});

describe('ExitFeeRow', () => {
beforeAll(async () => {
await i18n;
});

it('renders a single row with the net amount when the fee is active', () => {
const { container } = render(
<ExitFeeRow
gross={Decimal.from(100)}
rateBps={50}
active
unknown={false}
assetSymbol="DLLR"
/>,
);
expect(screen.getByText('You will receive')).toBeInTheDocument();
// "Perimeter fee" only lives inside the (closed) tooltip, not as a row label.
expect(screen.queryByText(/^Perimeter fee/)).not.toBeInTheDocument();
expect(
container.querySelector('[data-layout-id="exit-fee-net"]'),
).toHaveTextContent('99.5');
});

it('shows the fee amount and disclaimer inside the tooltip on click', () => {
const { container } = render(
<ExitFeeRow
gross={Decimal.from(100)}
rateBps={50}
active
unknown={false}
assetSymbol="DLLR"
/>,
);

const helperIcon = container.querySelector(
'[data-layout-id="exit-fee-helper"]',
);
expect(helperIcon).toBeInTheDocument();
// tooltip content is not rendered until the trigger is clicked
expect(
screen.queryByText(/Perimeter fee \(0\.5%\)/),
).not.toBeInTheDocument();

fireEvent.click(helperIcon as Element);

expect(screen.getByText(/Perimeter fee \(0\.5%\)/)).toBeInTheDocument();
expect(
screen.getByText(
/The perimeter fee is deducted from the withdrawn amount/,
),
).toBeInTheDocument();
});

it.each([
['inactive', false, 50, '100'],
['zero rate', true, 0, '100'],
['insane rate', true, 10001, '100'],
['zero gross', true, 50, '0'],
])('renders nothing when %s', (_label, active, rateBps, gross) => {
const { container } = render(
<ExitFeeRow
gross={Decimal.from(gross)}
rateBps={rateBps as number}
active={active as boolean}
assetSymbol="DLLR"
/>,
);
expect(container).toBeEmptyDOMElement();
});

it('renders the unavailable row when the quote could not be obtained', () => {
const { container } = render(
<ExitFeeRow
gross={Decimal.from(100)}
rateBps={0}
active={false}
unknown
assetSymbol="DLLR"
/>,
);
// The fail-open contract cannot tell "no fee" from "could not ask", so the
// UI must not answer for it. Absence of a row reads as "no fee"; this must
// render something.
expect(
container.querySelector('[data-layout-id="exit-fee-unavailable"]'),
).toBeInTheDocument();
expect(
container.querySelector('[data-layout-id="exit-fee-net"]'),
).not.toBeInTheDocument();
});

it('renders the unavailable row while the quote is still loading', () => {
const { container } = render(
<ExitFeeRow
gross={Decimal.from(100)}
rateBps={0}
active={false}
unknown={false}
loading
assetSymbol="DLLR"
/>,
);
// A quote that has not arrived is not evidence of a zero fee. This is the
// case every consumer used to get wrong by dropping `loading`.
expect(
container.querySelector('[data-layout-id="exit-fee-unavailable"]'),
).toBeInTheDocument();
});

it('renders nothing when the surface is genuinely uncharged', () => {
const { container } = render(
<ExitFeeRow
gross={Decimal.from(100)}
rateBps={0}
active={false}
unknown={false}
assetSymbol="DLLR"
/>,
);
expect(container).toBeEmptyDOMElement();
});
});
133 changes: 133 additions & 0 deletions apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import React, { FC, useMemo } from 'react';

import { t } from 'i18next';

import { HelperButton, SimpleTableRow, TooltipTrigger } from '@sovryn/ui';
import { Decimal } from '@sovryn/utils';

import { TOKEN_RENDER_PRECISION } from '../../../constants/currencies';
import { getTokenDisplayName } from '../../../constants/tokens';
import { translations } from '../../../locales/i18n';
import { getExitFeeAmount, getExitFeeDisplay } from '../../../utils/exitFee';
import { AmountRenderer } from '../AmountRenderer/AmountRenderer';

/** `rateBps / 100`, integer-safe (e.g. 50 -> "0.5", 100 -> "1"). */
const formatRate = (rateBps: number): string => {
const value = rateBps / 100;
return Number.isInteger(value) ? value.toFixed(0) : String(value);
};

export type ExitFeeTooltipContentProps = {
fee: Decimal;
rateBps: number;
assetSymbol: string;
precision?: number;
/** Fixed-gross surfaces (e.g. surplus claim) display the exact fee. */
approx?: boolean;
};

export const ExitFeeTooltipContent: FC<ExitFeeTooltipContentProps> = ({
fee,
rateBps,
assetSymbol,
precision = TOKEN_RENDER_PRECISION,
approx = true,
}) => (
<div className="flex flex-col gap-2">
<span>
{t(translations.exitFee.label, { rate: formatRate(rateBps) })}:{' '}
<AmountRenderer
value={fee}
suffix={getTokenDisplayName(assetSymbol)}
precision={precision}
prefix={approx ? '~ ' : undefined}
showRoundingPrefix={false}
/>
</span>
<span>{t(translations.exitFee.tooltip)}</span>
</div>
);

export type ExitFeeRowProps = {
gross: Decimal;
rateBps: number;
active: boolean;
assetSymbol: string;
precision?: number;
/**
* Required on purpose. The perimeter fails open, so a quote we could not get
* and a quote of zero look identical in `active`/`rateBps`; making the caller
* state which one this is stops the row from silently promising "no fee"
* while the quote is still loading or the RPC is down.
*/
unknown: boolean;
loading?: boolean;
};

export const ExitFeeRow: FC<ExitFeeRowProps> = ({
gross,
rateBps,
active,
assetSymbol,
precision = TOKEN_RENDER_PRECISION,
unknown,
loading = false,
}) => {
const fee = useMemo(() => getExitFeeAmount(gross, rateBps), [gross, rateBps]);
const display = getExitFeeDisplay({ active, rateBps, unknown, loading }, fee);

if (display === 'none') {
return null;
}

if (display === 'unknown') {
return (
<SimpleTableRow
label={
<span className="flex flex-row items-center gap-1 whitespace-nowrap">
{t(translations.exitFee.unavailable)}
<HelperButton
content={t(translations.exitFee.unavailableTooltip)}
trigger={TooltipTrigger.click}
dataAttribute="exit-fee-unavailable-helper"
/>
</span>
}
value="—"
dataAttribute="exit-fee-unavailable"
/>
);
}

return (
<SimpleTableRow
label={
<span className="flex flex-row items-center gap-1 whitespace-nowrap">
{t(translations.exitFee.youWillReceive)}
<HelperButton
content={
<ExitFeeTooltipContent
fee={fee}
rateBps={rateBps}
assetSymbol={assetSymbol}
precision={precision}
/>
}
trigger={TooltipTrigger.click}
dataAttribute="exit-fee-helper"
/>
</span>
}
value={
<AmountRenderer
value={gross.sub(fee)}
suffix={getTokenDisplayName(assetSymbol)}
precision={precision}
prefix="~ "
showRoundingPrefix={false}
/>
}
dataAttribute="exit-fee-net"
/>
);
};
98 changes: 98 additions & 0 deletions apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { render, screen, fireEvent } from '@testing-library/react';

import React from 'react';

import 'jest-canvas-mock';

import { Decimal } from '@sovryn/utils';

import { i18n } from '../../../locales/i18n';
import { LOCStatus } from './LOCStatus';

const mockRate = {
active: true,
rateBps: 50,
loading: false,
};

jest.mock('nanoid', () => {
return { nanoid: () => '1234' };
});

jest.mock('../../../contexts/NotificationContext', () => {
return {
useNotificationContext: () => ({
addNotification: jest.fn(),
}),
};
});

jest.mock('../../../hooks/exitFee/useExitFeeRate', () => ({
useExitFeeRate: () => mockRate,
}));

describe('LOCStatus perimeter fee', () => {
beforeAll(async () => {
await i18n;
});

beforeEach(() => {
Object.assign(mockRate, { active: true, rateBps: 50, loading: false });
});

it('shows the NET surplus with a fee tooltip when the fee is active', () => {
const { container } = render(
<LOCStatus
withdrawalSurplus={Decimal.from('0.4')}
collateral={Decimal.ZERO}
debt={Decimal.ZERO}
onWithdraw={jest.fn()}
/>,
);

expect(screen.getByText(/0\.398/)).toBeInTheDocument();
expect(screen.queryByText(/^0\.4 /)).not.toBeInTheDocument();
// "Perimeter fee" only lives inside the (closed) tooltip, not inline.
expect(screen.queryByText(/^Perimeter fee/)).not.toBeInTheDocument();

const helperIcon = container.querySelector(
'[data-layout-id="exit-fee-helper"]',
);
expect(helperIcon).toBeInTheDocument();

fireEvent.click(helperIcon as Element);
expect(screen.getByText(/Perimeter fee \(0\.5%\)/)).toBeInTheDocument();
});

it('shows the gross surplus with no helper icon when the fee is inactive', () => {
Object.assign(mockRate, { active: false, rateBps: 0 });

const { container } = render(
<LOCStatus
withdrawalSurplus={Decimal.from('0.4')}
collateral={Decimal.ZERO}
debt={Decimal.ZERO}
onWithdraw={jest.fn()}
/>,
);

expect(screen.getByText('0.4 BTC')).toBeInTheDocument();
expect(screen.queryByText(/Perimeter fee/)).not.toBeInTheDocument();
expect(
container.querySelector('[data-layout-id="exit-fee-helper"]'),
).not.toBeInTheDocument();
});

it('does not render the surplus stat at all when there is no surplus', () => {
render(
<LOCStatus
withdrawalSurplus={Decimal.ZERO}
collateral={Decimal.ZERO}
debt={Decimal.ZERO}
onWithdraw={jest.fn()}
/>,
);

expect(screen.queryByText('withdrawal surplus')).not.toBeInTheDocument();
});
});
Loading
Loading