From a1ea5225771d11d5080b242cc5ea25a291f50999 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Fri, 14 Aug 2026 00:37:31 +0300 Subject: [PATCH 1/9] feat: Sovryn Perimeter Fee display on withdraw and close flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the perimeter-fee UI to the public repo, rebased onto current develop (base moved 4971f72b -> 6cd83433). Shows the fee row, tooltip, and net 'You will receive' amount on lending withdrawals, borrower exits, Zero collateral withdrawal/close, and the surplus-claim view. Display is gated purely on on-chain state and fails hidden: the row renders only when the controller quotes an active policy with a non-zero rate and fee. While the perimeter is deployed-but-disabled (its state until SIP-0094 executes and the Exchequer enables charging), every form renders exactly as it does today. No feature flag, no env var. All user-facing copy says 'Perimeter fee' (renamed from the working title during this port, tests updated to pin the new copy). Internal identifiers and the on-chain surface-id constants are unchanged — the ids are keccak hashes verified against the deployed consumer contracts. The Spanish locale remains the app-wide stub (falls back to English), unchanged by this change. --- .../ExitFeeRow/ExitFeeRow.test.tsx | 91 +++++++++++++++ .../app/2_molecules/ExitFeeRow/ExitFeeRow.tsx | 103 +++++++++++++++++ .../2_molecules/LOCStatus/LOCStatus.test.tsx | 98 ++++++++++++++++ .../app/2_molecules/LOCStatus/LOCStatus.tsx | 66 ++++++++++- .../ZeroLocForm/CloseCreditLine.test.tsx | 96 ++++++++++++++++ .../ZeroLocForm/CloseCreditLine.tsx | 44 ++++++- .../src/app/3_organisms/ZeroLocForm/Row.tsx | 22 +++- .../components/FormContent.test.tsx | 108 ++++++++++++++++++ .../ZeroLocForm/components/FormContent.tsx | 21 +++- .../BorrowPage/BorrowPage.utils.test.tsx | 87 ++++++++++++++ .../5_pages/BorrowPage/BorrowPage.utils.tsx | 45 +++++++- .../AdjustLoanForm/AdjustLoanForm.tsx | 50 ++++++++ .../AdjustModal/LendingForm.test.tsx | 85 ++++++++++++++ .../components/AdjustModal/LendingForm.tsx | 21 ++++ .../src/hooks/exitFee/useExitFeeRate.ts | 85 ++++++++++++++ .../src/hooks/exitFee/useZeroExitFee.ts | 83 ++++++++++++++ .../frontend/src/locales/en/translations.json | 5 + apps/frontend/src/utils/exitFee.test.ts | 55 +++++++++ apps/frontend/src/utils/exitFee.ts | 29 +++++ 19 files changed, 1183 insertions(+), 11 deletions(-) create mode 100644 apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.test.tsx create mode 100644 apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.tsx create mode 100644 apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.test.tsx create mode 100644 apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.test.tsx create mode 100644 apps/frontend/src/app/3_organisms/ZeroLocForm/components/FormContent.test.tsx create mode 100644 apps/frontend/src/app/5_pages/BorrowPage/BorrowPage.utils.test.tsx create mode 100644 apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/LendingForm.test.tsx create mode 100644 apps/frontend/src/hooks/exitFee/useExitFeeRate.ts create mode 100644 apps/frontend/src/hooks/exitFee/useZeroExitFee.ts create mode 100644 apps/frontend/src/utils/exitFee.test.ts create mode 100644 apps/frontend/src/utils/exitFee.ts diff --git a/apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.test.tsx b/apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.test.tsx new file mode 100644 index 000000000..38aa87c62 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.test.tsx @@ -0,0 +1,91 @@ +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( + , + ); + 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( + , + ); + + 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( + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.tsx b/apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.tsx new file mode 100644 index 000000000..2fe0a08e6 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.tsx @@ -0,0 +1,103 @@ +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, isExitFeeShown } 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 = ({ + fee, + rateBps, + assetSymbol, + precision = TOKEN_RENDER_PRECISION, + approx = true, +}) => ( +
+ + {t(translations.exitFee.label, { rate: formatRate(rateBps) })}:{' '} + + + {t(translations.exitFee.tooltip)} +
+); + +export type ExitFeeRowProps = { + gross: Decimal; + rateBps: number; + active: boolean; + assetSymbol: string; + precision?: number; +}; + +export const ExitFeeRow: FC = ({ + gross, + rateBps, + active, + assetSymbol, + precision = TOKEN_RENDER_PRECISION, +}) => { + const fee = useMemo(() => getExitFeeAmount(gross, rateBps), [gross, rateBps]); + + if (!isExitFeeShown(active, rateBps, fee)) { + return null; + } + + return ( + + {t(translations.exitFee.youWillReceive)} + + } + trigger={TooltipTrigger.click} + dataAttribute="exit-fee-helper" + /> + + } + value={ + + } + dataAttribute="exit-fee-net" + /> + ); +}; diff --git a/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.test.tsx b/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.test.tsx new file mode 100644 index 000000000..6b1cff64e --- /dev/null +++ b/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.queryByText('withdrawal surplus')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.tsx b/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.tsx index 110d549bb..22fc21ed7 100644 --- a/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.tsx +++ b/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.tsx @@ -1,10 +1,17 @@ import React, { FC, useMemo } from 'react'; import classNames from 'classnames'; +import { constants } from 'ethers'; import { t } from 'i18next'; import CountUp from 'react-countup'; -import { Button, ButtonSize, ButtonStyle } from '@sovryn/ui'; +import { + Button, + ButtonSize, + ButtonStyle, + HelperButton, + TooltipTrigger, +} from '@sovryn/ui'; import { Decimal } from '@sovryn/utils'; import { RedemptionDialogButton } from '../../5_pages/ZeroPage/components/RedemptionDialog/RedemptionDialogButton'; @@ -13,7 +20,15 @@ import { BTC_RENDER_PRECISION, TOKEN_RENDER_PRECISION, } from '../../../constants/currencies'; +import { useExitFeeRate } from '../../../hooks/exitFee/useExitFeeRate'; +import { COMMON_SYMBOLS } from '../../../utils/asset'; +import { + SURFACE_ZERO_CLAIM_SURPLUS, + getExitFeeAmount, + isExitFeeShown, +} from '../../../utils/exitFee'; import { AmountRenderer } from '../AmountRenderer/AmountRenderer'; +import { ExitFeeTooltipContent } from '../ExitFeeRow/ExitFeeRow'; import { CRatioIndicator } from './components/CRatioIndicator/CRatioIndicator'; import { LOCStat } from './components/LOCStat/LOCStat'; @@ -45,6 +60,20 @@ export const LOCStatus: FC = ({ const ratio = useMemo(() => parseInt(cRatio.toString()), [cRatio]); + const { active: exitFeeActive, rateBps: exitFeeRateBps } = useExitFeeRate( + SURFACE_ZERO_CLAIM_SURPLUS, + constants.AddressZero, // ethers constants — subProduct dimension unused for Zero + ); + const surplusExitFee = useMemo( + () => getExitFeeAmount(withdrawalSurplus, exitFeeRateBps), + [withdrawalSurplus, exitFeeRateBps], + ); + const showSurplusExitFee = isExitFeeShown( + exitFeeActive, + exitFeeRateBps, + surplusExitFee, + ); + return (
= ({
{hasWithdrawalSurplus && ( + {t('LOCStatus.withdrawalSurplus')} + + } + trigger={TooltipTrigger.click} + dataAttribute="exit-fee-helper" + /> + + ) : ( + t('LOCStatus.withdrawalSurplus') + ) + } + value={ + showSurplusExitFee ? ( + + ) : ( + `${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..66736e5ed 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 { isExitFeeShown } 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,18 @@ export const CloseCreditLine: FC = ({ const { balance: availableBalance } = useAssetBalance(creditToken); + const exitFee = useZeroExitFee(collateralValue); + + const showExitFee = useMemo( + () => isExitFeeShown(exitFee.active, exitFee.rateBps, exitFee.feeAmount), + [exitFee], + ); + + const collateralToReceive = useMemo( + () => (showExitFee ? exitFee.netAmount : collateralValue), + [showExitFee, exitFee.netAmount, collateralValue], + ); + const collateralValueRenderer = useCallback( (value: Decimal) => ( = ({ > + ) : undefined + } + tooltipTrigger={TooltipTrigger.click} + tooltipDataAttribute="exit-fee-helper" + value={ + showExitFee ? ( + + ) : ( + 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..b73ca229e 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,16 @@ export const FormContent: FC = props => { [borrowLocked, props], ); + const { active: exitFeeActive, rateBps: exitFeeRateBps } = 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 +501,13 @@ 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..2db596f9b 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,41 @@ export const AdjustLoanForm: FC = ({ loan }) => { collateralWithdrawn, ]); + const loanTokenContract = useLoadContract( + debtToken, + 'loanTokens', + RSK_CHAIN_ID, + ); + + const { active: exitFeeActive, rateBps: exitFeeRateBps } = 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 +881,13 @@ 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..05c1a6473 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,16 @@ export const LendingForm: FC = ({ state, onConfirm }) => { GAS_LIMIT.LENDING_MINT, ); + const { active: exitFeeActive, rateBps: exitFeeRateBps } = 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 +194,14 @@ export const LendingForm: FC = ({ state, onConfirm }) => { /> } /> + {!isDeposit && ( + + )}