diff --git a/ui/css/index.css b/ui/css/index.css index 41f750d9..7c85242d 100644 --- a/ui/css/index.css +++ b/ui/css/index.css @@ -2262,6 +2262,18 @@ strong.metric-value-danger { overflow-wrap: anywhere; } +.liquidation-modal-actions { + align-items: flex-start; +} + +.liquidation-modal-actions > .tx-action { + align-self: flex-start; +} + +.liquidation-modal-actions > button { + align-self: flex-start; +} + .form-grid { display: grid; gap: 1.1rem; diff --git a/ui/dist/css/index.css b/ui/dist/css/index.css index 41f750d9..7c85242d 100644 --- a/ui/dist/css/index.css +++ b/ui/dist/css/index.css @@ -2262,6 +2262,18 @@ strong.metric-value-danger { overflow-wrap: anywhere; } +.liquidation-modal-actions { + align-items: flex-start; +} + +.liquidation-modal-actions > .tx-action { + align-self: flex-start; +} + +.liquidation-modal-actions > button { + align-self: flex-start; +} + .form-grid { display: grid; gap: 1.1rem; diff --git a/ui/ts/components/LiquidationModal.tsx b/ui/ts/components/LiquidationModal.tsx index fc8328d3..9557ce1c 100644 --- a/ui/ts/components/LiquidationModal.tsx +++ b/ui/ts/components/LiquidationModal.tsx @@ -8,6 +8,7 @@ import { FormInput } from './FormInput.js' import { MetricField } from './MetricField.js' import { OpenOraclePriceValue } from './OpenOraclePriceValue.js' import { TransactionActionButton } from './TransactionActionButton.js' +import { sameAddress } from '../lib/address.js' import { formatCurrencyInputBalance } from '../lib/formatters.js' import { getLiquidationFailureReason, simulateLiquidation } from '../lib/liquidation.js' import { parseRepAmountInput } from '../lib/marketForm.js' @@ -167,6 +168,8 @@ export function LiquidationModal({ const callerPoolOracleCollateralization = callerVaultSummary === undefined ? undefined : getVaultCollateralizationPercent(callerVaultSummary.repDepositShare, callerVaultSummary.securityBondAllowance, poolOraclePrice) const liquidationExecutionMode = getLiquidationExecutionMode(currentPoolOracleManagerDetails) const buttonLabels = getLiquidationButtonLabels(currentPoolOracleManagerDetails) + const trimmedLiquidationTargetVault = liquidationTargetVault.trim() + const sameVaultWarning = accountAddress === undefined || trimmedLiquidationTargetVault === '' || !sameAddress(accountAddress, trimmedLiquidationTargetVault) ? undefined : 'Select a target vault that is different from the caller vault.' const liquidationSimulation = targetVaultSummary === undefined || poolOraclePrice === undefined || selectedPool?.securityMultiplier === undefined || liquidationAmountValue === undefined ? undefined @@ -197,11 +200,13 @@ export function LiquidationModal({ ? 'Refreshing Open Oracle validity before liquidation.' : liquidationManagerAddress === undefined || liquidationSecurityPoolAddress === undefined ? 'Reload the selected pool before liquidating.' - : liquidationTargetVault.trim() === '' + : trimmedLiquidationTargetVault === '' ? 'Select a target vault first.' - : liquidationAmount.trim() === '' - ? 'Enter a liquidation amount.' - : directLiquidationReason + : sameVaultWarning !== undefined + ? sameVaultWarning + : liquidationAmount.trim() === '' + ? 'Enter a liquidation amount.' + : directLiquidationReason const queuedLiquidationOperation = securityPoolOverviewResult?.action !== 'queueLiquidation' || currentPoolOracleManagerDetails?.pendingOperation?.operation !== 'liquidation' || currentPoolOracleManagerDetails.pendingOperation.targetVault !== liquidationTargetVault ? undefined : currentPoolOracleManagerDetails.pendingOperation @@ -298,7 +303,7 @@ export function LiquidationModal({ {selectedPool?.securityMultiplier === undefined ? 'Unavailable' : `${selectedPool.securityMultiplier.toString()}x`} {accountAddress === undefined ? 'Connect wallet' : } - {liquidationTargetVault.trim() === '' ? 'None selected' : liquidationTargetVault} + {trimmedLiquidationTargetVault === '' ? 'None selected' : } @@ -313,6 +318,17 @@ export function LiquidationModal({ {callerPoolOracleCollateralization === undefined ? 'Unavailable' : } + {sameVaultWarning === undefined ? null : ( +
+
+
+

Invalid Liquidation Pair

+
+ Warning +
+

{sameVaultWarning}

+
+ )}
)} -
+
diff --git a/ui/ts/components/SecurityPoolSection.tsx b/ui/ts/components/SecurityPoolSection.tsx index 3750e065..e2a67dba 100644 --- a/ui/ts/components/SecurityPoolSection.tsx +++ b/ui/ts/components/SecurityPoolSection.tsx @@ -124,7 +124,11 @@ export function SecurityPoolSection({ ? undefined : { title: 'Security pool created', - detail: `Created pool ${securityPoolResult.securityPoolAddress}.`, + detail: ( + <> + Created pool . + + ), nextStep: 'Open the pool to begin operating vault, trading, reporting, and fork workflows.', } } diff --git a/ui/ts/components/SecurityPoolWorkflowSection.tsx b/ui/ts/components/SecurityPoolWorkflowSection.tsx index 3cffa7e8..974db0f3 100644 --- a/ui/ts/components/SecurityPoolWorkflowSection.tsx +++ b/ui/ts/components/SecurityPoolWorkflowSection.tsx @@ -1173,7 +1173,7 @@ export function SecurityPoolWorkflowSection({ setVaultActionModal(undefined)} title='Claim Fees' description='Confirm the claimable fee balance before submitting the fee redemption for this vault.'>
{selectedVaultDetails === undefined ? '—' : } - {selectedVaultAddress === '' ? 'None selected' : selectedVaultAddress} + {selectedVaultAddress === '' ? 'None selected' : }
}, { label: 'Universe', value: }, { label: 'Transaction', value: }, ]} diff --git a/ui/ts/tests/liquidationModal.test.tsx b/ui/ts/tests/liquidationModal.test.tsx index ab4ed920..18a0f25e 100644 --- a/ui/ts/tests/liquidationModal.test.tsx +++ b/ui/ts/tests/liquidationModal.test.tsx @@ -93,6 +93,8 @@ function createSelectedPool(overrides: Partial = {}): Listed describe('LiquidationModal', () => { let restoreDomEnvironment: (() => void) | undefined let cleanupRenderedComponent: (() => Promise) | undefined + const defaultCallerVaultAddress = getAddress('0x0000000000000000000000000000000000000001') + const defaultTargetVaultAddress = getAddress('0x00000000000000000000000000000000000000a1') beforeEach(() => { const domEnvironment = installDomEnvironment() @@ -109,7 +111,7 @@ describe('LiquidationModal', () => { function renderLiquidationModal(overrides: Partial[0]> = {}) { return renderIntoDocument( undefined} currentPoolOracleManagerDetails={undefined} isMainnet @@ -118,7 +120,7 @@ describe('LiquidationModal', () => { liquidationManagerAddress={zeroAddress} liquidationModalOpen liquidationSecurityPoolAddress={zeroAddress} - liquidationTargetVault={zeroAddress} + liquidationTargetVault={defaultTargetVaultAddress} loadingPoolOracleManager={false} onLoadPoolOracleManager={() => undefined} onLiquidationAmountChange={() => undefined} @@ -130,8 +132,8 @@ describe('LiquidationModal', () => { selectedPool={createSelectedPool()} securityPoolOverviewActiveAction={undefined} securityPoolOverviewResult={undefined} - callerVaultSummary={createTargetVaultSummary({ vaultAddress: getAddress('0x0000000000000000000000000000000000000001') })} - targetVaultSummary={createTargetVaultSummary()} + callerVaultSummary={createTargetVaultSummary({ vaultAddress: defaultCallerVaultAddress })} + targetVaultSummary={createTargetVaultSummary({ vaultAddress: defaultTargetVaultAddress })} {...overrides} />, ) @@ -377,6 +379,95 @@ describe('LiquidationModal', () => { expect(documentQueries.getByText(/^Open Oracle Price$/)).not.toBeNull() }) + test('uses a dedicated top-aligned action row when execute liquidation shows a disabled reason', async () => { + const renderedComponent = await renderLiquidationModal({ + currentPoolOracleManagerDetails: createOracleManagerDetails({ + isPriceValid: true, + lastPrice: 10n * 10n ** 18n, + }), + liquidationAmount: '2', + selectedPool: createSelectedPool({ + securityMultiplier: 2n, + }), + callerVaultSummary: createTargetVaultSummary({ + repDepositShare: 29n * 10n ** 18n, + securityBondAllowance: 1n * 10n ** 18n, + vaultAddress: getAddress('0x0000000000000000000000000000000000000001'), + }), + targetVaultSummary: createTargetVaultSummary({ + repDepositShare: 30n * 10n ** 18n, + securityBondAllowance: 2n * 10n ** 18n, + }), + }) + cleanupRenderedComponent = renderedComponent.cleanup + + const documentQueries = within(document.body) + const executeButton = documentQueries.getByRole('button', { name: 'Execute Liquidation' }) as HTMLButtonElement + const cancelButton = documentQueries.getByRole('button', { name: 'Cancel' }) + const actionContainer = cancelButton.closest('.liquidation-modal-actions') + + expect(executeButton.disabled).toBe(true) + expect(documentQueries.getByText('The caller vault would become liquidatable after this liquidation.')).not.toBeNull() + expect(actionContainer).not.toBeNull() + expect(actionContainer?.className).toContain('actions') + expect(actionContainer?.className).toContain('liquidation-modal-actions') + }) + + test('renders the target vault with the shared address value component', async () => { + const callerVaultAddress = getAddress('0x0000000000000000000000000000000000000001') + const targetVaultAddress = getAddress('0x00000000000000000000000000000000000000a1') + const renderedComponent = await renderLiquidationModal({ + accountAddress: callerVaultAddress, + liquidationTargetVault: targetVaultAddress, + callerVaultSummary: createTargetVaultSummary({ + vaultAddress: callerVaultAddress, + }), + targetVaultSummary: createTargetVaultSummary({ + vaultAddress: targetVaultAddress, + }), + }) + cleanupRenderedComponent = renderedComponent.cleanup + + const documentQueries = within(document.body) + expect(documentQueries.getByRole('button', { name: `Copy address ${callerVaultAddress}` })).not.toBeNull() + const targetVaultButton = documentQueries.getByRole('button', { name: `Copy address ${targetVaultAddress}` }) + expect(targetVaultButton).not.toBeNull() + expect(targetVaultButton.textContent).toContain(targetVaultAddress) + }) + + test('shows a warning and disables liquidation when caller and target vaults are the same', async () => { + const vaultAddress = getAddress('0x00000000000000000000000000000000000000a1') + const renderedComponent = await renderLiquidationModal({ + accountAddress: vaultAddress, + currentPoolOracleManagerDetails: createOracleManagerDetails({ + isPriceValid: true, + lastPrice: 10n * 10n ** 18n, + }), + liquidationAmount: '1', + liquidationTargetVault: vaultAddress, + selectedPool: createSelectedPool({ + securityMultiplier: 2n, + }), + callerVaultSummary: createTargetVaultSummary({ + repDepositShare: 100n * 10n ** 18n, + securityBondAllowance: 2n * 10n ** 18n, + vaultAddress, + }), + targetVaultSummary: createTargetVaultSummary({ + repDepositShare: 5n * 10n ** 18n, + securityBondAllowance: 2n * 10n ** 18n, + vaultAddress, + }), + }) + cleanupRenderedComponent = renderedComponent.cleanup + + const documentQueries = within(document.body) + const executeButton = documentQueries.getByRole('button', { name: 'Execute Liquidation' }) as HTMLButtonElement + expect(executeButton.disabled).toBe(true) + expect(documentQueries.getByRole('heading', { name: 'Invalid Liquidation Pair' })).not.toBeNull() + expect(documentQueries.getAllByText('Select a target vault that is different from the caller vault.')).toHaveLength(2) + }) + test('shows the caller vault and a post-liquidation simulation', async () => { const callerVaultAddress = getAddress('0x0000000000000000000000000000000000000001') const renderedComponent = await renderLiquidationModal({ diff --git a/ui/ts/tests/securityPoolSection.test.tsx b/ui/ts/tests/securityPoolSection.test.tsx index 0c2f16f4..7bba4e72 100644 --- a/ui/ts/tests/securityPoolSection.test.tsx +++ b/ui/ts/tests/securityPoolSection.test.tsx @@ -1,8 +1,9 @@ /// import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { within } from '@testing-library/dom' import { h } from 'preact' -import { zeroAddress } from 'viem' +import { getAddress, zeroAddress, zeroHash } from 'viem' import { SecurityPoolSection } from '../components/SecurityPoolSection.js' import type { AccountState } from '../types/app.js' import type { MarketDetails } from '../types/contracts.js' @@ -133,4 +134,27 @@ describe('SecurityPoolSection', () => { expect(headings).not.toContain('Requirements') expect(headings).not.toContain('Existing Pools') }) + + test('renders the created pool banner detail with the shared address value component', async () => { + const poolAddress = getAddress('0x00000000000000000000000000000000000000a1') + const renderedComponent = await renderIntoDocument( + h( + SecurityPoolSection, + createProps({ + securityPoolResult: { + deployPoolHash: zeroHash, + questionId: '0x01', + securityPoolAddress: poolAddress, + securityMultiplier: 2n, + universeId: 1n, + }, + }), + ), + ) + cleanupRenderedComponent = renderedComponent.cleanup + + const documentQueries = within(document.body) + expect(documentQueries.getByRole('heading', { name: 'Security pool created' })).not.toBeNull() + expect(documentQueries.getAllByRole('button', { name: `Copy address ${poolAddress}` }).length).toBeGreaterThanOrEqual(2) + }) }) diff --git a/ui/ts/tests/securityPoolWorkflowSection.test.tsx b/ui/ts/tests/securityPoolWorkflowSection.test.tsx index 0870f032..24256b5a 100644 --- a/ui/ts/tests/securityPoolWorkflowSection.test.tsx +++ b/ui/ts/tests/securityPoolWorkflowSection.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { fireEvent, within } from '@testing-library/dom' import { render } from 'preact' import { act } from 'preact/test-utils' -import { zeroAddress } from 'viem' +import { getAddress, zeroAddress } from 'viem' import { SecurityPoolWorkflowSection } from '../components/SecurityPoolWorkflowSection.js' import type { AccountState } from '../types/app.js' import type { ListedSecurityPool, MarketDetails, OracleManagerDetails, SecurityPoolVaultSummary, SecurityVaultDetails } from '../types/contracts.js' @@ -433,6 +433,51 @@ describe('SecurityPoolWorkflowSection', () => { expect(documentQueries.getAllByText('Locked REP').length).toBeGreaterThan(0) }) + test('renders the claim-fees modal vault with the shared address value component', async () => { + const vaultAddress = getAddress('0x00000000000000000000000000000000000000a1') + const poolVault = createSecurityPoolVaultSummary({ vaultAddress }) + const renderedComponent = await renderIntoDocument( + , + ) + cleanupRenderedComponent = renderedComponent.cleanup + + const documentQueries = within(document.body) + const claimFeesButton = documentQueries.getAllByRole('button', { name: 'Claim Fees' })[0] + if (!(claimFeesButton instanceof HTMLElement)) { + throw new Error('Expected claim fees launcher button') + } + + await act(() => { + fireEvent.click(claimFeesButton) + }) + + const dialog = documentQueries.getByRole('dialog') + expect(within(dialog).getByRole('button', { name: `Copy address ${vaultAddress}` })).not.toBeNull() + }) + test('auto-loads the selected vault when a routed pool opens in the vault view', async () => { const loadSecurityVaultCalls: Array = [] const selectedPoolAddress = zeroAddress diff --git a/ui/ts/tests/tradingSection.test.tsx b/ui/ts/tests/tradingSection.test.tsx index cb9bd535..ed144833 100644 --- a/ui/ts/tests/tradingSection.test.tsx +++ b/ui/ts/tests/tradingSection.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { fireEvent, within } from '@testing-library/dom' import { useState } from 'preact/hooks' import { act } from 'preact/test-utils' -import { zeroAddress } from 'viem' +import { getAddress, zeroAddress, zeroHash } from 'viem' import { TradingSection } from '../components/TradingSection.js' import { MARKET_NOT_FINALIZED_MESSAGE, NEED_MATCHING_COMPLETE_SET_SHARES_MESSAGE, NO_MINT_CAPACITY_NO_ACTIVE_ALLOWANCE_MESSAGE, SHARE_MIGRATION_AFTER_FORK_MESSAGE } from '../lib/trading.js' import type { AccountState, TradingFormState } from '../types/app.js' @@ -216,6 +216,27 @@ void describe('TradingSection', () => { expect(documentQueries.getByRole('heading', { name: 'Redeem Resolved Shares' })).not.toBeNull() }) + void test('renders the latest trading action pool with the shared address value component', async () => { + const poolAddress = getAddress('0x00000000000000000000000000000000000000a1') + const renderedComponent = await renderIntoDocument( + , + ) + cleanupRenderedComponent = renderedComponent.cleanup + + const documentQueries = within(document.body) + expect(documentQueries.getByRole('heading', { name: 'Latest Trading Action' })).not.toBeNull() + expect(documentQueries.getByRole('button', { name: `Copy address ${poolAddress}` })).not.toBeNull() + }) + void test('shows the minting disabled reason on the launcher when the pool has no active allowance', async () => { const renderedComponent = await renderIntoDocument(