diff --git a/.changeset/perimeter-hold-review-fixes.md b/.changeset/perimeter-hold-review-fixes.md new file mode 100644 index 000000000..8f680714e --- /dev/null +++ b/.changeset/perimeter-hold-review-fixes.md @@ -0,0 +1,5 @@ +--- +'frontend': patch +--- + +Perimeter withdraw queue fixes: exclude a just-released hold from the batch release so it cannot revert the whole atomic transaction; stop showing the withdrawal-hold notice on a borrow or add-collateral adjust that removes nothing; correct the "Releasable by the owner" status label and use the full "Sovryn Perimeter withdraw queue" name in the hold notification. diff --git a/.changeset/perimeter-hold-visibility.md b/.changeset/perimeter-hold-visibility.md new file mode 100644 index 000000000..23bfab18b --- /dev/null +++ b/.changeset/perimeter-hold-visibility.md @@ -0,0 +1,5 @@ +--- +'frontend': minor +--- + +Held withdrawals now say where the money went: a visible Perimeter withdraw queue notice with a link in every charged form, and a post-signature notification on the withdrawal itself. The Perimeter withdraw queue page is reached from the Perimeter entry in the account menu and gains a one-transaction release of all ready holds. diff --git a/.changeset/perimeter-withdrawal-hold.md b/.changeset/perimeter-withdrawal-hold.md new file mode 100644 index 000000000..5c1323b1e --- /dev/null +++ b/.changeset/perimeter-withdrawal-hold.md @@ -0,0 +1,5 @@ +--- +'frontend': minor +--- + +feat: Sovryn Perimeter withdrawal hold. Shows how long a withdrawal will be held before it is paid out, on lending withdrawals, borrower exits and Zero collateral withdrawal/close, and adds a Perimeter withdraw queue page at /perimeter listing the withdrawals currently held for the connected account with the state each is in and a Release action for the ones that are ready. Also corrects the on-chain surface ids after the contracts renamed them, without which the existing fee display would silently stop matching any configured policy. Gated purely on on-chain state and fail-hidden: while the perimeter is undeployed, unwired or disabled every form renders exactly as it does today and the withdraw queue page reads as empty. diff --git a/apps/frontend/README.md b/apps/frontend/README.md index a3358b480..155841988 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -15,3 +15,6 @@ yarn --cwd apps/frontend serve To use custom environment variables locally, create a file named `.env.local` and add required values (see `.env.example` for sample variables and values). For environment variables used in deployments see `netlify.toml`. + +For optional deployment inputs that keep Perimeter withdrawals discoverable after +consumer rollback, see [Perimeter queue discovery](docs/perimeter-queue-discovery.md). diff --git a/apps/frontend/docs/perimeter-queue-discovery.md b/apps/frontend/docs/perimeter-queue-discovery.md new file mode 100644 index 000000000..3720ebb7f --- /dev/null +++ b/apps/frontend/docs/perimeter-queue-discovery.md @@ -0,0 +1,41 @@ +# Perimeter queue discovery after rollback + +The withdraw queue page follows the lending and Zero consumer queue pointers and +remembers discovered queues for the current tab. A fresh tab after those getters +are retired also needs a deployment queue recorded in the frontend build. + +These optional public build inputs are selected only for the configured Rootstock +read network. They are compiled into the bundle and must contain no secrets. + +| Network | Queue address | Expected code hash | +| ------------------ | --------------------------------------- | ---------------------------------------------------- | +| Mainnet (chain 30) | `REACT_APP_PERIMETER_QUEUE_RSK_MAINNET` | `REACT_APP_PERIMETER_QUEUE_RSK_MAINNET_RUNTIME_HASH` | +| Testnet (chain 31) | `REACT_APP_PERIMETER_QUEUE_RSK_TESTNET` | `REACT_APP_PERIMETER_QUEUE_RSK_TESTNET_RUNTIME_HASH` | + +Leave both inputs unset when no Perimeter queue has been deployed. An unset pair +preserves the existing Phase 1 absence behavior. A nonzero address requires a +32-byte `0x`-prefixed hash. Mainnet inputs are never reused for testnet or another +chain. Invalid or incomplete inputs, a provider network mismatch, empty or +mismatched code, and failed validation reads produce the existing unreadable +warning; the unverified fallback address is not queried for withdrawals. + +The hash is **Keccak-256 of the complete runtime bytes returned by `eth_getCode` +at the queue address**, including metadata. For an ERC1967Proxy deployment this +is the **proxy runtime code hash**, not the implementation hash. Matching that +hash does not establish the proxy's implementation, storage slots, initialization, +authority or full implementation runtime. Those require independent deployment +and release verification before these public build inputs are supplied. + +Validated fallback queues are unioned with consumer-derived and remembered +queues. Duplicate queue addresses and request IDs are removed. Consumer read +failures remain visible even when fallback withdrawals can be listed. Validation +uses the existing 30-second cache and bounded reads. Each validation asks every +available JSON-RPC backend for `eth_chainId`; cached static-provider network +metadata is insufficient. A mismatching or unreadable backend rejects the +fallback validation. Keep the standalone queue +inputs in subsequent builds while it has outstanding requests after rollback. + +Use the actual deployment address and independently verified expected code hash; +unit-test addresses and dummy bytecode are not deployment inputs. Changes to +these values require a new frontend build. This fallback restores discovery; +release eligibility, signing checks and on-chain payment rules remain separate. diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6ef826c24..8e8c3054f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -102,6 +102,7 @@ "test": "craco test --watchAll=false --passWithNoTests", "test:staged": "craco test --watchAll=false --passWithNoTests --bail --onlyChanged", "lint": "eslint -c .eslintrc.js ./", + "typecheck": "tsc -p tsconfig.typecheck.json", "generate:graphql": "graphql-codegen", "generate:graphql:fetch:testnet": "env-cmd -f .env.development.local graphql-codegen -c codegen.fetch.yml", "generate:graphql:fetch:mainnet": "env-cmd -f .env.staging graphql-codegen -c codegen.fetch.yml", diff --git a/apps/frontend/scripts/typecheck-stubs/opaque.d.ts b/apps/frontend/scripts/typecheck-stubs/opaque.d.ts new file mode 100644 index 000000000..85d8d4886 --- /dev/null +++ b/apps/frontend/scripts/typecheck-stubs/opaque.d.ts @@ -0,0 +1,10 @@ +/** + * Stand-in for a dependency whose shipped types this project's TypeScript + * cannot parse. `skipLibCheck` does not help: those errors are SYNTACTIC, and + * tsc stops before reporting a single semantic diagnostic — which is why the + * typecheck passed while nothing in `src/` was being checked at all. + * Redirecting the offending modules here keeps them out of the parse, at the + * cost of typing them as `any`. + */ +declare const opaque: any; +export = opaque; diff --git a/apps/frontend/src/app/2_molecules/ConnectWalletButton/ConnectWalletButton.test.tsx b/apps/frontend/src/app/2_molecules/ConnectWalletButton/ConnectWalletButton.test.tsx new file mode 100644 index 000000000..b4c5eae6c --- /dev/null +++ b/apps/frontend/src/app/2_molecules/ConnectWalletButton/ConnectWalletButton.test.tsx @@ -0,0 +1,108 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import React from 'react'; + +import 'jest-canvas-mock'; +import { MemoryRouter } from 'react-router-dom'; + +import { ChainIds } from '@sovryn/ethers-provider'; + +import { i18n } from '../../../locales/i18n'; +import { ConnectWalletButton } from './ConnectWalletButton'; + +const ACCOUNT = '0x1111111111111111111111111111111111111111'; +const ID = 'dapp-header-connect'; + +let mockChainId: string; + +jest.mock('nanoid', () => ({ nanoid: () => '1234' })); + +jest.mock('../../../contexts/NotificationContext', () => ({ + useNotificationContext: () => ({ addNotification: jest.fn() }), +})); + +jest.mock('../../../hooks/useChainStore', () => ({ + useCurrentChain: () => mockChainId, +})); + +const openMenu = (chainId: string = ChainIds.RSK_MAINNET) => { + mockChainId = chainId; + const view = render( + + + , + ); + userEvent.click(screen.getByText(/^0x11/)); + return view; +}; + +const menuEntry = (name: string) => + document.querySelector( + `[data-layout-id="${ID}-menu-${name}"]`, + ) as HTMLElement; + +describe('ConnectWalletButton account menu', () => { + beforeAll(async () => { + await i18n; + }); + + beforeEach(() => { + // The address badge draws an identicon on a canvas; the test config resets + // the canvas mock before every test, so give the canvas back a minimal + // 2d context. + HTMLCanvasElement.prototype.getContext = (() => ({ + fillStyle: '', + fillRect: () => {}, + })) as unknown as HTMLCanvasElement['getContext']; + HTMLCanvasElement.prototype.toDataURL = () => ''; + }); + + it('opens the Perimeter page from the account menu', () => { + openMenu(); + + const entry = menuEntry('perimeter'); + expect(entry).toHaveTextContent(/^Perimeter queue$/); + expect(entry.closest('a')).toHaveAttribute('href', '/perimeter'); + }); + + it('lists the Perimeter entry after History and before Notifications', () => { + openMenu(); + + const order = Array.from( + document.querySelectorAll(`[data-layout-id^="${ID}-menu-"]`), + ) + .map(node => + node.getAttribute('data-layout-id')!.replace(`${ID}-menu-`, ''), + ) + .filter(name => + [ + 'portfolio', + 'rewards', + 'history', + 'perimeter', + 'notifications', + ].includes(name), + ); + + expect(order).toEqual([ + 'portfolio', + 'rewards', + 'history', + 'perimeter', + 'notifications', + ]); + }); + + it('shows the Perimeter entry on every chain, unlike Notifications', () => { + openMenu(ChainIds.BOB_MAINNET); + + expect(menuEntry('perimeter')).not.toHaveClass('hidden'); + expect(menuEntry('notifications')).toHaveClass('hidden'); + }); +}); diff --git a/apps/frontend/src/app/2_molecules/ConnectWalletButton/ConnectWalletButton.tsx b/apps/frontend/src/app/2_molecules/ConnectWalletButton/ConnectWalletButton.tsx index 640736e1a..1bdc13901 100644 --- a/apps/frontend/src/app/2_molecules/ConnectWalletButton/ConnectWalletButton.tsx +++ b/apps/frontend/src/app/2_molecules/ConnectWalletButton/ConnectWalletButton.tsx @@ -98,6 +98,12 @@ export const ConnectWalletButton: FC< dataAttribute={`${dataAttribute}-menu-history`} /> + + + { + return { nanoid: () => '1234' }; +}); + +jest.mock('../../../contexts/NotificationContext', () => { + return { + useNotificationContext: () => ({ + addNotification: jest.fn(), + }), + }; +}); + +const renderRow = (delaySeconds: number, unknown = false, loading = false) => + render( + + + , + ); + +describe('ExitDelayRow', () => { + beforeAll(async () => { + await i18n; + }); + + it('renders nothing when the perimeter imposes no delay', () => { + const { container } = renderRow(0); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows the hold duration in whole units', () => { + const { container } = renderRow(172800); + expect(screen.getByText('Withdrawal delay')).toBeInTheDocument(); + expect( + container.querySelector('[data-layout-id="exit-delay-duration"]'), + ).toHaveTextContent('2 days'); + }); + + it('uses the singular form for a one-unit hold', () => { + const { container } = renderRow(3600); + expect( + container.querySelector('[data-layout-id="exit-delay-duration"]'), + ).toHaveTextContent('1 hour'); + }); + + it('rounds a partial unit up, never down', () => { + const { container } = renderRow(5400); + expect( + container.querySelector('[data-layout-id="exit-delay-duration"]'), + ).toHaveTextContent('2 hours'); + }); + + it('names the withdraw queue and links to the Perimeter page, visibly', () => { + const { container } = renderRow(3600); + const notice = container.querySelector( + '[data-test-id="exit-delay-vault-notice"]', + ); + expect(notice).toBeInTheDocument(); + expect(notice).toHaveTextContent(/Perimeter withdraw queue/i); + const link = container.querySelector( + '[data-test-id="exit-delay-vault-link"]', + ); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/perimeter'); + }); + + it('admits it could not check, instead of rendering as an unheld withdrawal', () => { + // The delay fails closed on chain: a quote we could not read means the + // withdrawal is held or it reverts. Rendering nothing would tell the user + // the money arrives now. + const { container } = renderRow(0, true); + expect(container).not.toBeEmptyDOMElement(); + expect( + container.querySelector('[data-layout-id="exit-delay-unknown"]'), + ).toHaveTextContent('Could not be checked'); + const notice = container.querySelector( + '[data-test-id="exit-delay-unknown-notice"]', + ); + expect(notice).toHaveTextContent(/could not check/i); + expect(notice).toHaveTextContent(/Perimeter withdraw queue/i); + expect( + container.querySelector('[data-layout-id="exit-delay-duration"]'), + ).not.toBeInTheDocument(); + }); + + it('says it is still checking while the quote is in flight, instead of rendering as an unheld withdrawal', () => { + const { container } = renderRow(0, false, true); + expect( + container.querySelector('[data-layout-id="exit-delay-checking"]'), + ).toHaveTextContent(/Checking/); + expect( + container.querySelector('[data-layout-id="exit-delay-duration"]'), + ).not.toBeInTheDocument(); + expect( + container.querySelector('[data-layout-id="exit-delay-unknown"]'), + ).not.toBeInTheDocument(); + }); + + it('explains where the funds go, on click', () => { + const { container } = renderRow(3600); + const helper = container.querySelector( + '[data-layout-id="exit-delay-helper"]', + ); + expect(helper).toBeInTheDocument(); + fireEvent.click(helper!); + expect( + screen.getByText(/held in the Sovryn Perimeter withdraw queue/i), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/app/2_molecules/ExitDelayRow/ExitDelayRow.tsx b/apps/frontend/src/app/2_molecules/ExitDelayRow/ExitDelayRow.tsx new file mode 100644 index 000000000..abf954adc --- /dev/null +++ b/apps/frontend/src/app/2_molecules/ExitDelayRow/ExitDelayRow.tsx @@ -0,0 +1,129 @@ +import React, { FC } from 'react'; + +import { t } from 'i18next'; +import { Link } from 'react-router-dom'; + +import { HelperButton, SimpleTableRow, TooltipTrigger } from '@sovryn/ui'; + +import { translations } from '../../../locales/i18n'; +import { + formatDelayDuration, + getExitDelayDisplay, +} from '../../../utils/exitDelay'; + +export type ExitDelayRowProps = { + /** Seconds the perimeter will hold this withdrawal; 0 renders nothing. */ + delaySeconds: number; + /** + * Whether the quote was obtained at all. Unlike the fee, this cannot be + * dropped: the chain fails closed on the delay, so an unobtained quote means + * the withdrawal is held or it reverts, never that it is paid straight out. + */ + unknown: boolean; + /** + * Whether the quote is still on its way. Required: a caller that forgot it + * would render a quote that has not arrived as a withdrawal paid now. + */ + loading: boolean; +}; + +const VaultLink: FC = () => ( + + {t(translations.exitDelay.vaultLink)} + +); + +/** + * Tells the holder, before they sign, that this withdrawal will be held rather + * than paid straight out — for how long, where it goes, and where to release + * it from. The destination notice is visible in the form itself, not tucked + * behind the tooltip: a held withdrawal does not arrive, and a user who was + * only told about a fee would read that as money missing. + * + * Silence is reserved for the one case that earns it: a quote that ARRIVED and + * said nothing is held. A quote still on its way, and a quote we could not + * obtain, each get a row saying so, because on chain the delay fails closed — + * the exit is escrowed, or it reverts with `PERIMETER:delay-quote-failed`. + * Rendering nothing there would state, in the only way a form can, that the + * money arrives now. + */ +export const ExitDelayRow: FC = ({ + delaySeconds, + unknown, + loading, +}) => { + const display = getExitDelayDisplay({ delaySeconds, unknown, loading }); + + if (display === 'none') { + return null; + } + + if (display === 'checking') { + return ( + + ); + } + + if (display === 'unknown') { + return ( + <> + + {t(translations.exitDelay.unknown.label)} + + + } + value={t(translations.exitDelay.unknown.value)} + dataAttribute="exit-delay-unknown" + /> +
+ {t(translations.exitDelay.unknown.notice)} +
+ + ); + } + + const { value, unit } = formatDelayDuration(delaySeconds); + const duration = t(translations.exitDelay.duration[unit], { count: value }); + + return ( + <> + + {t(translations.exitDelay.label)} + + + } + value={duration} + dataAttribute="exit-delay-duration" + /> +
+ {t(translations.exitDelay.vaultNotice)} +
+ + ); +}; diff --git a/apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.test.tsx b/apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.test.tsx index acd86f7cd..b43413adb 100644 --- a/apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.test.tsx +++ b/apps/frontend/src/app/2_molecules/ExitFeeRow/ExitFeeRow.test.tsx @@ -69,7 +69,7 @@ describe('ExitFeeRow', () => { expect(screen.getByText(/Perimeter fee \(0\.5%\)/)).toBeInTheDocument(); expect( screen.getByText( - /The perimeter fee is deducted from the withdrawn amount/, + /The Perimeter fee is deducted from the withdrawn amount/, ), ).toBeInTheDocument(); }); diff --git a/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.test.tsx b/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.test.tsx index 6b1cff64e..a9117f757 100644 --- a/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.test.tsx +++ b/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.test.tsx @@ -1,98 +1,216 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +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'; +import { SURFACE_ZERO_CLAIM_SURPLUS } from '../../../utils/exitFee'; import { LOCStatus } from './LOCStatus'; -const mockRate = { - active: true, - rateBps: 50, - loading: false, +/** + * The surplus claim is a Zero exit like the others: charged a Perimeter fee + * through Zero's own controller pointer, and held by the withdrawal delay when + * it applies. The card shows both before the borrower presses Withdraw, and + * does not offer Withdraw while the delay quote is still on its way. + */ + +let mockFee: { + active: boolean; + rateBps: number; + feeAmount: Decimal; + netAmount: Decimal; + loading: boolean; + unknown: boolean; }; +let mockFeeGross: { toString(): string } | undefined; +let mockDelay: { delaySeconds: number; loading: boolean; unknown: boolean }; +let mockDelaySurface: string | undefined; -jest.mock('nanoid', () => { - return { nanoid: () => '1234' }; -}); +jest.mock('nanoid', () => ({ nanoid: () => '1234' })); -jest.mock('../../../contexts/NotificationContext', () => { - return { - useNotificationContext: () => ({ - addNotification: jest.fn(), - }), - }; -}); +jest.mock('../../../contexts/NotificationContext', () => ({ + useNotificationContext: () => ({ addNotification: jest.fn() }), +})); -jest.mock('../../../hooks/exitFee/useExitFeeRate', () => ({ - useExitFeeRate: () => mockRate, +jest.mock('../../../hooks/exitFee/useZeroClaimExitFee', () => ({ + useZeroClaimExitFee: (gross: { toString(): string }) => { + mockFeeGross = gross; + return mockFee; + }, })); -describe('LOCStatus perimeter fee', () => { - beforeAll(async () => { - await i18n; - }); +jest.mock('../../../hooks/exitDelay/useZeroExitDelayQuote', () => ({ + useZeroExitDelayQuote: (surface: string) => { + mockDelaySurface = surface; + return mockDelay; + }, +})); - beforeEach(() => { - Object.assign(mockRate, { active: true, rateBps: 50, loading: false }); - }); +const NO_DELAY = { delaySeconds: 0, loading: false, unknown: false }; - it('shows the NET surplus with a fee tooltip when the fee is active', () => { - const { container } = render( +const renderStatus = (withdrawalSurplus = Decimal.from('0.4')) => + render( + , - ); + /> + , + ); - expect(screen.getByText(/0\.398/)).toBeInTheDocument(); - expect(screen.queryByText(/^0\.4 /)).not.toBeInTheDocument(); - // "Perimeter fee" only lives inside the (closed) tooltip, not inline. - expect(screen.queryByText(/^Perimeter fee/)).not.toBeInTheDocument(); +const withdrawButton = (container: HTMLElement) => + container.querySelector('[data-layout-id="zero-loc-surplus-withdraw"]'); - const helperIcon = container.querySelector( - '[data-layout-id="exit-fee-helper"]', - ); - expect(helperIcon).toBeInTheDocument(); +describe('LOCStatus surplus claim', () => { + beforeAll(async () => { + await i18n; + }); - fireEvent.click(helperIcon as Element); - expect(screen.getByText(/Perimeter fee \(0\.5%\)/)).toBeInTheDocument(); + beforeEach(() => { + mockFee = { + active: true, + rateBps: 10, + feeAmount: Decimal.from('0.0004'), + netAmount: Decimal.from('0.3996'), + loading: false, + unknown: false, + }; + mockFeeGross = undefined; + mockDelay = NO_DELAY; + mockDelaySurface = undefined; }); - it('shows the gross surplus with no helper icon when the fee is inactive', () => { - Object.assign(mockRate, { active: false, rateBps: 0 }); + describe('Perimeter fee', () => { + it("shows the claim quote's net, marked approximate, with the fee in the tooltip", () => { + const { container } = renderStatus(); - const { container } = render( - , - ); - - expect(screen.getByText('0.4 BTC')).toBeInTheDocument(); - expect(screen.queryByText(/Perimeter fee/)).not.toBeInTheDocument(); - expect( - container.querySelector('[data-layout-id="exit-fee-helper"]'), - ).not.toBeInTheDocument(); + expect(mockFeeGross?.toString()).toBe('0.4'); + expect(screen.getByText(/0\.3996/)).toBeInTheDocument(); + expect(container.textContent).toContain('~'); + + fireEvent.click( + container.querySelector('[data-layout-id="exit-fee-helper"]')!, + ); + expect(screen.getByText(/Perimeter fee \(0\.1%\)/)).toBeInTheDocument(); + }); + + it('shows the gross surplus with no fee helper when no fee is charged', () => { + mockFee = { ...mockFee, active: false, rateBps: 0 }; + + const { container } = renderStatus(); + + expect(screen.getByText('0.4 BTC')).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 surplus with a note when the fee could not be checked, not as a settled amount', () => { + mockFee = { ...mockFee, active: false, rateBps: 0, unknown: true }; + + const { container } = renderStatus(); + + expect(screen.getByText('0.4 BTC')).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('does not render the surplus stat at all when there is no surplus', () => { + renderStatus(Decimal.ZERO); + + expect(screen.queryByText('withdrawal surplus')).not.toBeInTheDocument(); + }); + + it('waits for the fee quote even once the delay has settled', () => { + mockFee.loading = true; + + const { container } = renderStatus(); + + expect(withdrawButton(container)).toBeDisabled(); + }); + + it('offers Withdraw once the fee quote has settled', () => { + mockFee.loading = false; + + const { container } = renderStatus(); + + expect(withdrawButton(container)).toBeEnabled(); + }); }); - it('does not render the surplus stat at all when there is no surplus', () => { - render( - , - ); + describe('withdrawal delay', () => { + it('quotes the delay for the claim surface', () => { + renderStatus(); + + expect(mockDelaySurface).toBe(SURFACE_ZERO_CLAIM_SURPLUS); + }); + + it('tells the borrower the claim will be held, and for how long', () => { + mockDelay = { delaySeconds: 172800, loading: false, unknown: false }; + + const { container } = renderStatus(); + + expect(screen.getByText('Withdrawal delay')).toBeInTheDocument(); + expect(screen.getByText('2 days')).toBeInTheDocument(); + expect( + container.querySelector('[data-test-id="exit-delay-vault-notice"]'), + ).toBeInTheDocument(); + }); + + it('admits it could not check whether the claim will be held', () => { + mockDelay = { delaySeconds: 0, loading: false, unknown: true }; + + renderStatus(); + + expect(screen.getByText('Could not be checked')).toBeInTheDocument(); + }); + + it('waits for the delay quote before offering Withdraw, and says it is checking', () => { + mockDelay = { delaySeconds: 0, loading: true, unknown: false }; + + const { container } = renderStatus(); + + expect(screen.getByText(/^Checking/)).toBeInTheDocument(); + expect(withdrawButton(container)).toBeDisabled(); + }); + + it('offers Withdraw once a quote arrived', () => { + mockDelay = { delaySeconds: 172800, loading: false, unknown: false }; + + const { container } = renderStatus(); + + expect(withdrawButton(container)).toBeEnabled(); + }); + + it('offers Withdraw when the quote could not be read', () => { + mockDelay = { delaySeconds: 0, loading: false, unknown: true }; + + const { container } = renderStatus(); + + expect(withdrawButton(container)).toBeEnabled(); + }); + + it('shows no delay row without a surplus to claim', () => { + mockDelay = { delaySeconds: 172800, loading: false, unknown: false }; + + renderStatus(Decimal.ZERO); - expect(screen.queryByText('withdrawal surplus')).not.toBeInTheDocument(); + expect(screen.queryByText('Withdrawal delay')).not.toBeInTheDocument(); + }); }); }); diff --git a/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.tsx b/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.tsx index f357bd942..44dde3651 100644 --- a/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.tsx +++ b/apps/frontend/src/app/2_molecules/LOCStatus/LOCStatus.tsx @@ -1,7 +1,6 @@ import React, { FC, useMemo } from 'react'; import classNames from 'classnames'; -import { constants } from 'ethers'; import { t } from 'i18next'; import CountUp from 'react-countup'; @@ -10,6 +9,7 @@ import { ButtonSize, ButtonStyle, HelperButton, + SimpleTable, TooltipTrigger, } from '@sovryn/ui'; import { Decimal } from '@sovryn/utils'; @@ -20,14 +20,17 @@ import { BTC_RENDER_PRECISION, TOKEN_RENDER_PRECISION, } from '../../../constants/currencies'; -import { useExitFeeRate } from '../../../hooks/exitFee/useExitFeeRate'; +import { useZeroExitDelayQuote } from '../../../hooks/exitDelay/useZeroExitDelayQuote'; +import { useZeroClaimExitFee } from '../../../hooks/exitFee/useZeroClaimExitFee'; +import { translations } from '../../../locales/i18n'; import { COMMON_SYMBOLS } from '../../../utils/asset'; +import { getExitDelayDisplay } from '../../../utils/exitDelay'; import { SURFACE_ZERO_CLAIM_SURPLUS, - getExitFeeAmount, getExitFeeDisplay, } from '../../../utils/exitFee'; import { AmountRenderer } from '../AmountRenderer/AmountRenderer'; +import { ExitDelayRow } from '../ExitDelayRow/ExitDelayRow'; import { ExitFeeTooltipContent } from '../ExitFeeRow/ExitFeeRow'; import { CRatioIndicator } from './components/CRatioIndicator/CRatioIndicator'; import { LOCStat } from './components/LOCStat/LOCStat'; @@ -60,29 +63,20 @@ export const LOCStatus: FC = ({ const ratio = useMemo(() => parseInt(cRatio.toString()), [cRatio]); - const { - active: exitFeeActive, - rateBps: exitFeeRateBps, - unknown: exitFeeUnknown, - loading: exitFeeLoading, - } = useExitFeeRate( - SURFACE_ZERO_CLAIM_SURPLUS, - constants.AddressZero, // ethers constants — subProduct dimension unused for Zero - ); - const surplusExitFee = useMemo( - () => getExitFeeAmount(withdrawalSurplus, exitFeeRateBps), - [withdrawalSurplus, exitFeeRateBps], - ); - const exitFeeDisplay = getExitFeeDisplay( - { - active: exitFeeActive, - rateBps: exitFeeRateBps, - unknown: exitFeeUnknown, - loading: exitFeeLoading, - }, - surplusExitFee, - ); - const showSurplusExitFee = exitFeeDisplay === 'charged'; + // The claim is a Zero exit: its fee is quoted through BorrowerOperations' + // own controller pointer for this surplus, and its withdrawal delay for the + // claim surface. + const claimFee = useZeroClaimExitFee(withdrawalSurplus); + const showSurplusExitFee = + getExitFeeDisplay(claimFee, claimFee.feeAmount) === 'charged'; + // The claim's fee row stays hidden for this too — the chain fails open, so + // an unread quote is not a stated fee — but the figure below states the + // whole surplus as what the borrower will get, which needs its own + // qualifier when the quote never arrived. + const surplusFeeUnknown = !showSurplusExitFee && claimFee.unknown; + + const claimDelay = useZeroExitDelayQuote(SURFACE_ZERO_CLAIM_SURPLUS); + const showClaimDelay = getExitDelayDisplay(claimDelay) !== 'none'; return (
= ({ } trigger={TooltipTrigger.click} @@ -119,20 +112,42 @@ export const LOCStatus: FC = ({ value={ showSurplusExitFee ? ( ) : ( `${withdrawalSurplus} ${BITCOIN}` ) } + note={ + surplusFeeUnknown ? ( +
+ {t(translations.exitFee.unknownNotice)} +
+ ) : undefined + } /> )} + {hasWithdrawalSurplus && showClaimDelay && ( + + + + )} {showOpenLOC && ( <> = ({ onClick={onWithdraw} className="flex-1" dataAttribute="zero-loc-surplus-withdraw" + // Until the delay and fee quotes arrive the card cannot say + // whether the claim is paid now, held, or what it costs. + disabled={claimDelay.loading || claimFee.loading} /> )} {showOpenLOC && ( diff --git a/apps/frontend/src/app/2_molecules/LOCStatus/components/LOCStat/LOCStat.tsx b/apps/frontend/src/app/2_molecules/LOCStatus/components/LOCStat/LOCStat.tsx index efc10831a..f8a02852a 100644 --- a/apps/frontend/src/app/2_molecules/LOCStatus/components/LOCStat/LOCStat.tsx +++ b/apps/frontend/src/app/2_molecules/LOCStatus/components/LOCStat/LOCStat.tsx @@ -3,9 +3,11 @@ import React, { FC, ReactNode } from 'react'; export type LOCStatProps = { label: ReactNode; value: ReactNode; + /** Rendered below the value box, outside it — for a qualifier on the figure. */ + note?: ReactNode; }; -export const LOCStat: FC = ({ value, label }) => { +export const LOCStat: FC = ({ value, label, note }) => { return (
@@ -16,6 +18,7 @@ export const LOCStat: FC = ({ value, label }) => { {value}
+ {note}
); }; diff --git a/apps/frontend/src/app/3_organisms/Header/Header.constants.test.tsx b/apps/frontend/src/app/3_organisms/Header/Header.constants.test.tsx new file mode 100644 index 000000000..dad2c347f --- /dev/null +++ b/apps/frontend/src/app/3_organisms/Header/Header.constants.test.tsx @@ -0,0 +1,28 @@ +import { i18n } from '../../../locales/i18n'; +import type { MenuItem } from './Header.constants'; + +const urlsOf = (items: MenuItem[]): string[] => + items.flatMap(item => [item.url, ...(item.submenu ?? []).map(s => s.url)]); + +const textsOf = (items: MenuItem[]): string[] => + items.flatMap(item => [item.text, ...(item.submenu ?? []).map(s => s.text)]); + +describe('Header navigation items', () => { + let menuItemsMapping: MenuItem[]; + + beforeAll(async () => { + await i18n; + ({ menuItemsMapping } = await import('./Header.constants')); + }); + + it('does not list the Perimeter page in the top navigation', () => { + expect(urlsOf(menuItemsMapping)).not.toContain('/perimeter'); + expect(textsOf(menuItemsMapping).join(' ')).not.toMatch(/perimeter/i); + }); + + it('still lists the other top-level pages', () => { + expect(menuItemsMapping.map(item => item.url)).toEqual( + expect.arrayContaining(['/borrow', '/earn', '/convert', '/bitocracy']), + ); + }); +}); diff --git a/apps/frontend/src/app/3_organisms/TransactionStepDialog/TransactionStepDialog.tsx b/apps/frontend/src/app/3_organisms/TransactionStepDialog/TransactionStepDialog.tsx index 67567f1a9..dde70f7cb 100644 --- a/apps/frontend/src/app/3_organisms/TransactionStepDialog/TransactionStepDialog.tsx +++ b/apps/frontend/src/app/3_organisms/TransactionStepDialog/TransactionStepDialog.tsx @@ -59,6 +59,7 @@ export const TransactionStepDialog: FC = ({ gasPrice={gasPrice.toString()} onTxStatusChange={setTxStatus} setTxTrigger={setTxTrigger} + isOpen={isOpen} /> diff --git a/apps/frontend/src/app/3_organisms/TransactionStepDialog/TransactionStepDialog.types.ts b/apps/frontend/src/app/3_organisms/TransactionStepDialog/TransactionStepDialog.types.ts index 44cafb9ac..02ffc3f77 100644 --- a/apps/frontend/src/app/3_organisms/TransactionStepDialog/TransactionStepDialog.types.ts +++ b/apps/frontend/src/app/3_organisms/TransactionStepDialog/TransactionStepDialog.types.ts @@ -16,6 +16,8 @@ export interface TransactionConfig { amount?: BigNumberish; unlimitedAmount?: boolean; gasLimit?: BigNumberish; + /** Whether `gasLimit` was typed in Advanced settings rather than prepared by the dialog. */ + gasLimitTypedByUser?: boolean; gasPrice?: string; } @@ -38,12 +40,31 @@ export type TransactionUpdateHandler = { ) => TransactionRequest | Promise; }; +/** A send check's refusal: nothing was sent, for these reasons. */ +export type SendRefusal = Error & { notSentReasons: string[] }; + +export type TransactionPreflight = { + /** + * Runs inside the send step, after the user confirms and immediately + * before the wallet is asked to sign. The request and config it resolves to + * are what is sent in place of the step's own. When it rejects, nothing is + * sent: the step fails saying nothing was sent, with the reasons of a + * `SendRefusal` it rejects with, no failed transaction is reported, and + * Retry runs it again. + */ + beforeSend: (step: { + request: TransactionRequest; + config: TransactionConfig; + }) => Promise<{ request: TransactionRequest; config: TransactionConfig }>; +}; + export type Transaction = { title: string; subtitle?: string; request: TransactionRequest; } & Partial & - Partial; + Partial & + Partial; export enum TransactionType { signMessage = 'sign', diff --git a/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionStep/TransactionStep.tsx b/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionStep/TransactionStep.tsx index 097ee30d4..b3f31fd7c 100644 --- a/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionStep/TransactionStep.tsx +++ b/apps/frontend/src/app/3_organisms/TransactionStepDialog/components/TransactionStep/TransactionStep.tsx @@ -47,6 +47,7 @@ import { isSignTransactionDataRequest, isTransactionRequest, } from '../../helpers'; +import { resolveGasLimit } from '../../utils'; export type TransactionStepProps = { transaction: Transaction; @@ -57,6 +58,8 @@ export type TransactionStepProps = { updateConfig: (config: TransactionConfig) => void; gasPrice: string; isLoading: boolean; + /** Set when the step failed because its send check refused: why nothing was sent. */ + notSent?: string[]; }; export const TransactionStep: FC = ({ @@ -68,6 +71,7 @@ export const TransactionStep: FC = ({ gasPrice, updateConfig, isLoading, + notSent, }) => { const chainId = useCurrentChain(); const chain = useMemo(() => getChainById(chainId), [chainId]); @@ -123,13 +127,17 @@ export const TransactionStep: FC = ({ gasPrice: requestGasPrice, value, } = request; - const gasLimit = - requestGasLimit ?? - (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. + const gasLimit = await resolveGasLimit( + contract, + fnName, + args, + value, + requestGasLimit, + ); updateConfig({ unlimitedAmount: false, @@ -257,7 +265,7 @@ export const TransactionStep: FC = ({
- {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 && ( - + <> + + + )}
+ } + /> + ) : 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 ( + <> + + {t(translations.perimeterPage.meta.title)} + +
+ {/* Releases are signed on RSK. Without this the page would offer a + Release button on any connected chain, and a call to an address + with no code does not revert: the wallet reports success and + nothing is released. */} + + + {t(translations.perimeterPage.title)} + + + {t(translations.perimeterPage.subtitle)} + + {somePausedListed && ( + + {t( + // Two independently pausable queues can hold this account's + // withdrawals. Only when every queue behind a row listed + // below is paused is it true that releases are paused across + // the whole perimeter; otherwise the row-level status already + // says which ones are. + allPausedListed + ? translations.perimeterPage.statusTooltip.paused + : translations.perimeterPage.pausedPartial, + )} + + )} + {readFailed && account && ( + + {t(translations.perimeterPage.unreadable)} + + )} +
+ {releasableRows.length > 1 && ( +
+ +
+ } + /> +
+ )} + exitKey(row)} + isLoading={loading || (!!account && !now && !clockUnreadable)} + noData={emptyMessage} + dataAttribute="perimeter-vault-table" + /> + + + + + ); +}; + +export default PerimeterPage; +export { PerimeterPage }; diff --git a/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.utils.test.tsx b/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.utils.test.tsx new file mode 100644 index 000000000..0d37839f8 --- /dev/null +++ b/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.utils.test.tsx @@ -0,0 +1,55 @@ +import { i18n } from '../../../locales/i18n'; +import { PendingExitState } from '../../../utils/exitDelay'; +import { getTimeToRelease } from './PerimeterPage.utils'; + +/** + * The Releases column is read by someone waiting on a hold: whole minutes, + * rounded up, then hours and days. Seconds never appear, and a hold with less + * than a minute left says so rather than counting down. + */ + +const NOW = 1_800_000_000; + +const timeToRelease = ( + remainingSeconds: number, + state = PendingExitState.Locked, +) => getTimeToRelease(NOW + remainingSeconds, NOW, state); + +describe('getTimeToRelease', () => { + beforeAll(async () => { + await i18n; + }); + + it.each([ + [180, '3 min'], + [3_900, '1 h 5 min'], + [3_600, '1 h'], + [183_600, '2 d 3 h'], + [86_400, '1 d'], + [90_000, '1 d 1 h'], + [61, '2 min'], + [60, '1 min'], + [3_599, '1 h'], + ])('shows %i seconds left as "%s"', (remaining, expected) => { + expect(timeToRelease(remaining)).toBe(expected); + }); + + it.each([1, 30, 59])('shows %i seconds left as under a minute', remaining => { + expect(timeToRelease(remaining)).toBe('< 1 min'); + }); + + it('shows "Now" once the unlock time has passed', () => { + expect(timeToRelease(0)).toBe('Now'); + expect(timeToRelease(-120)).toBe('Now'); + }); + + it('shows no release time for a row it could not read', () => { + expect(timeToRelease(0, PendingExitState.Unreadable)).toBe('-'); + }); + + it('never prints seconds', () => { + for (let remaining = 1; remaining <= 200_000; remaining += 7) { + expect(timeToRelease(remaining)).not.toMatch(/\d\s?s\b|second/i); + } + }); +}); diff --git a/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.utils.tsx b/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.utils.tsx new file mode 100644 index 000000000..309732d4b --- /dev/null +++ b/apps/frontend/src/app/5_pages/PerimeterPage/PerimeterPage.utils.tsx @@ -0,0 +1,96 @@ +import { t } from 'i18next'; + +import { translations } from '../../../locales/i18n'; +import { + PendingExit, + PendingExitState, + formatDelayCountdown, + secondsUntilUnlock, +} from '../../../utils/exitDelay'; + +/** + * Every row carries the state it is in, resolved once, so the table and its + * action button can never disagree about whether an exit is releasable. + */ +export type PerimeterExitRow = PendingExit & { + state: PendingExitState; +}; + +export const getStatusLabel = (state: PendingExitState): string => + t(translations.perimeterPage.status[state]); + +// A paused row's own tooltip speaks about that row, not about every queue the +// page follows; the whole-perimeter sentence stays with the banner, which is +// the one place it is true of everything listed. +export const getStatusTooltip = (state: PendingExitState): string => + t( + state === PendingExitState.Paused + ? translations.perimeterPage.pausedRowTooltip + : translations.perimeterPage.statusTooltip[state], + ); + +/** + * Time left on a hold in whole minutes, rounded up, then hours and days when + * longer ("3 min", "1 h 5 min", "2 d 3 h"); "< 1 min" under one minute. Never + * seconds. An exit past its unlock time reads as ready rather than as "0 + * minutes", which would look like a stuck row. + * + * The form states a policy duration and rounds it to one whole unit; this is + * the screen where someone watches the clock, so it says what is actually left. + * + * A row nothing is ever released from — its block state could not be read, + * or it reached here with a status the pending list should already have + * dropped it for — carries no release time at all: an unlock time in the + * past reads as due now regardless of what became of the row, which is a + * readiness claim the row's own status denies. + */ +export const getTimeToRelease = ( + unlockAt: number, + now: number, + state: PendingExitState, +): string => { + if (state === PendingExitState.Unreadable) { + return '-'; + } + const remaining = secondsUntilUnlock(unlockAt, now); + if (remaining === 0) { + return t(translations.perimeterPage.readyNow); + } + if (remaining < 60) { + return t(translations.perimeterPage.lessThanMinute); + } + return formatDelayCountdown(remaining) + .map(({ value, unit }) => + t(translations.exitDelay.durationShort[unit], { count: value }), + ) + .join(' '); +}; + +/** Shorten an address for a table cell without hiding which address it is. */ +export const shortenAddress = (address: string): string => + `${address.slice(0, 6)}…${address.slice(-4)}`; + +/** The local-storage key prefix `clearPerimeterHistoryKeys` removes. */ +const PERIMETER_HISTORY_KEY_PREFIX = 'perimeter/history/'; + +/** + * Removes every local-storage key starting with `perimeter/history/`, for + * every chain and account any such key names, so none linger once this page + * has loaded. Never throws: a store that cannot be read or written leaves + * nothing to clear, and this is a convenience, never a statement about + * funds. + */ +export const clearPerimeterHistoryKeys = (): void => { + try { + const keysToRemove: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key?.startsWith(PERIMETER_HISTORY_KEY_PREFIX)) { + keysToRemove.push(key); + } + } + keysToRemove.forEach(key => window.localStorage.removeItem(key)); + } catch (error) { + // Nothing to do: see the doc comment above. + } +}; diff --git a/apps/frontend/src/app/5_pages/ZeroPage/hooks/useClaimCollateralSurplus.test.ts b/apps/frontend/src/app/5_pages/ZeroPage/hooks/useClaimCollateralSurplus.test.ts new file mode 100644 index 000000000..ae8df0bf1 --- /dev/null +++ b/apps/frontend/src/app/5_pages/ZeroPage/hooks/useClaimCollateralSurplus.test.ts @@ -0,0 +1,143 @@ +import { act, renderHook as renderHookBare } from '@testing-library/react'; + +import 'jest-canvas-mock'; +import { MemoryRouter } from 'react-router-dom'; + +import { GAS_LIMIT } from '../../../../constants/gasLimits'; +import { i18n } from '../../../../locales/i18n'; +import { SURFACE_ZERO_CLAIM_SURPLUS } from '../../../../utils/exitFee'; +import { useClaimCollateralSurplus } from './useClaimCollateralSurplus'; + +/** + * The hooks under test link to the Perimeter page from their hold notice, so + * they are rendered under a Router, as the pages that call them are. + */ +const renderHook = (callback: (props: Props) => Result) => + renderHookBare(callback, { wrapper: MemoryRouter }); + +/** + * A held claim does not arrive in the wallet, so a flow that reports only + * success reads as money missing. The claim's completion refreshes the page + * and then fires the same post-signature notice as the other Zero exits, + * built from the claim surface's own delay quote. + */ + +const mockSetTransactions = jest.fn(); +const mockSetIsOpen = jest.fn(); +const mockSetTitle = jest.fn(); +const mockAddNotification = 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: {} }; + +let mockDelay: { delaySeconds: number; loading: boolean; unknown: boolean }; +let mockDelaySurface: string | undefined; + +jest.mock('nanoid', () => ({ nanoid: () => '1234' })); + +jest.mock('@sovryn/contracts', () => ({ + ...jest.requireActual('@sovryn/contracts'), + getContract: async () => ({ + address: '0x5B9dB4B8bdeF3e57323187a9AC2639C5DEe5FD39', + abi: ['function claimCollateral()'], + }), +})); + +jest.mock('../../../../hooks/useAccount', () => ({ + useAccount: () => ({ signer: mockSigner }), +})); + +jest.mock('../../../../contexts/TransactionContext', () => ({ + useTransactionContext: () => ({ + setTransactions: (...args: unknown[]) => mockSetTransactions(...args), + setIsOpen: (...args: unknown[]) => mockSetIsOpen(...args), + setTitle: (...args: unknown[]) => mockSetTitle(...args), + }), +})); + +jest.mock('../../../../contexts/NotificationContext', () => ({ + useNotificationContext: () => ({ addNotification: mockAddNotification }), +})); + +jest.mock('../../../../hooks/exitDelay/useZeroExitDelayQuote', () => ({ + useZeroExitDelayQuote: (surface: string) => { + mockDelaySurface = surface; + return mockDelay; + }, +})); + +const claim = async () => { + const refresh = jest.fn(); + const { result } = renderHook(() => useClaimCollateralSurplus(refresh)); + await act(async () => { + await result.current(); + }); + const step = mockSetTransactions.mock.calls[0][0][0]; + return { step, refresh }; +}; + +describe('useClaimCollateralSurplus', () => { + beforeAll(async () => { + await i18n; + }); + + beforeEach(() => { + mockDelaySurface = undefined; + }); + + it('quotes the delay for the claim surface', async () => { + mockDelay = { delaySeconds: 0, loading: false, unknown: false }; + + await claim(); + + expect(mockDelaySurface).toBe(SURFACE_ZERO_CLAIM_SURPLUS); + }); + + it('configures a gas-limit floor so resolveGasLimit applies its margin', async () => { + mockDelay = { delaySeconds: 0, loading: false, unknown: false }; + + const { step } = await claim(); + + // With no floor, resolveGasLimit returns the bare estimate (no 30% + // margin) and falls back to the flat, unmeasured 6,000,000 default on a + // failed estimate — see TransactionStepDialog/utils.ts. This surface is + // hooked into the same withdrawal delay as the other three, so it needs + // the same protection. + expect(step.request.gasLimit).toBe(GAS_LIMIT.CLAIM_SURPLUS); + }); + + it('refreshes the page and names the withdraw queue once a held claim completes', async () => { + mockDelay = { delaySeconds: 172800, loading: false, unknown: false }; + + const { step, refresh } = await claim(); + expect(step.request.fnName).toBe('claimCollateral'); + step.onComplete('0xhash'); + + expect(refresh).toHaveBeenCalled(); + expect(mockAddNotification).toHaveBeenCalledTimes(1); + expect(mockAddNotification.mock.calls[0][0].title).toBe( + 'Withdrawal delayed by the Sovryn Perimeter', + ); + }); + + it('says the claim may be held when its delay could not be read', async () => { + mockDelay = { delaySeconds: 0, loading: false, unknown: true }; + + const { step } = await claim(); + step.onComplete('0xhash'); + + expect(mockAddNotification.mock.calls[0][0].title).toBe( + 'Withdrawal may be delayed by the Sovryn Perimeter', + ); + }); + + it('only refreshes the page when a quote arrived saying nothing is held', async () => { + mockDelay = { delaySeconds: 0, loading: false, unknown: false }; + + const { step, refresh } = await claim(); + step.onComplete('0xhash'); + + expect(refresh).toHaveBeenCalled(); + expect(mockAddNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/app/5_pages/ZeroPage/hooks/useClaimCollateralSurplus.ts b/apps/frontend/src/app/5_pages/ZeroPage/hooks/useClaimCollateralSurplus.ts index f41d76260..00bc4cf20 100644 --- a/apps/frontend/src/app/5_pages/ZeroPage/hooks/useClaimCollateralSurplus.ts +++ b/apps/frontend/src/app/5_pages/ZeroPage/hooks/useClaimCollateralSurplus.ts @@ -6,15 +6,24 @@ import { t } from 'i18next'; import { getContract } from '@sovryn/contracts'; import { TransactionType } from '../../../3_organisms/TransactionStepDialog/TransactionStepDialog.types'; +import { GAS_LIMIT } from '../../../../constants/gasLimits'; import { useTransactionContext } from '../../../../contexts/TransactionContext'; +import { usePerimeterHoldToast } from '../../../../hooks/exitDelay/usePerimeterHoldToast'; +import { useZeroExitDelayQuote } from '../../../../hooks/exitDelay/useZeroExitDelayQuote'; import { useAccount } from '../../../../hooks/useAccount'; import { translations } from '../../../../locales/i18n'; import { getRskChainId } from '../../../../utils/chain'; +import { SURFACE_ZERO_CLAIM_SURPLUS } from '../../../../utils/exitFee'; export const useClaimCollateralSurplus = (onComplete: () => void) => { const { signer } = useAccount(); const { setTransactions, setIsOpen, setTitle } = useTransactionContext(); + // The claim is held by the withdrawal delay like the other Zero exits, so + // its completion names the withdraw queue the same way. + const claimDelay = useZeroExitDelayQuote(SURFACE_ZERO_CLAIM_SURPLUS); + const notifyHold = usePerimeterHoldToast(claimDelay); + return useCallback(async () => { try { const { address, abi: massetManagerAbi } = await getContract( @@ -36,8 +45,16 @@ export const useClaimCollateralSurplus = (onComplete: () => void) => { contract: borrowerOperations, fnName: 'claimCollateral', args: [], + // Without a floor here, resolveGasLimit skips the 30% margin + // entirely and a failed live estimate falls back to the flat, + // unmeasured 6,000,000 default rather than a number sized to + // this call — see GAS_LIMIT.CLAIM_SURPLUS. + gasLimit: GAS_LIMIT.CLAIM_SURPLUS, + }, + onComplete: () => { + onComplete(); + notifyHold(); }, - onComplete, }, ]); setTitle(t(translations.zeroPage.tx.claimSurplusTitle)); @@ -45,5 +62,5 @@ export const useClaimCollateralSurplus = (onComplete: () => void) => { } catch (error) { console.log('error:', error); } - }, [onComplete, setIsOpen, setTitle, setTransactions, signer]); + }, [notifyHold, onComplete, setIsOpen, setTitle, setTransactions, signer]); }; diff --git a/apps/frontend/src/app/5_pages/ZeroPage/hooks/useHandleTrove.test.ts b/apps/frontend/src/app/5_pages/ZeroPage/hooks/useHandleTrove.test.ts new file mode 100644 index 000000000..1b5318eac --- /dev/null +++ b/apps/frontend/src/app/5_pages/ZeroPage/hooks/useHandleTrove.test.ts @@ -0,0 +1,206 @@ +import { act, renderHook as renderHookBare } from '@testing-library/react'; + +import 'jest-canvas-mock'; +import { MemoryRouter } from 'react-router-dom'; + +import { GAS_LIMIT } from '../../../../constants/gasLimits'; +import { i18n } from '../../../../locales/i18n'; +import { useHandleTrove } from './useHandleTrove'; + +/** + * The hooks under test link to the Perimeter page from their hold notice, so + * they are rendered under a Router, as the pages that call them are. + */ +const renderHook = (callback: (props: Props) => Result) => + renderHookBare(callback, { wrapper: MemoryRouter }); + +/** + * The Zero page submits adjustments and closes through the functions this hook + * returns. A collateral withdrawal is held by the withdrawal delay when it + * applies, so its completion must name the withdraw queue; an adjust that moves no + * collateral must not. These tests call the returned functions and run the + * completion of the transaction each one builds. + */ + +const ACCOUNT = '0x1111111111111111111111111111111111111111'; + +const mockSetTransactions = jest.fn(); +const mockSetIsOpen = jest.fn(); +const mockSetTitle = jest.fn(); +const mockAddNotification = jest.fn(); +const mockAdjustTrove = 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('@sovryn/contracts', () => ({ + ...jest.requireActual('@sovryn/contracts'), + getContract: async () => ({ + address: '0x5B9dB4B8bdeF3e57323187a9AC2639C5DEe5FD39', + abi: [ + 'function adjustTrove(uint256 maxFee, uint256 collWithdrawal, uint256 debtChange, bool isDebtIncrease, address upperHint, address lowerHint)', + 'function closeTrove()', + ], + }), +})); + +jest.mock('../utils/trove-manager', () => ({ + adjustTrove: (...args: unknown[]) => mockAdjustTrove(...args), + openTrove: jest.fn(), +})); + +const mockGetTrove = jest.fn(); + +jest.mock('../../../../utils/liquity', () => ({ + loadLiquity: async () => ({ liquity: { getTrove: mockGetTrove } }), +})); + +jest.mock('../../../../utils/transactions', () => ({ + getPermitTransferFrom: async () => ({ + permitted: {}, + spender: '', + nonce: 0, + deadline: 0, + }), + permitHandler: () => (request: unknown) => request, + prepareApproveTransaction: async () => undefined, + preparePermit2Transaction: async () => ({ + title: 'permit2', + request: { type: 'signTypedData' }, + }), + preparePermitTransaction: jest.fn(), + UNSIGNED_PERMIT: '0x', +})); + +jest.mock('../../../../hooks/useAccount', () => ({ + useAccount: () => ({ account: ACCOUNT, signer: mockSigner, provider: {} }), +})); + +jest.mock('../../../../contexts/TransactionContext', () => ({ + useTransactionContext: () => ({ + setTransactions: (...args: unknown[]) => mockSetTransactions(...args), + setIsOpen: (...args: unknown[]) => mockSetIsOpen(...args), + setTitle: (...args: unknown[]) => mockSetTitle(...args), + }), +})); + +jest.mock('../../../../contexts/NotificationContext', () => ({ + useNotificationContext: () => ({ addNotification: mockAddNotification }), +})); + +jest.mock('../../../../hooks/exitDelay/useZeroExitDelayQuote', () => ({ + useZeroExitDelayQuote: () => ({ + delaySeconds: 172800, + loading: false, + unknown: false, + }), +})); + +const submitValue = (overrides: Record = {}) => + ({ + token: 'zusd', + borrow: '', + repay: '', + depositCollateral: '', + withdrawCollateral: '', + maxOriginationFeeRate: '5', + ...overrides, + } as never); + +/** The last transaction the hook handed to the dialog. */ +const lastStep = () => { + const steps = mockSetTransactions.mock.calls[0][0]; + return steps[steps.length - 1]; +}; + +describe('useHandleTrove', () => { + beforeAll(async () => { + await i18n; + }); + + beforeEach(() => { + mockAdjustTrove.mockResolvedValue({ + fn: 'adjustTrove', + args: ['0', '0', '0', false, ACCOUNT, ACCOUNT], + value: '0', + }); + mockGetTrove.mockResolvedValue({ netDebt: { toString: () => '100' } }); + }); + + it('names the withdraw queue once a collateral withdrawal through Adjust completes', async () => { + const onTroveAdjusted = jest.fn(); + const { result } = renderHook(() => + useHandleTrove(true, { onTroveAdjusted }), + ); + + await act(async () => { + await result.current.handleTroveSubmit( + submitValue({ withdrawCollateral: '0.1' }), + ); + }); + lastStep().onComplete('0xhash'); + + expect(onTroveAdjusted).toHaveBeenCalled(); + expect(mockAddNotification).toHaveBeenCalledTimes(1); + expect(mockAddNotification.mock.calls[0][0].title).toBe( + 'Withdrawal delayed by the Sovryn Perimeter', + ); + }); + + it('stays silent after an adjust that moves no collateral', async () => { + // A collateral field typed into and cleared leaves a truthy '0.0'. + const onTroveAdjusted = jest.fn(); + const { result } = renderHook(() => + useHandleTrove(true, { onTroveAdjusted }), + ); + + await act(async () => { + await result.current.handleTroveSubmit( + submitValue({ repay: '10', withdrawCollateral: '0.0' }), + ); + }); + lastStep().onComplete('0xhash'); + + expect(onTroveAdjusted).toHaveBeenCalled(); + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it('names the withdraw queue once a close completes', async () => { + const onTroveClosed = jest.fn(); + const { result } = renderHook(() => + useHandleTrove(true, { onTroveClosed }), + ); + + await act(async () => { + await result.current.handleTroveClose('zusd'); + }); + lastStep().onComplete('0xhash'); + + expect(onTroveClosed).toHaveBeenCalled(); + expect(mockAddNotification).toHaveBeenCalledTimes(1); + }); + + it('configures a gas-limit floor for closing a ZUSD credit line', async () => { + const { result } = renderHook(() => useHandleTrove(true)); + + await act(async () => { + await result.current.handleTroveClose('zusd'); + }); + + expect(lastStep().request.fnName).toBe('closeTrove'); + expect(lastStep().request.gasLimit).toBe(GAS_LIMIT.CLOSE_TROVE); + }); + + it('configures a gas-limit floor for closing a DLLR credit line', async () => { + const { result } = renderHook(() => useHandleTrove(true)); + + await act(async () => { + await result.current.handleTroveClose('dllr'); + }); + + expect(lastStep().request.fnName).toBe('closeNueTroveWithPermit2'); + expect(lastStep().request.gasLimit).toBe(GAS_LIMIT.CLOSE_DLLR_TROVE); + }); +}); diff --git a/apps/frontend/src/app/5_pages/ZeroPage/hooks/useHandleTrove.ts b/apps/frontend/src/app/5_pages/ZeroPage/hooks/useHandleTrove.ts index 63f553831..fde1522eb 100644 --- a/apps/frontend/src/app/5_pages/ZeroPage/hooks/useHandleTrove.ts +++ b/apps/frontend/src/app/5_pages/ZeroPage/hooks/useHandleTrove.ts @@ -16,9 +16,12 @@ import { } from '../../../3_organisms/TransactionStepDialog/TransactionStepDialog.types'; import { isTransactionRequest } from '../../../3_organisms/TransactionStepDialog/helpers'; import { CreditLineSubmitValue } from '../../../3_organisms/ZeroLocForm/types'; +import { isCollateralWithdrawal } from '../../../3_organisms/ZeroLocForm/utils'; import { GAS_LIMIT } from '../../../../constants/gasLimits'; import { getTokenDisplayName } from '../../../../constants/tokens'; import { useTransactionContext } from '../../../../contexts/TransactionContext'; +import { usePerimeterHoldToast } from '../../../../hooks/exitDelay/usePerimeterHoldToast'; +import { useZeroExitDelayQuote } from '../../../../hooks/exitDelay/useZeroExitDelayQuote'; import { useAccount } from '../../../../hooks/useAccount'; import { translations } from '../../../../locales/i18n'; import { COMMON_SYMBOLS, compareAssets } from '../../../../utils/asset'; @@ -101,6 +104,8 @@ export const useHandleTrove = ( ) => { const { signer, account, provider } = useAccount(); const { setTransactions, setIsOpen, setTitle } = useTransactionContext(); + const zeroExitDelay = useZeroExitDelayQuote(); + const notifyHold = usePerimeterHoldToast(zeroExitDelay); const handleTroveSubmit = useCallback( async (value: CreditLineSubmitValue) => { @@ -179,7 +184,16 @@ export const useHandleTrove = ( value: adjustedTrove.value, gasLimit: GAS_LIMIT.ADJUST_TROVE, }, - onComplete: callbacks?.onTroveAdjusted, + onComplete: result => { + callbacks?.onTroveAdjusted?.(); + // Only collateral leaving the line of credit can be held; a + // debt-only adjust must not claim otherwise. Same test the + // form's delay row uses, so the two cannot disagree. + if (isCollateralWithdrawal(value.withdrawCollateral)) { + notifyHold(); + } + return result; + }, updateHandler: permitHandler((req, res) => { if (isTransactionRequest(req) && isDllr && params.repayZUSD) { req.args = [...adjustedTrove.args, permitTransferFrom, res]; @@ -221,9 +235,9 @@ export const useHandleTrove = ( }, [ account, - callbacks?.onTroveAdjusted, - callbacks?.onTroveOpened, + callbacks, hasLoc, + notifyHold, setIsOpen, setTitle, setTransactions, @@ -339,16 +353,7 @@ export const useHandleTrove = ( } } }, - [ - account, - callbacks?.onTroveAdjusted, - callbacks?.onTroveOpened, - hasLoc, - setIsOpen, - setTitle, - setTransactions, - signer, - ], + [account, callbacks, hasLoc, setIsOpen, setTitle, setTransactions, signer], ); const handleTroveClose = useCallback( @@ -400,7 +405,11 @@ export const useHandleTrove = ( args: [permitTransferFrom, ''], gasLimit: GAS_LIMIT.CLOSE_DLLR_TROVE, }, - onComplete: callbacks?.onTroveClosed, + onComplete: result => { + callbacks?.onTroveClosed?.(); + notifyHold(); + return result; + }, updateHandler: permitHandler((req, res) => { if (isTransactionRequest(req)) { req.args = [permitTransferFrom, res]; @@ -421,7 +430,11 @@ export const useHandleTrove = ( args: [], gasLimit: GAS_LIMIT.CLOSE_TROVE, }, - onComplete: callbacks?.onTroveClosed, + onComplete: result => { + callbacks?.onTroveClosed?.(); + notifyHold(); + return result; + }, }, ]); } else { @@ -434,7 +447,8 @@ export const useHandleTrove = ( }, [ account, - callbacks?.onTroveClosed, + callbacks, + notifyHold, provider, setIsOpen, setTitle, diff --git a/apps/frontend/src/constants/gasLimits.test.ts b/apps/frontend/src/constants/gasLimits.test.ts new file mode 100644 index 000000000..f86c09ffd --- /dev/null +++ b/apps/frontend/src/constants/gasLimits.test.ts @@ -0,0 +1,42 @@ +import { GAS_LIMIT } from './gasLimits'; + +/** + * Every Perimeter-hooked withdrawal surface's fallback floor must cover its + * own measured delay-armed cost plus the same 30% margin resolveGasLimit + * applies to a live estimate (TransactionStepDialog/utils.ts). The floor is + * what protects a user when that live estimate fails — a floor sized only + * for the plain, delay-off call is not enough, because it cannot rely on + * the margin to rescue an estimate that never ran. Each assertion cites the + * transaction the number was measured from. + */ +describe('GAS_LIMIT delay-armed floors', () => { + it('covers the fixed-interest borrower withdraw-collateral, measured 448,331 gas (rskForkedMainnetQa, delay 120s, tx 0x18da6c0d5f93b52528f2e0adad7f8ee3022a95d4c8147e071e1c926d3671114d)', () => { + expect(GAS_LIMIT.WITHDRAW_LOAN_COLLATERAL).toBeGreaterThanOrEqual( + Math.ceil(448_331 * 1.3), + ); + }); + + it('covers Zero closeTrove() (ZUSD), measured 588,995 gas (rskForkedMainnetQa, delay 120s, tx 0xa88b4522443397efbdee69a63f2297ba9f354f85597c9399cb488abb755f7ddf)', () => { + expect(GAS_LIMIT.CLOSE_TROVE).toBeGreaterThanOrEqual( + Math.ceil(588_995 * 1.3), + ); + }); + + it('covers Zero closeNueTroveWithPermit2() (DLLR), measured 745,682 gas (rskForkedMainnetQa, delay 120s, tx 0x4556ddca29569bf88b536e04e22680dc895eb400d0ef5943b52c1ca4ec7c2a7d)', () => { + expect(GAS_LIMIT.CLOSE_DLLR_TROVE).toBeGreaterThanOrEqual( + Math.ceil(745_682 * 1.3), + ); + }); + + it('covers Zero claimCollateral() (the surplus claim), measured 386,192 gas (rskForkedMainnetQa, delay 120s, tx 0xee909088e754c220b6be96c143b29828cf20950bc445ea4871e19a629408c20b)', () => { + expect(GAS_LIMIT.CLAIM_SURPLUS).toBeGreaterThanOrEqual( + Math.ceil(386_192 * 1.3), + ); + }); + + it('covers the lending burnToBTC() withdrawal, measured 601,556 gas with the withdrawal delay armed', () => { + expect(GAS_LIMIT.LENDING_BURN).toBeGreaterThanOrEqual( + Math.ceil(601_556 * 1.3), + ); + }); +}); diff --git a/apps/frontend/src/constants/gasLimits.ts b/apps/frontend/src/constants/gasLimits.ts index 1bcadcfbb..257cdb999 100644 --- a/apps/frontend/src/constants/gasLimits.ts +++ b/apps/frontend/src/constants/gasLimits.ts @@ -2,8 +2,19 @@ export const GAS_LIMIT = { MAX: 6_800_000, OPEN_TROVE: 1_200_000, ADJUST_TROVE: 4_000_000, - CLOSE_TROVE: 350_000, - CLOSE_DLLR_TROVE: 600_000, + // The fallback used only when a fresh gas estimate cannot be obtained — + // see resolveGasLimit. Zero's close-credit-line shares the same + // withdrawal-recording hook as Zero withdraw collateral (ADJUST_TROVE), + // so it is delay-armed the same way. Set to the measured delay-armed + // closeTrove() cost plus the same 30% margin the estimator applies to a + // live read (measured 588,995 gasUsed, fork rskForkedMainnetQa, delay + // 120s, charge on, 2026-09-19). + CLOSE_TROVE: 800_000, + // Same rationale as CLOSE_TROVE, measured against closeNueTroveWithPermit2() + // — the DLLR close path, which additionally verifies a Permit2 signature + // and pulls the repayment through it (measured 745,682 gasUsed, same fork + // and conditions). + CLOSE_DLLR_TROVE: 1_000_000, CONVERT: 750_000, STABILITY_POOL: 400_000, STABILITY_POOL_INC_WITHDRAW: 490_000, @@ -14,7 +25,21 @@ export const GAS_LIMIT = { REWARDS_OS_FEE: 600_000, TRANSFER_LOC: 900_000, LENDING_MINT: 350_000, - LENDING_BURN: 450_000, + // The fallback used only when a fresh gas estimate cannot be obtained — + // see resolveGasLimit in TransactionStepDialog/utils.ts, which is what + // sizes a real send. Set to the withdrawal delay's own cost through + // ExitDelayQueue.recordERC20Exit plus the same 30% margin the estimator + // applies to a live read, so a failed estimate still covers the + // delay-armed case, not only the plain one. + LENDING_BURN: 800_000, + // The fallback used only when a fresh gas estimate cannot be obtained — see + // resolveGasLimit. Zero's collateral-surplus claim previously configured no + // gasLimit at all, so it never received the 30% margin the estimator + // applies to a live read; this floor closes that gap. Set to the + // delay-armed claimCollateral() call's measured cost (386,192 gasUsed, + // fork rskForkedMainnetQa, delay 120s, charge on, 2026-09-19) plus the + // same 30% margin, rounded up. + CLAIM_SURPLUS: 600_000, APPROVE: 60_000, STAKING_STAKE: 1_400_000, STAKING_INCREASE_STAKE: 450_000, @@ -25,7 +50,13 @@ export const GAS_LIMIT = { SOV_WITHDRAW_VESTING_TEAM: 6_700_000, BORROW: 1_500_000, REPAY_LOAN: 950_000, - WITHDRAW_LOAN_COLLATERAL: 150_000, + // The fallback used only when a fresh gas estimate cannot be obtained — + // see resolveGasLimit. Set to the withdrawal delay's own cost through + // ExitDelayQueue plus the same 30% margin the estimator applies to a + // live read (measured 448,331 gasUsed, fork rskForkedMainnetQa, delay + // 120s, charge on, 2026-09-19), so a failed estimate still covers the + // delay-armed case, not only the plain one the old 150,000 was sized for. + WITHDRAW_LOAN_COLLATERAL: 600_000, GOVERNOR_PROPOSE: 1_500_000, PROPOSAL_VOTE: 300_000, PROPOSAL_QUEUE: 250_000, diff --git a/apps/frontend/src/constants/infrastructure/rsk.production-only.test.ts b/apps/frontend/src/constants/infrastructure/rsk.production-only.test.ts new file mode 100644 index 000000000..78a35a370 --- /dev/null +++ b/apps/frontend/src/constants/infrastructure/rsk.production-only.test.ts @@ -0,0 +1,30 @@ +import { Environments } from '../../types/global'; + +it.each(['development', 'production', 'test'])( + 'uses reviewed public Rootstock endpoints despite a leftover QA export in %s', + mode => { + const oldMode = process.env.NODE_ENV; + const oldOverride = process.env.REACT_APP_RSK_RPC_OVERRIDE; + try { + (process.env as Record).NODE_ENV = mode; + process.env.REACT_APP_RSK_RPC_OVERRIDE = 'http://127.0.0.1:19474'; + jest.isolateModules(() => { + const { RSK } = require('./rsk'); + expect(RSK.rpc[Environments.Mainnet]).toEqual([ + 'https://rsk-live.sovryn.app/rpc', + ]); + expect(RSK.publicRpc[Environments.Mainnet]).toBe( + 'https://mainnet.sovryn.app/rpc', + ); + expect(RSK.rpc[Environments.Testnet]).toEqual([ + 'https://testnet.sovryn.app/rpc', + ]); + }); + } finally { + (process.env as Record).NODE_ENV = oldMode; + if (oldOverride === undefined) + delete process.env.REACT_APP_RSK_RPC_OVERRIDE; + else process.env.REACT_APP_RSK_RPC_OVERRIDE = oldOverride; + } + }, +); diff --git a/apps/frontend/src/hooks/exitDelay/configuredDeploymentQueue.ts b/apps/frontend/src/hooks/exitDelay/configuredDeploymentQueue.ts new file mode 100644 index 000000000..5a3c3532e --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/configuredDeploymentQueue.ts @@ -0,0 +1,98 @@ +import { BigNumber, constants, utils } from 'ethers'; + +import { ChainId, ChainIds, getProvider } from '@sovryn/ethers-provider'; + +import { asyncCall } from '../../store/rxjs/provider-cache'; +import { EXIT_DELAY_TTL, RELEASE_READ_TIMEOUT_MS } from '../../utils/exitDelay'; +import { boundedBy, sendersOf } from './rawCall'; + +type DeploymentQueue = { address: string; runtimeCodeHash: string }; + +export type DeploymentQueueRead = + | { kind: 'none' } + | { kind: 'known'; address: string } + | { kind: 'unreadable' }; + +/** Public release inputs selected by read chain, never by user storage. */ +export const configuredDeploymentQueue = ( + chainId: ChainId, +): DeploymentQueue | undefined => { + const inputs: Record = { + [ChainIds.RSK_MAINNET]: [ + process.env.REACT_APP_PERIMETER_QUEUE_RSK_MAINNET, + process.env.REACT_APP_PERIMETER_QUEUE_RSK_MAINNET_RUNTIME_HASH, + ], + [ChainIds.RSK_TESTNET]: [ + process.env.REACT_APP_PERIMETER_QUEUE_RSK_TESTNET, + process.env.REACT_APP_PERIMETER_QUEUE_RSK_TESTNET_RUNTIME_HASH, + ], + }; + const [address, runtimeCodeHash] = inputs[chainId] ?? []; + if ((!address || address === constants.AddressZero) && !runtimeCodeHash) { + return undefined; + } + if ( + !address || + address === constants.AddressZero || + !utils.isAddress(address) || + !runtimeCodeHash || + !/^0x[0-9a-fA-F]{64}$/.test(runtimeCodeHash) + ) { + throw new Error( + 'Perimeter deployment queue requires an address and runtime hash.', + ); + } + return { + address: address.toLowerCase(), + runtimeCodeHash: runtimeCodeHash.toLowerCase(), + }; +}; + +/** Validate the code at the queue address; proxy code is not implementation proof. */ +export const readDeploymentQueue = async ( + chainId: ChainId, +): Promise => { + try { + const configured = configuredDeploymentQueue(chainId); + if (!configured) return { kind: 'none' }; + return await asyncCall( + `exitDelay/deployment/${chainId}/${configured.address}/${configured.runtimeCodeHash}`, + async (): Promise => { + const provider = getProvider(chainId); + // Static providers cache getNetwork(). Check the current JSON-RPC + // backends before using their code response for this deployment. + const backends = sendersOf(provider); + if (backends.length === 0) return { kind: 'unreadable' }; + const chains = await Promise.all( + backends.map(backend => + boundedBy(backend.send('eth_chainId', []), RELEASE_READ_TIMEOUT_MS), + ), + ); + if ( + chains.some( + chain => + typeof chain !== 'string' || + !utils.isHexString(chain) || + !BigNumber.from(chain).eq(Number(chainId)), + ) + ) { + return { kind: 'unreadable' }; + } + const code = await boundedBy( + provider.getCode(configured.address), + RELEASE_READ_TIMEOUT_MS, + ); + if ( + code === '0x' || + utils.keccak256(code) !== configured.runtimeCodeHash + ) { + return { kind: 'unreadable' }; + } + return { kind: 'known', address: configured.address }; + }, + { ttl: EXIT_DELAY_TTL }, + ); + } catch { + return { kind: 'unreadable' }; + } +}; diff --git a/apps/frontend/src/hooks/exitDelay/quoteExitDelay.force.test.ts b/apps/frontend/src/hooks/exitDelay/quoteExitDelay.force.test.ts new file mode 100644 index 000000000..7a0ed3f4e --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/quoteExitDelay.force.test.ts @@ -0,0 +1,104 @@ +import { utils } from 'ethers'; + +import { + JsonRpcStub, + StubAnswer, + addressResult, + selectorOf, + startJsonRpcStub, +} from '../../utils/testing/jsonRpcStub'; +import { quoteExitDelay } from './quoteExitDelay'; + +/** + * quoteExitDelay caches its own on-chain read of the controller (see its own + * doc comment and `quoteExitDelay.test.ts`'s "caches the quote under a key" + * case), on the app's real shared cache — unlike `quoteExitDelay.test.ts`, + * which mocks that cache away entirely to isolate the pointer/decode logic. + * These tests exercise the real cache, to prove `force` actually reaches it + * rather than only being accepted and ignored. + */ + +let mockRpcUrl = ''; + +jest.mock('@sovryn/ethers-provider', () => { + const actual = jest.requireActual('@sovryn/ethers-provider'); + return { + ...actual, + getProvider: () => + actual.getProvider({ + id: '0x1e', + label: 'RSK', + token: 'RBTC', + rpcUrl: [mockRpcUrl], + blockExplorerUrl: '', + }), + }; +}); + +const ACCOUNT = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; +const PROTOCOL = '0x5A0D867e0D70Fcc6Ade25C3F1B89d618b5B4Eaa7'; +const QUEUE = '0x1111111111111111111111111111111111111111'; +const CONTROLLER = '0x99994b4522483DE17F31a5bC010c5901AdD3440E'; +const SURFACE = + '0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe'; +const SUB_PRODUCT = '0x0000000000000000000000000000000000000123'; + +const QUEUE_GETTER = selectorOf('exitDelayQueue()'); +const CONTROLLER_GETTER = selectorOf('exitFeeController()'); +const CONTROLLER_ABI = new utils.Interface([ + 'function quoteExitDelayFor(address rawOriginator, address owner, address receiver, bytes32 surfaceId, address subProduct) view returns (uint32 d, address effOrig, address effOwner)', +]); +const QUOTE = CONTROLLER_ABI.getSighash('quoteExitDelayFor'); + +const delayResult = (seconds: number): StubAnswer => ({ + result: CONTROLLER_ABI.encodeFunctionResult('quoteExitDelayFor', [ + seconds, + ACCOUNT, + ACCOUNT, + ]), +}); + +const quote = (force?: boolean) => + quoteExitDelay({ + chainId: '0x1e', + consumerAddress: PROTOCOL, + account: ACCOUNT, + surfaceId: SURFACE, + subProduct: SUB_PRODUCT, + force, + }); + +describe('quoteExitDelay force', () => { + let stub: JsonRpcStub; + + beforeAll(async () => { + stub = await startJsonRpcStub(); + mockRpcUrl = stub.url; + }); + + afterEach(() => stub.reset()); + + afterAll(() => stub.close()); + + it('bypasses its own cached answer when forced, so a delay armed since the last read is not masked by it', async () => { + stub.onCall(PROTOCOL, QUEUE_GETTER, addressResult(QUEUE)); + stub.onCall(PROTOCOL, CONTROLLER_GETTER, addressResult(CONTROLLER)); + stub.onCall(CONTROLLER, QUOTE, delayResult(0)); + + expect(await quote()).toEqual({ delaySeconds: 0, unknown: false }); + + // The Owner arms the delay. Within the same cache lifetime, an unforced + // read still serves the cached answer — ordinary caching, not itself a + // bug — so this call is not the one under test. + stub.onCall(CONTROLLER, QUOTE, delayResult(172800)); + expect(await quote()).toEqual({ delaySeconds: 0, unknown: false }); + expect(stub.callCount(CONTROLLER, QUOTE)).toBe(1); + + // Forced, it re-reads the chain rather than serving that cached answer. + expect(await quote(true)).toEqual({ + delaySeconds: 172800, + unknown: false, + }); + expect(stub.callCount(CONTROLLER, QUOTE)).toBe(2); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/quoteExitDelay.test.ts b/apps/frontend/src/hooks/exitDelay/quoteExitDelay.test.ts new file mode 100644 index 000000000..3eebee4a4 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/quoteExitDelay.test.ts @@ -0,0 +1,292 @@ +import { utils } from 'ethers'; + +import { + JsonRpcStub, + RSK_PROTOCOL_TARGET_NOT_ACTIVE, + RSK_REVERT_WITHOUT_DATA, + StubAnswer, + addressResult, + revertReasonData, + selectorOf, + startJsonRpcStub, +} from '../../utils/testing/jsonRpcStub'; +import { NO_DELAY, UNREADABLE, quoteExitDelay } from './quoteExitDelay'; + +/** + * The delay fails CLOSED on chain: a consumer that cannot quote the delay + * reverts the withdrawal or escrows it. So a hold is reported as absent only + * when the chain positively said so, and every read that did not complete is + * reported as unreadable. These tests serve the RSK node's own answers through + * the app's real provider stack. + */ + +let mockRpcUrl = ''; + +jest.mock('@sovryn/ethers-provider', () => { + const actual = jest.requireActual('@sovryn/ethers-provider'); + return { + ...actual, + getProvider: () => + actual.getProvider({ + id: '0x1e', + label: 'RSK', + token: 'RBTC', + rpcUrl: [mockRpcUrl], + blockExplorerUrl: '', + }), + }; +}); + +// Every read goes to the node; only the keys the reads are cached under are +// recorded. +let mockCacheKeys: string[] = []; +jest.mock('../../store/rxjs/provider-cache', () => ({ + asyncCall: (key: string, fn: () => unknown) => { + mockCacheKeys.push(key); + return fn(); + }, +})); + +// Short enough that the hung-read test below does not wait ten real seconds. +jest.mock('../../utils/exitDelay', () => ({ + ...jest.requireActual('../../utils/exitDelay'), + RELEASE_READ_TIMEOUT_MS: 200, +})); + +const ACCOUNT = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; +const PROTOCOL = '0x5A0D867e0D70Fcc6Ade25C3F1B89d618b5B4Eaa7'; +const BORROWER_OPERATIONS = '0x5B9dB4B8bdeF3e57323187a9AC2639C5DEe5FD39'; +const QUEUE = '0x1111111111111111111111111111111111111111'; +const CONTROLLER = '0x99994b4522483DE17F31a5bC010c5901AdD3440E'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const SURFACE = + '0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe'; +const SUB_PRODUCT = '0x0000000000000000000000000000000000000123'; + +const QUEUE_GETTER = selectorOf('exitDelayQueue()'); +const CONTROLLER_GETTER = selectorOf('exitFeeController()'); +const CONTROLLER_ABI = new utils.Interface([ + 'function quoteExitDelayFor(address rawOriginator, address owner, address receiver, bytes32 surfaceId, address subProduct) view returns (uint32 d, address effOrig, address effOwner)', +]); +const QUOTE = CONTROLLER_ABI.getSighash('quoteExitDelayFor'); + +const delayResult = (seconds: number): StubAnswer => ({ + result: CONTROLLER_ABI.encodeFunctionResult('quoteExitDelayFor', [ + seconds, + ACCOUNT, + ACCOUNT, + ]), +}); + +const TRANSPORT_FAILURES: [string, StubAnswer][] = [ + ['an HTTP 503', { status: 503, body: 'Service Unavailable' }], + [ + 'a JSON-RPC rate limit', + { error: { code: -32005, message: 'rate limit exceeded' } }, + ], + ['a body that is not JSON', { raw: 'maintenance' }], + ['an empty result', { result: '0x' }], + ['a dropped connection', { drop: true }], +]; + +const quote = (consumerAddress = PROTOCOL) => + quoteExitDelay({ + chainId: '0x1e', + consumerAddress, + account: ACCOUNT, + surfaceId: SURFACE, + subProduct: SUB_PRODUCT, + }); + +describe('quoteExitDelay', () => { + let stub: JsonRpcStub; + + beforeAll(async () => { + stub = await startJsonRpcStub(); + mockRpcUrl = stub.url; + }); + + afterEach(() => { + stub.reset(); + mockCacheKeys = []; + }); + + afterAll(() => stub.close()); + + /** A consumer wired to a queue and a controller. */ + const wire = (consumer = PROTOCOL) => { + stub.onCall(consumer, QUEUE_GETTER, addressResult(QUEUE)); + stub.onCall(consumer, CONTROLLER_GETTER, addressResult(CONTROLLER)); + }; + + describe('when the queue getter reverts', () => { + it('reports no hold for the lending protocol, whose queue getter reverts with a reason', async () => { + stub.onCall(PROTOCOL, QUEUE_GETTER, RSK_PROTOCOL_TARGET_NOT_ACTIVE); + stub.onCall(PROTOCOL, CONTROLLER_GETTER, addressResult(CONTROLLER)); + stub.onCall(CONTROLLER, QUOTE, delayResult(172800)); + + expect(await quote()).toEqual(NO_DELAY); + expect(stub.callCount(CONTROLLER, QUOTE)).toBe(0); + }); + + it('reports no hold for Zero, whose queue getter reverts with no data', async () => { + stub.onCall(BORROWER_OPERATIONS, QUEUE_GETTER, RSK_REVERT_WITHOUT_DATA); + stub.onCall( + BORROWER_OPERATIONS, + CONTROLLER_GETTER, + addressResult(CONTROLLER), + ); + stub.onCall(CONTROLLER, QUOTE, delayResult(172800)); + + expect(await quote(BORROWER_OPERATIONS)).toEqual(NO_DELAY); + expect(stub.callCount(CONTROLLER, QUOTE)).toBe(0); + }); + }); + + describe('when a pointer read does not complete', () => { + it.each(TRANSPORT_FAILURES)( + 'reports unreadable when the queue pointer read meets %s', + async (_name, answer) => { + stub.onCall(PROTOCOL, QUEUE_GETTER, answer); + stub.onCall(PROTOCOL, CONTROLLER_GETTER, addressResult(CONTROLLER)); + + expect(await quote()).toEqual(UNREADABLE); + }, + ); + + it.each(TRANSPORT_FAILURES)( + 'reports unreadable when the controller pointer read meets %s', + async (_name, answer) => { + stub.onCall(PROTOCOL, QUEUE_GETTER, addressResult(QUEUE)); + stub.onCall(PROTOCOL, CONTROLLER_GETTER, answer); + + expect(await quote()).toEqual(UNREADABLE); + }, + ); + + it('reports unreadable when the controller getter reverts on a consumer whose queue getter answered', async () => { + // The queue getter proves the delay leg is installed; a controller read + // that then reverts is no statement that nothing is held. + stub.onCall(PROTOCOL, QUEUE_GETTER, addressResult(QUEUE)); + stub.onCall(PROTOCOL, CONTROLLER_GETTER, RSK_PROTOCOL_TARGET_NOT_ACTIVE); + + expect(await quote()).toEqual(UNREADABLE); + }); + }); + + describe('on a wired consumer', () => { + it('reports the delay the controller quotes', async () => { + wire(); + stub.onCall(CONTROLLER, QUOTE, delayResult(172800)); + + expect(await quote()).toEqual({ delaySeconds: 172800, unknown: false }); + }); + + it('quotes the account as all three identities, with the surface and product', async () => { + wire(); + stub.onCall(CONTROLLER, QUOTE, { + error: { code: -32601, message: 'unexpected arguments' }, + }); + stub.onCall( + CONTROLLER, + CONTROLLER_ABI.encodeFunctionData('quoteExitDelayFor', [ + ACCOUNT, + ACCOUNT, + ACCOUNT, + SURFACE, + SUB_PRODUCT, + ]), + delayResult(3600), + ); + + expect(await quote()).toEqual({ delaySeconds: 3600, unknown: false }); + }); + + it('reports no hold when the controller quotes zero', async () => { + wire(); + stub.onCall(CONTROLLER, QUOTE, delayResult(0)); + + expect(await quote()).toEqual(NO_DELAY); + }); + + it('caches the quote under a key naming the chain, controller, queue, surface, product and account', async () => { + // A rotated controller or queue asks a new question; its answer must not + // be served from the old pair's entry. + wire(); + stub.onCall(CONTROLLER, QUOTE, delayResult(3600)); + + await quote(); + + expect(mockCacheKeys).toContain( + [ + 'exitDelay/quoteFor', + '0x1e', + CONTROLLER.toLowerCase(), + QUEUE.toLowerCase(), + SURFACE, + SUB_PRODUCT.toLowerCase(), + ACCOUNT.toLowerCase(), + ].join('/'), + ); + }); + + it('reports unreadable when the controller reverts the quote', async () => { + wire(); + stub.onCall(CONTROLLER, QUOTE, { + error: { + code: -32015, + message: 'VM Exception while processing transaction: revert', + data: revertReasonData('paused'), + }, + }); + + expect(await quote()).toEqual(UNREADABLE); + }); + + it('reports unreadable when the quote read does not complete', async () => { + wire(); + stub.onCall(CONTROLLER, QUOTE, { status: 503, body: 'unavailable' }); + + expect(await quote()).toEqual(UNREADABLE); + }); + + it('reports unreadable within its own bound when the node accepts the quote request and never answers it', async () => { + // No transport-level timeout is configured on the app's real provider, + // so a node that never answers would otherwise hold this call open for + // as long as ethers' own default, minutes rather than seconds. + wire(); + stub.onCall(CONTROLLER, QUOTE, { hang: true }); + + expect(await quote()).toEqual(UNREADABLE); + }, 2_000); + + it('reports no hold when no controller is pinned, without quoting', async () => { + stub.onCall(PROTOCOL, QUEUE_GETTER, addressResult(QUEUE)); + stub.onCall(PROTOCOL, CONTROLLER_GETTER, addressResult(ZERO_ADDRESS)); + + expect(await quote()).toEqual(NO_DELAY); + expect(stub.callCount(CONTROLLER, QUOTE)).toBe(0); + }); + }); + + describe('on a consumer whose queue pointer is unset', () => { + beforeEach(() => { + stub.onCall(PROTOCOL, QUEUE_GETTER, addressResult(ZERO_ADDRESS)); + stub.onCall(PROTOCOL, CONTROLLER_GETTER, addressResult(CONTROLLER)); + }); + + it('reports no hold when the controller quotes zero', async () => { + stub.onCall(CONTROLLER, QUOTE, delayResult(0)); + + expect(await quote()).toEqual(NO_DELAY); + expect(stub.callCount(CONTROLLER, QUOTE)).toBe(1); + }); + + it('does not report the withdrawal as paid now when the controller quotes a delay', async () => { + // The consumer then has nowhere to escrow and reverts the withdrawal. + stub.onCall(CONTROLLER, QUOTE, delayResult(172800)); + + expect(await quote()).toEqual(UNREADABLE); + }); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/quoteExitDelay.ts b/apps/frontend/src/hooks/exitDelay/quoteExitDelay.ts new file mode 100644 index 000000000..ca3dd437a --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/quoteExitDelay.ts @@ -0,0 +1,145 @@ +import { Contract, constants } from 'ethers'; + +import { ChainId, getProvider } from '@sovryn/ethers-provider'; + +import { asyncCall } from '../../store/rxjs/provider-cache'; +import { + EXIT_DELAY_TTL, + ExitDelayQuote, + RELEASE_READ_TIMEOUT_MS, +} from '../../utils/exitDelay'; +import { boundedBy } from './rawCall'; +import { readPerimeterPointer } from './readPerimeterPointer'; + +/** A quote before the caller's own loading state is attached to it. */ +export type ResolvedExitDelay = Omit; + +/** The chain stated that nothing is held. */ +export const NO_DELAY: ResolvedExitDelay = { delaySeconds: 0, unknown: false }; + +/** + * The chain stated nothing. Reported as unknown rather than as zero: the exit + * hook fails CLOSED, so a withdrawal whose delay cannot be quoted is escrowed + * or reverts — the one thing it will not do is pay straight to the wallet. + */ +export const UNREADABLE: ResolvedExitDelay = { + delaySeconds: 0, + unknown: true, +}; + +const CONTROLLER_ABI = [ + 'function quoteExitDelayFor(address rawOriginator, address owner, address receiver, bytes32 surfaceId, address subProduct) view returns (uint32 d, address effOrig, address effOwner)', +]; + +/** + * How long the perimeter would hold a withdrawal from one surface, resolved + * the same way the consumer contract resolves it at execution time. + * + * The controller is reached through the consumer's own pointer rather than a + * configured address, so the quote cannot disagree with the exit it describes. + * Three outcomes, kept distinct all the way to the screen: a hold, a stated + * absence of one, and a read that did not complete. + * + * The consumer's queue getter is read FIRST. The controller resolves a delay + * from a global default for every surface without a bypass tier, including + * surfaces whose consumer has no delay leg, so its answer alone would announce + * a hold the consumer cannot impose. Only a getter the node positively + * reported as reverting means "no delay leg"; every read that did not complete + * is unreadable, because the consumer reads its pointers from chain state that + * the browser's RPC trouble does not touch. + * + * A consumer whose queue pointer is unset still asks the controller: at a + * delay of zero it pays direct, and at a delay above zero it reverts the + * withdrawal for want of a queue, which is not "paid now" either. + */ +export const quoteExitDelay = async ({ + chainId, + consumerAddress, + account, + surfaceId, + subProduct, + force, +}: { + chainId: ChainId; + /** The consumer contract holding the perimeter pointers for this surface. */ + consumerAddress: string; + account: string; + surfaceId: string; + subProduct: string; + /** + * Bypass this call's own cached answer and re-read the chain. Set only by + * a caller that already knows its own cached answer may be stale — see + * useExitDelay's requote timer — never on an ordinary read, so an + * unforced call still shares one on-chain read with every other consumer + * asking the same question within the cache's lifetime. + */ + force?: boolean; +}): Promise => { + const queue = await readPerimeterPointer( + chainId, + consumerAddress, + 'exitDelayQueue', + ); + if (queue.kind === 'unreadable') { + return UNREADABLE; + } + if (queue.kind === 'absent') { + return NO_DELAY; + } + + const controller = await readPerimeterPointer( + chainId, + consumerAddress, + 'exitFeeController', + ); + // The queue getter answered, so this consumer has a delay leg; a controller + // read that did not produce an address is no statement that nothing is held. + if (controller.kind !== 'address') { + return UNREADABLE; + } + // No controller pinned is the one wired case where zero is the chain's own + // answer: the consumer quotes no delay and pays direct. + if (controller.address === constants.AddressZero) { + return NO_DELAY; + } + + let delaySeconds: number; + try { + // Keyed on the pointers it was read through as well as the question, so + // a rotated controller or queue never answers from the old pair's entry. + const quote = await asyncCall( + [ + 'exitDelay/quoteFor', + chainId, + controller.address.toLowerCase(), + queue.address.toLowerCase(), + surfaceId, + subProduct.toLowerCase(), + account.toLowerCase(), + ].join('/'), + () => + boundedBy( + new Contract( + controller.address, + CONTROLLER_ABI, + getProvider(chainId), + ).quoteExitDelayFor(account, account, account, surfaceId, subProduct), + RELEASE_READ_TIMEOUT_MS, + ), + { ttl: EXIT_DELAY_TTL, force }, + ); + delaySeconds = Number(quote.d); + } catch (error) { + // A pinned controller that could not be quoted: on chain the same failure + // reverts the withdrawal, so there is no reading in which it pays out. + return UNREADABLE; + } + + if (delaySeconds === 0) { + return NO_DELAY; + } + if (queue.address === constants.AddressZero) { + return UNREADABLE; + } + return { delaySeconds, unknown: false }; +}; diff --git a/apps/frontend/src/hooks/exitDelay/rawCall.test.ts b/apps/frontend/src/hooks/exitDelay/rawCall.test.ts new file mode 100644 index 000000000..1de00eaf6 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/rawCall.test.ts @@ -0,0 +1,144 @@ +import { providers, utils } from 'ethers'; + +import { Chain, getProvider } from '@sovryn/ethers-provider'; + +import { + JsonRpcStub, + RSK_REVERT_WITHOUT_DATA, + startJsonRpcStub, +} from '../../utils/testing/jsonRpcStub'; +import { boundedBy, callRaw } from './rawCall'; + +describe('boundedBy', () => { + it('passes on an answer that arrives within the time given', async () => { + await expect(boundedBy(Promise.resolve('0x'), 200)).resolves.toBe('0x'); + }); + + it('passes on a failure that arrives within the time given', async () => { + await expect( + boundedBy(Promise.reject(new Error('refused')), 200), + ).rejects.toThrow('refused'); + }); + + it('rejects once the time given passes with no answer', async () => { + const started = Date.now(); + + await expect(boundedBy(new Promise(() => undefined), 50)).rejects.toThrow(); + expect(Date.now() - started).toBeLessThan(1_000); + }); +}); + +/** + * The app's provider path reports a revert and a transport failure alike and + * drops the node's revert data. A release dry run needs both the distinction + * and the data, to tell the holder why the queue would refuse. These tests send + * the call through the app's real fallback provider to a local node. + */ + +const HOLDER = '0x1111111111111111111111111111111111111111'; +const QUEUE = '0x9999999999999999999999999999999999999999'; + +const QUEUE_ABI = new utils.Interface([ + 'function executeExit(uint256 requestId)', +]); +const QUEUE_ERRORS = new utils.Interface(['error QueuePaused()']); +const EXECUTE_EXIT = QUEUE_ABI.getSighash('executeExit'); + +const call = { + from: HOLDER, + to: QUEUE, + data: QUEUE_ABI.encodeFunctionData('executeExit', [7]), +}; + +/** What a function that returns nothing gives back when it executes. */ +const VOID_RESULT = (result: string) => result === '0x'; + +describe('callRaw', () => { + let stub: JsonRpcStub; + let appProvider: providers.Provider; + + beforeAll(async () => { + stub = await startJsonRpcStub(); + appProvider = getProvider({ + id: '0x1e', + label: 'RSK', + token: 'RBTC', + rpcUrl: [stub.url], + blockExplorerUrl: '', + } as Chain); + }); + + afterEach(() => stub.reset()); + + afterAll(() => stub.close()); + + it('returns what a call the node executed returned', async () => { + stub.onCall(QUEUE, EXECUTE_EXIT, { result: '0x' }); + + expect(await callRaw(appProvider, call, VOID_RESULT)).toEqual({ + kind: 'result', + data: '0x', + }); + }); + + it("keeps the queue's revert data, which the app's fallback provider drops", async () => { + const data = QUEUE_ERRORS.encodeErrorResult('QueuePaused', []); + stub.onCall(QUEUE, EXECUTE_EXIT, { + error: { + code: -32015, + message: 'VM Exception while processing transaction: revert', + data, + }, + }); + + expect(await callRaw(appProvider, call, VOID_RESULT)).toEqual({ + kind: 'reverted', + data, + }); + }); + + it('reports a revert with no data as reverted', async () => { + stub.onCall(QUEUE, EXECUTE_EXIT, RSK_REVERT_WITHOUT_DATA); + + expect(await callRaw(appProvider, call, VOID_RESULT)).toEqual({ + kind: 'reverted', + data: '0x', + }); + }); + + it('reports a call the node did not answer as unreadable', async () => { + stub.onCall(QUEUE, EXECUTE_EXIT, { status: 503, body: 'unavailable' }); + + expect(await callRaw(appProvider, call, VOID_RESULT)).toEqual({ + kind: 'unreadable', + }); + }); + + it("makes the call from the caller's address", async () => { + stub.onCall(QUEUE, EXECUTE_EXIT, { result: '0x' }); + + await callRaw(appProvider, call, VOID_RESULT); + + expect(stub.callsTo(QUEUE, EXECUTE_EXIT)).toEqual([ + { from: HOLDER, data: call.data }, + ]); + }); + + it('gives up on a backend that does not answer within the time it is given', async () => { + stub.onCall(QUEUE, EXECUTE_EXIT, { hang: true }); + const started = Date.now(); + + expect(await callRaw(appProvider, call, VOID_RESULT, 200)).toEqual({ + kind: 'unreadable', + }); + expect(Date.now() - started).toBeLessThan(2_000); + }); + + it('treats a result the caller does not accept as no answer', async () => { + stub.onCall(QUEUE, EXECUTE_EXIT, { result: '0x' }); + + expect(await callRaw(appProvider, call, result => result !== '0x')).toEqual( + { kind: 'unreadable' }, + ); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/rawCall.ts b/apps/frontend/src/hooks/exitDelay/rawCall.ts new file mode 100644 index 000000000..6cb11d63c --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/rawCall.ts @@ -0,0 +1,139 @@ +import { providers, utils } from 'ethers'; + +type JsonRpcSender = { + send: (method: string, params: unknown[]) => Promise; +}; + +const canSend = (value: unknown): value is JsonRpcSender => + typeof (value as JsonRpcSender | undefined)?.send === 'function'; + +/** + * The JSON-RPC backends behind a provider, in the order it lists them: the + * provider itself when it speaks JSON-RPC, or the backends of the app's + * fallback provider, which does not. + */ +export const sendersOf = (provider: providers.Provider): JsonRpcSender[] => { + if (canSend(provider)) { + return [provider]; + } + const configs = ( + provider as { providerConfigs?: ReadonlyArray<{ provider?: unknown }> } + ).providerConfigs; + if (!Array.isArray(configs)) { + return []; + } + return configs.map(config => config.provider).filter(canSend); +}; + +type NodeError = { code: number; message: string; data?: unknown }; + +/** + * The error object the node itself returned, if the failure carries one. + * + * Ethers' `getResult` turns a JSON-RPC error response into an Error holding + * the node's numeric `code`, `message` and `data`, and `fetchJson` nests it as + * `error` of a "processing response error", which it raises only after an + * HTTP 2xx. A timeout, an HTTP error status, a dropped connection or a body + * that is not JSON never carries one: their codes are ethers' own strings. + */ +const nodeErrorOf = (error: unknown): NodeError | undefined => { + let current = error as { code?: unknown; message?: unknown; error?: unknown }; + for (let depth = 0; current && depth < 3; depth++) { + if ( + typeof current.code === 'number' && + typeof current.message === 'string' + ) { + return current as NodeError; + } + current = current.error as typeof current; + } + return undefined; +}; + +/** + * Matches "revert target not active", "transaction reverted" and "execution + * reverted". A rate limit or an upstream failure reported as a JSON-RPC error + * does not match, and is not a revert. + */ +const REVERTED = /\brevert(ed)?\b/i; + +export type RawCallOutcome = + /** The node executed the call, and it returned this data. */ + | { kind: 'result'; data: string } + /** The node executed the call, and it reverted with this data if it gave any. */ + | { kind: 'reverted'; data?: string } + /** No backend answered. */ + | { kind: 'unreadable' }; + +export type RawCall = { to: string; data: string; from?: string }; + +/** + * `pending`, or a rejection once `timeoutMs` passes without it settling. The + * timer is cleared either way, and a late failure of the abandoned `pending` + * is not left unhandled. + */ +export const boundedBy = ( + pending: Promise, + timeoutMs: number, +): Promise => { + let timer: ReturnType | undefined; + const expiry = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`No answer within ${timeoutMs} ms.`)), + timeoutMs, + ); + }); + pending.catch(() => undefined); + return Promise.race([pending, expiry]).finally(() => clearTimeout(timer)); +}; + +/** + * Send `eth_call` straight to the provider's JSON-RPC backends and report the + * node's own answer. + * + * On the app's ordinary call path ethers reports a revert and a transport + * failure with the same CALL_EXCEPTION, the app's fallback provider drops the + * nested node error and its revert data, and a revert whose message says + * "reverted" with hex data comes back as a successful result. Here a revert is + * recognised from the node's error object and keeps its data, so a caller can + * both tell "the chain said no" from "no answer" and say why. + * + * `isAnswer` says what a result must look like to count as the node's answer, + * and every caller states it: an address without code answers any call with + * empty data, so taking any hex would read that as a yes. A backend that does + * not answer, or whose result `isAnswer` rejects, is skipped for the next one; + * when none answers the call is unreadable. `timeoutMs`, when given, bounds + * each backend's answer: a backend that does not answer within it is skipped + * like one that failed. + */ +export const callRaw = async ( + provider: providers.Provider, + call: RawCall, + isAnswer: (result: string) => boolean, + timeoutMs?: number, +): Promise => { + for (const sender of sendersOf(provider)) { + try { + const sent = sender.send('eth_call', [call, 'latest']); + const result = await (timeoutMs === undefined + ? sent + : boundedBy(sent, timeoutMs)); + if ( + typeof result === 'string' && + utils.isHexString(result) && + isAnswer(result) + ) { + return { kind: 'result', data: result }; + } + } catch (error) { + const nodeError = nodeErrorOf(error); + if (nodeError && REVERTED.test(nodeError.message)) { + return { + kind: 'reverted', + data: typeof nodeError.data === 'string' ? nodeError.data : undefined, + }; + } + } + } + return { kind: 'unreadable' }; +}; diff --git a/apps/frontend/src/hooks/exitDelay/readPerimeterPointer.test.ts b/apps/frontend/src/hooks/exitDelay/readPerimeterPointer.test.ts new file mode 100644 index 000000000..4097d660a --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/readPerimeterPointer.test.ts @@ -0,0 +1,179 @@ +import { Contract, providers } from 'ethers'; + +import { Chain, getProvider } from '@sovryn/ethers-provider'; + +import { + JsonRpcStub, + RSK_LOAN_TOKEN_TARGET_NOT_ACTIVE, + RSK_PROTOCOL_TARGET_NOT_ACTIVE, + RSK_REVERT_WITHOUT_DATA, + StubAnswer, + addressResult, + selectorOf, + startJsonRpcStub, +} from '../../utils/testing/jsonRpcStub'; +import { readAddressGetter } from './readPerimeterPointer'; + +/** + * A pointer may be reported as absent only when the node positively answered + * that the getter reverted. The installed ethers reports a revert and a + * transport failure with the same CALL_EXCEPTION on the app's call path, so + * these tests drive the app's real provider against a local node and hold the + * two apart at the JSON-RPC answer itself. + */ + +const CONSUMER = '0x5A0D867e0D70Fcc6Ade25C3F1B89d618b5B4Eaa7'; +const QUEUE = '0x1111111111111111111111111111111111111111'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const QUEUE_GETTER = selectorOf('exitDelayQueue()'); + +const TRANSPORT_FAILURES: [string, StubAnswer][] = [ + ['an HTTP 503', { status: 503, body: 'Service Unavailable' }], + [ + 'an HTTP 502 gateway page', + { status: 502, body: 'Bad Gateway' }, + ], + [ + 'a JSON-RPC rate limit', + { error: { code: -32005, message: 'rate limit exceeded' } }, + ], + [ + 'a JSON-RPC upstream timeout', + { error: { code: -32603, message: 'upstream request timeout' } }, + ], + ['a body that is not JSON', { raw: 'maintenance' }], + ['an empty result', { result: '0x' }], + ['a dropped connection', { drop: true }], +]; + +describe('readAddressGetter', () => { + let stub: JsonRpcStub; + let appProvider: providers.Provider; + + beforeAll(async () => { + stub = await startJsonRpcStub(); + // The app reaches RSK mainnet through an array of RPC URLs, which + // `getProvider` wraps in the app's own fallback provider. + appProvider = getProvider({ + id: '0x1e', + label: 'RSK', + token: 'RBTC', + rpcUrl: [stub.url], + blockExplorerUrl: '', + } as Chain); + }); + + afterEach(() => stub.reset()); + + afterAll(() => stub.close()); + + /** Well above anything these tests need to finish in on their own. */ + const DEFAULT_TIMEOUT_MS = 5_000; + + const read = ( + provider: providers.Provider = appProvider, + timeoutMs = DEFAULT_TIMEOUT_MS, + ) => readAddressGetter(provider, CONSUMER, 'exitDelayQueue', timeoutMs); + + describe('the node answered', () => { + it('returns the address the getter holds', async () => { + stub.onCall(CONSUMER, QUEUE_GETTER, addressResult(QUEUE)); + + expect(await read()).toEqual({ kind: 'address', address: QUEUE }); + }); + + it('returns the zero address as an address, not as absent', async () => { + stub.onCall(CONSUMER, QUEUE_GETTER, addressResult(ZERO_ADDRESS)); + + expect(await read()).toEqual({ kind: 'address', address: ZERO_ADDRESS }); + }); + + it.each([ + ['the lending protocol proxy', RSK_PROTOCOL_TARGET_NOT_ACTIVE], + ['a loan token proxy', RSK_LOAN_TOKEN_TARGET_NOT_ACTIVE], + [ + "Zero's BorrowerOperations, with no revert data", + RSK_REVERT_WITHOUT_DATA, + ], + ])('reports absent when %s reverts the getter', async (_name, answer) => { + stub.onCall(CONSUMER, QUEUE_GETTER, answer); + + expect(await read()).toEqual({ kind: 'absent' }); + }); + }); + + describe('the node did not answer', () => { + it.each(TRANSPORT_FAILURES)( + 'reports unreadable for %s', + async (_name, answer) => { + stub.onCall(CONSUMER, QUEUE_GETTER, answer); + + expect(await read()).toEqual({ kind: 'unreadable' }); + }, + ); + + it('reports unreadable for an HTTP 429', async () => { + // A single attempt: ethers otherwise retries a 429 with a randomised + // backoff that can run for minutes. + stub.onCall(CONSUMER, QUEUE_GETTER, { + status: 429, + body: 'Too Many Requests', + }); + const provider = new providers.StaticJsonRpcProvider({ + url: stub.url, + throttleLimit: 1, + throttleSlotInterval: 1, + }); + + expect(await read(provider)).toEqual({ kind: 'unreadable' }); + }); + + it('reports unreadable for a request that times out', async () => { + stub.onCall(CONSUMER, QUEUE_GETTER, { hang: true }); + const provider = new providers.StaticJsonRpcProvider({ + url: stub.url, + timeout: 200, + }); + + expect(await read(provider)).toEqual({ kind: 'unreadable' }); + }); + + it('reports unreadable within its own bound for a node that never answers at all, on a provider with no transport timeout of its own', async () => { + // The app's real provider is not given its own request timeout, so + // without a bound of its own here this call would hang for as long as + // the node never answers — this test's own timeout would be the only + // thing that ever ends it. + stub.onCall(CONSUMER, QUEUE_GETTER, { hang: true }); + + expect(await read(appProvider, 200)).toEqual({ kind: 'unreadable' }); + }, 2_000); + + it('reports unreadable for a provider it cannot send raw requests through', async () => { + expect(await read({} as providers.Provider)).toEqual({ + kind: 'unreadable', + }); + }); + }); + + describe("the app's ordinary call path", () => { + // Why the reader exists: through the app's provider a revert and a + // transport failure arrive as the same error code, with nothing left to + // tell them apart. + it.each([ + ['a revert with reason data', RSK_PROTOCOL_TARGET_NOT_ACTIVE], + ['a revert without data', RSK_REVERT_WITHOUT_DATA], + ...TRANSPORT_FAILURES, + ])('reports %s as CALL_EXCEPTION', async (_name, answer) => { + stub.onCall(CONSUMER, QUEUE_GETTER, answer); + const pointer = new Contract( + CONSUMER, + ['function exitDelayQueue() view returns (address)'], + appProvider, + ); + + await expect(pointer.exitDelayQueue()).rejects.toMatchObject({ + code: 'CALL_EXCEPTION', + }); + }); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/readPerimeterPointer.ts b/apps/frontend/src/hooks/exitDelay/readPerimeterPointer.ts new file mode 100644 index 000000000..4222a02b5 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/readPerimeterPointer.ts @@ -0,0 +1,97 @@ +import { providers, utils } from 'ethers'; + +import { ChainId, getProvider } from '@sovryn/ethers-provider'; + +import { asyncCall } from '../../store/rxjs/provider-cache'; +import { EXIT_DELAY_TTL, RELEASE_READ_TIMEOUT_MS } from '../../utils/exitDelay'; +import { callRaw } from './rawCall'; + +export type PointerGetter = 'exitDelayQueue' | 'exitFeeController'; + +/** + * One read of a consumer's perimeter pointer. + * + * `absent` is a positive answer: the node executed the getter and it reverted, + * so the consumer has no such pointer. `unreadable` is no answer at all, and + * must never be treated as `absent`. + */ +export type PointerRead = + /** The getter's value. The zero address means the pointer is unset. */ + | { kind: 'address'; address: string } + | { kind: 'absent' } + | { kind: 'unreadable' }; + +export const UNREADABLE_POINTER: PointerRead = { kind: 'unreadable' }; + +const GETTERS = new utils.Interface([ + 'function exitDelayQueue() view returns (address)', + 'function exitFeeController() view returns (address)', +]); + +const decodeAddress = (result: string): string | undefined => { + if (utils.hexDataLength(result) !== 32) { + return undefined; + } + try { + return utils.defaultAbiCoder.decode(['address'], result)[0]; + } catch (error) { + return undefined; + } +}; + +/** + * Read one of a consumer's perimeter pointers, telling a getter the consumer + * does not have apart from a node that could not be reached. + * + * The node's own answer decides, through `callRaw`: + * + * - a 32-byte result is the pointer, the zero address included; + * - a call the node reports as reverted is `absent`; + * - anything else, from every backend, is `unreadable` — including a backend + * that does not answer at all within `timeoutMs`. + */ +export const readAddressGetter = async ( + provider: providers.Provider, + contractAddress: string, + getter: PointerGetter, + timeoutMs: number, +): Promise => { + const outcome = await callRaw( + provider, + { to: contractAddress, data: GETTERS.encodeFunctionData(getter) }, + result => decodeAddress(result) !== undefined, + timeoutMs, + ); + if (outcome.kind === 'result') { + const address = decodeAddress(outcome.data); + return address ? { kind: 'address', address } : UNREADABLE_POINTER; + } + return outcome.kind === 'reverted' ? { kind: 'absent' } : UNREADABLE_POINTER; +}; + +/** + * `readAddressGetter` through the shared cache, keyed by chain, consumer and + * getter, so every hook reading the same pointer shares one round trip and + * one classification. Never throws. + */ +export const readPerimeterPointer = async ( + chainId: ChainId, + consumerAddress: string, + getter: PointerGetter, +): Promise => { + try { + return await asyncCall( + `perimeter/pointer/${chainId}/${consumerAddress.toLowerCase()}/${getter}`, + async () => + readAddressGetter( + getProvider(chainId), + consumerAddress, + getter, + RELEASE_READ_TIMEOUT_MS, + ), + { ttl: EXIT_DELAY_TTL }, + ); + } catch (error) { + return UNREADABLE_POINTER; + } +}; diff --git a/apps/frontend/src/hooks/exitDelay/useChainTime.test.ts b/apps/frontend/src/hooks/exitDelay/useChainTime.test.ts new file mode 100644 index 000000000..3012107e6 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useChainTime.test.ts @@ -0,0 +1,222 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { + JsonRpcStub, + captureCallFailure, + startJsonRpcStub, +} from '../../utils/testing/jsonRpcStub'; +import { useChainTime } from './useChainTime'; + +/** + * The queue compares `block.timestamp`. A clock a couple of minutes fast flips + * a still-locked hold to "Ready", and the release reverts NotUnlocked — inside + * a batch, taking every other ready hold with it. So this hook must follow the + * chain and use the local clock only for the seconds elapsed since the block, + * and it must say when the block could not be read rather than leave callers + * waiting on a time that will not arrive. + */ + +const BLOCK_TIMESTAMP = 1_800_000_000; +/** The machine is an hour fast. Only the elapsed delta may survive that. */ +const LOCAL_NOW = (BLOCK_TIMESTAMP + 3_600) * 1000; + +const mockGetBlock = jest.fn(); + +jest.mock('@sovryn/ethers-provider', () => ({ + getProvider: () => ({ getBlock: (tag: string) => mockGetBlock(tag) }), +})); + +// Long enough to sit well above RELEASE_READ_TIMEOUT_MS below, so a test can +// tell the block read settling on its own bound apart from this one firing +// instead; short enough that the deadline test below does not wait ten real +// seconds. +jest.mock('./useExitDelay', () => ({ + ...jest.requireActual('./useExitDelay'), + EXIT_DELAY_QUOTE_TIMEOUT_MS: 500, +})); + +// Short enough that the hang test below settles well inside the deadline above. +jest.mock('../../utils/exitDelay', () => ({ + ...jest.requireActual('../../utils/exitDelay'), + RELEASE_READ_TIMEOUT_MS: 50, +})); + +jest.mock('../useCacheCall', () => { + const React = jest.requireActual('react'); + return { + useCacheCall: ( + _key: string, + _chainId: string, + fn: () => Promise, + _deps: unknown[], + defaultValue: unknown, + ) => { + const [state, setState] = React.useState({ + value: defaultValue, + // Idle at first, as the shared cache is: a result still to come must + // be reported by the hook under test, not by this stand-in. + loading: false, + error: null, + }); + // Hold the latest fn and default without making the mount effect below + // re-run: their identities change every render, but this mock fetches + // once. + const fnRef = React.useRef(fn); + fnRef.current = fn; + const defaultRef = React.useRef(defaultValue); + defaultRef.current = defaultValue; + React.useEffect(() => { + let alive = true; + // Like the shared cache, a failed fetch leaves the default value and + // reports the error. + Promise.resolve(fnRef.current()).then( + (value: unknown) => { + if (alive) setState({ value, loading: false, error: null }); + }, + (error: unknown) => { + if (alive) { + setState({ value: defaultRef.current, loading: false, error }); + } + }, + ); + return () => { + alive = false; + }; + }, []); + return state; + }, + }; +}); + +describe('useChainTime', () => { + let stub: JsonRpcStub; + let transportFailure: unknown; + + beforeAll(async () => { + stub = await startJsonRpcStub(); + transportFailure = await captureCallFailure(stub, { + status: 503, + body: 'unavailable', + }); + }); + + afterAll(async () => { + jest.restoreAllMocks(); + await stub.close(); + }); + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(LOCAL_NOW); + mockGetBlock.mockResolvedValue({ timestamp: BLOCK_TIMESTAMP }); + }); + + it("reads the chain's clock, not the machine's", async () => { + const { result } = renderHook(() => useChainTime('0x1e' as never)); + + await waitFor(() => expect(result.current.now).toBeGreaterThan(0)); + expect(mockGetBlock).toHaveBeenCalledWith('latest'); + expect(result.current.now).toBe(BLOCK_TIMESTAMP); + expect(result.current.unreadable).toBe(false); + }); + + describe('between block reads', () => { + beforeEach(() => { + jest.useFakeTimers('modern'); + // The machine's clock is an hour fast; only elapsed time may survive it. + jest.setSystemTime(LOCAL_NOW); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const readFirstBlock = async () => { + const rendered = renderHook(() => useChainTime('0x1e' as never)); + // The block read settles over several promise hops; timers stay put. + await act(async () => { + for (let hop = 0; hop < 20; hop++) { + await Promise.resolve(); + } + }); + expect(rendered.result.current.now).toBeGreaterThan(0); + return rendered.result; + }; + + it('advances by elapsed local time, so a wrong offset cancels out', async () => { + const result = await readFirstBlock(); + + act(() => { + jest.advanceTimersByTime(90_000); + }); + + expect(result.current.now).toBe(BLOCK_TIMESTAMP + 90); + // The latest block's own timestamp does not tick: the queue compares it, + // so readiness is judged against it and not against the ticking clock. + expect(result.current.blockTime).toBe(BLOCK_TIMESTAMP); + }); + + it('ticks every 15 seconds, not every second', async () => { + const result = await readFirstBlock(); + + act(() => { + jest.advanceTimersByTime(5_000); + }); + expect(result.current.now).toBe(BLOCK_TIMESTAMP); + + act(() => { + jest.advanceTimersByTime(10_000); + }); + expect(result.current.now).toBe(BLOCK_TIMESTAMP + 15); + }); + }); + + it('reports 0, and not unreadable, until a block has been read', () => { + mockGetBlock.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => useChainTime('0x1e' as never)); + + // Callers treat 0 as "not known yet": deciding a release against it would + // be deciding against the epoch. + expect(result.current.now).toBe(0); + expect(result.current.blockTime).toBe(0); + expect(result.current.unreadable).toBe(false); + }); + + it('reports the clock as unreadable when the block read fails', async () => { + mockGetBlock.mockRejectedValue(transportFailure); + + const { result } = renderHook(() => useChainTime('0x1e' as never)); + + await waitFor(() => expect(result.current.unreadable).toBe(true)); + expect(result.current.now).toBe(0); + }); + + it('reports the clock as unreadable, not stuck forever, once its deadline passes without a block', async () => { + // The block number itself failing to arrive — an RPC endpoint refusing + // every request — leaves the same shape as a rejected read: no block ever + // lands, and nothing here may wait on one that will not arrive. + mockGetBlock.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => useChainTime('0x1e' as never)); + expect(result.current.unreadable).toBe(false); + + await waitFor(() => expect(result.current.unreadable).toBe(true)); + expect(result.current.now).toBe(0); + }); + + it('settles on its own bound, not the outer deadline, when the block read hangs rather than failing outright', async () => { + // No answer ever arrives here, not even a rejection. A read with its own + // bound turns that into a rejection quickly, well inside + // EXIT_DELAY_QUOTE_TIMEOUT_MS above; one without a bound of its own is + // only ever caught by that outer deadline, which is why the wait below is + // capped well short of it. + mockGetBlock.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => useChainTime('0x1e' as never)); + + await waitFor(() => expect(result.current.unreadable).toBe(true), { + timeout: 300, + }); + expect(result.current.now).toBe(0); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/useChainTime.ts b/apps/frontend/src/hooks/exitDelay/useChainTime.ts new file mode 100644 index 000000000..aff1f6465 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useChainTime.ts @@ -0,0 +1,112 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { ChainId, getProvider } from '@sovryn/ethers-provider'; + +import { EXIT_DELAY_TTL, RELEASE_READ_TIMEOUT_MS } from '../../utils/exitDelay'; +import { useCacheCall } from '../useCacheCall'; +import { boundedBy } from './rawCall'; +import { EXIT_DELAY_QUOTE_TIMEOUT_MS, useDeadlinePassed } from './useExitDelay'; + +type Anchor = { + /** Latest block timestamp, in seconds. 0 until one has been read. */ + timestamp: number; + /** Local clock reading taken with it, used only for elapsed time. */ + readAt: number; +}; + +const NO_ANCHOR: Anchor = { timestamp: 0, readAt: 0 }; + +/** + * How often the clock advances between block reads. Every time shown from it + * is in whole minutes, so a tick is at most this late in showing the next + * minute, and the clock never runs ahead of the chain: a hold is never shown + * as unlocked before it is. + */ +const CHAIN_CLOCK_TICK_MS = 15_000; + +export type ChainClock = { + /** + * The chain's time in seconds, or 0 until the first block has been read. + * Callers must treat 0 as "not known yet" rather than as the epoch — a + * countdown or a release decision made against 0 would be nonsense. + */ + now: number; + /** + * The latest block's own timestamp, in seconds, or 0 until one has been + * read. It does not tick: the queue compares it, so whether a release can + * pass is judged against it, not against `now`. + */ + blockTime: number; + /** + * True when the block read failed and no time is known, so a caller can say + * it could not read instead of waiting for a time that will not arrive. + */ + unreadable: boolean; +}; + +/** + * The chain's clock, advancing every CHAIN_CLOCK_TICK_MS. + * + * The queue compares `block.timestamp`, so anything decided from `Date.now()` + * is decided from the user's own machine: a clock a couple of minutes fast + * flips a still-locked hold to "Ready", and the release reverts NotUnlocked — + * inside a batch, taking every other ready hold down with it. A slow clock + * withholds a release the holder is entitled to. + * + * So the value is anchored to the latest block's timestamp, refreshed with the + * block, and advanced between refreshes by ELAPSED local time. A wrong local + * offset cancels out in the subtraction; only the machine's clock RATE could + * drift, and no machine drifts a second per second. + */ +export const useChainTime = (chainId: ChainId): ChainClock => { + const key = `exitDelay/chainTime/${chainId}`; + + const { value: anchor, error } = useCacheCall( + key, + chainId, + async () => { + const block = await boundedBy( + getProvider(chainId).getBlock('latest'), + RELEASE_READ_TIMEOUT_MS, + ); + return { timestamp: block.timestamp, readAt: Date.now() }; + }, + [chainId], + NO_ANCHOR, + { ttl: EXIT_DELAY_TTL }, + ); + + const [now, setNow] = useState(0); + + useEffect(() => { + if (!anchor.timestamp) { + return; + } + const advance = () => + setNow( + anchor.timestamp + + Math.max(0, Math.floor((Date.now() - anchor.readAt) / 1000)), + ); + advance(); + const timer = setInterval(advance, CHAIN_CLOCK_TICK_MS); + return () => clearInterval(timer); + }, [anchor.timestamp, anchor.readAt]); + + // The shared cache starts no read at all until a block number is known, so + // a block number that never arrives leaves no anchor AND no error, forever. + // Past this deadline an anchor still not read counts the same as one that + // failed outright, rather than a caller waiting on a block that will not + // come. + const deadlinePassed = useDeadlinePassed( + key, + !!anchor.timestamp, + EXIT_DELAY_QUOTE_TIMEOUT_MS, + ); + + const unreadable = !anchor.timestamp && (!!error || deadlinePassed); + + return useMemo( + () => ({ now, blockTime: anchor.timestamp, unreadable }), + [now, anchor.timestamp, unreadable], + ); +}; diff --git a/apps/frontend/src/hooks/exitDelay/useExecuteExit.test.ts b/apps/frontend/src/hooks/exitDelay/useExecuteExit.test.ts new file mode 100644 index 000000000..b2170c7cf --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useExecuteExit.test.ts @@ -0,0 +1,388 @@ +import { act, renderHook } from '@testing-library/react'; + +import { i18n } from '../../locales/i18n'; +import { useExecuteExit, useExecuteExits } from './useExecuteExit'; + +/** + * These hooks are mocked away in the page's own tests, so nothing there sees + * what the real ones do with their arguments — and an `onComplete` the page + * passed but the hook never accepted sat in the tree unnoticed. That callback + * is what drops a just-released id, and without it a second release of the + * same id reverts the whole atomic batch. So it is asserted here, against the + * real hook, all the way onto the transaction it is attached to. + * + * The release is checked again inside the send step, immediately before the + * wallet is asked to sign. What that check returns — the ids that still pass + * and the gas estimated for exactly those — is what is sent. + */ + +const QUEUE = '0x9999999999999999999999999999999999999999'; +const OTHER_QUEUE = '0x8888888888888888888888888888888888888888'; +const HOLDER = '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. Its provider hands +// out a signer bound to one address, as a wallet's provider does. +const mockSigner = { + _isSigner: true, + provider: { + getSigner: (address: string) => ({ + _isSigner: true, + provider: {}, + getAddress: async () => address, + sendTransaction: async () => { + throw new Error('This fixture only inspects prepared requests.'); + }, + }), + }, +}; + +jest.mock('../useAccount', () => ({ + useAccount: () => ({ signer: mockSigner }), +})); + +jest.mock('../../contexts/TransactionContext', () => ({ + useTransactionContext: () => ({ + setTransactions: (...args: unknown[]) => mockSetTransactions(...args), + setIsOpen: (...args: unknown[]) => mockSetIsOpen(...args), + setTitle: (...args: unknown[]) => mockSetTitle(...args), + }), +})); + +const step = () => mockSetTransactions.mock.calls[0][0][0]; + +/** A step's config as the dialog prepared it when it opened: its flat gas default. */ +const OPENED_CONFIG = { gasLimit: '6000000', gasPrice: '0.065' }; + +/** Run a step's send check the way the dialog does when the holder confirms. */ +const sendStep = (transaction: { + request: unknown; + beforeSend: (step: { request: unknown; config: unknown }) => Promise<{ + request: { + args: unknown[]; + contract: { signer: { getAddress: () => Promise } }; + }; + config: { gasLimit?: string }; + }>; +}) => + transaction.beforeSend({ + request: transaction.request, + config: OPENED_CONFIG, + }); + +describe('useExecuteExit', () => { + beforeAll(async () => { + await i18n; + }); + + it('attaches the caller-supplied callback to the release transaction', async () => { + const onComplete = jest.fn(); + const { result } = renderHook(() => useExecuteExit()); + + await act(async () => { + await result.current(QUEUE, '7', { onComplete, preflight: jest.fn() }); + }); + + expect(step().title).toBe('Release delayed withdrawal'); + expect(step().request.fnName).toBe('executeExit'); + expect(step().request.args).toEqual(['7']); + // Not `expect.any(Function)`: the exact callback must survive, since it is + // the one that knows which id was just released. + expect(step().onComplete).toBe(onComplete); + }); + + it('opens the transaction dialog for the release', async () => { + const { result } = renderHook(() => useExecuteExit()); + + await act(async () => { + await result.current(QUEUE, '7', { preflight: jest.fn() }); + }); + + expect(mockSetIsOpen).toHaveBeenCalledWith(true); + }); + + it('checks the release again inside the send step, and sends it with the gas that check estimated', async () => { + const preflight = jest.fn().mockResolvedValue({ + requestIds: ['7'], + gasLimit: '50000', + from: HOLDER, + }); + const { result } = renderHook(() => useExecuteExit()); + + await act(async () => { + await result.current(QUEUE, '7', { preflight }); + }); + const sent = await sendStep(step()); + + // The dialog prepared its gas limit itself, so the check is told of no + // typed limit. + expect(preflight).toHaveBeenCalledWith( + { queueAddress: QUEUE, requestIds: ['7'] }, + undefined, + ); + expect(sent.request.args).toEqual(['7']); + expect(sent.config).toEqual({ ...OPENED_CONFIG, gasLimit: '50000' }); + }); + + it('hands the check inside the send step a gas limit the holder typed in Advanced settings, and sends the limit that check settles on', async () => { + const preflight = jest.fn().mockResolvedValue({ + requestIds: ['7'], + gasLimit: '75000', + from: HOLDER, + }); + const { result } = renderHook(() => useExecuteExit()); + + await act(async () => { + await result.current(QUEUE, '7', { preflight }); + }); + const sent = await step().beforeSend({ + request: step().request, + config: { + ...OPENED_CONFIG, + gasLimit: '75000', + gasLimitTypedByUser: true, + }, + }); + + expect(preflight).toHaveBeenCalledWith( + { queueAddress: QUEUE, requestIds: ['7'] }, + '75000', + ); + expect(sent.config.gasLimit).toBe('75000'); + }); + + it('hands the wallet the release from the account the check ran for, whichever account is active later', async () => { + const preflight = jest.fn().mockResolvedValue({ + requestIds: ['7'], + gasLimit: '50000', + from: HOLDER, + }); + const { result } = renderHook(() => useExecuteExit()); + + await act(async () => { + await result.current(QUEUE, '7', { preflight }); + }); + const sent = await sendStep(step()); + + expect(await sent.request.contract.signer.getAddress()).toBe(HOLDER); + }); + + it('sends nothing when the check inside the send step names no account to send from', async () => { + const preflight = jest + .fn() + .mockResolvedValue({ requestIds: ['7'], gasLimit: '50000', from: '' }); + const { result } = renderHook(() => useExecuteExit()); + + await act(async () => { + await result.current(QUEUE, '7', { preflight }); + }); + + await expect(sendStep(step())).rejects.toThrow(); + }); + + it('sends nothing when the check inside the send step refuses', async () => { + const preflight = jest.fn().mockRejectedValue(new Error('refused')); + const { result } = renderHook(() => useExecuteExit()); + + await act(async () => { + await result.current(QUEUE, '7', { preflight }); + }); + + await expect(sendStep(step())).rejects.toThrow(); + }); + + it('sends nothing when the check inside the send step does not pass the id', async () => { + const preflight = jest + .fn() + .mockResolvedValue({ requestIds: [], gasLimit: '50000' }); + const { result } = renderHook(() => useExecuteExit()); + + await act(async () => { + await result.current(QUEUE, '7', { preflight }); + }); + + await expect(sendStep(step())).rejects.toThrow(); + }); + + it('does nothing without a queue address', async () => { + const { result } = renderHook(() => useExecuteExit()); + + await act(async () => { + await result.current(undefined, '7', { + onComplete: jest.fn(), + preflight: jest.fn(), + }); + }); + + expect(mockSetTransactions).not.toHaveBeenCalled(); + }); +}); + +describe('useExecuteExits', () => { + beforeAll(async () => { + await i18n; + }); + + it('reports the batch it settled, queue and ids, so the page can drop exactly those rows', async () => { + // Ids restart in each queue: an id alone would also drop another queue's + // request that shares it. + const onComplete = jest.fn(); + const preflight = jest.fn().mockResolvedValue({ + requestIds: ['7', '8'], + gasLimit: '50000', + from: HOLDER, + }); + const { result } = renderHook(() => useExecuteExits()); + + await act(async () => { + await result.current([{ queueAddress: QUEUE, requestIds: ['7', '8'] }], { + onComplete, + preflight, + }); + }); + + expect(step().request.fnName).toBe('executeExits'); + expect(step().request.args).toEqual([['7', '8']]); + await sendStep(step()); + step().onComplete(); + expect(onComplete).toHaveBeenCalledWith({ + queueAddress: QUEUE, + requestIds: ['7', '8'], + }); + }); + + it('sends only the ids the check inside the send step still passes, with the gas estimated for them, and settles exactly those', async () => { + const onComplete = jest.fn(); + const preflight = jest.fn().mockResolvedValue({ + requestIds: ['7', '9'], + gasLimit: '50000', + from: HOLDER, + }); + const { result } = renderHook(() => useExecuteExits()); + + await act(async () => { + await result.current( + [{ queueAddress: QUEUE, requestIds: ['7', '8', '9'] }], + { onComplete, preflight }, + ); + }); + const sent = await sendStep(step()); + + expect(preflight).toHaveBeenCalledWith( + { queueAddress: QUEUE, requestIds: ['7', '8', '9'] }, + undefined, + ); + expect(sent.request.args).toEqual([['7', '9']]); + expect(sent.config).toEqual({ ...OPENED_CONFIG, gasLimit: '50000' }); + expect(await sent.request.contract.signer.getAddress()).toBe(HOLDER); + step().onComplete(); + expect(onComplete).toHaveBeenCalledWith({ + queueAddress: QUEUE, + requestIds: ['7', '9'], + }); + }); + + it('hands the check inside the send step a gas limit the holder typed in Advanced settings for the batch', async () => { + const preflight = jest.fn().mockResolvedValue({ + requestIds: ['7', '8'], + gasLimit: '90000', + from: HOLDER, + }); + const { result } = renderHook(() => useExecuteExits()); + + await act(async () => { + await result.current([{ queueAddress: QUEUE, requestIds: ['7', '8'] }], { + preflight, + }); + }); + const sent = await step().beforeSend({ + request: step().request, + config: { + ...OPENED_CONFIG, + gasLimit: '90000', + gasLimitTypedByUser: true, + }, + }); + + expect(preflight).toHaveBeenCalledWith( + { queueAddress: QUEUE, requestIds: ['7', '8'] }, + '90000', + ); + expect(sent.config.gasLimit).toBe('90000'); + }); + + it('sends no batch when the check inside the send step names no account to send from', async () => { + const preflight = jest + .fn() + .mockResolvedValue({ requestIds: ['7'], gasLimit: '50000', from: '' }); + const { result } = renderHook(() => useExecuteExits()); + + await act(async () => { + await result.current([{ queueAddress: QUEUE, requestIds: ['7', '8'] }], { + preflight, + }); + }); + + await expect(sendStep(step())).rejects.toThrow(); + }); + + it('never sends an empty batch when the check inside the send step passes no id', async () => { + const preflight = jest + .fn() + .mockResolvedValue({ requestIds: [], gasLimit: '50000' }); + const { result } = renderHook(() => useExecuteExits()); + + await act(async () => { + await result.current([{ queueAddress: QUEUE, requestIds: ['7', '8'] }], { + preflight, + }); + }); + + await expect(sendStep(step())).rejects.toThrow(); + }); + + it('signs one transaction per queue, in a single list', async () => { + // A second call would replace the first: the holder would sign only the + // last queue's release while believing they released everything. + const onComplete = jest.fn(); + const { result } = renderHook(() => useExecuteExits()); + + await act(async () => { + await result.current( + [ + { queueAddress: QUEUE, requestIds: ['7'] }, + { queueAddress: OTHER_QUEUE, requestIds: ['8'] }, + ], + { onComplete, preflight: jest.fn() }, + ); + }); + + const steps = mockSetTransactions.mock.calls[0][0]; + expect(steps).toHaveLength(1 + 1); + expect(steps[0].request.contract.address).toBe(QUEUE); + expect(steps[1].request.contract.address).toBe(OTHER_QUEUE); + expect(typeof steps[0].beforeSend).toBe('function'); + expect(typeof steps[1].beforeSend).toBe('function'); + steps[1].onComplete(); + expect(onComplete).toHaveBeenCalledWith({ + queueAddress: OTHER_QUEUE, + requestIds: ['8'], + }); + }); + + it('does nothing for an empty batch', async () => { + const { result } = renderHook(() => useExecuteExits()); + + await act(async () => { + await result.current([{ queueAddress: QUEUE, requestIds: [] }], { + onComplete: jest.fn(), + preflight: jest.fn(), + }); + }); + + expect(mockSetTransactions).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/useExecuteExit.ts b/apps/frontend/src/hooks/exitDelay/useExecuteExit.ts new file mode 100644 index 000000000..8a7abbde5 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useExecuteExit.ts @@ -0,0 +1,228 @@ +import { useCallback } from 'react'; + +import { ethers, providers } from 'ethers'; +import { t } from 'i18next'; + +import { RSK_CHAIN_ID } from '../../config/chains'; + +import { + SignTransactionRequest, + Transaction, + TransactionConfig, + TransactionType, +} from '../../app/3_organisms/TransactionStepDialog/TransactionStepDialog.types'; +import { useTransactionContext } from '../../contexts/TransactionContext'; +import { translations } from '../../locales/i18n'; +import { useAccount } from '../useAccount'; + +const QUEUE_ABI = [ + 'function executeExit(uint256 requestId)', + 'function executeExits(uint256[] ids)', +]; + +/** Ready exits from one queue. An id is only accepted by the queue holding it. */ +export type ExitBatch = { + queueAddress: string; + requestIds: string[]; +}; + +/** + * The release check, run inside the transaction dialog's send step, + * immediately before the wallet is asked to sign, and told the gas limit the + * holder typed in the dialog's Advanced settings, if any. It resolves to the + * ids of the batch that still pass, the gas limit to send exactly those with, + * and the account the check ran for, or rejects, and then nothing is sent. + */ +export type ExitPreflight = ( + batch: ExitBatch, + typedGasLimit: string | undefined, +) => Promise<{ requestIds: string[]; gasLimit: string; from: string }>; + +export type ExitSendOptions = { + preflight: ExitPreflight; + onComplete?: OnComplete; +}; + +const notSendable = () => + new Error('The release check passed nothing this transaction may send.'); + +/** + * The release's contract bound to one account's signer. The wallet is then + * handed a transaction from the account the check ran for, not from whichever + * account is active in it when it signs. + */ +const boundTo = ( + request: SignTransactionRequest, + provider: providers.JsonRpcProvider, + from: string, +) => { + const signer = provider.getSigner(from); + const sendTransaction = signer.sendTransaction.bind(signer); + // Contract overrides cannot carry chainId in ethers v5. Pin it at the + // fresh signer's submission boundary without changing shared wallet state. + signer.sendTransaction = transaction => + sendTransaction({ ...transaction, chainId: Number(RSK_CHAIN_ID) }); + return request.contract.connect(signer); +}; + +/** + * A gas limit the holder typed in the dialog's Advanced settings; undefined + * when the step holds a limit the dialog prepared, or the field is empty. + */ +const typedGasLimitOf = (config: TransactionConfig): string | undefined => { + const limit = config.gasLimit?.toString() ?? ''; + return config.gasLimitTypedByUser && limit !== '' ? limit : undefined; +}; + +/** + * Release one delayed exit from the perimeter vault to its receiver. + * + * The destination is not a parameter: the queue pays the receiver frozen into + * the request at the moment the withdrawal was made, so this cannot redirect + * funds. + * + * The dialog hands the release to the wallet only after `preflight` passes it + * inside the send step, with the gas limit `preflight` settles on for it and + * from the account `preflight` checked: state and the wallet's active account + * can change while the dialog is open, and the dialog's own estimate, made + * when it opens, falls back to a flat limit when it fails. + * + * The queue and the callback both belong to the call: an exit is held by the + * queue its own surface pointed at, and only the caller knows which id this + * release settles. + */ +export const useExecuteExit = () => { + const { signer } = useAccount(); + const { setTransactions, setIsOpen, setTitle } = useTransactionContext(); + + return useCallback( + async ( + queueAddress: string | undefined, + requestId: string, + { preflight, onComplete }: ExitSendOptions<() => void>, + ) => { + if (!queueAddress || !signer) { + return; + } + const { provider } = signer; + const request: SignTransactionRequest = { + type: TransactionType.signTransaction, + contract: new ethers.Contract(queueAddress, QUEUE_ABI, signer), + fnName: 'executeExit', + args: [requestId], + }; + + const transaction: Transaction = { + title: t(translations.perimeterPage.tx.executeExit), + request, + beforeSend: async ({ config }) => { + const checked = await preflight( + { queueAddress, requestIds: [requestId] }, + typedGasLimitOf(config), + ); + if ( + !checked.from || + checked.requestIds.length !== 1 || + checked.requestIds[0] !== requestId + ) { + throw notSendable(); + } + return { + request: { + ...request, + contract: boundTo(request, provider, checked.from), + }, + config: { ...config, gasLimit: checked.gasLimit }, + }; + }, + onComplete, + }; + + setTransactions([transaction]); + setTitle(t(translations.perimeterPage.tx.executeExitTitle)); + setIsOpen(true); + }, + [setIsOpen, setTitle, setTransactions, signer], + ); +}; + +/** + * Release several ready exits, one transaction per queue. + * + * `executeExits` is atomic on-chain: one locked, blocked or paused id reverts + * the whole batch. Each transaction therefore sends only the ids `preflight` + * still passes inside its send step, with the gas limit it settles on for + * exactly those, and reports exactly those as settled once it completes. + * + * Batches are per queue and go into ONE transaction list rather than one call + * each: a second call would replace the first, and the holder would sign only + * the last queue's release while believing they had released everything. + */ +export const useExecuteExits = () => { + const { signer } = useAccount(); + const { setTransactions, setIsOpen, setTitle } = useTransactionContext(); + + return useCallback( + async ( + batches: ExitBatch[], + { preflight, onComplete }: ExitSendOptions<(batch: ExitBatch) => void>, + ) => { + const usable = batches.filter( + batch => batch.queueAddress && batch.requestIds.length > 0, + ); + if (!signer || usable.length === 0) { + return; + } + const { provider } = signer; + + setTransactions( + usable.map(({ queueAddress, requestIds }): Transaction => { + const request: SignTransactionRequest = { + type: TransactionType.signTransaction, + contract: new ethers.Contract(queueAddress, QUEUE_ABI, signer), + fnName: 'executeExits', + args: [requestIds], + }; + // The ids the latest send step handed to the wallet. + let sentIds = requestIds; + + return { + title: t(translations.perimeterPage.tx.executeExits, { + count: requestIds.length, + }), + request, + beforeSend: async ({ config }) => { + const checked = await preflight( + { queueAddress, requestIds }, + typedGasLimitOf(config), + ); + if ( + !checked.from || + checked.requestIds.length === 0 || + checked.requestIds.some(id => !requestIds.includes(id)) + ) { + throw notSendable(); + } + sentIds = checked.requestIds; + return { + request: { + ...request, + contract: boundTo(request, provider, checked.from), + args: [checked.requestIds], + }, + config: { ...config, gasLimit: checked.gasLimit }, + }; + }, + // The batch, not its ids alone: ids restart in each queue. + onComplete: onComplete + ? () => onComplete({ queueAddress, requestIds: sentIds }) + : undefined, + }; + }), + ); + setTitle(t(translations.perimeterPage.tx.executeExitTitle)); + setIsOpen(true); + }, + [setIsOpen, setTitle, setTransactions, signer], + ); +}; diff --git a/apps/frontend/src/hooks/exitDelay/useExecuteExit.wallet.test.ts b/apps/frontend/src/hooks/exitDelay/useExecuteExit.wallet.test.ts new file mode 100644 index 000000000..eb63167a6 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useExecuteExit.wallet.test.ts @@ -0,0 +1,171 @@ +import { act, renderHook } from '@testing-library/react'; + +import { providers, utils } from 'ethers'; + +import { sendOrSimulateTx } from '../../app/3_organisms/TransactionStepDialog/utils'; +import { useExecuteExit, useExecuteExits } from './useExecuteExit'; + +const ACCOUNT = '0x1111111111111111111111111111111111111111'; +const OTHER = '0x3333333333333333333333333333333333333333'; +const QUEUE = '0x2222222222222222222222222222222222222222'; +const ABI = new utils.Interface([ + 'function executeExit(uint256 requestId)', + 'function executeExits(uint256[] ids)', +]); + +let mockSigner: providers.JsonRpcSigner; +const mockSetTransactions = jest.fn(); + +jest.mock('../useAccount', () => ({ + useAccount: () => ({ signer: mockSigner }), +})); +jest.mock('../../config/chains', () => ({ RSK_CHAIN_ID: '0x1e' })); +jest.mock('../../contexts/TransactionContext', () => ({ + useTransactionContext: () => ({ + setTransactions: (...args: unknown[]) => mockSetTransactions(...args), + setIsOpen: () => undefined, + setTitle: () => undefined, + }), +})); +jest.mock('nanoid', () => ({ nanoid: () => 'fixture' })); +jest.mock('@sovryn/ui', () => ({ NotificationType: {}, StatusType: {} })); +jest.mock('../../utils/simulator/simulateTx', () => ({ + simulateTx: () => { + throw new Error('Simulation is outside this fixture'); + }, +})); + +type WalletRequest = { + from: string; + to: string; + data: string; + value: string; + chainId?: string; +}; + +const wallet = async (change: 'none' | 'chain' | 'account') => { + let chain = '0x1e'; + let account = ACCOUNT; + const requests: WalletRequest[] = []; + const accepted: WalletRequest[] = []; + const provider = new providers.Web3Provider({ + request: async ({ method, params }) => { + if (method === 'eth_chainId') return chain; + if (method === 'eth_accounts') return [account]; + if (method === 'eth_getTransactionCount') return '0x0'; + if (method === 'eth_blockNumber') return '0x64'; + if (method === 'eth_sendTransaction') { + const transaction = (params as WalletRequest[])[0]; + requests.push(transaction); + if (change === 'chain') chain = '0x1'; + if (change === 'account') account = OTHER; + if (transaction.from.toLowerCase() !== account.toLowerCase()) { + throw new Error('Account refused'); + } + if (transaction.chainId && transaction.chainId !== chain) { + throw new Error('Chain refused'); + } + accepted.push(transaction); + // Capture the real wallet request without fabricating a mined receipt. + throw new Error('Captured without broadcast'); + } + throw new Error(`Unexpected fixture method: ${method}`); + }, + }); + await provider.getNetwork(); + mockSigner = provider.getSigner(ACCOUNT); + return { provider, requests, accepted }; +}; + +describe('Perimeter release wallet intent', () => { + beforeEach(() => { + process.env.REACT_APP_SIMULATE_TX = 'false'; + }); + + it.each([false, true])( + 'pins the actual wallet request for batch=%s', + async batch => { + const fixture = await wallet('none'); + const originalSend = mockSigner.sendTransaction; + const { result } = renderHook(() => ({ + single: useExecuteExit(), + batch: useExecuteExits(), + })); + const ids = batch ? ['7', '9'] : ['7']; + const onComplete = jest.fn(); + const options = { + preflight: async () => ({ + requestIds: ids, + gasLimit: '300000', + from: ACCOUNT, + }), + onComplete, + }; + await act(async () => { + if (batch) + await result.current.batch( + [{ queueAddress: QUEUE, requestIds: ids }], + options, + ); + else await result.current.single(QUEUE, '7', options); + }); + const step = mockSetTransactions.mock.calls[0][0][0]; + const checked = await step.beforeSend({ + config: { gasLimit: '300000', gasPrice: '0.06' }, + }); + await expect( + sendOrSimulateTx(checked.request, checked.request.args, checked.config), + ).rejects.toThrow('Captured without broadcast'); + expect(fixture.requests).toEqual([ + expect.objectContaining({ + chainId: '0x1e', + from: ACCOUNT, + to: QUEUE, + value: '0x0', + data: ABI.encodeFunctionData( + batch ? 'executeExits' : 'executeExit', + batch ? [ids] : ['7'], + ), + }), + ]); + expect(mockSigner.sendTransaction).toBe(originalSend); + expect(checked.request.contract.signer).not.toBe(mockSigner); + expect(onComplete).not.toHaveBeenCalled(); + step.onComplete(); + expect(onComplete).toHaveBeenCalledTimes(1); + if (batch) + expect(onComplete).toHaveBeenCalledWith({ + queueAddress: QUEUE, + requestIds: ids, + }); + fixture.provider.removeAllListeners(); + }, + ); + + it.each(['chain', 'account'] as const)( + 'allows the wallet to refuse a late %s switch', + async change => { + const fixture = await wallet(change); + const { result } = renderHook(() => useExecuteExit()); + await act(async () => { + await result.current(QUEUE, '7', { + preflight: async () => ({ + requestIds: ['7'], + gasLimit: '300000', + from: ACCOUNT, + }), + }); + }); + const step = mockSetTransactions.mock.calls[0][0][0]; + const checked = await step.beforeSend({ config: { gasPrice: '0.06' } }); + await expect( + sendOrSimulateTx(checked.request, checked.request.args, checked.config), + ).rejects.toThrow( + change === 'chain' ? 'Chain refused' : 'Account refused', + ); + expect(fixture.requests).toHaveLength(1); + expect(fixture.accepted).toHaveLength(0); + fixture.provider.removeAllListeners(); + }, + ); +}); diff --git a/apps/frontend/src/hooks/exitDelay/useExitDelay.test.ts b/apps/frontend/src/hooks/exitDelay/useExitDelay.test.ts new file mode 100644 index 000000000..02487b5a3 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useExitDelay.test.ts @@ -0,0 +1,348 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { ChainId } from '@sovryn/ethers-provider'; + +import { startCall } from '../../store/rxjs/provider-cache'; +import { EXIT_DELAY_TTL, getExitDelayDisplay } from '../../utils/exitDelay'; +import { ExitDelayRequest, useExitDelay } from './useExitDelay'; + +/** + * These tests run the hook on the app's real shared cache, which starts every + * value at its default with `loading: false` and does not fetch at all until a + * block number is known. A quote that has not arrived for exactly the question + * being asked is reported as loading, and a bounded wait turns it into + * "could not read" rather than leaving the form waiting. + */ + +const RSK: ChainId = '0x1e' as ChainId; +/** A chain whose block number never arrives, so the cache never fetches. */ +const STALLED: ChainId = '0x98' as ChainId; +const PROTOCOL = '0x5A0D867e0D70Fcc6Ade25C3F1B89d618b5B4Eaa7'; +const SURFACE = + '0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe'; +const SUB_PRODUCT = '0x0000000000000000000000000000000000000123'; +const OTHER_SUB_PRODUCT = '0x0000000000000000000000000000000000000456'; + +const mockQuoteExitDelay = jest.fn(); +const mockBlockNumber = jest.fn(); + +jest.mock('./quoteExitDelay', () => ({ + quoteExitDelay: (...args: unknown[]) => mockQuoteExitDelay(...args), +})); + +// The real chain configuration loads through the shared cache and calls this +// package's default export; spreading the module drops its non-enumerable +// `__esModule` flag, so it is restored for the default import to resolve. +jest.mock('@sovryn/ethers-provider', () => ({ + __esModule: true, + ...jest.requireActual('@sovryn/ethers-provider'), + getProvider: (chainId: string) => ({ + getBlockNumber: () => mockBlockNumber(chainId), + }), +})); + +// The shared cache is module-global, so every test asks for its own account +// and no answer from one test is fresh for another. A request is built once +// per test and passed to every render: a new account on each render would be +// a new question on each render. +let nextAccount = 1; +const freshAccount = () => + `0x${(nextAccount++).toString(16).padStart(40, '0')}`; + +const request = ( + overrides: Partial = {}, +): ExitDelayRequest => ({ + chainId: RSK, + consumerAddress: PROTOCOL, + surfaceId: SURFACE, + subProduct: SUB_PRODUCT, + account: 'account' in overrides ? overrides.account : freshAccount(), + ...overrides, +}); + +const renderRequest = (initial: ExitDelayRequest) => + renderHook((props: ExitDelayRequest) => useExitDelay(props), { + initialProps: initial, + }); + +const HELD = { delaySeconds: 172800, unknown: false }; + +describe('useExitDelay', () => { + beforeEach(() => { + mockBlockNumber.mockImplementation((chainId: string) => + chainId === STALLED ? new Promise(() => undefined) : Promise.resolve(100), + ); + }); + + it('reports checking, not "no hold", before the block number is known', async () => { + const { result } = renderRequest(request({ chainId: STALLED })); + + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(result.current.loading).toBe(true); + expect(getExitDelayDisplay(result.current)).toBe('checking'); + expect(mockQuoteExitDelay).not.toHaveBeenCalled(); + }); + + it('resolves to "could not read" once the bounded wait passes without a quote', async () => { + const { result } = renderRequest( + request({ chainId: STALLED, timeoutMs: 50 }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.unknown).toBe(true); + expect(getExitDelayDisplay(result.current)).toBe('unknown'); + }); + + it('reports the quote once it arrives', async () => { + mockQuoteExitDelay.mockResolvedValue(HELD); + + const { result } = renderRequest(request()); + + expect(getExitDelayDisplay(result.current)).toBe('checking'); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.delaySeconds).toBe(172800); + }); + + it('waits for a consumer that has not loaded, then quotes it rather than keeping a zero', async () => { + mockQuoteExitDelay.mockResolvedValue(HELD); + const account = freshAccount(); + + const { result, rerender } = renderRequest( + request({ account, consumerAddress: undefined }), + ); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(result.current.loading).toBe(true); + expect(mockQuoteExitDelay).not.toHaveBeenCalled(); + + rerender(request({ account })); + + await waitFor(() => expect(result.current.delaySeconds).toBe(172800)); + expect(result.current.loading).toBe(false); + }); + + it("never shows one question's quote as the answer to another", async () => { + mockQuoteExitDelay.mockResolvedValueOnce(HELD); + const account = freshAccount(); + + const { result, rerender } = renderRequest(request({ account })); + await waitFor(() => expect(result.current.delaySeconds).toBe(172800)); + + mockQuoteExitDelay.mockReturnValue(new Promise(() => undefined)); + rerender(request({ account, subProduct: OTHER_SUB_PRODUCT })); + + expect(result.current.loading).toBe(true); + expect(result.current.delaySeconds).toBe(0); + await waitFor(() => + expect(mockQuoteExitDelay).toHaveBeenLastCalledWith( + expect.objectContaining({ subProduct: OTHER_SUB_PRODUCT }), + ), + ); + }); + + it('passes the consumer, surface, product and account to the quote', async () => { + mockQuoteExitDelay.mockResolvedValue(HELD); + const account = freshAccount(); + + const { result } = renderRequest(request({ account })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockQuoteExitDelay).toHaveBeenCalledWith({ + chainId: RSK, + consumerAddress: PROTOCOL, + surfaceId: SURFACE, + subProduct: SUB_PRODUCT, + account, + }); + }); + + it('reports no hold without a wallet: there is no withdrawal to describe', () => { + const { result } = renderRequest(request({ account: undefined })); + + expect(result.current).toEqual({ + delaySeconds: 0, + unknown: false, + loading: false, + }); + }); + + it('reports "could not read" when the consumer address cannot be resolved', () => { + const { result } = renderRequest( + request({ consumerAddress: undefined, consumerUnresolvable: true }), + ); + + expect(result.current.loading).toBe(false); + expect(result.current.unknown).toBe(true); + }); + + describe('requoting while mounted', () => { + /** + * The block number this suite mocks (see the top-level `beforeEach`) + * never changes once a test starts, and none of these requests change + * their own key either — reproducing exactly the state an + * already-open withdraw form is left in: nothing that would otherwise + * make useCacheCall re-fetch. Without the requote timer this hook adds + * on top of that cache, a quote read once would be shown forever, + * however long the delay had since been armed elsewhere. + */ + beforeEach(() => { + jest.useFakeTimers('modern'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('picks up a delay armed elsewhere within one cache lifetime, with no remount and no new block', async () => { + const NOT_HELD = { delaySeconds: 0, unknown: false }; + const account = freshAccount(); + mockQuoteExitDelay.mockResolvedValue(NOT_HELD); + + const { result } = renderRequest(request({ account })); + + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.loading).toBe(false); + expect(result.current.delaySeconds).toBe(0); + expect(mockQuoteExitDelay).toHaveBeenCalledTimes(1); + + // The Owner arms the delay on a surface this open form already + // quoted. Nothing about the form's own props changes, and (per this + // suite's block mock) no new block arrives either. + mockQuoteExitDelay.mockResolvedValue(HELD); + + await act(async () => { + jest.advanceTimersByTime(EXIT_DELAY_TTL); + await Promise.resolve(); + }); + + expect(mockQuoteExitDelay).toHaveBeenCalledTimes(2); + expect(result.current.delaySeconds).toBe(172800); + expect(result.current.loading).toBe(false); + }); + + it("re-reads the quote itself on a forced tick, rather than showing that quote's own stale answer from before the tick", async () => { + // quoteExitDelay caches its own on-chain read separately from this + // hook's outer cache entry (see quoteExitDelay.ts), on a lifetime that + // starts slightly after this entry's own. Modelling that here: an + // unforced call keeps returning whatever was last read, the same way + // that inner cache would serve its own not-yet-expired answer; only a + // forced call re-reads the current on-chain state. + const account = freshAccount(); + let onChain = { delaySeconds: 0, unknown: false }; + let lastRead = onChain; + mockQuoteExitDelay.mockImplementation(({ force }) => { + if (force) { + lastRead = onChain; + } + return Promise.resolve(lastRead); + }); + + const { result } = renderRequest(request({ account })); + + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.delaySeconds).toBe(0); + + // The Owner arms the delay on a surface this open form already + // quoted, between this hook's initial read and the requote timer's + // first forced tick. + onChain = { delaySeconds: 172800, unknown: false }; + + await act(async () => { + jest.advanceTimersByTime(EXIT_DELAY_TTL); + await Promise.resolve(); + }); + + expect(result.current.delaySeconds).toBe(172800); + }); + + it('does not requote before one cache lifetime has passed', async () => { + const account = freshAccount(); + mockQuoteExitDelay.mockResolvedValue({ delaySeconds: 0, unknown: false }); + + renderRequest(request({ account })); + + await act(async () => { + await Promise.resolve(); + }); + expect(mockQuoteExitDelay).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(EXIT_DELAY_TTL - 1_000); + await Promise.resolve(); + }); + + expect(mockQuoteExitDelay).toHaveBeenCalledTimes(1); + }); + }); + + describe('two forms sharing a quote key', () => { + /** + * A summary widget and an open withdraw form both asking useExitDelay + * for the same chain/consumer/surface/product/account are meant to + * share one on-chain read per block, via useCacheCall's own cache-hit + * guard. That guard is skipped whenever `force` is true, so it must + * only be true on the requote timer's own tick — never on an ordinary + * block-driven re-run of the same effect, which happens for every + * mounted instance whenever the block number this suite tracks moves. + */ + beforeEach(() => { + jest.useFakeTimers('modern'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('issues one read per block change, and the timer tick still requotes within one cache lifetime', async () => { + const account = freshAccount(); + const NOT_HELD = { delaySeconds: 0, unknown: false }; + mockQuoteExitDelay.mockResolvedValue(NOT_HELD); + + const formA = renderRequest(request({ account })); + const formB = renderRequest(request({ account })); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(formA.result.current.loading).toBe(false); + expect(formB.result.current.loading).toBe(false); + // Both forms mount on the same known block: one shared read answers both. + expect(mockQuoteExitDelay).toHaveBeenCalledTimes(1); + + // An ordinary new block arrives — pushed the way a real block-driven + // updater would, straight onto the shared cache both forms' own + // useBlockNumber subscribes to — with neither form's own key changing. + mockQuoteExitDelay.mockClear(); + await act(async () => { + startCall(`${RSK}_blockNumber`, () => Promise.resolve(101), { + force: true, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mockQuoteExitDelay).toHaveBeenCalledTimes(1); + + // With no further block change, the requote timer still forces a + // fresh read within one cache lifetime. + mockQuoteExitDelay.mockClear(); + mockQuoteExitDelay.mockResolvedValue({ + delaySeconds: 172800, + unknown: false, + }); + await act(async () => { + jest.advanceTimersByTime(EXIT_DELAY_TTL); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mockQuoteExitDelay).toHaveBeenCalled(); + expect(formA.result.current.delaySeconds).toBe(172800); + }); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/useExitDelay.ts b/apps/frontend/src/hooks/exitDelay/useExitDelay.ts new file mode 100644 index 000000000..e047898a3 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useExitDelay.ts @@ -0,0 +1,215 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { ChainId } from '@sovryn/ethers-provider'; + +import { startCall } from '../../store/rxjs/provider-cache'; +import { EXIT_DELAY_TTL, ExitDelayQuote } from '../../utils/exitDelay'; +import { useCacheCall } from '../useCacheCall'; +import { ResolvedExitDelay, quoteExitDelay } from './quoteExitDelay'; + +/** + * How long a form waits for a delay quote before reporting it as unreadable. + * Long enough for the handful of reads a quote takes on a slow node, short + * enough that a stalled read does not keep Confirm disabled. + */ +export const EXIT_DELAY_QUOTE_TIMEOUT_MS = 10_000; + +/** + * How often an open form re-checks its own delay quote, regardless of + * whether a new block has arrived. + * + * The shared cache behind `useCacheCall` only re-invokes its fetch when the + * block number it tracks moves, or when this hook's own key changes — never + * on a bare timer. On a chain where blocks come only with transactions, a + * form left open across an unrelated policy change (the Owner arming or + * disarming the delay) can see no new block at all for as long as it stays + * mounted, so without this its quote would never refresh on its own: not + * "briefly behind" as `EXIT_DELAY_TTL` describes, but indefinitely. This + * forces a fresh read on a bound tied to that same TTL, so an open form is + * never more than one cache lifetime behind a change made while it is open. + */ +const REQUOTE_INTERVAL_MS = EXIT_DELAY_TTL; + +export type ExitDelayRequest = { + chainId: ChainId; + /** The consumer holding the perimeter pointers; undefined while it loads. */ + consumerAddress?: string; + /** True when the consumer's address could not be resolved at all. */ + consumerUnresolvable?: boolean; + surfaceId: string; + /** The product the withdrawal leaves; undefined while it loads. */ + subProduct?: string; + account?: string; + timeoutMs?: number; +}; + +type StampedDelay = ResolvedExitDelay & { forKey: string }; + +/** No fetch stamps its result with this, so a default is never an answer. */ +const PENDING_KEY = ''; + +const LOADING: ExitDelayQuote = { + delaySeconds: 0, + unknown: false, + loading: true, +}; + +const NOT_READ: ExitDelayQuote = { + delaySeconds: 0, + unknown: true, + loading: false, +}; + +/** No wallet: no withdrawal can be signed, so there is nothing to describe. */ +const NO_WITHDRAWAL: ExitDelayQuote = { + delaySeconds: 0, + unknown: false, + loading: false, +}; + +/** Whether `timeoutMs` has passed since `key` was first asked without it settling. */ +export const useDeadlinePassed = ( + key: string, + settled: boolean, + timeoutMs: number, +): boolean => { + const [passedFor, setPassedFor] = useState(); + + useEffect(() => { + if (settled) { + return; + } + const timer = setTimeout(() => setPassedFor(key), timeoutMs); + return () => clearTimeout(timer); + }, [key, settled, timeoutMs]); + + return passedFor === key; +}; + +/** + * The withdrawal delay for one surface, as a form should show it. + * + * The shared cache hands back its default until a block number is known and + * keeps a changed key's previous value until the new fetch lands, so every + * quote is stamped with the key it was fetched for: chain, consumer, surface, + * product and account. Anything else — the default, a value for another key, a + * consumer or product still loading — is reported as loading, never as a + * delay of zero. A quote that has not arrived within `timeoutMs` is reported + * as unreadable, which shows the warning and lets the user sign. + * + * That "changed key" guard does not cover the moment the withdrawal delay is + * first armed for a surface: the key above does not include the delay value, + * so a quote fetched just before arming and still within `EXIT_DELAY_TTL` is + * served as-is, unaware anything changed. A form left open across that + * moment can under-report the hold, but the requote timer below (see + * REQUOTE_INTERVAL_MS) bounds that lag to at most one cache lifetime, the + * same window a fresh block would already have closed — never indefinitely, + * however long the form stays open. The timer's own force is passed all the + * way into the quote itself (see `quoteExitDelay`'s `force`), not stopped at + * this hook's outer cache entry, so the bound is one lifetime and not two: + * the quote a forced tick reads is never older than the tick itself. The + * withdrawal itself is held on chain from the moment of arming either way; + * this hook's number is what can be briefly behind, not the money. Do not + * add a key on live policy state to close that window further — the bound + * above is the accepted one. + */ +export const useExitDelay = ({ + chainId, + consumerAddress, + consumerUnresolvable = false, + surfaceId, + subProduct, + account, + timeoutMs = EXIT_DELAY_QUOTE_TIMEOUT_MS, +}: ExitDelayRequest): ExitDelayQuote => { + const key = [ + 'exitDelay/quote', + chainId, + consumerAddress?.toLowerCase(), + surfaceId, + subProduct?.toLowerCase(), + account?.toLowerCase(), + ].join('/'); + + // `force` reaches quoteExitDelay's own cached read (see its doc comment) + // so a forced tick re-reads the chain instead of serving whatever that + // inner cache still holds from before this tick. An ordinary call passes + // no `force` at all — not `force: false` — so it is indistinguishable + // from any other unforced caller and still shares that inner cache's + // answer with them. + const fetchQuote = async (force?: boolean): Promise => { + if (!consumerAddress || !subProduct || !account) { + return { delaySeconds: 0, unknown: false, forKey: PENDING_KEY }; + } + const quote = await quoteExitDelay({ + chainId, + consumerAddress, + account, + surfaceId, + subProduct, + force, + }); + return { ...quote, forKey: key }; + }; + + // See REQUOTE_INTERVAL_MS: on each tick, forces the shared cache entry for + // this exact id directly, rather than threading `force` through + // useCacheCall's own effect below. That effect also re-runs on every + // ordinary block-driven change (any mounted instance's block ticking + // forward), and a `force` living in its options would ride along on + // those runs too — exactly as many times as there are mounted + // instances of this hook for the same key — defeating the de-dup two + // forms sharing a key rely on to share one on-chain read per block. The + // tick also calls `fetchQuote` with its own `force: true`, so the force + // reaches the quote itself (quoteExitDelay's own `force`) rather than + // stopping at this outer cache entry: without that, this entry's forced + // re-fetch could still be served the quote's own, not-yet-expired, + // cached answer from before the tick. A ref keeps the interval itself + // stable across renders (so it is not torn down and restarted on every + // block) while still calling with each render's latest values. + const latest = useRef({ id: `call:${chainId}:${key}`, fetchQuote }); + latest.current = { id: `call:${chainId}:${key}`, fetchQuote }; + useEffect(() => { + const timer = setInterval(() => { + const { id, fetchQuote } = latest.current; + startCall(id, () => fetchQuote(true), { + ttl: EXIT_DELAY_TTL, + force: true, + }); + }, REQUOTE_INTERVAL_MS); + return () => clearInterval(timer); + }, []); + + const { value } = useCacheCall( + key, + chainId, + fetchQuote, + [key], + { delaySeconds: 0, unknown: false, forKey: PENDING_KEY }, + { ttl: EXIT_DELAY_TTL }, + ); + + const fresh = value.forKey === key; + const deadlinePassed = useDeadlinePassed( + key, + !account || consumerUnresolvable || fresh, + timeoutMs, + ); + + return useMemo(() => { + if (!account) { + return NO_WITHDRAWAL; + } + if (consumerUnresolvable) { + return NOT_READ; + } + if (fresh) { + return { + delaySeconds: value.delaySeconds, + unknown: value.unknown, + loading: false, + }; + } + return deadlinePassed ? NOT_READ : LOADING; + }, [account, consumerUnresolvable, deadlinePassed, fresh, value]); +}; diff --git a/apps/frontend/src/hooks/exitDelay/useExitDelayQuote.test.ts b/apps/frontend/src/hooks/exitDelay/useExitDelayQuote.test.ts new file mode 100644 index 000000000..a91db4744 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useExitDelayQuote.test.ts @@ -0,0 +1,102 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { getExitDelayDisplay } from '../../utils/exitDelay'; +import { useExitDelayQuote } from './useExitDelayQuote'; + +/** + * How a quote is classified from the chain's answers is pinned in + * quoteExitDelay.test.ts, against a local node. This file runs the hook on the + * real shared cache and pins the route: the lending surfaces are quoted through + * the lending protocol's own pointers, for this account — and a quote asked + * before the protocol contract has loaded is never kept as the answer. + */ + +const PROTOCOL = '0x5A0D867e0D70Fcc6Ade25C3F1B89d618b5B4Eaa7'; +const SURFACE = + '0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe'; +const SUB_PRODUCT = '0x0000000000000000000000000000000000000123'; + +let mockAccount = ''; +let mockProtocol: { address: string } | undefined; + +const mockQuoteExitDelay = jest.fn(); + +jest.mock('./quoteExitDelay', () => ({ + quoteExitDelay: (...args: unknown[]) => mockQuoteExitDelay(...args), +})); + +jest.mock('@sovryn/ethers-provider', () => ({ + ...jest.requireActual('@sovryn/ethers-provider'), + getProvider: () => ({ getBlockNumber: () => Promise.resolve(100) }), +})); + +jest.mock('../../config/chains', () => ({ + RSK_CHAIN_ID: '0x1e', +})); + +jest.mock('../useAccount', () => ({ + useAccount: () => ({ account: mockAccount }), +})); + +jest.mock('../useGetContract', () => ({ + useGetProtocolContract: () => mockProtocol, +})); + +// The shared cache is module-global: each test quotes for its own account. +let nextAccount = 1; +const freshAccount = () => + `0x${(nextAccount++).toString(16).padStart(40, '0')}`; + +describe('useExitDelayQuote', () => { + beforeEach(() => { + mockAccount = freshAccount(); + mockProtocol = { address: PROTOCOL }; + }); + + it("quotes through the lending protocol's pointers, for this account", async () => { + mockQuoteExitDelay.mockResolvedValue({ delaySeconds: 0, unknown: false }); + + const { result } = renderHook(() => + useExitDelayQuote(SURFACE, SUB_PRODUCT), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockQuoteExitDelay).toHaveBeenCalledWith({ + chainId: '0x1e', + consumerAddress: PROTOCOL, + account: mockAccount, + surfaceId: SURFACE, + subProduct: SUB_PRODUCT, + }); + expect(getExitDelayDisplay(result.current)).toBe('none'); + }); + + it('reports checking while the protocol contract has not loaded, and the real quote once it has', async () => { + mockQuoteExitDelay.mockResolvedValue({ + delaySeconds: 172800, + unknown: false, + }); + mockProtocol = undefined; + + const { result, rerender } = renderHook(() => + useExitDelayQuote(SURFACE, SUB_PRODUCT), + ); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(getExitDelayDisplay(result.current)).toBe('checking'); + + mockProtocol = { address: PROTOCOL }; + rerender(); + + await waitFor(() => expect(result.current.delaySeconds).toBe(172800)); + expect(getExitDelayDisplay(result.current)).toBe('held'); + }); + + it('reports checking while the product has not loaded', async () => { + const { result } = renderHook(() => useExitDelayQuote(SURFACE, undefined)); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(getExitDelayDisplay(result.current)).toBe('checking'); + expect(mockQuoteExitDelay).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/useExitDelayQuote.ts b/apps/frontend/src/hooks/exitDelay/useExitDelayQuote.ts new file mode 100644 index 000000000..32139b28c --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useExitDelayQuote.ts @@ -0,0 +1,38 @@ +import { RSK_CHAIN_ID } from '../../config/chains'; + +import { ExitDelayQuote } from '../../utils/exitDelay'; +import { useAccount } from '../useAccount'; +import { useGetProtocolContract } from '../useGetContract'; +import { useExitDelay } from './useExitDelay'; + +export type { ExitDelayQuote }; + +/** + * How long the perimeter would hold a withdrawal this account makes now from a + * lending surface. + * + * Reads the controller through the lending protocol's own pointers, the same + * route the consumer contracts take, so the quote cannot disagree with what + * the exit will actually do. While the protocol contract or the product is + * still loading, the quote is loading too — never a stated zero. + * + * The three identity arguments are all this account: on the surfaces a user + * reaches from the app the originator, the position owner and the payout + * receiver are the same person. Passthrough surfaces resolve differently + * on-chain, and quoting them from here would be a guess. + */ +export const useExitDelayQuote = ( + surfaceId: string, + subProduct: string | undefined, +): ExitDelayQuote => { + const { account } = useAccount(); + const protocol = useGetProtocolContract('protocol', RSK_CHAIN_ID); + + return useExitDelay({ + chainId: RSK_CHAIN_ID, + consumerAddress: protocol?.address, + surfaceId, + subProduct, + account, + }); +}; diff --git a/apps/frontend/src/hooks/exitDelay/usePerimeterHoldToast.provider.test.tsx b/apps/frontend/src/hooks/exitDelay/usePerimeterHoldToast.provider.test.tsx new file mode 100644 index 000000000..bfc489bbc --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/usePerimeterHoldToast.provider.test.tsx @@ -0,0 +1,162 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import React, { FC, useEffect } from 'react'; + +import 'jest-canvas-mock'; +import { + MemoryRouter, + RouterProvider, + createHashRouter, + createMemoryRouter, + useLocation, +} from 'react-router-dom'; + +import { NotificationProvider } from '../../contexts/NotificationContext'; +import { i18n } from '../../locales/i18n'; +import { usePerimeterHoldToast } from './usePerimeterHoldToast'; + +/** + * The notification provider draws its toasts above the Router, so nothing a + * toast renders may need a Router ancestor: one that does throws while the + * toast is drawn and takes the whole app down the moment a hold is confirmed. + * These tests draw the toast through the real provider, with the Router only + * around the component that raises it — the app's own arrangement. + */ + +jest.mock('nanoid', () => ({ nanoid: () => '1234' })); + +const HELD = { delaySeconds: 172800, loading: false, unknown: false }; + +const LINK = '[data-test-id="perimeter-hold-toast-link"]'; + +const Raiser: FC = () => { + const notifyHold = usePerimeterHoldToast(HELD); + useEffect(() => { + notifyHold(); + }, [notifyHold]); + return null; +}; + +const Location: FC = () => ( +
{useLocation().pathname}
+); + +const toastLink = () => document.querySelector(LINK) as HTMLAnchorElement; + +const currentLocation = () => + document.querySelector('[data-test-id="location"]') as HTMLElement; + +describe('usePerimeterHoldToast in the provider tree', () => { + beforeAll(async () => { + await i18n; + }); + + it('draws the toast outside the Router and links to the Perimeter page', () => { + render( + + + + + , + ); + + expect( + screen.getByText('Withdrawal delayed by the Sovryn Perimeter'), + ).toBeInTheDocument(); + expect(toastLink()).toBeInTheDocument(); + expect(toastLink()).toHaveAttribute('href', '/perimeter'); + }); + + it('names the withdraw queue in the toast text', () => { + render( + + + + + , + ); + + expect( + screen.getByText(/went to the Sovryn Perimeter withdraw queue/), + ).toBeInTheDocument(); + }); + + it('opens the Perimeter page inside the app when the link is clicked', () => { + const router = createMemoryRouter( + [ + { + path: '/', + element: ( + <> + + + + ), + }, + { path: '/perimeter', element: }, + ], + { initialEntries: ['/'] }, + ); + render( + + + , + ); + expect(currentLocation()).toHaveTextContent('/'); + + act(() => { + fireEvent.click(toastLink()); + }); + + expect(currentLocation()).toHaveTextContent('/perimeter'); + }); + + it('leaves a modified click to the browser', () => { + const router = createMemoryRouter( + [ + { + path: '/', + element: ( + <> + + + + ), + }, + { path: '/perimeter', element: }, + ], + { initialEntries: ['/'] }, + ); + render( + + + , + ); + + act(() => { + fireEvent.click(toastLink(), { ctrlKey: true }); + }); + + expect(currentLocation()).toHaveTextContent('/'); + }); + + it('opens the Perimeter page inside a hash-routed build when the link is clicked', () => { + window.location.hash = ''; + const router = createHashRouter([ + { path: '/', element: }, + { path: '/perimeter', element: }, + ]); + render( + + + , + ); + + act(() => { + fireEvent.click(toastLink()); + }); + + expect(window.location.hash).toBe('#/perimeter'); + expect(currentLocation()).toHaveTextContent('/perimeter'); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/usePerimeterHoldToast.test.tsx b/apps/frontend/src/hooks/exitDelay/usePerimeterHoldToast.test.tsx new file mode 100644 index 000000000..05ad4fa60 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/usePerimeterHoldToast.test.tsx @@ -0,0 +1,104 @@ +import { render, renderHook } from '@testing-library/react'; + +import React from 'react'; + +import 'jest-canvas-mock'; +import { MemoryRouter } from 'react-router-dom'; + +import { i18n } from '../../locales/i18n'; +import { usePerimeterHoldToast } from './usePerimeterHoldToast'; + +/** + * A held withdrawal does not arrive in the wallet, so a flow that reports only + * success reads as money missing. The notice is the last thing standing between + * that and the holder — including when the hold could not be read, where the + * chain fails closed and the funds may well be in the vault. + */ + +const mockAddNotification = jest.fn(); + +jest.mock('nanoid', () => ({ nanoid: () => '1234' })); + +jest.mock('../../contexts/NotificationContext', () => ({ + useNotificationContext: () => ({ addNotification: mockAddNotification }), +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const notify = (quote: { + delaySeconds: number; + loading: boolean; + unknown: boolean; +}) => { + const { result } = renderHook(() => usePerimeterHoldToast(quote), { + wrapper, + }); + result.current(); + return mockAddNotification.mock.calls[0]?.[0]; +}; + +describe('usePerimeterHoldToast', () => { + beforeAll(async () => { + await i18n; + }); + + it('says the withdrawal was delayed when it was held', () => { + const notification = notify({ + delaySeconds: 172800, + loading: false, + unknown: false, + }); + + expect(notification.title).toBe( + 'Withdrawal delayed by the Sovryn Perimeter', + ); + }); + + it('carries a content link that renders with no Router around it', () => { + const notification = notify({ + delaySeconds: 172800, + loading: false, + unknown: false, + }); + + const { container } = render(<>{notification.content}); + + expect( + container.querySelector('[data-test-id="perimeter-hold-toast-link"]'), + ).toHaveAttribute('href', '/perimeter'); + }); + + it('says it could not check, rather than staying silent', () => { + const notification = notify({ + delaySeconds: 0, + loading: false, + unknown: true, + }); + + expect(notification.title).toBe( + 'Withdrawal may be delayed by the Sovryn Perimeter', + ); + }); + + it('stays silent when a quote arrived saying nothing is held', () => { + notify({ delaySeconds: 0, loading: false, unknown: false }); + + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it('says the withdrawal may be held when the quote had not arrived', () => { + // Silence is the notice's way of saying "paid now", which a quote that + // never arrived cannot support. + const notification = notify({ + delaySeconds: 0, + loading: true, + unknown: false, + }); + + expect(notification.title).toBe( + 'Withdrawal may be delayed by the Sovryn Perimeter', + ); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/usePerimeterHoldToast.tsx b/apps/frontend/src/hooks/exitDelay/usePerimeterHoldToast.tsx new file mode 100644 index 000000000..b1b7d7bf1 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/usePerimeterHoldToast.tsx @@ -0,0 +1,97 @@ +import React, { useCallback } from 'react'; + +import { t } from 'i18next'; +import { nanoid } from 'nanoid'; +import { useHref, useNavigate } from 'react-router-dom'; + +import { NotificationType } from '@sovryn/ui'; + +import { useNotificationContext } from '../../contexts/NotificationContext'; +import { translations } from '../../locales/i18n'; +import { ExitDelayQuote, getExitDelayDisplay } from '../../utils/exitDelay'; + +/** Long enough to survive the success toast that follows it. */ +const HOLD_TOAST_TIMEOUT_MS = 30_000; + +const PERIMETER_PATH = '/perimeter'; + +/** + * Post-signature notice that a withdrawal went to the Perimeter withdraw queue. + * + * A held withdrawal does not arrive in the wallet, so a flow that only reports + * success reads as money missing. The returned callback is attached to the + * withdrawal transaction's `onComplete`, and fires a notification naming where + * the funds are and linking to the page that releases them. + * + * Pass the same quote the form displayed, so the notice and the form agree. A + * quote that arrived saying nothing is held makes the callback a no-op and + * unheld flows keep their exact current behaviour. A quote we could not obtain, + * or one that had not arrived when the callback was made, gets the "may be + * held" notice: the transaction that just succeeded is then one whose funds + * may be sitting in the withdraw queue. + * + * The notification provider draws the toast above the Router, so the toast + * itself cannot use a router `Link`: it throws without a Router ancestor and + * unmounts the app. The link is a plain anchor built here instead, where the + * caller is under the Router: the router-aware `href` and the navigation are + * resolved from this hook's own router context and handed to the anchor. The + * caller must therefore be rendered under the Router. + */ +export const usePerimeterHoldToast = (quote: ExitDelayQuote) => { + const { addNotification } = useNotificationContext(); + const display = getExitDelayDisplay(quote); + const perimeterHref = useHref(PERIMETER_PATH); + const navigate = useNavigate(); + + return useCallback(() => { + if (display === 'none') { + return; + } + const isUnknown = display !== 'held'; + addNotification( + { + type: NotificationType.info, + id: nanoid(), + title: t( + isUnknown + ? translations.exitDelay.holdToast.unknownTitle + : translations.exitDelay.holdToast.title, + ), + content: ( + <> + {t( + isUnknown + ? translations.exitDelay.holdToast.unknownContent + : translations.exitDelay.holdToast.content, + )}{' '} + { + // Modified and non-primary clicks keep the browser's own + // behaviour (new tab, download, and so on). + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.altKey || + event.ctrlKey || + event.shiftKey + ) { + return; + } + event.preventDefault(); + navigate(PERIMETER_PATH); + }} + > + {t(translations.exitDelay.vaultLink)} + + + ), + dismissible: true, + }, + HOLD_TOAST_TIMEOUT_MS, + ); + }, [addNotification, display, navigate, perimeterHref]); +}; diff --git a/apps/frontend/src/hooks/exitDelay/usePerimeterRelease.test.tsx b/apps/frontend/src/hooks/exitDelay/usePerimeterRelease.test.tsx new file mode 100644 index 000000000..e6fb296c5 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/usePerimeterRelease.test.tsx @@ -0,0 +1,1281 @@ +import { act, render, renderHook } from '@testing-library/react'; + +import React from 'react'; + +import { utils } from 'ethers'; +import 'jest-canvas-mock'; + +import { i18n } from '../../locales/i18n'; +import { BlockState, ExitStatus, exitKey } from '../../utils/exitDelay'; +import { + JsonRpcStub, + StubAnswer, + selectorOf, + startJsonRpcStub, +} from '../../utils/testing/jsonRpcStub'; +import { ReleaseRow, usePerimeterRelease } from './usePerimeterRelease'; + +/** + * A queued withdrawal whose party is frozen or blacklisted looks like any + * other row. The block is read when the holder presses Release: nothing is + * sent for a row whose parties do not all read unblocked, and the holder is + * told the withdrawal was not released, in wording that reads the same + * whichever party or state caused it. These tests serve the block states + * from a local node through the app's real provider. + */ + +const ACCOUNT = '0x1111111111111111111111111111111111111111'; +const OTHER = '0x2222222222222222222222222222222222222222'; +const RECEIVER = '0x3333333333333333333333333333333333333333'; +const OTHER_RECEIVER = '0x4444444444444444444444444444444444444444'; +const QUEUE = '0x9999999999999999999999999999999999999999'; +const OTHER_QUEUE = '0x8888888888888888888888888888888888888888'; + +let mockRpcUrl = ''; + +const mockExecuteExit = jest.fn(); +const mockExecuteExits = jest.fn(); +const mockAddNotification = jest.fn(); + +jest.mock('@sovryn/ethers-provider', () => { + const actual = jest.requireActual('@sovryn/ethers-provider'); + return { + __esModule: true, + ...actual, + getProvider: () => + actual.getProvider({ + id: '0x1e', + label: 'RSK', + token: 'RBTC', + rpcUrl: [mockRpcUrl], + blockExplorerUrl: '', + }), + }; +}); + +jest.mock('../../config/chains', () => ({ + RSK_CHAIN_ID: '0x1e', +})); + +jest.mock('nanoid', () => ({ nanoid: () => '1234' })); + +// Each chain read the checks make gives up after this bound; short here so a +// read that never returns ends a test in about a second. +jest.mock('../../utils/exitDelay', () => ({ + ...jest.requireActual('../../utils/exitDelay'), + RELEASE_READ_TIMEOUT_MS: 1_000, +})); + +let mockSigner: unknown; +const mockWalletSend = jest.fn(); + +jest.mock('../useAccount', () => ({ + useAccount: () => ({ account: ACCOUNT, signer: mockSigner }), +})); + +jest.mock('./useExecuteExit', () => ({ + useExecuteExit: () => mockExecuteExit, + useExecuteExits: () => mockExecuteExits, +})); + +jest.mock('../../contexts/NotificationContext', () => ({ + useNotificationContext: () => ({ addNotification: mockAddNotification }), +})); + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const ZERO_BYTES32 = `0x${'00'.repeat(32)}`; + +const QUEUE_ABI = new utils.Interface([ + 'function blockStateOf(address a) view returns (uint8)', + 'function getRequest(uint256 id) view returns (tuple(uint128 amount, uint64 createdAt, uint64 unlockAt, address originator, address owner, address receiver, address token, bytes32 surfaceId, address subProduct, uint8 status, bool unwrapOnDelivery))', + 'function executeExit(uint256 requestId)', + 'function executeExits(uint256[] ids)', +]); +/** The queue's own errors for a delivery it refuses. */ +const QUEUE_ERRORS = new utils.Interface([ + 'error QueuePaused()', + 'error AlreadyTerminal(uint256 id)', + 'error NotUnlocked(uint256 id, uint64 unlockAt)', + 'error NotExecutor(address caller)', + 'error ActorBlocked(address actor, uint8 state)', +]); +const BLOCK_STATE = selectorOf('blockStateOf(address)'); +const GET_REQUEST = QUEUE_ABI.getSighash('getRequest'); +const EXECUTE_EXIT = QUEUE_ABI.getSighash('executeExit'); +const EXECUTE_EXITS = QUEUE_ABI.getSighash('executeExits'); + +const stateResult = (state: number): StubAnswer => ({ + result: utils.defaultAbiCoder.encode(['uint8'], [state]), +}); + +const requestResult = (status: ExitStatus): StubAnswer => ({ + result: QUEUE_ABI.encodeFunctionResult('getRequest', [ + [ + 1, + 1, + 1, + ACCOUNT, + ACCOUNT, + RECEIVER, + ZERO_ADDRESS, + ZERO_BYTES32, + ZERO_ADDRESS, + status, + false, + ], + ]), +}); + +/** How an RSK node answers a dry run the queue refuses with one of its errors. */ +const queueRefusal = (name: string, args: unknown[] = []): StubAnswer => ({ + error: { + code: -32015, + message: 'VM Exception while processing transaction: revert', + data: QUEUE_ERRORS.encodeErrorResult(name, args), + }, +}); + +/** What a release hands the transaction dialog besides the ids: its completion callback and its check inside the send step. */ +const SEND_OPTIONS = expect.objectContaining({ + onComplete: expect.any(Function), + preflight: expect.any(Function), +}); + +/** The node's gas estimate for a release: 50,000. */ +const GAS_ESTIMATE: StubAnswer = { result: '0xc350' }; + +/** Rootstock's own block gas limit, unless a test says otherwise: 10,000,000. */ +const BLOCK_GAS_LIMIT = 10_000_000; + +/** A full block, carrying the given gas limit, as a node answers `eth_getBlockByNumber`. */ +const blockResult = (gasLimit: number): StubAnswer => ({ + result: { + number: '0x10', + hash: `0x${'11'.repeat(32)}`, + parentHash: `0x${'22'.repeat(32)}`, + nonce: '0x0000000000000000', + timestamp: '0x60000000', + difficulty: '0x0', + gasLimit: `0x${gasLimit.toString(16)}`, + gasUsed: '0x5208', + miner: ZERO_ADDRESS, + extraData: '0x', + transactions: [], + }, +}); + +const row = (overrides: Partial = {}): ReleaseRow => ({ + id: '7', + queueAddress: QUEUE, + originator: ACCOUNT, + owner: ACCOUNT, + receiver: RECEIVER, + ...overrides, +}); + +describe('usePerimeterRelease', () => { + let stub: JsonRpcStub; + + beforeAll(async () => { + await i18n; + stub = await startJsonRpcStub(); + mockRpcUrl = stub.url; + }); + + beforeEach(() => { + // The wallet is on RSK and signs as the account the page shows, unless a + // test says otherwise. + mockWalletSend.mockImplementation(async (method: string) => { + if (method === 'eth_chainId') { + return '0x1e'; + } + return method === 'eth_accounts' ? [ACCOUNT] : null; + }); + mockSigner = { provider: { send: mockWalletSend } }; + // Every request is still queued, every party clear, and the queue accepts + // every dry run, unless a test says otherwise. + [QUEUE, OTHER_QUEUE].forEach(queue => { + stub.onCall(queue, BLOCK_STATE, stateResult(BlockState.None)); + stub.onCall(queue, GET_REQUEST, requestResult(ExitStatus.Queued)); + stub.onCall(queue, EXECUTE_EXIT, { result: '0x' }); + stub.onCall(queue, EXECUTE_EXITS, { result: '0x' }); + }); + stub.onMethod('eth_estimateGas', GAS_ESTIMATE); + // The queue address carries code unless a test says otherwise. + stub.onMethod('eth_getCode', { result: '0x6080604052' }); + // The latest block carries Rootstock's own gas limit unless a test says + // otherwise. + stub.onMethod('eth_getBlockByNumber', blockResult(BLOCK_GAS_LIMIT)); + }); + + afterEach(() => stub.reset()); + + afterAll(() => stub.close()); + + const blockOn = (queue: string, party: string, answer: StubAnswer) => + stub.onCall( + queue, + QUEUE_ABI.encodeFunctionData('blockStateOf', [party]), + answer, + ); + + const statusOf = (queue: string, id: number, answer: StubAnswer) => + stub.onCall( + queue, + QUEUE_ABI.encodeFunctionData('getRequest', [id]), + answer, + ); + + const release = async (rows: ReleaseRow[]) => { + const onReleased = jest.fn(); + const { result } = renderHook(() => usePerimeterRelease()); + await act(async () => { + await result.current(rows, onReleased); + }); + return onReleased; + }; + + /** + * Run the check a release handed to the transaction dialog, the way the + * dialog runs it when the holder presses Confirm: inside the send step, + * immediately before the wallet is asked to sign, with the gas limit the + * holder typed in the dialog's Advanced settings, if any. + */ + const confirmInDialog = (typedGasLimit?: string): Promise => { + const [single] = mockExecuteExit.mock.calls; + if (single) { + const [queueAddress, id, { preflight }] = single; + return preflight({ queueAddress, requestIds: [id] }, typedGasLimit); + } + const [batches, { preflight }] = mockExecuteExits.mock.calls[0]; + return preflight(batches[0], typedGasLimit); + }; + + /** What the latest refusal notice says, as the holder reads it. */ + const refusalText = () => { + const { calls } = mockAddNotification.mock; + const notification = calls[calls.length - 1]?.[0]; + if (!notification) { + return ''; + } + const { container } = render(<>{notification.content}); + return container.textContent ?? ''; + }; + + it('sends a release whose parties all read unblocked', async () => { + mockExecuteExit.mockImplementation( + async ( + _queue: string, + _id: string, + { onComplete }: { onComplete: () => void }, + ) => onComplete(), + ); + + const onReleased = await release([row()]); + + expect(mockExecuteExit).toHaveBeenCalledWith(QUEUE, '7', SEND_OPTIONS); + expect(onReleased).toHaveBeenCalledWith([ + { + key: exitKey({ queueAddress: QUEUE, id: '7' }), + status: ExitStatus.Executed, + }, + ]); + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it('sends a release for an account that is only the receiver', async () => { + mockExecuteExit.mockImplementation( + async ( + _queue: string, + _id: string, + { onComplete }: { onComplete: () => void }, + ) => onComplete(), + ); + + const onReleased = await release([ + row({ originator: OTHER, owner: OTHER, receiver: ACCOUNT }), + ]); + + expect(mockExecuteExit).toHaveBeenCalledWith(QUEUE, '7', SEND_OPTIONS); + expect(onReleased).toHaveBeenCalledWith([ + { + key: exitKey({ queueAddress: QUEUE, id: '7' }), + status: ExitStatus.Executed, + }, + ]); + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it.each([ + ['the receiver', 'frozen', row(), RECEIVER, BlockState.Frozen], + ['your address', 'blacklisted', row(), ACCOUNT, BlockState.Blacklisted], + [ + 'the address that started it', + 'frozen', + row({ originator: OTHER }), + OTHER, + BlockState.Frozen, + ], + [ + 'the position owner', + 'blacklisted', + row({ owner: OTHER }), + OTHER, + BlockState.Blacklisted, + ], + [ + 'the position owner, while your account is only the receiver', + 'frozen', + row({ originator: OTHER, owner: OTHER, receiver: ACCOUNT }), + OTHER, + BlockState.Frozen, + ], + ])( + 'does not send when %s is %s, and says only that the queue would refuse it', + async (party, state, exitRow, blockedAddress, blockState) => { + blockOn(QUEUE, blockedAddress, stateResult(blockState)); + + await release([exitRow]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(mockExecuteExits).not.toHaveBeenCalled(); + const text = refusalText(); + expect(text).toContain( + 'Withdrawal #7 was not released because the queue would refuse it.', + ); + // The refusal reads the same whichever party or state triggered it. + expect(text).not.toContain(party); + expect(text).not.toContain(state); + }, + ); + + it('does not send a release whose block states could not be read, and says so', async () => { + blockOn(QUEUE, RECEIVER, { status: 503, body: 'unavailable' }); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because we could not check whether its addresses are frozen or blacklisted.', + ); + }); + + it('treats a block state outside the known values as unread', async () => { + blockOn(QUEUE, RECEIVER, stateResult(3)); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain('we could not check'); + }); + + it('sends only the rows whose parties read unblocked, and reports the others', async () => { + blockOn(QUEUE, OTHER_RECEIVER, stateResult(BlockState.Frozen)); + + await release([ + row({ id: '7' }), + row({ id: '8', receiver: OTHER_RECEIVER }), + row({ id: '9' }), + ]); + + expect(mockExecuteExits).toHaveBeenCalledWith( + [{ queueAddress: QUEUE, requestIds: ['7', '9'] }], + SEND_OPTIONS, + ); + const text = refusalText(); + expect(text).toContain( + 'Withdrawal #8 was not released because the queue would refuse it.', + ); + expect(text).not.toContain('#7'); + expect(text).not.toContain('#9'); + }); + + it('batches by queue and reports what each batch settled by queue and id', async () => { + mockExecuteExits.mockImplementation( + async ( + batches: { queueAddress: string; requestIds: string[] }[], + { onComplete }: { onComplete: (batch: unknown) => void }, + ) => batches.forEach(batch => onComplete(batch)), + ); + + const onReleased = await release([ + row({ id: '7' }), + row({ id: '7', queueAddress: OTHER_QUEUE }), + ]); + + expect(mockExecuteExits).toHaveBeenCalledWith( + [ + { queueAddress: QUEUE, requestIds: ['7'] }, + { queueAddress: OTHER_QUEUE, requestIds: ['7'] }, + ], + SEND_OPTIONS, + ); + expect(onReleased).toHaveBeenCalledWith([ + { + key: exitKey({ queueAddress: QUEUE, id: '7' }), + status: ExitStatus.Executed, + }, + ]); + expect(onReleased).toHaveBeenCalledWith([ + { + key: exitKey({ queueAddress: OTHER_QUEUE, id: '7' }), + status: ExitStatus.Executed, + }, + ]); + }); + + describe('what the queue will still accept', () => { + // executeExits is atomic: one id that changed since the page last read it + // takes every other release in the batch down with it. + it('leaves a row delivered by someone else off the page without an error, and sends the rest', async () => { + statusOf(QUEUE, 8, requestResult(ExitStatus.Executed)); + + const onReleased = await release([ + row({ id: '7' }), + row({ id: '8' }), + row({ id: '9' }), + ]); + + expect(onReleased).toHaveBeenCalledWith([ + { + key: exitKey({ queueAddress: QUEUE, id: '8' }), + status: ExitStatus.Executed, + }, + ]); + expect(mockExecuteExits).toHaveBeenCalledWith( + [{ queueAddress: QUEUE, requestIds: ['7', '9'] }], + SEND_OPTIONS, + ); + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it('does not send a row whose status could not be read, and says so', async () => { + statusOf(QUEUE, 7, { status: 503, body: 'unavailable' }); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because we could not check whether it is still waiting.', + ); + }); + + it.each([ + ['releases are paused', queueRefusal('QueuePaused')], + ['#7 was already delivered', queueRefusal('AlreadyTerminal', [7])], + [ + 'the queue would refuse it', + queueRefusal('ActorBlocked', [ACCOUNT, BlockState.Frozen]), + ], + [ + 'your address may not release it', + queueRefusal('NotExecutor', [ACCOUNT]), + ], + [ + 'the queue would refuse it', + { + error: { + code: -32015, + message: 'VM Exception while processing transaction: revert', + data: '0x', + }, + }, + ], + ])( + 'does not send a release the queue would refuse, and says %s', + async (reason, answer) => { + stub.onCall(QUEUE, EXECUTE_EXIT, answer); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + `Withdrawal #7 was not released because ${reason}.`, + ); + }, + ); + + it('does not send a batch the queue would refuse, and names every withdrawal in it', async () => { + stub.onCall(QUEUE, EXECUTE_EXITS, queueRefusal('QueuePaused')); + + await release([row({ id: '7' }), row({ id: '9' })]); + + expect(mockExecuteExits).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'Withdrawals #7, #9 were not released because releases are paused.', + ); + }); + + it('drops a withdrawal the queue refuses by id, says why, and still sends the rest', async () => { + stub.onCall( + QUEUE, + QUEUE_ABI.encodeFunctionData('executeExits', [[7, 8, 9]]), + queueRefusal('AlreadyTerminal', [8]), + ); + + await release([row({ id: '7' }), row({ id: '8' }), row({ id: '9' })]); + + expect(mockExecuteExits).toHaveBeenCalledWith( + [{ queueAddress: QUEUE, requestIds: ['7', '9'] }], + SEND_OPTIONS, + ); + expect(refusalText()).toContain( + 'Withdrawal #8 was not released because #8 was already delivered.', + ); + }); + + it('does not send when the dry run could not be completed, and says so', async () => { + stub.onCall(QUEUE, EXECUTE_EXIT, { status: 503, body: 'unavailable' }); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because we could not check whether it would go through.', + ); + }); + + it("asks the queue from the holder's own address", async () => { + await release([row()]); + + expect(stub.callsTo(QUEUE, EXECUTE_EXIT)).toEqual([ + { + from: ACCOUNT, + data: QUEUE_ABI.encodeFunctionData('executeExit', [7]), + }, + ]); + }); + + it("still sends another queue's batch when one queue would refuse", async () => { + stub.onCall(QUEUE, EXECUTE_EXITS, queueRefusal('QueuePaused')); + + await release([ + row({ id: '7' }), + row({ id: '7', queueAddress: OTHER_QUEUE }), + ]); + + expect(mockExecuteExits).toHaveBeenCalledWith( + [{ queueAddress: OTHER_QUEUE, requestIds: ['7'] }], + SEND_OPTIONS, + ); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because releases are paused.', + ); + }); + }); + + describe('a node that does not answer', () => { + // A read that never returns ends in a refusal saying what could not be + // checked, not in a Release that stays checking for minutes. + it('gives up on a status read, and says it could not check whether the withdrawal is still waiting', async () => { + statusOf(QUEUE, 7, { hang: true }); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because we could not check whether it is still waiting.', + ); + }); + + it('gives up on a block state read, and says it could not check the addresses', async () => { + blockOn(QUEUE, RECEIVER, { hang: true }); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because we could not check whether its addresses are frozen or blacklisted.', + ); + }); + + it.each([ + [ + 'the code at the queue address', + () => stub.onMethod('eth_getCode', { hang: true }), + ], + ['a dry run', () => stub.onCall(QUEUE, EXECUTE_EXIT, { hang: true })], + ])( + 'gives up on %s, and says it could not check whether the release would go through', + async (_case, hang) => { + hang(); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because we could not check whether it would go through.', + ); + }, + ); + + it('gives up on a gas estimate when the holder confirms, and sends nothing', async () => { + await release([row()]); + stub.onMethod('eth_estimateGas', { hang: true }); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because we could not estimate the gas it needs', + ); + }); + }); + + describe('the queue it asks', () => { + // A call to an address with no code executes nothing and returns empty + // data, which is also what an accepted release returns. The dry run takes + // a yes only from an address with code, and only as the empty result a + // release gives. + const UNCHECKED = + 'Withdrawal #7 was not released because we could not check whether it would go through.'; + + it('does not take a yes from an address with no code: sends nothing, and says it could not check', async () => { + stub.onMethod('eth_getCode', { result: '0x' }); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain(UNCHECKED); + }); + + it('sends nothing when the code at the queue address could not be read, and says so', async () => { + stub.onMethod('eth_getCode', { status: 503, body: 'unavailable' }); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain(UNCHECKED); + }); + + it('does not take a result a release never returns as a yes', async () => { + stub.onCall(QUEUE, EXECUTE_EXIT, { result: `0x${'00'.repeat(32)}` }); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain(UNCHECKED); + }); + + it('checks the code at the queue address again when the holder confirms', async () => { + await release([row()]); + stub.onMethod('eth_getCode', { result: '0x' }); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain(UNCHECKED); + }); + }); + + describe('a status other than queued', () => { + // Only a withdrawal paid to its receiver has been delivered. A request the + // answering node does not hold reads as status None, which says nothing + // about the withdrawal. The two recovery outcomes paid it elsewhere, and + // the holder is told that and nothing more: no address and no block state. + const UNREAD = + 'Withdrawal #7 was not released because we could not check whether it is still waiting.'; + const TO_PROTOCOL = + 'cannot be released: it was sent to a recovery destination approved in advance for the product it came from.'; + const BY_OWNER = 'cannot be released: it was resolved by the Owner.'; + + it('keeps a row whose status the node reports as unknown, sends nothing, and says it could not check', async () => { + statusOf(QUEUE, 7, requestResult(ExitStatus.None)); + + const onReleased = await release([row()]); + + expect(onReleased).not.toHaveBeenCalled(); + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain(UNREAD); + }); + + it('keeps a row whose status is outside the known values, sends nothing, and says it could not check', async () => { + statusOf(QUEUE, 7, requestResult(9 as ExitStatus)); + + const onReleased = await release([row()]); + + expect(onReleased).not.toHaveBeenCalled(); + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain(UNREAD); + }); + + it.each([ + ['returned to the protocol', ExitStatus.ResolvedToProtocol, TO_PROTOCOL], + ['resolved by the Owner', ExitStatus.ResolvedByOwner, BY_OWNER], + ])( + // A row the queue resolved away by recovery was never delivered: the + // key it leaves the page with must carry that real status, not the + // executed status a genuinely delivered row gets, or the page would + // record it as paid out when it was not. + 'takes a withdrawal %s off the page with its real status, not executed, says it cannot be released, and sends the rest', + async (_case, status, line) => { + statusOf(QUEUE, 8, requestResult(status)); + + const onReleased = await release([row({ id: '7' }), row({ id: '8' })]); + + expect(onReleased).toHaveBeenCalledWith([ + { key: exitKey({ queueAddress: QUEUE, id: '8' }), status }, + ]); + expect(mockExecuteExit).toHaveBeenCalledWith(QUEUE, '7', SEND_OPTIONS); + expect(refusalText()).toContain(`Withdrawal #8 ${line}`); + }, + ); + + it('keeps a row the node reports as unknown when the holder confirms, and sends nothing', async () => { + const onReleased = await release([row()]); + statusOf(QUEUE, 7, requestResult(ExitStatus.None)); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(onReleased).not.toHaveBeenCalled(); + expect(refusalText()).toContain(UNREAD); + }); + + it.each([ + ['returned to the protocol', ExitStatus.ResolvedToProtocol, TO_PROTOCOL], + ['resolved by the Owner', ExitStatus.ResolvedByOwner, BY_OWNER], + ])( + // The same check runs again inside the send step. A withdrawal the + // Owner or the protocol resolved away between the press and the + // confirm must leave with its real recovery status here too, not + // executed — the same rule the press-time check follows above. + 'takes a withdrawal %s off the page with its real status, not executed, when the holder confirms, and says so', + async (_case, status, line) => { + const onReleased = await release([row()]); + statusOf(QUEUE, 7, requestResult(status)); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(onReleased).toHaveBeenCalledWith([ + { key: exitKey({ queueAddress: QUEUE, id: '7' }), status }, + ]); + expect(refusalText()).toContain(`Withdrawal #7 ${line}`); + expect(refusalText()).not.toContain('already delivered'); + }, + ); + }); + + describe('a withdrawal still unlocking', () => { + // The dry run executes against the latest block, whose timestamp trails + // wall time by up to about a minute on RSK mainnet. A withdrawal the queue + // refuses as not yet unlocked is named as unlocking, and a batch sends the + // rest rather than failing whole. + const refuseAsLocked = (ids: number[], lockedId: number) => + stub.onCall( + QUEUE, + QUEUE_ABI.encodeFunctionData('executeExits', [ids]), + queueRefusal('NotUnlocked', [lockedId, 1_800_000_000]), + ); + + it('says a withdrawal is unlocking and to try again in a minute, and sends nothing for it', async () => { + stub.onCall( + QUEUE, + EXECUTE_EXIT, + queueRefusal('NotUnlocked', [7, 1_800_000_000]), + ); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'Withdrawal #7 is unlocking — try again in a minute.', + ); + expect(refusalText()).not.toContain('was not released'); + }); + + it('drops the withdrawals still unlocking from a batch, names them, and sends the rest', async () => { + refuseAsLocked([7, 8, 9], 8); + refuseAsLocked([7, 9], 9); + + await release([row({ id: '7' }), row({ id: '8' }), row({ id: '9' })]); + + expect(mockExecuteExits).toHaveBeenCalledWith( + [{ queueAddress: QUEUE, requestIds: ['7'] }], + SEND_OPTIONS, + ); + expect(refusalText()).toContain( + 'Withdrawals #8, #9 are unlocking — try again in a minute.', + ); + }); + + it('drops a withdrawal still unlocking when the holder confirms, names it, and sends the rest', async () => { + await release([row({ id: '7' }), row({ id: '9' })]); + refuseAsLocked([7, 9], 9); + + await expect(confirmInDialog()).resolves.toEqual({ + requestIds: ['7'], + gasLimit: '60000', + from: ACCOUNT, + }); + expect(refusalText()).toContain( + 'Withdrawal #9 is unlocking — try again in a minute.', + ); + }); + }); + + describe('the gas limit it is sent with', () => { + // The node estimates 50,000 for the state the check just read, and the + // release is mined in a later block. It is sent with 20% above that + // estimate, or with a gas limit the holder typed in the dialog's Advanced + // settings when that limit covers the estimate. + it('sends with 20% above the fresh estimate when the holder typed no gas limit', async () => { + await release([row()]); + + await expect(confirmInDialog()).resolves.toEqual({ + requestIds: ['7'], + gasLimit: '60000', + from: ACCOUNT, + }); + }); + + it.each([ + ['at', '50000'], + ['above', '75000'], + ])( + 'keeps a gas limit typed in Advanced settings %s the fresh estimate', + async (_case, typed) => { + await release([row()]); + + await expect(confirmInDialog(typed)).resolves.toEqual({ + requestIds: ['7'], + gasLimit: typed, + from: ACCOUNT, + }); + expect(mockAddNotification).not.toHaveBeenCalled(); + }, + ); + + it('sends nothing when a gas limit typed in Advanced settings is below the fresh estimate, and says the limit is too low', async () => { + await release([row()]); + + await expect(confirmInDialog('40000')).rejects.toThrow(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because the gas limit set in Advanced settings is too low: 40000 is below the 50000 it needs.', + ); + }); + }); + + describe("the latest block's own gas limit", () => { + // The margin above the estimate must never ask for more gas than a block + // can include. The block's own gas limit is read fresh at the send step, + // because the network's own limit can change between reads. + it("caps the gas limit at the latest block's own gas limit when the margin above the estimate would exceed it", async () => { + stub.onMethod('eth_getBlockByNumber', blockResult(55_000)); + + await release([row()]); + + await expect(confirmInDialog()).resolves.toEqual({ + requestIds: ['7'], + gasLimit: '55000', + from: ACCOUNT, + }); + }); + + it('sends nothing when the latest block could not be read, and says the gas limit could not be checked', async () => { + stub.onMethod('eth_getBlockByNumber', { + status: 503, + body: 'unavailable', + }); + + await release([row()]); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because we could not read the current network gas limit, so the gas limit it needs could not be checked.', + ); + }); + + it('sends nothing when the latest block cannot be read in time, and says the gas limit could not be checked', async () => { + stub.onMethod('eth_getBlockByNumber', { hang: true }); + + await release([row()]); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain( + 'we could not read the current network gas limit, so the gas limit it needs could not be checked', + ); + }); + + it("tells the holder to release fewer at once when a batch's estimate could not be made", async () => { + await release([row({ id: '7' }), row({ id: '9' })]); + stub.onMethod('eth_estimateGas', { status: 503, body: 'unavailable' }); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain( + 'Withdrawals #7, #9 were not released because we could not estimate the gas it needs, and a release is never sent on a guess. Release fewer at once.', + ); + }); + + it("tells the holder to release fewer at once when a batch's estimate exceeds the latest block's own gas limit", async () => { + await release([row({ id: '7' }), row({ id: '9' })]); + stub.onMethod('eth_getBlockByNumber', blockResult(40_000)); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain( + 'Withdrawals #7, #9 were not released because we could not estimate the gas it needs, and a release is never sent on a guess. Release fewer at once.', + ); + }); + + it("keeps the single-withdrawal wording when one withdrawal's estimate exceeds the latest block's own gas limit", async () => { + await release([row()]); + stub.onMethod('eth_getBlockByNumber', blockResult(40_000)); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because we could not estimate the gas it needs, and a release is never sent on a guess. Try again in a moment.', + ); + }); + + it('does not cap a gas limit typed in Advanced settings at the latest block gas limit', async () => { + stub.onMethod('eth_getBlockByNumber', blockResult(55_000)); + + await release([row()]); + + await expect(confirmInDialog('75000')).resolves.toEqual({ + requestIds: ['7'], + gasLimit: '75000', + from: ACCOUNT, + }); + }); + }); + + describe('at the moment of sending', () => { + // The holder may press Confirm in the transaction dialog long after + // Release. The same check runs again inside the send step, immediately + // before the wallet is asked to sign: only what still passes is sent, with + // a gas limit set from a fresh estimate for exactly that, and never with a + // guessed gas limit. + const estimates = () => stub.requestsFor('eth_estimateGas'); + + it('checks again on Confirm, and sends with a gas limit set from the estimate for what it sends', async () => { + await release([row({ id: '7' }), row({ id: '9' })]); + + await expect(confirmInDialog()).resolves.toEqual({ + requestIds: ['7', '9'], + gasLimit: '60000', + from: ACCOUNT, + }); + expect(stub.callsTo(QUEUE, EXECUTE_EXITS)).toHaveLength(2); + expect(estimates()).toEqual([ + [ + expect.objectContaining({ + from: ACCOUNT, + to: QUEUE, + data: QUEUE_ABI.encodeFunctionData('executeExits', [[7, 9]]), + }), + ], + ]); + }); + + it('drops a withdrawal whose party is blocked by the time the holder confirms, says which and why, and sends the rest', async () => { + await release([ + row({ id: '7' }), + row({ id: '8', receiver: OTHER_RECEIVER }), + row({ id: '9' }), + ]); + blockOn(QUEUE, OTHER_RECEIVER, stateResult(BlockState.Frozen)); + + await expect(confirmInDialog()).resolves.toEqual({ + requestIds: ['7', '9'], + gasLimit: '60000', + from: ACCOUNT, + }); + expect(refusalText()).toContain( + 'Withdrawal #8 was not released because the queue would refuse it.', + ); + expect(estimates()).toEqual([ + [ + expect.objectContaining({ + data: QUEUE_ABI.encodeFunctionData('executeExits', [[7, 9]]), + }), + ], + ]); + }); + + it('drops a withdrawal the queue refuses by id at the moment of sending, says why, and sends the rest', async () => { + await release([row({ id: '7' }), row({ id: '8' }), row({ id: '9' })]); + stub.onCall( + QUEUE, + QUEUE_ABI.encodeFunctionData('executeExits', [[7, 8, 9]]), + queueRefusal('AlreadyTerminal', [8]), + ); + + await expect(confirmInDialog()).resolves.toEqual({ + requestIds: ['7', '9'], + gasLimit: '60000', + from: ACCOUNT, + }); + expect(refusalText()).toContain( + 'Withdrawal #8 was not released because #8 was already delivered.', + ); + }); + + it('sends nothing when the queue is paused by the time the holder confirms, and says so', async () => { + await release([row({ id: '7' }), row({ id: '9' })]); + stub.onCall(QUEUE, EXECUTE_EXITS, queueRefusal('QueuePaused')); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain( + 'Withdrawals #7, #9 were not released because releases are paused.', + ); + expect(estimates()).toHaveLength(0); + }); + + it.each<[string, () => void, string | undefined, string]>([ + [ + 'the queue is paused', + () => stub.onCall(QUEUE, EXECUTE_EXITS, queueRefusal('QueuePaused')), + undefined, + 'Withdrawals #7, #9 were not released because releases are paused.', + ], + [ + 'the wallet switched account', + () => + mockWalletSend.mockImplementation(async (method: string) => { + if (method === 'eth_chainId') { + return '0x1e'; + } + return method === 'eth_accounts' ? [OTHER] : null; + }), + undefined, + 'Your wallet switched to a different account, so nothing was sent. Switch back to the account these withdrawals belong to, then release again.', + ], + [ + 'the typed gas limit is too low', + () => undefined, + '40000', + 'Withdrawals #7, #9 were not released because the gas limit set in Advanced settings is too low: 40000 is below the 50000 it needs. Raise it or reset the values, then retry.', + ], + ])( + 'refuses the send with the lines its notice shows when %s, so the dialog can say why nothing was sent', + async (_case, change, typedGasLimit, line) => { + await release([row({ id: '7' }), row({ id: '9' })]); + change(); + + await expect(confirmInDialog(typedGasLimit)).rejects.toMatchObject({ + notSentReasons: [line], + }); + expect(refusalText()).toContain(line); + }, + ); + + it('sends nothing for a withdrawal someone else delivers before the holder confirms, takes it off the page, and says why', async () => { + const onReleased = await release([row()]); + statusOf(QUEUE, 7, requestResult(ExitStatus.Executed)); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(onReleased).toHaveBeenCalledWith([ + { + key: exitKey({ queueAddress: QUEUE, id: '7' }), + status: ExitStatus.Executed, + }, + ]); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because #7 was already delivered.', + ); + }); + + it.each([ + ['the node does not answer', { status: 503, body: 'unavailable' }], + [ + 'the node reports that the estimate reverted', + { + error: { + code: -32015, + message: 'VM Exception while processing transaction: revert', + data: '0x', + }, + }, + ], + ])( + 'never sends on a guessed gas limit: when %s, nothing is sent, and it says so', + async (_case, answer) => { + await release([row()]); + stub.onMethod('eth_estimateGas', answer as StubAnswer); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain( + 'Withdrawal #7 was not released because we could not estimate the gas it needs, and a release is never sent on a guess.', + ); + }, + ); + + it('sends nothing when the wallet is on another network by the time the holder confirms, and says so', async () => { + await release([row()]); + mockWalletSend.mockImplementation(async (method: string) => + method === 'eth_chainId' ? '0x1' : null, + ); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain( + 'Your wallet is connected to another network. Switch it to Rootstock, then release again.', + ); + expect(estimates()).toHaveLength(0); + }); + }); + + describe("the wallet's network", () => { + // The release is signed on the wallet's chain. Sent from another chain, a + // call to the queue's address finds no code, succeeds, and does nothing. + it('asks the wallet itself which network it is on before releasing', async () => { + await release([row()]); + + expect(mockWalletSend).toHaveBeenCalledWith('eth_chainId', []); + expect(mockExecuteExit).toHaveBeenCalled(); + }); + + it('reads and sends nothing when the wallet is on another network, and says so', async () => { + mockWalletSend.mockImplementation(async (method: string) => + method === 'eth_chainId' ? '0x1' : null, + ); + + await release([row()]); + + expect(stub.callCount(QUEUE)).toBe(0); + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(mockExecuteExits).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'Your wallet is connected to another network. Switch it to Rootstock, then release again.', + ); + }); + + it('sends nothing when the wallet cannot say which network it is on, and says so', async () => { + // How an EIP-1193 wallet rejects a request once it is disconnected. + mockWalletSend.mockRejectedValue( + Object.assign( + new Error('The provider is disconnected from all chains.'), + { code: 4900 }, + ), + ); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'We could not check which network your wallet is on, so nothing was released.', + ); + }); + + it('gives up, and sends nothing, when the wallet never answers which network it is on', async () => { + // A locked or sleeping wallet can accept the request and never settle + // it. This read is bounded like every other the check makes, so the + // release is refused instead of leaving the page checking forever. + mockWalletSend.mockImplementation(() => new Promise(() => undefined)); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'We could not check which network your wallet is on, so nothing was released.', + ); + }); + + it('sends nothing without a connected signer to ask', async () => { + mockSigner = undefined; + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'We could not check which network your wallet is on', + ); + }); + }); + + describe("the wallet's account", () => { + // The checks ask about the account the page shows, but the wallet signs as + // whichever account is active in it at that moment, and a release from an + // account that is neither the originator nor the owner reverts. So the + // wallet's own answer is compared with that account at the press, when the + // send step starts, and as the last read before the wallet is asked to sign. + const ACCOUNT_CHANGED = + 'Your wallet switched to a different account, so nothing was sent. Switch back to the account these withdrawals belong to, then release again.'; + + /** The wallet answers `eth_accounts` with each list in turn, then the last one. */ + const accountsInTurn = (...answers: string[][]) => + mockWalletSend.mockImplementation(async (method: string) => { + if (method === 'eth_chainId') { + return '0x1e'; + } + if (method !== 'eth_accounts') { + return null; + } + return answers.length > 1 ? answers.shift() : answers[0]; + }); + + it('asks the wallet itself which account it signs as before releasing', async () => { + await release([row()]); + + expect(mockWalletSend).toHaveBeenCalledWith('eth_accounts', []); + expect(mockExecuteExit).toHaveBeenCalled(); + }); + + it('reads and sends nothing when the wallet signs as another account than the page shows, and says so', async () => { + accountsInTurn([OTHER]); + + await release([row()]); + + expect(stub.callCount(QUEUE)).toBe(0); + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(mockExecuteExits).not.toHaveBeenCalled(); + expect(refusalText()).toContain(ACCOUNT_CHANGED); + }); + + it('sends nothing when the wallet switches account before the holder confirms, and says so', async () => { + await release([row()]); + accountsInTurn([OTHER]); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain(ACCOUNT_CHANGED); + expect(stub.requestsFor('eth_estimateGas')).toHaveLength(0); + }); + + it('sends nothing when the wallet switches account while the check inside the send step runs, and says so', async () => { + await release([row({ id: '7' }), row({ id: '9' })]); + accountsInTurn([ACCOUNT], [OTHER]); + + await expect(confirmInDialog()).rejects.toThrow(); + expect(refusalText()).toContain(ACCOUNT_CHANGED); + }); + + it('sends nothing when the wallet cannot say which account it signs as, and says so', async () => { + // How an EIP-1193 wallet refuses a method the site is not authorised for. + mockWalletSend.mockImplementation(async (method: string) => { + if (method === 'eth_chainId') { + return '0x1e'; + } + throw Object.assign( + new Error( + 'The requested method has not been authorized by the user.', + ), + { code: 4100 }, + ); + }); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'We could not check which account your wallet is using, so nothing was released.', + ); + }); + + it('gives up, and sends nothing, when the wallet never answers which account it signs as', async () => { + // Bounded the same way as every other read the check makes: a wallet + // that accepts the request and never answers it refuses the release + // rather than leaving every control checking for the life of the page. + mockWalletSend.mockImplementation(async (method: string) => + method === 'eth_chainId' ? '0x1e' : new Promise(() => undefined), + ); + + await release([row()]); + + expect(mockExecuteExit).not.toHaveBeenCalled(); + expect(refusalText()).toContain( + 'We could not check which account your wallet is using, so nothing was released.', + ); + }); + + it('names the account the checks ran for as the one to send from', async () => { + await release([row()]); + + await expect(confirmInDialog()).resolves.toEqual( + expect.objectContaining({ from: ACCOUNT }), + ); + }); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/usePerimeterRelease.tsx b/apps/frontend/src/hooks/exitDelay/usePerimeterRelease.tsx new file mode 100644 index 000000000..211460d0f --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/usePerimeterRelease.tsx @@ -0,0 +1,875 @@ +import React, { useCallback } from 'react'; + +import { BigNumber, Contract, providers, utils } from 'ethers'; +import { t } from 'i18next'; +import { nanoid } from 'nanoid'; + +import { getProvider } from '@sovryn/ethers-provider'; +import { NotificationType } from '@sovryn/ui'; + +import { RSK_CHAIN_ID } from '../../config/chains'; + +import { sendRefusal } from '../../app/3_organisms/TransactionStepDialog/helpers'; +import { useNotificationContext } from '../../contexts/NotificationContext'; +import { translations } from '../../locales/i18n'; +import { + BlockState, + ExitStatus, + PendingExit, + RELEASE_GAS_MARGIN_PERCENT, + RELEASE_READ_TIMEOUT_MS, + exitKey, +} from '../../utils/exitDelay'; +import { useAccount } from '../useAccount'; +import { boundedBy, callRaw } from './rawCall'; +import { + ExitPreflight, + useExecuteExit, + useExecuteExits, +} from './useExecuteExit'; + +export type ReleaseRow = Pick< + PendingExit, + 'id' | 'queueAddress' | 'originator' | 'owner' | 'receiver' +>; + +/** A row's key and the terminal status it left the page under. */ +export type ReleaseEntry = { key: string; status: ExitStatus }; + +const QUEUE_READS = [ + 'function blockStateOf(address a) view returns (uint8)', + 'function getRequest(uint256 id) view returns (tuple(uint128 amount, uint64 createdAt, uint64 unlockAt, address originator, address owner, address receiver, address token, bytes32 surfaceId, address subProduct, uint8 status, bool unwrapOnDelivery))', +]; + +const QUEUE_RELEASES = new utils.Interface([ + 'function executeExit(uint256 requestId)', + 'function executeExits(uint256[] ids)', +]); + +/** The queue's own errors for a delivery it refuses. */ +const QUEUE_ERRORS = new utils.Interface([ + 'error QueuePaused()', + 'error UnknownRequest(uint256 id)', + 'error AlreadyTerminal(uint256 id)', + 'error NotUnlocked(uint256 id, uint64 unlockAt)', + 'error NotExecutor(address caller)', + 'error ActorBlocked(address actor, uint8 state)', +]); + +/** Long enough to read a list of withdrawals that were not released. */ +const REFUSAL_TIMEOUT_MS = 30_000; + +type BlockCheck = 'clear' | 'blocked' | 'unreadable'; + +type WalletState = + | 'ready' + | 'otherNetwork' + | 'networkUnreadable' + | 'otherAccount' + | 'accountUnreadable'; + +/** What the holder is told when the wallet is not ready to release. */ +const WALLET_REFUSALS: Record, string> = { + otherNetwork: translations.perimeterPage.releaseRefused.wrongNetwork, + networkUnreadable: + translations.perimeterPage.releaseRefused.networkUnreadable, + otherAccount: translations.perimeterPage.releaseRefused.accountChanged, + accountUnreadable: + translations.perimeterPage.releaseRefused.accountUnreadable, +}; + +/** + * Whether the wallet itself is on RSK and signs as `account`. + * + * The wallet is asked directly, with `eth_chainId` and then `eth_accounts`: + * the signer's provider keeps the network it first detected, and the signer + * resolves its address only when it signs, so a switch made in the wallet + * shows in the wallet's own answers and nowhere else. No signer, a rejected + * request, an answer that is not a chain id or an account, or a request the + * wallet never answers within `RELEASE_READ_TIMEOUT_MS`, is unreadable. + */ +const readWallet = async ( + signer: providers.JsonRpcSigner | undefined, + account: string, +): Promise => { + if (!signer || typeof signer.provider?.send !== 'function') { + return 'networkUnreadable'; + } + try { + const chainId = await boundedBy( + signer.provider.send('eth_chainId', []), + RELEASE_READ_TIMEOUT_MS, + ); + if (!BigNumber.from(chainId).eq(BigNumber.from(RSK_CHAIN_ID))) { + return 'otherNetwork'; + } + } catch (error) { + return 'networkUnreadable'; + } + try { + const accounts: unknown = await boundedBy( + signer.provider.send('eth_accounts', []), + RELEASE_READ_TIMEOUT_MS, + ); + const active = Array.isArray(accounts) ? accounts[0] : undefined; + if (typeof active !== 'string') { + return 'accountUnreadable'; + } + return active.toLowerCase() === account.toLowerCase() + ? 'ready' + : 'otherAccount'; + } catch (error) { + return 'accountUnreadable'; + } +}; + +const queueReader = (queueAddress: string) => + new Contract(queueAddress, QUEUE_READS, getProvider(RSK_CHAIN_ID)); + +/** A row's status, read fresh from its queue; undefined when the read failed. */ +const readStatus = async (row: ReleaseRow): Promise => { + try { + const request = await boundedBy<{ status: number }>( + queueReader(row.queueAddress).getRequest(row.id), + RELEASE_READ_TIMEOUT_MS, + ); + return Number(request.status); + } catch (error) { + return undefined; + } +}; + +/** + * Read, fresh from the queue, whether any party the delivery rule checks — + * the address that started the withdrawal, the position owner, the receiver + * — is blocked. A read that fails, or a state outside the known values, is + * unreadable: nothing may be sent on it. + */ +const checkBlocks = async (row: ReleaseRow): Promise => { + const addresses = [row.originator, row.owner, row.receiver]; + + let states: number[]; + try { + const queue = queueReader(row.queueAddress); + const reads = new Map>(); + const readState = (address: string): Promise => { + const key = address.toLowerCase(); + const cached = reads.get(key); + if (cached) { + return cached; + } + const read: Promise = boundedBy( + queue.blockStateOf(address), + RELEASE_READ_TIMEOUT_MS, + ).then(Number); + reads.set(key, read); + return read; + }; + states = await Promise.all(addresses.map(readState)); + } catch (error) { + return 'unreadable'; + } + + for (const state of states) { + if (state === BlockState.None) { + continue; + } + if (state !== BlockState.Frozen && state !== BlockState.Blacklisted) { + return 'unreadable'; + } + return 'blocked'; + } + return 'clear'; +}; + +const blockRefusal = (row: ReleaseRow, check: BlockCheck): string => + check === 'blocked' + ? rowsRefusal( + [row], + t(translations.perimeterPage.releaseRefused.reason.refused), + ) + : t(translations.perimeterPage.releaseRefused.unreadable, { id: row.id }); + +/** One line naming withdrawals that are not released, and why. */ +const rowsRefusal = (rows: ReleaseRow[], reason: string): string => + t(translations.perimeterPage.releaseRefused.batch, { + count: rows.length, + ids: rows.map(row => `#${row.id}`).join(', '), + reason, + }); + +type Refusal = + | { + kind: 'refused'; + reason: string; + /** The ids the refusal names; undefined when it holds for the whole call. */ + ids?: string[]; + } + | { + /** + * The named withdrawals are not unlocked in the block the dry run + * executed in, whose timestamp can trail the page's clock. + */ + kind: 'unlocking'; + ids: string[]; + }; + +/** + * Why the queue would refuse a delivery, in plain words, from its revert data, + * and which of the withdrawals asked about it names, when it names any. + */ +const refusalOf = (data: string | undefined, rows: ReleaseRow[]): Refusal => { + const reasons = translations.perimeterPage.releaseRefused.reason; + let error: ReturnType | undefined; + try { + error = data && data !== '0x' ? QUEUE_ERRORS.parseError(data) : undefined; + } catch (parseError) { + error = undefined; + } + if (!error) { + return { kind: 'refused', reason: t(reasons.refused) }; + } + switch (error.name) { + case 'QueuePaused': + return { kind: 'refused', reason: t(reasons.paused) }; + case 'NotUnlocked': + return { kind: 'unlocking', ids: [error.args.id.toString()] }; + case 'AlreadyTerminal': { + const id = error.args.id.toString(); + return { + kind: 'refused', + reason: t(reasons.alreadyTerminal, { id }), + ids: [id], + }; + } + case 'UnknownRequest': { + const id = error.args.id.toString(); + return { + kind: 'refused', + reason: t(reasons.unknownRequest, { id }), + ids: [id], + }; + } + case 'NotExecutor': + return { kind: 'refused', reason: t(reasons.notExecutor) }; + case 'ActorBlocked': { + const actor = String(error.args.actor).toLowerCase(); + return { + kind: 'refused', + reason: t(reasons.refused), + ids: rows + .filter(row => + [row.originator, row.owner, row.receiver].some( + address => address.toLowerCase() === actor, + ), + ) + .map(row => row.id), + }; + } + default: + return { kind: 'refused', reason: t(reasons.refused) }; + } +}; + +/** A release's calldata: one id as `executeExit`, a batch as `executeExits`. */ +const releaseData = (rows: ReleaseRow[], single: boolean): string => + single + ? QUEUE_RELEASES.encodeFunctionData('executeExit', [rows[0].id]) + : QUEUE_RELEASES.encodeFunctionData('executeExits', [ + rows.map(row => row.id), + ]); + +type DryRun = { kind: 'accepted' } | Refusal; + +/** + * Whether an address carries code, read fresh; undefined when the read did not + * complete or its answer is not hex. + */ +const readHasCode = async (address: string): Promise => { + try { + const length = utils.hexDataLength( + await boundedBy( + getProvider(RSK_CHAIN_ID).getCode(address), + RELEASE_READ_TIMEOUT_MS, + ), + ); + return length === null ? undefined : length > 0; + } catch (error) { + return undefined; + } +}; + +/** + * Ask the queue, with `eth_call` from the holder's own address, whether it + * would accept this release now. A dry run that could not be completed is a + * refusal too: an unanswered check is not a yes. + */ +const dryRun = async ( + rows: ReleaseRow[], + single: boolean, + account: string, +): Promise => { + try { + const outcome = await callRaw( + getProvider(RSK_CHAIN_ID), + { + from: account, + to: rows[0].queueAddress, + data: releaseData(rows, single), + }, + // The release functions return nothing: an executed release answers + // with empty data, and any other result is not the queue's yes. + result => result === '0x', + RELEASE_READ_TIMEOUT_MS, + ); + if (outcome.kind === 'result') { + return { kind: 'accepted' }; + } + if (outcome.kind === 'reverted') { + return refusalOf(outcome.data, rows); + } + } catch (error) { + // Treated below as a dry run that could not be completed. + } + return { + kind: 'refused', + reason: t(translations.perimeterPage.releaseRefused.reason.unchecked), + }; +}; + +/** + * Dry-run one queue's release, dropping what the queue refuses by name. + * + * `executeExits` is atomic, so one withdrawal the queue refuses would take the + * others down with it. A refusal that names withdrawals — already delivered, + * unknown to the queue, or with a blocked party — drops those, and the queue + * is asked again about the rest. A withdrawal not yet unlocked in the latest + * block is dropped the same way and set aside as unlocking. A refusal that + * holds for the whole call, or a dry run that could not be completed, sends + * nothing from this queue. Every pass drops at least one row or stops, so this + * ends. + * + * Nothing is asked of an address without code, or whose code could not be + * read: a call to it executes nothing and answers with the same empty data an + * accepted release gives. + */ +const askQueue = async ( + rows: ReleaseRow[], + single: boolean, + account: string, +): Promise<{ + accepted: ReleaseRow[]; + unlocking: ReleaseRow[]; + refusals: string[]; +}> => { + const refusals: string[] = []; + const unlocking: ReleaseRow[] = []; + if ((await readHasCode(rows[0].queueAddress)) !== true) { + refusals.push( + rowsRefusal( + rows, + t(translations.perimeterPage.releaseRefused.reason.unchecked), + ), + ); + return { accepted: [], unlocking, refusals }; + } + let remaining = rows; + while (remaining.length > 0) { + const outcome = await dryRun(remaining, single, account); + if (outcome.kind === 'accepted') { + return { accepted: remaining, unlocking, refusals }; + } + const named = outcome.ids ?? []; + const dropped = remaining.filter(row => named.includes(row.id)); + if (dropped.length === 0) { + refusals.push( + rowsRefusal( + remaining, + outcome.kind === 'refused' + ? outcome.reason + : t(translations.perimeterPage.releaseRefused.reason.refused), + ), + ); + return { accepted: [], unlocking, refusals }; + } + if (outcome.kind === 'unlocking') { + unlocking.push(...dropped); + } else { + refusals.push(rowsRefusal(dropped, outcome.reason)); + } + remaining = remaining.filter(row => !named.includes(row.id)); + } + return { accepted: [], unlocking, refusals }; +}; + +/** Rows grouped by the queue that holds them: a queue accepts only its own ids. */ +const byQueue = (rows: ReleaseRow[]): ReleaseRow[][] => { + const groups = new Map(); + rows.forEach(row => + groups.set(row.queueAddress, [ + ...(groups.get(row.queueAddress) ?? []), + row, + ]), + ); + return [...groups.values()]; +}; + +/** A row the queue resolved away by recovery, with which of the two it was. */ +type ResolvedRow = { + row: ReleaseRow; + status: ExitStatus.ResolvedToProtocol | ExitStatus.ResolvedByOwner; +}; + +type ReleaseCheck = { + /** Rows paid to their receiver: someone has delivered them. */ + delivered: ReleaseRow[]; + /** Rows the queue resolved away by recovery; each is named in `refusals`. */ + resolved: ResolvedRow[]; + /** One line per withdrawal, or group of withdrawals, not released, and why. */ + refusals: string[]; + /** Whether the dry run asked about `executeExit` rather than `executeExits`. */ + single: boolean; + /** Per queue, the rows its dry run accepted. */ + accepted: ReleaseRow[][]; +}; + +/** Each row's key paired with the real status it settled at: `delivered` rows + * as executed, `resolved` rows as whichever recovery outcome they read. */ +const settledEntries = ( + delivered: ReleaseRow[], + resolved: ResolvedRow[], +): ReleaseEntry[] => [ + ...delivered.map(row => ({ key: exitKey(row), status: ExitStatus.Executed })), + ...resolved.map(({ row, status }) => ({ key: exitKey(row), status })), +]; + +/** + * The release check: each row's status and each party's block state read + * fresh, then the queue's own dry run from the holder's address. + * + * `single` names the call that will be sent. When it is not given, one row + * left after the reads is asked about as `executeExit`, and more as + * `executeExits`. + */ +const checkRelease = async ( + rows: ReleaseRow[], + account: string, + single?: boolean, +): Promise => { + const refusals: string[] = []; + + const statuses = await Promise.all(rows.map(readStatus)); + const delivered: ReleaseRow[] = []; + const resolved: ResolvedRow[] = []; + const queued = rows.filter((row, index) => { + switch (statuses[index]) { + case ExitStatus.Queued: + return true; + case ExitStatus.Executed: + delivered.push(row); + return false; + case ExitStatus.ResolvedToProtocol: + resolved.push({ row, status: ExitStatus.ResolvedToProtocol }); + refusals.push( + t(translations.perimeterPage.releaseRefused.resolvedToProtocol, { + id: row.id, + }), + ); + return false; + case ExitStatus.ResolvedByOwner: + resolved.push({ row, status: ExitStatus.ResolvedByOwner }); + refusals.push( + t(translations.perimeterPage.releaseRefused.resolvedByOwner, { + id: row.id, + }), + ); + return false; + default: + // A read that failed, None from a node that does not hold the + // request, or a value outside the known ones: nothing is known about + // the withdrawal, so nothing is sent and the row stays. + refusals.push( + t(translations.perimeterPage.releaseRefused.statusUnreadable, { + id: row.id, + }), + ); + return false; + } + }); + + const checks = await Promise.all(queued.map(row => checkBlocks(row))); + const clear = queued.filter((row, index) => { + if (checks[index] === 'clear') { + return true; + } + refusals.push(blockRefusal(row, checks[index])); + return false; + }); + + const asSingle = single ?? clear.length === 1; + const asked = await Promise.all( + byQueue(clear).map(queueRows => askQueue(queueRows, asSingle, account)), + ); + asked.forEach(outcome => refusals.push(...outcome.refusals)); + const unlocking = asked.flatMap(outcome => outcome.unlocking); + if (unlocking.length > 0) { + refusals.push( + t(translations.perimeterPage.releaseRefused.unlocking, { + count: unlocking.length, + ids: unlocking.map(row => `#${row.id}`).join(', '), + }), + ); + } + + return { + delivered, + resolved, + refusals, + single: asSingle, + accepted: asked + .map(outcome => outcome.accepted) + .filter(queueRows => queueRows.length > 0), + }; +}; + +/** + * The gas a release needs, estimated from the holder's address for exactly + * these rows. Undefined when no estimate is obtained: a release is never sent + * on a guessed limit. + */ +const estimateReleaseGas = async ( + rows: ReleaseRow[], + single: boolean, + account: string, +): Promise => { + try { + const gas = await boundedBy( + getProvider(RSK_CHAIN_ID).estimateGas({ + from: account, + to: rows[0].queueAddress, + data: releaseData(rows, single), + }), + RELEASE_READ_TIMEOUT_MS, + ); + return gas.gt(0) ? gas : undefined; + } catch (error) { + return undefined; + } +}; + +/** + * A limit the holder typed in Advanced settings, kept only when it is at + * least the fresh estimate; undefined when it is below the estimate, or not a + * whole number, so the release is not sent on it. + */ +const typedReleaseGasLimit = ( + estimate: BigNumber, + typedGasLimit: string, +): string | undefined => { + try { + const typed = BigNumber.from(typedGasLimit); + return typed.gte(estimate) ? typed.toString() : undefined; + } catch (error) { + return undefined; + } +}; + +/** + * The latest block's own gas limit, read fresh; undefined when the read did + * not complete. + */ +const readBlockGasLimit = async (): Promise => { + try { + const block = await boundedBy( + getProvider(RSK_CHAIN_ID).getBlock('latest'), + RELEASE_READ_TIMEOUT_MS, + ); + return block?.gasLimit; + } catch (error) { + return undefined; + } +}; + +type AutoGasLimit = + | { kind: 'ok'; gasLimit: string } + /** The latest block's own gas limit could not be read. */ + | { kind: 'blockUnreadable' } + /** The estimate alone is above the latest block's own gas limit: no margin can be sent. */ + | { kind: 'tooLarge' }; + +/** + * The gas limit an untyped release is sent with: the fresh estimate plus + * `RELEASE_GAS_MARGIN_PERCENT`, never above the latest block's own gas limit. + * The block's own gas limit is read fresh, because the margin is added for a + * release that executes in a later block, and that block's own limit can + * change between reads. An estimate that alone is above it cannot be sent at + * any margin, and a block read that does not complete leaves the ceiling + * unknown, so neither is sent on a guess. + */ +const autoReleaseGasLimit = async ( + estimate: BigNumber, +): Promise => { + const blockGasLimit = await readBlockGasLimit(); + if (!blockGasLimit) { + return { kind: 'blockUnreadable' }; + } + if (estimate.gt(blockGasLimit)) { + return { kind: 'tooLarge' }; + } + const withMargin = estimate.mul(100 + RELEASE_GAS_MARGIN_PERCENT).div(100); + return { + kind: 'ok', + gasLimit: (withMargin.lt(blockGasLimit) + ? withMargin + : blockGasLimit + ).toString(), + }; +}; + +/** + * Release withdrawals from the Perimeter vault, one row or several, sending + * only what the queue will still accept, from the network it lives on. + * + * The page's rows are only as fresh as its last read, and `executeExits` is + * atomic: one id that changed since then reverts every release in its batch. + * So the release check runs when the holder presses Release, and again inside + * the transaction dialog's send step, immediately before the wallet is asked + * to sign: the holder may confirm long after pressing. Each run: + * + * 1. asks the wallet which network it is on and which account it signs as. A + * release signed on another chain reaches an address with no code, + * succeeds and does nothing, and one signed by an account other than the + * one checked reverts, so on any other answer, or none, nothing is read or + * sent; + * 2. reads each row's status fresh. A row paid to its receiver by someone else + * leaves the page through `onReleased`; a row resolved away by recovery + * leaves it too, and is named with what happened to it. A status that says + * nothing about the request, or no answer, keeps the row and sends nothing; + * 3. gives the rows still queued the block check: a withdrawal whose party is + * frozen or blacklisted is not sent, and the refusal names neither the + * party nor the state; + * 4. asks the queue, in a dry run from the holder's address, whether it would + * accept each queue's release, dropping each withdrawal it refuses by name. + * + * Every read a run makes — of the wallet and of the chain — gives up after + * `RELEASE_READ_TIMEOUT_MS`, and a read that gives up refuses like one that + * failed. + * + * At the press, one notice tells the holder which withdrawals are not + * released and why, and the rest opens in the dialog: one row as + * `executeExit`, several grouped by queue into one transaction list. In the + * send step the check also estimates the gas for exactly what passed and sets + * the gas limit from that estimate, capped at the latest block's own gas + * limit (`autoReleaseGasLimit`), and reads the wallet once more as its last + * step. A withdrawal that does not pass at that moment is named and dropped. + * When nothing passes, the gas cannot be estimated, its own gas limit could + * not be checked, a gas limit the holder typed is below the estimate, or the + * wallet signs as + * another account, the wallet is not asked at all; otherwise it is handed the + * release from the account the check ran for. `onReleased` receives each + * settled row's key (queue and id) paired with the status it actually left + * the page under: `Executed` for a row found delivered or one a completed + * transaction settled, and the queue's own `ResolvedToProtocol` / + * `ResolvedByOwner` for a row the queue resolved away by recovery instead. + */ +export const usePerimeterRelease = () => { + const { account, signer } = useAccount(); + const executeExit = useExecuteExit(); + const executeExits = useExecuteExits(); + const { addNotification } = useNotificationContext(); + + return useCallback( + async ( + rows: ReleaseRow[], + onReleased: (entries: ReleaseEntry[]) => void, + ) => { + if (!account || rows.length === 0) { + return; + } + + const notify = (lines: string[]) => + addNotification( + { + type: NotificationType.warning, + id: nanoid(), + title: t(translations.perimeterPage.releaseRefused.title), + content: ( + <> + {lines.map(line => ( +

{line}

+ ))} + + ), + dismissible: true, + }, + REFUSAL_TIMEOUT_MS, + ); + + /** + * Why the wallet is not ready to release — not on RSK, or not signing as + * `account` — or undefined when it is. + */ + const walletRefusal = async (): Promise => { + const wallet = await readWallet(signer, account); + if (wallet === 'ready') { + return undefined; + } + const refusal: string = t(WALLET_REFUSALS[wallet]); + return refusal; + }; + + /** + * Refuse a send step: the holder is told why in the notice, and the + * dialog is handed the same lines to say why nothing was sent. + */ + const refuseSend = (lines: string[]) => { + if (lines.length > 0) { + notify(lines); + } + return sendRefusal(lines); + }; + + const walletAtPress = await walletRefusal(); + if (walletAtPress) { + notify([walletAtPress]); + return; + } + + const check = await checkRelease(rows, account); + const settled = settledEntries(check.delivered, check.resolved); + if (settled.length > 0) { + onReleased(settled); + } + if (check.refusals.length > 0) { + notify(check.refusals); + } + if (check.accepted.length === 0) { + return; + } + + const pressed = new Map(rows.map(row => [exitKey(row), row])); + + const preflight = + (single: boolean): ExitPreflight => + async (batch, typedGasLimit) => { + const walletAtSend = await walletRefusal(); + if (walletAtSend) { + throw refuseSend([walletAtSend]); + } + const batchRows = batch.requestIds + .map(id => + pressed.get(exitKey({ queueAddress: batch.queueAddress, id })), + ) + .filter((row): row is ReleaseRow => !!row); + + const again = await checkRelease(batchRows, account, single); + const refusals: string[] = []; + const settledAgain = settledEntries(again.delivered, again.resolved); + if (settledAgain.length > 0) { + onReleased(settledAgain); + } + again.delivered.forEach(row => + refusals.push( + rowsRefusal( + [row], + t( + translations.perimeterPage.releaseRefused.reason + .alreadyTerminal, + { id: row.id }, + ), + ), + ), + ); + refusals.push(...again.refusals); + + const [passed] = again.accepted; + const estimate = passed + ? await estimateReleaseGas(passed, single, account) + : undefined; + + const reasons = translations.perimeterPage.releaseRefused.reason; + /** "We could not get it a usable gas limit": the batch-aware ending. */ + const noUsableGasLimit = () => + t( + passed && passed.length > 1 + ? reasons.gasUnestimatedBatch + : reasons.gasUnestimated, + ); + + let gasLimit: string | undefined; + if (passed && estimate && typedGasLimit !== undefined) { + gasLimit = typedReleaseGasLimit(estimate, typedGasLimit); + if (!gasLimit) { + refusals.push( + rowsRefusal( + passed, + t(reasons.gasLimitTooLow, { + limit: typedGasLimit, + needed: estimate.toString(), + }), + ), + ); + } + } else if (passed && estimate) { + const auto = await autoReleaseGasLimit(estimate); + if (auto.kind === 'ok') { + gasLimit = auto.gasLimit; + } else if (auto.kind === 'blockUnreadable') { + refusals.push(rowsRefusal(passed, t(reasons.gasLimitUnreadable))); + } else { + refusals.push(rowsRefusal(passed, noUsableGasLimit())); + } + } else if (passed && !estimate) { + refusals.push(rowsRefusal(passed, noUsableGasLimit())); + } + + // The wallet is read once more, as the last step before it is asked + // to sign: an account switched during the reads above cancels the + // send. + const walletAtSign = + passed && gasLimit ? await walletRefusal() : undefined; + if (walletAtSign) { + throw refuseSend([walletAtSign]); + } + if (!passed || !gasLimit) { + throw refuseSend(refusals); + } + if (refusals.length > 0) { + notify(refusals); + } + return { + requestIds: passed.map(row => row.id), + gasLimit, + from: account, + }; + }; + + if (check.single) { + const [[row]] = check.accepted; + await executeExit(row.queueAddress, row.id, { + preflight: preflight(true), + onComplete: () => + onReleased([{ key: exitKey(row), status: ExitStatus.Executed }]), + }); + return; + } + await executeExits( + check.accepted.map(queueRows => ({ + queueAddress: queueRows[0].queueAddress, + requestIds: queueRows.map(queueRow => queueRow.id), + })), + { + preflight: preflight(false), + onComplete: batch => + onReleased( + batch.requestIds.map(id => ({ + key: exitKey({ queueAddress: batch.queueAddress, id }), + status: ExitStatus.Executed, + })), + ), + }, + ); + }, + [account, addNotification, executeExit, executeExits, signer], + ); +}; diff --git a/apps/frontend/src/hooks/exitDelay/usePerimeterVault.test.ts b/apps/frontend/src/hooks/exitDelay/usePerimeterVault.test.ts new file mode 100644 index 000000000..edc071e66 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/usePerimeterVault.test.ts @@ -0,0 +1,797 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { BigNumber, utils } from 'ethers'; + +import { + JsonRpcStub, + captureCallFailure, + startJsonRpcStub, +} from '../../utils/testing/jsonRpcStub'; +import { PointerRead } from './readPerimeterPointer'; +import { usePerimeterVault } from './usePerimeterVault'; + +/** + * "No delayed withdrawals" is a definitive statement. These tests hold + * apart an honest empty queue, a consumer with no delay leg, and every read + * that did not complete — which must reach the page as unknown. + */ + +const ACCOUNT = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; +const PROTOCOL = '0x5A0D867e0D70Fcc6Ade25C3F1B89d618b5B4Eaa7'; +const BORROWER_OPERATIONS = '0x5B9dB4B8bdeF3e57323187a9AC2639C5DEe5FD39'; +// The hook remembers queue addresses for the life of the tab, keyed by the +// consumer they came from. Tests that move a pointer use their own consumer so +// nothing they leave behind is remembered by another test. +const OTHER_BORROWER_OPERATIONS = '0x6B9dB4B8bdeF3e57323187a9AC2639C5DEe5FD39'; +const MOVING_PROTOCOL = '0x7A0D867e0D70Fcc6Ade25C3F1B89d618b5B4Eaa7'; +const UNWIRED_PROTOCOL = '0x8A0D867e0D70Fcc6Ade25C3F1B89d618b5B4Eaa7'; +const QUEUE = '0x1111111111111111111111111111111111111111'; +const ZERO_QUEUE = '0x2222222222222222222222222222222222222222'; +const NEW_QUEUE = '0x4444444444444444444444444444444444444444'; +const RECEIVER = '0x3333333333333333333333333333333333333333'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +/** rUSDT on RSK — 18 decimals, like every asset on these surfaces today. */ +const RUSDT = '0xEf213441a85DF4d7acBdAe0Cf78004E1e486BB96'; + +let mockProtocolAddress = PROTOCOL; + +const mockReadPointer = jest.fn(); +const mockGetActive = jest.fn(); +const mockGetRequest = jest.fn(); +const mockBlockStateOf = jest.fn(); +const mockPaused = jest.fn(); +const mockZeroContract = jest.fn(); +const mockGetCode = jest.fn(); +const mockGetNetwork = jest.fn(); +let mockReadChain = '0x1e'; + +jest.mock('./readPerimeterPointer', () => ({ + readPerimeterPointer: (...args: unknown[]) => mockReadPointer(...args), +})); + +jest.mock('ethers', () => { + const actual = jest.requireActual('ethers'); + return { + ...actual, + Contract: function (address: string) { + return { + getActive: (...args: unknown[]) => mockGetActive(address, ...args), + getRequest: (id: string) => mockGetRequest(address, id), + blockStateOf: (party: string) => mockBlockStateOf(address, party), + securityPerimeterPaused: () => mockPaused(address), + }; + }, + }; +}); + +// Only the Zero lookup is stubbed: the asset list this hook reads to resolve +// a token's symbol and decimals is the real one. +jest.mock('@sovryn/contracts', () => ({ + ...jest.requireActual('@sovryn/contracts'), + getZeroContract: (...args: unknown[]) => mockZeroContract(...args), +})); + +// Only the provider is stubbed: the asset lookups this hook does to scale an +// amount go through the real network mapping. +jest.mock('@sovryn/ethers-provider', () => ({ + ...jest.requireActual('@sovryn/ethers-provider'), + getProvider: () => ({ + getCode: mockGetCode, + getNetwork: async () => ({ chainId: 30 }), + send: async (method: string) => { + if (method !== 'eth_chainId') throw new Error('Unexpected fixture RPC'); + return jest + .requireActual('ethers') + .utils.hexValue((await mockGetNetwork()).chainId); + }, + }), +})); + +jest.mock('../../config/chains', () => ({ + get RSK_CHAIN_ID() { + return mockReadChain; + }, +})); + +// Long enough to sit well above RELEASE_READ_TIMEOUT_MS below, so a test can +// tell a raw read settling on its own bound apart from this one firing +// instead; short enough that the deadline test below does not wait ten real +// seconds. +jest.mock('./useExitDelay', () => ({ + ...jest.requireActual('./useExitDelay'), + EXIT_DELAY_QUOTE_TIMEOUT_MS: 500, +})); + +// Short enough that a hang test below settles well inside the deadline above. +jest.mock('../../utils/exitDelay', () => ({ + ...jest.requireActual('../../utils/exitDelay'), + RELEASE_READ_TIMEOUT_MS: 50, +})); + +jest.mock('../../store/rxjs/provider-cache', () => ({ + asyncCall: (_key: string, fn: () => unknown) => fn(), +})); + +jest.mock('../useAccount', () => ({ + useAccount: () => ({ account: ACCOUNT }), +})); + +jest.mock('../useGetContract', () => ({ + useGetProtocolContract: () => ({ address: mockProtocolAddress }), +})); + +jest.mock('../useCacheCall', () => { + const React = jest.requireActual('react'); + return { + useCacheCall: ( + _key: string, + _chainId: string, + fn: () => Promise, + _deps: unknown[], + defaultValue: unknown, + ) => { + const [state, setState] = React.useState({ + value: defaultValue, + // Idle at first, as the shared cache is: a result still to come must + // be reported by the hook under test, not by this stand-in. + loading: false, + }); + // Holds the latest fn without making the mount effect below re-run: + // fn's identity changes every render, but this mock fetches once. + const fnRef = React.useRef(fn); + fnRef.current = fn; + React.useEffect(() => { + let alive = true; + Promise.resolve(fnRef.current()).then((value: unknown) => { + if (alive) setState({ value, loading: false }); + }); + return () => { + alive = false; + }; + }, []); + return state; + }, + }; +}); + +const address = (value: string): PointerRead => ({ + kind: 'address', + address: value, +}); +const ABSENT: PointerRead = { kind: 'absent' }; +const UNREADABLE: PointerRead = { kind: 'unreadable' }; + +/** A queued request in the contract's own field order. */ +const request = (overrides: Record = {}) => ({ + amount: BigNumber.from('1500000000000000000'), + createdAt: BigNumber.from(1_800_000_000), + unlockAt: BigNumber.from(1_800_086_400), + originator: ACCOUNT, + owner: ACCOUNT, + receiver: RECEIVER, + token: ZERO_ADDRESS, + surfaceId: '0x00', + subProduct: ZERO_ADDRESS, + status: 1, + unwrapOnDelivery: false, + ...overrides, +}); + +const holding = (...ids: number[]) => + mockGetActive.mockResolvedValue({ + ids: ids.map(id => BigNumber.from(id)), + nextCursor: BigNumber.from(0), + }); + +const settled = async () => { + const hook = renderHook(() => usePerimeterVault()); + await waitFor(() => expect(hook.result.current.loading).toBe(false)); + return hook.result; +}; + +describe('usePerimeterVault', () => { + let stub: JsonRpcStub; + let transportFailure: unknown; + + beforeAll(async () => { + stub = await startJsonRpcStub(); + transportFailure = await captureCallFailure(stub, { + status: 503, + body: 'Service Unavailable', + }); + }); + + afterAll(() => stub.close()); + + beforeEach(() => { + mockReadChain = '0x1e'; + mockProtocolAddress = PROTOCOL; + mockZeroContract.mockResolvedValue({ address: BORROWER_OPERATIONS }); + mockReadPointer.mockImplementation(async (_chainId, consumer: string) => + consumer === PROTOCOL ? address(QUEUE) : address(ZERO_ADDRESS), + ); + holding(); + mockPaused.mockResolvedValue(false); + mockBlockStateOf.mockResolvedValue(0); + mockGetRequest.mockResolvedValue(request()); + mockGetCode.mockResolvedValue('0x'); + mockGetNetwork.mockResolvedValue({ chainId: 30 }); + }); + + describe('deployment queue after consumer rollback', () => { + const CODE = '0x6001600101'; + const HASH = utils.keccak256(CODE); + const keys = [ + 'REACT_APP_PERIMETER_QUEUE_RSK_MAINNET', + 'REACT_APP_PERIMETER_QUEUE_RSK_MAINNET_RUNTIME_HASH', + 'REACT_APP_PERIMETER_QUEUE_RSK_TESTNET', + 'REACT_APP_PERIMETER_QUEUE_RSK_TESTNET_RUNTIME_HASH', + ]; + let saved: Record; + let consumer = 1000; + + beforeEach(() => { + saved = Object.fromEntries(keys.map(key => [key, process.env[key]])); + keys.forEach(key => delete process.env[key]); + // Each case models a fresh tab: no consumer identity was seen before. + mockProtocolAddress = utils.hexZeroPad(utils.hexlify(++consumer), 20); + mockZeroContract.mockResolvedValue({ + address: utils.hexZeroPad(utils.hexlify(++consumer), 20), + }); + mockReadPointer.mockResolvedValue(ABSENT); + holding(7, 8); + mockGetCode.mockImplementation(async (address: string) => + address.toLowerCase() === QUEUE ? CODE : '0x', + ); + }); + afterEach(() => + keys.forEach(key => { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + }), + ); + const configure = () => { + process.env.REACT_APP_PERIMETER_QUEUE_RSK_MAINNET = QUEUE; + process.env.REACT_APP_PERIMETER_QUEUE_RSK_MAINNET_RUNTIME_HASH = HASH; + }; + + it('discovers retained requests in a fresh tab after both getters retire', async () => { + configure(); + const result = await settled(); + expect(result.current.exits.map(row => row.id)).toEqual(['7', '8']); + expect(result.current.unknown).toBe(false); + expect(mockGetNetwork).toHaveBeenCalledTimes(1); + expect(mockGetCode).toHaveBeenCalledWith(QUEUE); + }); + it('keeps Phase 1 absence when no deployment queue is configured', async () => { + const result = await settled(); + expect(result.current.exits).toEqual([]); + expect(result.current.unknown).toBe(false); + expect(mockGetNetwork).not.toHaveBeenCalled(); + }); + it('keeps a same-tab queue after getters retire without fallback configuration', async () => { + mockReadPointer.mockResolvedValue(address(QUEUE)); + await settled(); + mockReadPointer.mockResolvedValue(ABSENT); + const result = await settled(); + expect(result.current.exits.map(row => row.id)).toEqual(['7', '8']); + expect(result.current.unknown).toBe(false); + expect(mockGetNetwork).not.toHaveBeenCalled(); + }); + it('keeps fallback rows while warning about an unread consumer', async () => { + configure(); + mockReadPointer.mockResolvedValue(UNREADABLE); + const result = await settled(); + expect(result.current.exits.map(row => row.id)).toEqual(['7', '8']); + expect(result.current.unknown).toBe(true); + }); + it.each([ + 'wrong code', + 'empty code', + 'wrong chain', + 'code failure', + 'incomplete config', + 'invalid address', + 'invalid hash', + 'network failure', + 'network timeout', + 'code timeout', + ])('warns and never reads an unverified fallback for %s', async failure => { + configure(); + if (failure === 'wrong code') mockGetCode.mockResolvedValue('0x6002'); + if (failure === 'empty code') mockGetCode.mockResolvedValue('0x'); + if (failure === 'wrong chain') + mockGetNetwork.mockResolvedValue({ chainId: 31 }); + if (failure === 'code failure') + mockGetCode.mockRejectedValue(new Error('Transport unavailable')); + if (failure === 'incomplete config') + delete process.env.REACT_APP_PERIMETER_QUEUE_RSK_MAINNET_RUNTIME_HASH; + if (failure === 'invalid address') + process.env.REACT_APP_PERIMETER_QUEUE_RSK_MAINNET = 'not-an-address'; + if (failure === 'invalid hash') + process.env.REACT_APP_PERIMETER_QUEUE_RSK_MAINNET_RUNTIME_HASH = + '0x1234'; + if (failure === 'network failure') + mockGetNetwork.mockRejectedValue(new Error('Transport unavailable')); + if (failure === 'network timeout') + mockGetNetwork.mockReturnValue(new Promise(() => undefined)); + if (failure === 'code timeout') + mockGetCode.mockReturnValue(new Promise(() => undefined)); + const result = await settled(); + expect(result.current.exits).toEqual([]); + expect(result.current.unknown).toBe(true); + expect(mockGetActive).not.toHaveBeenCalled(); + }); + it('never reuses mainnet configuration for testnet', async () => { + configure(); + mockReadChain = '0x1f'; + const result = await settled(); + expect(result.current.exits).toEqual([]); + expect(result.current.unknown).toBe(false); + expect(mockGetNetwork).not.toHaveBeenCalled(); + }); + it('uses explicitly configured testnet inputs only on testnet', async () => { + mockReadChain = '0x1f'; + mockGetNetwork.mockResolvedValue({ chainId: 31 }); + process.env.REACT_APP_PERIMETER_QUEUE_RSK_TESTNET = QUEUE; + process.env.REACT_APP_PERIMETER_QUEUE_RSK_TESTNET_RUNTIME_HASH = HASH; + const result = await settled(); + expect(result.current.exits.map(row => row.id)).toEqual(['7', '8']); + expect(result.current.unknown).toBe(false); + expect(mockGetNetwork).toHaveBeenCalledTimes(1); + }); + it('deduplicates the deployment queue and consumer-derived queue', async () => { + configure(); + mockReadPointer.mockResolvedValue(address(QUEUE)); + const result = await settled(); + expect(result.current.exits.map(row => row.id)).toEqual(['7', '8']); + expect(mockGetActive).toHaveBeenCalledTimes(1); + }); + }); + + it('reports an empty queue as empty, not as unread', async () => { + const result = await settled(); + + expect(result.current.exits).toHaveLength(0); + expect(result.current.unknown).toBe(false); + }); + + it.each([false, true])( + 'qualifies the read when the last bounded page has more entries: %s', + async moreEntries => { + mockGetActive.mockImplementation( + async (_queue: string, _party: string, cursor: BigNumber) => { + const end = cursor.toNumber() + 500; + return { + ids: Array.from({ length: 500 }, (_, index) => + BigNumber.from(cursor.toNumber() + index + 1), + ), + nextCursor: BigNumber.from( + !moreEntries && end === 25_000 ? 0 : end, + ), + }; + }, + ); + + const { result } = renderHook(() => usePerimeterVault()); + await waitFor(() => expect(result.current.exits).toHaveLength(25_000), { + timeout: 5_000, + }); + + expect(mockGetActive).toHaveBeenCalledTimes(50); + expect(result.current.unknown).toBe(moreEntries); + }, + 10_000, + ); + + it('lists a hold for the connected account even when it is only the receiver', async () => { + // getActive is indexed by party, and the receiver is one of the three + // the queue records — the same page read used for an originator or + // owner surfaces a row where the connected account is only the receiver. + holding(7); + mockGetRequest.mockResolvedValue( + request({ originator: RECEIVER, owner: RECEIVER, receiver: ACCOUNT }), + ); + + const result = await settled(); + + expect(result.current.exits.map(exit => exit.id)).toEqual(['7']); + expect(result.current.exits[0].receiver.toLowerCase()).toBe( + ACCOUNT.toLowerCase(), + ); + }); + + it("reads each consumer's queue pointer", async () => { + await settled(); + + expect(mockReadPointer).toHaveBeenCalledWith( + '0x1e', + PROTOCOL, + 'exitDelayQueue', + ); + expect(mockReadPointer).toHaveBeenCalledWith( + '0x1e', + BORROWER_OPERATIONS, + 'exitDelayQueue', + ); + }); + + it('reports nothing held, as a completed read, when no consumer has a delay leg', async () => { + mockProtocolAddress = UNWIRED_PROTOCOL; + mockReadPointer.mockResolvedValue(ABSENT); + + const result = await settled(); + + expect(result.current.unknown).toBe(false); + expect(result.current.exits).toHaveLength(0); + expect(mockGetActive).not.toHaveBeenCalled(); + }); + + it('reports unknown when a queue pointer could not be read', async () => { + mockReadPointer.mockResolvedValue(UNREADABLE); + + const result = await settled(); + + expect(result.current.unknown).toBe(true); + }); + + it("reports unknown when Zero's pointer could not be read, and still lists the protocol's holds", async () => { + holding(7); + mockReadPointer.mockImplementation(async (_chainId, consumer: string) => + consumer === PROTOCOL ? address(QUEUE) : UNREADABLE, + ); + + const result = await settled(); + + expect(result.current.exits.map(exit => exit.id)).toEqual(['7']); + expect(result.current.unknown).toBe(true); + }); + + it("reports unknown when Zero's BorrowerOperations address cannot be resolved", async () => { + mockZeroContract.mockRejectedValue(new Error('no artifact')); + + const result = await settled(); + + expect(result.current.unknown).toBe(true); + }); + + it('reports unknown when a queue read fails, never an empty vault', async () => { + mockGetActive.mockRejectedValue(transportFailure); + + const result = await settled(); + + expect(result.current.unknown).toBe(true); + expect(result.current.exits).toHaveLength(0); + }); + + it('stays loading until the first attempt resolves', () => { + mockGetActive.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => usePerimeterVault()); + + expect(result.current.loading).toBe(true); + }); + + it('reports unknown, not loading forever, once its deadline passes without a value', async () => { + // A block number that never arrives — the RPC endpoint refusing every + // request, say — leaves this same shape: no fetch ever lands. The vault + // must stop reporting a loader that will never end and say it could not + // be read, the way every other failed read here does. + mockGetActive.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => usePerimeterVault()); + expect(result.current.loading).toBe(true); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.unknown).toBe(true); + expect(result.current.exits).toHaveLength(0); + }); + + // Each read below hangs rather than failing outright — no answer ever + // arrives, not even a rejection. A read with its own bound turns that into + // a rejection quickly, well inside EXIT_DELAY_QUOTE_TIMEOUT_MS above; one + // without a bound of its own is only ever caught by that outer deadline, + // which is why the wait below is capped well short of it. + it('settles on its own bound, not the outer deadline, when getActive hangs', async () => { + mockGetActive.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => usePerimeterVault()); + + await waitFor(() => expect(result.current.unknown).toBe(true), { + timeout: 300, + }); + expect(result.current.loading).toBe(false); + }); + + it('settles on its own bound, not the outer deadline, when securityPerimeterPaused hangs', async () => { + mockPaused.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => usePerimeterVault()); + + await waitFor(() => expect(result.current.unknown).toBe(true), { + timeout: 300, + }); + expect(result.current.loading).toBe(false); + }); + + it('settles on its own bound, not the outer deadline, when getRequest hangs', async () => { + holding(7); + mockGetRequest.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => usePerimeterVault()); + + await waitFor(() => expect(result.current.unknown).toBe(true), { + timeout: 300, + }); + expect(result.current.loading).toBe(false); + }); + + it('settles on its own bound, not the outer deadline, when blockStateOf hangs', async () => { + holding(7); + mockBlockStateOf.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => usePerimeterVault()); + + await waitFor(() => expect(result.current.unknown).toBe(true), { + timeout: 300, + }); + expect(result.current.loading).toBe(false); + }); + + it('settles on its own bound, not the outer deadline, when an owner code read hangs', async () => { + holding(7); + mockGetCode.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => usePerimeterVault()); + + await waitFor(() => expect(result.current.unknown).toBe(true), { + timeout: 300, + }); + expect(result.current.loading).toBe(false); + }); + + it('de-duplicates ids repeated by a best-effort getActive page', async () => { + // The contract documents getActive as best-effort over a mutating set. A + // repeated id puts the same id twice into one executeExits call, where the + // second pass reverts AlreadyTerminal and takes the batch with it. + holding(7, 7, 8); + + const result = await settled(); + + expect(result.current.exits.map(exit => exit.id)).toEqual(['7', '8']); + }); + + it("reads every party's block state once and marks a clear hold as unblocked", async () => { + holding(7); + + const result = await settled(); + + expect(result.current.exits).toHaveLength(1); + expect(result.current.exits[0].blockedState).toBeUndefined(); + expect(result.current.exits[0].blockedStateUnreadable).toBe(false); + const parties = mockBlockStateOf.mock.calls.map(([, party]) => + (party as string).toLowerCase(), + ); + expect(new Set(parties)).toEqual( + new Set([ACCOUNT.toLowerCase(), RECEIVER.toLowerCase()]), + ); + expect(parties).toHaveLength(2); + }); + + it('marks a hold whose receiver is frozen as frozen', async () => { + holding(7); + mockBlockStateOf.mockImplementation(async (_queue: string, party: string) => + party.toLowerCase() === RECEIVER.toLowerCase() ? 1 : 0, + ); + + const result = await settled(); + + expect(result.current.exits[0].blockedState).toBe(1); + }); + + it('marks a hold whose originator is blacklisted as blacklisted', async () => { + holding(7); + mockGetRequest.mockResolvedValue(request({ originator: RECEIVER })); + mockBlockStateOf.mockImplementation(async (_queue: string, party: string) => + party.toLowerCase() === RECEIVER.toLowerCase() ? 2 : 0, + ); + + const result = await settled(); + + expect(result.current.exits[0].blockedState).toBe(2); + }); + + it('lets a frozen party outrank a blacklisted one on the same hold', async () => { + holding(7); + mockBlockStateOf.mockImplementation(async (_queue: string, party: string) => + party.toLowerCase() === RECEIVER.toLowerCase() ? 1 : 2, + ); + + const result = await settled(); + + expect(result.current.exits[0].blockedState).toBe(1); + }); + + it('reports the vault as unknown when a block-state read fails, and still lists the row', async () => { + holding(7); + mockBlockStateOf.mockRejectedValue(new Error('rpc down')); + + const result = await settled(); + + expect(result.current.exits).toHaveLength(1); + expect(result.current.exits[0].blockedState).toBeUndefined(); + // The row itself must say it could not be checked, not read as Ready: + // the press-time check is a second line of defence, not the only one. + expect(result.current.exits[0].blockedStateUnreadable).toBe(true); + expect(result.current.unknown).toBe(true); + }); + + it('reports the vault as unknown when a block state is outside the known values', async () => { + holding(7); + mockBlockStateOf.mockResolvedValue(3); + + const result = await settled(); + + expect(result.current.exits).toHaveLength(1); + expect(result.current.exits[0].blockedState).toBeUndefined(); + expect(result.current.exits[0].blockedStateUnreadable).toBe(true); + expect(result.current.unknown).toBe(true); + }); + + it('marks a row unreadable only for the party whose read failed, not for the whole hold when another party is confirmed frozen', async () => { + // A confirmed fact about one party is worth keeping even when another + // party's own read failed alongside it. + holding(7); + mockBlockStateOf.mockImplementation( + async (_queue: string, party: string) => { + if (party.toLowerCase() === RECEIVER.toLowerCase()) { + return 1; + } + throw new Error('rpc down'); + }, + ); + + const result = await settled(); + + expect(result.current.exits[0].blockedState).toBe(1); + expect(result.current.exits[0].blockedStateUnreadable).toBe(false); + }); + + it('reports the vault as unknown when a request comes back with the zero status, and still lists the row', async () => { + // The zero status means the queue holds no such request at all — a node + // that did not state the record, not a withdrawal settled with a real + // answer of zero-everything. + holding(7); + mockGetRequest.mockResolvedValue(request({ status: 0 })); + + const result = await settled(); + + expect(result.current.exits).toHaveLength(1); + expect(result.current.exits[0].status).toBe(0); + expect(result.current.unknown).toBe(true); + }); + + it('reports the vault as unknown when a request status is outside the values the queue defines', async () => { + holding(7); + mockGetRequest.mockResolvedValue(request({ status: 9 })); + + const result = await settled(); + + expect(result.current.exits).toHaveLength(1); + expect(result.current.unknown).toBe(true); + }); + + it("lists holds from Zero's queue as well as the protocol's", async () => { + // The two pointers are independently settable. If they diverge, a Zero + // close form promises the holder they can release on this page, and the + // page must not be looking at a different queue. + mockZeroContract.mockResolvedValue({ address: OTHER_BORROWER_OPERATIONS }); + mockReadPointer.mockImplementation(async (_chainId, consumer: string) => + consumer === PROTOCOL ? address(QUEUE) : address(ZERO_QUEUE), + ); + mockGetActive.mockImplementation(async (queueAddress: string) => ({ + ids: [BigNumber.from(queueAddress === QUEUE.toLowerCase() ? 7 : 9)], + nextCursor: BigNumber.from(0), + })); + + const result = await settled(); + + expect(result.current.exits.map(exit => exit.id).sort()).toEqual([ + '7', + '9', + ]); + // Each row carries the queue that holds it: an id is only accepted by its + // own queue contract. + expect(result.current.exits.map(exit => exit.queueAddress).sort()).toEqual([ + QUEUE.toLowerCase(), + ZERO_QUEUE.toLowerCase(), + ]); + }); + + it('keeps listing the queue a consumer pointed at earlier in the tab after the pointer moves', async () => { + // The setters refuse zero but accept a new queue. Requests left in the old + // queue are still held there and still released there. + mockProtocolAddress = MOVING_PROTOCOL; + mockReadPointer.mockImplementation(async (_chainId, consumer: string) => + consumer === MOVING_PROTOCOL ? address(QUEUE) : ABSENT, + ); + mockGetActive.mockImplementation(async (queueAddress: string) => ({ + ids: [BigNumber.from(queueAddress === QUEUE.toLowerCase() ? 7 : 9)], + nextCursor: BigNumber.from(0), + })); + const first = await settled(); + expect(first.current.exits.map(exit => exit.id)).toEqual(['7']); + + mockReadPointer.mockImplementation(async (_chainId, consumer: string) => + consumer === MOVING_PROTOCOL ? address(NEW_QUEUE) : ABSENT, + ); + const second = await settled(); + + expect(second.current.exits.map(exit => exit.queueAddress).sort()).toEqual([ + QUEUE.toLowerCase(), + NEW_QUEUE.toLowerCase(), + ]); + }); + + it('names the asset each amount is denominated in', async () => { + holding(7); + mockGetRequest.mockResolvedValue(request({ token: RUSDT })); + + const result = await settled(); + + expect(result.current.exits[0].tokenSymbol).toBe('RUSDT'); + expect(result.current.exits[0].amount?.toString()).toBe('1.5'); + }); + + it('prints no amount for an asset whose decimals it cannot resolve', async () => { + // Scaling by an assumed 18 would be silently wrong by orders of magnitude + // for a 6- or 8-decimal asset. + holding(7); + mockGetRequest.mockResolvedValue( + request({ token: '0x5555555555555555555555555555555555555555' }), + ); + + const result = await settled(); + + expect(result.current.exits[0].amount).toBeUndefined(); + expect(result.current.exits[0].tokenSymbol).toBeUndefined(); + }); + + it('flags a request whose recorded owner is a contract', async () => { + holding(7); + mockGetCode.mockResolvedValue('0x6001600101'); + + const result = await settled(); + + expect(result.current.exits[0].ownerHasCode).toBe(true); + }); + + it('flags a request whose recorded owner is a plain wallet as such', async () => { + holding(7); + mockGetCode.mockResolvedValue('0x'); + + const result = await settled(); + + expect(result.current.exits[0].ownerHasCode).toBe(false); + }); + + it('reports the vault as unknown when an owner-code read fails, and still lists the row', async () => { + holding(7); + mockGetCode.mockRejectedValue(transportFailure); + + const result = await settled(); + + expect(result.current.exits).toHaveLength(1); + expect(result.current.exits[0].ownerHasCode).toBeUndefined(); + expect(result.current.unknown).toBe(true); + }); + + it('reads an owner shared by several requests only once', async () => { + holding(7, 8); + mockGetRequest.mockImplementation(async () => request({ owner: ACCOUNT })); + + await settled(); + + expect(mockGetCode).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/usePerimeterVault.ts b/apps/frontend/src/hooks/exitDelay/usePerimeterVault.ts new file mode 100644 index 000000000..f0c879ba1 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/usePerimeterVault.ts @@ -0,0 +1,491 @@ +import { useMemo } from 'react'; + +import { BigNumber, Contract, constants } from 'ethers'; +import { formatUnits } from 'ethers/lib/utils'; + +import { getZeroContract } from '@sovryn/contracts'; +import { getProvider } from '@sovryn/ethers-provider'; +import { Decimal } from '@sovryn/utils'; + +import { RSK_CHAIN_ID } from '../../config/chains'; + +import { asyncCall } from '../../store/rxjs/provider-cache'; +import { findAssetByAddress, findNativeAsset } from '../../utils/asset'; +import { + BlockState, + EXIT_DELAY_TTL, + ExitStatus, + PendingExit, + RELEASE_READ_TIMEOUT_MS, + isStatedExitStatus, +} from '../../utils/exitDelay'; +import { useAccount } from '../useAccount'; +import { useCacheCall } from '../useCacheCall'; +import { useGetProtocolContract } from '../useGetContract'; +import { readDeploymentQueue } from './configuredDeploymentQueue'; +import { boundedBy } from './rawCall'; +import { readPerimeterPointer } from './readPerimeterPointer'; +import { EXIT_DELAY_QUOTE_TIMEOUT_MS, useDeadlinePassed } from './useExitDelay'; + +/** + * What the vault page lists. Every party's block state is read alongside the + * request itself, so a frozen or blacklisted hold reads that way from the + * list rather than only at the press. A block state that could not be read + * marks the whole vault unknown, the same as any other failed read here. + */ +export type PerimeterVault = { + exits: PendingExit[]; + /** Whether releases are paused, per queue address (lower-cased). */ + pausedByQueue: Record; + /** True while any queue holding this account's exits is paused. */ + paused: boolean; + loading: boolean; + /** + * True when the vault could not be read. NOT the same as nothing held: an + * account with queued exits produces exactly the same empty list when a + * round trip fails, and telling someone their funds are not held is the one + * thing this page must never do on a guess. + */ + unknown: boolean; +}; + +type StampedVault = Omit & { forKey: string }; + +const EMPTY: Omit = { + exits: [], + pausedByQueue: {}, + paused: false, + unknown: false, +}; + +const UNREADABLE: Omit = { ...EMPTY, unknown: true }; + +/** No fetch stamps its result with this, so a default value is never "fresh". */ +const PENDING_KEY = ''; + +const QUEUE_ABI = [ + 'function getActive(address party, uint256 cursor, uint256 n) view returns (uint256[] ids, uint256 nextCursor)', + 'function getRequest(uint256 id) view returns (tuple(uint128 amount, uint64 createdAt, uint64 unlockAt, address originator, address owner, address receiver, address token, bytes32 surfaceId, address subProduct, uint8 status, bool unwrapOnDelivery))', + 'function securityPerimeterPaused() view returns (bool)', + 'function blockStateOf(address a) view returns (uint8)', +]; + +type ActiveExitPage = { ids: BigNumber[]; nextCursor: BigNumber }; + +type QueueRequest = { + amount: BigNumber; + createdAt: BigNumber; + unlockAt: BigNumber; + originator: string; + owner: string; + receiver: string; + token: string; + surfaceId: string; + subProduct: string; + status: number; + unwrapOnDelivery: boolean; +}; + +/** Whether a raw `blockStateOf` answer is one of the values the queue defines. */ +const isKnownBlockState = (state: number): state is BlockState => + state === BlockState.None || + state === BlockState.Frozen || + state === BlockState.Blacklisted; + +/** + * The block state of each given address, deduplicated. An address whose read + * did not complete, or whose answer is outside the known values, maps to + * undefined, never to "clear", and marks the result unknown. + */ +const resolveBlockStates = async ( + queue: Contract, + addresses: string[], +): Promise<{ + states: Map; + unknown: boolean; +}> => { + const unique = [...new Set(addresses.map(a => a.toLowerCase()))]; + let unknown = false; + const entries = await Promise.all( + unique.map(async (address): Promise<[string, BlockState | undefined]> => { + try { + const raw = Number( + await boundedBy(queue.blockStateOf(address), RELEASE_READ_TIMEOUT_MS), + ); + if (!isKnownBlockState(raw)) { + unknown = true; + return [address, undefined]; + } + return [address, raw]; + } catch (error) { + unknown = true; + return [address, undefined]; + } + }), + ); + return { states: new Map(entries), unknown }; +}; + +/** + * A frozen party wins over a blacklisted one. When neither is stated and at + * least one party's own read did not complete, the row is unreadable rather + * than silently counted as clear. + */ +const blockedStateOf = ( + parties: string[], + states: Map, +): { state: BlockState | undefined; unreadable: boolean } => { + const read = parties.map(party => states.get(party.toLowerCase())); + if (read.includes(BlockState.Frozen)) { + return { state: BlockState.Frozen, unreadable: false }; + } + if (read.includes(BlockState.Blacklisted)) { + return { state: BlockState.Blacklisted, unreadable: false }; + } + return { + state: undefined, + unreadable: read.some(state => state === undefined), + }; +}; + +/** The contract clamps a page to MAX_GET_ACTIVE_PAGE; asking for more wastes a round trip. */ +const PAGE = 500; +/** + * A hard stop on paging. `getActive` is best-effort over a mutating set, so a + * concurrent removal can repeat an entry; without a cap a pathological cursor + * sequence could spin. Fifty pages is far beyond any real holder's queue. + */ +const MAX_PAGES = 50; + +/** + * Every queue address each consumer has resolved to, remembered for the life + * of the tab. + * + * The consumers' setters refuse the zero address but accept a new queue, and + * requests left in the old queue are still held and still released there. So + * a queue seen once stays listed after the pointer moves on. Deliberately in + * memory only — a persisted address would be an attacker-supplied contract for + * the release button to call after someone edited local storage. + */ +const knownQueues = new Map>(); + +/** + * Follow one consumer's queue pointer. + * + * A getter the consumer does not have is a completed read with no queue; a + * read that did not complete is reported so the page can say it could not + * read rather than that nothing is held. + */ +const resolveQueues = async ( + consumerAddress: string, +): Promise<{ addresses: string[]; unknown: boolean }> => { + const memoKey = `${RSK_CHAIN_ID}/${consumerAddress.toLowerCase()}`; + const remembered = knownQueues.get(memoKey) ?? new Set(); + const pointer = await readPerimeterPointer( + RSK_CHAIN_ID, + consumerAddress, + 'exitDelayQueue', + ); + if (pointer.kind === 'address' && pointer.address !== constants.AddressZero) { + remembered.add(pointer.address.toLowerCase()); + knownQueues.set(memoKey, remembered); + } + return { + addresses: [...remembered], + unknown: pointer.kind === 'unreadable', + }; +}; + +/** + * Whether each of the given owner addresses carries code, deduplicated so an + * owner shared by several requests is only read once. An owner whose code + * read did not complete maps to undefined, never to "a plain wallet", and + * marks the result unknown, the same as the vault's other reads. + */ +const resolveOwnerCode = async ( + owners: string[], +): Promise<{ + codes: Map; + unknown: boolean; +}> => { + const provider = getProvider(RSK_CHAIN_ID); + const uniqueOwners = [...new Set(owners.map(owner => owner.toLowerCase()))]; + let unknown = false; + const entries = await Promise.all( + uniqueOwners.map(async (owner): Promise<[string, boolean | undefined]> => { + try { + const code = await asyncCall( + `exitDelay/ownerCode/${RSK_CHAIN_ID}/${owner}`, + () => boundedBy(provider.getCode(owner), RELEASE_READ_TIMEOUT_MS), + { ttl: EXIT_DELAY_TTL }, + ); + return [owner, code !== '0x']; + } catch (error) { + unknown = true; + return [owner, undefined]; + } + }), + ); + return { codes: new Map(entries), unknown }; +}; + +/** + * Resolve the asset an escrowed amount is denominated in. + * + * One queue holds every asset the perimeter covers, so a bare number is + * unreadable: 1.5 RBTC and 1.5 DOC are three orders of magnitude apart. The + * amount is scaled by the token's OWN decimals rather than the 18 the rest of + * the app assumes, and an asset we cannot resolve yields no amount at all — a + * number scaled by a guess would be silently wrong by orders of magnitude, + * which is worse than an obvious gap. + */ +export const resolveAmount = ( + token: string, + unwrapOnDelivery: boolean, + amount: BigNumber, +): { amount?: Decimal; tokenSymbol?: string } => { + const native = findNativeAsset(RSK_CHAIN_ID); + const escrowed = + token === constants.AddressZero + ? native + : findAssetByAddress(token, RSK_CHAIN_ID); + if (!escrowed) { + return {}; + } + // The queue unwraps on the way out, so the holder receives the native asset + // even though the wrapped one is what is escrowed. + const delivered = unwrapOnDelivery ? native ?? escrowed : escrowed; + return { + amount: Decimal.from(formatUnits(amount, escrowed.decimals)), + tokenSymbol: delivered.symbol, + }; +}; + +/** + * Every exit the perimeter currently holds for the connected account, with the + * state each one is in. + * + * The queues are reached through the consumers' own pointers, the same route + * the consumer contracts take. Zero keeps its pointer separately from the + * lending protocol's and the two are independently settable, so BOTH are + * followed and the results unioned: a hold promised by one surface's form can + * never be missing from the page that releases it. + * + * `getActive` is indexed by PARTY and an account is a party to an exit as + * originator, owner, or receiver, so this returns every exit the account can + * see — including one where the account is only the receiver, which the + * queue also lets that account deliver itself. + */ +export const usePerimeterVault = (): PerimeterVault => { + const { account } = useAccount(); + const protocol = useGetProtocolContract('protocol', RSK_CHAIN_ID); + + const key = `exitDelay/vault/${account}/${protocol?.address}`; + + const { value, loading } = useCacheCall( + key, + RSK_CHAIN_ID, + async () => { + if (!account) { + return { ...EMPTY, forKey: key }; + } + if (!protocol) { + // The contracts have not loaded yet. Nothing has been read, so nothing + // may be stated: stamp it so the hook keeps reporting "loading". + return { ...EMPTY, forKey: PENDING_KEY }; + } + try { + const consumers = [protocol.address]; + let zeroUnresolved = false; + try { + const { address } = await getZeroContract( + 'borrowerOperations', + RSK_CHAIN_ID, + ); + consumers.push(address); + } catch (error) { + // Zero's queue cannot be followed without its address, so whatever + // it holds is unread; the protocol's queue is still worth listing. + zeroUnresolved = true; + } + + const resolved = await Promise.all(consumers.map(resolveQueues)); + const deployment = await readDeploymentQueue(RSK_CHAIN_ID); + const pointerUnknown = + zeroUnresolved || + resolved.some(entry => entry.unknown) || + deployment.kind === 'unreadable'; + const queueAddresses = [ + ...new Set([ + ...resolved.flatMap(entry => entry.addresses), + ...(deployment.kind === 'known' ? [deployment.address] : []), + ]), + ]; + + if (queueAddresses.length === 0) { + return { + ...EMPTY, + unknown: pointerUnknown, + forKey: key, + }; + } + + const exits: PendingExit[] = []; + const pausedByQueue: Record = {}; + let blockStateUnknown = false; + let requestStatusUnknown = false; + let ownerCodeUnknown = false; + let paginationUnknown = false; + + for (const queueAddress of queueAddresses) { + const queue = new Contract( + queueAddress, + QUEUE_ABI, + getProvider(RSK_CHAIN_ID), + ); + + const ids: string[] = []; + let cursor = BigNumber.from(0); + for (let page = 0; page < MAX_PAGES; page++) { + const result = await boundedBy( + queue.getActive(account, cursor, PAGE), + RELEASE_READ_TIMEOUT_MS, + ); + ids.push(...result.ids.map((id: BigNumber) => id.toString())); + cursor = result.nextCursor; + if (cursor.isZero()) { + break; + } + } + // A nonzero cursor means more holds remain beyond the read cap. + // Keep the rows read so far without presenting a complete list. + paginationUnknown = paginationUnknown || !cursor.isZero(); + + // `getActive` is documented as best-effort over a mutating set: a + // concurrent removal can repeat an id. Two rows sharing a React key + // is the visible symptom; the dangerous one is executeExits([7, 7]), + // where the second pass reverts AlreadyTerminal and takes every other + // release in the batch with it. + const uniqueIds = [...new Set(ids)]; + + pausedByQueue[queueAddress] = await boundedBy( + queue.securityPerimeterPaused(), + RELEASE_READ_TIMEOUT_MS, + ); + + // One wave of requests rather than one round trip per id: the read + // runs every block. + const requests = await Promise.all( + uniqueIds.map(id => + boundedBy( + queue.getRequest(id), + RELEASE_READ_TIMEOUT_MS, + ), + ), + ); + + const { codes: ownerCode, unknown: theseOwnerCodesUnknown } = + await resolveOwnerCode(requests.map(request => request.owner)); + ownerCodeUnknown = ownerCodeUnknown || theseOwnerCodesUnknown; + const { states: blockStates, unknown: theseBlockStatesUnknown } = + await resolveBlockStates( + queue, + requests.flatMap(request => [ + request.originator, + request.owner, + request.receiver, + ]), + ); + blockStateUnknown = blockStateUnknown || theseBlockStatesUnknown; + + // The zero value means the queue holds no such request at all, and + // a value outside the five it defines is no more stated than that + // — either way the row is kept and the vault marked unknown, the + // same as any other read that did not complete, rather than shown + // as whatever the fallback status would make of it. + const theseStatusesUnknown = requests.some( + request => !isStatedExitStatus(Number(request.status)), + ); + requestStatusUnknown = requestStatusUnknown || theseStatusesUnknown; + + uniqueIds.forEach((id, index) => { + const request = requests[index]; + const blocked = blockedStateOf( + [request.originator, request.owner, request.receiver], + blockStates, + ); + exits.push({ + id, + queueAddress, + ...resolveAmount( + request.token, + request.unwrapOnDelivery, + request.amount, + ), + token: request.token, + createdAt: Number(request.createdAt), + unlockAt: Number(request.unlockAt), + originator: request.originator, + owner: request.owner, + receiver: request.receiver, + surfaceId: request.surfaceId, + subProduct: request.subProduct, + status: Number(request.status) as ExitStatus, + unwrapOnDelivery: request.unwrapOnDelivery, + ownerHasCode: ownerCode.get(request.owner.toLowerCase()), + blockedState: blocked.state, + blockedStateUnreadable: blocked.unreadable, + }); + }); + } + + // Soonest to unlock first: the row a holder is waiting on is the one + // worth putting at the top. + exits.sort((a, b) => a.unlockAt - b.unlockAt); + + return { + exits, + pausedByQueue, + paused: Object.values(pausedByQueue).some(Boolean), + unknown: + pointerUnknown || + paginationUnknown || + blockStateUnknown || + requestStatusUnknown || + ownerCodeUnknown, + forKey: key, + }; + } catch (error) { + // A timeout, a rate limit, a malformed response. Whatever it was, this + // account's holds were not read, and the page must say so rather than + // print a definitive negative. + return { ...UNREADABLE, forKey: key }; + } + }, + [protocol?.address, account], + { ...EMPTY, forKey: PENDING_KEY }, + { ttl: EXIT_DELAY_TTL }, + ); + + // A value stamped for another key belongs to the previous account, or to no + // fetch at all — the seeded default the cache hands back before the first + // attempt resolves, including one that never starts because the shared + // cache is still waiting on a block number. Past this deadline a value + // still not fresh is reported unknown rather than left loading for ever. + const fresh = value.forKey === key; + const deadlinePassed = useDeadlinePassed( + key, + fresh, + EXIT_DELAY_QUOTE_TIMEOUT_MS, + ); + + return useMemo(() => { + if (!fresh) { + return deadlinePassed + ? { ...UNREADABLE, loading: false } + : { ...EMPTY, loading: true }; + } + const { forKey, ...vault } = value; + return { ...vault, loading }; + }, [value, loading, fresh, deadlinePassed]); +}; diff --git a/apps/frontend/src/hooks/exitDelay/useZeroExitDelayQuote.test.ts b/apps/frontend/src/hooks/exitDelay/useZeroExitDelayQuote.test.ts new file mode 100644 index 000000000..d9dc6f8e7 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useZeroExitDelayQuote.test.ts @@ -0,0 +1,113 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { getExitDelayDisplay } from '../../utils/exitDelay'; +import { + SURFACE_ZERO_CLAIM_SURPLUS, + SURFACE_ZERO_WITHDRAW_COLL, +} from '../../utils/exitFee'; +import { useZeroExitDelayQuote } from './useZeroExitDelayQuote'; + +/** + * Zero keeps its own pointers on BorrowerOperations, independently settable + * from the lending protocol's, so its withdrawals are quoted through Zero. How + * the chain's answers are classified is pinned in quoteExitDelay.test.ts; this + * file runs the hook on the real shared cache and pins the route. + */ + +const BORROWER_OPERATIONS = '0x5B9dB4B8bdeF3e57323187a9AC2639C5DEe5FD39'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +let mockAccount = ''; + +const mockZeroContract = jest.fn(); +const mockQuoteExitDelay = jest.fn(); + +jest.mock('./quoteExitDelay', () => ({ + quoteExitDelay: (...args: unknown[]) => mockQuoteExitDelay(...args), +})); + +jest.mock('@sovryn/contracts', () => ({ + ...jest.requireActual('@sovryn/contracts'), + getZeroContract: (...args: unknown[]) => mockZeroContract(...args), +})); + +jest.mock('@sovryn/ethers-provider', () => ({ + ...jest.requireActual('@sovryn/ethers-provider'), + getProvider: () => ({ getBlockNumber: () => Promise.resolve(100) }), +})); + +jest.mock('../../utils/chain', () => ({ + getRskChainId: () => '0x1e', +})); + +jest.mock('../useAccount', () => ({ + useAccount: () => ({ account: mockAccount }), +})); + +// The shared cache is module-global: each test quotes for its own account. +let nextAccount = 1; +const freshAccount = () => + `0x${(nextAccount++).toString(16).padStart(40, '0')}`; + +describe('useZeroExitDelayQuote', () => { + beforeEach(() => { + mockAccount = freshAccount(); + mockZeroContract.mockResolvedValue({ address: BORROWER_OPERATIONS }); + }); + + it("quotes Zero's collateral surface through Zero's own pointers", async () => { + mockQuoteExitDelay.mockResolvedValue({ + delaySeconds: 3600, + unknown: false, + }); + + const { result } = renderHook(() => useZeroExitDelayQuote()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockZeroContract).toHaveBeenCalledWith('borrowerOperations', '0x1e'); + expect(mockQuoteExitDelay).toHaveBeenCalledWith({ + chainId: '0x1e', + consumerAddress: BORROWER_OPERATIONS, + account: mockAccount, + surfaceId: SURFACE_ZERO_WITHDRAW_COLL, + subProduct: ZERO_ADDRESS, + }); + expect(getExitDelayDisplay(result.current)).toBe('held'); + }); + + it("quotes the surplus claim's own surface through BorrowerOperations when asked", async () => { + mockQuoteExitDelay.mockResolvedValue({ delaySeconds: 0, unknown: false }); + + const { result } = renderHook(() => + useZeroExitDelayQuote(SURFACE_ZERO_CLAIM_SURPLUS), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockQuoteExitDelay).toHaveBeenCalledWith( + expect.objectContaining({ + consumerAddress: BORROWER_OPERATIONS, + surfaceId: SURFACE_ZERO_CLAIM_SURPLUS, + }), + ); + }); + + it('reports checking until BorrowerOperations has been resolved', async () => { + mockZeroContract.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => useZeroExitDelayQuote()); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(getExitDelayDisplay(result.current)).toBe('checking'); + expect(mockQuoteExitDelay).not.toHaveBeenCalled(); + }); + + it('reports unknown when BorrowerOperations cannot be resolved', async () => { + mockZeroContract.mockRejectedValue(new Error('no artifact')); + + const { result } = renderHook(() => useZeroExitDelayQuote()); + + await waitFor(() => expect(result.current.unknown).toBe(true)); + expect(result.current.loading).toBe(false); + expect(mockQuoteExitDelay).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/hooks/exitDelay/useZeroExitDelayQuote.ts b/apps/frontend/src/hooks/exitDelay/useZeroExitDelayQuote.ts new file mode 100644 index 000000000..3949ec9b2 --- /dev/null +++ b/apps/frontend/src/hooks/exitDelay/useZeroExitDelayQuote.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; + +import { constants } from 'ethers'; + +import { getZeroContract } from '@sovryn/contracts'; +import { ChainId } from '@sovryn/ethers-provider'; + +import { getRskChainId } from '../../utils/chain'; +import { ExitDelayQuote } from '../../utils/exitDelay'; +import { SURFACE_ZERO_WITHDRAW_COLL } from '../../utils/exitFee'; +import { useAccount } from '../useAccount'; +import { useIsMounted } from '../useIsMounted'; +import { useExitDelay } from './useExitDelay'; + +export type ZeroExitDelayQuote = ExitDelayQuote; + +type BorrowerOperations = { address?: string; unresolvable: boolean }; + +/** + * How long the perimeter would hold a withdrawal from a Zero surface. + * + * Zero keeps its own controller and queue pointers on BorrowerOperations + * rather than reading the lending protocol's, so this resolves through Zero — + * the same route the exit itself takes. Until BorrowerOperations' address is + * known the quote is loading; if it cannot be resolved at all the quote is + * unreadable, because Zero's exit hook fails closed too. + * + * Zero has no passthrough: the borrower is the originator, the position owner + * and the payout receiver, so all three identity arguments are this account. + * `subProduct` is the zero address — there is a single Zero deployment. + */ +export const useZeroExitDelayQuote = ( + surfaceId: string = SURFACE_ZERO_WITHDRAW_COLL, +): ZeroExitDelayQuote => { + const { account } = useAccount(); + const chainId = getRskChainId() as ChainId; + const isMounted = useIsMounted(); + const [borrowerOperations, setBorrowerOperations] = + useState({ unresolvable: false }); + + useEffect(() => { + getZeroContract('borrowerOperations', chainId) + .then(({ address }) => { + if (isMounted()) { + setBorrowerOperations({ address, unresolvable: false }); + } + }) + .catch(() => { + if (isMounted()) { + setBorrowerOperations({ unresolvable: true }); + } + }); + }, [chainId, isMounted]); + + return useExitDelay({ + chainId, + consumerAddress: borrowerOperations.address, + consumerUnresolvable: borrowerOperations.unresolvable, + surfaceId, + subProduct: constants.AddressZero, + account, + }); +}; diff --git a/apps/frontend/src/hooks/exitFee/useExitFeeRate.test.ts b/apps/frontend/src/hooks/exitFee/useExitFeeRate.test.ts new file mode 100644 index 000000000..5063fe448 --- /dev/null +++ b/apps/frontend/src/hooks/exitFee/useExitFeeRate.test.ts @@ -0,0 +1,167 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { getExitFeeDisplay } from '../../utils/exitFee'; +import { useExitFeeRate } from './useExitFeeRate'; + +/** + * The fee fails OPEN on chain, so every consumer hides the rows unless a quote + * arrived saying a fee is charged. The hook still reports which of "no fee" + * and "not read" happened: a controller getter the protocol does not have is a + * stated "no fee", and a pointer read that did not complete is unknown. + */ + +const ACCOUNT = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; +const PROTOCOL = '0x5A0D867e0D70Fcc6Ade25C3F1B89d618b5B4Eaa7'; +const CONTROLLER = '0x99994b4522483DE17F31a5bC010c5901AdD3440E'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const SURFACE = + '0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe'; +const SUB_PRODUCT = '0x0000000000000000000000000000000000000123'; + +const mockReadPointer = jest.fn(); +const mockQuoteExitFee = jest.fn(); + +jest.mock('../exitDelay/readPerimeterPointer', () => ({ + readPerimeterPointer: (...args: unknown[]) => mockReadPointer(...args), +})); + +jest.mock('ethers', () => { + const actual = jest.requireActual('ethers'); + return { + ...actual, + Contract: function () { + return { + functions: { + quoteExitFee: (...args: unknown[]) => mockQuoteExitFee(...args), + }, + }; + }, + }; +}); + +jest.mock('@sovryn/ethers-provider', () => ({ + getProvider: () => ({}), +})); + +jest.mock('../../config/chains', () => ({ + RSK_CHAIN_ID: '0x1e', +})); + +jest.mock('../useAccount', () => ({ + useAccount: () => ({ account: ACCOUNT }), +})); + +jest.mock('../useGetContract', () => ({ + useGetProtocolContract: () => ({ address: PROTOCOL }), +})); + +// Short enough that the deadline test below does not wait ten real seconds. +jest.mock('../exitDelay/useExitDelay', () => ({ + ...jest.requireActual('../exitDelay/useExitDelay'), + EXIT_DELAY_QUOTE_TIMEOUT_MS: 50, +})); + +jest.mock('../useCacheCall', () => { + const React = jest.requireActual('react'); + return { + useCacheCall: ( + _key: string, + _chainId: string, + fn: () => Promise, + _deps: unknown[], + defaultValue: unknown, + ) => { + const [state, setState] = React.useState({ + value: defaultValue, + // Idle at first, as the shared cache is: a result still to come must + // be reported by the hook under test, not by this stand-in. + loading: false, + }); + // Holds the latest fn without making the mount effect below re-run: + // fn's identity changes every render, but this mock fetches once. + const fnRef = React.useRef(fn); + fnRef.current = fn; + React.useEffect(() => { + let alive = true; + Promise.resolve(fnRef.current()).then((value: unknown) => { + if (alive) setState({ value, loading: false }); + }); + return () => { + alive = false; + }; + }, []); + return state; + }, + }; +}); + +const render = () => renderHook(() => useExitFeeRate(SURFACE, SUB_PRODUCT)); + +describe('useExitFeeRate', () => { + it("reads the lending protocol's controller pointer and quotes the rate", async () => { + mockReadPointer.mockResolvedValue({ kind: 'address', address: CONTROLLER }); + mockQuoteExitFee.mockResolvedValue([{ active: true, rateBps: 10 }]); + + const { result } = render(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockReadPointer).toHaveBeenCalledWith( + '0x1e', + PROTOCOL, + 'exitFeeController', + ); + expect(result.current.active).toBe(true); + expect(result.current.rateBps).toBe(10); + expect(result.current.unknown).toBe(false); + }); + + it('reports a stated "no fee" when the protocol has no controller getter', async () => { + mockReadPointer.mockResolvedValue({ kind: 'absent' }); + + const { result } = render(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.active).toBe(false); + expect(result.current.unknown).toBe(false); + expect(mockQuoteExitFee).not.toHaveBeenCalled(); + }); + + it('reports a stated "no fee" when no controller is pinned', async () => { + mockReadPointer.mockResolvedValue({ + kind: 'address', + address: ZERO_ADDRESS, + }); + + const { result } = render(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.unknown).toBe(false); + expect(mockQuoteExitFee).not.toHaveBeenCalled(); + }); + + it('reports unknown when the controller pointer could not be read, and still hides the rows', async () => { + mockReadPointer.mockResolvedValue({ kind: 'unreadable' }); + + const { result } = render(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.unknown).toBe(true); + expect(mockQuoteExitFee).not.toHaveBeenCalled(); + expect(getExitFeeDisplay(result.current, { gt: () => true } as never)).toBe( + 'none', + ); + }); + + it('reports unknown, not still loading, once its deadline passes without an answer', async () => { + // A stalled node must not hold Confirm forever: past the deadline the rate + // is reported unreadable, the same as the two Zero fee hooks already do. + mockReadPointer.mockResolvedValue({ kind: 'address', address: CONTROLLER }); + mockQuoteExitFee.mockReturnValue(new Promise(() => undefined)); + + const { result } = render(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.unknown).toBe(true); + expect(result.current.active).toBe(false); + }); +}); diff --git a/apps/frontend/src/hooks/exitFee/useExitFeeRate.ts b/apps/frontend/src/hooks/exitFee/useExitFeeRate.ts index 0cd50835f..ca216b905 100644 --- a/apps/frontend/src/hooks/exitFee/useExitFeeRate.ts +++ b/apps/frontend/src/hooks/exitFee/useExitFeeRate.ts @@ -6,12 +6,16 @@ import { getProvider } from '@sovryn/ethers-provider'; import { RSK_CHAIN_ID } from '../../config/chains'; -import { asyncCall } from '../../store/rxjs/provider-cache'; import { EXIT_FEE_REFERENCE_GROSS, EXIT_FEE_TTL, ExitFeeQuote, } from '../../utils/exitFee'; +import { readPerimeterPointer } from '../exitDelay/readPerimeterPointer'; +import { + EXIT_DELAY_QUOTE_TIMEOUT_MS, + useDeadlinePassed, +} from '../exitDelay/useExitDelay'; import { useAccount } from '../useAccount'; import { useCacheCall } from '../useCacheCall'; import { useGetProtocolContract } from '../useGetContract'; @@ -45,10 +49,6 @@ const stamped = async ( */ const UNCHARGED = { active: false, rateBps: 0, unknown: false }; -const CONTROLLER_GETTER_ABI = [ - 'function exitFeeController() view returns (address)', -]; - const CONTROLLER_ABI = [ 'function quoteExitFee(bytes32 surfaceId, address subProduct, address actor, uint256 grossAmount) view returns (tuple(bool active, uint16 rateBps, uint256 feeAmount, uint256 netAmount, address feeReceiver, uint8 reason))', ]; @@ -79,45 +79,31 @@ export const useExitFeeRate = ( return UNCHARGED; } - const getter = new Contract( + // The pointer is cached for a TTL under a key with no block + // dimension, and a refetch only runs on the next observed block, so + // at the moment the Owner pins the controller an open client can + // report "no fee" for up to one TTL plus one block (about 60 s on + // RSK) while the chain has already started charging. The window is + // bounded and one-time, tied to a single Owner action. + const pointer = await readPerimeterPointer( + RSK_CHAIN_ID, protocol.address, - CONTROLLER_GETTER_ABI, - getProvider(RSK_CHAIN_ID), + 'exitFeeController', ); - - let controllerAddress; - try { - // Negative-cached too: while undeployed this reverts and we cache the - // uncharged answer. - // - // Known and accepted: the pointer is cached for EXIT_FEE_TTL under a - // key with no block dimension, and a refetch only runs on the next - // observed block, so at the moment governance pins the controller an - // open client can report "no fee" for up to one TTL plus one block - // (about 60 s on RSK) while the chain has started charging. Bounded, - // one-time, and covered by the release order: the dapp ships before - // charging is enabled, never after. Closing it properly means block- - // based invalidation in the shared cache, a wider change than this - // window justifies. - controllerAddress = await asyncCall( - `exitFee/controllerAddress/${RSK_CHAIN_ID}/${protocol.address}`, - () => getter.exitFeeController(), - { ttl: EXIT_FEE_TTL }, - ); - } catch (error) { - // The getter itself is missing or reverts, which is what a protocol - // without the perimeter looks like. Nothing is charged there, and the - // spec requires the forms to look exactly as they do today -- so this - // is a real answer of "no fee", not a failure to obtain one. It is - // also the state of mainnet until the activation SIPs execute. - return UNCHARGED; + if (pointer.kind === 'unreadable') { + // The node gave no answer. The rows stay hidden, but this is not a + // stated "no fee": the chain may well be charging. + return UNKNOWN; } - - if (!controllerAddress || controllerAddress === constants.AddressZero) { - // Perimeter deployed but not yet pinned: charges nothing by - // construction. + if ( + pointer.kind === 'absent' || + pointer.address === constants.AddressZero + ) { + // No controller getter, or no controller pinned: the protocol + // charges nothing by construction, a real answer of "no fee". return UNCHARGED; } + const controllerAddress = pointer.address; try { const controller = new Contract( @@ -149,16 +135,27 @@ export const useExitFeeRate = ( { ttl: EXIT_FEE_TTL }, ); + // A rate that has not arrived within the same deadline the delay quote is + // given is reported as unreadable, so a form waiting on it can let the user + // sign with the warning rather than wait on a stalled read. + const fresh = value.forKey === key; + const passed = useDeadlinePassed( + key, + fresh && !loading, + EXIT_DELAY_QUOTE_TIMEOUT_MS, + ); return useMemo(() => { // A value fetched for a different key is the previous account's or // pool's answer. Report it as still loading, which the display treats // as "nothing charged" — never as that other party's fee. - const fresh = value.forKey === key; + if (!fresh && passed) { + return { active: false, rateBps: 0, unknown: true, loading: false }; + } return { active: fresh ? value.active : false, rateBps: fresh ? value.rateBps : 0, unknown: fresh ? value.unknown : true, loading: loading || !fresh, }; - }, [value, loading, key]); + }, [value, loading, fresh, passed]); }; diff --git a/apps/frontend/src/hooks/exitFee/useZeroClaimExitFee.test.ts b/apps/frontend/src/hooks/exitFee/useZeroClaimExitFee.test.ts new file mode 100644 index 000000000..0d9ca2e2a --- /dev/null +++ b/apps/frontend/src/hooks/exitFee/useZeroClaimExitFee.test.ts @@ -0,0 +1,248 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { BigNumber, constants } from 'ethers'; + +import { Decimal } from '@sovryn/utils'; + +import { SURFACE_ZERO_CLAIM_SURPLUS } from '../../utils/exitFee'; +import { + JsonRpcStub, + captureCallFailure, + startJsonRpcStub, +} from '../../utils/testing/jsonRpcStub'; +import { useZeroClaimExitFee } from './useZeroClaimExitFee'; + +/** + * The surplus claim is charged by Zero's BorrowerOperations, which keeps its + * own controller pointer, independently settable from the lending protocol's. + * So the claim's fee is quoted through that pointer, for the claim surface and + * the actual surplus: the fee and net shown are the controller's own figures + * for this claim. The fee fails OPEN on chain, so every answer but a charged + * quote hides the rows; the hook still reports "no fee" apart from "not read". + */ + +const ACCOUNT = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; +const BORROWER_OPERATIONS = '0x5B9dB4B8bdeF3e57323187a9AC2639C5DEe5FD39'; +const CONTROLLER = '0x99994b4522483DE17F31a5bC010c5901AdD3440E'; +const FEE_RECEIVER = '0xDDE75f75ff33Aa802f2316cCAe2bE77823fc6f9B'; +const SURPLUS_WEI = '400000000000000000'; + +const mockReadPointer = jest.fn(); +const mockQuoteExitFee = jest.fn(); +const mockZeroContract = jest.fn(); + +jest.mock('../exitDelay/readPerimeterPointer', () => ({ + readPerimeterPointer: (...args: unknown[]) => mockReadPointer(...args), +})); + +jest.mock('ethers', () => { + const actual = jest.requireActual('ethers'); + return { + ...actual, + Contract: function (address: string) { + return { + quoteExitFee: (...args: unknown[]) => + mockQuoteExitFee(address, ...args), + }; + }, + }; +}); + +jest.mock('@sovryn/contracts', () => ({ + getZeroContract: (...args: unknown[]) => mockZeroContract(...args), +})); + +jest.mock('@sovryn/ethers-provider', () => ({ + getProvider: () => ({}), +})); + +jest.mock('../../utils/chain', () => ({ + getRskChainId: () => '0x1e', +})); + +jest.mock('../useAccount', () => ({ + useAccount: () => ({ account: ACCOUNT }), +})); + +// Short enough that the deadline test below does not wait ten real seconds. +jest.mock('../exitDelay/useExitDelay', () => ({ + ...jest.requireActual('../exitDelay/useExitDelay'), + EXIT_DELAY_QUOTE_TIMEOUT_MS: 50, +})); + +jest.mock('../useCacheCall', () => { + const React = jest.requireActual('react'); + return { + useCacheCall: ( + _key: string, + _chainId: string, + fn: () => Promise, + _deps: unknown[], + defaultValue: unknown, + ) => { + const [state, setState] = React.useState({ + value: defaultValue, + // Idle at first, as the shared cache is: a result still to come must + // be reported by the hook under test, not by this stand-in. + loading: false, + }); + // Holds the latest fn without making the mount effect below re-run: + // fn's identity changes every render, but this mock fetches once. + const fnRef = React.useRef(fn); + fnRef.current = fn; + React.useEffect(() => { + let alive = true; + Promise.resolve(fnRef.current()).then((value: unknown) => { + if (alive) setState({ value, loading: false }); + }); + return () => { + alive = false; + }; + }, []); + return state; + }, + }; +}); + +/** A controller quote in the contract's own shape. */ +const quote = (overrides: Record = {}) => ({ + active: true, + rateBps: 10, + feeAmount: BigNumber.from('400000000000000'), + netAmount: BigNumber.from('399600000000000000'), + feeReceiver: FEE_RECEIVER, + reason: 0, + ...overrides, +}); + +const SURPLUS = Decimal.from('0.4'); + +const settled = async (gross: Decimal = SURPLUS) => { + const hook = renderHook(() => useZeroClaimExitFee(gross)); + await waitFor(() => expect(hook.result.current.loading).toBe(false)); + return hook.result; +}; + +describe('useZeroClaimExitFee', () => { + let stub: JsonRpcStub; + let transportFailure: unknown; + + beforeAll(async () => { + stub = await startJsonRpcStub(); + transportFailure = await captureCallFailure(stub, { + status: 503, + body: 'unavailable', + }); + }); + + afterAll(() => stub.close()); + + beforeEach(() => { + mockZeroContract.mockResolvedValue({ address: BORROWER_OPERATIONS }); + mockReadPointer.mockResolvedValue({ kind: 'address', address: CONTROLLER }); + mockQuoteExitFee.mockResolvedValue(quote()); + }); + + it("quotes the claim through BorrowerOperations' own controller pointer, for the actual surplus", async () => { + const result = await settled(); + + expect(mockReadPointer).toHaveBeenCalledWith( + '0x1e', + BORROWER_OPERATIONS, + 'exitFeeController', + ); + expect(mockQuoteExitFee).toHaveBeenCalledWith( + CONTROLLER, + SURFACE_ZERO_CLAIM_SURPLUS, + constants.AddressZero, + ACCOUNT, + SURPLUS_WEI, + ); + expect(result.current.active).toBe(true); + expect(result.current.rateBps).toBe(10); + expect(result.current.feeAmount.toString()).toBe('0.0004'); + expect(result.current.netAmount.toString()).toBe('0.3996'); + expect(result.current.unknown).toBe(false); + }); + + it('reports a stated "no fee" when BorrowerOperations has no controller getter', async () => { + mockReadPointer.mockResolvedValue({ kind: 'absent' }); + + const result = await settled(); + + expect(result.current.active).toBe(false); + expect(result.current.unknown).toBe(false); + expect(mockQuoteExitFee).not.toHaveBeenCalled(); + }); + + it('reports a stated "no fee" when no controller is pinned', async () => { + mockReadPointer.mockResolvedValue({ + kind: 'address', + address: constants.AddressZero, + }); + + const result = await settled(); + + expect(result.current.active).toBe(false); + expect(result.current.unknown).toBe(false); + expect(mockQuoteExitFee).not.toHaveBeenCalled(); + }); + + it('reports unknown when the controller pointer could not be read', async () => { + mockReadPointer.mockResolvedValue({ kind: 'unreadable' }); + + const result = await settled(); + + expect(result.current.unknown).toBe(true); + expect(mockQuoteExitFee).not.toHaveBeenCalled(); + }); + + it('reports unknown when the quote could not be read', async () => { + mockQuoteExitFee.mockRejectedValue(transportFailure); + + const result = await settled(); + + expect(result.current.active).toBe(false); + expect(result.current.unknown).toBe(true); + }); + + it('reports unknown when BorrowerOperations cannot be resolved', async () => { + mockZeroContract.mockRejectedValue(new Error('no artifact')); + + const result = await settled(); + + expect(result.current.unknown).toBe(true); + expect(mockReadPointer).not.toHaveBeenCalled(); + }); + + it('reports unknown, not a stated no fee, when the net is not the surplus less the fee', async () => { + // The chain re-derives net from gross and fee and charges nothing when + // they disagree, so a quote that fails that check is not a stated + // answer — it is reported the same as a quote the chain could not + // answer at all. + mockQuoteExitFee.mockResolvedValue( + quote({ netAmount: BigNumber.from('390000000000000000') }), + ); + + const result = await settled(); + + expect(result.current.active).toBe(false); + expect(result.current.unknown).toBe(true); + }); + + it('quotes nothing for a zero surplus', async () => { + const result = await settled(Decimal.ZERO); + + expect(result.current.active).toBe(false); + expect(mockQuoteExitFee).not.toHaveBeenCalled(); + }); + + it('reports unknown, not still loading, once its deadline passes without an answer', async () => { + mockQuoteExitFee.mockReturnValue(new Promise(() => undefined)); // never settles + + const result = await settled(); + + expect(result.current.unknown).toBe(true); + expect(result.current.active).toBe(false); + }); +}); diff --git a/apps/frontend/src/hooks/exitFee/useZeroClaimExitFee.ts b/apps/frontend/src/hooks/exitFee/useZeroClaimExitFee.ts new file mode 100644 index 000000000..041612552 --- /dev/null +++ b/apps/frontend/src/hooks/exitFee/useZeroClaimExitFee.ts @@ -0,0 +1,177 @@ +import { useMemo } from 'react'; + +import { BigNumber, Contract, constants } from 'ethers'; + +import { getZeroContract } from '@sovryn/contracts'; +import { ChainId, getProvider } from '@sovryn/ethers-provider'; +import { Decimal } from '@sovryn/utils'; + +import { getRskChainId } from '../../utils/chain'; +import { + EXIT_FEE_MAX_BPS, + EXIT_FEE_TTL, + ExitFeeQuote, + SURFACE_ZERO_CLAIM_SURPLUS, +} from '../../utils/exitFee'; +import { readPerimeterPointer } from '../exitDelay/readPerimeterPointer'; +import { + EXIT_DELAY_QUOTE_TIMEOUT_MS, + useDeadlinePassed, +} from '../exitDelay/useExitDelay'; +import { useAccount } from '../useAccount'; +import { useCacheCall } from '../useCacheCall'; + +export type ZeroClaimExitFee = ExitFeeQuote & { + feeAmount: Decimal; + netAmount: Decimal; +}; + +type ClaimQuote = Omit; + +/** A stated "no fee": nothing is charged, and the rows stay hidden. */ +const INACTIVE: ClaimQuote = { + active: false, + rateBps: 0, + feeAmount: Decimal.ZERO, + netAmount: Decimal.ZERO, + unknown: false, +}; + +/** No quote obtained. The rows stay hidden too, since the fee fails open. */ +const UNKNOWN: ClaimQuote = { ...INACTIVE, unknown: true }; + +const CONTROLLER_ABI = [ + 'function quoteExitFee(bytes32 surfaceId, address subProduct, address actor, uint256 grossAmount) view returns (tuple(bool active, uint16 rateBps, uint256 feeAmount, uint256 netAmount, address feeReceiver, uint8 reason))', +]; + +const quoteClaimFee = async ( + chainId: ChainId, + account: string | undefined, + grossWei: string | null, +): Promise => { + if (grossWei === null) { + // Nothing to claim, so nothing can be charged. + return INACTIVE; + } + if (!account) { + return UNKNOWN; + } + + let borrowerOperations: string; + try { + ({ address: borrowerOperations } = await getZeroContract( + 'borrowerOperations', + chainId, + )); + } catch (error) { + return UNKNOWN; + } + + const pointer = await readPerimeterPointer( + chainId, + borrowerOperations, + 'exitFeeController', + ); + if (pointer.kind === 'unreadable') { + return UNKNOWN; + } + if (pointer.kind === 'absent' || pointer.address === constants.AddressZero) { + return INACTIVE; + } + + try { + const quote = await new Contract( + pointer.address, + CONTROLLER_ABI, + getProvider(chainId), + ).quoteExitFee( + SURFACE_ZERO_CLAIM_SURPLUS, + constants.AddressZero, + account, + grossWei, + ); + // The claim re-derives net from gross and fee on chain and charges + // nothing when they disagree, so a quote that fails that check is not a + // stated answer: a mangled or truncated response from a degraded + // endpoint, or a rate above the maximum, produced it. Reported as + // unread, the same as a quote the node could not answer at all. + const gross = BigNumber.from(grossWei); + if ( + quote.feeAmount.gt(gross) || + !quote.netAmount.eq(gross.sub(quote.feeAmount)) || + Number(quote.rateBps) > EXIT_FEE_MAX_BPS + ) { + return UNKNOWN; + } + return { + active: quote.active, + rateBps: Number(quote.rateBps), + feeAmount: Decimal.fromBigNumberString(quote.feeAmount.toString()), + netAmount: Decimal.fromBigNumberString(quote.netAmount.toString()), + unknown: false, + }; + } catch (error) { + return UNKNOWN; + } +}; + +/** + * The Perimeter fee on claiming a Zero collateral surplus, quoted the way the + * claim is charged. + * + * Zero's BorrowerOperations charges the claim through its own controller + * pointer, independently settable from the lending protocol's, so the quote + * follows that pointer: for the claim surface, the single Zero deployment + * (sub-product zero), this account, and the actual surplus. The fee and net + * are the controller's own figures for this claim; the fee itself is set when + * the claim executes. + * + * The fee fails OPEN on chain, so every answer but a charged quote leaves the + * rows hidden. The hook still reports a stated "no fee" apart from a quote + * that was not obtained (`unknown`), and a value fetched for another account + * or surplus is reported as loading. + */ +export const useZeroClaimExitFee = (gross: Decimal): ZeroClaimExitFee => { + const { account } = useAccount(); + const chainId = getRskChainId() as ChainId; + + const grossWei = useMemo( + () => (gross.isZero() ? null : gross.toBigNumber().toString()), + [gross], + ); + + const key = `exitFee/zeroClaim/${chainId}/${account}/${grossWei}`; + + const { value, loading } = useCacheCall( + key, + chainId, + async () => ({ + ...(await quoteClaimFee(chainId, account, grossWei)), + forKey: key, + }), + [key], + { ...INACTIVE, forKey: '' }, + { ttl: EXIT_FEE_TTL }, + ); + + // A quote that has not arrived within the same deadline the delay quote is + // given is reported as unknown, so the surplus card can let the holder see + // Withdraw rather than wait on a stalled read. + const fresh = value.forKey === key; + const passed = useDeadlinePassed( + key, + fresh && !loading, + EXIT_DELAY_QUOTE_TIMEOUT_MS, + ); + + return useMemo(() => { + if (!fresh && passed) { + return { ...UNKNOWN, loading: false }; + } + if (!fresh) { + return { ...INACTIVE, loading: true }; + } + const { forKey, ...quote } = value; + return { ...quote, loading }; + }, [value, loading, fresh, passed]); +}; diff --git a/apps/frontend/src/hooks/exitFee/useZeroExitFee.test.ts b/apps/frontend/src/hooks/exitFee/useZeroExitFee.test.ts index c48e239f9..558ab8877 100644 --- a/apps/frontend/src/hooks/exitFee/useZeroExitFee.test.ts +++ b/apps/frontend/src/hooks/exitFee/useZeroExitFee.test.ts @@ -33,7 +33,7 @@ const CONTROLLER = '0x99994b4522483DE17F31a5bC010c5901AdD3440E'; const BORROWER_OPERATIONS = '0x5B9dB4B8bdeF3e57323187a9AC2639C5DEe5FD39'; const mockPreview = jest.fn(); -const mockControllerPointer = jest.fn(); +const mockReadPointer = jest.fn(); const mockZeroContract = jest.fn(); // create-react-app's jest preset sets `resetMocks: true`, which strips the @@ -41,17 +41,19 @@ const mockZeroContract = jest.fn(); // must be plain functions that DELEGATE to jest.fn()s wired up in beforeEach — // an implementation attached here would be gone by the time a test runs, and // the hook would fall into its catch on every case. +jest.mock('../exitDelay/readPerimeterPointer', () => ({ + readPerimeterPointer: (...args: unknown[]) => mockReadPointer(...args), +})); + jest.mock('ethers', () => { const actual = jest.requireActual('ethers'); return { ...actual, - Contract: function (_address: string, abi: unknown) { - return JSON.stringify(abi).includes('previewZeroCollWithdrawExitFee') - ? { - previewZeroCollWithdrawExitFee: (...args: unknown[]) => - mockPreview(...args), - } - : { exitFeeController: () => mockControllerPointer() }; + Contract: function () { + return { + previewZeroCollWithdrawExitFee: (...args: unknown[]) => + mockPreview(...args), + }; }, }; }); @@ -68,6 +70,12 @@ jest.mock('../../utils/chain', () => ({ getRskChainId: () => '0x1e', })); +// Short enough that the deadline test below does not wait ten real seconds. +jest.mock('../exitDelay/useExitDelay', () => ({ + ...jest.requireActual('../exitDelay/useExitDelay'), + EXIT_DELAY_QUOTE_TIMEOUT_MS: 50, +})); + jest.mock('../../store/rxjs/provider-cache', () => ({ asyncCall: (_key: string, fn: () => unknown) => fn(), })); @@ -90,11 +98,17 @@ jest.mock('../useCacheCall', () => { ) => { const [state, setState] = React.useState({ value: defaultValue, - loading: true, + // Idle at first, as the shared cache is: a result still to come must + // be reported by the hook under test, not by this stand-in. + loading: false, }); + // Holds the latest fn without making the mount effect below re-run: + // fn's identity changes every render, but this mock fetches once. + const fnRef = React.useRef(fn); + fnRef.current = fn; React.useEffect(() => { let alive = true; - Promise.resolve(fn()).then((value: unknown) => { + Promise.resolve(fnRef.current()).then((value: unknown) => { if (alive) setState({ value, loading: false }); }); return () => { @@ -122,7 +136,46 @@ const previewResult = ( describe('useZeroExitFee', () => { beforeEach(() => { mockZeroContract.mockResolvedValue({ address: BORROWER_OPERATIONS }); - mockControllerPointer.mockResolvedValue(CONTROLLER); + mockReadPointer.mockResolvedValue({ kind: 'address', address: CONTROLLER }); + }); + + it("reads Zero's own controller pointer", async () => { + mockPreview.mockResolvedValue( + previewResult(REASON.NONE, { netAmount: '1000000000000000000' }), + ); + + const { result } = renderHook(() => useZeroExitFee(Decimal.from(1))); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockReadPointer).toHaveBeenCalledWith( + '0x1e', + BORROWER_OPERATIONS, + 'exitFeeController', + ); + }); + + it('reports a stated "no fee" when BorrowerOperations has no controller getter', async () => { + mockReadPointer.mockResolvedValue({ kind: 'absent' }); + + const { result } = renderHook(() => useZeroExitFee(Decimal.from(1))); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.active).toBe(false); + expect(result.current.unknown).toBe(false); + expect(mockPreview).not.toHaveBeenCalled(); + }); + + it('reports unknown when the controller pointer could not be read, and still hides the rows', async () => { + mockReadPointer.mockResolvedValue({ kind: 'unreadable' }); + + const { result } = renderHook(() => useZeroExitFee(Decimal.from(1))); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.unknown).toBe(true); + expect(mockPreview).not.toHaveBeenCalled(); + expect(getExitFeeDisplay(result.current, result.current.feeAmount)).toBe( + 'none', + ); }); it('reports a live charge when the quote resolves', async () => { @@ -143,6 +196,25 @@ describe('useZeroExitFee', () => { expect(result.current.unknown).toBe(false); }); + it('reports unknown, not a stated no fee, when the preview does not add up', async () => { + // The controller derives net from gross and fee on chain; a net that + // does not match that arithmetic is not an answer it would ever give. + mockPreview.mockResolvedValue( + previewResult(REASON.NONE, { + active: true, + rateBps: 10, + feeAmount: '1000000000000000', + netAmount: '1000000000000000000', + }), + ); + + const { result } = renderHook(() => useZeroExitFee(Decimal.from(1))); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.active).toBe(false); + expect(result.current.unknown).toBe(true); + }); + it.each([ ['charging is switched off globally', REASON.INACTIVE], ['the surface carries no policy', REASON.DISABLED], @@ -182,4 +254,14 @@ describe('useZeroExitFee', () => { 'none', ); }); + + it('reports unknown, not still loading, once its deadline passes without an answer', async () => { + mockPreview.mockReturnValue(new Promise(() => undefined)); // never settles + + const { result } = renderHook(() => useZeroExitFee(Decimal.from(1))); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.unknown).toBe(true); + expect(result.current.active).toBe(false); + }); }); diff --git a/apps/frontend/src/hooks/exitFee/useZeroExitFee.ts b/apps/frontend/src/hooks/exitFee/useZeroExitFee.ts index 873843c7f..563e4536b 100644 --- a/apps/frontend/src/hooks/exitFee/useZeroExitFee.ts +++ b/apps/frontend/src/hooks/exitFee/useZeroExitFee.ts @@ -6,7 +6,6 @@ import { getZeroContract } from '@sovryn/contracts'; import { getProvider } from '@sovryn/ethers-provider'; import { Decimal } from '@sovryn/utils'; -import { asyncCall } from '../../store/rxjs/provider-cache'; import { getRskChainId } from '../../utils/chain'; import { EXIT_FEE_MAX_BPS, @@ -14,6 +13,11 @@ import { EXIT_FEE_TTL, ExitFeeQuote, } from '../../utils/exitFee'; +import { readPerimeterPointer } from '../exitDelay/readPerimeterPointer'; +import { + EXIT_DELAY_QUOTE_TIMEOUT_MS, + useDeadlinePassed, +} from '../exitDelay/useExitDelay'; import { useAccount } from '../useAccount'; import { useCacheCall } from '../useCacheCall'; @@ -41,11 +45,7 @@ const stamped = async ( fetch: () => Promise, ) => ({ ...(await fetch()), forKey }); -// The Z-1 kept preview — same _safeQuote path as the live charge hook. -const CONTROLLER_GETTER_ABI = [ - 'function exitFeeController() view returns (address)', -]; - +// The preview runs the same `_safeQuote` path as the live charge. const PREVIEW_ABI = [ 'function previewZeroCollWithdrawExitFee(address borrower, uint256 grossColl) view returns (uint16 rateBps, uint256 feeAmount, uint256 netAmount, address feeReceiver, bool active, uint8 reason)', ]; @@ -82,30 +82,24 @@ export const useZeroExitFee = (gross?: Decimal): ZeroExitFee => { getRskChainId(), ); /** - * Same split as the lending hook. A missing or reverting - * `exitFeeController()` is what Zero looks like before the perimeter - * ships: nothing is charged, and the form must look untouched. Only - * once that pointer resolves does a failing preview mean we genuinely - * do not know the rate. + * Same split as the lending hook. A controller getter + * BorrowerOperations does not have, or no controller pinned, charges + * nothing: a stated "no fee", and the form looks untouched. A pointer + * read that did not complete is unknown. Only once the pointer + * resolves does a failing preview mean we genuinely do not know the + * rate. */ - const pointer = new Contract( + const pointer = await readPerimeterPointer( + getRskChainId(), address, - CONTROLLER_GETTER_ABI, - getProvider(getRskChainId()), + 'exitFeeController', ); - let controllerAddress; - try { - controllerAddress = await asyncCall( - `exitFee/zeroController/${getRskChainId()}/${address}`, - () => pointer.exitFeeController(), - { ttl: EXIT_FEE_TTL }, - ); - } catch (error) { - return INACTIVE; + if (pointer.kind === 'unreadable') { + return { ...INACTIVE, unknown: true }; } if ( - !controllerAddress || - controllerAddress === constants.AddressZero + pointer.kind === 'absent' || + pointer.address === constants.AddressZero ) { return INACTIVE; } @@ -126,18 +120,19 @@ export const useZeroExitFee = (gross?: Decimal): ZeroExitFee => { // nothing and pays the gross. So `active=false` here IS the answer — // no fee is taken — and the rows stay hidden. The reason is not // consulted: whichever it is, the user receives the whole amount. - // A quote the chain itself would refuse is not one to display. The - // on-chain hook re-derives net from gross and fee and charges nothing - // when they disagree, so mirror that test here: an inconsistent - // preview — only a tampered RPC can produce one — hides the rows - // rather than printing a net the chain will not pay. const gross = ethers.BigNumber.from(grossWei); + // The controller derives net from gross and fee on chain and + // charges nothing when they disagree, so a preview that fails that + // check is not a stated answer: a mangled or truncated response + // from a degraded endpoint, or a rate above the maximum, produced + // it. Reported as unread, the same as a preview the node could not + // answer at all. if ( result.feeAmount.gt(gross) || !result.netAmount.eq(gross.sub(result.feeAmount)) || Number(result.rateBps) > EXIT_FEE_MAX_BPS ) { - return INACTIVE; + return { ...INACTIVE, unknown: true }; } return { active: result.active, @@ -158,11 +153,23 @@ export const useZeroExitFee = (gross?: Decimal): ZeroExitFee => { { ttl: EXIT_FEE_TTL }, ); + // A quote that has not arrived within the same deadline the delay quote is + // given is reported as unreadable, so a form waiting on it can let the + // holder sign with the warning rather than wait on a stalled read. + const fresh = value.forKey === key; + const passed = useDeadlinePassed( + key, + fresh && !loading, + EXIT_DELAY_QUOTE_TIMEOUT_MS, + ); + return useMemo(() => { // A value fetched for a different key belongs to the previous account or // gross. Report it as still loading — displayed as nothing charged — rather // than showing that other quote's numbers for one frame. - const fresh = value.forKey === key; + if (!fresh && passed) { + return { ...INACTIVE, unknown: true, loading: false }; + } return fresh ? { ...value, loading } : { ...INACTIVE, loading: true }; - }, [value, loading, key]); + }, [value, loading, fresh, passed]); }; diff --git a/apps/frontend/src/locales/en/perimeterCopy.test.ts b/apps/frontend/src/locales/en/perimeterCopy.test.ts new file mode 100644 index 000000000..d9f6da29e --- /dev/null +++ b/apps/frontend/src/locales/en/perimeterCopy.test.ts @@ -0,0 +1,185 @@ +import en from './translations.json'; + +/** + * The house terms for what the Sovryn Perimeter does to a withdrawal are + * "withdrawal delay" and "Perimeter fee". "Held" stays only where it states + * what happens to one particular withdrawal — the delay's own tooltips, and + * the contract-owner notice, which says the product holds that withdrawal. + */ + +const stringsOf = (node: unknown, path: string): [string, string][] => + typeof node === 'string' + ? [[path, node]] + : Object.entries(node as Record).flatMap(([key, value]) => + stringsOf(value, `${path}.${key}`), + ); + +const perimeterCopy = [ + ...stringsOf(en.perimeterPage, 'perimeterPage'), + ...stringsOf(en.exitDelay, 'exitDelay'), + ...stringsOf(en.exitFee, 'exitFee'), +]; + +describe('Perimeter copy', () => { + it('names the mechanism as the withdrawal delay, never as holding', () => { + const statesOfOneWithdrawal = new Set([ + 'perimeterPage.contractOwnerNotice', + 'exitDelay.tooltip', + 'exitDelay.unknown.tooltip', + ]); + + expect( + perimeterCopy.filter( + ([path, text]) => + !statesOfOneWithdrawal.has(path) && + /\b(hold|holds|holding|held)\b/i.test(text), + ), + ).toEqual([]); + }); + + it('uses none of the retired names', () => { + expect( + perimeterCopy.filter(([, text]) => + /exit fee|sovryn secure|colfee|keeper|delivered automatically/i.test( + text, + ), + ), + ).toEqual([]); + }); + + it('would catch a keeper script or a promise of automatic delivery', () => { + // A guard is only worth having if it actually bites: run the same check + // against a corpus that carries the retired wording, and require it to + // come back non-empty rather than trusting the regex by inspection. + const retiredNames = + /exit fee|sovryn secure|colfee|keeper|delivered automatically/i; + const synthetic: [string, string][] = [ + ['synthetic.keeper', 'A keeper delivers this automatically.'], + ['synthetic.automatic', 'Nothing is delivered automatically here.'], + ]; + + expect(synthetic.filter(([, text]) => retiredNames.test(text))).toEqual( + synthetic, + ); + }); + + it('names the account-menu entry "Perimeter queue" and the page "Perimeter withdraw queue"', () => { + expect(en.connectWalletButton.perimeter).toBe('Perimeter queue'); + expect(en.perimeterPage.title).toBe('Perimeter withdraw queue'); + expect(en.perimeterPage.meta.title).toBe('Perimeter withdraw queue'); + }); + + it('calls the place held withdrawals sit the withdraw queue, never a vault', () => { + const perimeterSurfaces = [ + ...perimeterCopy, + ['connectWalletButton.perimeter', en.connectWalletButton.perimeter], + ] as [string, string][]; + + expect(perimeterSurfaces.filter(([, text]) => /vault/i.test(text))).toEqual( + [], + ); + }); + + it('would catch a held withdrawal called a vault', () => { + const synthetic: [string, string][] = [ + ['synthetic.vault', 'The funds went to the Perimeter vault.'], + ]; + + expect(synthetic.filter(([, text]) => /vault/i.test(text))).toEqual( + synthetic, + ); + }); + + it('writes the place as "the Sovryn Perimeter withdraw queue" in full sentences', () => { + expect(en.exitDelay.tooltip).toContain( + 'the Sovryn Perimeter withdraw queue', + ); + expect(en.exitDelay.vaultNotice).toContain( + 'the Sovryn Perimeter withdraw queue', + ); + expect(en.exitDelay.unknown.notice).toContain( + 'the Sovryn Perimeter withdraw queue', + ); + expect(en.exitDelay.holdToast.content).toContain( + 'the Sovryn Perimeter withdraw queue', + ); + expect(en.exitDelay.holdToast.unknownContent).toContain( + 'the Sovryn Perimeter withdraw queue', + ); + expect(en.perimeterPage.unreadable).toContain( + 'the Sovryn Perimeter withdraw queue', + ); + }); + + it('labels the fee as the Perimeter fee and the delay as the withdrawal delay', () => { + expect(en.exitFee.label).toMatch(/^Perimeter fee\b/); + expect(en.exitDelay.label).toBe('Withdrawal delay'); + }); + + it('names only the position owner in status copy, never the bare Owner role', () => { + // The pending list carries no status of its own for a withdrawal the + // Owner resolved away — that row is dropped rather than labeled — so + // nothing in status copy has occasion to name the Owner role at all. + const statusCopy = stringsOf( + en.perimeterPage.status, + 'perimeterPage.status', + ); + const namesOwnerRole = /(? namesOwnerRole.test(text))).toEqual( + [], + ); + }); + + it('would catch the Owner role named without "position" in status copy', () => { + const namesOwnerRole = /(? namesOwnerRole.test(text))).toEqual( + synthetic, + ); + }); + + it('never names governance as a role', () => { + expect( + perimeterCopy.filter(([, text]) => /governance/i.test(text)), + ).toEqual([]); + }); + + it('would catch "governance" used to name a role', () => { + const synthetic: [string, string][] = [ + ['synthetic.governance', 'Resolved by governance.'], + ]; + + expect(synthetic.filter(([, text]) => /governance/i.test(text))).toEqual( + synthetic, + ); + }); + + it('never names a block state anywhere in the Perimeter page copy, except admitting one could not be checked', () => { + const exempt = 'perimeterPage.releaseRefused.unreadable'; + + expect( + stringsOf(en.perimeterPage, 'perimeterPage').filter( + ([path, text]) => + path !== exempt && /\b(frozen|blacklisted)\b/i.test(text), + ), + ).toEqual([]); + }); + + it('would catch a block state named outside the one string that admits it could not check', () => { + const exempt = 'perimeterPage.releaseRefused.unreadable'; + const synthetic: [string, string][] = [ + ['synthetic.statusTooltip', 'This party is frozen.'], + ]; + + expect( + synthetic.filter( + ([path, text]) => + path !== exempt && /\b(frozen|blacklisted)\b/i.test(text), + ), + ).toEqual(synthetic); + }); +}); diff --git a/apps/frontend/src/locales/en/translations.json b/apps/frontend/src/locales/en/translations.json index b26dd2756..19b0cebd7 100644 --- a/apps/frontend/src/locales/en/translations.json +++ b/apps/frontend/src/locales/en/translations.json @@ -82,7 +82,8 @@ "history": "History", "copyAddress": "Copy address", "disconnect": "Disconnect", - "portfolio": "Portfolio" + "portfolio": "Portfolio", + "perimeter": "Perimeter queue" }, "header": { "funding": "Funding", @@ -186,6 +187,8 @@ "unlimitedAmountTooltip": "Limiting the amount of approved tokens as an additional security measure may result higher fees", "transactionFailedTitle": "Your transaction has failed", "transactionFailedSubtitle": "Please close or retry your transaction.", + "notSentTitle": "Nothing was sent", + "notSentSubtitle": "Close, or retry to check again.", "transactionSuccessTitle": "Transaction confirmed successfully", "estimatedGasFee": "Estimated gas fee", "gasSettings": "Gas settings", @@ -1673,7 +1676,8 @@ "exitFee": { "label": "Perimeter fee ({{rate}}%)", "youWillReceive": "You will receive", - "tooltip": "The perimeter fee is deducted from the withdrawn amount when your transaction executes. Shown values are approximate — the final fee is determined at execution time." + "tooltip": "The Perimeter fee is deducted from the withdrawn amount when your transaction executes. Shown values are approximate — the final fee is determined at execution time.", + "unknownNotice": "We could not check the Perimeter fee for this withdrawal. The amount shown is the full amount before any Perimeter fee; the final amount is set when the withdrawal executes." }, "lending": { "title": "Deposit", @@ -2030,5 +2034,126 @@ "aboveMaximum": "Amount exceeds the maximum limit ({{max}})", "invalidNumberFormat": "Invalid number format." } + }, + "exitDelay": { + "label": "Withdrawal delay", + "tooltip": "While the Sovryn Perimeter is active this withdrawal is held in the Sovryn Perimeter withdraw queue before it reaches you. Nothing else changes: the destination is fixed when you sign, and you release the funds yourself from the Perimeter page once the delay ends.", + "duration": { + "days": "{{count}} days", + "days_one": "{{count}} day", + "days_other": "{{count}} days", + "hours": "{{count}} hours", + "hours_one": "{{count}} hour", + "hours_other": "{{count}} hours", + "minutes": "{{count}} minutes", + "minutes_one": "{{count}} minute", + "minutes_other": "{{count}} minutes", + "seconds": "{{count}} seconds", + "seconds_one": "{{count}} second", + "seconds_other": "{{count}} seconds" + }, + "durationShort": { + "days": "{{count}} d", + "hours": "{{count}} h", + "minutes": "{{count}} min" + }, + "vaultNotice": "This amount goes to the Sovryn Perimeter withdraw queue when you sign, and reaches you after the delay.", + "vaultLink": "Track and release it on the Perimeter page", + "checking": { + "label": "Withdrawal delay", + "value": "Checking…" + }, + "unknown": { + "label": "Withdrawal delay", + "value": "Could not be checked", + "notice": "We could not check whether the Sovryn Perimeter will delay this withdrawal. If it does, the amount goes to the Sovryn Perimeter withdraw queue when you sign instead of reaching your wallet; if the delay still cannot be read at that moment, the transaction fails and nothing moves.", + "tooltip": "The delay is quoted from the Sovryn Perimeter when you sign, not from this page. We could not reach it just now, so we cannot tell you whether this withdrawal is paid straight out or held in the Sovryn Perimeter withdraw queue first. Try again in a moment." + }, + "holdToast": { + "title": "Withdrawal delayed by the Sovryn Perimeter", + "content": "The funds went to the Sovryn Perimeter withdraw queue, not to your wallet. Release them from the Perimeter page once the delay ends.", + "unknownTitle": "Withdrawal may be delayed by the Sovryn Perimeter", + "unknownContent": "We could not check whether the Sovryn Perimeter delayed this withdrawal. If your wallet balance has not changed, look for the funds in the Sovryn Perimeter withdraw queue." + } + }, + "perimeterPage": { + "meta": { + "title": "Perimeter withdraw queue" + }, + "title": "Perimeter withdraw queue", + "subtitle": "Withdrawals delayed by the Sovryn Perimeter appear here. The destination of each one was fixed when you signed it and cannot be changed. Release it yourself once its delay ends.", + "connectWallet": "Connect your wallet to see your delayed withdrawals.", + "inactive": "The Sovryn Perimeter has no delayed withdrawals for this account.", + "unreadable": "We could not read the Sovryn Perimeter withdraw queue just now. Any withdrawal delayed for this account is still in the withdraw queue — this list may be incomplete or empty for that reason alone. Try again in a moment.", + "table": { + "id": "Reference", + "amount": "Amount", + "receiver": "Destination", + "unlocks": "Releases", + "status": "Status", + "action": "Action", + "unknownAsset": "Unknown asset" + }, + "status": { + "locked": "Delayed", + "unlocking": "Unlocking", + "unlocked": "Ready", + "notExecutor": "Releasable by the position owner", + "frozen": "Under investigation", + "paused": "Paused", + "unreadable": "Could not be read" + }, + "statusTooltip": { + "locked": "Still inside its withdrawal delay. It becomes releasable at the time shown.", + "unlocking": "The delay has ended. Release becomes available once the chain records a block past the unlock time, usually within a minute.", + "unlocked": "The delay has ended. Release it to send the funds to their destination.", + "notExecutor": "This withdrawal is released by the account that made it or by the position owner.", + "frozen": "The delay has ended, but this withdrawal stays in the withdraw queue while it is being looked into. Nothing is lost; release becomes available once the review ends.", + "paused": "Releases are paused across the whole perimeter. Nothing is lost; try again once they resume.", + "unreadable": "We could not read this withdrawal just now. It has not been shown as released. Try again in a moment." + }, + "release": "Release", + "checking": "Checking…", + "wrongNetworkTooltip": "wrong network", + "pausedPartial": "Releases are paused for some of the withdrawals listed below. Nothing is lost; try again once they resume.", + "pausedRowTooltip": "Releases are paused for this withdrawal. Nothing is lost; try again once they resume.", + "readyNow": "Now", + "lessThanMinute": "< 1 min", + "contractOwnerNotice": "Owned by a contract: the product you used holds this withdrawal.", + "tx": { + "executeExit": "Release delayed withdrawal", + "executeExitTitle": "Release from the Perimeter withdraw queue", + "executeExits": "Release {{count}} withdrawals from the Perimeter withdraw queue" + }, + "releaseAll": "Release all ready ({{count}})", + "releaseRefused": { + "title": "Not released", + "unreadable": "Withdrawal #{{id}} was not released because we could not check whether its addresses are frozen or blacklisted. Try again in a moment.", + "statusUnreadable": "Withdrawal #{{id}} was not released because we could not check whether it is still waiting. Try again in a moment.", + "resolvedToProtocol": "Withdrawal #{{id}} cannot be released: it was sent to a recovery destination approved in advance for the product it came from.", + "resolvedByOwner": "Withdrawal #{{id}} cannot be released: it was resolved by the Owner.", + "wrongNetwork": "Your wallet is connected to another network. Switch it to Rootstock, then release again.", + "networkUnreadable": "We could not check which network your wallet is on, so nothing was released.", + "accountChanged": "Your wallet switched to a different account, so nothing was sent. Switch back to the account these withdrawals belong to, then release again.", + "accountUnreadable": "We could not check which account your wallet is using, so nothing was released.", + "batch": "Withdrawals {{ids}} were not released because {{reason}}.", + "batch_one": "Withdrawal {{ids}} was not released because {{reason}}.", + "batch_other": "Withdrawals {{ids}} were not released because {{reason}}.", + "unlocking": "Withdrawals {{ids}} are unlocking — try again in a minute.", + "unlocking_one": "Withdrawal {{ids}} is unlocking — try again in a minute.", + "unlocking_other": "Withdrawals {{ids}} are unlocking — try again in a minute.", + "reason": { + "paused": "releases are paused", + "alreadyTerminal": "#{{id}} was already delivered", + "unknownRequest": "the queue does not know #{{id}}", + "notExecutor": "your address may not release it", + "refused": "the queue would refuse it", + "unchecked": "we could not check whether it would go through. Try again in a moment", + "gasUnestimated": "we could not estimate the gas it needs, and a release is never sent on a guess. Try again in a moment", + "gasUnestimatedBatch": "we could not estimate the gas it needs, and a release is never sent on a guess. Release fewer at once", + "gasLimitUnreadable": "we could not read the current network gas limit, so the gas limit it needs could not be checked. Try again in a moment", + "gasLimitTooLow": "the gas limit set in Advanced settings is too low: {{limit}} is below the {{needed}} it needs. Raise it or reset the values, then retry" + } + } } } diff --git a/apps/frontend/src/router.tsx b/apps/frontend/src/router.tsx index 61aab3791..0e417652f 100644 --- a/apps/frontend/src/router.tsx +++ b/apps/frontend/src/router.tsx @@ -70,6 +70,10 @@ const ClaimLpPage = loadable( const RunesPage = loadable(() => import('./app/5_pages/RunesPage/RunesPage')); +const PerimeterPage = loadable( + () => import('./app/5_pages/PerimeterPage/PerimeterPage'), +); + const routes = [ { path: '/', @@ -161,6 +165,10 @@ const routes = [ path: '/claim-lp', element: , }, + { + path: '/perimeter', + element: , + }, { path: '/runes', element: , diff --git a/apps/frontend/src/store/rxjs/provider-cache.test.ts b/apps/frontend/src/store/rxjs/provider-cache.test.ts new file mode 100644 index 000000000..1cf4362c0 --- /dev/null +++ b/apps/frontend/src/store/rxjs/provider-cache.test.ts @@ -0,0 +1,57 @@ +import { firstValueFrom } from 'rxjs'; + +import { observeCall, startCall } from './provider-cache'; + +/** + * Two reads on the same id can overlap: a new block starts a second read + * before the first one has finished. Whichever settles last must not be + * allowed to overwrite whichever answer is actually current. + */ + +describe('provider-cache', () => { + it('keeps the newer read’s value when an older, slower read finishes after it', async () => { + const id = `test:${Math.random()}`; + let resolveOlder: (value: string) => void; + let resolveNewer: (value: string) => void; + const older = new Promise(resolve => { + resolveOlder = resolve; + }); + const newer = new Promise(resolve => { + resolveNewer = resolve; + }); + + startCall(id, () => older, { blockNumber: 1 }); + startCall(id, () => newer, { blockNumber: 2 }); + + resolveNewer!('newer'); + await newer; + resolveOlder!('older'); + await older; + + const state = await firstValueFrom(observeCall(id)); + expect(state.result.value).toBe('newer'); + }); + + it('keeps the newer read’s failure when an older read fails after it', async () => { + const id = `test:${Math.random()}`; + let rejectOlder: (error: Error) => void; + let rejectNewer: (error: Error) => void; + const older = new Promise((_resolve, reject) => { + rejectOlder = reject; + }); + const newer = new Promise((_resolve, reject) => { + rejectNewer = reject; + }); + + startCall(id, () => older, { blockNumber: 1 }); + startCall(id, () => newer, { blockNumber: 2 }); + + rejectNewer!(new Error('newer failed')); + await newer.catch(() => undefined); + rejectOlder!(new Error('older failed')); + await older.catch(() => undefined); + + const state = await firstValueFrom(observeCall(id)); + expect(state.result.error?.message).toBe('newer failed'); + }); +}); diff --git a/apps/frontend/src/store/rxjs/provider-cache.ts b/apps/frontend/src/store/rxjs/provider-cache.ts index 05c707748..f11b65d96 100644 --- a/apps/frontend/src/store/rxjs/provider-cache.ts +++ b/apps/frontend/src/store/rxjs/provider-cache.ts @@ -22,6 +22,8 @@ type SubjectState = { blockNumber: number; promise: Promise; result: CacheCallResponse; + /** Identifies the read this entry is currently waiting on. */ + generation: number; }; type SubjectMap = { @@ -71,13 +73,17 @@ export const startCall = ( return; } + // Each call started for this id gets the next generation, so a completion + // can be told apart from one belonging to a call this one has superseded. + const generation = (state[id]?.generation ?? 0) + 1; + const promise$ = promise() .then(result => { - completeCall(id, result, null); + completeCall(id, result, null, generation); return result; }) .catch(error => { - completeCall(id, null, error); + completeCall(id, null, error, generation); return error; }); @@ -103,14 +109,24 @@ export const startCall = ( blockNumber: options?.blockNumber ?? 0, promise: promise$, result, + generation, }, }); }; -export const completeCall = (id: string, result: any, error: Error | null) => { +export const completeCall = ( + id: string, + result: any, + error: Error | null, + generation: number, +) => { const state = store.getValue(); - if (!state.hasOwnProperty(id)) { + // A completion belongs to the call it was started for. One that arrives + // after a newer call has already started on the same id is from a read + // this entry has moved past, and must not overwrite the newer one's + // bookkeeping or answer. + if (!state.hasOwnProperty(id) || state[id].generation !== generation) { return; } diff --git a/apps/frontend/src/utils/exitDelay.test.ts b/apps/frontend/src/utils/exitDelay.test.ts new file mode 100644 index 000000000..f332b2c0f --- /dev/null +++ b/apps/frontend/src/utils/exitDelay.test.ts @@ -0,0 +1,304 @@ +import { + BlockState, + ExitStatus, + PendingExitState, + canExecuteExit, + exitKey, + formatDelayCountdown, + formatDelayDuration, + getExitDelayDisplay, + getPendingExitState, + isExecutor, + isPendingExitStatus, + secondsUntilUnlock, +} from './exitDelay'; + +const ORIGINATOR = '0x1111111111111111111111111111111111111111'; +const OWNER = '0x2222222222222222222222222222222222222222'; +const RECEIVER = '0x3333333333333333333333333333333333333333'; +const STRANGER = '0x4444444444444444444444444444444444444444'; + +const NOW = 1_800_000_000; + +const queued = (unlockAt: number) => ({ + status: ExitStatus.Queued, + unlockAt, + originator: ORIGINATOR, + owner: OWNER, + receiver: RECEIVER, +}); + +describe('exitDelay utils', () => { + it('shows the hold only when the chain said there is one', () => { + expect( + getExitDelayDisplay({ delaySeconds: 0, loading: false, unknown: false }), + ).toBe('none'); + expect( + getExitDelayDisplay({ delaySeconds: 1, loading: false, unknown: false }), + ).toBe('held'); + }); + + it('says so when the hold could not be read, instead of saying there is none', () => { + // The delay fails CLOSED on chain: an unread quote means the withdrawal is + // held or it reverts. Reporting it as zero would be a false statement. + expect( + getExitDelayDisplay({ delaySeconds: 0, loading: false, unknown: true }), + ).toBe('unknown'); + }); + + it('reports a quote still in flight as checking, never as no hold', () => { + // The delay fails CLOSED on chain, so a quote that has not arrived says + // nothing about whether the money is paid now. + expect( + getExitDelayDisplay({ delaySeconds: 0, loading: true, unknown: false }), + ).toBe('checking'); + expect( + getExitDelayDisplay({ delaySeconds: 0, loading: true, unknown: true }), + ).toBe('checking'); + }); + + it('formats a duration in whole units, rounding up', () => { + expect(formatDelayDuration(0)).toEqual({ value: 0, unit: 'seconds' }); + expect(formatDelayDuration(45)).toEqual({ value: 45, unit: 'seconds' }); + expect(formatDelayDuration(60)).toEqual({ value: 1, unit: 'minutes' }); + expect(formatDelayDuration(90)).toEqual({ value: 2, unit: 'minutes' }); + expect(formatDelayDuration(3_600)).toEqual({ value: 1, unit: 'hours' }); + expect(formatDelayDuration(3_601)).toEqual({ value: 2, unit: 'hours' }); + expect(formatDelayDuration(86_400)).toEqual({ value: 1, unit: 'days' }); + expect(formatDelayDuration(172_801)).toEqual({ value: 3, unit: 'days' }); + }); + + it('counts down in whole minutes, then two units, without ever understating the wait', () => { + // The form rounds a policy duration up to one unit, which never promises + // money sooner than it arrives. On the screen where someone is watching a + // clock, 25 hours left reading as "2 days" is uselessly coarse. + expect(formatDelayCountdown(90_000)).toEqual([ + { value: 1, unit: 'days' }, + { value: 1, unit: 'hours' }, + ]); + expect(formatDelayCountdown(172_801)).toEqual([ + { value: 2, unit: 'days' }, + { value: 1, unit: 'hours' }, + ]); + expect(formatDelayCountdown(183_600)).toEqual([ + { value: 2, unit: 'days' }, + { value: 3, unit: 'hours' }, + ]); + expect(formatDelayCountdown(3_900)).toEqual([ + { value: 1, unit: 'hours' }, + { value: 5, unit: 'minutes' }, + ]); + expect(formatDelayCountdown(180)).toEqual([{ value: 3, unit: 'minutes' }]); + expect(formatDelayCountdown(86_400)).toEqual([{ value: 1, unit: 'days' }]); + expect(formatDelayCountdown(3_600)).toEqual([{ value: 1, unit: 'hours' }]); + expect(formatDelayCountdown(0)).toEqual([]); + }); + + it('rounds a partial minute up and never returns seconds', () => { + expect(formatDelayCountdown(1)).toEqual([{ value: 1, unit: 'minutes' }]); + expect(formatDelayCountdown(60)).toEqual([{ value: 1, unit: 'minutes' }]); + expect(formatDelayCountdown(61)).toEqual([{ value: 2, unit: 'minutes' }]); + expect(formatDelayCountdown(3_599)).toEqual([{ value: 1, unit: 'hours' }]); + for (let seconds = 1; seconds <= 200_000; seconds += 37) { + formatDelayCountdown(seconds).forEach(({ unit }) => + expect(['days', 'hours', 'minutes']).toContain(unit), + ); + } + }); + + it('rolls a carrying remainder into the unit above it', () => { + // 1h 59m 59s must not print as "1h 60m". + expect(formatDelayCountdown(7_199)).toEqual([{ value: 2, unit: 'hours' }]); + // 1d 23h 59m must not print as "1d 24h". + expect(formatDelayCountdown(172_799)).toEqual([{ value: 2, unit: 'days' }]); + }); + + it('never reports a negative time to unlock', () => { + expect(secondsUntilUnlock(NOW + 60, NOW)).toEqual(60); + expect(secondsUntilUnlock(NOW - 60, NOW)).toEqual(0); + }); + + it('treats originator, owner, and receiver as executors, and a stranger as not one', () => { + const exit = queued(NOW); + expect(isExecutor(exit, ORIGINATOR)).toBe(true); + expect(isExecutor(exit, OWNER)).toBe(true); + expect(isExecutor(exit, RECEIVER)).toBe(true); + expect(isExecutor(exit, ORIGINATOR.toUpperCase())).toBe(true); + expect(isExecutor(exit, STRANGER)).toBe(false); + expect(isExecutor(exit, undefined)).toBe(false); + }); + + it('keys an exit by its queue and id, so equal ids in two queues stay apart', () => { + expect(exitKey({ queueAddress: '0xAbC', id: '7' })).toBe('0xabc:7'); + expect(exitKey({ queueAddress: '0xabc', id: '7' })).not.toBe( + exitKey({ queueAddress: '0xdef', id: '7' }), + ); + }); + + describe('isPendingExitStatus', () => { + it('keeps a queued withdrawal, and a status the queue never stated', () => { + expect(isPendingExitStatus(ExitStatus.Queued)).toBe(true); + expect(isPendingExitStatus(ExitStatus.None)).toBe(true); + expect(isPendingExitStatus(99)).toBe(true); + }); + + it('drops a withdrawal the queue stated as done, however it was done', () => { + expect(isPendingExitStatus(ExitStatus.Executed)).toBe(false); + expect(isPendingExitStatus(ExitStatus.ResolvedByOwner)).toBe(false); + expect(isPendingExitStatus(ExitStatus.ResolvedToProtocol)).toBe(false); + }); + }); + + describe('pending exit state', () => { + // The countdown ticks on the page's clock; readiness waits for the latest + // block, because the queue compares that block's timestamp. + const at = (now: number, blockTime = now) => ({ now, blockTime }); + + it('is locked before the unlock time', () => { + expect( + getPendingExitState(queued(NOW + 60), false, OWNER, at(NOW)), + ).toEqual(PendingExitState.Locked); + }); + + it('is unlocked for an executor once the latest block reaches the unlock time', () => { + expect(getPendingExitState(queued(NOW), false, OWNER, at(NOW))).toEqual( + PendingExitState.Unlocked, + ); + }); + + it('is unlocked for an account that is only the receiver', () => { + expect( + getPendingExitState(queued(NOW), false, RECEIVER, at(NOW)), + ).toEqual(PendingExitState.Unlocked); + }); + + it('is unlocking, not ready, while the countdown has ended but the latest block is still before the unlock time', () => { + // The latest block trails wall time, by up to about a minute on RSK + // mainnet, and the queue refuses a release until a block reaches the + // unlock time. + expect( + getPendingExitState(queued(NOW - 1), false, OWNER, at(NOW, NOW - 30)), + ).toEqual(PendingExitState.Unlocking); + expect( + getPendingExitState(queued(NOW - 30), false, OWNER, at(NOW, NOW - 30)), + ).toEqual(PendingExitState.Unlocked); + }); + + it('tells a non-party it cannot execute, rather than offering a reverting button', () => { + expect( + getPendingExitState(queued(NOW), false, STRANGER, at(NOW)), + ).toEqual(PendingExitState.NotExecutor); + }); + + it("shows a frozen party as under investigation once the time has passed; a blacklisted party's row reads Ready", () => { + const frozen = { ...queued(NOW - 60), blockedState: BlockState.Frozen }; + expect( + getPendingExitState(frozen, false, OWNER, { now: NOW, blockTime: NOW }), + ).toBe(PendingExitState.Frozen); + // Before the time has passed the hold itself is what shows. + expect( + getPendingExitState( + { ...queued(NOW + 60), blockedState: BlockState.Frozen }, + false, + OWNER, + { now: NOW, blockTime: NOW }, + ), + ).toBe(PendingExitState.Locked); + expect( + getPendingExitState( + { ...queued(NOW - 60), blockedState: BlockState.Blacklisted }, + false, + OWNER, + { now: NOW, blockTime: NOW }, + ), + ).toBe(PendingExitState.Unlocked); + expect(canExecuteExit(PendingExitState.Frozen)).toBe(false); + }); + + it('shows a row whose block state could not be read as unreadable, never as ready', () => { + expect( + getPendingExitState( + { ...queued(NOW - 60), blockedStateUnreadable: true }, + false, + OWNER, + { now: NOW, blockTime: NOW }, + ), + ).toBe(PendingExitState.Unreadable); + // Before the time has passed the hold itself is what shows. + expect( + getPendingExitState( + { ...queued(NOW + 60), blockedStateUnreadable: true }, + false, + OWNER, + { now: NOW, blockTime: NOW }, + ), + ).toBe(PendingExitState.Locked); + expect(canExecuteExit(PendingExitState.Unreadable)).toBe(false); + }); + + it.each([ + ['delivered to its receiver', ExitStatus.Executed], + ['resolved by the Owner', ExitStatus.ResolvedByOwner], + ['returned to the product', ExitStatus.ResolvedToProtocol], + ])( + // The pending list drops a withdrawal %s before this is ever asked + // about it; a stated status other than Queued reaching this function + // regardless must not be read as ready, which would offer a Release + // that could only revert. + 'reads a withdrawal %s as unreadable rather than ready, since the pending list should have dropped it already', + (_case, status) => { + expect( + getPendingExitState( + { ...queued(NOW - 60), status }, + false, + OWNER, + at(NOW), + ), + ).toEqual(PendingExitState.Unreadable); + }, + ); + + it('ranks the global pause above the unlock time', () => { + expect(getPendingExitState(queued(NOW), true, OWNER, at(NOW))).toEqual( + PendingExitState.Paused, + ); + }); + + it('reads the zero status as unreadable, never as settled', () => { + // The zero value means the queue holds no such request at all — a node + // that did not state the record, not a withdrawal that was paid out. + expect( + getPendingExitState( + { ...queued(NOW - 60), status: ExitStatus.None }, + false, + OWNER, + at(NOW), + ), + ).toEqual(PendingExitState.Unreadable); + }); + + it('reads a status outside the five the queue defines as unreadable, never as settled', () => { + expect( + getPendingExitState( + { ...queued(NOW - 60), status: 99 as ExitStatus }, + false, + OWNER, + at(NOW), + ), + ).toEqual(PendingExitState.Unreadable); + }); + }); + + it('enables execution for exactly one state', () => { + expect(canExecuteExit(PendingExitState.Unlocked)).toBe(true); + for (const state of [ + PendingExitState.Locked, + PendingExitState.Unlocking, + PendingExitState.NotExecutor, + PendingExitState.Paused, + PendingExitState.Unreadable, + ]) { + expect(canExecuteExit(state)).toBe(false); + } + }); +}); diff --git a/apps/frontend/src/utils/exitDelay.ts b/apps/frontend/src/utils/exitDelay.ts new file mode 100644 index 000000000..a63a70a04 --- /dev/null +++ b/apps/frontend/src/utils/exitDelay.ts @@ -0,0 +1,408 @@ +import { Decimal } from '@sovryn/utils'; + +/** + * 30 s — the perimeter's operational admin can change policy instantly (no + * timelock), so cache briefly. + * + * That briefness still leaves one window uncovered: the moment the Owner + * first arms the withdrawal delay for a surface. A form that was already + * open when that happens can go on showing "not held" for up to one cache + * lifetime, because both the pointer read and the delay length read behind + * the quote are cached under this same TTL and neither is keyed on the delay + * value itself. `useExitDelay`'s own requote timer (REQUOTE_INTERVAL_MS) is + * what keeps that window bounded at one cache lifetime rather than open — + * without it, a form that saw no new block while mounted would not refresh + * this quote again at all, however long it stayed open. The withdrawal is + * held on chain from the moment the delay is armed regardless of what any + * open form shows — the delay fails closed — so nothing is ever at risk; + * only the number on screen can lag, for that one window, tied to that one + * Owner action. Do not treat that lag as a bug to close by shortening this + * TTL or re-deriving it from policy state on every render. + */ +export const EXIT_DELAY_TTL = 30_000; + +/** + * How long any single chain read behind these hooks may take before the + * caller gives up on it and treats it as failed. Without a bound, a node that + * never answers a raw `eth_call` never settles at all, and one made through + * ethers holds the caller for its own two-minute default. + */ +export const RELEASE_READ_TIMEOUT_MS = 10_000; + +/** + * How far above the node's fresh gas estimate a release's gas limit is set, in + * percent, when the holder typed no limit: the estimate is made against the + * state the check read, and the release executes in a later block. + */ +export const RELEASE_GAS_MARGIN_PERCENT = 20; + +/** Mirrors ExitDelayQueue.ExitStatus. */ +export enum ExitStatus { + None = 0, + Queued = 1, + Executed = 2, + ResolvedToProtocol = 3, + ResolvedByOwner = 4, +} + +/** Mirrors ExitDelayQueue.BlockState. */ +export enum BlockState { + None = 0, + Frozen = 1, + Blacklisted = 2, +} + +/** + * What the holder can actually do with a still-queued exit right now. Derived + * from the same conditions `executeExit` checks on-chain, in the order it + * checks them, so the UI never offers a button that would revert. + * + * A withdrawal the queue does not count as queued — paid out, or resolved + * away by recovery — carries no state from this type at all: the pending + * list drops that row once its own status read says so, rather than holding + * a state for it here. Nothing left in this type stands for "done". + */ +export enum PendingExitState { + /** Still inside its delay window. */ + Locked = 'locked', + /** + * Past its unlock time on the page's ticking clock, but the latest block read + * is still before it. The queue compares that block's timestamp, so a release + * is refused until a block reaches the unlock time. + */ + Unlocking = 'unlocking', + /** Past its unlock time and executable by this account. */ + Unlocked = 'unlocked', + /** Past its unlock time, but this account is not an executor for it. */ + NotExecutor = 'notExecutor', + /** + * Past its unlock time, but a party to it is frozen while it is looked + * into. The holder waits; nothing is lost. + */ + Frozen = 'frozen', + /** The whole queue is paused. */ + Paused = 'paused', + /** + * The queue's answer for this request was the zero value, meaning it holds + * no such request at all, or a value outside the five it defines — or the + * request's own status was read but is not `Queued`, which the pending + * list is not supposed to hand this function at all. Neither the garbled + * read nor that unexpected case is a stated fact this function can act on, + * so both are read this way rather than as ready, which would offer a + * Release that could only revert. + */ + Unreadable = 'unreadable', +} + +export type PendingExit = { + id: string; + /** The queue holding this exit — the contract a release is sent to. */ + queueAddress: string; + /** + * The escrowed amount in the asset's own units. Undefined when the asset + * could not be resolved: its decimals are then unknown, and a number scaled + * by a guess would be silently wrong by orders of magnitude. + */ + amount?: Decimal; + /** Symbol of the asset the holder receives, undefined when unresolved. */ + tokenSymbol?: string; + /** address(0) means native RBTC. */ + token: string; + createdAt: number; + unlockAt: number; + originator: string; + owner: string; + receiver: string; + surfaceId: string; + subProduct: string; + status: ExitStatus; + unwrapOnDelivery: boolean; + /** + * Whether the recorded owner address carries code, per `extcodesize`. The + * queue lets anyone deliver such a request, so the page can note that + * instead of asking the holder to press anything. Undefined when the code + * read did not complete. + */ + ownerHasCode?: boolean; + /** + * The block state on one of the request's parties, when one is set; a + * frozen party wins over a blacklisted one. Undefined when no party is + * stated as frozen or blacklisted, whether every read completed clear or + * one of them did not complete at all — `blockedStateUnreadable` carries + * that difference. + */ + blockedState?: BlockState; + /** + * True when at least one party's own block-state read did not complete, + * and no party is stated as frozen or blacklisted. Undefined block state + * alone cannot tell a party confirmed clear apart from one never read. + */ + blockedStateUnreadable?: boolean; +}; + +/** + * An exit's identity on the page: its queue and its id. Ids restart in each + * queue, so an id alone can name two different requests. + */ +export const exitKey = ( + exit: Pick, +): string => `${exit.queueAddress.toLowerCase()}:${exit.id}`; + +/** + * A hold quote, including whether we managed to get one. + * + * `unknown` exists because the delay fails CLOSED on chain — the mirror image + * of the fee. When a controller is pinned but cannot be quoted, + * `PerimeterLib.safeQuoteDelay` reverts the whole withdrawal + * (`PERIMETER:delay-quote-failed`) rather than paying direct. So a read that + * did not complete means one of two things — the money is held, or the + * transaction fails — and never "paid straight to the wallet". Zero is a + * truthful answer only where the perimeter is unwired, which is a different + * fact and is reported with `unknown: false`. + */ +export type ExitDelayQuote = { + /** Seconds the perimeter will hold this withdrawal. 0 means paid directly. */ + delaySeconds: number; + loading: boolean; + /** True when no quote was obtained. NOT the same as a delay of zero. */ + unknown: boolean; +}; + +/** A settled quote saying nothing is held: the shape callers pass to suppress the hold UI. */ +export const NO_EXIT_DELAY: ExitDelayQuote = { + delaySeconds: 0, + loading: false, + unknown: false, +}; + +/** + * Deliberately NOT exported. It answers "does the perimeter hold this" and only + * that, so on its own it cannot tell a hold of zero from a hold nobody could + * read — and silence is truthful only for the first. `getExitDelayDisplay` is + * the exported entry point; keeping this private makes the mistake a compile + * error instead of a bug a caller could introduce unnoticed. + */ +const isExitDelayShown = (delaySeconds: number): boolean => delaySeconds > 0; + +/** + * What the UI shows: the hold rows, a note that the quote is still being + * checked, an admission that we could not check, or nothing at all. + * + * `held` and `none` are the two answers the chain stated. `unknown` is the one + * it did not, and `checking` is the one it has not yet: unlike the fee, an + * unread delay is not equivalent to no delay, so neither borrows silence from + * a fact we do not have. Silence is reserved for `none`. + */ +export type ExitDelayDisplay = 'held' | 'unknown' | 'checking' | 'none'; + +export const getExitDelayDisplay = ( + quote: ExitDelayQuote, +): ExitDelayDisplay => { + if (quote.loading) { + return 'checking'; + } + if (quote.unknown) { + return 'unknown'; + } + return isExitDelayShown(quote.delaySeconds) ? 'held' : 'none'; +}; + +/** + * Whole-unit duration for display: days, then hours, then minutes, then + * seconds. Rounded UP, because a delay shown as shorter than it is would be + * read as a promise the contract does not make. + */ +export const formatDelayDuration = ( + seconds: number, +): { value: number; unit: 'days' | 'hours' | 'minutes' | 'seconds' } => { + if (seconds <= 0) { + return { value: 0, unit: 'seconds' }; + } + if (seconds >= 86_400) { + return { value: Math.ceil(seconds / 86_400), unit: 'days' }; + } + if (seconds >= 3_600) { + return { value: Math.ceil(seconds / 3_600), unit: 'hours' }; + } + if (seconds >= 60) { + return { value: Math.ceil(seconds / 60), unit: 'minutes' }; + } + return { value: seconds, unit: 'seconds' }; +}; + +type CountdownUnit = 'days' | 'hours' | 'minutes'; + +const MINUTES_PER_HOUR = 60; +const MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR; + +/** + * A countdown in whole minutes, then at most two units: "3 min", "1 h 5 min", + * "2 d 3 h". Never seconds. + * + * `formatDelayDuration` rounds a whole policy duration up to one unit, which is + * right for a form — it never promises money sooner than it arrives — but on + * the one screen where someone is watching a clock, 25 hours left reading as + * "2 days" is uselessly coarse. Time left is rounded UP to whole minutes, the + * remainder below a day is rounded UP to whole hours, and a remainder that + * carries (1 h 60 min, 1 d 24 h) rolls into the unit above rather than + * printing an impossible count. Anything under a minute reads as one minute; + * a caller that wants to say "less than a minute" tests for it first. + */ +export const formatDelayCountdown = ( + seconds: number, +): { value: number; unit: CountdownUnit }[] => { + if (seconds <= 0) { + return []; + } + const minutes = Math.ceil(seconds / 60); + if (minutes < MINUTES_PER_HOUR) { + return [{ value: minutes, unit: 'minutes' }]; + } + if (minutes < MINUTES_PER_DAY) { + const hours = Math.floor(minutes / MINUTES_PER_HOUR); + const restMinutes = minutes - hours * MINUTES_PER_HOUR; + return restMinutes === 0 + ? [{ value: hours, unit: 'hours' }] + : [ + { value: hours, unit: 'hours' }, + { value: restMinutes, unit: 'minutes' }, + ]; + } + const days = Math.floor(minutes / MINUTES_PER_DAY); + const restMinutes = minutes - days * MINUTES_PER_DAY; + if (restMinutes === 0) { + return [{ value: days, unit: 'days' }]; + } + const restHours = Math.ceil(restMinutes / MINUTES_PER_HOUR); + return restHours === 24 + ? [{ value: days + 1, unit: 'days' }] + : [ + { value: days, unit: 'days' }, + { value: restHours, unit: 'hours' }, + ]; +}; + +/** Seconds remaining until an exit unlocks; never negative. */ +export const secondsUntilUnlock = (unlockAt: number, now: number): number => + Math.max(0, unlockAt - now); + +/** + * Whether the given account is one of THIS request's own parties — + * `{originator, owner, receiver}` — who may deliver it directly. When the + * owner has code, the queue also lets anyone else deliver the request; that + * does not change what this check reports for the connected account. + */ +export const isExecutor = ( + exit: Pick, + account: string | undefined, +): boolean => { + if (!account) { + return false; + } + const a = account.toLowerCase(); + return ( + exit.originator.toLowerCase() === a || + exit.owner.toLowerCase() === a || + exit.receiver.toLowerCase() === a + ); +}; + +/** The chain's time as the page reads it, in seconds. */ +export type ChainTimes = { + /** The latest block's own timestamp: what the queue compares. */ + blockTime: number; + /** That timestamp advanced by the local time elapsed since the read. */ + now: number; +}; + +/** + * Whether the queue actually stated this outcome, rather than answering with + * the zero value — no such request at all — or a number outside the five it + * defines. Both of those are a read that did not complete, never a fact about + * the withdrawal, and must not be read as paid out. + */ +export const isStatedExitStatus = (status: number): status is ExitStatus => + status === ExitStatus.Queued || + status === ExitStatus.Executed || + status === ExitStatus.ResolvedToProtocol || + status === ExitStatus.ResolvedByOwner; + +/** + * Whether the pending list still carries this exit: either it is genuinely + * still queued, or its status could not be stated at all, which must stay + * listed as unreadable rather than silently dropped. A status the queue + * stated as anything else — paid out, or resolved away by recovery — means + * the withdrawal is done, and the pending list is for what is still waiting. + */ +export const isPendingExitStatus = (status: number): boolean => + !isStatedExitStatus(status) || status === ExitStatus.Queued; + +/** + * Resolve what a still-pending exit shows right now. + * + * Callers hand this only an exit `isPendingExitStatus` still counts as + * pending: a withdrawal paid out, or resolved away by recovery, is dropped + * from the list before it ever reaches this function, rather than shown here + * with a state naming what became of it. A status the queue did not actually + * state — its zero value, or one outside the five it defines — reads as + * unreadable, and so does the one case that should not reach this function at + * all: a stated status other than `Queued`, read this way rather than as + * ready, which would offer a Release that could only revert. The unlock time + * is judged twice: the ticking clock says when the delay has ended, and the + * latest block's own timestamp says when a release can pass, because the + * queue compares `block.timestamp`. Between the two the exit is unlocking. A + * frozen party shows as under investigation once the time has passed. A + * blacklisted party is not given a state of its own here: its row reads + * Ready, same as any other unlocked one. A party whose own block-state read + * did not complete, with no party stated as frozen or blacklisted, reads as + * unreadable rather than falling into Ready, which would tell the holder a + * check that never ran came back clear. + */ +export const getPendingExitState = ( + exit: Pick< + PendingExit, + | 'status' + | 'unlockAt' + | 'originator' + | 'owner' + | 'receiver' + | 'blockedState' + | 'blockedStateUnreadable' + >, + paused: boolean, + account: string | undefined, + { now, blockTime }: ChainTimes, +): PendingExitState => { + if (!isPendingExitStatus(exit.status)) { + // A stated status other than `Queued`: the caller should have dropped + // this exit from the pending list already, and never asked what it + // reads as. + return PendingExitState.Unreadable; + } + if (!isStatedExitStatus(exit.status)) { + // The zero value, or one outside the five the queue defines: a read + // that did not complete, not a fact about the withdrawal. + return PendingExitState.Unreadable; + } + if (paused) { + return PendingExitState.Paused; + } + if (now < exit.unlockAt) { + return PendingExitState.Locked; + } + if (blockTime < exit.unlockAt) { + return PendingExitState.Unlocking; + } + if (exit.blockedState === BlockState.Frozen) { + return PendingExitState.Frozen; + } + if (exit.blockedStateUnreadable) { + return PendingExitState.Unreadable; + } + return isExecutor(exit, account) + ? PendingExitState.Unlocked + : PendingExitState.NotExecutor; +}; + +export const canExecuteExit = (state: PendingExitState): boolean => + state === PendingExitState.Unlocked; diff --git a/apps/frontend/src/utils/exitFee.test.ts b/apps/frontend/src/utils/exitFee.test.ts index ebf2253f0..d167ed39d 100644 --- a/apps/frontend/src/utils/exitFee.test.ts +++ b/apps/frontend/src/utils/exitFee.test.ts @@ -27,9 +27,9 @@ describe('exitFee utils', () => { expect(SURFACE_ZERO_CLAIM_SURPLUS).toEqual( utils.id('PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS'), ); - // Pin the literals too. The preimage assertions above cannot catch a - // rename that rewrites the constant and its own expectation together — - // which is exactly how a stale `COLFEE:` prefix survived once. + // Pin the literals too: a rename that rewrites the constant and its own + // expectation together would still pass the preimage assertions above, + // so the literals are pinned independently. expect(SURFACE_LENDING_LENDER_WITHDRAW).toEqual( '0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe', ); diff --git a/apps/frontend/src/utils/exitFee.ts b/apps/frontend/src/utils/exitFee.ts index ebc425858..08e061f08 100644 --- a/apps/frontend/src/utils/exitFee.ts +++ b/apps/frontend/src/utils/exitFee.ts @@ -32,11 +32,11 @@ export const getExitFeeNet = (gross: Decimal, rateBps: number): Decimal => /** * Mirror of the on-chain charge test (PERIMETER_FEE_CALL_GRAPH.md §"Quoting for UIs"). * - * Deliberately NOT exported. It answers "would a fee be charged" and only that, - * so on its own it cannot tell a rate of zero from a rate nobody could read — - * and four separate consumers reached that wrong conclusion by calling it - * directly. `getExitFeeDisplay` is the exported entry point; keeping this - * private makes the mistake a compile error rather than a review finding. + * Deliberately NOT exported. It answers "would a fee be charged" and only + * that, so on its own it cannot tell a rate of zero from a rate nobody could + * read. `getExitFeeDisplay` is the exported entry point; keeping this + * private makes that mistake a compile error instead of a bug a caller could + * introduce unnoticed. */ const isExitFeeShown = ( active: boolean, @@ -71,9 +71,11 @@ export type ExitFeeQuote = { * cannot quote, it charges nothing and pays the gross, so a form that says * nothing in those cases is telling the truth. * - * There is deliberately no third state. "A fee applies but we cannot say how - * much" is not a message this product sends: if no fee is taken, the user - * receives the whole amount and has nothing to be told. + * There is deliberately no third state in this type: a hidden fee row states + * nothing, so it needs none. The Zero surplus card and the loan close form + * restate the gross as the amount the user will receive, so on those two + * screens an unread quote is qualified separately, straight off the quote's + * own `unknown` flag rather than through this type. */ export type ExitFeeDisplay = 'charged' | 'none'; diff --git a/apps/frontend/src/utils/testing/jsonRpcStub.ts b/apps/frontend/src/utils/testing/jsonRpcStub.ts new file mode 100644 index 000000000..bb363176d --- /dev/null +++ b/apps/frontend/src/utils/testing/jsonRpcStub.ts @@ -0,0 +1,277 @@ +import { providers, utils } from 'ethers'; +import { createServer, IncomingMessage, ServerResponse } from 'http'; +import { AddressInfo, Socket } from 'net'; + +/** + * A local JSON-RPC node for tests. + * + * It lets a test drive the app's real provider stack — ethers' HTTP transport, + * its error wrapping, and the app's own fallback provider — against the exact + * answers a node gives, instead of against error objects written by hand. The + * server listens on an ephemeral port chosen by the operating system, which is + * always above 8547, and `close` must be called when the test file finishes. + */ + +export type StubAnswer = + /** A successful JSON-RPC response carrying this result. */ + | { result: unknown } + /** A JSON-RPC error object inside an HTTP 200 response. */ + | { error: { code: number; message: string; data?: string } } + /** An HTTP response with this status and body, not JSON-RPC at all. */ + | { status: number; body: string } + /** HTTP 200 with this body verbatim, whether or not it parses. */ + | { raw: string } + /** The connection is closed without a response. */ + | { drop: true } + /** The request is accepted and never answered. */ + | { hang: true }; + +type Rule = { to: string; dataPrefix: string; answer: StubAnswer }; + +type RecordedCall = { + method: string; + from?: string; + to?: string; + data?: string; +}; + +export type JsonRpcStub = { + url: string; + /** + * Answer `eth_call` to `to` whose calldata starts with `dataPrefix`. When + * several rules match, the longest prefix wins, so a rule for one exact + * argument can sit beside a rule for the whole function; among rules with + * the same prefix the latest wins, so a test can override a default. + */ + onCall: (to: string, dataPrefix: string, answer: StubAnswer) => void; + /** Answer every request for a method other than `eth_call`. */ + onMethod: (method: string, answer: StubAnswer) => void; + /** How many `eth_call`s reached `to`, optionally only those starting with `dataPrefix`. */ + callCount: (to: string, dataPrefix?: string) => number; + /** The `eth_call`s that reached `to`, with the address each was made from (lower-cased). */ + callsTo: ( + to: string, + dataPrefix?: string, + ) => { from?: string; data: string }[]; + /** The params of every request for `method`, other than `eth_call`, in the order they arrived. */ + requestsFor: (method: string) => unknown[][]; + /** Forget every rule and recorded call; the default chain answers remain. */ + reset: () => void; + close: () => Promise; +}; + +const RSK_MAINNET_CHAIN_ID = '0x1e'; + +const defaultMethods = (): Record => ({ + eth_chainId: { result: RSK_MAINNET_CHAIN_ID }, + net_version: { result: '30' }, + eth_blockNumber: { result: '0x10' }, +}); + +/** Revert data for `Error(string)`, encoded the way Solidity encodes a reason. */ +export const revertReasonData = (reason: string): string => + utils.hexConcat([ + utils.id('Error(string)').slice(0, 10), + utils.defaultAbiCoder.encode(['string'], [reason]), + ]); + +/** + * The RSK node's answer to `exitDelayQueue()` on the lending protocol proxy, + * which routes that selector to no module: code -32015 and an `Error(string)` + * payload. + */ +export const RSK_PROTOCOL_TARGET_NOT_ACTIVE: StubAnswer = { + error: { + code: -32015, + message: + 'VM Exception while processing transaction: revert target not active', + data: revertReasonData('target not active'), + }, +}; + +/** The RSK node's answer to `exitDelayQueue()` on a loan token proxy with no such module. */ +export const RSK_LOAN_TOKEN_TARGET_NOT_ACTIVE: StubAnswer = { + error: { + code: -32015, + message: + 'VM Exception while processing transaction: revert LoanTokenLogicProxy:target not active', + data: revertReasonData('LoanTokenLogicProxy:target not active'), + }, +}; + +/** + * The RSK node's answer to `exitDelayQueue()` on Zero's BorrowerOperations, + * whose implementation has no such function: code -32015 and empty revert + * data. Ethers turns this one into a successful `0x` result on its own call + * path, because the message contains "reverted" and `0x` is valid hex. + */ +export const RSK_REVERT_WITHOUT_DATA: StubAnswer = { + error: { + code: -32015, + message: 'VM Exception while processing transaction: transaction reverted', + data: '0x', + }, +}; + +/** An ABI-encoded address result. */ +export const addressResult = (address: string): StubAnswer => ({ + result: utils.defaultAbiCoder.encode(['address'], [address]), +}); + +export const selectorOf = (signature: string): string => + utils.id(signature).slice(0, 10); + +/** + * The error the installed ethers raises for a read that meets `answer`, taken + * from a real `provider.call` against the stub rather than written by hand. + */ +export const captureCallFailure = async ( + stub: JsonRpcStub, + answer: StubAnswer, +): Promise => { + const target = '0x000000000000000000000000000000000000dead'; + stub.onCall(target, '0x', answer); + try { + await new providers.StaticJsonRpcProvider(stub.url).call({ + to: target, + data: '0x12345678', + }); + } catch (error) { + return error; + } + throw new Error('the stubbed call succeeded'); +}; + +const readBody = (request: IncomingMessage): Promise => + new Promise((resolve, reject) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', chunk => (body += chunk)); + request.on('end', () => resolve(body)); + request.on('error', reject); + }); + +export const startJsonRpcStub = async (): Promise => { + let rules: Rule[] = []; + let methods = defaultMethods(); + let calls: RecordedCall[] = []; + let requests: { method: string; params: unknown[] }[] = []; + const sockets = new Set(); + + const answerFor = (method: string, params: unknown[]): StubAnswer => { + if (method !== 'eth_call') { + requests.push({ method, params }); + return ( + methods[method] ?? { + error: { code: -32601, message: `stub: no answer for ${method}` }, + } + ); + } + const [tx] = params as [{ from?: string; to?: string; data?: string }]; + const to = (tx?.to ?? '').toLowerCase(); + const data = (tx?.data ?? '').toLowerCase(); + calls.push({ method, from: tx?.from?.toLowerCase(), to, data }); + const match = rules + .map((rule, index) => ({ rule, index })) + .filter(({ rule }) => rule.to === to && data.startsWith(rule.dataPrefix)) + .sort( + (a, b) => + b.rule.dataPrefix.length - a.rule.dataPrefix.length || + b.index - a.index, + )[0]?.rule; + return ( + match?.answer ?? { + error: { code: -32601, message: 'stub: no answer for this call' }, + } + ); + }; + + const server = createServer( + async (request: IncomingMessage, response: ServerResponse) => { + const body = await readBody(request); + let payload: { id?: unknown; method?: string; params?: unknown[] }; + try { + payload = JSON.parse(body); + } catch (error) { + response.writeHead(400); + response.end('bad request'); + return; + } + const answer = answerFor(payload.method ?? '', payload.params ?? []); + + if ('hang' in answer) { + return; + } + if ('drop' in answer) { + request.socket.destroy(); + return; + } + if ('status' in answer) { + response.writeHead(answer.status, { 'content-type': 'text/html' }); + response.end(answer.body); + return; + } + response.writeHead(200, { 'content-type': 'application/json' }); + if ('raw' in answer) { + response.end(answer.raw); + return; + } + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: payload.id, + ...('result' in answer + ? { result: answer.result } + : { error: answer.error }), + }), + ); + }, + ); + + server.on('connection', socket => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + + const callsTo = (to: string, dataPrefix = '') => + calls + .filter( + call => + call.to === to.toLowerCase() && + (call.data ?? '').startsWith(dataPrefix.toLowerCase()), + ) + .map(call => ({ from: call.from, data: call.data ?? '' })); + + return { + url: `http://127.0.0.1:${port}`, + onCall: (to, dataPrefix, answer) => + rules.push({ + to: to.toLowerCase(), + dataPrefix: dataPrefix.toLowerCase(), + answer, + }), + onMethod: (method, answer) => { + methods[method] = answer; + }, + callCount: (to, dataPrefix = '') => callsTo(to, dataPrefix).length, + callsTo, + requestsFor: method => + requests + .filter(request => request.method === method) + .map(request => request.params), + reset: () => { + rules = []; + methods = defaultMethods(); + calls = []; + requests = []; + }, + close: () => + new Promise(resolve => { + sockets.forEach(socket => socket.destroy()); + server.close(() => resolve()); + }), + }; +}; diff --git a/apps/frontend/tsconfig.typecheck.json b/apps/frontend/tsconfig.typecheck.json new file mode 100644 index 000000000..290f770f5 --- /dev/null +++ b/apps/frontend/tsconfig.typecheck.json @@ -0,0 +1,24 @@ +{ + // Typecheck-only view of the project, used by `yarn typecheck`. + // + // `tsc -p tsconfig.json` reports nothing about src/: viem, ox and abitype + // ship declaration files written for a newer TypeScript, and their parse + // errors end the run before semantic checking begins. Those three are + // redirected to an `any` stub here so the check reaches our own code. + // + // Full coverage also wants the workspace packages built (`yarn build` at the + // repo root); without their .d.ts files a few call sites degrade to `any`. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "baseUrl": ".", + "paths": { + "abitype": ["scripts/typecheck-stubs/opaque.d.ts"], + "abitype/*": ["scripts/typecheck-stubs/opaque.d.ts"], + "ox": ["scripts/typecheck-stubs/opaque.d.ts"], + "ox/*": ["scripts/typecheck-stubs/opaque.d.ts"], + "viem": ["scripts/typecheck-stubs/opaque.d.ts"], + "viem/*": ["scripts/typecheck-stubs/opaque.d.ts"] + } + } +}