Skip to content

Commit

Permalink
feat: Max EB Consolidation (#294)
Browse files Browse the repository at this point in the history
  • Loading branch information
rickimoore authored Feb 11, 2025
1 parent 71e048d commit d122485
Show file tree
Hide file tree
Showing 37 changed files with 1,432 additions and 204 deletions.
24 changes: 15 additions & 9 deletions app/dashboard/validators/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import DashboardWrapper from '../../../src/components/DashboardWrapper/Dashboard
import EditValidatorModal from '../../../src/components/EditValidatorModal/EditValidatorModal'
import Typography from '../../../src/components/Typography/Typography'
import AddValidatorView from '../../../src/components/ValidatorManagement/AddValidatorView/AddValidatorView'
import ConsolidateView from '../../../src/components/ValidatorManagement/ConsolidateView/ConsolidateView'
import CreateValidatorView from '../../../src/components/ValidatorManagement/CreateValidatorView/CreateValidatorView'
import MainView from '../../../src/components/ValidatorManagement/MainView'
import ValidatorModal from '../../../src/components/ValidatorModal/ValidatorModal'
Expand Down Expand Up @@ -67,7 +68,7 @@ const Main: FC<MainProps> = (props) => {
})

const router = useRouter()
const { SECONDS_PER_SLOT, SLOTS_PER_EPOCH } = beaconSpec
const { SECONDS_PER_SLOT, SLOTS_PER_EPOCH, DEPOSIT_CHAIN_ID } = beaconSpec
const setExchangeRate = useSetRecoilState(exchangeRates)
const [search, setSearch] = useState('')
const [activeValId, setValidatorId] = useRecoilState(activeValidatorId)
Expand Down Expand Up @@ -176,22 +177,23 @@ const Main: FC<MainProps> = (props) => {
}

const changeView = (view: ValidatorManagementView) => setView(view)
const viewAddValidator = () => changeView(ValidatorManagementView.ADD)
const viewMain = () => changeView(ValidatorManagementView.MAIN)

const goBack = () => {
let backView = ValidatorManagementView.MAIN
if (view === ValidatorManagementView.CREATE) {
viewAddValidator()
} else {
viewMain()
backView = ValidatorManagementView.ADD
}

changeView(backView)
}
const getPageTitle = (view: string) => {
switch (view) {
case ValidatorManagementView.CREATE:
return t('validatorManagement.titles.create')
case ValidatorManagementView.ADD:
return t('validatorManagement.titles.add')
case ValidatorManagementView.CONSOLIDATE:
return 'Consolidate'
default:
return t('validatorManagement.titles.main')
}
Expand All @@ -204,13 +206,16 @@ const Main: FC<MainProps> = (props) => {
)
case ValidatorManagementView.ADD:
return <AddValidatorView onChangeView={changeView} />
case ValidatorManagementView.CONSOLIDATE:
return <ConsolidateView chainId={Number(DEPOSIT_CHAIN_ID)} validators={validatorStates} />
default:
return (
<MainView
validators={filteredValidators}
search={search}
chainId={Number(DEPOSIT_CHAIN_ID)}
onSetSearch={setSearch}
onChangeView={viewAddValidator}
onChangeView={changeView}
scrollPercentage={scrollPercentage}
/>
)
Expand All @@ -227,8 +232,9 @@ const Main: FC<MainProps> = (props) => {
isBeaconError={isBeaconError}
isValidatorError={isValidatorError}
nodeHealth={nodeHealth}
className='w-full flex flex-1 flex-col p-4 max-w-[96vw]'
>
<div className='w-full flex flex-col pb-12 p-4 max-w-[96vw]'>
<>
<div className='w-full mb-6 flex flex-col lg:items-center lg:flex-row space-y-8 lg:space-y-0 justify-between'>
<div className='space-x-4 flex items-center'>
{view !== ValidatorManagementView.MAIN && (
Expand All @@ -253,7 +259,7 @@ const Main: FC<MainProps> = (props) => {
/>
</div>
{renderView(view)}
</div>
</>
</DashboardWrapper>
<BlsExecutionModal />
{isValDetail && activeValidator && (
Expand Down
47 changes: 27 additions & 20 deletions src/components/CheckBox/CheckBox.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
import { FC } from 'react'
import React, { FC, InputHTMLAttributes } from 'react'
import addClassString from '../../../utilities/addClassString'

export interface CheckBoxProps {
className?: string
id: string
export interface CheckBoxProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
value?: string
checked?: boolean
onChange?: () => void
containerClassName?: string
labelStyle?: string
inputClassName?: string
}

const CheckBox: FC<CheckBoxProps> = ({ id, className, label, value, checked, onChange }) => {
const classes = addClassString('flex items-center', [className])
const CheckBox: FC<CheckBoxProps> = ({
id,
label,
containerClassName,
labelStyle,
inputClassName,
...inputProps
}) => {
const containerClasses = addClassString('flex items-center', [containerClassName])
const inputClasses = addClassString(
'w-5 h-5 border border-gray-300 accent-primary bg-transparent border-style500 rounded focus:ring-purple-500 dark:focus:ring-purple-600 dark:ring-offset-gray-800 dark:border-gray-600',
[inputClassName],
)
const labelClasses = addClassString('ml-2 text-gray-900 dark:text-gray-300', [
labelStyle || 'text-sm font-medium',
])

return (
<div className={classes}>
<div className={containerClasses}>
<input
data-testid='checkbox'
onChange={onChange}
checked={checked}
id={id}
data-testid='checkbox'
type='checkbox'
value={value}
className='w-5 rounded h-5 border-gray-300 accent-primary bg-transparent border-style500 rounded focus:ring-purple-500 dark:focus:ring-purple-600 dark:ring-offset-gray-800 focus:ring-2 dark:border-gray-600'
{...inputProps}
className={inputClasses}
/>
{label && (
<label
data-testid='checkbox-label'
htmlFor={id}
className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'
>
<label data-testid='checkbox-label' htmlFor={id} className={labelClasses}>
{label}
</label>
)}
Expand Down
123 changes: 123 additions & 0 deletions src/components/ConsolidateValidator/Consolidate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { dataSlice, getAddress } from 'ethers'
import { FC, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAccount, useSendTransaction, useEstimateGas, useGasPrice } from 'wagmi'
import displayToast from '../../../utilities/displayToast'
import { CONSOLIDATION_CONTRACT } from '../../constants/constants'
import useHasSufficientBalance from '../../hooks/useHasSufficientBalance'
import { Address, ConsolidationTx, ToastType } from '../../types'
import { ValidatorInfo } from '../../types/validator'
import Button, { ButtonFace } from '../Button/Button'
import WalletActionGuard from '../WalletActionGuard/WalletActionGuard'

export interface ConsolidateViewProps {
targetPubKey: string
sourceValidator: ValidatorInfo
queueLength?: BigInt | undefined
chainId: number
bufferPercentage: bigint
onSubmitRequest: (request: ConsolidationTx) => void
}

const Consolidate: FC<ConsolidateViewProps> = ({
targetPubKey,
sourceValidator,
chainId,
queueLength,
bufferPercentage,
onSubmitRequest,
}) => {
const { t } = useTranslation()
const { index, pubKey: sourcePubKey, withdrawalAddress } = sourceValidator
const txData = '0x' + sourcePubKey.substring(2) + targetPubKey.substring(2)
const { address } = useAccount()
const [isLoading, setIsLoading] = useState(false)
const submitRequest = useSendTransaction()
const getRequiredFee = (numerator: bigint, percentage = 0n): bigint => {
// https://eips.ethereum.org/EIPS/eip-7251#fee-calculation
let i = 1n
let output = 0n
let numeratorAccum = 1n * 17n

while (numeratorAccum > 0n) {
output += numeratorAccum
numeratorAccum = (numeratorAccum * numerator) / (17n * i)
i += 1n
}

const baseFee = output / 17n

return (baseFee * (100n + BigInt(percentage)) + (100n - 1n)) / 100n
}
const requestFee = useMemo(() => {
if (!queueLength) return 0n

return getRequiredFee(BigInt(queueLength), bufferPercentage)
}, [queueLength, bufferPercentage])

const { data: gasPrice } = useGasPrice({ chainId })

const { data: estimatedGasData } = useEstimateGas({
to: CONSOLIDATION_CONTRACT,
value: requestFee,
data: txData,
})

const estimatedGasLimit = estimatedGasData
? (BigInt(estimatedGasData.toString()) * 110n) / 100n
: null
const gasFee = estimatedGasLimit ? estimatedGasLimit * gasPrice : 0n
const totalRequiredFunds = requestFee + gasFee
const { isSufficient } = useHasSufficientBalance(totalRequiredFunds)

const formattedWithdrawalAddress = getAddress(
dataSlice(withdrawalAddress as string, 12),
) as Address

const handleTxError = (e: any) => {
const error = (e as Error).message
let errorMessage = 'error.unexpectedConsolidationError'
if (error.toLowerCase().includes('user rejected the request')) {
errorMessage = 'error.userRejectedTransaction'
}

displayToast(t(errorMessage), ToastType.ERROR)
}

const submitConsolidation = async () => {
setIsLoading(true)
try {
const txHash = await submitRequest.sendTransactionAsync({
to: CONSOLIDATION_CONTRACT,
account: address,
chainId,
value: requestFee,
data: txData,
gas: estimatedGasLimit,
})
onSubmitRequest({
index,
pubKey: sourcePubKey,
txHash,
status: 'pending',
})
} catch (error) {
handleTxError(error)
} finally {
setIsLoading(false)
}
}

return (
<WalletActionGuard
isSufficientBalance={isSufficient}
targetAddress={formattedWithdrawalAddress}
>
<Button isLoading={isLoading} type={ButtonFace.SECONDARY} onClick={submitConsolidation}>
{t('validatorManagement.consolidateView.signAndSubmit.submitRequest')}
</Button>
</WalletActionGuard>
)
}

export default Consolidate
94 changes: 94 additions & 0 deletions src/components/HorizontalStepper/HorizontalStepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { motion } from 'framer-motion'
import Carousel from 'nuka-carousel'
import React, {
FC,
ReactElement,
useState,
useCallback,
useMemo,
Fragment,
Children,
isValidElement,
} from 'react'
import ProgressBar from '../ProgressBar/ProgressBar'
import Typography from '../Typography/Typography'

export interface StepperRenderProps {
incrementStep: () => void
decrementStep: () => void
step: number
}

export interface HorizontalStepperProps {
children: (props: StepperRenderProps) => ReactElement | ReactElement[]
steps: string[]
}

const HorizontalStepper: FC<HorizontalStepperProps> = ({ children, steps }) => {
const totalSteps = steps.length
const [currentStep, setCurrentStep] = useState(0)

const incrementStep = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, totalSteps - 1))
}, [totalSteps])

const decrementStep = useCallback(() => {
setCurrentStep((prev) => Math.max(prev - 1, 0))
}, [])

const stepperProps = useMemo(
() => ({
incrementStep,
decrementStep,
step: currentStep,
}),
[incrementStep, decrementStep, currentStep],
)

const slides = useMemo(() => {
return Children.toArray(children(stepperProps)).flatMap((child) => {
if (isValidElement(child) && child.type === Fragment) {
return Children.toArray(child.props.children)
}
return child
})
}, [children, stepperProps])

return (
<>
<div className='w-full'>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className='w-full flex'>
{steps.map((label, index) => (
<div
key={index}
className='flex-1 h-11 bg-dark25 dark:bg-dark750 flex items-center justify-center border-r dark:border-r-dark600 last:border-r-0'
>
<div className='flex space-x-2 items-center'>
<div className='w-6 h-6 lg:w-3 lg:h-3 flex items-center justify-center border dark:border-dark300 text-dark900 rounded-full'>
<Typography type='text-caption' className='lg:text-xTiny'>
{index + 1}
</Typography>
</div>
<Typography className='hidden lg:block' type='text-caption1'>
{label}
</Typography>
</div>
</div>
))}
</motion.div>
<ProgressBar total={totalSteps} position={currentStep + 1} />
</div>
<div className='w-full h-full relative createSlide'>
<Carousel swiping={false} slideIndex={currentStep} dragging={false} withoutControls>
{slides.map((child, index) => (
<div key={index} className='h-full w-full'>
{child}
</div>
))}
</Carousel>
</div>
</>
)
}

export default HorizontalStepper
Loading

0 comments on commit d122485

Please sign in to comment.