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(