Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/components/validators/ConsolidationDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use client'

import { useMemo, useState } from 'react'
import type { ValidatorReconciliation } from '@/src/hooks/useValidatorBalances'
import { useConsolidationEligibility } from '@/src/hooks/useConsolidationEligibility'
import { sortConsolidationRecommendations, type ConsolidationSortKey } from '@/src/utils/consolidationEligibility'

interface ConsolidationDashboardProps {
validatorIndices: number[]
byValidator: Record<number, ValidatorReconciliation>
}

const sortLabels: Record<ConsolidationSortKey, string> = {
savings: 'Potential savings',
validator_count: 'Validator count',
readiness_score: 'Readiness score',
}

export function ConsolidationDashboard({ validatorIndices, byValidator }: ConsolidationDashboardProps) {
const [sortBy, setSortBy] = useState<ConsolidationSortKey>('savings')
const { recommendations, processed, total, isLoading, error } = useConsolidationEligibility(validatorIndices, byValidator)
const sorted = useMemo(() => sortConsolidationRecommendations(recommendations, sortBy), [recommendations, sortBy])

return (
<div className="space-y-4 px-6 pb-6">
<div className="flex flex-col justify-between gap-3 rounded-2xl border border-sky-400/20 bg-sky-400/10 p-4 sm:flex-row sm:items-center">
<div>
<h3 className="font-semibold text-sky-100">EIP-7251 consolidation eligibility</h3>
<p className="text-sm text-slate-300">
Scans up to 10,000 validators in 500-validator worker chunks and groups validators that share withdrawal credentials.
</p>
</div>
<label className="text-sm text-slate-300">
Sort by{' '}
<select
value={sortBy}
onChange={(event) => setSortBy(event.target.value as ConsolidationSortKey)}
className="rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white"
>
{Object.entries(sortLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
</div>

{isLoading && <p className="text-sm text-slate-400">Scanning validators… {processed.toLocaleString()} / {total.toLocaleString()}</p>}
{error && <p className="text-sm text-red-300">{error}</p>}

<div className="overflow-x-auto rounded-2xl border border-white/10">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="bg-white/5 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th className="px-4 py-3">Group ID</th>
<th className="px-4 py-3">Current count</th>
<th className="px-4 py-3">Merged count</th>
<th className="px-4 py-3">Potential gas savings/year</th>
<th className="px-4 py-3">Readiness score</th>
<th className="px-4 py-3">Total effective ETH</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{sorted.map((group) => (
<tr key={group.groupId} className="text-slate-200">
<td className="px-4 py-3 font-mono text-sky-300">{group.groupId}</td>
<td className="px-4 py-3 tabular-nums">{group.currentCount.toLocaleString()}</td>
<td className="px-4 py-3 tabular-nums">{group.mergedCount.toLocaleString()}</td>
<td className="px-4 py-3 tabular-nums text-emerald-300">{group.estimatedAnnualGasSavings.toLocaleString()} gas</td>
<td className="px-4 py-3 tabular-nums">{(group.readinessScore * 100).toFixed(1)}%</td>
<td className="px-4 py-3 tabular-nums">{group.totalEffectiveBalanceEth.toFixed(2)}</td>
</tr>
))}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-400">
No consolidation-eligible groups found for this validator set.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
25 changes: 25 additions & 0 deletions src/components/validators/ValidatorDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ValidatorUnlockCard } from '@/src/components/validators/ValidatorUnlock
import { ExitQueuePositionCard } from '@/src/components/validators/ExitQueuePositionCard'
import { CommitteeTopologyMap } from '@/src/components/canvas/CommitteeTopologyMap'
import { ShardLegend } from '@/src/components/validators/ShardLegend'
import { ConsolidationDashboard } from '@/src/components/validators/ConsolidationDashboard'
import { useValidatorBalances } from '@/src/hooks/useValidatorBalances'
import { useCommitteeAssignments } from '@/src/hooks/useCommitteeAssignments'

Expand All @@ -26,6 +27,7 @@ export function ValidatorDashboard({
const [open, setOpen] = useState(true)
const [queueOpen, setQueueOpen] = useState(true)
const [topoOpen, setTopoOpen] = useState(true)
const [consolidationOpen, setConsolidationOpen] = useState(true)
const { byValidator } = useValidatorBalances(validatorIndices, { beaconNodeUrl })
const committee = useCommitteeAssignments(validatorIndices, { beaconNodeUrl })

Expand Down Expand Up @@ -102,6 +104,29 @@ export function ValidatorDashboard({
)}
</section>

<section className="overflow-hidden rounded-3xl border border-white/10 bg-slate-900/80 text-white">
<button
type="button"
onClick={() => setConsolidationOpen((o) => !o)}
className="flex w-full items-center justify-between gap-4 p-6 text-left"
aria-expanded={consolidationOpen}
>
<div>
<h2 className="text-xl font-semibold">Consolidation</h2>
<p className="text-sm text-slate-400">
EIP-7251 merge recommendations · {validatorIndices.length} validators
</p>
</div>
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-white/10 text-lg text-slate-300">
{consolidationOpen ? '−' : '+'}
</span>
</button>

{consolidationOpen && (
<ConsolidationDashboard validatorIndices={validatorIndices} byValidator={byValidator} />
)}
</section>

<section className="overflow-hidden rounded-3xl border border-white/10 bg-slate-900/80 text-white">
<button
type="button"
Expand Down
76 changes: 76 additions & 0 deletions src/hooks/useConsolidationEligibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client'

import { useEffect, useMemo, useState } from 'react'
import type { ValidatorReconciliation } from '@/src/hooks/useValidatorBalances'
import type { ConsolidationRecommendation, ConsolidationValidator } from '@/src/utils/consolidationEligibility'
import type { ConsolidationScannerResponse } from '@/src/workers/consolidationScannerWorker'
import { gweiToEth } from '@/src/utils/ethMath'

interface UseConsolidationEligibilityOptions {
avgGasPerValidatorPerEpoch?: number
}

function fallbackWithdrawalCredentials(validatorIndex: number): string {
return `0xdemo${String(Math.floor(validatorIndex / 64)).padStart(60, '0')}`
}

export function useConsolidationEligibility(
validatorIndices: number[],
byValidator: Record<number, ValidatorReconciliation>,
options: UseConsolidationEligibilityOptions = {},
) {
const [recommendations, setRecommendations] = useState<ConsolidationRecommendation[]>([])
const [processed, setProcessed] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const scanKey = validatorIndices.join(',')

const validators = useMemo<ConsolidationValidator[]>(() => validatorIndices.map((validatorIndex) => {
const latest = byValidator[validatorIndex]?.summary.latest
return {
validatorIndex,
effectiveBalanceEth: latest ? gweiToEth(latest.effectiveBalanceGwei) : 32,
activationEpoch: Math.max(0, (latest?.epoch ?? 0) - (byValidator[validatorIndex]?.summary.recordCount ?? 0)),
withdrawalCredentials: fallbackWithdrawalCredentials(validatorIndex),
}
}), [validatorIndices, byValidator])

const [trackedScanKey, setTrackedScanKey] = useState<string | undefined>(undefined)
if (trackedScanKey !== scanKey) {
setTrackedScanKey(scanKey)
setIsLoading(validators.length > 0)
setError(null)
setProcessed(0)
setRecommendations([])
}

useEffect(() => {
if (validators.length === 0) return
const worker = new Worker(new URL('../workers/consolidationScannerWorker.ts', import.meta.url))

worker.onmessage = (event: MessageEvent<ConsolidationScannerResponse>) => {
const message = event.data
setProcessed(message.processed)
if (message.type === 'complete') {
setRecommendations(message.recommendations ?? [])
setIsLoading(false)
worker.terminate()
} else if (message.type === 'error') {
setError(message.error ?? 'Failed to scan validators')
setIsLoading(false)
worker.terminate()
}
}

worker.onerror = () => {
setError('Consolidation scanner worker failed')
setIsLoading(false)
worker.terminate()
}

worker.postMessage({ validators, avgGasPerValidatorPerEpoch: options.avgGasPerValidatorPerEpoch })
return () => worker.terminate()
}, [validators, options.avgGasPerValidatorPerEpoch])

return { recommendations, processed, total: validators.length, isLoading, error }
}
88 changes: 88 additions & 0 deletions src/utils/consolidationEligibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { CONSOLIDATED_VALIDATOR_EFFECTIVE_BALANCE_CAP_ETH, STANDARD_VALIDATOR_EFFECTIVE_BALANCE_ETH, clamp } from '@/src/utils/ethMath'
import { DEFAULT_AVG_GAS_PER_VALIDATOR_PER_EPOCH, estimateAnnualGasSavings } from '@/src/utils/gasEstimator'

export interface ConsolidationValidator {
validatorIndex: number
effectiveBalanceEth: number
activationEpoch: number
withdrawalCredentials: string
}

export interface ConsolidationRecommendation {
groupId: string
withdrawalCredentials: string
validatorIndices: number[]
currentCount: number
mergedCount: number
totalEffectiveBalanceEth: number
estimatedAnnualGasSavings: number
readinessScore: number
matchingWithdrawalCredsRatio: number
}

export type ConsolidationSortKey = 'savings' | 'validator_count' | 'readiness_score'

function readinessScore(validators: ConsolidationValidator[], groupSize: number): number {
const matchingWithdrawalCredsRatio = validators.length === 0 ? 0 : groupSize / validators.length
const balanceProximityTo32 = validators.length === 0
? 0
: validators.reduce((sum, v) => sum + clamp(1 - Math.abs(v.effectiveBalanceEth - STANDARD_VALIDATOR_EFFECTIVE_BALANCE_ETH) / STANDARD_VALIDATOR_EFFECTIVE_BALANCE_ETH), 0) / validators.length
const latestActivation = Math.max(...validators.map((v) => v.activationEpoch), 0)
const activationEpochProximity = validators.length === 0 || latestActivation === 0
? 1
: validators.reduce((sum, v) => sum + clamp(v.activationEpoch / latestActivation), 0) / validators.length

return Number((0.5 * matchingWithdrawalCredsRatio + 0.3 * balanceProximityTo32 + 0.2 * activationEpochProximity).toFixed(4))
}

export function findConsolidationRecommendations(
validators: ConsolidationValidator[],
avgGasPerValidatorPerEpoch = DEFAULT_AVG_GAS_PER_VALIDATOR_PER_EPOCH,
): ConsolidationRecommendation[] {
const byCreds = new Map<string, ConsolidationValidator[]>()
for (const validator of validators) {
const list = byCreds.get(validator.withdrawalCredentials) ?? []
list.push(validator)
byCreds.set(validator.withdrawalCredentials, list)
}

const recommendations: ConsolidationRecommendation[] = []
for (const [withdrawalCredentials, group] of byCreds) {
if (group.length < 2) continue
const sorted = [...group].sort((a, b) => b.effectiveBalanceEth - a.effectiveBalanceEth)
const bins: number[] = []
for (const validator of sorted) {
const fitIndex = bins.findIndex((balance) => balance + validator.effectiveBalanceEth <= CONSOLIDATED_VALIDATOR_EFFECTIVE_BALANCE_CAP_ETH)
if (fitIndex === -1) bins.push(validator.effectiveBalanceEth)
else bins[fitIndex] += validator.effectiveBalanceEth
}

const mergedCount = bins.length
if (mergedCount >= group.length) continue
const totalEffectiveBalanceEth = group.reduce((sum, v) => sum + v.effectiveBalanceEth, 0)
recommendations.push({
groupId: `wc-${withdrawalCredentials.slice(-8)}`,
withdrawalCredentials,
validatorIndices: group.map((v) => v.validatorIndex),
currentCount: group.length,
mergedCount,
totalEffectiveBalanceEth,
estimatedAnnualGasSavings: estimateAnnualGasSavings({ currentValidatorCount: group.length, mergedValidatorCount: mergedCount, avgGasPerValidatorPerEpoch }),
readinessScore: readinessScore(group, group.length),
matchingWithdrawalCredsRatio: 1,
})
}

return sortConsolidationRecommendations(recommendations, 'savings')
}

export function sortConsolidationRecommendations(
recommendations: ConsolidationRecommendation[],
sortBy: ConsolidationSortKey,
): ConsolidationRecommendation[] {
return [...recommendations].sort((a, b) => {
if (sortBy === 'validator_count') return b.currentCount - a.currentCount
if (sortBy === 'readiness_score') return b.readinessScore - a.readinessScore
return b.estimatedAnnualGasSavings - a.estimatedAnnualGasSavings
})
}
15 changes: 15 additions & 0 deletions src/utils/ethMath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const GWEI_PER_ETH = BigInt(1_000_000_000)
export const STANDARD_VALIDATOR_EFFECTIVE_BALANCE_ETH = 32
export const CONSOLIDATED_VALIDATOR_EFFECTIVE_BALANCE_CAP_ETH = 2048

export function gweiToEth(gwei: bigint): number {
return Number(gwei) / Number(GWEI_PER_ETH)
}

export function ethToGwei(eth: number): bigint {
return BigInt(Math.round(eth * Number(GWEI_PER_ETH)))
}

export function clamp(value: number, min = 0, max = 1): number {
return Math.min(max, Math.max(min, value))
}
28 changes: 28 additions & 0 deletions src/utils/gasEstimator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export interface GasSavingsInput {
currentValidatorCount: number
mergedValidatorCount: number
avgGasPerValidatorPerEpoch: number
epochsPerDay?: number
}

export const DEFAULT_AVG_GAS_PER_VALIDATOR_PER_EPOCH = 21_000
export const DEFAULT_EPOCHS_PER_DAY = 225
export const DAYS_PER_YEAR = 365.25

export function estimateAnnualGasSavings({
currentValidatorCount,
mergedValidatorCount,
avgGasPerValidatorPerEpoch,
epochsPerDay = DEFAULT_EPOCHS_PER_DAY,
}: GasSavingsInput): number {
const removedValidators = Math.max(0, currentValidatorCount - mergedValidatorCount)
return Math.round(removedValidators * avgGasPerValidatorPerEpoch * epochsPerDay * DAYS_PER_YEAR)
}

export function estimatePerValidatorEpochGas({
attestationGas = 14_000,
proposalGas = 5_000,
withdrawalGas = 2_000,
}: Partial<Record<'attestationGas' | 'proposalGas' | 'withdrawalGas', number>> = {}): number {
return attestationGas + proposalGas + withdrawalGas
}
Loading
Loading