= ({
{hasWithdrawalSurplus && (
+ {t('LOCStatus.withdrawalSurplus')}
+
+ }
+ trigger={TooltipTrigger.click}
+ dataAttribute="exit-fee-helper"
+ />
+
+ ) : exitFeeUnavailable ? (
+
+ {t('LOCStatus.withdrawalSurplus')}
+
+
+ ) : (
+ t('LOCStatus.withdrawalSurplus')
+ )
+ }
+ value={
+ showSurplusExitFee ? (
+
+ ) : exitFeeUnavailable ? (
+ // Not a number: the surplus is charged a fee we could not read,
+ // so the gross is not what arrives and printing it would be a
+ // receipt we cannot honour.
+ '—'
+ ) : (
+ `${withdrawalSurplus} ${BITCOIN}`
+ )
+ }
/>
)}
{showOpenLOC && (
diff --git a/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.test.tsx b/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.test.tsx
new file mode 100644
index 000000000..91d5717ea
--- /dev/null
+++ b/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.test.tsx
@@ -0,0 +1,96 @@
+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 { CloseCreditLine } from './CloseCreditLine';
+
+const mockQuote = {
+ active: true,
+ rateBps: 50,
+ feeAmount: Decimal.from('0.002'),
+ netAmount: Decimal.from('0.398'),
+ loading: false,
+};
+
+jest.mock('nanoid', () => {
+ return { nanoid: () => '1234' };
+});
+
+jest.mock('../../../contexts/NotificationContext', () => {
+ return {
+ useNotificationContext: () => ({
+ addNotification: jest.fn(),
+ }),
+ };
+});
+
+jest.mock('../../../hooks/exitFee/useZeroExitFee', () => ({
+ useZeroExitFee: () => mockQuote,
+}));
+
+jest.mock('./hooks/useZeroData', () => ({
+ useZeroData: () => ({ isRecoveryMode: false }),
+}));
+
+jest.mock('../../../hooks/useMaintenance', () => ({
+ useMaintenance: () => ({ checkMaintenance: () => false, States: {} }),
+}));
+
+jest.mock('../../../hooks/useAssetBalance', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return {
+ useAssetBalance: () => ({ balance: ActualDecimal.from(10000) }),
+ };
+});
+
+describe('CloseCreditLine perimeter fee', () => {
+ beforeAll(async () => {
+ await i18n;
+ });
+
+ it('shows the NET collateral to receive with a fee tooltip', () => {
+ const { container } = render(
+ ,
+ );
+ expect(screen.getByText('Collateral to receive')).toBeInTheDocument();
+ expect(screen.getByText(/0\.398/)).toBeInTheDocument();
+ expect(screen.queryByText(/^0\.4 /)).not.toBeInTheDocument();
+
+ // "Perimeter fee" only lives inside the (closed) tooltip, not as a row label.
+ 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 with no helper icon when the fee is inactive', () => {
+ Object.assign(mockQuote, { active: false, rateBps: 0 });
+ const { container } = render(
+ ,
+ );
+ expect(screen.queryByText(/Perimeter fee/)).not.toBeInTheDocument();
+ expect(screen.getByText(/0\.4/)).toBeInTheDocument();
+ expect(
+ container.querySelector('[data-layout-id="exit-fee-helper"]'),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.tsx b/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.tsx
index 573166f33..3d64a9de7 100644
--- a/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.tsx
+++ b/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.tsx
@@ -14,19 +14,23 @@ import {
ParagraphStyle,
Select,
SimpleTable,
+ TooltipTrigger,
} from '@sovryn/ui';
import { Decimal } from '@sovryn/utils';
import { AmountRenderer } from '../../2_molecules/AmountRenderer/AmountRenderer';
import { AssetRenderer } from '../../2_molecules/AssetRenderer/AssetRenderer';
+import { ExitFeeTooltipContent } from '../../2_molecules/ExitFeeRow/ExitFeeRow';
import { BITCOIN, BTC_RENDER_PRECISION } from '../../../constants/currencies';
import { getTokenDisplayName } from '../../../constants/tokens';
+import { useZeroExitFee } from '../../../hooks/exitFee/useZeroExitFee';
import { useAssetBalance } from '../../../hooks/useAssetBalance';
import { useMaintenance } from '../../../hooks/useMaintenance';
import { translations } from '../../../locales/i18n';
+import { COMMON_SYMBOLS } from '../../../utils/asset';
+import { getExitFeeDisplay } from '../../../utils/exitFee';
import { Row } from './Row';
import { useZeroData } from './hooks/useZeroData';
-import { COMMON_SYMBOLS } from '../../../utils/asset';
type CloseCreditLineProps = {
collateralValue: Decimal;
@@ -50,6 +54,26 @@ export const CloseCreditLine: FC = ({
const { balance: availableBalance } = useAssetBalance(creditToken);
+ const exitFee = useZeroExitFee(collateralValue);
+
+ const exitFeeDisplay = useMemo(
+ () => getExitFeeDisplay(exitFee, exitFee.feeAmount),
+ [exitFee],
+ );
+ const showExitFee = exitFeeDisplay === 'charged';
+ /**
+ * Closing the line returns collateral through a charged surface, so the
+ * amount below is only the gross when nothing is charged. When the quote
+ * could not be read it is not evidence of that, and saying nothing would
+ * present the gross as settled.
+ */
+ const exitFeeUnavailable = exitFeeDisplay === 'unknown';
+
+ const collateralToReceive = useMemo(
+ () => (showExitFee ? exitFee.netAmount : collateralValue),
+ [showExitFee, exitFee.netAmount, collateralValue],
+ );
+
const collateralValueRenderer = useCallback(
(value: Decimal) => (
= ({
>
+ ) : exitFeeUnavailable ? (
+ t(translations.exitFee.unavailableTooltip)
+ ) : undefined
+ }
+ tooltipTrigger={TooltipTrigger.click}
+ tooltipDataAttribute="exit-fee-helper"
+ value={
+ showExitFee ? (
+
+ ) : exitFeeUnavailable ? (
+ // See LOCStatus: an unreadable fee means the gross is not the
+ // amount received, so we decline to name one.
+ '—'
+ ) : (
+ collateralValueRenderer(collateralValue)
+ )
+ }
/>
diff --git a/apps/frontend/src/app/3_organisms/ZeroLocForm/Row.tsx b/apps/frontend/src/app/3_organisms/ZeroLocForm/Row.tsx
index c1f313f7f..8d491617d 100644
--- a/apps/frontend/src/app/3_organisms/ZeroLocForm/Row.tsx
+++ b/apps/frontend/src/app/3_organisms/ZeroLocForm/Row.tsx
@@ -1,21 +1,35 @@
import React, { FC, ReactNode } from 'react';
-import { HelperButton, SimpleTableRow } from '@sovryn/ui';
+import { HelperButton, SimpleTableRow, TooltipTrigger } from '@sovryn/ui';
export type RowProps = {
label: string;
- tooltip?: string;
+ tooltip?: ReactNode;
+ tooltipTrigger?: TooltipTrigger;
+ tooltipDataAttribute?: string;
value: ReactNode;
valueClassName?: string;
};
-export const Row: FC = ({ label, tooltip, ...props }) => (
+export const Row: FC = ({
+ label,
+ tooltip,
+ tooltipTrigger,
+ tooltipDataAttribute,
+ ...props
+}) => (
{label}
- {tooltip && }
+ {tooltip && (
+
+ )}
}
{...props}
diff --git a/apps/frontend/src/app/3_organisms/ZeroLocForm/components/FormContent.test.tsx b/apps/frontend/src/app/3_organisms/ZeroLocForm/components/FormContent.test.tsx
new file mode 100644
index 000000000..b1d2efdff
--- /dev/null
+++ b/apps/frontend/src/app/3_organisms/ZeroLocForm/components/FormContent.test.tsx
@@ -0,0 +1,108 @@
+import { render, screen } from '@testing-library/react';
+
+import React from 'react';
+
+import 'jest-canvas-mock';
+
+import { Decimal } from '@sovryn/utils';
+
+import { i18n } from '../../../../locales/i18n';
+import { AmountType } from '../types';
+import { FormContent } from './FormContent';
+
+jest.mock('nanoid', () => {
+ return { nanoid: () => '1234' };
+});
+
+jest.mock('../../../../contexts/NotificationContext', () => {
+ return {
+ useNotificationContext: () => ({
+ addNotification: jest.fn(),
+ }),
+ };
+});
+
+// react-scripts' jest preset hoists jest.mock() calls above imports, so the
+// factory can't close over the top-level `Decimal` import directly — pull it
+// via requireActual instead (same pattern as LendingForm.test.tsx).
+jest.mock('../../../../hooks/exitFee/useZeroExitFee', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return {
+ useZeroExitFee: () => ({
+ active: true,
+ rateBps: 50,
+ feeAmount: ActualDecimal.ZERO,
+ netAmount: ActualDecimal.ZERO,
+ loading: false,
+ }),
+ };
+});
+
+jest.mock('../../../../hooks/useMaintenance', () => ({
+ useMaintenance: () => ({
+ checkMaintenance: () => false,
+ States: {},
+ }),
+}));
+
+jest.mock('../../../5_pages/ZeroPage/hooks/useLiquityBaseParams', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return {
+ useLiquityBaseParams: () => ({
+ minBorrowingFeeRate: ActualDecimal.ZERO,
+ maxBorrowingFeeRate: ActualDecimal.from(0.05),
+ }),
+ };
+});
+
+const makeProps = (overrides: object = {}) =>
+ ({
+ hasTrove: true,
+ existingDebt: Decimal.from(3000),
+ existingCollateral: Decimal.from(0.4),
+ debtType: AmountType.Add,
+ onDebtTypeChange: jest.fn(),
+ collateralType: AmountType.Remove,
+ onCollateralTypeChange: jest.fn(),
+ rbtcPrice: Decimal.from(67000),
+ borrowingRate: Decimal.ZERO,
+ originationFee: Decimal.ZERO,
+ maxOriginationFeeRate: '5',
+ onMaxOriginationFeeRateChange: jest.fn(),
+ debtAmount: '0',
+ maxDebtAmount: Decimal.from(1000),
+ onDebtAmountChange: jest.fn(),
+ debtToken: 'zusd',
+ onDebtTokenChange: jest.fn(),
+ collateralAmount: '0.02',
+ maxCollateralAmount: Decimal.from(0.2),
+ onCollateralAmountChange: jest.fn(),
+ initialRatio: Decimal.from(200),
+ currentRatio: Decimal.from(180),
+ initialLiquidationPrice: Decimal.from(40000),
+ liquidationPrice: Decimal.from(45000),
+ initialLiquidationPriceInRecoveryMode: Decimal.from(50000),
+ liquidationPriceInRecoveryMode: Decimal.from(55000),
+ totalDebt: Decimal.from(3000),
+ totalCollateral: Decimal.from(0.38),
+ onFormSubmit: jest.fn(),
+ ...overrides,
+ } as any);
+
+describe('FormContent perimeter fee', () => {
+ beforeAll(async () => {
+ await i18n;
+ });
+
+ it('shows the "You will receive" row when withdrawing collateral', () => {
+ render(
);
+ 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();
+ });
+
+ it('hides the "You will receive" row when adding collateral', () => {
+ render(
);
+ expect(screen.queryByText('You will receive')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/frontend/src/app/3_organisms/ZeroLocForm/components/FormContent.tsx b/apps/frontend/src/app/3_organisms/ZeroLocForm/components/FormContent.tsx
index a9c2fd542..5c44afb8b 100644
--- a/apps/frontend/src/app/3_organisms/ZeroLocForm/components/FormContent.tsx
+++ b/apps/frontend/src/app/3_organisms/ZeroLocForm/components/FormContent.tsx
@@ -25,6 +25,7 @@ import { Decimal } from '@sovryn/utils';
import { AdvancedSettings } from '../../../2_molecules/AdvancedSettings/AdvancedSettings';
import { AmountRenderer } from '../../../2_molecules/AmountRenderer/AmountRenderer';
import { AssetRenderer } from '../../../2_molecules/AssetRenderer/AssetRenderer';
+import { ExitFeeRow } from '../../../2_molecules/ExitFeeRow/ExitFeeRow';
import { BORROW_ASSETS } from '../../../5_pages/ZeroPage/constants';
import { useLiquityBaseParams } from '../../../5_pages/ZeroPage/hooks/useLiquityBaseParams';
import {
@@ -35,14 +36,15 @@ import {
} from '../../../../constants/currencies';
import { COLLATERAL_RATIO_THRESHOLDS } from '../../../../constants/general';
import { WIKI_LINKS } from '../../../../constants/links';
+import { useZeroExitFee } from '../../../../hooks/exitFee/useZeroExitFee';
import { useMaintenance } from '../../../../hooks/useMaintenance';
import { translations } from '../../../../locales/i18n';
+import { COMMON_SYMBOLS } from '../../../../utils/asset';
import { formatValue, decimalic } from '../../../../utils/math';
import { CurrentTroveData } from '../CurrentTroveData';
import { Label } from '../Label';
import { Row } from '../Row';
import { AmountType } from '../types';
-import { COMMON_SYMBOLS } from '../../../../utils/asset';
export type OpenTroveProps = {
hasTrove: false;
@@ -150,6 +152,21 @@ export const FormContent: FC
= props => {
[borrowLocked, props],
);
+ const {
+ active: exitFeeActive,
+ rateBps: exitFeeRateBps,
+ unknown: exitFeeUnknown,
+ loading: exitFeeLoading,
+ } = useZeroExitFee();
+
+ const exitFeeGross = useMemo(
+ () =>
+ props.hasTrove && props.collateralType === AmountType.Remove
+ ? decimalic(props.collateralAmount)
+ : Decimal.ZERO,
+ [props],
+ );
+
const { minBorrowingFeeRate, maxBorrowingFeeRate } = useLiquityBaseParams();
const minOriginationFeeRate = useMemo(
@@ -489,6 +506,15 @@ export const FormContent: FC = props => {
/>
}
/>
+
>
) : (
<>
diff --git a/apps/frontend/src/app/5_pages/BorrowPage/BorrowPage.utils.test.tsx b/apps/frontend/src/app/5_pages/BorrowPage/BorrowPage.utils.test.tsx
new file mode 100644
index 000000000..4fff22ad6
--- /dev/null
+++ b/apps/frontend/src/app/5_pages/BorrowPage/BorrowPage.utils.test.tsx
@@ -0,0 +1,87 @@
+import 'jest-canvas-mock';
+
+import { Decimal } from '@sovryn/utils';
+
+import { getBorrowerExitFeeGross } from './BorrowPage.utils';
+
+jest.mock('nanoid', () => {
+ return { nanoid: () => '1234' };
+});
+
+const base = {
+ isCloseTab: false,
+ isRepayTab: false,
+ isCollateralWithdrawTab: false,
+ loanCollateral: Decimal.from(10),
+ collateralSize: Decimal.from(2),
+ collateralWithdrawn: Decimal.from(3),
+ debtSize: Decimal.from(500),
+ maximumRepayAmount: Decimal.from(1000),
+};
+
+describe('getBorrowerExitFeeGross', () => {
+ it('close tab returns the full collateral', () => {
+ expect(
+ getBorrowerExitFeeGross({ ...base, isCloseTab: true }).toString(),
+ ).toEqual('10');
+ });
+
+ it('repay tab returns the proportional withdrawal', () => {
+ expect(
+ getBorrowerExitFeeGross({ ...base, isRepayTab: true }).toString(),
+ ).toEqual('3');
+ });
+
+ it('full repay returns the full collateral', () => {
+ expect(
+ getBorrowerExitFeeGross({
+ ...base,
+ isRepayTab: true,
+ debtSize: Decimal.from(1000),
+ }).toString(),
+ ).toEqual('10');
+ });
+
+ it('repay tab with zero debt input returns zero', () => {
+ expect(
+ getBorrowerExitFeeGross({
+ ...base,
+ isRepayTab: true,
+ debtSize: Decimal.ZERO,
+ }).isZero(),
+ ).toBe(true);
+ });
+
+ it('withdraw-collateral tab returns the entered amount', () => {
+ expect(
+ getBorrowerExitFeeGross({
+ ...base,
+ isCollateralWithdrawTab: true,
+ }).toString(),
+ ).toEqual('2');
+ });
+
+ it('borrow/add tabs return zero', () => {
+ expect(getBorrowerExitFeeGross(base).isZero()).toBe(true);
+ });
+
+ it('close tab wins over simultaneous collateral-withdraw tab', () => {
+ expect(
+ getBorrowerExitFeeGross({
+ ...base,
+ isCloseTab: true,
+ isCollateralWithdrawTab: true,
+ }).toString(),
+ ).toEqual('10');
+ });
+
+ it('repay tab wins over simultaneous collateral-withdraw tab', () => {
+ expect(
+ getBorrowerExitFeeGross({
+ ...base,
+ isRepayTab: true,
+ isCollateralWithdrawTab: true,
+ }).toString(),
+ ).toEqual('3');
+ });
+});
diff --git a/apps/frontend/src/app/5_pages/BorrowPage/BorrowPage.utils.tsx b/apps/frontend/src/app/5_pages/BorrowPage/BorrowPage.utils.tsx
index b21605432..5503fbd0f 100644
--- a/apps/frontend/src/app/5_pages/BorrowPage/BorrowPage.utils.tsx
+++ b/apps/frontend/src/app/5_pages/BorrowPage/BorrowPage.utils.tsx
@@ -20,7 +20,11 @@ import {
} from '../../../constants/lending';
import { translations } from '../../../locales/i18n';
import { COMMON_SYMBOLS, findAsset } from '../../../utils/asset';
-import { isBitpro, isBtcBasedAsset } from '../../../utils/helpers';
+import {
+ areValuesIdentical,
+ isBitpro,
+ isBtcBasedAsset,
+} from '../../../utils/helpers';
import { decimalic } from '../../../utils/math';
export const renderValue = (
@@ -115,3 +119,42 @@ export const normalizeTokenWrapped = (token: string): string => {
return normalizeToken(token);
};
+
+export const getBorrowerExitFeeGross = ({
+ isCloseTab,
+ isRepayTab,
+ isCollateralWithdrawTab,
+ loanCollateral,
+ collateralSize,
+ collateralWithdrawn,
+ debtSize,
+ maximumRepayAmount,
+}: {
+ isCloseTab: boolean;
+ isRepayTab: boolean;
+ isCollateralWithdrawTab: boolean;
+ loanCollateral: Decimal;
+ collateralSize: Decimal;
+ collateralWithdrawn: Decimal;
+ debtSize: Decimal;
+ maximumRepayAmount: Decimal;
+}): Decimal => {
+ // Order matters: Repay/Close force the collateral tab to Withdraw
+ // (AdjustLoanForm.onDebtTabChange), so close/repay must win over
+ // isCollateralWithdrawTab.
+ if (isCloseTab) {
+ return loanCollateral;
+ }
+ if (isRepayTab) {
+ if (debtSize.isZero()) {
+ return Decimal.ZERO;
+ }
+ return areValuesIdentical(debtSize, maximumRepayAmount)
+ ? loanCollateral
+ : collateralWithdrawn;
+ }
+ if (isCollateralWithdrawTab) {
+ return collateralSize;
+ }
+ return Decimal.ZERO;
+};
diff --git a/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/AdjustLoanForm.tsx b/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/AdjustLoanForm.tsx
index 0d193a425..605751116 100644
--- a/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/AdjustLoanForm.tsx
+++ b/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/AdjustLoanForm.tsx
@@ -17,26 +17,34 @@ import {
} from '@sovryn/ui';
import { Decimal } from '@sovryn/utils';
+import { RSK_CHAIN_ID } from '../../../../../config/chains';
+
import { AmountRenderer } from '../../../../2_molecules/AmountRenderer/AmountRenderer';
import { AssetRenderer } from '../../../../2_molecules/AssetRenderer/AssetRenderer';
+import { ExitFeeRow } from '../../../../2_molecules/ExitFeeRow/ExitFeeRow';
import { LabelWithTabsAndMaxButton } from '../../../../2_molecules/LabelWithTabsAndMaxButton/LabelWithTabsAndMaxButton';
import { convertLoanTokenToSupportedAssets } from '../../../../5_pages/BorrowPage/components/OpenLoansTable/OpenLoans.utils';
import { LoanItem } from '../../../../5_pages/BorrowPage/components/OpenLoansTable/OpenLoansTable.types';
import { useGetMinCollateralRatio } from '../../../../5_pages/BorrowPage/hooks/useGetMinCollateralRatio';
+import { BTC_RENDER_PRECISION } from '../../../../../constants/currencies';
import {
MINIMUM_COLLATERAL_RATIO_LENDING_POOLS,
MINIMUM_COLLATERAL_RATIO_LENDING_POOLS_SOV,
} from '../../../../../constants/lending';
import { getTokenDisplayName } from '../../../../../constants/tokens';
+import { useExitFeeRate } from '../../../../../hooks/exitFee/useExitFeeRate';
import { useDecimalAmountInput } from '../../../../../hooks/useDecimalAmountInput';
+import { useLoadContract } from '../../../../../hooks/useLoadContract';
import { useMaxAssetBalance } from '../../../../../hooks/useMaxAssetBalance';
import { useQueryRate } from '../../../../../hooks/useQueryRate';
import { translations } from '../../../../../locales/i18n';
import { COMMON_SYMBOLS } from '../../../../../utils/asset';
+import { SURFACE_LENDING_BORROWER_WITHDRAW } from '../../../../../utils/exitFee';
import { areValuesIdentical } from '../../../../../utils/helpers';
import { decimalic } from '../../../../../utils/math';
import {
calculatePrepaidInterestFromDuration,
+ getBorrowerExitFeeGross,
getCollateralRatioThresholds,
getOriginationFeeAmount,
normalizeToken,
@@ -256,6 +264,46 @@ export const AdjustLoanForm: FC = ({ loan }) => {
collateralWithdrawn,
]);
+ const loanTokenContract = useLoadContract(
+ debtToken,
+ 'loanTokens',
+ RSK_CHAIN_ID,
+ );
+
+ const {
+ active: exitFeeActive,
+ rateBps: exitFeeRateBps,
+ unknown: exitFeeUnknown,
+ loading: exitFeeLoading,
+ } = useExitFeeRate(
+ SURFACE_LENDING_BORROWER_WITHDRAW,
+ loanTokenContract?.address,
+ );
+
+ const exitFeeGross = useMemo(
+ () =>
+ getBorrowerExitFeeGross({
+ isCloseTab,
+ isRepayTab,
+ isCollateralWithdrawTab,
+ loanCollateral: decimalic(loan.collateral.toString()),
+ collateralSize,
+ collateralWithdrawn,
+ debtSize,
+ maximumRepayAmount,
+ }),
+ [
+ isCloseTab,
+ isRepayTab,
+ isCollateralWithdrawTab,
+ loan.collateral,
+ collateralSize,
+ collateralWithdrawn,
+ debtSize,
+ maximumRepayAmount,
+ ],
+ );
+
const prepaidInterest = calculatePrepaidInterestFromDuration(
borrowApr,
debtSize.toString(),
@@ -838,6 +886,15 @@ export const AdjustLoanForm: FC = ({ loan }) => {
}
/>
)}
+
{(isBorrowTab || isRepayTab) && (
{
+ return { nanoid: () => '1234' };
+});
+
+jest.mock('../../../../../contexts/NotificationContext', () => {
+ return {
+ useNotificationContext: () => ({
+ addNotification: jest.fn(),
+ }),
+ };
+});
+
+jest.mock('../../../../../hooks/exitFee/useExitFeeRate', () => ({
+ useExitFeeRate: () => ({ active: true, rateBps: 50, loading: false }),
+}));
+
+jest.mock('../../../../../hooks/useMaxAssetBalance', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return {
+ useMaxAssetBalance: () => ({ balance: ActualDecimal.from(1000) }),
+ };
+});
+
+// note: react-scripts' jest preset sets `resetMocks: true`, which strips any
+// mockResolvedValue configured inline here before every test runs — so
+// `asyncCall` is reconfigured fresh in `beforeEach` below instead.
+jest.mock('../../../../../store/rxjs/provider-cache', () => ({
+ ...jest.requireActual('../../../../../store/rxjs/provider-cache'),
+ asyncCall: jest.fn(),
+}));
+
+const state = {
+ token: 'dllr',
+ tokenDetails: { symbol: 'dllr' },
+ poolTokenContract: { address: '0x0000000000000000000000000000000000000001' },
+ balance: Decimal.from(5000),
+ liquidity: Decimal.from(100000),
+ apr: Decimal.from(2),
+} as any;
+
+describe('LendingForm perimeter fee', () => {
+ beforeAll(async () => {
+ await i18n;
+ });
+
+ beforeEach(() => {
+ (asyncCall as jest.Mock).mockResolvedValue(BigNumber.from(0));
+ });
+
+ it('shows the "You will receive" row on the withdraw tab once an amount is entered', () => {
+ render();
+ fireEvent.click(screen.getByText('Withdraw'));
+ const input = screen.getByPlaceholderText('0');
+ fireEvent.change(input, { target: { value: '100' } });
+ // AmountInput/InputBase debounces onChangeText (default 500ms) for the
+ // "change" event; blur commits the value synchronously (type="number"
+ // path in InputBase.handleOnBlur), which is what the parent's `amount`
+ // state (and therefore ExitFeeRow) reacts to.
+ fireEvent.blur(input);
+ 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();
+ });
+
+ it('shows no "You will receive" row on the deposit tab', () => {
+ render();
+ const input = screen.getByPlaceholderText('0');
+ fireEvent.change(input, { target: { value: '100' } });
+ fireEvent.blur(input);
+ expect(screen.queryByText('You will receive')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/LendingForm.tsx b/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/LendingForm.tsx
index b51eae382..08a4fb835 100644
--- a/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/LendingForm.tsx
+++ b/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/LendingForm.tsx
@@ -18,12 +18,15 @@ import { RSK_CHAIN_ID } from '../../../../../config/chains';
import { AmountRenderer } from '../../../../2_molecules/AmountRenderer/AmountRenderer';
import { AssetRenderer } from '../../../../2_molecules/AssetRenderer/AssetRenderer';
+import { ExitFeeRow } from '../../../../2_molecules/ExitFeeRow/ExitFeeRow';
import { GAS_LIMIT } from '../../../../../constants/gasLimits';
import { getTokenDisplayName } from '../../../../../constants/tokens';
+import { useExitFeeRate } from '../../../../../hooks/exitFee/useExitFeeRate';
import { useMaxAssetBalance } from '../../../../../hooks/useMaxAssetBalance';
import { useWeiAmountInput } from '../../../../../hooks/useWeiAmountInput';
import { translations } from '../../../../../locales/i18n';
import { asyncCall } from '../../../../../store/rxjs/provider-cache';
+import { SURFACE_LENDING_LENDER_WITHDRAW } from '../../../../../utils/exitFee';
import { FullAdjustModalState } from './AdjustLendingModalContainer';
import { Label } from './Label';
@@ -49,6 +52,21 @@ export const LendingForm: FC = ({ state, onConfirm }) => {
GAS_LIMIT.LENDING_MINT,
);
+ const {
+ active: exitFeeActive,
+ rateBps: exitFeeRateBps,
+ unknown: exitFeeUnknown,
+ loading: exitFeeLoading,
+ } = useExitFeeRate(
+ SURFACE_LENDING_LENDER_WITHDRAW,
+ state.poolTokenContract.address,
+ );
+
+ const withdrawAmount = useMemo(
+ () => Decimal.fromBigNumberString(amount.toString()),
+ [amount],
+ );
+
const balance = useMemo(
() =>
isDeposit ? userBalance : Decimal.min(state.balance, state.liquidity),
@@ -181,6 +199,16 @@ export const LendingForm: FC = ({ state, onConfirm }) => {
/>
}
/>
+ {!isDeposit && (
+
+ )}