Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
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.
23 changes: 23 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,26 @@ jobs:
cache: 'yarn'
- run: yarn install
- run: yarn test

# `yarn test` type-STRIPS: craco/jest go through babel, and craco.config.js
# runs ts-loader with transpileOnly. The only full type check in the whole
# pipeline happens during the production build, so a type error can pass the
# matrix above and fail at the Netlify deploy gate instead — which is exactly
# how a missing required prop reached a release branch.
#
# `tsc --noEmit` is NOT the check to use here: this repo pins TypeScript 4.8,
# which cannot even parse some dependencies' modern .d.ts syntax, so it fails
# on node_modules regardless of our own code. Building is the gate that works.
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install
- run: yarn build
128 changes: 128 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,128 @@
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}
// Every case here is a quote we DID obtain, which says nothing is
// charged — as opposed to the unavailable case below.
unknown={false}
assetSymbol="DLLR"
/>,
);
expect(container).toBeEmptyDOMElement();
});

it.each([
['the quote could not be obtained', { unknown: true, loading: false }],
['the quote is still loading', { unknown: false, loading: true }],
])('renders nothing when %s', (_label, state) => {
// Fail-hidden. The chain charges nothing when it cannot quote and pays
// the gross, so the honest display is the form exactly as it was before
// the perimeter existed — not a row that hints at a fee it cannot name.
const { container } = render(
<ExitFeeRow
gross={Decimal.from(100)}
rateBps={50}
active
assetSymbol="DLLR"
{...state}
/>,
);
expect(container).toBeEmptyDOMElement();
});

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();
});
});
114 changes: 114 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,114 @@
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;
/**
* Whether the quote was obtained at all. Either way the row is hidden when
* nothing is charged — the perimeter fails open, so an unobtainable quote
* means the chain pays the gross — but the hooks report the distinction and
* the caller is asked to pass it through rather than drop it.
*/
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;
}

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