- {status === StatusType.error && (
+ {status === StatusType.error && !notSent && (
{t(translations.transactionStep.transactionFailedTitle)}
@@ -267,6 +275,19 @@ export const TransactionStep: FC = ({
)}
+ {status === StatusType.error && notSent && (
+
+
+ {t(translations.transactionStep.notSentTitle)}
+
+ {notSent.map(reason => (
+
+ {reason}
+
+ ))}
+ {t(translations.transactionStep.notSentSubtitle)}
+
+ )}
{subtitle && status !== StatusType.error && (
{subtitle}
)}
@@ -359,6 +380,7 @@ export const TransactionStep: FC
= ({
updateConfig({
...config,
gasLimit: e.target.value.replace(/[^0-9]/g, ''),
+ gasLimitTypedByUser: true,
})
}
step="0"
diff --git a/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.test.tsx b/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.test.tsx
new file mode 100644
index 000000000..031cff2ae
--- /dev/null
+++ b/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.test.tsx
@@ -0,0 +1,514 @@
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+
+import React from 'react';
+
+import { BigNumber } from 'ethers';
+import 'jest-canvas-mock';
+
+import { Overlay, StatusType } from '@sovryn/ui';
+
+import { i18n } from '../../../../../locales/i18n';
+import {
+ Transaction,
+ TransactionType,
+} from '../../TransactionStepDialog.types';
+import { sendRefusal } from '../../helpers';
+import { TransactionSteps } from './TransactionSteps';
+
+/**
+ * A transaction may carry a check that runs inside the send step, after the
+ * holder presses Confirm and immediately before the wallet is asked to sign.
+ * What it returns is what the wallet is handed. When it refuses, the wallet is
+ * never asked, and the step says nothing was sent and why, with Retry offered;
+ * the dialog reports a failed transaction only when the wallet was asked.
+ */
+
+const ACCOUNT = '0x1111111111111111111111111111111111111111';
+const QUEUE = '0x9999999999999999999999999999999999999999';
+
+jest.mock('nanoid', () => ({ nanoid: () => '1234' }));
+
+jest.mock('../../../../../contexts/NotificationContext', () => ({
+ useNotificationContext: () => ({ addNotification: () => undefined }),
+}));
+
+jest.mock('../../../../../hooks/useChainStore', () => ({
+ useCurrentChain: () => '0x1e',
+}));
+
+jest.mock('../../../../../hooks/useNativeAssetBalance', () => {
+ const { Decimal } = jest.requireActual('@sovryn/utils');
+ return {
+ useNativeAssetBalance: () => ({ balance: Decimal.from(1), loading: false }),
+ };
+});
+
+jest.mock('../../../../../hooks/useAccount', () => ({
+ useAccount: () => ({ account: '0x1111111111111111111111111111111111111111' }),
+}));
+
+jest.mock('@sovryn/contracts', () => ({
+ ...jest.requireActual('@sovryn/contracts'),
+ findContract: () => Promise.reject(new Error('not a known contract')),
+}));
+
+const mockSend = jest.fn();
+const mockEstimate = jest.fn();
+
+const releaseTransaction = (
+ beforeSend?: Transaction['beforeSend'],
+): Transaction => ({
+ title: 'Release 3 withdrawals from the Perimeter withdraw queue',
+ request: {
+ type: TransactionType.signTransaction,
+ contract: {
+ address: QUEUE,
+ signer: { getAddress: async () => ACCOUNT },
+ provider: { getTransactionCount: async () => 3 },
+ estimateGas: { executeExits: mockEstimate },
+ executeExits: mockSend,
+ } as never,
+ fnName: 'executeExits',
+ args: [['7', '8', '9']],
+ },
+ beforeSend,
+});
+
+const confirm = async () => {
+ const button = await waitFor(() => {
+ const found = document.querySelector(
+ '[data-layout-id="tx-dialog-confirm"]',
+ );
+ expect(found).toBeInTheDocument();
+ return found!;
+ });
+ fireEvent.click(button);
+};
+
+describe('TransactionSteps', () => {
+ beforeAll(async () => {
+ await i18n;
+ });
+
+ beforeEach(() => {
+ // The dialog could not estimate gas when it opened, so it holds its flat
+ // default for the step.
+ mockEstimate.mockRejectedValue(new Error('execution reverted'));
+ mockSend.mockResolvedValue({ hash: '0xabc', wait: async () => ({}) });
+ jest.spyOn(console, 'error').mockImplementation(() => undefined);
+ });
+
+ it("hands the wallet the request and gas its send check returns, not the dialog's own", async () => {
+ const beforeSend = jest.fn(async ({ request, config }) => ({
+ request: { ...request, args: [['7', '9']] },
+ config: { ...config, gasLimit: '50000' },
+ }));
+ render(
+ ,
+ );
+
+ await confirm();
+
+ await waitFor(() => expect(mockSend).toHaveBeenCalled());
+ expect(beforeSend).toHaveBeenCalledTimes(1);
+ expect(beforeSend.mock.invocationCallOrder[0]).toBeLessThan(
+ mockSend.mock.invocationCallOrder[0],
+ );
+ expect(mockSend).toHaveBeenCalledWith(
+ ['7', '9'],
+ expect.objectContaining({ gasLimit: '50000', from: ACCOUNT }),
+ );
+ });
+
+ it('reports a release complete only after its receipt wait succeeds', async () => {
+ let mined!: () => void;
+ mockSend.mockResolvedValue({
+ hash: '0xabc',
+ wait: () =>
+ new Promise(resolve => {
+ mined = resolve;
+ }),
+ });
+ const transaction = releaseTransaction(async step => step);
+ const onComplete = jest.fn();
+ transaction.onComplete = onComplete;
+ render(
+ ,
+ );
+ await confirm();
+ await waitFor(() => expect(mined).toBeDefined());
+ expect(onComplete).not.toHaveBeenCalled();
+ await act(async () => mined());
+ await waitFor(() => expect(onComplete).toHaveBeenCalledWith('0xabc'));
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ });
+
+ it('never reports a release complete when its receipt wait rejects', async () => {
+ mockSend.mockResolvedValue({
+ hash: '0xabc',
+ wait: async () => {
+ throw new Error('Receipt reverted');
+ },
+ });
+ const transaction = releaseTransaction(async step => step);
+ const onComplete = jest.fn();
+ transaction.onComplete = onComplete;
+ render(
+ ,
+ );
+ await confirm();
+ await waitFor(() =>
+ expect(
+ document.querySelector('[data-layout-id="tx-dialog-retry"]'),
+ ).toBeInTheDocument(),
+ );
+ expect(onComplete).not.toHaveBeenCalled();
+ });
+
+ it('never asks the wallet when the send check refuses, and says nothing was sent and why, with Retry offered', async () => {
+ const reason =
+ 'Withdrawal #8 was not released because the queue would refuse it.';
+ const beforeSend = jest.fn().mockRejectedValue(sendRefusal([reason]));
+ const onTxStatusChange = jest.fn();
+ render(
+ ,
+ );
+
+ await confirm();
+
+ await waitFor(() =>
+ expect(
+ document.querySelector('[data-layout-id="tx-dialog-retry"]'),
+ ).toBeInTheDocument(),
+ );
+ expect(beforeSend).toHaveBeenCalledTimes(1);
+ expect(mockSend).not.toHaveBeenCalled();
+ expect(screen.getByText('Nothing was sent')).toBeInTheDocument();
+ expect(screen.getByText(reason)).toBeInTheDocument();
+ expect(
+ screen.queryByText('Your transaction has failed'),
+ ).not.toBeInTheDocument();
+ // No failed transaction is reported, so closing the dialog does not
+ // announce one.
+ expect(onTxStatusChange).not.toHaveBeenCalledWith(StatusType.error);
+ });
+
+ it('says nothing was sent when the send check rejects without naming a reason', async () => {
+ const beforeSend = jest.fn().mockRejectedValue(new Error('node error'));
+ render(
+ ,
+ );
+
+ await confirm();
+
+ await waitFor(() =>
+ expect(screen.getByText('Nothing was sent')).toBeInTheDocument(),
+ );
+ expect(mockSend).not.toHaveBeenCalled();
+ expect(
+ screen.queryByText('Your transaction has failed'),
+ ).not.toBeInTheDocument();
+ });
+
+ it.each<[string, Transaction['beforeSend']]>([
+ ['a transaction with no send check', undefined],
+ ['a transaction whose send check passed', async step => step],
+ ])(
+ 'says the transaction failed when the wallet does not send %s',
+ async (_case, beforeSend) => {
+ mockSend.mockRejectedValue(new Error('user rejected transaction'));
+ const onTxStatusChange = jest.fn();
+ render(
+ ,
+ );
+
+ await confirm();
+
+ await waitFor(() =>
+ expect(
+ screen.getByText('Your transaction has failed'),
+ ).toBeInTheDocument(),
+ );
+ expect(screen.queryByText('Nothing was sent')).not.toBeInTheDocument();
+ expect(onTxStatusChange).toHaveBeenCalledWith(StatusType.error);
+ },
+ );
+
+ it('runs the send check again on Retry', async () => {
+ const beforeSend = jest
+ .fn()
+ .mockRejectedValueOnce(new Error('refused'))
+ .mockImplementationOnce(async ({ request, config }) => ({
+ request,
+ config: { ...config, gasLimit: '50000' },
+ }));
+ render(
+ ,
+ );
+
+ await confirm();
+ const retry = await waitFor(() => {
+ const found = document.querySelector(
+ '[data-layout-id="tx-dialog-retry"]',
+ );
+ expect(found).toBeInTheDocument();
+ return found!;
+ });
+ fireEvent.click(retry);
+
+ await waitFor(() => expect(mockSend).toHaveBeenCalled());
+ expect(beforeSend).toHaveBeenCalledTimes(2);
+ expect(mockSend).toHaveBeenCalledWith(
+ ['7', '8', '9'],
+ expect.objectContaining({ gasLimit: '50000' }),
+ );
+ });
+
+ it('sends what the dialog prepared for a transaction with no send check', async () => {
+ mockEstimate.mockResolvedValue(BigNumber.from(42_000));
+ render(
+ ,
+ );
+
+ await confirm();
+
+ await waitFor(() => expect(mockSend).toHaveBeenCalled());
+ expect(mockSend).toHaveBeenCalledWith(
+ ['7', '8', '9'],
+ expect.objectContaining({ gasLimit: '42000' }),
+ );
+ });
+
+ describe('a gas limit typed in Advanced settings', () => {
+ // A send check keeps a limit the holder typed and replaces one the dialog
+ // prepared, so the step's config says which it holds.
+ const passThrough = () =>
+ jest.fn(
+ async (step: Parameters['beforeSend']>[0]) =>
+ step,
+ );
+
+ const openSettings = async () =>
+ fireEvent.click(
+ await waitFor(() => {
+ const found = document.querySelector(
+ '[data-layout-id="tx-dialog-settings"]',
+ );
+ expect(found).toBeInTheDocument();
+ return found!;
+ }),
+ );
+
+ const typeGasLimit = async (value: string) => {
+ const [gasLimit] = screen.getAllByRole('textbox');
+ fireEvent.change(gasLimit, { target: { value } });
+ // The input reports a change once typing pauses.
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 600));
+ });
+ };
+
+ it('reaches the send check marked as typed', async () => {
+ const beforeSend = passThrough();
+ render(
+ ,
+ );
+
+ await openSettings();
+ await typeGasLimit('75000');
+ await confirm();
+
+ await waitFor(() => expect(beforeSend).toHaveBeenCalled());
+ expect(beforeSend.mock.calls[0][0].config).toEqual(
+ expect.objectContaining({
+ gasLimit: '75000',
+ gasLimitTypedByUser: true,
+ }),
+ );
+ });
+
+ it('is not marked as typed when the dialog prepared the limit', async () => {
+ const beforeSend = passThrough();
+ render(
+ ,
+ );
+
+ await confirm();
+
+ await waitFor(() => expect(beforeSend).toHaveBeenCalled());
+ expect(
+ beforeSend.mock.calls[0][0].config.gasLimitTypedByUser,
+ ).toBeFalsy();
+ });
+
+ it('is no longer marked as typed after Reset values', async () => {
+ const beforeSend = passThrough();
+ render(
+ ,
+ );
+
+ await openSettings();
+ await typeGasLimit('75000');
+ fireEvent.click(
+ document.querySelector('[data-layout-id="tx-dialog-settings-reset"]')!,
+ );
+ await waitFor(() =>
+ expect(screen.getAllByRole('textbox')[0]).toHaveValue('6000000'),
+ );
+ await confirm();
+
+ await waitFor(() => expect(beforeSend).toHaveBeenCalled());
+ expect(
+ beforeSend.mock.calls[0][0].config.gasLimitTypedByUser,
+ ).toBeFalsy();
+ });
+ });
+
+ describe('the dialog closed while the send check runs', () => {
+ // The check may still be reading the chain after the holder closes the
+ // dialog. A check that then passes must not reach the wallet: nobody is
+ // left to see a request they never asked to close out of.
+ const Harness: React.FC<{
+ transaction: Transaction;
+ register: (setOpen: (value: boolean) => void) => void;
+ }> = ({ transaction, register }) => {
+ const [isOpen, setIsOpen] = React.useState(true);
+ React.useEffect(() => {
+ register(setIsOpen);
+ }, [register]);
+ return (
+
+ );
+ };
+
+ // The dialog's own Overlay unmounts its children the moment it closes, so
+ // this renders through it the way the app does — a harness that only
+ // flips the isOpen prop on a component that stays mounted proves nothing
+ // about a check that finishes after the dialog is gone.
+ const MountedHarness: React.FC<{
+ transaction: Transaction;
+ register: (setOpen: (value: boolean) => void) => void;
+ }> = ({ transaction, register }) => {
+ const [isOpen, setIsOpen] = React.useState(true);
+ React.useEffect(() => {
+ register(setIsOpen);
+ }, [register]);
+ return (
+
+
+
+ );
+ };
+
+ it('does not ask the wallet once a send check passes after the dialog is closed', async () => {
+ let setOpen: (value: boolean) => void = () => {};
+ let resolveCheck: () => void = () => {};
+ const beforeSend = jest.fn(
+ (step: unknown) =>
+ new Promise(resolve => {
+ resolveCheck = () => resolve(step);
+ }),
+ );
+ render(
+ {
+ setOpen = fn;
+ }}
+ />,
+ );
+
+ await confirm();
+ await waitFor(() => expect(beforeSend).toHaveBeenCalledTimes(1));
+
+ act(() => setOpen(false));
+ await act(async () => {
+ resolveCheck();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockSend).not.toHaveBeenCalled();
+ });
+
+ it('still sends a transaction with no send check regardless of isOpen', async () => {
+ mockEstimate.mockResolvedValue(BigNumber.from(42_000));
+ let setOpen: (value: boolean) => void = () => {};
+ render(
+ {
+ setOpen = fn;
+ }}
+ />,
+ );
+
+ act(() => setOpen(false));
+ await confirm();
+
+ await waitFor(() => expect(mockSend).toHaveBeenCalled());
+ });
+ });
+});
diff --git a/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.tsx b/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.tsx
index bb6905baf..fd7407ed2 100644
--- a/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.tsx
+++ b/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionSteps/TransactionSteps.tsx
@@ -1,4 +1,11 @@
-import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
+import React, {
+ FC,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import classNames from 'classnames';
import { BigNumber, ethers } from 'ethers';
@@ -36,8 +43,9 @@ import {
isSignTransactionDataRequest,
isTransactionRequest,
isTypedDataRequest,
+ notSentReasonsOf,
} from '../../helpers';
-import { sendOrSimulateTx } from '../../utils';
+import { resolveGasLimit, sendOrSimulateTx } from '../../utils';
import { TransactionStep } from '../TransactionStep/TransactionStep';
export type TransactionStepsProps = {
@@ -47,6 +55,8 @@ export type TransactionStepsProps = {
gasPrice: string;
onTxStatusChange?: (status: StatusType) => void;
setTxTrigger: (id: string) => void;
+ /** Whether the dialog showing these steps is still open. Defaults to open. */
+ isOpen?: boolean;
};
export const TransactionSteps: FC = ({
@@ -56,15 +66,33 @@ export const TransactionSteps: FC = ({
gasPrice,
onTxStatusChange,
setTxTrigger,
+ isOpen = true,
}) => {
const chainId = useCurrentChain();
const [stepData, setStepData] = useState([]);
const [step, setStep] = useState(-1);
const [error, setError] = useState(false);
+ // Set when the failed step's send check refused it: nothing reached the
+ // wallet, for these reasons.
+ const [notSent, setNotSent] = useState();
const [estimatedGasFee, setEstimatedGasFee] = useState(0);
const { balance: nativeBalance, loading } = useNativeAssetBalance(chainId);
const { account } = useAccount();
+ // Read inside `submit`, so a send check that is still running when the
+ // dialog closes sees the closed state as soon as it finishes, not the open
+ // state it started with. The dialog's Overlay unmounts this component in
+ // the same commit that flips `isOpen` to false, so an unmounted component
+ // never runs this effect again with the closed value; the cleanup covers
+ // that by reading unmounting itself as closed.
+ const isOpenRef = useRef(isOpen);
+ useEffect(() => {
+ isOpenRef.current = isOpen;
+ return () => {
+ isOpenRef.current = false;
+ };
+ }, [isOpen]);
+
const hasEnoughBalance = useMemo(
() => account && !loading && nativeBalance.sub(estimatedGasFee).gt(0),
[account, loading, nativeBalance, estimatedGasFee],
@@ -100,13 +128,17 @@ export const TransactionSteps: FC = ({
args[1] = ethers.constants.MaxUint256;
}
- item.config.gasLimit =
- gasLimit ??
- (await contract.estimateGas[fnName](
- ...[...args, { value: value ?? 0 }],
- )
- .then(gas => gas.toString())
- .catch(() => BigNumber.from(6_000_000).toString()));
+ // A request's own `gasLimit` is a constant sized for the plain
+ // call; the Perimeter's withdrawal delay adds queue-recording cost
+ // on top of it when armed. Re-price that constant against a fresh
+ // estimate rather than trusting it outright — see resolveGasLimit.
+ item.config.gasLimit = await resolveGasLimit(
+ contract,
+ fnName,
+ args,
+ value,
+ gasLimit,
+ );
item.config.gasLimit &&
setEstimatedGasFee(
@@ -210,16 +242,35 @@ export const TransactionSteps: FC = ({
}, [stepData]);
const submit = useCallback(async () => {
+ // Set when a send check refuses: nothing reached the wallet.
+ let notSentReasons: string[] | undefined;
try {
let i = 0;
if (error) {
setError(false);
+ setNotSent(undefined);
i = step;
}
for (; i < transactions.length; i++) {
setStep(i);
- const config = stepData[i].config;
- const { request } = transactions[i];
+ let config = stepData[i].config;
+ let { request } = transactions[i];
+ const { beforeSend } = transactions[i];
+ if (beforeSend) {
+ try {
+ ({ request, config } = await beforeSend({ request, config }));
+ } catch (refusal) {
+ notSentReasons = notSentReasonsOf(refusal);
+ throw refusal;
+ }
+ // The holder may have closed the dialog while the check above was
+ // still reading the chain. Nobody is left to see a wallet prompt,
+ // so a check that passed after that is not acted on.
+ if (!isOpenRef.current) {
+ return;
+ }
+ updateConfig(i, config);
+ }
if (isTransactionRequest(request)) {
const args = [...request.args];
if (request.fnName === APPROVAL_FUNCTION) {
@@ -361,13 +412,18 @@ export const TransactionSteps: FC = ({
setTimeout(() => setTxTrigger(nanoid()), 1000);
} catch (error) {
- onTxStatusChange?.(StatusType.error);
+ // A step its send check refused was never sent, so no failed
+ // transaction is reported for it.
+ if (!notSentReasons) {
+ onTxStatusChange?.(StatusType.error);
+ }
console.error('error:', error);
transactions[0].onChangeStatus?.(StatusType.error);
handleUpdates();
+ setNotSent(notSentReasons);
setError(true);
}
}, [
@@ -375,6 +431,7 @@ export const TransactionSteps: FC = ({
transactions,
step,
stepData,
+ updateConfig,
updateReceipt,
onTxStatusChange,
handleUpdates,
@@ -437,6 +494,7 @@ export const TransactionSteps: FC = ({
receipt={getReceipt(i)}
updateConfig={(config: TransactionConfig) => updateConfig(i, config)}
gasPrice={gasPrice}
+ notSent={i === step ? notSent : undefined}
/>
))}
{!isLoading && transactions.length > step && (
diff --git a/apps/frontend/src/app/3_organisms/TransactionStepDialog/helpers.ts b/apps/frontend/src/app/3_organisms/TransactionStepDialog/helpers.ts
index dbd4491e0..0203731fe 100644
--- a/apps/frontend/src/app/3_organisms/TransactionStepDialog/helpers.ts
+++ b/apps/frontend/src/app/3_organisms/TransactionStepDialog/helpers.ts
@@ -1,4 +1,5 @@
import {
+ SendRefusal,
SignMessageRequest,
SignTransactionDataRequest,
SignTransactionRequest,
@@ -26,3 +27,21 @@ export const isSignTransactionDataRequest = (
request: TransactionRequest,
): request is SignTransactionDataRequest =>
request.type === TransactionType.signTransactionData;
+
+/**
+ * What a send check rejects with when it refuses a transaction: nothing is
+ * sent, and `reasons` are the lines that say why, as the user was told them.
+ */
+export const sendRefusal = (reasons: string[]): SendRefusal =>
+ Object.assign(new Error('The send check refused, so nothing was sent.'), {
+ notSentReasons: reasons,
+ });
+
+/** The reasons a send check's rejection names; none when it names none. */
+export const notSentReasonsOf = (rejection: unknown): string[] => {
+ const reasons = (rejection as Partial | undefined)
+ ?.notSentReasons;
+ return Array.isArray(reasons)
+ ? reasons.filter((reason): reason is string => typeof reason === 'string')
+ : [];
+};
diff --git a/apps/frontend/src/app/3_organisms/TransactionStepDialog/utils.test.ts b/apps/frontend/src/app/3_organisms/TransactionStepDialog/utils.test.ts
new file mode 100644
index 000000000..c9cf9243a
--- /dev/null
+++ b/apps/frontend/src/app/3_organisms/TransactionStepDialog/utils.test.ts
@@ -0,0 +1,106 @@
+import { BigNumber } from 'ethers/lib/ethers';
+import 'jest-canvas-mock';
+
+import { resolveGasLimit } from './utils';
+
+/**
+ * A withdrawal hooked into the Perimeter's withdrawal delay costs more gas
+ * than the same call with the delay off (it enqueues into the delay queue
+ * and updates the receiver index), so a flat constant sized for the
+ * un-hooked call can undershoot once the delay is armed. `resolveGasLimit`
+ * is what `TransactionSteps` (opening a dialog) and `TransactionStep`
+ * ("Reset values") both call to turn a request's configured constant into
+ * the gas limit actually used, re-pricing it against a fresh estimate of
+ * the real call rather than trusting the constant outright.
+ */
+
+// The nested nanoid package under apps/frontend/node_modules ships ESM-only,
+// which Jest's default CJS transform cannot parse; every other suite that
+// reaches utils.ts (via nanoid's use in handleNotification) mocks it the
+// same way rather than fighting the resolution.
+jest.mock('nanoid', () => ({ nanoid: () => 'test-id' }));
+
+const makeContract = (overrides: {
+ estimate?: BigNumber;
+ estimateError?: Error;
+}) => {
+ const estimateFn = jest.fn(async () => {
+ if (overrides.estimateError) {
+ throw overrides.estimateError;
+ }
+ return overrides.estimate;
+ });
+
+ return {
+ estimateGas: { withdraw: estimateFn },
+ } as any;
+};
+
+describe('resolveGasLimit', () => {
+ const floor = BigNumber.from(450_000);
+
+ it('raises the floor when a fresh estimate plus margin exceeds it', async () => {
+ const contract = makeContract({ estimate: BigNumber.from(500_000) });
+
+ const result = await resolveGasLimit(
+ contract,
+ 'withdraw',
+ [],
+ undefined,
+ floor,
+ );
+
+ // 500_000 * 1.30 = 650_000, above the 450_000 floor.
+ expect(result).toBe('650000');
+ });
+
+ it('never goes below the floor when the estimate is cheap', async () => {
+ const contract = makeContract({ estimate: BigNumber.from(100_000) });
+
+ const result = await resolveGasLimit(
+ contract,
+ 'withdraw',
+ [],
+ undefined,
+ floor,
+ );
+
+ // 100_000 * 1.30 = 130_000, below the floor — the floor wins, matching
+ // the delay-off case: the estimate is cheaper, so nothing changes.
+ expect(result).toBe(floor.toString());
+ });
+
+ it('falls back to the floor outright when estimation fails', async () => {
+ const contract = makeContract({
+ estimateError: new Error('execution reverted'),
+ });
+
+ const result = await resolveGasLimit(
+ contract,
+ 'withdraw',
+ [],
+ undefined,
+ floor,
+ );
+
+ expect(result).toBe(floor.toString());
+ });
+
+ it('with no floor, returns the bare estimate unmargined (the pre-existing, unconfigured-limit case)', async () => {
+ const contract = makeContract({ estimate: BigNumber.from(321_000) });
+
+ const result = await resolveGasLimit(contract, 'withdraw', [], undefined);
+
+ expect(result).toBe('321000');
+ });
+
+ it('with no floor, falls back to the flat 6,000,000 default when estimation fails', async () => {
+ const contract = makeContract({
+ estimateError: new Error('execution reverted'),
+ });
+
+ const result = await resolveGasLimit(contract, 'withdraw', [], undefined);
+
+ expect(result).toBe('6000000');
+ });
+});
diff --git a/apps/frontend/src/app/3_organisms/TransactionStepDialog/utils.ts b/apps/frontend/src/app/3_organisms/TransactionStepDialog/utils.ts
index a1b35f473..10a8e8c9f 100644
--- a/apps/frontend/src/app/3_organisms/TransactionStepDialog/utils.ts
+++ b/apps/frontend/src/app/3_organisms/TransactionStepDialog/utils.ts
@@ -3,7 +3,7 @@ import {
TransactionReceipt,
} from '@ethersproject/providers';
-import { BigNumber } from 'ethers/lib/ethers';
+import { BigNumber, BigNumberish, Contract } from 'ethers/lib/ethers';
import { parseUnits } from 'ethers/lib/utils';
import { t } from 'i18next';
import { nanoid } from 'nanoid';
@@ -50,6 +50,59 @@ export const renderNotification = (txStatus: StatusType) => {
return handleNotification(type, title, subtitle);
};
+/**
+ * How far above a fresh gas estimate a configured-gas-limit transaction's
+ * limit is set, in percent. A withdrawal hooked into the Perimeter's
+ * withdrawal delay does more work than a plain transfer (it enqueues into
+ * the delay queue and updates the receiver index), so a constant sized for
+ * the un-hooked call can undershoot; the margin also covers an estimate
+ * reading slightly cheaper state than the block the transaction actually
+ * lands in.
+ */
+const GAS_ESTIMATE_MARGIN_PERCENT = 30;
+
+/** Gas estimation failed and the request named no constant to fall back to. */
+const NO_ESTIMATE_FALLBACK_GAS_LIMIT = BigNumber.from(6_000_000);
+
+/**
+ * The gas limit to open a transaction step with, or to re-derive for its
+ * "Reset values" control.
+ *
+ * With no `floor`, this is a plain estimate of the call — the flat 6,000,000
+ * fallback on failure existed before this function did and is kept as-is.
+ *
+ * With a `floor` (a request that configured a flat constant), the constant
+ * is no longer used outright: it is re-priced against a fresh estimate of
+ * this exact call, raised by `GAS_ESTIMATE_MARGIN_PERCENT`, and never sent
+ * lower than `floor` itself. Falls back to `floor` outright when the
+ * estimate fails — a call that cannot be estimated is either going to
+ * revert regardless of the limit chosen, or the node does not support
+ * estimation, and either way `floor` is the best number left to send.
+ */
+export const resolveGasLimit = async (
+ contract: Contract,
+ fnName: string,
+ args: unknown[],
+ value: BigNumberish | undefined,
+ floor?: BigNumberish,
+): Promise => {
+ try {
+ const estimate = await contract.estimateGas[fnName](...args, {
+ value: value ?? 0,
+ });
+ if (floor === undefined) {
+ return estimate.toString();
+ }
+ const floorBn = BigNumber.from(floor);
+ const withMargin = estimate.mul(100 + GAS_ESTIMATE_MARGIN_PERCENT).div(100);
+ return withMargin.gt(floorBn) ? withMargin.toString() : floorBn.toString();
+ } catch {
+ return floor !== undefined
+ ? BigNumber.from(floor).toString()
+ : NO_ESTIMATE_FALLBACK_GAS_LIMIT.toString();
+ }
+};
+
export const sendOrSimulateTx = async (
request: SignTransactionRequest,
args: string[],
diff --git a/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.test.tsx b/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.test.tsx
index 91d5717ea..7945dc2e2 100644
--- a/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.test.tsx
+++ b/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.test.tsx
@@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import 'jest-canvas-mock';
+import { MemoryRouter } from 'react-router-dom';
import { Decimal } from '@sovryn/utils';
@@ -15,6 +16,7 @@ const mockQuote = {
feeAmount: Decimal.from('0.002'),
netAmount: Decimal.from('0.398'),
loading: false,
+ unknown: false,
};
jest.mock('nanoid', () => {
@@ -33,6 +35,12 @@ jest.mock('../../../hooks/exitFee/useZeroExitFee', () => ({
useZeroExitFee: () => mockQuote,
}));
+let mockDelay: { delaySeconds: number; loading: boolean; unknown: boolean };
+
+jest.mock('../../../hooks/exitDelay/useZeroExitDelayQuote', () => ({
+ useZeroExitDelayQuote: () => mockDelay,
+}));
+
jest.mock('./hooks/useZeroData', () => ({
useZeroData: () => ({ isRecoveryMode: false }),
}));
@@ -53,15 +61,34 @@ describe('CloseCreditLine perimeter fee', () => {
await i18n;
});
- it('shows the NET collateral to receive with a fee tooltip', () => {
- const { container } = render(
- ,
+ beforeEach(() => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: false };
+ Object.assign(mockQuote, {
+ active: true,
+ rateBps: 50,
+ feeAmount: Decimal.from('0.002'),
+ netAmount: Decimal.from('0.398'),
+ loading: false,
+ unknown: false,
+ });
+ });
+
+ // The hold row links to the Perimeter page, so the form needs a router the
+ // way it has one in the app.
+ const renderForm = () =>
+ render(
+
+
+ ,
);
+
+ it('shows the NET collateral to receive with a fee tooltip', () => {
+ const { container } = renderForm();
expect(screen.getByText('Collateral to receive')).toBeInTheDocument();
expect(screen.getByText(/0\.398/)).toBeInTheDocument();
expect(screen.queryByText(/^0\.4 /)).not.toBeInTheDocument();
@@ -78,19 +105,101 @@ describe('CloseCreditLine perimeter fee', () => {
});
it('shows the gross with no helper icon when the fee is inactive', () => {
- Object.assign(mockQuote, { active: false, rateBps: 0 });
- const { container } = render(
- ,
- );
+ Object.assign(mockQuote, { active: false, rateBps: 0, unknown: false });
+ const { container } = renderForm();
expect(screen.queryByText(/Perimeter fee/)).not.toBeInTheDocument();
expect(screen.getByText(/0\.4/)).toBeInTheDocument();
expect(
container.querySelector('[data-layout-id="exit-fee-helper"]'),
).not.toBeInTheDocument();
+ expect(
+ container.querySelector('[data-test-id="exit-fee-unknown-notice"]'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows the gross with a note when the fee could not be checked, not as a settled amount', () => {
+ Object.assign(mockQuote, { active: false, rateBps: 0, unknown: true });
+ const { container } = renderForm();
+ expect(screen.queryByText(/^Perimeter fee/)).not.toBeInTheDocument();
+ expect(screen.getByText(/0\.4/)).toBeInTheDocument();
+ expect(
+ container.querySelector('[data-layout-id="exit-fee-helper"]'),
+ ).not.toBeInTheDocument();
+ const notice = container.querySelector(
+ '[data-test-id="exit-fee-unknown-notice"]',
+ );
+ expect(notice).toBeInTheDocument();
+ expect(notice?.textContent).toMatch(/Perimeter fee/);
+ });
+
+ it('tells the borrower the collateral will be held, and for how long', () => {
+ // A close always returns collateral, so the hold applies unconditionally.
+ mockDelay = { delaySeconds: 172800, loading: false, unknown: false };
+
+ renderForm();
+
+ expect(screen.getByText('Withdrawal delay')).toBeInTheDocument();
+ expect(screen.getByText('2 days')).toBeInTheDocument();
+ });
+
+ it('admits it could not check whether the collateral will be held', () => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: true };
+
+ renderForm();
+
+ expect(screen.getByText('Could not be checked')).toBeInTheDocument();
+ });
+
+ describe('Confirm and the delay quote', () => {
+ const confirm = (container: HTMLElement) =>
+ container.querySelector('[data-layout-id="close-credit-line-confirm"]');
+
+ it('waits for the delay quote, and says it is checking', () => {
+ // The close form is prefilled: without this, Confirm is live before a
+ // single delay read has returned.
+ mockDelay = { delaySeconds: 0, loading: true, unknown: false };
+
+ const { container } = renderForm();
+
+ expect(screen.getByText(/^Checking/)).toBeInTheDocument();
+ expect(confirm(container)).toBeDisabled();
+ });
+
+ it('offers Confirm once a quote arrived', () => {
+ mockDelay = { delaySeconds: 172800, loading: false, unknown: false };
+
+ const { container } = renderForm();
+
+ expect(confirm(container)).toBeEnabled();
+ });
+
+ it('offers Confirm when the quote could not be read', () => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: true };
+
+ const { container } = renderForm();
+
+ expect(confirm(container)).toBeEnabled();
+ });
+ });
+
+ describe('Confirm and the fee quote', () => {
+ const confirm = (container: HTMLElement) =>
+ container.querySelector('[data-layout-id="close-credit-line-confirm"]');
+
+ it('waits for the fee quote even once the delay has settled', () => {
+ mockQuote.loading = true;
+
+ const { container } = renderForm();
+
+ expect(confirm(container)).toBeDisabled();
+ });
+
+ it('offers Confirm once the fee quote has settled', () => {
+ mockQuote.loading = false;
+
+ const { container } = renderForm();
+
+ expect(confirm(container)).toBeEnabled();
+ });
});
});
diff --git a/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.tsx b/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.tsx
index c3d6492f3..4296ed5b6 100644
--- a/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.tsx
+++ b/apps/frontend/src/app/3_organisms/ZeroLocForm/CloseCreditLine.tsx
@@ -20,9 +20,11 @@ import { Decimal } from '@sovryn/utils';
import { AmountRenderer } from '../../2_molecules/AmountRenderer/AmountRenderer';
import { AssetRenderer } from '../../2_molecules/AssetRenderer/AssetRenderer';
+import { ExitDelayRow } from '../../2_molecules/ExitDelayRow/ExitDelayRow';
import { ExitFeeTooltipContent } from '../../2_molecules/ExitFeeRow/ExitFeeRow';
import { BITCOIN, BTC_RENDER_PRECISION } from '../../../constants/currencies';
import { getTokenDisplayName } from '../../../constants/tokens';
+import { useZeroExitDelayQuote } from '../../../hooks/exitDelay/useZeroExitDelayQuote';
import { useZeroExitFee } from '../../../hooks/exitFee/useZeroExitFee';
import { useAssetBalance } from '../../../hooks/useAssetBalance';
import { useMaintenance } from '../../../hooks/useMaintenance';
@@ -55,12 +57,22 @@ export const CloseCreditLine: FC = ({
const { balance: availableBalance } = useAssetBalance(creditToken);
const exitFee = useZeroExitFee(collateralValue);
+ const {
+ delaySeconds,
+ unknown: exitDelayUnknown,
+ loading: exitDelayLoading,
+ } = useZeroExitDelayQuote();
const exitFeeDisplay = useMemo(
() => getExitFeeDisplay(exitFee, exitFee.feeAmount),
[exitFee],
);
const showExitFee = exitFeeDisplay === 'charged';
+ // The row itself stays hidden for an unread quote — the chain fails open,
+ // so it is not a stated fee — but the collateral figure below states the
+ // whole collateral as what the borrower will get, which needs its own
+ // qualifier when the quote never arrived.
+ const exitFeeUnknown = !showExitFee && exitFee.unknown;
const collateralToReceive = useMemo(
() => (showExitFee ? exitFee.netAmount : collateralValue),
@@ -97,9 +109,22 @@ export const CloseCreditLine: FC = ({
[closeLocked, dllrLocked, creditToken],
);
+ // A close always returns collateral, so it always waits for the delay and
+ // fee quotes.
const submitButtonDisabled = useMemo(
- () => hasError || isInMaintenance || isRecoveryMode,
- [isInMaintenance, hasError, isRecoveryMode],
+ () =>
+ hasError ||
+ isInMaintenance ||
+ isRecoveryMode ||
+ exitDelayLoading ||
+ exitFee.loading,
+ [
+ isInMaintenance,
+ hasError,
+ isRecoveryMode,
+ exitDelayLoading,
+ exitFee.loading,
+ ],
);
const tokenOptions = useMemo(
@@ -187,6 +212,19 @@ export const CloseCreditLine: FC = ({
)
}
/>
+ {exitFeeUnknown && (
+
+ {t(translations.exitFee.unknownNotice)}
+
+ )}
+
{hasError && !isRecoveryMode && (
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
index b1d2efdff..4f5b8df41 100644
--- a/apps/frontend/src/app/3_organisms/ZeroLocForm/components/FormContent.test.tsx
+++ b/apps/frontend/src/app/3_organisms/ZeroLocForm/components/FormContent.test.tsx
@@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import 'jest-canvas-mock';
+import { MemoryRouter } from 'react-router-dom';
import { Decimal } from '@sovryn/utils';
@@ -25,18 +26,22 @@ jest.mock('../../../../contexts/NotificationContext', () => {
// 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,
- }),
- };
-});
+let mockDelay: { delaySeconds: number; loading: boolean; unknown: boolean };
+let mockFee: {
+ active: boolean;
+ rateBps: number;
+ feeAmount: Decimal;
+ netAmount: Decimal;
+ loading: boolean;
+};
+
+jest.mock('../../../../hooks/exitDelay/useZeroExitDelayQuote', () => ({
+ useZeroExitDelayQuote: () => mockDelay,
+}));
+
+jest.mock('../../../../hooks/exitFee/useZeroExitFee', () => ({
+ useZeroExitFee: () => mockFee,
+}));
jest.mock('../../../../hooks/useMaintenance', () => ({
useMaintenance: () => ({
@@ -94,15 +99,142 @@ describe('FormContent perimeter fee', () => {
await i18n;
});
+ beforeEach(() => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: false };
+ mockFee = {
+ active: true,
+ rateBps: 50,
+ feeAmount: Decimal.ZERO,
+ netAmount: Decimal.ZERO,
+ loading: false,
+ };
+ });
+
+ // The hold row links to the Perimeter page, so the form needs a router the
+ // way it has one in the app.
+ const renderForm = (overrides: object = {}) =>
+ render(
+
+
+ ,
+ );
+
it('shows the "You will receive" row when withdrawing collateral', () => {
- render( );
+ renderForm();
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( );
+ renderForm({ collateralType: AmountType.Add });
expect(screen.queryByText('You will receive')).not.toBeInTheDocument();
});
+
+ it('tells the borrower the collateral will be held, and for how long', () => {
+ mockDelay = { delaySeconds: 172800, loading: false, unknown: false };
+
+ renderForm();
+
+ expect(screen.getByText('Withdrawal delay')).toBeInTheDocument();
+ expect(screen.getByText('2 days')).toBeInTheDocument();
+ });
+
+ it('shows no hold for an adjust that removes no collateral', () => {
+ // A borrow or an add-collateral adjust takes nothing out, so the perimeter
+ // holds nothing back however long a hold it would impose on a withdrawal.
+ mockDelay = { delaySeconds: 172800, loading: false, unknown: false };
+
+ renderForm({ collateralType: AmountType.Add });
+
+ expect(screen.queryByText('Withdrawal delay')).not.toBeInTheDocument();
+ });
+
+ it('shows no hold for a debt-only adjust', () => {
+ mockDelay = { delaySeconds: 172800, loading: false, unknown: false };
+
+ // A collateral field typed into and cleared leaves a truthy '0.0'.
+ renderForm({ collateralAmount: '0.0' });
+
+ expect(screen.queryByText('Withdrawal delay')).not.toBeInTheDocument();
+ });
+
+ it('admits it could not check whether the collateral will be held', () => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: true };
+
+ renderForm();
+
+ expect(screen.getByText('Could not be checked')).toBeInTheDocument();
+ });
+
+ describe('Confirm and the delay quote', () => {
+ const confirm = (container: HTMLElement) =>
+ container.querySelector(
+ '[data-layout-id="adjust-credit-line-confirm-button"]',
+ );
+
+ it('waits for the delay quote on a collateral withdrawal, and says it is checking', () => {
+ mockDelay = { delaySeconds: 0, loading: true, unknown: false };
+
+ const { container } = renderForm();
+
+ expect(screen.getByText(/^Checking/)).toBeInTheDocument();
+ expect(confirm(container)).toBeDisabled();
+ });
+
+ it('offers Confirm once a quote arrived', () => {
+ mockDelay = { delaySeconds: 172800, loading: false, unknown: false };
+
+ const { container } = renderForm();
+
+ expect(confirm(container)).toBeEnabled();
+ });
+
+ it('offers Confirm when the quote could not be read', () => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: true };
+
+ const { container } = renderForm();
+
+ expect(confirm(container)).toBeEnabled();
+ });
+
+ it('does not hold an adjust that removes no collateral back for the quote', () => {
+ mockDelay = { delaySeconds: 0, loading: true, unknown: false };
+
+ const { container } = renderForm({ collateralType: AmountType.Add });
+
+ expect(confirm(container)).toBeEnabled();
+ });
+ });
+
+ describe('Confirm and the fee quote', () => {
+ const confirm = (container: HTMLElement) =>
+ container.querySelector(
+ '[data-layout-id="adjust-credit-line-confirm-button"]',
+ );
+
+ it('waits for the fee quote on a collateral withdrawal even once the delay has settled', () => {
+ mockFee.loading = true;
+
+ const { container } = renderForm();
+
+ expect(confirm(container)).toBeDisabled();
+ });
+
+ it('offers Confirm once the fee quote has settled', () => {
+ mockFee.loading = false;
+
+ const { container } = renderForm();
+
+ expect(confirm(container)).toBeEnabled();
+ });
+
+ it('does not hold an adjust that removes no collateral back for the fee quote', () => {
+ mockFee.loading = true;
+
+ const { container } = renderForm({ collateralType: AmountType.Add });
+
+ expect(confirm(container)).toBeEnabled();
+ });
+ });
});
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 5c44afb8b..0d35dfd98 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 { ExitDelayRow } from '../../../2_molecules/ExitDelayRow/ExitDelayRow';
import { ExitFeeRow } from '../../../2_molecules/ExitFeeRow/ExitFeeRow';
import { BORROW_ASSETS } from '../../../5_pages/ZeroPage/constants';
import { useLiquityBaseParams } from '../../../5_pages/ZeroPage/hooks/useLiquityBaseParams';
@@ -36,6 +37,7 @@ import {
} from '../../../../constants/currencies';
import { COLLATERAL_RATIO_THRESHOLDS } from '../../../../constants/general';
import { WIKI_LINKS } from '../../../../constants/links';
+import { useZeroExitDelayQuote } from '../../../../hooks/exitDelay/useZeroExitDelayQuote';
import { useZeroExitFee } from '../../../../hooks/exitFee/useZeroExitFee';
import { useMaintenance } from '../../../../hooks/useMaintenance';
import { translations } from '../../../../locales/i18n';
@@ -158,6 +160,11 @@ export const FormContent: FC = props => {
unknown: exitFeeUnknown,
loading: exitFeeLoading,
} = useZeroExitFee();
+ const {
+ delaySeconds,
+ unknown: exitDelayUnknown,
+ loading: exitDelayLoading,
+ } = useZeroExitDelayQuote();
const exitFeeGross = useMemo(
() =>
@@ -210,7 +217,9 @@ export const FormContent: FC = props => {
isInMaintenance ||
(isBorrowDisabled && Number(props.debtAmount) > 0) ||
isInvalidOriginationFee ||
- (!props.hasTrove && !hasDisclaimerBeenChecked)
+ (!props.hasTrove && !hasDisclaimerBeenChecked) ||
+ // A collateral withdrawal waits for its delay and fee quotes.
+ (exitFeeGross.gt(0) && (exitDelayLoading || exitFeeLoading))
);
}, [
props.errors,
@@ -224,6 +233,9 @@ export const FormContent: FC = props => {
isBorrowDisabled,
isInvalidOriginationFee,
hasDisclaimerBeenChecked,
+ exitFeeGross,
+ exitDelayLoading,
+ exitFeeLoading,
]);
const handleDebtTypeChange = useCallback(
@@ -515,6 +527,15 @@ export const FormContent: FC = props => {
assetSymbol={COMMON_SYMBOLS.BTC}
precision={BTC_RENDER_PRECISION}
/>
+ {/* Only a collateral withdrawal is held; a borrow or add-collateral
+ adjust removes nothing, so the hold notice must not show for it. */}
+ {exitFeeGross.gt(0) && (
+
+ )}
>
) : (
<>
diff --git a/apps/frontend/src/app/3_organisms/ZeroLocForm/utils.test.ts b/apps/frontend/src/app/3_organisms/ZeroLocForm/utils.test.ts
new file mode 100644
index 000000000..5614d6c92
--- /dev/null
+++ b/apps/frontend/src/app/3_organisms/ZeroLocForm/utils.test.ts
@@ -0,0 +1,30 @@
+import 'jest-canvas-mock';
+
+import { isCollateralWithdrawal } from './utils';
+
+/**
+ * `withdrawCollateral` reaches the submit handler as the raw input string. The
+ * post-signature hold notice is gated on it, and a string test disagreed with
+ * the form's own row: a repay that moved no collateral announced that the
+ * holder's funds had gone to the Perimeter withdraw queue.
+ */
+describe('isCollateralWithdrawal', () => {
+ it('is true only for an amount that actually leaves', () => {
+ expect(isCollateralWithdrawal('0.5')).toBe(true);
+ expect(isCollateralWithdrawal('0.00000001')).toBe(true);
+ });
+
+ it('is false for a field that was typed into and cleared', () => {
+ // Truthy strings that are not '0', reachable by typing a digit and
+ // deleting it.
+ expect(isCollateralWithdrawal('0.0')).toBe(false);
+ expect(isCollateralWithdrawal('0.00')).toBe(false);
+ expect(isCollateralWithdrawal('0.')).toBe(false);
+ });
+
+ it('is false for an adjust that carries no collateral leg at all', () => {
+ expect(isCollateralWithdrawal('0')).toBe(false);
+ expect(isCollateralWithdrawal('')).toBe(false);
+ expect(isCollateralWithdrawal(undefined)).toBe(false);
+ });
+});
diff --git a/apps/frontend/src/app/3_organisms/ZeroLocForm/utils.ts b/apps/frontend/src/app/3_organisms/ZeroLocForm/utils.ts
index 5cbff57c4..e1fa6482c 100644
--- a/apps/frontend/src/app/3_organisms/ZeroLocForm/utils.ts
+++ b/apps/frontend/src/app/3_organisms/ZeroLocForm/utils.ts
@@ -12,6 +12,20 @@ import {
} from './constants';
import { AmountType } from './types';
+/**
+ * Whether an adjust actually removes collateral.
+ *
+ * `withdrawCollateral` is the raw input string, assigned verbatim from the
+ * form, so a field that was typed into and cleared leaves '0.0' or '0.00' —
+ * truthy, and not equal to '0'. The form's own hold row is gated on the amount
+ * being greater than zero, so a string comparison made the row and the
+ * post-signature notice disagree: a repay that moved no collateral told the
+ * holder their funds had gone to the Perimeter withdraw queue.
+ */
+export const isCollateralWithdrawal = (
+ withdrawCollateral: string | undefined,
+): boolean => decimalic(withdrawCollateral).gt(0);
+
export const normalizeAmountByType = (
amount: Decimal,
amountType: AmountType,
diff --git a/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/AdjustLoanForm.test.tsx b/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/AdjustLoanForm.test.tsx
new file mode 100644
index 000000000..bcdcfe9c2
--- /dev/null
+++ b/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/AdjustLoanForm.test.tsx
@@ -0,0 +1,311 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import React from 'react';
+
+import 'jest-canvas-mock';
+import { MemoryRouter } from 'react-router-dom';
+
+import { Decimal } from '@sovryn/utils';
+
+import { i18n } from '../../../../../locales/i18n';
+
+// The form's tab labels are translated at module load, so the module has to be
+// required only once i18n has resolved — in the app it always is.
+let AdjustLoanForm: typeof import('./AdjustLoanForm').AdjustLoanForm;
+
+/**
+ * On this form the hold row is gated on a computed gross rather than on a
+ * tab: it must appear for a collateral withdrawal and stay away from a
+ * borrow or an add-collateral adjust, which take nothing out of the
+ * protocol.
+ */
+
+let mockDelay: { delaySeconds: number; loading: boolean; unknown: boolean };
+
+// The real submit hooks return the submit function itself, so these do too.
+const mockHandleRepay = jest.fn();
+const mockHandleBorrow = jest.fn();
+const mockHandleWithdrawCollateral = jest.fn();
+const mockHandleDepositCollateral = jest.fn();
+
+jest.mock('nanoid', () => ({ nanoid: () => '1234' }));
+
+jest.mock('../../../../../contexts/NotificationContext', () => ({
+ useNotificationContext: () => ({ addNotification: jest.fn() }),
+}));
+
+jest.mock('../../../../../hooks/exitDelay/useExitDelayQuote', () => ({
+ useExitDelayQuote: () => mockDelay,
+}));
+
+let mockFee: {
+ active: boolean;
+ rateBps: number;
+ unknown: boolean;
+ loading: boolean;
+};
+
+jest.mock('../../../../../hooks/exitFee/useExitFeeRate', () => ({
+ useExitFeeRate: () => mockFee,
+}));
+
+jest.mock('../../../../../hooks/useLoadContract', () => ({
+ useLoadContract: () => ({
+ address: '0x0000000000000000000000000000000000000001',
+ }),
+}));
+
+jest.mock('../../../../../hooks/useQueryRate', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return { useQueryRate: () => [ActualDecimal.from(67000)] };
+});
+
+jest.mock('../../../../../hooks/useMaxAssetBalance', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return { useMaxAssetBalance: () => ({ balance: ActualDecimal.from(1000) }) };
+});
+
+jest.mock('../../hooks/useGetBorrowingAPR', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return { useGetBorrowingAPR: () => ({ borrowApr: ActualDecimal.from(5) }) };
+});
+
+jest.mock('../../hooks/useGetMaintenanceStates', () => ({
+ useGetMaintenanceStates: () => ({
+ checkMaintenance: () => false,
+ borrowLocked: false,
+ repayLocked: false,
+ closeLocked: false,
+ addCollateralLocked: false,
+ withdrawCollateralLocked: false,
+ }),
+}));
+
+jest.mock('../../hooks/useGetOriginationFee', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return { useGetOriginationFee: () => ActualDecimal.from(0.01) };
+});
+
+jest.mock('../../hooks/useGetMinCollateralRatio', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return { useGetMinCollateralRatio: () => ActualDecimal.from(1.5) };
+});
+
+jest.mock('../NewLoanForm/hooks/useBorrow', () => ({
+ useBorrow: () => mockHandleBorrow,
+}));
+
+jest.mock('../NewLoanForm/hooks/useGetMaximumCollateralAmount', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return {
+ useGetMaximumCollateralAmount: () => ({
+ maximumCollateralAmount: ActualDecimal.from(1),
+ loading: false,
+ }),
+ };
+});
+
+jest.mock('./hooks/useCloseWithDepositIsTinyPosition', () => ({
+ useCloseWithDepositIsTinyPosition: () => jest.fn(),
+}));
+
+jest.mock('./hooks/useDepositCollateral', () => ({
+ useDepositCollateral: () => mockHandleDepositCollateral,
+}));
+
+jest.mock('./hooks/useDrawdown', () => ({
+ useDrawdown: () => ({ handleSubmit: jest.fn() }),
+}));
+
+jest.mock('./hooks/useGetInterestRefund', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return { useGetInterestRefund: () => ActualDecimal.ZERO };
+});
+
+jest.mock('./hooks/useGetMaxCollateralWithdrawal', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return { useGetMaxCollateralWithdrawal: () => ActualDecimal.from(0.2) };
+});
+
+jest.mock('./hooks/useGetMaxRepayAmount', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return {
+ useGetMaxRepayAmount: () => ({
+ maximumRepayAmount: ActualDecimal.from(3000),
+ maximumAvailableRepayAmount: ActualDecimal.from(3000),
+ }),
+ };
+});
+
+jest.mock('./hooks/useGetMaximumBorrowAmount', () => {
+ const { Decimal: ActualDecimal } = jest.requireActual('@sovryn/utils');
+ return { useGetMaximumBorrowAmount: () => ActualDecimal.from(1000) };
+});
+
+jest.mock('./hooks/useRepayLoan', () => ({
+ useRepayLoan: () => mockHandleRepay,
+}));
+
+jest.mock('./hooks/useWithdrawCollateral', () => ({
+ useWithdrawCollateral: () => mockHandleWithdrawCollateral,
+}));
+
+const loan = {
+ id: '0xloan',
+ debt: 3000,
+ debtAsset: 'zusd',
+ collateral: 0.4,
+ collateralAsset: 'btc',
+ collateralRatio: 200,
+ liquidationPrice: 40000,
+ apr: '5',
+ rolloverDate: 1_800_000_000,
+ interestOwedPerDay: 0.1,
+ startMargin: Decimal.from(200),
+ currentMargin: Decimal.from(180),
+ maintenanceMargin: Decimal.from(15),
+} as any;
+
+const HOLD = { delaySeconds: 172800, loading: false, unknown: false };
+
+const renderForm = () =>
+ render(
+
+
+ ,
+ );
+
+/** Enter an amount into the collateral input, the way a borrower would. */
+const enterCollateral = (amount: string) => {
+ const inputs = screen.getAllByPlaceholderText('0');
+ const collateralInput = inputs[inputs.length - 1];
+ fireEvent.change(collateralInput, { target: { value: amount } });
+ fireEvent.blur(collateralInput);
+};
+
+describe('AdjustLoanForm perimeter hold', () => {
+ beforeAll(async () => {
+ await i18n;
+ AdjustLoanForm = require('./AdjustLoanForm').AdjustLoanForm;
+ });
+
+ beforeEach(() => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: false };
+ mockFee = { active: true, rateBps: 10, unknown: false, loading: false };
+ });
+
+ it('shows no hold for an adjust that takes nothing out', () => {
+ // The default tabs are Borrow and Add collateral: nothing leaves the
+ // protocol, so nothing can be held however long the hold would be.
+ mockDelay = HOLD;
+
+ renderForm();
+
+ expect(screen.queryByText('Withdrawal delay')).not.toBeInTheDocument();
+ });
+
+ it('tells the borrower a collateral withdrawal will be held', () => {
+ mockDelay = HOLD;
+
+ renderForm();
+ fireEvent.click(screen.getByText('Withdraw collateral'));
+ enterCollateral('0.1');
+
+ expect(screen.getByText('Withdrawal delay')).toBeInTheDocument();
+ expect(screen.getByText('2 days')).toBeInTheDocument();
+ });
+
+ it('admits it could not check whether the withdrawal will be held', () => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: true };
+
+ renderForm();
+ fireEvent.click(screen.getByText('Withdraw collateral'));
+ enterCollateral('0.1');
+
+ expect(screen.getByText('Could not be checked')).toBeInTheDocument();
+ });
+
+ it('shows no hold when a quote arrived saying nothing is held', () => {
+ renderForm();
+ fireEvent.click(screen.getByText('Withdraw collateral'));
+ enterCollateral('0.1');
+
+ expect(screen.queryByText('Withdrawal delay')).not.toBeInTheDocument();
+ expect(screen.queryByText('Could not be checked')).not.toBeInTheDocument();
+ });
+
+ describe('Confirm and the delay quote', () => {
+ const confirm = () =>
+ document.querySelector(
+ '[data-layout-id="adjust-loan-confirm-button"]',
+ ) as HTMLButtonElement;
+
+ const withdrawCollateral = () => {
+ renderForm();
+ fireEvent.click(screen.getByText('Withdraw collateral'));
+ enterCollateral('0.1');
+ };
+
+ it('waits for the delay quote on a collateral withdrawal, and says it is checking', () => {
+ mockDelay = { delaySeconds: 0, loading: true, unknown: false };
+
+ withdrawCollateral();
+
+ expect(screen.getByText(/^Checking/)).toBeInTheDocument();
+ expect(confirm()).toBeDisabled();
+ });
+
+ it('offers Confirm once a quote arrived, and hands the post-signature notice to the withdrawal', () => {
+ mockDelay = HOLD;
+
+ withdrawCollateral();
+ expect(confirm()).toBeEnabled();
+ fireEvent.click(confirm());
+
+ expect(mockHandleWithdrawCollateral).toHaveBeenCalledWith(
+ '0.1',
+ loan.id,
+ expect.any(Function),
+ );
+ });
+
+ it('offers Confirm when the quote could not be read', () => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: true };
+
+ withdrawCollateral();
+
+ expect(confirm()).toBeEnabled();
+ });
+ });
+
+ describe('Confirm and the fee quote', () => {
+ const confirm = () =>
+ document.querySelector(
+ '[data-layout-id="adjust-loan-confirm-button"]',
+ ) as HTMLButtonElement;
+
+ const withdrawCollateral = () => {
+ renderForm();
+ fireEvent.click(screen.getByText('Withdraw collateral'));
+ enterCollateral('0.1');
+ };
+
+ it('waits for the fee quote even once the delay has settled', () => {
+ mockDelay = HOLD;
+ mockFee.loading = true;
+
+ withdrawCollateral();
+
+ expect(confirm()).toBeDisabled();
+ });
+
+ it('offers Confirm once the fee quote has settled', () => {
+ mockDelay = HOLD;
+ mockFee.loading = false;
+
+ withdrawCollateral();
+
+ expect(confirm()).toBeEnabled();
+ });
+ });
+});
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 605751116..7dad34b7f 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
@@ -21,6 +21,7 @@ import { RSK_CHAIN_ID } from '../../../../../config/chains';
import { AmountRenderer } from '../../../../2_molecules/AmountRenderer/AmountRenderer';
import { AssetRenderer } from '../../../../2_molecules/AssetRenderer/AssetRenderer';
+import { ExitDelayRow } from '../../../../2_molecules/ExitDelayRow/ExitDelayRow';
import { ExitFeeRow } from '../../../../2_molecules/ExitFeeRow/ExitFeeRow';
import { LabelWithTabsAndMaxButton } from '../../../../2_molecules/LabelWithTabsAndMaxButton/LabelWithTabsAndMaxButton';
import { convertLoanTokenToSupportedAssets } from '../../../../5_pages/BorrowPage/components/OpenLoansTable/OpenLoans.utils';
@@ -32,6 +33,8 @@ import {
MINIMUM_COLLATERAL_RATIO_LENDING_POOLS_SOV,
} from '../../../../../constants/lending';
import { getTokenDisplayName } from '../../../../../constants/tokens';
+import { useExitDelayQuote } from '../../../../../hooks/exitDelay/useExitDelayQuote';
+import { usePerimeterHoldToast } from '../../../../../hooks/exitDelay/usePerimeterHoldToast';
import { useExitFeeRate } from '../../../../../hooks/exitFee/useExitFeeRate';
import { useDecimalAmountInput } from '../../../../../hooks/useDecimalAmountInput';
import { useLoadContract } from '../../../../../hooks/useLoadContract';
@@ -39,6 +42,7 @@ import { useMaxAssetBalance } from '../../../../../hooks/useMaxAssetBalance';
import { useQueryRate } from '../../../../../hooks/useQueryRate';
import { translations } from '../../../../../locales/i18n';
import { COMMON_SYMBOLS } from '../../../../../utils/asset';
+import { NO_EXIT_DELAY } from '../../../../../utils/exitDelay';
import { SURFACE_LENDING_BORROWER_WITHDRAW } from '../../../../../utils/exitFee';
import { areValuesIdentical } from '../../../../../utils/helpers';
import { decimalic } from '../../../../../utils/math';
@@ -280,6 +284,11 @@ export const AdjustLoanForm: FC = ({ loan }) => {
loanTokenContract?.address,
);
+ const exitDelay = useExitDelayQuote(
+ SURFACE_LENDING_BORROWER_WITHDRAW,
+ loanTokenContract?.address,
+ );
+
const exitFeeGross = useMemo(
() =>
getBorrowerExitFeeGross({
@@ -304,6 +313,12 @@ export const AdjustLoanForm: FC = ({ loan }) => {
],
);
+ // A debt-only adjust removes nothing, so it is not a withdrawal the
+ // perimeter can hold — the notice must stay silent for it either way.
+ const notifyHold = usePerimeterHoldToast(
+ exitFeeGross.gt(0) ? exitDelay : NO_EXIT_DELAY,
+ );
+
const prepaidInterest = calculatePrepaidInterestFromDuration(
borrowApr,
debtSize.toString(),
@@ -450,6 +465,8 @@ export const AdjustLoanForm: FC = ({ loan }) => {
loan.debt.toString(),
loan.id,
convertLoanTokenToSupportedAssets(loan.debtAsset),
+ false,
+ notifyHold,
);
return;
}
@@ -460,6 +477,7 @@ export const AdjustLoanForm: FC = ({ loan }) => {
loan.id,
convertLoanTokenToSupportedAssets(loan.debtAsset),
true,
+ notifyHold,
);
return;
}
@@ -486,7 +504,7 @@ export const AdjustLoanForm: FC = ({ loan }) => {
}
if (isCollateralWithdrawMode) {
- handleWithdrawCollateral(collateralAmount, loan.id);
+ handleWithdrawCollateral(collateralAmount, loan.id, notifyHold);
}
}, [
collateralAmount,
@@ -496,6 +514,7 @@ export const AdjustLoanForm: FC = ({ loan }) => {
debtSize,
debtToken,
handleBorrow,
+ notifyHold,
handleDepositCollateral,
handleRepay,
handleWithdrawCollateral,
@@ -895,6 +914,13 @@ export const AdjustLoanForm: FC = ({ loan }) => {
assetSymbol={collateralToken}
precision={BTC_RENDER_PRECISION}
/>
+ {exitFeeGross.gt(0) && (
+
+ )}
{(isBorrowTab || isRepayTab) && (
= ({ loan }) => {
onClick={handleFormSubmit}
dataAttribute="adjust-loan-confirm-button"
disabled={
- submitButtonDisabled || (isBorrowTab && debtSize.gt(maxBorrow))
+ submitButtonDisabled ||
+ (isBorrowTab && debtSize.gt(maxBorrow)) ||
+ // Collateral leaving the protocol waits for its delay and fee figures.
+ (exitFeeGross.gt(0) && (exitDelay.loading || exitFeeLoading))
}
/>
diff --git a/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useRepayLoan.ts b/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useRepayLoan.ts
index 7bb38e756..670e05a68 100644
--- a/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useRepayLoan.ts
+++ b/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useRepayLoan.ts
@@ -28,6 +28,7 @@ export const useRepayLoan = () => {
loanId: string,
borrowToken: string,
isPartialRepay?: boolean,
+ onHeld?: () => void,
) => {
if (!contract || !account || !signer) {
return;
@@ -65,6 +66,7 @@ export const useRepayLoan = () => {
value: isBorrowTokenRbtc ? weiRepayAmount : '0',
gasLimit: GAS_LIMIT.REPAY_LOAN,
},
+ onComplete: onHeld,
});
setTransactions(transactions);
diff --git a/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useWithdrawCollateral.test.ts b/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useWithdrawCollateral.test.ts
new file mode 100644
index 000000000..c04299c5d
--- /dev/null
+++ b/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useWithdrawCollateral.test.ts
@@ -0,0 +1,66 @@
+import { act, renderHook } from '@testing-library/react';
+
+import 'jest-canvas-mock';
+
+import { GAS_LIMIT } from '../../../../../../constants/gasLimits';
+import { i18n } from '../../../../../../locales/i18n';
+import { useWithdrawCollateral } from './useWithdrawCollateral';
+
+/**
+ * A fixed-interest loan's collateral withdrawal is one of the four surfaces
+ * the Perimeter withdrawal delay hooks into; resolveGasLimit only applies
+ * its 30% margin on top of whatever floor a request configures, so this
+ * floor must itself already cover the delay-armed cost — see
+ * GAS_LIMIT.WITHDRAW_LOAN_COLLATERAL.
+ */
+
+const ACCOUNT = '0x1111111111111111111111111111111111111111';
+const mockSetTransactions = jest.fn();
+const mockSetIsOpen = jest.fn();
+const mockSetTitle = jest.fn();
+// ethers only accepts a real Signer here; this is the smallest object that
+// satisfies `Signer.isSigner` without reaching a network.
+const mockSigner = { _isSigner: true, provider: {} };
+
+jest.mock('nanoid', () => ({ nanoid: () => '1234' }));
+
+jest.mock('../../../../../../hooks/useLoadContract', () => ({
+ useLoadContract: () => ({
+ address: '0x5A0D867e0D70Fcc6Ade25C3F1B89d618b5B4Eaa7',
+ estimateGas: { withdrawCollateral: jest.fn() },
+ }),
+}));
+
+jest.mock('../../../../../../hooks/useAccount', () => ({
+ useAccount: () => ({ account: ACCOUNT, signer: mockSigner }),
+}));
+
+jest.mock('../../../../../../contexts/TransactionContext', () => ({
+ useTransactionContext: () => ({
+ setTransactions: (...args: unknown[]) => mockSetTransactions(...args),
+ setIsOpen: (...args: unknown[]) => mockSetIsOpen(...args),
+ setTitle: (...args: unknown[]) => mockSetTitle(...args),
+ }),
+}));
+
+describe('useWithdrawCollateral', () => {
+ beforeAll(async () => {
+ await i18n;
+ });
+
+ it('configures a gas-limit floor sized to the delay-armed call', async () => {
+ const { result } = renderHook(() => useWithdrawCollateral());
+
+ await act(async () => {
+ await result.current('0.1', '0xloan', jest.fn());
+ });
+
+ const step = mockSetTransactions.mock.calls[0][0][0];
+ expect(step.request.fnName).toBe('withdrawCollateral');
+ // With the floor too low, resolveGasLimit's own margin never rescues a
+ // failed estimate — a failed live read falls straight back to this
+ // constant outright. It must already cover the delay-armed cost on its
+ // own, not rely on the margin ever landing.
+ expect(step.request.gasLimit).toBe(GAS_LIMIT.WITHDRAW_LOAN_COLLATERAL);
+ });
+});
diff --git a/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useWithdrawCollateral.ts b/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useWithdrawCollateral.ts
index 7d81648cc..a25ad21da 100644
--- a/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useWithdrawCollateral.ts
+++ b/apps/frontend/src/app/5_pages/BorrowPage/components/AdjustLoanForm/hooks/useWithdrawCollateral.ts
@@ -21,7 +21,7 @@ export const useWithdrawCollateral = () => {
const { account, signer } = useAccount();
const handleSubmit = useCallback(
- async (amount: string, loanId: string) => {
+ async (amount: string, loanId: string, onHeld?: () => void) => {
if (!contract || !account || !signer) {
return;
}
@@ -41,6 +41,7 @@ export const useWithdrawCollateral = () => {
args: [loanId, account, weiAmount],
gasLimit: GAS_LIMIT.WITHDRAW_LOAN_COLLATERAL,
},
+ onComplete: onHeld,
},
];
diff --git a/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/AdjustLendingModalContainer.tsx b/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/AdjustLendingModalContainer.tsx
index 357fdab6b..6274a2127 100644
--- a/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/AdjustLendingModalContainer.tsx
+++ b/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/AdjustLendingModalContainer.tsx
@@ -21,6 +21,9 @@ import { translations } from '../../../../../locales/i18n';
import { eventDriven } from '../../../../../store/rxjs/event-driven';
import { asyncCall } from '../../../../../store/rxjs/provider-cache';
import { Nullable } from '../../../../../types/global';
+import { useExitDelayQuote } from '../../../../../hooks/exitDelay/useExitDelayQuote';
+import { usePerimeterHoldToast } from '../../../../../hooks/exitDelay/usePerimeterHoldToast';
+import { SURFACE_LENDING_LENDER_WITHDRAW } from '../../../../../utils/exitFee';
import { LendModalAction } from '../../LendPage.types';
import { FormType, LendingForm } from './LendingForm';
@@ -30,6 +33,7 @@ export type AdjustModalProps = {
amount: Decimal,
token: AssetDetailsData,
pool: Contract,
+ onHeld?: () => void,
) => void;
onClose: () => void;
isOpen: boolean;
@@ -122,6 +126,12 @@ export const AdjustLendingModalContainer: FC
= ({
onClose();
}, [onClose, push]);
+ const exitDelay = useExitDelayQuote(
+ SURFACE_LENDING_LENDER_WITHDRAW,
+ state?.poolTokenContract?.address,
+ );
+ const notifyHold = usePerimeterHoldToast(exitDelay);
+
const handleConfirm = useCallback(
(type: FormType, amount: Decimal) => {
if (state == null) {
@@ -130,10 +140,15 @@ export const AdjustLendingModalContainer: FC = ({
if (type === FormType.Deposit) {
onDeposit(amount, state.tokenDetails, state.poolTokenContract);
} else {
- onWithdraw(amount, state.tokenDetails, state.poolTokenContract);
+ onWithdraw(
+ amount,
+ state.tokenDetails,
+ state.poolTokenContract,
+ notifyHold,
+ );
}
},
- [onDeposit, onWithdraw, state],
+ [notifyHold, onDeposit, onWithdraw, state],
);
return (
diff --git a/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/LendingForm.test.tsx b/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/LendingForm.test.tsx
index 3540a53e1..4ae3e6dfa 100644
--- a/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/LendingForm.test.tsx
+++ b/apps/frontend/src/app/5_pages/LendPage/components/AdjustModal/LendingForm.test.tsx
@@ -4,6 +4,7 @@ import React from 'react';
import { BigNumber } from 'ethers';
import 'jest-canvas-mock';
+import { MemoryRouter } from 'react-router-dom';
import { Decimal } from '@sovryn/utils';
@@ -23,8 +24,21 @@ jest.mock('../../../../../contexts/NotificationContext', () => {
};
});
+let mockFee: {
+ active: boolean;
+ rateBps: number;
+ loading: boolean;
+ unknown: boolean;
+};
+
jest.mock('../../../../../hooks/exitFee/useExitFeeRate', () => ({
- useExitFeeRate: () => ({ active: true, rateBps: 50, loading: false }),
+ useExitFeeRate: () => mockFee,
+}));
+
+let mockDelay: { delaySeconds: number; loading: boolean; unknown: boolean };
+
+jest.mock('../../../../../hooks/exitDelay/useExitDelayQuote', () => ({
+ useExitDelayQuote: () => mockDelay,
}));
jest.mock('../../../../../hooks/useMaxAssetBalance', () => {
@@ -58,10 +72,30 @@ describe('LendingForm perimeter fee', () => {
beforeEach(() => {
(asyncCall as jest.Mock).mockResolvedValue(BigNumber.from(0));
+ mockDelay = { delaySeconds: 0, loading: false, unknown: false };
+ mockFee = { active: true, rateBps: 50, loading: false, unknown: false };
});
+ // The hold row links to the Perimeter page, so the form needs a router the
+ // way it has one in the app.
+ const renderForm = () =>
+ render(
+
+
+ ,
+ );
+
+ /** Enter an amount on the withdraw tab, the way a lender would. */
+ const withdraw = (amount = '100') => {
+ renderForm();
+ fireEvent.click(screen.getByText('Withdraw'));
+ const input = screen.getByPlaceholderText('0');
+ fireEvent.change(input, { target: { value: amount } });
+ fireEvent.blur(input);
+ };
+
it('shows the "You will receive" row on the withdraw tab once an amount is entered', () => {
- render( );
+ renderForm();
fireEvent.click(screen.getByText('Withdraw'));
const input = screen.getByPlaceholderText('0');
fireEvent.change(input, { target: { value: '100' } });
@@ -75,8 +109,100 @@ describe('LendingForm perimeter fee', () => {
expect(screen.queryByText(/^Perimeter fee/)).not.toBeInTheDocument();
});
+ it('tells the lender the withdrawal will be held, and for how long', () => {
+ // Pins that the hold row appears for a withdrawal with a real hold.
+ mockDelay = { delaySeconds: 172800, loading: false, unknown: false };
+
+ withdraw();
+
+ expect(screen.getByText('Withdrawal delay')).toBeInTheDocument();
+ expect(screen.getByText('2 days')).toBeInTheDocument();
+ expect(screen.getByText(/Perimeter withdraw queue/)).toBeInTheDocument();
+ });
+
+ it('admits it could not check whether the withdrawal will be held', () => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: true };
+
+ withdraw();
+
+ expect(screen.getByText('Could not be checked')).toBeInTheDocument();
+ });
+
+ describe('Confirm and the delay quote', () => {
+ const confirm = () => screen.getByRole('button', { name: 'Confirm' });
+
+ it('waits for the delay quote on a withdrawal, and says it is checking', () => {
+ mockDelay = { delaySeconds: 0, loading: true, unknown: false };
+
+ withdraw();
+
+ expect(screen.getByText(/^Checking/)).toBeInTheDocument();
+ expect(confirm()).toBeDisabled();
+ });
+
+ it('offers Confirm once a quote arrived', () => {
+ mockDelay = { delaySeconds: 172800, loading: false, unknown: false };
+
+ withdraw();
+
+ expect(confirm()).toBeEnabled();
+ });
+
+ it('offers Confirm when the quote could not be read, with the warning shown', () => {
+ mockDelay = { delaySeconds: 0, loading: false, unknown: true };
+
+ withdraw();
+
+ expect(screen.getByText('Could not be checked')).toBeInTheDocument();
+ expect(confirm()).toBeEnabled();
+ });
+
+ it('does not hold a deposit back for the delay quote', () => {
+ mockDelay = { delaySeconds: 0, loading: true, unknown: false };
+
+ renderForm();
+ const input = screen.getByPlaceholderText('0');
+ fireEvent.change(input, { target: { value: '100' } });
+ fireEvent.blur(input);
+
+ expect(confirm()).toBeEnabled();
+ });
+ });
+
+ describe('Confirm and the fee quote', () => {
+ const confirm = () => screen.getByRole('button', { name: 'Confirm' });
+
+ it('waits for the fee quote even once the delay has settled', () => {
+ mockFee.loading = true;
+
+ withdraw();
+
+ expect(confirm()).toBeDisabled();
+ });
+
+ it('offers Confirm once the fee quote has settled', () => {
+ mockFee.loading = false;
+
+ withdraw();
+
+ expect(confirm()).toBeEnabled();
+ });
+ });
+
+ it('shows no hold on the deposit tab, however long the hold would be', () => {
+ // Nothing leaves on a deposit, so nothing is held.
+ mockDelay = { delaySeconds: 172800, loading: false, unknown: false };
+
+ renderForm();
+ const input = screen.getByPlaceholderText('0');
+ fireEvent.change(input, { target: { value: '100' } });
+ fireEvent.blur(input);
+
+ expect(screen.queryByText('Withdrawal delay')).not.toBeInTheDocument();
+ });
+
it('shows no "You will receive" row on the deposit tab', () => {
- render( );
+ renderForm();
const input = screen.getByPlaceholderText('0');
fireEvent.change(input, { target: { value: '100' } });
fireEvent.blur(input);
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 08a4fb835..0d8251ac4 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,9 +18,11 @@ import { RSK_CHAIN_ID } from '../../../../../config/chains';
import { AmountRenderer } from '../../../../2_molecules/AmountRenderer/AmountRenderer';
import { AssetRenderer } from '../../../../2_molecules/AssetRenderer/AssetRenderer';
+import { ExitDelayRow } from '../../../../2_molecules/ExitDelayRow/ExitDelayRow';
import { ExitFeeRow } from '../../../../2_molecules/ExitFeeRow/ExitFeeRow';
import { GAS_LIMIT } from '../../../../../constants/gasLimits';
import { getTokenDisplayName } from '../../../../../constants/tokens';
+import { useExitDelayQuote } from '../../../../../hooks/exitDelay/useExitDelayQuote';
import { useExitFeeRate } from '../../../../../hooks/exitFee/useExitFeeRate';
import { useMaxAssetBalance } from '../../../../../hooks/useMaxAssetBalance';
import { useWeiAmountInput } from '../../../../../hooks/useWeiAmountInput';
@@ -62,6 +64,15 @@ export const LendingForm: FC = ({ state, onConfirm }) => {
state.poolTokenContract.address,
);
+ const {
+ delaySeconds,
+ unknown: exitDelayUnknown,
+ loading: exitDelayLoading,
+ } = useExitDelayQuote(
+ SURFACE_LENDING_LENDER_WITHDRAW,
+ state.poolTokenContract.address,
+ );
+
const withdrawAmount = useMemo(
() => Decimal.fromBigNumberString(amount.toString()),
[amount],
@@ -200,20 +211,31 @@ export const LendingForm: FC = ({ state, onConfirm }) => {
}
/>
{!isDeposit && (
-
+ <>
+
+
+ >
)}
diff --git a/apps/frontend/src/app/5_pages/LendPage/hooks/useHandleLending.ts b/apps/frontend/src/app/5_pages/LendPage/hooks/useHandleLending.ts
index 586ff5162..3c9954ae9 100644
--- a/apps/frontend/src/app/5_pages/LendPage/hooks/useHandleLending.ts
+++ b/apps/frontend/src/app/5_pages/LendPage/hooks/useHandleLending.ts
@@ -104,6 +104,7 @@ export const useHandleLending = (
amount: Decimal,
tokenDetails: AssetDetailsData,
poolTokenContract: Contract,
+ onHeld?: () => void,
) => {
if (!account || !signer || !tokenDetails || !poolTokenContract) {
return;
@@ -146,7 +147,11 @@ export const useHandleLending = (
args: [account, withdrawAmount.toBigNumber().toString(), poolUsesLM],
gasLimit: GAS_LIMIT.LENDING_BURN,
},
- onComplete,
+ onComplete: result => {
+ onComplete();
+ onHeld?.();
+ return result;
+ },
});
setTransactions(transactions);
diff --git a/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.test.tsx b/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.test.tsx
new file mode 100644
index 000000000..b35a2bb1e
--- /dev/null
+++ b/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.test.tsx
@@ -0,0 +1,874 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import React from 'react';
+
+import 'jest-canvas-mock';
+
+import { Decimal } from '@sovryn/utils';
+
+import { RSK_CHAIN_ID } from '../../../config/chains';
+
+import { i18n } from '../../../locales/i18n';
+import { ExitStatus, exitKey } from '../../../utils/exitDelay';
+import { PerimeterPage } from './PerimeterPage';
+
+const ACCOUNT = '0x1111111111111111111111111111111111111111';
+const RECEIVER = '0x3333333333333333333333333333333333333333';
+const QUEUE = '0x9999999999999999999999999999999999999999';
+const OTHER_QUEUE = '0x8888888888888888888888888888888888888888';
+
+const NO_DELAYED =
+ 'The Sovryn Perimeter has no delayed withdrawals for this account.';
+
+const UNREADABLE =
+ /Any withdrawal delayed for this account is still in the withdraw queue/;
+
+const mockRelease = jest.fn();
+
+let mockVault: {
+ exits: any[];
+ pausedByQueue: Record;
+ paused: boolean;
+ loading: boolean;
+ unknown: boolean;
+};
+let mockChainTime: { now: number; blockTime: number; unreadable: boolean };
+let mockCurrentChainId: string;
+let mockWallets: {
+ chains: { id: string }[];
+ accounts: { address: string }[];
+}[];
+
+jest.mock('nanoid', () => ({ nanoid: () => '1234' }));
+
+jest.mock('react-helmet-async', () => ({
+ Helmet: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}));
+
+jest.mock('../../../contexts/NotificationContext', () => ({
+ useNotificationContext: () => ({ addNotification: jest.fn() }),
+}));
+
+let mockAccount: string | undefined;
+
+jest.mock('../../../hooks/useAccount', () => ({
+ useAccount: () => ({ account: mockAccount, signer: undefined }),
+}));
+
+jest.mock('../../../hooks/exitDelay/usePerimeterVault', () => ({
+ usePerimeterVault: () => mockVault,
+}));
+
+jest.mock('../../../hooks/exitDelay/usePerimeterRelease', () => ({
+ usePerimeterRelease: () => mockRelease,
+}));
+
+jest.mock('../../../hooks/exitDelay/useChainTime', () => ({
+ useChainTime: () => mockChainTime,
+}));
+
+jest.mock('../../../hooks/useChainStore', () => ({
+ useChainStore: () => ({
+ currentChainId: mockCurrentChainId,
+ setCurrentChainId: jest.fn(),
+ }),
+}));
+
+jest.mock('../../../hooks/useWalletConnect', () => ({
+ useWalletConnect: () => ({ wallets: mockWallets }),
+}));
+
+const NOW = 1_800_000_000;
+
+const exit = (overrides: Record = {}) => ({
+ id: '7',
+ queueAddress: QUEUE,
+ amount: Decimal.from(1.5),
+ tokenSymbol: 'BTC',
+ token: '0x0000000000000000000000000000000000000000',
+ createdAt: NOW - 100,
+ unlockAt: NOW - 1,
+ originator: ACCOUNT,
+ owner: ACCOUNT,
+ receiver: RECEIVER,
+ surfaceId: '0x00',
+ subProduct: '0x0000000000000000000000000000000000000000',
+ status: ExitStatus.Queued,
+ unwrapOnDelivery: false,
+ ownerHasCode: false,
+ ...overrides,
+});
+
+/** What a genuinely completed release hands `onReleased` for each row. */
+const asExecuted = (rows: { queueAddress: string; id: string }[]) =>
+ rows.map(row => ({ key: exitKey(row), status: ExitStatus.Executed }));
+
+const releaseButton = (container: HTMLElement, queue: string, id: string) =>
+ container.querySelector(
+ `[data-layout-id="perimeter-release-${exitKey({
+ queueAddress: queue,
+ id,
+ })}"]`,
+ );
+
+describe('PerimeterPage', () => {
+ beforeAll(async () => {
+ await i18n;
+ });
+
+ beforeEach(() => {
+ // Re-applied per test: this project's jest config resets mocks between
+ // tests, and a reset Date.now would make every row read as unlocked.
+ jest.spyOn(Date, 'now').mockReturnValue(NOW * 1000);
+ mockAccount = ACCOUNT;
+ mockChainTime = { now: NOW, blockTime: NOW, unreadable: false };
+ mockCurrentChainId = RSK_CHAIN_ID;
+ mockWallets = [];
+ mockVault = {
+ exits: [],
+ pausedByQueue: {},
+ paused: false,
+ loading: false,
+ unknown: false,
+ };
+ window.localStorage.clear();
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('asks for a wallet to show delayed withdrawals when none is connected', () => {
+ mockAccount = undefined;
+ render( );
+
+ expect(
+ screen.getAllByText(
+ 'Connect your wallet to see your delayed withdrawals.',
+ ).length,
+ ).toBeGreaterThan(0);
+ });
+
+ it('says there are no delayed withdrawals when the queue has none for this account', () => {
+ render( );
+ // The table renders a desktop and a mobile variant, so the empty message
+ // legitimately appears more than once.
+ expect(screen.getAllByText(NO_DELAYED).length).toBeGreaterThan(0);
+ });
+
+ it('never states there are no delayed withdrawals when the read failed', () => {
+ // An account with queued exits produces exactly this empty list when a
+ // round trip fails. Printing the definitive negative for it is the one
+ // thing this page must not do.
+ mockVault.unknown = true;
+ const { container } = render( );
+
+ expect(screen.queryByText(NO_DELAYED)).not.toBeInTheDocument();
+ expect(
+ container.querySelector('[data-layout-id="perimeter-unreadable"]'),
+ ).toBeInTheDocument();
+ expect(screen.getAllByText(UNREADABLE).length).toBeGreaterThan(0);
+ });
+
+ it('says the read failed even while it lists what it did get', () => {
+ mockVault.unknown = true;
+ mockVault.exits = [exit()];
+ const { container } = render( );
+
+ expect(
+ container.querySelector('[data-layout-id="perimeter-unreadable"]'),
+ ).toBeInTheDocument();
+ expect(screen.getAllByText('Ready').length).toBeGreaterThan(0);
+ });
+
+ it('draws no row until the chain clock has been read', () => {
+ // Every status and countdown is derived from it; a row resolved against a
+ // missing time reads as delayed for decades.
+ mockChainTime = { now: 0, blockTime: 0, unreadable: false };
+ mockVault.exits = [exit()];
+ render( );
+
+ expect(screen.queryAllByText('#7')).toHaveLength(0);
+ expect(screen.queryAllByText('Delayed')).toHaveLength(0);
+ });
+
+ it('says the vault could not be read when the chain clock could not be read', () => {
+ mockChainTime = { now: 0, blockTime: 0, unreadable: true };
+ mockVault.exits = [exit()];
+ const { container } = render( );
+
+ expect(
+ container.querySelector('[data-layout-id="perimeter-unreadable"]'),
+ ).toBeInTheDocument();
+ expect(screen.queryAllByText('#7')).toHaveLength(0);
+ expect(screen.queryByText(NO_DELAYED)).not.toBeInTheDocument();
+ });
+
+ it('decides readiness from the chain clock, not the browser clock', () => {
+ // A machine two minutes fast would otherwise flip a locked hold to Ready;
+ // the release reverts NotUnlocked and, inside a batch, takes every other
+ // ready hold with it.
+ jest.spyOn(Date, 'now').mockReturnValue((NOW + 7200) * 1000);
+ mockVault.exits = [exit({ unlockAt: NOW + 3600 })];
+ const { container } = render( );
+
+ expect(screen.getAllByText('Delayed').length).toBeGreaterThan(0);
+ expect(releaseButton(container, QUEUE, '7')).not.toBeInTheDocument();
+ });
+
+ describe('a row whose delay has ended on the page clock but not yet in the latest block', () => {
+ // The queue compares the latest block's timestamp, which trails wall time
+ // by up to about a minute on RSK mainnet. A row offered as Ready before
+ // that block arrives is refused, so it reads as unlocking until then.
+ it('shows it as Unlocking, offers no Release on it, and leaves it out of Release all', () => {
+ mockChainTime = { now: NOW, blockTime: NOW - 30, unreadable: false };
+ mockVault.exits = [
+ exit({ id: '7', unlockAt: NOW - 60 }),
+ exit({ id: '8', unlockAt: NOW - 1 }),
+ exit({ id: '9', unlockAt: NOW - 45 }),
+ ];
+ const { container } = render( );
+
+ expect(screen.getAllByText('Unlocking').length).toBeGreaterThan(0);
+ expect(releaseButton(container, QUEUE, '8')).not.toBeInTheDocument();
+ expect(releaseButton(container, QUEUE, '7')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Release all ready (2)'));
+
+ const [rows] = mockRelease.mock.calls[0];
+ expect(rows.map((row: { id: string }) => row.id)).toEqual(['7', '9']);
+ });
+
+ it('offers Release once the latest block reaches the unlock time', () => {
+ mockChainTime = { now: NOW + 30, blockTime: NOW, unreadable: false };
+ mockVault.exits = [exit({ unlockAt: NOW })];
+ const { container } = render( );
+
+ expect(screen.getAllByText('Ready').length).toBeGreaterThan(0);
+ expect(releaseButton(container, QUEUE, '7')).toBeInTheDocument();
+ });
+ });
+
+ describe('while a release is being checked', () => {
+ // The checks before the dialog opens take several round trips. A second
+ // press meanwhile would start a second release whose transaction list
+ // replaces the first one's in the dialog, so every release control waits
+ // until the checks finish, and the pressed one says it is checking.
+ const releaseThatWaits = () => {
+ let finish: () => void = () => undefined;
+ mockRelease.mockImplementation(
+ () =>
+ new Promise(resolve => {
+ finish = resolve;
+ }),
+ );
+ return () => finish();
+ };
+
+ const releaseAllButton = (container: HTMLElement) =>
+ container.querySelector('[data-layout-id="perimeter-release-all"]');
+
+ it('says Checking… on the pressed Release, and takes no other press until the checks finish', async () => {
+ const finish = releaseThatWaits();
+ mockVault.exits = [exit({ id: '7' }), exit({ id: '8' })];
+ const { container } = render( );
+
+ fireEvent.click(releaseButton(container, QUEUE, '7')!);
+
+ expect(releaseButton(container, QUEUE, '7')).toHaveTextContent(
+ 'Checking…',
+ );
+ expect(releaseButton(container, QUEUE, '7')).toBeDisabled();
+ expect(releaseButton(container, QUEUE, '8')).toHaveTextContent('Release');
+ expect(releaseButton(container, QUEUE, '8')).toBeDisabled();
+ expect(releaseAllButton(container)).toBeDisabled();
+ fireEvent.click(releaseButton(container, QUEUE, '7')!);
+ fireEvent.click(releaseButton(container, QUEUE, '8')!);
+ fireEvent.click(releaseAllButton(container)!);
+ expect(mockRelease).toHaveBeenCalledTimes(1);
+
+ await act(async () => finish());
+
+ expect(releaseButton(container, QUEUE, '7')).toHaveTextContent('Release');
+ expect(releaseButton(container, QUEUE, '7')).not.toBeDisabled();
+ expect(releaseAllButton(container)).not.toBeDisabled();
+ });
+
+ it('says Checking… on Release all, and takes no other press until the checks finish', async () => {
+ const finish = releaseThatWaits();
+ mockVault.exits = [exit({ id: '7' }), exit({ id: '8' })];
+ const { container } = render( );
+
+ fireEvent.click(releaseAllButton(container)!);
+
+ expect(releaseAllButton(container)).toHaveTextContent('Checking…');
+ expect(releaseAllButton(container)).toBeDisabled();
+ expect(releaseButton(container, QUEUE, '7')).toBeDisabled();
+ fireEvent.click(releaseButton(container, QUEUE, '7')!);
+ fireEvent.click(releaseAllButton(container)!);
+ expect(mockRelease).toHaveBeenCalledTimes(1);
+
+ await act(async () => finish());
+
+ expect(releaseAllButton(container)).toHaveTextContent(
+ 'Release all ready (2)',
+ );
+ expect(releaseAllButton(container)).not.toBeDisabled();
+ });
+ });
+
+ it('names the asset an amount is denominated in', () => {
+ // One queue holds every asset the perimeter covers: 1.5 RBTC and 1.5 DOC
+ // are adjacent rows three orders of magnitude apart.
+ mockVault.exits = [exit()];
+ render( );
+
+ expect(screen.getAllByText(/1\.5/).length).toBeGreaterThan(0);
+ expect(screen.getAllByText(/BTC/).length).toBeGreaterThan(0);
+ });
+
+ it('prints no amount for an asset it could not resolve', () => {
+ mockVault.exits = [exit({ amount: undefined, tokenSymbol: undefined })];
+ render( );
+
+ expect(screen.getAllByText('Unknown asset').length).toBeGreaterThan(0);
+ });
+
+ it('counts down to two units rather than rounding up to one', () => {
+ mockVault.exits = [exit({ unlockAt: NOW + 90_000 })];
+ render( );
+
+ expect(screen.getAllByText('1 d 1 h').length).toBeGreaterThan(0);
+ });
+
+ it('shows no release time for a row it could not read a status for', () => {
+ // unlockAt sits in the past, so the Releases column would otherwise read
+ // "Now" for a row nothing is released from.
+ mockVault.exits = [exit({ id: '5', status: ExitStatus.None })];
+ render( );
+
+ expect(screen.getAllByText('Could not be read').length).toBeGreaterThan(0);
+ expect(screen.queryByText('Now')).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ['delivered', ExitStatus.Executed],
+ ['resolved by the Owner', ExitStatus.ResolvedByOwner],
+ ['returned to the product', ExitStatus.ResolvedToProtocol],
+ ])(
+ // A withdrawal the vault itself reports %s is done: the pending list is
+ // for what is still waiting, so the row is dropped rather than shown
+ // with a label for what became of it.
+ 'drops a row the vault reports as %s, rather than showing it with a resolved label',
+ (_case, status) => {
+ mockVault.exits = [exit({ id: '4', status })];
+ render( );
+
+ expect(screen.queryByText('#4')).not.toBeInTheDocument();
+ expect(screen.getAllByText(NO_DELAYED).length).toBeGreaterThan(0);
+ },
+ );
+
+ it('lists a frozen or blacklisted row untouched — still Queued, so still pending — while dropping a genuinely resolved one alongside it', () => {
+ mockVault.exits = [
+ exit({ id: '6', blockedState: 1 }),
+ exit({ id: '4', status: ExitStatus.ResolvedByOwner }),
+ ];
+ render( );
+
+ expect(screen.getAllByText('#6').length).toBeGreaterThan(0);
+ expect(screen.queryByText('#4')).not.toBeInTheDocument();
+ });
+
+ it('renders no History UI: no toggle, and no history-only status line', () => {
+ mockVault.exits = [exit()];
+ const { container } = render( );
+
+ expect(screen.queryByText('Show history')).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('Show waiting withdrawals'),
+ ).not.toBeInTheDocument();
+ expect(
+ container.querySelector('[data-layout-id="perimeter-history-toggle"]'),
+ ).not.toBeInTheDocument();
+ expect(
+ container.querySelector(
+ '[data-layout-id="perimeter-history-unreadable"]',
+ ),
+ ).not.toBeInTheDocument();
+ });
+
+ it('clears local-storage keys under the perimeter-history prefix on load, leaving unrelated keys alone', () => {
+ window.localStorage.setItem(
+ 'perimeter/history/0x1e/0x1111111111111111111111111111111111111111',
+ JSON.stringify([{ queueAddress: QUEUE, id: '7' }]),
+ );
+ window.localStorage.setItem('unrelated-key', 'keep me');
+
+ render( );
+
+ expect(
+ window.localStorage.getItem(
+ 'perimeter/history/0x1e/0x1111111111111111111111111111111111111111',
+ ),
+ ).toBeNull();
+ expect(window.localStorage.getItem('unrelated-key')).toBe('keep me');
+ });
+
+ it.each([
+ ['as the position owner', {}],
+ [
+ 'as the receiver',
+ { originator: RECEIVER, owner: RECEIVER, receiver: ACCOUNT },
+ ],
+ ])(
+ 'a row resolved by the Owner disappears on the next read, without any action on this page, for the connected account %s',
+ (_case, overrides) => {
+ mockVault.exits = [exit({ id: '7', ...overrides })];
+ const { container, rerender } = render( );
+
+ expect(screen.getAllByText('#7').length).toBeGreaterThan(0);
+ expect(releaseButton(container, QUEUE, '7')).toBeInTheDocument();
+
+ // Nothing was pressed on this page: the vault's own next read is what
+ // reports the withdrawal as resolved.
+ mockVault.exits = [
+ exit({ id: '7', ...overrides, status: ExitStatus.ResolvedByOwner }),
+ ];
+ rerender( );
+
+ expect(screen.queryByText('#7')).not.toBeInTheDocument();
+ expect(screen.getAllByText(NO_DELAYED).length).toBeGreaterThan(0);
+ },
+ );
+
+ it.each([
+ ['as the position owner', {}],
+ [
+ 'as the receiver',
+ { originator: RECEIVER, owner: RECEIVER, receiver: ACCOUNT },
+ ],
+ ])(
+ 'a released row disappears at once and does not reappear when a stale chain read still lists it, for the connected account %s',
+ (_case, overrides) => {
+ mockRelease.mockImplementation(
+ (rows: { queueAddress: string; id: string }[], onReleased) =>
+ onReleased(asExecuted(rows)),
+ );
+ const row = exit({ id: '7', ...overrides });
+ mockVault.exits = [row];
+ const { container, rerender } = render( );
+
+ fireEvent.click(releaseButton(container, QUEUE, '7')!);
+
+ expect(screen.queryByText('#7')).not.toBeInTheDocument();
+
+ // The vault has not caught up yet: its own list still carries the row,
+ // still Queued, exactly as it read before the release. The page must
+ // not trust that stale read over what it already knows happened.
+ mockVault.exits = [row];
+ rerender( );
+
+ expect(screen.queryByText('#7')).not.toBeInTheDocument();
+ expect(screen.getAllByText(NO_DELAYED).length).toBeGreaterThan(0);
+ },
+ );
+
+ it('disables Release and Release all with a wrong-network tooltip when the wallet is on another network', () => {
+ mockCurrentChainId = '0x1';
+ mockVault.exits = [exit({ id: '7' }), exit({ id: '8' })];
+ const { container } = render( );
+
+ const button = releaseButton(container, QUEUE, '7');
+ expect(button).toBeInTheDocument();
+ expect(button).toBeDisabled();
+
+ const buttonTooltip = container.querySelector(
+ `[data-layout-id="perimeter-release-tooltip-${exitKey({
+ queueAddress: QUEUE,
+ id: '7',
+ })}"]`,
+ );
+ userEvent.hover(buttonTooltip!);
+ expect(screen.getAllByText('wrong network').length).toBeGreaterThan(0);
+
+ const allButton = container.querySelector(
+ '[data-layout-id="perimeter-release-all"]',
+ );
+ expect(allButton).toBeInTheDocument();
+ expect(allButton).toBeDisabled();
+
+ const allTooltip = container.querySelector(
+ '[data-layout-id="perimeter-release-all-tooltip"]',
+ );
+ userEvent.hover(allTooltip!);
+ expect(screen.getAllByText('wrong network').length).toBeGreaterThan(1);
+ });
+
+ it('disables Release and Release all when the wallet itself is on another network, even though the app is on Rootstock', () => {
+ mockWallets = [
+ { chains: [{ id: '0x1' }], accounts: [{ address: ACCOUNT }] },
+ ];
+ mockVault.exits = [exit({ id: '7' }), exit({ id: '8' })];
+ const { container } = render( );
+
+ expect(releaseButton(container, QUEUE, '7')).toBeDisabled();
+ expect(
+ container.querySelector('[data-layout-id="perimeter-release-all"]'),
+ ).toBeDisabled();
+ });
+
+ it('leaves Release enabled when the wallet is disconnected but the app is on Rootstock', () => {
+ mockWallets = [];
+ mockVault.exits = [exit({ id: '7' })];
+ const { container } = render( );
+
+ expect(releaseButton(container, QUEUE, '7')).toBeEnabled();
+ });
+
+ it('lets a wrong-network tooltip be hovered under the network banner', () => {
+ // pointer-events-none from the banner would otherwise starve the wrapper
+ // of hover, so it is put back explicitly; jsdom applies none of the
+ // Tailwind classes, so this pins the class rather than real hover.
+ mockCurrentChainId = '0x1';
+ mockVault.exits = [exit({ id: '7' }), exit({ id: '8' })];
+ const { container } = render( );
+
+ const buttonTooltip = container.querySelector(
+ `[data-layout-id="perimeter-release-tooltip-${exitKey({
+ queueAddress: QUEUE,
+ id: '7',
+ })}"]`,
+ );
+ expect(buttonTooltip).toHaveClass('pointer-events-auto');
+
+ const allTooltip = container.querySelector(
+ '[data-layout-id="perimeter-release-all-tooltip"]',
+ );
+ expect(allTooltip).toHaveClass('pointer-events-auto');
+ });
+
+ it('offers Release on an unlocked row and hands the row to the release check', () => {
+ mockVault.exits = [exit()];
+ const { container } = render( );
+
+ expect(screen.getAllByText('Ready').length).toBeGreaterThan(0);
+ const button = releaseButton(container, QUEUE, '7');
+ expect(button).toBeInTheDocument();
+ fireEvent.click(button!);
+
+ expect(mockRelease).toHaveBeenCalledWith(
+ [expect.objectContaining({ id: '7', queueAddress: QUEUE })],
+ expect.any(Function),
+ );
+ });
+
+ it('never reads a zero-filled record as a withdrawal that was paid out', () => {
+ // A zero-filled record — the answering node does not hold it — reads as
+ // unreadable, not as a fact about the withdrawal.
+ mockVault.exits = [exit({ status: ExitStatus.None })];
+ mockVault.unknown = true;
+ const { container } = render( );
+
+ expect(screen.getAllByText('Could not be read').length).toBeGreaterThan(0);
+ expect(
+ container.querySelector('[data-layout-id="perimeter-unreadable"]'),
+ ).toBeInTheDocument();
+ });
+
+ it('shows an unlocked row with a frozen party as under investigation, with no Release', () => {
+ mockVault.exits = [exit({ blockedState: 1 })];
+ const { container } = render( );
+
+ expect(screen.getAllByText('Under investigation').length).toBeGreaterThan(
+ 0,
+ );
+ expect(screen.queryByText('Ready')).not.toBeInTheDocument();
+ expect(releaseButton(container, QUEUE, '7')).not.toBeInTheDocument();
+ expect(screen.queryByText(/frozen/i)).not.toBeInTheDocument();
+ });
+
+ it('keeps a frozen party silent while the delay still runs', () => {
+ mockVault.exits = [exit({ blockedState: 1, unlockAt: NOW + 3600 })];
+ render( );
+
+ expect(screen.queryByText('Under investigation')).not.toBeInTheDocument();
+ expect(screen.getAllByText('Delayed').length).toBeGreaterThan(0);
+ });
+
+ it("shows a blacklisted party's row as ready", () => {
+ mockVault.exits = [exit({ blockedState: 2 })];
+ const { container } = render( );
+
+ expect(screen.getAllByText('Ready').length).toBeGreaterThan(0);
+ expect(releaseButton(container, QUEUE, '7')).toBeInTheDocument();
+ expect(screen.queryByText(/blacklist/i)).not.toBeInTheDocument();
+ });
+
+ it('marks a contract-owned row as held by the product, and offers no Release on it', () => {
+ mockVault.exits = [
+ exit({ owner: RECEIVER, originator: RECEIVER, ownerHasCode: true }),
+ ];
+ const { container } = render( );
+
+ expect(
+ screen.getByText(
+ 'Owned by a contract: the product you used holds this withdrawal.',
+ ),
+ ).toBeInTheDocument();
+ expect(releaseButton(container, QUEUE, '7')).not.toBeInTheDocument();
+ });
+
+ it('drops the session’s released-row filtering when the connected account changes, so a row still live for the new account is not hidden by a key the old one released', () => {
+ // releasedKeys is this browser tab's own session state, not read from
+ // chain, so nothing scopes it to an account unless this effect does:
+ // nothing else remounts the page on an account change.
+ const OTHER_ACCOUNT = '0x2222222222222222222222222222222222222222';
+ mockRelease.mockImplementation(
+ (rows: { queueAddress: string; id: string }[], onReleased) =>
+ onReleased(asExecuted(rows)),
+ );
+ mockVault.exits = [exit({ id: '7' })];
+ const { container, rerender } = render( );
+
+ fireEvent.click(releaseButton(container, QUEUE, '7')!);
+ expect(screen.queryByText('#7')).not.toBeInTheDocument();
+
+ // The row itself is still account A's in this mock, and switching to B
+ // must not carry A's released-key filtering over: a row that is
+ // genuinely live for B would otherwise be hidden by a key A released.
+ mockAccount = OTHER_ACCOUNT;
+ rerender( );
+
+ expect(screen.getAllByText('#7').length).toBeGreaterThan(0);
+ });
+
+ it('withholds Release while an exit is still on hold', () => {
+ mockVault.exits = [exit({ unlockAt: NOW + 3600 })];
+ const { container } = render( );
+
+ expect(screen.getAllByText('Delayed').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('1 h').length).toBeGreaterThan(0);
+ expect(releaseButton(container, QUEUE, '7')).not.toBeInTheDocument();
+ });
+
+ it('withholds Release for the whole queue while releases are paused, but keeps the held withdrawal listed', () => {
+ // Pausing disables withdrawing, not seeing: the row and its own Paused
+ // status must stay on the page, not just the banner saying releases are
+ // paused.
+ mockVault.exits = [exit()];
+ mockVault.paused = true;
+ mockVault.pausedByQueue = { [QUEUE]: true };
+ const { container } = render( );
+
+ expect(
+ container.querySelector('[data-layout-id="perimeter-paused"]'),
+ ).toBeInTheDocument();
+ expect(releaseButton(container, QUEUE, '7')).not.toBeInTheDocument();
+ expect(screen.getAllByText('#7').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Paused').length).toBeGreaterThan(0);
+ });
+
+ it('says releases are paused for some withdrawals, not the whole perimeter, when only one of two queues is paused', () => {
+ mockVault.exits = [
+ exit({ id: '7', queueAddress: QUEUE }),
+ exit({ id: '9', queueAddress: OTHER_QUEUE }),
+ ];
+ mockVault.paused = true;
+ mockVault.pausedByQueue = { [QUEUE]: true, [OTHER_QUEUE]: false };
+ const { container } = render( );
+
+ expect(
+ screen.queryByText(/paused across the whole perimeter/i),
+ ).not.toBeInTheDocument();
+ expect(
+ container.querySelector('[data-layout-id="perimeter-paused"]'),
+ ).toBeInTheDocument();
+ expect(releaseButton(container, QUEUE, '7')).not.toBeInTheDocument();
+ expect(releaseButton(container, OTHER_QUEUE, '9')).toBeInTheDocument();
+ });
+
+ it('prints no paused paragraph when the only paused queue holds none of this account’s rows', () => {
+ // The account never used the paused queue, so nothing it is shown is
+ // affected: the banner must be judged against what is listed, not
+ // against every queue the page happens to follow.
+ mockVault.exits = [exit({ id: '7', queueAddress: QUEUE })];
+ mockVault.paused = true;
+ mockVault.pausedByQueue = { [QUEUE]: false, [OTHER_QUEUE]: true };
+ const { container } = render( );
+
+ expect(
+ container.querySelector('[data-layout-id="perimeter-paused"]'),
+ ).not.toBeInTheDocument();
+ expect(releaseButton(container, QUEUE, '7')).toBeInTheDocument();
+ });
+
+ it('tells the row itself is paused, not the whole perimeter, when its status tooltip is opened', () => {
+ mockVault.exits = [
+ exit({ id: '7', queueAddress: QUEUE }),
+ exit({ id: '9', queueAddress: OTHER_QUEUE }),
+ ];
+ mockVault.paused = true;
+ mockVault.pausedByQueue = { [QUEUE]: true, [OTHER_QUEUE]: false };
+ const { container } = render( );
+
+ const helper = container.querySelector(
+ `[data-layout-id="perimeter-status-${exitKey({
+ queueAddress: QUEUE,
+ id: '7',
+ })}"]`,
+ );
+ fireEvent.click(helper!);
+
+ expect(
+ screen.queryByText(/paused across the whole perimeter/i),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText(/paused for this withdrawal/i)).toBeInTheDocument();
+ });
+
+ it('offers Release to an account that is only the receiver, and presses through to it', () => {
+ const row = exit({
+ originator: RECEIVER,
+ owner: RECEIVER,
+ receiver: ACCOUNT,
+ });
+ mockVault.exits = [row];
+ const { container } = render( );
+
+ expect(screen.getAllByText('Ready').length).toBeGreaterThan(0);
+ const button = releaseButton(container, QUEUE, '7');
+ expect(button).toBeInTheDocument();
+
+ fireEvent.click(button!);
+
+ expect(mockRelease).toHaveBeenCalledTimes(1);
+ const [rows] = mockRelease.mock.calls[0];
+ expect(rows).toEqual([expect.objectContaining({ id: '7' })]);
+ });
+
+ it('withholds Release from an account that is neither party nor receiver', () => {
+ const STRANGER = '0x4444444444444444444444444444444444444444';
+ mockVault.exits = [
+ exit({
+ originator: RECEIVER,
+ owner: RECEIVER,
+ receiver: STRANGER,
+ }),
+ ];
+ const { container } = render( );
+
+ expect(
+ screen.getAllByText('Releasable by the position owner').length,
+ ).toBeGreaterThan(0);
+ expect(releaseButton(container, QUEUE, '7')).not.toBeInTheDocument();
+ });
+
+ it('hands every ready row, and no other, to one release', () => {
+ mockVault.exits = [
+ exit({ id: '7' }),
+ exit({ id: '8' }),
+ exit({ id: '9', unlockAt: NOW + 3600 }),
+ ];
+ render( );
+
+ fireEvent.click(screen.getByText('Release all ready (2)'));
+
+ expect(mockRelease).toHaveBeenCalledTimes(1);
+ const [rows] = mockRelease.mock.calls[0];
+ expect(rows.map((row: { id: string }) => row.id)).toEqual(['7', '8']);
+ });
+
+ it('drops a row once its release completes, so a later batch cannot carry it', () => {
+ // executeExits is atomic on-chain: a terminal id reverts the batch and
+ // takes every other ready release down with it.
+ mockRelease.mockImplementation(
+ (rows: { queueAddress: string; id: string }[], onReleased) =>
+ onReleased(asExecuted(rows)),
+ );
+ mockVault.exits = [exit({ id: '7' }), exit({ id: '8' }), exit({ id: '9' })];
+ const { container } = render( );
+
+ expect(screen.getByText('Release all ready (3)')).toBeInTheDocument();
+
+ fireEvent.click(releaseButton(container, QUEUE, '7')!);
+
+ expect(releaseButton(container, QUEUE, '7')).not.toBeInTheDocument();
+ expect(screen.getByText('Release all ready (2)')).toBeInTheDocument();
+ });
+
+ it('drops every row a batch release completed', () => {
+ mockRelease.mockImplementation(
+ (rows: { queueAddress: string; id: string }[], onReleased) =>
+ onReleased(asExecuted(rows)),
+ );
+ mockVault.exits = [exit({ id: '7' }), exit({ id: '8' })];
+ render( );
+
+ fireEvent.click(screen.getByText('Release all ready (2)'));
+
+ expect(screen.queryByText(/Release all ready/)).not.toBeInTheDocument();
+ expect(screen.getAllByText(NO_DELAYED).length).toBeGreaterThan(0);
+ });
+
+ it('keeps two queues’ requests that share an id apart', () => {
+ // Ids restart in each queue. Releasing request 7 in one queue must not
+ // take request 7 of the other off the page.
+ mockRelease.mockImplementation(
+ (rows: { queueAddress: string; id: string }[], onReleased) =>
+ onReleased(asExecuted(rows)),
+ );
+ mockVault.exits = [
+ exit({ id: '7', queueAddress: QUEUE }),
+ exit({ id: '7', queueAddress: OTHER_QUEUE }),
+ ];
+ const { container } = render( );
+
+ expect(releaseButton(container, QUEUE, '7')).toBeInTheDocument();
+ expect(releaseButton(container, OTHER_QUEUE, '7')).toBeInTheDocument();
+
+ fireEvent.click(releaseButton(container, QUEUE, '7')!);
+
+ expect(releaseButton(container, QUEUE, '7')).not.toBeInTheDocument();
+ expect(releaseButton(container, OTHER_QUEUE, '7')).toBeInTheDocument();
+ });
+
+ it('withholds the batch button when only one row is ready', () => {
+ mockVault.exits = [
+ exit({ id: '7' }),
+ exit({ id: '8', unlockAt: NOW + 60 }),
+ ];
+ render( );
+ expect(screen.queryByText(/Release all ready/)).not.toBeInTheDocument();
+ });
+
+ it('shows the contract-owner notice on a row whose owner has code', () => {
+ mockVault.exits = [exit({ ownerHasCode: true })];
+ const { container } = render( );
+
+ expect(
+ container.querySelector(
+ `[data-layout-id="perimeter-contract-owner-${exitKey({
+ queueAddress: QUEUE,
+ id: '7',
+ })}"]`,
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getAllByText(
+ 'Owned by a contract: the product you used holds this withdrawal.',
+ ).length,
+ ).toBeGreaterThan(0);
+ });
+
+ it('hides the contract-owner notice on a row whose owner is a plain wallet', () => {
+ mockVault.exits = [exit({ ownerHasCode: false })];
+ render( );
+
+ expect(
+ screen.queryByText(
+ 'Owned by a contract: the product you used holds this withdrawal.',
+ ),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.tsx b/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.tsx
new file mode 100644
index 000000000..5f86b2b7b
--- /dev/null
+++ b/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.tsx
@@ -0,0 +1,437 @@
+import React, {
+ FC,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import { t } from 'i18next';
+import { Helmet } from 'react-helmet-async';
+
+import {
+ Button,
+ ButtonSize,
+ ButtonStyle,
+ Heading,
+ HelperButton,
+ Paragraph,
+ ParagraphSize,
+ Table,
+ Tooltip,
+ TooltipTrigger,
+} from '@sovryn/ui';
+
+import { RSK_CHAIN_ID } from '../../../config/chains';
+
+import { AmountRenderer } from '../../2_molecules/AmountRenderer/AmountRenderer';
+import { NetworkBanner } from '../../2_molecules/NetworkBanner/NetworkBanner';
+import {
+ BTC_RENDER_PRECISION,
+ TOKEN_RENDER_PRECISION,
+} from '../../../constants/currencies';
+import { useChainTime } from '../../../hooks/exitDelay/useChainTime';
+import {
+ ReleaseEntry,
+ usePerimeterRelease,
+} from '../../../hooks/exitDelay/usePerimeterRelease';
+import { usePerimeterVault } from '../../../hooks/exitDelay/usePerimeterVault';
+import { useAccount } from '../../../hooks/useAccount';
+import { useChainStore } from '../../../hooks/useChainStore';
+import { useWalletConnect } from '../../../hooks/useWalletConnect';
+import { translations } from '../../../locales/i18n';
+import {
+ canExecuteExit,
+ exitKey,
+ getPendingExitState,
+ isPendingExitStatus,
+} from '../../../utils/exitDelay';
+import {
+ PerimeterExitRow,
+ clearPerimeterHistoryKeys,
+ getStatusLabel,
+ getStatusTooltip,
+ getTimeToRelease,
+ shortenAddress,
+} from './PerimeterPage.utils';
+
+/** Bitcoin-denominated rows need every satoshi; everything else does not. */
+const precisionFor = (symbol?: string): number =>
+ symbol === 'BTC' || symbol === 'WBTC' || symbol === 'RBTC'
+ ? BTC_RENDER_PRECISION
+ : TOKEN_RENDER_PRECISION;
+
+const PerimeterPage: FC = () => {
+ const { account } = useAccount();
+ const { exits, pausedByQueue, paused, loading, unknown } =
+ usePerimeterVault();
+
+ // Every withdrawal here lives in the RSK vault. The release checks its own
+ // network at the press regardless; this only decides whether the button
+ // offers itself as pressable. Wrong network is judged the same way the
+ // header's own check judges it: the app's selected network, or a connected
+ // wallet whose own network disagrees with it.
+ const { currentChainId } = useChainStore();
+ const { wallets } = useWalletConnect();
+ const walletConnected = !!wallets[0]?.accounts[0]?.address;
+ const wrongNetwork =
+ currentChainId !== RSK_CHAIN_ID ||
+ (walletConnected && wallets[0]?.chains[0]?.id !== RSK_CHAIN_ID);
+
+ // Chain time, not the browser's: the queue compares block.timestamp, and a
+ // machine whose clock runs fast would otherwise offer a release that reverts
+ // and, in a batch, revert every other release with it. `now` ticks and drives
+ // the countdown; `blockTime` is the latest block's own timestamp, and a row is
+ // ready only once that reaches its unlock time. Zero means the chain clock has
+ // not been read yet; `clockUnreadable` means it could not be.
+ const {
+ now,
+ blockTime,
+ unreadable: clockUnreadable,
+ } = useChainTime(RSK_CHAIN_ID);
+
+ // Nothing on the page is stated from a read that did not complete: neither
+ // the vault's own reads nor the clock every row is judged against.
+ const readFailed = unknown || clockUnreadable;
+
+ // Withdrawals released in this session, by queue and id, held until the next
+ // vault read drops them. A row released one moment and carried by a "release
+ // all" the next would revert the whole atomic batch, since its status is not
+ // Queued. Ids restart in each queue, so the queue is part of the key.
+ // This is this tab's own session memory, not read from chain, so nothing
+ // scopes it to an account unless this does: nothing else remounts the page
+ // on an account change. Cleared the moment the connected account changes,
+ // so a row released under one account is never carried into another's live
+ // filtering as though it were theirs. Every read this page drives is
+ // pinned to RSK_CHAIN_ID regardless of the wallet's or the app's selected
+ // chain (see usePerimeterVault), so there is no equivalent chain-scoped
+ // state here to reset on a chain change.
+ const [releasedKeys, setReleasedKeys] = useState>(new Set());
+
+ useEffect(() => {
+ setReleasedKeys(new Set());
+ }, [account]);
+
+ // Once, on the first mount of this page: removes local-storage keys under
+ // `perimeter/history/`, the prefix nothing else in this app writes under.
+ useEffect(() => {
+ clearPerimeterHistoryKeys();
+ }, []);
+
+ const markReleased = useCallback((entries: ReleaseEntry[]) => {
+ setReleasedKeys(prev => {
+ const next = new Set(prev);
+ entries.forEach(({ key }) => next.add(key));
+ return next;
+ });
+ }, []);
+
+ const release = usePerimeterRelease();
+
+ // What the page lists: the vault's own rows, minus this session's released
+ // ones and minus any row the vault itself reports as done. A release drops
+ // its row from here at once, ahead of the vault's next read, and a row the
+ // vault reports with a stated status other than Queued — released by
+ // another party, or resolved away by the Owner — is trusted and dropped
+ // the same way the moment that read lands, rather than shown with a label
+ // for what became of it. Neither kind of row ever comes back: the filter
+ // is re-applied on every vault read, not just the read that first dropped
+ // it. A status the vault could not state at all stays listed instead, so a
+ // read failure is never mistaken for a withdrawal that already left.
+ const liveExits = useMemo(
+ () =>
+ exits.filter(
+ exit =>
+ !releasedKeys.has(exitKey(exit)) && isPendingExitStatus(exit.status),
+ ),
+ [exits, releasedKeys],
+ );
+
+ const rows: PerimeterExitRow[] = useMemo(
+ () =>
+ liveExits.map(exit => ({
+ ...exit,
+ state: getPendingExitState(
+ exit,
+ pausedByQueue[exit.queueAddress] ?? paused,
+ account,
+ { now, blockTime },
+ ),
+ })),
+ [account, blockTime, liveExits, now, paused, pausedByQueue],
+ );
+
+ // Whether releases are paused for what is actually listed below, rather
+ // than for every queue the page happens to follow: a queue holding none of
+ // this account's rows must never make the banner speak for the list. The
+ // same per-row fallback the Status column uses keeps a row's own queue
+ // counted even if it is missing from the map.
+ const pausedStatesListed = useMemo(
+ () =>
+ [...new Set(rows.map(row => row.queueAddress))].map(
+ queueAddress => pausedByQueue[queueAddress] ?? paused,
+ ),
+ [rows, pausedByQueue, paused],
+ );
+ const somePausedListed = pausedStatesListed.some(Boolean);
+ const allPausedListed =
+ pausedStatesListed.length > 0 && pausedStatesListed.every(Boolean);
+
+ // The release checks take several round trips before the dialog opens. A
+ // second release started meanwhile would replace the first one's
+ // transaction list in the dialog, so every release control waits while one
+ // runs, and the control that started it says it is checking. The ref stops
+ // a second press that lands before the page renders again.
+ const releaseRunning = useRef(false);
+ const [checking, setChecking] = useState<
+ { keys: string[]; all: boolean } | undefined
+ >();
+ const releaseChecked = useCallback(
+ async (targets: PerimeterExitRow[], all: boolean) => {
+ if (releaseRunning.current) {
+ return;
+ }
+ releaseRunning.current = true;
+ setChecking({ keys: targets.map(exitKey), all });
+ try {
+ await release(targets, markReleased);
+ } finally {
+ releaseRunning.current = false;
+ setChecking(undefined);
+ }
+ },
+ [markReleased, release],
+ );
+
+ const handleRelease = useCallback(
+ (row: PerimeterExitRow) => releaseChecked([row], false),
+ [releaseChecked],
+ );
+
+ // Release all offers exactly the rows the per-row button would. Whether each
+ // one's parties are blocked is read when it is pressed, and only the rows
+ // that read clear are sent.
+ const releasableRows = useMemo(
+ () => (now ? rows.filter(row => canExecuteExit(row.state)) : []),
+ [now, rows],
+ );
+
+ const handleReleaseAll = useCallback(
+ () => releaseChecked(releasableRows, true),
+ [releaseChecked, releasableRows],
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'id',
+ title: t(translations.perimeterPage.table.id),
+ cellRenderer: (row: PerimeterExitRow) => `#${row.id}`,
+ },
+ {
+ id: 'amount',
+ title: t(translations.perimeterPage.table.amount),
+ // One queue holds every asset the perimeter covers, so a bare number
+ // says nothing: 1.5 RBTC and 1.5 DOC are three orders of magnitude
+ // apart. An asset whose decimals could not be resolved carries no
+ // amount at all rather than one scaled by a guess.
+ cellRenderer: (row: PerimeterExitRow) =>
+ row.amount ? (
+
+ ) : (
+ t(translations.perimeterPage.table.unknownAsset)
+ ),
+ },
+ {
+ id: 'receiver',
+ title: t(translations.perimeterPage.table.receiver),
+ cellRenderer: (row: PerimeterExitRow) => shortenAddress(row.receiver),
+ },
+ {
+ id: 'unlocks',
+ title: t(translations.perimeterPage.table.unlocks),
+ cellRenderer: (row: PerimeterExitRow) =>
+ getTimeToRelease(row.unlockAt, now, row.state),
+ },
+ {
+ id: 'status',
+ title: t(translations.perimeterPage.table.status),
+ cellRenderer: (row: PerimeterExitRow) => (
+
+
+ {getStatusLabel(row.state)}
+
+
+ {row.ownerHasCode && (
+
+ {t(translations.perimeterPage.contractOwnerNotice)}
+
+ )}
+
+ ),
+ },
+ {
+ id: 'action',
+ title: t(translations.perimeterPage.table.action),
+ cellRenderer: (row: PerimeterExitRow) =>
+ // Offered for exactly the states the contract's time, pause and
+ // executor checks accept; the block check runs on the press itself.
+ canExecuteExit(row.state) ? (
+
+ handleRelease(row)}
+ dataAttribute={`perimeter-release-${exitKey(row)}`}
+ />
+
+ }
+ />
+ ) : null,
+ },
+ ],
+ [checking, handleRelease, now, wrongNetwork],
+ );
+
+ // "No delayed withdrawals" is a definitive statement, and only a
+ // completed read earns it. A read that failed says so instead.
+ const emptyMessage = useMemo(() => {
+ if (!account) {
+ return t(translations.perimeterPage.connectWallet);
+ }
+ return readFailed
+ ? t(translations.perimeterPage.unreadable)
+ : t(translations.perimeterPage.inactive);
+ }, [account, readFailed]);
+
+ return (
+ <>
+