diff --git a/ui/ts/components/SecurityPoolSection.tsx b/ui/ts/components/SecurityPoolSection.tsx
index 693a0ca..ed35199 100644
--- a/ui/ts/components/SecurityPoolSection.tsx
+++ b/ui/ts/components/SecurityPoolSection.tsx
@@ -13,13 +13,12 @@ export function SecurityPoolSection({
accountState,
checkingDuplicateOriginPool,
duplicateOriginPoolExists,
- lastCreatedQuestionId,
loadingMarketDetails,
marketDetails,
onCreateSecurityPool,
- onLoadLatestMarket,
onLoadMarket,
onSecurityPoolFormChange,
+ onResetSecurityPoolCreation,
securityPools,
securityPoolCreating,
securityPoolError,
@@ -27,10 +26,12 @@ export function SecurityPoolSection({
securityPoolResult,
showHeader = true,
poolCreationMarketDetails: carriedPoolCreationMarketDetails,
+ zoltarUniverseHasForked,
}: SecurityPoolSectionProps) {
const isMainnet = isMainnetChain(accountState.chainId)
const isPoolActionPending = securityPoolCreating || checkingDuplicateOriginPool
- const isCreateDisabled = accountState.address === undefined || !isMainnet || isPoolActionPending || duplicateOriginPoolExists || marketDetails?.marketType !== 'binary'
+ const hasSecurityPoolResult = securityPoolResult !== undefined
+ const isCreateDisabled = accountState.address === undefined || !isMainnet || isPoolActionPending || duplicateOriginPoolExists || marketDetails?.marketType !== 'binary' || zoltarUniverseHasForked
const matchingPools = marketDetails === undefined ? [] : securityPools.filter(pool => pool.questionId.toLowerCase() === marketDetails.questionId.toLowerCase())
const hasMatchingSecurityMultiplier = matchingPools.some(pool => pool.securityMultiplier.toString() === securityPoolForm.securityMultiplier.trim())
let createdQuestionDetails = undefined
@@ -49,6 +50,8 @@ export function SecurityPoolSection({
createButtonLabel =
} else if (duplicateOriginPoolExists) {
createButtonLabel = 'Pool Already Exists'
+ } else if (zoltarUniverseHasForked) {
+ createButtonLabel = 'Pool Creation Locked'
} else if (matchingPools.length > 0) {
createButtonLabel = 'Create Another Pool'
}
@@ -64,58 +67,19 @@ export function SecurityPoolSection({
) : undefined}
-
- {marketDetails === undefined ? undefined : (
+ {hasSecurityPoolResult ? (
+
{marketDetails.marketType}}
+ title='Pool created'
+ badge={Deployed}
actions={
-
-
}
>
-
- {marketDetails.marketType === 'scalar' ? undefined : (
-
- {marketDetails.outcomeLabels.map(label => (
-
- {label}
-
- ))}
-
- )}
-
- )}
-
- {matchingPools.length === 0 ? undefined : (
-
{matchingPools.length} existing}>
-
- {matchingPools.map(pool => (
-
} badge={
{pool.systemState}}>
-
-
- Security Multiplier
- {pool.securityMultiplier.toString()}
-
-
- Open Interest Fee / Year
- {formatOpenInterestFeePerYearPercent(pool.currentRetentionRate)}
-
-
-
- ))}
-
-
- )}
-
- {securityPoolResult === undefined ? undefined : (
-
Deployed}>
-
@@ -136,50 +100,101 @@ export function SecurityPoolSection({
- )}
-
-
-
-
-
+
+ ) : (
+ <>
+
+ {marketDetails === undefined ? undefined : (
+
{marketDetails.marketType}}
+ actions={
+
+
+ {loadingMarketDetails ? Loading Question... : 'Reload Question'}
+
+
+ }
+ >
+
+ {marketDetails.marketType === 'scalar' ? undefined : (
+
+ {marketDetails.outcomeLabels.map(label => (
+
+ {label}
+
+ ))}
+
+ )}
+
+ )}
-
-
- {loadingMarketDetails ? Loading Question... : 'Load Question'}
-
-
- Use Latest Question
-
+ {matchingPools.length === 0 ? undefined : (
+
{matchingPools.length} existing}>
+
+ {matchingPools.map(pool => (
+
} badge={
{pool.systemState}}>
+
+
+ Security Multiplier
+ {pool.securityMultiplier.toString()}
+
+
+ Open Interest Fee / Year
+ {formatOpenInterestFeePerYearPercent(pool.currentRetentionRate)}
+
+
+
+ ))}
+
+
+ )}
-
+
+
binary}>
+
+
- {!duplicateOriginPoolExists && !hasMatchingSecurityMultiplier ? undefined : A pool for this question and security multiplier already exists. Origin pool deployment is deterministic for that pair, so change the security multiplier to create a different pool.
}
- {securityPoolError === undefined ? undefined : {securityPoolError}
}
-
+
+
+
+
+ {createButtonLabel}
+
+
+
+
+
+ {!duplicateOriginPoolExists && !hasMatchingSecurityMultiplier ? undefined :
A pool for this question and security multiplier already exists. Origin pool deployment is deterministic for that pair, so change the security multiplier to create a different pool.
}
+ {marketDetails !== undefined && marketDetails.marketType !== 'binary' ?
Security pools can only be created for binary markets. Load a binary market to proceed.
: undefined}
+ {zoltarUniverseHasForked ?
Security pools cannot be created after Zoltar has forked.
: undefined}
+ {securityPoolError === undefined ? undefined :
{securityPoolError}
}
+
+ >
+ )}
)
diff --git a/ui/ts/components/SecurityPoolWorkflowSection.tsx b/ui/ts/components/SecurityPoolWorkflowSection.tsx
index 46fe4a2..dc2c771 100644
--- a/ui/ts/components/SecurityPoolWorkflowSection.tsx
+++ b/ui/ts/components/SecurityPoolWorkflowSection.tsx
@@ -3,7 +3,7 @@ import { AddressValue } from './AddressValue.js'
import { EntityCard } from './EntityCard.js'
import { ForkAuctionSection } from './ForkAuctionSection.js'
import { LiquidationModal } from './LiquidationModal.js'
-import { Question } from './Question.js'
+import { Question, getQuestionTitle } from './Question.js'
import { ReportingSection } from './ReportingSection.js'
import { SecurityVaultSection } from './SecurityVaultSection.js'
import { TradingSection } from './TradingSection.js'
@@ -11,6 +11,7 @@ import { UniverseLink } from './UniverseLink.js'
import { CurrencyValue } from './CurrencyValue.js'
import { isMainnetChain } from '../lib/network.js'
import { formatOpenInterestFeePerYearPercent } from '../lib/retentionRate.js'
+import { formatUniverseLabel } from '../lib/universe.js'
import { readSelectedPoolViewQueryParam, writeSelectedPoolViewQueryParam } from '../lib/urlParams.js'
import type { SecurityPoolWorkflowRouteContentProps } from '../types/components.js'
@@ -29,6 +30,7 @@ function getSelectedPoolView(value: string | undefined): SelectedPoolView {
export function SecurityPoolWorkflowSection({
accountState,
+ activeUniverseId,
closeLiquidationModal,
forkAuction,
liquidationAmount,
@@ -56,8 +58,9 @@ export function SecurityPoolWorkflowSection({
const currentTimestamp = reporting.reportingDetails?.currentTime ?? BigInt(Math.floor(Date.now() / 1000))
const reportingReady = marketDetails !== undefined && marketDetails.endTime <= currentTimestamp
const forkReady = selectedPoolState !== undefined && selectedPoolState !== 'operational'
+ const selectedPoolUniverseMismatch = selectedPool !== undefined && selectedPool.universeId !== activeUniverseId
const hasSelectedPoolAddress = securityPoolAddress.trim() !== ''
- const selectedPoolTitle = securityPoolAddress === '' ? 'Select a security pool' :
+ const selectedPoolTitle = selectedPool !== undefined ? getQuestionTitle(selectedPool.marketDetails) : securityPoolAddress === '' ? 'Select a security pool' :
useEffect(() => {
const nextSearch = writeSelectedPoolViewQueryParam(window.location.search, hasSelectedPoolAddress ? view : undefined)
@@ -75,7 +78,7 @@ export function SecurityPoolWorkflowSection({
) : undefined}
-
{selectedPoolState}}>
+ {selectedPoolState}}>
{!hasSelectedPoolAddress ? (
- Select a pool.
+ Browse Pools to pick one, or paste an address above.
) : selectedPool === undefined ? (
- Pool metadata unavailable.
+ Pool metadata unavailable. Refresh Pool Registry in the Browse tab to load metadata for this address.
) : (
<>
@@ -95,12 +98,6 @@ export function SecurityPoolWorkflowSection({
{selectedPool.vaultCount.toString()} vaults
-
- Universe
-
-
-
-
Security Multiplier
{selectedPool.securityMultiplier.toString()}
@@ -109,34 +106,40 @@ export function SecurityPoolWorkflowSection({
Open Interest Fee / Year
{formatOpenInterestFeePerYearPercent(selectedPool.currentRetentionRate)}
-
- Reporting
- {reportingReady ? 'Unlocked' : 'Locked until question end'}
-
-
- Fork Flow
- {forkReady ? 'Forked / active' : 'Not forked'}
-
+ {reportingReady ? (
+
+ Reporting
+ Unlocked
+
+ ) : undefined}
-
-
- Fork Mode
- {selectedPool.forkOwnSecurityPool ? 'Own escalation fork' : 'Parent / Zoltar fork'}
-
-
- Fork Outcome
- {selectedPool.forkOutcome}
-
+ {forkReady ? (
+ <>
+
+ Fork Flow
+ Forked / active
+
+
+
+ Fork Mode
+ {selectedPool.forkOwnSecurityPool ? 'Own escalation fork' : 'Parent / Zoltar fork'}
+
+
+ Fork Outcome
+ {selectedPool.forkOutcome}
+
+ >
+ ) : undefined}
@@ -153,7 +156,16 @@ export function SecurityPoolWorkflowSection({
)}
- {!hasSelectedPoolAddress ? undefined : (
+ {selectedPool === undefined || !selectedPoolUniverseMismatch ? undefined : (
+
Blocked}>
+
+ This pool belongs to but the app is currently set to {formatUniverseLabel(activeUniverseId)}.
+
+ Switch the application universe to match this pool before using Vaults, Trading, or Resolution.
+
+ )}
+
+ {!hasSelectedPoolAddress || selectedPoolUniverseMismatch ? undefined : (
<>
setView('vaults')} aria-pressed={view === 'vaults'}>
@@ -169,23 +181,11 @@ export function SecurityPoolWorkflowSection({
{view === 'vaults' ? (
-
-
-
-
Your Vault
-
-
Wallet owned
-
-
-
+
+
+
-
-
-
-
Pool Vaults
-
-
{selectedPool?.vaultCount.toString() ?? '0'} vaults
-
+
{selectedPool?.vaultCount.toString() ?? '0'} vaults}>
{selectedPool === undefined ? (
No pool metadata
) : selectedPool.vaults.length === 0 ? (
@@ -197,9 +197,8 @@ export function SecurityPoolWorkflowSection({
key={`${selectedPool.securityPoolAddress}-${vault.vaultAddress}`}
className='compact'
title={}
- badge={Vault}
actions={
- onOpenLiquidationModal(selectedPool.managerAddress, selectedPool.securityPoolAddress, vault.vaultAddress)} disabled={accountState.address === undefined || !isMainnet}>
+ onOpenLiquidationModal(selectedPool.managerAddress, selectedPool.securityPoolAddress, vault.vaultAddress)} disabled={accountState.address === undefined || !isMainnet}>
Liquidate Vault
}
@@ -242,56 +241,35 @@ export function SecurityPoolWorkflowSection({
))}
)}
-
+
) : undefined}
{view === 'trading' ? (
) : undefined}
{view === 'resolution' ? (
-
-
-
-
Reporting
-
-
{reportingReady ? 'Unlocked' : 'Locked until question end'}
-
- {reportingReady ? (
+ {reportingReady ? (
+
Unlocked}>
- ) : (
- Waiting}>
- Wait for question end.
-
- )}
-
+
+ ) : undefined}
-
-
-
-
Fork & Truth Auction
-
-
{forkReady ? 'Available' : 'Locked until fork'}
-
+
{forkReady ? 'Available' : 'Locked until fork'}}>
{forkReady ? (
) : (
Operational}>
- Not forked.
+ The pool must enter a non-operational state (forked or in escalation) before the fork & auction flow becomes available.
)}
-
+
) : undefined}
>
diff --git a/ui/ts/components/SecurityPoolsOverviewSection.tsx b/ui/ts/components/SecurityPoolsOverviewSection.tsx
index c2bee51..70f3c02 100644
--- a/ui/ts/components/SecurityPoolsOverviewSection.tsx
+++ b/ui/ts/components/SecurityPoolsOverviewSection.tsx
@@ -3,7 +3,7 @@ import { CurrencyValue } from './CurrencyValue.js'
import { EntityCard } from './EntityCard.js'
import { LiquidationModal } from './LiquidationModal.js'
import { LoadingText } from './LoadingText.js'
-import { Question } from './Question.js'
+import { Question, getQuestionTitle } from './Question.js'
import { TransactionHashLink } from './TransactionHashLink.js'
import { UniverseLink } from './UniverseLink.js'
import { isMainnetChain } from '../lib/network.js'
@@ -43,7 +43,7 @@ export function SecurityPoolsOverviewSection({
}
>
- <>>
+
Displays all pools loaded from the on-chain registry.
{securityPoolOverviewResult === undefined ? undefined : (
@@ -54,19 +54,19 @@ export function SecurityPoolsOverviewSection({
{securityPoolOverviewError === undefined ? undefined :
{securityPoolOverviewError}
}
{securityPools.length === 0 ? (
-
Registry empty}>
- Use Refresh Pool Registry.
+ Empty}>
+ No pools loaded yet. Use Refresh Pool Registry to fetch them from the chain.
) : (
{securityPools.map(pool => (
}
+ title={getQuestionTitle(pool.marketDetails)}
badge={
{pool.systemState}}
actions={
onSelectSecurityPool === undefined ? undefined : (
-
onSelectSecurityPool(pool.securityPoolAddress)}>
+ onSelectSecurityPool(pool.securityPoolAddress)}>
Open Pool
)
@@ -134,9 +134,8 @@ export function SecurityPoolsOverviewSection({
key={`${pool.securityPoolAddress}-${vault.vaultAddress}`}
className='compact'
title={}
- badge={Vault}
actions={
- onOpenLiquidationModal(pool.managerAddress, pool.securityPoolAddress, vault.vaultAddress)} disabled={accountState.address === undefined || !isMainnet}>
+ onOpenLiquidationModal(pool.managerAddress, pool.securityPoolAddress, vault.vaultAddress)} disabled={accountState.address === undefined || !isMainnet}>
Liquidate Vault
}
diff --git a/ui/ts/components/SecurityPoolsSection.tsx b/ui/ts/components/SecurityPoolsSection.tsx
index 1b54399..5fc2b13 100644
--- a/ui/ts/components/SecurityPoolsSection.tsx
+++ b/ui/ts/components/SecurityPoolsSection.tsx
@@ -17,12 +17,6 @@ export function SecurityPoolsSection({ createPool, overview, workflow }: Securit
return (
-
-
setView('browse')} aria-pressed={view === 'browse'}>
Browse Pools
@@ -38,7 +32,6 @@ export function SecurityPoolsSection({ createPool, overview, workflow }: Securit
{view === 'browse' ? (
{
workflow.onSecurityPoolAddressChange(securityPoolAddress)
setView('operate')
diff --git a/ui/ts/components/SecurityVaultSection.tsx b/ui/ts/components/SecurityVaultSection.tsx
index 50fd789..4c885d6 100644
--- a/ui/ts/components/SecurityVaultSection.tsx
+++ b/ui/ts/components/SecurityVaultSection.tsx
@@ -1,30 +1,307 @@
-import { AddressValue } from './AddressValue.js'
+import { useEffect, useRef } from 'preact/hooks'
import { CurrencyValue } from './CurrencyValue.js'
import { EntityCard } from './EntityCard.js'
import { LoadingText } from './LoadingText.js'
-import { UniverseLink } from './UniverseLink.js'
import { TransactionHashLink } from './TransactionHashLink.js'
+import { formatCurrencyBalance } from '../lib/formatters.js'
import { isMainnetChain } from '../lib/network.js'
+import { parseRepAmountInput } from '../lib/marketForm.js'
import type { SecurityVaultSectionProps } from '../types/components.js'
export function SecurityVaultSection({
accountState,
+ compactLayout = false,
+ autoLoadVault = false,
loadingSecurityVault,
onApproveRep,
onDepositRep,
onLoadSecurityVault,
onRedeemFees,
- onRedeemRep,
+ onSetSecurityBondAllowance,
onSecurityVaultFormChange,
- onUpdateVaultFees,
+ onWithdrawRep,
securityVaultDetails,
securityVaultError,
securityVaultForm,
+ securityVaultRepAllowance,
+ securityVaultRepBalance,
securityVaultResult,
showHeader = true,
showSecurityPoolAddressInput = true,
}: SecurityVaultSectionProps) {
const isMainnet = isMainnetChain(accountState?.chainId)
+ const normalizedSecurityVaultForm = {
+ depositAmount: securityVaultForm.depositAmount ?? '0',
+ securityBondAllowanceAmount: securityVaultForm.securityBondAllowanceAmount ?? '0',
+ repWithdrawAmount: securityVaultForm.repWithdrawAmount ?? '0',
+ securityPoolAddress: securityVaultForm.securityPoolAddress ?? '',
+ }
+ const hasWithdrawAmount = normalizedSecurityVaultForm.repWithdrawAmount.trim() !== '' && normalizedSecurityVaultForm.repWithdrawAmount.trim() !== '0'
+ const depositAmount = (() => {
+ try {
+ return parseRepAmountInput(normalizedSecurityVaultForm.depositAmount, 'REP deposit amount')
+ } catch {
+ return undefined
+ }
+ })()
+ const securityBondAllowanceAmount = (() => {
+ try {
+ return parseRepAmountInput(normalizedSecurityVaultForm.securityBondAllowanceAmount, 'Security bond allowance')
+ } catch {
+ return undefined
+ }
+ })()
+ const securityBondAllowance = securityVaultDetails?.securityBondAllowance ?? 0n
+ const approvedRep = securityVaultRepAllowance
+ const approvalShortage = depositAmount === undefined || approvedRep === undefined ? undefined : depositAmount > approvedRep ? depositAmount - approvedRep : 0n
+ const withdrawableRepAmount = securityVaultDetails === undefined ? undefined : securityVaultDetails.repDepositShare > securityVaultDetails.lockedRepInEscalationGame ? securityVaultDetails.repDepositShare - securityVaultDetails.lockedRepInEscalationGame : 0n
+ const hasClaimableFees = securityVaultDetails !== undefined && securityVaultDetails.unpaidEthFees > 0n
+ const canClaimFees = accountState.address !== undefined && isMainnet && hasClaimableFees
+ const hasSufficientDepositAllowance = approvedRep !== undefined && depositAmount !== undefined && depositAmount > 0n && approvedRep >= depositAmount
+ const canApproveRep = accountState.address !== undefined && isMainnet && securityVaultDetails !== undefined && depositAmount !== undefined && depositAmount > 0n && approvalShortage !== undefined && approvalShortage > 0n
+ const canSetSecurityBondAllowance = accountState.address !== undefined && isMainnet && securityVaultDetails !== undefined && securityBondAllowanceAmount !== undefined && securityBondAllowanceAmount > 0n
+ const approveButtonLabel = depositAmount === undefined ? 'Approve REP' : approvalShortage === 0n ? 'Approval Satisfied' : `Approve ${formatCurrencyBalance(approvalShortage)} REP`
+ const approveButtonTitle = (() => {
+ if (accountState.address === undefined) return 'Connect a wallet before approving REP.'
+ if (!isMainnet) return 'Switch your wallet to Ethereum mainnet.'
+ if (securityVaultDetails === undefined) return 'Load the vault to calculate the required approval amount.'
+ if (depositAmount === undefined || depositAmount <= 0n) return 'Enter a deposit amount greater than zero.'
+ if (approvalShortage === 0n) return 'No additional REP approval is needed for this deposit amount.'
+ return `Approve ${formatCurrencyBalance(approvalShortage)} more REP before depositing.`
+ })()
+ const latestActionLabel =
+ securityVaultResult === undefined
+ ? undefined
+ : {
+ approveRep: 'Approve REP',
+ depositRep: 'Deposit REP',
+ queueSetSecurityBondAllowance: 'Set Security Bond Allowance',
+ queueWithdrawRep: 'Withdraw REP',
+ redeemFees: 'Redeem Fees',
+ updateVaultFees: 'Update Fees',
+ }[securityVaultResult.action]
+ const autoLoadKey = `${accountState.address ?? ''}:${normalizedSecurityVaultForm.securityPoolAddress}`
+ const hasLoadedCurrentVault = securityVaultDetails !== undefined && securityVaultDetails.vaultAddress.toLowerCase() === accountState.address?.toLowerCase() && securityVaultDetails.securityPoolAddress.toLowerCase() === normalizedSecurityVaultForm.securityPoolAddress.toLowerCase()
+ const lastAutoLoadKey = useRef(undefined)
+
+ useEffect(() => {
+ if (!autoLoadVault) return
+ if (showSecurityPoolAddressInput) return
+ if (accountState.address === undefined) return
+ if (normalizedSecurityVaultForm.securityPoolAddress.trim() === '') return
+ if (hasLoadedCurrentVault || loadingSecurityVault) return
+ if (lastAutoLoadKey.current === autoLoadKey) return
+ lastAutoLoadKey.current = autoLoadKey
+ void onLoadSecurityVault()
+ }, [accountState.address, autoLoadKey, autoLoadVault, hasLoadedCurrentVault, loadingSecurityVault, normalizedSecurityVaultForm.securityPoolAddress, onLoadSecurityVault, showSecurityPoolAddressInput])
+
+ const vaultDetails =
+ securityVaultDetails === undefined ? undefined : (
+
+
+
Vault Details
+
+
+
+ REP Deposit Share
+
+
+
+
+
+ Approved REP
+ {approvedRep === undefined ? Loading... : }
+
+
+ Security Bond Allowance
+
+
+
+
+
+ Unpaid ETH Fees
+
+
+
+
+
+ Locked REP
+
+
+
+
+
+ Total Security Bond Allowance
+
+
+
+
+
+
+
+ Claim Fees
+
+
+
+ )
+
+ const securityBondAllowanceSection =
+ securityVaultDetails === undefined ? undefined : (
+
+
+
Set Security Bond Allowance
+
+
+
+ Current Security Bond Allowance
+
+
+
+
+
+
+
+
+ Set Security Bond Allowance
+
+
+
+ )
+
+ const latestAction =
+ securityVaultResult === undefined ? undefined : (
+
+
+
Latest Vault Action
+
+
Action: {latestActionLabel}
+
+ Transaction:
+
+
+ )
+
+ const vaultLoadSection = (
+
+
+
Load Vault
+
+ {showSecurityPoolAddressInput ? (
+
+ ) : (
+
Uses the selected security pool address from the selected-pool view.
+ )}
+
+
+ {loadingSecurityVault ? Loading Vault... : 'Load My Vault'}
+
+
+
+ )
+
+ const vaultDepositSection = (
+
+
+
Deposit REP
+
+
+
Available REP: {securityVaultRepBalance === undefined ? 'Load the vault to fetch your balance.' : }
+
+ onApproveRep(approvalShortage)} disabled={!canApproveRep}>
+ {approveButtonLabel}
+
+
+ Create / Deposit REP
+
+
+ {depositAmount === undefined ? undefined : approvalShortage === undefined ? undefined : approvalShortage > 0n ? (
+
Need {} more REP approved before depositing.
+ ) : (
+
No additional REP approval is needed for this deposit amount.
+ )}
+
+ )
+
+ const vaultRepSection = (
+
+
+
Withdraw REP
+
+ {withdrawableRepAmount === undefined ? (
+
Load the vault to calculate withdrawable REP.
+ ) : (
+
+
+ Withdrawable REP
+
+
+
+
+
+ )}
+
Withdrawals are queued through the oracle manager.
+
+
+
+ Withdraw REP
+
+
+
+ )
+
+ if (compactLayout) {
+ return (
+ <>
+ {vaultDetails}
+ {latestAction}
+ {vaultLoadSection}
+ {vaultDepositSection}
+ {securityBondAllowanceSection}
+ {vaultRepSection}
+ {securityVaultError === undefined ? undefined : {securityVaultError}
}
+ >
+ )
+ }
+
return (
{showHeader ? (
@@ -39,36 +316,22 @@ export function SecurityVaultSection({
{securityVaultDetails === undefined ? undefined : (
-
} badge={
Your Vault}>
+
-
-
- Universe
+ REP Deposit Share
-
+
- REP Deposit Share
-
-
-
+ Approved REP
+ {approvedRep === undefined ? Loading... : }
Security Bond Allowance
-
+
@@ -80,21 +343,26 @@ export function SecurityVaultSection({
Locked REP
-
+
- Total Bond Allowance
+ Total Security Bond Allowance
-
+
+
+
+ Claim Fees
+
+
)}
{securityVaultResult === undefined ? undefined : (
-
{securityVaultResult.action}}>
+
Action
@@ -112,51 +380,12 @@ export function SecurityVaultSection({
-
- {showSecurityPoolAddressInput ? (
-
- ) : undefined}
-
-
-
- {loadingSecurityVault ? Loading Vault... : 'Load My Vault'}
-
-
-
-
-
-
-
-
-
- Approve REP
-
-
- Create / Deposit REP
-
-
-
-
-
- Update Vault Fees
-
-
- Redeem Fees
-
-
- Redeem REP
-
-
-
+
+ {vaultLoadSection}
+ {vaultDepositSection}
+ {securityBondAllowanceSection}
+ {vaultRepSection}
+
{securityVaultError === undefined ? undefined :
{securityVaultError}
}
diff --git a/ui/ts/components/TradingSection.tsx b/ui/ts/components/TradingSection.tsx
index 0931848..6443236 100644
--- a/ui/ts/components/TradingSection.tsx
+++ b/ui/ts/components/TradingSection.tsx
@@ -1,4 +1,5 @@
import { EnumDropdown } from './EnumDropdown.js'
+import { EntityCard } from './EntityCard.js'
import { TransactionHashLink } from './TransactionHashLink.js'
import { UniverseLink } from './UniverseLink.js'
import { isMainnetChain } from '../lib/network.js'
@@ -22,8 +23,7 @@ export function TradingSection({ accountState, onCreateCompleteSet, onMigrateSha
{tradingResult === undefined ? undefined : (
-
-
Latest Trading Action
+
{tradingResult.action}}>
Action: {tradingResult.action}
Pool: {tradingResult.securityPoolAddress}
@@ -32,56 +32,58 @@ export function TradingSection({ accountState, onCreateCompleteSet, onMigrateSha
Transaction:
-
+
)}
-
- {showSecurityPoolAddressInput ? (
-
- ) : undefined}
+
manage}>
+
+
{tradingError === undefined ? undefined :
{tradingError}
}
diff --git a/ui/ts/components/ZoltarMigrationSection.tsx b/ui/ts/components/ZoltarMigrationSection.tsx
index 17f1671..adee8ac 100644
--- a/ui/ts/components/ZoltarMigrationSection.tsx
+++ b/ui/ts/components/ZoltarMigrationSection.tsx
@@ -6,7 +6,7 @@ import { FormInput } from './FormInput.js'
import { LoadingText } from './LoadingText.js'
import { TransactionHashLink } from './TransactionHashLink.js'
import { UniverseLink } from './UniverseLink.js'
-import { MigrationOutcomeUniversesSection } from './MigrationOutcomeUniversesSection.js'
+import { getMigrationOutcomeSplitLimit, MigrationOutcomeUniversesSection } from './MigrationOutcomeUniversesSection.js'
import { formatCurrencyBalance, formatCurrencyInputBalance } from '../lib/formatters.js'
import { parseBigIntListInput } from '../lib/inputs.js'
import { parseRepAmountInput as parseMigrationAmountInput } from '../lib/marketForm.js'
@@ -22,6 +22,9 @@ type ZoltarMigrationSectionProps = {
onPrepareRepForMigration: () => void
onZoltarMigrationFormChange: (update: Partial
) => void
zoltarForkRepBalance: bigint | undefined
+ zoltarForkAllowance: bigint | undefined
+ zoltarForkActiveAction: 'approve' | 'fork' | undefined
+ zoltarForkPending: boolean
zoltarMigrationChildRepBalances: Record
zoltarMigrationActiveAction: 'prepare' | 'split' | undefined
zoltarMigrationError: string | undefined
@@ -31,6 +34,7 @@ type ZoltarMigrationSectionProps = {
zoltarMigrationResult: ZoltarMigrationActionResult | undefined
zoltarUniverse: ZoltarUniverseSummary | undefined
zoltarUniverseMissing: boolean
+ onApproveZoltarForkRep: (amount?: bigint) => void
}
function getMigrationAmount(value: string) {
@@ -68,6 +72,9 @@ export function ZoltarMigrationSection({
onPrepareRepForMigration,
onZoltarMigrationFormChange,
zoltarForkRepBalance,
+ zoltarForkAllowance,
+ zoltarForkActiveAction,
+ zoltarForkPending,
zoltarMigrationChildRepBalances,
zoltarMigrationActiveAction,
zoltarMigrationError,
@@ -77,6 +84,7 @@ export function ZoltarMigrationSection({
zoltarMigrationResult,
zoltarUniverse,
zoltarUniverseMissing,
+ onApproveZoltarForkRep,
}: ZoltarMigrationSectionProps) {
const rootUniverse = zoltarUniverse
const universeMissing = rootUniverse === undefined && zoltarUniverseMissing && !loadingZoltarUniverse
@@ -103,19 +111,45 @@ export function ZoltarMigrationSection({
const amountExceedsAvailableRep = hasValidAmount && migrationAmount !== undefined && migrationAmount > totalRepAvailable
const hasEnoughRep = hasValidAmount && zoltarForkRepBalance !== undefined && zoltarForkRepBalance >= missingPreparationAmount
const hasPreparedBalance = hasValidAmount && zoltarMigrationPreparedRepBalance !== undefined && zoltarMigrationPreparedRepBalance >= migrationAmount
+ const approvedRep = zoltarForkAllowance ?? 0n
+ const approvalShortage = missingPreparationAmount > approvedRep ? missingPreparationAmount - approvedRep : 0n
+ const hasSufficientAllowance = zoltarForkAllowance !== undefined && approvalShortage === 0n
const hasValidOutcomeIndexes = selectedOutcomeIndexes.length > 0
const needsAdditionalPreparation = missingPreparationAmount > 0n
- const canPrepare = accountAddress !== undefined && isMainnet && rootUniverse !== undefined && hasForked && !zoltarMigrationPending && hasValidAmount && needsAdditionalPreparation && hasEnoughRep
- const canSplit = accountAddress !== undefined && isMainnet && rootUniverse !== undefined && hasForked && !zoltarMigrationPending && hasValidAmount && hasPreparedBalance && hasValidOutcomeIndexes
+ const splitLimit = useMemo(() => getMigrationOutcomeSplitLimit(rootUniverse?.childUniverses ?? [], zoltarMigrationChildRepBalances, zoltarMigrationPreparedRepBalance, selectedOutcomeIndexSet), [rootUniverse?.childUniverses, selectedOutcomeIndexSet, zoltarMigrationChildRepBalances, zoltarMigrationPreparedRepBalance])
+ const hasSufficientSplitLimit = migrationAmount !== undefined && splitLimit !== undefined && migrationAmount <= splitLimit
+ const canPrepare = accountAddress !== undefined && isMainnet && rootUniverse !== undefined && hasForked && !zoltarMigrationPending && hasValidAmount && needsAdditionalPreparation && hasEnoughRep && hasSufficientAllowance
+ const canApproveRep = accountAddress !== undefined && isMainnet && rootUniverse !== undefined && hasForked && !zoltarForkPending && hasValidAmount && approvalShortage > 0n
+ const canSplit = accountAddress !== undefined && isMainnet && rootUniverse !== undefined && hasForked && !zoltarMigrationPending && hasValidAmount && hasPreparedBalance && hasValidOutcomeIndexes && hasSufficientSplitLimit
const migrationAmountSource = getMigrationAmountSource(zoltarMigrationPreparedRepBalance, zoltarForkRepBalance)
+ const approveButtonLabel = !hasValidAmount || migrationAmount === undefined ? 'Approve REP' : approvalShortage > 0n ? `Approve ${formatCurrencyBalance(approvalShortage)} REP` : 'Approval Satisfied'
+ const approveButtonTitle = (() => {
+ const guard = getMigrationGuardMessage(accountAddress, isMainnet, rootUniverse, loadingZoltarForkAccess, hasForked, loadingZoltarUniverse, 'Fork Zoltar before preparing REP.')
+ if (guard !== undefined) return guard
+ if (!hasValidAmount || migrationAmount === undefined) return 'Enter an amount greater than zero.'
+ if (approvalShortage === 0n) return 'No additional REP approval is needed for this amount.'
+ return `Approve ${formatCurrencyBalance(approvalShortage)} more REP for Zoltar before preparing the selected amount.`
+ })()
+ const getAlreadyPreparedHint = () => {
+ if (hasValidOutcomeIndexes && splitLimit === 0n) {
+ return 'This amount is already fully split across the selected universes.'
+ }
+ return 'This amount is already in your migration balance. Split REP when ready.'
+ }
const prepareHintMessage = (() => {
const guard = getMigrationGuardMessage(accountAddress, isMainnet, rootUniverse, loadingZoltarForkAccess, hasForked, loadingZoltarUniverse, 'Fork Zoltar before preparing REP.')
if (guard !== undefined) return guard
if (!hasValidAmount || migrationAmount === undefined) return 'Enter an amount greater than zero.'
- if (missingPreparationAmount === 0n) return 'This amount is already in your migration balance. Split REP when ready.'
+ if (missingPreparationAmount === 0n) return getAlreadyPreparedHint()
if (zoltarForkRepBalance === undefined || zoltarForkRepBalance < missingPreparationAmount) {
return `Need ${formatCurrencyBalance(missingPreparationAmount)} more REP in this universe to prepare the selected amount.`
}
+ if (approvalShortage > 0n) {
+ return `Approve ${formatCurrencyBalance(approvalShortage)} more REP for Zoltar before preparing the selected amount.`
+ }
+ if (!hasSufficientAllowance) {
+ return 'Waiting for approved REP amount before preparing the selected amount.'
+ }
return `Add ${formatCurrencyBalance(missingPreparationAmount)} REP to your migration balance from this universe, then split it across the selected universes.`
})()
const splitHintMessage = (() => {
@@ -126,6 +160,15 @@ export function ZoltarMigrationSection({
return `Add ${formatCurrencyBalance(missingPreparationAmount ?? 0n)} REP to your migration balance first, then split it across the selected universes.`
}
if (!hasValidOutcomeIndexes) return 'Select at least one outcome universe.'
+ if (splitLimit === undefined) {
+ return 'Loading outcome universe balances...'
+ }
+ if (splitLimit === 0n) {
+ return 'This amount is already fully split across the selected universes.'
+ }
+ if (!hasSufficientSplitLimit) {
+ return `The selected universes only have ${formatCurrencyBalance(splitLimit)} REP of room left for this amount. Reduce the amount or choose different universes.`
+ }
return 'Split the migration REP across the selected universes.'
})()
const migrationAmountHintMessage = (() => {
@@ -135,7 +178,7 @@ export function ZoltarMigrationSection({
if (amountExceedsAvailableRep) {
return `You only have ${formatCurrencyBalance(totalRepAvailable)} REP available for migration in this universe (${formatCurrencyBalance(zoltarMigrationPreparedRepBalance ?? 0n)} in your migration balance and ${formatCurrencyBalance(zoltarForkRepBalance ?? 0n)} wallet REP).`
}
- if (missingPreparationAmount === 0n) return 'This amount is already in your migration balance. Split REP when ready.'
+ if (missingPreparationAmount === 0n) return getAlreadyPreparedHint()
return `Add ${formatCurrencyBalance(missingPreparationAmount)} REP to your migration balance from this universe, then split it across the selected universes.`
})()
const selectAllAmount = () => {
@@ -177,15 +220,21 @@ export function ZoltarMigrationSection({
Migration REP Balance
-
+
+
+
+
+
Approved REP
+
+
+ {approvalShortage > 0n ?
Need {formatCurrencyBalance(approvalShortage)} more REP approved before preparing the current amount.
: undefined}
Universe
{rootUniverse === undefined ? Loading universe data... : }
-
Migration Amount
@@ -212,11 +261,14 @@ export function ZoltarMigrationSection({
)}
+ onApproveZoltarForkRep(approvalShortage)} disabled={!canApproveRep || zoltarForkActiveAction === 'approve'}>
+ {zoltarForkActiveAction === 'approve' ? Approving REP... : approveButtonLabel}
+
- {zoltarMigrationActiveAction === 'prepare' ? Prepare REP : 'Prepare REP'}
+ {zoltarMigrationActiveAction === 'prepare' ? Preparing REP... : 'Prepare REP'}
-
- {zoltarMigrationActiveAction === 'split' ? Split REP : 'Split REP'}
+
+ {zoltarMigrationActiveAction === 'split' ? Splitting REP... : 'Split REP'}
diff --git a/ui/ts/contracts.ts b/ui/ts/contracts.ts
index c017cca..fcf7254 100644
--- a/ui/ts/contracts.ts
+++ b/ui/ts/contracts.ts
@@ -1,4 +1,4 @@
-import { encodeAbiParameters, encodeDeployData, getAddress, getContractAddress, getCreate2Address, keccak256, numberToBytes, parseAbiItem, toHex, zeroAddress, type Address, type Hash, type Hex } from 'viem'
+import { encodeAbiParameters, encodeDeployData, getAddress, getContractAddress, getCreate2Address, keccak256, numberToBytes, parseAbiItem, toHex, zeroAddress, RpcError, type Address, type Hash, type Hex } from 'viem'
import { ABIS } from './abis.js'
import { assertNever } from './lib/assert.js'
import {
@@ -311,6 +311,21 @@ async function deployViaProxy(client: WriteClient, bytecode: Hex) {
return hash
}
+type ContractCallParams = Parameters
[0]
+
+async function getContractRevertReason(client: ReadClient | WriteClient, params: ContractCallParams) {
+ try {
+ await client.call(params as Parameters[0])
+ return undefined
+ } catch (error) {
+ if (error instanceof RpcError) {
+ return error.shortMessage ?? error.message ?? (error.cause instanceof Error ? error.cause.message : undefined)
+ }
+ if (error instanceof Error) return error.message
+ return undefined
+ }
+}
+
async function writeContractAndWait(client: WriteClient, write: () => Promise) {
const hash = await write()
const receipt = await client.waitForTransactionReceipt({ hash })
@@ -1041,13 +1056,19 @@ export async function originSecurityPoolExists(client: Pick {
- const [currentRetentionRate, poolOwnershipDenominator, repToken, totalSecurityBondAllowance, universeId, vaultData] = await Promise.all([
+ const [currentRetentionRate, managerAddress, poolOwnershipDenominator, repToken, totalSecurityBondAllowance, universeId, vaultData] = await Promise.all([
client.readContract({
abi: peripherals_SecurityPool_SecurityPool.abi,
functionName: 'currentRetentionRate',
address: securityPoolAddress,
args: [],
}),
+ client.readContract({
+ abi: peripherals_SecurityPool_SecurityPool.abi,
+ functionName: 'priceOracleManagerAndOperatorQueuer',
+ address: securityPoolAddress,
+ args: [],
+ }),
client.readContract({
abi: peripherals_SecurityPool_SecurityPool.abi,
functionName: 'poolOwnershipDenominator',
@@ -1080,6 +1101,7 @@ export async function loadSecurityVaultDetails(client: ReadClient, securityPoolA
return {
currentRetentionRate: currentRetentionRate,
lockedRepInEscalationGame,
+ managerAddress,
poolOwnershipDenominator: poolOwnershipDenominator,
repDepositShare,
repToken,
@@ -1203,22 +1225,7 @@ export async function redeemSecurityVaultFees(client: WriteClient, securityPoolA
} satisfies SecurityVaultActionResult
}
-export async function redeemSecurityVaultRep(client: WriteClient, securityPoolAddress: Address, vaultAddress: Address) {
- const hash = await writeContractAndWait(client, () =>
- client.writeContract({
- address: securityPoolAddress,
- abi: peripherals_SecurityPool_SecurityPool.abi,
- functionName: 'redeemRep',
- args: [vaultAddress],
- }),
- )
- return {
- action: 'redeemRep',
- hash,
- } satisfies SecurityVaultActionResult
-}
-
-export async function loadOracleManagerDetails(client: ReadClient, managerAddress: Address): Promise {
+export async function loadOracleManagerDetails(client: ReadClient, managerAddress: Address, openOracleAddress?: Address): Promise {
const [lastPrice, pendingReportId, requestPriceEthCost] = await Promise.all([
client.readContract({
abi: peripherals_PriceOracleManagerAndOperatorQueuer_PriceOracleManagerAndOperatorQueuer.abi,
@@ -1240,6 +1247,8 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres
}),
])
+ const resolvedOracleAddress = openOracleAddress ?? getInfraContractAddresses().openOracle
+
let callbackStateHash: Hex | undefined
let exactToken1Report: bigint | undefined
let token1: Address | undefined
@@ -1249,14 +1258,14 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres
const extraData = await client.readContract({
abi: peripherals_openOracle_OpenOracle_OpenOracle.abi,
functionName: 'extraData',
- address: getInfraContractAddresses().openOracle,
+ address: resolvedOracleAddress,
args: [pendingReportId],
})
const reportMeta = await client.readContract({
abi: peripherals_openOracle_OpenOracle_OpenOracle.abi,
functionName: 'reportMeta',
- address: getInfraContractAddresses().openOracle,
+ address: resolvedOracleAddress,
args: [pendingReportId],
})
@@ -1271,7 +1280,7 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres
exactToken1Report,
lastPrice,
managerAddress,
- openOracleAddress: getInfraContractAddresses().openOracle,
+ openOracleAddress: resolvedOracleAddress,
pendingReportId,
requestPriceEthCost,
token1,
@@ -1279,6 +1288,58 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres
}
}
+export async function loadOpenOracleReportDetails(client: ReadClient, openOracleAddress: Address, reportId: bigint): Promise {
+ const [meta, status, extra] = await Promise.all([
+ client.readContract({
+ abi: peripherals_openOracle_OpenOracle_OpenOracle.abi,
+ functionName: 'reportMeta',
+ address: openOracleAddress,
+ args: [reportId],
+ }),
+ client.readContract({
+ abi: peripherals_openOracle_OpenOracle_OpenOracle.abi,
+ functionName: 'reportStatus',
+ address: openOracleAddress,
+ args: [reportId],
+ }),
+ client.readContract({
+ abi: peripherals_openOracle_OpenOracle_OpenOracle.abi,
+ functionName: 'extraData',
+ address: openOracleAddress,
+ args: [reportId],
+ }),
+ ])
+
+ return {
+ reportId,
+ openOracleAddress,
+ exactToken1Report: meta[0],
+ escalationHalt: meta[1],
+ fee: meta[2],
+ settlerReward: meta[3],
+ token1: meta[4],
+ settlementTime: BigInt(meta[5]),
+ token2: meta[6],
+ timeType: meta[7],
+ feePercentage: BigInt(meta[8]),
+ protocolFee: BigInt(meta[9]),
+ multiplier: BigInt(meta[10]),
+ disputeDelay: BigInt(meta[11]),
+ currentAmount1: status[0],
+ currentAmount2: status[1],
+ price: status[2],
+ currentReporter: status[3],
+ reportTimestamp: BigInt(status[4]),
+ settlementTimestamp: BigInt(status[5]),
+ initialReporter: status[6],
+ disputeOccurred: status[8],
+ isDistributed: status[9],
+ stateHash: extra[0],
+ callbackContract: extra[1],
+ numReports: BigInt(extra[2]),
+ }
+}
+
export async function requestOraclePrice(client: WriteClient, managerAddress: Address, ethCost: bigint) {
const hash = await writeContractAndWait(client, () =>
client.writeContract({
@@ -1295,10 +1356,10 @@ export async function requestOraclePrice(client: WriteClient, managerAddress: Ad
} satisfies OpenOracleActionResult
}
-export async function submitInitialOracleReport(client: WriteClient, reportId: bigint, amount1: bigint, amount2: bigint, stateHash: Hex) {
+export async function submitInitialOracleReport(client: WriteClient, openOracleAddress: Address, reportId: bigint, amount1: bigint, amount2: bigint, stateHash: Hex) {
const hash = await writeContractAndWait(client, () =>
client.writeContract({
- address: getInfraContractAddresses().openOracle,
+ address: openOracleAddress,
abi: peripherals_openOracle_OpenOracle_OpenOracle.abi,
functionName: 'submitInitialReport',
args: [reportId, amount1, amount2, stateHash],
@@ -1310,10 +1371,10 @@ export async function submitInitialOracleReport(client: WriteClient, reportId: b
} satisfies OpenOracleActionResult
}
-export async function settleOracleReport(client: WriteClient, reportId: bigint) {
+export async function settleOracleReport(client: WriteClient, openOracleAddress: Address, reportId: bigint) {
const hash = await writeContractAndWait(client, () =>
client.writeContract({
- address: getInfraContractAddresses().openOracle,
+ address: openOracleAddress,
abi: peripherals_openOracle_OpenOracle_OpenOracle.abi,
functionName: 'settle',
args: [reportId],
@@ -1325,6 +1386,21 @@ export async function settleOracleReport(client: WriteClient, reportId: bigint)
} satisfies OpenOracleActionResult
}
+export async function disputeOracleReport(client: WriteClient, openOracleAddress: Address, reportId: bigint, tokenToSwap: Address, newAmount1: bigint, newAmount2: bigint, amt2Expected: bigint, stateHash: Hex) {
+ const hash = await writeContractAndWait(client, () =>
+ client.writeContract({
+ address: openOracleAddress,
+ abi: peripherals_openOracle_OpenOracle_OpenOracle.abi,
+ functionName: 'disputeAndSwap',
+ args: [reportId, tokenToSwap, newAmount1, newAmount2, amt2Expected, stateHash],
+ }),
+ )
+ return {
+ action: 'dispute',
+ hash,
+ } satisfies OpenOracleActionResult
+}
+
export async function loadForkAuctionDetails(client: ReadClient, securityPoolAddress: Address): Promise {
const [questionId, parentSecurityPoolAddress, universeId, systemStateValue, truthAuctionAddress, completeSetCollateralAmount, forkData, block, questionOutcome] = await Promise.all([
client.readContract({
@@ -1574,57 +1650,41 @@ export async function createZoltarChildUniverse(client: WriteClient, universeId:
} satisfies ZoltarChildUniverseActionResult
}
-async function executeZoltarMigrationAction(client: WriteClient, action: ZoltarMigrationActionResult['action'], universeId: bigint, amount: bigint, outcomeIndexes: bigint[], request: () => Promise) {
- const hash = await request()
- const receipt = await client.waitForTransactionReceipt({ hash })
- if (receipt.status === 'reverted') {
- throw new Error('Transaction reverted')
+async function executeZoltarMigrationAction(client: WriteClient, action: ZoltarMigrationActionResult['action'], universeId: bigint, amount: bigint, outcomeIndexes: bigint[], callParams: ContractCallParams) {
+ try {
+ const hash = await writeContractAndWait(client, () => client.writeContract(callParams))
+ return {
+ action,
+ amount,
+ hash,
+ outcomeIndexes,
+ universeId,
+ } satisfies ZoltarMigrationActionResult
+ } catch (error) {
+ const reason = await getContractRevertReason(client, callParams)
+ const message = reason ?? (error instanceof Error ? error.message : 'Transaction reverted')
+ throw new Error(message)
}
- return {
- action,
- amount,
- hash,
- outcomeIndexes,
- universeId,
- } satisfies ZoltarMigrationActionResult
}
export async function prepareRepForMigrationInZoltar(client: WriteClient, universeId: bigint, amount: bigint) {
- return await executeZoltarMigrationAction(
- client,
- 'addRepToMigrationBalance',
- universeId,
- amount,
- [],
- async () =>
- await writeContractAndWait(client, () =>
- client.writeContract({
- address: getDeploymentStep('zoltar').address,
- abi: Zoltar_Zoltar.abi,
- functionName: 'addRepToMigrationBalance',
- args: [universeId, amount],
- }),
- ),
- )
+ const callParams: ContractCallParams = {
+ address: getDeploymentStep('zoltar').address,
+ abi: Zoltar_Zoltar.abi,
+ functionName: 'addRepToMigrationBalance',
+ args: [universeId, amount],
+ }
+ return await executeZoltarMigrationAction(client, 'addRepToMigrationBalance', universeId, amount, [], callParams)
}
export async function migrateInternalRepInZoltar(client: WriteClient, universeId: bigint, amount: bigint, outcomeIndexes: bigint[]) {
- return await executeZoltarMigrationAction(
- client,
- 'splitMigrationRep',
- universeId,
- amount,
- outcomeIndexes,
- async () =>
- await writeContractAndWait(client, () =>
- client.writeContract({
- address: getDeploymentStep('zoltar').address,
- abi: Zoltar_Zoltar.abi,
- functionName: 'splitMigrationRep',
- args: [universeId, amount, outcomeIndexes],
- }),
- ),
- )
+ const callParams: ContractCallParams = {
+ address: getDeploymentStep('zoltar').address,
+ abi: Zoltar_Zoltar.abi,
+ functionName: 'splitMigrationRep',
+ args: [universeId, amount, outcomeIndexes],
+ }
+ return await executeZoltarMigrationAction(client, 'splitMigrationRep', universeId, amount, outcomeIndexes, callParams)
}
export async function migrateRepToZoltarFromSecurityPool(client: WriteClient, securityPoolAddress: Address, universeId: bigint, outcomes: ReportingOutcomeKey[]) {
diff --git a/ui/ts/hooks/useOnchainState.ts b/ui/ts/hooks/useOnchainState.ts
index ed5c19f..86a9911 100644
--- a/ui/ts/hooks/useOnchainState.ts
+++ b/ui/ts/hooks/useOnchainState.ts
@@ -69,7 +69,9 @@ export function useOnchainState() {
const nextRefresh = useRequestGuard()
const errorMessage = useSignal(undefined)
const setDeploymentStatuses = (update: (current: DeploymentStatus[]) => DeploymentStatus[]) => {
- deploymentStatuses.value = update(deploymentStatuses.value)
+ const updated = update(deploymentStatuses.value)
+ deploymentStatuses.value = updated
+ if (updated.every(step => step.deployed)) augurPlaceHolderDeployed.value = true
}
const refreshState = async (options: RefreshStateOptions = {}) => {
diff --git a/ui/ts/hooks/useOpenOracleOperations.ts b/ui/ts/hooks/useOpenOracleOperations.ts
index 05a9242..6b785ca 100644
--- a/ui/ts/hooks/useOpenOracleOperations.ts
+++ b/ui/ts/hooks/useOpenOracleOperations.ts
@@ -1,6 +1,6 @@
import { useSignal } from '@preact/signals'
import type { Address, Hash } from 'viem'
-import { approveErc20, loadOracleManagerDetails, queueOracleManagerOperation, requestOraclePrice, settleOracleReport, submitInitialOracleReport } from '../contracts.js'
+import { approveErc20, disputeOracleReport, loadOpenOracleReportDetails, loadOracleManagerDetails, queueOracleManagerOperation, requestOraclePrice, settleOracleReport, submitInitialOracleReport } from '../contracts.js'
import { createConnectedReadClient, createWalletWriteClient } from '../lib/clients.js'
import { getErrorMessage } from '../lib/errors.js'
import { runWriteAction } from '../lib/writeAction.js'
@@ -8,7 +8,7 @@ import { parseAddressInput, parseBytes32Input, parseOracleQueueOperationInput, p
import { parseBigIntInput } from '../lib/marketForm.js'
import { getDefaultOpenOracleFormState } from '../lib/marketForm.js'
import type { OpenOracleFormState } from '../types/app.js'
-import type { OpenOracleActionResult, OracleManagerDetails } from '../types/contracts.js'
+import type { OpenOracleActionResult, OpenOracleReportDetails, OracleManagerDetails } from '../types/contracts.js'
type UseOpenOracleOperationsParameters = {
accountAddress: Address | undefined
@@ -21,22 +21,31 @@ type UseOpenOracleOperationsParameters = {
export function useOpenOracleOperations({ accountAddress, onTransaction, onTransactionFinished, onTransactionRequested, onTransactionSubmitted, refreshState }: UseOpenOracleOperationsParameters) {
const loadingOracleManager = useSignal(false)
+ const loadingOracleReport = useSignal(false)
const openOracleError = useSignal(undefined)
const openOracleForm = useSignal(getDefaultOpenOracleFormState())
const openOracleResult = useSignal(undefined)
const oracleManagerDetails = useSignal(undefined)
+ const openOracleReportDetails = useSignal(undefined)
+
+ const getOpenOracleAddress = () => {
+ const addr = openOracleForm.value.openOracleAddress.trim()
+ return addr !== '' ? parseAddressInput(addr, 'OpenOracle address') : undefined
+ }
const loadOracleManager = async () => {
loadingOracleManager.value = true
openOracleError.value = undefined
try {
const managerAddress = parseAddressInput(openOracleForm.value.managerAddress, 'Manager address')
- const details = await loadOracleManagerDetails(createConnectedReadClient(), managerAddress)
+ const openOracleAddress = getOpenOracleAddress()
+ const details = await loadOracleManagerDetails(createConnectedReadClient(), managerAddress, openOracleAddress)
oracleManagerDetails.value = details
const current = openOracleForm.value
openOracleForm.value = {
...current,
amount1: details.exactToken1Report?.toString() ?? current.amount1,
+ openOracleAddress: details.openOracleAddress,
reportId: details.pendingReportId === 0n ? current.reportId : details.pendingReportId.toString(),
stateHash: details.callbackStateHash ?? current.stateHash,
}
@@ -48,6 +57,27 @@ export function useOpenOracleOperations({ accountAddress, onTransaction, onTrans
}
}
+ const loadOracleReport = async () => {
+ loadingOracleReport.value = true
+ openOracleError.value = undefined
+ try {
+ const openOracleAddress = parseAddressInput(openOracleForm.value.openOracleAddress, 'OpenOracle address')
+ const reportId = parseReportIdInput(openOracleForm.value.reportId)
+ const details = await loadOpenOracleReportDetails(createConnectedReadClient(), openOracleAddress, reportId)
+ openOracleReportDetails.value = details
+ openOracleForm.value = {
+ ...openOracleForm.value,
+ amount1: details.exactToken1Report.toString(),
+ stateHash: details.stateHash,
+ }
+ } catch (error) {
+ openOracleReportDetails.value = undefined
+ openOracleError.value = getErrorMessage(error, 'Failed to load oracle report')
+ } finally {
+ loadingOracleReport.value = false
+ }
+ }
+
const runOracleAction = async (action: (walletAddress: Address) => Promise, errorFallback: string) =>
await runWriteAction(
{
@@ -68,35 +98,50 @@ export function useOpenOracleOperations({ accountAddress, onTransaction, onTrans
errorFallback,
async result => {
openOracleResult.value = result
+ if (openOracleForm.value.openOracleAddress.trim() !== '' && openOracleForm.value.reportId.trim() !== '') {
+ await loadOracleReport()
+ }
if (openOracleForm.value.managerAddress.trim() !== '') {
await loadOracleManager()
}
},
)
+ const resolveOpenOracleAddress = (): Address => {
+ const fromForm = openOracleForm.value.openOracleAddress.trim()
+ if (fromForm !== '') return parseAddressInput(fromForm, 'OpenOracle address')
+ const fromManager = oracleManagerDetails.value?.openOracleAddress
+ if (fromManager !== undefined) return fromManager
+ throw new Error('Enter an OpenOracle address or load an oracle manager first')
+ }
+
const approveToken1 = async () =>
await runOracleAction(async walletAddress => {
- const details = oracleManagerDetails.value
- if (details?.token1 === undefined) throw new Error('Load an oracle report first')
- return await approveErc20(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), details.token1, details.openOracleAddress, parseBigIntInput(openOracleForm.value.amount1, 'Token1 amount'), 'approveToken1')
+ const details = oracleManagerDetails.value ?? openOracleReportDetails.value
+ const token1 = details !== undefined && 'token1' in details ? details.token1 : undefined
+ if (token1 === undefined) throw new Error('Load an oracle report or manager first')
+ const openOracleAddress = resolveOpenOracleAddress()
+ return await approveErc20(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), token1, openOracleAddress, parseBigIntInput(openOracleForm.value.amount1, 'Token1 amount'), 'approveToken1')
}, 'Failed to approve token1')
const approveToken2 = async () =>
await runOracleAction(async walletAddress => {
- const details = oracleManagerDetails.value
- if (details?.token2 === undefined) throw new Error('Load an oracle report first')
- return await approveErc20(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), details.token2, details.openOracleAddress, parseBigIntInput(openOracleForm.value.amount2, 'Token2 amount'), 'approveToken2')
+ const details = oracleManagerDetails.value ?? openOracleReportDetails.value
+ const token2 = details !== undefined && 'token2' in details ? details.token2 : undefined
+ if (token2 === undefined) throw new Error('Load an oracle report or manager first')
+ const openOracleAddress = resolveOpenOracleAddress()
+ return await approveErc20(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), token2, openOracleAddress, parseBigIntInput(openOracleForm.value.amount2, 'Token2 amount'), 'approveToken2')
}, 'Failed to approve token2')
const requestPrice = async () =>
await runOracleAction(async walletAddress => {
- const details = oracleManagerDetails.value ?? (await loadOracleManagerDetails(createConnectedReadClient(), parseAddressInput(openOracleForm.value.managerAddress, 'Manager address')))
+ const details = oracleManagerDetails.value ?? (await loadOracleManagerDetails(createConnectedReadClient(), parseAddressInput(openOracleForm.value.managerAddress, 'Manager address'), getOpenOracleAddress()))
return await requestOraclePrice(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), details.managerAddress, details.requestPriceEthCost)
}, 'Failed to request price')
const queueOperation = async () =>
await runOracleAction(async walletAddress => {
- const details = oracleManagerDetails.value ?? (await loadOracleManagerDetails(createConnectedReadClient(), parseAddressInput(openOracleForm.value.managerAddress, 'Manager address')))
+ const details = oracleManagerDetails.value ?? (await loadOracleManagerDetails(createConnectedReadClient(), parseAddressInput(openOracleForm.value.managerAddress, 'Manager address'), getOpenOracleAddress()))
return await queueOracleManagerOperation(
createWalletWriteClient(walletAddress, { onTransactionSubmitted }),
details.managerAddress,
@@ -112,6 +157,7 @@ export function useOpenOracleOperations({ accountAddress, onTransaction, onTrans
async walletAddress =>
await submitInitialOracleReport(
createWalletWriteClient(walletAddress, { onTransactionSubmitted }),
+ resolveOpenOracleAddress(),
parseReportIdInput(openOracleForm.value.reportId),
parseBigIntInput(openOracleForm.value.amount1, 'Token1 amount'),
parseBigIntInput(openOracleForm.value.amount2, 'Token2 amount'),
@@ -120,17 +166,39 @@ export function useOpenOracleOperations({ accountAddress, onTransaction, onTrans
'Failed to submit initial report',
)
- const settleReport = async () => await runOracleAction(async walletAddress => await settleOracleReport(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), parseReportIdInput(openOracleForm.value.reportId)), 'Failed to settle report')
+ const settleReport = async () => await runOracleAction(async walletAddress => await settleOracleReport(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), resolveOpenOracleAddress(), parseReportIdInput(openOracleForm.value.reportId)), 'Failed to settle report')
+
+ const disputeReport = async () =>
+ await runOracleAction(async walletAddress => {
+ const reportDetails = openOracleReportDetails.value
+ if (reportDetails === undefined) throw new Error('Load an oracle report first')
+ const form = openOracleForm.value
+ const tokenToSwap = form.disputeTokenToSwap === 'token1' ? reportDetails.token1 : reportDetails.token2
+ return await disputeOracleReport(
+ createWalletWriteClient(walletAddress, { onTransactionSubmitted }),
+ resolveOpenOracleAddress(),
+ reportDetails.reportId,
+ tokenToSwap,
+ parseBigIntInput(form.disputeNewAmount1, 'New token1 amount'),
+ parseBigIntInput(form.disputeNewAmount2, 'New token2 amount'),
+ reportDetails.currentAmount2,
+ reportDetails.stateHash,
+ )
+ }, 'Failed to dispute report')
return {
approveToken1,
approveToken2,
+ disputeReport,
loadOracleManager,
+ loadOracleReport,
loadingOracleManager: loadingOracleManager.value,
+ loadingOracleReport: loadingOracleReport.value,
onRequestPrice: requestPrice,
onQueueOperation: queueOperation,
openOracleError: openOracleError.value,
openOracleForm: openOracleForm.value,
+ openOracleReportDetails: openOracleReportDetails.value,
openOracleResult: openOracleResult.value,
oracleManagerDetails: oracleManagerDetails.value,
setOpenOracleForm: (updater: (current: OpenOracleFormState) => OpenOracleFormState) => {
diff --git a/ui/ts/hooks/useRepPrices.ts b/ui/ts/hooks/useRepPrices.ts
new file mode 100644
index 0000000..4d3586b
--- /dev/null
+++ b/ui/ts/hooks/useRepPrices.ts
@@ -0,0 +1,68 @@
+import { useSignal } from '@preact/signals'
+import { useEffect } from 'preact/hooks'
+import { createConnectedReadClient } from '../lib/clients.js'
+import { quoteRepForEth, quoteRepForEthV3, quoteRepForUsdcV4 } from '../lib/uniswapQuoter.js'
+
+const ONE_REP = 10n ** 18n
+
+type PriceSource = 'v4' | 'v3'
+
+type RepPrices = {
+ repEthPrice: bigint | undefined // ETH in wei received for 1 REP
+ repEthSource: PriceSource | undefined
+ repUsdcPrice: bigint | undefined // USDC in 1e6 units received for 1 REP
+ repUsdcSource: PriceSource | undefined
+ isLoadingRepPrices: boolean
+}
+
+async function fetchRepEthPrice(client: ReturnType): Promise<{ price: bigint; source: PriceSource }> {
+ try {
+ const price = await quoteRepForEth(client, ONE_REP)
+ return { price, source: 'v4' }
+ } catch {
+ // V4 REP/ETH pool doesn't exist yet — fall back to V3 REP/WETH (1% pool)
+ const price = await quoteRepForEthV3(client, ONE_REP)
+ return { price, source: 'v3' }
+ }
+}
+
+export function useRepPrices(): RepPrices {
+ const repEthPrice = useSignal(undefined)
+ const repEthSource = useSignal(undefined)
+ const repUsdcPrice = useSignal(undefined)
+ const repUsdcSource = useSignal(undefined)
+ const isLoadingRepPrices = useSignal(false)
+
+ useEffect(() => {
+ let cancelled = false
+ const client = createConnectedReadClient()
+ isLoadingRepPrices.value = true
+
+ void Promise.all([fetchRepEthPrice(client), quoteRepForUsdcV4(client, ONE_REP)])
+ .then(([{ price: ethPrice, source: ethSource }, usdcPrice]) => {
+ if (cancelled) return
+ repEthPrice.value = ethPrice
+ repEthSource.value = ethSource
+ repUsdcPrice.value = usdcPrice
+ repUsdcSource.value = 'v4'
+ })
+ .catch(() => {
+ // prices unavailable — leave as undefined
+ })
+ .finally(() => {
+ if (!cancelled) isLoadingRepPrices.value = false
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ return {
+ repEthPrice: repEthPrice.value,
+ repEthSource: repEthSource.value,
+ repUsdcPrice: repUsdcPrice.value,
+ repUsdcSource: repUsdcSource.value,
+ isLoadingRepPrices: isLoadingRepPrices.value,
+ }
+}
diff --git a/ui/ts/hooks/useSecurityPoolCreation.ts b/ui/ts/hooks/useSecurityPoolCreation.ts
index d635bc7..208cb05 100644
--- a/ui/ts/hooks/useSecurityPoolCreation.ts
+++ b/ui/ts/hooks/useSecurityPoolCreation.ts
@@ -18,9 +18,10 @@ type UseSecurityPoolCreationParameters = {
onTransactionRequested: () => void
onTransactionSubmitted: (hash: Hash) => void
refreshState: () => Promise
+ zoltarUniverseHasForked: boolean
}
-export function useSecurityPoolCreation({ accountAddress, deploymentStatuses, onTransaction, onTransactionFinished, onTransactionRequested, onTransactionSubmitted, refreshState }: UseSecurityPoolCreationParameters) {
+export function useSecurityPoolCreation({ accountAddress, deploymentStatuses, onTransaction, onTransactionFinished, onTransactionRequested, onTransactionSubmitted, refreshState, zoltarUniverseHasForked }: UseSecurityPoolCreationParameters) {
const loadingMarketDetails = useSignal(false)
const marketDetails = useSignal(undefined)
const poolCreationMarketDetails = useSignal(undefined)
@@ -121,6 +122,10 @@ export function useSecurityPoolCreation({ accountAddress, deploymentStatuses, on
securityPoolError.value = 'Deploy SecurityPoolFactory before creating a security pool'
return
}
+ if (zoltarUniverseHasForked) {
+ securityPoolError.value = 'Security pools cannot be created after the universe has forked'
+ return
+ }
if (securityPoolCreating.value) {
securityPoolError.value = 'Security pool creation already in progress'
return
@@ -163,6 +168,11 @@ export function useSecurityPoolCreation({ accountAddress, deploymentStatuses, on
}
}
+ const resetSecurityPoolCreation = () => {
+ securityPoolError.value = undefined
+ securityPoolResult.value = undefined
+ }
+
useEffect(() => {
void loadDuplicateOriginPoolState()
}, [securityPoolForm.value.marketId, securityPoolForm.value.securityMultiplier])
@@ -179,6 +189,7 @@ export function useSecurityPoolCreation({ accountAddress, deploymentStatuses, on
securityPoolForm: securityPoolForm.value,
securityPoolResult: securityPoolResult.value,
poolCreationMarketDetails: poolCreationMarketDetails.value,
+ resetSecurityPoolCreation,
setSecurityPoolForm: (updater: (current: SecurityPoolFormState) => SecurityPoolFormState) => {
securityPoolForm.value = updater(securityPoolForm.value)
},
diff --git a/ui/ts/hooks/useSecurityVaultOperations.ts b/ui/ts/hooks/useSecurityVaultOperations.ts
index f145d2a..fb0700a 100644
--- a/ui/ts/hooks/useSecurityVaultOperations.ts
+++ b/ui/ts/hooks/useSecurityVaultOperations.ts
@@ -1,11 +1,13 @@
import { useSignal } from '@preact/signals'
+import { useEffect } from 'preact/hooks'
import type { Address, Hash } from 'viem'
-import { approveErc20, depositRepToSecurityPool, loadSecurityVaultDetails, redeemSecurityVaultFees, redeemSecurityVaultRep, updateSecurityVaultFees } from '../contracts.js'
+import { approveErc20, depositRepToSecurityPool, loadErc20Allowance, loadErc20Balance, loadOracleManagerDetails, loadSecurityVaultDetails, queueOracleManagerOperation, redeemSecurityVaultFees, updateSecurityVaultFees } from '../contracts.js'
import { createConnectedReadClient, createWalletWriteClient } from '../lib/clients.js'
import { getErrorMessage } from '../lib/errors.js'
import { parseAddressInput } from '../lib/inputs.js'
import { parseBigIntInput } from '../lib/marketForm.js'
import { getDefaultSecurityVaultFormState } from '../lib/marketForm.js'
+import { useRequestGuard } from '../lib/requestGuard.js'
import { runWriteAction } from '../lib/writeAction.js'
import type { SecurityVaultFormState } from '../types/app.js'
import type { SecurityVaultActionResult, SecurityVaultDetails } from '../types/contracts.js'
@@ -24,11 +26,44 @@ export function useSecurityVaultOperations({ accountAddress, onTransaction, onTr
const securityVaultDetails = useSignal(undefined)
const securityVaultError = useSignal(undefined)
const securityVaultForm = useSignal(getDefaultSecurityVaultFormState())
+ const securityVaultRepBalance = useSignal(undefined)
+ const securityVaultRepAllowance = useSignal(undefined)
const securityVaultResult = useSignal(undefined)
+ const nextSecurityVaultRepBalanceLoad = useRequestGuard()
+ const nextSecurityVaultRepAllowanceLoad = useRequestGuard()
+
+ const reloadSecurityVaultRepBalance = async (repToken: Address, vaultAddress: Address) => {
+ const isCurrent = nextSecurityVaultRepBalanceLoad()
+ try {
+ const balance = await loadErc20Balance(createConnectedReadClient(), repToken, vaultAddress)
+ if (!isCurrent()) return
+ securityVaultRepBalance.value = balance
+ } catch {
+ if (!isCurrent()) return
+ securityVaultRepBalance.value = undefined
+ }
+ }
+
+ const reloadSecurityVaultRepAllowance = async (repToken: Address, vaultAddress: Address, securityPoolAddress: Address) => {
+ const isCurrent = nextSecurityVaultRepAllowanceLoad()
+ try {
+ const allowance = await loadErc20Allowance(createConnectedReadClient(), repToken, vaultAddress, securityPoolAddress)
+ if (!isCurrent()) return
+ securityVaultRepAllowance.value = allowance
+ } catch {
+ if (!isCurrent()) return
+ securityVaultRepAllowance.value = undefined
+ }
+ }
+
const reloadSecurityVaultDetails = async (securityPoolAddress: Address, vaultAddress: Address) => {
securityVaultDetails.value = await loadSecurityVaultDetails(createConnectedReadClient(), securityPoolAddress, vaultAddress)
}
+ const refreshVaultFees = async (vaultAddress: Address, securityPoolAddress: Address) => {
+ await updateSecurityVaultFees(createWalletWriteClient(vaultAddress, { onTransactionSubmitted }), securityPoolAddress, vaultAddress)
+ }
+
const loadSecurityVault = async () => {
if (accountAddress === undefined) {
securityVaultError.value = 'Connect a wallet before loading a security vault'
@@ -41,8 +76,12 @@ export function useSecurityVaultOperations({ accountAddress, onTransaction, onTr
const securityPoolAddress = parseAddressInput(securityVaultForm.value.securityPoolAddress, 'Security pool address')
const details = await loadSecurityVaultDetails(createConnectedReadClient(), securityPoolAddress, accountAddress)
securityVaultDetails.value = details
+ await reloadSecurityVaultRepBalance(details.repToken, accountAddress)
+ await reloadSecurityVaultRepAllowance(details.repToken, accountAddress, securityPoolAddress)
} catch (error) {
securityVaultDetails.value = undefined
+ securityVaultRepBalance.value = undefined
+ securityVaultRepAllowance.value = undefined
securityVaultError.value = getErrorMessage(error, 'Failed to load security vault')
} finally {
loadingSecurityVault.value = false
@@ -79,15 +118,19 @@ export function useSecurityVaultOperations({ accountAddress, onTransaction, onTr
)
}
- const approveRep = async () =>
+ const approveRep = async (amount?: bigint) =>
await runVaultAction(
async (vaultAddress, securityPoolAddress) => {
const details = securityVaultDetails.value ?? (await loadSecurityVaultDetails(createConnectedReadClient(), securityPoolAddress, vaultAddress))
- return await approveErc20(createWalletWriteClient(vaultAddress, { onTransactionSubmitted }), details.repToken, securityPoolAddress, parseBigIntInput(securityVaultForm.value.repApprovalAmount, 'REP approval amount'), 'approveRep')
+ const approvalAmount = amount ?? parseBigIntInput(securityVaultForm.value.depositAmount, 'REP deposit amount')
+ return await approveErc20(createWalletWriteClient(vaultAddress, { onTransactionSubmitted }), details.repToken, securityPoolAddress, approvalAmount, 'approveRep')
},
'Failed to approve REP',
async (_result, securityPoolAddress, vaultAddress) => {
await reloadSecurityVaultDetails(securityPoolAddress, vaultAddress)
+ const details = securityVaultDetails.value
+ if (details === undefined) return
+ await reloadSecurityVaultRepAllowance(details.repToken, vaultAddress, securityPoolAddress)
},
)
@@ -97,13 +140,26 @@ export function useSecurityVaultOperations({ accountAddress, onTransaction, onTr
'Failed to deposit REP',
async (_result, securityPoolAddress, vaultAddress) => {
await reloadSecurityVaultDetails(securityPoolAddress, vaultAddress)
+ const details = securityVaultDetails.value
+ if (details === undefined) return
+ await reloadSecurityVaultRepAllowance(details.repToken, vaultAddress, securityPoolAddress)
},
)
- const updateVaultFees = async () =>
+ const setSecurityBondAllowance = async () =>
await runVaultAction(
- async (vaultAddress, securityPoolAddress) => await updateSecurityVaultFees(createWalletWriteClient(vaultAddress, { onTransactionSubmitted }), securityPoolAddress, vaultAddress),
- 'Failed to update vault fees',
+ async (vaultAddress, securityPoolAddress) => {
+ const amount = parseBigIntInput(securityVaultForm.value.securityBondAllowanceAmount, 'Security bond allowance')
+ if (amount <= 0n) throw new Error('Security bond allowance must be greater than zero')
+ const details = securityVaultDetails.value ?? (await loadSecurityVaultDetails(createConnectedReadClient(), securityPoolAddress, vaultAddress))
+ const managerDetails = await loadOracleManagerDetails(createConnectedReadClient(), details.managerAddress)
+ const result = await queueOracleManagerOperation(createWalletWriteClient(vaultAddress, { onTransactionSubmitted }), details.managerAddress, 'setSecurityBondsAllowance', vaultAddress, amount, managerDetails.requestPriceEthCost)
+ return {
+ action: 'queueSetSecurityBondAllowance',
+ hash: result.hash,
+ } satisfies SecurityVaultActionResult
+ },
+ 'Failed to set security bond allowance',
async (_result, securityPoolAddress, vaultAddress) => {
await reloadSecurityVaultDetails(securityPoolAddress, vaultAddress)
},
@@ -111,36 +167,63 @@ export function useSecurityVaultOperations({ accountAddress, onTransaction, onTr
const redeemFees = async () =>
await runVaultAction(
- async (vaultAddress, securityPoolAddress) => await redeemSecurityVaultFees(createWalletWriteClient(vaultAddress, { onTransactionSubmitted }), securityPoolAddress, vaultAddress),
+ async (vaultAddress, securityPoolAddress) => {
+ await refreshVaultFees(vaultAddress, securityPoolAddress)
+ return await redeemSecurityVaultFees(createWalletWriteClient(vaultAddress, { onTransactionSubmitted }), securityPoolAddress, vaultAddress)
+ },
'Failed to redeem fees',
async (_result, securityPoolAddress, vaultAddress) => {
await reloadSecurityVaultDetails(securityPoolAddress, vaultAddress)
},
)
- const redeemRep = async () =>
+ const withdrawRep = async () =>
await runVaultAction(
- async (vaultAddress, securityPoolAddress) => await redeemSecurityVaultRep(createWalletWriteClient(vaultAddress, { onTransactionSubmitted }), securityPoolAddress, vaultAddress),
- 'Failed to redeem REP',
+ async (vaultAddress, securityPoolAddress) => {
+ const amount = parseBigIntInput(securityVaultForm.value.repWithdrawAmount, 'REP withdraw amount')
+ if (amount <= 0n) throw new Error('REP withdraw amount must be greater than zero')
+
+ const details = securityVaultDetails.value ?? (await loadSecurityVaultDetails(createConnectedReadClient(), securityPoolAddress, vaultAddress))
+ const managerDetails = await loadOracleManagerDetails(createConnectedReadClient(), details.managerAddress)
+ const result = await queueOracleManagerOperation(createWalletWriteClient(vaultAddress, { onTransactionSubmitted }), details.managerAddress, 'withdrawRep', vaultAddress, amount, managerDetails.requestPriceEthCost)
+ return {
+ action: 'queueWithdrawRep',
+ hash: result.hash,
+ } satisfies SecurityVaultActionResult
+ },
+ 'Failed to withdraw REP',
async (_result, securityPoolAddress, vaultAddress) => {
await reloadSecurityVaultDetails(securityPoolAddress, vaultAddress)
},
)
+ useEffect(() => {
+ if (accountAddress === undefined || securityVaultDetails.value === undefined) {
+ securityVaultRepBalance.value = undefined
+ securityVaultRepAllowance.value = undefined
+ return
+ }
+
+ void reloadSecurityVaultRepBalance(securityVaultDetails.value.repToken, accountAddress).catch(() => undefined)
+ void reloadSecurityVaultRepAllowance(securityVaultDetails.value.repToken, accountAddress, securityVaultDetails.value.securityPoolAddress).catch(() => undefined)
+ }, [accountAddress, securityVaultDetails.value?.repToken, securityVaultDetails.value?.securityPoolAddress])
+
return {
approveRep,
depositRep,
loadSecurityVault,
loadingSecurityVault: loadingSecurityVault.value,
redeemFees,
- redeemRep,
+ setSecurityBondAllowance,
+ withdrawRep,
+ securityVaultRepAllowance: securityVaultRepAllowance.value,
securityVaultDetails: securityVaultDetails.value,
securityVaultError: securityVaultError.value,
securityVaultForm: securityVaultForm.value,
+ securityVaultRepBalance: securityVaultRepBalance.value,
securityVaultResult: securityVaultResult.value,
setSecurityVaultForm: (updater: (current: SecurityVaultFormState) => SecurityVaultFormState) => {
securityVaultForm.value = updater(securityVaultForm.value)
},
- updateVaultFees,
}
}
diff --git a/ui/ts/hooks/useZoltarFork.ts b/ui/ts/hooks/useZoltarFork.ts
index c07cc2f..66d8f7f 100644
--- a/ui/ts/hooks/useZoltarFork.ts
+++ b/ui/ts/hooks/useZoltarFork.ts
@@ -1,5 +1,5 @@
import { useSignal } from '@preact/signals'
-import { useEffect } from 'preact/hooks'
+import { useCallback, useEffect } from 'preact/hooks'
import { zeroAddress, type Address, type Hash } from 'viem'
import { approveErc20, forkZoltarUniverse, getDeploymentSteps, loadErc20Allowance, loadErc20Balance, loadRepTokensMigratedRepBalance } from '../contracts.js'
import { createConnectedReadClient, createWalletWriteClient, getRequiredInjectedEthereum } from '../lib/clients.js'
@@ -111,7 +111,7 @@ export function useZoltarFork({ accountAddress, activeUniverseId, ensureZoltarUn
if (pending === 0) loadingZoltarForkAccess.value = false
}
- const runZoltarForkAction = async (actionName: 'approve' | 'fork', action: (walletAddress: Address, universe: ZoltarUniverseSummary, questionId: bigint) => Promise, errorFallback: string, refreshAfter: boolean) => {
+ const runZoltarForkAction = async (actionName: 'approve' | 'fork', action: (walletAddress: Address, universe: ZoltarUniverseSummary, questionId: bigint) => Promise, errorFallback: string, refreshAfter: boolean, options?: { requireQuestionIdInput?: boolean }) => {
try {
getRequiredInjectedEthereum()
} catch {
@@ -130,8 +130,14 @@ export function useZoltarFork({ accountAddress, activeUniverseId, ensureZoltarUn
try {
onTransactionRequested()
- const questionId = parseBigIntInput(zoltarForkQuestionId.value, 'Fork question ID')
const universe = await ensureZoltarUniverse()
+ const questionId = options?.requireQuestionIdInput
+ ? parseBigIntInput(zoltarForkQuestionId.value, 'Fork question ID')
+ : (() => {
+ const questionIdString = universe.forkQuestionDetails?.questionId ?? ''
+ if (questionIdString === '') throw new Error('Fork question ID is missing')
+ return BigInt(questionIdString)
+ })()
const result = await action(accountAddress, universe, questionId)
zoltarForkResult.value = result
onTransaction(result.hash)
@@ -149,21 +155,26 @@ export function useZoltarFork({ accountAddress, activeUniverseId, ensureZoltarUn
}
}
- const approveZoltarForkRep = async () =>
- await runZoltarForkAction(
- 'approve',
- async (walletAddress, universe, questionId) => {
- const approval = await approveErc20(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), universe.reputationToken, getZoltarAddress(), universe.forkThreshold, 'approveForkRep')
- return {
- action: 'approveForkRep',
- hash: approval.hash,
- questionId: formatQuestionId(questionId),
- universeId: universe.universeId,
- } satisfies ZoltarForkActionResult
- },
- 'Failed to approve REP for Zoltar fork',
- false,
- )
+ const approveZoltarForkRep = useCallback(
+ async (amount?: bigint) =>
+ await runZoltarForkAction(
+ 'approve',
+ async (walletAddress, universe, questionId) => {
+ const approvalAmount = amount ?? universe.forkThreshold
+ const approval = await approveErc20(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), universe.reputationToken, getZoltarAddress(), approvalAmount, 'approveForkRep')
+ return {
+ action: 'approveForkRep',
+ hash: approval.hash,
+ questionId: formatQuestionId(questionId),
+ universeId: universe.universeId,
+ } satisfies ZoltarForkActionResult
+ },
+ 'Failed to approve REP for Zoltar fork',
+ false,
+ { requireQuestionIdInput: false },
+ ),
+ [runZoltarForkAction, onTransactionSubmitted],
+ )
const forkZoltar = async () =>
await runZoltarForkAction(
diff --git a/ui/ts/hooks/useZoltarUniverse.ts b/ui/ts/hooks/useZoltarUniverse.ts
index 01986fd..ac97452 100644
--- a/ui/ts/hooks/useZoltarUniverse.ts
+++ b/ui/ts/hooks/useZoltarUniverse.ts
@@ -171,10 +171,11 @@ export function useZoltarUniverse({ accountAddress, activeUniverseId, autoLoadIn
}
}
+ const zoltarDeployed = hasDeployedStep(deploymentStatuses, 'zoltar')
useLayoutEffect(() => {
if (!autoLoadInitialData) return
void Promise.allSettled([loadZoltarUniverse(), loadZoltarQuestionCountData()])
- }, [activeUniverseId, autoLoadInitialData, deploymentStatuses])
+ }, [activeUniverseId, autoLoadInitialData, zoltarDeployed])
useLayoutEffect(() => {
return () => {
diff --git a/ui/ts/lib/formatters.ts b/ui/ts/lib/formatters.ts
index 1a0d07f..5867288 100644
--- a/ui/ts/lib/formatters.ts
+++ b/ui/ts/lib/formatters.ts
@@ -47,18 +47,25 @@ export function formatRoundedCurrencyBalance(value: bigint | undefined, units: n
const isNegative = value < 0n
const absoluteValue = isNegative ? -value : value
- const scale = 10n ** BigInt(decimals)
+ const prefix = isNegative ? '-' : ''
+
+ // For tiny values between 0 and 1, extend decimal places to show 2 significant figures.
+ // floatValue is used only for order-of-magnitude detection; bigint arithmetic handles rounding.
+ const floatValue = Number(absoluteValue) / 10 ** units
+ const effectiveDecimals =
+ floatValue > 0 && floatValue < 1 ? Math.max(decimals, Math.ceil(-Math.log10(floatValue)) + 1) : decimals
+
+ const scale = 10n ** BigInt(effectiveDecimals)
const base = 10n ** BigInt(units)
const rounded = (absoluteValue * scale + base / 2n) / base
const integerPart = rounded / scale
- const prefix = isNegative ? '-' : ''
- if (decimals === 0) {
+ if (effectiveDecimals === 0) {
return `${prefix}${formatGroupedInteger(integerPart)}`
}
const fractionalPart = rounded % scale
- return `${prefix}${formatGroupedInteger(integerPart)}.${fractionalPart.toString().padStart(decimals, '0')}`
+ return `${prefix}${formatGroupedInteger(integerPart)}.${fractionalPart.toString().padStart(effectiveDecimals, '0')}`
}
export function formatTimestamp(timestamp: bigint) {
diff --git a/ui/ts/lib/marketCreation.ts b/ui/ts/lib/marketCreation.ts
index bcae519..6ab961f 100644
--- a/ui/ts/lib/marketCreation.ts
+++ b/ui/ts/lib/marketCreation.ts
@@ -4,6 +4,15 @@ import type { DeploymentStatus, QuestionData } from '../types/contracts.js'
import { assertNever } from './assert.js'
import { parseBigIntInput, parseTimestampInput } from './marketForm.js'
import { parseOpenInterestFeePerYearPercentInput } from './retentionRate.js'
+import { parseScalarFormInputs } from './scalarOutcome.js'
+
+type MarketFormField = keyof Pick
+
+type MarketFormValidation = {
+ fieldErrors: Partial>
+ isValid: boolean
+ notice: string | undefined
+}
export function hasDeployedStep(steps: DeploymentStatus[], stepId: DeploymentStatus['id']) {
return steps.some(step => step.id === stepId && step.deployed)
@@ -12,9 +21,7 @@ export function hasDeployedStep(steps: DeploymentStatus[], stepId: DeploymentSta
function getScalarQuestionData(form: MarketFormState) {
return {
answerUnit: form.answerUnit.trim(),
- displayValueMax: parseBigIntInput(form.displayValueMax, 'Display value max'),
- displayValueMin: parseBigIntInput(form.displayValueMin, 'Display value min'),
- numTicks: parseBigIntInput(form.numTicks, 'Number of ticks'),
+ ...parseScalarFormInputs(form),
}
}
@@ -48,8 +55,6 @@ function createQuestionData(form: MarketFormState): QuestionData {
if (questionData.title === '') throw new Error('Title is required')
if (questionData.endTime <= questionData.startTime) throw new Error('End time must be after start time')
- if (form.marketType === 'scalar' && questionData.numTicks <= 1n) throw new Error('Number of ticks must be greater than 1')
- if (form.marketType === 'scalar' && questionData.displayValueMax <= questionData.displayValueMin) throw new Error('Display value max must be greater than display value min')
return questionData
}
@@ -69,13 +74,10 @@ function compareOutcomeLabels(left: string, right: string) {
}
function getCategoricalOutcomeLabels(form: MarketFormState) {
- const outcomeLabels = form.categoricalOutcomes
- .split('\n')
- .map(normalizeOutcomeLabel)
- .filter(label => label !== '')
+ const outcomeLabels = form.categoricalOutcomes.map(normalizeOutcomeLabel).filter(label => label !== '')
- if (outcomeLabels.length < 2) throw new Error('Categorical markets require at least 2 outcome labels')
- if (new Set(outcomeLabels).size !== outcomeLabels.length) throw new Error('Outcome labels must be unique')
+ if (outcomeLabels.length < 2) throw new Error('Categorical markets require at least 2 outcomes')
+ if (new Set(outcomeLabels).size !== outcomeLabels.length) throw new Error('Outcomes must be unique')
return [...outcomeLabels].sort(compareOutcomeLabels)
}
@@ -93,6 +95,121 @@ function getOutcomeLabels(form: MarketFormState) {
}
}
+function setFieldError(fieldErrors: Partial>, field: MarketFormField, message: string) {
+ if (fieldErrors[field] !== undefined) return
+ fieldErrors[field] = message
+}
+
+function formatFieldList(fields: string[]) {
+ return fields.join(', ')
+}
+
+export function validateMarketForm(form: MarketFormState): MarketFormValidation {
+ const fieldErrors: Partial> = {}
+ const missingFields: string[] = []
+ const invalidMessages: string[] = []
+
+ if (form.title.trim() === '') {
+ setFieldError(fieldErrors, 'title', 'Title is required')
+ missingFields.push('Title')
+ }
+
+ const startTime = form.startTime.trim()
+ const endTime = form.endTime.trim()
+ let parsedStartTime: bigint | undefined
+ let parsedEndTime: bigint | undefined
+
+ if (startTime !== '') {
+ try {
+ parsedStartTime = parseTimestampInput(form.startTime, 'Start time')
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Start time is invalid'
+ setFieldError(fieldErrors, 'startTime', message)
+ invalidMessages.push(message)
+ }
+ }
+
+ if (endTime === '') {
+ setFieldError(fieldErrors, 'endTime', 'End time is required')
+ missingFields.push('End Time')
+ } else {
+ try {
+ parsedEndTime = parseTimestampInput(form.endTime, 'End time')
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'End time is invalid'
+ setFieldError(fieldErrors, 'endTime', message)
+ invalidMessages.push(message)
+ }
+ }
+
+ if (parsedEndTime !== undefined && parsedStartTime !== undefined && parsedEndTime <= parsedStartTime) {
+ const message = 'End time must be after start time'
+ setFieldError(fieldErrors, 'startTime', message)
+ setFieldError(fieldErrors, 'endTime', message)
+ invalidMessages.push(message)
+ }
+
+ if (form.marketType === 'categorical') {
+ const normalizedOutcomeLabels = form.categoricalOutcomes.map(normalizeOutcomeLabel).filter(label => label !== '')
+
+ if (normalizedOutcomeLabels.length === 0) {
+ setFieldError(fieldErrors, 'categoricalOutcomes', 'Outcomes are required')
+ missingFields.push('Outcomes')
+ } else {
+ try {
+ getCategoricalOutcomeLabels(form)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Outcomes are invalid'
+ setFieldError(fieldErrors, 'categoricalOutcomes', message)
+ invalidMessages.push(message)
+ }
+ }
+ }
+
+ if (form.marketType === 'scalar') {
+ const scalarFields: Array<{ key: 'scalarMin' | 'scalarMax' | 'scalarIncrement'; label: string }> = [
+ { key: 'scalarMin', label: 'Scalar Min' },
+ { key: 'scalarMax', label: 'Scalar Max' },
+ { key: 'scalarIncrement', label: 'Scalar Increment' },
+ ]
+
+ const missingScalarFields = scalarFields.filter(field => form[field.key].trim() === '')
+ for (const field of missingScalarFields) {
+ setFieldError(fieldErrors, field.key, `${field.label} is required`)
+ missingFields.push(field.label)
+ }
+
+ if (missingScalarFields.length === 0) {
+ try {
+ parseScalarFormInputs(form)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Scalar inputs are invalid'
+ if (message.includes('Scalar increment')) {
+ setFieldError(fieldErrors, 'scalarIncrement', message)
+ } else if (message.includes('Scalar max must be greater than scalar min')) {
+ setFieldError(fieldErrors, 'scalarMin', message)
+ setFieldError(fieldErrors, 'scalarMax', message)
+ } else {
+ setFieldError(fieldErrors, 'scalarMin', message)
+ setFieldError(fieldErrors, 'scalarMax', message)
+ setFieldError(fieldErrors, 'scalarIncrement', message)
+ }
+ invalidMessages.push(message)
+ }
+ }
+ }
+
+ const noticeParts: string[] = []
+ if (missingFields.length > 0) noticeParts.push(`Missing required fields: ${formatFieldList(missingFields)}`)
+ if (invalidMessages.length > 0) noticeParts.push(`Fix invalid fields: ${[...new Set(invalidMessages)].join(', ')}`)
+
+ return {
+ fieldErrors,
+ isValid: missingFields.length === 0 && invalidMessages.length === 0,
+ notice: noticeParts.length === 0 ? undefined : noticeParts.join('. '),
+ }
+}
+
export function createMarketParameters(form: MarketFormState) {
return {
marketType: form.marketType,
diff --git a/ui/ts/lib/marketForm.ts b/ui/ts/lib/marketForm.ts
index 4f5f804..61e9ccb 100644
--- a/ui/ts/lib/marketForm.ts
+++ b/ui/ts/lib/marketForm.ts
@@ -6,13 +6,13 @@ const DEFAULT_CURRENT_RETENTION_RATE = '10'
export function getDefaultMarketFormState(): MarketFormState {
return {
answerUnit: '',
- categoricalOutcomes: 'Yes\nNo',
+ categoricalOutcomes: ['Yes', 'No'],
description: '',
- displayValueMax: '100',
- displayValueMin: '0',
endTime: '',
marketType: 'binary',
- numTicks: '100',
+ scalarIncrement: '1',
+ scalarMax: '100',
+ scalarMin: '0',
title: '',
startTime: '',
}
@@ -30,7 +30,8 @@ export function getDefaultSecurityPoolFormState(): SecurityPoolFormState {
export function getDefaultSecurityVaultFormState(): SecurityVaultFormState {
return {
depositAmount: '0',
- repApprovalAmount: '0',
+ securityBondAllowanceAmount: '0',
+ repWithdrawAmount: '0',
securityPoolAddress: '',
}
}
@@ -39,7 +40,11 @@ export function getDefaultOpenOracleFormState(): OpenOracleFormState {
return {
amount1: '0',
amount2: '0',
+ disputeNewAmount1: '0',
+ disputeNewAmount2: '0',
+ disputeTokenToSwap: 'token1',
managerAddress: '',
+ openOracleAddress: '',
operationAmount: '0',
operationTargetVault: '',
queuedOperation: 'liquidation',
diff --git a/ui/ts/lib/scalarOutcome.ts b/ui/ts/lib/scalarOutcome.ts
index 9655732..e657368 100644
--- a/ui/ts/lib/scalarOutcome.ts
+++ b/ui/ts/lib/scalarOutcome.ts
@@ -1,3 +1,5 @@
+import { parseUnits } from 'viem'
+
type ScalarQuestionDetails = {
answerUnit: string
displayValueMax: bigint
@@ -5,11 +7,33 @@ type ScalarQuestionDetails = {
numTicks: bigint
}
+type ScalarFormInputs = {
+ scalarIncrement: string
+ scalarMax: string
+ scalarMin: string
+}
+
const SCALAR_DECIMALS = 18n
const SCALAR_DECIMAL_BASE = 10n ** SCALAR_DECIMALS
const SCALAR_PART_BIT_LENGTH = 120n
const SCALAR_TOTAL_BITS = 256n
+function normalizeDecimalInput(value: string) {
+ const trimmed = value.trim()
+ if (trimmed === '') return trimmed
+ return trimmed.startsWith('.') ? `0${trimmed}` : trimmed.endsWith('.') ? `${trimmed}0` : trimmed
+}
+
+function parseScalarDecimalInput(value: string, label: string) {
+ const normalized = normalizeDecimalInput(value)
+ if (normalized === '') throw new Error(`${label} is required`)
+ try {
+ return parseUnits(normalized, Number(SCALAR_DECIMALS))
+ } catch {
+ throw new Error(`${label} must be a decimal number`)
+ }
+}
+
function combineUint256FromTwoWithInvalid(invalid: boolean, firstPart: bigint, secondPart: bigint): bigint {
const oneHundredTwentyBitMask = (1n << SCALAR_PART_BIT_LENGTH) - 1n
const normalizedFirstPart = firstPart & oneHundredTwentyBitMask
@@ -39,6 +63,11 @@ export function getScalarSliderProgress(tickIndex: bigint, numTicks: bigint) {
return Number((tickIndex * 100n) / numTicks)
}
+export function getScalarSliderFillWidth(tickIndex: bigint, numTicks: bigint) {
+ const fraction = Number(tickIndex) / Number(numTicks)
+ return `calc(${fraction * 100}% - ${fraction}rem + 0.5rem)`
+}
+
export function clampScalarTickIndex(tickIndex: bigint, numTicks: bigint) {
if (numTicks <= 0n) throw new Error('Scalar question numTicks must be positive')
if (tickIndex < 0n) return 0n
@@ -46,6 +75,29 @@ export function clampScalarTickIndex(tickIndex: bigint, numTicks: bigint) {
return tickIndex
}
+export function parseScalarFormInputs({ scalarIncrement, scalarMax, scalarMin }: ScalarFormInputs) {
+ const displayValueMin = parseScalarDecimalInput(scalarMin, 'Scalar min')
+ const displayValueMax = parseScalarDecimalInput(scalarMax, 'Scalar max')
+ const increment = parseScalarDecimalInput(scalarIncrement, 'Scalar increment')
+
+ if (increment <= 0n) throw new Error('Scalar increment must be greater than 0')
+ if (displayValueMax <= displayValueMin) throw new Error('Scalar max must be greater than scalar min')
+
+ const range = displayValueMax - displayValueMin
+ if (range % increment !== 0n) {
+ throw new Error('Scalar min, max, and increment do not produce a whole number of ticks')
+ }
+
+ const numTicks = range / increment
+ if (numTicks <= 1n) throw new Error('Scalar inputs must produce more than 1 tick')
+
+ return {
+ displayValueMax,
+ displayValueMin,
+ numTicks,
+ }
+}
+
export function getScalarOutcomeIndex(question: ScalarQuestionDetails, tickIndex: bigint) {
validateTickIndex(question, tickIndex)
return combineUint256FromTwoWithInvalid(false, question.numTicks - tickIndex, tickIndex)
diff --git a/ui/ts/lib/uniswapQuoter.ts b/ui/ts/lib/uniswapQuoter.ts
new file mode 100644
index 0000000..207bc69
--- /dev/null
+++ b/ui/ts/lib/uniswapQuoter.ts
@@ -0,0 +1,167 @@
+import { type Address, zeroAddress } from 'viem'
+import type { ReadClient } from './clients.js'
+
+export const UNISWAP_V4_QUOTER_ADDRESS: Address = '0x52f0e24d1c21c8a0cb1e5a5dd6198556bd9e1203'
+// Uniswap V3 QuoterV2 — used as fallback when a V4 pool doesn't exist
+const UNISWAP_V3_QUOTER_ADDRESS: Address = '0x61fFE014bA17989E743c5F6cB21bF9697530B21e'
+
+// Known token addresses (mainnet)
+export const REP_ADDRESS: Address = '0x221657776846890989a759BA2973e427DfF5C9bB'
+export const USDC_ADDRESS: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
+// WETH — used for V3 quotes (V3 doesn't support native ETH, only WETH)
+const WETH_ADDRESS: Address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
+// ETH in Uniswap V4 is represented as address(0)
+export const ETH_ADDRESS: Address = zeroAddress
+
+type PoolConfig = {
+ fee: number
+ tickSpacing: number
+ hooks?: Address
+}
+
+// Default pool config (0.3% fee, standard tick spacing, no hooks)
+export const DEFAULT_POOL_CONFIG: PoolConfig = {
+ fee: 3000,
+ tickSpacing: 60,
+}
+
+const QUOTER_ABI = [
+ {
+ name: 'quoteExactInputSingle',
+ type: 'function',
+ stateMutability: 'nonpayable',
+ inputs: [
+ {
+ name: 'params',
+ type: 'tuple',
+ components: [
+ {
+ name: 'poolKey',
+ type: 'tuple',
+ components: [
+ { name: 'currency0', type: 'address' },
+ { name: 'currency1', type: 'address' },
+ { name: 'fee', type: 'uint24' },
+ { name: 'tickSpacing', type: 'int24' },
+ { name: 'hooks', type: 'address' },
+ ],
+ },
+ { name: 'zeroForOne', type: 'bool' },
+ { name: 'exactAmount', type: 'uint128' },
+ { name: 'hookData', type: 'bytes' },
+ ],
+ },
+ ],
+ outputs: [
+ { name: 'amountOut', type: 'uint256' },
+ { name: 'gasEstimate', type: 'uint256' },
+ ],
+ },
+] as const
+
+// Returns how much tokenOut you receive for swapping `amountIn` of tokenIn.
+// Use ETH_ADDRESS (zeroAddress) for ETH. Tokens can be in any order — currency0/1
+// ordering and zeroForOne direction are derived from the addresses automatically.
+export async function quoteExactInput(client: ReadClient, tokenIn: Address, tokenOut: Address, amountIn: bigint, poolConfig: PoolConfig = DEFAULT_POOL_CONFIG): Promise {
+ const tokenInBig = BigInt(tokenIn)
+ const tokenOutBig = BigInt(tokenOut)
+ const zeroForOne = tokenInBig < tokenOutBig
+ const [currency0, currency1] = zeroForOne ? [tokenIn, tokenOut] : [tokenOut, tokenIn]
+
+ const { result } = await client.simulateContract({
+ address: UNISWAP_V4_QUOTER_ADDRESS,
+ abi: QUOTER_ABI,
+ functionName: 'quoteExactInputSingle',
+ args: [
+ {
+ poolKey: {
+ currency0,
+ currency1,
+ fee: poolConfig.fee,
+ tickSpacing: poolConfig.tickSpacing,
+ hooks: poolConfig.hooks ?? zeroAddress,
+ },
+ zeroForOne,
+ exactAmount: amountIn,
+ hookData: '0x',
+ },
+ ],
+ })
+ return result[0]
+}
+
+// Returns how much ETH (in wei) you receive for swapping `amountIn` of `token`
+export async function quoteTokenForEth(client: ReadClient, token: Address, amountIn: bigint, poolConfig: PoolConfig = DEFAULT_POOL_CONFIG): Promise {
+ return quoteExactInput(client, token, ETH_ADDRESS, amountIn, poolConfig)
+}
+
+// Returns how much `token` (in token's native units) you receive for swapping `amountIn` ETH (in wei)
+export async function quoteEthForToken(client: ReadClient, token: Address, amountIn: bigint, poolConfig: PoolConfig = DEFAULT_POOL_CONFIG): Promise {
+ return quoteExactInput(client, ETH_ADDRESS, token, amountIn, poolConfig)
+}
+
+// Convenience: REP → ETH using the default pool config
+export async function quoteRepForEth(client: ReadClient, repAmount: bigint): Promise {
+ return quoteTokenForEth(client, REP_ADDRESS, repAmount)
+}
+
+// Convenience: ETH → REP using the default pool config
+export async function quoteEthForRep(client: ReadClient, ethAmount: bigint): Promise {
+ return quoteEthForToken(client, REP_ADDRESS, ethAmount)
+}
+
+// ─── Uniswap V3 ───────────────────────────────────────────────────────────────
+
+const V3_QUOTER_ABI = [
+ {
+ name: 'quoteExactInputSingle',
+ type: 'function',
+ stateMutability: 'nonpayable',
+ inputs: [
+ {
+ name: 'params',
+ type: 'tuple',
+ components: [
+ { name: 'tokenIn', type: 'address' },
+ { name: 'tokenOut', type: 'address' },
+ { name: 'amountIn', type: 'uint256' },
+ { name: 'fee', type: 'uint24' },
+ { name: 'sqrtPriceLimitX96', type: 'uint160' },
+ ],
+ },
+ ],
+ outputs: [
+ { name: 'amountOut', type: 'uint256' },
+ { name: 'sqrtPriceX96After', type: 'uint160' },
+ { name: 'initializedTicksCrossed', type: 'uint32' },
+ { name: 'gasEstimate', type: 'uint256' },
+ ],
+ },
+] as const
+
+// Returns how much tokenOut you receive for swapping `amountIn` of tokenIn via Uniswap V3.
+// Use WETH_ADDRESS for ETH (V3 does not support native ETH).
+async function quoteV3ExactInput(client: ReadClient, tokenIn: Address, tokenOut: Address, amountIn: bigint, fee: number): Promise {
+ const { result } = await client.simulateContract({
+ address: UNISWAP_V3_QUOTER_ADDRESS,
+ abi: V3_QUOTER_ABI,
+ functionName: 'quoteExactInputSingle',
+ args: [{ tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96: 0n }],
+ })
+ return result[0]
+}
+
+// Returns how much WETH (= ETH) you receive for `repAmount` REP via Uniswap V3 (1% pool).
+export async function quoteRepForEthV3(client: ReadClient, repAmount: bigint): Promise {
+ return quoteV3ExactInput(client, REP_ADDRESS, WETH_ADDRESS, repAmount, 10000)
+}
+
+// ─── Known V4 REP pools ───────────────────────────────────────────────────────
+// REP/USDC V4 pool (fee=10001, tickSpacing=200, no hooks)
+// Pool ID: 0x75d479eb83b7c9008ab854e74625a01841e5b3e06af40a89c10998ad2664f356
+const REP_USDC_V4_POOL: PoolConfig = { fee: 10001, tickSpacing: 200 }
+
+// Returns how much USDC (6 decimals) you receive for `repAmount` REP via the V4 REP/USDC pool.
+export async function quoteRepForUsdcV4(client: ReadClient, repAmount: bigint): Promise {
+ return quoteExactInput(client, REP_ADDRESS, USDC_ADDRESS, repAmount, REP_USDC_V4_POOL)
+}
diff --git a/ui/ts/tests/formatters.test.ts b/ui/ts/tests/formatters.test.ts
index ae772c8..987d5ee 100644
--- a/ui/ts/tests/formatters.test.ts
+++ b/ui/ts/tests/formatters.test.ts
@@ -20,4 +20,36 @@ void describe('formatting helpers', () => {
void test('formatCurrencyInputBalance returns a compact decimal string without grouped separators', () => {
expect(formatCurrencyInputBalance(1234567890000000000000n)).toBe('1234.56789')
})
+
+ void describe('formatRoundedCurrencyBalance — 2 significant figures for tiny values', () => {
+ // 0.000025532 ETH → 6 decimal places to capture 2 sig figs
+ void test('0.000025532 ETH rounds to 0.000026', () => {
+ expect(formatRoundedCurrencyBalance(25532000000000n, 18, 2)).toBe('0.000026')
+ })
+
+ // 0.023 ETH → 3 decimal places (first non-zero at position 2)
+ void test('0.023 ETH rounds to 0.023', () => {
+ expect(formatRoundedCurrencyBalance(23000000000000000n, 18, 2)).toBe('0.023')
+ })
+
+ // 0.0045 ETH → 4 decimal places to capture 2 sig figs (4 and 5)
+ void test('0.0045 ETH rounds to 0.0045', () => {
+ expect(formatRoundedCurrencyBalance(4500000000000000n, 18, 2)).toBe('0.0045')
+ })
+
+ // 0.00041 ETH (typical REP/ETH price) → 5 decimal places: 0.00041
+ void test('0.00041 ETH rounds to 0.00041', () => {
+ expect(formatRoundedCurrencyBalance(410000000000000n, 18, 2)).toBe('0.00041')
+ })
+
+ // Values >= 1 are unaffected — still use fixed decimal count
+ void test('1.234 ETH rounds to 1.23 (unchanged behaviour)', () => {
+ expect(formatRoundedCurrencyBalance(1234000000000000000n, 18, 2)).toBe('1.23')
+ })
+
+ // USDC (6 decimals) — 0.85 USDC stays at 2 decimal places
+ void test('0.85 USDC rounds to 0.85', () => {
+ expect(formatRoundedCurrencyBalance(850000n, 6, 2)).toBe('0.85')
+ })
+ })
})
diff --git a/ui/ts/tests/marketCreation.test.ts b/ui/ts/tests/marketCreation.test.ts
index 43159a9..db17dd2 100644
--- a/ui/ts/tests/marketCreation.test.ts
+++ b/ui/ts/tests/marketCreation.test.ts
@@ -1,7 +1,7 @@
///
import { describe, expect, test } from 'bun:test'
-import { createMarketParameters } from '../lib/marketCreation.js'
+import { createMarketParameters, validateMarketForm } from '../lib/marketCreation.js'
import { sortStringArrayByKeccak } from '../lib/sortStringArrayByKeccak.js'
import type { MarketFormState } from '../types/app.js'
@@ -9,13 +9,13 @@ void describe('market creation helpers', () => {
void test('categorical outcomes are sorted by the contract hash order before submission', () => {
const form: MarketFormState = {
answerUnit: '',
- categoricalOutcomes: 'Cherry\nApple\nBanana',
+ categoricalOutcomes: ['Cherry', 'Apple', 'Banana'],
description: 'test categorical description',
- displayValueMax: '0',
- displayValueMin: '0',
endTime: '2000',
marketType: 'categorical',
- numTicks: '0',
+ scalarIncrement: '1',
+ scalarMax: '0',
+ scalarMin: '0',
title: 'test categorical question',
startTime: '1000',
}
@@ -24,4 +24,66 @@ void describe('market creation helpers', () => {
expect(parameters.outcomeLabels).toEqual(sortStringArrayByKeccak(['Cherry', 'Apple', 'Banana']))
})
+
+ void test('scalar inputs map to the expected contract values', () => {
+ const form: MarketFormState = {
+ answerUnit: '$',
+ categoricalOutcomes: ['Yes', 'No'],
+ description: 'test scalar description',
+ endTime: '2000',
+ marketType: 'scalar',
+ scalarIncrement: '0.1',
+ scalarMax: '10',
+ scalarMin: '1',
+ title: 'test scalar question',
+ startTime: '1000',
+ }
+
+ const parameters = createMarketParameters(form)
+
+ expect(parameters.questionData.displayValueMin).toBe(1n * 10n ** 18n)
+ expect(parameters.questionData.displayValueMax).toBe(10n * 10n ** 18n)
+ expect(parameters.questionData.numTicks).toBe(90n)
+ })
+
+ void test('validation reports missing required fields and impossible scalar combinations', () => {
+ const validation = validateMarketForm({
+ answerUnit: '$',
+ categoricalOutcomes: ['Yes', 'No'],
+ description: 'test scalar description',
+ endTime: '',
+ marketType: 'scalar',
+ scalarIncrement: '0.4',
+ scalarMax: '10',
+ scalarMin: '1',
+ title: '',
+ startTime: '1000',
+ })
+
+ expect(validation.isValid).toBe(false)
+ expect(validation.fieldErrors.title).toBe('Title is required')
+ expect(validation.fieldErrors.endTime).toBe('End time is required')
+ expect(validation.fieldErrors.scalarMin).toBe('Scalar min, max, and increment do not produce a whole number of ticks')
+ expect(validation.notice).toContain('Missing required fields: Title, End Time')
+ expect(validation.notice).toContain('Fix invalid fields: Scalar min, max, and increment do not produce a whole number of ticks')
+ })
+
+ void test('validation reports duplicate categorical outcomes', () => {
+ const validation = validateMarketForm({
+ answerUnit: '',
+ categoricalOutcomes: ['Apple', 'Apple'],
+ description: 'test categorical description',
+ endTime: '2000',
+ marketType: 'categorical',
+ scalarIncrement: '1',
+ scalarMax: '0',
+ scalarMin: '0',
+ title: 'test categorical question',
+ startTime: '1000',
+ })
+
+ expect(validation.isValid).toBe(false)
+ expect(validation.fieldErrors.categoricalOutcomes).toBe('Outcomes must be unique')
+ expect(validation.notice).toContain('Fix invalid fields: Outcomes must be unique')
+ })
})
diff --git a/ui/ts/tests/migrationOutcomeUniversesSection.test.ts b/ui/ts/tests/migrationOutcomeUniversesSection.test.ts
index 9f87346..53a8cdd 100644
--- a/ui/ts/tests/migrationOutcomeUniversesSection.test.ts
+++ b/ui/ts/tests/migrationOutcomeUniversesSection.test.ts
@@ -2,11 +2,11 @@
import { describe, expect, test } from 'bun:test'
import { zeroAddress } from 'viem'
-import { getMigrationOutcomeHeldBalance } from '../components/MigrationOutcomeUniversesSection.js'
+import { getMigrationOutcomeHeldBalance, getMigrationOutcomeSplitLimit } from '../components/MigrationOutcomeUniversesSection.js'
import type { ZoltarChildUniverseSummary } from '../types/contracts.js'
void describe('getMigrationOutcomeHeldBalance', () => {
- void test('returns undefined for undeployed child universes', () => {
+ void test('returns zero for undeployed child universes', () => {
const child = {
exists: false,
forkTime: 0n,
@@ -17,7 +17,7 @@ void describe('getMigrationOutcomeHeldBalance', () => {
universeId: 123n,
} satisfies ZoltarChildUniverseSummary
- expect(getMigrationOutcomeHeldBalance(child, {})).toBe(undefined)
+ expect(getMigrationOutcomeHeldBalance(child, {})).toBe(0n)
})
void test('returns the recorded balance for deployed child universes', () => {
@@ -33,4 +33,45 @@ void describe('getMigrationOutcomeHeldBalance', () => {
expect(getMigrationOutcomeHeldBalance(child, { '123': 42n })).toBe(42n)
})
+
+ void test('returns the minimum remaining capacity across selected universes', () => {
+ const childUniverses = [
+ {
+ exists: true,
+ forkTime: 0n,
+ outcomeIndex: 1n,
+ outcomeLabel: 'Yes',
+ parentUniverseId: 0n,
+ reputationToken: zeroAddress,
+ universeId: 123n,
+ },
+ {
+ exists: true,
+ forkTime: 0n,
+ outcomeIndex: 2n,
+ outcomeLabel: 'No',
+ parentUniverseId: 0n,
+ reputationToken: zeroAddress,
+ universeId: 456n,
+ },
+ ] satisfies ZoltarChildUniverseSummary[]
+
+ expect(getMigrationOutcomeSplitLimit(childUniverses, { '123': 10n, '456': 40n }, 50n, new Set(['1', '2']))).toBe(10n)
+ })
+
+ void test('returns zero when every selected universe is fully migrated', () => {
+ const childUniverses = [
+ {
+ exists: true,
+ forkTime: 0n,
+ outcomeIndex: 1n,
+ outcomeLabel: 'Yes',
+ parentUniverseId: 0n,
+ reputationToken: zeroAddress,
+ universeId: 123n,
+ },
+ ] satisfies ZoltarChildUniverseSummary[]
+
+ expect(getMigrationOutcomeSplitLimit(childUniverses, { '123': 50n }, 50n, new Set(['1']))).toBe(0n)
+ })
})
diff --git a/ui/ts/tests/scalarOutcome.test.ts b/ui/ts/tests/scalarOutcome.test.ts
index 63ba1a2..a6b7c5e 100644
--- a/ui/ts/tests/scalarOutcome.test.ts
+++ b/ui/ts/tests/scalarOutcome.test.ts
@@ -1,7 +1,7 @@
///
import { describe, expect, test } from 'bun:test'
-import { formatScalarOutcomeLabel, getScalarOutcomeIndex, getScalarSliderProgress } from '../lib/scalarOutcome.js'
+import { formatScalarOutcomeLabel, getScalarOutcomeIndex, getScalarSliderProgress, parseScalarFormInputs } from '../lib/scalarOutcome.js'
const scalarQuestion = {
answerUnit: 'km',
@@ -23,4 +23,16 @@ void describe('scalar outcome helpers', () => {
expect(() => getScalarOutcomeIndex(scalarQuestion, 11n)).toThrow('Tick index is out of range')
expect(() => formatScalarOutcomeLabel(scalarQuestion, 11n)).toThrow('Tick index is out of range')
})
+
+ void test('parses scalar form decimals into contract values', () => {
+ expect(parseScalarFormInputs({ scalarMin: '1', scalarMax: '10', scalarIncrement: '0.1' })).toEqual({
+ displayValueMax: 10n * 10n ** 18n,
+ displayValueMin: 1n * 10n ** 18n,
+ numTicks: 90n,
+ })
+ })
+
+ void test('rejects scalar inputs that do not divide into whole ticks', () => {
+ expect(() => parseScalarFormInputs({ scalarMin: '1', scalarMax: '10', scalarIncrement: '0.4' })).toThrow('Scalar min, max, and increment do not produce a whole number of ticks')
+ })
})
diff --git a/ui/ts/tests/uniswapQuoter.integration.test.ts b/ui/ts/tests/uniswapQuoter.integration.test.ts
new file mode 100644
index 0000000..f108902
--- /dev/null
+++ b/ui/ts/tests/uniswapQuoter.integration.test.ts
@@ -0,0 +1,121 @@
+///
+
+/**
+ * Integration tests — these hit the real Ethereum mainnet RPC.
+ * They verify on-chain behavior and are intentionally separate from the unit tests
+ * in uniswapQuoter.test.ts which mock all contract calls.
+ */
+
+import { describe, expect, test } from 'bun:test'
+import { createPublicClient, http, zeroAddress } from 'viem'
+import { mainnet } from 'viem/chains'
+import { ETH_ADDRESS, REP_ADDRESS, USDC_ADDRESS, quoteExactInput, quoteRepForEth, quoteRepForEthV3, quoteEthForRep, quoteTokenForEth } from '../lib/uniswapQuoter.js'
+
+const RPC_URL = 'https://ethereum.dark.florist'
+
+const client = createPublicClient({
+ chain: mainnet,
+ transport: http(RPC_URL),
+})
+
+const ONE_ETH = 10n ** 18n
+const ONE_REP = 10n ** 18n
+
+void describe('Uniswap V4 Quoter — integration', () => {
+ // Sanity check: ETH/USDC 0.05% pool is established and should always return a price
+ void describe('ETH/USDC (0.05% pool — known to exist on V4)', () => {
+ void test('quotes 1 ETH → USDC and returns a plausible price', async () => {
+ const usdcOut = await quoteExactInput(client, ETH_ADDRESS, USDC_ADDRESS, ONE_ETH, { fee: 500, tickSpacing: 10 })
+ // At time of writing ETH is roughly $2 191 — assert a wide range to keep test non-brittle
+ expect(usdcOut).toBeGreaterThan(100n * 10n ** 6n) // > $100 USDC
+ expect(usdcOut).toBeLessThan(100_000n * 10n ** 6n) // < $100 000 USDC
+ })
+
+ void test('quotes 1 USDC → ETH and returns a plausible price', async () => {
+ const ethOut = await quoteExactInput(client, USDC_ADDRESS, ETH_ADDRESS, 1n * 10n ** 6n, { fee: 500, tickSpacing: 10 })
+ // 1 USDC should buy a small fraction of ETH (more than 0 wei, less than 1 ETH)
+ expect(ethOut).toBeGreaterThan(0n)
+ expect(ethOut).toBeLessThan(ONE_ETH)
+ })
+ })
+
+ // REP does not have any V4 pools at time of writing.
+ // These tests document the current on-chain reality and are expected to throw.
+ // If REP V4 pools are ever created, these tests will start returning prices instead.
+ void describe('REP/ETH — no V4 pool exists yet', () => {
+ void test('quoteRepForEth throws because no V4 REP pool exists', async () => {
+ await expect(quoteRepForEth(client, ONE_REP)).rejects.toThrow()
+ })
+
+ void test('quoteEthForRep throws because no V4 REP pool exists', async () => {
+ await expect(quoteEthForRep(client, ONE_ETH)).rejects.toThrow()
+ })
+
+ void test('quoteTokenForEth(REP) throws for all standard fee tiers', async () => {
+ for (const poolConfig of [
+ { fee: 100, tickSpacing: 1 },
+ { fee: 500, tickSpacing: 10 },
+ { fee: 3000, tickSpacing: 60 },
+ { fee: 10000, tickSpacing: 200 },
+ ]) {
+ await expect(quoteTokenForEth(client, REP_ADDRESS, ONE_REP, poolConfig)).rejects.toThrow()
+ }
+ })
+ })
+
+ void describe('REP/USDC — no V4 pool exists yet', () => {
+ void test('quoteExactInput(REP→USDC) throws because no V4 REP pool exists', async () => {
+ await expect(quoteExactInput(client, REP_ADDRESS, USDC_ADDRESS, ONE_REP)).rejects.toThrow()
+ })
+
+ void test('quoteEthForToken(USDC via REP) throws for all standard fee tiers', async () => {
+ for (const poolConfig of [
+ { fee: 100, tickSpacing: 1 },
+ { fee: 500, tickSpacing: 10 },
+ { fee: 3000, tickSpacing: 60 },
+ { fee: 10000, tickSpacing: 200 },
+ ]) {
+ await expect(quoteExactInput(client, REP_ADDRESS, USDC_ADDRESS, ONE_REP, poolConfig)).rejects.toThrow()
+ }
+ })
+ })
+
+ // REP/WETH V3 1% pool is the live source used as fallback when V4 is unavailable
+ void describe('REP/ETH — Uniswap V3 fallback (1% pool)', () => {
+ void test('quoteRepForEthV3 returns a plausible REP price in ETH', async () => {
+ const ethOut = await quoteRepForEthV3(client, ONE_REP)
+ // At time of writing REP is roughly $0.40 and ETH ~$2 191 → ~0.00018 ETH per REP
+ // Assert a wide range to avoid brittleness
+ expect(ethOut).toBeGreaterThan(10n ** 12n) // > 0.000001 ETH
+ expect(ethOut).toBeLessThan(10n ** 18n) // < 1 ETH
+ })
+ })
+
+ void describe('address constants', () => {
+ void test('ETH_ADDRESS is zeroAddress', () => {
+ expect(ETH_ADDRESS).toBe(zeroAddress)
+ })
+
+ void test('REP_ADDRESS is a valid checksummed address accepted by viem', async () => {
+ // If REP_ADDRESS had a bad checksum, getBlockNumber would still work but this
+ // call would throw an address validation error before any RPC call is made.
+ await expect(
+ client.readContract({
+ address: REP_ADDRESS,
+ abi: [{ name: 'decimals', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] }],
+ functionName: 'decimals',
+ }),
+ ).resolves.toBe(18)
+ })
+
+ void test('USDC_ADDRESS is a valid checksummed address with 6 decimals', async () => {
+ await expect(
+ client.readContract({
+ address: USDC_ADDRESS,
+ abi: [{ name: 'decimals', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] }],
+ functionName: 'decimals',
+ }),
+ ).resolves.toBe(6)
+ })
+ })
+})
diff --git a/ui/ts/tests/uniswapQuoter.test.ts b/ui/ts/tests/uniswapQuoter.test.ts
new file mode 100644
index 0000000..bfeade3
--- /dev/null
+++ b/ui/ts/tests/uniswapQuoter.test.ts
@@ -0,0 +1,188 @@
+///
+
+import { describe, expect, test } from 'bun:test'
+import { zeroAddress } from 'viem'
+import { DEFAULT_POOL_CONFIG, ETH_ADDRESS, REP_ADDRESS, UNISWAP_V4_QUOTER_ADDRESS, quoteEthForRep, quoteEthForToken, quoteExactInput, quoteRepForEth, quoteTokenForEth } from '../lib/uniswapQuoter.js'
+import type { ReadClient } from '../lib/clients.js'
+
+type SimulateArgs = Parameters[0]
+
+type RawSimulateParam = {
+ poolKey: { currency0: string; currency1: string; fee: number; tickSpacing: number; hooks: string }
+ zeroForOne: boolean
+ exactAmount: bigint
+}
+
+type CapturedCall = {
+ address: string
+ zeroForOne: boolean
+ currency0: string
+ currency1: string
+ fee: number
+ tickSpacing: number
+ hooks: string
+ exactAmount: bigint
+}
+
+function extractParams(args: SimulateArgs): CapturedCall {
+ const [param] = args.args as unknown as [RawSimulateParam]
+ return {
+ address: args.address,
+ zeroForOne: param.zeroForOne,
+ currency0: param.poolKey.currency0,
+ currency1: param.poolKey.currency1,
+ fee: param.poolKey.fee,
+ tickSpacing: param.poolKey.tickSpacing,
+ hooks: param.poolKey.hooks,
+ exactAmount: param.exactAmount,
+ }
+}
+
+function createCapturingClient(amountOut: bigint): { client: ReadClient; captured: CapturedCall } {
+ const captured: CapturedCall = { address: '', zeroForOne: false, currency0: '', currency1: '', fee: 0, tickSpacing: 0, hooks: '', exactAmount: 0n }
+ const client = {
+ simulateContract: async (args: SimulateArgs) => {
+ Object.assign(captured, extractParams(args))
+ return { result: [amountOut, 100000n] }
+ },
+ } as unknown as ReadClient
+ return { client, captured }
+}
+
+void describe('quoteExactInput', () => {
+ void test('returns amountOut from the quoter result', async () => {
+ const { client } = createCapturingClient(500000000000000000n)
+ const result = await quoteExactInput(client, REP_ADDRESS, ETH_ADDRESS, 1000000000000000000n)
+ expect(result).toBe(500000000000000000n)
+ })
+
+ void test('calls the Uniswap V4 Quoter contract address', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteExactInput(client, ETH_ADDRESS, REP_ADDRESS, 1n)
+ expect(captured.address).toBe(UNISWAP_V4_QUOTER_ADDRESS)
+ })
+
+ void test('sets zeroForOne = true when tokenIn is numerically lower (ETH → REP)', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteExactInput(client, ETH_ADDRESS, REP_ADDRESS, 1n)
+ expect(captured.zeroForOne).toBe(true)
+ })
+
+ void test('sets zeroForOne = false when tokenIn is numerically higher (REP → ETH)', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteExactInput(client, REP_ADDRESS, ETH_ADDRESS, 1n)
+ expect(captured.zeroForOne).toBe(false)
+ })
+
+ void test('always places the lower address as currency0 (ETH → REP swap)', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteExactInput(client, ETH_ADDRESS, REP_ADDRESS, 1n)
+ expect(captured.currency0).toBe(ETH_ADDRESS)
+ expect(captured.currency1).toBe(REP_ADDRESS)
+ })
+
+ void test('always places the lower address as currency0 (REP → ETH swap)', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteExactInput(client, REP_ADDRESS, ETH_ADDRESS, 1n)
+ expect(captured.currency0).toBe(ETH_ADDRESS)
+ expect(captured.currency1).toBe(REP_ADDRESS)
+ })
+
+ void test('passes pool config fee and tickSpacing', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteExactInput(client, ETH_ADDRESS, REP_ADDRESS, 1n, { fee: 500, tickSpacing: 10 })
+ expect(captured.fee).toBe(500)
+ expect(captured.tickSpacing).toBe(10)
+ })
+
+ void test('defaults hooks to zeroAddress when not specified', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteExactInput(client, ETH_ADDRESS, REP_ADDRESS, 1n, { fee: 3000, tickSpacing: 60 })
+ expect(captured.hooks).toBe(zeroAddress)
+ })
+
+ void test('passes amountIn as exactAmount', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ const amountIn = 7500000000000000000n
+ await quoteExactInput(client, ETH_ADDRESS, REP_ADDRESS, amountIn)
+ expect(captured.exactAmount).toBe(amountIn)
+ })
+
+ void test('uses DEFAULT_POOL_CONFIG when no pool config is provided', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteExactInput(client, ETH_ADDRESS, REP_ADDRESS, 1n)
+ expect(captured.fee).toBe(DEFAULT_POOL_CONFIG.fee)
+ expect(captured.tickSpacing).toBe(DEFAULT_POOL_CONFIG.tickSpacing)
+ })
+})
+
+void describe('quoteTokenForEth', () => {
+ void test('returns ETH amount out for the given token amount', async () => {
+ const { client } = createCapturingClient(400000000000000000n)
+ const result = await quoteTokenForEth(client, REP_ADDRESS, 1000000000000000000n)
+ expect(result).toBe(400000000000000000n)
+ })
+
+ void test('routes token → ETH (zeroForOne = false for REP)', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteTokenForEth(client, REP_ADDRESS, 1n)
+ expect(captured.zeroForOne).toBe(false)
+ })
+
+ void test('uses the provided token as currency1 when it is numerically higher than ETH', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteTokenForEth(client, REP_ADDRESS, 1n)
+ expect(captured.currency1).toBe(REP_ADDRESS)
+ expect(captured.currency0).toBe(ETH_ADDRESS)
+ })
+})
+
+void describe('quoteEthForToken', () => {
+ void test('returns token amount out for the given ETH amount', async () => {
+ const { client } = createCapturingClient(12000000000000000000n)
+ const result = await quoteEthForToken(client, REP_ADDRESS, 1000000000000000000n)
+ expect(result).toBe(12000000000000000000n)
+ })
+
+ void test('routes ETH → token (zeroForOne = true for REP)', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteEthForToken(client, REP_ADDRESS, 1n)
+ expect(captured.zeroForOne).toBe(true)
+ })
+})
+
+void describe('quoteRepForEth', () => {
+ void test('returns ETH amount out for REP input', async () => {
+ const { client } = createCapturingClient(300000000000000000n)
+ const result = await quoteRepForEth(client, 1000000000000000000n)
+ expect(result).toBe(300000000000000000n)
+ })
+
+ void test('uses REP_ADDRESS as the input token', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteRepForEth(client, 1n)
+ expect(captured.currency1).toBe(REP_ADDRESS)
+ expect(captured.zeroForOne).toBe(false)
+ })
+})
+
+void describe('quoteEthForRep', () => {
+ void test('returns REP amount out for ETH input', async () => {
+ const { client } = createCapturingClient(8000000000000000000n)
+ const result = await quoteEthForRep(client, 1000000000000000000n)
+ expect(result).toBe(8000000000000000000n)
+ })
+
+ void test('uses REP_ADDRESS as the output token', async () => {
+ const { client, captured } = createCapturingClient(1n)
+ await quoteEthForRep(client, 1n)
+ expect(captured.currency1).toBe(REP_ADDRESS)
+ expect(captured.zeroForOne).toBe(true)
+ })
+})
+
+void describe('ETH_ADDRESS', () => {
+ void test('is the zero address (Uniswap V4 ETH convention)', () => {
+ expect(ETH_ADDRESS).toBe(zeroAddress)
+ })
+})
diff --git a/ui/ts/types/app.ts b/ui/ts/types/app.ts
index 336780f..960175a 100644
--- a/ui/ts/types/app.ts
+++ b/ui/ts/types/app.ts
@@ -11,14 +11,14 @@ export type AccountState = {
export type MarketFormState = {
answerUnit: string
- categoricalOutcomes: string
+ categoricalOutcomes: string[]
description: string
- displayValueMax: string
- displayValueMin: string
+ scalarIncrement: string
+ scalarMax: string
+ scalarMin: string
title: string
endTime: string
marketType: MarketType
- numTicks: string
startTime: string
}
@@ -31,14 +31,19 @@ export type SecurityPoolFormState = {
export type SecurityVaultFormState = {
depositAmount: string
- repApprovalAmount: string
+ securityBondAllowanceAmount: string
+ repWithdrawAmount: string
securityPoolAddress: string
}
export type OpenOracleFormState = {
amount1: string
amount2: string
+ disputeNewAmount1: string
+ disputeNewAmount2: string
+ disputeTokenToSwap: 'token1' | 'token2'
managerAddress: string
+ openOracleAddress: string
operationAmount: string
operationTargetVault: string
queuedOperation: 'liquidation' | 'withdrawRep' | 'setSecurityBondsAllowance'
diff --git a/ui/ts/types/components.ts b/ui/ts/types/components.ts
index ad4e80c..818c00f 100644
--- a/ui/ts/types/components.ts
+++ b/ui/ts/types/components.ts
@@ -9,6 +9,7 @@ import type {
MarketCreationResult,
MarketDetails,
OpenOracleActionResult,
+ OpenOracleReportDetails,
OracleManagerDetails,
ReportingActionResult,
ReportingDetails,
@@ -40,6 +41,11 @@ export type OverviewPanelsProps = {
universeErrorMessage: string | undefined
universeLabel: string
isRefreshing: boolean
+ repEthPrice: bigint | undefined
+ repEthSource: 'v4' | 'v3' | undefined
+ repUsdcPrice: bigint | undefined
+ repUsdcSource: 'v4' | 'v3' | undefined
+ isLoadingRepPrices: boolean
onConnect: () => void
onGoToGenesisUniverse: () => void
onRefresh: () => void
@@ -74,7 +80,7 @@ export type DeploymentRouteContentProps = {
export type MarketRouteContentProps = {
accountState: AccountState
- onApproveZoltarForkRep: () => void
+ onApproveZoltarForkRep: (amount?: bigint) => void
onCreateChildUniverseForOutcomeIndex: (outcomeIndex: bigint) => void
onCreateMarket: () => void
onForkZoltar: () => void
@@ -121,14 +127,14 @@ export type SecurityPoolRouteContentProps = {
checkingDuplicateOriginPool: boolean
duplicateOriginPoolExists: boolean
onCreateSecurityPool: () => void
- lastCreatedQuestionId: string | undefined
- onLoadLatestMarket?: () => void
onLoadMarket: () => void
onLoadMarketById: (marketId: string) => Promise
loadingMarketDetails: boolean
marketDetails: MarketDetails | undefined
poolCreationMarketDetails: MarketDetails | undefined
+ onResetSecurityPoolCreation: () => void
onSecurityPoolFormChange: (update: Partial) => void
+ zoltarUniverseHasForked: boolean
securityPools: ListedSecurityPool[]
securityPoolCreating: boolean
securityPoolError: string | undefined
@@ -164,12 +170,11 @@ export type SecurityPoolsOverviewRouteContentProps = {
securityPools: ListedSecurityPool[]
} & LiquidationControlsProps
-export type SecurityPoolsOverviewSectionProps = SecurityPoolsOverviewRouteContentProps & {
- showHeader?: boolean
-}
+export type SecurityPoolsOverviewSectionProps = SecurityPoolsOverviewRouteContentProps
export type SecurityPoolWorkflowRouteContentProps = {
accountState: AccountState
+ activeUniverseId: bigint
closeLiquidationModal: () => void
forkAuction: ForkAuctionRouteContentProps
liquidationAmount: string
@@ -198,20 +203,24 @@ export type SecurityPoolsSectionProps = {
export type SecurityVaultRouteContentProps = {
accountState: AccountState
loadingSecurityVault: boolean
- onApproveRep: () => void
+ onApproveRep: (amount?: bigint) => void
onDepositRep: () => void
onLoadSecurityVault: () => void
onRedeemFees: () => void
- onRedeemRep: () => void
+ onSetSecurityBondAllowance: () => void
onSecurityVaultFormChange: (update: Partial) => void
- onUpdateVaultFees: () => void
+ onWithdrawRep: () => void
securityVaultDetails: SecurityVaultDetails | undefined
securityVaultError: string | undefined
securityVaultForm: SecurityVaultFormState
+ securityVaultRepAllowance: bigint | undefined
+ securityVaultRepBalance: bigint | undefined
securityVaultResult: SecurityVaultActionResult | undefined
}
export type SecurityVaultSectionProps = SecurityVaultRouteContentProps & {
+ compactLayout?: boolean
+ autoLoadVault?: boolean
showSecurityPoolAddressInput?: boolean
showHeader?: boolean
}
@@ -219,9 +228,12 @@ export type SecurityVaultSectionProps = SecurityVaultRouteContentProps & {
export type OpenOracleRouteContentProps = {
accountState: AccountState
loadingOracleManager: boolean
+ loadingOracleReport: boolean
onApproveToken1: () => void
onApproveToken2: () => void
+ onDisputeReport: () => void
onLoadOracleManager: () => void
+ onLoadOracleReport: () => void
onOpenOracleFormChange: (update: Partial) => void
onQueueOperation: () => void
onRequestPrice: () => void
@@ -229,6 +241,7 @@ export type OpenOracleRouteContentProps = {
onSubmitInitialReport: () => void
openOracleError: string | undefined
openOracleForm: OpenOracleFormState
+ openOracleReportDetails: OpenOracleReportDetails | undefined
openOracleResult: OpenOracleActionResult | undefined
oracleManagerDetails: OracleManagerDetails | undefined
}
diff --git a/ui/ts/types/contracts.ts b/ui/ts/types/contracts.ts
index d60a84d..1b937af 100644
--- a/ui/ts/types/contracts.ts
+++ b/ui/ts/types/contracts.ts
@@ -119,6 +119,7 @@ export type SecurityPoolCreationResult = {
export type SecurityVaultDetails = {
currentRetentionRate: bigint
lockedRepInEscalationGame: bigint
+ managerAddress: Address
poolOwnershipDenominator: bigint
repDepositShare: bigint
repToken: Address
@@ -131,7 +132,7 @@ export type SecurityVaultDetails = {
}
export type SecurityVaultActionResult = {
- action: 'approveRep' | 'depositRep' | 'redeemFees' | 'redeemRep' | 'updateVaultFees'
+ action: 'approveRep' | 'depositRep' | 'queueSetSecurityBondAllowance' | 'queueWithdrawRep' | 'redeemFees' | 'updateVaultFees'
hash: Hash
}
@@ -148,10 +149,43 @@ export type OracleManagerDetails = {
}
export type OpenOracleActionResult = {
- action: 'approveToken1' | 'approveToken2' | 'queueOperation' | 'requestPrice' | 'settle' | 'submitInitialReport'
+ action: 'approveToken1' | 'approveToken2' | 'dispute' | 'queueOperation' | 'requestPrice' | 'settle' | 'submitInitialReport'
hash: Hash
}
+export type OpenOracleReportDetails = {
+ // Identity
+ reportId: bigint
+ openOracleAddress: Address
+ // Meta
+ exactToken1Report: bigint
+ escalationHalt: bigint
+ fee: bigint
+ settlerReward: bigint
+ token1: Address
+ token2: Address
+ settlementTime: bigint
+ timeType: boolean
+ feePercentage: bigint
+ protocolFee: bigint
+ multiplier: bigint
+ disputeDelay: bigint
+ // Status
+ currentAmount1: bigint
+ currentAmount2: bigint
+ price: bigint
+ currentReporter: Address
+ reportTimestamp: bigint
+ settlementTimestamp: bigint
+ initialReporter: Address
+ disputeOccurred: boolean
+ isDistributed: boolean
+ // Extra
+ stateHash: Hex
+ callbackContract: Address
+ numReports: bigint
+}
+
export type ListedSecurityPool = {
currentRetentionRate: bigint
forkOutcome: ReportingOutcomeKey | 'none'